Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/main/index.ts

typescript1502 行查看原文
import { stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
  fetchProviderModels,
  getProviderPreset,
  type ModelDiscoveryInput,
  usesChatGPTAuth,
} from "@zpi-dev/ai";
import { imageLimits } from "@zpi-dev/coding-agent";
import { attachmentLimitMessage } from "@zpi-dev/ui/errors";
import {
  app,
  type BrowserWindow,
  clipboard,
  dialog,
  globalShortcut,
  ipcMain,
  Menu,
  nativeTheme,
  net,
  powerMonitor,
  safeStorage,
  screen,
  session,
  shell,
} from "electron";
import { version } from "../../../../package.json";
import type {
  CombinedSelection,
  ErrorCode,
  InterfacePreferences,
  ProviderInput,
  Result,
  RunInput,
} from "../shared/bridge.ts";
import { methods } from "../shared/bridge.ts";
import { type ReviewSource, reviewSources } from "../shared/review.ts";
import type { ShortcutSettings } from "../shared/shortcuts.ts";
import { createDesktopWindow, hiddenTestMode, testMode } from "./automation.ts";
import { browserUrl } from "./browser-url.ts";
import { ChatGPTAuth } from "./chatgpt-auth.ts";
import { defaultFileOpener } from "./default-file-open.ts";
import { getInstalledEditors, openInEditor } from "./editors.ts";
import { ErrorLog } from "./error-log.ts";
import { EventBatcher } from "./event-batcher.ts";
import { performFileAction } from "./file-actions.ts";
import { downloadableImageUrl, readFilePreview, readPreviewRange } from "./file-preview.ts";
import { gitReview, gitReviewDiff } from "./git-review.ts";
import { loadRenderer } from "./load-renderer.ts";
import { PaneServices } from "./pane-services.ts";
import { startMainPerformanceDiagnostics, trackRendererResponsiveness } from "./performance-diagnostics.ts";
import { checkPreviewFiles } from "./preview-files.ts";
import { PreviewPdfExporter, previewPdfOptions } from "./preview-pdf-export.ts";
import { previewPrintHostScript, previewPrintIsolationCss } from "./preview-print-host.ts";
import { openProjectFromDialog } from "./project-picker.ts";
import { responsesProxyUrl } from "./responses-proxy.ts";
import { SessionHost } from "./session-host.ts";
import { shellEnvironment } from "./shell-environment.ts";
import {
  forwardBrowserShortcut,
  ShortcutController,
  setWindowMenuShortcutsIgnored,
  toggleShortcutWindow,
} from "./shortcut-controller.ts";
import { ShortcutModifier } from "./shortcut-modifier.ts";
import { StateStore } from "./state-store.ts";
import { SettingsStore } from "./storage.ts";
import { openStorageGeneration } from "./storage-generation.ts";
import { TaskNotifications } from "./task-notifications.ts";
import { WebSourceIcons } from "./web-source-icons.ts";
import {
  ignoredWorkspacePaths,
  readWorkspaceDirectory,
  WorkspaceDirectoryWatchers,
  workspaceGitStatus,
} from "./workspace-directory.ts";

const webSourceIcons = new WebSourceIcons();
const dir = fileURLToPath(new URL(".", import.meta.url));
const openFile = defaultFileOpener(
  (path) => shell.openPath(path),
  async (path) => (await app.getApplicationInfoForProtocol(pathToFileURL(path).href)).path,
);
// Match ZCode's Chromium locale: on macOS zh-CN changes system-ui's Chinese
// fallback from PingFang UI Display to PingFang SC, enlarging every UI label.
// UI copy is supplied by React and remains Chinese.
if (process.platform === "darwin") app.commandLine.appendSwitch("lang", "en-US");
const requestFetch: typeof fetch = (input, init) =>
  net.fetch(input instanceof URL ? input.href : input, { ...init, credentials: "omit" });
const responsesProxy = async (url: string) =>
  responsesProxyUrl(await session.defaultSession.resolveProxy(url));
let ending = false;
// Development shares Preview's data and Keychain identity; production remains separate.
const dataName = app.isPackaged ? app.getName() : process.env.ZPI_NPM_LAUNCH === "1" ? "ZPI" : "ZPI Preview";
app.setName(dataName);
app.setPath("userData", join(app.getPath("appData"), dataName));
const dataOverride = (testMode && process.env.ZPI_TEST_DATA_DIR) || process.env.ZPI_DATA_DIR;
if (dataOverride) {
  if (!isAbsolute(dataOverride)) throw new Error("storage: ZPI_DATA_DIR/ZPI_TEST_DATA_DIR must be absolute");
  app.setPath("userData", dataOverride);
}
const errors = new ErrorLog(app.getPath("userData"), {
  version: app.isPackaged ? app.getVersion() : version,
  electron: process.versions.electron,
});
const mainPerformance = startMainPerformanceDiagnostics(errors);
void app.whenReady().then(() => {
  powerMonitor.on("suspend", mainPerformance.suspend);
  powerMonitor.on("resume", mainPerformance.resume);
});
app.once("will-quit", () => {
  powerMonitor.removeListener("suspend", mainPerformance.suspend);
  powerMonitor.removeListener("resume", mainPerformance.resume);
  mainPerformance.stop();
});
process.on("uncaughtExceptionMonitor", (error, origin) =>
  errors.write(origin === "unhandledRejection" ? "main.unhandled-rejection" : "main.uncaught", error),
);
// Electron warns about unhandled rejections without routing them through the exception monitor.
process.on("unhandledRejection", (error) => {
  errors.write("main.unhandled-rejection", error);
  console.error("Unhandled promise rejection:", error);
});
app.on("web-contents-created", (_event, contents) => {
  trackRendererResponsiveness(contents, errors);
  contents.on("preload-error", (_event, _path, error) => errors.write("preload", error));
  contents.on("render-process-gone", (_event, details) => {
    if (!ending && details.reason !== "clean-exit")
      errors.write("renderer.process-gone", details.reason, {
        webContentsId: contents.id,
        exitCode: details.exitCode,
      });
  });
});
app.on("child-process-gone", (_event, details) => {
  if (!ending && details.reason !== "clean-exit")
    errors.write("main.child-process-gone", details.reason, {
      type: details.type,
      exitCode: details.exitCode,
    });
});
if (!app.requestSingleInstanceLock()) app.quit();
else
  void launch().catch((error) => {
    errors.write("main.startup", error);
    if (!ending) {
      if (testMode) console.error("ZPI 启动失败", error);
      else dialog.showErrorBox("ZPI 启动失败", error instanceof Error ? error.message : String(error));
    }
    app.quit();
  });
