diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7db03..8d7ce88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Install for all users — now available any time from Settings (Linux).** Previously a machine-wide `/opt` install could only be chosen at the first-run prompt. The App section of Settings now has an explicit **install for all users** action that relocates the app to `/opt` under a single administrator prompt; once it's installed system-wide the control greys out and shows that it's already installed for all users (`/opt`). +- **The machine-wide app now shows a proper icon in the apps menu (Linux).** Installing for all users also drops the app's icon into the system icon theme, so its launcher entry shows the ws-scrcpy-web icon instead of a generic placeholder. +- **Uninstall ws-scrcpy-web from inside the app (Linux).** The App section adds a complete **uninstall…** action that removes the app — including a machine-wide `/opt` install and any installed user- or system-scope service — in a single pass, with at most one administrator prompt. A **keep my settings & logs** option preserves your `config.json` and logs (so a later reinstall reuses your saved port) while still removing the program and its bundled dependencies. + ## [0.1.30-beta.48] - 2026-06-08 ### Fixed diff --git a/docs/smoke-tests/v0.1.30-beta.48-checklist.md b/docs/smoke-tests/v0.1.30-beta.48-checklist.md index 33fdb17..805308c 100644 --- a/docs/smoke-tests/v0.1.30-beta.48-checklist.md +++ b/docs/smoke-tests/v0.1.30-beta.48-checklist.md @@ -121,6 +121,20 @@ Get the from-build first: `gh run download 26859605903 --repo bilbospocketses/ws | ☐ **11.4** `[W]` PerMachine intact | After the MSI install, check the location | `C:\Program Files\WsScrcpyWeb\` (PerMachine) | | ☐ **12.3** `[W]` Stop-exit reaps tray | Local mode → stop server & exit | Tray disappears; no lingering launcher/node/tray/adb; **cancel** leaves running | +## #15 — beta.49 App-section UX (Linux) + +beta.49 App-section additions (no module-doc counterpart): the one-click **install for all users**, the machine-wide start-menu icon, and the in-app **uninstall** flows. + +| Test | How to perform | Expected + verify | +|---|---|---| +| ☐ **15.1** `[L]` install-for-all-users button | Local (me-only) install running → Settings → **App** → click **install for all users**; authenticate the one prompt. | One pkexec; binary **relocates to `/opt/ws-scrcpy-web/`**; the button then goes **greyed/disabled** reading **"already installed for all users (/opt)"**; app keeps serving on the same port. | +| ☐ **15.2** `[L]` start-menu icon | After a machine-wide install (15.1 or 1.2), open the desktop apps menu and find the **ws-scrcpy-web** entry; also check the icon on disk. | The launcher entry shows the **ws-scrcpy-web icon** (not a generic placeholder); `ls /usr/share/icons/hicolor/256x256/apps/ws-scrcpy-web.png` → **exists**. | +| ☐ **15.3** `[L]` uninstall — local | Local mode → Settings → **App** → **uninstall…** → confirm (leave **"keep my settings & logs" unchecked**). | App removed; tab shows **"uninstalled — close this tab"**; `docs/smoke-tests/clear-install.sh` verify → **CLEAN SLATE** (no leftover binary / deps / config / decline marker). | +| ☐ **15.4** `[L]` uninstall — user-service cascade | User-scope service installed (machine-wide `/opt` binary) → Settings → **App** → **uninstall…** → confirm. | **One pkexec** (for the `/opt` removal); the `--user` unit is **gone** AND the app is **removed in one pass**; **no relaunch**. | +| ☐ **15.5** `[L]` uninstall — system-service cascade | System-scope service installed → **uninstall…** (runs from the root service context). | Runs **as root, NO pkexec**; `/opt/ws-scrcpy-web` + `/var/opt/ws-scrcpy-web` + the systemd unit are **all gone**; **zero AVC**. | +| ☐ **15.6** `[L]` uninstall — keep settings & logs | Uninstall with **"keep my settings & logs" checked**. | `config.json` + `logs/` **survive** at the data root (`~/.local/share/WsScrcpyWeb` local, or `/var/opt/ws-scrcpy-web` system); `dependencies/` is **gone either way**; a **reinstall reuses the saved port**. | +| ☐ **15.7** `[L]` uninstall — SELinux clean | After any uninstall, inspect the fcontext rules + the AVC monitor. | `semanage fcontext -l \| grep ws-scrcpy-web` → **empty**; **zero AVC**. | + --- ## Global pass criteria diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index fca7224..696688a 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -21,6 +21,14 @@ velopack.workspace = true windows.workspace = true zip.workspace = true +# Linux-only: the in-app uninstall helper (src/linux_app_uninstall.rs) detects +# whether the root system-service launched it via rustix::process::getuid (run +# the privileged teardown directly) vs a non-root server (re-invoke under +# pkexec). The `process` feature is scoped to Linux so the Windows build — which +# never compiles that module — doesn't enable it. +[target.'cfg(target_os = "linux")'.dependencies] +rustix = { version = "1", features = ["process"] } + [build-dependencies] winresource.workspace = true diff --git a/launcher/src/linux_app_uninstall.rs b/launcher/src/linux_app_uninstall.rs new file mode 100644 index 0000000..82a67ec --- /dev/null +++ b/launcher/src/linux_app_uninstall.rs @@ -0,0 +1,855 @@ +// In-app "complete uninstall" (beta.49) — the PURE command-vector builder plus +// the Task-2 dispatch/exec layer that runs it. +// +// `app_uninstall_commands` returns the ordered teardown argv-vectors for a full +// app removal, split into a `privileged` group (run under ONE pkexec elevation +// by the Task-2 dispatch layer) and an unelevated `user_owned` group. It mirrors +// the teardown phases of docs/smoke-tests/clear-install.sh and reuses the scope / +// unit-path / sbin helpers from `linux_service` so the two stay in lockstep. +// +// Dispatch (Task 2): the UNELEVATED entry `handle` (`--linux-app-uninstall`, +// spawned by the server via `systemd-run --user --collect`) runs the +// `privileged` group FIRST (so a declined/failed elevation aborts before +// anything is removed), then the `user_owned` group. HOW the privileged group +// runs depends on the server's uid — mirroring the service-update path's +// `getuid()==0 ? direct : pkexec` split (the decision is the pure +// `privileged_mode`): +// * already root (the ROOT system-service launched the helper): run the +// privileged group DIRECTLY, no pkexec (it would prompt redundantly). A +// complete uninstall, so this path never relaunches. +// * non-root (local / user-scope service): re-invoke the launcher under ONE +// pkexec; that lands on the ELEVATED entry `handle_elevated` +// (`--linux-app-uninstall-elevated`), which runs ONLY the `privileged` group +// as root. A pkexec decline (126/127) or a privileged failure aborts the +// uninstall and relaunches the running AppImage locally so the user is never +// stranded. +// The direct and the pkexec-elevated executions feed the SAME args to the SAME +// builder, so the privileged/user_owned split is identical either way. +// +// Local-Dependencies-Only: every tool is resolved under `bindir` (sbin tools via +// `sbindir_from(bindir)`) — never a bare name and never via PATH. +use crate::linux_service::{scope_prefix, sbindir_from, tool_dir, unit_path, Scope}; +use crate::log; + +/// App / systemd-unit identity shared by every footprint path. +const UNIT_NAME: &str = "WsScrcpyWeb"; +/// `pkill -f` pattern matching every long-lived process the app can spawn +/// (server, launcher, the standalone tray, and an escaped scrcpy-server). +const PROC_PATTERN: &str = "WsScrcpyWeb|ws-scrcpy-web-tray|ws-scrcpy-web-launcher|scrcpy-server"; +/// Machine-wide install staging dir: binary + bundled deps, root-owned, ALWAYS +/// fully removed (never "kept"). The system-service DATA root (/var/opt, holding +/// config.json + logs) is deliberately NOT a const — it arrives as `data_root` so +/// keep/wipe applies to it exactly like a user data root. +const OPT_DIR: &str = "/opt/ws-scrcpy-web"; +/// System menu entry + icon a machine-wide install drops under /usr/share. +const SYS_DESKTOP: &str = "/usr/share/applications/ws-scrcpy-web.desktop"; +const SYS_ICON: &str = "/usr/share/icons/hicolor/256x256/apps/ws-scrcpy-web.png"; +/// SELinux fcontext specs the install adds: the /opt bin_t tree, the /var/opt +/// var_lib_t state, and the legacy beta.40 /opt/.../data rule (removed too so a +/// stale rule never lingers post-uninstall). Matches clear-install.sh. +const FCONTEXT_SPECS: [&str; 3] = [ + "/opt/ws-scrcpy-web(/.*)?", + "/var/opt/ws-scrcpy-web(/.*)?", + "/opt/ws-scrcpy-web/data(/.*)?", +]; + +/// Ordered teardown argv-vectors for a complete app uninstall, split by +/// privilege. `privileged` is meant to run under ONE elevation (pkexec, Task 2); +/// an EMPTY `privileged` means a purely-local install with no root footprint, so +/// the dispatch layer can skip the elevation prompt entirely. +#[derive(Debug, Clone)] +pub struct UninstallPlan { + /// Root-only steps: system service cascade, the /opt staging removal, the + /// system-service data root (/var/opt) keep/wipe, the .desktop + icon plus a + /// menu-cache refresh, and the SELinux fcontext rules. + pub privileged: Vec>, + /// Unelevated steps: kill strays, user-scope service cascade, the instance + /// lock, and the data root (whole, or regenerable subdirs when `keep`). + pub user_owned: Vec>, +} + +/// stop -> disable -> reset-failed -> `rm -f ` -> daemon-reload for one +/// scope. The common core of `linux_service::teardown_commands` (which also +/// interleaves the system /opt + fcontext block); here those root steps are +/// emitted separately into `privileged`, so this stays scope-agnostic. +fn service_teardown(scope: Scope, bindir: &str) -> Vec> { + let systemctl = format!("{bindir}/systemctl"); + let rm = format!("{bindir}/rm"); + let pre = scope_prefix(scope); + let unit = format!("{UNIT_NAME}.service"); + let unit_file = unit_path(scope, UNIT_NAME); + vec![ + [vec![systemctl.clone()], pre.clone(), vec!["stop".into(), unit.clone()]].concat(), + [vec![systemctl.clone()], pre.clone(), vec!["disable".into(), unit.clone()]].concat(), + [vec![systemctl.clone()], pre.clone(), vec!["reset-failed".into(), unit.clone()]].concat(), + vec![rm.clone(), "-f".into(), unit_file.to_string_lossy().into_owned()], + [vec![systemctl.clone()], pre.clone(), vec!["daemon-reload".into()]].concat(), + ] +} + +/// `rm -rf` argv-vectors for a data root. `keep=false` wipes the whole root; +/// `keep=true` deletes ONLY the regenerable subdirs (dependencies/bin/control), +/// preserving the root itself, config.json and logs/. `rm` is the resolved +/// absolute rm path. Used for BOTH the user data root (~/.local/...) and the +/// system-service data root (/var/opt/...) — whichever owns config.json + logs. +fn data_root_commands(rm: &str, data_root: &str, keep: bool) -> Vec> { + if keep { + ["dependencies", "bin", "control"] + .into_iter() + .map(|sub| vec![rm.to_string(), "-rf".into(), format!("{data_root}/{sub}")]) + .collect() + } else { + vec![vec![rm.to_string(), "-rf".into(), data_root.to_string()]] + } +} + +/// Build the split teardown plan. See the module docs for the full contract. +/// +/// * `svc_scope` — installed service scope (None = no service installed). +/// * `machine_wide` — a /opt/ws-scrcpy-web install exists. +/// * `keep` — preserve config.json + logs/ (delete only deps/bin/control); +/// false wipes the whole data root. +/// * `bindir` — resolved bin dir (e.g. "/usr/bin"); all tools resolve under it. +/// * `data_root` — the app data root to tear down. +/// * `xdg_runtime_dir` — runtime dir holding the instance lock (None = skip the lock). +pub fn app_uninstall_commands( + svc_scope: Option, + machine_wide: bool, + keep: bool, + bindir: &str, + data_root: &str, + xdg_runtime_dir: Option<&str>, +) -> UninstallPlan { + let rm = format!("{bindir}/rm"); + + // ── user_owned (always; in teardown order) ─────────────────────────────── + // 1. kill stray app processes (server, launcher, tray, escaped scrcpy-server). + // Seeds the vec (vec![..]-init mirrors teardown_commands; the rest is conditional). + let mut user_owned: Vec> = vec![vec![ + format!("{bindir}/pkill"), + "-KILL".into(), + "-f".into(), + PROC_PATTERN.to_string(), + ]]; + + // 1b. reap the bundled adb daemon by exact name — it daemonizes and escapes + // the pattern pkill above. + user_owned.push(vec![format!("{bindir}/pkill"), "-KILL".into(), "-x".into(), "adb".into()]); + + // 2. user-scope service cascade — only when the service was installed --user. + if svc_scope == Some(Scope::User) { + user_owned.extend(service_teardown(Scope::User, bindir)); + } + + // 2b. tray autostart entry — defensive: pre-beta.45 installs wrote it. Always + // attempted; HOME-relative (resolved like unit_path). + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + user_owned.push(vec![ + rm.clone(), + "-f".into(), + format!("{home}/.config/autostart/ws-scrcpy-web-tray.desktop"), + ]); + + // 3. single-instance lock — only when the runtime dir is known. + if let Some(xrd) = xdg_runtime_dir { + user_owned.push(vec![rm.clone(), "-f".into(), format!("{xrd}/ws-scrcpy-web.lock")]); + } + + // 4. data root — user-owned ONLY for local / user-scope installs (data_root is + // ~/.local/...). A system service's data_root is /var/opt (root-owned), so + // its keep/wipe is emitted in the privileged group instead — exactly once, + // in the group that owns it. + if svc_scope != Some(Scope::System) { + user_owned.extend(data_root_commands(&rm, data_root, keep)); + } + + // ── privileged (only when a root-owned footprint exists) ────────────────── + // A /opt machine-wide install OR a system-scope service. Empty otherwise, so + // a purely-local uninstall needs no elevation. + let mut privileged: Vec> = Vec::new(); + if machine_wide || svc_scope == Some(Scope::System) { + // 1. system service cascade — only when the service was installed system-wide. + if svc_scope == Some(Scope::System) { + privileged.extend(service_teardown(Scope::System, bindir)); + } + // 2. /opt staging: binary + bundled deps are ALWAYS fully removed (never kept). + privileged.push(vec![rm.clone(), "-rf".into(), OPT_DIR.to_string()]); + // 2b. system-service data root (/var/opt) keep/wipe — root-owned, emitted + // here (NOT in user_owned). No blanket /var/opt rm: that would delete the + // preserved config.json + logs on keep. + if svc_scope == Some(Scope::System) { + privileged.extend(data_root_commands(&rm, data_root, keep)); + } + // 3. system menu entry, refresh the menu cache, then the icon. + privileged.push(vec![rm.clone(), "-f".into(), SYS_DESKTOP.to_string()]); + privileged.push(vec![ + format!("{bindir}/update-desktop-database"), + "/usr/share/applications".into(), + ]); + privileged.push(vec![rm.clone(), "-f".into(), SYS_ICON.to_string()]); + // 4. SELinux fcontext rules (current x2 + legacy /opt/.../data). + let semanage = format!("{}/semanage", sbindir_from(bindir)); + for spec in FCONTEXT_SPECS { + privileged.push(vec![ + semanage.clone(), + "fcontext".into(), + "-d".into(), + spec.to_string(), + ]); + } + } + + UninstallPlan { privileged, user_owned } +} + +// ─── Task 2: dispatch + execution (runs the pure builder above) ──────────────── + +/// Parsed `--linux-app-uninstall[-elevated]` invocation. `relaunch` is only +/// meaningful on the unelevated path (the currently-running `$APPIMAGE` to +/// restart if the user declines the pkexec prompt); it defaults to `""` on the +/// elevated path, which never relaunches. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UninstallArgs { + pub svc_scope: Option, + pub machine_wide: bool, + pub keep: bool, + pub data_root: String, + pub relaunch: String, +} + +/// Parse the uninstall flags. Returns `None` (a parse error) on a missing/invalid +/// `--scope`, a missing/invalid `--machine-wide`, a missing `--data-root`, or +/// anything other than EXACTLY one of `--keep` / `--wipe`. `--scope none` is a +/// VALID value mapping to `svc_scope: None` (no service was installed) — distinct +/// from the outer `None` that signals a parse error. `--relaunch` is optional and +/// defaults to `""` (the elevated path never reads it). +pub fn parse_args(args: &[String]) -> Option { + // --scope user|system|none (none = no service; missing/invalid = parse error) + let svc_scope = match args + .iter() + .position(|a| a == "--scope") + .and_then(|i| args.get(i + 1)) + .map(String::as_str) + { + Some("user") => Some(Scope::User), + Some("system") => Some(Scope::System), + Some("none") => None, + _ => return None, + }; + // --machine-wide 0|1 (missing/invalid = parse error) + let machine_wide = match args + .iter() + .position(|a| a == "--machine-wide") + .and_then(|i| args.get(i + 1)) + .map(String::as_str) + { + Some("1") => true, + Some("0") => false, + _ => return None, + }; + // --keep XOR --wipe (exactly one required) + let keep = match ( + args.iter().any(|a| a == "--keep"), + args.iter().any(|a| a == "--wipe"), + ) { + (true, false) => true, + (false, true) => false, + _ => return None, + }; + // --data-root (required) + let data_root = args + .iter() + .position(|a| a == "--data-root") + .and_then(|i| args.get(i + 1)) + .cloned()?; + // --relaunch (optional; only read on a pkexec decline) + let relaunch = args + .iter() + .position(|a| a == "--relaunch") + .and_then(|i| args.get(i + 1)) + .cloned() + .unwrap_or_default(); + Some(UninstallArgs { svc_scope, machine_wide, keep, data_root, relaunch }) +} + +/// Dispatch the UNELEVATED entry `--linux-app-uninstall` — the one the Node +/// server spawns (via `systemd-run --user --collect`). Returns `Some(exit_code)` +/// when it owns the invocation, `None` to let the next dispatcher try. +pub fn handle(args: &[String]) -> Option { + if !args.iter().any(|a| a == "--linux-app-uninstall") { + return None; + } + let a = match parse_args(args) { + Some(v) => v, + None => { + log::error("linux-app-uninstall: missing/invalid args"); + return Some(2); + } + }; + Some(run_unelevated(&a)) +} + +/// Dispatch the ELEVATED entry `--linux-app-uninstall-elevated` — the pkexec +/// re-invoke lands here as root and runs ONLY the privileged group. Returns +/// `Some(exit_code)` when it owns the invocation, `None` otherwise. +pub fn handle_elevated(args: &[String]) -> Option { + if !args.iter().any(|a| a == "--linux-app-uninstall-elevated") { + return None; + } + let a = match parse_args(args) { + Some(v) => v, + None => { + log::error("linux-app-uninstall-elevated: missing/invalid args"); + return Some(2); + } + }; + Some(run_elevated(&a)) +} + +/// Unelevated run, invoked from the (possibly non-root) server. The privileged +/// group runs FIRST, then the best-effort `user_owned` group runs on EVERY path. +/// `privileged_mode` picks HOW the privileged group runs — mirroring the +/// service-update path's `getuid()==0 ? direct : pkexec` split: +/// * `Skip` — empty group (purely-local install): no elevation at all. +/// * `Direct` — already root (the root system-service launched us): run the +/// group DIRECTLY, best-effort (same idiom as `user_owned`); pkexec would +/// prompt redundantly. A complete uninstall, so it never relaunches. +/// * `Pkexec` — non-root: re-invoke self under ONE pkexec. A decline (126/127), +/// a privileged failure, or a spawn error aborts + relaunches the local +/// AppImage and returns 0 (the privileged group is all-or-nothing there — it +/// never partially ran — so the user keeps a working local app). +fn run_unelevated(a: &UninstallArgs) -> i32 { + log::info(&format!( + "linux-app-uninstall: scope={:?} machine_wide={} keep={}", + a.svc_scope, a.machine_wide, a.keep + )); + let plan = plan_for(a); + + // 1. Privileged group FIRST. Already root -> run it directly (no pkexec); + // non-root -> re-invoke self under ONE pkexec; empty -> skip elevation. + let is_root = rustix::process::getuid().is_root(); + match privileged_mode(is_root, plan.privileged.is_empty()) { + PrivMode::Skip => {} + PrivMode::Direct => { + // Already root (system-service mode): run the privileged group + // DIRECTLY, best-effort (mirrors linux_service::run / the user_owned + // loop). No relaunch — a complete uninstall never relaunches. + log::info("uninstall: already root (system-service) — running privileged group directly"); + for argv in &plan.privileged { + let (cmd, rest) = argv.split_first().expect("non-empty argv"); + match std::process::Command::new(cmd).args(rest).status() { + Ok(s) if s.success() => log::info(&format!("uninstall (root) ok: {}", argv.join(" "))), + Ok(s) => log::error(&format!("uninstall (root) non-zero ({:?}): {}", s.code(), argv.join(" "))), + Err(e) => log::error(&format!("uninstall (root) spawn failed: {} ({e})", argv.join(" "))), + } + } + } + PrivMode::Pkexec => { + let pkexec = format!("{}/pkexec", tool_dir("pkexec")); + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + log::error(&format!( + "uninstall: cannot resolve self exe for pkexec re-invoke ({e}) — aborting + relaunching local" + )); + relaunch(&a.relaunch); + return 0; + } + }; + let scope_arg = match a.svc_scope { + Some(Scope::User) => "user", + Some(Scope::System) => "system", + None => "none", + }; + let mw_arg = if a.machine_wide { "1" } else { "0" }; + let keep_arg = if a.keep { "--keep" } else { "--wipe" }; + // argv all the way (no `sh -c`): re-invoke ourselves under pkexec with + // the same inputs MINUS --relaunch (the elevated half never relaunches). + match std::process::Command::new(&pkexec) + .arg(&exe) + .args([ + "--linux-app-uninstall-elevated", + "--scope", + scope_arg, + "--machine-wide", + mw_arg, + keep_arg, + "--data-root", + a.data_root.as_str(), + ]) + .status() + { + Ok(s) if s.success() => log::info("uninstall: privileged group complete (pkexec)"), + Ok(s) if declined(s) => { + log::error("uninstall: pkexec declined — aborting + relaunching local"); + relaunch(&a.relaunch); + return 0; + } + Ok(s) => { + log::error(&format!( + "uninstall: privileged step failed ({:?}) — aborting + relaunching local", + s.code() + )); + relaunch(&a.relaunch); + return 0; + } + Err(e) => { + log::error(&format!( + "uninstall: pkexec spawn failed ({e}) — aborting + relaunching local" + )); + relaunch(&a.relaunch); + return 0; + } + } + } + } + + // 2. Unelevated group (kills our own processes + tears down the user data + // root). Best-effort: log non-zero, KEEP GOING (mirrors linux_service::run). + for argv in plan.user_owned { + let (cmd, rest) = argv.split_first().expect("non-empty argv"); + match std::process::Command::new(cmd).args(rest).status() { + Ok(s) if s.success() => log::info(&format!("uninstall ok: {}", argv.join(" "))), + Ok(s) => log::error(&format!("uninstall non-zero ({:?}): {}", s.code(), argv.join(" "))), + Err(e) => log::error(&format!("uninstall spawn failed: {} ({e})", argv.join(" "))), + } + } + 0 +} + +/// Elevated run (under pkexec, as root): the privileged group ONLY, best-effort +/// (log non-zero, keep going). The unelevated instance runs the `user_owned` +/// half; same builder + same args on both sides → an identical split. +fn run_elevated(a: &UninstallArgs) -> i32 { + log::info(&format!( + "linux-app-uninstall-elevated: scope={:?} machine_wide={} keep={}", + a.svc_scope, a.machine_wide, a.keep + )); + let plan = plan_for(a); + for argv in plan.privileged { + let (cmd, rest) = argv.split_first().expect("non-empty argv"); + match std::process::Command::new(cmd).args(rest).status() { + Ok(s) if s.success() => log::info(&format!("uninstall (root) ok: {}", argv.join(" "))), + Ok(s) => log::error(&format!("uninstall (root) non-zero ({:?}): {}", s.code(), argv.join(" "))), + Err(e) => log::error(&format!("uninstall (root) spawn failed: {} ({e})", argv.join(" "))), + } + } + 0 +} + +/// How `run_unelevated` runs the privileged teardown group — the +/// `getuid()==0 ? direct : pkexec` decision, made pure so its three outcomes are +/// unit-testable even though the run fns themselves shell out. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PrivMode { + /// Empty privileged group (purely-local install): no elevation at all. + Skip, + /// Already root (the root system-service launched the helper): run the group + /// directly, no pkexec. + Direct, + /// Non-root (local / user-scope server): re-invoke self under ONE pkexec. + Pkexec, +} + +/// Decide how to run the privileged group: `Skip` when it's empty (no root-owned +/// footprint), else `Direct` when already root (pkexec would prompt redundantly / +/// wrongly when the root system-service launched us), else `Pkexec`. Pure — the +/// caller passes `is_root` from `getuid().is_root()` — so all three branches are +/// unit-testable. +fn privileged_mode(is_root: bool, privileged_empty: bool) -> PrivMode { + if privileged_empty { + PrivMode::Skip + } else if is_root { + PrivMode::Direct + } else { + PrivMode::Pkexec + } +} + +/// Build the teardown plan from parsed args, resolving `bindir` + XDG from the +/// live environment. BOTH entries call this with the SAME `a`, so the +/// privileged / user_owned split is identical on the two sides — each then runs +/// only its own half. (XDG only feeds `user_owned`; the elevated side, which +/// runs only `privileged`, is unaffected by whatever value root's env carries.) +fn plan_for(a: &UninstallArgs) -> UninstallPlan { + let bindir = tool_dir("systemctl"); + let xdg = std::env::var("XDG_RUNTIME_DIR").ok(); + app_uninstall_commands( + a.svc_scope, + a.machine_wide, + a.keep, + &bindir, + &a.data_root, + xdg.as_deref(), + ) +} + +/// pkexec exit codes meaning auth was NOT granted: 126 = the user dismissed / +/// cancelled the auth dialog, 127 = authorization could not be obtained. Either +/// is treated as a decline → abort the uninstall and relaunch local. +fn declined(status: std::process::ExitStatus) -> bool { + matches!(status.code(), Some(126 | 127)) +} + +/// Relaunch the currently-running AppImage in its OWN transient unit so it +/// survives this helper's exit — the same `systemd-run --user --collect ` +/// seam as `linux_service::run`. Best-effort: log ok/err, never fail over it. +/// Skipped when `path` is empty (no `--relaunch` was supplied). +fn relaunch(path: &str) { + if path.is_empty() { + log::info("uninstall: no --relaunch target supplied; skipping local relaunch"); + return; + } + let systemd_run = format!("{}/systemd-run", tool_dir("systemd-run")); + match std::process::Command::new(&systemd_run) + .args(["--user", "--collect", path]) + .status() + { + Ok(s) => log::info(&format!( + "uninstall: relaunched local {path} via systemd-run (exit {:?})", + s.code() + )), + Err(e) => log::error(&format!("uninstall: relaunch via systemd-run failed: {e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Space-join each argv-vector for readable, order-preserving assertions. + fn joined(cmds: &[Vec]) -> Vec { + cmds.iter().map(|c| c.join(" ")).collect() + } + + const DR_LOCAL: &str = "/home/u/.local/share/WsScrcpyWeb"; + + #[test] + fn local_wipe() { + // No service, no /opt, wipe the whole data root. + let plan = + app_uninstall_commands(None, false, false, "/usr/bin", DR_LOCAL, Some("/run/user/1000")); + // Exact ordered user_owned: pattern-kill -> adb-kill -> autostart -> lock + // -> data-root wipe. (autostart is HOME-relative: matched by prefix+suffix.) + let u = joined(&plan.user_owned); + assert_eq!(u.len(), 5); + assert_eq!( + u[0], + "/usr/bin/pkill -KILL -f WsScrcpyWeb|ws-scrcpy-web-tray|ws-scrcpy-web-launcher|scrcpy-server" + ); + assert_eq!(u[1], "/usr/bin/pkill -KILL -x adb"); + assert!(u[2].starts_with("/usr/bin/rm -f ") + && u[2].ends_with("/.config/autostart/ws-scrcpy-web-tray.desktop")); + assert_eq!(u[3], "/usr/bin/rm -f /run/user/1000/ws-scrcpy-web.lock"); + assert_eq!(u[4], "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb"); + // privileged is empty -> no elevation. + assert!(plan.privileged.is_empty()); + // no systemctl anywhere (no service installed). + assert!(!u.iter().any(|c| c.contains("systemctl"))); + } + + #[test] + fn local_keep() { + // keep=true deletes only deps/bin/control; preserves root, config.json, logs/. + let plan = + app_uninstall_commands(None, false, true, "/usr/bin", DR_LOCAL, Some("/run/user/1000")); + let u = joined(&plan.user_owned); + assert!(u + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb/dependencies")); + assert!(u + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb/bin")); + assert!(u + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb/control")); + // NOT a bare wipe of the data root itself. + assert!(!u + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb")); + // preserved paths are never referenced. + assert!(!u.iter().any(|c| c.contains("config.json"))); + assert!(!u.iter().any(|c| c.contains("/logs"))); + assert!(plan.privileged.is_empty()); + } + + #[test] + fn user_service_cascade() { + // user-scope service -> cascade lands in user_owned; nothing privileged. + let plan = app_uninstall_commands( + Some(Scope::User), + false, + false, + "/usr/bin", + DR_LOCAL, + Some("/run/user/1000"), + ); + let u = joined(&plan.user_owned); + assert!(u.iter().any(|c| c.as_str() == "/usr/bin/systemctl --user stop WsScrcpyWeb.service")); + assert!(u + .iter() + .any(|c| c.as_str() == "/usr/bin/systemctl --user disable WsScrcpyWeb.service")); + assert!(u + .iter() + .any(|c| c.as_str() == "/usr/bin/systemctl --user reset-failed WsScrcpyWeb.service")); + assert!(u.iter().any(|c| c.as_str() == "/usr/bin/systemctl --user daemon-reload")); + // user unit file removed (HOME-relative; assert on the stable suffix). + assert!(u.iter().any(|c| c.starts_with("/usr/bin/rm -f ") + && c.ends_with("/.config/systemd/user/WsScrcpyWeb.service"))); + assert!(plan.privileged.is_empty()); + } + + #[test] + fn system_install() { + // system service + machine-wide, WIPE (keep=false): /opt fully removed AND + // /var/opt fully removed (the data root here IS /var/opt). All root-owned. + let plan = app_uninstall_commands( + Some(Scope::System), + true, + false, + "/usr/bin", + "/var/opt/ws-scrcpy-web", + None, + ); + let p = joined(&plan.privileged); + // system service cascade (system prefix = empty, so NO --user). + assert!(p.iter().any(|c| c.as_str() == "/usr/bin/systemctl stop WsScrcpyWeb.service")); + assert!(!p.iter().any(|c| c.contains("--user"))); + // /opt removed; /var/opt fully removed (bare rm -rf) because keep=false. + assert!(p.iter().any(|c| c.as_str() == "/usr/bin/rm -rf /opt/ws-scrcpy-web")); + assert!(p.iter().any(|c| c.as_str() == "/usr/bin/rm -rf /var/opt/ws-scrcpy-web")); + // system menu entry, menu-cache refresh, icon. + assert!(p + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -f /usr/share/applications/ws-scrcpy-web.desktop")); + assert!(p + .iter() + .any(|c| c.as_str() == "/usr/bin/update-desktop-database /usr/share/applications")); + assert!(p.iter().any( + |c| c.as_str() == "/usr/bin/rm -f /usr/share/icons/hicolor/256x256/apps/ws-scrcpy-web.png" + )); + // SELinux fcontext: both current specs + the legacy /opt/.../data rule, via sbin. + assert!(p + .iter() + .any(|c| c.as_str() == "/usr/sbin/semanage fcontext -d /opt/ws-scrcpy-web(/.*)?")); + assert!(p + .iter() + .any(|c| c.as_str() == "/usr/sbin/semanage fcontext -d /var/opt/ws-scrcpy-web(/.*)?")); + assert!(p + .iter() + .any(|c| c.as_str() == "/usr/sbin/semanage fcontext -d /opt/ws-scrcpy-web/data(/.*)?")); + } + + #[test] + fn machine_wide_no_service() { + // /opt install but NO service -> privileged runs (no systemctl); data root still wiped. + let plan = + app_uninstall_commands(None, true, false, "/usr/bin", DR_LOCAL, Some("/run/user/1000")); + let p = joined(&plan.privileged); + assert!(!plan.privileged.is_empty()); + assert!(p.iter().any(|c| c.as_str() == "/usr/bin/rm -rf /opt/ws-scrcpy-web")); + assert!(p + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -f /usr/share/applications/ws-scrcpy-web.desktop")); + assert!(p + .iter() + .any(|c| c.as_str() == "/usr/bin/update-desktop-database /usr/share/applications")); + assert!(p.iter().any(|c| c.contains("ws-scrcpy-web.png"))); + // no service installed -> no systemctl, and no /var/opt DATA-ROOT removal + // (the fcontext -d /var/opt rule still stands as SELinux cleanup; only the + // `rm` of the /var/opt state dir is absent — there is no system service). + assert!(!p.iter().any(|c| c.contains("systemctl"))); + assert!(!p.iter().any(|c| c.contains("rm -rf /var/opt"))); + // user_owned still wipes the (user) data root. + assert!(joined(&plan.user_owned) + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb")); + } + + #[test] + fn lock_skipped_when_no_runtime_dir() { + // xdg_runtime_dir = None -> no lock-removal command is emitted. + let plan = app_uninstall_commands(None, false, false, "/usr/bin", DR_LOCAL, None); + let u = joined(&plan.user_owned); + assert!(!u.iter().any(|c| c.contains("ws-scrcpy-web.lock"))); + // but the kill is still first and the data root is still wiped. + assert!(u[0].starts_with("/usr/bin/pkill -KILL -f ")); + assert!(u.iter().any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb")); + } + + #[test] + fn user_service_keep() { + // user-scope service + KEEP: cascade in user_owned; data root selectively + // cleaned (deps/bin/control) with config.json + logs preserved; none privileged. + let plan = app_uninstall_commands( + Some(Scope::User), + false, + true, + "/usr/bin", + DR_LOCAL, + Some("/run/user/1000"), + ); + let u = joined(&plan.user_owned); + assert!(u.iter().any(|c| c.as_str() == "/usr/bin/systemctl --user stop WsScrcpyWeb.service")); + assert!(u + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb/dependencies")); + assert!(u + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb/control")); + // never a bare wipe; never the preserved paths. + assert!(!u + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb")); + assert!(!u.iter().any(|c| c.contains("config.json"))); + assert!(!u.iter().any(|c| c.contains("/logs"))); + assert!(plan.privileged.is_empty()); + } + + #[test] + fn system_keep_preserves_var_opt_config_logs() { + // system service + KEEP: /opt removed fully, but /var/opt gets the SELECTIVE + // subdir rm so /var/opt/config.json + /var/opt/logs survive. + let plan = app_uninstall_commands( + Some(Scope::System), + true, + true, + "/usr/bin", + "/var/opt/ws-scrcpy-web", + None, + ); + let p = joined(&plan.privileged); + assert!(p.iter().any(|c| c.as_str() == "/usr/bin/rm -rf /opt/ws-scrcpy-web")); + assert!(p + .iter() + .any(|c| c.as_str() == "/usr/bin/rm -rf /var/opt/ws-scrcpy-web/dependencies")); + assert!(p.iter().any(|c| c.as_str() == "/usr/bin/rm -rf /var/opt/ws-scrcpy-web/bin")); + assert!(p.iter().any(|c| c.as_str() == "/usr/bin/rm -rf /var/opt/ws-scrcpy-web/control")); + // NO bare wipe of /var/opt (would delete the preserved config.json + logs). + assert!(!p.iter().any(|c| c.as_str() == "/usr/bin/rm -rf /var/opt/ws-scrcpy-web")); + assert!(!p.iter().any(|c| c.contains("config.json"))); + assert!(!p.iter().any(|c| c.contains("/logs"))); + // data root handled ONCE, in privileged — user_owned must not touch /var/opt. + assert!(!joined(&plan.user_owned).iter().any(|c| c.contains("/var/opt"))); + } + + // ── Task 2: pure arg-parsing. The run fns shell out (and aren't even compiled + // on the Windows dev host), so `parse_args` is the only unit-testable part. ── + + #[test] + fn parse_args_round_trips_full_valid() { + let args: Vec = [ + "--linux-app-uninstall", + "--scope", + "system", + "--machine-wide", + "1", + "--wipe", + "--data-root", + "/var/opt/ws-scrcpy-web", + "--relaunch", + "/home/u/Apps/App.AppImage", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!( + parse_args(&args), + Some(UninstallArgs { + svc_scope: Some(Scope::System), + machine_wide: true, + keep: false, + data_root: "/var/opt/ws-scrcpy-web".to_string(), + relaunch: "/home/u/Apps/App.AppImage".to_string(), + }) + ); + } + + #[test] + fn parse_args_scope_none_and_user() { + // A full, otherwise-valid vector with only --scope varying. + let with_scope = |scope: &str| -> Vec { + [ + "--linux-app-uninstall", + "--scope", + scope, + "--machine-wide", + "0", + "--keep", + "--data-root", + "/home/u/.local/share/WsScrcpyWeb", + "--relaunch", + "/home/u/Apps/App.AppImage", + ] + .iter() + .map(|s| s.to_string()) + .collect() + }; + // --scope none is VALID and maps to svc_scope: None (no service installed). + assert_eq!(parse_args(&with_scope("none")).unwrap().svc_scope, None); + assert_eq!( + parse_args(&with_scope("user")).unwrap().svc_scope, + Some(Scope::User) + ); + } + + #[test] + fn parse_args_requires_exactly_one_of_keep_wipe() { + // Base vector WITHOUT --keep / --wipe; the test appends the combination. + let with_flags = |flags: &[&str]| -> Vec { + let mut v: Vec = [ + "--linux-app-uninstall", + "--scope", + "user", + "--machine-wide", + "0", + "--data-root", + "/home/u/.local/share/WsScrcpyWeb", + "--relaunch", + "/home/u/Apps/App.AppImage", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + v.extend(flags.iter().map(|s| s.to_string())); + v + }; + // both present → parse error; neither present → parse error. + assert_eq!(parse_args(&with_flags(&["--keep", "--wipe"])), None); + assert_eq!(parse_args(&with_flags(&[])), None); + // exactly one → ok, with the expected keep bool (sanity). + assert!(parse_args(&with_flags(&["--keep"])).unwrap().keep); + assert!(!parse_args(&with_flags(&["--wipe"])).unwrap().keep); + } + + #[test] + fn parse_args_rejects_invalid_scope() { + let args: Vec = [ + "--linux-app-uninstall", + "--scope", + "bogus", + "--machine-wide", + "1", + "--wipe", + "--data-root", + "/var/opt/ws-scrcpy-web", + "--relaunch", + "/home/u/Apps/App.AppImage", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(parse_args(&args), None); + } + + #[test] + fn privileged_mode_skip_direct_pkexec() { + // Empty privileged group → no elevation at all, whatever the uid. + assert_eq!(privileged_mode(false, true), PrivMode::Skip); + assert_eq!(privileged_mode(true, true), PrivMode::Skip); + // Non-empty + already root → run directly (no pkexec). + assert_eq!(privileged_mode(true, false), PrivMode::Direct); + // Non-empty + non-root → pkexec re-invoke. + assert_eq!(privileged_mode(false, false), PrivMode::Pkexec); + } +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs index b3c6958..9b23e38 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -23,6 +23,8 @@ mod operation_server; mod linux_apply; #[cfg(target_os = "linux")] mod linux_service; +#[cfg(target_os = "linux")] +mod linux_app_uninstall; #[cfg(windows)] mod user_session_spawn; @@ -111,6 +113,26 @@ fn main() { std::process::exit(code); } + // In-app "complete uninstall" (beta.49). MUST come before service-defer and + // the /opt bootstrapper below: an uninstall invocation carries its own flags + // and must not be diverted into a service-defer browser-open or an /opt + // re-exec that would drop those flags. The UNELEVATED entry (spawned by Node + // via `systemd-run --user --collect`) runs the user-owned teardown group and + // re-invokes the launcher under ONE pkexec for the privileged group. + #[cfg(target_os = "linux")] + if let Some(code) = linux_app_uninstall::handle(&args) { + log::info(&format!("linux-app-uninstall exiting with code {code}")); + std::process::exit(code); + } + + // The ELEVATED entry the above pkexec re-invoke lands on (as root): runs ONLY + // the privileged (root-owned) teardown group, then exits. + #[cfg(target_os = "linux")] + if let Some(code) = linux_app_uninstall::handle_elevated(&args) { + log::info(&format!("linux-app-uninstall-elevated exiting with code {code}")); + std::process::exit(code); + } + // Service-defer: if an ACTIVE system-scope service owns the app, open the // browser at its URL and exit instead of spawning a duplicate local server. #[cfg(target_os = "linux")] diff --git a/src/app/client/SettingsModal.ts b/src/app/client/SettingsModal.ts index b087295..6568a45 100644 --- a/src/app/client/SettingsModal.ts +++ b/src/app/client/SettingsModal.ts @@ -154,6 +154,40 @@ export function stopServerButtonState(resp: ScopeRadioInputs): { : { disabled: false, note: null }; } +/** + * Derive visibility/enabled state for the two Linux-only App-section rows — + * "install for all users" and "uninstall ws-scrcpy-web". Pure (no DOM) so it is + * unit-testable; mirrors stopServerButtonState's shape and is driven from + * renderServiceState once /api/service/status resolves. + * + * - Both rows are Linux-only (hidden on win32/other). + * - "install for all users" is disabled once the shared /opt machine-wide + * install already exists (the root service execs that binary; re-installing it + * is a no-op), with an explanatory note in that state. + * - "uninstall" is ALWAYS enabled on Linux (unlike "stop server & exit" it is + * NOT gated on service mode — uninstalling is exactly how you tear a service + * down). Fields admit `undefined` so the full ServiceStatusResponse is + * assignable under exactOptionalPropertyTypes. + */ +export function appSectionButtonsState(resp: { + platform?: string | null | undefined; + machineWideInstalled?: boolean | undefined; +}): { + showInstallAllUsers: boolean; + installAllUsersDisabled: boolean; + installAllUsersNote: string | null; + showUninstall: boolean; +} { + const linux = resp.platform === 'linux'; + const machineWide = resp.machineWideInstalled === true; + return { + showInstallAllUsers: linux, + installAllUsersDisabled: linux && machineWide, + installAllUsersNote: linux && machineWide ? 'already installed for all users (/opt)' : null, + showUninstall: linux, + }; +} + export interface SystemServiceInstallGate { enabled: boolean; note: string | null; } /** * Derive whether the migration reinstall notice should be shown. Pure (no DOM) @@ -227,6 +261,153 @@ export function buildServiceInfoRow(message: string): HTMLElement { return p; } +/** + * Build the Linux-only "install for all users" control: an "install" button + * plus its full-width status note. Clicking POSTs /api/service/install-system-wide + * (the server runs pkexec, relocates to /opt, and re-execs — the OS pkexec prompt + * IS the confirmation, so there is no extra modal); on success the server is + * about to re-exec, so the page reloads; on failure the note shows an inline + * error. `reload` is injected so the unit test can observe it without navigating. + * Self-contained DOM + wiring (no network until clicked) so it is unit-testable + * like buildServiceInfoRow. Show/hide + the machine-wide disabled+note state are + * applied separately via appSectionButtonsState (from renderServiceState). + */ +export function buildInstallAllUsersControl(opts: { reload: () => void }): { + button: HTMLButtonElement; + note: HTMLElement; +} { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'settings-btn settings-btn-primary'; + button.textContent = 'install'; + + const note = document.createElement('p'); + note.className = 'settings-status'; + note.style.gridColumn = '1 / -1'; + note.hidden = true; + + button.addEventListener('click', () => { + button.disabled = true; + button.textContent = 'installing…'; + note.hidden = true; + void (async () => { + try { + const res = await fetch('/api/service/install-system-wide', { method: 'POST' }); + if (res.ok) { + // The server is re-execing from /opt — reload onto the new instance. + opts.reload(); + return; + } + note.textContent = 'install failed — see the server logs and try again.'; + } catch { + note.textContent = 'install failed — could not reach the server.'; + } + note.hidden = false; + button.disabled = false; + button.textContent = 'install'; + })(); + }); + + return { button, note }; +} + +/** + * Build the Linux-only "uninstall ws-scrcpy-web" control: a danger trigger + * button plus an inline confirm panel (the same settings-confirm-panel pattern + * the reset-prompts row uses). The trigger toggles the panel; the panel holds a + * warning line, a "keep my settings & logs" checkbox (unchecked by default), and + * confirm/cancel buttons. Confirm POSTs /api/service/uninstall-app with + * `{ keep: }`; on success it invokes `onUninstalled` (the terminal + * "uninstalled — close this tab" message, since the server tears itself down); + * on failure it surfaces an inline error inside the panel. Self-contained DOM + + * wiring (no network until confirm is clicked) so it is unit-testable. + */ +export function buildUninstallControl(opts: { onUninstalled: () => void }): { + button: HTMLButtonElement; + confirmPanel: HTMLElement; + keepCheckbox: HTMLInputElement; + confirmButton: HTMLButtonElement; + cancelButton: HTMLButtonElement; +} { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'settings-btn settings-btn-danger'; + button.textContent = 'uninstall…'; + + const confirmPanel = document.createElement('div'); + confirmPanel.className = 'settings-confirm-panel'; + + const warning = document.createElement('p'); + warning.textContent = 'this completely removes ws-scrcpy-web, including any installed service.'; + confirmPanel.appendChild(warning); + + const keepLabel = document.createElement('label'); + keepLabel.className = 'settings-radio-label'; + const keepCheckbox = document.createElement('input'); + keepCheckbox.type = 'checkbox'; + keepLabel.appendChild(keepCheckbox); + keepLabel.appendChild(document.createTextNode('keep my settings & logs')); + confirmPanel.appendChild(keepLabel); + + const errorNote = document.createElement('p'); + errorNote.className = 'settings-status settings-status-error'; + errorNote.hidden = true; + confirmPanel.appendChild(errorNote); + + const confirmButtons = document.createElement('div'); + confirmButtons.className = 'settings-confirm-buttons'; + + const cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'settings-btn'; + cancelButton.textContent = 'cancel'; + cancelButton.addEventListener('click', () => { + confirmPanel.classList.remove('expanded'); + }); + confirmButtons.appendChild(cancelButton); + + const confirmButton = document.createElement('button'); + confirmButton.type = 'button'; + confirmButton.className = 'settings-btn settings-btn-primary'; + confirmButton.textContent = 'uninstall'; + confirmButton.addEventListener('click', () => { + const keep = keepCheckbox.checked; + confirmButton.disabled = true; + cancelButton.disabled = true; + confirmButton.textContent = 'uninstalling…'; + errorNote.hidden = true; + void (async () => { + try { + const res = await fetch('/api/service/uninstall-app', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keep }), + }); + if (res.ok) { + opts.onUninstalled(); + return; + } + errorNote.textContent = 'uninstall failed — see the server logs and try again.'; + } catch { + errorNote.textContent = 'uninstall failed — could not reach the server.'; + } + errorNote.hidden = false; + confirmButton.disabled = false; + cancelButton.disabled = false; + confirmButton.textContent = 'uninstall'; + })(); + }); + confirmButtons.appendChild(confirmButton); + + confirmPanel.appendChild(confirmButtons); + + button.addEventListener('click', () => { + confirmPanel.classList.toggle('expanded'); + }); + + return { button, confirmPanel, keepCheckbox, confirmButton, cancelButton }; +} + /** * Settings modal — unified two-column grid layout. * @@ -260,6 +441,14 @@ export class SettingsModal extends Modal { // ── App section state ───────────────────────────────────────────────── private stopServerButton: HTMLButtonElement | null = null; private stopServerNote: HTMLElement | null = null; + // Linux-only rows (hidden on win32). Shown/disabled via appSectionButtonsState + // from renderServiceState once /api/service/status resolves. + private installAllUsersRow: HTMLElement | null = null; + private installAllUsersButton: HTMLButtonElement | null = null; + private installAllUsersNote: HTMLElement | null = null; + private uninstallRow: HTMLElement | null = null; + private uninstallButton: HTMLButtonElement | null = null; + private uninstallConfirmPanel: HTMLElement | null = null; // ── Updates section state ───────────────────────────────────────────── private updatesBody!: HTMLElement; @@ -1029,6 +1218,8 @@ export class SettingsModal extends Modal { this.servicePlatform = (resp.platform as 'win32' | 'linux') ?? null; // Gate the App-section "stop server & exit" button off in service mode. this.applyStopServerButtonState(resp); + // Reveal/disable the Linux-only "install for all users" + "uninstall" rows. + this.applyAppSectionButtonsState(resp); if (!resp.supported) { const notice = document.createElement('p'); @@ -1478,6 +1669,35 @@ export class SettingsModal extends Modal { confirmPanel.classList.toggle('expanded'); }); + // §beta.49 — two Linux-only rows. Built hidden (display:none overrides the + // .settings-row { display: contents } rule via inline style) and revealed + // only on Linux by applyAppSectionButtonsState once /api/service/status + // resolves, so they never flash on Windows. + + // "install for all users": POST /api/service/install-system-wide (pkexec + // → relocate to /opt → re-exec). The OS pkexec dialog is the confirmation, + // so no extra modal — just reload onto the re-execed instance on success. + const install = buildInstallAllUsersControl({ reload: () => window.location.reload() }); + this.installAllUsersButton = install.button; + this.installAllUsersNote = install.note; + const installRow = this.buildRow('install ws-scrcpy-web for all users', install.button); + installRow.style.display = 'none'; + this.installAllUsersRow = installRow; + body.appendChild(installRow); + body.appendChild(install.note); + + // "uninstall ws-scrcpy-web": always enabled on Linux (NOT gated on service + // mode — uninstalling is how you remove a service). Reuses the confirm-panel + // pattern; confirm POSTs /api/service/uninstall-app { keep }. + const uninstall = buildUninstallControl({ onUninstalled: () => this.showUninstalledOverlay() }); + this.uninstallButton = uninstall.button; + this.uninstallConfirmPanel = uninstall.confirmPanel; + const uninstallRow = this.buildRow('uninstall ws-scrcpy-web', uninstall.button); + uninstallRow.style.display = 'none'; + this.uninstallRow = uninstallRow; + body.appendChild(uninstallRow); + body.appendChild(uninstall.confirmPanel); + return section; } @@ -1497,6 +1717,41 @@ export class SettingsModal extends Modal { } } + /** + * Reflect the (unit-tested) appSectionButtonsState decision onto the two + * Linux-only App-section rows. Called from renderServiceState once + * /api/service/status resolves: reveals the rows on Linux (inline display + * overrides the .settings-row { display: contents } rule), disables the + * "install for all users" button with an explanatory note once the machine-wide + * /opt install exists, and keeps the uninstall row always enabled on Linux. + */ + private applyAppSectionButtonsState(resp: ServiceStatusResponse): void { + const state = appSectionButtonsState(resp); + if (this.installAllUsersRow) { + this.installAllUsersRow.style.display = state.showInstallAllUsers ? '' : 'none'; + } + if (this.installAllUsersButton) { + this.installAllUsersButton.disabled = state.installAllUsersDisabled; + } + if (this.installAllUsersNote) { + this.installAllUsersNote.textContent = state.installAllUsersNote ?? ''; + this.installAllUsersNote.hidden = state.installAllUsersNote === null; + } + if (this.uninstallRow) { + this.uninstallRow.style.display = state.showUninstall ? '' : 'none'; + } + if (this.uninstallButton) { + // Uninstall is ALWAYS enabled on Linux — never gated on service mode + // (unlike "stop server & exit"); uninstalling is how you tear a service + // down. Asserting it here documents and enforces that invariant. + this.uninstallButton.disabled = false; + } + // Collapse a left-open confirm panel if the row is being hidden (non-Linux). + if (!state.showUninstall && this.uninstallConfirmPanel) { + this.uninstallConfirmPanel.classList.remove('expanded'); + } + } + /** * Confirm, then POST /api/server/shutdown (graceful teardown + exit 0), * then try to self-close the tab. Falls back to a full-page "app stopped" @@ -1536,4 +1791,16 @@ export class SettingsModal extends Modal { overlay.appendChild(msg); document.body.replaceChildren(overlay); } + + /** Blank the page with a terminal "uninstalled" notice after uninstall succeeds. */ + private showUninstalledOverlay(): void { + const overlay = document.createElement('div'); + overlay.style.cssText = + 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' + + 'padding:1rem;text-align:center;opacity:0.85;'; + const msg = document.createElement('p'); + msg.textContent = 'ws-scrcpy-web uninstalled — you can close this tab.'; + overlay.appendChild(msg); + document.body.replaceChildren(overlay); + } } diff --git a/src/app/client/__tests__/SettingsModal.test.ts b/src/app/client/__tests__/SettingsModal.test.ts index 7da19b8..c9a1bbd 100644 --- a/src/app/client/__tests__/SettingsModal.test.ts +++ b/src/app/client/__tests__/SettingsModal.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { uninstallFollowupMessage, classifyInstallPoll, @@ -12,8 +12,19 @@ import { systemServiceInstallGate, applySystemInstallGate, migrationNotice, + appSectionButtonsState, + buildInstallAllUsersControl, + buildUninstallControl, } from '../SettingsModal'; +/** Flush microtasks + a macrotask so the awaited fetch handlers settle. */ +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + describe('uninstallFollowupMessage', () => { it('user scope -> reconnect/relaunch message', () => { expect(uninstallFollowupMessage('user')).toMatch(/relaunch|reconnect|local/i); @@ -220,3 +231,115 @@ describe('migrationNotice', () => { expect(migrationNotice({})).toEqual({ show: false, text: '' }); }); }); + +describe('appSectionButtonsState', () => { + it('linux, not machine-wide -> both rows shown, install enabled with no note', () => { + expect(appSectionButtonsState({ platform: 'linux', machineWideInstalled: false })).toEqual({ + showInstallAllUsers: true, + installAllUsersDisabled: false, + installAllUsersNote: null, + showUninstall: true, + }); + }); + + it('linux, machine-wide -> install disabled with an "already installed" note, uninstall still shown', () => { + const s = appSectionButtonsState({ platform: 'linux', machineWideInstalled: true }); + expect(s.showInstallAllUsers).toBe(true); + expect(s.installAllUsersDisabled).toBe(true); + expect(s.installAllUsersNote).toMatch(/already installed for all users/i); + expect(s.showUninstall).toBe(true); + }); + + it('non-linux (win32) -> both rows hidden, nothing disabled, no note', () => { + expect(appSectionButtonsState({ platform: 'win32', machineWideInstalled: false })).toEqual({ + showInstallAllUsers: false, + installAllUsersDisabled: false, + installAllUsersNote: null, + showUninstall: false, + }); + }); +}); + +describe('buildInstallAllUsersControl', () => { + it('clicking install POSTs /api/service/install-system-wide and reloads on ok', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + const reload = vi.fn(); + + const { button } = buildInstallAllUsersControl({ reload }); + button.click(); + + // fetch is invoked synchronously, before the first await in the handler. + expect(fetchMock).toHaveBeenCalledWith('/api/service/install-system-wide', { method: 'POST' }); + + await flush(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('shows an inline error note and does NOT reload when the server rejects', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal('fetch', fetchMock); + const reload = vi.fn(); + + const { button, note } = buildInstallAllUsersControl({ reload }); + button.click(); + await flush(); + + expect(reload).not.toHaveBeenCalled(); + expect(note.hidden).toBe(false); + expect(note.textContent).toMatch(/install/i); + }); +}); + +describe('buildUninstallControl', () => { + it('clicking the trigger expands the inline confirm panel', () => { + const { button, confirmPanel } = buildUninstallControl({ onUninstalled: vi.fn() }); + expect(confirmPanel.classList.contains('settings-confirm-panel')).toBe(true); + expect(confirmPanel.classList.contains('expanded')).toBe(false); + button.click(); + expect(confirmPanel.classList.contains('expanded')).toBe(true); + }); + + it('confirm with "keep" checked POSTs /api/service/uninstall-app with {keep:true}', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + + const { button, keepCheckbox, confirmButton } = buildUninstallControl({ onUninstalled: vi.fn() }); + button.click(); + keepCheckbox.checked = true; + confirmButton.click(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]! as [string, RequestInit]; + expect(url).toBe('/api/service/uninstall-app'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ keep: true }); + await flush(); + }); + + it('confirm with "keep" unchecked POSTs {keep:false}', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + + const { button, confirmButton } = buildUninstallControl({ onUninstalled: vi.fn() }); + button.click(); + confirmButton.click(); + + const [, init] = fetchMock.mock.calls[0]! as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ keep: false }); + await flush(); + }); + + it('invokes onUninstalled (the terminal message) after a successful uninstall', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + const onUninstalled = vi.fn(); + + const { button, confirmButton } = buildUninstallControl({ onUninstalled }); + button.click(); + confirmButton.click(); + await flush(); + + expect(onUninstalled).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/common/ServiceEvents.ts b/src/common/ServiceEvents.ts index b10ef73..fcdcd34 100644 --- a/src/common/ServiceEvents.ts +++ b/src/common/ServiceEvents.ts @@ -159,6 +159,11 @@ export interface ServiceInstallRequest { scope?: 'user' | 'system'; } +/** Request body for POST /api/service/uninstall-app. `keep` preserves config.json + logs/. */ +export interface AppUninstallRequest { + keep: boolean; +} + /** Canonical Windows service name registered with Servy / SCM. */ export const WS_SCRCPY_SERVICE_NAME = 'WsScrcpyWeb'; export const WS_SCRCPY_SERVICE_DISPLAY_NAME = 'ws-scrcpy-web'; diff --git a/src/server/__tests__/ServiceApi.test.ts b/src/server/__tests__/ServiceApi.test.ts index c7d5474..e736d84 100644 --- a/src/server/__tests__/ServiceApi.test.ts +++ b/src/server/__tests__/ServiceApi.test.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ServiceApi, systemServiceNeedsMigration } from '../api/ServiceApi'; +import { ServiceApi, buildUninstallHelperArgs, systemServiceNeedsMigration } from '../api/ServiceApi'; import { Config } from '../Config'; import { EnvName } from '../EnvName'; import { @@ -1561,6 +1561,161 @@ describe('ServiceApi', () => { }); }); + // ── app-uninstall (POST /api/service/uninstall-app) — beta.49 ───────────── + // + // Linux-only endpoint that spawns the detached Rust uninstall helper via + // systemd-run. The arg vector is built by the pure buildUninstallHelperArgs + // (unit-tested below without any process/systemd mocking); the handler wires + // it to the live spawn + schedules the local-instance exit. On keep=true the + // handler also resets installMode to null so the preserved config.json comes + // back up in local mode (not phantom service mode) on next launch. + + describe('buildUninstallHelperArgs (pure)', () => { + const base = { + unit: '--unit=wsscrcpy-uninstall-123', + helper: '/home/jamie/.local/share/WsScrcpyWeb/control/operation-server/ws-scrcpy-web-launcher.exe', + dataRoot: '/home/jamie/.local/share/WsScrcpyWeb', + relaunch: '/home/jamie/Applications/WsScrcpyWeb.AppImage', + }; + + it('non-root: --user --collect prefix, --wipe when keep=false, machine-wide 0, scope none', () => { + const args = buildUninstallHelperArgs({ + isRoot: false, scope: 'none', machineWide: false, keep: false, ...base, + }); + // Non-root → user manager: leading --user --collect, then unit, helper, mode flag. + expect(args.slice(0, 5)).toEqual([ + '--user', '--collect', base.unit, base.helper, '--linux-app-uninstall', + ]); + expect(args).toContain('--wipe'); + expect(args).not.toContain('--keep'); + // Each value-flag is immediately followed by its value. + expect(args[args.indexOf('--machine-wide') + 1]).toBe('0'); + expect(args[args.indexOf('--scope') + 1]).toBe('none'); + expect(args[args.indexOf('--data-root') + 1]).toBe(base.dataRoot); + expect(args[args.indexOf('--relaunch') + 1]).toBe(base.relaunch); + }); + + it('non-root: --keep when keep=true, machine-wide 1, scope user', () => { + const args = buildUninstallHelperArgs({ + isRoot: false, scope: 'user', machineWide: true, keep: true, ...base, + }); + expect(args).toContain('--keep'); + expect(args).not.toContain('--wipe'); + expect(args[args.indexOf('--machine-wide') + 1]).toBe('1'); + expect(args[args.indexOf('--scope') + 1]).toBe('user'); + }); + + it('root: --collect prefix with NO --user, scope system', () => { + const args = buildUninstallHelperArgs({ + isRoot: true, scope: 'system', machineWide: true, keep: false, ...base, + }); + // Root → system manager: leading --collect (no --user). + expect(args.slice(0, 4)).toEqual([ + '--collect', base.unit, base.helper, '--linux-app-uninstall', + ]); + expect(args).not.toContain('--user'); + expect(args[args.indexOf('--scope') + 1]).toBe('system'); + }); + }); + + describe('app-uninstall handler (POST /api/service/uninstall-app)', () => { + it('POST /uninstall-app {keep:true} on linux → 200 uninstalling, spawns systemd-run helper with --keep + --scope user', async () => { + const client = fakeClient({ + getInstalledScope: vi.fn(async () => 'user' as const), + }); + const factoryResult: ServiceClientFactoryResult = { + client, + supported: true, + platform: 'linux', + }; + let spawnedCmd = ''; + let spawnedArgs: string[] = []; + const spawnMock = vi.fn((cmd: string, args: string[]) => { spawnedCmd = cmd; spawnedArgs = args; }); + // existsCheck → helper present; scheduleExit → no-op (don't sacrifice the worker). + const api = new ServiceApi(() => factoryResult, () => 'user', () => true, spawnMock, () => {}); + const { req, res } = makeReqRes('/api/service/uninstall-app', 'POST', JSON.stringify({ keep: true })); + await api.handle(req, res); + + expect((res as any).getStatus()).toBe(200); + const body = JSON.parse((res as any).getBody()); + expect(body).toEqual({ ok: true, status: 'uninstalling' }); + + expect(spawnMock).toHaveBeenCalledTimes(1); + // systemd-run resolved via resolveSystemTool — falls back to the bare name on a non-Linux host. + expect(spawnedCmd).toMatch(/systemd-run/); + expect(spawnedArgs).toContain('--linux-app-uninstall'); + expect(spawnedArgs).toContain('--keep'); + expect(spawnedArgs).toContain('--scope'); + expect(spawnedArgs).toContain('user'); + // Helper path is the operation-server staged launcher (same .exe suffix even on Linux). + const helperArg = spawnedArgs.find((a) => a.endsWith('ws-scrcpy-web-launcher.exe')); + expect(helperArg).toBeDefined(); + expect(helperArg).toContain('operation-server'); + expect(client.getInstalledScope).toHaveBeenCalledWith('WsScrcpyWeb'); + }); + + it('POST /uninstall-app {keep:true} resets installMode to null (preserved config returns in local mode)', async () => { + const client = fakeClient({ + getInstalledScope: vi.fn(async () => 'user' as const), + }); + const factoryResult: ServiceClientFactoryResult = { + client, + supported: true, + platform: 'linux', + }; + Config.getInstance().updateAppConfig({ installMode: 'user-service' }); + const spawnMock = vi.fn(); + const api = new ServiceApi(() => factoryResult, () => 'user', () => true, spawnMock, () => {}); + const { req, res } = makeReqRes('/api/service/uninstall-app', 'POST', JSON.stringify({ keep: true })); + await api.handle(req, res); + expect((res as any).getStatus()).toBe(200); + // keep=true must reset installMode to null so the preserved config.json + // boots in local mode, not a phantom service mode with no service. + expect(Config.getInstance().getAppConfig().installMode).toBeNull(); + }); + + it('POST /uninstall-app returns 500 when the helper is missing, does NOT spawn', async () => { + const client = fakeClient({ + getInstalledScope: vi.fn(async () => 'user' as const), + }); + const factoryResult: ServiceClientFactoryResult = { + client, + supported: true, + platform: 'linux', + }; + const spawnMock = vi.fn(); + // existsCheck → false: the staged helper is absent (dev/from-source run). + const api = new ServiceApi(() => factoryResult, () => 'user', () => false, spawnMock, () => {}); + const { req, res } = makeReqRes('/api/service/uninstall-app', 'POST', JSON.stringify({ keep: false })); + await api.handle(req, res); + + expect((res as any).getStatus()).toBe(500); + const body = JSON.parse((res as any).getBody()); + expect(body.ok).toBe(false); + expect(body.error).toMatch(/ws-scrcpy-web-launcher\.exe/); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('POST /uninstall-app on non-linux → 200 unsupported, does NOT spawn', async () => { + const client = fakeClient(); + const factoryResult: ServiceClientFactoryResult = { + client, + supported: true, + platform: 'win32', + }; + const spawnMock = vi.fn(); + const api = new ServiceApi(() => factoryResult, () => 'user', () => true, spawnMock, () => {}); + const { req, res } = makeReqRes('/api/service/uninstall-app', 'POST', JSON.stringify({ keep: true })); + await api.handle(req, res); + + expect((res as any).getStatus()).toBe(200); + const body = JSON.parse((res as any).getBody()); + expect(body.ok).toBe(false); + expect(body.reason).toBe('unsupported'); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + it('returns 404 for unrecognized /api/service/* paths', async () => { const factoryResult: ServiceClientFactoryResult = { client: fakeClient(), diff --git a/src/server/api/ServiceApi.ts b/src/server/api/ServiceApi.ts index bd6a2de..e4cf57f 100644 --- a/src/server/api/ServiceApi.ts +++ b/src/server/api/ServiceApi.ts @@ -9,6 +9,7 @@ import { WS_SCRCPY_SERVICE_DESCRIPTION, WS_SCRCPY_SERVICE_DISPLAY_NAME, WS_SCRCPY_SERVICE_NAME, + type AppUninstallRequest, type ServiceActionFailure, type ServiceActionSuccess, type ServiceInstallRequest, @@ -40,6 +41,43 @@ export function systemServiceNeedsMigration(input: { dataRootEnv?: string; oldDa return input.dataRootEnv === '/opt/ws-scrcpy-web/data' || input.oldDataDirExists; } +/** + * Build the `systemd-run` arg vector that spawns the detached Rust app-uninstall + * helper (`--linux-app-uninstall`). Pure so the spawn shape is unit-testable + * without mocking process/systemd. + * + * Transient-unit scope mirrors the service-teardown handoff: as root we target + * the SYSTEM manager (`--collect`, no `--user`); unprivileged we target the + * per-user manager (`--user --collect`). The helper then self-elevates as needed + * (root → direct; non-root → pkexec; declined → relaunch local), so the spawn + * itself stays unelevated regardless. + * + * The helper flags forwarded: + * --scope installed service scope to tear down (none = no service) + * --machine-wide <0|1> whether the shared /opt machine-wide AppImage exists + * --keep | --wipe preserve config.json + logs/ vs. remove all state + * --data-root writable-state root to wipe/preserve + * --relaunch home AppImage to re-launch in local mode ('' = none) + */ +export function buildUninstallHelperArgs(o: { + isRoot: boolean; + unit: string; + helper: string; + scope: 'user' | 'system' | 'none'; + machineWide: boolean; + keep: boolean; + dataRoot: string; + relaunch: string; +}): string[] { + // root → system transient unit (`--collect`); non-root → user manager (`--user --collect`). + const prefix = o.isRoot ? ['--collect'] : ['--user', '--collect']; + return [ + ...prefix, o.unit, o.helper, '--linux-app-uninstall', + '--scope', o.scope, '--machine-wide', o.machineWide ? '1' : '0', + o.keep ? '--keep' : '--wipe', '--data-root', o.dataRoot, '--relaunch', o.relaunch, + ]; +} + /** * F3: poll the service's is-active state until it reports `running`, up to ~15s * (the old blind-exit cap). Returns true as soon as it's up; false if it never @@ -126,6 +164,9 @@ export class ServiceApi { if (req.method === 'POST' && url === '/api/service/migrate-system') { return await this.handleMigrateSystem(res); } + if (req.method === 'POST' && url === '/api/service/uninstall-app') { + return await this.handleAppUninstall(req, res); + } res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); @@ -799,7 +840,17 @@ export class ServiceApi { return true; } const version = getAppVersion(); - const script = buildMachineWideInstallScript({ sourceAppImage: appImage, version }); + // Resolve the launcher icon from the mounted AppImage so the machine-wide + // install can drop it into the hicolor theme (the system .desktop entry + // references `Icon=ws-scrcpy-web`). vpk embeds the icon as the AppImage's + // `.DirIcon` (built from `--icon assets/tray-icon.png`); `$APPDIR` is the + // FUSE-mount root of the running AppImage. This in-AppImage path is a + // runtime assumption (vpk's AppDir layout) verified in the smoke; the + // script's cp is best-effort, so a miss never fails the install. Gated on + // `$APPDIR` being set — when absent the icon step is skipped entirely. + const appDir = process.env['APPDIR']; + const iconSource = appDir ? path.join(appDir, '.DirIcon') : undefined; + const script = buildMachineWideInstallScript({ sourceAppImage: appImage, version, iconSource }); try { await this.runPkexecFn(script, 'install-system-wide'); // F5: the running instance launched from the home AppImage (now @@ -925,6 +976,89 @@ export class ServiceApi { return true; } + /** + * POST /api/service/uninstall-app — Linux-only. Spawn the detached Rust + * uninstall helper (`--linux-app-uninstall`) and exit the local instance so + * the out-of-cgroup helper can tear the app down from underneath us. + * + * `keep` (request body) preserves config.json + logs/ (`--keep`) vs. wiping + * all state (`--wipe`). On keep we ALSO reset installMode to null up front so + * the preserved config.json boots in local mode next time rather than a + * phantom service mode with no backing service. + * + * Scope/machine-wide context is resolved here and forwarded so the helper + * tears down the right pieces: + * - scope: the installed systemd unit scope (user/system) or 'none'. + * - machineWide: whether the shared /opt machine-wide AppImage exists. + * The helper self-elevates (root → direct; non-root → pkexec; declined → + * relaunch local), so this spawn stays unelevated. Mirrors the systemd-run + * teardown handoff in handleUninstall. + */ + private async handleAppUninstall(req: IncomingMessage, res: ServerResponse): Promise { + const result = this.factory(); + if (result.platform !== 'linux') { + res.writeHead(200); + res.end(JSON.stringify({ ok: false, reason: 'unsupported', error: 'app uninstall is linux-only' })); + return true; + } + + const reqBody = await readJsonBody(req); + const keep = Boolean((reqBody as Partial).keep); + + const cfg = Config.getInstance(); + const dataRoot = cfg.dataRoot ?? path.dirname(cfg.dependenciesPath); + // Same staged out-of-mount helper UpdateService.applyUpdate uses — note + // the `.exe` suffix is the fixed staged name even on Linux. + const helper = path.join(dataRoot, 'control', 'operation-server', 'ws-scrcpy-web-launcher.exe'); + if (!this.existsCheck(helper)) { + const failure: ServiceActionFailure = { + ok: false, + error: `uninstall helper not found at ${helper}`, + reason: 'unknown', + }; + res.writeHead(500); + res.end(JSON.stringify(failure)); + return true; + } + + // Installed service scope (Linux-only getInstalledScope; SystemdClient + // implements it). null → no service unit on disk → 'none'. + const svc = result.client.getInstalledScope + ? await result.client.getInstalledScope(WS_SCRCPY_SERVICE_NAME) + : null; + const scope: 'user' | 'system' | 'none' = svc === 'system' ? 'system' : svc === 'user' ? 'user' : 'none'; + + const machineWide = this.existsCheck(`${STAGED_SYSTEM_DIR}/${STAGED_SYSTEM_APPIMAGE}`); + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + const relaunch = process.env['APPIMAGE'] ?? ''; + + // keep=true: reset installMode to null BEFORE spawning so the preserved + // config.json comes back up in local mode, not a phantom service mode. + // Best-effort — log and proceed; the teardown still goes ahead. + if (keep) { + try { + cfg.updateAppConfig({ installMode: null }); + } catch (err) { + log.warn(`app-uninstall: installMode reset failed (continuing): ${(err as Error).message}`); + } + } + + const systemdRun = resolveSystemTool('systemd-run'); + const unit = `--unit=wsscrcpy-uninstall-${Date.now()}`; + this.spawnDetached( + systemdRun, + buildUninstallHelperArgs({ isRoot, unit, helper, scope, machineWide, keep, dataRoot, relaunch }), + ); + this.scheduleExit(() => { + log.info('app-uninstall: local instance exiting → detached teardown'); + process.exit(0); + }, 1_500); + + res.writeHead(200); + res.end(JSON.stringify({ ok: true, status: 'uninstalling' })); + return true; + } + /** * Read the legacy system-service webPort so the migration can carry it * forward into the new /var/opt config. Reads the old service's own diff --git a/src/server/service/SystemdClient.test.ts b/src/server/service/SystemdClient.test.ts index b2eb207..da6be02 100644 --- a/src/server/service/SystemdClient.test.ts +++ b/src/server/service/SystemdClient.test.ts @@ -205,6 +205,42 @@ describe('buildMachineWideInstallScript', () => { expect(s).not.toContain('systemctl'); // no service install here expect(s).toContain('rm -f "/home/u/Downloads/WsScrcpyWeb-linux-beta.AppImage"'); // final step: delete the original (true relocate) }); + + it('installs the menu icon into the hicolor theme + refreshes the icon cache when iconSource is given', () => { + const s = buildMachineWideInstallScript( + { + sourceAppImage: '/home/u/Downloads/WsScrcpyWeb-linux-beta.AppImage', + version: '0.1.31-beta.1', + iconSource: '/tmp/.mount_x/.DirIcon', + }, + (t) => `/usr/bin/${t}`, (t) => `/usr/sbin/${t}`, + ); + // icon staged into the hicolor 256x256 apps dir under the name the + // .desktop's `Icon=ws-scrcpy-web` resolves to (also the path the launcher + // uninstaller's SYS_ICON teardown removes). + expect(s).toContain('mkdir -p /usr/share/icons/hicolor/256x256/apps'); + expect(s).toContain('cp "/tmp/.mount_x/.DirIcon" /usr/share/icons/hicolor/256x256/apps/ws-scrcpy-web.png'); + expect(s).toContain('/usr/share/icons/hicolor/256x256/apps/ws-scrcpy-web.png'); + // best-effort cache refresh, mirroring the update-desktop-database subshell. + expect(s).toContain('gtk-update-icon-cache'); + expect(s).toMatch(/\(\s*\/usr\/bin\/gtk-update-icon-cache -f \/usr\/share\/icons\/hicolor \|\| true\s*\)/); + // ordering: icon install lands AFTER the .desktop write and BEFORE the home-AppImage delete. + const desktopIdx = s.indexOf('/usr/share/applications/ws-scrcpy-web.desktop'); + const iconIdx = s.indexOf('/usr/share/icons/hicolor/256x256/apps/ws-scrcpy-web.png'); + const rmIdx = s.indexOf('rm -f "/home/u/Downloads/WsScrcpyWeb-linux-beta.AppImage"'); + expect(desktopIdx).toBeGreaterThanOrEqual(0); + expect(desktopIdx).toBeLessThan(iconIdx); + expect(iconIdx).toBeLessThan(rmIdx); + }); + + it('skips the icon steps entirely when no iconSource is given (graceful skip)', () => { + const s = buildMachineWideInstallScript( + { sourceAppImage: '/home/u/Downloads/WsScrcpyWeb-linux-beta.AppImage', version: '0.1.31-beta.1' }, + (t) => `/usr/bin/${t}`, (t) => `/usr/sbin/${t}`, + ); + expect(s).not.toContain('/usr/share/icons/hicolor'); + expect(s).not.toContain('gtk-update-icon-cache'); + }); }); describe('buildMachineWideUpdateScript', () => { diff --git a/src/server/service/SystemdClient.ts b/src/server/service/SystemdClient.ts index 5d4f6a4..f35c2c5 100644 --- a/src/server/service/SystemdClient.ts +++ b/src/server/service/SystemdClient.ts @@ -88,6 +88,13 @@ export const SYSTEM_STATE_DIR = '/var/opt/ws-scrcpy-web'; export const SYSTEM_OPT_VERSION_FILE = `${STAGED_SYSTEM_DIR}/VERSION`; /** System-wide .desktop entry for all users (machine-wide install only). */ export const SYSTEM_DESKTOP_FILE = '/usr/share/applications/ws-scrcpy-web.desktop'; +/** hicolor theme apps dir for the machine-wide menu icon (256x256). */ +export const SYSTEM_ICON_DIR = '/usr/share/icons/hicolor/256x256/apps'; +/** Machine-wide menu icon file — its basename matches the .desktop's + * `Icon=ws-scrcpy-web` so the launcher entry resolves to a real icon, and the + * full path matches the launcher uninstaller's SYS_ICON teardown + * (launcher/src/linux_app_uninstall.rs). */ +export const SYSTEM_ICON_FILE = `${SYSTEM_ICON_DIR}/ws-scrcpy-web.png`; /** * Root-owned dependencies dir for the system-scope service (node/adb/ * scrcpy-server). Under the bin_t-labelled /opt tree so init_t may exec them, @@ -355,14 +362,18 @@ export function buildSystemInstallScript( * Build the privileged shell script for a machine-wide install. Runs under a * single pkexec prompt. Relocates ONLY the AppImage binary to /opt (no deps, * no systemd unit) — deps stay per-user in ~/.local. Writes VERSION, drops a - * system-wide .desktop entry for all users, and refreshes the menu cache. As the - * final step it DELETES the original (home) AppImage — a true relocate — so the - * user can't end up running a stale home copy alongside the /opt one. + * system-wide .desktop entry for all users, and refreshes the menu cache. When + * `iconSource` is supplied it also installs that icon file into the hicolor theme + * (so the .desktop's `Icon=ws-scrcpy-web` resolves to a real icon, not a generic + * placeholder) and refreshes the icon cache — best-effort, omitted entirely when + * absent. As the final step it DELETES the original (home) AppImage — a true + * relocate — so the user can't end up running a stale home copy alongside the + * /opt one. * `binTool`/`sbinTool` are injectable for testing; production resolves absolute * paths via systemTools (Local-Dependencies-Only — no bare-name $PATH lookup). */ export function buildMachineWideInstallScript( - args: { sourceAppImage: string; version: string }, + args: { sourceAppImage: string; version: string; iconSource?: string | undefined }, binTool: (t: string) => string = (t) => resolveSystemTool(t), sbinTool: (t: string) => string = (t) => resolveSystemTool(t), ): string { @@ -384,7 +395,7 @@ export function buildMachineWideInstallScript( 'Icon=ws-scrcpy-web', 'Categories=Utility;', ].join('\\n'); - return [ + const steps = [ `${mkdir} -p ${STAGED_SYSTEM_DIR}`, `${cp} "${args.sourceAppImage}" "${staged}"`, `${chmod} 0755 "${staged}"`, @@ -392,14 +403,31 @@ export function buildMachineWideInstallScript( `( ( ${semanage} fcontext -a -t bin_t '${STAGED_SYSTEM_DIR}(/.*)?' && ${restorecon} -Rv "${STAGED_SYSTEM_DIR}" ) || ${chcon} -t bin_t "${staged}" || true )`, `( ${printf} '${desktop}\\n' > ${SYSTEM_DESKTOP_FILE} || true )`, `( ${updateDesktopDb} /usr/share/applications || true )`, - // Final step — remove the original (home) AppImage now that the binary - // lives in /opt: a true relocate. Runs as root (pkexec), so it can unlink - // the user's file; unlinking is safe while the home AppImage is still the - // running process (the inode stays alive for the live FUSE mount until it - // exits / re-execs to /opt). `|| true` so a failed cleanup never aborts an - // otherwise-successful install. - `( ${rm} -f "${args.sourceAppImage}" || true )`, - ].join(' && '); + ]; + // Install the launcher icon into the hicolor theme so the .desktop's + // `Icon=ws-scrcpy-web` resolves to a real icon instead of a generic + // placeholder. The icon ships embedded in the AppImage (vpk `--icon + // assets/tray-icon.png` → the AppImage's `.DirIcon`); the caller passes its + // mounted path. Omitted (graceful skip) when no `iconSource` is supplied. + // Wrapped in ONE best-effort group (trailing `|| true`, like the SELinux line + // above): POSIX sh gives `&&`/`||` EQUAL left-assoc precedence, so a missing + // `.DirIcon` (cp fails) must not break the outer `&&` chain and skip the + // home-AppImage delete below — the relocate must still complete. Teardown + // removes this file (launcher/src/linux_app_uninstall.rs — SYS_ICON). + if (args.iconSource) { + const iconCache = binTool('gtk-update-icon-cache'); + steps.push( + `( ${mkdir} -p ${SYSTEM_ICON_DIR} && ${cp} "${args.iconSource}" ${SYSTEM_ICON_FILE} && ( ${iconCache} -f /usr/share/icons/hicolor || true ) || true )`, + ); + } + // Final step — remove the original (home) AppImage now that the binary + // lives in /opt: a true relocate. Runs as root (pkexec), so it can unlink + // the user's file; unlinking is safe while the home AppImage is still the + // running process (the inode stays alive for the live FUSE mount until it + // exits / re-execs to /opt). `|| true` so a failed cleanup never aborts an + // otherwise-successful install. + steps.push(`( ${rm} -f "${args.sourceAppImage}" || true )`); + return steps.join(' && '); } /**