diff --git a/STATUS.html b/STATUS.html index bb120a9..6121445 100644 --- a/STATUS.html +++ b/STATUS.html @@ -14,7 +14,7 @@ const STATUS = { app: "TidyDisk", version: "1.0.0", - updated: "2026-07-17", + updated: "2026-07-29", phase: "πŸš€ v1.0.0 β€” TidyDisk is LIVE-ready. All three PRs merged to main (landing refresh #22, license gate + analytics #30, rename + honest copy #31); the release gate is lifted; tidydisk.app serves the new site with the €19 Polar checkout wired. The v1.0.0 tag fires the signed-release pipeline (first tidydisk-arm64.dmg). Your last clicks before selling: publish the draft release when the workflow finishes, then run the launch playbook (#27 scan screenshot, #28 assets, #29 the wave).", summary: "This session (2026-07-16): the menu bar panel is no longer a mini node_modules cleaner. It is a read-only dashboard: an aggregate 'Tracked on disk' hero (every tracked byte counted once, against the sum of your three limits) plus four click-through rows (Projects / Caches / Packages / Docker) that open that tab in the main window. Gone from the panel: the reclaimable list, the Clean stale CTA, the pnpm prune row, and the paywall. NOTE: that removes all three paywall_shown triggers from the panel, so the launcher is now the only paid-conversion surface. Built spec -> plan -> subagent-driven TDD (9 tasks, fresh implementer + independent review each); all arithmetic + gating lives in pure panelAreas.ts / launcherNav.ts so the .tsx stays thin. The hero counts the pnpm store once and is therefore smaller than the four rows added up (rows mirror their tabs so click-through never lies); Docker refreshes on open when stale, but the store re-sizes only on a scan (no du on every popover open), and the panel never computes the package inventory. 315 tests, typecheck/lint/build green. In your court: manual QA (needs a running app + Docker daemon) and whether the launcher needs its own paywall moment now the panel has none. SHIPPED: merged to main via PR #42 (the whole Docker stack + the panel; PR #40 auto-closed) and tagged v1.1.0, which fired the signed-release pipeline to build a DRAFT dmg. Test that draft, then publish it to promote v1.1.0 to latest. Prior session (2026-07-14): finished the Docker feature on feat/docker-project-grouping (spec + plan docs/superpowers/2026-07-14-docker-buildcache-*, subagent-driven TDD, 6 tasks + a final-review fix wave). Two things you asked for. (1) Docker build cache no longer sits in the Docker tab: groupDockerForDisplay drops the build-cache group (items still count in the Docker header gauge, but the list only shows project/repo/orphan resources), and it now appears as a single aggregated 'Docker build cache' row in the CACHES tab next to the pnpm store. Its red Delete routes through the EXISTING requestDockerPrune('buildCache') β€” same license gate, same confirm, same toast, no new delete path β€” and the Caches tab was generalized from a hard-coded pnpm row into a list of 'live caches' (keyboard nav + Enter across them; the tab also auto-scans Docker on open when data is missing/stale, mirroring the Docker tab). (2) The deletion buttons now unmistakably read as deletions: the Docker bulk-prune buttons became 'Delete unused images / dangling images / stopped containers / unused volumes' with a trash icon and red danger styling; the build-cache row's Delete is likewise danger-styled (CacheRow gained an opt-in danger + icon variant); the per-item unused-image/volume/container trash stays hover-revealed like the node_modules rows (already danger). SAFETY UNCHANGED: presentation + a delete-button relabel; the typed-name volume confirm gate is byte-identical and build cache is still never individually removable (3-layer block, verified). Whole-branch Opus review: 1 Important fixed β€” Caches Enter indexed the full cache list while the rows were query-filtered, so Enter could fire a row scrolled off by search; a new tested selectedActionableCache() guard makes Enter a no-op unless the selected row is visible + actionable (confirm-gated the whole time, never a silent delete). Also swept em dashes out of the npm/yarn/bun placeholder copy. 293 tests, typecheck/lint/build green. In your court: manual QA (needs a running app + Docker daemon) and push/PR (rides PR #42 or its own branch, your call). Prior session (2026-07-09): per-tab headline data-viz + Docker auto-scan, BUILT on feat/docker-project-grouping (spec + plan in docs/superpowers/2026-07-09-*, subagent-driven TDD, 8 tasks + a final-review fix). Each launcher tab's header slot and footer line now reflect THAT tab's core metric instead of always node_modules: Projects/Caches/Docker each fill a size-vs-limit Gauge against their OWN configurable GB limit (new cacheThresholdGB=10, dockerThresholdGB=20 in Settings > Scanning > Storage limits, clamped 0.1-1000 like the node_modules threshold); Packages shows a compact SeverityMeter (a stacked critical/high/moderate/low advisory bar + outdated count, full breakdown in the tooltip). A new TabHeadline switches the header slot; a new pure tabSummary() switches the footer line; both null-gate honestly (no gauge when a store/daemon is absent). The Docker tab auto-scans on open when its data is missing or >5 min stale (loop-guarded, decoupled from the disk rescan; manual Cmd-R/refresh kept). Whole-branch Opus review returned one Important finding, fixed: the Packages headline no longer shows a false 'all clear' when advisories were never actually checked (checks off / offline / before enrichment now render nothing) - the same fix removes the first-open flash. 277 tests, typecheck/lint/build green; Projects tab confirmed unregressed and the Docker typed-name confirm safety gate untouched. In your court: manual QA (needs a running app + Docker daemon) and whether this rides the still-pending Docker-grouping PR #42 or gets its own branch/PR. Prior sessions: Two new app features shipped on feat/scan-coverage-and-liveness (PR #39), each brainstormed β†’ spec'd β†’ planned β†’ built via subagent-driven TDD (15 tasks, fresh implementer + independent review each), then a whole-branch review on the strongest tier (READY TO MERGE). (1) External-drive / scan-location coverage β€” the scanner was hard-wired to ~; now a Settings 'Scan locations' section lists mounted external volumes (detected under /Volumes, boot disk excluded by device id) as toggles plus an 'Add folder…' picker, choices persist in a new scanRoots setting, and resolveScanRoots feeds home + opted-in roots to the scanner each scan (mounted-only, deduped, nesting-dropped) β€” so node_modules on an external SSD or any folder is finally scannable and cleanable; an unplugged drive's projects just drop off next scan and return on replug, and a delete-time guard refuses trashing a path that vanished. (2) Live/running indicator β€” a green dot marks any project with a process running from inside it (one lsof cwd sweep per refresh, best-effort port label, soft-fails to no-dots on any error), its delete is disabled and main hard-refuses it re-checked at the instant of delete, so you cannot trash a running app's node_modules; polls every 45s while a window is open. Shared: unified deleteNodeModules β†’ { freed, blocked? }; a batch deleteMany IPC keeps 'Clean N stale' to one liveness check (the final review's one Important finding). 183 tests, typecheck/biome/build green; the real lsof flags smoke-tested on macOS. Awaiting your review/merge + manual pass (needs a physical external drive + a running dev server). Previous session context: SEO + content session (site only, no app code). Audited tidydisk.app's SEO: robots.txt and sitemap.xml were 404, no canonical tags, no structured data, and the apexβ†’www redirect direction contradicts the code's canonical base (fix is one Vercel dashboard click on your side). Built the fixes plus a self-publishing blog on feat/blog-seo (PR #34): markdown articles in site/content/blog/ parsed by a tested lib/blog pipeline; every surface (listing, article pages, sitemap, RSS) revalidates hourly and filters on publish date, so the 10 pre-written keyword-targeted articles release themselves one per week (2026-07-04 β†’ 2026-09-05) with zero deploys. Also: SoftwareApplication + BlogPosting JSON-LD, RSS feed, Blog in nav/footer. 17 unit tests, build green, rendering verified via Playwright. Earlier context: decided to reposition the product as TidyDisk (from 'Clean my node_modules'), broadening the pitch from node_modules to all developer disk waste (caches, builds, later Docker/Xcode), and to monetize via free-scan / paid-clean rather than a free/OSS-only model: the free scan stays the marketing engine (its shocking number is the shareable artifact) and the one-click Clean action becomes a paid, lifetime unlock (€19 founding β†’ €29). Produced two artifacts: TIDYDISK-LAUNCH.html β€” an interactive, on-brand launch dashboard (live priceβ†’sales calculator, pricing ladder, offer stack, competitor table, Show HN / Reddit drafts, and a persistent 2–4 week launch checklist; rendering + interactivity verified via Playwright) β€” and docs/superpowers/specs/2026-07-01-tidydisk-gtm-and-monetization-design.md, the GTM + monetization spec, whose one implementation slice is a license gate on the destructive actions (new src/main/license/*, offline-verifiable keys, enforced inside the destructive IPC handlers, Merchant-of-Record checkout via Lemon Squeezy/Paddle). Nothing renamed or gated in code yet β€” awaiting your review of the spec and your go-ahead. Still open from the prior session: publish the v0.2.0 draft release and deploy site/ to Vercel.", @@ -55,6 +55,7 @@ { label: "App icon for the packaged .app", detail: "1024px icon generated from the app's cube glyph (scripts/make-icon.mjs reuses the tray glyph-raster + png-encode; matches the in-app AppIcon β€” white cube on the red gradient squircle). Wired as mac.icon; 'pnpm icon' regenerates it", status: "done" }, { label: "GitHub release pipeline (tag β†’ signed DMG β†’ draft release)", detail: ".github/workflows/release.yml: pushing a vX.Y.Z tag has a macOS runner build, sign + notarize the arm64 app and upload the DMG/ZIP + latest-mac.yml to a GitHub draft release (build.publish github/draft). Cycle: 'pnpm version patch && git push --follow-tags'. Needs your 5 repo secrets β€” docs/SIGNING.md", status: "done" }, { label: "In-app Uninstall button", detail: "Settings now has an Uninstall row: a native macOS confirm dialog, then a clean uninstall β€” the .app bundle is moved to the Trash (skipped in dev) and the app-data dir (settings + projects cache) is removed, before quitting. New src/main/actions/app-actions.ts via the existing IPC pattern (app:uninstall). Branch feat/uninstall-button", status: "done" }, + { label: "In-app auto-update (check silently, click to download, click to install)", detail: "BUILT on feat/auto-update: UpdaterService wraps electron-updater (autoDownload=false, install-on-quit after a download) behind an injectable interface with a unit-tested state machine; silent background checks run at launch + every 6h, packaged builds only. The panel shows a compact dismissible 'vX.Y.Z available' banner (size + date) that deep-links to Settings β†’ Updates, dismissal is per-version. Settings β†’ Updates tab: current version, manual check, release notes from the GitHub release body, Download β†’ progress β†’ Restart and install. Translocation (app run outside /Applications) is detected up front with a friendly fix-it message instead of a crash. Analytics: update_available (deduped per version), update_download_clicked, update_installed. Release process unchanged β€” publishing the GitHub release draft remains the go-live switch. Spec: docs/superpowers/specs/2026-07-22-auto-update-design.md. 351 tests, typecheck/lint/build green; whole-branch review READY FOR PR after one fix commit. Awaiting your review + a manual pass with a signed build (CI can't exercise the real update flow)", status: "progress" }, { label: "Launch at login", detail: "app.setLoginItemSettings + a toggle in Settings", status: "planned" }, { label: "External drives + configurable scan roots", detail: "BUILT on feat/scan-coverage-and-liveness (PR #39). Settings 'Scan locations' lists mounted external volumes (detected under /Volumes, boot disk excluded by device id) as toggles + an 'Add folder…' picker; choices persist in a new validated scanRoots setting. resolveScanRoots feeds home + opted-in roots to the scanner each scan, keeping only currently-mounted paths (deduped, nesting dropped), so an unplugged drive's projects drop off next scan and return on replug. A delete-time guard refuses trashing a path that vanished since the scan (DeleteResult.blocked='unmounted'). MERGED to main (PR #39, 2026-07-08)", status: "done" } ] @@ -127,8 +128,7 @@ items: [ { label: "Ignore list / pinned projects", detail: "Protect specific projects from 'Clean stale'", status: "planned" }, { label: "Size shown in the menu bar itself", detail: "Tray title text next to the icon", status: "planned" }, - { label: "Auto-clean rules", detail: "e.g. auto-trash anything stale beyond 6 months", status: "planned" }, - { label: "Auto-updates for the signed release", detail: "electron-updater + GitHub releases, once signing works", status: "planned" } + { label: "Auto-clean rules", detail: "e.g. auto-trash anything stale beyond 6 months", status: "planned" } ] } ], @@ -163,7 +163,8 @@ { id: "test-license-gate", category: "testing", label: "Manual pass of the license gate (after the Polar rework)", detail: "On feat/license-gate once the rework lands: pnpm dev β†’ as a free user, try deleting a project (launcher ⌘⌫ and panel trash icon), 'Clean N stale', and the pnpm Prune β€” each should show the unlock bar, mutate nothing. Then activate a Polar sandbox key in launcher Settings β†’ row flips to Pro βœ“, panel updates live, delete/prune actually work. Revoke the sandbox key in Polar β†’ relaunch β†’ app drops to Free. Esc dismisses the unlock bar everywhere; funnel events should appear in the PostHog project." }, { id: "test-external-drives", category: "testing", label: "Test external-drive scan coverage (needs a physical drive)", detail: "On feat/scan-coverage-and-liveness: pnpm dev, plug in an external drive that has a project with node_modules on it. Settings β†’ Scan locations: the drive should appear as a toggle; turn it on, rescan (⌘R), and its projects should show up in the list. Also try 'Add folder…' on a normal directory. Unplug the drive and rescan β†’ its projects drop off; replug + rescan β†’ they return. Deleting a project whose drive you just unplugged should refuse cleanly, not error." }, { id: "test-live-indicator", category: "testing", label: "Test the live/running indicator + delete guard", detail: "On feat/scan-coverage-and-liveness: pnpm dev, then start a dev server inside one of your scanned projects (e.g. pnpm dev in some app). Open the launcher (⌘O) / tray panel: that project should show a green dot with a tooltip naming the process (and :port if it listens), and its delete button should be disabled ('This app is running'). Stop the server, wait up to 45s or reopen the window β†’ the dot clears and delete re-enables. Bonus: with the server running, confirm a panel 'Clean N stale' that includes it does not delete it (main refuses)." }, - { id: "test-tab-headlines", category: "testing", label: "Check the per-tab headline gauges + Docker auto-scan", detail: "On feat/docker-project-grouping: pnpm dev -> open the launcher (Cmd-O), switch tabs, and confirm the header data-viz changes per tab: Projects shows the node_modules size-vs-limit gauge (unchanged); Caches shows a gauge against the pnpm cache limit; Docker shows a gauge against the Docker limit; Packages shows the severity meter (stacked crit/high/mod/low + outdated, hover for the full breakdown). Settings -> Scanning -> Storage limits: change the pnpm cache limit and the Docker limit and watch those gauges refill. Confirm Packages shows NOTHING (not a green 'all clear') when 'Check npm for updates & advisories' is off or you are offline. Open the Docker tab cold and confirm it auto-scans; switch away and back within 5 min and confirm it does NOT rescan; Cmd-R still forces a refresh. NOTE: the main-process settings/docker changes need a full app restart (pnpm dev hot-reloads only the renderer)." } + { id: "test-tab-headlines", category: "testing", label: "Check the per-tab headline gauges + Docker auto-scan", detail: "On feat/docker-project-grouping: pnpm dev -> open the launcher (Cmd-O), switch tabs, and confirm the header data-viz changes per tab: Projects shows the node_modules size-vs-limit gauge (unchanged); Caches shows a gauge against the pnpm cache limit; Docker shows a gauge against the Docker limit; Packages shows the severity meter (stacked crit/high/mod/low + outdated, hover for the full breakdown). Settings -> Scanning -> Storage limits: change the pnpm cache limit and the Docker limit and watch those gauges refill. Confirm Packages shows NOTHING (not a green 'all clear') when 'Check npm for updates & advisories' is off or you are offline. Open the Docker tab cold and confirm it auto-scans; switch away and back within 5 min and confirm it does NOT rescan; Cmd-R still forces a refresh. NOTE: the main-process settings/docker changes need a full app restart (pnpm dev hot-reloads only the renderer)." }, + { id: "test-auto-update", category: "testing", label: "Review the auto-update PR and manually test the update flow with a signed build", detail: "feat/auto-update β€” CI (typecheck/lint/tests/build + unsigned packaging) can't exercise electron-updater against a real signed release. Skim the PR, then: build a signed version + a fake higher version (pnpm release twice, bumping package.json in between), publish the higher one as a GitHub prerelease-draft β†’ publish, install the lower one in /Applications. Confirm the panel banner appears within ~10s of launch with the right version/size/date; its βœ• hides it and it stays hidden after a restart while Settings β†’ Updates still shows the update; clicking the banner opens the launcher on Settings β†’ Updates; Download shows live percent and Restart and install relaunches on the new version; update_installed fires on that relaunch (PostHog); and running from ~/Downloads (translocated) shows the friendly 'move to Applications' message with no crash." } ], log: [ @@ -236,7 +237,9 @@ { date: "2026-07-16", text: "Menu bar panel became a read-only dashboard: aggregate 'Tracked on disk' hero + four click-through rows (Projects / Caches / Packages / Docker) that open that tab in the main window; the panel's reclaimable list, Clean-stale CTA, pnpm prune row and paywall are all gone (so the launcher is now the only paid-conversion surface). Brainstormed with a visual companion (picked the aggregate-hero layout), spec'd the honest-number decisions (hero counts the pnpm store once so it is deliberately smaller than the four rows summed, since rows mirror their tabs and click-through must never lie; 'tracked' not 'reclaimable' because the store's prunable share is unknown until pruned; combined limit = sum of the three existing limits), then built subagent-driven with TDD across 9 tasks. All arithmetic + gating went into pure panelAreas.ts (hero + row gating) and launcherNav.ts (nav-target map), keeping the .tsx thin, per the pure-logic-only test setup. Freshness tuned to not regress popover open speed: Docker stale-refreshes on open (5 min, non-looping), the pnpm store re-sizes only when a scan finishes (getPnpmStoreInfo has no TTL, so a timer would du on every open), and the panel never computes the package inventory (cached read only, placeholder row clicks through to compute it in the tab). One plan-authored test bug caught + fixed (asserted 41.2 GB but the code sums 34.8+6.4, off at the IEEE 754 bit). 315 tests, typecheck/lint/build green." }, { date: "2026-07-16", text: "Shipped the panel + the whole Docker stack to main and cut v1.1.0. You confirmed the panel looked right (after one fix: the per-row PixelMeter bars were collapsing to zero width inside AreaBar's flex column, since the meter cells are flex:1 with no basis and the root had no width, so only the GB number showed; fixed with width:100% on the PixelMeter root, a no-op in the block-context hero). Then, on your go-ahead: synced main into the branch and resolved the one STATUS.html conflict by taking the union (main's #43/#44 GA + legal work kept alongside the panel session), bumped to v1.1.0, retargeted PR #42's base from feat/docker-cleanup to main and merged it (bringing the entire Docker stack; PR #40 auto-closed), waited for CI green (check + package-macos), then tagged v1.1.0 which fired the signed-release pipeline. The build passed typecheck/tests/build and CODE-SIGNED fine, but Apple NOTARIZATION failed with HTTP 403 'a required agreement is missing or has expired' β€” an Apple Developer account agreement you need to accept, not a code/secrets problem (nothing published; v1.0.0 is still latest). Your court: accept the pending agreement at developer.apple.com/account then re-run the release (tag already exists), plus the still-pending manual daemon QA + the launcher-paywall decision. Tracked in userActions release-110-apple-agreement." }, { date: "2026-07-17", text: "Repositioned the landing page from 'node_modules cleaner' to 'all developer disk waste' (feat/landing-all-disk-waste, site only). Brainstormed with a visual companion (picked a four-peer-cards 'areas' section over an aggregate bar or a tabbed app-window mock), spec'd, planned, then built subagent-driven with TDD across 7 tasks in all 5 locales. Changes: hero subhead + the 'heaviest object' band widened to name Docker/caches/dead-projects (joke kept); a new Areas section (Projects / Caches / Packages / Docker peer cards, Docker accented) between the band and the features; Docker added as a 5th feature row with a hand-built by-project visual + a new i-docker sprite icon; the 'coming soon' pill reframed to only genuinely-future items; meta.description broadened; and the hero mockup rebuilt to show the app's NEW panel dashboard (the old one recreated a reclaimable-list UI the app no longer has). The Dictionary type forced all 5 locales (features became a 5-tuple, a new areas slice), so fr/es/de/pt were translated in the same pass (per-locale tab-title choices matched each dictionary's existing naming). Reviews caught + fixed one whole-branch issue the per-task gates missed: 6 French no-unescaped-entities lint errors (straight apostrophes in JSX text where the site convention is curly) fixed by currying them. typecheck + build green (41 pages, 5 locales); site lint clean except a pre-existing CookieConsent error from #44 (not this branch, not in CI). Not pushed; your court: review/merge the PR + an optional native-speaker proofread of the new machine-translated fr/es/de/pt copy." }, - { date: "2026-07-20", text: "Merged the landing repositioning to main (PR #45) and retried the v1.1.0 release, which succeeded this time. The site work is site-only so it did not change what the v1.1.0 tag builds; merging to main also fires the Vercel production deploy, so the new 'all developer disk waste' landing (four-area section, Docker row, rebuilt hero mockup, 5 locales) is now live. The release re-run got through Apple notarization (the agreement had finally propagated), producing a signed + notarized DRAFT v1.1.0 release (tidydisk-arm64.dmg + auto-update zip/latest-mac.yml); it stays draft until you test and publish it. Your court: test + publish the draft (userAction release-110-publish-draft), the standing manual daemon QA + launcher-paywall decision, and an optional proofread of the new non-English landing copy." } + { date: "2026-07-20", text: "Merged the landing repositioning to main (PR #45) and retried the v1.1.0 release, which succeeded this time. The site work is site-only so it did not change what the v1.1.0 tag builds; merging to main also fires the Vercel production deploy, so the new 'all developer disk waste' landing (four-area section, Docker row, rebuilt hero mockup, 5 locales) is now live. The release re-run got through Apple notarization (the agreement had finally propagated), producing a signed + notarized DRAFT v1.1.0 release (tidydisk-arm64.dmg + auto-update zip/latest-mac.yml); it stays draft until you test and publish it. Your court: test + publish the draft (userAction release-110-publish-draft), the standing manual daemon QA + launcher-paywall decision, and an optional proofread of the new non-English landing copy." }, + { date: "2026-07-22", text: "Built in-app auto-update end-to-end on feat/auto-update via subagent-driven TDD (11 tasks): UpdaterService wraps electron-updater (autoDownload=false, install-on-quit after a download) behind an injectable interface with a unit-tested state machine; silent checks run at launch + every 6h in packaged builds only, and the flow stays fully user-driven (Download β†’ progress β†’ Restart and install, never a forced restart). Shipped: IPC/types/settings wiring, a useUpdater hook syncing state to both renderers, a Settings β†’ Updates tab (current version, manual check, GitHub release notes), a dismissible per-version panel banner, a live deep link from the banner to Settings β†’ Updates, translocation detection (app run outside /Applications gets a friendly fix-it message instead of a crash), and analytics (update_available deduped per version, update_download_clicked, update_installed) plus post-install version detection. A whole-branch review caught real issues β€” the banner not surviving background re-checks and a stale deep-link β€” fixed in 858da18, then returned READY FOR PR. 351 tests, typecheck/lint/build green. Opened the auto-update PR; the release process is unchanged (publishing the GitHub release draft is still the go-live switch). Your court: review the PR and a manual pass with a signed build, since CI can't exercise the real update flow (userActions test-auto-update)." }, + { date: "2026-07-29", text: "Full code review of the auto-update PR (#46): architecture, security and release plumbing all check out (release.yml publishes latest-mac.yml, so 'publish the draft = go-live' holds). The review found six small issues, all fixed in 671ad80 on the branch: Download click now transitions to 'downloading 0%' synchronously (double-click could previously start a second download + duplicate analytics), progress broadcasts are deduped by rounded percent (IPC spam), a repeated banner deep-link now always lands on the Updates tab (nav seq), 4xx errors are no longer misreported as 'offline' (a 404 = broken feed should surface its message), HTML entities decode exactly once, and package.json gained an explicit repository field so app-update.yml generation is deterministic. 355 tests, typecheck/lint green. One test-plan gotcha for your manual pass: publish the fake higher version as a FULL release, not a prerelease β€” electron-updater ignores prereleases by default, so a prerelease would look like the feature is broken when it isn't. Your court: PR review + the signed-build manual checklist." } ] }; diff --git a/docs/superpowers/plans/2026-07-22-auto-update.md b/docs/superpowers/plans/2026-07-22-auto-update.md new file mode 100644 index 0000000..d6cd1b4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-auto-update.md @@ -0,0 +1,1583 @@ +# Auto-Update Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** TidyDisk checks GitHub Releases for updates silently; the user clicks once to download and once to restart-and-install, driven from a Settings "Updates" tab and a compact banner in the menu bar panel. + +**Architecture:** A main-process `UpdaterService` wraps `electron-updater` (injected via a structural `AutoUpdaterLike` interface so tests use a fake) and broadcasts an `UpdaterState` over IPC. The renderer consumes it through a `useUpdater` hook feeding two surfaces: `UpdateBanner` in the panel (tray dropdown) and `UpdateSettings` in the launcher's Settings view. Pure logic (summarizing electron-updater's `UpdateInfo`, error classification, banner visibility) lives in standalone testable modules. + +**Tech Stack:** Electron 33, electron-updater (new prod dep), electron-vite (main deps externalized, electron-builder packs prod `dependencies`), React 18, vitest, pnpm. + +**Spec:** `docs/superpowers/specs/2026-07-22-auto-update-design.md`. One refinement vs. the spec's state sketch: `currentVersion` and `checkedAt` are hoisted to the top level of `UpdaterState` (with the phase union nested under `status`) because the Settings tab must always render them, in every phase. + +## Global Constraints + +- Package manager: pnpm (pnpm 11; build-script approvals live in `pnpm-workspace.yaml`, not package.json β€” electron-updater has no build scripts, nothing to approve). +- One folder per component: `index.ts`, `Component.tsx`, `Component.types.ts`, optional `.constants.ts` + tests. +- Conventional-commit subjects; branch `feat/auto-update`; PR into `main`. +- Commit trailer on every commit: `Co-Authored-By: Claude Fable 5 `. +- No em dashes in any user-facing copy. +- The renderer is untrusted: every value crossing IPC into persisted settings goes through `coerceSetting`. +- Downloads and installs are ONLY user-initiated (`autoDownload = false`). Checks are automatic (launch + every 6 h) but only in packaged builds. +- Analytics: flat primitive props only, through the existing `Analytics.capture` gate. +- Style: inline styles with CSS vars (`var(--text)`, `var(--text-dim)`, `var(--surface-2)`, `var(--hairline)`), buttons via `cc-btn` / `cc-btn ghost` / `cc-btn danger` classes, sizes in the 11.5–13.5px range (copy existing SettingsView idioms). + +--- + +### Task 1: Branch, dependency, shared types, IPC channels + +**Files:** +- Create: `src/shared/updater.types.ts` +- Modify: `src/shared/ipc.constants.ts` +- Modify: `src/shared/launcher-nav.types.ts` +- Modify: `package.json` (via pnpm add) + +**Interfaces:** +- Produces: `UpdateSummary`, `UpdaterErrorKind`, `UpdaterState` (all later tasks); IPC channel names `updaterGetState`, `updaterCheck`, `updaterDownload`, `updaterInstall`, `onUpdaterState`; `LauncherNavTarget` gains `'settings-updates'`. + +- [ ] **Step 1: Branch and install dependency** + +```bash +git checkout -b feat/auto-update +pnpm add electron-updater +``` + +Expected: `electron-updater` appears under `"dependencies"` in package.json (NOT devDependencies β€” electron-vite's `externalizeDepsPlugin` keeps it out of the bundle and electron-builder packs prod deps, same as `posthog-node`). + +- [ ] **Step 2: Create `src/shared/updater.types.ts`** + +```ts +/** What the renderer needs to describe one available update. */ +export interface UpdateSummary { + version: string + /** ISO date string from the release feed; '' when the feed omits it. */ + releaseDate: string + /** Size of the update zip in bytes; 0 when the feed omits it. */ + sizeBytes: number + /** Plain-text release notes; null when the GitHub release body is empty. */ + notes: string | null +} + +export type UpdaterErrorKind = 'network' | 'translocation' | 'unknown' + +/** Full updater snapshot broadcast to renderers on every transition. */ +export interface UpdaterState { + currentVersion: string + /** Epoch ms of the last completed check; null before the first one. */ + checkedAt: number | null + status: + | { phase: 'idle' } + | { phase: 'checking' } + | { phase: 'available'; info: UpdateSummary } + | { phase: 'downloading'; info: UpdateSummary; percent: number } + | { phase: 'downloaded'; info: UpdateSummary } + | { phase: 'error'; message: string; kind: UpdaterErrorKind } +} +``` + +- [ ] **Step 3: Add IPC channels to `src/shared/ipc.constants.ts`** + +In the invoke block (after `consumeLauncherNav`): + +```ts + updaterGetState: 'updater:get-state', + updaterCheck: 'updater:check', + updaterDownload: 'updater:download', + updaterInstall: 'updater:install', +``` + +In the events block (after `onLauncherNavigate`): + +```ts + onUpdaterState: 'updater:state', +``` + +- [ ] **Step 4: Extend `src/shared/launcher-nav.types.ts`** + +```ts +/** A view or tab the launcher can be told to open on (e.g. from a menu-bar panel click). */ +export type LauncherNavTarget = 'settings' | 'settings-updates' | 'projects' | 'caches' | 'packages' | 'docker' +``` + +(`launcherNavState` maps unknown targets to the list view, so this is safe before Task 10 wires the mapping.) + +- [ ] **Step 5: Typecheck and commit** + +```bash +pnpm typecheck +git add package.json pnpm-lock.yaml src/shared/updater.types.ts src/shared/ipc.constants.ts src/shared/launcher-nav.types.ts +git commit -m "feat(updater): shared updater types, IPC channels, electron-updater dep" +``` + +Expected: typecheck passes. + +--- + +### Task 2: `dismissedUpdateVersion` setting + +**Files:** +- Modify: `src/shared/settings.types.ts` +- Modify: `src/main/settings/validate-setting.ts` +- Test: `src/main/settings/validate-setting.test.ts` + +**Interfaces:** +- Produces: optional `Settings.dismissedUpdateVersion?: string` (consumed by Task 9's banner). Not added to `DEFAULT_SETTINGS` (optional keys like `pnpmStorePath` aren't). + +- [ ] **Step 1: Write the failing tests** (append inside the existing describe block of `validate-setting.test.ts`, matching its style): + +```ts + it('accepts dismissedUpdateVersion strings', () => { + expect(coerceSetting('dismissedUpdateVersion', '1.2.0')).toEqual({ + key: 'dismissedUpdateVersion', + value: '1.2.0', + }) + }) + + it('rejects non-string dismissedUpdateVersion', () => { + expect(coerceSetting('dismissedUpdateVersion', 5)).toBeNull() + expect(coerceSetting('dismissedUpdateVersion', null)).toBeNull() + }) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run src/main/settings/validate-setting.test.ts` +Expected: FAIL (coerceSetting returns null for the unknown key). + +- [ ] **Step 3: Implement** + +`src/shared/settings.types.ts` β€” add to the `Settings` interface after `dockerBinaryPath`: + +```ts + /** Update banner dismissed for this version; a newer release re-shows the banner. */ + dismissedUpdateVersion?: string +``` + +`src/main/settings/validate-setting.ts` β€” add a case next to the other string cases: + +```ts + case 'dismissedUpdateVersion': + return typeof value === 'string' ? { key, value: value.trim() } : null +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm vitest run src/main/settings/validate-setting.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/settings.types.ts src/main/settings/validate-setting.ts src/main/settings/validate-setting.test.ts +git commit -m "feat(updater): dismissedUpdateVersion setting with IPC validation" +``` + +--- + +### Task 3: Pure updater logic + +**Files:** +- Create: `src/main/updater/updater-logic.ts` +- Test: `src/main/updater/updater-logic.test.ts` + +**Interfaces:** +- Produces: `UpdateInfoLike`, `isTranslocated(execPath: string): boolean`, `classifyUpdaterError(message: string): UpdaterErrorKind`, `summarizeUpdate(info: UpdateInfoLike): UpdateSummary` β€” all consumed by Task 4. + +- [ ] **Step 1: Write the failing tests** β€” `src/main/updater/updater-logic.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { classifyUpdaterError, isTranslocated, summarizeUpdate } from './updater-logic' + +describe('isTranslocated', () => { + it('detects the App Translocation mount path', () => { + expect(isTranslocated('/private/var/folders/x/AppTranslocation/ABC/d/TidyDisk.app/Contents/MacOS/TidyDisk')).toBe(true) + }) + it('passes a normal /Applications path', () => { + expect(isTranslocated('/Applications/TidyDisk.app/Contents/MacOS/TidyDisk')).toBe(false) + }) +}) + +describe('classifyUpdaterError', () => { + it('classifies network failures', () => { + expect(classifyUpdaterError('net::ERR_INTERNET_DISCONNECTED')).toBe('network') + expect(classifyUpdaterError('getaddrinfo ENOTFOUND github.com')).toBe('network') + expect(classifyUpdaterError('HttpError: 503 status 503')).toBe('network') + }) + it('classifies translocation mentions', () => { + expect(classifyUpdaterError('cannot update in /AppTranslocation/ path')).toBe('translocation') + }) + it('falls back to unknown', () => { + expect(classifyUpdaterError('something exploded')).toBe('unknown') + }) +}) + +describe('summarizeUpdate', () => { + it('picks the zip file size and passes fields through', () => { + const s = summarizeUpdate({ + version: '1.2.0', + releaseDate: '2026-07-20T14:26:58.000Z', + releaseNotes: 'Fixes and speedups', + files: [ + { url: 'tidydisk-arm64.dmg', size: 99 }, + { url: 'TidyDisk-1.2.0-arm64-mac.zip', size: 4200000 }, + ], + }) + expect(s).toEqual({ + version: '1.2.0', + releaseDate: '2026-07-20T14:26:58.000Z', + sizeBytes: 4200000, + notes: 'Fixes and speedups', + }) + }) + + it('strips HTML from notes and nulls empty ones', () => { + expect(summarizeUpdate({ version: '1.2.0', releaseNotes: '

