-
Notifications
You must be signed in to change notification settings - Fork 2
feat(installer): perMachine writes userData to ProgramData; uninstall cleans both #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5ba52b1
feat(installer): perMachine writes userData to ProgramData; uninstall…
d56da8a
fix(installer): remove invalid NSIS commands from custom uninstall macro
182ff3c
fix(installer): rename uninstall macro to customUnInstall (in Section…
cf87401
fix(installer): use StrCpy for NSIS path concatenation
5762924
fix(installer+userdata): address 5 review comments
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
| } | ||
|
|
||
| /** 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); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.