Skip to content

feat(linux): App-section UX — install-for-all-users, start-menu icon, complete uninstall (beta.49)#315

Merged
bilbospocketses merged 11 commits into
mainfrom
beta49-linux-app-section-ux
Jun 8, 2026
Merged

feat(linux): App-section UX — install-for-all-users, start-menu icon, complete uninstall (beta.49)#315
bilbospocketses merged 11 commits into
mainfrom
beta49-linux-app-section-ux

Conversation

@bilbospocketses

Copy link
Copy Markdown
Owner

What

Three Linux-only Settings → App features (spec docs/specs/2026-06-08-linux-app-section-ux-design.md, plan docs/plans/2026-06-08-linux-app-section-ux.md):

  1. Install for all users — a persistent App-section button for the machine-wide /opt install (reuses POST /api/service/install-system-wide); greyed with "already installed for all users (/opt)" once on /opt.
  2. Start-menu icon — the machine-wide install copies the AppImage's embedded icon into the hicolor theme so the .desktop Icon=ws-scrcpy-web resolves (no more generic placeholder).
  3. Complete uninstall — an always-available App-section button that fully removes the install, cascading through any installed service in one pass, with an optional "keep my settings & logs".

How

  • Launcher (Rust): a pure app_uninstall_commands builder (privileged / user-owned split) + a --linux-app-uninstall dispatch. Elevation mirrors the service-update path: already-root (system service) runs the privileged group directly; non-root (local / user-scope) re-invokes the launcher under one pkexec — a declined prompt aborts before anything is removed and relaunches the app. Local-Dependencies-Only: every tool resolved under the launcher bindir.
  • Server: POST /api/service/uninstall-app {keep} spawns the detached helper (systemd-run --collect, --user when the server is the user) and exits so the out-of-cgroup teardown runs from underneath; resets installMode in the kept config so a reinstall comes up clean.
  • Frontend: the two App-section rows (Linux-only), the uninstall confirm panel with the "keep my settings & logs" checkbox, and the terminal "uninstalled — close this tab" page.
  • Keep semantics: preserves config.json + logs/ only (at the data root's owner — ~/.local or /var/opt); dependencies always re-download.

Verification

  • TypeScript (local): vitest 910 passed; tsc --noEmit exit 0.
  • Rust: CI cargo test --workspace + clippy gates it (the launcher Linux teardown code can't be compiled/tested from the Windows dev host). Builder/parser tests cover the local/user/system × keep/wipe matrix + the privileged_mode branch.
  • Smoke: docs/smoke-tests/v0.1.30-beta.48-checklist.md gains batch chore(ci): bump setup-dotnet v4 → v5.2.0 + dotnet-version 9.x → 10.x #15 — to run on the Fedora VM.

Notes

  • The in-AppImage icon source ($APPDIR/.DirIcon) is a runtime assumption verified by the smoke; the install icon step is best-effort and never aborts the install.
  • TDD throughout (subagent-driven); the keep-on-system-install config-loss bug was caught in review before it could ship.

…cleanup (beta.49)

Route the data-root keep/wipe to the group that OWNS it, fixing a keep
violation: for a system service the data root is /var/opt (config.json + logs
live there), so the old unconditional `rm -rf /var/opt` in the privileged group
wiped preserved data even on keep=true. Now:

- Extract `data_root_commands(rm, data_root, keep)`: keep=false wipes the whole
  root; keep=true deletes only dependencies/bin/control, never config.json/logs.
- user_owned emits it ONLY for local/user installs (data_root is ~/.local/...).
- privileged emits it ONLY for a system service (data_root is /var/opt).
- /opt staging is still ALWAYS fully removed (binary + bundled deps).
- machine-wide-no-service => no /var/opt data-root removal line at all.

Defensive cleanup also added to the plan:
- adb reap (`pkill -KILL -x adb`) after the pattern pkill.
- tray autostart removal (`rm -f <HOME>/.config/autostart/ws-scrcpy-web-tray.desktop`).
- menu-cache refresh (`update-desktop-database /usr/share/applications`) right
  after the system .desktop removal.

Tests: add system_keep_preserves_var_opt_config_logs and user_service_keep;
rewrite local_wipe to exact ordered (incl. adb + autostart); system_install is
now the system-WIPE case and asserts the menu-cache refresh. keep/wipe matrix
(local/user/system) fully covered.
…eta.49)

Wires the pure app_uninstall_commands builder (Task 1) into a runnable
two-entry dispatch:

- --linux-app-uninstall (unelevated; the entry Node spawns via
  `systemd-run --user --collect`): runs the privileged group FIRST under
  ONE pkexec re-invocation of the launcher itself (argv all the way, no
  `sh -c`), then the best-effort user_owned group. A pkexec decline
  (126/127), a privileged failure, or a spawn error aborts and relaunches
  the running AppImage locally so the user is never stranded.
- --linux-app-uninstall-elevated (the pkexec re-invoke, as root): runs
  ONLY the privileged group. Same builder + same flags on both sides, so
  the privileged/user_owned split is identical and each instance runs
  exactly its own half.

parse_args is a pure flag parser (--scope user|system|none, --machine-wide
0|1, --keep XOR --wipe, --data-root, optional --relaunch); the run fns only
shell out. Every tool resolves under tool_dir/sbindir (absolute, Local-Deps,
never PATH), reusing linux_service::tool_dir.

Dispatch is wired into main.rs ahead of service-defer and the /opt
bootstrapper so an uninstall invocation can't be diverted into a
browser-open or an /opt re-exec that would drop its flags. Drops the
module's blanket dead_code allow now that the builder is live.

Tests cover parse_args round-trip, --scope none/user mapping, keep/wipe
exclusivity, and invalid-scope rejection. The run fns shell out and aren't
compiled on non-Linux, so they're not unit-testable here; CI (cargo
test/clippy on Linux) is the gate.
…a.49)

