diff --git a/src-tauri/examples/nyaterm-unlock-rescue.rs b/src-tauri/examples/nyaterm-unlock-rescue.rs index de823f1f..396f9bea 100644 --- a/src-tauri/examples/nyaterm-unlock-rescue.rs +++ b/src-tauri/examples/nyaterm-unlock-rescue.rs @@ -47,8 +47,14 @@ fn run() -> Result<(), Box> { .and_then(Value::as_object_mut) .ok_or("settings.security is missing or not an object")?; - let was_enabled = security - .get("enable_screen_lock") + let was_startup_enabled = security + .get("enable_startup_lock") + .or_else(|| security.get("enable_screen_lock")) + .and_then(Value::as_bool) + .unwrap_or(false); + let was_idle_enabled = security + .get("enable_idle_lock") + .or_else(|| security.get("enable_screen_lock")) .and_then(Value::as_bool) .unwrap_or(false); let old_idle = security @@ -56,7 +62,7 @@ fn run() -> Result<(), Box> { .and_then(Value::as_u64) .unwrap_or(0); - (settings, was_enabled, old_idle) + (settings, was_startup_enabled || was_idle_enabled, old_idle) }; let security = settings @@ -65,7 +71,9 @@ fn run() -> Result<(), Box> { .and_then(Value::as_object_mut) .ok_or("settings.security is missing or not an object")?; - security.insert("enable_screen_lock".to_string(), Value::Bool(false)); + security.insert("enable_startup_lock".to_string(), Value::Bool(false)); + security.insert("enable_idle_lock".to_string(), Value::Bool(false)); + security.remove("enable_screen_lock"); security.insert( "idle_lock_minutes".to_string(), Value::Number(serde_json::Number::from(0)), diff --git a/src-tauri/src/config/settings/mod.rs b/src-tauri/src/config/settings/mod.rs index d543aa16..8a4e8496 100644 --- a/src-tauri/src/config/settings/mod.rs +++ b/src-tauri/src/config/settings/mod.rs @@ -125,6 +125,9 @@ pub fn load_app_settings(app: &AppHandle) -> AppResult { if normalize_ai_settings(&mut settings.ai) { migrated = true; } + if settings.security.migrate_legacy_screen_lock() { + migrated = true; + } if migrate_terminal_timestamp_format(&raw_settings, &mut settings.terminal) { migrated = true; } diff --git a/src-tauri/src/config/settings/security.rs b/src-tauri/src/config/settings/security.rs index 40eb7960..6545dc0b 100644 --- a/src-tauri/src/config/settings/security.rs +++ b/src-tauri/src/config/settings/security.rs @@ -6,9 +6,14 @@ pub struct SecuritySettings { #[serde(default = "default_true")] pub use_os_keyring: bool, #[serde(default = "default_false")] - pub enable_screen_lock: bool, + pub enable_startup_lock: bool, + #[serde(default = "default_false")] + pub enable_idle_lock: bool, #[serde(default)] pub idle_lock_minutes: u32, + /// Legacy combined lock switch. It is read for migration and never written back. + #[serde(rename = "enable_screen_lock", default, skip_serializing)] + legacy_enable_screen_lock: Option, /// Master password used to derive the wrapping key for `master.key`. /// Also serves as the lock-screen password when set. #[serde(default)] @@ -25,10 +30,82 @@ impl Default for SecuritySettings { fn default() -> Self { Self { use_os_keyring: true, - enable_screen_lock: false, + enable_startup_lock: false, + enable_idle_lock: false, idle_lock_minutes: 0, + legacy_enable_screen_lock: None, master_password: None, host_key_policy: default_host_key_policy(), } } } + +impl SecuritySettings { + /// Migrates the legacy combined screen-lock setting into both independent modes. + /// + /// The legacy field is intentionally not serialized, so this is only applied when + /// loading settings or importing an older portable snapshot. + pub fn migrate_legacy_screen_lock(&mut self) -> bool { + let Some(enabled) = self.legacy_enable_screen_lock.take() else { + return false; + }; + + self.enable_startup_lock = enabled; + self.enable_idle_lock = enabled; + true + } +} + +#[cfg(test)] +mod tests { + use super::SecuritySettings; + + #[test] + fn defaults_disable_both_lock_modes() { + let settings = SecuritySettings::default(); + + assert!(!settings.enable_startup_lock); + assert!(!settings.enable_idle_lock); + assert_eq!(settings.idle_lock_minutes, 0); + } + + #[test] + fn migrates_legacy_enabled_screen_lock_to_both_modes() { + let mut settings: SecuritySettings = serde_json::from_value(serde_json::json!({ + "enable_screen_lock": true, + "idle_lock_minutes": 15 + })) + .expect("legacy settings should deserialize"); + + assert!(settings.migrate_legacy_screen_lock()); + assert!(settings.enable_startup_lock); + assert!(settings.enable_idle_lock); + assert_eq!(settings.idle_lock_minutes, 15); + } + + #[test] + fn migrates_legacy_disabled_screen_lock_to_both_modes() { + let mut settings: SecuritySettings = serde_json::from_value(serde_json::json!({ + "enable_screen_lock": false + })) + .expect("legacy settings should deserialize"); + + assert!(settings.migrate_legacy_screen_lock()); + assert!(!settings.enable_startup_lock); + assert!(!settings.enable_idle_lock); + } + + #[test] + fn new_lock_modes_are_serialized_without_legacy_field() { + let settings = SecuritySettings { + enable_startup_lock: true, + enable_idle_lock: false, + ..SecuritySettings::default() + }; + let value = serde_json::to_value(settings).expect("settings should serialize"); + + assert_eq!(value["enable_startup_lock"], true); + assert_eq!(value["enable_idle_lock"], false); + assert!(value.get("enable_screen_lock").is_none()); + } +} diff --git a/src-tauri/src/core/portable_snapshot/tests.rs b/src-tauri/src/core/portable_snapshot/tests.rs index 6c87e64d..33cfecb5 100644 --- a/src-tauri/src/core/portable_snapshot/tests.rs +++ b/src-tauri/src/core/portable_snapshot/tests.rs @@ -182,6 +182,26 @@ mod tests { ); } + #[test] + fn portable_settings_migrate_legacy_screen_lock_modes() { + let portable = sample_portable_settings(); + let mut raw = serde_json::to_value(portable).expect("portable settings should serialize"); + let security = raw + .get_mut("security") + .and_then(serde_json::Value::as_object_mut) + .expect("security settings should be an object"); + security.remove("enable_startup_lock"); + security.remove("enable_idle_lock"); + security.insert("enable_screen_lock".to_string(), serde_json::json!(true)); + + let decoded: PortableAppSettings = + serde_json::from_value(raw).expect("legacy portable settings should deserialize"); + let merged = decoded.apply_to(AppSettings::default(), &PortableSnapshotKind::Backup); + + assert!(merged.security.enable_startup_lock); + assert!(merged.security.enable_idle_lock); + } + #[test] fn sync_portable_settings_strip_device_local_paths() { let mut current = AppSettings::default(); diff --git a/src-tauri/src/core/portable_snapshot/types.rs b/src-tauri/src/core/portable_snapshot/types.rs index ac2a3620..ae43cfdc 100644 --- a/src-tauri/src/core/portable_snapshot/types.rs +++ b/src-tauri/src/core/portable_snapshot/types.rs @@ -367,6 +367,8 @@ impl PortableAppSettings { mut current: AppSettings, snapshot_kind: &PortableSnapshotKind, ) -> AppSettings { + let mut snapshot_security = self.security; + snapshot_security.migrate_legacy_screen_lock(); let master_password = current.security.master_password.clone(); let ui_state = current.ui.clone(); let device_appearance = current.appearance.clone(); @@ -377,7 +379,7 @@ impl PortableAppSettings { current.proxy = self.proxy; current.search = self.search; current.translation = self.translation; - current.security = self.security; + current.security = snapshot_security; current.security.master_password = master_password; current.terminal = self.terminal; current.interaction = self.interaction; diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index f49f7062..a1b7d7ef 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -385,7 +385,7 @@ fn rebuild_root_menu( menu.append(&settings_item)?; menu.append(&minimize_to_tray)?; - if settings.security.enable_screen_lock { + if settings.security.enable_startup_lock || settings.security.enable_idle_lock { let lock_screen = new_menu_item(app, MENU_LOCK_SCREEN, strings.lock_screen, true)?; menu.append(&lock_screen)?; } diff --git a/src/App.tsx b/src/App.tsx index d2921263..b03a0e4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -316,7 +316,7 @@ function App() { // Idle auto-lock useIdleLock( - appSettings.security.enable_screen_lock ? appSettings.security.idle_lock_minutes : 0, + appSettings.security.enable_idle_lock ? appSettings.security.idle_lock_minutes : 0, isLocked, () => setIsLocked(true), ); @@ -1936,10 +1936,14 @@ function App() { }, []); const handleLockScreen = useCallback(() => { - if (appSettings.security.enable_screen_lock) { + if (appSettings.security.enable_startup_lock || appSettings.security.enable_idle_lock) { setIsLocked(true); } - }, [appSettings.security.enable_screen_lock, setIsLocked]); + }, [ + appSettings.security.enable_idle_lock, + appSettings.security.enable_startup_lock, + setIsLocked, + ]); const persistWorkspaceLayoutNow = useCallback(async () => { if (!settingsLoaded || !startupRestoreComplete || !terminalWindowsRestoredRef.current) { diff --git a/src/components/dialog/app/LockScreen.tsx b/src/components/dialog/app/LockScreen.tsx index 382f1a53..0dfbf26a 100644 --- a/src/components/dialog/app/LockScreen.tsx +++ b/src/components/dialog/app/LockScreen.tsx @@ -19,7 +19,7 @@ interface LockScreenProps { } /** - * Full-screen overlay shown when the app has been locked due to inactivity. + * Full-screen overlay shown when the app has been locked at startup or due to inactivity. * If a lock password is set, the user must enter it to unlock (verified by backend). * Otherwise, clicking the unlock button is sufficient. */ diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index fc736d29..56663580 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -115,20 +115,34 @@ export function SecurityTab() { updateAppSettings({ - security: { ...appSettings.security, enable_screen_lock: v }, + security: { ...appSettings.security, enable_startup_lock: v }, }) } /> - {appSettings.security.enable_screen_lock && ( + + + updateAppSettings({ + security: { ...appSettings.security, enable_idle_lock: v }, + }) + } + /> + + + {appSettings.security.enable_idle_lock && (