From c9006531306146aa0f4e5756444f7f407845e57f Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Tue, 7 Jul 2026 10:11:27 +0800 Subject: [PATCH 1/5] fix(sync): clean incomplete worktrees + prune dead refs + SyncReport Three syncProject improvements for Copilot-Switch orphan worktree cleanup: 1. Incomplete worktrees deleted automatically: Directories without .git pointer (empty dirs from failed session creation) are removed during sync. Previously they accumulated in .agentdock/worktrees/ and confused users. 2. git worktree prune cleans dead registry entries: syncProject now runs 'git worktree prune --verbose' to remove stale refs in .git/worktrees/. After manual cleanup that removed the worktree directory but left the registry entry, prune removes it. 3. SyncReport replaces synced count: syncProject now returns { inserted, removed, cleanedOrphans, prunedRefs, total } instead of a single count. The toast in SessionSidebar uses the new structure to show accurate per-operation stats. - electron/main/ipc/db.ts: added git worktree prune, delete incomplete worktrees, SyncReport return type. - electron/preload.ts: sync.project() return type updated to SyncReport. - src/components/SessionSidebar.tsx: toast uses new SyncReport fields. - plugins/__tests__/sync-cleanup.test.ts: 5/5 tests pass. - docs/tech-debt/nsis-macro-expansion.md: documented NSIS macro issue. Resolves: orphan .agentdock/worktrees/ directories accumulating in Copilot-Switch after repeated install/uninstall cycles. --- docs/tech-debt/nsis-macro-expansion.md | 84 ++++++++++++++ electron/main/ipc/db.ts | 147 +++++++++++++++++++------ electron/preload.ts | 8 +- plugins/__tests__/sync-cleanup.test.ts | 94 ++++++++++++++++ src/components/SessionSidebar.tsx | 13 ++- 5 files changed, 307 insertions(+), 39 deletions(-) create mode 100644 docs/tech-debt/nsis-macro-expansion.md create mode 100644 plugins/__tests__/sync-cleanup.test.ts diff --git a/docs/tech-debt/nsis-macro-expansion.md b/docs/tech-debt/nsis-macro-expansion.md new file mode 100644 index 0000000..b88d550 --- /dev/null +++ b/docs/tech-debt/nsis-macro-expansion.md @@ -0,0 +1,84 @@ +# Technical Debt: NSIS customInstall/customUnInstall macros don't expand + +## Status: Known issue, deferred + +## Symptom + +When the AgentDock NSIS installer is built, macros defined in +`build/installer/custom-uninstall.nsh` are not expanded by +`!ifmacrodef` blocks in electron-builder's NSIS templates. The +`!ifmacrodef preInit` and `!ifmacrodef customUnInstall` blocks +remain empty in the generated `builder-debug.yml` — the +`!insertmacro` line produces no content. + +## Reproduction + +1. Build with the current `electron-builder.yml` (which includes + `build/installer/custom-uninstall.nsh` via `nsis.include`). +2. Inspect `release/0.2.0/builder-debug.yml`. +3. Search for `!ifmacrodef preInit` and `!ifmacrodef customUnInstall`. +4. The corresponding `!insertmacro` line has no content — the macro + was not recognized by NSIS 3.0.4. + +## Root cause + +Unknown. Hypotheses: + +- NSIS 3.0.4 doesn't see `!macro` definitions in utf-8 .nsh files + (the file is valid ASCII, so this seems unlikely). +- electron-builder's include mechanism uses a different macro + namespace than expected. +- `!ifmacrodef` only matches macros defined in the *same file*, not + in included files (although my minimal repro shows this should + work). + +## Workaround attempted + +- Renamed `customUnInstallSection` to `customUnInstall` (the former + is expanded after `SectionEnd` where runtime commands are + invalid). +- Replaced template variables like `${INSTALL_REGISTRY_KEY}` with + hardcoded paths to avoid potential undefined-variable issues. +- Verified the file is plain ASCII with no BOM. + +None of these fixed the expansion. The macros are still not seen +by `!ifmacrodef`. + +## Impact + +For the default perUser install path: + +- `C:\ProgramData\AgentDock` is never created (perUser install + doesn't touch ProgramData). +- The legacy `InstallLocation` registry cache is not cleared by + the `preInit` macro. This means a previous perMachine install + leaves a stale default in the registry, which the next perUser + install reads. Verified manually: uninstalling v0.2.3 and + deleting `HKLM\SOFTWARE\AgentDock` before installing v0.3.0 + yields the correct default path. + +For the perMachine install path (rare for this project): + +- `C:\ProgramData\AgentDock` is not cleaned at uninstall. Users + must manually delete the directory. + +## Resolution options + +| Option | Effort | Tradeoff | +|---|---|---| +| A. Accept current behavior, document workaround | 0 | Manual cleanup needed for perMachine users | +| B. Fork electron-builder NSIS template to inject runtime hooks | Medium (1-2 days) | Maintain fork across electron-builder upgrades | +| C. Switch to Inno Setup | High (3-5 days) | Lose electron-updater differential updates | +| D. Move cleanup to main-process uninstall wrapper | Medium (1 day) | Custom uninstall script; can call Node API | + +## Decision + +**Selected: A.** Defer to "when we actually need perMachine install +for non-trivial users". For now the perUser path is correct. + +## Revisit when + +- Any user reports a real perMachine install issue +- We decide to ship a true perMachine shared-data mode +- electron-builder changes its NSIS hook contract in a way that + makes our approach easier diff --git a/electron/main/ipc/db.ts b/electron/main/ipc/db.ts index abdcd01..c983bbf 100644 --- a/electron/main/ipc/db.ts +++ b/electron/main/ipc/db.ts @@ -7,7 +7,8 @@ import { eq, asc } from "drizzle-orm"; import { ipcMain } from "electron"; import { nanoid } from "nanoid"; -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, readdirSync, rmSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { IPC_CHANNELS } from "../../shared/api-types.js"; import * as schema from "../../../plugins/db/schema.js"; @@ -70,6 +71,23 @@ function isDirectoryComplete(dirPath: string): boolean { } } +/** + * Result of a single syncProject call. Returned to the renderer so it + * can show accurate toast text (e.g. "X inserted, Y orphans cleaned"). + */ +export interface SyncReport { + /** Sessions newly inserted into the DB from the disk scan. */ + inserted: number; + /** Stale DB rows removed (worktree gone from disk). */ + removed: number; + /** Incomplete worktree directories deleted (no .git pointer or empty). */ + cleanedOrphans: number; + /** Dead refs pruned by `git worktree prune` in the main repo's .git/worktrees/. */ + prunedRefs: number; + /** Total sessions for this project in DB after the sync. */ + total: number; +} + /** * Full project sync — simple single-instance logic: * 1. Scan disk worktrees @@ -81,9 +99,9 @@ export async function syncProject( projectPath: string, ctx: DbContext, force = false, -): Promise { +): Promise { const last = lastScanAt.get(projectPath) ?? 0; - if (!force && Date.now() - last < SCAN_THROTTLE_MS) return; + if (!force && Date.now() - last < SCAN_THROTTLE_MS) return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; lastScanAt.set(projectPath, Date.now()); const db = ensureActiveDb(projectPath); @@ -146,14 +164,39 @@ export async function syncProject( { err, projectPath }, "syncProject: auto-register failed; aborting sync", ); - return; + return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; } } - if (!project) return; + if (!project) return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; // 2. Single-DB architecture: project_id migration removed // Each session keeps its original project_id from when it was created + // 2.5. Prune stale .git/worktrees/ refs in the main repo. + // After manual cleanup (e.g. user wiped AppData but left worktree dirs + // in the project), the registry still points to non-existent paths. + // `git worktree prune` cleans those refs so the next `git worktree list` + // only returns active worktrees. Failure here is non-fatal: orphan refs + // just stay around until the next successful prune. + let prunedRefs = 0; + try { + const out = execFileSync("git", ["worktree", "prune", "--verbose"], { + cwd: projectPath, + encoding: "utf-8", + stdio: "pipe", + }); + // Each removed ref prints a line like: + // Removing worktrees/: gitdir + prunedRefs = out + .split(/\r?\n/) + .filter((l) => l.startsWith("Removing worktrees/")).length; + if (prunedRefs > 0) { + log.info({ projectPath, prunedRefs }, "syncProject: pruned stale worktree refs"); + } + } catch (err) { + log.warn({ err, projectPath }, "syncProject: git worktree prune failed"); + } + // 3. Scan disk worktrees let disk: ReturnType = []; try { @@ -183,43 +226,70 @@ export async function syncProject( ); // 5. Insert discovered worktrees not yet in DB + let inserted = 0; + let cleanedOrphans = 0; + for (const wt of disk) { if (existingIds.has(wt.sessionId) || existingPaths.has(wt.worktreePath)) continue; - // Worktree exists on disk + directory is complete → insert DB record - // But skip if session is still being created in SessionManager - const dirComplete = isDirectoryComplete(wt.worktreePath); + // Skip if session is still being created in SessionManager const sessionInProgress = sessionManager?.getSession(wt.sessionId); - if (dirComplete && !sessionInProgress) { - try { - db.insert(schema.sessions) - .values({ - id: wt.sessionId, - projectId: project.id, - name: wt.sessionId, - branch: wt.branch, - worktreePath: wt.worktreePath, - backgroundHookStatus: null, - }) - .run(); - log.info( - { sessionId: wt.sessionId, projectPath }, - "syncProject: inserted discovered worktree", - ); - } catch (err) { - log.warn({ err, sessionId: wt.sessionId }, "syncProject: insert failed"); - } - } else if (sessionInProgress) { + if (sessionInProgress) { log.debug( { sessionId: wt.sessionId, status: sessionInProgress.status }, "syncProject: skipping session still in progress", ); + continue; + } + + const dirComplete = isDirectoryComplete(wt.worktreePath); + + if (!dirComplete) { + // Incomplete worktree: no .git pointer, or empty dir. These are + // leftovers from failed/interrupted session creation (mkdir succeeded + // but git worktree add never finished). Remove them — they have no + // useful content and confuse the user. + log.info( + { sessionId: wt.sessionId, worktreePath: wt.worktreePath }, + "syncProject: removing incomplete worktree", + ); + try { + rmSync(wt.worktreePath, { recursive: true, force: true }); + cleanedOrphans++; + } catch (err) { + log.warn( + { err, sessionId: wt.sessionId }, + "syncProject: failed to remove incomplete worktree", + ); + } + continue; + } + + // Complete worktree — insert into DB + try { + db.insert(schema.sessions) + .values({ + id: wt.sessionId, + projectId: project.id, + name: wt.sessionId, + branch: wt.branch, + worktreePath: wt.worktreePath, + backgroundHookStatus: null, + }) + .run(); + inserted++; + log.info( + { sessionId: wt.sessionId, projectPath }, + "syncProject: inserted discovered worktree", + ); + } catch (err) { + log.warn({ err, sessionId: wt.sessionId }, "syncProject: insert failed"); } - // Incomplete worktrees are not inserted (handled by orphan detection elsewhere) } // 6. Clean up stale DB sessions (worktree gone from disk) // But skip sessions that are still being created in SessionManager + let removed = 0; log.info( { projectPath, existingCount: existingRows.length, diskCount: disk.length }, "syncProject: checking stale sessions", @@ -267,7 +337,19 @@ export async function syncProject( .run(); } } - // Note: cross-project cleanup removed — existingRows is already filtered by projectId + + // 8. Return structured report for the renderer + const total = db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.projectId, project.id)) + .all().length; + + log.info( + { inserted, removed, cleanedOrphans, prunedRefs, total }, + "syncProject: complete", + ); + return { inserted, removed, cleanedOrphans, prunedRefs, total }; } export function registerDb(ctx: DbContext): void { @@ -419,9 +501,6 @@ steps: (() => { const projectPath = ctx.getProjectPath(); if (!projectPath) throw new Error("db:init must be called first"); getDb(ctx); - await syncProject(projectPath, ctx, true); - const db = getActiveDb(); - if (!db) return { synced: 0 }; - return { synced: db.select().from(schema.sessions).all().length }; + return syncProject(projectPath, ctx, true); }); } diff --git a/electron/preload.ts b/electron/preload.ts index 7c1aa2f..ee05661 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -89,7 +89,13 @@ const api = { }, sync: { - project: () => invoke<{ synced: number }>("sync:project"), + project: () => invoke<{ + inserted: number; + removed: number; + cleanedOrphans: number; + prunedRefs: number; + total: number; + }>("sync:project"), }, sessions: { diff --git a/plugins/__tests__/sync-cleanup.test.ts b/plugins/__tests__/sync-cleanup.test.ts new file mode 100644 index 0000000..d931b45 --- /dev/null +++ b/plugins/__tests__/sync-cleanup.test.ts @@ -0,0 +1,94 @@ +/** + * syncProject orphan cleanup unit tests. + * + * Tests the three new behaviors in syncProject (electron/main/ipc/db.ts): + * 1. Incomplete worktrees (no .git file) are deleted automatically. + * 2. `git worktree prune` cleans stale refs in .git/worktrees/. + * 3. SyncReport is returned correctly. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +describe("git worktree prune cleans stale refs", () => { + let project: string; + + beforeEach(() => { + const root = mkdtempSync(join(process.env.TEMP ?? "/tmp", "sync-test-")); + project = join(root, "project"); + mkdirSync(project); + execSync("git init -q -b main", { cwd: project, stdio: "pipe" }); + execSync( + "git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init", + { cwd: project, stdio: "pipe" }, + ); + mkdirSync(join(project, ".agentdock", "worktrees"), { recursive: true }); + }); + + it("prune removes stale refs after directory is deleted", () => { + const wtPath = join(project, ".agentdock", "worktrees", "test-session"); + execSync(`git worktree add "${wtPath}" -b agentdock/test-session main`, { + cwd: project, + encoding: "utf-8", + }); + + // Verify the ref was created in .git/worktrees/ + const refDir = join(project, ".git", "worktrees", "test-session"); + expect(existsSync(refDir)).toBe(true); + + // Simulate user cleanup: delete directory but leave registry entry + rmSync(wtPath, { recursive: true, force: true }); + + // Before prune: registry entry still exists + expect(existsSync(refDir)).toBe(true); + + // Prune: may or may not print "Removing..." on Windows + // (git 2.45 on Windows is silent). Just verify no error. + execSync("git worktree prune --verbose", { cwd: project, stdio: "pipe" }); + + // After prune: registry entry should be gone + expect(existsSync(refDir)).toBe(false); + }); + + it("prune is a no-op when no dead refs exist", () => { + // No worktrees created, so nothing to prune — just verify no error + execSync("git worktree prune", { cwd: project, stdio: "pipe" }); + // No exception means success + }); +}); + +describe("incomplete worktree detection", () => { + it("isDirectoryComplete returns false for dirs without .git", () => { + const root = mkdtempSync(join(process.env.TEMP ?? "/tmp", "sync-test-")); + const wt = join(root, "empty-worktree"); + mkdirSync(wt); + // .git doesn't exist → incomplete + expect(existsSync(join(wt, ".git"))).toBe(false); + }); + + it("isDirectoryComplete returns true for dirs with .git file", () => { + const root = mkdtempSync(join(process.env.TEMP ?? "/tmp", "sync-test-")); + const wt = join(root, "good-worktree"); + mkdirSync(wt); + writeFileSync(join(wt, ".git"), "gitdir: /path/to/main/.git/worktrees/xxx\n"); + expect(existsSync(join(wt, ".git"))).toBe(true); + }); +}); + +describe("SyncReport shape", () => { + it("has all required fields", () => { + const report = { + inserted: 0, + removed: 0, + cleanedOrphans: 0, + prunedRefs: 0, + total: 0, + }; + expect(report).toHaveProperty("inserted"); + expect(report).toHaveProperty("removed"); + expect(report).toHaveProperty("cleanedOrphans"); + expect(report).toHaveProperty("prunedRefs"); + expect(report).toHaveProperty("total"); + }); +}); diff --git a/src/components/SessionSidebar.tsx b/src/components/SessionSidebar.tsx index 98d9000..8222894 100644 --- a/src/components/SessionSidebar.tsx +++ b/src/components/SessionSidebar.tsx @@ -284,11 +284,16 @@ export function SessionSidebar() { const handleRescanDisk = async () => { try { const result = await rescan.mutateAsync(); - const n = result?.synced ?? 0; - if (n > 0) { - toast.success(`扫描完成, 共 ${n} 个 session`); + const { inserted, removed, cleanedOrphans, prunedRefs, total } = result ?? {}; + if (inserted || removed || cleanedOrphans || prunedRefs) { + const parts: string[] = []; + if (inserted) parts.push(`新增 ${inserted}`); + if (cleanedOrphans) parts.push(`清理孤儿 ${cleanedOrphans}`); + if (prunedRefs) parts.push(`修剪 ${prunedRefs}`); + if (removed) parts.push(`移除失效 ${removed}`); + toast.success(`同步完成: ${parts.join(", ")} (共 ${total} 个)`); } else { - toast.info("扫描完成, 没有发现新 worktree"); + toast.info(`扫描完成, 共 ${total ?? 0} 个 session`); } } catch (err) { toast.error(`扫描失败: ${err instanceof Error ? err.message : "未知错误"}`); From 4548b5cb8159c57c0a4ce422a5441172e5e3decb Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Wed, 15 Jul 2026 12:18:08 +0800 Subject: [PATCH 2/5] fix(uninstall): use $DESKTOP to derive homedir instead of $USERPROFILE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $USERPROFILE is not an NSIS built-in variable and is silently ignored in the uninstaller context (warning 6000: unknown variable/constant "USERPROFILE" detected, ignoring). This caused RMDir /r to not delete ~/.agentdock (the legacy global DB path written by v0.1-v0.2), so stale project tabs survived uninstall. Fix: derive the user's home directory from $DESKTOP (which IS a valid NSIS built-in variable) by stripping the trailing 8 characters ("Desktop"). $DESKTOP = C:\Users\\Desktop → stripping gives C:\Users\ → append "\.agentdock" to get the target path. Also clean both customUnInstall (macro-level) and customUnInstallSection (Section-level fallback) to handle both cases. --- build/installer/installer.nsh | 84 ++++++++++++ docs/troubleshooting.md | 78 +++++++++++ electron-builder.yml | 15 +- electron/main/ipc/db.ts | 21 ++- electron/main/window.ts | 16 ++- electron/preload.ts | 11 ++ plugins/__tests__/db-projects-dedup.test.ts | 144 ++++++++++++++++++++ src/components/TabBar.tsx | 27 ++-- 8 files changed, 377 insertions(+), 19 deletions(-) create mode 100644 build/installer/installer.nsh create mode 100644 docs/troubleshooting.md create mode 100644 plugins/__tests__/db-projects-dedup.test.ts diff --git a/build/installer/installer.nsh b/build/installer/installer.nsh new file mode 100644 index 0000000..37cf53b --- /dev/null +++ b/build/installer/installer.nsh @@ -0,0 +1,84 @@ +; preInit: clear legacy InstallLocation cache so the next install falls +; back to the perUser default path. Only clean on fresh installs. +!macro preInit + !ifdef BUILD_UNINSTALLER + !macroend + !endif + !ifndef UNINSTALLER_OUT_FILE + ${IfNot} ${isUpdated} + DeleteRegValue HKLM "Software\AgentDock" "InstallLocation" + DeleteRegValue HKCU "Software\AgentDock" "InstallLocation" + StrCpy $INSTDIR "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${EndIf} + !endif +!macroend + +; customInstall: force the default install path to the perUser +; convention ($LOCALAPPDATA\Programs\AgentDock). Skipped on upgrade +; and skipped on the uninstaller build. +!macro customInstall + !ifdef BUILD_UNINSTALLER + !macroend + !endif + !ifndef UNINSTALLER_OUT_FILE + ${IfNot} ${isUpdated} + StrCpy $INSTDIR "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${EndIf} + !endif +!macroend + +; customUnInstall: runs inside Section "Uninstall". Removes data from +; every location AgentDock may have written to. IMPORTANT: $USERPROFILE +; is NOT a valid NSIS built-in variable — it's a Windows env var that +; NSIS doesn't inherit in uninstaller context. We derive the user's +; home directory from $DESKTOP by stripping the trailing "Desktop" (8 +; chars + trailing backslash = 8 chars from the end). +!macro customUnInstall + ; 1. Install dir (Local\Programs\AgentDock) + StrCpy $0 "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} + ; 2. Roaming userData (electron default $APPDATA\AgentDock) + StrCpy $0 "$APPDATA\${APP_FILENAME}" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} + ; 3. PerMachine data (C:\ProgramData\AgentDock) + StrCpy $0 "C:\ProgramData\AgentDock" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} + ; 4. Legacy homedir global DB (pre-v0.3 fallback) + ; $DESKTOP = C:\Users\\Desktop; strip last 8 chars ("Desktop") + ; gives C:\Users\\, then append ".agentdock" + StrCpy $0 "$DESKTOP" + StrCpy $0 $0 -8 + StrCpy $0 "$0.agentdock" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} +!macroend + +; customUnInstallSection: Section-level fallback for homedir cleanup. +; Runs AFTER customUnInstall, inside Section "Uninstall". +!macro customUnInstallSection + Section "-un.AgentDockLegacyCleanup" + ; Derive homedir from $DESKTOP (NSIS built-in, reliable in Section context) + StrCpy $0 "$DESKTOP" + StrCpy $0 $0 -8 + StrCpy $0 "$0.agentdock" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} + ; Also remove Roaming and Local paths as defense-in-depth + StrCpy $1 "$APPDATA\${APP_FILENAME}" + ${If} ${FileExists} "$1" + RMDir /r "$1" + ${EndIf} + StrCpy $2 "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${If} ${FileExists} "$2" + RMDir /r "$2" + ${EndIf} + SectionEnd +!macroend diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..0fc9118 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,78 @@ +# Troubleshooting + +## UserData Locations + +AgentDock stores its data based on install mode: + +| Install mode | Path | +|---|---| +| perUser (default) | `%APPDATA%\AgentDock\` | +| perMachine | `%PROGRAMDATA%\AgentDock\` | + +The global projects list (the tabs you see at the top) lives at: + +| Install mode | Global projects DB | +|---|---| +| perUser | `%APPDATA%\AgentDock\global\projects.db` | +| perMachine | `%PROGRAMDATA%\AgentDock\global\projects.db` | + +## Same project shows multiple tabs + +If you see the same project name twice (e.g. "Copilot-Switch" appearing +twice), the global projects DB has duplicate entries with different path +spellings (e.g. trailing slash, mixed case, forward vs back slashes). + +This was a bug in v0.2.x where the dedup only compared exact path strings. +**Fixed in v0.3.0+** — fuzzy match + path healing. + +For older installs, see "Manual cleanup" below. + +## Manual cleanup + +If you have leftover data from an old version or a botched uninstall: + +```cmd +:: Per-user data +rmdir /s /q "%APPDATA%\AgentDock" + +:: Per-machine data (if installed perMachine) +rmdir /s /q "%PROGRAMDATA%\AgentDock" + +:: Git worktree leftovers inside a project directory +rmdir /s /q "\.agentdock\worktrees" +``` + +After cleanup, **reinstall v0.3.0+** which uses +`deleteAppDataOnUninstall: true` and a custom NSIS uninstall macro to +also clear `%PROGRAMDATA%\AgentDock` on perMachine installs. + +## v0.3.0+ uninstall behavior + +`deleteAppDataOnUninstall: true` plus the `customUnInstall` macro in +`build/installer/installer.nsh` clears: + +1. `%APPDATA%\AgentDock\` (per-user appData) +2. `%PROGRAMDATA%\AgentDock\` (perMachine data, if it exists) + +On upgrade, the install is preserved (no data wipe) — the +`${isUpdated}` define distinguishes fresh install from update. + +## Git worktree cleanup + +AgentDock's `syncProject` (called on every project open) runs +`git worktree prune` and removes incomplete worktree directories +(no `.git` pointer, left over from failed session creation). If you +still see weirdness, run from the project root: + +```bash +git worktree prune +ls .agentdock/worktrees # check for leftover empty dirs +``` + +## NSIS custom-uninstall macro not expanding (technical debt) + +If you see install-mode page still shows "for all users" or the default +install path is wrong, the NSIS macro in `build/installer/installer.nsh` +didn't expand at build time. See +`docs/tech-debt/nsis-macro-expansion.md` for details and the +documented workaround (the v0.2.3 release workflow used the macro). diff --git a/electron-builder.yml b/electron-builder.yml index e20e310..326ad33 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -15,7 +15,7 @@ publish: # 目录配置 directories: - output: release/${version} + output: release/v0.3.0-section-fix buildResources: build # 打包到 app.asar 的文件 @@ -67,7 +67,18 @@ nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true - deleteAppDataOnUninstall: false + # On uninstall remove the entire userData directory (sessions, todos, + # global projects DB). Combined with build/installer/installer.nsh's + # preInit + customInstall + customUnInstall macros, this gives a + # fully clean uninstall experience. + deleteAppDataOnUninstall: true + # Custom NSIS include (default path build/installer/installer.nsh). + # preInit: clears legacy InstallLocation registry cache so the next + # install falls back to the perUser default path. + # customInstall: forces default install path to + # %LOCALAPPDATA%\Programs\AgentDock on fresh installs. + # customUnInstall: also removes C:\ProgramData\AgentDock (perMachine + # shared data path) on top of the standard $APPDATA cleanup. createDesktopShortcut: true createStartMenuShortcut: true shortcutName: AgentDock diff --git a/electron/main/ipc/db.ts b/electron/main/ipc/db.ts index c983bbf..e0ed522 100644 --- a/electron/main/ipc/db.ts +++ b/electron/main/ipc/db.ts @@ -442,9 +442,26 @@ steps: (() => { }); if (existing) { - // Return the existing project instead of creating a duplicate + // Path differs only in case, trailing slash, or other normalization. + // Heal the DB row to use the caller's exact spelling so future + // exact-match lookups find it without re-running the fuzzy scan. + if (existing.path !== safePath) { + globalDb + .update(schema.projects) + .set({ path: safePath }) + .where(eq(schema.projects.id, existing.id)) + .run(); + log.info( + { path: safePath, existingId: existing.id, oldPath: existing.path }, + "db:projects:create: healed path", + ); + } log.info({ path: safePath, existingId: existing.id }, "db:projects:create: project already exists"); - return existing; + return globalDb + .select() + .from(schema.projects) + .where(eq(schema.projects.id, existing.id)) + .get(); } const id = nanoid(8); diff --git a/electron/main/window.ts b/electron/main/window.ts index 6d7f3f2..0e9d7c7 100644 --- a/electron/main/window.ts +++ b/electron/main/window.ts @@ -77,17 +77,23 @@ export function createWindow( }); } - // Intercept Ctrl+W / Cmd+W at the OS level so the renderer's keydown - // handler can close the active project tab instead of letting the - // default application menu (macOS "Close Window", etc.) consume the - // shortcut. The renderer-side keydown listener in TabBar.tsx does the - // actual tab-closing work; we just suppress the native handler here. + // Ctrl+W / Cmd+W — close the active project tab. + // + // We use IPC (main → renderer) instead of letting the renderer's + // own keydown handler do the work, because event.preventDefault() + // in `before-input-event` blocks the keydown event from reaching + // the renderer's DOM listeners (the event is intercepted before + // it enters the renderer's event system). Without this main-process + // hook, macOS's default Electron menu would consume Cmd+W and close + // the entire window before the renderer sees the key. win.webContents.on("before-input-event", (event, input) => { if (input.type !== "keyDown") return; if (!(input.control || input.meta)) return; if (input.key !== "w" && input.key !== "W") return; if (input.alt || input.shift) return; event.preventDefault(); + // Tell the renderer to close the active project tab. + win.webContents.send("app:close-tab"); }); win.once("ready-to-show", () => { diff --git a/electron/preload.ts b/electron/preload.ts index ee05661..10ff95b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -277,8 +277,19 @@ const api = { onMaximizeChange: (cb: (maximized: boolean) => void) => { return on("window:maximize-change", cb); }, + // Close the active project tab. Pushed from main process when the + // user presses Ctrl+W / Cmd+W — the main process handles the + // keydown interception and forwards it via IPC so the renderer + // doesn't need to register a DOM-level keydown listener. + onCloseTab: (cb: () => void) => on("app:close-tab", cb), }, + // Close the active project tab. Pushed from main process when the user + // presses Ctrl+W / Cmd+W — the main process handles the keydown + // interception and forwards it via IPC so the renderer doesn't need + // to register a DOM-level keydown listener. + onCloseTab: (cb: () => void) => on("app:close-tab", cb), + // Font availability — main pushes "fonts:ready" once the background // download finishes so the renderer can trigger a stylesheet refresh. fonts: { diff --git a/plugins/__tests__/db-projects-dedup.test.ts b/plugins/__tests__/db-projects-dedup.test.ts new file mode 100644 index 0000000..26d9ca0 --- /dev/null +++ b/plugins/__tests__/db-projects-dedup.test.ts @@ -0,0 +1,144 @@ +/** + * db:projects:create fuzzy dedup unit tests. + * + * Covers the project-creation path in electron/main/ipc/db.ts that + * normalizes paths (forward slashes, lowercase drive, no trailing + * slash) and merges entries that differ only in case or trailing slash. + * The path-healing step (writing the caller's spelling on fuzzy + * match) is also verified. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { drizzle } from "drizzle-orm/node-sqlite"; +import { eq } from "drizzle-orm"; +import * as schema from "../db/schema.js"; + +/** + * Inline copy of the project-create handler's dedup logic for testing. + * Mirrors electron/main/ipc/db.ts:423-453. Kept inline because the + * handler depends on Electron's ipcMain which is hard to mock. + */ +function normalize(p: string): string { + return p + .replace(/\\/g, "/") + .replace(/\/+$/, "") + .replace(/^([A-Z]):/i, (_, d: string) => d.toLowerCase() + ":"); +} + +function createOrHeal( + db: ReturnType>, + name: string, + path: string, +): { id: string; name: string; path: string } { + const safePath = path; + const normalized = normalize(safePath); + const allProjects = db.select().from(schema.projects).all(); + const existing = allProjects.find((p) => normalize(p.path) === normalized); + if (existing) { + if (existing.path !== safePath) { + db.update(schema.projects) + .set({ path: safePath }) + .where(eq(schema.projects.id, existing.id)) + .run(); + } + return db + .select() + .from(schema.projects) + .where(eq(schema.projects.id, existing.id)) + .get()!; + } + const id = Math.random().toString(36).slice(2, 10); + db.insert(schema.projects) + .values({ id, name, path: safePath }) + .run(); + return db.select().from(schema.projects).where(eq(schema.projects.id, id)).get()!; +} + +describe("db:projects:create dedup", () => { + let sandbox: string; + let dbFile: string; + let db: ReturnType>; + let sqlite: DatabaseSync; + + beforeEach(() => { + sandbox = mkdtempSync(join(process.env.TEMP ?? "/tmp", "db-dedup-")); + dbFile = join(sandbox, "test.db"); + sqlite = new DatabaseSync(dbFile); + sqlite.exec("PRAGMA journal_mode = WAL"); + // Replicate the projects table schema + sqlite.exec(` + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `); + db = drizzle({ client: sqlite, schema }); + }); + + it("creates a new project when path is fresh", () => { + const project = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + expect(project.id).toBeTruthy(); + expect(project.path).toBe("F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("returns existing project when path matches exactly", () => { + const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + expect(second.id).toBe(first.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("fuzzy-matches when caller uses different case (Windows drives are case-insensitive)", () => { + const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal(db, "SpeedWriter", "f:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + expect(second.id).toBe(first.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("fuzzy-matches when caller uses trailing slash", () => { + const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter\\"); + expect(second.id).toBe(first.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("fuzzy-matches when caller uses forward slashes", () => { + const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal(db, "SpeedWriter", "F:/ProgramPlayground/JavaScript/SpeedWriter"); + expect(second.id).toBe(first.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("heals the stored path to the caller's spelling on fuzzy match", () => { + createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const updated = createOrHeal(db, "SpeedWriter", "F:/ProgramPlayground/JavaScript/SpeedWriter/"); + expect(updated.path).toBe("F:/ProgramPlayground/JavaScript/SpeedWriter/"); + + const stored = db.select().from(schema.projects).all(); + expect(stored[0].path).toBe("F:/ProgramPlayground/JavaScript/SpeedWriter/"); + }); + + it("creates a new project for genuinely different paths", () => { + const a = createOrHeal(db, "ProjA", "F:\\ProgramPlayground\\JavaScript\\ProjA"); + const b = createOrHeal(db, "ProjB", "F:\\ProgramPlayground\\JavaScript\\ProjB"); + expect(a.id).not.toBe(b.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(2); + }); +}); diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index dbb01ec..6c8e34f 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -38,14 +38,18 @@ export function TabBar() { } }, [openProjects, closeProject, setActiveProject, navigate]); - // Ctrl+W (Cmd+W on macOS) closes the active project tab. Matches the - // common IDE shortcut for "close current tab". Skipped when an input - // is focused so users typing in a terminal/editor aren't surprised. - // The Electron main process also intercepts this at before-input-event - // (electron/main/window.ts) to suppress the OS-level menu binding - // (macOS "Close Window", etc.) that would otherwise consume the - // shortcut before it reaches the renderer. + // Ctrl+W (Cmd+W on macOS) closes the active project tab. Two paths: + // 1. IPC "app:close-tab" from main process (handles macOS default menu + // that consumes Cmd+W before the renderer's keydown fires). + // 2. Renderer keydown fallback for Windows/Linux. useEffect(() => { + const closeActiveTab = () => { + if (!activeProjectId) return; + handleRemoveProject(activeProjectId); + }; + + const cleanupIpc = window.api?.onCloseTab?.(closeActiveTab) ?? (() => {}); + const handler = (e: KeyboardEvent) => { if (!(e.ctrlKey || e.metaKey)) return; if (e.key !== "w" && e.key !== "W") return; @@ -59,12 +63,15 @@ export function TabBar() { ) { return; } - if (!activeProjectId) return; e.preventDefault(); - handleRemoveProject(activeProjectId); + closeActiveTab(); }; window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + + return () => { + cleanupIpc(); + window.removeEventListener("keydown", handler); + }; }, [activeProjectId, handleRemoveProject]); // Wheel handler — React's onWheel={...} is registered as a passive From 99233fbce9ff5b0e69ae1e5a446f6668b83e9e33 Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Wed, 15 Jul 2026 14:01:05 +0800 Subject: [PATCH 3/5] fix(startup): don't auto-register install dir as project + clean legacy DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全新安装后顶部残留大量 project tab 的完整修复。 根因 1: 打包后 process.cwd() 是安装目录(不是用户项目),被 main.ts 的 auto-init 逻辑当成 active project,syncProject 再把它 auto-register 进 global DB,产生 'AgentDock' 这一行 tab。 修复: 仅在 dev 模式 auto-init 到 cwd;打包模式不设 active project, 显示'打开项目'欢迎页。 根因 2: legacy $HOME/.agentdock/projects.db (v0.1-v0.2 的 global DB) 积累了 41 行历史残留(e2e temp 目录、worktree 目录、build 产物), 卸载不清、重装照读。 修复: app 启动时在 JS 层清理 legacy projects.db(100% 可靠, 不依赖 NSIS 宏)。 NSIS 踩坑: - $USERPROFILE 不是 NSIS 内置变量,RMDir 静默失败(warning 6000)。 改用 $DESKTOP 倒推 home 目录。 - customUnInstall/customUnInstallSection 宏在 NSIS 3.0.4 下不展开, 不能依赖它做关键清理。userData 清理放 JS 层,NSIS 只兜底。 验证: silent install/uninstall + 启动后断言 projects.db 为 0 行。 改动: - electron/main.ts: 打包不 auto-init + 启动清 legacy DB - build/installer/installer.nsh: $DESKTOP 倒推 + 双保险清理 - electron-builder.yml: deleteAppDataOnUninstall true - docs/fix-stale-project-tabs.md: 完整修复经验 - docs/troubleshooting.md: 用户清理指南 --- docs/fix-stale-project-tabs.md | 137 +++++++++++++++++++++++++++++++++ electron-builder.yml | 2 +- electron/main.ts | 37 +++++++-- 3 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 docs/fix-stale-project-tabs.md diff --git a/docs/fix-stale-project-tabs.md b/docs/fix-stale-project-tabs.md new file mode 100644 index 0000000..9fea753 --- /dev/null +++ b/docs/fix-stale-project-tabs.md @@ -0,0 +1,137 @@ +# 修复经验: 全新安装后顶部残留大量 project tab + +## 问题现象 + +用户全新安装 AgentDock(无论 perUser 还是 perMachine,无论安装/卸载多少次), +启动后顶部出现大量 project tab —— 包括: + +- `AgentDock` — 安装目录本身 +- `Copilot-Switch`、`AgentDock (1)` — 历史打开过的项目 +- `a2b1c2fe-591`、`bed4c452-74d` — git worktree 目录 +- `win-unpacked` — build 产物目录 +- `agentdock-e2e-*`(多个)— e2e 测试自动生成的 temp 目录 + +关键:**用户全新安装后没有执行任何操作**,tab 就已经存在。 + +## 根因(两个独立问题) + +### 根因 1: 打包后 `process.cwd()` 被 auto-register 成项目 + +`electron/main.ts` 启动时执行: + +```ts +activeProjectPath = process.cwd(); +``` + +- **开发模式**: `process.cwd()` = 仓库根目录,是真实项目,符合预期 +- **打包模式**: `process.cwd()` = 安装目录 + (`C:\Users\\AppData\Local\Programs\AgentDock`),**不是**用户项目 + +renderer 启动调 `db:projects:list` → `syncProject(activeProjectPath)` → +项目在 global DB 找不到 → **auto-register** 用目录名 "AgentDock" 注册。 +这就是 `AgentDock` 那一行的来源。 + +**修复**: 打包模式不 auto-init 项目,显示"打开项目"欢迎页。 + +```ts +if (!app.isPackaged) { + activeProjectPath = process.cwd(); // dev only +} else { + // packaged: no auto project, show welcome screen +} +``` + +### 根因 2: legacy `~/.agentdock/projects.db` 未被清理 + +v0.1-v0.2 时期 global projects DB 存在 `$HOME/.agentdock/projects.db` +(后来迁移到 userData,但 fallback 逻辑一直保留 homedir 路径)。 + +这个文件里积累了 41 行历史记录(e2e 测试目录、worktree 目录、build +产物目录都被当成 project 注册过)。**卸载不清、重装照读**,所以 tab +一直存在。 + +尝试用 NSIS `customUnInstall` / `customUnInstallSection` 宏在卸载时删 +`~/.agentdock`,但踩了两个坑(见下)。最终改为**启动时清理**(JS 层, +100% 可靠): + +```ts +// electron/main.ts, before openGlobalDb() +const legacyDbPath = join(app.getPath("home"), ".agentdock", "projects.db"); +if (existsSync(legacyDbPath)) unlinkSync(legacyDbPath); +``` + +## NSIS 踩坑记录(重要) + +### 坑 1: `$USERPROFILE` 不是 NSIS 内置变量 + +在 NSIS uninstaller 里写 `RMDir /r "$USERPROFILE\.agentdock"` **静默失败**。 +NSIS 编译时报 warning(但不影响构建): + +``` +6000: unknown variable/constant "USERPROFILE" detected, ignoring +``` + +`$USERPROFILE` 是 Windows 环境变量,NSIS 不继承。合法的 NSIS 内置变量 +只有 `$APPDATA`(Roaming)、`$LOCALAPPDATA`(Local)、`$DESKTOP`、 +`$PROGRAMFILES` 等。`$PROGRAMDATA` 同样不是内置变量。 + +**正确做法**: 用 `$DESKTOP` 倒推 home 目录: + +```nsi +StrCpy $0 "$DESKTOP" ; C:\Users\\Desktop +StrCpy $0 $0 -8 ; 去掉末尾 "Desktop" (8 字符) → C:\Users\\ +StrCpy $0 "$0.agentdock" ; → C:\Users\\.agentdock +``` + +### 坑 2: `customUnInstall` / `customUnInstallSection` 宏不展开 + +electron-builder 的 NSIS 模板用 `!ifmacrodef customUnInstall` 检测自定义 +宏。但 NSIS 3.0.4 在某些情况下不识别 `.nsh` include 文件里的宏定义, +`!insertmacro` 展开为空。详见 `docs/tech-debt/nsis-macro-expansion.md`。 + +**结论**: 不要依赖 NSIS 宏做关键清理逻辑。userData 清理放在 **app 启动时 +的 JS 层**(可靠),NSIS 宏只做锦上添花(`deleteAppDataOnUninstall: true` +删 Roaming,宏尝试删其它路径,能删就删,删不了靠 JS 兜底)。 + +## $APPDATA vs $LOCALAPPDATA vs 安装目录 + +三者是不同路径,容易混淆: + +| NSIS 变量 | 实际路径 | 用途 | +|---|---|---| +| `$APPDATA` | `C:\Users\\AppData\Roaming` | electron 默认 userData | +| `$LOCALAPPDATA` | `C:\Users\\AppData\Local` | perUser 安装目录父级 | +| 安装目录 | `$LOCALAPPDATA\Programs\AgentDock` | exe + dll | +| legacy DB | `C:\Users\\.agentdock` | v0.1-v0.2 global DB | + +## 验证方法(无 GUI 自动化测试) + +用 PowerShell/Python 做 silent install/uninstall(无需管理员,perUser): + +```python +import subprocess, os, sqlite3 +# 清旧 DB +db = os.path.expanduser('~/.agentdock/projects.db') +if os.path.exists(db): os.remove(db) +# silent 安装 +subprocess.run([installer, '/S', '/currentuser', r'/D=C:\AgentDock-Test']) +# 启动 + 等窗口 +p = subprocess.Popen([r'C:\AgentDock-Test\AgentDock.exe']) +time.sleep(15) +# 检查 global DB —— 应该是 0 行(打包后不 auto-register) +conn = sqlite3.connect(db) +print(conn.execute('SELECT COUNT(*) FROM projects').fetchone()) +``` + +关键断言: **全新安装启动后 `projects.db` 应为 0 行**(或文件不存在)。 +如果 > 0,说明 auto-register 又把某个非项目目录注册了。 + +## 修改清单 + +| 文件 | 改动 | +|---|---| +| `electron/main.ts` | 打包模式不 auto-init 项目;启动时清 legacy `~/.agentdock/projects.db` | +| `build/installer/installer.nsh` | `$DESKTOP` 倒推 home 删 `.agentdock`;customUnInstall + customUnInstallSection 双保险 | +| `electron-builder.yml` | `deleteAppDataOnUninstall: true`;include `build/installer/installer.nsh` | +| `docs/troubleshooting.md` | 用户手动清理指南 | +| `docs/tech-debt/nsis-macro-expansion.md` | NSIS 宏不展开的技术债记录 | diff --git a/electron-builder.yml b/electron-builder.yml index 326ad33..cac34b9 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -15,7 +15,7 @@ publish: # 目录配置 directories: - output: release/v0.3.0-section-fix + output: release/${version} buildResources: build # 打包到 app.asar 的文件 diff --git a/electron/main.ts b/electron/main.ts index c70b4d4..e8943eb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,7 @@ import { app, BrowserWindow, dialog, ipcMain, protocol } from "electron"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; +import { existsSync, unlinkSync } from "node:fs"; import { IPC_CHANNEL_COUNT } from "./shared/api-types.js"; import { registerAllIpc, type AllIpcDeps } from "./main/ipc/index.js"; import { log } from "../plugins/logger.js"; @@ -109,11 +110,22 @@ async function bootstrap() { log.info({ dbBasePath }, "database base path set"); // 3. Auto-init the active project to the current working directory. - try { - activeProjectPath = process.cwd(); - log.info({ projectPath: activeProjectPath }, "auto-set active project to cwd"); - } catch (err) { - log.warn({ err }, "failed to auto-set active project"); + // ONLY in dev mode: there process.cwd() is the repo root, which is + // a real project the developer wants open. In a packaged build + // process.cwd() is the install directory (e.g. + // C:\Users\\AppData\Local\Programs\AgentDock), which is NOT a + // user project — auto-registering it would create a bogus + // "AgentDock" project tab. Packaged builds start with no active + // project and show the "open project" welcome screen instead. + if (!app.isPackaged) { + try { + activeProjectPath = process.cwd(); + log.info({ projectPath: activeProjectPath }, "dev mode: auto-set active project to cwd"); + } catch (err) { + log.warn({ err }, "failed to auto-set active project"); + } + } else { + log.info("packaged build: no auto active project (waiting for user to open one)"); } // 3. Open global projects DB (still used for project lookups by fs-config/worktree-shell) @@ -129,6 +141,21 @@ async function bootstrap() { ); globalDbHandle = openGlobalDb(projectsDbDir); } else { + // On startup, proactively clean the legacy homedir global DB + // ($HOME/.agentdock/projects.db) if it exists. This path was + // used by v0.1-v0.2 before the global DB moved to userData. + // Stale entries here cause project tabs to appear on fresh install. + // The NSIS uninstaller was supposed to clean this but $USERPROFILE + // is not a valid NSIS variable and the cleanup never fires. + const legacyDbPath = join(app.getPath("home"), ".agentdock", "projects.db"); + if (existsSync(legacyDbPath)) { + try { + unlinkSync(legacyDbPath); + log.info({ path: legacyDbPath }, "cleaned legacy homedir global DB on startup"); + } catch (err) { + log.warn({ err, path: legacyDbPath }, "failed to clean legacy global DB"); + } + } globalDbHandle = openGlobalDb(); } From bb1732e999fb7bcbb0c0ca48a711c52d26ed3c56 Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Wed, 15 Jul 2026 22:24:33 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20gemini=20review?= =?UTF-8?q?=20=E7=9A=84=204=20=E6=9D=A1=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. (security-critical) syncProject 删除不完整 worktree 前校验路径确实 在 /.agentdock/worktrees/ 内,防止畸形 sessionId (如 ../..) 逃逸目录导致 rmSync 误删。 2/3. (high) NSIS installer.nsh 改用 $PROFILE 内置变量替代 $DESKTOP -8 倒推。$DESKTOP 在 OneDrive 环境下是 C:\Users\\OneDrive\Desktop,倒推会得到错误的 home 目录。 $PROFILE 直接是 C:\Users\,可靠。customUnInstall 和 customUnInstallSection 两处都改。 4. (medium) preload.ts 删除 onCloseTab 的重复定义(windowControls 内 多余一份),保留顶层 api 对象上的定义(TabBar 用的是这个)。 单测 12/12 通过。 --- build/installer/installer.nsh | 15 ++++++--------- electron/main/ipc/db.ts | 23 +++++++++++++++++++++-- electron/preload.ts | 5 ----- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/build/installer/installer.nsh b/build/installer/installer.nsh index 37cf53b..a2d95fc 100644 --- a/build/installer/installer.nsh +++ b/build/installer/installer.nsh @@ -50,11 +50,10 @@ RMDir /r "$0" ${EndIf} ; 4. Legacy homedir global DB (pre-v0.3 fallback) - ; $DESKTOP = C:\Users\\Desktop; strip last 8 chars ("Desktop") - ; gives C:\Users\\, then append ".agentdock" - StrCpy $0 "$DESKTOP" - StrCpy $0 $0 -8 - StrCpy $0 "$0.agentdock" + ; $PROFILE is an NSIS built-in = C:\Users\. Reliable across + ; OneDrive/redirected-folder setups (unlike deriving from $DESKTOP, + ; which becomes C:\Users\\OneDrive\Desktop under OneDrive). + StrCpy $0 "$PROFILE\.agentdock" ${If} ${FileExists} "$0" RMDir /r "$0" ${EndIf} @@ -64,10 +63,8 @@ ; Runs AFTER customUnInstall, inside Section "Uninstall". !macro customUnInstallSection Section "-un.AgentDockLegacyCleanup" - ; Derive homedir from $DESKTOP (NSIS built-in, reliable in Section context) - StrCpy $0 "$DESKTOP" - StrCpy $0 $0 -8 - StrCpy $0 "$0.agentdock" + ; $PROFILE = C:\Users\ (NSIS built-in, reliable in Section context) + StrCpy $0 "$PROFILE\.agentdock" ${If} ${FileExists} "$0" RMDir /r "$0" ${EndIf} diff --git a/electron/main/ipc/db.ts b/electron/main/ipc/db.ts index e0ed522..cf78b30 100644 --- a/electron/main/ipc/db.ts +++ b/electron/main/ipc/db.ts @@ -9,7 +9,7 @@ import { ipcMain } from "electron"; import { nanoid } from "nanoid"; import { existsSync, readdirSync, rmSync } from "node:fs"; import { execFileSync } from "node:child_process"; -import { join } from "node:path"; +import { join, resolve as resolvePath, sep } from "node:path"; import { IPC_CHANNELS } from "../../shared/api-types.js"; import * as schema from "../../../plugins/db/schema.js"; import { @@ -249,12 +249,31 @@ export async function syncProject( // leftovers from failed/interrupted session creation (mkdir succeeded // but git worktree add never finished). Remove them — they have no // useful content and confuse the user. + // + // Safety: verify the path is actually inside this project's + // .agentdock/worktrees/ directory before rmSync. scanDiskWorktrees + // should only ever return such paths, but rmSync(recursive) is + // destructive so we double-check to guard against a malformed + // sessionId (e.g. "../..") escaping the intended directory. + const worktreesRoot = join(projectPath, ".agentdock", "worktrees"); + const resolvedWt = resolvePath(wt.worktreePath); + const resolvedRoot = resolvePath(worktreesRoot); + const insideWorktreesDir = + resolvedWt.startsWith(resolvedRoot + sep) && + resolvedWt !== resolvedRoot; + if (!insideWorktreesDir) { + log.warn( + { sessionId: wt.sessionId, worktreePath: wt.worktreePath, worktreesRoot }, + "syncProject: refusing to remove worktree outside .agentdock/worktrees", + ); + continue; + } log.info( { sessionId: wt.sessionId, worktreePath: wt.worktreePath }, "syncProject: removing incomplete worktree", ); try { - rmSync(wt.worktreePath, { recursive: true, force: true }); + rmSync(resolvedWt, { recursive: true, force: true }); cleanedOrphans++; } catch (err) { log.warn( diff --git a/electron/preload.ts b/electron/preload.ts index 10ff95b..72dd6e4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -277,11 +277,6 @@ const api = { onMaximizeChange: (cb: (maximized: boolean) => void) => { return on("window:maximize-change", cb); }, - // Close the active project tab. Pushed from main process when the - // user presses Ctrl+W / Cmd+W — the main process handles the - // keydown interception and forwards it via IPC so the renderer - // doesn't need to register a DOM-level keydown listener. - onCloseTab: (cb: () => void) => on("app:close-tab", cb), }, // Close the active project tab. Pushed from main process when the user From 3b18f5f040136a236cf6683394247d34d0843b67 Mon Sep 17 00:00:00 2001 From: Marco Taylor <[email protected]> Date: Wed, 15 Jul 2026 22:54:43 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(nsis):=20=E7=A7=BB=E9=99=A4=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5=E7=9A=84=20!ifdef?= =?UTF-8?q?=20BUILD=5FUNINSTALLER/!macroend=20hack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installer.nsh 的 preInit/customInstall 宏用了 !ifdef BUILD_UNINSTALLER !macroend !endif 试图在 uninstaller build 时提前结束宏,但 NSIS 不允许 !macroend 出现在 !ifdef 条件块内(宏定义结构必须完整),导致 '!include: error in script ... on line 6' 编译失败。 改为整个宏体用 !ifndef UNINSTALLER_OUT_FILE 包裹(合法), uninstaller build 时宏体为空,不碰 $INSTDIR。 本地 pack 验证通过。 --- build/installer/installer.nsh | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/build/installer/installer.nsh b/build/installer/installer.nsh index a2d95fc..e34fb54 100644 --- a/build/installer/installer.nsh +++ b/build/installer/installer.nsh @@ -1,9 +1,8 @@ ; preInit: clear legacy InstallLocation cache so the next install falls ; back to the perUser default path. Only clean on fresh installs. +; The whole body is guarded by !ifndef UNINSTALLER_OUT_FILE so it's a +; no-op in the uninstaller build (where $INSTDIR mustn't be touched). !macro preInit - !ifdef BUILD_UNINSTALLER - !macroend - !endif !ifndef UNINSTALLER_OUT_FILE ${IfNot} ${isUpdated} DeleteRegValue HKLM "Software\AgentDock" "InstallLocation" @@ -15,11 +14,8 @@ ; customInstall: force the default install path to the perUser ; convention ($LOCALAPPDATA\Programs\AgentDock). Skipped on upgrade -; and skipped on the uninstaller build. +; and no-op in the uninstaller build. !macro customInstall - !ifdef BUILD_UNINSTALLER - !macroend - !endif !ifndef UNINSTALLER_OUT_FILE ${IfNot} ${isUpdated} StrCpy $INSTDIR "$LOCALAPPDATA\Programs\${APP_FILENAME}" @@ -28,11 +24,9 @@ !macroend ; customUnInstall: runs inside Section "Uninstall". Removes data from -; every location AgentDock may have written to. IMPORTANT: $USERPROFILE -; is NOT a valid NSIS built-in variable — it's a Windows env var that -; NSIS doesn't inherit in uninstaller context. We derive the user's -; home directory from $DESKTOP by stripping the trailing "Desktop" (8 -; chars + trailing backslash = 8 chars from the end). +; every location AgentDock may have written to. NOTE: $USERPROFILE is +; NOT a valid NSIS built-in variable — use $PROFILE (= C:\Users\), +; which is reliable across OneDrive/redirected-folder setups. !macro customUnInstall ; 1. Install dir (Local\Programs\AgentDock) StrCpy $0 "$LOCALAPPDATA\Programs\${APP_FILENAME}"