吸收数字员工通知权限引导

This commit is contained in:
baiyanyun
2026-06-12 10:16:02 +08:00
parent e57c94c19d
commit e473b5c147
17 changed files with 642 additions and 204 deletions

View File

@@ -13,6 +13,8 @@ const mockArtifactAccess = vi.hoisted(() => ({
const mockDesktopNotification = vi.hoisted(() => ({
showDigitalEmployeeDesktopNotification: vi.fn(() => ({ ok: true, shown: true })),
getDigitalEmployeeDesktopNotificationStatus: vi.fn(() => ({ supported: true, platform: "darwin", can_open_settings: true, reason: null })),
openDigitalEmployeeDesktopNotificationSettings: vi.fn(async () => ({ success: true, platform: "darwin" })),
}));
const mockServiceManager = vi.hoisted(() => ({
@@ -127,6 +129,8 @@ vi.mock("../services/digitalEmployee/artifactAccessService", () => ({
vi.mock("../services/digitalEmployee/desktopNotificationService", () => ({
showDigitalEmployeeDesktopNotification: mockDesktopNotification.showDigitalEmployeeDesktopNotification,
getDigitalEmployeeDesktopNotificationStatus: mockDesktopNotification.getDigitalEmployeeDesktopNotificationStatus,
openDigitalEmployeeDesktopNotificationSettings: mockDesktopNotification.openDigitalEmployeeDesktopNotificationSettings,
}));
vi.mock("../services/memory", () => ({
@@ -419,20 +423,34 @@ describe("digital employee managed service actions", () => {
it("registers desktop notification requests through IPC", async () => {
const electron = await import("electron");
const { showDigitalEmployeeDesktopNotification } = await import("../services/digitalEmployee/desktopNotificationService");
const {
getDigitalEmployeeDesktopNotificationStatus,
openDigitalEmployeeDesktopNotificationSettings,
showDigitalEmployeeDesktopNotification,
} = await import("../services/digitalEmployee/desktopNotificationService");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const notifyCall = calls.find(([channel]) => channel === "digitalEmployee:showDesktopNotification");
const statusCall = calls.find(([channel]) => channel === "digitalEmployee:getDesktopNotificationStatus");
const openSettingsCall = calls.find(([channel]) => channel === "digitalEmployee:openDesktopNotificationSettings");
expect(notifyCall).toBeTruthy();
expect(statusCall).toBeTruthy();
expect(openSettingsCall).toBeTruthy();
const notifyResult = await (notifyCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { notification_id: "task:1", title: "任务异常" });
const fallbackResult = await (notifyCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, null);
const statusResult = await (statusCall?.[1] as (_event: unknown) => Promise<unknown>)({});
const openResult = await (openSettingsCall?.[1] as (_event: unknown) => Promise<unknown>)({});
expect(notifyResult).toMatchObject({ ok: true, shown: true });
expect(fallbackResult).toMatchObject({ ok: true, shown: true });
expect(statusResult).toMatchObject({ supported: true, can_open_settings: true });
expect(openResult).toMatchObject({ success: true, platform: "darwin" });
expect(showDigitalEmployeeDesktopNotification).toHaveBeenCalledWith({ notification_id: "task:1", title: "任务异常" });
expect(showDigitalEmployeeDesktopNotification).toHaveBeenCalledWith({});
expect(getDigitalEmployeeDesktopNotificationStatus).toHaveBeenCalled();
expect(openDigitalEmployeeDesktopNotificationSettings).toHaveBeenCalled();
});
});

View File

@@ -80,7 +80,11 @@ import {
accessDigitalEmployeeArtifact,
type DigitalEmployeeArtifactAccessRequest,
} from "../services/digitalEmployee/artifactAccessService";
import { showDigitalEmployeeDesktopNotification } from "../services/digitalEmployee/desktopNotificationService";
import {
getDigitalEmployeeDesktopNotificationStatus,
openDigitalEmployeeDesktopNotificationSettings,
showDigitalEmployeeDesktopNotification,
} from "../services/digitalEmployee/desktopNotificationService";
import { memoryService } from "../services/memory";
interface RestartManagedServiceRequest {
@@ -238,6 +242,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return showDigitalEmployeeDesktopNotification(typeof input === "object" && input ? input as Record<string, unknown> : {});
});
ipcMain.handle("digitalEmployee:getDesktopNotificationStatus", async () => {
return getDigitalEmployeeDesktopNotificationStatus();
});
ipcMain.handle("digitalEmployee:openDesktopNotificationSettings", async () => {
return openDigitalEmployeeDesktopNotificationSettings();
});
ipcMain.handle("digitalEmployee:evaluateRiskPolicy", async (_, input: unknown) => {
return evaluateDigitalEmployeeRiskPolicy(typeof input === "object" && input ? input as Parameters<typeof evaluateDigitalEmployeeRiskPolicy>[0] : {});
});

View File

@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const notificationMock = vi.hoisted(() => ({
instances: [] as Array<{ options: Record<string, unknown> }>,
show: vi.fn(),
isSupported: vi.fn(() => true),
openExternal: vi.fn(async () => undefined),
}));
vi.mock("electron", () => {
@@ -24,16 +25,29 @@ vi.mock("electron", () => {
}
}
return { Notification: MockNotification };
return { Notification: MockNotification, shell: { openExternal: notificationMock.openExternal } };
});
const originalPlatform = process.platform;
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", { value: platform, configurable: true });
}
describe("digital employee desktop notifications", () => {
beforeEach(() => {
vi.resetModules();
setPlatform(originalPlatform);
notificationMock.instances = [];
notificationMock.show.mockReset();
notificationMock.isSupported.mockReset();
notificationMock.isSupported.mockReturnValue(true);
notificationMock.openExternal.mockReset();
notificationMock.openExternal.mockResolvedValue(undefined);
});
afterEach(() => {
setPlatform(originalPlatform);
});
it("shows unread action notifications through Electron Notification", async () => {
@@ -120,4 +134,52 @@ describe("digital employee desktop notifications", () => {
error: "system denied",
});
});
it("reports desktop notification status from Electron support", async () => {
const { getDigitalEmployeeDesktopNotificationStatus } = await import("./desktopNotificationService");
expect(getDigitalEmployeeDesktopNotificationStatus()).toMatchObject({
supported: true,
platform: originalPlatform,
can_open_settings: ["darwin", "win32"].includes(originalPlatform),
reason: null,
});
notificationMock.isSupported.mockReturnValue(false);
expect(getDigitalEmployeeDesktopNotificationStatus()).toMatchObject({
supported: false,
reason: "notification_unavailable",
});
});
it("opens macOS and Windows notification settings", async () => {
const { openDigitalEmployeeDesktopNotificationSettings } = await import("./desktopNotificationService");
setPlatform("darwin");
await expect(openDigitalEmployeeDesktopNotificationSettings()).resolves.toMatchObject({ success: true, platform: "darwin" });
expect(notificationMock.openExternal).toHaveBeenLastCalledWith(expect.stringContaining("x-apple.systempreferences"));
setPlatform("win32");
await expect(openDigitalEmployeeDesktopNotificationSettings()).resolves.toMatchObject({ success: true, platform: "win32" });
expect(notificationMock.openExternal).toHaveBeenLastCalledWith("ms-settings:notifications");
});
it("returns a safe failure when notification settings cannot be opened", async () => {
const { openDigitalEmployeeDesktopNotificationSettings } = await import("./desktopNotificationService");
setPlatform("linux");
await expect(openDigitalEmployeeDesktopNotificationSettings()).resolves.toMatchObject({
success: false,
platform: "linux",
reason: "notification_settings_unavailable",
});
expect(notificationMock.openExternal).not.toHaveBeenCalled();
setPlatform("darwin");
notificationMock.openExternal.mockRejectedValueOnce(new Error("settings denied"));
await expect(openDigitalEmployeeDesktopNotificationSettings()).resolves.toMatchObject({
success: false,
error: "settings denied",
});
});
});

View File

@@ -1,4 +1,4 @@
import { Notification } from "electron";
import { Notification, shell } from "electron";
const IMPORTANT_LEVELS = new Set(["action", "warn", "error"]);
const TITLE_MAX_LENGTH = 80;
@@ -22,6 +22,25 @@ export interface DigitalEmployeeDesktopNotificationResult {
error?: string | null;
}
export interface DigitalEmployeeDesktopNotificationStatus {
supported: boolean;
platform: NodeJS.Platform;
can_open_settings: boolean;
reason: string | null;
}
export interface DigitalEmployeeDesktopNotificationSettingsResult {
success: boolean;
platform: NodeJS.Platform;
reason?: string | null;
error?: string | null;
}
const NOTIFICATION_SETTINGS_URLS: Partial<Record<NodeJS.Platform, string>> = {
darwin: "x-apple.systempreferences:com.apple.Notifications-Settings.extension",
win32: "ms-settings:notifications",
};
export function showDigitalEmployeeDesktopNotification(
input: DigitalEmployeeDesktopNotificationInput,
): DigitalEmployeeDesktopNotificationResult {
@@ -45,6 +64,29 @@ export function showDigitalEmployeeDesktopNotification(
}
}
export function getDigitalEmployeeDesktopNotificationStatus(): DigitalEmployeeDesktopNotificationStatus {
const supported = Boolean(Notification && typeof Notification.isSupported === "function" && Notification.isSupported());
return {
supported,
platform: process.platform,
can_open_settings: Boolean(NOTIFICATION_SETTINGS_URLS[process.platform]),
reason: supported ? null : "notification_unavailable",
};
}
export async function openDigitalEmployeeDesktopNotificationSettings(): Promise<DigitalEmployeeDesktopNotificationSettingsResult> {
const url = NOTIFICATION_SETTINGS_URLS[process.platform];
if (!url) {
return { success: false, platform: process.platform, reason: "notification_settings_unavailable" };
}
try {
await shell.openExternal(url);
return { success: true, platform: process.platform };
} catch (error) {
return { success: false, platform: process.platform, error: normalizeError(error) };
}
}
function stringField(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}