From c4266bdaefccdc219224923845b5dd610b241858 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 14:50:32 -0500 Subject: [PATCH 01/11] Disable Setup Wraith together with the stock launcher during a Google TV takeover (#122) --- v2/crates/core/data/app-lists/launchers.json | 3 +- v2/crates/core/src/commands/launcher.rs | 264 +++++++++++++++++-- v2/crates/core/src/engine/launcher.rs | 48 ++++ v2/crates/core/src/engine/mod.rs | 4 +- v2/src-tauri/src/lib.rs | 1 + 5 files changed, 295 insertions(+), 25 deletions(-) diff --git a/v2/crates/core/data/app-lists/launchers.json b/v2/crates/core/data/app-lists/launchers.json index 7034476..c271502 100644 --- a/v2/crates/core/data/app-lists/launchers.json +++ b/v2/crates/core/data/app-lists/launchers.json @@ -43,7 +43,8 @@ }, { "name": "Google TV Home (Stock)", - "package": "com.google.android.apps.tv.launcherx" + "package": "com.google.android.apps.tv.launcherx", + "disable_with": ["com.google.android.tungsten.setupwraith"] }, { "name": "Leanback Launcher (Stock)", diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index 4d0adc2..ad65e48 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -4,8 +4,8 @@ use serde::Serialize; use tauri::State; use crate::engine::{ - is_last_enabled_home_handler, is_valid_package_name, launcher_rows, pick_current_home, - HomeReading, LauncherStatus, + is_last_enabled_home_handler, is_valid_package_name, launcher_rows, paired_transient_holders, + pick_current_home, HomeReading, LauncherStatus, }; use crate::license::Feature; @@ -579,14 +579,28 @@ pub async fn disable_stock_launcher_impl( .await .confirmed { - return Ok(SetLauncherResult { - ok: true, - strategy: Some("disable_stock_takeover".into()), - current_launcher: Some(target.to_string()), - last_error: None, - stock_takeover_available: false, - diagnostics, - }); + match disable_paired_holders( + &*adb, + serial, + target, + &disabled, + progress, + &mut diagnostics, + ) + .await + { + Ok(()) => { + return Ok(SetLauncherResult { + ok: true, + strategy: Some("disable_stock_takeover".into()), + current_launcher: Some(target.to_string()), + last_error: None, + stock_takeover_available: false, + diagnostics, + }); + } + Err(reason) => failure = Some(reason), + } } for stock in &disabled { let restore = adb.shell(serial, &format!("pm enable {stock}")).await; @@ -1044,6 +1058,194 @@ pub async fn set_default_launcher_impl( }) } +/// After `target` is confirmed as Home with `stocks` disabled, also disable +/// the transient HOME holders the catalog pairs with those stock launchers +/// (Setup Wraith on Google TV, #122): with stock gone they take the Home button +/// back. A device with no such holder enabled is untouched. +/// +/// Every guard of the stock takeover applies: the do-not-disable gate, the +/// last-Home-handler check, and a second verification that `target` still holds +/// Home. On any failure the holders disabled here are re-enabled before this +/// returns `Err`, and the caller re-enables stock, so the TV is never left +/// switched halfway. +async fn disable_paired_holders( + adb: &dyn crate::adb::AdbDriver, + serial: &str, + target: &str, + stocks: &[String], + progress: &Progress, + diagnostics: &mut Vec, +) -> Result<(), String> { + if stocks + .iter() + .all(|s| launchers().disable_with_for(s).is_empty()) + { + return Ok(()); + } + let enabled = match adb.shell(serial, HOME_HANDLER_QUERY).await { + Ok(out) if out.success() && !out.shell_reported_failure() => { + parse_home_handler_packages(&out.stdout) + } + _ => { + diagnostics.push("query-activities HOME (setup helper) -> unavailable".to_string()); + return Ok(()); + } + }; + let mut disabled: Vec = Vec::new(); + let mut failure = None; + for holder in paired_transient_holders(launchers(), stocks, &enabled) { + if matches!( + crate::engine::classify_safety(&holder), + crate::engine::Safety::NeverDisable { .. } + ) { + diagnostics.push(format!("{holder} skipped: on the do-not-disable list")); + continue; + } + let remaining: Vec = enabled + .iter() + .filter(|h| !stocks.contains(h)) + .cloned() + .collect(); + if is_last_enabled_home_handler(&holder, &remaining, launchers()) + || !remaining.iter().any(|h| h == target) + { + diagnostics.push(format!("{holder} skipped: it would leave no Home app")); + continue; + } + progress.step("Turning off Google TV's setup helper"); + let result = adb + .shell(serial, &format!("pm disable-user --user 0 {holder}")) + .await; + // An errored disable may still have landed; restore it too. + disabled.push(holder.clone()); + match command_failure(&result) { + Some(f) => { + diagnostics.push(format!("pm disable-user {holder} -> {f}")); + failure = Some(format!( + "Setup-helper disable command failed for {holder}: {f}" + )); + break; + } + None => diagnostics.push(format!("pm disable-user {holder} -> ok")), + } + } + if disabled.is_empty() { + return Ok(()); + } + if failure.is_none() { + let _ = adb + .shell( + serial, + "am start -W -a android.intent.action.MAIN -c android.intent.category.HOME", + ) + .await; + if verify_active(adb, serial, target, diagnostics) + .await + .confirmed + { + return Ok(()); + } + failure = Some(format!( + "Home moved away from {target} after the setup helper was disabled" + )); + } + for holder in &disabled { + let restore = adb.shell(serial, &format!("pm enable {holder}")).await; + diagnostics.push(match command_failure(&restore) { + Some(f) => format!("pm enable {holder} (restore) -> {f}"), + None => format!("pm enable {holder} (restore) -> ok"), + }); + } + Err(failure.unwrap_or_default()) +} + +/// `disable_setup_helper` — the Launcher tab's one-click fix for an enabled +/// transient HOME holder (Setup Wraith) that is live while stock is already +/// disabled. Same shape as the takeover's own step: the do-not-disable gate, +/// the last-Home-handler check, a verification that Home still resolves, and a +/// re-enable if it doesn't. +#[tauri::command] +pub async fn disable_setup_helper( + state: State<'_, AppState>, + serial: String, + package: String, +) -> Result { + let result = disable_setup_helper_impl(state.inner(), &serial, &package).await; + match &result { + Ok(r) => tracing::info!(%serial, %package, ok = r.ok, "disable setup helper finished"), + Err(e) => { + tracing::info!(%serial, %package, ok = false, error = %e, "disable setup helper finished") + } + } + result +} + +pub async fn disable_setup_helper_impl( + state: &AppState, + serial: &str, + package: &str, +) -> Result { + let refuse = |message: String| Ok(crate::commands::apps::ActionResult { ok: false, message }); + if !is_valid_package_name(package) || !launchers().is_transient_home_holder(package) { + return refuse(format!("{package} is not a setup helper this app knows.")); + } + if matches!( + crate::engine::classify_safety(package), + crate::engine::Safety::NeverDisable { .. } + ) { + return refuse(format!("{package} is on the do-not-disable list.")); + } + let adb = state.adb_snapshot().await; + let enabled = match adb.shell(serial, HOME_HANDLER_QUERY).await { + Ok(out) if out.success() && !out.shell_reported_failure() => { + parse_home_handler_packages(&out.stdout) + } + _ => { + return refuse( + "Couldn't read this TV's Home apps, so turning the setup helper off can't be \ + proven safe. Nothing was changed." + .to_string(), + ) + } + }; + if is_last_enabled_home_handler(package, &enabled, launchers()) { + return refuse(format!( + "Refusing to disable {package}: no real launcher is enabled, so it would leave the \ + TV without a Home app. Re-enable a launcher first." + )); + } + let result = adb + .shell(serial, &format!("pm disable-user --user 0 {package}")) + .await; + let failed = command_failure(&result); + let _ = adb + .shell( + serial, + "am start -W -a android.intent.action.MAIN -c android.intent.category.HOME", + ) + .await; + let home_ok = failed.is_none() + && read_current_home(&*adb, serial) + .await + .ok() + .and_then(|r| r.package) + .is_some_and(|p| p != package); + if home_ok { + return Ok(crate::commands::apps::ActionResult { + ok: true, + message: "Setup Wraith is off.".to_string(), + }); + } + let restore = adb.shell(serial, &format!("pm enable {package}")).await; + let _ = restore; + refuse(match failed { + Some(f) => format!("Couldn't disable {package}: {f}. It was left enabled."), + None => format!( + "Home didn't stay on a launcher after {package} was disabled, so it was re-enabled." + ), + }) +} + /// Disable the active *stock* launcher so HOME resolves to `package`. On builds /// where an enabled stock launcher overrides set-home-activity / the role API /// (they answer "Success" but HOME keeps resolving to stock — verified live on @@ -1106,15 +1308,30 @@ async fn stock_takeover( .await; progress.step("Checking whether Home switched over"); let verification = verify_active(adb, serial, package, diagnostics).await; + let mut paired_failure = None; if verification.confirmed { - return Some(SetLauncherResult { - ok: true, - strategy: Some("disable_stock_takeover".into()), - current_launcher: Some(package.to_string()), - last_error: None, - stock_takeover_available: false, - diagnostics: std::mem::take(diagnostics), - }); + match disable_paired_holders( + adb, + serial, + package, + &[active.to_string()], + progress, + diagnostics, + ) + .await + { + Ok(()) => { + return Some(SetLauncherResult { + ok: true, + strategy: Some("disable_stock_takeover".into()), + current_launcher: Some(package.to_string()), + last_error: None, + stock_takeover_available: false, + diagnostics: std::mem::take(diagnostics), + }); + } + Err(reason) => paired_failure = Some(reason), + } } // Stock only gets re-enabled below when it *positively* still holds // HOME at the end of the window, or when the resolver never answered @@ -1126,7 +1343,9 @@ async fn stock_takeover( // from landing, so restoring it would only undo real progress // (GitHub #122: the onn 4K Pro reported this as a failure and rolled // stock back even though the switch had actually worked). - if let Some(holder) = verification + if let Some(reason) = paired_failure { + reason + } else if let Some(holder) = verification .last_active .as_deref() .filter(|holder| *holder != active) @@ -1149,10 +1368,11 @@ async fn stock_takeover( stock_takeover_available: false, diagnostics: std::mem::take(diagnostics), }); + } else { + format!( + "Takeover verification failed: Android did not report {package} as the Home app after the stock-disable command completed for {active}" + ) } - format!( - "Takeover verification failed: Android did not report {package} as the Home app after the stock-disable command completed for {active}" - ) }; // A transport or shell error cannot prove that the disable had no effect. diff --git a/v2/crates/core/src/engine/launcher.rs b/v2/crates/core/src/engine/launcher.rs index 338d79c..9fdac98 100644 --- a/v2/crates/core/src/engine/launcher.rs +++ b/v2/crates/core/src/engine/launcher.rs @@ -17,6 +17,11 @@ pub struct LauncherEntry { /// HOME handlers discovered on the device rather than read from the file. #[serde(default)] pub source_url: Option, + /// Transient HOME holders (see `transient_home_holders`) that a takeover + /// disables together with this stock launcher, because with stock gone + /// they take the Home button back (#122). Stock entries only. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disable_with: Vec, } /// The launcher catalog, as loaded from `launchers.json`. @@ -54,6 +59,16 @@ impl LauncherCatalog { self.transient_home_holders.iter().any(|p| p == pkg) } + /// Transient holders to disable together with `stock_pkg`. Empty for a + /// package that isn't a stock launcher or has no pairing. + pub fn disable_with_for(&self, stock_pkg: &str) -> &[String] { + self.stock + .iter() + .find(|e| e.package == stock_pkg) + .map(|e| e.disable_with.as_slice()) + .unwrap_or(&[]) + } + /// True when `pkg` appears in either catalog list. pub fn contains(&self, pkg: &str) -> bool { self.is_stock(pkg) || self.custom.iter().any(|e| e.package == pkg) @@ -126,9 +141,19 @@ pub fn launcher_rows( }); let mut seen_other = std::collections::HashSet::new(); + // A disabled transient holder no longer answers the HOME query, and a + // takeover disables it without tracking it, so it is read from the package + // state instead: it keeps its row (and its re-enable path) while installed. + let transient_installed: Vec = catalog + .transient_home_holders + .iter() + .filter(|pkg| installed_pkgs.iter().any(|p| p == *pkg)) + .cloned() + .collect(); let other = home_handler_pkgs .iter() .chain(tracked_disabled_pkgs.iter()) + .chain(transient_installed.iter()) .filter(|pkg| { !catalog.contains(pkg) && !safe_home_handlers().contains(&pkg.as_str()) @@ -139,6 +164,7 @@ pub fn launcher_rows( name: catalog.home_handler_name(pkg).unwrap_or(pkg).to_string(), package: pkg.clone(), source_url: None, + disable_with: Vec::new(), }, installed: true, enabled: !is_disabled(pkg), @@ -150,6 +176,27 @@ pub fn launcher_rows( stock.chain(custom).chain(other).collect() } +/// The transient HOME holders to disable together with `stocks`, deduplicated +/// and limited to the ones the catalog knows as transient and `enabled` lists. +pub fn paired_transient_holders( + catalog: &LauncherCatalog, + stocks: &[String], + enabled: &[String], +) -> Vec { + let mut out: Vec = Vec::new(); + for stock in stocks { + for holder in catalog.disable_with_for(stock) { + if catalog.is_transient_home_holder(holder) + && enabled.iter().any(|e| e == holder) + && !out.contains(holder) + { + out.push(holder.clone()); + } + } + } + out +} + /// True when disabling `target` would leave the device without a single /// enabled HOME handler the user can actually land on. Safe fallbacks /// (Settings) don't count — they're a recovery hatch, not a launcher — and @@ -287,6 +334,7 @@ mod tests { name: name.to_string(), package: package.to_string(), source_url: source_url.map(str::to_string), + disable_with: Vec::new(), }; LauncherCatalog { custom: vec![ diff --git a/v2/crates/core/src/engine/mod.rs b/v2/crates/core/src/engine/mod.rs index 305522c..7263a39 100644 --- a/v2/crates/core/src/engine/mod.rs +++ b/v2/crates/core/src/engine/mod.rs @@ -19,8 +19,8 @@ pub use app_lists::{AppList, AppListBundle}; pub use detection::{detect_device_type, tv_evidence, DeviceType, TvEvidence}; pub use diagnostics::{format_diagnostics, DeviceDiagnostics, DiagnosticsInput}; pub use launcher::{ - is_last_enabled_home_handler, is_valid_package_name, launcher_rows, pick_current_home, - HomeReading, LauncherCatalog, LauncherEntry, LauncherStatus, + is_last_enabled_home_handler, is_valid_package_name, launcher_rows, paired_transient_holders, + pick_current_home, HomeReading, LauncherCatalog, LauncherEntry, LauncherStatus, }; pub use media::{ build_capabilities, parse_media_codecs, surround_mode, video_formats, AudioPassthrough, diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 27454ac..d330b72 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -100,6 +100,7 @@ pub fn invoke_handler( launcher::disable_launcher, launcher::set_home_any, launcher::disable_stock_launcher, + launcher::disable_setup_helper, apps::disable_package, apps::enable_package, apps::force_stop, From 242ce9d0b6389a99712eb3087ea1497a9578c727 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 14:53:31 -0500 Subject: [PATCH 02/11] Model the Google TV takeover in the simulator and test the paired disable (#122) --- v2/crates/core/src/adb/sim/device.rs | 24 +++- v2/crates/core/src/adb/sim/tests.rs | 173 ++++++++++++++++++++++++++ v2/crates/core/src/commands/loader.rs | 12 ++ v2/crates/core/src/engine/launcher.rs | 92 ++++++++++++++ 4 files changed, 295 insertions(+), 6 deletions(-) diff --git a/v2/crates/core/src/adb/sim/device.rs b/v2/crates/core/src/adb/sim/device.rs index 9807de1..e47c081 100644 --- a/v2/crates/core/src/adb/sim/device.rs +++ b/v2/crates/core/src/adb/sim/device.rs @@ -67,6 +67,11 @@ pub enum HomePolicy { /// role. Google TV with Setup Wraith enabled (#122): the resolver names /// Setup Wraith even after the role, and Home, moved to Monet. PriorityResolver, + /// Google TV with Setup Wraith enabled (#122): an enabled stock launcher + /// overrides every preference, and once stock is disabled the highest + /// priority HOME filter (Setup Wraith) takes Home back from the app that + /// holds the role, until Setup Wraith is disabled too. + StockThenPriority, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -242,16 +247,23 @@ impl Device { } let handlers: Vec = self.home_handlers().into_iter().cloned().collect(); let real: Vec<&HomeComponent> = handlers.iter().filter(|c| c.priority > -1000).collect(); - if self.home.policy == HomePolicy::PriorityResolver { - if let Some(top) = real.first() { - return Some(top.short()); - } - } - if self.home.policy == HomePolicy::StockOverrides { + let policy = self.home.policy; + if matches!( + policy, + HomePolicy::StockOverrides | HomePolicy::StockThenPriority + ) { if let Some(s) = real.iter().find(|c| stock.contains(&c.package)) { return Some(s.short()); } } + if matches!( + policy, + HomePolicy::PriorityResolver | HomePolicy::StockThenPriority + ) { + if let Some(top) = real.first() { + return Some(top.short()); + } + } if let Some(pref) = &self.home.preferred { if let Some(c) = real.iter().find(|c| c.matches(pref)) { return Some(c.short()); diff --git a/v2/crates/core/src/adb/sim/tests.rs b/v2/crates/core/src/adb/sim/tests.rs index 17add61..af8b3b4 100644 --- a/v2/crates/core/src/adb/sim/tests.rs +++ b/v2/crates/core/src/adb/sim/tests.rs @@ -263,3 +263,176 @@ async fn setup_wraith_outranking_the_role_holder_is_not_the_current_launcher() { .unwrap(); assert!(!wraith.ok); } + +const WRAITH_PKG: &str = "com.google.android.tungsten.setupwraith"; +const GTV_HOME: &str = "com.google.android.apps.tv.launcherx"; +const PROJECTIVY_PKG: &str = "com.spocky.projengmenu"; +const SHIELD_SERIAL: &str = "1324619053514"; + +/// The reporter's onn 4K Pro (#122): stock Google TV Home enabled, Setup +/// Wraith enabled with the highest HOME priority, and Projectivy installed. +fn google_tv_world(with_wraith: bool) -> SimulatedAdb { + let sim = shield_world(); + { + let mut w = sim.world(); + let d = w.devices.get_mut(SHIELD_SERIAL).unwrap(); + d.props.insert("ro.build.version.sdk".into(), "31".into()); + d.home.policy = HomePolicy::StockThenPriority; + d.add_home_app(GTV_HOME, &format!("{GTV_HOME}.home.HomeActivity"), 0); + if with_wraith { + d.add_home_app(WRAITH_PKG, &format!("{WRAITH_PKG}.ui.MainActivity"), 3); + } + d.add_home_app( + PROJECTIVY_PKG, + &format!("{PROJECTIVY_PKG}.ui.home.MainActivity"), + 0, + ); + } + sim +} + +async fn pick_projectivy_and_disable_stock(sim: &SimulatedAdb) -> launcher::SetLauncherResult { + let st = state(sim); + let serial = "192.0.2.1:5555"; + launcher::set_home_any_impl(&st, serial, PROJECTIVY_PKG, None) + .await + .unwrap(); + launcher::disable_stock_launcher_impl(&st, serial, PROJECTIVY_PKG, &launcher::Progress::Silent) + .await + .unwrap() +} + +fn enabled(sim: &SimulatedAdb, pkg: &str) -> bool { + sim.world() + .devices + .get(SHIELD_SERIAL) + .unwrap() + .package(pkg) + .unwrap() + .enabled +} + +/// #122: with stock disabled, Setup Wraith takes the Home key back unless the +/// takeover turns it off too. +#[tokio::test] +async fn google_tv_takeover_disables_setup_wraith_with_stock() { + let sim = google_tv_world(true); + let res = pick_projectivy_and_disable_stock(&sim).await; + assert!(res.ok, "{:?} {:?}", res.last_error, res.diagnostics); + assert!(!enabled(&sim, GTV_HOME)); + assert!(!enabled(&sim, WRAITH_PKG)); + let stock = super::stock_launchers(); + let mut w = sim.world(); + let d = w.devices.get_mut(SHIELD_SERIAL).unwrap(); + assert!(d.resolve_home(&stock).unwrap().starts_with(PROJECTIVY_PKG)); +} + +/// The simulator models the reporter's failure: without the paired disable, +/// Setup Wraith wins the resolver once stock is gone. +#[tokio::test] +async fn google_tv_without_the_paired_disable_wraith_takes_home_back() { + let sim = google_tv_world(true); + sim.world() + .setup_shell( + SHIELD_SERIAL, + &format!("pm disable-user --user 0 {GTV_HOME}"), + ) + .unwrap(); + let stock = super::stock_launchers(); + let mut w = sim.world(); + let d = w.devices.get_mut(SHIELD_SERIAL).unwrap(); + assert!(d.resolve_home(&stock).unwrap().starts_with(WRAITH_PKG)); +} + +/// A failure while turning Setup Wraith off re-enables stock and Setup Wraith. +#[tokio::test] +async fn google_tv_takeover_failure_re_enables_stock_and_wraith() { + let sim = google_tv_world(true); + sim.world().faults.push(FaultRule { + serial: None, + scope: FaultScope::Shell, + matches: format!("pm disable-user --user 0 {WRAITH_PKG}"), + effect: FaultEffect::Fail { + stdout: String::new(), + stderr: "java.lang.SecurityException: Permission denial\n".into(), + exit_code: 255, + }, + times: None, + after: 0, + fired: 0, + }); + let res = pick_projectivy_and_disable_stock(&sim).await; + assert!(!res.ok); + assert!(enabled(&sim, GTV_HOME), "stock re-enabled"); + assert!(enabled(&sim, WRAITH_PKG), "Setup Wraith re-enabled"); +} + +/// A device without Setup Wraith behaves exactly as before: no command ever +/// names the setup helper. +#[tokio::test] +async fn takeover_without_setup_wraith_never_touches_it() { + let sim = google_tv_world(false); + let res = pick_projectivy_and_disable_stock(&sim).await; + assert!(res.ok, "{:?} {:?}", res.last_error, res.diagnostics); + assert!(!format!("{:?}", sim.world().log).contains("setupwraith")); + assert!(!res.diagnostics.iter().any(|l| l.contains("setupwraith"))); +} + +/// Shield path: stock is `com.google.android.tvlauncher`, which pairs with +/// nothing, so the takeover never names the setup helper. +#[tokio::test] +async fn shield_takeover_is_unchanged() { + let sim = shield_world(); + { + let mut w = sim.world(); + let d = w.devices.get_mut(SHIELD_SERIAL).unwrap(); + d.home.policy = HomePolicy::StockOverrides; + } + sim.world() + .setup_shell(SHIELD_SERIAL, "pm enable com.google.android.tvlauncher") + .unwrap(); + let res = pick_projectivy_and_disable_stock(&sim).await; + assert!(res.ok, "{:?} {:?}", res.last_error, res.diagnostics); + assert!(!format!("{:?}", sim.world().log).contains("setupwraith")); +} + +/// The one-click fix: refuses when no real launcher is left, otherwise +/// disables Setup Wraith and keeps Home on a launcher. +#[tokio::test] +async fn disable_setup_helper_is_guarded_and_verified() { + let sim = google_tv_world(true); + let st = state(&sim); + let serial = "192.0.2.1:5555"; + sim.world() + .setup_shell( + SHIELD_SERIAL, + &format!("pm disable-user --user 0 {GTV_HOME}"), + ) + .unwrap(); + sim.world() + .setup_shell( + SHIELD_SERIAL, + &format!("pm disable-user --user 0 {PROJECTIVY_PKG}"), + ) + .unwrap(); + let refused = launcher::disable_setup_helper_impl(&st, serial, WRAITH_PKG) + .await + .unwrap(); + assert!(!refused.ok, "{}", refused.message); + assert!(enabled(&sim, WRAITH_PKG)); + + sim.world() + .setup_shell(SHIELD_SERIAL, &format!("pm enable {PROJECTIVY_PKG}")) + .unwrap(); + let ok = launcher::disable_setup_helper_impl(&st, serial, WRAITH_PKG) + .await + .unwrap(); + assert!(ok.ok, "{}", ok.message); + assert!(!enabled(&sim, WRAITH_PKG)); + + let not_helper = launcher::disable_setup_helper_impl(&st, serial, PROJECTIVY_PKG) + .await + .unwrap(); + assert!(!not_helper.ok); + assert!(enabled(&sim, PROJECTIVY_PKG)); +} diff --git a/v2/crates/core/src/commands/loader.rs b/v2/crates/core/src/commands/loader.rs index c5417ef..9a8df23 100644 --- a/v2/crates/core/src/commands/loader.rs +++ b/v2/crates/core/src/commands/loader.rs @@ -403,6 +403,18 @@ mod tests { Some("Google TV setup helper — not a launcher") ); assert!(cat.is_transient_home_holder("com.google.android.tungsten.setupwraith")); + // #122: only Google TV Home pairs with Setup Wraith; Shield stock pairs with nothing. + assert_eq!( + cat.disable_with_for("com.google.android.apps.tv.launcherx"), + ["com.google.android.tungsten.setupwraith".to_string()] + ); + for entry in cat.stock.iter().filter(|e| !e.disable_with.is_empty()) { + assert_eq!(entry.package, "com.google.android.apps.tv.launcherx"); + assert!(entry + .disable_with + .iter() + .all(|p| cat.is_transient_home_holder(p))); + } } /// A custom launcher with no source is a row whose "Get" link cannot be diff --git a/v2/crates/core/src/engine/launcher.rs b/v2/crates/core/src/engine/launcher.rs index 9fdac98..792711f 100644 --- a/v2/crates/core/src/engine/launcher.rs +++ b/v2/crates/core/src/engine/launcher.rs @@ -626,6 +626,98 @@ mod tests { .all(|r| !r.setup_helper)); } + const GTV_HOME: &str = "com.google.android.apps.tv.launcherx"; + + fn google_tv_catalog() -> LauncherCatalog { + let mut cat = catalog(); + cat.stock.push(LauncherEntry { + name: "Google TV Home (Stock)".to_string(), + package: GTV_HOME.to_string(), + source_url: None, + disable_with: vec![WRAITH.to_string()], + }); + cat + } + + #[test] + fn paired_holders_come_only_from_the_stock_being_disabled() { + let cat = google_tv_catalog(); + let enabled = pkgs(&[MONET, GTV_HOME, WRAITH]); + assert_eq!( + paired_transient_holders(&cat, &pkgs(&[GTV_HOME]), &enabled), + pkgs(&[WRAITH]) + ); + // Shield stock pairs with nothing, even with Setup Wraith enabled. + assert!(paired_transient_holders( + &cat, + &pkgs(&["com.google.android.tvlauncher"]), + &enabled + ) + .is_empty()); + // Already disabled (absent from the enabled list): nothing to do. + assert!( + paired_transient_holders(&cat, &pkgs(&[GTV_HOME]), &pkgs(&[MONET, GTV_HOME])) + .is_empty() + ); + // Two stocks naming one holder disable it once. + assert_eq!( + paired_transient_holders(&cat, &pkgs(&[GTV_HOME, GTV_HOME]), &enabled), + pkgs(&[WRAITH]) + ); + } + + #[test] + fn a_pairing_must_name_a_catalogued_transient_holder() { + let mut cat = google_tv_catalog(); + cat.stock.last_mut().unwrap().disable_with = pkgs(&["com.android.tv.settings"]); + assert!(paired_transient_holders( + &cat, + &pkgs(&[GTV_HOME]), + &pkgs(&["com.android.tv.settings"]) + ) + .is_empty()); + } + + #[test] + fn wraith_alone_is_not_a_home_so_it_is_never_the_survivor() { + // Stock gone, Wraith the only other handler: disabling Wraith would + // leave nothing, and stock disabling would leave only Wraith. + let cat = google_tv_catalog(); + assert!(is_last_enabled_home_handler( + GTV_HOME, + &pkgs(&[GTV_HOME, WRAITH]), + &cat + )); + assert!(is_last_enabled_home_handler(WRAITH, &pkgs(&[WRAITH]), &cat)); + } + + #[test] + fn disabled_setup_wraith_keeps_its_row_without_being_tracked() { + // A disabled package no longer answers the HOME query and a takeover + // does not track it, so the package state alone has to surface it. + let rows = launcher_rows( + &catalog(), + &pkgs(&[MONET, WRAITH]), + &pkgs(&[WRAITH]), + &[], + &[], + ); + let row = rows.iter().find(|r| r.entry.package == WRAITH).unwrap(); + assert!(row.installed && !row.enabled && row.setup_helper && row.other); + // Not installed (a Shield): no row at all. + let rows = launcher_rows(&catalog(), &pkgs(&[MONET]), &[], &[], &[]); + assert!(!rows.iter().any(|r| r.entry.package == WRAITH)); + // Enabled and answering the query: still one row. + let rows = launcher_rows( + &catalog(), + &pkgs(&[MONET, WRAITH]), + &[], + &pkgs(&[WRAITH]), + &[], + ); + assert_eq!(rows.iter().filter(|r| r.entry.package == WRAITH).count(), 1); + } + #[test] fn package_name_validation_accepts_valid() { for valid in &[ From e34ca2efdeb7099ebdde0cc40b6ff3a973607c62 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 14:54:16 -0500 Subject: [PATCH 03/11] Report the setup helper's state in the bug-report bundle (#122) --- v2/crates/core/src/engine/diagnostics.rs | 54 ++++++++++++++++++++++++ v2/src-tauri/src/commands/diagnostics.rs | 49 ++++++++++++++++----- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/v2/crates/core/src/engine/diagnostics.rs b/v2/crates/core/src/engine/diagnostics.rs index fb5215d..11b0ceb 100644 --- a/v2/crates/core/src/engine/diagnostics.rs +++ b/v2/crates/core/src/engine/diagnostics.rs @@ -44,6 +44,11 @@ pub struct DeviceDiagnostics<'a> { /// The current Home app as the Launcher tab reads it (`None` when it /// could not be read), with the role/resolver disagreement note if any. pub current_home: Option<&'a super::launcher::HomeReading>, + /// Catalogued setup helpers (Setup Wraith) installed on the device, as + /// `(package, enabled)`. `None` when the package state could not be read. + /// A disabled one never answers the HOME query, so this is read from the + /// package lists. + pub setup_helpers: Option<&'a [(String, bool)]>, } fn label(evidence: TvEvidence) -> &'static str { @@ -162,6 +167,18 @@ pub fn format_diagnostics(input: &DiagnosticsInput) -> String { if let Some(note) = device.current_home.and_then(|h| h.note.as_deref()) { out.push_str(&format!("- Note: {note}\n")); } + match device.setup_helpers { + None => out.push_str("- Setup helper: unknown (package state unreadable)\n"), + Some([]) => out.push_str("- Setup helper: not installed\n"), + Some(helpers) => { + for (package, enabled) in helpers { + out.push_str(&format!( + "- Setup helper `{package}`: {}\n", + if *enabled { "enabled" } else { "disabled" } + )); + } + } + } out.push_str("\n#### HOME handlers\n\n"); if device.home_handlers.is_empty() { @@ -247,6 +264,34 @@ mod tests { /// The whole point of #120's bundle: the two signals that decide whether /// the tools open have to be in what the user pastes, verbatim. + #[test] + fn setup_helper_state_says_not_installed_or_unknown_without_guessing() { + let render = |helpers: Option<&[(String, bool)]>| { + format_diagnostics(&DiagnosticsInput { + app_version: "2.3.0", + os: "linux", + arch: "x86_64", + adb_path: None, + adb_version: None, + device: Some(DeviceDiagnostics { + serial: "s", + connection: ConnectionType::Usb, + properties: None, + tv_evidence: TvEvidence::Unknown, + device_type: DeviceType::Unknown, + home_handlers: &[], + current_home: None, + setup_helpers: helpers, + }), + unreadable_device: None, + log_tail: &[], + }) + }; + assert!(render(Some(&[])).contains("- Setup helper: not installed")); + assert!(render(None).contains("- Setup helper: unknown")); + assert!(render(Some(&[("a.b.c".to_string(), true)])).contains("`a.b.c`: enabled")); + } + #[test] fn the_device_section_carries_both_tv_signals_and_the_verdict() { let props = props(); @@ -269,11 +314,19 @@ mod tests { activity: None, note: Some("resolve-activity HOME named a setup helper".to_string()), }), + setup_helpers: Some(&[( + "com.google.android.tungsten.setupwraith".to_string(), + false, + )]), }), unreadable_device: None, log_tail: &["first".to_string(), "second".to_string()], }); + assert!( + report.contains("- Setup helper `com.google.android.tungsten.setupwraith`: disabled"), + "{report}" + ); assert!( report.contains("- ro.build.characteristics: `nosdcard`"), "{report}" @@ -318,6 +371,7 @@ mod tests { device_type: DeviceType::Unknown, home_handlers: &[], current_home: None, + setup_helpers: None, }), unreadable_device: None, log_tail: &[], diff --git a/v2/src-tauri/src/commands/diagnostics.rs b/v2/src-tauri/src/commands/diagnostics.rs index 0854e52..381ed29 100644 --- a/v2/src-tauri/src/commands/diagnostics.rs +++ b/v2/src-tauri/src/commands/diagnostics.rs @@ -268,6 +268,31 @@ fn home_handler_components(stdout: &str) -> Vec { launcher::parse_home_handler_packages(stdout) } +/// `(package, enabled)` for each catalogued setup helper that is installed. +/// Read from the package lists because a disabled one never answers the HOME +/// query; `None` when they could not be read. +async fn setup_helper_states( + adb: &dyn shield_optimizer_core::adb::AdbDriver, + serial: &str, +) -> Option> { + let cmd = shield_optimizer_core::adb::checked_batch_command(&[ + "pm list packages", + "pm list packages -d", + ]); + let out = adb.shell(serial, &cmd).await.ok()?; + let sections = shield_optimizer_core::adb::parse_checked_batch(&out.stdout, 2, &[0, 1]).ok()?; + let installed = shield_optimizer_core::adb::parse_installed_packages_output(§ions[0]); + let disabled = shield_optimizer_core::adb::parse_disabled_packages_output(§ions[1]); + Some( + shield_optimizer_core::commands::loader::launchers() + .transient_home_holders + .iter() + .filter(|p| installed.contains(p)) + .map(|p| (p.clone(), !disabled.contains(p))) + .collect(), + ) +} + /// `collect_diagnostics` — the text a user pastes into a bug report. /// /// Nothing is sent anywhere: this command returns a string and the UI puts it @@ -303,7 +328,8 @@ pub async fn collect_diagnostics( .map(|out| home_handler_components(&out.stdout)) .unwrap_or_default(); let current_home = launcher::read_current_home(&*adb, serial).await.ok(); - Some((device, handlers, current_home)) + let helpers = setup_helper_states(&*adb, serial).await; + Some((device, handlers, current_home, helpers)) } Err(e) => { tracing::warn!(serial, error = %e, "diagnostics: device unavailable"); @@ -323,15 +349,18 @@ pub async fn collect_diagnostics( adb_version: adb_version.as_deref(), device: device .as_ref() - .map(|(device, handlers, current_home)| DeviceDiagnostics { - serial: &device.serial, - connection: device.connection, - properties: device.properties.as_ref(), - tv_evidence: device.tv_evidence, - device_type: device.device_type, - home_handlers: handlers, - current_home: current_home.as_ref(), - }), + .map( + |(device, handlers, current_home, helpers)| DeviceDiagnostics { + serial: &device.serial, + connection: device.connection, + properties: device.properties.as_ref(), + tv_evidence: device.tv_evidence, + device_type: device.device_type, + home_handlers: handlers, + current_home: current_home.as_ref(), + setup_helpers: helpers.as_deref(), + }, + ), unreadable_device: match (serial.as_deref(), unreadable.as_deref()) { (Some(serial), Some(error)) => Some((serial, error)), _ => None, From 142f1788d8964c0a018dda0c7c63fdda9b41226f Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 14:58:10 -0500 Subject: [PATCH 04/11] Show Setup Wraith's state on the Launcher tab and add the one-click turn off and re-enable (#122) --- v2/CHANGELOG.md | 14 ++ v2/e2e/scenarios/42-setup-wraith.mjs | 208 ++++++++++++++++++++ v2/mobile/src/lib/types.ts | 1 + v2/src/lib/api.ts | 2 + v2/src/lib/demo-mock.ts | 2 + v2/src/lib/types.ts | 3 + v2/src/routes/devices/[serial]/+page.svelte | 101 +++++++++- 7 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 v2/e2e/scenarios/42-setup-wraith.mjs diff --git a/v2/CHANGELOG.md b/v2/CHANGELOG.md index 4ec1fa1..9fa494b 100644 --- a/v2/CHANGELOG.md +++ b/v2/CHANGELOG.md @@ -65,6 +65,20 @@ this file and shows the newest few sections, dated, from its version button. ### Fixed +- **Disabling the stock launcher on Google TV now turns off Setup Wraith too, so + it can't take the Home button back** (#122). Google TV's setup helper + (`com.google.android.tungsten.setupwraith`) declares a Home screen, and with + the stock launcher gone it became the fallback Home. The takeover now + disables it together with stock, only after your launcher is confirmed as + Home, and checks again afterwards. If anything fails, stock and Setup Wraith + are both re-enabled. The pairing lives in `launchers.json`, so a Shield, which + has no Setup Wraith, behaves exactly as before. Emergency Recovery re-enables + both. The Launcher tab now always says whether Setup Wraith is on or off, warns + when it is on with stock off, offers a one-click **Turn it off** (guarded and + verified like the takeover) and a **Re-enable Setup Wraith** button for when + Google asks you to sign in again or you need to pair a remote. The confirm + names both effects, and the bug-report bundle records its state. + - **An app report no longer carries state from before an Optimize run** (#138). Right after a run, the page re-reads package states in the background. A report opened in that window with state included could carry diff --git a/v2/e2e/scenarios/42-setup-wraith.mjs b/v2/e2e/scenarios/42-setup-wraith.mjs new file mode 100644 index 0000000..ba0cb2e --- /dev/null +++ b/v2/e2e/scenarios/42-setup-wraith.mjs @@ -0,0 +1,208 @@ +// Google TV's Setup Wraith (#122): it declares HOME with a high filter +// priority, so once the stock launcher is disabled it takes the Home button +// back unless the takeover turns it off too. The simulator's +// `stock_then_priority` policy models the reporter's onn 4K Pro. + +import { SHIELD, shieldScenario, stockHomeShield } from "../lib/worlds.mjs"; + +const PROJECTIVY = "com.spocky.projengmenu"; +const GTV_HOME = "com.google.android.apps.tv.launcherx"; +const WRAITH = "com.google.android.tungsten.setupwraith"; +const SDK31 = { "ro.build.version.sdk": "31" }; + +function googleTv(extra = {}) { + const { setup = [], ...rest } = extra; + return shieldScenario({ + props: SDK31, + home_policy: "stock_then_priority", + home_apps: [ + { package: GTV_HOME, class: `${GTV_HOME}.home.HomeActivity`, priority: 0 }, + { package: WRAITH, class: `${WRAITH}.ui.MainActivity`, priority: 3 }, + { package: PROJECTIVY, class: `${PROJECTIVY}.ui.home.MainActivity`, priority: 0 }, + ], + setup, + ...rest, + }); +} + +const HOME_ROLE = `cmd role add-role-holder android.app.role.HOME ${PROJECTIVY}`; + +async function device(ctx) { + return ctx.device(SHIELD.serial); +} + +async function assertKeepsHome(ctx) { + const h = (await device(ctx)).home; + ctx.assert.ok( + h.handlers.some((c) => !c.includes("FallbackHome")), + `a real Home app is still enabled (handlers: ${h.handlers.join(", ")})`, + ); +} + +function callout(ctx) { + return ctx.page.locator(".setup-helper-callout"); +} + +async function takeOver(ctx) { + const set = await ctx.invoke("set_home_any", { serial: SHIELD.key, package: PROJECTIVY, activity: null }); + ctx.assert.equal(set.value.ok, true, "Projectivy accepted as Home"); + return ctx.invoke("disable_stock_launcher", { serial: SHIELD.key, target: PROJECTIVY }); +} + +export const scenarios = [ + { + name: "setup-wraith-takeover-disables-it-and-recovery-restores-both", + async run(ctx) { + await ctx.reset(googleTv()); + const before = await device(ctx); + ctx.assert.ok(before.packages.enabled.includes(WRAITH), "Setup Wraith starts enabled"); + ctx.assert.ok(before.packages.enabled.includes(GTV_HOME), "stock starts enabled"); + + const res = await takeOver(ctx); + ctx.assert.equal(res.value.ok, true, JSON.stringify(res.value.diagnostics)); + const d = await device(ctx); + ctx.assert.ok(d.packages.disabled.includes(GTV_HOME), "stock disabled"); + ctx.assert.ok(d.packages.disabled.includes(WRAITH), "Setup Wraith disabled with it"); + ctx.assert.match(d.home.resolved, new RegExp(PROJECTIVY), "Projectivy keeps Home"); + ctx.assert.equal(d.home.role_holder, PROJECTIVY); + await assertKeepsHome(ctx); + + await ctx.openDevice(SHIELD.key, "launcher"); + await ctx.page.locator("ul.launcher-list").getByText("Google TV setup helper").waitFor(); + await callout(ctx).waitFor(); + ctx.assert.equal(await callout(ctx).getAttribute("data-setup-helper"), "off"); + await ctx.step("takeover left Setup Wraith off, with its way back"); + + await callout(ctx).getByRole("button", { name: "Re-enable Setup Wraith" }).click(); + await ctx.waitFor(async () => (await device(ctx)).packages.enabled.includes(WRAITH), { message: "Setup Wraith re-enabled" }); + await ctx.waitFor(async () => (await callout(ctx).getAttribute("data-setup-helper")) === "on", { message: "callout says it is on" }); + await ctx.step("Re-enable Setup Wraith works"); + + // Emergency Recovery re-enables everything disabled, stock and helper alike. + await ctx.invoke("disable_setup_helper", { serial: SHIELD.key, package: WRAITH }); + const rec = await ctx.invoke("panic_recovery", { serial: SHIELD.key }); + ctx.assert.ok(rec.value.restored.includes(GTV_HOME), "recovery restored stock"); + const after = await device(ctx); + ctx.assert.ok(after.packages.enabled.includes(GTV_HOME), "stock enabled again"); + ctx.assert.ok(after.packages.enabled.includes(WRAITH), "Setup Wraith enabled again"); + await assertKeepsHome(ctx); + }, + }, + { + name: "setup-wraith-takeover-failure-restores-stock-and-the-helper", + async run(ctx) { + await ctx.reset({ + ...googleTv(), + faults: [ + { matches: `pm disable-user --user 0 ${WRAITH}`, effect: { type: "fail", stderr: "java.lang.SecurityException: Permission denial\n", exit_code: 255 } }, + ], + }); + const res = await takeOver(ctx); + ctx.assert.equal(res.value.ok, false, "the takeover reports the failure"); + const d = await device(ctx); + ctx.assert.ok(d.packages.enabled.includes(GTV_HOME), "stock re-enabled"); + ctx.assert.ok(d.packages.enabled.includes(WRAITH), "Setup Wraith re-enabled"); + await assertKeepsHome(ctx); + }, + }, + { + name: "setup-wraith-callout-states", + async run(ctx) { + // Enabled, stock still on: heads-up pointing at the takeover. + await ctx.reset(googleTv()); + await ctx.openDevice(SHIELD.key, "launcher"); + await callout(ctx).waitFor(); + ctx.assert.equal(await callout(ctx).getAttribute("data-setup-helper"), "on"); + ctx.assert.match(await callout(ctx).innerText(), /Disable stock launcher turns it off too/); + ctx.assert.equal(await callout(ctx).getByRole("button").count(), 0, "no action while stock is on"); + await ctx.step("enabled while stock is on"); + + // Enabled while stock is off and nothing real holds Home: it grabs Home. + await ctx.reset(googleTv({ setup: [`pm disable-user --user 0 ${GTV_HOME}`] })); + await ctx.openDevice(SHIELD.key, "launcher"); + await callout(ctx).waitFor(); + ctx.assert.equal(await callout(ctx).getAttribute("data-setup-helper"), "risk"); + ctx.assert.match(await callout(ctx).innerText(), /will likely grab the Home button/); + ctx.assert.match((await device(ctx)).home.resolved, new RegExp(WRAITH), "the resolver names Setup Wraith"); + await ctx.step("enabled while stock is off"); + + await callout(ctx).getByRole("button", { name: "Turn it off" }).click(); + await ctx.waitFor(async () => (await device(ctx)).packages.disabled.includes(WRAITH), { message: "Setup Wraith disabled" }); + await ctx.waitFor(async () => (await callout(ctx).getAttribute("data-setup-helper")) === "off", { message: "callout says it is off" }); + ctx.assert.match((await device(ctx)).home.resolved, new RegExp(PROJECTIVY), "Home lands on Projectivy"); + ctx.assert.match(await callout(ctx).innerText(), /sign in again or you\s+need to pair a remote/); + await ctx.step("one-click fix turned it off"); + await assertKeepsHome(ctx); + + // Disabled: the way back is on the Launcher tab. + await callout(ctx).getByRole("button", { name: "Re-enable Setup Wraith" }).waitFor(); + }, + }, + { + name: "setup-wraith-one-click-fix-refuses-without-a-real-launcher", + async run(ctx) { + await ctx.reset( + googleTv({ + setup: [`pm disable-user --user 0 ${GTV_HOME}`, `pm disable-user --user 0 ${PROJECTIVY}`], + }), + ); + const r = await ctx.invoke("disable_setup_helper", { serial: SHIELD.key, package: WRAITH }); + ctx.assert.equal(r.value.ok, false); + ctx.assert.ok((await device(ctx)).packages.enabled.includes(WRAITH), "left enabled"); + const notHelper = await ctx.invoke("disable_setup_helper", { serial: SHIELD.key, package: PROJECTIVY }); + ctx.assert.equal(notHelper.value.ok, false, "only a catalogued setup helper is accepted"); + await assertKeepsHome(ctx); + }, + }, + { + name: "setup-wraith-confirm-names-both-effects", + async run(ctx) { + // The role readback is unavailable, so Android's answer leaves stock in + // charge and the picker offers the takeover. + await ctx.reset({ + ...googleTv(), + faults: [{ matches: "get-role-holders", effect: { type: "fail", stderr: "Error\n", exit_code: 255 } }], + }); + await ctx.openDevice(SHIELD.key, "launcher"); + await ctx.page.getByText("Advanced: set another app as Home…").click(); + const select = ctx.page.getByLabel("App to set as Home"); + await ctx.waitFor(async () => (await select.locator(`option[value="${PROJECTIVY}"]`).count()) > 0, { message: "picker lists Projectivy" }); + await select.selectOption(PROJECTIVY); + await ctx.page.getByRole("button", { name: "Set as Home" }).click(); + await ctx.page.locator(".home-picker-result").waitFor({ timeout: 30_000 }); + await ctx.page.locator(".home-picker-row").getByRole("button", { name: "Disable stock launcher" }).click(); + const confirm = ctx.page.locator(".home-picker-confirm"); + await confirm.waitFor(); + const text = await confirm.innerText(); + ctx.assert.match(text, /Also turns off Google TV's setup helper \(Setup Wraith\)/); + ctx.assert.match(text, /turn it back on briefly to sign in to Google again or\s+pair a remote/); + ctx.assert.match(text, /Save snapshot first/, "snapshot-first is still offered"); + await ctx.step("confirm names both effects"); + }, + }, + { + name: "setup-wraith-shield-is-unchanged", + async run(ctx) { + // The captured Shield carries a disabled Setup Wraith; a real Shield + // without one is the case under test. + const world = stockHomeShield(); + world.devices[0].setup.push(`pm uninstall --user 0 ${WRAITH}`); + await ctx.reset(world); + await ctx.openDevice(SHIELD.key, "launcher"); + await ctx.page.locator("ul.launcher-list").getByText("Projectivy Launcher").waitFor(); + ctx.assert.equal(await callout(ctx).count(), 0, "no setup-helper callout on a Shield"); + const res = await ctx.invoke("set_home_any", { serial: SHIELD.key, package: PROJECTIVY, activity: null }); + ctx.assert.equal(res.value.ok, false, "stock still holds Home"); + const out = await ctx.invoke("disable_stock_launcher", { serial: SHIELD.key, target: PROJECTIVY }); + ctx.assert.equal(out.value.ok, true, JSON.stringify(out.value.diagnostics)); + const d = await device(ctx); + ctx.assert.ok(d.packages.disabled.includes("com.google.android.tvlauncher"), "stock disabled"); + ctx.assert.match(d.home.resolved, new RegExp(PROJECTIVY)); + const log = (await ctx.log()).map((i) => i.args.join(" ")).join("\n"); + ctx.assert.ok(!log.includes("setupwraith"), "no command named the setup helper"); + await ctx.openDevice(SHIELD.key, "launcher"); + ctx.assert.equal(await callout(ctx).count(), 0, "still no callout"); + await assertKeepsHome(ctx); + }, + }, +]; diff --git a/v2/mobile/src/lib/types.ts b/v2/mobile/src/lib/types.ts index 7bc76d0..883423c 100644 --- a/v2/mobile/src/lib/types.ts +++ b/v2/mobile/src/lib/types.ts @@ -198,6 +198,7 @@ export type Entitlement = "free" | "pro"; export interface LauncherEntry { name: string; package: string; + disable_with?: string[]; } export interface LauncherStatus { diff --git a/v2/src/lib/api.ts b/v2/src/lib/api.ts index 3564122..3e90993 100644 --- a/v2/src/lib/api.ts +++ b/v2/src/lib/api.ts @@ -114,6 +114,8 @@ export const api = { }), disableLauncher: (serial: string, pkg: string) => invoke("disable_launcher", { serial, package: pkg }), + disableSetupHelper: (serial: string, pkg: string) => + invoke("disable_setup_helper", { serial, package: pkg }), setHomeAny: (serial: string, pkg: string, activity: string | null = null) => invoke("set_home_any", { serial, package: pkg, activity }), disableStockLauncher: (serial: string, target: string) => diff --git a/v2/src/lib/demo-mock.ts b/v2/src/lib/demo-mock.ts index 30cb3c2..7a0d870 100644 --- a/v2/src/lib/demo-mock.ts +++ b/v2/src/lib/demo-mock.ts @@ -810,6 +810,8 @@ function handle(cmd: string, args: Record): unknown { diagnostics: [], }; } + case "disable_setup_helper": + return { ok: true, message: "Setup Wraith is off." }; case "disable_stock_launcher": return { ok: true, diff --git a/v2/src/lib/types.ts b/v2/src/lib/types.ts index 28841e4..2d5c995 100644 --- a/v2/src/lib/types.ts +++ b/v2/src/lib/types.ts @@ -86,6 +86,9 @@ export interface LauncherEntry { /// installed. Null for stock launchers and for HOME handlers found on the /// device rather than in the catalog. source_url: string | null; + /// Setup helpers (Setup Wraith) a takeover turns off together with this + /// stock launcher. Present only on a stock entry that has one. + disable_with?: string[]; } export interface LauncherStatus { diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 31075ec..7891ac1 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -1388,10 +1388,13 @@ homePickerBusy = "stock"; homePickerMessage = ""; try { + const wraithNote = takeoverTurnsOffSetupHelper + ? " Google TV's setup helper (Setup Wraith) was turned off too." + : ""; const r = await api.disableStockLauncher(serial, pkg); homePickerOk = r.ok; homePickerMessage = r.ok - ? `The stock launcher is disabled and ${pkg} is Home. Re-enable stock from the list above any time.` + ? `The stock launcher is disabled and ${pkg} is Home.${wraithNote} Re-enable stock from the list above any time.` : (r.last_error ?? "The stock launcher was left alone."); launcherDiagnostics = r.ok ? [] : (r.diagnostics ?? []); stockHoldsHomeFor = null; @@ -1450,6 +1453,60 @@ } } + /// Google TV's setup helper (Setup Wraith), whenever it is installed. A + /// disabled one no longer answers the HOME query, so the backend reads it + /// from the package lists and it stays a row either way. + const setupHelper = $derived(launchers.find((l) => l.setup_helper && l.installed) ?? null); + const stockEnabled = $derived(launchers.some((l) => l.stock && l.enabled)); + const customIsCurrent = $derived( + currentLauncher?.package != null && + !launchers.some( + (l) => l.entry.package === currentLauncher?.package && (l.stock || l.setup_helper), + ), + ); + /// "off": disabled. "on": enabled, with something else holding or able to + /// hold Home. "risk": enabled while stock is off and no custom launcher is + /// current, which is the state where it takes the Home button. + const setupHelperState = $derived<"off" | "on" | "risk" | null>( + !setupHelper + ? null + : !setupHelper.enabled + ? "off" + : stockEnabled || customIsCurrent + ? "on" + : "risk", + ); + /// A takeover will also turn the setup helper off: it is enabled and an + /// enabled stock launcher names it in the catalog. + const takeoverTurnsOffSetupHelper = $derived( + !!setupHelper?.enabled && + launchers.some( + (l) => l.stock && l.enabled && (l.entry.disable_with ?? []).includes(setupHelper.entry.package), + ), + ); + + async function turnOffSetupHelper() { + const row = setupHelper; + if (!row) return; + const pkg = row.entry.package; + launcherActionBusy = pkg; + launcherActionMessage = ""; + launcherProgress = "Turning off Google TV's setup helper"; + try { + const r = await api.disableSetupHelper(serial, pkg); + launcherActionMessage = r.ok + ? "Setup Wraith is off. Press Home on the TV to check it lands on your launcher." + : `Couldn't turn off Setup Wraith: ${r.message.trim() || "failed"}`; + await loadLauncher(); + invalidateDeviceCaches(); + } catch (e) { + launcherActionMessage = String(e); + } finally { + launcherActionBusy = null; + launcherProgress = ""; + } + } + async function disableLauncher(pkg: string) { const row = launchers.find((l) => l.entry.package === pkg); const name = row?.entry.name ?? pkg; @@ -2621,6 +2678,43 @@ {/each} + {#if setupHelper && setupHelperState} +
+ + + {#if setupHelperState === "off"} + Setup Wraith is off. Turn this back on if Google asks you to sign in again or you + need to pair a remote; turn it off again after. + {:else if setupHelperState === "on"} + Google TV's setup helper (Setup Wraith) is on. It can take the Home button back + after you switch launchers. Disable stock launcher turns it off too. + {:else} + Google TV's setup helper (Setup Wraith) is on while the stock launcher is off. It + will likely grab the Home button. + {/if} + + {#if setupHelperState === "off"} + + {:else if setupHelperState === "risk"} + + {/if} +
+ {/if} @@ -2731,6 +2825,11 @@ Disable the stock launcher and hand Home to {homePickerChoice}? If Home doesn't land on it, the stock launcher is re-enabled straight away. You can re-enable it from the list above at any time. + {#if takeoverTurnsOffSetupHelper} + Also turns off Google TV's setup helper (Setup Wraith), or it takes the Home + button back. You may need to turn it back on briefly to sign in to Google again + or pair a remote. + {/if} From da8e3492e8024edd56eef212e9c0e48b50120bc5 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 16:52:06 -0500 Subject: [PATCH 05/11] Re-enabling stock on the Launcher tab re-enables its paired setup helper too (#122) --- v2/e2e/scenarios/42-setup-wraith.mjs | 20 ++++++++++++++++++++ v2/src/routes/devices/[serial]/+page.svelte | 9 +++++++++ 2 files changed, 29 insertions(+) diff --git a/v2/e2e/scenarios/42-setup-wraith.mjs b/v2/e2e/scenarios/42-setup-wraith.mjs index ba0cb2e..2f82dab 100644 --- a/v2/e2e/scenarios/42-setup-wraith.mjs +++ b/v2/e2e/scenarios/42-setup-wraith.mjs @@ -88,6 +88,26 @@ export const scenarios = [ await assertKeepsHome(ctx); }, }, + { + name: "setup-wraith-re-enabling-stock-re-enables-the-helper", + async run(ctx) { + await ctx.reset(googleTv()); + const res = await takeOver(ctx); + ctx.assert.equal(res.value.ok, true, JSON.stringify(res.value.diagnostics)); + await ctx.openDevice(SHIELD.key, "launcher"); + await callout(ctx).waitFor(); + const stockRow = ctx.page.locator("ul.launcher-list li", { hasText: "Google TV Home (Stock)" }); + await stockRow.getByRole("button", { name: "Enable", exact: true }).click(); + await ctx.waitFor( + async () => { + const p = (await device(ctx)).packages.enabled; + return p.includes(GTV_HOME) && p.includes(WRAITH); + }, + { message: "stock and Setup Wraith both re-enabled" }, + ); + await assertKeepsHome(ctx); + }, + }, { name: "setup-wraith-takeover-failure-restores-stock-and-the-helper", async run(ctx) { diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 7891ac1..47fc696 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -1423,6 +1423,15 @@ launcherActionMessage = `Couldn't enable ${name}: ${r.message.trim() || "failed"}`; return; } + // Re-enabling a stock launcher undoes its takeover, which also turned + // off the setup helpers it is paired with (#122). + const pairedOff = (launchers.find((l) => l.entry.package === pkg && l.stock)?.entry.disable_with ?? []).filter( + (h) => launchers.some((l) => l.entry.package === h && l.installed && !l.enabled), + ); + for (const helper of pairedOff) { + launcherProgress = "Re-enabling Google TV's setup helper"; + await api.enablePackage(serial, helper); + } refreshMeasurements(); launcherProgress = "Refreshing the launcher list"; await loadLauncher(); From fcff8ae05a716b42fec709850c19f3b6fd9fe0fc Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 16:52:22 -0500 Subject: [PATCH 06/11] Note stock re-enable in the #122 changelog entry --- v2/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v2/CHANGELOG.md b/v2/CHANGELOG.md index 9fa494b..0b98e12 100644 --- a/v2/CHANGELOG.md +++ b/v2/CHANGELOG.md @@ -71,7 +71,7 @@ this file and shows the newest few sections, dated, from its version button. the stock launcher gone it became the fallback Home. The takeover now disables it together with stock, only after your launcher is confirmed as Home, and checks again afterwards. If anything fails, stock and Setup Wraith - are both re-enabled. The pairing lives in `launchers.json`, so a Shield, which + are both re-enabled, as is re-enabling stock from the Launcher tab. The pairing lives in `launchers.json`, so a Shield, which has no Setup Wraith, behaves exactly as before. Emergency Recovery re-enables both. The Launcher tab now always says whether Setup Wraith is on or off, warns when it is on with stock off, offers a one-click **Turn it off** (guarded and From 50bfa1c14adb456fb990f3f2ba00df4d7831f8ce Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 17:02:04 -0500 Subject: [PATCH 07/11] Show the setup-helper row only with a paired Google TV stock, and warn whenever it is on with stock off (#122) --- v2/crates/core/src/engine/launcher.rs | 23 ++++++++++++++++++--- v2/e2e/scenarios/42-setup-wraith.mjs | 10 ++++----- v2/src/routes/devices/[serial]/+page.svelte | 13 ++++-------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/v2/crates/core/src/engine/launcher.rs b/v2/crates/core/src/engine/launcher.rs index 792711f..6f9d0d0 100644 --- a/v2/crates/core/src/engine/launcher.rs +++ b/v2/crates/core/src/engine/launcher.rs @@ -148,6 +148,14 @@ pub fn launcher_rows( .transient_home_holders .iter() .filter(|pkg| installed_pkgs.iter().any(|p| p == *pkg)) + // Only on Google TV, where a paired stock launcher is installed: a + // Shield ships the package disabled and has no use for the row. + .filter(|pkg| { + catalog.stock.iter().any(|e| { + e.disable_with.iter().any(|h| h == *pkg) + && installed_pkgs.iter().any(|p| p == &e.package) + }) + }) .cloned() .collect(); let other = home_handler_pkgs @@ -696,15 +704,24 @@ mod tests { // A disabled package no longer answers the HOME query and a takeover // does not track it, so the package state alone has to surface it. let rows = launcher_rows( - &catalog(), - &pkgs(&[MONET, WRAITH]), + &google_tv_catalog(), + &pkgs(&[MONET, GTV_HOME, WRAITH]), &pkgs(&[WRAITH]), &[], &[], ); let row = rows.iter().find(|r| r.entry.package == WRAITH).unwrap(); assert!(row.installed && !row.enabled && row.setup_helper && row.other); - // Not installed (a Shield): no row at all. + // Installed but disabled with no Google TV stock (a Shield): no row. + let rows = launcher_rows( + &google_tv_catalog(), + &pkgs(&[MONET, WRAITH]), + &pkgs(&[WRAITH]), + &[], + &[], + ); + assert!(!rows.iter().any(|r| r.entry.package == WRAITH)); + // Not installed: no row at all. let rows = launcher_rows(&catalog(), &pkgs(&[MONET]), &[], &[], &[]); assert!(!rows.iter().any(|r| r.entry.package == WRAITH)); // Enabled and answering the query: still one row. diff --git a/v2/e2e/scenarios/42-setup-wraith.mjs b/v2/e2e/scenarios/42-setup-wraith.mjs index 2f82dab..e2b18c7 100644 --- a/v2/e2e/scenarios/42-setup-wraith.mjs +++ b/v2/e2e/scenarios/42-setup-wraith.mjs @@ -75,7 +75,7 @@ export const scenarios = [ await callout(ctx).getByRole("button", { name: "Re-enable Setup Wraith" }).click(); await ctx.waitFor(async () => (await device(ctx)).packages.enabled.includes(WRAITH), { message: "Setup Wraith re-enabled" }); - await ctx.waitFor(async () => (await callout(ctx).getAttribute("data-setup-helper")) === "on", { message: "callout says it is on" }); + await ctx.waitFor(async () => (await callout(ctx).getAttribute("data-setup-helper")) === "risk", { message: "callout warns it is on with stock off" }); await ctx.step("Re-enable Setup Wraith works"); // Emergency Recovery re-enables everything disabled, stock and helper alike. @@ -203,11 +203,9 @@ export const scenarios = [ { name: "setup-wraith-shield-is-unchanged", async run(ctx) { - // The captured Shield carries a disabled Setup Wraith; a real Shield - // without one is the case under test. - const world = stockHomeShield(); - world.devices[0].setup.push(`pm uninstall --user 0 ${WRAITH}`); - await ctx.reset(world); + // The captured Shield ships Setup Wraith installed but disabled; it must + // get no row and no callout. + await ctx.reset(stockHomeShield()); await ctx.openDevice(SHIELD.key, "launcher"); await ctx.page.locator("ul.launcher-list").getByText("Projectivy Launcher").waitFor(); ctx.assert.equal(await callout(ctx).count(), 0, "no setup-helper callout on a Shield"); diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 47fc696..bcc60de 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -1467,21 +1467,16 @@ /// from the package lists and it stays a row either way. const setupHelper = $derived(launchers.find((l) => l.setup_helper && l.installed) ?? null); const stockEnabled = $derived(launchers.some((l) => l.stock && l.enabled)); - const customIsCurrent = $derived( - currentLauncher?.package != null && - !launchers.some( - (l) => l.entry.package === currentLauncher?.package && (l.stock || l.setup_helper), - ), - ); /// "off": disabled. "on": enabled, with something else holding or able to - /// hold Home. "risk": enabled while stock is off and no custom launcher is - /// current, which is the state where it takes the Home button. + /// hold Home. "risk": enabled while stock is off. The role can still name a + /// custom launcher while the helper's higher priority wins Home, so the + /// role-derived current launcher does not clear it. const setupHelperState = $derived<"off" | "on" | "risk" | null>( !setupHelper ? null : !setupHelper.enabled ? "off" - : stockEnabled || customIsCurrent + : stockEnabled ? "on" : "risk", ); From c8cd6c97b167755496acc0e5bde02ca7fce73732 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 17:58:46 -0500 Subject: [PATCH 08/11] Gate the setup-helper turn-off behind the launcher entitlement and surface a failed helper re-enable (#122) --- v2/crates/core/src/commands/launcher.rs | 1 + v2/src/routes/devices/[serial]/+page.svelte | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index ad65e48..be283b6 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -1170,6 +1170,7 @@ pub async fn disable_setup_helper( serial: String, package: String, ) -> Result { + state.require_pro(Feature::LauncherTakeover)?; let result = disable_setup_helper_impl(state.inner(), &serial, &package).await; match &result { Ok(r) => tracing::info!(%serial, %package, ok = r.ok, "disable setup helper finished"), diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index bcc60de..cf899e9 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -1428,9 +1428,11 @@ const pairedOff = (launchers.find((l) => l.entry.package === pkg && l.stock)?.entry.disable_with ?? []).filter( (h) => launchers.some((l) => l.entry.package === h && l.installed && !l.enabled), ); + const helperFailures: string[] = []; for (const helper of pairedOff) { launcherProgress = "Re-enabling Google TV's setup helper"; - await api.enablePackage(serial, helper); + const hr = await api.enablePackage(serial, helper); + if (!hr.ok) helperFailures.push(`${helper}: ${hr.message.trim() || "failed"}`); } refreshMeasurements(); launcherProgress = "Refreshing the launcher list"; @@ -1452,6 +1454,9 @@ } else { launcherActionMessage = `${name} enabled.`; } + if (helperFailures.length > 0) { + launcherActionMessage += ` Couldn't re-enable Google TV's setup helper (${helperFailures.join("; ")}). Use Re-enable Setup Wraith to retry.`; + } // A launcher's enabled state changed — the Memory tab's report is now stale. invalidateDeviceCaches(); } catch (e) { From e58dcbe48495fa0aa6bacbe9d1382290b860134c Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 23:15:27 -0500 Subject: [PATCH 09/11] Treat an empty package list as unknown in the setup-helper diagnostics (#122) --- v2/src-tauri/src/commands/diagnostics.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/v2/src-tauri/src/commands/diagnostics.rs b/v2/src-tauri/src/commands/diagnostics.rs index 381ed29..a778da5 100644 --- a/v2/src-tauri/src/commands/diagnostics.rs +++ b/v2/src-tauri/src/commands/diagnostics.rs @@ -281,8 +281,17 @@ async fn setup_helper_states( ]); let out = adb.shell(serial, &cmd).await.ok()?; let sections = shield_optimizer_core::adb::parse_checked_batch(&out.stdout, 2, &[0, 1]).ok()?; - let installed = shield_optimizer_core::adb::parse_installed_packages_output(§ions[0]); - let disabled = shield_optimizer_core::adb::parse_disabled_packages_output(§ions[1]); + setup_helper_states_from(§ions[0], §ions[1]) +} + +/// An Android device always has packages, so an empty installed list is an +/// unreadable answer, not proof the helper is absent. +fn setup_helper_states_from(installed: &str, disabled: &str) -> Option> { + let installed = shield_optimizer_core::adb::parse_installed_packages_output(installed); + if installed.is_empty() { + return None; + } + let disabled = shield_optimizer_core::adb::parse_disabled_packages_output(disabled); Some( shield_optimizer_core::commands::loader::launchers() .transient_home_holders @@ -373,6 +382,14 @@ pub async fn collect_diagnostics( mod tests { use super::*; + #[test] + fn an_empty_installed_list_is_unknown_not_absent() { + assert_eq!(setup_helper_states_from("", ""), None); + assert_eq!(setup_helper_states_from("\n", "package:a\n"), None); + let some = setup_helper_states_from("package:com.android.settings\n", ""); + assert_eq!(some, Some(vec![])); + } + #[test] fn home_handlers_are_read_from_the_real_resolveinfo_shape() { // `name=` is the bare class (no slash) and `packageName=` is the From bd338c908b65e33e0f5368e63f20bcc3f742f7bf Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 23:17:23 -0500 Subject: [PATCH 10/11] Roll stock back when the setup-helper check can't be made, and report a failed helper rollback (#122) --- v2/crates/core/src/commands/launcher.rs | 118 +++++++++++++++++++++--- 1 file changed, 106 insertions(+), 12 deletions(-) diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index be283b6..257f9d6 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -584,6 +584,7 @@ pub async fn disable_stock_launcher_impl( serial, target, &disabled, + Some(&handlers), progress, &mut diagnostics, ) @@ -1073,6 +1074,7 @@ async fn disable_paired_holders( serial: &str, target: &str, stocks: &[String], + inventory: Option<&[String]>, progress: &Progress, diagnostics: &mut Vec, ) -> Result<(), String> { @@ -1082,18 +1084,20 @@ async fn disable_paired_holders( { return Ok(()); } - let enabled = match adb.shell(serial, HOME_HANDLER_QUERY).await { - Ok(out) if out.success() && !out.shell_reported_failure() => { - parse_home_handler_packages(&out.stdout) - } - _ => { - diagnostics.push("query-activities HOME (setup helper) -> unavailable".to_string()); - return Ok(()); - } + // The Home-handler list read before stock was disabled. Without it the + // helper can't be checked, and leaving it on with stock off is the #122 + // state, so that is a failure the caller rolls back. + let Some(enabled) = inventory else { + diagnostics.push("query-activities HOME (setup helper) -> unavailable".to_string()); + return Err( + "Couldn't read this TV's Home apps before disabling stock, so the setup helper \ + couldn't be checked" + .to_string(), + ); }; let mut disabled: Vec = Vec::new(); let mut failure = None; - for holder in paired_transient_holders(launchers(), stocks, &enabled) { + for holder in paired_transient_holders(launchers(), stocks, enabled) { if matches!( crate::engine::classify_safety(&holder), crate::engine::Safety::NeverDisable { .. } @@ -1149,14 +1153,25 @@ async fn disable_paired_holders( "Home moved away from {target} after the setup helper was disabled" )); } + let mut unrestored: Vec = Vec::new(); for holder in &disabled { let restore = adb.shell(serial, &format!("pm enable {holder}")).await; diagnostics.push(match command_failure(&restore) { - Some(f) => format!("pm enable {holder} (restore) -> {f}"), + Some(f) => { + unrestored.push(holder.clone()); + format!("pm enable {holder} (restore) -> {f}") + } None => format!("pm enable {holder} (restore) -> ok"), }); } - Err(failure.unwrap_or_default()) + let mut reason = failure.unwrap_or_default(); + if !unrestored.is_empty() { + reason.push_str(&format!( + ". Setup Wraith may still be off ({}). Use Re-enable Setup Wraith or Emergency Recovery", + unrestored.join(", ") + )); + } + Err(reason) } /// `disable_setup_helper` — the Launcher tab's one-click fix for an enabled @@ -1238,7 +1253,12 @@ pub async fn disable_setup_helper_impl( }); } let restore = adb.shell(serial, &format!("pm enable {package}")).await; - let _ = restore; + if let Some(f) = command_failure(&restore) { + return refuse(format!( + "Couldn't confirm {package} is back on ({f}). Setup Wraith may still be off. Use \ + Re-enable Setup Wraith or Emergency Recovery." + )); + } refuse(match failed { Some(f) => format!("Couldn't disable {package}: {f}. It was left enabled."), None => format!( @@ -1288,6 +1308,16 @@ async fn stock_takeover( diagnostics: std::mem::take(diagnostics), }); } + let inventory = if launchers().disable_with_for(active).is_empty() { + Some(Vec::new()) + } else { + match adb.shell(serial, HOME_HANDLER_QUERY).await { + Ok(out) if out.success() && !out.shell_reported_failure() => { + Some(parse_home_handler_packages(&out.stdout)) + } + _ => None, + } + }; progress.step(&format!( "Disabling the stock launcher ({active}) to hand Home over" )); @@ -1316,6 +1346,7 @@ async fn stock_takeover( serial, package, &[active.to_string()], + inventory.as_deref(), progress, diagnostics, ) @@ -2587,6 +2618,69 @@ mod tests { mod launcher_verification_122 { use super::*; + const GTV_STOCK: &str = "com.google.android.apps.tv.launcherx"; + const WRAITH: &str = "com.google.android.tungsten.setupwraith"; + + #[tokio::test] + async fn unreadable_home_apps_never_leave_stock_off_with_the_helper_on() { + let mock = MockAdb::default() + .on_shell("add-role-holder", "Unknown command") + .on_shell_failure("set-home-activity", "Error: no such activity") + .on_shell_err("query-activities", "device offline") + .on_shell_seq( + "resolve-activity", + &[ + &format!("{GTV_STOCK}/.Home"), + &format!("{GTV_STOCK}/.Home"), + "com.example.launcher/.MainActivity", + ], + ); + let log = mock.shell_log(); + let state = state_with(mock); + + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + true, + &Progress::Silent, + ) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + let stock_off = calls + .iter() + .any(|c| c == &format!("pm disable-user --user 0 {GTV_STOCK}")); + let stock_back = calls.iter().any(|c| c == &format!("pm enable {GTV_STOCK}")); + assert!( + !res.ok && (!stock_off || stock_back), + "stock off with the helper unchecked: {calls:?} {:?}", + res.last_error + ); + } + + #[tokio::test] + async fn a_failed_helper_rollback_is_reported_not_called_re_enabled() { + let query = format!( + " priority=0\n packageName={WRAITH}\n priority=0\n packageName=com.example.launcher\n" + ); + let mock = MockAdb::default() + .on_shell("query-activities", &query) + .on_shell("get-role-holders", "") + .on_shell("resolve-activity", &format!("{WRAITH}/.Wraith")) + .on_shell_failure("pm enable", "Failure: not allowed"); + let state = state_with(mock); + + let res = disable_setup_helper_impl(&state, "serial", WRAITH) + .await + .unwrap(); + + assert!(!res.ok); + assert!(res.message.contains("may still be off"), "{}", res.message); + assert!(!res.message.contains("was re-enabled"), "{}", res.message); + } + #[tokio::test] async fn resolver_lag_then_target_confirms_without_rollback() { // The resolver can take a few polls to catch up after a From fad558d23910f8aec6423b23689dfc8d050bbc72 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Fri, 2 Oct 2026 23:30:03 -0500 Subject: [PATCH 11/11] Treat an empty Home inventory as unreadable and report a failed stock restore after a helper error (#122) --- v2/crates/core/src/commands/launcher.rs | 92 +++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index 257f9d6..0503e56 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -603,10 +603,14 @@ pub async fn disable_stock_launcher_impl( Err(reason) => failure = Some(reason), } } + let mut unrestored: Vec = Vec::new(); for stock in &disabled { let restore = adb.shell(serial, &format!("pm enable {stock}")).await; diagnostics.push(match command_failure(&restore) { - Some(f) => format!("pm enable {stock} (restore) -> {f}"), + Some(f) => { + unrestored.push(stock.clone()); + format!("pm enable {stock} (restore) -> {f}") + } None => format!("pm enable {stock} (restore) -> ok"), }); } @@ -617,10 +621,19 @@ pub async fn disable_stock_launcher_impl( Ok(refuse( current, format!( - "{}. The stock launcher was re-enabled.", + "{}. {}", failure.unwrap_or_else(|| format!( "Home moved away from {target} after the stock launcher was disabled" - )) + )), + if unrestored.is_empty() { + "The stock launcher was re-enabled.".to_string() + } else { + format!( + "Re-enabling the stock launcher failed ({}); it may still be off. \ + Use Emergency Recovery or re-enable it from the launcher list.", + unrestored.join(", ") + ) + } ), diagnostics, )) @@ -1312,8 +1325,10 @@ async fn stock_takeover( Some(Vec::new()) } else { match adb.shell(serial, HOME_HANDLER_QUERY).await { + // A device with a stock launcher always has Home handlers, so an + // empty list is an unreadable answer, not an inventory. Ok(out) if out.success() && !out.shell_reported_failure() => { - Some(parse_home_handler_packages(&out.stdout)) + Some(parse_home_handler_packages(&out.stdout)).filter(|h| !h.is_empty()) } _ => None, } @@ -2660,6 +2675,75 @@ mod tests { ); } + #[tokio::test] + async fn an_empty_home_inventory_never_leaves_stock_off_with_the_helper_on() { + let mock = MockAdb::default() + .on_shell("add-role-holder", "Unknown command") + .on_shell_failure("set-home-activity", "Error: no such activity") + .on_shell("query-activities", "") + .on_shell_seq( + "resolve-activity", + &[ + &format!("{GTV_STOCK}/.Home"), + &format!("{GTV_STOCK}/.Home"), + "com.example.launcher/.MainActivity", + ], + ); + let log = mock.shell_log(); + let state = state_with(mock); + + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + true, + &Progress::Silent, + ) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + let stock_off = calls + .iter() + .any(|c| c == &format!("pm disable-user --user 0 {GTV_STOCK}")); + let stock_back = calls.iter().any(|c| c == &format!("pm enable {GTV_STOCK}")); + assert!( + !res.ok && (!stock_off || stock_back), + "stock off with the helper unchecked: {calls:?} {:?}", + res.last_error + ); + } + + #[tokio::test] + async fn a_failed_stock_restore_after_a_helper_error_is_reported() { + let query = format!( + " packageName={GTV_STOCK}\n packageName={WRAITH}\n packageName=com.example.launcher\n" + ); + let mock = MockAdb::default() + .on_shell("query-activities", &query) + .on_shell("resolve-activity", "com.example.launcher/.MainActivity") + .on_shell_failure( + "disable-user --user 0 com.google.android.tungsten", + "Failure: no", + ) + .on_shell_failure(&format!("pm enable {GTV_STOCK}"), "Failure: no"); + let state = state_with(mock); + + let res = disable_stock_launcher_impl( + &state, + "serial", + "com.example.launcher", + &Progress::Silent, + ) + .await + .unwrap(); + + let error = res.last_error.unwrap_or_default(); + assert!(!res.ok); + assert!(error.contains("may still be off"), "{error}"); + assert!(!error.contains("was re-enabled"), "{error}"); + } + #[tokio::test] async fn a_failed_helper_rollback_is_reported_not_called_re_enabled() { let query = format!(