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 diff --git a/v2/crates/core/src/commands/apps.rs b/v2/crates/core/src/commands/apps.rs index aab55b1..a4b397c 100644 --- a/v2/crates/core/src/commands/apps.rs +++ b/v2/crates/core/src/commands/apps.rs @@ -449,12 +449,95 @@ 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 outcome = run( state, serial, &format!("pm disable-user --user 0 {package}"), ) - .await + .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; + 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}." + ); + } + // 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"); + } + Ok(result) } /// `Some(refusal)` when disabling `package` would leave the device without an @@ -490,10 +573,38 @@ 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 + { + // 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"); + } + 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 0503e56..249b1ff 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -217,6 +217,77 @@ 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) +} + +/// 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; + 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; +/// 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 { @@ -798,6 +869,34 @@ pub async fn set_default_launcher_impl( package: &str, 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 = 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 { + // 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) = + 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, + }); + } + } + 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 +1181,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, @@ -1187,6 +1286,118 @@ 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, @@ -2636,6 +2847,289 @@ 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.ok && 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_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_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:?}" + ); + } + + /// #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 + ); + } + + /// #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 + ); + } + + /// 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:?}" + ); + } + + /// 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 4d422f7..77ccb3f 100644 --- a/v2/crates/core/src/commands/snapshot.rs +++ b/v2/crates/core/src/commands/snapshot.rs @@ -422,13 +422,44 @@ 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; // `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 @@ -445,20 +476,80 @@ 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. + launcher_note = r.last_error.clone(); + 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 }; } } + // 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 => { + 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"); + } + 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(), @@ -467,6 +558,14 @@ pub async fn apply_snapshot( settings_deleted.len(), settings_failed.len() ); + if let Some(note) = &launcher_note { + 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 f75f837..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; @@ -435,8 +438,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/Launcher.svelte b/v2/mobile/src/screens/Launcher.svelte index 79807a1..0b62827 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) { @@ -145,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/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 cf899e9..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; @@ -1047,7 +1051,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"); @@ -1100,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; @@ -1140,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; @@ -1420,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 @@ -1540,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); @@ -1591,6 +1615,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