async function launch(): Promise<void> {
  await app.whenReady();
  if (!testMode) Object.assign(process.env, await shellEnvironment());
  const icon = app.isPackaged
    ? join(process.resourcesPath, "icon.png")
    : process.platform === "darwin"
      ? join(dir, "../icon-mac.png")
      : join(dir, "../renderer/icon.png");
  app.dock?.setIcon(icon);
  const encryption = testMode
    ? { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0), decryptString: () => "" }
    : safeStorage;
  const storage = openStorageGeneration(app.getPath("userData"), (directory) => {
    const initial = new StateStore(join(directory, "state.sqlite"));
    initial.close();
  });
  let state: StateStore;
  try {
    state = new StateStore(join(storage.directory, "state.sqlite"));
  } catch (error) {
    storage.close();
    throw error;
  }
  let storageClosed = false;
  const closeStorage = () => {
    if (storageClosed) return;
    state.close();
    storage.close();
    storageClosed = true;
  };
  app.once("will-quit", () => {
    try {
      closeStorage();
    } catch (error) {
      errors.write("storage.close", error);
    }
  });
  const settings = new SettingsStore(storage.directory, encryption, state);
  nativeTheme.themeSource = settings.get().interface.theme;
  let discovered: string[] = [];
  if (!testMode) {
    try {
      discovered = settings.discoverEnvironment(process.env);
    } catch (error) {
      errors.write("settings.discovery", error);
    }
  }
  const chatgptAuth = new ChatGPTAuth(settings, requestFetch, errors);
  if (testMode && process.env.ZPI_TEST_BASE_URL && settings.get().providers.length === 0)
    settings.saveProvider({
      id: "custom",
      name: "Custom",
      baseUrl: process.env.ZPI_TEST_BASE_URL,
      apiKey: "local-test-key",
      models: [
        {
          id: "fake",
          input: ["text"],
          reasoning: true,
          contextWindow: 32768,
          maxTokens: 4096,
          compat: { supportsReasoningEffort: true },
        },
      ],
    });
  if (testMode && process.env.ZPI_TEST_AUTO_SELECTION === "1") {
    if (!settings.get().lastSelection)
      settings.rememberSelection({ provider: "custom", modelId: "fake", reasoning: "none" });
    settings.updatePreferences({ showSendButton: true, showContextUsage: true });
  }
  const host = new SessionHost(
    storage.directory,
    settings,
    testMode ? app.getPath("userData") : homedir(),
    testMode ? join(app.getPath("userData"), "workspace") : join(homedir(), ".zpi", "workspace"),
    requestFetch,
    errors,
    testMode ? join(app.getPath("userData"), "worktree") : undefined,
    undefined,
    responsesProxy,
  );
  await host.init();
  const display = screen.getPrimaryDisplay().workAreaSize;
  // 非 macOS 的默认菜单直接显示在窗口顶栏,与 ZCode 的无菜单界面不符,整体移除。
  if (testMode || process.platform !== "darwin") Menu.setApplicationMenu(null);
  // Match ZCode's inset panels to the native window corners (Tahoe uses larger corners).
  const workspacePanelRadius =
    process.platform === "darwin" && Number.parseInt(process.getSystemVersion?.() ?? "", 10) < 26 ? 6 : 12;
  const window = createDesktopWindow({
    width: Math.min(1200, display.width),
    height: Math.min(800, display.height),
    minWidth: 740,
    minHeight: 560,
    title: dataName,
    backgroundColor: "#f8f8f8",
    icon,
    // 与 ZCode 的 macOS 顶栏一致:隐藏原生标题栏,让窗口控件与侧栏开关位于同一排。
    titleBarStyle: process.platform === "linux" ? "default" : "hidden",
    ...(process.platform === "darwin"
      ? {
          backgroundColor: "#00000000",
          trafficLightPosition: { x: 22, y: 23 },
          vibrancy: "under-window",
          visualEffectState: "active",
        }
      : {}),
    webPreferences: {
      additionalArguments: [`--zpi-panel-radius=${workspacePanelRadius}`],
      preload: join(dir, "../preload/index.cjs"),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
      webSecurity: true,
      webviewTag: true,
    },
  });
  window.webContents.on("console-message", (details) => {
    if (details.level === "error")
      errors.write("renderer.console", details.message, { file: details.sourceId, line: details.lineNumber });
  });
  ipcMain.on("zpi:error", (event, error: unknown) => {
    if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) return;
    if (!error || typeof error !== "object") return;
    const details = error as { source?: unknown; message?: unknown; stack?: unknown };
    if (
      typeof details.source !== "string" ||
      details.source.length > 120 ||
      typeof details.message !== "string" ||
      details.message.length > 32_000 ||
      (details.stack !== undefined && (typeof details.stack !== "string" || details.stack.length > 32_000))
    )
      return;
    errors.write(`renderer.${details.source}`, { message: details.message, stack: details.stack });
  });
  const settingsReady = new Promise<void>((resolveReady) =>
    window.webContents.once("did-finish-load", () => resolveReady()),
  );
  for (const id of discovered) {
    const original = settings.snapshot(id);
    void fetchProviderModels(
      { preset: original.preset, api: original.api, apiKey: original.apiKey },
      requestFetch,
    )
      .then(async (result) => {
        if (result.source !== "remote" || ending) return;
        if (!settings.get().providers.some((provider) => provider.id === id)) return;
        const current = settings.snapshot(id);
        if (JSON.stringify(current) !== JSON.stringify(original)) return;
        settings.saveProvider({
          ...original,
          models: result.models.map((model) => ({ ...model, useRecommendedConfig: true })),
        });
        await settingsReady;
        if (!window.isDestroyed()) window.webContents.send("zpi:settings", settings.get());
      })
      .catch((error) => {
        errors.write("models.discovery", error, { providerId: id });
        /* Keep the usable catalog; settings offers explicit retry. */
      });
  }
  const rendererUrl = process.env.ZPI_DEV_URL ?? pathToFileURL(join(dir, "../renderer/index.html")).href;
  const shortcuts = new ShortcutController(
    globalShortcut,
    process.platform,
    () => {
      if (ending) return;
      if (hiddenTestMode || !toggleShortcutWindow(window)) return;
      // Native guests and DOM controls normally retain focus; notify only for a lost-target fallback.
      if (!window.webContents.isDestroyed() && !panes.hasFocusedBrowser())
        window.webContents.send("zpi:shortcut", "toggle-window");
    },
    testMode,
    (ignore) => setWindowMenuShortcutsIgnored(window, ignore),
  );
  let shortcutSettings = settings.get().shortcuts;
  const shortcutModifier = new ShortcutModifier(
    process.platform,
    () => !ending && shortcutSettings.enabled && !shortcuts.isCapturing,
    (held) => {
      if (!window.isDestroyed() && !window.webContents.isDestroyed())
        window.webContents.send("zpi:shortcut-modifier", held);
    },
  );
  shortcutModifier.observe(window.webContents, true);
  window.on("blur", shortcutModifier.reset);
  window.on("hide", shortcutModifier.reset);
  window.on("minimize", shortcutModifier.reset);
  window.on("closed", () => shortcutModifier.dispose());
  let shortcutStartupError: Error | undefined;
  try {
    shortcuts.initialize(shortcutSettings);
  } catch (error) {
    shortcutStartupError = error instanceof Error ? error : new Error(String(error));
    errors.write("shortcuts.registration", error);
  }
  // A reload/crash must never leave recording mode active indefinitely.
  const resetShortcutCapture = () => {
    if (!shortcuts.isCapturing) return;
    try {
      shortcuts.setCapture(false);
    } catch (error) {
      errors.write("shortcuts.capture-reset", error);
      if (!testMode && !ending && !window.isDestroyed())
        void dialog.showMessageBox(window, {
          type: "warning",
          title: "全局快捷键不可用",
          message: String(error),
          detail: "录制已结束,但无法恢复全局快捷键。请在快捷键设置中更换组合并保存。",
        });
    }
  };
  window.on("blur", resetShortcutCapture);
  window.webContents.on("did-start-loading", resetShortcutCapture);
  window.webContents.on("render-process-gone", resetShortcutCapture);
  const panes = new PaneServices(
    window,
    (event) => {
      if (!window.isDestroyed()) window.webContents.send("zpi:pane-event", event);
    },
    (event) => {
      if (ending || shortcuts.isCapturing || window.isDestroyed() || window.webContents.isDestroyed())
        return false;
      return forwardBrowserShortcut(
        event,
        process.platform,
        shortcutSettings,
        (command) => window.webContents.send("zpi:shortcut", command),
        () => window.webContents.focus(),
      );
    },
    shortcutModifier,
  );
  window.webContents.on("will-attach-webview", (event, preferences, params) => {
    if (!panes.prepareBrowserGuest(preferences, params)) event.preventDefault();
  });
  window.webContents.on("did-start-navigation", (details) => {
    if (details.isMainFrame && !details.isSameDocument) panes.reset();
  });
  window.webContents.on("render-process-gone", () => panes.reset());
  window.on("closed", () => panes.close());
  const directoryWatchers = new WorkspaceDirectoryWatchers((id, dirPath) => {
    if (!window.isDestroyed()) window.webContents.send("zpi:workspace-directory", { id, dirPath });
  });
  window.on("closed", () => directoryWatchers.close());
  window.webContents.on("render-process-gone", () => directoryWatchers.close());
  const fileSearchOwner = window.webContents.id;
  const clearFileSearches = () => void host.workspaceFileSearches.close(fileSearchOwner);
  window.on("closed", clearFileSearches);
  window.webContents.on("render-process-gone", clearFileSearches);
  window.webContents.on("did-start-navigation", (details) => {
    if (details.isMainFrame && !details.isSameDocument) {
      directoryWatchers.close();
      clearFileSearches();
    }
  });
  window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
  window.webContents.on("will-navigate", (e) => e.preventDefault());
  window.webContents.on("will-redirect", (e) => e.preventDefault());
  window.webContents.session.setPermissionRequestHandler((_, __, cb) => cb(false));
  window.webContents.session.setPermissionCheckHandler(() => false);
  const pdfExporter = new PreviewPdfExporter({
    ready: (sessionId) => window.webContents.executeJavaScript(previewPrintHostScript(sessionId)),
    print: async () => {
      const key = await window.webContents.insertCSS(previewPrintIsolationCss, { cssOrigin: "user" });
      try {
        return await window.webContents.printToPDF(previewPdfOptions);
      } finally {
        await window.webContents.removeInsertedCSS(key);
      }
    },
    chooseFile: (suggestedName) =>
      dialog.showSaveDialog(window, {
        title: "导出 PDF",
        defaultPath: suggestedName,
        filters: [{ name: "PDF", extensions: ["pdf"] }],
      }),
    write: (path, data) => writeFile(path, data),
  });
  const notifications = new TaskNotifications(window, () => settings.get().interface);
  host.setNotificationSink((request) => !ending && notifications.show(request));
  const eventBatcher = new EventBatcher((events) => {
    if (window.isDestroyed() || window.webContents.isDestroyed() || window.webContents.isCrashed()) return;
    try {
      window.webContents.send("zpi:events", events);
    } catch (error) {
      // 定时发送已离开 Host 订阅者的异常边界;导航/进程退出不能让定时器异常终止主进程。
      errors.write("renderer.event-delivery", error);
    }
  });
  const offEvents = host.subscribe((event) => {
    if (!window.webContents.isCrashed()) eventBatcher.push(event);
  });
  window.on("closed", () => {
    offEvents();
    eventBatcher.close();
  });
  window.webContents.on("render-process-gone", () => eventBatcher.clear());
  window.webContents.on("did-navigate", () => eventBatcher.clear());
  ipcMain.handle("zpi:call", async (event, method: unknown, args: unknown): Promise<Result<unknown>> => {
    try {
      if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame)
        throw new Error("invalid_input: 未授权的 IPC sender");
      if (
        typeof method !== "string" ||
        !(methods as readonly string[]).includes(method) ||
        !Array.isArray(args)
      )
        throw new Error("invalid_input: 未知 IPC 方法");
      const string = (i: number) => {
        const value = args[i];
        if (typeof value !== "string") throw new Error("invalid_input: 参数必须是字符串");
        return value;
      };
      const object = (i: number, keys: string[]): Record<string, unknown> => {
        const value = args[i];
        if (
          !value ||
          typeof value !== "object" ||
          Array.isArray(value) ||
          Object.keys(value).some((k) => !keys.includes(k))
        )
          throw new Error("invalid_input: 参数对象无效");
        return value;
      };
      const fileTreeRoot = (rootIndex: number) => {
        const workspace = host.workspaceInfo(sessionContext());
        if (args[rootIndex] === undefined) return workspace.cwd;
        const path = string(rootIndex);
        if (!isAbsolute(path) || path.includes("\0") || path.length > 8192)
          throw new Error("invalid_input: 工作目录无效");
        // File-manager tabs freeze their local target when opened, including across worktree activation.
        return path;
      };
      const count: Record<(typeof methods)[number], number> = {
        setSessionUnread: 1,
        getTaskGroups: 0,
        updateTaskGroups: 1,
        listSubagents: 1,
        saveSubagent: 1,
        deleteSubagent: 2,
        setSubagentEnabled: 3,
        stopSubagent: 2,
        stopBackgroundTask: 2,
        readBackgroundTask: 2,
        setPluginEnabled: 2,
        getLocalPlugins: 0,
        getHooks: 1,
        saveHook: 1,
        setHookEnabled: 2,
        deleteHook: 1,
        importLocalPlugin: 0,
        setPluginOptions: 2,
        respondToQuestion: 3,
        snoozeQuestion: 2,
        createSideChat: 1,
        closeSideChat: 1,
        getGitEnvironment: 1,
        setNewWorktree: 3,
        activateSession: 1,
        archiveSession: 1,
        listArchivedSessions: 0,
        restoreSession: 1,
        openDirectory: 1,
        createTerminal: 1,
        terminalInput: 2,
        resizeTerminal: 3,
        closeTerminal: 1,
        createBrowser: 1,
        browserAction: 3,
        browserGuest: 3,
        closeBrowser: 1,
        getDraft: 1,
        saveDraft: 2,
        getHistoryPage: 2,
        copyText: 1,
        copySessionPath: 1,
        fileAction: 4,
        getInstalledEditors: 0,
        printPageToPdf: 1,
        savePreviewPdf: 2,
        openInEditor: 3,
        searchFiles: 2,
        getWorkspaceInfo: 1,
        readFilePreview: 3,
        readWorkspaceDirectory: 3,
        workspaceGitStatus: 2,
        ignoredWorkspacePaths: 3,
        watchWorkspaceDirectory: 4,
        watchReviewDirectory: 2,
        unwatchWorkspaceDirectory: 1,
        readPreviewRange: 4,
        checkPreviewFiles: 2,
        downloadImage: 1,
        readWebSourceIcon: 3,
        pickAttachments: 1,
        importAttachment: 2,
        readAttachment: 2,
        removeAttachment: 2,
        getChanges: 4,
        readPatch: 3,
        getReview: 2,
        readReviewDiff: 4,
        previewPrompt: 0,
        listTools: 0,
        getCustomCommands: 1,
        listSessionCustomCommands: 1,
        createCustomCommand: 2,
        updateCustomCommand: 2,
        deleteCustomCommand: 2,
        setCustomCommandEnabled: 2,
        discoverCommandImports: 1,
        importCommands: 2,
        getSkillSettings: 0,
        readSkill: 1,
        setSkillEnabled: 2,
        listProjects: 0,
        addProject: 1,
        removeProject: 1,
        reorderProjects: 1,
        listSessions: 1,
        listRecentSessions: 0,
        searchTasks: 2,
        getTaskSearchChanges: 1,
        searchWorkspaceFiles: 2,
        cancelWorkspaceFileSearch: 1,
        listSessionMentions: 1,
        setSessionPinned: 2,
        getProviderCredentials: 1,
        getWebSearchCredentials: 0,
        updatePreferences: 1,
        setSessionSelection: 2,
        listSessionSkills: 1,
        createSession: 2,
        getSessionSnapshot: 1,
        renameSession: 2,
        deleteSession: 1,
        startRun: 1,
        submitInput: 1,
        editQueuedInput: 2,
        removeQueuedInput: 2,
        sendQueuedNow: 2,
        moveQueuedInput: 3,
        resumeInputQueue: 1,
        forkSession: 2,
        editUserMessage: 3,
        abortRun: 1,
        retryModelRequest: 1,
        getSettings: 0,
        getMemorySettings: 0,
        saveMemorySettings: 1,
        getMemoryStatus: 1,
        checkMemoryNow: 1,
        clearLongMemory: 0,
        openMemoryFile: 3,
        updateShortcuts: 1,
        setShortcutCapture: 1,
        beginChatGPTLogin: 1,
        completeChatGPTLogin: 1,
        submitChatGPTCallback: 2,
        cancelChatGPTLogin: 1,
        disconnectChatGPT: 1,
        openExternal: 1,
        saveProvider: 1,
        saveWebSearch: 1,
        reorderProviders: 1,
        discoverModels: 1,
        deleteProvider: 1,
        listCommands: 1,
      };
      if (args.length !== count[method as (typeof methods)[number]])
        throw new Error("invalid_input: 参数数量无效");
      const sessionContext = () => (args[0] === null ? null : string(0));
      let value: unknown;
      switch (method) {
        case "listSubagents":
          value = host.listSubagents(sessionContext());
          break;
        case "saveSubagent":
          value = host.plugins.saveSubagent(
            object(0, [
              "id",
              "config",
              "scope",
              "sessionId",
            ]) as unknown as import("@zpi-dev/builtin-plugins/collaboration/types").SaveSubagentInput,
          );
          break;
        case "deleteSubagent":
          value = host.plugins.collaboration.profiles.delete(
            string(0),
            args[1] === null ? undefined : host.workspaceInfo(string(1)).cwd,
          );
          break;
        case "setSubagentEnabled":
          value = host.plugins.collaboration.profiles.enable(
            string(0),
            args[1] as boolean,
            args[2] === null ? undefined : host.workspaceInfo(string(2)).cwd,
          );
          break;
        case "stopSubagent":
          await host.plugins.collaboration.stop(string(0), string(1));
          break;
        case "stopBackgroundTask":
          await host.plugins.background.stop(string(0), string(1));
          break;
        case "readBackgroundTask":
          value = await host.plugins.background.read(string(0), string(1));
          break;
        case "setPluginEnabled":
          settings.setPluginEnabled(string(0), args[1] as boolean);
          await host.refreshPluginSettings();
          value = settings.get();
          break;
        case "getLocalPlugins":
          value = host.localPlugins
            .list()
            .map(({ id, version, name, description }) => ({ id, version, name, description }));
          break;
        case "getHooks":
          value = host.getHooks(args[0] === null ? null : string(0));
          break;
        case "saveHook":
          value = host.saveHook(
            object(0, [
              "id",
              "expectedRevision",
              "projectId",
              "event",
              "type",
              "command",
              "args",
              "matcher",
              "timeoutMs",
              "enabled",
            ]) as unknown as import("../shared/hooks.ts").SaveHookInput,
          );
          break;
        case "setHookEnabled":
          value = host.setHookEnabled(
            object(0, [
              "id",
              "projectId",
              "expectedRevision",
            ]) as unknown as import("../shared/hooks.ts").HookTarget,
            args[1] as boolean,
          );
          break;
        case "deleteHook":
          value = host.deleteHook(
            object(0, [
              "id",
              "projectId",
              "expectedRevision",
            ]) as unknown as import("../shared/hooks.ts").HookTarget,
          );
          break;
        case "importLocalPlugin": {
          const selected = await dialog.showOpenDialog(window, {
            title: "添加本地插件",
            properties: ["openDirectory"],
          });
          if (!selected.canceled && selected.filePaths[0]) {
            const plugin = await host.localPlugins.import(selected.filePaths[0]);
            value = settings.setPluginEnabled(plugin.id, true);
          } else value = settings.get();
          break;
        }
        case "setPluginOptions":
          settings.setPluginOptions(string(0), args[1] as import("@zpi-dev/ai").JsonObject);
          await host.refreshPluginSettings();
          value = settings.get();
          break;
        case "respondToQuestion":
          host.plugins.questions.respond(
            string(0),
            string(1),
            object(2, ["answers", "annotations"]) as unknown as import("@zpi-dev/ui").UserQuestionResponse,
          );
          break;
        case "snoozeQuestion":
          host.plugins.questions.snooze(string(0), string(1));
          break;
        case "closeSideChat":
          await host.closeSideChat(string(0));
          break;
        case "createSideChat":
          value = await host.createSideChat(string(0));
          break;
        case "getGitEnvironment":
          value = await host.getGitEnvironment(string(0));
          break;
        case "setNewWorktree":
          value = await host.setNewWorktree(string(0), args[1] as boolean, args[2] as string | undefined);
          break;
        case "archiveSession":
          await host.archiveSession(string(0));
          break;
        case "listArchivedSessions":
          value = host.listArchivedSessions();
          break;
        case "restoreSession":
          value = host.restoreSession(string(0));
          break;
        case "openDirectory": {
          const path = string(0);
          if (!isAbsolute(path) || !(await stat(path)).isDirectory())
            throw new Error("invalid_input: 请选目录");
          const failure = await shell.openPath(path);
          if (failure) throw new Error(`storage: ${failure}`);
          break;
        }
        case "createTerminal":
          value = await panes.createTerminal(host.workspaceInfo(string(0)).cwd);
          break;
        case "terminalInput":
          panes.terminalInput(string(0), string(1));
          break;
        case "resizeTerminal":
          panes.resizeTerminal(string(0), args[1] as number, args[2] as number);
          break;
        case "closeTerminal":
          panes.closeTerminal(string(0));
          break;
        case "createBrowser":
          value = panes.createBrowser(string(0));
          break;
        case "browserAction":
          panes.action(string(0), string(1), args[2] === undefined ? undefined : string(2));
          break;
        case "browserGuest":
          panes.attachBrowserGuest(string(0), args[1] as number, args[2] as boolean);
          break;
        case "closeBrowser":
          panes.closeBrowser(string(0));
          break;
        case "getDraft":
          value = await host.getDraft(string(0));
          break;
        case "saveDraft":
          host.saveDraft(
            string(0),
            object(1, [
              "text",
              "fileReferences",
              "selection",
              "selections",
              "revision",
            ]) as unknown as import("../shared/bridge.ts").TextDraft,
          );
          break;
        case "getHistoryPage":
          if (typeof args[1] !== "number") throw new Error("invalid_input: 历史游标必须为数字");
          value = host.getHistoryPage(string(0), args[1]);
          break;
        case "copySessionPath":
          clipboard.writeText(host.sessionPath(string(0)));
          break;
        case "copyText": {
          const text = string(0);
          if (Buffer.byteLength(text, "utf8") > 2 * 1024 * 1024)
            throw new Error("invalid_input: 复制内容超过 2 MiB");
          clipboard.writeText(text);
          break;
        }
        case "printPageToPdf":
          host.workspaceInfo(string(0));
          value = await pdfExporter.print(string(0));
          break;
        case "savePreviewPdf":
          value = await pdfExporter.save(args[0], string(1));
          break;
        case "getInstalledEditors":
          value = await getInstalledEditors();
          break;
        case "openInEditor":
          await openInEditor(host.workspaceInfo(string(0)).cwd, string(1), string(2));
          break;
        case "fileAction":
          await performFileAction(
            host.workspaceInfo(string(0)).cwd,
            string(1),
            string(2),
            {
              openPath: openFile,
              showItemInFolder: (path) => shell.showItemInFolder(path),
              writeText: (text) => clipboard.writeText(text),
            },
            args[3],
          );
          break;
        case "readWebSourceIcon": {
          const icon = host.recordedWebSourceIcon(string(0), string(1), string(2));
          const dataUrl = icon ? await webSourceIcons.load(icon) : undefined;
          value = dataUrl ? { dataUrl } : {};
          break;
        }
        case "downloadImage": {
          const src = string(0);
          if (!downloadableImageUrl(src)) throw new Error("invalid_input: 图片下载地址无效");
          window.webContents.downloadURL(src);
          break;
        }
        case "searchFiles":
          value = await host.searchFiles(string(0), string(1));
          break;
        case "getWorkspaceInfo":
          value = host.workspaceInfo(sessionContext());
          break;
        case "checkPreviewFiles":
          value = await checkPreviewFiles(host.workspaceInfo(string(0)).cwd, args[1]);
          break;
        case "readPreviewRange":
          value = await readPreviewRange(host.workspaceInfo(string(0)).cwd, string(1), args[2], args[3]);
          break;
        case "readWorkspaceDirectory":
          value = await readWorkspaceDirectory(fileTreeRoot(2), string(1));
          break;
        case "workspaceGitStatus":
          value = await workspaceGitStatus(fileTreeRoot(1));
          break;
        case "ignoredWorkspacePaths":
          value = await ignoredWorkspacePaths(fileTreeRoot(2), args[1]);
          break;
        case "watchWorkspaceDirectory":
          if (args[3] !== undefined && typeof args[3] !== "boolean")
            throw new Error("invalid_input: 递归参数必须是布尔值");
          value = directoryWatchers.watch(fileTreeRoot(2), string(1), args[3] === true);
          break;
        case "watchReviewDirectory":
          value = await directoryWatchers.watchReview(host.workspaceInfo(string(0)).cwd, string(1));
          break;
        case "unwatchWorkspaceDirectory":
          directoryWatchers.unwatch(string(0));
          break;
        case "readFilePreview":
          value = await readFilePreview(host.workspaceInfo(string(0)).cwd, string(1), args[2]);
          break;
        case "importAttachment": {
          const id = string(0);
          if (typeof args[1] === "string") value = await host.importFile(id, string(1));
          else {
            const file = object(1, ["name", "bytes"]);
            if (typeof file.name !== "string" || !(file.bytes instanceof Uint8Array))
              throw new Error("invalid_input: 附件参数无效");
            value = await host.importAttachment(id, file.name, file.bytes);
          }
          break;
        }
        case "pickAttachments": {
          const id = string(0);
          host.workspaceInfo(id);
          const paths = testMode
            ? (JSON.parse(
                process.env.ZPI_TEST_ATTACHMENT_FILES ?? process.env.ZPI_TEST_IMAGE_FILES ?? "[]",
              ) as string[])
            : (
                await dialog.showOpenDialog(window, {
                  properties: ["openFile", "multiSelections"],
                })
              ).filePaths;
          if (paths.length > imageLimits.count) throw new Error(`invalid_input: ${attachmentLimitMessage}`);
          const imported = [];
          try {
            for (const path of paths) {
              imported.push(await host.importFile(id, path));
            }
          } catch (e) {
            for (const item of imported) await host.removeAttachment(id, item.id);
            throw e;
          }
          value = imported;
          break;
        }
        case "readAttachment":
          value = await host.readAttachment(string(0), string(1));
          break;
        case "removeAttachment":
          value = await host.removeAttachment(string(0), string(1));
          break;
        case "getChanges":
          if (typeof args[2] !== "boolean") throw new Error("invalid_input: 参数必须是布尔值");
          if (typeof args[3] !== "boolean") throw new Error("invalid_input: 参数必须是布尔值");
          value = await host.getChanges(string(0), args[1] === null ? null : string(1), args[2], args[3]);
          break;
        case "getReview": {
          const source = string(1);
          if (!Object.hasOwn(reviewSources, source)) throw new Error("invalid_input: 审查来源无效");
          value =
            source === "last-turn"
              ? await host.getLastTurnReview(string(0))
              : await gitReview(host.workspaceInfo(string(0)).cwd, source as ReviewSource);
          break;
        }
        case "readReviewDiff": {
          const source = string(1);
          if (!Object.hasOwn(reviewSources, source)) throw new Error("invalid_input: 审查来源无效");
          value =
            source === "last-turn"
              ? await host.readTurnReviewDiff(string(0), string(3), string(2))
              : await gitReviewDiff(host.workspaceInfo(string(0)).cwd, source as ReviewSource, string(2));
          break;
        }
        case "readPatch":
          value = await host.readPatch(string(0), args[1] === null ? null : string(1), string(2));
          break;
        case "previewPrompt":
          value = await host.previewPrompt();
          break;
        case "listTools":
          value = host.listTools();
          break;
        case "getCustomCommands":
          value = await host.commandServices(args[0] === undefined ? undefined : string(0)).commands.list();
          break;
        case "listSessionCustomCommands":
          value = await host.listSessionCustomCommands(string(0));
          break;
        case "createCustomCommand":
          value = await host
            .commandServices(args[1] === undefined ? undefined : string(1))
            .commands.create(
              object(0, [
                "name",
                "prompt",
                "description",
                "argumentHint",
              ]) as unknown as import("../shared/custom-commands.ts").CustomCommandInput,
            );
          break;
        case "updateCustomCommand":
          value = await host
            .commandServices(args[1] === undefined ? undefined : string(1))
            .commands.update(
              object(0, [
                "id",
                "expectedRevision",
                "name",
                "prompt",
                "description",
                "argumentHint",
                "confirmLinkedSource",
                "expectedSourcePath",
              ]) as unknown as import("../shared/custom-commands.ts").CustomCommandUpdate,
            );
          break;
        case "deleteCustomCommand":
          value = await host
            .commandServices(args[1] === undefined ? undefined : string(1))
            .commands.delete(
              object(0, [
                "id",
                "expectedRevision",
              ]) as unknown as import("../shared/custom-commands.ts").CustomCommandDelete,
            );
          break;
        case "setCustomCommandEnabled":
          value = await host
            .commandServices(args[1] === undefined ? undefined : string(1))
            .commands.setEnabled(
              object(0, [
                "id",
                "enabled",
              ]) as unknown as import("../shared/custom-commands.ts").CustomCommandSetEnabled,
            );
          break;
        case "discoverCommandImports":
          value = await host
            .commandServices(args[0] === undefined ? undefined : string(0))
            .imports.discover();
          break;
        case "importCommands":
          value = await host
            .commandServices(args[1] === undefined ? undefined : string(1))
            .imports.importSelected(
              object(0, [
                "scanId",
                "mode",
                "selections",
              ]) as unknown as import("../shared/command-import.ts").CommandImportRequest,
            );
          break;
        case "getSkillSettings":
          value = await host.getSkillSettings();
          break;
        case "readSkill":
          value = await host.readSkill(string(0));
          break;
        case "setSkillEnabled": {
          if (typeof args[1] !== "boolean") throw new Error("invalid_input: 开关必须为 boolean");
          value = await host.setSkillEnabled(string(0), args[1]);
          break;
        }

        case "listProjects":
          value = host.listProjects();
          break;
        case "addProject": {
          const id = sessionContext();
          value = testMode
            ? process.env.ZPI_TEST_PROJECT_DIR
              ? await host.addProject(process.env.ZPI_TEST_PROJECT_DIR)
              : null
            : await openProjectFromDialog(id, host, (options) => dialog.showOpenDialog(window, options));
          break;
        }
        case "reorderProjects":
          value = host.reorderProjects(args[0]);
          break;
        case "removeProject":
          value = host.removeProject(string(0));
          break;
        case "setSessionUnread":
          host.setSessionUnread(string(0));
          break;
        case "getTaskGroups":
          value = host.getTaskGroups();
          break;
        case "updateTaskGroups":
          value = host.updateTaskGroups(args[0]);
          break;
        case "listSessions":
          value = host.listSessions(string(0));
          break;
        case "getTaskSearchChanges":
          value = await host.getTaskSearchChanges(string(0));
          break;
        case "searchTasks":
          value = host.searchTasks(string(0), args[1] === null ? null : string(1));
          break;
        case "searchWorkspaceFiles":
          value = await host.searchWorkspaceFiles(
            sessionContext(),
            object(1, [
              "query",
              "requestId",
              "rootPath",
              "filesOnly",
              "limit",
              "continuation",
            ]) as unknown as import("../shared/workspace-files.ts").WorkspaceFileSearchInput,
            window.webContents.id,
          );
          break;
        case "cancelWorkspaceFileSearch":
          value = await host.workspaceFileSearches.cancel(window.webContents.id, string(0));
          break;
        case "listRecentSessions":
          value = host.listRecentSessions();
          break;
        case "listSessionMentions":
          value = host.listSessionMentions(string(0));
          break;
        case "setSessionPinned": {
          if (typeof args[1] !== "boolean") throw new Error("invalid_input: 参数必须为布尔值");
          value = host.setSessionPinned(string(0), args[1]);
          break;
        }
        case "activateSession":
          value = host.activateSession(string(0));
          break;
        case "getProviderCredentials":
          value = settings.getProviderCredentials(string(0));
          break;
        case "getWebSearchCredentials":
          value = settings.getWebSearchCredentials();
          break;
        case "updateShortcuts": {
          const updated = shortcuts.update(
            object(0, ["enabled", "bindings"]) as unknown as ShortcutSettings,
            (input) => settings.updateShortcuts(input),
          );
          shortcutSettings = updated.shortcuts;
          shortcutModifier.reset();
          value = updated;
          break;
        }
        case "setShortcutCapture":
          shortcutModifier.reset();
          shortcuts.setCapture(args[0] as boolean);
          break;
        case "updatePreferences":
          value = settings.updatePreferences(
            object(0, [
              "theme",
              "fontSize",
              "codePreview",
              "showContextUsage",
              "showModelPerformance",
              "showSendButton",
              "notificationEnabled",
              "notificationSoundEnabled",
              "sidebarCollapsed",
              "sidebarWidth",
              "collapsedProjectIds",
              "projectsCollapsed",
              "tasksCollapsed",
            ]) as Partial<InterfacePreferences>,
          );
          nativeTheme.themeSource = settings.get().interface.theme;
          break;
        case "setSessionSelection":
          value = await host.setSessionSelection(
            string(0),
            object(1, ["provider", "modelId", "reasoning"]) as unknown as CombinedSelection,
          );
          break;
        case "listSessionSkills":
          value = await host.listSessionSkills(string(0));
          break;
        case "createSession":
          if (args[1] !== undefined && args[1] !== null && typeof args[1] !== "string")
            throw new Error("invalid_input: 无效分组 ID");
          value = await host.createSession(args[0] === null ? null : string(0), {
            groupId: args[1] as string | null | undefined,
          });
          break;
        case "getSessionSnapshot":
          value = host.getSessionSnapshot(string(0));
          break;
        case "renameSession":
          value = host.renameSession(string(0), string(1));
          break;
        case "deleteSession":
          value = await host.deleteSession(string(0));
          break;
        case "forkSession":
          value = await host.forkSession(string(0), string(1));
          break;
        case "editUserMessage": {
          const input = object(2, ["text", "fileReferences", "attachments", "workspaceMode"]);
          if (typeof input.text !== "string") throw new Error("invalid_input: 消息参数无效");
          value = await host.editUserMessage(
            string(0),
            string(1),
            input as unknown as import("../shared/bridge.ts").EditUserInput,
          );
          break;
        }
        case "startRun": {
          const o = object(0, ["sessionId", "text", "fileReferences", "attachments"]);
          if (typeof o.sessionId !== "string" || typeof o.text !== "string")
            throw new Error("invalid_input: 消息参数无效");
          value = await host.startRun(o as unknown as RunInput);
          break;
        }
        case "submitInput": {
          const input = object(0, [
            "sessionId",
            "text",
            "fileReferences",
            "attachments",
            "queueDisposition",
            "immediate",
          ]);
          if (
            typeof input.sessionId !== "string" ||
            typeof input.text !== "string" ||
            (input.queueDisposition !== undefined &&
              input.queueDisposition !== "keep" &&
              input.queueDisposition !== "clear") ||
            (input.immediate !== undefined && typeof input.immediate !== "boolean")
          )
            throw new Error("invalid_input: 消息参数无效");
          value = await host.submitInput(
            input as unknown as RunInput,
            input.queueDisposition as "keep" | "clear" | undefined,
            input.immediate as boolean | undefined,
          );
          break;
        }
        case "editQueuedInput":
          value = await host.editQueuedInput(string(0), string(1));
          break;
        case "removeQueuedInput":
          host.workspaceInfo(string(0));
          await host.inputQueue.remove(string(0), string(1));
          break;
        case "sendQueuedNow":
          host.workspaceInfo(string(0));
          await host.inputQueue.sendNow(string(0), string(1));
          break;
        case "moveQueuedInput":
          host.workspaceInfo(string(0));
          await host.inputQueue.move(string(0), string(1), args[2] === null ? null : string(2));
          break;
        case "resumeInputQueue":
          host.workspaceInfo(string(0));
          await host.inputQueue.resume(string(0));
          break;
        case "retryModelRequest": {
          const o = object(0, ["sessionId", "runId"]);
          if (typeof o.sessionId !== "string" || typeof o.runId !== "string")
            throw new Error("invalid_input: 重试参数无效");
          value = host.retryModelRequest({ sessionId: o.sessionId, runId: o.runId });
          break;
        }
        case "abortRun": {
          const o = object(0, ["sessionId", "runId"]);
          if (typeof o.sessionId !== "string" || typeof o.runId !== "string")
            throw new Error("invalid_input: 取消参数无效");
          value = await host.abortRun({ sessionId: o.sessionId, runId: o.runId });
          break;
        }
        case "getSettings":
          value = settings.get();
          break;
        case "getMemorySettings":
          value = settings.getMemorySettings();
          break;
        case "saveMemorySettings":
          value = await host.saveMemorySettings(
            args[0] as import("@zpi-dev/builtin-plugins/memory/types").MemorySettings,
          );
          break;
        case "getMemoryStatus":
          value = await host.plugins.getMemoryStatus(sessionContext());
          break;
        case "checkMemoryNow":
          value = await host.plugins.checkMemoryNow(sessionContext());
          break;
        case "clearLongMemory":
          value = await host.plugins.clearLongMemory();
          break;
        case "openMemoryFile": {
          const result = await host.plugins.openMemoryFile(sessionContext(), string(1), string(2));
          if (result.exists && (args[2] === "edit" || args[1] === "long")) await openFile(result.path);
          value = result;
          break;
        }
        case "beginChatGPTLogin": {
          const login = await chatgptAuth.begin(args[0] === null ? null : string(0));
          try {
            await shell.openExternal(login.url);
          } catch {
            chatgptAuth.cancel(login.loginId);
            throw new Error("provider: 无法打开浏览器,请重试");
          }
          value = login;
          break;
        }
        case "completeChatGPTLogin":
          value = await chatgptAuth.complete(string(0));
          break;
        case "submitChatGPTCallback":
          chatgptAuth.submit(string(0), string(1));
          break;
        case "cancelChatGPTLogin":
          chatgptAuth.cancel(string(0));
          break;
        case "disconnectChatGPT":
          value = await chatgptAuth.disconnect(string(0));
          break;
        case "discoverModels": {
          const input = object(0, ["preset", "providerId", "baseUrl", "apiKey", "api"]);
          if (Object.values(input).some((value) => typeof value !== "string"))
            throw new Error("invalid_input: 模型发现参数无效");
          const saved =
            typeof input.providerId === "string" ? settings.snapshot(input.providerId) : undefined;
          const preset = input.preset ?? saved?.preset;
          if (preset && (typeof preset !== "string" || !getProviderPreset(preset)))
            throw new Error("invalid_input: 未知厂商");
          value = await fetchProviderModels(
            {
              preset,
              api: input.api ?? saved?.api,
              baseUrl: input.baseUrl ?? saved?.baseUrl,
              apiKey: usesChatGPTAuth(preset as string | undefined)
                ? saved
                  ? await settings.getRequestApiKey(saved.id, requestFetch)
                  : (() => {
                      throw new Error("configuration: 请先登录 ChatGPT");
                    })()
                : (input.apiKey ?? saved?.apiKey ?? ""),
            } as ModelDiscoveryInput,
            requestFetch,
          );
          break;
        }
        case "saveWebSearch":
          value = settings.saveWebSearch(
            object(0, [
              "provider",
              "apiKey",
              "enabled",
            ]) as unknown as import("../shared/bridge.ts").WebSearchSettingsInput,
          );
          break;
        case "saveProvider": {
          const input = object(0, ["id", "name", "baseUrl", "models", "apiKey", "preset", "enabled", "api"]);
          if (!usesChatGPTAuth(input.preset as string | undefined) && typeof input.apiKey !== "string")
            throw new Error("invalid_input: 保存时必须提供实际 API key,可为空字符串");
          value = settings.saveProvider(input as unknown as ProviderInput);
          break;
        }
        case "deleteProvider":
          value = settings.deleteProvider(string(0));
          break;
        case "reorderProviders":
          value = settings.reorderProviders(args[0] as string[]);
          break;
        case "listCommands":
          value = host.listCommands(args[0] === null ? undefined : string(0));
          break;
        case "openExternal": {
          const url = new URL(browserUrl(string(0)));
          if (url.protocol === "file:") {
            const failure = await shell.openPath(fileURLToPath(url));
            if (failure) throw new Error(failure);
          } else await shell.openExternal(url.href);
          break;
        }
      }
      if (
        [
          "updateShortcuts",
          "updatePreferences",
          "saveWebSearch",
          "saveProvider",
          "deleteProvider",
          "reorderProviders",
          "completeChatGPTLogin",
          "disconnectChatGPT",
          "setPluginEnabled",
          "setPluginOptions",
          "saveMemorySettings",
          "importLocalPlugin",
        ].includes(method)
      ) {
        window.webContents.send("zpi:settings", settings.get());
      }
      return { ok: true, value };
    } catch (error) {
      errors.write("ipc", error, { method: typeof method === "string" ? method : "unknown" });
      const message = error instanceof Error ? error.message : String(error);
      const codes: ErrorCode[] = [
        "configuration",
        "busy",
        "not_found",
        "provider",
        "storage",
        "invalid_input",
      ];
      const code = codes.find((c) => message.startsWith(`${c}:`)) ?? "storage";
      return { ok: false, error: { code, message } };
    }
  });
  process.on("SIGTERM", () => app.quit());
  process.on("SIGINT", () => app.quit());
  let quitting = false;
  let preparingQuit = false;
  app.on("before-quit", (event) => {
    if (quitting) return;
    event.preventDefault();
    if (preparingQuit) return;
    preparingQuit = true;
    ending = true;
    // On macOS setEnabled(false) creates a visible native overlay even for a hidden window.
    if (!hiddenTestMode && !window.isDestroyed()) window.setEnabled(false);
    void flushDraftsBeforeQuit(window)
      .then(async () => {
        // Drafts are durable now. Finish shutdown even if one service fails;
        // returning to a partially disposed application is not recoverable.
        for (const [scope, close] of [
          ["chatgpt-auth", () => chatgptAuth.close()],
          ["notifications", () => notifications.dispose()],
          ["panes", () => panes.close()],
          ["host", () => host.close()],
        ] as const) {
          try {
            await close();
          } catch (error) {
            errors.write(`main.quit.${scope}`, error);
          }
        }
        quitting = true;
        app.quit();
      })
      .catch((error) => {
        ending = false;
        if (!hiddenTestMode && !window.isDestroyed()) window.setEnabled(true);
        errors.write("drafts.quit", error);
        if (!testMode) dialog.showErrorBox("草稿未保存,已取消退出", String(error));
      })
      .finally(() => {
        preparingQuit = false;
      });
  });
  window.on("close", (event) => {
    if (!quitting) {
      event.preventDefault();
      if (process.platform === "darwin") {
        if (!preparingQuit) window.hide();
      } else app.quit();
    }
  });
  const showWindow = () => {
    if (hiddenTestMode || preparingQuit || window.isDestroyed()) return;
    if (window.isMinimized()) window.restore();
    window.show();
    window.focus();
  };
  app.on("activate", showWindow);
  app.on("second-instance", showWindow);
  app.on("will-quit", () => shortcuts.dispose());
  await loadRenderer(window, rendererUrl);
  if (shortcutStartupError && !testMode && !ending)
    void dialog.showMessageBox(window, {
      type: "warning",
      title: "全局快捷键不可用",
      message: shortcutStartupError.message,
      detail: "应用内快捷键仍可使用。请在设置的快捷键页面更换全局组合或关闭它。",
    });
}

