From 1a724568b4fd32212b508445315ee0068f49f9a1 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sat, 3 Oct 2026 00:53:22 -0500 Subject: [PATCH 1/9] WIP: re-enable the paired helper after Enable & set default (#158) --- v2/crates/core/src/commands/launcher.rs | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index 0503e56..50aed45 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -798,6 +798,33 @@ pub async fn set_default_launcher_impl( package: &str, allow_stock_disable: bool, progress: &Progress, +) -> Result { + let mut result = + set_default_launcher_core(state, serial, package, allow_stock_disable, progress).await?; + if result + .diagnostics + .iter() + .any(|l| l == &format!("pm enable {package} -> ok")) + { + let adb = state.adb_snapshot().await; + if let Some(warning) = + reenable_paired_helpers(&*adb, serial, package, &mut result.diagnostics).await + { + result.last_error = Some(match result.last_error.take() { + Some(e) => format!("{e} {warning}"), + None => warning, + }); + } + } + Ok(result) +} + +async fn set_default_launcher_core( + state: &AppState, + serial: &str, + package: &str, + allow_stock_disable: bool, + progress: &Progress, ) -> Result { // Per-stage record of what was issued and what came back. A launcher // failure is only diagnosable with this: the same sequence succeeds on one @@ -1082,7 +1109,7 @@ pub async fn set_default_launcher_impl( /// 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( +pub(crate) async fn disable_paired_holders( adb: &dyn crate::adb::AdbDriver, serial: &str, target: &str, From fa521f48bce2878f5714596312938b353ef26d52 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 4 Oct 2026 11:50:52 -0500 Subject: [PATCH 2/9] Every stock-launcher path disables or restores its paired setup helper (#157, #158) #157: disabling Google TV's stock launcher from the App List, Optimize or the Launcher tab's Disable left Setup Wraith on, and it took the Home button back - the #122 state through a different door. All three go through disable_package_impl, which now reads the Home apps first and, once stock is off, turns the paired helper off through the takeover's guarded, verified path when another launcher already holds Home. A failure turns stock back on. With no other launcher on Home the helper is left on, since it may be the only Home left, and the message says how to finish. #158: Enable & set default on a disabled stock launcher now re-enables its paired helper, decided from the package state before the switch rather than the switch's transcript, and a helper that can't be confirmed back on is reported. --- v2/crates/core/src/commands/apps.rs | 52 +++++- v2/crates/core/src/commands/launcher.rs | 215 +++++++++++++++++++++++- 2 files changed, 260 insertions(+), 7 deletions(-) diff --git a/v2/crates/core/src/commands/apps.rs b/v2/crates/core/src/commands/apps.rs index aab55b1..fac580b 100644 --- a/v2/crates/core/src/commands/apps.rs +++ b/v2/crates/core/src/commands/apps.rs @@ -449,12 +449,60 @@ pub(crate) async fn disable_package_impl( return Ok(refusal); } state.require_pro(Feature::CuratedDebloat)?; - run( + // A stock launcher with a paired setup helper (Google TV's Setup Wraith): + // read the Home apps before disabling it, so the helper can follow (#157). + let pairs_helper = !super::loader::launchers() + .disable_with_for(package) + .is_empty(); + let inventory = if pairs_helper { + let adb = state.adb_snapshot().await; + match adb.shell(serial, super::launcher::HOME_HANDLER_QUERY).await { + Ok(out) if out.success() && !out.shell_reported_failure() => { + Some(super::launcher::parse_home_handler_packages(&out.stdout)) + .filter(|h| !h.is_empty()) + } + _ => None, + } + } else { + None + }; + let mut result = run( state, serial, &format!("pm disable-user --user 0 {package}"), ) - .await + .await?; + if pairs_helper && result.ok { + let adb = state.adb_snapshot().await; + let mut diagnostics = Vec::new(); + use super::launcher::PairedHelperOutcome as Outcome; + match super::launcher::settle_paired_helpers_after_stock_disable( + &*adb, + serial, + package, + inventory.as_deref(), + &mut diagnostics, + ) + .await + { + Outcome::Untouched => {} + Outcome::Disabled { target } => result.message.push_str(&format!( + " Google TV's setup helper (Setup Wraith) was turned off too, so {target} keeps the Home button." + )), + Outcome::RolledBack { reason } => { + result.ok = false; + result.message = format!( + "Couldn't turn off Google TV's setup helper after disabling {package}: {reason}." + ); + } + Outcome::LeftOn => result.message.push_str( + " Google TV's setup helper (Setup Wraith) is still on and can take the Home button. \ + Set another launcher as Home, then use Turn it off on the Launcher tab.", + ), + } + tracing::info!(serial, package, diagnostics = ?diagnostics, "stock disable: paired helper"); + } + Ok(result) } /// `Some(refusal)` when disabling `package` would leave the device without an diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index 50aed45..2b8c670 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -799,13 +799,23 @@ pub async fn set_default_launcher_impl( allow_stock_disable: bool, progress: &Progress, ) -> Result { + // "Enable & set default" on a disabled stock launcher: once stock is back + // on, its paired setup helper comes back too (#158). Decided from the + // package state before the switch, not from the switch's transcript. + let stock_was_disabled = + if launchers().is_stock(package) && !launchers().disable_with_for(package).is_empty() { + let adb = state.adb_snapshot().await; + matches!( + adb.shell(serial, &format!("pm list packages -d {package}")).await, + Ok(out) if out.success() + && out.stdout.lines().any(|l| l.trim() == format!("package:{package}")) + ) + } else { + false + }; let mut result = set_default_launcher_core(state, serial, package, allow_stock_disable, progress).await?; - if result - .diagnostics - .iter() - .any(|l| l == &format!("pm enable {package} -> ok")) - { + if stock_was_disabled { let adb = state.adb_snapshot().await; if let Some(warning) = reenable_paired_helpers(&*adb, serial, package, &mut result.diagnostics).await @@ -1214,6 +1224,118 @@ pub(crate) async fn disable_paired_holders( Err(reason) } +/// What happened to a stock launcher's paired setup helper after that stock +/// launcher was disabled outside the takeover (App List, Optimize, the +/// Launcher tab's Disable). Setup Wraith left on with stock off grabs the Home +/// button back (#157), so the helper follows stock whenever another launcher +/// already holds Home. +pub(crate) enum PairedHelperOutcome { + /// No paired helper, or none enabled: nothing to do. + Untouched, + /// The helper was turned off too and Home stayed on `target`. + Disabled { target: String }, + /// Turning the helper off failed, so stock was turned back on. + RolledBack { reason: String }, + /// No other launcher holds Home yet, so the helper was left on; the + /// Launcher tab warns about it and offers the fix. + LeftOn, +} + +pub(crate) async fn settle_paired_helpers_after_stock_disable( + adb: &dyn crate::adb::AdbDriver, + serial: &str, + stock: &str, + inventory: Option<&[String]>, + diagnostics: &mut Vec, +) -> PairedHelperOutcome { + let catalog = launchers(); + let stocks = vec![stock.to_string()]; + let paired: Vec = match inventory { + Some(enabled) => paired_transient_holders(catalog, &stocks, enabled), + None => catalog.disable_with_for(stock).to_vec(), + }; + if paired.is_empty() { + return PairedHelperOutcome::Untouched; + } + let target = read_current_home(adb, serial) + .await + .ok() + .and_then(|r| r.package) + .filter(|p| p != stock && !catalog.is_stock(p) && !catalog.is_transient_home_holder(p)); + let Some(target) = target else { + diagnostics.push("setup helper left on: no other launcher holds Home".to_string()); + return PairedHelperOutcome::LeftOn; + }; + match disable_paired_holders( + adb, + serial, + &target, + &stocks, + inventory, + &Progress::Silent, + diagnostics, + ) + .await + { + Ok(()) => PairedHelperOutcome::Disabled { target }, + Err(reason) => { + let restore = adb.shell(serial, &format!("pm enable {stock}")).await; + let reason = match command_failure(&restore) { + None => format!("{reason}. The stock launcher was turned back on"), + Some(f) => format!( + "{reason}. Turning the stock launcher back on also failed ({f}); use Emergency Recovery" + ), + }; + PairedHelperOutcome::RolledBack { reason } + } + } +} + +/// Turn a stock launcher's paired setup helpers back on after stock itself +/// was re-enabled (#158). `Some(warning)` when one could not be re-enabled; +/// an unreadable state is reported, never assumed fine. +pub(crate) async fn reenable_paired_helpers( + adb: &dyn crate::adb::AdbDriver, + serial: &str, + stock: &str, + diagnostics: &mut Vec, +) -> Option { + let mut failures = Vec::new(); + for holder in launchers().disable_with_for(stock) { + let state = adb + .shell(serial, &format!("pm list packages -d {holder}")) + .await; + let disabled = match &state { + Ok(out) if out.success() && !out.shell_reported_failure() => out + .stdout + .lines() + .any(|l| l.trim() == format!("package:{holder}")), + _ => { + diagnostics.push(format!("pm list packages -d {holder} -> unreadable")); + failures.push(format!("{holder} (state unreadable)")); + continue; + } + }; + if !disabled { + continue; + } + let result = adb.shell(serial, &format!("pm enable {holder}")).await; + match command_failure(&result) { + None => diagnostics.push(format!("pm enable {holder} (paired helper) -> ok")), + Some(f) => { + diagnostics.push(format!("pm enable {holder} (paired helper) -> {f}")); + failures.push(format!("{holder}: {f}")); + } + } + } + (!failures.is_empty()).then(|| { + format!( + "Couldn't confirm Google TV's setup helper is back on ({}). Use Re-enable Setup Wraith.", + failures.join("; ") + ) + }) +} + /// `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, @@ -2663,6 +2785,89 @@ mod tests { const GTV_STOCK: &str = "com.google.android.apps.tv.launcherx"; const WRAITH: &str = "com.google.android.tungsten.setupwraith"; + fn gtv_home_query() -> String { + format!( + " packageName={GTV_STOCK}\n packageName={WRAITH}\n packageName=com.example.launcher\n" + ) + } + + /// #157: disabling stock from the App List or Optimize while another + /// launcher already holds Home turns Setup Wraith off with it. + #[tokio::test] + async fn a_generic_stock_disable_turns_the_paired_helper_off_too() { + let mock = MockAdb::default() + .on_shell("query-activities", >v_home_query()) + .on_shell("get-role-holders", "com.example.launcher") + .on_shell("resolve-activity", "com.example.launcher/.MainActivity"); + let log = mock.shell_log(); + let state = state_with(mock); + + let res = crate::commands::apps::disable_package_impl(&state, "serial", GTV_STOCK) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + assert!(res.ok, "{}", res.message); + assert!( + calls + .iter() + .any(|c| c == &format!("pm disable-user --user 0 {WRAITH}")), + "helper left on with stock off: {calls:?}" + ); + } + + /// #157: with no other launcher holding Home yet, the helper is left + /// on (it may be the only Home left) and the user is told why. + #[tokio::test] + async fn a_generic_stock_disable_leaves_the_helper_on_without_another_home() { + let mock = MockAdb::default() + .on_shell("query-activities", >v_home_query()) + .on_shell("get-role-holders", "") + .on_shell("resolve-activity", &format!("{WRAITH}/.SetupActivity")); + let log = mock.shell_log(); + let state = state_with(mock); + + let res = crate::commands::apps::disable_package_impl(&state, "serial", GTV_STOCK) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + assert!( + !calls + .iter() + .any(|c| c == &format!("pm disable-user --user 0 {WRAITH}")), + "helper disabled with no launcher to take Home: {calls:?}" + ); + assert!(res.message.contains("still on"), "{}", res.message); + } + + /// #158: "Enable & set default" on a disabled stock launcher brings + /// its paired setup helper back as well. + #[tokio::test] + async fn enable_and_set_default_on_stock_re_enables_the_paired_helper() { + let mock = MockAdb::default() + .on_shell( + "pm list packages -d", + &format!("package:{GTV_STOCK}\npackage:{WRAITH}\n"), + ) + .on_shell("query-activities", >v_home_query()) + .on_shell("get-role-holders", GTV_STOCK) + .on_shell("resolve-activity", &format!("{GTV_STOCK}/.Home")); + let log = mock.shell_log(); + let state = state_with(mock); + + let _ = + set_default_launcher_impl(&state, "serial", GTV_STOCK, false, &Progress::Silent) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + assert!( + calls.iter().any(|c| c == &format!("pm enable {WRAITH}")), + "stock re-enabled but its helper left off: {calls:?}" + ); + } + #[tokio::test] async fn unreadable_home_apps_never_leave_stock_off_with_the_helper_on() { let mock = MockAdb::default() From 41291de36c57055738c2c48cc8d7da0c33a1b8fa Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 4 Oct 2026 11:51:42 -0500 Subject: [PATCH 3/9] Changelog: #157 and #158 --- v2/CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/v2/CHANGELOG.md b/v2/CHANGELOG.md index 0b98e12..1631734 100644 --- a/v2/CHANGELOG.md +++ b/v2/CHANGELOG.md @@ -65,6 +65,15 @@ this file and shows the newest few sections, dated, from its version button. ### Fixed +- **Disabling the stock launcher from the App List or Optimize left Setup Wraith on** + ([#157](https://github.com/bryanroscoe/shield_optimizer/issues/157)). Every way of + disabling Google TV's stock launcher now turns Setup Wraith off with it, once another + launcher holds Home, using the same checks as Disable stock launcher. If no other + launcher holds Home yet, Setup Wraith is left on and the app says how to finish. +- **"Enable & set default" on the stock launcher left Setup Wraith off** + ([#158](https://github.com/bryanroscoe/shield_optimizer/issues/158)). Turning the stock + launcher back on by any route now turns Setup Wraith back on too, on desktop and mobile. + - **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 From 60233e3931c387e9386fd3abffdb0cc863b788ee Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 4 Oct 2026 12:02:22 -0500 Subject: [PATCH 4/9] Restore the paired helper in the shared enable path, only once stock is on, and show its warning on success Codex on #159: - App List and Optimize Restore re-enable stock through apps::enable_package, which never restored Setup Wraith. The shared enable path now does. - Enable & set default could turn the helper on after its stock enable had failed, recreating the state that steals the Home button. The helper is now restored only once stock is confirmed back on. - A helper that couldn't be re-enabled was stored in last_error on a successful switch, which neither UI shows. Both now append it to the success message. --- v2/crates/core/src/commands/apps.rs | 29 ++++- v2/crates/core/src/commands/launcher.rs | 119 +++++++++++++++++++- v2/mobile/src/screens/Launcher.svelte | 8 +- v2/src/routes/devices/[serial]/+page.svelte | 3 + 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/v2/crates/core/src/commands/apps.rs b/v2/crates/core/src/commands/apps.rs index fac580b..1d6585c 100644 --- a/v2/crates/core/src/commands/apps.rs +++ b/v2/crates/core/src/commands/apps.rs @@ -538,10 +538,35 @@ pub async fn enable_package( serial: String, package: String, ) -> Result { - if let Some(rejection) = reject_invalid_package(&package) { + enable_package_impl(&state, &serial, &package).await +} + +pub(crate) async fn enable_package_impl( + state: &AppState, + serial: &str, + package: &str, +) -> Result { + if let Some(rejection) = reject_invalid_package(package) { return Ok(rejection); } - run(&state, &serial, &format!("pm enable {package}")).await + let mut result = run(state, serial, &format!("pm enable {package}")).await?; + // App List and Optimize Restore bring a disabled stock launcher back + // through here, so its paired setup helper (Setup Wraith) follows (#158). + if result.ok + && !super::loader::launchers() + .disable_with_for(package) + .is_empty() + { + let adb = state.adb_snapshot().await; + let mut diagnostics = Vec::new(); + if let Some(warning) = + super::launcher::reenable_paired_helpers(&*adb, serial, package, &mut diagnostics).await + { + result.message.push_str(&format!(" {warning}")); + } + tracing::info!(serial, package, diagnostics = ?diagnostics, "stock enable: paired helper"); + } + Ok(result) } /// `trim_caches` — ask the package manager to clear app caches device-wide. diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index 2b8c670..14c2acc 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -817,9 +817,33 @@ pub async fn set_default_launcher_impl( set_default_launcher_core(state, serial, package, allow_stock_disable, progress).await?; if stock_was_disabled { let adb = state.adb_snapshot().await; - if let Some(warning) = - reenable_paired_helpers(&*adb, serial, package, &mut result.diagnostics).await + // Only once stock is confirmed back on: the helper on with stock off + // is the state that takes the Home button away. + let stock_now_enabled = match adb + .shell(serial, &format!("pm list packages -d {package}")) + .await { + Ok(out) if out.success() && !out.shell_reported_failure() => Some( + !out.stdout + .lines() + .any(|l| l.trim() == format!("package:{package}")), + ), + _ => None, + }; + let warning = match stock_now_enabled { + Some(true) => { + reenable_paired_helpers(&*adb, serial, package, &mut result.diagnostics).await + } + Some(false) => None, + None => Some( + "Couldn't confirm the stock launcher is back on, so Google TV's setup helper \ + was left off. Use Re-enable Setup Wraith once it is." + .to_string(), + ), + }; + // On a successful switch `last_error` is otherwise empty, so the UI + // shows this as a note beside the success message. + if let Some(warning) = warning { result.last_error = Some(match result.last_error.take() { Some(e) => format!("{e} {warning}"), None => warning, @@ -2846,9 +2870,13 @@ mod tests { #[tokio::test] async fn enable_and_set_default_on_stock_re_enables_the_paired_helper() { let mock = MockAdb::default() - .on_shell( + .on_shell_seq( "pm list packages -d", - &format!("package:{GTV_STOCK}\npackage:{WRAITH}\n"), + &[ + &format!("package:{GTV_STOCK}\npackage:{WRAITH}\n"), + &format!("package:{WRAITH}\n"), + &format!("package:{WRAITH}\n"), + ], ) .on_shell("query-activities", >v_home_query()) .on_shell("get-role-holders", GTV_STOCK) @@ -2868,6 +2896,89 @@ mod tests { ); } + /// #158: App List / Optimize Restore re-enable stock through the + /// shared enable command, and the helper follows. + #[tokio::test] + async fn the_shared_enable_command_restores_the_paired_helper() { + let mock = + MockAdb::default().on_shell("pm list packages -d", &format!("package:{WRAITH}\n")); + let log = mock.shell_log(); + let state = state_with(mock); + + let res = crate::commands::apps::enable_package_impl(&state, "serial", GTV_STOCK) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + assert!(res.ok, "{}", res.message); + assert!( + calls.iter().any(|c| c == &format!("pm enable {WRAITH}")), + "stock back on but its helper left off: {calls:?}" + ); + } + + /// The helper is never turned on while stock is still off: that is + /// the state that takes the Home button away. + #[tokio::test] + async fn a_failed_stock_enable_never_turns_the_helper_on() { + let mock = MockAdb::default() + .on_shell( + "pm list packages -d", + &format!("package:{GTV_STOCK}\npackage:{WRAITH}\n"), + ) + .on_shell_failure(&format!("pm enable {GTV_STOCK}"), "Error: denied") + .on_shell("query-activities", >v_home_query()) + .on_shell("get-role-holders", "com.example.launcher") + .on_shell("resolve-activity", "com.example.launcher/.MainActivity"); + let log = mock.shell_log(); + let state = state_with(mock); + + let _ = + set_default_launcher_impl(&state, "serial", GTV_STOCK, false, &Progress::Silent) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + assert!( + !calls.iter().any(|c| c == &format!("pm enable {WRAITH}")), + "helper turned on while stock stayed off: {calls:?}" + ); + } + + /// A helper that can't be re-enabled is reported even when the + /// switch itself succeeded, so the UI can show it. + #[tokio::test] + async fn a_failed_helper_re_enable_is_visible_on_a_successful_switch() { + let mock = MockAdb::default() + .on_shell_seq( + "pm list packages -d", + &[ + &format!("package:{GTV_STOCK}\npackage:{WRAITH}\n"), + &format!("package:{WRAITH}\n"), + &format!("package:{WRAITH}\n"), + ], + ) + .on_shell_failure(&format!("pm enable {WRAITH}"), "Error: denied") + .on_shell("query-activities", >v_home_query()) + .on_shell("get-role-holders", GTV_STOCK) + .on_shell("resolve-activity", &format!("{GTV_STOCK}/.Home")); + let state = state_with(mock); + + let res = + set_default_launcher_impl(&state, "serial", GTV_STOCK, false, &Progress::Silent) + .await + .unwrap(); + + assert!(res.ok, "{:?}", res.last_error); + assert!( + res.last_error + .as_deref() + .is_some_and(|e| e.contains("Re-enable Setup Wraith")), + "{:?}", + res.last_error + ); + } + #[tokio::test] async fn unreadable_home_apps_never_leave_stock_off_with_the_helper_on() { let mock = MockAdb::default() diff --git a/v2/mobile/src/screens/Launcher.svelte b/v2/mobile/src/screens/Launcher.svelte index 79807a1..33f4703 100644 --- a/v2/mobile/src/screens/Launcher.svelte +++ b/v2/mobile/src/screens/Launcher.svelte @@ -113,7 +113,13 @@ (msg) => (progress = msg), ); if (res.ok) { - showToast(`${l.entry.name} is now the default launcher.`, "success"); + // A successful switch only carries `last_error` as a follow-up note. + showToast( + res.last_error + ? `${l.entry.name} is now the default launcher. ${res.last_error}` + : `${l.entry.name} is now the default launcher.`, + res.last_error ? "info" : "success", + ); session.invalidateAll(); await load(); } else if (res.stock_takeover_available) { diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index cf899e9..39f4677 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -1591,6 +1591,9 @@ r.strategy === "disable_stock_takeover" ? `${name} is now your default launcher — the stock launcher was disabled to hand it over. Re-enable it from this list any time.` : `${name} is now your default launcher.`; + // A successful switch only carries `last_error` as a follow-up note + // (e.g. the setup helper couldn't be re-enabled). + if (r.last_error) launcherActionMessage += ` ${r.last_error}`; } else { // Backend messages are full sentences (including the "device accepted // the change — press Home" case) — render them verbatim rather than From 64cff76dcc4fee269e1373ca43e33ba3553c64d5 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 4 Oct 2026 12:21:44 -0500 Subject: [PATCH 5/9] Make partial setup-helper outcomes visible everywhere, and cover the Advanced picker Codex on #159: - A stock disable that had to leave Setup Wraith on, and a stock enable whose helper couldn't follow, came back ok with the detail in the message. The Launcher tab, Optimize and mobile Apps replace successful messages, so both read as plain success. They now return not-ok with a message that says what happened and how to finish, which every caller shows. - The Advanced picker enables a disabled stock launcher itself and skipped the helper restore. It now shares the same before/after check as Enable & set default. --- v2/crates/core/src/commands/apps.rs | 19 ++- v2/crates/core/src/commands/launcher.rs | 162 ++++++++++++++++++------ 2 files changed, 138 insertions(+), 43 deletions(-) diff --git a/v2/crates/core/src/commands/apps.rs b/v2/crates/core/src/commands/apps.rs index 1d6585c..5c33359 100644 --- a/v2/crates/core/src/commands/apps.rs +++ b/v2/crates/core/src/commands/apps.rs @@ -495,10 +495,16 @@ pub(crate) async fn disable_package_impl( "Couldn't turn off Google TV's setup helper after disabling {package}: {reason}." ); } - Outcome::LeftOn => result.message.push_str( - " Google TV's setup helper (Setup Wraith) is still on and can take the Home button. \ - Set another launcher as Home, then use Turn it off on the Launcher tab.", - ), + // Reported as not-ok so every caller shows it: a success toast + // would hide that the helper can still take the Home button. + Outcome::LeftOn => { + result.ok = false; + result.message = format!( + "{package} was disabled, but Google TV's setup helper (Setup Wraith) is still \ + on and can take the Home button. Set another launcher as Home, then use Turn \ + it off on the Launcher tab." + ); + } } tracing::info!(serial, package, diagnostics = ?diagnostics, "stock disable: paired helper"); } @@ -562,7 +568,10 @@ pub(crate) async fn enable_package_impl( if let Some(warning) = super::launcher::reenable_paired_helpers(&*adb, serial, package, &mut diagnostics).await { - result.message.push_str(&format!(" {warning}")); + // Not-ok so Optimize and the App List show it rather than a plain + // "Enabled": stock is back, the helper is not. + result.ok = false; + result.message = format!("{package} was enabled. {warning}"); } tracing::info!(serial, package, diagnostics = ?diagnostics, "stock enable: paired helper"); } diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index 14c2acc..afa3e11 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -217,6 +217,70 @@ pub async fn set_home_any_impl( serial: &str, package: &str, activity: Option<&str>, +) -> Result { + // Picking a disabled stock launcher here enables it, so its paired setup + // helper follows, once stock is confirmed back on (#158). + let stock_was_disabled = stock_is_disabled_with_helper(state, serial, package).await; + let mut result = set_home_any_core(state, serial, package, activity).await?; + if stock_was_disabled { + if let Some(warning) = + restore_helper_if_stock_enabled(state, serial, package, &mut result.diagnostics).await + { + result.message = format!("{} {warning}", result.message); + } + } + Ok(result) +} + +/// A stock launcher with a paired setup helper, currently disabled. +async fn stock_is_disabled_with_helper(state: &AppState, serial: &str, package: &str) -> bool { + if !launchers().is_stock(package) || launchers().disable_with_for(package).is_empty() { + return false; + } + let adb = state.adb_snapshot().await; + matches!( + adb.shell(serial, &format!("pm list packages -d {package}")).await, + Ok(out) if out.success() + && out.stdout.lines().any(|l| l.trim() == format!("package:{package}")) + ) +} + +/// Re-enable `stock`'s paired helpers only once stock is confirmed enabled; +/// the helper on with stock off is the state that takes the Home button. +async fn restore_helper_if_stock_enabled( + state: &AppState, + serial: &str, + stock: &str, + diagnostics: &mut Vec, +) -> Option { + let adb = state.adb_snapshot().await; + let stock_now_enabled = match adb + .shell(serial, &format!("pm list packages -d {stock}")) + .await + { + Ok(out) if out.success() && !out.shell_reported_failure() => Some( + !out.stdout + .lines() + .any(|l| l.trim() == format!("package:{stock}")), + ), + _ => None, + }; + match stock_now_enabled { + Some(true) => reenable_paired_helpers(&*adb, serial, stock, diagnostics).await, + Some(false) => None, + None => Some( + "Couldn't confirm the stock launcher is back on, so Google TV's setup helper \ + was left off. Use Re-enable Setup Wraith once it is." + .to_string(), + ), + } +} + +async fn set_home_any_core( + state: &AppState, + serial: &str, + package: &str, + activity: Option<&str>, ) -> Result { let mut diagnostics = Vec::new(); let refuse = |message: String, diagnostics: Vec| SetHomeAnyResult { @@ -802,48 +866,15 @@ pub async fn set_default_launcher_impl( // "Enable & set default" on a disabled stock launcher: once stock is back // on, its paired setup helper comes back too (#158). Decided from the // package state before the switch, not from the switch's transcript. - let stock_was_disabled = - if launchers().is_stock(package) && !launchers().disable_with_for(package).is_empty() { - let adb = state.adb_snapshot().await; - matches!( - adb.shell(serial, &format!("pm list packages -d {package}")).await, - Ok(out) if out.success() - && out.stdout.lines().any(|l| l.trim() == format!("package:{package}")) - ) - } else { - false - }; + let stock_was_disabled = stock_is_disabled_with_helper(state, serial, package).await; let mut result = set_default_launcher_core(state, serial, package, allow_stock_disable, progress).await?; if stock_was_disabled { - let adb = state.adb_snapshot().await; - // Only once stock is confirmed back on: the helper on with stock off - // is the state that takes the Home button away. - let stock_now_enabled = match adb - .shell(serial, &format!("pm list packages -d {package}")) - .await - { - Ok(out) if out.success() && !out.shell_reported_failure() => Some( - !out.stdout - .lines() - .any(|l| l.trim() == format!("package:{package}")), - ), - _ => None, - }; - let warning = match stock_now_enabled { - Some(true) => { - reenable_paired_helpers(&*adb, serial, package, &mut result.diagnostics).await - } - Some(false) => None, - None => Some( - "Couldn't confirm the stock launcher is back on, so Google TV's setup helper \ - was left off. Use Re-enable Setup Wraith once it is." - .to_string(), - ), - }; // On a successful switch `last_error` is otherwise empty, so the UI // shows this as a note beside the success message. - if let Some(warning) = warning { + if let Some(warning) = + restore_helper_if_stock_enabled(state, serial, package, &mut result.diagnostics).await + { result.last_error = Some(match result.last_error.take() { Some(e) => format!("{e} {warning}"), None => warning, @@ -2862,7 +2893,11 @@ mod tests { .any(|c| c == &format!("pm disable-user --user 0 {WRAITH}")), "helper disabled with no launcher to take Home: {calls:?}" ); - assert!(res.message.contains("still on"), "{}", res.message); + assert!( + !res.ok && res.message.contains("still on"), + "{}", + res.message + ); } /// #158: "Enable & set default" on a disabled stock launcher brings @@ -2979,6 +3014,57 @@ mod tests { ); } + /// #158: the Advanced picker enables a disabled stock launcher itself, + /// so the helper must follow there too. + #[tokio::test] + async fn the_advanced_picker_restores_the_paired_helper_with_stock() { + let mock = MockAdb::default() + .on_shell_seq( + "pm list packages -d", + &[ + &format!("package:{GTV_STOCK}\npackage:{WRAITH}\n"), + &format!("package:{WRAITH}\n"), + &format!("package:{WRAITH}\n"), + ], + ) + .on_shell("query-activities", >v_home_query()) + .on_shell("get-role-holders", GTV_STOCK) + .on_shell("resolve-activity", &format!("{GTV_STOCK}/.Home")); + let log = mock.shell_log(); + let state = state_with(mock); + + let _ = set_home_any_impl(&state, "serial", GTV_STOCK, None) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + assert!( + calls.iter().any(|c| c == &format!("pm enable {WRAITH}")), + "stock enabled by the picker but its helper left off: {calls:?}" + ); + } + + /// A stock enable whose helper can't follow is reported as not-ok, so + /// Optimize and the App List show it instead of "Enabled". + #[tokio::test] + async fn a_shared_enable_with_a_failed_helper_restore_is_not_reported_as_done() { + let mock = MockAdb::default() + .on_shell("pm list packages -d", &format!("package:{WRAITH}\n")) + .on_shell_failure(&format!("pm enable {WRAITH}"), "Error: denied"); + let state = state_with(mock); + + let res = crate::commands::apps::enable_package_impl(&state, "serial", GTV_STOCK) + .await + .unwrap(); + + assert!(!res.ok, "{}", res.message); + assert!( + res.message.contains("Re-enable Setup Wraith"), + "{}", + res.message + ); + } + #[tokio::test] async fn unreadable_home_apps_never_leave_stock_off_with_the_helper_on() { let mock = MockAdb::default() From d5ec864aefcc933ae8a058e63daa04f8761dd63f Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 4 Oct 2026 12:31:24 -0500 Subject: [PATCH 6/9] Treat an unreadable stock preflight as unknown, and keep the helper note through snapshot apply Codex on #159: - A failed "was stock disabled?" read collapsed to no, so the helper was never restored and nothing said so. Unknown now counts as "may need it", and the helper is still restored only once stock is confirmed enabled. - Snapshot apply replaced a successful launcher result with its own success text, dropping a helper re-enable warning. The warning now rides along in the launcher line of the summary. --- v2/crates/core/src/commands/launcher.rs | 50 ++++++++++++++++++++++--- v2/crates/core/src/commands/snapshot.rs | 13 +++++-- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index afa3e11..cc5fc1c 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -232,17 +232,24 @@ pub async fn set_home_any_impl( Ok(result) } -/// A stock launcher with a paired setup helper, currently disabled. +/// Whether the helper may need restoring after this operation: a stock +/// launcher with a paired setup helper that is disabled, or whose state +/// couldn't be read (unknown is treated as "may need it", never as "no"). async fn stock_is_disabled_with_helper(state: &AppState, serial: &str, package: &str) -> bool { if !launchers().is_stock(package) || launchers().disable_with_for(package).is_empty() { return false; } let adb = state.adb_snapshot().await; - matches!( - adb.shell(serial, &format!("pm list packages -d {package}")).await, - Ok(out) if out.success() - && out.stdout.lines().any(|l| l.trim() == format!("package:{package}")) - ) + match adb + .shell(serial, &format!("pm list packages -d {package}")) + .await + { + Ok(out) if out.success() && !out.shell_reported_failure() => out + .stdout + .lines() + .any(|l| l.trim() == format!("package:{package}")), + _ => true, + } } /// Re-enable `stock`'s paired helpers only once stock is confirmed enabled; @@ -3065,6 +3072,37 @@ mod tests { ); } + /// An unreadable "was stock disabled?" read is unknown, not "no": the + /// helper is still restored once stock is confirmed enabled. + #[tokio::test] + async fn an_unreadable_stock_preflight_still_restores_the_helper() { + let mock = MockAdb::default() + .on_shell_seq( + "pm list packages -d", + &[ + "Error: transient", + &format!("package:{WRAITH}\n"), + &format!("package:{WRAITH}\n"), + ], + ) + .on_shell("query-activities", >v_home_query()) + .on_shell("get-role-holders", GTV_STOCK) + .on_shell("resolve-activity", &format!("{GTV_STOCK}/.Home")); + let log = mock.shell_log(); + let state = state_with(mock); + + let _ = + set_default_launcher_impl(&state, "serial", GTV_STOCK, false, &Progress::Silent) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + assert!( + calls.iter().any(|c| c == &format!("pm enable {WRAITH}")), + "unknown preflight skipped the helper: {calls:?}" + ); + } + #[tokio::test] async fn unreadable_home_apps_never_leave_stock_off_with_the_helper_on() { let mock = MockAdb::default() diff --git a/v2/crates/core/src/commands/snapshot.rs b/v2/crates/core/src/commands/snapshot.rs index 4d422f7..8944a1c 100644 --- a/v2/crates/core/src/commands/snapshot.rs +++ b/v2/crates/core/src/commands/snapshot.rs @@ -445,10 +445,15 @@ pub async fn apply_snapshot( if let Ok(r) = result { launcher_set = r.ok; launcher_message = if r.ok { - Some(format!( - "{launcher_pkg} via {}", - r.strategy.unwrap_or_default() - )) + // A successful switch only carries `last_error` as a follow-up + // (the setup helper couldn't be re-enabled); keep it visible. + Some(match r.last_error { + Some(note) => format!( + "{launcher_pkg} via {}. {note}", + r.strategy.unwrap_or_default() + ), + None => format!("{launcher_pkg} via {}", r.strategy.unwrap_or_default()), + }) } else { r.last_error }; From fea28edf1ff06e9b807f0276bdfd5d9558982e87 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 4 Oct 2026 12:44:00 -0500 Subject: [PATCH 7/9] Re-read the device after a not-ok result, and carry the helper note into the snapshot summary Codex on #159: - A stock disable that had to leave Setup Wraith on is reported not-ok, but the stock disable did land; the desktop App List kept its old state and mobile Apps flipped the row back to enabled. Both now re-read the device after any not-ok result instead of assuming nothing changed. - The mobile snapshot toast shows only the summary, which didn't include a launcher follow-up. The summary now carries it, and that apply is shown as a notice rather than a clean success. --- v2/crates/core/src/commands/snapshot.rs | 10 +++++++++- v2/mobile/src/screens/Apps.svelte | 6 +++++- v2/mobile/src/screens/Snapshots.svelte | 8 +++++++- v2/src/routes/devices/[serial]/+page.svelte | 8 +++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/v2/crates/core/src/commands/snapshot.rs b/v2/crates/core/src/commands/snapshot.rs index 8944a1c..1f5235c 100644 --- a/v2/crates/core/src/commands/snapshot.rs +++ b/v2/crates/core/src/commands/snapshot.rs @@ -429,6 +429,9 @@ pub async fn apply_snapshot( // `None` skips the whole switch ladder. let mut launcher_set = false; let mut launcher_message = None; + // A follow-up from a successful launcher switch (the setup helper couldn't + // be re-enabled); the summary carries it so every caller shows it. + let mut launcher_note: Option = None; if let Some(launcher_pkg) = &plan.launcher_to_set { // Reuse the multi-strategy set-default helper from the launcher module. // No stock takeover here: a snapshot that had stock disabled carries @@ -447,6 +450,7 @@ pub async fn apply_snapshot( launcher_message = if r.ok { // A successful switch only carries `last_error` as a follow-up // (the setup helper couldn't be re-enabled); keep it visible. + launcher_note = r.last_error.clone(); Some(match r.last_error { Some(note) => format!( "{launcher_pkg} via {}. {note}", @@ -463,7 +467,7 @@ pub async fn apply_snapshot( let (settings_written, settings_deleted, settings_failed) = apply_settings_from_plan(adb.as_ref(), &serial, &plan).await; - let summary = format!( + let mut summary = format!( "Disabled {} packages ({} failed). Launcher: {}. {} settings written, {} reset ({} failed).", packages_disabled.len(), packages_failed.len(), @@ -472,6 +476,10 @@ pub async fn apply_snapshot( settings_deleted.len(), settings_failed.len() ); + if let Some(note) = &launcher_note { + summary.push(' '); + summary.push_str(note); + } Ok(ApplyResult { packages_disabled, diff --git a/v2/mobile/src/screens/Apps.svelte b/v2/mobile/src/screens/Apps.svelte index f75f837..697acf6 100644 --- a/v2/mobile/src/screens/Apps.svelte +++ b/v2/mobile/src/screens/Apps.svelte @@ -435,8 +435,12 @@ showToast(`${intent.action === "disable" ? "Disabled" : "Uninstalled"} ${label(intent.app)}.`, "success"); session.invalidateAll(); } else { - if (intent.action === "disable") patch(intent.app.package, true, intent); + // A not-ok result can still have landed (stock disabled with its + // setup helper left on), so reload the real state instead of + // assuming nothing changed. showToast(r.message || `${intent.action === "disable" ? "Disable" : "Uninstall"} failed.`, "error"); + session.invalidateAll(); + await loadApps(true); } } catch (e) { if (!actionCurrent(request, intent)) return; diff --git a/v2/mobile/src/screens/Snapshots.svelte b/v2/mobile/src/screens/Snapshots.svelte index 0e74408..5afd691 100644 --- a/v2/mobile/src/screens/Snapshots.svelte +++ b/v2/mobile/src/screens/Snapshots.svelte @@ -119,7 +119,13 @@ busyPath = p.snap.path; try { const res = await api.applySnapshot(p.serial, p.snap.path); - showToast(res.summary || "Snapshot applied.", res.packages_failed.length || res.settings_failed.length ? "info" : "success"); + // The summary carries any launcher follow-up (a setup helper that + // couldn't be re-enabled); that is not a clean success either. + const followUp = res.summary?.includes("Re-enable Setup Wraith"); + showToast( + res.summary || "Snapshot applied.", + res.packages_failed.length || res.settings_failed.length || followUp ? "info" : "success", + ); session.invalidateAll(); } catch (e) { if (isLocked(e)) showPaywall = true; diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 39f4677..73c4e7b 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -1047,7 +1047,13 @@ || request !== mutationRequest || !removalSourceIsCurrent(source, pkg, inventoryVersion)) return; appActionMessage = `${pkg}: ${result.message.trim() || (result.ok ? action === "disable" ? "disabled" : "uninstalled" : "failed")}`; - if (!result.ok) return; + // A not-ok result can still have landed (stock disabled but its setup + // helper left on), so re-read the device rather than guess. + if (!result.ok) { + invalidateDeviceCaches(); + void resyncAfterBulkChange(); + return; + } appActionBusy = null; if (source === "catalog") setCatalogState(pkg, action === "disable" ? "disabled" : "missing"); else patchOtherState(pkg, action === "disable" ? false : "removed"); From c8a9118efc2d92f174871cfd604841f57c86bd94 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 4 Oct 2026 12:59:09 -0500 Subject: [PATCH 8/9] Re-read the device on every not-ok launcher result, and settle the helper during snapshot apply Codex on #159: - Not-ok results from stock enables and Launcher-screen disables can still have landed. The desktop App List enable rows, the desktop Launcher tab's enable and disable, mobile Apps and the mobile Launcher now all re-read the device instead of keeping or reverting their old state. - Snapshot apply disabled stock directly, so a snapshot captured before this fix could turn stock off and leave Setup Wraith on. It now reads the Home apps first and, after the launcher step, settles each disabled stock launcher's helper through the shared guarded path, reporting a left-on or rolled-back helper in the summary. --- v2/crates/core/src/commands/snapshot.rs | 68 ++++++++++++++++++++- v2/mobile/src/screens/Apps.svelte | 5 +- v2/mobile/src/screens/Launcher.svelte | 8 +-- v2/src/routes/devices/[serial]/+page.svelte | 22 ++++++- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/v2/crates/core/src/commands/snapshot.rs b/v2/crates/core/src/commands/snapshot.rs index 1f5235c..d243ad4 100644 --- a/v2/crates/core/src/commands/snapshot.rs +++ b/v2/crates/core/src/commands/snapshot.rs @@ -422,7 +422,35 @@ pub async fn apply_snapshot( ); // 1. Disable packages from the plan (additive only — never re-enable). - let (packages_disabled, packages_failed) = + // A stock launcher with a paired setup helper (Google TV's Setup Wraith) + // needs the Home apps read before it goes off, so the helper can follow + // once Home is settled (#159). Snapshots taken before that fix list stock + // without its helper. + let paired_stocks: Vec = plan + .packages_to_disable + .iter() + .filter(|p| { + !crate::commands::loader::launchers() + .disable_with_for(p) + .is_empty() + }) + .cloned() + .collect(); + let home_inventory = if paired_stocks.is_empty() { + None + } else { + match adb + .shell(&serial, crate::commands::launcher::HOME_HANDLER_QUERY) + .await + { + Ok(out) if out.success() && !out.shell_reported_failure() => Some( + crate::commands::launcher::parse_home_handler_packages(&out.stdout), + ) + .filter(|h| !h.is_empty()), + _ => None, + } + }; + let (mut packages_disabled, mut packages_failed) = disable_from_plan(adb.as_ref(), &serial, &plan.packages_to_disable).await; // 2. Set launcher only when the plan says Home differs from the snapshot's; @@ -464,6 +492,40 @@ pub async fn apply_snapshot( } } + // 3. Settle each disabled stock launcher's setup helper now that Home is + // where the snapshot wants it. + let mut helper_notes: Vec = Vec::new(); + let landed_stocks: Vec = paired_stocks + .iter() + .filter(|s| packages_disabled.contains(s)) + .cloned() + .collect(); + for stock in &landed_stocks { + let mut diagnostics = Vec::new(); + use crate::commands::launcher::PairedHelperOutcome as Outcome; + match crate::commands::launcher::settle_paired_helpers_after_stock_disable( + adb.as_ref(), + &serial, + stock, + home_inventory.as_deref(), + &mut diagnostics, + ) + .await + { + Outcome::Untouched | Outcome::Disabled { .. } => {} + Outcome::RolledBack { reason } => { + packages_disabled.retain(|p| p != stock); + packages_failed.push(format!("{stock} (setup helper: {reason})")); + } + Outcome::LeftOn => helper_notes.push(format!( + "{stock} is off but Google TV's setup helper (Setup Wraith) is still on and can \ + take the Home button. Set another launcher as Home, then use Turn it off on the \ + Launcher tab." + )), + } + tracing::info!(serial = %serial, stock = %stock, diagnostics = ?diagnostics, "snapshot: paired helper"); + } + let (settings_written, settings_deleted, settings_failed) = apply_settings_from_plan(adb.as_ref(), &serial, &plan).await; @@ -480,6 +542,10 @@ pub async fn apply_snapshot( summary.push(' '); summary.push_str(note); } + for note in &helper_notes { + summary.push(' '); + summary.push_str(note); + } Ok(ApplyResult { packages_disabled, diff --git a/v2/mobile/src/screens/Apps.svelte b/v2/mobile/src/screens/Apps.svelte index 697acf6..c75eec3 100644 --- a/v2/mobile/src/screens/Apps.svelte +++ b/v2/mobile/src/screens/Apps.svelte @@ -299,8 +299,11 @@ showToast(`Enabled ${label(app)}`, "success"); session.invalidateAll(); } else { - patch(app.package, false, identity); + // Not-ok can still have landed (stock enabled, its setup helper not), + // so reload the real state instead of reverting the row. showToast(r.message || "Action failed.", "error"); + session.invalidateAll(); + await loadApps(true); } } catch (e) { if (!actionCurrent(request, identity)) return; diff --git a/v2/mobile/src/screens/Launcher.svelte b/v2/mobile/src/screens/Launcher.svelte index 33f4703..0b62827 100644 --- a/v2/mobile/src/screens/Launcher.svelte +++ b/v2/mobile/src/screens/Launcher.svelte @@ -151,10 +151,10 @@ try { const res = await api.disableLauncher(session.serial, l.entry.package); showToast(res.message || (res.ok ? "Disabled." : "Couldn't disable."), res.ok ? "success" : "error"); - if (res.ok) { - session.invalidateAll(); - await load(); - } + // Reload on not-ok too: stock can be disabled with its setup helper + // left on, and the row must show the TV's real state. + session.invalidateAll(); + await load(); } catch (e) { if (isLocked(e)) showPaywall = true; else showToast(String(e), "error"); diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 73c4e7b..e54291e 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -857,6 +857,10 @@ appActionBusy = null; patchOtherState(pkg, true); invalidateDeviceCaches(); + } else { + // Not-ok can still have landed (stock enabled, its setup helper not). + invalidateDeviceCaches(); + void resyncAfterBulkChange(); } } catch (e) { if (!pageContextIsCurrent(context) || request !== mutationRequest || !otherStateIsCurrent(pkg, false, inventoryVersion)) return; @@ -1106,6 +1110,10 @@ appActionBusy = null; setCatalogState(pkg, "enabled"); invalidateDeviceCaches(); + } else { + // Not-ok can still have landed (stock enabled, its setup helper not). + invalidateDeviceCaches(); + void resyncAfterBulkChange(); } } catch (e) { if (!pageContextIsCurrent(context) || request !== mutationRequest || !catalogStateIsCurrent(pkg, "disabled", inventoryVersion)) return; @@ -1146,6 +1154,10 @@ appActionBusy = null; setCatalogState(pkg, "enabled"); invalidateDeviceCaches(); + } else { + // Not-ok can still have landed (stock enabled, its setup helper not). + invalidateDeviceCaches(); + void resyncAfterBulkChange(); } } catch (e) { if (!pageContextIsCurrent(context) || request !== mutationRequest || !catalogStateIsCurrent(pkg, "missing", inventoryVersion)) return; @@ -1426,7 +1438,10 @@ try { const r = await api.enablePackage(serial, pkg); if (!r.ok) { - launcherActionMessage = `Couldn't enable ${name}: ${r.message.trim() || "failed"}`; + launcherActionMessage = `Couldn't fully enable ${name}: ${r.message.trim() || "failed"}`; + // Not-ok can still have landed (stock enabled, its setup helper not). + await loadLauncher(); + invalidateDeviceCaches(); return; } // Re-enabling a stock launcher undoes its takeover, which also turned @@ -1546,7 +1561,10 @@ invalidateDeviceCaches(); launcherActionMessage = `${name} disabled.`; } else { - launcherActionMessage = `Couldn't disable ${name}: ${r.message.trim() || "failed"}`; + launcherActionMessage = `Couldn't fully disable ${name}: ${r.message.trim() || "failed"}`; + // Not-ok can still have landed (stock disabled, its setup helper left on). + await loadLauncher(); + invalidateDeviceCaches(); } } catch (e) { launcherActionMessage = String(e); From 7b83516a45f84949d5617afbb3e1426cd1e7f2ac Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 4 Oct 2026 13:12:05 -0500 Subject: [PATCH 9/9] Settle the helper after an uncertain stock disable, and restore stock when a snapshot leaves no Home Codex on #159: - A stock disable whose reply was lost or failure-marked can still have landed, and the helper step was skipped, leaving stock off with Setup Wraith on. The device is now asked, and the helper is settled whenever stock is actually off. - A snapshot captured after a takeover disables stock and Setup Wraith. If the launcher switch then leaves no other launcher on Home, the TV may have no Home screen; stock is now turned back on (or Emergency Recovery is named) instead of reporting the helper as still on. --- v2/crates/core/src/commands/apps.rs | 35 ++++++++++++++++++++++--- v2/crates/core/src/commands/launcher.rs | 27 +++++++++++++++++++ v2/crates/core/src/commands/snapshot.rs | 30 +++++++++++++++++---- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/v2/crates/core/src/commands/apps.rs b/v2/crates/core/src/commands/apps.rs index 5c33359..a4b397c 100644 --- a/v2/crates/core/src/commands/apps.rs +++ b/v2/crates/core/src/commands/apps.rs @@ -466,13 +466,42 @@ pub(crate) async fn disable_package_impl( } else { None }; - let mut result = run( + let outcome = run( state, serial, &format!("pm disable-user --user 0 {package}"), ) - .await?; - if pairs_helper && result.ok { + .await; + if !pairs_helper { + return outcome; + } + // An errored or failure-marked disable may still have landed (the + // takeover path assumes the same), and stock off with its helper on is + // the unsafe state, so ask the device rather than trust the reply. + let landed = match &outcome { + Ok(r) if r.ok => true, + _ => { + let adb = state.adb_snapshot().await; + matches!( + adb.shell(serial, &format!("pm list packages -d {package}")).await, + Ok(out) if out.success() + && out.stdout.lines().any(|l| l.trim() == format!("package:{package}")) + ) + } + }; + let mut result = match outcome { + Ok(r) if r.ok => r, + Ok(r) if landed => ActionResult { + ok: true, + message: r.message, + }, + Err(_) if landed => ActionResult { + ok: true, + message: format!("{package} was disabled (the device's reply was lost)."), + }, + other => return other, + }; + if result.ok { let adb = state.adb_snapshot().await; let mut diagnostics = Vec::new(); use super::launcher::PairedHelperOutcome as Outcome; diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index cc5fc1c..249b1ff 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -3103,6 +3103,33 @@ mod tests { ); } + /// A disable whose reply was lost may still have landed: the device + /// is asked, and the helper follows if stock is off. + #[tokio::test] + async fn a_landed_disable_with_a_lost_reply_still_settles_the_helper() { + let mock = MockAdb::default() + .on_shell_err( + &format!("pm disable-user --user 0 {GTV_STOCK}"), + "device offline", + ) + .on_shell("pm list packages -d", &format!("package:{GTV_STOCK}\n")) + .on_shell("query-activities", >v_home_query()) + .on_shell("get-role-holders", "com.example.launcher") + .on_shell("resolve-activity", "com.example.launcher/.MainActivity"); + let log = mock.shell_log(); + let state = state_with(mock); + + let _ = crate::commands::apps::disable_package_impl(&state, "serial", GTV_STOCK).await; + + let calls = log.lock().unwrap(); + assert!( + calls + .iter() + .any(|c| c == &format!("pm disable-user --user 0 {WRAITH}")), + "stock off with the helper left on after a lost reply: {calls:?}" + ); + } + #[tokio::test] async fn unreadable_home_apps_never_leave_stock_off_with_the_helper_on() { let mock = MockAdb::default() diff --git a/v2/crates/core/src/commands/snapshot.rs b/v2/crates/core/src/commands/snapshot.rs index d243ad4..77ccb3f 100644 --- a/v2/crates/core/src/commands/snapshot.rs +++ b/v2/crates/core/src/commands/snapshot.rs @@ -517,11 +517,31 @@ pub async fn apply_snapshot( packages_disabled.retain(|p| p != stock); packages_failed.push(format!("{stock} (setup helper: {reason})")); } - Outcome::LeftOn => helper_notes.push(format!( - "{stock} is off but Google TV's setup helper (Setup Wraith) is still on and can \ - take the Home button. Set another launcher as Home, then use Turn it off on the \ - Launcher tab." - )), + Outcome::LeftOn => { + let helper_also_off = crate::commands::loader::launchers() + .disable_with_for(stock) + .iter() + .any(|h| packages_disabled.contains(h)); + if helper_also_off { + // The snapshot turned the helper off too, and no other + // launcher took Home: the TV may have no Home screen. + // Bring stock back rather than report the opposite state. + let restore = adb.shell(&serial, &format!("pm enable {stock}")).await; + let restored = matches!(&restore, Ok(out) if out.success() && !out.shell_reported_failure()); + packages_disabled.retain(|p| p != stock); + packages_failed.push(if restored { + format!("{stock} (turned back on: no other launcher took Home)") + } else { + format!("{stock} (no other launcher took Home and turning it back on failed; use Emergency Recovery)") + }); + } else { + helper_notes.push(format!( + "{stock} is off but Google TV's setup helper (Setup Wraith) is still on and \ + can take the Home button. Set another launcher as Home, then use Turn it \ + off on the Launcher tab." + )); + } + } } tracing::info!(serial = %serial, stock = %stock, diagnostics = ?diagnostics, "snapshot: paired helper"); }