From d794bc4f35cbeff55068e59820c704a4a02197bc Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 21 Jun 2026 20:45:47 -0500 Subject: [PATCH 1/7] Launcher: one-click set-default on disabled launchers + live progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disabled launcher's 'Set as default' was a dead button (gated on !enabled) even though the backend already runs pm enable first — so the user had to Enable, then Set as default. Drop the gate and relabel it 'Enable & set default' when disabled; one click now enables and switches. The multi-strategy switch (enable -> role -> set-home-activity -> verify, with stock-takeover as last resort) can take several seconds, so stream per-step progress over a Tauri Channel and show it inline in the row being acted on, including the verify polls and the final list refresh that otherwise left the spinner frozen on a stale label. Always re-read launcher state when the action finishes so the list and active-launcher line redraw without a manual Refresh. --- v2/src-tauri/src/commands/launcher.rs | 90 +++++++++++++++++---- v2/src-tauri/src/commands/snapshot.rs | 1 + v2/src/lib/api.ts | 18 ++++- v2/src/routes/devices/[serial]/+page.svelte | 65 +++++++++++++-- 4 files changed, 150 insertions(+), 24 deletions(-) diff --git a/v2/src-tauri/src/commands/launcher.rs b/v2/src-tauri/src/commands/launcher.rs index c09630c..9cf74d4 100644 --- a/v2/src-tauri/src/commands/launcher.rs +++ b/v2/src-tauri/src/commands/launcher.rs @@ -13,6 +13,23 @@ use super::{home_tracking, AppState}; const HOME_HANDLER_QUERY: &str = "cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.HOME"; +/// Per-step progress sink for `set_default_launcher`. The multi-strategy +/// switch can take a few seconds (enable → role → set-home-activity → verify, +/// with backoff polls in between), so the frontend passes a Channel to narrate +/// each step. Internal callers (snapshot apply, tests) use `Progress::Silent`. +pub enum Progress { + Channel(tauri::ipc::Channel), + Silent, +} + +impl Progress { + fn step(&self, msg: &str) { + if let Progress::Channel(ch) = self { + let _ = ch.send(msg.to_string()); + } + } +} + #[tauri::command] pub async fn list_launchers( state: State<'_, AppState>, @@ -166,12 +183,14 @@ pub async fn set_default_launcher( serial: String, package: String, allow_stock_disable: Option, + on_progress: tauri::ipc::Channel, ) -> Result { set_default_launcher_impl( state.inner(), &serial, &package, allow_stock_disable.unwrap_or(false), + &Progress::Channel(on_progress), ) .await } @@ -185,6 +204,7 @@ pub async fn set_default_launcher_impl( serial: &str, package: &str, allow_stock_disable: bool, + progress: &Progress, ) -> Result { // `package` can come from a custom-launcher entry the user typed, so it's // interpolated into shell commands below — validate it first. @@ -201,6 +221,7 @@ pub async fn set_default_launcher_impl( let adb = state.adb_snapshot().await; // 1. Enable the package — no-op for already-enabled. + progress.step("Enabling this launcher"); let _ = adb.shell(serial, &format!("pm enable {package}")).await; let mut last_error: Option = None; @@ -210,6 +231,7 @@ pub async fn set_default_launcher_impl( let mut device_accepted = false; // 2. Role API. + progress.step("Assigning the Home role to it"); let role_out = adb .shell( serial, @@ -218,6 +240,7 @@ pub async fn set_default_launcher_impl( .await; match role_out { Ok(out) if !out.stdout.contains("Unknown command") => { + progress.step("Confirming the switch took"); if verify_active(&*adb, serial, package).await { return Ok(SetLauncherResult { ok: true, @@ -243,6 +266,7 @@ pub async fn set_default_launcher_impl( } // 3. Discover activity candidates. + progress.step("Registering it as the Home app"); let mut candidates: Vec = Vec::new(); if let Some(activity) = discover_home_activity(&*adb, serial, package).await { candidates.push(activity); @@ -273,6 +297,7 @@ pub async fn set_default_launcher_impl( }; if is_success_ack(msg) || msg.is_empty() { device_accepted = true; + progress.step("Confirming the switch took"); if verify_active(&*adb, serial, package).await { return Ok(SetLauncherResult { ok: true, @@ -297,6 +322,7 @@ pub async fn set_default_launcher_impl( // 4. HOME-intent kick — system will resolve to the only remaining HOME app // if everything else got disabled. + progress.step("Switching Home over to it"); let _ = adb .shell( serial, @@ -345,6 +371,9 @@ pub async fn set_default_launcher_impl( stock_takeover_available: true, }); } + progress.step(&format!( + "Disabling the stock launcher ({active}) to hand Home over" + )); let disabled_ok = matches!( adb.shell(serial, &format!("pm disable-user --user 0 {active}")).await, Ok(ref o) if !o.shell_reported_failure() @@ -356,6 +385,7 @@ pub async fn set_default_launcher_impl( "am start -W -a android.intent.action.MAIN -c android.intent.category.HOME", ) .await; + progress.step("Confirming the switch took"); if verify_active(&*adb, serial, package).await { return Ok(SetLauncherResult { ok: true, @@ -502,9 +532,15 @@ mod tests { let log = mock.shell_log(); let state = state_with(mock); - let res = set_default_launcher_impl(&state, "serial", "com.example.launcher", false) - .await - .unwrap(); + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + false, + &Progress::Silent, + ) + .await + .unwrap(); assert!(res.ok); assert_eq!(res.strategy.as_deref(), Some("role_api")); @@ -530,9 +566,15 @@ mod tests { let log = mock.shell_log(); let state = state_with(mock); - let res = set_default_launcher_impl(&state, "serial", "com.example.launcher", false) - .await - .unwrap(); + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + false, + &Progress::Silent, + ) + .await + .unwrap(); assert!(res.ok); assert_eq!(res.strategy.as_deref(), Some("set_home_activity")); @@ -551,9 +593,15 @@ mod tests { .on_shell_failure("set-home-activity", "Error: Activity class does not exist"); let state = state_with(mock); - let res = set_default_launcher_impl(&state, "serial", "com.example.launcher", false) - .await - .unwrap(); + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + false, + &Progress::Silent, + ) + .await + .unwrap(); assert!(!res.ok); assert_eq!(res.strategy, None); @@ -575,9 +623,15 @@ mod tests { let log = mock.shell_log(); let state = state_with(mock); - let res = set_default_launcher_impl(&state, "serial", "com.example.launcher", true) - .await - .unwrap(); + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + true, + &Progress::Silent, + ) + .await + .unwrap(); assert!(!res.ok); let calls = log.lock().unwrap(); @@ -609,9 +663,15 @@ mod tests { let log = mock.shell_log(); let state = state_with(mock); - let res = set_default_launcher_impl(&state, "serial", "com.example.launcher", true) - .await - .unwrap(); + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + true, + &Progress::Silent, + ) + .await + .unwrap(); assert!(res.ok); assert_eq!(res.strategy.as_deref(), Some("disable_stock_takeover")); diff --git a/v2/src-tauri/src/commands/snapshot.rs b/v2/src-tauri/src/commands/snapshot.rs index 7df3625..ab16479 100644 --- a/v2/src-tauri/src/commands/snapshot.rs +++ b/v2/src-tauri/src/commands/snapshot.rs @@ -427,6 +427,7 @@ pub async fn apply_snapshot( &serial, launcher_pkg, false, + &crate::commands::launcher::Progress::Silent, ) .await; if let Ok(r) = result { diff --git a/v2/src/lib/api.ts b/v2/src/lib/api.ts index ca87b0f..b613d65 100644 --- a/v2/src/lib/api.ts +++ b/v2/src/lib/api.ts @@ -1,7 +1,7 @@ // Typed wrappers around Tauri's `invoke()`. Single point of contact with the // Rust backend — every command goes through here. -import { invoke } from "@tauri-apps/api/core"; +import { invoke, Channel } from "@tauri-apps/api/core"; import type { ActionResult, AdbStatus, @@ -80,8 +80,20 @@ export const api = { invoke("current_launcher", { serial }), channelProviderDisabled: (serial: string) => invoke("channel_provider_disabled", { serial }), - setDefaultLauncher: (serial: string, pkg: string, allowStockDisable = false) => - invoke("set_default_launcher", { serial, package: pkg, allowStockDisable }), + setDefaultLauncher: ( + serial: string, + pkg: string, + allowStockDisable = false, + onProgress?: Channel, + ) => + invoke("set_default_launcher", { + serial, + package: pkg, + allowStockDisable, + // The command always expects a progress channel; callers that don't care + // (e.g. the enable-then-restore path) get a throwaway one. + onProgress: onProgress ?? new Channel(), + }), disableLauncher: (serial: string, pkg: string) => invoke("disable_launcher", { serial, package: pkg }), diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index b1be386..44f94b4 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -4,6 +4,7 @@ import { goto } from "$app/navigation"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; + import { Channel } from "@tauri-apps/api/core"; import { api } from "$lib/api"; import type { Device, @@ -72,6 +73,7 @@ let launcherErr = $state(null); let launcherActionBusy = $state(null); // package id currently being acted on let launcherActionMessage = $state(""); + let launcherProgress = $state(""); // live per-step status while a switch is in flight let apps = $state([]); let appsLoaded = $state(false); @@ -712,22 +714,30 @@ async function setDefaultLauncher(pkg: string) { launcherActionBusy = pkg; launcherActionMessage = ""; + launcherProgress = ""; + // The backend works through several strategies (enable → role → set-home- + // activity → verify) that can take a few seconds; narrate each step so the + // user knows it's working rather than hung. + const onProgress = new Channel(); + onProgress.onmessage = (step) => { + if (launcherActionBusy === pkg) launcherProgress = step; + }; try { - let r = await api.setDefaultLauncher(serial, pkg); + let r = await api.setDefaultLauncher(serial, pkg, false, onProgress); if (!r.ok && r.stock_takeover_available) { // The only working method on this build disables the stock launcher. // Never do that silently — ask, then retry with the opt-in flag. + launcherProgress = ""; const proceed = confirm( `${r.last_error ?? "This device ignores the standard launcher-switch commands."}\n\nDisable the stock launcher and switch to ${pkg}? You can re-enable it from this list at any time.`, ); - if (proceed) r = await api.setDefaultLauncher(serial, pkg, true); + if (proceed) r = await api.setDefaultLauncher(serial, pkg, true, onProgress); } if (r.ok) { launcherActionMessage = r.strategy === "disable_stock_takeover" ? `Set ${pkg} as default — the stock launcher was disabled to hand HOME over (re-enable it from the list any time).` : `Set ${pkg} as default launcher (via ${r.strategy ?? "ok"}).`; - await loadLauncher(); } else { // Backend messages are full sentences (including the "device accepted // the change — press Home" case) — render them verbatim rather than @@ -735,10 +745,18 @@ launcherActionMessage = r.last_error ?? "Could not set default launcher. Try disabling other launchers first."; } + // Always re-read state: the switch can land a beat after the backend's + // own poll window, and the takeover path flips enabled/disabled badges — + // so the list should redraw without the user hitting Refresh. This reload + // is itself a few ADB queries, so keep the row's status line alive for it + // rather than leaving the spinner frozen on the last backend step. + if (launcherActionBusy === pkg) launcherProgress = "Refreshing the launcher list"; + await loadLauncher(); } catch (e) { launcherActionMessage = String(e); } finally { launcherActionBusy = null; + launcherProgress = ""; } } @@ -1336,6 +1354,11 @@ {/if}
{l.entry.package}
+ {#if busy && launcherProgress} +
+ {launcherProgress}… +
+ {/if}
@@ -1376,10 +1399,12 @@ {/if} {#if !isCurrent && l.enabled} @@ -2127,6 +2152,34 @@ border-radius: 4px; word-break: break-word; } + .launcher-progress { + display: flex; + align-items: center; + gap: 0.45rem; + margin-top: 0.35rem; + color: var(--accent); + font-size: 0.8rem; + font-weight: 500; + } + .launcher-progress .spinner { + width: 0.85rem; + height: 0.85rem; + flex: none; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: launcher-spin 0.7s linear infinite; + } + @keyframes launcher-spin { + to { + transform: rotate(360deg); + } + } + @media (prefers-reduced-motion: reduce) { + .launcher-progress .spinner { + animation: none; + } + } .dismiss { margin-left: 0.5rem; padding: 0 0.3rem; From e2064c34bc2fdf95411f18ebaff349dcd8c59982 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 21 Jun 2026 20:51:43 -0500 Subject: [PATCH 2/7] Launcher: stop repeating the verify-progress message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrating 'Confirming…' before all three verify attempts (and the takeover flow runs the strategy chain twice) made it churn the same line over and over. Keep a single check, only in the takeover path. --- v2/src-tauri/src/commands/launcher.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/v2/src-tauri/src/commands/launcher.rs b/v2/src-tauri/src/commands/launcher.rs index 9cf74d4..27672a4 100644 --- a/v2/src-tauri/src/commands/launcher.rs +++ b/v2/src-tauri/src/commands/launcher.rs @@ -240,7 +240,6 @@ pub async fn set_default_launcher_impl( .await; match role_out { Ok(out) if !out.stdout.contains("Unknown command") => { - progress.step("Confirming the switch took"); if verify_active(&*adb, serial, package).await { return Ok(SetLauncherResult { ok: true, @@ -297,7 +296,6 @@ pub async fn set_default_launcher_impl( }; if is_success_ack(msg) || msg.is_empty() { device_accepted = true; - progress.step("Confirming the switch took"); if verify_active(&*adb, serial, package).await { return Ok(SetLauncherResult { ok: true, @@ -385,7 +383,7 @@ pub async fn set_default_launcher_impl( "am start -W -a android.intent.action.MAIN -c android.intent.category.HOME", ) .await; - progress.step("Confirming the switch took"); + progress.step("Checking whether Home switched over"); if verify_active(&*adb, serial, package).await { return Ok(SetLauncherResult { ok: true, From 48d315c79e44bc3b995f9bdab87bc845b1b0bd96 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 21 Jun 2026 21:16:55 -0500 Subject: [PATCH 3/7] Launcher: fast, reliable switch away from stock (disable-stock first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified live on Shield/Android 11: an enabled stock launcher overrides both set-home-activity and the role API — they return 'Success' but HOME keeps resolving to stock. The only thing that hands HOME over is disabling stock (exactly what v1's Launcher Wizard does). Switching between two non-stock launchers via set-home-activity works fine. The old flow ran the full polite ladder first (role + set-home-activity, each with a ~1.6s verify back-off) before offering the disable-stock takeover, so leaving stock took ~4s of doomed attempts and a misleading 'press Home' message before the confirm even appeared. Now: when a stock launcher currently holds HOME, try the polite setters once, and if HOME still resolves to stock go straight to the opt-in disable-stock takeover. Other launchers are never touched. The takeover logic is extracted into one shared helper used by both the fast path and the ladder's last resort. --- v2/src-tauri/src/commands/launcher.rs | 281 +++++++++++++++++++------- 1 file changed, 213 insertions(+), 68 deletions(-) diff --git a/v2/src-tauri/src/commands/launcher.rs b/v2/src-tauri/src/commands/launcher.rs index 27672a4..16348e7 100644 --- a/v2/src-tauri/src/commands/launcher.rs +++ b/v2/src-tauri/src/commands/launcher.rs @@ -168,15 +168,18 @@ pub struct SetLauncherResult { /// `set_default_launcher` — port of v1's multi-strategy promotion (PR #17/#18). /// Strategy: /// 1. `pm enable ` — unblock a previously-disabled launcher. -/// 2. Modern role API: `cmd role add-role-holder android.app.role.HOME `. -/// Skipped immediately when the build returns "Unknown command". -/// 3. For each discovered HOME activity (via `cmd package query-activities -/// --components`, falling back to common-name guesses), try -/// `cmd package set-home-activity --user 0 ` then `pm -/// set-home-activity --user 0 `. -/// 4. Last resort: send a HOME intent — when other launchers are disabled, -/// the system resolves to the only remaining one. -/// Every attempt is verified by re-resolving the active launcher. +/// 2. Stock fast path: if a *stock* launcher currently holds HOME, try the +/// polite setters (role + set-home-activity) once and, if HOME still +/// resolves to stock, go straight to the opt-in disable-stock takeover. +/// An enabled stock launcher overrides set-home-activity / the role API +/// (they answer "Success" but HOME stays on stock; verified live on +/// Shield / Android 11), so the only reliable switch is to disable stock — +/// v1's Launcher-Wizard move. Other launchers are never touched. +/// 3. Otherwise (switching between non-stock launchers) run the full ladder: +/// role API → set-home-activity over discovered/guessed HOME activities → +/// HOME-intent kick → disable-stock takeover as a last resort. +/// Every attempt is verified by re-resolving the active launcher, and the +/// takeover is gated on the caller's explicit `allow_stock_disable` opt-in. #[tauri::command] pub async fn set_default_launcher( state: State<'_, AppState>, @@ -224,6 +227,64 @@ pub async fn set_default_launcher_impl( progress.step("Enabling this launcher"); let _ = adb.shell(serial, &format!("pm enable {package}")).await; + // Stock fast path: when the launcher currently holding HOME is *stock*, the + // polite setters can't win — an enabled stock launcher overrides + // set-home-activity and the role API (they return "Success" but HOME keeps + // resolving to stock; verified live on Shield / Android 11). So try the + // cheap setters exactly once, and if HOME still resolves to stock go + // straight to the opt-in disable-stock takeover rather than grinding the + // full strategy ladder with its multi-second verify back-offs. Switches + // between non-stock launchers fall through to the normal ladder below, + // which works for them. + if let Some(active) = active_launcher(&*adb, serial).await { + let active_is_stock = stock_launcher_catalog().iter().any(|e| e.package == active); + if active_is_stock && active != package { + progress.step("Assigning the Home role to it"); + let _ = adb + .shell( + serial, + &format!("cmd role add-role-holder android.app.role.HOME {package}"), + ) + .await; + if let Some(comp) = discover_home_activity(&*adb, serial, package).await { + progress.step("Registering it as the Home app"); + let _ = adb + .shell( + serial, + &format!("cmd package set-home-activity --user 0 {comp}"), + ) + .await; + } + // When the setters work they take effect immediately; when stock + // overrides them they never will — one quick check is enough. + progress.step("Checking whether Home switched over"); + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + if active_launcher(&*adb, serial).await.as_deref() == Some(package) { + return Ok(SetLauncherResult { + ok: true, + strategy: Some("set_home_activity".into()), + current_launcher: Some(package.to_string()), + last_error: None, + stock_takeover_available: false, + }); + } + if let Some(result) = stock_takeover( + &*adb, + serial, + package, + &active, + allow_stock_disable, + progress, + ) + .await + { + return Ok(result); + } + // Stock is on the NEVER_DISABLE list — fall through to the ladder, + // which will at least try the polite strategies and report cleanly. + } + } + let mut last_error: Option = None; // Set when a strategy was acknowledged by the device ("Success" or a clean // silent exit) even if the active-HOME resolver never confirmed the switch @@ -339,66 +400,22 @@ pub async fn set_default_launcher_impl( }); } - // 5. The strategy that actually works on Android 11 TV builds where the - // role API silently no-ops and set-home-activity answers "Success" - // without effect (verified live on a Shield 2019 / Android 11): when the - // app holding HOME is a *stock* launcher, disable it — Android then - // resolves HOME to the remaining launcher. This is v1's proven - // Setup-Launcher mechanism — but it is NEVER applied without the - // caller's explicit opt-in (`allow_stock_disable`); otherwise we report - // that the option exists and let the user decide. Never touches a custom - // launcher; the NEVER_DISABLE gate still applies; and if the takeover - // doesn't verify, the stock launcher is re-enabled. + // 5. Last resort: if the launcher still holding HOME is *stock*, disable it + // (the same takeover the stock fast path uses). This catches the case where + // HOME wasn't stock at the start but resolved back to it after the polite + // strategies. Gated, never touches other launchers, re-enables on failure. if let Some(active) = now_active.clone() { - let active_is_stock = stock_launcher_catalog().iter().any(|e| e.package == active); - let blocked = matches!( - crate::engine::classify_safety(&active), - crate::engine::Safety::NeverDisable { .. } - ); - if active != package && active_is_stock && !blocked { - if !allow_stock_disable { - return Ok(SetLauncherResult { - ok: false, - strategy: None, - current_launcher: Some(active.clone()), - last_error: Some(format!( - "This device ignores the standard launcher-switch commands. The only \ - way to hand HOME to {package} is to disable the stock launcher \ - ({active}) — it can be re-enabled from the list at any time." - )), - stock_takeover_available: true, - }); - } - progress.step(&format!( - "Disabling the stock launcher ({active}) to hand Home over" - )); - let disabled_ok = matches!( - adb.shell(serial, &format!("pm disable-user --user 0 {active}")).await, - Ok(ref o) if !o.shell_reported_failure() - ); - if disabled_ok { - let _ = adb - .shell( - serial, - "am start -W -a android.intent.action.MAIN -c android.intent.category.HOME", - ) - .await; - progress.step("Checking whether Home switched over"); - if verify_active(&*adb, serial, package).await { - return Ok(SetLauncherResult { - ok: true, - strategy: Some("disable_stock_takeover".into()), - current_launcher: Some(package.to_string()), - last_error: None, - stock_takeover_available: false, - }); - } - // Takeover didn't verify — put the stock launcher back the - // way we found it rather than leaving a half-applied state. - if is_valid_package_name(&active) { - let _ = adb.shell(serial, &format!("pm enable {active}")).await; - } - } + if let Some(result) = stock_takeover( + &*adb, + serial, + package, + &active, + allow_stock_disable, + progress, + ) + .await + { + return Ok(result); } } @@ -423,6 +440,87 @@ pub async fn set_default_launcher_impl( }) } +/// 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 +/// Shield / Android 11), this is the only switch that sticks. It's v1's proven +/// Launcher-Wizard move: leave the *other* launchers alone, just take stock out +/// of the way. Gated on `allow_stock_disable`; without it, returns +/// `stock_takeover_available` so the UI can confirm. Never disables a +/// NEVER_DISABLE package, and re-enables stock if the switch doesn't verify. +/// Returns `None` when `active` isn't a disable-able stock launcher — the +/// caller then falls through to its normal failure path. +async fn stock_takeover( + adb: &dyn crate::adb::AdbDriver, + serial: &str, + package: &str, + active: &str, + allow_stock_disable: bool, + progress: &Progress, +) -> Option { + let active_is_stock = stock_launcher_catalog().iter().any(|e| e.package == active); + let blocked = matches!( + crate::engine::classify_safety(active), + crate::engine::Safety::NeverDisable { .. } + ); + if active == package || !active_is_stock || blocked { + return None; + } + if !allow_stock_disable { + return Some(SetLauncherResult { + ok: false, + strategy: None, + current_launcher: Some(active.to_string()), + last_error: Some(format!( + "Switching to {package} means disabling the stock launcher ({active}) — on this \ + device that's the only thing that hands HOME over. Your other launchers are left \ + alone, and stock can be re-enabled from this list at any time." + )), + stock_takeover_available: true, + }); + } + progress.step(&format!( + "Disabling the stock launcher ({active}) to hand Home over" + )); + let disabled_ok = matches!( + adb.shell(serial, &format!("pm disable-user --user 0 {active}")).await, + Ok(ref o) if !o.shell_reported_failure() + ); + if disabled_ok { + let _ = adb + .shell( + serial, + "am start -W -a android.intent.action.MAIN -c android.intent.category.HOME", + ) + .await; + progress.step("Checking whether Home switched over"); + if verify_active(adb, serial, package).await { + return Some(SetLauncherResult { + ok: true, + strategy: Some("disable_stock_takeover".into()), + current_launcher: Some(package.to_string()), + last_error: None, + stock_takeover_available: false, + }); + } + // Didn't verify — put stock back rather than leave a half-applied state. + if is_valid_package_name(active) { + let _ = adb.shell(serial, &format!("pm enable {active}")).await; + } + } + Some(SetLauncherResult { + ok: false, + strategy: None, + current_launcher: Some(active.to_string()), + last_error: Some(format!( + "Disabled {active} but HOME still didn't switch to {package}, so it was re-enabled to \ + avoid leaving the device without a launcher. Try again, or set it from the TV's \ + Settings." + )), + stock_takeover_available: false, + }) +} + /// `cmd package set-home-activity` / `pm set-home-activity` acknowledge with a /// bare "Success" line on many builds (and stay silent on others). That's an /// acceptance, not a diagnostic — surfacing it as an error produced the @@ -644,6 +742,50 @@ mod tests { ); } + #[tokio::test] + async fn set_launcher_from_stock_offers_takeover_without_grinding_the_ladder() { + // Stock holds HOME and overrides the polite setters (the resolver keeps + // naming stock even after set-home-activity "Success"). Without the + // opt-in, the fast path should try the setter once and then surface the + // takeover immediately — no HOME-intent kick, no stock disabled. + let mock = MockAdb::default() + .on_shell("add-role-holder", "Success") + .on_shell("query-activities", "com.example.launcher/.MainActivity") + .on_shell("set-home-activity", "Success") + .on_shell("resolve-activity", "com.google.android.tvlauncher/.Home"); + let log = mock.shell_log(); + let state = state_with(mock); + + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + false, + &Progress::Silent, + ) + .await + .unwrap(); + + assert!(!res.ok); + assert!( + res.stock_takeover_available, + "should offer the disable-stock takeover" + ); + let calls = log.lock().unwrap(); + assert!( + calls.iter().any(|c| c.contains("set-home-activity")), + "fast path should try the polite setter once" + ); + assert!( + !calls.iter().any(|c| c.contains("am start")), + "fast path should skip the HOME-intent kick" + ); + assert!( + !calls.iter().any(|c| c.contains("disable-user")), + "must not disable stock without the opt-in" + ); + } + #[tokio::test] async fn set_launcher_stock_takeover_does_not_revert_when_it_verifies() { // Resolver reports the stock launcher until it is disabled, then the @@ -654,6 +796,9 @@ mod tests { .on_shell_seq( "resolve-activity", &[ + // stock holds HOME on the fast-path read and the quick check, + // then the target after the takeover disables stock. + "com.google.android.tvlauncher/.Home", "com.google.android.tvlauncher/.Home", "com.example.launcher/.MainActivity", ], From 718473654364a67ddfb7ae016b795c19c197dd96 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 21 Jun 2026 21:24:57 -0500 Subject: [PATCH 4/7] Launcher: open the new launcher on success + friendlier status text Foreground the now-default launcher with a HOME intent on the success paths that didn't already (role API, set-home-activity, stock fast path), so the TV actually shows the new launcher instead of sitting on the old screen until the user presses Home. Gated behind verify_active, so it only ever foregrounds the confirmed target. Status message now reads ' is now your default launcher.' using the friendly launcher name instead of the raw package id, and drops the internal '(via role_api)' strategy detail that meant nothing to users. --- v2/src-tauri/src/commands/launcher.rs | 22 ++++++++++++++++++++- v2/src/routes/devices/[serial]/+page.svelte | 7 ++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/v2/src-tauri/src/commands/launcher.rs b/v2/src-tauri/src/commands/launcher.rs index 16348e7..97fb97d 100644 --- a/v2/src-tauri/src/commands/launcher.rs +++ b/v2/src-tauri/src/commands/launcher.rs @@ -260,6 +260,7 @@ pub async fn set_default_launcher_impl( progress.step("Checking whether Home switched over"); tokio::time::sleep(std::time::Duration::from_millis(400)).await; if active_launcher(&*adb, serial).await.as_deref() == Some(package) { + focus_home(&*adb, serial, progress).await; return Ok(SetLauncherResult { ok: true, strategy: Some("set_home_activity".into()), @@ -302,6 +303,7 @@ pub async fn set_default_launcher_impl( match role_out { Ok(out) if !out.stdout.contains("Unknown command") => { if verify_active(&*adb, serial, package).await { + focus_home(&*adb, serial, progress).await; return Ok(SetLauncherResult { ok: true, strategy: Some("role_api".into()), @@ -358,6 +360,7 @@ pub async fn set_default_launcher_impl( if is_success_ack(msg) || msg.is_empty() { device_accepted = true; if verify_active(&*adb, serial, package).await { + focus_home(&*adb, serial, progress).await; return Ok(SetLauncherResult { ok: true, strategy: Some("set_home_activity".into()), @@ -529,6 +532,21 @@ fn is_success_ack(s: &str) -> bool { s.trim().eq_ignore_ascii_case("success") } +/// Foreground the now-default launcher so the TV actually shows it the moment +/// we report success — otherwise the screen sits on whatever was up (or the +/// screensaver) until the user presses Home. HOME resolves to the default we +/// just set, so this brings the new launcher forward. Fire-and-forget; the +/// takeover and HOME-intent-kick paths already do this inline. +async fn focus_home(adb: &dyn crate::adb::AdbDriver, serial: &str, progress: &Progress) { + progress.step("Opening the new launcher"); + let _ = adb + .shell( + serial, + "am start -a android.intent.action.MAIN -c android.intent.category.HOME", + ) + .await; +} + /// Poll the active-HOME resolver until it reports `package`, with backoff. /// Propagation after set-home-activity / role changes isn't instant on every /// build — a single immediate check produced false "failed" results even when @@ -648,7 +666,9 @@ mod tests { let calls = log.lock().unwrap(); assert!(!calls.iter().any(|c| c.contains("set-home-activity"))); assert!(!calls.iter().any(|c| c.contains("query-activities"))); - assert!(!calls.iter().any(|c| c.contains("am start"))); + // The HOME-intent *kick* (am start -W) must not run; the plain `am start` + // that foregrounds the launcher on success is expected. + assert!(!calls.iter().any(|c| c.contains("am start -W"))); } #[tokio::test] diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 44f94b4..2371de6 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -712,6 +712,7 @@ } async function setDefaultLauncher(pkg: string) { + const name = launchers.find((l) => l.entry.package === pkg)?.entry.name ?? pkg; launcherActionBusy = pkg; launcherActionMessage = ""; launcherProgress = ""; @@ -729,15 +730,15 @@ // Never do that silently — ask, then retry with the opt-in flag. launcherProgress = ""; const proceed = confirm( - `${r.last_error ?? "This device ignores the standard launcher-switch commands."}\n\nDisable the stock launcher and switch to ${pkg}? You can re-enable it from this list at any time.`, + `${r.last_error ?? "This device ignores the standard launcher-switch commands."}\n\nDisable the stock launcher and switch to ${name}? You can re-enable it from this list at any time.`, ); if (proceed) r = await api.setDefaultLauncher(serial, pkg, true, onProgress); } if (r.ok) { launcherActionMessage = r.strategy === "disable_stock_takeover" - ? `Set ${pkg} as default — the stock launcher was disabled to hand HOME over (re-enable it from the list any time).` - : `Set ${pkg} as default launcher (via ${r.strategy ?? "ok"}).`; + ? `${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.`; } else { // Backend messages are full sentences (including the "device accepted // the change — press Home" case) — render them verbatim rather than From ba2ec37bbb0f75e8d307efa63ccd50e447807233 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 21 Jun 2026 21:30:25 -0500 Subject: [PATCH 5/7] Launcher + memory actions: consistent busy status, spinner, and feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Launcher Disable now shows 'Disabling…', an inline status line, refreshes the list, and confirms ' disabled.' (was a bare 'Disable' that gave no sign it worked). Enable gets the same inline status + friendly names. - Memory-table Force stop / Disable buttons show a spinner + 'Stopping…' / 'Disabling…' instead of a bare '…', with clearer result messages. - Generalize the spinner into a reusable .spinner / .busy pair. --- v2/src/routes/devices/[serial]/+page.svelte | 66 +++++++++++++++------ 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 2371de6..b84c518 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -407,8 +407,8 @@ try { const r = await api.forceStop(serial, pkg); appActionMessage = r.ok - ? `${pkg}: stopped — refresh the report to see freed RAM.` - : `${pkg}: ${r.message.trim()}`; + ? `${pkg} stopped — its RAM frees up now (it restarts on next launch). Refresh the report to see the change.` + : `Couldn't stop ${pkg}: ${r.message.trim()}`; } catch (e) { appActionMessage = String(e); } finally { @@ -660,54 +660,70 @@ } async function enableLauncher(pkg: string) { + const name = launchers.find((l) => l.entry.package === pkg)?.entry.name ?? pkg; const prevDefault = currentLauncher?.package ?? null; + const prevName = prevDefault + ? (launchers.find((l) => l.entry.package === prevDefault)?.entry.name ?? prevDefault) + : null; launcherActionBusy = pkg; launcherActionMessage = ""; + launcherProgress = "Enabling this launcher"; try { const r = await api.enablePackage(serial, pkg); if (!r.ok) { - launcherActionMessage = `${pkg}: ${r.message.trim() || "failed"}`; + launcherActionMessage = `Couldn't enable ${name}: ${r.message.trim() || "failed"}`; return; } + launcherProgress = "Refreshing the launcher list"; await loadLauncher(); // Android clears its preferred-HOME record when a launcher package's // state changes, so a freshly re-enabled launcher (especially stock) // can steal the active-launcher slot. Enabling ≠ switching — put the // user's previous default back. if (prevDefault && prevDefault !== pkg && currentLauncher?.package === pkg) { + launcherProgress = `Restoring ${prevName} as default`; const back = await api.setDefaultLauncher(serial, prevDefault); await loadLauncher(); launcherActionMessage = back.ok - ? `Enabled ${pkg}. Android made it the active launcher, so ${prevDefault} was re-set as your default.` + ? `Enabled ${name}. Android made it the active launcher, so ${prevName} was re-set as your default.` : back.stock_takeover_available - ? `Enabled ${pkg} — it also took over HOME, and this build can't hand HOME back without disabling it again. Use "Set as default" on ${prevDefault} if you want it back.` - : `Enabled ${pkg} — Android made it the active launcher, and re-setting ${prevDefault} failed` + + ? `Enabled ${name} — it also took over HOME, and this build can't hand HOME back without disabling it again. Use "Set as default" on ${prevName} if you want it back.` + : `Enabled ${name} — Android made it the active launcher, and re-setting ${prevName} failed` + `${back.last_error ? `: ${back.last_error}` : ""}. Use "Set as default" on your preferred launcher.`; } else { - launcherActionMessage = `${pkg}: enabled`; + launcherActionMessage = `${name} enabled.`; } } catch (e) { launcherActionMessage = String(e); } finally { launcherActionBusy = null; + launcherProgress = ""; } } async function disableLauncher(pkg: string) { + const name = launchers.find((l) => l.entry.package === pkg)?.entry.name ?? pkg; const advice = launchers.find((l) => l.entry.package === pkg)?.other ? " Tip: save a snapshot first (Snapshot tab) so you have a record of today's state." : ""; - if (!confirm(`Disable ${pkg}? You'll lose access to it as a HOME app until you re-enable.${advice}`)) return; + if (!confirm(`Disable ${name}? You'll lose access to it as a HOME app until you re-enable.${advice}`)) return; launcherActionBusy = pkg; launcherActionMessage = ""; + launcherProgress = "Disabling this launcher"; try { const r = await api.disableLauncher(serial, pkg); - launcherActionMessage = `${pkg}: ${r.message.trim() || (r.ok ? "disabled" : "failed")}`; - if (r.ok) await loadLauncher(); + if (r.ok) { + launcherProgress = "Refreshing the launcher list"; + await loadLauncher(); + launcherActionMessage = `${name} disabled.`; + } else { + launcherActionMessage = `Couldn't disable ${name}: ${r.message.trim() || "failed"}`; + } } catch (e) { launcherActionMessage = String(e); } finally { launcherActionBusy = null; + launcherProgress = ""; } } @@ -1290,7 +1306,11 @@ disabled={appActionBusy === m.package} title="am force-stop {m.package} — frees its RAM now; the app restarts on next launch" > - {appActionBusy === m.package ? "…" : "Force stop"} + {#if appActionBusy === m.package} + Stopping… + {:else} + Force stop + {/if} {#if blocked} Protected @@ -1302,7 +1322,11 @@ disabled={appActionBusy === m.package} title="pm disable-user --user 0 {m.package}" > - {appActionBusy === m.package ? "…" : "Disable"} + {#if appActionBusy === m.package} + Disabling… + {:else} + Disable + {/if} {/if} @@ -1414,7 +1438,7 @@ onclick={() => disableLauncher(l.entry.package)} disabled={launcherActionBusy !== null} title="pm disable-user --user 0 {l.entry.package}" - >Disable + >{busy ? "Disabling…" : "Disable"} {:else if isCurrent} Date: Sun, 21 Jun 2026 21:41:38 -0500 Subject: [PATCH 6/7] Memory table: drop the row when an app is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit safeDisableFromMemory updated the catalog state but never the health report's top_memory list that feeds the table, so a disabled app's row lingered until a full refresh. Remove the row on success — a disabled app isn't running, so it belongs out of the memory list immediately. --- v2/src/routes/devices/[serial]/+page.svelte | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index b84c518..273c8f6 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -458,6 +458,13 @@ prompt += "Proceed?"; if (!confirm(prompt)) return; await disableApp(pkg); + // The memory table is fed by the health report's top_memory list, which + // disableApp doesn't touch — so the row lingered until a full refresh. A + // disabled app isn't running, so drop its row now (freed RAM and the rest + // reconcile on the next report refresh). + if (appStates[pkg] === "disabled" && report) { + report.top_memory = report.top_memory.filter((m) => m.package !== pkg); + } } /// Record a curated app's new on-device state and keep the two tabs in From de29498088d68488b877230f778678f03ce0d68c Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Sun, 21 Jun 2026 21:54:26 -0500 Subject: [PATCH 7/7] Cross-tab refresh: invalidate other tabs' caches on device-state change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each tab cached its own snapshot, so a change in one left the others stale — disabling a launcher from the Memory tab left the Launchers tab showing it as enabled. Add invalidateDeviceCaches(): a state change marks the other data tabs (Launchers, Memory report) for a fresh load on next visit, while the acting tab refreshes itself inline. Existing data stays on screen until each reload completes, so there's no flash. Wired into every package/launcher state change (enable/disable/uninstall, set-default, launcher enable/disable) plus the bulk paths (snapshot apply, panic recovery, Optimize wizard). --- v2/src/routes/devices/[serial]/+page.svelte | 55 ++++++++++++++++++--- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 273c8f6..8f813e2 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -43,6 +43,10 @@ let report = $state(null); let reportLoading = $state(false); let reportErr = $state(null); + // Set when a device-state change in another tab makes the health report + // stale, so it re-fetches (in the background, keeping current data on screen) + // the next time the Memory tab is opened. + let healthStale = $state(false); let reportLastRefreshed = $state(null); let liveRefresh = $state(false); let liveRefreshTimer: ReturnType | null = null; @@ -331,7 +335,10 @@ try { const r = await api.disablePackage(serial, pkg); appActionMessage = `${pkg}: ${r.message.trim() || (r.ok ? "disabled" : "failed")}`; - if (r.ok) patchOtherState(pkg, false); + if (r.ok) { + patchOtherState(pkg, false); + invalidateDeviceCaches(); + } } catch (e) { appActionMessage = `${pkg}: ${e}`; } finally { @@ -345,7 +352,10 @@ try { const r = await api.enablePackage(serial, pkg); appActionMessage = `${pkg}: ${r.message.trim() || (r.ok ? "enabled" : "failed")}`; - if (r.ok) patchOtherState(pkg, true); + if (r.ok) { + patchOtherState(pkg, true); + invalidateDeviceCaches(); + } } catch (e) { appActionMessage = `${pkg}: ${e}`; } finally { @@ -360,7 +370,10 @@ try { const r = await api.uninstallPackage(serial, pkg); appActionMessage = `${pkg}: ${r.message.trim()}`; - if (r.ok) patchOtherState(pkg, "removed"); + if (r.ok) { + patchOtherState(pkg, "removed"); + invalidateDeviceCaches(); + } } catch (e) { appActionMessage = `${pkg}: ${e}`; } finally { @@ -387,6 +400,9 @@ if (apps.length > 0) { appStates = await fetchAppStates(apps.map((a) => a.package)); } + // The Optimize wizard can disable launchers and many packages — mark the + // Launcher and Memory caches stale so they reload fresh on next visit. + invalidateDeviceCaches(); } /// Lookup a package in the loaded app catalog (if it's there) for risk-aware @@ -481,7 +497,10 @@ try { const r = await api.disablePackage(serial, pkg); appActionMessage = `${pkg}: ${r.message.trim()}`; - if (r.ok) setCatalogState(pkg, "disabled"); + if (r.ok) { + setCatalogState(pkg, "disabled"); + invalidateDeviceCaches(); + } } catch (e) { appActionMessage = `${pkg}: ${e}`; } finally { @@ -495,7 +514,10 @@ try { const r = await api.enablePackage(serial, pkg); appActionMessage = `${pkg}: ${r.message.trim()}`; - if (r.ok) setCatalogState(pkg, "enabled"); + if (r.ok) { + setCatalogState(pkg, "enabled"); + invalidateDeviceCaches(); + } } catch (e) { appActionMessage = `${pkg}: ${e}`; } finally { @@ -700,6 +722,8 @@ } else { launcherActionMessage = `${name} enabled.`; } + // A launcher's enabled state changed — the Memory tab's report is now stale. + invalidateDeviceCaches(); } catch (e) { launcherActionMessage = String(e); } finally { @@ -722,6 +746,7 @@ if (r.ok) { launcherProgress = "Refreshing the launcher list"; await loadLauncher(); + invalidateDeviceCaches(); launcherActionMessage = `${name} disabled.`; } else { launcherActionMessage = `Couldn't disable ${name}: ${r.message.trim() || "failed"}`; @@ -776,6 +801,9 @@ // rather than leaving the spinner frozen on the last backend step. if (launcherActionBusy === pkg) launcherProgress = "Refreshing the launcher list"; await loadLauncher(); + // The takeover may have disabled stock; either way launcher state changed, + // so the Memory tab's report is stale. + invalidateDeviceCaches(); } catch (e) { launcherActionMessage = String(e); } finally { @@ -839,6 +867,7 @@ async function resyncAfterBulkChange() { optimizeResetToken++; launchersLoaded = false; + healthStale = true; if (apps.length === 0) return; try { appStates = await fetchAppStates(apps.map((a) => a.package)); @@ -973,7 +1002,10 @@ $effect(() => { visited[activeTab] = true; if (activeTab === "health") { - if (report === null && !reportLoading && !reportErr) loadHealth(); + if ((report === null || healthStale) && !reportLoading) { + healthStale = false; + loadHealth(); + } // Preload catalog so the memory table can show risk tiers. if (!appsLoaded && !appsLoading) loadApps(); } @@ -982,6 +1014,17 @@ if (activeTab === "snapshot" && !snapshotsLoaded) loadSnapshots(); }); + // A device-state change (enable/disable/uninstall/launcher switch) in one tab + // leaves the other tabs' cached snapshots stale — e.g. disabling a launcher + // from the Memory tab must show up on the Launchers tab. Mark the *other* + // data tabs for a fresh load on their next visit; the tab the action happened + // on refreshes itself inline. Existing data stays on screen until each reload + // finishes, so there's no flash of empty state. + function invalidateDeviceCaches() { + if (activeTab !== "launcher") launchersLoaded = false; + if (activeTab !== "health") healthStale = true; + } + /// Wipe all per-device state. Used if the route's serial changes under a /// live component (today the only way off this page is "← Back to devices", /// so this is defensive — but it guarantees no device's data or in-flight