Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions build/installer/custom-uninstall.nsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
; Custom NSIS uninstaller for AgentDock.
;
; electron-builder's default uninstaller cleans $APPDATA\<APP_FILENAME>
; (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
36 changes: 34 additions & 2 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 34 additions & 10 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
);
}
}
}

// ============================================================
Expand Down
132 changes: 132 additions & 0 deletions electron/main/__tests__/userdata.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
128 changes: 128 additions & 0 deletions electron/main/userdata.ts
Original file line number Diff line number Diff line change
@@ -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%\<productName> for userData. This module
* calls app.setPath('userData', <resolved path>) 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\<productName>).
// 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";
}
Comment thread
ACCSCI marked this conversation as resolved.

/** 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);
}
}
Loading
Loading