From 31d15b66ed8ac01895c1a64979dd42ef6cf2ff27 Mon Sep 17 00:00:00 2001 From: kipavy Date: Fri, 9 Oct 2026 13:32:28 +0000 Subject: [PATCH] fix(lock): lock at launch once the auto-lock time has passed (#510) The idle timer only lived in memory, so force-closing the app and reopening it after the timeout signed straight back in. The last activity is now written to `/last-active` (on change, at most every 5 s, and when the app is hidden). A fresh launch past the timeout, or any launch with Immediately, applies the configured lock before auto-login. Reloads of the running app are not launches. With no recorded activity the launch fails closed. A local account that never wrote a secret had no secrets.enc. Lock vault then landed on onboarding, and with no file to check, any password would have opened it. Unlocking now writes the empty vault when it is missing, and the splash treats any local or cloud account as one to unlock. Closes #592 --- src-tauri/src/app_lock.rs | 54 ++++++++++++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/storage/secrets.rs | 26 ++++++-- .../layout/SplashScreen.appLock.test.tsx | 43 +++++++++++-- .../layout/SplashScreen.keychainHint.test.tsx | 2 + src/components/layout/SplashScreen.tsx | 11 ++-- src/hooks/useSessionExpiration.test.ts | 28 +++++++- src/hooks/useSessionExpiration.ts | 17 ++++- src/services/appLock.test.ts | 64 ++++++++++++++++++- src/services/appLock.ts | 59 +++++++++++++---- 10 files changed, 274 insertions(+), 33 deletions(-) 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 {