diff --git a/src-tauri/src/app_lock.rs b/src-tauri/src/app_lock.rs index dcbb2f9e7..9b578b044 100644 --- a/src-tauri/src/app_lock.rs +++ b/src-tauri/src/app_lock.rs @@ -65,6 +65,30 @@ impl AppLock { } } +pub struct LastActive { + path: PathBuf, +} + +impl LastActive { + pub fn load() -> Self { + Self { + path: crate::storage::config::config_dir().join("last-active"), + } + } + + pub fn get(&self) -> Option { + std::fs::read_to_string(&self.path) + .ok()? + .trim() + .parse() + .ok() + } + + pub fn set(&self, at_ms: u64) -> std::io::Result<()> { + std::fs::write(&self.path, at_ms.to_string()) + } +} + pub fn is_locked(app: &tauri::AppHandle) -> bool { app.try_state::() .is_some_and(|l| l.get().is_some()) @@ -97,6 +121,17 @@ pub fn app_lock_set(lock: tauri::State<'_, AppLock>, kind: Option) -> Re persisted.map_err(|e| format!("could not persist the app lock: {e}")) } +#[tauri::command] +pub fn app_lock_last_active(last: tauri::State<'_, LastActive>) -> Option { + last.get() +} + +#[tauri::command] +pub fn app_lock_touch(last: tauri::State<'_, LastActive>, at: u64) -> Result<(), String> { + last.set(at) + .map_err(|e| format!("could not persist the last activity: {e}")) +} + #[tauri::command] pub fn app_lock_hide_in_recents(on: bool) { #[cfg(target_os = "android")] @@ -109,6 +144,25 @@ pub fn app_lock_hide_in_recents(on: bool) { mod tests { use super::*; + #[test] + fn last_activity_survives_a_reload_from_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("last-active"); + assert_eq!(LastActive { path: path.clone() }.get(), None); + LastActive { path: path.clone() } + .set(1_700_000_000_000) + .unwrap(); + assert_eq!(LastActive { path }.get(), Some(1_700_000_000_000)); + } + + #[test] + fn unreadable_last_activity_reads_as_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("last-active"); + std::fs::write(&path, "garbage").unwrap(); + assert_eq!(LastActive { path }.get(), None); + } + #[test] fn a_fresh_store_is_unlocked() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2ed9c599..a396d7a80 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -247,6 +247,7 @@ pub fn run() { app.manage(updater::UpdaterState::default()); app.manage(KnownHostsStore::load()); app.manage(app_lock::AppLock::load()); + app.manage(app_lock::LastActive::load()); app.manage(Arc::new(PendingConflicts::new())); app.manage(PortForwardManager::new(app.handle().clone())); app.manage(Arc::new(mcp::McpState::new())); @@ -277,6 +278,8 @@ pub fn run() { app_lock::app_lock_get, app_lock::app_lock_set, app_lock::app_lock_hide_in_recents, + app_lock::app_lock_last_active, + app_lock::app_lock_touch, system_auth::system_auth_available, system_auth::system_auth_verify, terminal_kbd::terminal_show_keyboard, diff --git a/src-tauri/src/storage/secrets.rs b/src-tauri/src/storage/secrets.rs index da39dcd72..169fc56d2 100644 --- a/src-tauri/src/storage/secrets.rs +++ b/src-tauri/src/storage/secrets.rs @@ -60,18 +60,26 @@ impl SecretsStore { } pub fn unlock(&self, path: PathBuf, enc_key: [u8; 32]) -> Result<(), AppError> { - let data = if path.exists() { + let existed = path.exists(); + let data = if existed { let bytes = std::fs::read(&path).map_err(read_failed)?; decrypt(&enc_key, &bytes)? } else { SecretsData::default() }; - *self.inner.lock().unwrap() = Some(StoreInner { + let inner = StoreInner { enc_key, secrets: data.secrets, clocks: data.clocks, path, - }); + }; + // Without a file, the next unlock has nothing to check a password against. + if !existed { + if let Err(e) = save(&inner) { + log::warn!("could not write the empty vault: {e}"); + } + } + *self.inner.lock().unwrap() = Some(inner); Ok(()) } @@ -1113,6 +1121,15 @@ mod tests { (store, path) } + #[test] + fn unlocking_without_a_file_writes_one_only_that_key_opens() { + let dir = tempfile::tempdir().unwrap(); + let (_, path) = unlocked_store(&dir); + assert!(path.exists()); + assert!(SecretsStore::new().unlock(path.clone(), [8u8; 32]).is_err()); + assert!(SecretsStore::new().unlock(path, [7u8; 32]).is_ok()); + } + #[test] fn purge_removes_only_present_keys_and_tombstones_them() { let dir = tempfile::tempdir().unwrap(); @@ -1136,8 +1153,9 @@ mod tests { fn purge_with_nothing_present_does_not_write() { let dir = tempfile::tempdir().unwrap(); let (store, path) = unlocked_store(&dir); + let before = std::fs::read(&path).unwrap(); assert!(store.purge(&["password:x".to_string()]).unwrap().is_empty()); - assert!(!path.exists()); + assert_eq!(std::fs::read(&path).unwrap(), before); } #[test] diff --git a/src/components/layout/SplashScreen.appLock.test.tsx b/src/components/layout/SplashScreen.appLock.test.tsx index f01f6c426..d6c08082d 100644 --- a/src/components/layout/SplashScreen.appLock.test.tsx +++ b/src/components/layout/SplashScreen.appLock.test.tsx @@ -7,7 +7,8 @@ const h = vi.hoisted(() => ({ systemAuthUnlock: false, available: true, exists: true, - serverMode: false, + mode: null as string | null, + launchLock: null as string | null, secret: "plain", authProps: null as null | { isLocked: boolean; systemAuth?: boolean }, })); @@ -21,9 +22,13 @@ vi.mock("@/services/account", () => ({ autoLogin: h.autoLogin, getAppLock: vi.fn(async () => h.lock), setAppLock: vi.fn(async () => {}), - isServerMode: vi.fn(async () => h.serverMode), + isServerMode: vi.fn(async () => h.mode === "server"), + getAccountMode: vi.fn(async () => h.mode), +})); +vi.mock("@/services/appLock", () => ({ + systemAuthAvailable: vi.fn(async () => h.available), + lockOnLaunchIfIdle: vi.fn(async () => h.launchLock), })); -vi.mock("@/services/appLock", () => ({ systemAuthAvailable: vi.fn(async () => h.available) })); vi.mock("@/services/vaultSecret", () => ({ secretState: vi.fn(async () => h.secret) })); vi.mock("@/stores/securityStore", () => ({ useSecurityStore: { getState: () => ({ systemAuthUnlock: h.systemAuthUnlock }) }, @@ -41,7 +46,8 @@ beforeEach(() => { h.lock = null; h.systemAuthUnlock = false; h.exists = true; - h.serverMode = false; + h.mode = null; + h.launchLock = null; h.secret = "plain"; h.authProps = null; h.autoLogin.mockClear(); @@ -85,7 +91,7 @@ test("a screen lock that survived a restart is up before the shell shows", async test("a vault-locked cloud account with no vault file yet still gets the unlock page", async () => { h.lock = "vault"; h.exists = false; - h.serverMode = true; + h.mode = "server"; render( {}} />); await advance(2000); expect(h.authProps).toMatchObject({ isLocked: true }); @@ -99,6 +105,31 @@ test("a vault lock with no vault file and no cloud account is a first launch", a expect(h.authProps).toMatchObject({ isLocked: false }); }); +test("a vault-locked local account that never wrote a secret gets the unlock page, not onboarding", async () => { + h.lock = "vault"; + h.exists = false; + h.mode = "local"; + render( {}} />); + await advance(2000); + expect(h.authProps).toMatchObject({ isLocked: true }); +}); + +test("a launch past the auto-lock timeout with Lock vault goes to the unlock page, never auto-login", async () => { + h.launchLock = "vault"; + render( {}} />); + await advance(2000); + expect(h.autoLogin).not.toHaveBeenCalled(); + expect(h.authProps).toMatchObject({ isLocked: true }); +}); + +test("a launch past the auto-lock timeout with Lock screen still opens the vault behind the lock", async () => { + h.launchLock = "screen"; + render( {}} />); + await advance(10_000); + expect(h.autoLogin).toHaveBeenCalled(); + expect(h.authProps).toBeNull(); +}); + test("sealed launch goes to the locked screen without a vault file", async () => { h.autoLogin.mockResolvedValueOnce("sealed"); h.exists = false; @@ -118,7 +149,7 @@ test("a vault lock over a sealed secret offers system auth even with the setting test("a declined login on a cloud account with no vault file yet still gets the unlock page", async () => { h.autoLogin.mockResolvedValueOnce("declined"); h.exists = false; - h.serverMode = true; + h.mode = "server"; render( {}} />); await advance(2000); expect(h.authProps).toMatchObject({ isLocked: true }); diff --git a/src/components/layout/SplashScreen.keychainHint.test.tsx b/src/components/layout/SplashScreen.keychainHint.test.tsx index e9e8b1123..12fa76625 100644 --- a/src/components/layout/SplashScreen.keychainHint.test.tsx +++ b/src/components/layout/SplashScreen.keychainHint.test.tsx @@ -16,7 +16,9 @@ vi.mock("@/services/account", () => ({ getAppLock: vi.fn(async () => null), setAppLock: vi.fn(async () => {}), isServerMode: vi.fn(async () => false), + getAccountMode: vi.fn(async () => "local"), })); +vi.mock("@/services/appLock", () => ({ systemAuthAvailable: async () => false, lockOnLaunchIfIdle: async () => null })); vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: true })) })); vi.mock("./AuthPage", () => ({ default: () =>
auth-page
})); vi.mock("./LogoBadge", () => ({ default: () =>
})); diff --git a/src/components/layout/SplashScreen.tsx b/src/components/layout/SplashScreen.tsx index a6ebef70a..1e85fb64f 100644 --- a/src/components/layout/SplashScreen.tsx +++ b/src/components/layout/SplashScreen.tsx @@ -8,8 +8,8 @@ import { useFolderStore } from "@/stores/folderStore"; import { useSnippetStore } from "@/stores/snippetStore"; import { useSnippetFolderStore } from "@/stores/snippetFolderStore"; import { usePortForwardingStore } from "@/stores/portForwardingStore"; -import { autoLogin, getAppLock, isServerMode, setAppLock } from "@/services/account"; -import { systemAuthAvailable } from "@/services/appLock"; +import { autoLogin, getAccountMode, getAppLock, isServerMode, setAppLock } from "@/services/account"; +import { lockOnLaunchIfIdle, systemAuthAvailable } from "@/services/appLock"; import { secretState } from "@/services/vaultSecret"; import { useAppLockStore } from "@/stores/appLockStore"; import { useSecurityStore } from "@/stores/securityStore"; @@ -22,6 +22,7 @@ import { usePluginRegistryStore } from "@/stores/pluginRegistryStore"; import { useThemeStore } from "@/stores/themeStore"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; import { usePlatform } from "@/utils/platform"; +import { canLockVault } from "@/utils/accountMode"; import AuthPage from "./AuthPage"; import LogoBadge from "./LogoBadge"; @@ -43,9 +44,9 @@ function keepSwitcherFresh(): void { saveCurrentAccount().catch((e) => console.warn("[splash] could not save this account to the switcher:", e)); } -// A cloud account has nothing on disk until its first secret; login() re-derives from the server. +// An account can predate its vault file: a cloud one re-derives from the server, a local one never wrote a secret. async function hasAccountToUnlock(): Promise { - return (await getVaultStatus()).exists || (await isServerMode()); + return (await getVaultStatus()).exists || canLockVault(await getAccountMode()); } export default function SplashScreen({ onReady }: Props) { @@ -70,7 +71,7 @@ export default function SplashScreen({ onReady }: Props) { setStep("vault", "running"); await delay(200); - if ((await getAppLock()) === "vault") { + if (((await getAppLock()) ?? (await lockOnLaunchIfIdle().catch(() => null))) === "vault") { const sealed = (await secretState().catch(() => "none")) === "sealed"; setSystemAuth(sealed || (useSecurityStore.getState().systemAuthUnlock && (await systemAuthAvailable()))); try { diff --git a/src/hooks/useSessionExpiration.test.ts b/src/hooks/useSessionExpiration.test.ts index 10355519c..28c77ea63 100644 --- a/src/hooks/useSessionExpiration.test.ts +++ b/src/hooks/useSessionExpiration.test.ts @@ -5,13 +5,16 @@ import { renderHook, act, cleanup } from "@testing-library/react"; const h = vi.hoisted(() => ({ lockApp: vi.fn(async () => undefined), hideInRecents: vi.fn(async (_on: boolean) => undefined), + recordLastActive: vi.fn(async (_at: number) => undefined), suppressed: false, mode: "local" as string | null, resized: null as null | (() => void), minimized: false, })); -vi.mock("@/services/appLock", () => ({ lockApp: h.lockApp, setHideInRecents: h.hideInRecents })); +vi.mock("@/services/appLock", () => ({ + lockApp: h.lockApp, setHideInRecents: h.hideInRecents, recordLastActive: h.recordLastActive, +})); vi.mock("@/services/leaveLockSuppression", () => ({ isLeaveLockSuppressed: () => h.suppressed })); vi.mock("@/services/account", () => ({ getAccountMode: async () => h.mode, @@ -192,3 +195,26 @@ test("a policy timeout applies when the member chose Never", async () => { await act(async () => { await vi.advanceTimersByTimeAsync(15 * 60_000 + 5_000); }); expect(h.lockApp).toHaveBeenCalled(); }); + +test("the last activity is written down for the next launch, once per change", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: 15 }); + await mount(); + expect(h.recordLastActive).toHaveBeenCalledTimes(1); + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(h.recordLastActive).toHaveBeenCalledTimes(1); + await act(async () => { + window.dispatchEvent(new Event("keydown")); + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(h.recordLastActive).toHaveBeenCalledTimes(2); + expect(h.recordLastActive).toHaveBeenLastCalledWith(Date.now() - 5_000); +}); + +test("leaving the app writes down the last activity before it can be killed", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: 15 }); + await mount(); + await act(async () => { window.dispatchEvent(new Event("keydown")); }); + const at = Date.now(); + setVisibility("hidden"); + expect(h.recordLastActive).toHaveBeenLastCalledWith(at); +}); diff --git a/src/hooks/useSessionExpiration.ts b/src/hooks/useSessionExpiration.ts index 1ff92c6f2..cd1f8e181 100644 --- a/src/hooks/useSessionExpiration.ts +++ b/src/hooks/useSessionExpiration.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { getAccountMode } from "@/services/account"; -import { lockApp, setHideInRecents } from "@/services/appLock"; +import { lockApp, recordLastActive, setHideInRecents } from "@/services/appLock"; import { isLeaveLockSuppressed } from "@/services/leaveLockSuppression"; import { useAppLockStore } from "@/stores/appLockStore"; import { useEffectiveLockSettings } from "@/hooks/useEffectiveLockSettings"; @@ -22,6 +22,7 @@ export function useSessionExpiration(ready = true): void { const immediate = sessionTimeoutMinutes === IMMEDIATELY; const timeoutMs = (immediate ? IMMEDIATE_IDLE_MINUTES : sessionTimeoutMinutes) * 60_000; let lastActivityAt = Date.now(); + let recordedAt = 0; let lockable = false; let disposed = false; let lockInProgress = false; @@ -54,8 +55,15 @@ export function useSessionExpiration(ready = true): void { if (immediate && !isLeaveLockSuppressed()) lockNow(); }; + const persistActivity = () => { + if (recordedAt === lastActivityAt) return; + recordedAt = lastActivityAt; + void recordLastActive(lastActivityAt); + }; + const checkIdle = () => { if (Date.now() - lastActivityAt >= timeoutMs) lockNow(); + else persistActivity(); }; const recordActivity = () => { @@ -63,8 +71,10 @@ export function useSessionExpiration(ready = true): void { }; const onVisibilityChange = () => { - if (document.visibilityState === "hidden") lockOnLeave(); - else checkIdle(); + if (document.visibilityState === "hidden") { + persistActivity(); + lockOnLeave(); + } else checkIdle(); }; if (immediate) { @@ -83,6 +93,7 @@ export function useSessionExpiration(ready = true): void { for (const e of ACTIVITY_EVENTS) window.addEventListener(e, recordActivity, { passive: true }); window.addEventListener("focus", checkIdle); document.addEventListener("visibilitychange", onVisibilityChange); + persistActivity(); const intervalId = window.setInterval(checkIdle, CHECK_INTERVAL_MS); const unsubscribeLock = useAppLockStore.subscribe((s, prev) => { if (prev.kind && !s.kind) lastActivityAt = Date.now(); diff --git a/src/services/appLock.test.ts b/src/services/appLock.test.ts index dc3f8fbfd..2cae89692 100644 --- a/src/services/appLock.test.ts +++ b/src/services/appLock.test.ts @@ -16,7 +16,7 @@ vi.mock("@/services/account", () => ({ getAppLock: () => h.invoke("app_lock_get"), })); -import { lockApp, systemAuthVerify } from "./appLock"; +import { lockApp, lockOnLaunchIfIdle, systemAuthVerify } from "./appLock"; import { isLeaveLockSuppressed, withLeaveLockSuppressed } from "./leaveLockSuppression"; import { useSecurityStore } from "@/stores/securityStore"; import { useAppLockStore } from "@/stores/appLockStore"; @@ -26,7 +26,8 @@ beforeEach(() => { vi.clearAllMocks(); h.invoke.mockImplementation(async () => undefined); h.mode = "local"; - useSecurityStore.setState({ lockAction: "vault", systemAuthUnlock: false }); + useSecurityStore.setState({ lockAction: "vault", systemAuthUnlock: false, sessionTimeoutMinutes: null }); + sessionStorage.clear(); useAppLockStore.setState({ kind: null }); useOrgLockPolicyStore.setState({ policy: null }); Object.defineProperty(window, "location", { value: { reload: h.reload }, writable: true, configurable: true }); @@ -139,3 +140,62 @@ test("a forced Lock vault policy locks the vault even when the member chose Lock expect(h.lockVaultSession).toHaveBeenCalled(); expect(useAppLockStore.getState().kind).toBeNull(); }); + +const MINUTE = 60_000; +const lastActiveAgo = (ms: number | null) => + h.invoke.mockImplementation(async (cmd: unknown) => + cmd === "app_lock_last_active" ? (ms === null ? null : Date.now() - ms) : undefined); + +test("a launch past the timeout locks the vault without reloading", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: 15 }); + lastActiveAgo(16 * MINUTE); + expect(await lockOnLaunchIfIdle()).toBe("vault"); + expect(h.lockVaultSession).toHaveBeenCalledWith({ keepKeychainEntry: false }); + expect(h.reload).not.toHaveBeenCalled(); +}); + +test("a launch within the timeout does not lock", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: 15 }); + lastActiveAgo(14 * MINUTE); + expect(await lockOnLaunchIfIdle()).toBeNull(); + expect(h.lockVaultSession).not.toHaveBeenCalled(); +}); + +test("a launch past the timeout with Lock screen raises the overlay", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: 5, lockAction: "screen" }); + lastActiveAgo(6 * MINUTE); + expect(await lockOnLaunchIfIdle()).toBe("screen"); + expect(useAppLockStore.getState().kind).toBe("screen"); +}); + +test("a launch with no recorded activity fails closed", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: 240 }); + lastActiveAgo(null); + expect(await lockOnLaunchIfIdle()).toBe("vault"); +}); + +test("a launch with Immediately always locks", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: 0 }); + lastActiveAgo(1000); + expect(await lockOnLaunchIfIdle()).toBe("vault"); +}); + +test("a launch with Never does not lock", async () => { + lastActiveAgo(null); + expect(await lockOnLaunchIfIdle()).toBeNull(); +}); + +test("an org maximum timeout applies at launch too", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: null }); + useOrgLockPolicyStore.setState({ policy: { maxMinutes: 15, forceVault: false } }); + lastActiveAgo(16 * MINUTE); + expect(await lockOnLaunchIfIdle()).toBe("vault"); +}); + +test("a reload of the running app is not a launch", async () => { + useSecurityStore.setState({ sessionTimeoutMinutes: 0 }); + await lockOnLaunchIfIdle(); + h.lockVaultSession.mockClear(); + expect(await lockOnLaunchIfIdle()).toBeNull(); + expect(h.lockVaultSession).not.toHaveBeenCalled(); +}); diff --git a/src/services/appLock.ts b/src/services/appLock.ts index 3c31437e1..dc08cbcd1 100644 --- a/src/services/appLock.ts +++ b/src/services/appLock.ts @@ -1,10 +1,11 @@ import { invoke } from "@/lib/invoke"; -import { getAccountMode, lockVaultSession } from "@/services/account"; +import { getAccountMode, lockVaultSession, type LockKind } from "@/services/account"; import { getEffectiveLockSettings } from "@/hooks/useEffectiveLockSettings"; import { useAppLockStore } from "@/stores/appLockStore"; import { useSecurityStore } from "@/stores/securityStore"; import { canLockApp, canLockVault } from "@/utils/accountMode"; import { withLeaveLockSuppressed } from "@/services/leaveLockSuppression"; +import { IMMEDIATELY } from "@/utils/sessionTimeout"; export type VerifyOutcome = "ok" | "cancelled" | "failed" | "unavailable"; @@ -18,21 +19,55 @@ export function systemAuthVerify(reason: string): Promise { ); } -export async function lockApp(): Promise { +async function lockKind(): Promise { const { systemAuthUnlock } = useSecurityStore.getState(); const { lockAction } = getEffectiveLockSettings(); const mode = await getAccountMode().catch(() => null); - if (!canLockApp(mode, systemAuthUnlock)) return; - if (!canLockVault(mode) && !(await systemAuthAvailable())) return; - if (lockAction === "vault" && canLockVault(mode)) { - try { - await lockVaultSession({ keepKeychainEntry: systemAuthUnlock }); - } finally { - window.location.reload(); - } - return; + if (!canLockApp(mode, systemAuthUnlock)) return null; + if (!canLockVault(mode) && !(await systemAuthAvailable())) return null; + return lockAction === "vault" && canLockVault(mode) ? "vault" : "screen"; +} + +async function engage(kind: LockKind): Promise { + if (kind === "screen") return useAppLockStore.getState().lockScreen(); + await lockVaultSession({ keepKeychainEntry: useSecurityStore.getState().systemAuthUnlock }); +} + +export async function lockApp(): Promise { + const kind = await lockKind(); + if (!kind) return; + if (kind === "screen") return engage(kind); + try { + await engage(kind); + } finally { + window.location.reload(); } - await useAppLockStore.getState().lockScreen(); +} + +export function recordLastActive(at: number): Promise { + return invoke("app_lock_touch", { at }).catch(() => {}); +} + +async function idleSinceLastRun(): Promise { + const { sessionTimeoutMinutes: minutes } = getEffectiveLockSettings(); + if (minutes === null || minutes < 0) return false; + if (minutes === IMMEDIATELY) return true; + const last = await invoke("app_lock_last_active").catch(() => null); + if (last === null) return true; + const idle = Date.now() - last; + return idle < 0 || idle >= minutes * 60_000; +} + +const LAUNCH_CHECKED_KEY = "voltius.launch-lock-checked"; + +/** A killed app never saw its timeout fire, so a fresh launch past it starts locked. Reloads skip this. */ +export async function lockOnLaunchIfIdle(): Promise { + if (sessionStorage.getItem(LAUNCH_CHECKED_KEY)) return null; + sessionStorage.setItem(LAUNCH_CHECKED_KEY, "1"); + if (!(await idleSinceLastRun())) return null; + const kind = await lockKind(); + if (kind) await engage(kind); + return kind; } export function setHideInRecents(on: boolean): Promise {