run_unelevated now supports BOTH elevation modes for the privileged
teardown group, mirroring the service-update path's `getuid()==0 ?
direct : pkexec` split:

- already root (the launcher was started by the ROOT system-service): run
  the privileged group DIRECTLY, best-effort — no pkexec (which would
  prompt redundantly/wrongly). A complete uninstall, so it never relaunches.
- non-root (local / user-scope server): unchanged — re-invoke self under
  ONE pkexec; a decline (126/127), failure, or spawn error still aborts +
  relaunches the local AppImage.
- empty privileged group: unchanged — skip elevation entirely.

The decision is a pure `privileged_mode(is_root, privileged_empty) ->
Skip|Direct|Pkexec` helper, unit-tested for all three outcomes (the run
fns shell out and aren't unit-testable); run_unelevated matches on it.

Root detection uses rustix::process::getuid().is_root() — rustix is already
a launcher dep (single_instance's flock). The `process` feature is added
scoped to cfg(target_os = "linux") so the Windows build, which never
compiles this module, doesn't enable it.

handle_elevated and the user_owned group are unchanged. Linux-cfg'd and
unverifiable on Windows; CI (cargo test/clippy on Linux) is the gate.
Linux-only endpoint that spawns the detached Rust app-uninstall helper
(--linux-app-uninstall) via systemd-run, then exits the local instance so
the out-of-cgroup helper can tear the app down from underneath it. Mirrors
the existing service-teardown handoff in handleUninstall.

- AppUninstallRequest { keep } type for the request body.
- buildUninstallHelperArgs(): pure, exported arg-vector builder (root ->
  system transient unit --collect; non-root -> --user --collect), so the
  spawn shape is unit-tested without mocking process/systemd.
- handleAppUninstall(): resolves installed scope (getInstalledScope),
  machine-wide /opt presence, isRoot, and the home AppImage relaunch path,
  then forwards them to the helper. keep=true resets installMode to null up
  front so a preserved config.json boots in local mode, not a phantom
  service mode. Missing staged helper -> 500; non-linux -> 200 unsupported.

Tests: 3 pure arg-builder cases + 4 handler cases (keep success+spawn args,
installMode reset, missing-helper 500/no-spawn, non-linux unsupported).
Full suite 899/899 green; tsc --noEmit exit 0.
… to App section

Adds two Linux-only rows to Settings -> App, driven by a pure, unit-tested
state helper (appSectionButtonsState) applied from renderServiceState once
/api/service/status resolves:

- "install ws-scrcpy-web for all users": POSTs /api/service/install-system-wide
  (pkexec -> /opt -> re-exec; the OS pkexec prompt is the confirmation) and
  reloads on success. Disabled with an "already installed for all users (/opt)"
  note once machine-wide installed.
- "uninstall ws-scrcpy-web": always enabled on Linux (not gated on service mode).
  Reuses the settings-confirm-panel pattern with a "keep my settings & logs"
  checkbox; confirm POSTs /api/service/uninstall-app { keep } and shows a terminal
  "uninstalled -- close this tab" message on success.

Both rows are hidden on non-Linux (inline display:none overrides the
.settings-row display:contents rule). Logic lives in exported helpers
(appSectionButtonsState, buildInstallAllUsersControl, buildUninstallControl)
with vitest coverage; full suite 908 passing, tsc --noEmit clean.
…nstall (beta.49)

The machine-wide install writes a system .desktop with Icon=ws-scrcpy-web, but
nothing installed an icon file under that name, so the menu entry showed a
generic icon. The AppImage already embeds the icon (vpk --icon -> .DirIcon), so
buildMachineWideInstallScript now takes an optional iconSource and, when present,
copies it into /usr/share/icons/hicolor/256x256/apps/ws-scrcpy-web.png and
refreshes the icon cache (best-effort). The whole icon block is one best-effort
subshell group so a missing .DirIcon (cp fails) cannot break the && chain and
skip the home-AppImage delete. handleInstallSystemWide resolves the icon from
$APPDIR/.DirIcon and passes it through; absent $APPDIR skips the icon entirely.
Tools resolve via systemTools (absolute paths, Local-Deps). Teardown already
removes the icon (launcher/src/linux_app_uninstall.rs SYS_ICON).
Smoke checklist (v0.1.30-beta.44-full.md): new Module 14 with seven [Linux] rows
covering the install-for-all-users button, the start-menu icon, and the four
in-app uninstall paths (local, user-service cascade, system-service cascade,
keep-settings-&-logs) plus the SELinux-clean check. CHANGELOG [Unreleased] gains
an Added block describing the three user-facing features.


The beta.49 App-section rows belong in the execution-ordered run-sheet
(v0.1.30-beta.48-checklist.md), the active tracker — not in beta.44-full.md,
which is the module-organized reference under deferred refresh (item 46). Adds
batch '## #15 - beta.49 App-section UX (Linux)' before Global pass criteria with
the seven rows relabeled 14.x->15.x and tags converted [Linux]->[L] to match the
run-sheet's format; reverts the Module 14 section from beta.44-full.md so it
flows Module 13 -> --- -> Global pass criteria again.
@bilbospocketses bilbospocketses added the release:beta Auto-release: bump to next beta on merge label Jun 8, 2026
@bilbospocketses bilbospocketses enabled auto-merge (squash) June 8, 2026 17:04
@bilbospocketses bilbospocketses merged commit 2871df9 into main Jun 8, 2026
9 checks passed
@bilbospocketses bilbospocketses deleted the beta49-linux-app-section-ux branch June 8, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:beta Auto-release: bump to next beta on merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant