/**
 * MindOS Desktop — Electron Main Process
 *
 * Startup flow:
 * 1. Show splash screen immediately (brand + progress)
 * 2. Detect environment + resolve URL (splash shows status)
 * 3. Create main window + loadURL → hide splash
 *
 * Mode switching flow:
 * 1. Inject overlay on main window (keep old content visible)
 * 2. Start new mode in background
 * 3. Success → loadURL new mode; Failure → remove overlay, keep old mode
 */
import { app, BrowserWindow, dialog, ipcMain, shell, type IpcMainInvokeEvent } from 'electron';
import path from 'path';
import { appendFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, rmSync } from 'fs';
import { execFile as execFileChild, spawn as spawnChild, type ChildProcess } from 'child_process';
import { inspect, promisify } from 'util';
import { ProcessManager } from './process-manager';
import { findAvailablePort, waitForPortRelease, isPortInUse } from './port-finder';
import { createTray, updateTrayMenu, type TrayCallbacks } from './tray';
import { registerShortcuts, unregisterShortcuts } from './shortcuts';
import { restoreWindowState, saveWindowState, saveWindowStateNow } from './window-state';
import { isSafeExternalUrl } from './open-external-guard';
import { planUninstall } from './uninstall-plan';
import { rewriteMcpClientConfig } from './mcp-config-rewrite';
import { setupUpdater } from './updater';
import { setupAppMenu } from './app-menu';
import { ConnectionMonitor } from './connection-monitor';
import { showConnectWindow, showModeSelectWindow, getActiveRemoteConnection, getLastSshConnection, setActiveRemoteConnection, loadPassword, clearActiveTunnel } from './connect-window';
import { cleanupOrphanedSshTunnel, SshTunnel } from './ssh-tunnel';
import { testConnection } from './connection-sdk';
import { getNodePath, getNpxPath, getNpmPath, getLocalBinPath, getEnrichedEnv } from './node-detect';
import { resolveExecTarget } from './exec-target';
import { downloadNode, installMindosWithPrivateNode } from './node-bootstrap';
import { resolveLocalMindOsProjectRoot } from './mindos-runtime-resolve';
import { isNextBuildCurrent, BUILD_VERSION_FILE, analyzeMindOsLayout, resolveWebAppDir } from './mindos-runtime-layout';
import { hasRequiredStandaloneAppFiles } from './runtime-health-contract';
import { getDefaultBundledMindOsDirectory } from './mindos-runtime-path';
import { getEffectiveMindRootFromConfig } from './mindos-desktop-config';
import { ensureMindosCliShim, refreshMindosCliAndNotify } from './install-cli-shim';
import { verifyMindOsWebHealth, verifyMindOsWebListening } from './mindos-web-health';
import { resolvePreferUnpacked } from './resolve-packaged-asset';
import { registerMindosConnectSchemePrivileged, registerMindosConnectProtocol } from './mindos-connect-protocol';
import { CoreUpdater } from './core-updater';
import { getAppConfigStore } from './app-config-store';
import { desktopTelemetry } from './telemetry';
import {
  CONFIG_DIR,
  DEFAULT_MCP_PORT,
  DEFAULT_WEB_PORT,
  DESKTOP_HOME,
  PID_PATH,
  invalidateConfig,
  loadConfig,
  needsDesktopModeSelectAtLaunch,
  readMindOsConfigFileUncached,
  resolveLocalMindOsBrowseUrl,
  saveDesktopMode,
} from './desktop-config';
import {
  isAllowedMainWindowNavigation,
  isTrustedLocalRenderer,
  trustedLocalRendererError,
  type RendererTrustSnapshot,
} from './ipc-trust';

registerMindosConnectSchemePrivileged();

installSmokeFileLogger();

// Intel Mac GPU workaround: some Intel HD/Iris/UHD GPUs are on Chromium's
// blocklist, which disables GPU compositing and breaks backdrop-filter.
// --ignore-gpu-blocklist re-enables GPU acceleration on these devices.
if (process.platform === 'darwin' && process.arch === 'x64') {
  app.commandLine.appendSwitch('ignore-gpu-blocklist');
  app.commandLine.appendSwitch('enable-gpu-rasterization');
}
const execFileAsync = promisify(execFileChild);

function getDesktopInstallPath(): string {
  let appPath = app.getPath('exe');
  if (process.platform === 'darwin') {
    const appMatch = appPath.match(/^(.*?\.app)(\/|$)/);
    if (appMatch) appPath = appMatch[1];
  } else if (process.platform === 'linux') {
    // AppImage sets APPIMAGE env to the actual .AppImage file path
    appPath = process.env.APPIMAGE || path.dirname(appPath);
  } else {
    // Windows: installation directory containing MindOS.exe
    appPath = path.dirname(appPath);
  }
  return appPath;
}

// ── Paths (prefer app.asar.unpacked on macOS — see electron-builder asarUnpack) ──
const SPLASH_HTML = resolvePreferUnpacked('src', 'splash.html');
const SPLASH_PRELOAD = resolvePreferUnpacked('dist-electron', 'preload', 'splash-preload.js');
const MAIN_PRELOAD = resolvePreferUnpacked('dist-electron', 'preload', 'index.js');

// ── State ──
let splashWindow: BrowserWindow | null = null;
let mainWindow: BrowserWindow | null = null;
let processManager: ProcessManager | null = null;
let connectionMonitor: ConnectionMonitor | null = null;
let isQuitting = false;
let isUpdating = false; // Set before quitAndInstall — skips cleanup so the installer can launch
let trayAvailable = false;
let closingSplashForTransition = false;
let activeRecoveryPoll: ReturnType<typeof setInterval> | null = null;
let cleanupUpdater: (() => void) | null = null;
let currentMode: 'local' | 'remote' = 'local';
let currentWebPort: number | undefined;
let currentMcpPort: number | undefined;
let currentRemoteAddress: string | undefined;
const coreUpdater = new CoreUpdater();
let currentCoreVersion: string | null = null;

// ── Single instance ──
// A second instance would run healPreviousInstallation() and kill THIS
// instance's web/MCP servers, then both would race on ~/.mindos/config.json.
// Refuse to start and hand focus to the running instance instead.
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
  app.quit();
} else {
  app.on('second-instance', () => {
    if (mainWindow && !mainWindow.isDestroyed()) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.show();
      mainWindow.focus();
    }
  });
}

function installSmokeFileLogger(): void {
  const logPath = process.env.MINDOS_DESKTOP_CI_LOG?.trim();
  if (!logPath) return;

  const write = (level: string, args: unknown[]) => {
    try {
      mkdirSync(path.dirname(logPath), { recursive: true });
      const line = args.map((arg) => {
        if (arg instanceof Error) return arg.stack || arg.message;
        if (typeof arg === 'string') return arg;
        return inspect(arg, { depth: 6, breakLength: 160 });
      }).join(' ');
      appendFileSync(logPath, `[desktop ${new Date().toISOString()} ${level}] ${line}\n`, 'utf-8');
    } catch {
      // Smoke diagnostics must never affect normal startup.
    }
  };

  const wrap = (level: 'log' | 'info' | 'warn' | 'error') => {
    const original = console[level].bind(console);
    console[level] = (...args: unknown[]) => {
      write(level, args);
      original(...args);
    };
  };

  wrap('log');
  wrap('info');
  wrap('warn');
  wrap('error');
  process.on('uncaughtException', (error) => write('uncaughtException', [error]));
  process.on('unhandledRejection', (reason) => write('unhandledRejection', [reason]));
  write('info', ['MindOS Desktop smoke file logger enabled']);
}

// ── Splash Screen ──

function createSplash(): BrowserWindow {
  const win = new BrowserWindow({
    width: 360,
    height: 240,
    resizable: false,
    minimizable: false,
    maximizable: false,
    frame: false,
    transparent: process.platform === 'darwin',
    titleBarStyle: 'hidden',
    alwaysOnTop: true,
    webPreferences: {
      preload: SPLASH_PRELOAD,
      nodeIntegration: false,
      contextIsolation: true,
      sandbox: false,
    },
    show: false,
  });

  win.loadFile(SPLASH_HTML).catch((err) => {
    console.error('[MindOS] Splash screen load failed:', err);
    dialog.showErrorBox('Startup Error', `Failed to load splash screen: ${err.message}\n\nThe installation may be corrupt. Please reinstall MindOS.`);
    app.quit();
  });
  win.once('ready-to-show', () => win.show());

  // If user closes splash, quit the app
  win.on('closed', () => {
    const transitionClose = closingSplashForTransition;
    closingSplashForTransition = false;
    splashWindow = null;
    if (!mainWindow && !transitionClose) app.quit();
  });

  return win;
}

function splashStatus(data: Record<string, unknown>): void {
  if (splashWindow && !splashWindow.isDestroyed()) {
    splashWindow.webContents.send('splash:status', data);
  }
}

function closeSplash(): void {
  if (splashWindow && !splashWindow.isDestroyed()) {
    closingSplashForTransition = true;
    splashWindow.close();
    splashWindow = null;
  }
}

// ── Main Window ──

function createMainWindow(): BrowserWindow {
  const savedState = restoreWindowState();
  const win = new BrowserWindow({
    width: savedState?.width ?? 1200,
    height: savedState?.height ?? 800,
    x: savedState?.x, y: savedState?.y,
    minWidth: 800, minHeight: 600,
    title: 'MindOS',
    titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
    trafficLightPosition: process.platform === 'darwin' ? { x: 12, y: 17 } : undefined,
    /** Match app light `globals.css` --background (#f8f6f1); reduces white flash before first paint. */
    backgroundColor: '#f8f6f1',
    webPreferences: {
      preload: MAIN_PRELOAD,
      nodeIntegration: false,
      contextIsolation: true,
    },
    show: false,
  });

  if (savedState?.maximized) win.maximize();

  // Hide window instead of closing only when the tray is actually available.
  // If tray creation fails (common on some Linux desktops), closing the last
  // window must quit so users do not get an invisible background app.
  win.on('close', (e) => {
    if (!isQuitting && !isUpdating && trayAvailable) { e.preventDefault(); win.hide(); }
  });
  win.on('resize', () => saveWindowState(win));
  win.on('move', () => saveWindowState(win));

  // macOS: forward fullscreen state so the Web titlebar row can drop the
  // traffic-light clearance (html[data-mac-fullscreen], see preload.ts)
  if (process.platform === 'darwin') {
    win.on('enter-full-screen', () => win.webContents.send('mindos:mac-fullscreen', true));
    win.on('leave-full-screen', () => win.webContents.send('mindos:mac-fullscreen', false));
  }

  return win;
}

// ── CLI Conflict Detection ──

function checkCliConflict(): { running: boolean; webPort?: number; mcpPort?: number } {
  try {
    if (!existsSync(PID_PATH)) return { running: false };
    const pids = readFileSync(PID_PATH, 'utf-8').trim().split('\n').map(Number).filter(Boolean);
    for (const pid of pids) {
      try {
        process.kill(pid, 0);
        const config = loadConfig();
        return { running: true, webPort: config.port || DEFAULT_WEB_PORT, mcpPort: config.mcpPort || DEFAULT_MCP_PORT };
      } catch { /* not running */ }
    }
    return { running: false };
  } catch {
    return { running: false };
  }
}

// ── Local Mode ──

async function startLocalMode(): Promise<string | null> {
  invalidateConfig(); // Always re-read config (may have changed via setup wizard or settings)
  const config = loadConfig();
  const zh = navigator_lang() === 'zh';

  splashStatus({ status: 'detecting' });

  // 1. Node.js check — bundled > private ~/.mindos/node > system > auto-download
  let nodePath = await getNodePath();
  if (nodePath) {
    console.info(`[MindOS] Node.js: ${nodePath}`);
  }
  if (!nodePath) {
    splashStatus({ message: zh ? '正在下载 Node.js 运行环境...' : 'Downloading Node.js runtime...' });
    try {
      nodePath = await downloadNode((percent, status) => {
        if (status === 'downloading') {
          splashStatus({ message: zh ? `正在下载 Node.js... ${percent}%` : `Downloading Node.js... ${percent}%` });
        } else if (status === 'extracting') {
          splashStatus({ message: zh ? '正在安装 Node.js...' : 'Installing Node.js...' });
        }
      });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      splashStatus({
        error: zh ? `Node.js 下载失败: ${msg}` : `Node.js download failed: ${msg}`,
        actions: [
          { id: 'retry', label: 'retry', primary: true },
          { id: 'switch-remote', label: 'switchRemote' },
          { id: 'quit', label: 'quit' },
        ],
      });
      return null;
    }
  }

  // 2. MindOS root — bundled vs cached vs global vs override
  // Clean up stale cached runtimes before resolution
  try {
    const bundledDir = getDefaultBundledMindOsDirectory();
    const bundledVer = bundledDir && existsSync(bundledDir) ? analyzeMindOsLayout(bundledDir).version : null;
    coreUpdater.cleanupOnBoot(bundledVer);
  } catch (e) { console.warn('[MindOS] cleanupOnBoot failed:', e); }

  const runtimeRes = await resolveLocalMindOsProjectRoot(loadConfig(), nodePath);
  if (!runtimeRes.ok) {
    splashStatus({
      error: zh ? runtimeRes.messageZh : runtimeRes.messageEn,
      actions: [
        { id: 'retry', label: 'retry', primary: true },
        { id: 'switch-remote', label: 'switchRemote' },
        { id: 'quit', label: 'quit' },
      ],
    });
    return null;
  }

  const { pick: runtimePick } = runtimeRes;
  currentCoreVersion = runtimePick.version;
  console.info(
    `[MindOS] runtime pick source=${runtimePick.source} root=${runtimePick.projectRoot ?? '—'} version=${runtimePick.version ?? '—'}${runtimePick.reason ? ` reason=${runtimePick.reason}` : ''}`,
  );

  let projectRoot: string | null = runtimeRes.projectRoot;
  if (!projectRoot && runtimeRes.needsInstallFallback) {
    projectRoot = runtimeRes.userCandidatePath;
    if (!projectRoot) {
      splashStatus({ message: zh ? '正在安装 MindOS...' : 'Installing MindOS...' });
      try {
        projectRoot = await installMindosWithPrivateNode(nodePath, (status) => {
          if (status === 'installing') {
            splashStatus({ message: zh ? '正在安装 MindOS（首次约需 1-2 分钟）...' : 'Installing MindOS (first time, ~1-2 min)...' });
          }
        });
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        splashStatus({
          error: zh ? `MindOS 安装失败: ${msg}` : `MindOS install failed: ${msg}`,
          actions: [
            { id: 'retry', label: 'retry', primary: true },
            { id: 'switch-remote', label: 'switchRemote' },
            { id: 'quit', label: 'quit' },
          ],
        });
        return null;
      }
    }
  }

  if (!projectRoot) {
    splashStatus({
      error: zh ? '未找到可运行的 MindOS 目录' : 'No runnable MindOS installation found',
      actions: [
        { id: 'retry', label: 'retry', primary: true },
        { id: 'switch-remote', label: 'switchRemote' },
        { id: 'quit', label: 'quit' },
      ],
    });
    return null;
  }

  const npxPath = getNpxPath(nodePath);

  // 3. CLI conflict check — mindos.pid can be stale; must not loadURL before /api/health works
  const conflict = checkCliConflict();
  if (conflict.running && conflict.webPort != null) {
    const healthy = await verifyMindOsWebListening(conflict.webPort);
    if (healthy) {
      splashStatus({ status: 'connecting' });
      currentWebPort = conflict.webPort;
      currentMcpPort = conflict.mcpPort;
      return `http://127.0.0.1:${conflict.webPort}`;
    }
    console.warn(
      '[MindOS] mindos.pid suggests a running CLI but /api/health did not succeed — starting a local server from the bundled runtime.',
    );
  }

  // 4. Ensure app is built (first run or after update — npm package has no .next)
  //    Check for valid build (BUILD_ID or standalone/server.js), not just .next dir existence.
  //    An incomplete .next (interrupted build, empty dir) would let Next.js crash at startup.
  //    OPTIMIZATION: Bundled/cached standalone runtimes ship pre-built — skip rebuild entirely
  //    when standalone/server.js and all required assets are present. This avoids the costly
  //    npm install + next build cycle (5-15 min on Windows) that was triggered by version
  //    stamp mismatches on upgrade-in-place scenarios.
  const appDir = resolveWebAppDir(projectRoot);
  const nextDir = path.join(appDir, '.next');
  const isPrebuiltStandalone =
    (runtimePick.source === 'bundled' || runtimePick.source === 'cached') &&
    hasRequiredStandaloneAppFiles(appDir);
  if (isPrebuiltStandalone) {
    console.info(`[MindOS] Skipping rebuild — ${runtimePick.source} standalone runtime is intact`);
  } else if (!isNextBuildCurrent(appDir, projectRoot)) {
    splashStatus({ message: zh ? '正在构建 MindOS（首次约需 1-2 分钟）...' : 'Building MindOS (first run, ~1-2 min)...' });
    try {
      const enrichedEnv = getEnrichedEnv(nodePath);
      // Step 4a: Install app dependencies
      const npmBin = getNpmPath(path.dirname(nodePath));
      if (existsSync(npmBin) && existsSync(path.join(appDir, 'package.json'))) {
        splashStatus({ message: zh ? '正在安装依赖...' : 'Installing dependencies...' });
        await spawnWithEnv(npmBin, ['install'], appDir, enrichedEnv, 300000);
      }
      // Step 4b: Generate renderer index (needed before build)
      const genScript = path.join(projectRoot, 'scripts', 'gen-renderer-index.js');
      if (existsSync(genScript)) {
        await spawnWithEnv(nodePath, [genScript], projectRoot, enrichedEnv, 30000);
      }
      // Step 4c: Run next build
      splashStatus({ message: zh ? '正在编译前端（约需 1-2 分钟）...' : 'Compiling frontend (~1-2 min)...' });
      const nextBin = getLocalBinPath(appDir, 'next');
      const buildBin = existsSync(nextBin) ? nextBin : npxPath;
      const buildArgs = existsSync(nextBin) ? ['build'] : ['next', 'build'];
      await spawnWithEnv(buildBin, buildArgs, appDir, enrichedEnv, 600000);
      // Write build version stamp
      try {
        const version = JSON.parse(readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')).version;
        writeFileSync(path.join(nextDir, BUILD_VERSION_FILE), version, 'utf-8');
      } catch (stampErr) { console.warn('[MindOS] Failed to write build version stamp:', stampErr); }
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      splashStatus({
        error: zh ? `构建失败: ${msg}` : `Build failed: ${msg}`,
        actions: [
          { id: 'retry', label: 'retry', primary: true },
          { id: 'switch-remote', label: 'switchRemote' },
          { id: 'quit', label: 'quit' },
        ],
      });
      return null;
    }
  } // closes the else-if for !isNextBuildCurrent

  splashStatus({ status: 'starting' });

  const findLocalModePorts = async (webStart: number, mcpStart: number) => {
    const resolvedWebPort = await findAvailablePort(webStart);
    let probe = mcpStart;
    for (let i = 0; i < 30; i++) {
      const resolvedMcpPort = await findAvailablePort(probe);
      if (resolvedMcpPort !== resolvedWebPort) {
        return { webPort: resolvedWebPort, mcpPort: resolvedMcpPort };
      }
      probe = resolvedMcpPort + 1;
    }
    throw new Error(`No distinct MCP port available near ${mcpStart}`);
  };

  // 5. Find distinct ports + spawn (retry once if a port was stolen between check and bind)
  let webPort: number;
  let mcpPort: number;
  try {
    ({ webPort, mcpPort } = await findLocalModePorts(config.port || DEFAULT_WEB_PORT, config.mcpPort || DEFAULT_MCP_PORT));
  } catch {
    // Port range exhausted — likely orphaned processes from a previous crash.
    // Kill them and retry instead of showing a dead-end error.
    await ProcessManager.cleanupOrphanedChildren();
    try {
      ({ webPort, mcpPort } = await findLocalModePorts(config.port || DEFAULT_WEB_PORT, config.mcpPort || DEFAULT_MCP_PORT));
    } catch {
      const basePort = config.port || DEFAULT_WEB_PORT;
      const portHint = process.platform === 'win32'
        ? `netstat -ano | findstr :${basePort}`
        : `lsof -nP -iTCP:${basePort} -sTCP:LISTEN`;
      splashStatus({
        error: zh
          ? `端口 ${basePort}-${basePort + 9} 均被占用。\n请关闭其他占用这些端口的程序，或用以下命令查看占用者:\n  ${portHint}`
          : `Ports ${basePort}-${basePort + 9} are all in use.\nClose other programs using these ports, or inspect the owner with:\n  ${portHint}`,
        actions: [
          { id: 'retry', label: 'retry', primary: true },
          { id: 'quit', label: 'quit' },
        ],
      });
      return null;
    }
  }

  const createProcessManager = (wp: number, mp: number) => new ProcessManager({
    nodePath, npxPath, projectRoot, webPort: wp, mcpPort: mp,
    mindRoot:
      getEffectiveMindRootFromConfig(config) ||
      path.join(DESKTOP_HOME, 'MindOS', 'mind'),
    authToken: config.authToken,
    webPassword: typeof config.webPassword === 'string' ? config.webPassword : undefined,
    installDir: getDesktopInstallPath(),
    verbose: false,
    env: getEnrichedEnv(nodePath),
  });

  // Clean up any previous processManager (e.g. from retry or mode switch)
  if (processManager) {
    processManager.removeAllListeners();
    try { await processManager.stop(); } catch { /* best-effort cleanup */ }
  }

  processManager = createProcessManager(webPort, mcpPort);

  try {
    await processManager.start();
  } catch (startErr) {
    const msg = startErr instanceof Error ? startErr.message : '';
    // Port stolen between findAvailablePort and actual bind — retry once with fresh ports
    if (msg.includes('EADDRINUSE') || msg.includes('address already in use')) {
      console.warn('[MindOS] Port conflict detected, retrying with fresh ports...');
      try { await processManager.stop(); } catch { /* best-effort */ }
      ({ webPort, mcpPort } = await findLocalModePorts(webPort + 1, mcpPort + 1));
      processManager = createProcessManager(webPort, mcpPort);
      await processManager.start(); // let this throw if it fails again
    } else {
      throw startErr;
    }
  }

  // Read effective ports (may have changed during EADDRINUSE retry or respawn)
  currentWebPort = processManager.webPort;
  currentMcpPort = processManager.mcpPort;
  webPort = currentWebPort;
  mcpPort = currentMcpPort;

  // Auto-update MCP client configs if ports shifted from configured values.
  // This happens when healing couldn't free the original port (non-MindOS process occupying it).
  const configuredMcpPort = config.mcpPort || DEFAULT_MCP_PORT;
  if (mcpPort !== configuredMcpPort) {
    console.info(`[MindOS] MCP port shifted ${configuredMcpPort} → ${mcpPort} — updating client configs`);
    updateMcpClientConfigs(configuredMcpPort, mcpPort);
  }

  let crashDialogShown = false;
  let startupComplete = false;  // Only show crash dialog after successful startup

  processManager.on('mcp-port-blocked', async (blockedPort: number) => {
    const zh = navigator_lang() === 'zh';
    // Find a suggested alternative port
    let suggestedPort: number | null = null;
    try {
      suggestedPort = await findAvailablePort(blockedPort + 1);
    } catch { /* fallback to no suggestion */ }

    const title = zh ? 'MCP 端口被占用' : 'MCP Port Unavailable';
    const detail = suggestedPort
      ? (zh
        ? `端口 ${blockedPort} 被其他程序占用，MCP 服务无法启动。\n\n推荐切换到端口 ${suggestedPort}（当前可用）。\n已安装的 AI 助手配置将自动更新。`
        : `Port ${blockedPort} is occupied by another program. MCP cannot start.\n\nSuggested alternative: port ${suggestedPort} (currently available).\nInstalled AI tool configurations will be updated automatically.`)
      : (zh
        ? `端口 ${blockedPort} 被其他程序占用，MCP 服务无法启动。\n\n请关闭占用该端口的程序后重启 MindOS。`
        : `Port ${blockedPort} is occupied by another program. MCP cannot start.\n\nClose the program using that port and restart MindOS.`);

    const buttons = suggestedPort
      ? [zh ? `使用端口 ${suggestedPort}` : `Use port ${suggestedPort}`, zh ? '稍后处理' : 'Dismiss']
      : [zh ? '确定' : 'OK'];

    if (!mainWindow || mainWindow.isDestroyed()) return;
    const result = await dialog.showMessageBox(mainWindow, {
      type: 'warning',
      title,
      message: title,
      detail,
      buttons,
      defaultId: 0,
    });

    if (suggestedPort && result.response === 0) {
      // User chose to use the suggested port — respawn MCP and update client configs
      try {
        processManager?.startMcpOnPort(suggestedPort);
        console.info(`[MindOS] MCP restarted on port ${suggestedPort}`);
        updateTrayMenu(currentMode, 'running', undefined, processManager?.webPort, suggestedPort);
        // Auto-update MCP client configs that use http transport with the old port
        updateMcpClientConfigs(blockedPort, suggestedPort);
      } catch (err) {
        console.error('[MindOS] Failed to start MCP on suggested port:', err);
      }
    }
  });

  processManager.on('crash', (which: string, count: number, exitCode?: number | null, stderrLines?: string[]) => {
    if (which === 'mcp' && count >= 3) {
      updateTrayMenu(currentMode, 'running', undefined, processManager?.webPort, processManager?.mcpPort);
    }
    // During startup, crashes are handled by start()'s throw → splash error.
    // Only show crash dialog for post-startup failures.
    if (which === 'web' && count >= 3 && !crashDialogShown && startupComplete) {
      // Check if MindOS update is in progress — don't show crash dialog during update
      const updateStatusPath = path.join(CONFIG_DIR, 'update-status.json');
      let isUpdating = false;
      try {
        if (existsSync(updateStatusPath)) {
          const status = JSON.parse(readFileSync(updateStatusPath, 'utf-8'));
          isUpdating = status.stage && status.stage !== 'done' && status.stage !== 'failed';
        }
      } catch (err) { console.warn('[MindOS] removeOverlay failed:', (err as Error)?.message); }

      if (isUpdating) {
        // Update in progress — inject overlay and wait for new server
        const zh = navigator_lang() === 'zh';
        injectOverlay('mindos-update-overlay', `
          <div style="position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:system-ui;backdrop-filter:blur(8px)">
            <div style="width:28px;height:28px;border:3px solid rgba(212,149,74,0.3);border-top-color:#d4954a;border-radius:50%;animation:spin 1s linear infinite;margin-bottom:14px"></div>
            <div style="color:#e8e4dc;font-size:18px;font-weight:600">${zh ? 'MindOS 正在更新...' : 'MindOS is Updating...'}</div>
            <div style="color:#8a8275;font-size:13px;margin-top:6px;text-align:center;max-width:300px;line-height:1.5">${zh ? '服务正在重启，完成后将自动刷新。' : 'Server is restarting. Will auto-reload when ready.'}</div>
            <style>@keyframes spin{to{transform:rotate(360deg)}}</style>
          </div>
        `);
        refreshTray('starting');
        // Poll for server recovery
        activeRecoveryPoll = setInterval(async () => {
          try {
            const effectiveWebPort = processManager?.webPort ?? webPort;
            if (await verifyMindOsWebHealth(effectiveWebPort, 3000)) {
              clearInterval(activeRecoveryPoll!);
              activeRecoveryPoll = null;
              mainWindow?.loadURL(
                resolveLocalMindOsBrowseUrl(`http://127.0.0.1:${effectiveWebPort}`),
              );
              refreshTray('running');
            }
          } catch { /* still down */ }
        }, 3000);
        // Timeout after 5 minutes — clean up overlay and show error
        setTimeout(() => {
          if (activeRecoveryPoll) {
            clearInterval(activeRecoveryPoll);
            activeRecoveryPoll = null;
            removeOverlay('mindos-update-overlay');
            refreshTray('error');
          }
        }, 300_000);
      } else {
        crashDialogShown = true;
        const zh = navigator_lang() === 'zh';
        // Strip ANSI escape codes from stderr for clean display in native dialog
        const ansiEscapePattern = new RegExp(`${String.fromCharCode(27)}\\\\[[0-9;]*m`, 'g');
        const stripAnsi = (s: string) => s.replace(ansiEscapePattern, '');
        const stderr = stripAnsi(stderrLines?.slice(-5).join('\n') || '');
        const lastExitCode = exitCode ?? null;
        // Diagnose crash cause from exit code and stderr
        let hint: string;
        if (lastExitCode === 137 || lastExitCode === 9) {
          hint = zh
            ? '\n\n可能原因：内存不足 (OOM)。尝试关闭其他应用后重启。'
            : '\n\nLikely cause: out of memory (OOM). Close other apps and restart.';
        } else if (stderr.includes('ENOSPC') || stderr.includes('no space left')) {
          hint = zh
            ? '\n\n可能原因：磁盘空间不足。请清理磁盘后重启。'
            : '\n\nLikely cause: disk full. Free up disk space and restart.';
        } else if (stderr.includes('EADDRINUSE') || stderr.includes('address already in use')) {
          hint = zh
            ? '\n\n可能原因：端口被占用。请关闭占用端口的程序后重启。'
            : '\n\nLikely cause: port in use. Close the program using the port and restart.';
        } else if (stderr.includes('MODULE_NOT_FOUND') || stderr.includes('Cannot find module')) {
          hint = zh
            ? '\n\n可能原因：构建产物过期。请在终端运行 mindos start 重新编译。'
            : '\n\nLikely cause: stale build. Run "mindos start" in terminal to rebuild.';
        } else {
          hint = zh
            ? '\n\n请检查 Node.js 环境后重启。'
            : '\n\nPlease check your Node.js environment and restart.';
        }
        dialog.showErrorBox(
          zh ? 'MindOS 服务崩溃' : 'MindOS Service Crashed',
          (zh ? 'Web 服务连续崩溃 3 次。' : 'The web server crashed 3 times.')
            + hint + '\n\n' + (zh ? '详细日志：~/.mindos/crash.log' : 'Details: ~/.mindos/crash.log') + (stderr ? '\n\n--- Last output ---\n' + stderr : ''),
        );
      }
    }
  });

  processManager.on('status-change', (status: string) => {
    refreshTray(status as 'starting' | 'running' | 'error');
    // Reset crash dialog flag when service recovers — so it can show again if it crashes later
    if (status === 'running') crashDialogShown = false;
  });

  startupComplete = true;
  splashStatus({ status: 'ready', done: true });
  return `http://127.0.0.1:${webPort}`;
}

// ── Remote Mode ──

async function startRemoteMode(): Promise<string | null> {
  splashStatus({ status: 'connecting' });
  const savedAddress = getActiveRemoteConnection();
  if (savedAddress) {
    try {
      const result = await testConnection(savedAddress);
      if (result.status === 'online') {
        // If auth required, try saved password for seamless reconnect
        if (result.authRequired) {
          const password = loadPassword(savedAddress);
          if (password) {
            try {
              const res = await fetch(`${savedAddress}/api/auth`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ password }),
              });
              if (res.ok) {
                currentRemoteAddress = savedAddress;
                closeSplash();
                return savedAddress;
              }
            } catch { /* saved password failed, fall through */ }
          }
          // No password or auth failed → show connect window
        } else {
          currentRemoteAddress = savedAddress;
          closeSplash();
          return savedAddress;
        }
      }
    } catch { /* fall through */ }
  }

  // Auto-reconnect last SSH tunnel if saved address was ephemeral (localhost tunnel)
  const lastSsh = getLastSshConnection();
  if (lastSsh) {
    const zh = navigator_lang() === 'zh';
    splashStatus({ status: 'connecting', message: zh ? `正在重连 SSH ${lastSsh.host}...` : `Reconnecting SSH to ${lastSsh.host}...` });
    try {
      const localPort = await findAvailablePort(lastSsh.remotePort);
      const tunnel = new SshTunnel(lastSsh.host, localPort, lastSsh.remotePort);
      await tunnel.start();
      clearActiveTunnel(); // clean any stale reference before storing new tunnel

      const url = `http://localhost:${localPort}`;
      const result = await testConnection(url);
      if (result.status === 'online') {
        setActiveRemoteConnection(url);
        currentRemoteAddress = url;
        closeSplash();
        return url;
      }
      // Tunnel up but MindOS not responding - fall through
      tunnel.stop().catch(() => {});
    } catch (err) {
      console.warn('[MindOS] SSH auto-reconnect failed:', (err as Error)?.message);
    }
  }

  // Need user input — close splash, show connect window
  closeSplash();
  return showConnectWindow();
}

// ── Helper ──

function navigator_lang(): 'zh' | 'en' {
  const locale = app.getLocale();
  return locale?.startsWith('zh') ? 'zh' : 'en';
}

/**
 * Scan known MCP client config files and replace old port URLs with new port.
 * Matches both `localhost:` and `127.0.0.1:` host forms and writes back the
 * 127.0.0.1 form (the MCP server binds IPv4-only — see mcp-config-rewrite.ts).
 * Safe for stdio configs (no url field → no change).
 */
function updateMcpClientConfigs(oldPort: number, newPort: number): void {
  const home = DESKTOP_HOME;
  const resolve = (p: string) => p.startsWith('~/') ? path.join(home, p.slice(2)) : p;
  // All known MCP client config paths (global only — project configs are repo-specific)
  const configPaths = [
    '~/.claude.json',
    '~/.cursor/mcp.json',
    '~/.codeium/windsurf/mcp_config.json',
    '~/.trae/mcp.json',
    '~/.gemini/settings.json',
    '~/.openclaw/mcp.json',
    '~/.codebuddy/mcp.json',
    '~/.mindos/mcp.json',
  ];
  let updated = 0;
  for (const rel of configPaths) {
    const abs = resolve(rel);
    try {
      if (!existsSync(abs)) continue;
      const raw = readFileSync(abs, 'utf-8');
      const replaced = rewriteMcpClientConfig(raw, oldPort, newPort);
      if (replaced === null) continue;
      writeFileSync(abs, replaced, 'utf-8');
      updated++;
      console.info(`[MindOS] Updated MCP port in ${rel}: ${oldPort} → ${newPort}`);
    } catch (err) {
      console.warn(`[MindOS] Failed to update ${rel}:`, err instanceof Error ? err.message : err);
    }
  }
  if (updated > 0) {
    console.info(`[MindOS] Updated ${updated} MCP client config(s)`);
  }
}

