diff --git a/v2/src-tauri/src/commands/launcher.rs b/v2/src-tauri/src/commands/launcher.rs index c09630c..97fb97d 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>, @@ -151,27 +168,32 @@ 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>, 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 +207,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,8 +224,68 @@ 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; + // 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) { + focus_home(&*adb, serial, progress).await; + 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 @@ -210,6 +293,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, @@ -219,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()), @@ -243,6 +328,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); @@ -274,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()), @@ -297,6 +384,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, @@ -315,62 +403,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, - }); - } - 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; - 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); } } @@ -395,6 +443,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 @@ -403,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 @@ -502,9 +646,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")); @@ -516,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] @@ -530,9 +682,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 +709,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 +739,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(); @@ -592,6 +762,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 @@ -602,6 +816,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", ], @@ -609,9 +826,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..8f813e2 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, @@ -42,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; @@ -72,6 +77,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); @@ -329,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 { @@ -343,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 { @@ -358,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 { @@ -385,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 @@ -405,8 +423,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 { @@ -456,6 +474,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 @@ -472,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 { @@ -486,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 { @@ -658,76 +689,104 @@ } 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.`; } + // A launcher's enabled state changed — the Memory tab's report is now stale. + invalidateDeviceCaches(); } 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(); + invalidateDeviceCaches(); + launcherActionMessage = `${name} disabled.`; + } else { + launcherActionMessage = `Couldn't disable ${name}: ${r.message.trim() || "failed"}`; + } } catch (e) { launcherActionMessage = String(e); } finally { launcherActionBusy = null; + launcherProgress = ""; } } async function setDefaultLauncher(pkg: string) { + const name = launchers.find((l) => l.entry.package === pkg)?.entry.name ?? pkg; 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.`, + `${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); + 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(); + ? `${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 @@ -735,10 +794,21 @@ 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(); + // 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 { launcherActionBusy = null; + launcherProgress = ""; } } @@ -797,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)); @@ -931,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(); } @@ -940,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 @@ -1271,7 +1356,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 @@ -1283,7 +1372,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} @@ -1336,6 +1429,11 @@ {/if}
{l.entry.package}
+ {#if busy && launcherProgress} +
+ {launcherProgress}… +
+ {/if}
@@ -1376,10 +1474,12 @@ {/if} {#if !isCurrent && l.enabled} @@ -1388,7 +1488,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}