New

' }).notes).toBe( + 'New\nFaster scans', + ) + expect(summarizeUpdate({ version: '1.2.0', releaseNotes: '' }).notes).toBeNull() + expect(summarizeUpdate({ version: '1.2.0' }).notes).toBeNull() + }) + + it('joins ReleaseNoteInfo arrays', () => { + const s = summarizeUpdate({ + version: '1.3.0', + releaseNotes: [ + { version: '1.3.0', note: 'C' }, + { version: '1.2.0', note: 'B' }, + ], + }) + expect(s.notes).toBe('1.3.0: C\n\n1.2.0: B') + }) + + it('defaults missing date and size', () => { + const s = summarizeUpdate({ version: '1.2.0' }) + expect(s.releaseDate).toBe('') + expect(s.sizeBytes).toBe(0) + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run src/main/updater/updater-logic.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** β€” `src/main/updater/updater-logic.ts`: + +```ts +import type { UpdateSummary, UpdaterErrorKind } from '@shared/updater.types' + +/** Structural slice of electron-updater's UpdateInfo that we consume; injectable for tests. */ +export interface UpdateInfoLike { + version: string + releaseDate?: string + releaseNotes?: string | Array<{ version: string; note: string | null }> | null + files?: Array<{ url: string; size?: number }> +} + +/** macOS App Translocation runs the app from a random read-only mount; updates cannot install there. */ +export function isTranslocated(execPath: string): boolean { + return execPath.includes('/AppTranslocation/') +} + +export function classifyUpdaterError(message: string): UpdaterErrorKind { + if (/apptranslocation/i.test(message)) return 'translocation' + if (/net::|enotfound|etimedout|econn|getaddrinfo|socket|network|status [45]\d\d/i.test(message)) return 'network' + return 'unknown' +} + +/** GitHub release bodies can arrive as HTML; keep the text, drop the tags. */ +function stripHtml(s: string): string { + return s + .replace(/<\/(h[1-6]|li|p|ul|ol|div|br)>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n') +} + +export function summarizeUpdate(info: UpdateInfoLike): UpdateSummary { + // The DMG is for humans; the zip is what Squirrel.Mac downloads and applies. + const zip = info.files?.find((f) => f.url.endsWith('.zip')) ?? info.files?.[0] + // stripHtml runs per note fragment, not on the joined string: it drops blank + // lines, which would otherwise collapse the '\n\n' separators between entries. + const notes = Array.isArray(info.releaseNotes) + ? info.releaseNotes + .map((n) => stripHtml([n.version, n.note ?? ''].filter(Boolean).join(': '))) + .filter((s) => s.length > 0) + .join('\n\n') + : stripHtml(info.releaseNotes ?? '') + return { + version: info.version, + releaseDate: info.releaseDate ?? '', + sizeBytes: zip?.size ?? 0, + notes: notes.length > 0 ? notes : null, + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm vitest run src/main/updater/updater-logic.test.ts` +Expected: PASS (all 5 groups). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/updater/updater-logic.ts src/main/updater/updater-logic.test.ts +git commit -m "feat(updater): pure logic for update summaries, error kinds, translocation" +``` + +--- + +### Task 4: `UpdaterService` + +**Files:** +- Create: `src/main/updater/updater-service.ts` +- Create: `src/main/updater/index.ts` +- Test: `src/main/updater/updater-service.test.ts` + +**Interfaces:** +- Consumes: Task 3's functions, `UpdaterState` from shared. +- Produces (for Tasks 6): + - `interface AutoUpdaterLike { autoDownload: boolean; autoInstallOnAppQuit: boolean; on(event: string, listener: (...args: never[]) => void): unknown; checkForUpdates(): Promise; downloadUpdate(): Promise; quitAndInstall(): void }` + - `class UpdaterService { constructor(updater: AutoUpdaterLike, opts: UpdaterServiceOptions); getState(): UpdaterState; check(): void; download(): void; quitAndInstall(): void; start(): void; stop(): void }` + - `interface UpdaterServiceOptions { currentVersion: string; execPath: string; onState: (s: UpdaterState) => void; onEvent: (event: 'update_available' | 'update_download_clicked', props?: Record) => void }` + +- [ ] **Step 1: Write the failing tests** β€” `src/main/updater/updater-service.test.ts`: + +```ts +import type { UpdaterState } from '@shared/updater.types' +import { describe, expect, it, vi } from 'vitest' +import type { AutoUpdaterLike } from './updater-service' +import { UpdaterService } from './updater-service' + +class FakeAutoUpdater implements AutoUpdaterLike { + autoDownload = true + autoInstallOnAppQuit = false + checkForUpdates = vi.fn(async () => undefined) + downloadUpdate = vi.fn(async () => undefined) + quitAndInstall = vi.fn() + private listeners = new Map void>() + on(event: string, listener: (...args: never[]) => void): unknown { + this.listeners.set(event, listener as (...args: unknown[]) => void) + return this + } + emit(event: string, ...args: unknown[]): void { + this.listeners.get(event)?.(...args) + } +} + +const INFO = { + version: '1.2.0', + releaseDate: '2026-07-20T14:26:58.000Z', + releaseNotes: 'Notes', + files: [{ url: 'TidyDisk-1.2.0-arm64-mac.zip', size: 4200000 }], +} + +function makeService(execPath = '/Applications/TidyDisk.app/Contents/MacOS/TidyDisk') { + const fake = new FakeAutoUpdater() + const states: UpdaterState[] = [] + const onEvent = vi.fn() + const service = new UpdaterService(fake, { + currentVersion: '1.1.0', + execPath, + onState: (s) => states.push(s), + onEvent, + }) + return { fake, states, onEvent, service } +} + +describe('UpdaterService', () => { + it('configures manual download and install-on-quit', () => { + const { fake } = makeService() + expect(fake.autoDownload).toBe(false) + expect(fake.autoInstallOnAppQuit).toBe(true) + }) + + it('starts idle with the current version and no checkedAt', () => { + const { service } = makeService() + expect(service.getState()).toEqual({ currentVersion: '1.1.0', checkedAt: null, status: { phase: 'idle' } }) + }) + + it('walks check -> checking -> available and reports the event', () => { + const { fake, service, states, onEvent } = makeService() + service.check() + expect(fake.checkForUpdates).toHaveBeenCalledOnce() + fake.emit('checking-for-update') + expect(states.at(-1)?.status.phase).toBe('checking') + fake.emit('update-available', INFO) + const last = states.at(-1) + expect(last?.status).toEqual({ + phase: 'available', + info: { version: '1.2.0', releaseDate: '2026-07-20T14:26:58.000Z', sizeBytes: 4200000, notes: 'Notes' }, + }) + expect(last?.checkedAt).toBeTypeOf('number') + expect(onEvent).toHaveBeenCalledWith('update_available', { version: '1.2.0' }) + }) + + it('returns to idle with checkedAt when no update exists', () => { + const { fake, service, states } = makeService() + service.check() + fake.emit('update-not-available') + expect(states.at(-1)?.status).toEqual({ phase: 'idle' }) + expect(states.at(-1)?.checkedAt).toBeTypeOf('number') + }) + + it('refuses download unless an update is available', () => { + const { fake, service } = makeService() + service.download() + expect(fake.downloadUpdate).not.toHaveBeenCalled() + }) + + it('downloads from available, tracks progress and downloaded', () => { + const { fake, service, states, onEvent } = makeService() + fake.emit('update-available', INFO) + service.download() + expect(fake.downloadUpdate).toHaveBeenCalledOnce() + expect(onEvent).toHaveBeenCalledWith('update_download_clicked', { version: '1.2.0' }) + fake.emit('download-progress', { percent: 41.7 }) + expect(states.at(-1)?.status).toMatchObject({ phase: 'downloading', percent: 42 }) + fake.emit('update-downloaded') + expect(states.at(-1)?.status).toMatchObject({ phase: 'downloaded' }) + }) + + it('refuses install unless downloaded', () => { + const { fake, service } = makeService() + service.quitAndInstall() + expect(fake.quitAndInstall).not.toHaveBeenCalled() + fake.emit('update-available', INFO) + fake.emit('update-downloaded') + service.quitAndInstall() + expect(fake.quitAndInstall).toHaveBeenCalledOnce() + }) + + it('maps errors through classifyUpdaterError', () => { + const { fake, states } = makeService() + fake.emit('error', new Error('getaddrinfo ENOTFOUND github.com')) + expect(states.at(-1)?.status).toEqual({ + phase: 'error', + message: 'getaddrinfo ENOTFOUND github.com', + kind: 'network', + }) + }) + + it('short-circuits to a translocation error without hitting the network', () => { + const { fake, service, states } = makeService('/private/var/folders/x/AppTranslocation/ABC/d/TidyDisk.app/Contents/MacOS/TidyDisk') + service.check() + expect(fake.checkForUpdates).not.toHaveBeenCalled() + expect(states.at(-1)?.status).toMatchObject({ phase: 'error', kind: 'translocation' }) + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run src/main/updater/updater-service.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** β€” `src/main/updater/updater-service.ts`: + +```ts +import type { UpdateSummary, UpdaterState } from '@shared/updater.types' +import { classifyUpdaterError, isTranslocated, summarizeUpdate, type UpdateInfoLike } from './updater-logic' + +/** Structural subset of electron-updater's autoUpdater; injectable for tests. */ +export interface AutoUpdaterLike { + autoDownload: boolean + autoInstallOnAppQuit: boolean + on(event: string, listener: (...args: never[]) => void): unknown + checkForUpdates(): Promise + downloadUpdate(): Promise + quitAndInstall(): void +} + +export interface UpdaterServiceOptions { + currentVersion: string + execPath: string + onState: (state: UpdaterState) => void + onEvent: ( + event: 'update_available' | 'update_download_clicked', + props?: Record, + ) => void +} + +const FIRST_CHECK_DELAY_MS = 10_000 +const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 + +/** + * Wraps electron-updater with a fully user-driven flow: checks are silent and + * automatic, but download and install each require an explicit call (a click). + */ +export class UpdaterService { + private status: UpdaterState['status'] = { phase: 'idle' } + private checkedAt: number | null = null + private lastInfo: UpdateSummary | null = null + private firstCheckTimer: ReturnType | null = null + private checkInterval: ReturnType | null = null + + constructor( + private updater: AutoUpdaterLike, + private opts: UpdaterServiceOptions, + ) { + updater.autoDownload = false + updater.autoInstallOnAppQuit = true + updater.on('checking-for-update', () => this.setStatus({ phase: 'checking' })) + updater.on('update-available', (info: UpdateInfoLike) => { + const summary = summarizeUpdate(info) + this.lastInfo = summary + this.checkedAt = Date.now() + this.opts.onEvent('update_available', { version: summary.version }) + this.setStatus({ phase: 'available', info: summary }) + }) + updater.on('update-not-available', () => { + this.checkedAt = Date.now() + this.setStatus({ phase: 'idle' }) + }) + updater.on('download-progress', (p: { percent: number }) => { + if (this.lastInfo) this.setStatus({ phase: 'downloading', info: this.lastInfo, percent: Math.round(p.percent) }) + }) + updater.on('update-downloaded', () => { + if (this.lastInfo) this.setStatus({ phase: 'downloaded', info: this.lastInfo }) + }) + updater.on('error', (err: Error) => { + this.setStatus({ phase: 'error', message: err.message, kind: classifyUpdaterError(err.message) }) + }) + } + + getState(): UpdaterState { + return { currentVersion: this.opts.currentVersion, checkedAt: this.checkedAt, status: this.status } + } + + check(): void { + if (isTranslocated(this.opts.execPath)) { + this.setStatus({ + phase: 'error', + message: 'TidyDisk is running from a translocated path.', + kind: 'translocation', + }) + return + } + // Never interrupt an in-flight or completed download with a fresh check. + if (this.status.phase === 'downloading' || this.status.phase === 'downloaded') return + void this.updater.checkForUpdates().catch(() => { + // failures surface through the 'error' event + }) + } + + download(): void { + if (this.status.phase !== 'available') return + this.opts.onEvent('update_download_clicked', { version: this.status.info.version }) + void this.updater.downloadUpdate().catch(() => { + // failures surface through the 'error' event + }) + } + + quitAndInstall(): void { + if (this.status.phase !== 'downloaded') return + this.updater.quitAndInstall() + } + + /** Silent background checks: shortly after launch, then every 6 hours. */ + start(): void { + this.firstCheckTimer = setTimeout(() => this.check(), FIRST_CHECK_DELAY_MS) + this.checkInterval = setInterval(() => this.check(), CHECK_INTERVAL_MS) + } + + stop(): void { + if (this.firstCheckTimer) clearTimeout(this.firstCheckTimer) + if (this.checkInterval) clearInterval(this.checkInterval) + this.firstCheckTimer = null + this.checkInterval = null + } + + private setStatus(status: UpdaterState['status']): void { + this.status = status + this.opts.onState(this.getState()) + } +} +``` + +`src/main/updater/index.ts`: + +```ts +export type { AutoUpdaterLike, UpdaterServiceOptions } from './updater-service' +export { UpdaterService } from './updater-service' +export { classifyUpdaterError, isTranslocated, summarizeUpdate } from './updater-logic' +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm vitest run src/main/updater/updater-service.test.ts` +Expected: PASS (9 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/updater +git commit -m "feat(updater): UpdaterService state machine over injectable autoUpdater" +``` + +--- + +### Task 5: Analytics events + version-change detection + +**Files:** +- Modify: `src/main/analytics/analytics.ts` (ANALYTICS_EVENTS) +- Create: `src/main/updater/note-version-change.ts` +- Test: `src/main/updater/note-version-change.test.ts` + +**Interfaces:** +- Produces: analytics events `'update_available' | 'update_download_clicked' | 'update_installed'`; `noteVersionChange(current: string, filePath?: string): string | null` returning the previous version only when this launch is the first on a strictly newer version. + +- [ ] **Step 1: Write the failing tests** β€” `src/main/updater/note-version-change.test.ts` (mirrors `settings-store.test.ts`'s electron mock pattern): + +```ts +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) + +// imported after the mock so the module's `import { app }` resolves +const { noteVersionChange } = await import('./note-version-change') + +const dir = mkdtempSync(join(tmpdir(), 'cmn-updater-')) +const fileIn = (name: string): string => join(dir, name) +afterAll(() => rmSync(dir, { recursive: true, force: true })) + +describe('noteVersionChange', () => { + it('returns null on first run and records the version', () => { + const f = fileIn('first.json') + expect(noteVersionChange('1.1.0', f)).toBeNull() + expect(JSON.parse(readFileSync(f, 'utf8'))).toEqual({ version: '1.1.0' }) + }) + + it('returns null when the version is unchanged', () => { + const f = fileIn('same.json') + noteVersionChange('1.1.0', f) + expect(noteVersionChange('1.1.0', f)).toBeNull() + }) + + it('returns the previous version after an upgrade', () => { + const f = fileIn('upgrade.json') + noteVersionChange('1.1.0', f) + expect(noteVersionChange('1.2.0', f)).toBe('1.1.0') + expect(JSON.parse(readFileSync(f, 'utf8'))).toEqual({ version: '1.2.0' }) + }) + + it('returns null on a downgrade but still records it', () => { + const f = fileIn('downgrade.json') + noteVersionChange('1.2.0', f) + expect(noteVersionChange('1.1.0', f)).toBeNull() + expect(JSON.parse(readFileSync(f, 'utf8'))).toEqual({ version: '1.1.0' }) + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run src/main/updater/note-version-change.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** β€” `src/main/updater/note-version-change.ts`: + +```ts +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { app } from 'electron' +import { lt, valid } from 'semver' + +/** + * Records the running version in userData; returns the previous version only when + * this launch is the first on a strictly newer one (i.e. an update just landed). + */ +export function noteVersionChange( + current: string, + filePath = join(app.getPath('userData'), 'last-run-version.json'), +): string | null { + let previous: string | null = null + try { + const raw = JSON.parse(readFileSync(filePath, 'utf8')) as { version?: unknown } + if (typeof raw.version === 'string') previous = raw.version + } catch { + // first run: no file yet + } + if (previous !== current) { + try { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify({ version: current })) + } catch (err) { + console.error('Failed to persist last-run version', err) + } + } + return previous && valid(previous) && valid(current) && lt(previous, current) ? previous : null +} +``` + +Add to `ANALYTICS_EVENTS` in `src/main/analytics/analytics.ts` (after `'share_card_copied'`): + +```ts + 'update_available', + 'update_download_clicked', + 'update_installed', +``` + +Also export from `src/main/updater/index.ts`: + +```ts +export { noteVersionChange } from './note-version-change' +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm vitest run src/main/updater/note-version-change.test.ts src/main/analytics/analytics.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/updater src/main/analytics/analytics.ts +git commit -m "feat(updater): update analytics events and post-install version detection" +``` + +--- + +### Task 6: Wire main process β€” AppContext, IPC handlers, preload, app startup + +**Files:** +- Modify: `src/main/app-context.types.ts` +- Modify: `src/main/ipc/register-ipc.ts` +- Modify: `src/main/ipc/register-ipc.test.ts` (ctx fixture) +- Modify: `src/preload/index.ts` +- Modify: `src/preload/api.types.ts` +- Modify: `src/main/index.ts` + +**Interfaces:** +- Consumes: `UpdaterService` (Task 4), `noteVersionChange` (Task 5), IPC channels (Task 1). +- Produces (renderer surface, used by Task 7): `window.clean.getUpdaterState(): Promise`, `updaterCheck(): Promise`, `updaterDownload(): Promise`, `updaterInstall(): Promise`, `onUpdaterState(fn: (s: UpdaterState) => void): () => void`. + +- [ ] **Step 1: AppContext** β€” in `src/main/app-context.types.ts` add: + +```ts +import type { UpdaterService } from './updater/updater-service' +``` + +and inside `AppContext`, after `license: LicenseStore`: + +```ts + updater: UpdaterService +``` + +- [ ] **Step 2: IPC handlers** β€” in `src/main/ipc/register-ipc.ts`, after the `IPC.consumeLauncherNav` handler: + +```ts + ipcMain.handle(IPC.updaterGetState, () => ctx.updater.getState()) + ipcMain.handle(IPC.updaterCheck, () => ctx.updater.check()) + ipcMain.handle(IPC.updaterDownload, () => ctx.updater.download()) + ipcMain.handle(IPC.updaterInstall, () => ctx.updater.quitAndInstall()) +``` + +- [ ] **Step 3: Test fixture** β€” in `src/main/ipc/register-ipc.test.ts`, find the ctx object literal passed to `registerIpc(...)` and add an `updater` stub alongside the other stores: + +```ts + updater: { + getState: vi.fn(() => ({ currentVersion: '1.1.0', checkedAt: null, status: { phase: 'idle' } })), + check: vi.fn(), + download: vi.fn(), + quitAndInstall: vi.fn(), + } as unknown as UpdaterService, +``` + +with `import type { UpdaterService } from '../updater/updater-service'` at the top. Then add a test: + +```ts + it('exposes updater state and forwards updater actions', async () => { + expect(await handlers.get(IPC.updaterGetState)?.(null)).toMatchObject({ status: { phase: 'idle' } }) + await handlers.get(IPC.updaterDownload)?.(null) + expect(ctx.updater.download).toHaveBeenCalledOnce() + }) +``` + +(Adapt `handlers.get(...)` / ctx access to the file's existing helper style β€” it stores handlers in a Map keyed by channel.) + +- [ ] **Step 4: Preload** β€” `src/preload/api.types.ts`: add `import type { UpdaterState } from '@shared/updater.types'` and, to `CleanApi` after `consumeLauncherNav`: + +```ts + /** Current updater snapshot; renderers fetch once on mount then subscribe. */ + getUpdaterState(): Promise + /** Triggers a silent update check (also used by the Settings "Check for updates" button). */ + updaterCheck(): Promise + /** Starts downloading the available update; no-op unless one is available. */ + updaterDownload(): Promise + /** Quits and installs the downloaded update; no-op unless downloaded. */ + updaterInstall(): Promise +``` + +and after `onLauncherNavigate`: + +```ts + onUpdaterState(fn: (s: UpdaterState) => void): () => void +``` + +`src/preload/index.ts`: add to the `api` object: + +```ts + getUpdaterState: () => ipcRenderer.invoke(IPC.updaterGetState), + updaterCheck: () => ipcRenderer.invoke(IPC.updaterCheck), + updaterDownload: () => ipcRenderer.invoke(IPC.updaterDownload), + updaterInstall: () => ipcRenderer.invoke(IPC.updaterInstall), + onUpdaterState: subscribe(IPC.onUpdaterState), +``` + +- [ ] **Step 5: App startup** β€” `src/main/index.ts`: + +Imports (electron-updater is CJS; with `"type": "module"` use the default-import interop): + +```ts +import electronUpdater from 'electron-updater' +import { noteVersionChange, UpdaterService } from './updater' +``` + +After `analytics.capture('app_launched', ...)`: + +```ts + const prevVersion = noteVersionChange(app.getVersion()) + if (prevVersion) analytics.capture('update_installed', { from: prevVersion, to: app.getVersion() }) +``` + +After `const notifier = new ThresholdNotifier(...)`: + +```ts + const updater = new UpdaterService(electronUpdater.autoUpdater, { + currentVersion: app.getVersion(), + execPath: process.execPath, + onState: (s) => broadcast(IPC.onUpdaterState, s), + onEvent: (event, props) => analytics.capture(event, props), + }) + // Silent checks only make sense for a packaged, signed build. + if (app.isPackaged) updater.start() +``` + +In the `before-quit` handler add `updater.stop()`. In the `registerIpc({...})` call add `updater`. + +- [ ] **Step 6: Verify** + +Run: `pnpm typecheck && pnpm vitest run src/main/ipc/register-ipc.test.ts` +Expected: both PASS. + +- [ ] **Step 7: Smoke-run in dev** (updater inert in dev, but the app must boot): + +Run: `timeout 20 pnpm dev > /tmp/dev-smoke.log 2>&1; grep -i "error" /tmp/dev-smoke.log | grep -vi "0 error" || echo CLEAN` +Expected: `CLEAN` (or only pre-existing noise; no updater/import errors). + +- [ ] **Step 8: Commit** + +```bash +git add src/main/app-context.types.ts src/main/ipc src/preload src/main/index.ts +git commit -m "feat(updater): wire UpdaterService through IPC, preload and app startup" +``` + +--- + +### Task 7: `useUpdater` renderer hook + +**Files:** +- Create: `src/renderer/src/hooks/useUpdater.ts` + +**Interfaces:** +- Consumes: `window.clean.getUpdaterState` / `updaterCheck` / `updaterDownload` / `updaterInstall` / `onUpdaterState` (Task 6). +- Produces: `useUpdater(): { state: UpdaterState; check: () => void; download: () => void; install: () => void }` (Tasks 8–9). Follows the `useSettings` fetch-then-subscribe pattern. + +- [ ] **Step 1: Implement** β€” `src/renderer/src/hooks/useUpdater.ts`: + +```ts +import type { UpdaterState } from '@shared/updater.types' +import { useEffect, useState } from 'react' + +const INITIAL: UpdaterState = { currentVersion: '', checkedAt: null, status: { phase: 'idle' } } + +/** Live updater state synced with the main process, plus the three user actions. */ +export function useUpdater(): { + state: UpdaterState + check: () => void + download: () => void + install: () => void +} { + const [state, setState] = useState(INITIAL) + + useEffect(() => { + let alive = true + void window.clean.getUpdaterState().then((s) => { + if (alive) setState(s) + }) + const unsubscribe = window.clean.onUpdaterState(setState) + return () => { + alive = false + unsubscribe() + } + }, []) + + return { + state, + check: () => void window.clean.updaterCheck(), + download: () => void window.clean.updaterDownload(), + install: () => void window.clean.updaterInstall(), + } +} +``` + +- [ ] **Step 2: Verify and commit** + +Run: `pnpm typecheck` +Expected: PASS. + +```bash +git add src/renderer/src/hooks/useUpdater.ts +git commit -m "feat(updater): useUpdater hook syncing updater state to renderers" +``` + +--- + +### Task 8: Settings "Updates" tab (`UpdateSettings` component) + +**Files:** +- Create: `src/renderer/src/components/UpdateSettings/index.ts` +- Create: `src/renderer/src/components/UpdateSettings/UpdateSettings.tsx` +- Create: `src/renderer/src/components/UpdateSettings/UpdateSettings.types.ts` +- Modify: `src/renderer/src/lib/format/format.ts` (add `releaseDateLabel`) +- Test: `src/renderer/src/lib/format/format.test.ts` (append) +- Modify: `src/renderer/src/launcher/views/SettingsView.tsx` (new tab) + +**Interfaces:** +- Consumes: `useUpdater` (Task 7), `formatSizeStr` from `@renderer/lib/format/format`. +- Produces: ``; `SettingsView` exports `type SettingsTab` (now including `'updates'`) and accepts optional prop `initialTab?: SettingsTab` (default `'scanning'`) β€” Task 10 consumes both. `releaseDateLabel(iso: string): string | null` in the format lib (Task 9 also uses it). + +- [ ] **Step 1: Failing test for the date helper** β€” append to `src/renderer/src/lib/format/format.test.ts`: + +```ts +describe('releaseDateLabel', () => { + it('formats an ISO date', () => { + expect(releaseDateLabel('2026-07-20T14:26:58.000Z')).toMatch(/2026/) + }) + it('returns null for empty or garbage input', () => { + expect(releaseDateLabel('')).toBeNull() + expect(releaseDateLabel('not-a-date')).toBeNull() + }) +}) +``` + +(add `releaseDateLabel` to the file's import from `./format`.) + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run src/renderer/src/lib/format/format.test.ts` +Expected: FAIL (no export). + +- [ ] **Step 3: Implement the helper** β€” append to `src/renderer/src/lib/format/format.ts`: + +```ts +/** "Jul 20, 2026" from an ISO string, or null when absent/unparseable. */ +export function releaseDateLabel(iso: string): string | null { + if (!iso) return null + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return null + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} +``` + +Run: `pnpm vitest run src/renderer/src/lib/format/format.test.ts` β€” expected PASS. + +- [ ] **Step 4: Component** β€” `src/renderer/src/components/UpdateSettings/UpdateSettings.types.ts`: + +```ts +export interface UpdateSettingsProps { + accent: string +} +``` + +`src/renderer/src/components/UpdateSettings/index.ts`: + +```ts +export { UpdateSettings } from './UpdateSettings' +export type { UpdateSettingsProps } from './UpdateSettings.types' +``` + +`src/renderer/src/components/UpdateSettings/UpdateSettings.tsx`: + +```tsx +import { useUpdater } from '@renderer/hooks/useUpdater' +import { formatSizeStr, relativeTime, releaseDateLabel } from '@renderer/lib/format/format' +import type { ReactNode } from 'react' +import type { UpdateSettingsProps } from './UpdateSettings.types' + +/** The Updates tab of Settings: version info, manual check, and the two-click + * download -> restart-and-install flow. All updater actions live here. */ +export function UpdateSettings({ accent }: UpdateSettingsProps): ReactNode { + const { state, check, download, install } = useUpdater() + const { status } = state + const info = 'info' in status ? status.info : null + const meta = info + ? [info.sizeBytes ? formatSizeStr(info.sizeBytes) : null, releaseDateLabel(info.releaseDate)] + .filter(Boolean) + .join(' Β· ') + : '' + + return ( + <> +
+
+
+ TidyDisk {state.currentVersion || '…'} +
+
+ {status.phase === 'checking' + ? 'Checking…' + : state.checkedAt + ? `Last checked ${relativeTime(state.checkedAt)}` + : 'Updates are checked automatically in the background'} +
+
+ +
+ + {status.phase === 'idle' && state.checkedAt !== null && ( +
Up to date.
+ )} + + {info && ( + <> +
+
+
+
+
+ Version {info.version} available +
+ {meta &&
{meta}
} +
+ {status.phase === 'available' && ( + + )} + {status.phase === 'downloading' && ( + + Downloading… {status.percent}% + + )} + {status.phase === 'downloaded' && ( + + )} +
+ {info.notes && ( +
+ {info.notes} +
+ )} +
+ + )} + + {status.phase === 'error' && ( +
+ + {status.kind === 'translocation' + ? 'Updates need TidyDisk to run from the Applications folder. Move the app there and relaunch.' + : status.kind === 'network' + ? 'Could not reach the update server.' + : `Update check failed: ${status.message}`} + + {status.kind !== 'translocation' && ( + + )} +
+ )} + + ) +} +``` + +Note: verify `formatSizeStr` and `relativeTime` import paths by checking an existing consumer (`grep -rn "formatSizeStr" src/renderer/src --include="*.tsx" | head -3`) and match that exact import specifier. + +- [ ] **Step 5: SettingsView tab** β€” in `src/renderer/src/launcher/views/SettingsView.tsx`: + +Export the tab type and add the tab (replacing the current private declarations): + +```ts +export type SettingsTab = 'scanning' | 'packages' | 'privacy' | 'updates' | 'license' + +const SETTINGS_TABS: { value: SettingsTab; label: string }[] = [ + { value: 'scanning', label: 'Scanning' }, + { value: 'packages', label: 'Packages' }, + { value: 'privacy', label: 'Privacy' }, + { value: 'updates', label: 'Updates' }, + { value: 'license', label: 'License' }, +] +``` + +Add `initialTab` to `SettingsViewProps`: + +```ts + /** Tab to land on when the view mounts (deep link from the panel banner). */ + initialTab?: SettingsTab +``` + +In the component: destructure `initialTab` and change the tab state line to + +```ts + const [tab, setTab] = useState(initialTab ?? 'scanning') +``` + +Add the import `import { UpdateSettings } from '@renderer/components/UpdateSettings'` and render, between the `privacy` and `license` blocks: + +```tsx + {tab === 'updates' && } +``` + +- [ ] **Step 6: Verify** + +Run: `pnpm typecheck && pnpm lint` +Expected: PASS. Then visually: `pnpm dev`, open full window β†’ Settings β†’ Updates tab renders version row and "Check for updates" (check errors in dev are fine/expected β€” dev has no update feed; confirm the error renders as the friendly network message, not a crash). + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/src/components/UpdateSettings src/renderer/src/lib/format src/renderer/src/launcher/views/SettingsView.tsx +git commit -m "feat(updater): Updates tab in Settings with manual download/install flow" +``` + +--- + +### Task 9: Panel `UpdateBanner` + +**Files:** +- Create: `src/renderer/src/components/UpdateBanner/index.ts` +- Create: `src/renderer/src/components/UpdateBanner/UpdateBanner.tsx` +- Create: `src/renderer/src/components/UpdateBanner/UpdateBanner.types.ts` +- Create: `src/renderer/src/components/UpdateBanner/UpdateBanner.constants.ts` +- Test: `src/renderer/src/components/UpdateBanner/UpdateBanner.constants.test.ts` +- Modify: `src/renderer/src/panel/PanelApp/PanelApp.tsx` + +**Interfaces:** +- Consumes: `useUpdater` (Task 7), `Settings.dismissedUpdateVersion` (Task 2), `formatSizeStr`/`releaseDateLabel` (Task 8), `window.clean.openLauncher('settings-updates')`. +- Produces: `bannerModel(status: UpdaterState['status'], dismissedVersion: string | undefined): BannerModel | null`; `` in the panel. + +- [ ] **Step 1: Write the failing tests** β€” `UpdateBanner.constants.test.ts`: + +```ts +import type { UpdaterState } from '@shared/updater.types' +import { describe, expect, it } from 'vitest' +import { bannerModel } from './UpdateBanner.constants' + +const INFO = { version: '1.2.0', releaseDate: '2026-07-20T14:26:58.000Z', sizeBytes: 4200000, notes: null } + +describe('bannerModel', () => { + it('hides for idle, checking and error phases', () => { + expect(bannerModel({ phase: 'idle' }, undefined)).toBeNull() + expect(bannerModel({ phase: 'checking' }, undefined)).toBeNull() + expect(bannerModel({ phase: 'error', message: 'x', kind: 'network' }, undefined)).toBeNull() + }) + + it('shows a dismissible banner when an update is available', () => { + expect(bannerModel({ phase: 'available', info: INFO }, undefined)).toEqual({ + info: INFO, + phase: 'available', + dismissible: true, + }) + }) + + it('hides when that exact version was dismissed, but not a different one', () => { + expect(bannerModel({ phase: 'available', info: INFO }, '1.2.0')).toBeNull() + expect(bannerModel({ phase: 'available', info: INFO }, '1.1.5')).not.toBeNull() + }) + + it('ignores dismissal once a download is in flight or done', () => { + const downloading: UpdaterState['status'] = { phase: 'downloading', info: INFO, percent: 30 } + expect(bannerModel(downloading, '1.2.0')).toEqual({ info: INFO, phase: 'downloading', percent: 30, dismissible: false }) + expect(bannerModel({ phase: 'downloaded', info: INFO }, '1.2.0')).toEqual({ + info: INFO, + phase: 'downloaded', + dismissible: false, + }) + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run src/renderer/src/components/UpdateBanner/UpdateBanner.constants.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** β€” `UpdateBanner.constants.ts`: + +```ts +import type { UpdateSummary, UpdaterState } from '@shared/updater.types' + +export interface BannerModel { + info: UpdateSummary + phase: 'available' | 'downloading' | 'downloaded' + percent?: number + dismissible: boolean +} + +/** What the panel banner shows, or null to hide. Dismissal only silences the plain + * "available" nudge; an in-flight or completed download stays visible. */ +export function bannerModel( + status: UpdaterState['status'], + dismissedVersion: string | undefined, +): BannerModel | null { + switch (status.phase) { + case 'available': + if (status.info.version === dismissedVersion) return null + return { info: status.info, phase: 'available', dismissible: true } + case 'downloading': + return { info: status.info, phase: 'downloading', percent: status.percent, dismissible: false } + case 'downloaded': + return { info: status.info, phase: 'downloaded', dismissible: false } + default: + return null + } +} +``` + +`UpdateBanner.types.ts`: + +```ts +export interface UpdateBannerProps { + accent: string + dismissedVersion?: string + onDismiss: (version: string) => void +} +``` + +`index.ts`: + +```ts +export { UpdateBanner } from './UpdateBanner' +export type { UpdateBannerProps } from './UpdateBanner.types' +``` + +`UpdateBanner.tsx`: + +```tsx +import { useUpdater } from '@renderer/hooks/useUpdater' +import { formatSizeStr, releaseDateLabel } from '@renderer/lib/format/format' +import type { ReactNode } from 'react' +import { bannerModel } from './UpdateBanner.constants' +import type { UpdateBannerProps } from './UpdateBanner.types' + +/** Compact one-line update nudge for the menu bar panel. Clicking it deep-links to + * Settings -> Updates; all download/install actions live there, not here. */ +export function UpdateBanner({ accent, dismissedVersion, onDismiss }: UpdateBannerProps): ReactNode { + const { state } = useUpdater() + const model = bannerModel(state.status, dismissedVersion) + if (!model) return null + + const { info } = model + const date = releaseDateLabel(info.releaseDate) + const label = + model.phase === 'downloading' + ? `Downloading v${info.version}… ${model.percent ?? 0}%` + : model.phase === 'downloaded' + ? `v${info.version} ready to install` + : `v${info.version} available${info.sizeBytes ? ` Β· ${formatSizeStr(info.sizeBytes)}` : ''}${date ? ` Β· ${date}` : ''}` + + return ( +
void window.clean.openLauncher('settings-updates')} + onKeyDown={(e) => { + if (e.key === 'Enter') void window.clean.openLauncher('settings-updates') + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + margin: '6px 10px 2px', + padding: '7px 10px', + borderRadius: 8, + background: 'var(--surface-2)', + border: '1px solid var(--hairline)', + cursor: 'pointer', + }} + > + + + {label} + + {model.dismissible && ( + + )} +
+ ) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm vitest run src/renderer/src/components/UpdateBanner/UpdateBanner.constants.test.ts` +Expected: PASS. + +- [ ] **Step 5: Mount in the panel** β€” `src/renderer/src/panel/PanelApp/PanelApp.tsx`: + +Change the settings destructure to expose the setter: + +```ts + const [settings, setSetting, settingsLoaded] = useSettings() +``` + +Add `import { UpdateBanner } from '@renderer/components/UpdateBanner'`. In the `view === 'main'`, onboarded branch, directly BEFORE ` void setSetting('dismissedUpdateVersion', v)} + /> +``` + +- [ ] **Step 6: Verify** + +Run: `pnpm typecheck && pnpm lint && pnpm test` +Expected: all PASS (banner renders nothing in dev since state stays idle). + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/src/components/UpdateBanner src/renderer/src/panel/PanelApp/PanelApp.tsx +git commit -m "feat(updater): dismissible update banner in the menu bar panel" +``` + +--- + +### Task 10: Deep link `settings-updates` through launcher navigation + +**Files:** +- Modify: `src/renderer/src/launcher/LauncherApp/launcherNav.ts` +- Test: `src/renderer/src/launcher/LauncherApp/launcherNav.test.ts` (append) +- Modify: `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx` + +**Interfaces:** +- Consumes: `LauncherNavTarget` incl. `'settings-updates'` (Task 1), `SettingsView`'s `initialTab` prop + exported `SettingsTab` (Task 8). +- Produces: `LauncherNavState` gains optional `settingsTab?: 'updates'`; banner clicks land on Settings β†’ Updates whether the launcher was closed or already open. + +- [ ] **Step 1: Write the failing test** β€” append to `launcherNav.test.ts`: + +```ts + it('maps settings-updates to the settings view with the updates tab', () => { + expect(launcherNavState('settings-updates')).toEqual({ view: 'settings', settingsTab: 'updates' }) + }) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run src/renderer/src/launcher/LauncherApp/launcherNav.test.ts` +Expected: FAIL (`settingsTab` missing; falls back to list view). + +- [ ] **Step 3: Implement** β€” `launcherNav.ts`: + +```ts +export interface LauncherNavState { + view: LauncherView + /** Only set for tab targets; undefined leaves the current tab alone. */ + tab?: LauncherTab + /** Only set for 'settings-updates': which settings tab to land on. */ + settingsTab?: 'updates' +} +``` + +and in `launcherNavState`, before the `settings` check: + +```ts + if (target === 'settings-updates') return { view: 'settings', settingsTab: 'updates' } +``` + +Run the test again β€” expected PASS. + +- [ ] **Step 4: Thread through LauncherApp** β€” `LauncherApp.tsx`: + +Add `import type { SettingsTab } from '../views/SettingsView'` and state near the `view` state (`~line 72`): + +```ts + const [settingsLandingTab, setSettingsLandingTab] = useState('scanning') +``` + +In `applyNav` (the `useCallback` around line 125), before `setView(next.view)`: + +```ts + setSettingsLandingTab(next.settingsTab ?? 'scanning') +``` + +In the footer gear toggle (`~line 1200`, `onClick={() => setView(view === 'settings' ? 'list' : 'settings')}`), reset the landing tab so a manual visit doesn't inherit a stale deep link: + +```ts + onClick={() => { + setSettingsLandingTab('scanning') + setView(view === 'settings' ? 'list' : 'settings') + }} +``` + +In the `