/** Update tray with current state — always includes ports/address */
function refreshTray(status: 'starting' | 'running' | 'error'): void {
  updateTrayMenu(currentMode, status, currentRemoteAddress, currentWebPort, currentMcpPort);
}

// ── Boot-time Silent Healing ──

/**
 * Detect and silently fix residual state from a previous MindOS installation.
 *
 * When users delete MindOS.app from /Applications (the standard macOS uninstall),
 * orphaned processes, stale PID files, incompatible Node.js, and corrupt build
 * caches may remain. This function fixes them all before normal startup proceeds
 * so that reinstalling "just works" — no user intervention needed.
 *
 * Safe to call on every boot (idempotent). Never touches user data (config values,
 * knowledge base, auth tokens).
 */
async function healPreviousInstallation(): Promise<void> {
  const t0 = Date.now();
  const stopHeal = desktopTelemetry.startTimer('desktop.boot.heal');

  // Fast-restart optimisation: if the app exited cleanly less than 30 s ago,
  // skip the full cleanup — no orphaned processes or stale ports to worry about.
  try {
    const configStore = getAppConfigStore();
    const lastCleanExit = configStore.get('lastCleanExit');
    if (lastCleanExit && (t0 - lastCleanExit) < 30_000) {
      const cleanExitAgoMs = t0 - lastCleanExit;
      console.info(`[MindOS:heal] Clean exit ${cleanExitAgoMs}ms ago — skipping cleanup`);
      stopHeal({ skipped: true, cleanExitAgoMs });
      return;
    }
  } catch { /* config read failure — run full heal */ }

  // 1. Stop launchd/systemd daemon — prevents it from respawning processes we just killed
  splashStatus({ status: 'healing', message: 'Checking previous installation...' });
  await cleanupConflictingLaunchdService();
  await cleanupLinuxSystemdService();

  // 2. Kill orphaned processes from BOTH Desktop and CLI pid files
  await ProcessManager.cleanupOrphanedChildren();
  await ProcessManager.cleanupCliPidFile();

  // 3. Port-based fallback kill — catches processes not tracked by PID files
  //    (e.g. Next.js worker processes, externally started MCP)
  //    Brief pause gives SIGTERM'd processes time to exit before we check ports.
  await new Promise(r => setTimeout(r, 500));

  const config = readMindOsConfigFileUncached();
  const webPort = config.port || DEFAULT_WEB_PORT;
  const mcpPort = config.mcpPort || DEFAULT_MCP_PORT;

  // Port cleanup and runtime validation are independent — run them in parallel.
  // Port chain: check → kill → wait release (must be serial internally).
  // Validate chain: node → cache (fast, no port dependency).
  splashStatus({ status: 'healing', message: 'Freeing ports & validating runtime...' });

  const portCleanup = async () => {
    const webInUse = await isPortInUse(webPort);
    const mcpInUse = await isPortInUse(mcpPort);

    if (webInUse) {
      await ProcessManager.killProcessesOnPort(webPort);
    }
    if (mcpInUse) {
      await ProcessManager.killProcessesOnPort(mcpPort);
    }

    // Wait for configured ports to free up (gives killed processes time to exit)
    // This prevents findAvailablePort from jumping to 3457 during reinstall.
    if (webInUse || mcpInUse) {
      const [webFreed, mcpFreed] = await Promise.all([
        webInUse ? waitForPortRelease(webPort, 5000) : Promise.resolve(true),
        mcpInUse ? waitForPortRelease(mcpPort, 5000) : Promise.resolve(true),
      ]);
      if (!webFreed) {
        console.warn(`[MindOS:heal] Port ${webPort} still in use after cleanup — findAvailablePort will handle it`);
      }
      if (!mcpFreed) {
        console.warn(`[MindOS:heal] Port ${mcpPort} still in use after cleanup — findAvailablePort will handle it`);
      }
    }

    return { webInUse, mcpInUse };
  };

  const runtimeValidation = async () => {
    await validatePrivateNode();

    try {
      const bundledRoot = getDefaultBundledMindOsDirectory();
      if (bundledRoot) {
        validateBuildCache(resolveWebAppDir(bundledRoot));
      }
    } catch { /* non-critical */ }
  };

  const [portResult] = await Promise.all([portCleanup(), runtimeValidation()]);

  const elapsed = Date.now() - t0;
  stopHeal({ skipped: false, webInUse: portResult.webInUse, mcpInUse: portResult.mcpInUse });
  if (elapsed > 100) {
    console.info(`[MindOS:heal] Previous installation healing completed in ${elapsed}ms`);
  }
}

/** Remove private Node.js if it can't run or version is too low (< 18). */
async function validatePrivateNode(): Promise<'missing' | 'ok' | 'removed'> {
  const stop = desktopTelemetry.startTimer('desktop.boot.validate_node');
  const nodeBin = path.join(
    DESKTOP_HOME, '.mindos', 'node',
    process.platform === 'win32' ? 'node.exe' : 'bin/node',
  );
  if (!existsSync(nodeBin)) {
    stop({ result: 'missing' });
    return 'missing';
  }

  try {
    const { stdout } = await execFileAsync(nodeBin, ['--version'], {
      encoding: 'utf-8',
      timeout: 5000,
    });
    const version = (stdout as string).trim();
    const match = version.match(/^v(\d+)/);
    if (match && parseInt(match[1], 10) >= 18) {
      stop({ result: 'ok' });
      return 'ok';
    }
    console.warn(`[MindOS:heal] Private Node.js ${version} is below v18 — removing`);
  } catch {
    console.warn('[MindOS:heal] Private Node.js failed version check — removing');
  }

  // Remove the entire private node directory — downloadNode() will replace it
  const nodeDir = path.join(DESKTOP_HOME, '.mindos', 'node');
  try { rmSync(nodeDir, { recursive: true, force: true }); } catch { /* best effort */ }
  stop({ result: 'removed' });
  return 'removed';
}

/** Remove .next if it exists but is corrupt (missing BUILD_ID and no standalone server, or partial standalone). */
function validateBuildCache(appDir: string): 'missing' | 'ok' | 'removed' {
  const stop = desktopTelemetry.startTimer('desktop.boot.validate_cache');
  const nextDir = path.join(appDir, '.next');
  if (!existsSync(nextDir)) {
    stop({ result: 'missing' });
    return 'missing';
  }

  const hasBuildId = existsSync(path.join(nextDir, 'BUILD_ID'));
  const hasStandalone = existsSync(path.join(nextDir, 'standalone', 'server.js'));

  // Standalone mode requires .next/static/ for CSS/JS assets — without it the UI is broken
  if (hasStandalone) {
    if (!existsSync(path.join(nextDir, 'static'))) {
      console.warn('[MindOS:heal] Incomplete standalone build (missing .next/static/) — removing to trigger rebuild');
      try { rmSync(nextDir, { recursive: true, force: true }); } catch { /* best effort */ }
      stop({ result: 'removed' });
      return 'removed';
    }
    stop({ result: 'ok' });
    return 'ok';
  }

  if (hasBuildId) {
    stop({ result: 'ok' });
    return 'ok';
  }

  // .next exists but neither BUILD_ID nor standalone — corrupt / interrupted build
  console.warn('[MindOS:heal] Corrupt .next build cache detected — removing to trigger rebuild');
  try { rmSync(nextDir, { recursive: true, force: true }); } catch { /* best effort */ }
  stop({ result: 'removed' });
  return 'removed';
}

/**
 * Detect and clean up a conflicting CLI launchd service (com.mindos.app).
 *
 * When users delete MindOS.app from Finder without quitting first, the CLI's
 * launchd daemon keeps running and auto-restarting `mindos start`, occupying
 * all available ports. Desktop needs to stop it before starting its own services.
 *
 * Only acts on macOS. Only stops the service if it exists and conflicts with
 * Desktop's own startup (i.e. Desktop is about to manage its own processes).
 */
async function cleanupConflictingLaunchdService(): Promise<void> {
  if (process.platform !== 'darwin') return;

  try {
    const execOpts = { encoding: 'utf-8' as const, timeout: 3000 };

    // Check if com.mindos.app service is registered with launchd
    let serviceExists = false;
    try {
      const { stdout } = await execFileAsync('launchctl', ['list', 'com.mindos.app'], execOpts);
      serviceExists = stdout.includes('com.mindos.app');
    } catch {
      // launchctl list exits non-zero if service doesn't exist — that's fine
      return;
    }

    if (!serviceExists) return;

    console.warn('[MindOS] Detected conflicting launchd service com.mindos.app — stopping it');

    // Step 1: bootout the service so launchd stops restarting it
    try {
      const { stdout: uidOutput } = await execFileAsync('id', ['-u'], execOpts);
      const uid = uidOutput.trim();
      await execFileAsync('launchctl', ['bootout', `gui/${uid}/com.mindos.app`], { ...execOpts, timeout: 5000 });
      console.info('[MindOS] Stopped launchd service com.mindos.app');
    } catch (err) {
      // Try `launchctl remove` as fallback (works on some macOS versions)
      try {
        await execFileAsync('launchctl', ['remove', 'com.mindos.app'], { ...execOpts, timeout: 5000 });
        console.info('[MindOS] Removed launchd service com.mindos.app via fallback');
      } catch {
        console.warn('[MindOS] Could not stop launchd service:', err instanceof Error ? err.message : err);
      }
    }

    // Step 2: Remove the plist file to prevent re-registration on next login
    const plistPath = path.join(DESKTOP_HOME, 'Library', 'LaunchAgents', 'com.mindos.app.plist');
    if (existsSync(plistPath)) {
      try {
        unlinkSync(plistPath);
        console.info(`[MindOS] Removed ${plistPath}`);
      } catch (err) {
        console.warn('[MindOS] Could not remove plist:', err instanceof Error ? err.message : err);
      }
    }

    // Step 3: Kill residual CLI mindos processes still holding ports.
    // Use full path pattern to avoid killing our own Desktop process.
    try {
      await execFileAsync('pkill', ['-f', 'node_modules/@geminilight/mindos/bin/cli.js start'], execOpts);
    } catch { /* no matching processes — fine */ }
    // Also kill Next.js workers spawned by the CLI. Keep the old app/ pattern
    // as a cleanup-only fallback for users upgrading from pre-v1 packages.
    try {
      await execFileAsync('pkill', ['-f', 'node_modules/@geminilight/mindos/packages/web/node_modules/.bin/next'], execOpts);
    } catch { /* no matching processes — fine */ }
    try {
      await execFileAsync('pkill', ['-f', 'node_modules/@geminilight/mindos/app/node_modules/.bin/next'], execOpts);
    } catch { /* no matching processes — fine */ }

    // Note: no explicit wait needed — findAvailablePort will retry if ports haven't released yet

  } catch (err) {
    // Non-critical — if cleanup fails, findAvailablePort will still work as fallback
    console.warn('[MindOS] launchd cleanup failed:', err instanceof Error ? err.message : err);
  }
}

/**
 * Detect and clean up a conflicting CLI systemd user service on Linux.
 *
 * Similar to the macOS launchd case: if the user ran `mindos start --daemon`
 * (which creates ~/.config/systemd/user/mindos.service), then deleted the app,
 * systemd keeps restarting the service and occupies all ports.
 */
async function cleanupLinuxSystemdService(): Promise<void> {
  if (process.platform !== 'linux') return;

  try {
    const opts = { encoding: 'utf-8' as const, timeout: 5000 };
    const isSystemdServiceActive = async (service: string) => {
      try {
        const { stdout } = await execFileAsync('systemctl', ['--user', 'is-active', service], opts);
        const state = stdout.trim();
        return state === 'active' || state === 'activating';
      } catch {
        return false;
      }
    };
    const stopSystemdService = async (service: string) => {
      try { await execFileAsync('systemctl', ['--user', 'stop', service], opts); } catch { /* ok */ }
      try { await execFileAsync('systemctl', ['--user', 'disable', service], opts); } catch { /* ok */ }
    };

    // Check if the service is active or enabled
    let isActive = await isSystemdServiceActive('mindos');

    if (!isActive) {
      // Also check by alternative service name com.mindos.app
      isActive = await isSystemdServiceActive('com.mindos.app');
      if (isActive) {
        console.warn('[MindOS] Detected conflicting systemd service com.mindos.app — stopping it');
        await stopSystemdService('com.mindos.app');
        return;
      }
      return;
    }

    console.warn('[MindOS] Detected conflicting systemd service mindos — stopping it');
    await stopSystemdService('mindos');
    console.info('[MindOS] Stopped systemd user service');
  } catch {
    // Non-critical
  }
}

/** Spawn a process with enriched env, wait for exit. Rejects on non-zero or timeout. */
function forceTerminateProcessTree(proc: ChildProcess): void {
  try {
    if (process.platform === 'win32' && proc.pid) {
      execFileChild('taskkill.exe', ['/PID', String(proc.pid), '/T', '/F'], () => {});
      proc.kill();
      return;
    }
    if (proc.pid) {
      try { process.kill(-proc.pid, 'SIGKILL'); } catch { /* process may not own a group */ }
    }
    if (process.platform === 'win32') proc.kill();
    else proc.kill('SIGKILL');
  } catch { /* already exited */ }
}

function spawnWithEnv(bin: string, args: string[], cwd: string, env: Record<string, string>, timeoutMs: number): Promise<void> {
  return new Promise((resolve, reject) => {
    // On Windows, .cmd/.bat (npm.cmd, next.cmd) can't be spawned directly.
    // resolveExecTarget wraps them in cmd.exe with quoted argv — shell:true
    // would concatenate unquoted and break on paths with spaces.
    const target = resolveExecTarget(bin, args);
    const proc = spawnChild(target.command, target.args, {
      cwd,
      env,
      stdio: ['ignore', 'pipe', 'pipe'],
      detached: process.platform !== 'win32',
    });
    let settled = false;

    // Log last output for diagnostics on failure
    let lastOutput = '';
    proc.stdout?.on('data', (d: Buffer) => { lastOutput = d.toString().trim().split('\n').pop() || ''; });
    proc.stderr?.on('data', (d: Buffer) => { lastOutput = d.toString().trim().split('\n').pop() || ''; });

    const timer = setTimeout(() => {
      if (settled) return;
      settled = true;
      forceTerminateProcessTree(proc);
      reject(new Error(`${path.basename(bin)} ${args[0] || ''} timed out after ${Math.round(timeoutMs / 1000)}s\nLast output: ${lastOutput}`));
    }, timeoutMs);
    proc.on('exit', (code: number | null) => {
      clearTimeout(timer);
      if (settled) return;
      settled = true;
      if (code === 0) resolve();
      else reject(new Error(`${path.basename(bin)} ${args[0] || ''} exited with code ${code}\n${lastOutput}`));
    });
    proc.on('error', (err: Error) => { clearTimeout(timer); if (!settled) { settled = true; reject(err); } });
  });
}

// ── Tray Action: Switch Mode (show selection window, then switch if different) ──

let isSwitchingMode = false;
async function handleChangeMode(): Promise<void> {
  if (isSwitchingMode || isQuitting || isUpdating) return;
  isSwitchingMode = true;
  try {
    const selectedMode = await showModeSelectWindow();
    if (!selectedMode || selectedMode === currentMode) return;
    await switchToMode(selectedMode);
  } finally {
    isSwitchingMode = false;
  }
}

// ── Tray Action: Switch Server (remote mode — show connect window) ──

