From bbeee07e2fd1330d01a9b6a57223e136c0a366f8 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:04:24 -0400 Subject: [PATCH 01/10] feat(launcher): pure app_uninstall_commands builder (beta.49) --- launcher/src/linux_app_uninstall.rs | 303 ++++++++++++++++++++++++++++ launcher/src/main.rs | 2 + 2 files changed, 305 insertions(+) create mode 100644 launcher/src/linux_app_uninstall.rs diff --git a/launcher/src/linux_app_uninstall.rs b/launcher/src/linux_app_uninstall.rs new file mode 100644 index 0000000..cc5e165 --- /dev/null +++ b/launcher/src/linux_app_uninstall.rs @@ -0,0 +1,303 @@ +// In-app "complete uninstall" — PURE command-vector builder (beta.49). +// +// `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. +// +// PURE builder only — no dispatch, no std::process. Task 2 wires it into main.rs +// and runs the vectors (user_owned unelevated, privileged under a single pkexec). +// Until then nothing in production calls the builder, hence the module-level +// `dead_code` allow (same rationale as linux_service::bootstrap_target's +// `#[allow(dead_code)]`); Task 2 narrows/removes it once the dispatch lands. +// +// Local-Dependencies-Only: every tool is resolved under `bindir` (sbin tools via +// `sbindir_from(bindir)`) — never a bare name and never via PATH. +#![allow(dead_code)] + +use crate::linux_service::{scope_prefix, sbindir_from, unit_path, Scope}; + +/// 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 (root-owned). +const OPT_DIR: &str = "/opt/ws-scrcpy-web"; +/// System-service state dir (root-owned). +const VAR_OPT_DIR: &str = "/var/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, /opt + /var/opt, the system + /// .desktop + icon, 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(), + ] +} + +/// 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(), + ]]; + + // 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)); + } + + // 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. keep=false wipes the whole root; keep=true deletes only the + // regenerable subdirs and preserves , config.json and logs/. + if keep { + for sub in ["dependencies", "bin", "control"] { + user_owned.push(vec![rm.clone(), "-rf".into(), format!("{data_root}/{sub}")]); + } + } else { + user_owned.push(vec![rm.clone(), "-rf".into(), data_root.to_string()]); + } + + // ── 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 + 3. machine-wide staging + system state (always when this group runs). + privileged.push(vec![rm.clone(), "-rf".into(), OPT_DIR.to_string()]); + privileged.push(vec![rm.clone(), "-rf".into(), VAR_OPT_DIR.to_string()]); + // 4. system menu entry + icon. + privileged.push(vec![rm.clone(), "-f".into(), SYS_DESKTOP.to_string()]); + privileged.push(vec![rm.clone(), "-f".into(), SYS_ICON.to_string()]); + // 5. 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 } +} + +#[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: kill -> lock -> data-root wipe. + assert_eq!( + joined(&plan.user_owned), + vec![ + "/usr/bin/pkill -KILL -f WsScrcpyWeb|ws-scrcpy-web-tray|ws-scrcpy-web-launcher|scrcpy-server", + "/usr/bin/rm -f /run/user/1000/ws-scrcpy-web.lock", + "/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!(!joined(&plan.user_owned).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 -> everything root-owned lands in privileged. + 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 + /var/opt removed. + 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 + icon removed. + 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/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.contains("ws-scrcpy-web.png"))); + // no service installed -> no systemctl in the privileged group. + assert!(!p.iter().any(|c| c.contains("systemctl"))); + // user_owned still wipes the 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")); + } +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs index b3c6958..4d7931b 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; From ca41d2c9b87dd6cea3b5b4cc9ae1f285e8f55fe1 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:20:27 -0400 Subject: [PATCH 02/10] fix(launcher): keep-safe system uninstall + adb/autostart/menu-cache 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 /.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. --- launcher/src/linux_app_uninstall.rs | 168 +++++++++++++++++++++++----- 1 file changed, 138 insertions(+), 30 deletions(-) diff --git a/launcher/src/linux_app_uninstall.rs b/launcher/src/linux_app_uninstall.rs index cc5e165..5433d84 100644 --- a/launcher/src/linux_app_uninstall.rs +++ b/launcher/src/linux_app_uninstall.rs @@ -23,10 +23,11 @@ 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 (root-owned). +/// 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-service state dir (root-owned). -const VAR_OPT_DIR: &str = "/var/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"; @@ -45,8 +46,9 @@ const FCONTEXT_SPECS: [&str; 3] = [ /// the dispatch layer can skip the elevation prompt entirely. #[derive(Debug, Clone)] pub struct UninstallPlan { - /// Root-only steps: system service cascade, /opt + /var/opt, the system - /// .desktop + icon, and the SELinux fcontext rules. + /// 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`). @@ -72,6 +74,22 @@ fn service_teardown(scope: Scope, bindir: &str) -> Vec> { ] } +/// `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). @@ -101,24 +119,35 @@ pub fn app_uninstall_commands( 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. keep=false wipes the whole root; keep=true deletes only the - // regenerable subdirs and preserves , config.json and logs/. - if keep { - for sub in ["dependencies", "bin", "control"] { - user_owned.push(vec![rm.clone(), "-rf".into(), format!("{data_root}/{sub}")]); - } - } else { - user_owned.push(vec![rm.clone(), "-rf".into(), data_root.to_string()]); + // 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) ────────────────── @@ -130,13 +159,22 @@ pub fn app_uninstall_commands( if svc_scope == Some(Scope::System) { privileged.extend(service_teardown(Scope::System, bindir)); } - // 2 + 3. machine-wide staging + system state (always when this group runs). + // 2. /opt staging: binary + bundled deps are ALWAYS fully removed (never kept). privileged.push(vec![rm.clone(), "-rf".into(), OPT_DIR.to_string()]); - privileged.push(vec![rm.clone(), "-rf".into(), VAR_OPT_DIR.to_string()]); - // 4. system menu entry + icon. + // 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()]); - // 5. SELinux fcontext rules (current x2 + legacy /opt/.../data). + // 4. SELinux fcontext rules (current x2 + legacy /opt/.../data). let semanage = format!("{}/semanage", sbindir_from(bindir)); for spec in FCONTEXT_SPECS { privileged.push(vec![ @@ -167,19 +205,23 @@ mod tests { // 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: kill -> lock -> data-root wipe. + // 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!( - joined(&plan.user_owned), - vec![ - "/usr/bin/pkill -KILL -f WsScrcpyWeb|ws-scrcpy-web-tray|ws-scrcpy-web-launcher|scrcpy-server", - "/usr/bin/rm -f /run/user/1000/ws-scrcpy-web.lock", - "/usr/bin/rm -rf /home/u/.local/share/WsScrcpyWeb", - ] + 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!(!joined(&plan.user_owned).iter().any(|c| c.contains("systemctl"))); + assert!(!u.iter().any(|c| c.contains("systemctl"))); } #[test] @@ -235,7 +277,8 @@ mod tests { #[test] fn system_install() { - // system service + machine-wide -> everything root-owned lands in privileged. + // 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, @@ -248,13 +291,16 @@ mod tests { // 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 + /var/opt removed. + // /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 + icon removed. + // 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" )); @@ -281,10 +327,16 @@ mod tests { 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 in the privileged group. + // 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"))); - // user_owned still wipes the data root. + 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")); @@ -300,4 +352,60 @@ mod tests { 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"))); + } } From dee47ec50d07b3d3f14db3d199b7c35f41f70194 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:44:42 -0400 Subject: [PATCH 03/10] feat(launcher): --linux-app-uninstall dispatch + pkexec exec layer (beta.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. --- launcher/src/linux_app_uninstall.rs | 388 +++++++++++++++++++++++++++- launcher/src/main.rs | 20 ++ 2 files changed, 399 insertions(+), 9 deletions(-) diff --git a/launcher/src/linux_app_uninstall.rs b/launcher/src/linux_app_uninstall.rs index 5433d84..ccabda0 100644 --- a/launcher/src/linux_app_uninstall.rs +++ b/launcher/src/linux_app_uninstall.rs @@ -1,4 +1,5 @@ -// In-app "complete uninstall" — PURE command-vector builder (beta.49). +// 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 @@ -6,17 +7,20 @@ // 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. // -// PURE builder only — no dispatch, no std::process. Task 2 wires it into main.rs -// and runs the vectors (user_owned unelevated, privileged under a single pkexec). -// Until then nothing in production calls the builder, hence the module-level -// `dead_code` allow (same rationale as linux_service::bootstrap_target's -// `#[allow(dead_code)]`); Task 2 narrows/removes it once the dispatch lands. +// Dispatch (Task 2): the UNELEVATED entry `handle` (`--linux-app-uninstall`, +// spawned by the Node server via `systemd-run --user --collect`) runs the +// `user_owned` group, then re-invokes the launcher under ONE pkexec for the +// `privileged` group. That pkexec lands on the ELEVATED entry `handle_elevated` +// (`--linux-app-uninstall-elevated`), which runs ONLY the `privileged` group as +// root. Both sides feed the SAME args to the SAME builder, so the split is +// identical and each instance runs exactly its own half. On a pkexec decline +// (126/127) or a privileged failure the uninstall aborts and the running +// AppImage is relaunched locally so the user is never stranded. // // Local-Dependencies-Only: every tool is resolved under `bindir` (sbin tools via // `sbindir_from(bindir)`) — never a bare name and never via PATH. -#![allow(dead_code)] - -use crate::linux_service::{scope_prefix, sbindir_from, unit_path, Scope}; +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"; @@ -189,6 +193,263 @@ pub fn app_uninstall_commands( 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: the privileged group FIRST under ONE pkexec (re-invoking the +/// launcher as root), then the best-effort `user_owned` group. A pkexec decline +/// (126/127), a privileged failure, or a spawn error aborts the teardown and +/// relaunches the local AppImage, returning 0 — the privileged group is all-or- +/// nothing (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, under ONE pkexec (re-invoke self as root). An + // empty privileged group (purely-local install) skips elevation entirely. + if !plan.privileged.is_empty() { + 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 +} + +/// 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::*; @@ -408,4 +669,113 @@ mod tests { // 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); + } } diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 4d7931b..9b23e38 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -113,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")] From 70274e30d8ae58d11afaa1a6bde237597457ee4c Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:49:44 -0400 Subject: [PATCH 04/10] docs(launcher): correct uninstall dispatch order in module doc (privileged-first) --- launcher/src/linux_app_uninstall.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/launcher/src/linux_app_uninstall.rs b/launcher/src/linux_app_uninstall.rs index ccabda0..4789ac9 100644 --- a/launcher/src/linux_app_uninstall.rs +++ b/launcher/src/linux_app_uninstall.rs @@ -8,9 +8,10 @@ // 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 Node server via `systemd-run --user --collect`) runs the -// `user_owned` group, then re-invokes the launcher under ONE pkexec for the -// `privileged` group. That pkexec lands on the ELEVATED entry `handle_elevated` +// spawned by the Node server via `systemd-run --user --collect`) re-invokes the +// launcher under ONE pkexec for the `privileged` group FIRST (so a declined +// prompt aborts before anything is removed), then runs the unelevated +// `user_owned` group. That pkexec lands on the ELEVATED entry `handle_elevated` // (`--linux-app-uninstall-elevated`), which runs ONLY the `privileged` group as // root. Both sides feed the SAME args to the SAME builder, so the split is // identical and each instance runs exactly its own half. On a pkexec decline From 7af5e67e28e26db335b1157c83740df8ac0eb5f3 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:11:41 -0400 Subject: [PATCH 05/10] feat(launcher): root-direct vs pkexec split for in-app uninstall (beta.49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- launcher/Cargo.toml | 8 ++ launcher/src/linux_app_uninstall.rs | 213 +++++++++++++++++++--------- 2 files changed, 151 insertions(+), 70 deletions(-) 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 index 4789ac9..82a67ec 100644 --- a/launcher/src/linux_app_uninstall.rs +++ b/launcher/src/linux_app_uninstall.rs @@ -8,15 +8,23 @@ // 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 Node server via `systemd-run --user --collect`) re-invokes the -// launcher under ONE pkexec for the `privileged` group FIRST (so a declined -// prompt aborts before anything is removed), then runs the unelevated -// `user_owned` group. That pkexec lands on the ELEVATED entry `handle_elevated` -// (`--linux-app-uninstall-elevated`), which runs ONLY the `privileged` group as -// root. Both sides feed the SAME args to the SAME builder, so the split is -// identical and each instance runs exactly its own half. On a pkexec decline -// (126/127) or a privileged failure the uninstall aborts and the running -// AppImage is relaunched locally so the user is never stranded. +// 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. @@ -298,11 +306,18 @@ pub fn handle_elevated(args: &[String]) -> Option { Some(run_elevated(&a)) } -/// Unelevated run: the privileged group FIRST under ONE pkexec (re-invoking the -/// launcher as root), then the best-effort `user_owned` group. A pkexec decline -/// (126/127), a privileged failure, or a spawn error aborts the teardown and -/// relaunches the local AppImage, returning 0 — the privileged group is all-or- -/// nothing (it never partially ran), so the user keeps a working local app. +/// 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={}", @@ -310,63 +325,81 @@ fn run_unelevated(a: &UninstallArgs) -> i32 { )); let plan = plan_for(a); - // 1. Privileged group FIRST, under ONE pkexec (re-invoke self as root). An - // empty privileged group (purely-local install) skips elevation entirely. - if !plan.privileged.is_empty() { - 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; + // 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(" "))), + } } - }; - 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; + } + 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; + } } } } @@ -404,6 +437,35 @@ fn run_elevated(a: &UninstallArgs) -> i32 { 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 @@ -779,4 +841,15 @@ mod tests { .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); + } } From 8a76b68029049e897f57a7d4535eb585d06d52e2 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:22:27 -0400 Subject: [PATCH 06/10] feat(service): add POST /api/service/uninstall-app endpoint 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. --- src/common/ServiceEvents.ts | 5 + src/server/__tests__/ServiceApi.test.ts | 157 +++++++++++++++++++++++- src/server/api/ServiceApi.ts | 124 +++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) 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..a16b7ed 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' })); @@ -925,6 +966,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 From 294d5aef736a987c7ccf582153c55f6830011c68 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:38:06 -0400 Subject: [PATCH 07/10] feat(settings): add Linux-only install-for-all-users + uninstall rows 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. --- src/app/client/SettingsModal.ts | 267 ++++++++++++++++++ .../client/__tests__/SettingsModal.test.ts | 125 +++++++- 2 files changed, 391 insertions(+), 1 deletion(-) 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); + }); +}); From 053083686a874f0dc3792e1c7ffe09d42325f207 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:52:17 -0400 Subject: [PATCH 08/10] feat(service): install start-menu icon into hicolor on machine-wide install (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). --- src/server/api/ServiceApi.ts | 12 +++++- src/server/service/SystemdClient.test.ts | 36 ++++++++++++++++ src/server/service/SystemdClient.ts | 54 ++++++++++++++++++------ 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/server/api/ServiceApi.ts b/src/server/api/ServiceApi.ts index a16b7ed..e4cf57f 100644 --- a/src/server/api/ServiceApi.ts +++ b/src/server/api/ServiceApi.ts @@ -840,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 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(' && '); } /** From 07abceec4d429b072e72017c091e20e17a47eef6 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:52:24 -0400 Subject: [PATCH 09/10] docs: add beta.49 App-section smoke rows + CHANGELOG Added (beta.49) 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. --- CHANGELOG.md | 6 ++++++ docs/smoke-tests/v0.1.30-beta.44-full.md | 14 ++++++++++++++ 2 files changed, 20 insertions(+) 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.44-full.md b/docs/smoke-tests/v0.1.30-beta.44-full.md index beba6a7..141fb7b 100644 --- a/docs/smoke-tests/v0.1.30-beta.44-full.md +++ b/docs/smoke-tests/v0.1.30-beta.44-full.md @@ -170,6 +170,20 @@ beta.39 added a "stop server & exit" button (Settings → App) with graceful tea | **13.1** `[Both]` Bookmark global-dismiss *(beta.32)* | Reach the bookmark / port-change reminder → check "don't show again — ever, even when the port changes" → confirm. | The confirmation dialog uses the white-outline buttons; checking it **supersedes + disables** the per-port checkbox; persists (`bookmarkDismissedGlobally` in `config.json`). | | **13.2** `[Both]` Reset welcome & bookmark prompts *(beta.40 fix)* | Settings → "reset welcome and bookmark prompts". | Re-shows the welcome modal **and** clears the bookmark dismissal — both **per-port** and **global** — so the reminder can re-fire. Regression check: the welcome reset must **not** re-suppress the per-port bookmark (the eager `bookmarkDismissedForPort` re-stamp is gone). | +## Module 14 — beta.49 App-section UX (Linux) + +beta.49 adds three Linux App-section actions: a one-click **install for all users** (post-first-run, from Settings), a real start-menu icon for the machine-wide entry, and an in-app **complete uninstall** with an optional **keep my settings & logs**. + +| Test | How to perform | Expected + verify | +|---|---|---| +| ☐ **14.1** `[Linux]` 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. | +| ☐ **14.2** `[Linux]` start-menu icon | After a machine-wide install (14.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**. | +| ☐ **14.3** `[Linux]` 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). | +| ☐ **14.4** `[Linux]` 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**. | +| ☐ **14.5** `[Linux]` 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**. | +| ☐ **14.6** `[Linux]` 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**. | +| ☐ **14.7** `[Linux]` 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 From 56336438fbcb8d12cf539618083f2d6ba83c0bc8 Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:02:20 -0400 Subject: [PATCH 10/10] docs(smoke): move beta.49 App-section rows to the run-sheet as batch #15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/smoke-tests/v0.1.30-beta.44-full.md | 14 -------------- docs/smoke-tests/v0.1.30-beta.48-checklist.md | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/smoke-tests/v0.1.30-beta.44-full.md b/docs/smoke-tests/v0.1.30-beta.44-full.md index 141fb7b..beba6a7 100644 --- a/docs/smoke-tests/v0.1.30-beta.44-full.md +++ b/docs/smoke-tests/v0.1.30-beta.44-full.md @@ -170,20 +170,6 @@ beta.39 added a "stop server & exit" button (Settings → App) with graceful tea | **13.1** `[Both]` Bookmark global-dismiss *(beta.32)* | Reach the bookmark / port-change reminder → check "don't show again — ever, even when the port changes" → confirm. | The confirmation dialog uses the white-outline buttons; checking it **supersedes + disables** the per-port checkbox; persists (`bookmarkDismissedGlobally` in `config.json`). | | **13.2** `[Both]` Reset welcome & bookmark prompts *(beta.40 fix)* | Settings → "reset welcome and bookmark prompts". | Re-shows the welcome modal **and** clears the bookmark dismissal — both **per-port** and **global** — so the reminder can re-fire. Regression check: the welcome reset must **not** re-suppress the per-port bookmark (the eager `bookmarkDismissedForPort` re-stamp is gone). | -## Module 14 — beta.49 App-section UX (Linux) - -beta.49 adds three Linux App-section actions: a one-click **install for all users** (post-first-run, from Settings), a real start-menu icon for the machine-wide entry, and an in-app **complete uninstall** with an optional **keep my settings & logs**. - -| Test | How to perform | Expected + verify | -|---|---|---| -| ☐ **14.1** `[Linux]` 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. | -| ☐ **14.2** `[Linux]` start-menu icon | After a machine-wide install (14.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**. | -| ☐ **14.3** `[Linux]` 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). | -| ☐ **14.4** `[Linux]` 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**. | -| ☐ **14.5** `[Linux]` 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**. | -| ☐ **14.6** `[Linux]` 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**. | -| ☐ **14.7** `[Linux]` 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/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