let draftFlushRequest = 0;
function flushDraftsBeforeQuit(window: BrowserWindow): Promise<void> {
  if (window.isDestroyed()) return Promise.resolve();
  const contents = window.webContents;
  if (contents.isDestroyed() || contents.isCrashed() || !contents.getURL()) return Promise.resolve();
  const request = ++draftFlushRequest;
  return new Promise((resolveFlush, rejectFlush) => {
    const finish = (error?: Error) => {
      clearTimeout(timer);
      ipcMain.removeListener("zpi:drafts-flushed", reply);
      contents.removeListener("render-process-gone", gone);
      contents.removeListener("destroyed", gone);
      if (error) rejectFlush(error);
      else resolveFlush();
    };
    const gone = () => finish();
    const reply = (event: Electron.IpcMainEvent, id: unknown, failure: unknown) => {
      if (
        event.sender !== contents ||
        event.senderFrame !== contents.mainFrame ||
        id !== request ||
        (failure !== null && typeof failure !== "string")
      )
        return;
      finish(failure === null ? undefined : new Error(failure));
    };
    const timer = setTimeout(() => finish(new Error("草稿保存确认超时,请重试退出")), 5000);
    ipcMain.on("zpi:drafts-flushed", reply);
    contents.once("render-process-gone", gone);
    contents.once("destroyed", gone);
    contents.send("zpi:prepare-quit", request);
  });
}