async function handleSwitchServer(): Promise<void> {
  if (isQuitting || isUpdating) return;
  clearActiveTunnel(); // Close existing SSH tunnel before switching
  const url = await showConnectWindow();
  if (!url) return; // user cancelled

  if (connectionMonitor) connectionMonitor.stop();
  currentRemoteAddress = url;
  setupConnectionMonitor(url);
  mainWindow?.loadURL(url);
  refreshTray('running');
}

// ── Core: switch from current mode to target mode with preheat ──

async function switchToMode(targetMode: 'local' | 'remote'): Promise<void> {
  if (!mainWindow || mainWindow.isDestroyed()) return;
  if (isQuitting || isUpdating) return;

  const oldMode = currentMode;
  const zh = navigator_lang() === 'zh';

  // 1. Overlay on current content
  await injectOverlay('mindos-switch-overlay', `
    <div style="position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;font-family:system-ui;color:white;font-size:16px;backdrop-filter:blur(4px)">
      ${zh ? '正在切换...' : 'Switching...'}
    </div>
  `);

  // 2. Preheat: keep old alive, start new
  const oldPM = processManager;
  const oldCM = connectionMonitor;
  const oldWebPort = currentWebPort;
  const oldMcpPort = currentMcpPort;
  const oldRemoteAddress = currentRemoteAddress;
  processManager = null;
  connectionMonitor = null;
  if (targetMode === 'local') { clearActiveTunnel(); currentRemoteAddress = undefined; }
  else { currentWebPort = undefined; currentMcpPort = undefined; }
  currentMode = targetMode;
  invalidateConfig();

  let url: string | null = null;
  try {
    if (targetMode === 'local') {
      url = await startLocalMode();
    } else {
      url = await startRemoteMode();
      if (url) setupConnectionMonitor(url);
    }
  } catch { /* handled below */ }

  // 3. Apply
  if (url) {
    // Clean up any ongoing recovery poll from a previous crash
    if (activeRecoveryPoll) { clearInterval(activeRecoveryPoll); activeRecoveryPoll = null; }
    saveDesktopMode(targetMode);
    const openUrl = targetMode === 'local' ? resolveLocalMindOsBrowseUrl(url) : url;
    mainWindow.loadURL(openUrl);
    refreshTray('running');
    // Stop old processes with timeout to avoid hanging
    if (oldPM) {
      Promise.race([
        oldPM.stop(),
        new Promise<void>((_, reject) => setTimeout(() => reject(new Error('stop timeout')), 5000)),
      ]).catch((err) => console.warn('[MindOS] Old process cleanup:', err instanceof Error ? err.message : err));
    }
    if (oldCM) oldCM.stop();
  } else {
    // Revert silently — restore all state
    currentMode = oldMode;
    processManager = oldPM;
    connectionMonitor = oldCM;
    currentWebPort = oldWebPort;
    currentMcpPort = oldMcpPort;
    currentRemoteAddress = oldRemoteAddress;
    await removeOverlay('mindos-switch-overlay');
    refreshTray(processManager ? 'running' : 'error');
  }
}

// ── Tray Action: Restart Services ──

let isRestarting = false;
async function handleRestartServices(): Promise<void> {
  if (currentMode !== 'local' || isRestarting || isQuitting || isUpdating) return;
  isRestarting = true;
  invalidateConfig(); // Re-read config (setup wizard may have changed ports/paths)
  const zh = navigator_lang() === 'zh';

  if (mainWindow && !mainWindow.isDestroyed()) {
    await injectOverlay('mindos-switch-overlay', `
      <div style="position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;font-family:system-ui;color:white;font-size:16px;backdrop-filter:blur(4px)">
        ${zh ? '正在重启...' : 'Restarting...'}
      </div>
    `);
  }

  try {
    refreshTray('starting');
    if (processManager) {
      // Desktop owns the processes — restart them
      const previousMcpPort = currentMcpPort;
      await processManager.restart();
      currentWebPort = processManager.webPort;
      currentMcpPort = processManager.mcpPort;
      if (previousMcpPort !== undefined && currentMcpPort !== previousMcpPort) {
        updateMcpClientConfigs(previousMcpPort, currentMcpPort);
      }
      refreshTray('running');
      await removeOverlay('mindos-switch-overlay');
      if (mainWindow && currentWebPort !== undefined) {
        mainWindow.loadURL(
          resolveLocalMindOsBrowseUrl(`http://127.0.0.1:${currentWebPort}`),
        );
      } else {
        mainWindow?.reload();
      }
    } else {
      // Connected to external CLI — do a full re-launch
      const url = await startLocalMode();
      if (url && mainWindow) {
        mainWindow.loadURL(resolveLocalMindOsBrowseUrl(url));
        refreshTray('running');
      }
      await removeOverlay('mindos-switch-overlay');
    }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    await removeOverlay('mindos-switch-overlay');
    refreshTray('error');
    dialog.showErrorBox(zh ? '重启失败' : 'Restart Failed', msg);
  } finally {
    isRestarting = false;
  }
}

// ── Tray Callbacks ──

const trayCallbacks: TrayCallbacks = {
  onChangeMode: handleChangeMode,
  onOpenMindRoot: () => {
    const configured = getEffectiveMindRootFromConfig(loadConfig());
    shell.openPath(configured || path.join(DESKTOP_HOME, 'MindOS', 'mind'));
  },
  onRestartServices: handleRestartServices,
  onSwitchServer: handleSwitchServer,
  onReconnect: async () => {
    if (!currentRemoteAddress || !mainWindow || mainWindow.isDestroyed()) return;
    refreshTray('starting');
    try {
      const result = await testConnection(currentRemoteAddress);
      if (result.status === 'online') {
        removeOverlay('mindos-disconnect-overlay');
        mainWindow.reload();
        refreshTray('running');
      } else {
        refreshTray('error');
      }
    } catch {
      refreshTray('error');
    }
  },
  onRefreshCliShim: () => { refreshMindosCliAndNotify(mainWindow); },
};

// ── IPC Handlers ──

function rendererTrustSnapshot(event: IpcMainInvokeEvent): RendererTrustSnapshot {
  return {
    currentMode,
    currentWebPort,
    currentRemoteAddress,
    senderMatchesMainWindow:
      !!mainWindow &&
      !mainWindow.isDestroyed() &&
      event.sender === mainWindow.webContents,
    senderUrl: event.senderFrame?.url || event.sender.getURL(),
    mainWindowUrl:
      mainWindow && !mainWindow.isDestroyed()
        ? mainWindow.webContents.getURL()
        : undefined,
  };
}

function assertTrustedLocalRenderer(event: IpcMainInvokeEvent, capability: string): void {
  const snapshot = rendererTrustSnapshot(event);
  if (isTrustedLocalRenderer(snapshot)) return;
  console.warn('[MindOS:ipc] blocked local-only capability', {
    capability,
    mode: snapshot.currentMode,
    senderUrl: snapshot.senderUrl,
    mainWindowUrl: snapshot.mainWindowUrl,
  });
  throw trustedLocalRendererError(capability);
}

function isTrustedActiveMainWindowRenderer(event: IpcMainInvokeEvent): boolean {
  const snapshot = rendererTrustSnapshot(event);
  if (!snapshot.senderMatchesMainWindow) return false;
  return isAllowedMainWindowNavigation(snapshot.senderUrl, {
    currentMode,
    currentWebPort,
    currentRemoteAddress,
  });
}

function assertTrustedActiveMainWindowRenderer(event: IpcMainInvokeEvent, capability: string): void {
  if (isTrustedActiveMainWindowRenderer(event)) return;
  throw trustedLocalRendererError(capability);
}

function handleLocalOnly<TArgs extends unknown[]>(
  channel: string,
  handler: (event: IpcMainInvokeEvent, ...args: TArgs) => unknown,
): void {
  ipcMain.handle(channel, (event, ...args) => {
    assertTrustedLocalRenderer(event, channel);
    return handler(event, ...(args as TArgs));
  });
}

function handleActiveMainWindowOnly<TArgs extends unknown[]>(
  channel: string,
  handler: (event: IpcMainInvokeEvent, ...args: TArgs) => unknown,
): void {
  ipcMain.handle(channel, (event, ...args) => {
    assertTrustedActiveMainWindowRenderer(event, channel);
    return handler(event, ...(args as TArgs));
  });
}

function openExternalGuarded(url: string): void {
  // Only web/mail URLs may leave the app — file://, UNC and OS scheme
  // handlers can execute local programs (especially on Windows).
  if (!isSafeExternalUrl(url)) {
    console.warn('[MindOS] Blocked external open of non-web URL:', url.slice(0, 200));
    return;
  }
  shell.openExternal(url).catch((err) => {
    console.warn('[MindOS] Failed to open external URL:', err instanceof Error ? err.message : err);
  });
}

function installMainWindowNavigationGuard(win: BrowserWindow): void {
  win.webContents.setWindowOpenHandler(({ url }) => {
    if (isAllowedMainWindowNavigation(url, { currentMode, currentWebPort, currentRemoteAddress })) {
      return { action: 'allow' };
    }
    openExternalGuarded(url);
    return { action: 'deny' };
  });

  win.webContents.on('will-navigate', (event, url) => {
    if (isAllowedMainWindowNavigation(url, { currentMode, currentWebPort, currentRemoteAddress })) {
      return;
    }
    event.preventDefault();
    openExternalGuarded(url);
  });
}

function parseCoreDownloadArgs(
  urls: unknown,
  version: unknown,
  size: unknown,
  sha256: unknown,
): { urls: string[]; version: string; size: number; sha256: string } {
  if (!Array.isArray(urls) || urls.some((url) => typeof url !== 'string')) {
    throw new Error('Invalid core update URLs');
  }
  if (typeof version !== 'string' || !version.trim()) throw new Error('Invalid core update version');
  if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) throw new Error('Invalid core update size');
  if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(sha256)) throw new Error('Invalid core update SHA-256');
  return { urls, version, size, sha256 };
}

