From 5ba52b1ebce53aa3cb36791a70911252da0af8b4 Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Sat, 4 Jul 2026 19:23:00 +0800 Subject: [PATCH 1/5] feat(installer): perMachine writes userData to ProgramData; uninstall cleans both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This unblocks the install mode page (perUser / perMachine radio) and makes the data path follow the user's install choice: - perUser install -> %APPDATA%\AgentDock\ (current user only) - perMachine install -> %PROGRAMDATA%\AgentDock\ (shared by all users) Before this change, the global projects DB always lived in %USERPROFILE%\.agentdock regardless of install mode, which made perMachine install misleading: the binary was perMachine but the project list was per-user. Now both move together. - electron/main/userdata.ts: resolveUserDataPath() picks ProgramData vs AppData based on process.execPath. Migration copies legacy perUser AppData into the new location on first launch after switching install modes. - electron/main.ts: call app.setPath('userData', resolved) before any IPC handler registers. Co-locate the global DB with the rest of the userData. - plugins/db/global.ts: drop the homedir fallback so the global DB always follows userData. - build/installer/custom-uninstall.nsh: NSIS macro that also cleans %PROGRAMDATA%\AgentDock\ on top of the standard %APPDATA%\AgentDock\ path electron-builder cleans by default. - electron-builder.yml: deleteAppDataOnUninstall: true + include the new uninstall macro. - electron/main/__tests__/userdata.test.ts: unit coverage for the decision logic (8/8 tests pass). E2E coverage deferred — CI doesn't run e2e, so it'll be local-only. --- build/installer/custom-uninstall.nsh | 43 ++++++++ electron-builder.yml | 36 ++++++- electron/main.ts | 44 ++++++-- electron/main/__tests__/userdata.test.ts | 132 +++++++++++++++++++++++ electron/main/userdata.ts | 128 ++++++++++++++++++++++ plugins/db/global.ts | 27 +++-- 6 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 build/installer/custom-uninstall.nsh create mode 100644 electron/main/__tests__/userdata.test.ts create mode 100644 electron/main/userdata.ts diff --git a/build/installer/custom-uninstall.nsh b/build/installer/custom-uninstall.nsh new file mode 100644 index 0000000..de68af8 --- /dev/null +++ b/build/installer/custom-uninstall.nsh @@ -0,0 +1,43 @@ +; Custom NSIS uninstaller for AgentDock. +; +; electron-builder's default uninstaller only cleans +; $APPDATA\ (per-user appData) +; It does NOT clean the perMachine data path: +; $PROGRAMDATA\AgentDock +; which we now write to when installed perMachine. +; +; This script hooks the standard "delete app data" path and additionally +; removes $PROGRAMDATA\AgentDock if it exists, so uninstall fully +; cleans up regardless of which install mode the user picked. + +!macro customUnInstallSection + ; electron-builder's standard delete-data path runs at + ; un.onInit, before customUnInstallSection. By the time we get here, + ; $APPDATA\AgentDock is already gone if DELETE_APP_DATA_ON_UNINSTALL + ; was set. We just need to nuke the ProgramData location too. + ${If} ${Silent} + ${If} ${RunningX64} + StrCpy $0 "$PROGRAMFILES64" + ${Else} + StrCpy $0 "$PROGRAMFILES" + ${EndIf} + StrCpy $0 "$0\${APP_FILENAME}" + + ; Detect whether we were installed perMachine by checking if the + ; uninstaller was registered under HKLM (perMachine) vs HKCU (perUser). + ReadRegStr $1 HKLM "${UNINSTALL_REGISTRY_KEY}" DisplayName + ${If} $1 != "" + ; PerMachine install — also nuke $PROGRAMDATA\AgentDock + ${If} ${FileExists} "$PROGRAMDATA\AgentDock" + RMDir /r "$PROGRAMDATA\AgentDock" + ${EndIf} + ${EndIf} + ${Else} + ; GUI uninstall — also clear perMachine data so the user doesn't + ; leave behind a shared project DB after they think they've cleaned + ; everything up. PerUser-only installs won't have PROGRA~1\AgentDock. + ${If} ${FileExists} "$PROGRAMDATA\AgentDock" + RMDir /r "$PROGRAMDATA\AgentDock" + ${EndIf} + ${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..51ca2ed 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: process.execPath.toLowerCase().includes("\\program files\\") ? "perMachine" : "perUser" }, + "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 } 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..b25b1dd --- /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 }); } From d56da8ac0724ae142da7a6dcc2289518989c04b2 Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Sat, 4 Jul 2026 19:30:07 +0800 Subject: [PATCH 2/5] fix(installer): remove invalid NSIS commands from custom uninstall macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit customUnInstallSection was compiled into uninstaller.nsh which is inside a Section body, so commands like ${Silent} and ReadRegStr (which are valid runtime checks in a Function body but NOT valid in this Section context) caused compilation to fail with: 'Error: command IfSilent not valid outside Section or Function' Replace with ${FileExists} + RMDir /r, which are legal NSIS runtime commands in a Section. The macro now unconditionally cleans $PROGRAMDATA\AgentDock\ if it exists — regardless of install mode. For perUser installs (no ProgramData path) the ${FileExists} guard prevents errors. For perMachine installs it removes the shared data directory. --- build/installer/custom-uninstall.nsh | 42 +++++----------------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/build/installer/custom-uninstall.nsh b/build/installer/custom-uninstall.nsh index de68af8..62f0c41 100644 --- a/build/installer/custom-uninstall.nsh +++ b/build/installer/custom-uninstall.nsh @@ -1,43 +1,15 @@ ; Custom NSIS uninstaller for AgentDock. ; -; electron-builder's default uninstaller only cleans -; $APPDATA\ (per-user appData) +; electron-builder's default uninstaller cleans +; $APPDATA\ (per-user appData). ; It does NOT clean the perMachine data path: -; $PROGRAMDATA\AgentDock -; which we now write to when installed perMachine. +; $PROGRAMDATA\AgentDock. ; -; This script hooks the standard "delete app data" path and additionally -; removes $PROGRAMDATA\AgentDock if it exists, so uninstall fully -; cleans up regardless of which install mode the user picked. +; This macro hooks the standard "delete app data" path and additionally +; removes $PROGRAMDATA\AgentDock when it exists. !macro customUnInstallSection - ; electron-builder's standard delete-data path runs at - ; un.onInit, before customUnInstallSection. By the time we get here, - ; $APPDATA\AgentDock is already gone if DELETE_APP_DATA_ON_UNINSTALL - ; was set. We just need to nuke the ProgramData location too. - ${If} ${Silent} - ${If} ${RunningX64} - StrCpy $0 "$PROGRAMFILES64" - ${Else} - StrCpy $0 "$PROGRAMFILES" - ${EndIf} - StrCpy $0 "$0\${APP_FILENAME}" - - ; Detect whether we were installed perMachine by checking if the - ; uninstaller was registered under HKLM (perMachine) vs HKCU (perUser). - ReadRegStr $1 HKLM "${UNINSTALL_REGISTRY_KEY}" DisplayName - ${If} $1 != "" - ; PerMachine install — also nuke $PROGRAMDATA\AgentDock - ${If} ${FileExists} "$PROGRAMDATA\AgentDock" - RMDir /r "$PROGRAMDATA\AgentDock" - ${EndIf} - ${EndIf} - ${Else} - ; GUI uninstall — also clear perMachine data so the user doesn't - ; leave behind a shared project DB after they think they've cleaned - ; everything up. PerUser-only installs won't have PROGRA~1\AgentDock. - ${If} ${FileExists} "$PROGRAMDATA\AgentDock" - RMDir /r "$PROGRAMDATA\AgentDock" - ${EndIf} + ${If} ${FileExists} "$PROGRAMDATA\AgentDock" + RMDir /r "$PROGRAMDATA\AgentDock" ${EndIf} !macroend From 182ff3cb51b8ceb614d0c2080b2fd7e11d639558 Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Sat, 4 Jul 2026 19:35:33 +0800 Subject: [PATCH 3/5] fix(installer): rename uninstall macro to customUnInstall (in Section body) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit customUnInstallSection is expanded at script top-level (after SectionEnd) where runtime NSIS commands are forbidden — and RMDir both cause 'command not valid outside Section or Function' errors. Rename to customUnInstall, which is expanded inside Section 'Uninstall' (line 156-157 of uninstaller.nsh), where runtime commands are valid. --- build/installer/custom-uninstall.nsh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/build/installer/custom-uninstall.nsh b/build/installer/custom-uninstall.nsh index 62f0c41..2215b20 100644 --- a/build/installer/custom-uninstall.nsh +++ b/build/installer/custom-uninstall.nsh @@ -1,14 +1,18 @@ ; Custom NSIS uninstaller for AgentDock. ; -; electron-builder's default uninstaller cleans -; $APPDATA\ (per-user appData). -; It does NOT clean the perMachine data path: -; $PROGRAMDATA\AgentDock. +; Hook: customUnInstall — called inside Section "Uninstall" body +; (valid for runtime NSIS commands). NOT customUnInstallSection +; which is expanded at script top-level where runtime commands are +; forbidden by the NSIS compiler. ; -; This macro hooks the standard "delete app data" path and additionally -; removes $PROGRAMDATA\AgentDock when it exists. +; electron-builder's default uninstaller already cleans +; $APPDATA\ (per-user appData). +; This macro additionally cleans +; $PROGRAMDATA\AgentDock (perMachine shared data path) +; when it exists, so uninstall fully cleans up regardless of which +; install mode the user originally picked. -!macro customUnInstallSection +!macro customUnInstall ${If} ${FileExists} "$PROGRAMDATA\AgentDock" RMDir /r "$PROGRAMDATA\AgentDock" ${EndIf} From cf874010c0df2c60085345b05b21c92eabde3508 Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Sat, 4 Jul 2026 19:40:33 +0800 Subject: [PATCH 4/5] fix(installer): use StrCpy for NSIS path concatenation NSIS treats \AgentDock as a single variable lookup (not string concatenation), causing warning 6000 which is fatal in CI. Fix by assigning $PROGRAMDATA to $0 via StrCpy, then appending \AgentDock via StrCpy $0 "$0\AgentDock". --- build/installer/custom-uninstall.nsh | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/build/installer/custom-uninstall.nsh b/build/installer/custom-uninstall.nsh index 2215b20..9d23334 100644 --- a/build/installer/custom-uninstall.nsh +++ b/build/installer/custom-uninstall.nsh @@ -1,19 +1,16 @@ ; Custom NSIS uninstaller for AgentDock. ; ; Hook: customUnInstall — called inside Section "Uninstall" body -; (valid for runtime NSIS commands). NOT customUnInstallSection -; which is expanded at script top-level where runtime commands are -; forbidden by the NSIS compiler. +; (valid for runtime NSIS commands). ; -; electron-builder's default uninstaller already cleans -; $APPDATA\ (per-user appData). -; This macro additionally cleans -; $PROGRAMDATA\AgentDock (perMachine shared data path) -; when it exists, so uninstall fully cleans up regardless of which -; install mode the user originally picked. +; electron-builder's default uninstaller cleans $APPDATA\ +; (per-user appData). This macro additionally cleans +; $PROGRAMDATA\AgentDock (perMachine shared data path) when it exists. !macro customUnInstall - ${If} ${FileExists} "$PROGRAMDATA\AgentDock" - RMDir /r "$PROGRAMDATA\AgentDock" + StrCpy $0 "$PROGRAMDATA" + StrCpy $0 "$0\AgentDock" + ${If} ${FileExists} "$0" + RMDir /r "$0" ${EndIf} !macroend From 5762924b24d488689688e28fa2a16bed1c687455 Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Sat, 4 Jul 2026 19:47:34 +0800 Subject: [PATCH 5/5] fix(installer+userdata): address 5 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. userdata.ts:85 — ||→&& in filter (critical: always-true bug) The filter was always true because every string satisfies at least one side. Fixed to && so both files are correctly excluded. 2. custom-uninstall.nsh — replaced NSIS (not a valid NSIS built-in variable, causes warning 6000) with hardcoded 'C:\ProgramData\AgentDock' + StrCpy + guard. This is safe because ProgramData is always English on all Windows locales, and the path doesn't change per-user. 3. electron/main.ts — removed duplicate install mode detection (was re-checking process.execPath manually instead of calling detectInstallMode()) and added the missing import. --- build/installer/custom-uninstall.nsh | 14 +++++++------- electron/main.ts | 4 ++-- electron/main/userdata.ts | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/build/installer/custom-uninstall.nsh b/build/installer/custom-uninstall.nsh index 9d23334..72cab55 100644 --- a/build/installer/custom-uninstall.nsh +++ b/build/installer/custom-uninstall.nsh @@ -1,15 +1,15 @@ ; Custom NSIS uninstaller for AgentDock. ; -; Hook: customUnInstall — called inside Section "Uninstall" body -; (valid for runtime NSIS commands). -; ; electron-builder's default uninstaller cleans $APPDATA\ -; (per-user appData). This macro additionally cleans -; $PROGRAMDATA\AgentDock (perMachine shared data path) when it exists. +; (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 "$PROGRAMDATA" - StrCpy $0 "$0\AgentDock" + StrCpy $0 "C:\ProgramData\AgentDock" ${If} ${FileExists} "$0" RMDir /r "$0" ${EndIf} diff --git a/electron/main.ts b/electron/main.ts index 51ca2ed..c4c1a1b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -134,7 +134,7 @@ async function bootstrap() { // ProgramData\AgentDock). See electron/main/userdata.ts. const userDataDir = app.getPath("userData"); log.info( - { userDataDir, installMode: process.execPath.toLowerCase().includes("\\program files\\") ? "perMachine" : "perUser" }, + { userDataDir, installMode: detectInstallMode() }, "global DB co-located with userData", ); globalDbHandle = openGlobalDb(userDataDir); @@ -239,7 +239,7 @@ protocol.registerSchemesAsPrivileged([ // // The decision is made here — before app.whenReady — so every IPC handler // that reads the DB path sees the right location. -import { resolveUserDataPath, migrateLegacyUserData } from "./main/userdata.js"; +import { resolveUserDataPath, migrateLegacyUserData, detectInstallMode } from "./main/userdata.js"; if (process.env.AGENTDOCK_USER_DATA_DIR) { app.setPath("userData", resolve(process.env.AGENTDOCK_USER_DATA_DIR)); diff --git a/electron/main/userdata.ts b/electron/main/userdata.ts index b25b1dd..340a1fb 100644 --- a/electron/main/userdata.ts +++ b/electron/main/userdata.ts @@ -82,7 +82,7 @@ function legacyCandidatePaths(): string[] { */ 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"); + 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 };