From 753dbb0f20acf0828af7037ee15011432ff8036e Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:22:34 -0400 Subject: [PATCH 1/9] docs(spec): app-section redesign + in-app windows uninstall design --- ...ction-redesign-windows-uninstall-design.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/specs/2026-06-08-app-section-redesign-windows-uninstall-design.md diff --git a/docs/specs/2026-06-08-app-section-redesign-windows-uninstall-design.md b/docs/specs/2026-06-08-app-section-redesign-windows-uninstall-design.md new file mode 100644 index 0000000..e62b559 --- /dev/null +++ b/docs/specs/2026-06-08-app-section-redesign-windows-uninstall-design.md @@ -0,0 +1,93 @@ +# App-section redesign + in-app Windows uninstall — design + +**Date:** 2026-06-08 +**Status:** approved (brainstorm) — pending spec review + +## Goal + +Three changes to **Settings → App**, bringing the Windows and Linux variants closer: + +1. **Reorder** the App-section rows (per-OS). +2. Move the **uninstall confirmation** from an inline panel to an **overlay modal**. +3. Add an **in-app Windows uninstall** — Windows has no in-app uninstall today (only Add/Remove Programs); give it the same "uninstall ws-scrcpy-web" button Linux has. + +## Current state + +- `src/app/client/SettingsModal.ts` builds the App-section rows in this order via `buildRow(...)` + `appendChild`: **stop-server** (~L1610), **reset prompts** (~L1623), **install-for-all-users** (~L1683, Linux-only), **uninstall** (~L1695, Linux-only). Row visibility/enabled state comes from a pure helper (`appSectionButtonsState`, ~L158–209; applied at ~L1221). The uninstall confirmation is an **inline panel** beneath the button (`buildUninstallControl`, the settings-confirm-panel pattern). +- **Linux uninstall:** `POST /api/service/uninstall-app` (`ServiceApi.handleAppUninstall`, ~L980) → spawns the detached, out-of-cgroup `--linux-app-uninstall` helper (`launcher/src/linux_app_uninstall.rs`, pure `app_uninstall_commands` split into privileged/user-owned) → cascades through any service + removes `/opt` + deps; `keep` preserves `config.json` + `logs/`, else wipes. App then exits. +- **Windows uninstall:** only via Add/Remove Programs → the Velopack uninstaller runs the launcher with `--veloapp-uninstall` → `hooks.rs:on_uninstall` (servy stop + uninstall the `WsScrcpyWeb` service, kill the tray, **preserve user data**). No in-app trigger. Each install has `\Update.exe` (Velopack's updater/uninstaller). + +## Design + +### 1. App-section row order + +``` +LINUX WINDOWS +───────────────────────────── ───────────────────────────── +reset welcome & bookmark reset welcome & bookmark +install for all users stop the server and close the app +stop the server and close the app uninstall ws-scrcpy-web ← NEW +uninstall ws-scrcpy-web +``` + +Pure reorder of the `appendChild` sequence. `install for all users` stays **Linux-only**; the **uninstall** row now renders on **both** OSes. `reset` moves to the top on both. The `appSectionButtonsState` helper is extended so `showUninstall` is true on win32 too (today it is `linux` only). + +### 2. Uninstall confirmation → overlay modal (both OS) + +Replaces the inline confirm panel with a **top-layer ``** opened via `showModal()` (consistent with the welcome / system-wide-install modals, and avoids the z-index/top-layer trap noted in the Velopack packaging memory). + +Contents: +- Title: **uninstall ws-scrcpy-web** +- One-line body: *"this removes the app, its dependencies, and any installed service."* +- Checkbox: **keep my settings & logs** — **defaults to checked** (deleting data is a deliberate uncheck). No extra explanatory line. +- Two buttons: + - **cancel** — white text + white border (the existing white-outline style) → closes the modal, no action. + - **uninstall** — **red text + red border** (new red-outline danger style) → `POST /api/service/uninstall-app { keep: }`, then the "uninstalling…" overlay, then the app exits. + +The `keep` semantics are identical to Linux's existing toggle; the modal simply moves where it is asked. + +### 3. Windows uninstall backend + +`ServiceApi.handleAppUninstall` gains a **win32 branch** (today it returns `{ ok:false, reason:'unsupported' }` on non-linux). It mirrors the Linux branch: resolve `keep` from the body, spawn a **detached + elevated** Windows uninstall helper, write a 200 `{ ok:true, status:'uninstalling' }`, and schedule the local instance's exit so the helper can remove the running binary. + +New launcher routine **`launcher/src/windows_app_uninstall.rs`** (mirrors `linux_app_uninstall.rs`), dispatched by a new **`--windows-app-uninstall [--keep|--wipe]`** flag in `main.rs`. It: + +1. **Triggers the Velopack uninstaller** — `\Update.exe --uninstall` (primary). This removes the Program Files install and fires the existing `--veloapp-uninstall` hook (servy stop + uninstall the service, kill the tray, ARP cleanup). + - **VM-verified fallback:** if `Update.exe --uninstall` leaves the MSI's Add/Remove-Programs entry orphaned, switch to `msiexec /x {ProductCode}` (literally what ARP runs). Decided on the Win11 VM (see §6). +2. **Handles the dataRoot** (`%ProgramData%\WsScrcpyWeb`) for keep/wipe parity with Linux: + - always remove `dependencies/`; + - remove `config.json` + `logs/` **only if `keep` is false**. + +The helper is the win32 analog of `linux_app_uninstall.rs`: a pure command/step builder (`windows_app_uninstall_commands` returning the `Update.exe` invocation + the dataRoot keep/wipe steps) wrapped by a thin best-effort executor. + +**Elevation + survival.** The helper runs **elevated** (one UAC prompt) via the existing elevated-runner / `ShellExecuteEx "runas"` pattern, and must **survive the app's exit and job-object teardown** the same way the apply-time `Update.exe` does (it can't delete a running binary otherwise — cf. the Velopack Job-Object `KILL_ON_JOB_CLOSE` gotcha). Spawned out of the app's job, then the app exits. + +### 4. Keep/wipe semantics (both OS, unchanged contract) + +- **keep (checked — default):** `config.json` + `logs/` survive; `dependencies/` removed; app binary/install removed. A reinstall reuses the saved config (port). +- **wipe (unchecked):** the whole dataRoot is removed in addition to the app binary/install. + +### 5. Pure / testable units + +- **`appSectionButtonsState`** — extend + test: `showUninstall` true on win32; ordering reflected where state-driven. +- **Uninstall modal** — DOM construction, checkbox **defaults checked**, cancel(white)/uninstall(red) button classes, `keep` read from the checkbox. +- **`windows_app_uninstall_commands`** — Rust unit (like `app_uninstall_commands`): the `Update.exe --uninstall` step is always present; the dataRoot steps include `dependencies/` always and `config.json`+`logs/` only when `!keep`. +- **`ServiceApi` win32 branch** — vitest, platform pinned to `win32` (per the cross-platform-test discipline): asserts it spawns the helper + schedules exit, doesn't run the Linux path. + +### 6. Verification + +- **Automated:** vitest (frontend + API) + `cargo`/`cross` (launcher) — the pure pieces. CI is the Rust gate (the win32 helper compiles/tests there; local Windows `cargo test` covers the cross-platform parts). +- **The real gate — Win11 VM smoke** (you already run it): install via the MSI → in-app **uninstall** → + - `Update.exe --uninstall` removes `C:\Program Files\WsScrcpyWeb\`, the service is gone (`sc query WsScrcpyWeb` → not found), the tray is gone, **the Add/Remove-Programs entry is gone** (no orphan), **one** UAC prompt; + - **keep checked** → `config.json` + `logs/` survive under `%ProgramData%\WsScrcpyWeb`, `dependencies/` gone, a reinstall reuses the port; **unchecked** → the whole dataRoot is gone; + - if the ARP entry orphans → flip to `msiexec /x {ProductCode}` and re-verify. +- Add Windows uninstall rows to the smoke docs (the Linux App-section rows already exist in Module 14 / batch #15). + +### 7. Non-goals + +- **No Windows "install for all users."** Velopack's PerMachine MSI already installs machine-wide; an in-app machine-wide install is meaningless on Windows. +- **No change to the Linux uninstall mechanism** — only its confirmation UX moves to the shared modal, and the checkbox now defaults checked. + +### 8. Open item (VM-gated) + +`Update.exe --uninstall` vs `msiexec /x {ProductCode}` — which produces a clean MSI uninstall (hook fires **and** ARP entry removed, no orphan). Resolved on the Win11 VM during the smoke; the helper is structured so the command choice is a one-line swap. From 585191215e6f84244f44a116842c3b96583ec896 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:43:32 -0400 Subject: [PATCH 2/9] docs(plan): app-section redesign implementation plan + spec item 4 (Windows stop-exit tray + adb reap) Adds the phased TDD plan (frontend reorder + modal, Rust windows_app_uninstall helper + ServiceApi win32 branch, tray-reap stop_flag, adb taskkill belt-and-braces) and amends the spec with item 4 after verifying gracefulShutdown only kill-servers adb on Windows (missing the taskkill the update path uses). --- ...-app-section-redesign-windows-uninstall.md | 249 ++++++++++++++++++ ...ction-redesign-windows-uninstall-design.md | 20 +- 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-06-08-app-section-redesign-windows-uninstall.md diff --git a/docs/plans/2026-06-08-app-section-redesign-windows-uninstall.md b/docs/plans/2026-06-08-app-section-redesign-windows-uninstall.md new file mode 100644 index 0000000..2c8af5c --- /dev/null +++ b/docs/plans/2026-06-08-app-section-redesign-windows-uninstall.md @@ -0,0 +1,249 @@ +# App-section redesign + in-app Windows uninstall — 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:** Reorder Settings → App, move the uninstall confirm to an overlay modal, add an in-app Windows uninstall (parity with Linux), and fix the Windows tray reap on "stop server & exit". + +**Architecture:** Frontend (TS, `SettingsModal.ts` + a new modal) is one layer; backend (`ServiceApi.ts` win32 branch + a new Rust `windows_app_uninstall.rs` helper) is the other; the tray-reap fix is launcher-only. The contract between them is unchanged: the uninstall button POSTs `/api/service/uninstall-app { keep }` on both OSes; the server branches by platform. Mirror the existing Linux uninstall throughout (`linux_app_uninstall.rs`, `handleAppUninstall`). + +**Tech Stack:** TypeScript (vitest, jsdom), Rust (launcher; `cargo`/`cross`), no new deps. + +**Spec:** `docs/specs/2026-06-08-app-section-redesign-windows-uninstall-design.md`. + +**Parallelization:** Phases A/B (frontend) and C (backend) are independent after this plan; D (tray-reap) is independent of all. Good `/build-with-agent-team` split: one agent on A+B, one on C, one on D. + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `src/app/client/SettingsModal.ts` | App-section assembly + pure state helper + `buildUninstallControl` | modify: reorder appends; `appSectionButtonsState.showUninstall` → both OS; rewire uninstall button to open the modal | +| `src/app/client/UninstallConfirmModal.ts` | the overlay uninstall modal (checkbox + cancel/uninstall) | **create** | +| `src/app/client/__tests__/UninstallConfirmModal.test.ts` | modal unit tests | **create** | +| `src/app/client/__tests__/SettingsModal.test.ts` | state + ordering + rewire tests | modify | +| `src/style/modal.css` | red-outline "uninstall" button style | modify | +| `src/server/api/ServiceApi.ts` | `handleAppUninstall` win32 branch | modify (~L980–1060) | +| `src/server/__tests__/ServiceApi.test.ts` | win32-pinned branch test | modify | +| `launcher/src/windows_app_uninstall.rs` | pure command builder + parse_args + dispatch + run | **create** | +| `launcher/src/main.rs` | dispatch `--windows-app-uninstall` | modify (cfg(windows) flag dispatch) | +| `launcher/src/lib.rs` (or `main.rs` mod block) | declare `mod windows_app_uninstall` (cfg windows) | modify | +| `launcher/src/tray_supervisor.rs` + `supervisor.rs` + `main.rs` | stop the poll thread before the reap | modify | +| `docs/smoke-tests/smoke-full.md` + `smoke-checklist.md` | Windows uninstall + tray-reap rows | modify | + +--- + +## Phase A — Frontend: reorder + uninstall-on-both-OS + +### Task A1: `appSectionButtonsState` reveals uninstall on Windows too + +**Files:** Modify `src/app/client/SettingsModal.ts:172-189`; Test `src/app/client/__tests__/SettingsModal.test.ts`. + +- [ ] **Step 1 — failing test.** In `SettingsModal.test.ts`, in the `appSectionButtonsState` describe block, add: + +```ts +it('shows uninstall on win32 (parity with linux), hides install-all-users', () => { + const s = appSectionButtonsState({ platform: 'win32', machineWideInstalled: false }); + expect(s.showUninstall).toBe(true); // NEW: win32 gets the uninstall row + expect(s.showInstallAllUsers).toBe(false); // install-all-users stays linux-only +}); +it('shows both rows on linux', () => { + const s = appSectionButtonsState({ platform: 'linux', machineWideInstalled: false }); + expect(s.showUninstall).toBe(true); + expect(s.showInstallAllUsers).toBe(true); +}); +``` + +- [ ] **Step 2 — run, expect FAIL** (`showUninstall` is `false` on win32 today). `npx vitest run src/app/client/__tests__/SettingsModal.test.ts -t appSectionButtonsState` +- [ ] **Step 3 — implement.** In `appSectionButtonsState`, compute `const showUninstall = linux || resp.platform === 'win32';` and return `showUninstall` (replace the `showUninstall: linux`). Update the doc-comment ("two Linux-only rows" → "install-all-users is Linux-only; uninstall shows on Linux + Windows"). +- [ ] **Step 4 — run, expect PASS.** Same command. +- [ ] **Step 5 — commit.** `git add -A && git commit -m "feat(client): reveal App-section uninstall row on Windows"` + +### Task A2: Reorder the App-section rows + +**Files:** Modify `src/app/client/SettingsModal.ts:buildAppSection` (~1595-1702). + +- [ ] **Step 1 — reorder the appends.** The desired DOM order (top→bottom) is: reset → install-all-users (Linux) → stop-server → uninstall. Move the `body.appendChild(...)` calls into that order. Keep each control's construction; only the append sequence changes. Specifically: build `reset` (+ its inline confirm panel) first and append; then `install` row + note; then `stop` row + note; then `uninstall` row. The reset confirm panel stays inline (only the *uninstall* confirm becomes a modal — Phase B). Result, in append order: + 1. reset row, reset confirm panel + 2. install-all-users row + note (hidden until Linux) + 3. stop-server row, stop note + 4. uninstall row (hidden until A1 reveals it) +- [ ] **Step 2 — verify ordering test.** Add a test asserting the rendered App-section row labels appear in order. In `SettingsModal.test.ts`, render the section (the test file already constructs a `SettingsModal`; follow its existing pattern for building the App section), collect `.settings-row` label texts, and assert `['reset welcome and bookmark prompts', 'install for all users', 'stop the server and close the app', 'uninstall ws-scrcpy-web']` is a subsequence. Run it; expect PASS after Step 1 (write the test first if the existing harness supports it — if rendering the full section in jsdom is heavy, assert order by reading `body.children` label cells). +- [ ] **Step 3 — run vitest** for the file; expect PASS. +- [ ] **Step 4 — commit.** `git commit -am "feat(client): reorder App-section (reset, install, stop, uninstall)"` + +--- + +## Phase B — Frontend: uninstall confirm modal + +### Task B1: `UninstallConfirmModal` (top-layer dialog + checkbox + cancel/uninstall) + +**Files:** Create `src/app/client/UninstallConfirmModal.ts`; Create `src/app/client/__tests__/UninstallConfirmModal.test.ts`. **Pattern to mirror:** `src/app/client/ConfirmModal.ts` (static `confirm(): Promise<...>`, extends `Modal`, `showModal()` top layer) and `AdminConfirmModal.ts`. **Test pattern:** `src/app/client/__tests__/AdminConfirmModal.test.ts` (stubs `HTMLDialogElement.showModal`). + +Contract: +```ts +// Resolves on cancel → { confirmed: false }; on uninstall → { confirmed: true, keep: }. +UninstallConfirmModal.confirm(): Promise<{ confirmed: boolean; keep: boolean }> +``` + +- [ ] **Step 1 — failing tests** (`UninstallConfirmModal.test.ts`), mirroring `AdminConfirmModal.test.ts`'s `showModal` stub: + +```ts +it('defaults keep checkbox to checked', async () => { + const p = UninstallConfirmModal.confirm(); + const box = document.querySelector('input[type=checkbox]') as HTMLInputElement; + expect(box.checked).toBe(true); // SAFETY DEFAULT + (document.querySelector('.uninstall-cancel') as HTMLButtonElement).click(); + expect(await p).toEqual({ confirmed: false, keep: true }); +}); +it('resolves confirmed + keep=false when unchecked then uninstall clicked', async () => { + const p = UninstallConfirmModal.confirm(); + (document.querySelector('input[type=checkbox]') as HTMLInputElement).checked = false; + (document.querySelector('.uninstall-confirm') as HTMLButtonElement).click(); + expect(await p).toEqual({ confirmed: true, keep: false }); +}); +it('renders the body copy and red uninstall button', () => { + void UninstallConfirmModal.confirm(); + expect(document.body.textContent).toContain('this removes the app, its dependencies, and any installed service.'); + expect(document.querySelector('.uninstall-confirm')?.className).toContain('settings-btn-danger-outline'); +}); +``` + +- [ ] **Step 2 — run, expect FAIL** (module missing). `npx vitest run src/app/client/__tests__/UninstallConfirmModal.test.ts` +- [ ] **Step 3 — implement** `UninstallConfirmModal.ts`: a `Modal` subclass building title `uninstall ws-scrcpy-web`, body `

this removes the app, its dependencies, and any installed service.

`, a `