function setupIPC(): void {
  handleActiveMainWindowOnly('get-app-info', () => ({
    version: app.getVersion(),
    platform: process.platform,
    mode: currentMode,
  }));

  handleLocalOnly('open-mindroot', () => {
    const configured = getEffectiveMindRootFromConfig(loadConfig());
    shell.openPath(configured || path.join(DESKTOP_HOME, 'MindOS', 'mind'));
  });

  // Directory picker for onboarding setup
  handleLocalOnly('select-directory', async () => {
    const result = await dialog.showOpenDialog({
      properties: ['openDirectory', 'createDirectory'],
      title: 'Select Knowledge Base Directory',
    });
    return result.canceled ? null : result.filePaths[0] ?? null;
  });

  handleActiveMainWindowOnly('switch-mode', () => handleChangeMode());
  handleLocalOnly('restart-services', () => handleRestartServices());
  handleActiveMainWindowOnly('switch-server', () => handleSwitchServer());

  // ── Core Hot Update IPC ──

  handleActiveMainWindowOnly('check-core-update', async () => {
    // CRITICAL FIX: Always force a fresh read of currentCoreVersion for the check
    // This ensures that after apply(), the new version is immediately reflected
    if (currentMode !== 'local') {
      return { available: false, currentVersion: currentCoreVersion || '', latestVersion: '' };
    }
    
    // If currentCoreVersion is not set, it means startLocalMode hasn't been called yet
    // Try to read it from the runtime directory
    let versionToCheck = currentCoreVersion;
    if (!versionToCheck) {
      try {
        versionToCheck = coreUpdater.getCachedVersion();
        if (versionToCheck) {
          console.info(`[MindOS] Recovered currentCoreVersion from cache: ${versionToCheck}`);
        }
      } catch { /* ignore */ }
    }
    
    if (!versionToCheck) {
      return { available: false, currentVersion: '', latestVersion: '' };
    }
    
    return coreUpdater.check(versionToCheck);
  });

  handleLocalOnly('download-core-update', async (_e, urls: unknown, version: unknown, size: unknown, sha256: unknown) => {
    const download = parseCoreDownloadArgs(urls, version, size, sha256);
    // Forward progress events to renderer
    const onProgress = (p: { percent: number; transferred: number; total: number }) => {
      const wins = BrowserWindow.getAllWindows();
      for (const win of wins) {
        if (!win.isDestroyed()) win.webContents.send('core-update-progress', p);
      }
    };
    coreUpdater.on('progress', onProgress);
    try {
      await coreUpdater.download(download.urls, download.version, download.size, download.sha256);
    } finally {
      coreUpdater.removeListener('progress', onProgress);
    }
  });

  handleLocalOnly('cancel-core-download', () => {
    coreUpdater.cancelDownload();
  });

  handleActiveMainWindowOnly('get-core-update-pending', () => {
    return { version: coreUpdater.getPendingVersion() };
  });

  handleLocalOnly('apply-core-update', async () => {
    if (isQuitting || isUpdating) throw new Error('App is shutting down');
    const zh = navigator_lang() === 'zh';
    const previousVersion = currentCoreVersion;

    // Inject overlay before stopping services
    if (mainWindow && !mainWindow.isDestroyed()) {
      await injectOverlay('mindos-core-update-overlay', `
        <div style="position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:system-ui;backdrop-filter:blur(8px)">
          <div style="width:28px;height:28px;border:3px solid rgba(212,149,74,0.3);border-top-color:#d4954a;border-radius:50%;animation:spin 1s linear infinite;margin-bottom:14px"></div>
          <div style="color:#e8e4dc;font-size:18px;font-weight:600">${zh ? '正在更新 MindOS...' : 'Updating MindOS...'}</div>
          <div style="color:#8a8275;font-size:13px;margin-top:6px">${zh ? '服务重启后将自动刷新' : 'Will auto-reload when ready'}</div>
          <style>@keyframes spin{to{transform:rotate(360deg)}}</style>
        </div>
      `);
    }

    let appliedVersion: string | null = null;
    try {
      // 1. Stop processes (release file locks for Windows)
      console.info(`[CoreUpdater] Applying update: stopping processes (current: v${previousVersion})`);
      if (processManager) {
        await processManager.stop();
        processManager = null;
      }

      // 2. Atomic file replacement
      const newRuntimeDir = coreUpdater.apply();
      const verifiedVersion = coreUpdater.getCachedVersion();
      console.info(`[CoreUpdater] Files replaced: ${newRuntimeDir}, verified version: ${verifiedVersion}`);

      // 2b. Refresh CLI shim so `mindos -v` reflects the new version
      try { ensureMindosCliShim({ appendPath: false }); } catch (e) {
        console.warn('[CoreUpdater] CLI shim refresh failed:', e);
      }

      // 3. Restart with new runtime (startLocalMode re-resolves → picks cached)
      invalidateConfig();
      const url = await startLocalMode();
      appliedVersion = currentCoreVersion;
      console.info(`[CoreUpdater] startLocalMode completed: url=${url}, version=${appliedVersion} (was: ${previousVersion})`);

      if (appliedVersion === previousVersion) {
        console.warn(`[CoreUpdater] WARNING: Version did not change after apply! (${previousVersion} → ${appliedVersion}). Runtime may not have been picked correctly.`);
      }

      if (url && mainWindow && !mainWindow.isDestroyed()) {
        const result = { ok: true, version: appliedVersion };
        // Clear Electron HTTP cache before loading the new page.
        // Without this, Electron may serve stale HTML/JS from disk cache,
        // causing the user to see old UI even though the backend is running new code.
        setTimeout(async () => {
          if (mainWindow && !mainWindow.isDestroyed()) {
            try {
              await mainWindow.webContents.session.clearCache();
            } catch (e) {
              console.warn('[CoreUpdater] clearCache failed:', e);
            }
            mainWindow.loadURL(resolveLocalMindOsBrowseUrl(url));
            refreshTray('running');
          }
        }, 100);
        return result;
      }
      return { ok: true, version: appliedVersion };
    } catch (err) {
      // Recovery: try to restart with whatever runtime is available
      console.error('[MindOS] Core update apply failed, recovering:', err);
      try {
        invalidateConfig();
        const url = await startLocalMode();
        appliedVersion = currentCoreVersion;
        if (url && mainWindow && !mainWindow.isDestroyed()) {
          try { await mainWindow.webContents.session.clearCache(); } catch { /* best effort */ }
          mainWindow.loadURL(resolveLocalMindOsBrowseUrl(url));
        }
      } catch (recoverErr) {
        console.error('[MindOS] Recovery also failed:', recoverErr);
        await removeOverlay('mindos-core-update-overlay');
        refreshTray('error');
      }
      throw err;
    }
  });

  // Uninstall: per-platform plan (trash / NSIS uninstaller / manual instructions).
  // Server-side cleanup (stop services, remove config) is handled by /api/uninstall
  // before this IPC is called.
  handleLocalOnly('uninstall-app', async () => {
    try {
      // Stop managed child processes first
      await processManager?.stop();

      const plan = planUninstall({
        platform: process.platform,
        installPath: getDesktopInstallPath(),
        appImagePath: process.env.APPIMAGE,
        fileExists: existsSync,
      });

      if (plan.kind === 'manual') {
        return { ok: false, error: plan.instructions };
      }

      if (plan.kind === 'run-uninstaller') {
        // NSIS uninstaller removes files + registry entry; detach so it survives our quit
        const child = spawnChild(plan.uninstallerPath, [], { detached: true, stdio: 'ignore', windowsHide: false });
        child.unref();
        setTimeout(() => app.quit(), 500);
        return { ok: true };
      }

      // trashItem is the modern async replacement for deprecated moveItemToTrash
      try {
        await shell.trashItem(plan.target);
      } catch (trashErr) {
        return { ok: false, error: `Failed to move ${plan.target} to Trash: ${(trashErr as Error)?.message}. You may need to delete it manually.` };
      }

      // Quit after a brief delay to let the IPC response reach the renderer
      setTimeout(() => app.quit(), 500);
      return { ok: true };
    } catch (err) {
      return { ok: false, error: err instanceof Error ? err.message : String(err) };
    }
  });
}

// ── Connection Monitor ──

/** Inject or remove a full-screen overlay on the main window.
 *  id must be a safe CSS identifier (alphanumeric + hyphens only). */
async function injectOverlay(id: string, html: string): Promise<void> {
  if (!mainWindow || mainWindow.isDestroyed()) return;
  // Sanitize id — only allow safe CSS identifier characters
  if (!/^[a-zA-Z][\w-]*$/.test(id)) return;
  const safeId = JSON.stringify(id);
  try {
    await mainWindow.webContents.executeJavaScript(`
      (function() {
        var _id = ${safeId};
        if (document.getElementById(_id)) return;
        const d = document.createElement('div');
        d.id = _id;
        d.insertAdjacentHTML("beforeend", ${JSON.stringify(html)});
        document.body.appendChild(d);
      })()
    `);
  } catch (err) { console.warn('[MindOS] injectOverlay failed:', (err as Error)?.message); }
}

async function removeOverlay(id: string): Promise<void> {
  if (!mainWindow || mainWindow.isDestroyed()) return;
  if (!/^[a-zA-Z][\w-]*$/.test(id)) return;
  try {
    await mainWindow.webContents.executeJavaScript(
      `document.getElementById(${JSON.stringify(id)})?.remove()`
    );
  } catch (err) { console.warn('[MindOS] removeOverlay failed:', (err as Error)?.message); }
}

