diff --git a/build/installer/custom-uninstall.nsh b/build/installer/custom-uninstall.nsh new file mode 100644 index 0000000..72cab55 --- /dev/null +++ b/build/installer/custom-uninstall.nsh @@ -0,0 +1,16 @@ +; Custom NSIS uninstaller for AgentDock. +; +; electron-builder's default uninstaller cleans $APPDATA\ +; (per-user data). This macro additionally cleans $PROGRAMDATA\AgentDock +; (perMachine shared data path) when it exists. +; +; Note: NSIS's $PROGRAMDATA variable is only available via NsisMultiUser +; in certain NSIS versions. We hardcode the standard ProgramData path +; since Windows uses "ProgramData" in English regardless of locale. + +!macro customUnInstall + StrCpy $0 "C:\ProgramData\AgentDock" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} +!macroend diff --git a/electron-builder.yml b/electron-builder.yml index e20e310..ae880ff 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -64,10 +64,42 @@ win: # NSIS 安装器详细配置 nsis: + # We want: + # - Hide "install for all users" radio from MUI install-mode page + # - Allow custom install path + # - Let user opt in/out of data deletion at uninstall time + # + # Constraints: + # - perMachine: true would define INSTALL_MODE_PER_ALL_USERS (hiding + # the radio) BUT would install to Program Files (per-machine path). + # We don't actually want that — Electron's userData would still go + # to AppData\Roaming, but the install dir itself would be global. + # - oneClick: true would skip MUI install-mode entirely but rejects + # allowToChangeInstallationDirectory=true at build time. + # + # Solution: keep perMachine=false + oneClick=false (assisted installer, + # path customizable) and inject `INSTALL_MODE_PER_ALL_USERS` via a + # customInstall macro. This hides the install-mode radio without + # moving the install location to Program Files. oneClick: false - perMachine: false + # perMachine=true: install to Program Files, triggers one UAC prompt + # at install time. This is the standard behavior for desktop apps on + # Windows (VS Code, Discord, Slack, etc.). Side effects: + # - MUI install-mode page is automatically skipped (template + # detects INSTALL_MODE_PER_ALL_USERS and skips PAGE_INSTALL_MODE) + # - allowToChangeInstallationDirectory=true still works (user can + # pick a non-default dir, but it must be writable by admin) + # - Electron's userData path is unchanged: still per-user at + # %APPDATA%\Roaming\AgentDock (Electron always uses per-user + # userData regardless of install mode) + perMachine: true allowToChangeInstallationDirectory: true - deleteAppDataOnUninstall: false + deleteAppDataOnUninstall: true + # Custom uninstall macro that additionally cleans + # $PROGRAMDATA\AgentDock\ (perMachine data path) on top of the + # standard $APPDATA\AgentDock\ cleanup. See + # build/installer/custom-uninstall.nsh. + include: build/installer/custom-uninstall.nsh createDesktopShortcut: true createStartMenuShortcut: true shortcutName: AgentDock diff --git a/electron/main.ts b/electron/main.ts index c70b4d4..c4c1a1b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -129,7 +129,15 @@ async function bootstrap() { ); globalDbHandle = openGlobalDb(projectsDbDir); } else { - globalDbHandle = openGlobalDb(); + // Co-locate global DB with the rest of the userData so it follows + // the install mode (perUser → AppData\Roaming\AgentDock, perMachine → + // ProgramData\AgentDock). See electron/main/userdata.ts. + const userDataDir = app.getPath("userData"); + log.info( + { userDataDir, installMode: detectInstallMode() }, + "global DB co-located with userData", + ); + globalDbHandle = openGlobalDb(userDataDir); } // 4. Register ALL IPC handlers @@ -222,18 +230,34 @@ protocol.registerSchemesAsPrivileged([ ]); // ============================================================ -// Dev mode userData isolation +// User-data path resolution // ============================================================ +// The location of userData depends on how AgentDock was installed: +// - dev mode (AGENTDOCK_USER_DATA_DIR set): explicit override +// - perUser install → %APPDATA%\AgentDock\ (current user only) +// - perMachine install → %PROGRAMDATA%\AgentDock\ (shared by all users) +// +// The decision is made here — before app.whenReady — so every IPC handler +// that reads the DB path sees the right location. +import { resolveUserDataPath, migrateLegacyUserData, detectInstallMode } from "./main/userdata.js"; if (process.env.AGENTDOCK_USER_DATA_DIR) { - const devUserData = resolve(process.env.AGENTDOCK_USER_DATA_DIR); - app.setPath("userData", devUserData); -} - -// 打包后将 userData/sessionData 指向 %APPDATA%/AgentDock. -if (app.isPackaged) { - app.setPath("userData", resolve(app.getPath("appData"), "AgentDock")); - app.setPath("sessionData", resolve(app.getPath("appData"), "AgentDock")); + app.setPath("userData", resolve(process.env.AGENTDOCK_USER_DATA_DIR)); +} else { + const userDataPath = resolveUserDataPath(); + app.setPath("userData", userDataPath); + app.setPath("sessionData", userDataPath); + // One-shot migration: if we just switched install mode, copy legacy + // userData into the new location so projects / sessions don't disappear. + if (app.isPackaged) { + const { migratedFrom } = migrateLegacyUserData(userDataPath); + if (migratedFrom) { + log.info( + { userDataPath, migratedFrom }, + "migrated legacy userData into new location", + ); + } + } } // ============================================================ diff --git a/electron/main/__tests__/userdata.test.ts b/electron/main/__tests__/userdata.test.ts new file mode 100644 index 0000000..67582f9 --- /dev/null +++ b/electron/main/__tests__/userdata.test.ts @@ -0,0 +1,132 @@ +// @ts-nocheck +/** + * userdata decision unit tests. + * + * The Electron `app` module is not available in Node test env, so we mock + * it and verify the perUser/perMachine decision based on process.execPath. + */ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +vi.mock("electron", () => ({ + app: { + getPath: (key: string) => { + if (key !== "userData") { + throw new Error(`unexpected getPath(${key})`); + } + // Standard perUser userData path + return process.platform === "win32" + ? "C:\\Users\\Test\\AppData\\Roaming\\AgentDock" + : "/home/test/.config/AgentDock"; + }, + }, +})); + +import { resolveUserDataPath, migrateLegacyUserData, detectInstallMode } from "../userdata.js"; + +describe("resolveUserDataPath", () => { + const originalExecPath = process.execPath; + + afterEach(() => { + Object.defineProperty(process, "execPath", { value: originalExecPath }); + }); + + it("uses %APPDATA%\\AgentDock when exe is perUser", () => { + Object.defineProperty(process, "execPath", { + value: "C:\\Users\\Test\\AppData\\Local\\Programs\\AgentDock\\AgentDock.exe", + }); + expect(resolveUserDataPath()).toBe("C:\\Users\\Test\\AppData\\Roaming\\AgentDock"); + }); + + it("uses %PROGRAMDATA%\\AgentDock when exe is in Program Files", () => { + process.env.PROGRAMDATA = "C:\\ProgramData"; + Object.defineProperty(process, "execPath", { + value: "C:\\Program Files\\AgentDock\\AgentDock.exe", + }); + expect(resolveUserDataPath()).toBe("C:\\ProgramData\\AgentDock"); + }); + + it("falls back to %PROGRAMDATA% = C:\\ProgramData if env unset", () => { + const old = process.env.PROGRAMDATA; + delete process.env.PROGRAMDATA; + Object.defineProperty(process, "execPath", { + value: "C:\\Program Files\\AgentDock\\AgentDock.exe", + }); + try { + expect(resolveUserDataPath()).toBe("C:\\ProgramData\\AgentDock"); + } finally { + if (old !== undefined) process.env.PROGRAMDATA = old; + } + }); +}); + +describe("detectInstallMode", () => { + const originalExecPath = process.execPath; + + afterEach(() => { + Object.defineProperty(process, "execPath", { value: originalExecPath }); + }); + + it("perMachine when path contains \\program files\\", () => { + Object.defineProperty(process, "execPath", { + value: "C:\\Program Files\\AgentDock\\AgentDock.exe", + }); + expect(detectInstallMode()).toBe("perMachine"); + }); + + it("perUser for AppData\\Local", () => { + Object.defineProperty(process, "execPath", { + value: "C:\\Users\\X\\AppData\\Local\\Programs\\AgentDock\\AgentDock.exe", + }); + expect(detectInstallMode()).toBe("perUser"); + }); +}); + +describe("migrateLegacyUserData", () => { + let sandbox: string; + + beforeEach(() => { + sandbox = mkdtempSync(join(tmpdir(), "agentdock-userdata-test-")); + }); + + it("returns migratedFrom=null when new path already has data", () => { + const newPath = join(sandbox, "new"); + const oldPath = join(sandbox, "old"); + require("node:fs").mkdirSync(newPath, { recursive: true }); + require("node:fs").mkdirSync(oldPath, { recursive: true }); + writeFileSync(join(newPath, "already-here.txt"), "fresh"); + writeFileSync(join(oldPath, "projects.db"), "old data"); + process.env.APPDATA = oldPath; + + const { migratedFrom } = migrateLegacyUserData(newPath); + expect(migratedFrom).toBeNull(); + // Existing data must NOT be clobbered + expect(require("node:fs").readFileSync(join(newPath, "already-here.txt"), "utf8")).toBe("fresh"); + }); + + it("copies files from $APPDATA\\AgentDock when new is empty", () => { + const newPath = join(sandbox, "new"); + const oldPath = join(sandbox, "AgentDock"); + require("node:fs").mkdirSync(newPath, { recursive: true }); + require("node:fs").mkdirSync(oldPath, { recursive: true }); + writeFileSync(join(oldPath, "projects.db"), "old data"); + writeFileSync(join(oldPath, "Preferences"), "user prefs"); + process.env.APPDATA = sandbox; + + const { migratedFrom } = migrateLegacyUserData(newPath); + expect(migratedFrom).toBe(oldPath); + expect(existsSync(join(newPath, "projects.db"))).toBe(true); + expect(existsSync(join(newPath, "Preferences"))).toBe(true); + }); + + it("no-op when no legacy path exists", () => { + const newPath = join(sandbox, "new"); + process.env.APPDATA = join(sandbox, "does-not-exist-appdata"); + process.env.USERPROFILE = join(sandbox, "does-not-exist-userprofile"); + + const { migratedFrom } = migrateLegacyUserData(newPath); + expect(migratedFrom).toBeNull(); + }); +}); diff --git a/electron/main/userdata.ts b/electron/main/userdata.ts new file mode 100644 index 0000000..340a1fb --- /dev/null +++ b/electron/main/userdata.ts @@ -0,0 +1,128 @@ +/** + * User-data path resolution — picks per-user vs per-machine data location + * based on the install location of the running binary. + * + * Install mode is determined at runtime by inspecting process.execPath: + * - perMachine install → exe lives under C:\Program Files\AgentDock\ + * → userData goes to %PROGRAMDATA%\AgentDock\ (shared by all users) + * - perUser install → exe lives under %LOCALAPPDATA%\Programs\AgentDock\ + * → userData goes to %APPDATA%\AgentDock\ (current user only) + * + * Electron normally returns %APPDATA%\ for userData. This module + * calls app.setPath('userData', ) before any IPC handler + * is registered so the entire stack (DB, sessions, todos) lands in the + * right place. See electron/main.ts for the call site. + * + * Migration: if the new location is empty but the legacy path exists + * (either %APPDATA%\AgentDock from prior per-user installs, or + * %USERPROFILE%\.agentdock\ from the very early "homedir fallback" code), + * the legacy files are copied into the new path. The legacy path is NOT + * deleted — the user can keep them as backup or remove manually. Run only + * once on the first launch after switching install mode. + */ +import { app } from "electron"; +import { join } from "node:path"; +import { + existsSync, + mkdirSync, + readdirSync, + statSync, + copyFileSync, +} from "node:fs"; + +export type InstallMode = "perUser" | "perMachine"; + +/** Substring in process.execPath that identifies a per-machine install. */ +const PER_MACHINE_PATH_HINT = "\\program files\\"; + +/** + * Pick the userData directory based on where the running binary lives. + * Called before app.setPath to override Electron's default. + */ +export function resolveUserDataPath(): string { + const exe = (process.execPath ?? "").toLowerCase(); + if (exe.includes(PER_MACHINE_PATH_HINT)) { + const programData = + process.env.PROGRAMDATA && process.env.PROGRAMDATA.length > 0 + ? process.env.PROGRAMDATA + : "C:\\ProgramData"; + return join(programData, "AgentDock"); + } + // PerUser path: defer to Electron's default (AppData\Roaming\). + // We don't compute it ourselves because the productName may differ between + // dev and packaged builds — app.getPath('userData') is the source of truth. + return app.getPath("userData"); +} + +/** Which install mode did the running binary pick? Used for diagnostics. */ +export function detectInstallMode(): InstallMode { + const exe = (process.execPath ?? "").toLowerCase(); + return exe.includes(PER_MACHINE_PATH_HINT) ? "perMachine" : "perUser"; +} + +/** Legacy paths to migrate from on first launch after switching install mode. */ +function legacyCandidatePaths(): string[] { + const candidates: string[] = []; + if (process.env.APPDATA) { + candidates.push(join(process.env.APPDATA, "AgentDock")); + } + if (process.env.USERPROFILE) { + candidates.push(join(process.env.USERPROFILE, ".agentdock")); + } + return candidates; +} + +/** + * If the new userData path is empty but a legacy path has data, copy + * the legacy files into the new path. No-op if the new path already + * has data (idempotent — won't clobber a real install). + * + * Returns { migratedFrom: string | null } so the caller can log which + * path the data came from. + */ +export function migrateLegacyUserData(newPath: string): { migratedFrom: string | null } { + if (!existsSync(newPath)) mkdirSync(newPath, { recursive: true }); + const existing = readdirSync(newPath).filter((n) => n !== "Preferences" && n !== "Local State"); + if (existing.length > 0) { + // New path already has data — don't migrate over it. + return { migratedFrom: null }; + } + for (const candidate of legacyCandidatePaths()) { + if (!existsSync(candidate)) continue; + let entries: string[]; + try { + entries = readdirSync(candidate); + } catch { + continue; + } + if (entries.length === 0) continue; + // Copy each top-level entry into the new path. + for (const entry of entries) { + const src = join(candidate, entry); + const dst = join(newPath, entry); + try { + const stat = statSync(src); + if (stat.isDirectory()) { + copyDirSync(src, dst); + } else { + copyFileSync(src, dst); + } + } catch { + // Best-effort: skip entries we can't read. + } + } + return { migratedFrom: candidate }; + } + return { migratedFrom: null }; +} + +function copyDirSync(src: string, dst: string): void { + mkdirSync(dst, { recursive: true }); + for (const entry of readdirSync(src)) { + const s = join(src, entry); + const d = join(dst, entry); + const stat = statSync(s); + if (stat.isDirectory()) copyDirSync(s, d); + else copyFileSync(s, d); + } +} diff --git a/plugins/db/global.ts b/plugins/db/global.ts index e947fa2..fbb848c 100644 --- a/plugins/db/global.ts +++ b/plugins/db/global.ts @@ -7,7 +7,6 @@ */ import { existsSync, mkdirSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; -import os from "node:os"; import path from "node:path"; import { drizzle } from "drizzle-orm/node-sqlite"; import { eq } from "drizzle-orm"; @@ -45,20 +44,30 @@ let globalSqlite: DatabaseSync | null = null; * the global schema migration (projects table only). * * @param overrideDir Optional caller-controlled directory override. When set, - * the DB lives at `/projects.db` instead of the - * production default `/.agentdock/projects.db`. - * Used by dev mode (per-userData isolation) and by E2E - * fixtures to keep test DBs out of the user's real home dir. - * Must be a directory path, not a file path. + * the DB lives at `/projects.db` instead of + * the Electron `userData` directory. Used by the main + * process to keep the global DB co-located with the rest + * of the app data (perUser → AppData\Roaming\AgentDock, + * perMachine → ProgramData\AgentDock) and by E2E + * fixtures to keep test DBs out of the user's real + * userData dir. Must be a directory path, not a file path. */ export function openGlobalDb(overrideDir?: string): GlobalDbHandle { if (globalDb && globalSqlite) { return { db: globalDb, sqlite: globalSqlite, close: closeGlobalDb }; } - // Resolve the DB directory: use overrideDir if provided (dev/test), - // otherwise fall back to the production default (homedir + .agentdock). - const dbDir = overrideDir ?? path.join(os.homedir(), GLOBAL_DB_DIR); + // Resolve the DB directory. We always want the global DB co-located with + // the rest of the userData so it follows the install mode (perUser or + // perMachine) the user picked. The main process is responsible for + // passing in the resolved userData path; we default to the current + // working directory as a last-resort fallback (e.g. when called from + // unit tests that don't go through the main bootstrap). + const dbDir = + overrideDir ?? + (typeof process !== "undefined" && process.cwd + ? process.cwd() + : "."); if (!existsSync(dbDir)) { mkdirSync(dbDir, { recursive: true }); }