function setupConnectionMonitor(url: string): void {
  if (connectionMonitor) connectionMonitor.stop();
  connectionMonitor = new ConnectionMonitor(url, {
    onLost: () => {
      mainWindow?.webContents.send('connection-lost');
      refreshTray('error');
      // Inject reconnection overlay
      const zh = navigator_lang() === 'zh';
      injectOverlay('mindos-disconnect-overlay', `
        <div style="position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.6);display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:system-ui;backdrop-filter:blur(6px)">
          <div style="color:#e8e4dc;font-size:18px;margin-bottom:8px">${zh ? '⚠ 与服务器的连接已断开' : '⚠ Connection Lost'}</div>
          <div style="color:#8a8275;font-size:13px;margin-bottom:20px">${zh ? '正在尝试重新连接...' : 'Attempting to reconnect...'}</div>
          <div style="display:flex;gap:8px">
            <button onclick="location.reload()" style="padding:8px 18px;border-radius:8px;border:none;background:#c8873a;color:#fff;font-size:13px;cursor:pointer;font-weight:500">${zh ? '立即重试' : 'Retry Now'}</button>
            <button onclick="window.mindos?.switchMode()" style="padding:8px 18px;border-radius:8px;border:1px solid rgba(232,228,220,0.15);background:rgba(255,255,255,0.08);color:#e8e4dc;font-size:13px;cursor:pointer">${zh ? '切换到本地模式' : 'Switch to Local'}</button>
          </div>
        </div>
      `);
    },
    onRestored: () => {
      mainWindow?.webContents.send('connection-restored');
      removeOverlay('mindos-disconnect-overlay');
      mainWindow?.reload();
      refreshTray('running');
    },
  });
  connectionMonitor.start();
}

// ── Splash Action Handler ──

let isBooting = false;
async function handleSplashAction(actionId: string): Promise<void> {
  switch (actionId) {
    case 'install-node':
      shell.openExternal('https://nodejs.org/');
      break;
    case 'switch-remote': {
      currentMode = 'remote';
      saveDesktopMode('remote');
      closeSplash();
      await bootApp();
      break;
    }
    case 'retry':
      if (isBooting) break;
      isBooting = true;
      splashStatus({ status: 'detecting' });
      try { await bootApp(); } finally { isBooting = false; }
      break;
    case 'quit':
      app.quit();
      break;
    case 'select-mode': {
      closeSplash();
      const mode = await showModeSelectWindow();
      if (mode) {
        currentMode = mode;
        saveDesktopMode(mode, { allowSeedWebSetup: true });
        // Create new splash for boot
        splashWindow = createSplash();
        await bootApp();
      }
      // User cancelled — do nothing (app stays with splash closed, tray keeps it alive)
      break;
    }
  }
}

// ── Boot App (resolve URL + show main window) ──

async function bootApp(): Promise<void> {
  let url: string | null = null;

  try {
    if (currentMode === 'local') {
      url = await startLocalMode();
    } else {
      url = await startRemoteMode();
      if (url) setupConnectionMonitor(url);
    }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    splashStatus({
      error: msg,
      actions: [
        { id: 'retry', label: 'retry', primary: true },
        { id: 'quit', label: 'quit' },
      ],
    });
    return;
  }

  if (!url) return; // splash is showing error + actions, wait for user

  // Create main window
  if (!mainWindow || mainWindow.isDestroyed()) {
    mainWindow = createMainWindow();
    installMainWindowNavigationGuard(mainWindow);
    setupIPC();
    try {
      const trayInstance = createTray(mainWindow, trayCallbacks);
      if (!trayInstance) {
        // createTray swallowed the error and returned null — same recovery as catch
        console.warn('[MindOS] Tray creation returned null — close will quit instead of hide');
        trayAvailable = false;
      } else {
        trayAvailable = true;
      }
    } catch (trayErr) {
      console.warn('[MindOS] Tray creation failed — close will quit instead of hide:', (trayErr as Error)?.message);
      trayAvailable = false;
    }
    registerShortcuts(mainWindow);
    cleanupUpdater = setupUpdater({
      onBeforeQuitAndInstall: () => { isUpdating = true; },
      assertTrustedLocalRenderer,
    });

    // Core Hot Update: silent check 30s after startup
    setTimeout(async () => {
      if (currentMode !== 'local' || !currentCoreVersion) return;
      try {
        // Check for pending download first (user downloaded but didn't apply last session)
        const pending = coreUpdater.getPendingVersion();
        if (pending && mainWindow && !mainWindow.isDestroyed()) {
          mainWindow.webContents.send('core-update-available', {
            current: currentCoreVersion,
            latest: pending,
            ready: true,
          });
          return;
        }
        // Otherwise check remote
        const info = await coreUpdater.check(currentCoreVersion);
        if (info.available && !info.desktopTooOld && mainWindow && !mainWindow.isDestroyed()) {
          mainWindow.webContents.send('core-update-available', {
            current: info.currentVersion,
            latest: info.latestVersion,
            ready: false,
          });
        }
      } catch { /* silent check — don't bother user */ }
    }, 30_000);
  }

  refreshTray('running');

  const loadUrl = currentMode === 'local' ? resolveLocalMindOsBrowseUrl(url) : url;
  mainWindow.loadURL(loadUrl);

  // Remove stale listeners from previous bootApp() calls to prevent stacking
  mainWindow.webContents.removeAllListeners('did-fail-load');
  mainWindow.webContents.removeAllListeners('did-finish-load');

  mainWindow.webContents.on('did-fail-load', (_event, code, desc, failedUrl) => {
    console.error('[MindOS] main window did-fail-load', code, desc, failedUrl);
    closeSplash();
    const zh = navigator_lang() === 'zh';
    if (mainWindow && !mainWindow.isDestroyed()) {
      dialog.showMessageBox(mainWindow, {
        type: 'error',
        title: zh ? '页面加载失败' : 'Page failed to load',
        message: zh ? `无法加载：${failedUrl}` : `Could not load: ${failedUrl}`,
        detail: `${desc} (code ${code})\n\n${zh ? '若使用本地模式，请在终端执行 MINDOS_OPEN_DEVTOOLS=1 启动应用以打开开发者工具，或在浏览器访问同一地址对比。' : 'Tip: launch with MINDOS_OPEN_DEVTOOLS=1 to open DevTools, or open the same URL in a browser.'}`,
      }).then(() => {
        if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show();
      }).catch((err) => {
        console.warn('[MindOS] Error showing load failure dialog:', err);
      });
    }
  });

  // Show main + hide splash on each navigation (not just the first)
  let firstLoad = true;
  mainWindow.webContents.on('did-finish-load', () => {
    if (firstLoad) {
      mainWindow?.show();
      closeSplash();
      if (process.env.MINDOS_OPEN_DEVTOOLS === '1') {
        mainWindow?.webContents.openDevTools({ mode: 'detach' });
      }
      firstLoad = false;
    }
    // macOS: resend fullscreen state — each load creates a fresh document,
    // so the html attribute must be rebuilt (Cmd+R, connection-lost recovery)
    if (process.platform === 'darwin' && mainWindow && !mainWindow.isDestroyed()) {
      mainWindow.webContents.send('mindos:mac-fullscreen', mainWindow.isFullScreen());
    }
  });

  // Fallback: if did-finish-load doesn't fire in 10s, show anyway
  setTimeout(() => {
    if (mainWindow && !mainWindow.isVisible()) {
      mainWindow.show();
      closeSplash();
    }
  }, 10000);
}

// ── App Lifecycle ──

app.whenReady().then(async () => {
  // Second instance: app.quit() is already queued — don't start servers or heal
  if (!hasSingleInstanceLock) return;

  registerMindosConnectProtocol();

  // Set up bilingual application menu (replaces default English-only menu on Windows/Linux)
  setupAppMenu({
    onOpenMindRoot: () => {
      const configured = getEffectiveMindRootFromConfig(loadConfig());
      shell.openPath(configured || path.join(DESKTOP_HOME, 'MindOS', 'mind'));
    },
    onChangeMode: handleChangeMode,
    onRestartServices: handleRestartServices,
  });

  ipcMain.handle('splash:action', (_e, actionId: string) => handleSplashAction(actionId));

  const stopBoot = desktopTelemetry.startTimer('desktop.boot.total');
  try {
    ensureMindosCliShim({ appendPath: process.env.MINDOS_DISABLE_CLI_SHIM_PATH_APPEND !== '1' });
    cleanupOrphanedSshTunnel();

    // Show splash BEFORE healing so users see immediate visual feedback
    // instead of staring at an empty desktop during port cleanup.
    if (needsDesktopModeSelectAtLaunch()) {
      const mode = await showModeSelectWindow();
      if (!mode) {
        stopBoot({ modeSelected: false, success: false });
        app.quit();
        return;
      }
      currentMode = mode;
      saveDesktopMode(mode, { allowSeedWebSetup: true });
      splashWindow = createSplash();
    } else {
      const disk = readMindOsConfigFileUncached();
      currentMode = disk.desktopMode === 'remote' ? 'remote' : 'local';
      splashWindow = createSplash();
    }

    await healPreviousInstallation();
    await bootApp();
    stopBoot({ mode: currentMode, modeSelected: true, success: true });
  } catch (error) {
    stopBoot({ mode: currentMode, success: false });
    throw error;
  }
});

app.on('window-all-closed', () => {
  if (!trayAvailable && !isQuitting && !isUpdating) app.quit();
});
app.on('activate', () => { if (mainWindow) mainWindow.show(); });

app.on('before-quit', (e) => {
  // When updating via electron-updater, skip cleanup — let the installer relaunch the app
  if (isUpdating) return;
  if (!isQuitting) {
    e.preventDefault();
    isQuitting = true;
    unregisterShortcuts();
    // Synchronous save — the debounced saveWindowState timer never fires before app.exit
    if (mainWindow && !mainWindow.isDestroyed()) saveWindowStateNow(mainWindow);
    const cleanup = async () => {
      try {
        if (processManager) {
          // Timeout: force exit if stop() hangs (child process not responding)
          await Promise.race([
            processManager.stop(),
            new Promise<void>((_, reject) => setTimeout(() => reject(new Error('stop timeout')), 8000)),
          ]);
        }
      } catch { /* best-effort */ }
      if (connectionMonitor) connectionMonitor.stop();
      if (activeRecoveryPoll) { clearInterval(activeRecoveryPoll); activeRecoveryPoll = null; }
      if (cleanupUpdater) { cleanupUpdater(); cleanupUpdater = null; }
      clearActiveTunnel();
      try { getAppConfigStore().set('lastCleanExit', Date.now()); } catch { /* best-effort */ }
      app.exit(0);
    };
    // Must use .then() — event handler cannot be async, but cleanup must complete before exit
    cleanup().catch(() => app.exit(1));
  }
});
