diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 72eb6e8..26db04b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,5 +53,5 @@ core-graphics = { version = "0.25", features = ["highsierra"] } [target.'cfg(target_os = "windows")'.dependencies] windows-service = "0.8.1" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_DataExchange", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Pipes", "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Services", "Win32_System_StationsAndDesktops", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_System_Shutdown", "Win32_Storage_FileSystem", "Win32_System_DataExchange", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Pipes", "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Services", "Win32_System_StationsAndDesktops", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } image = { version = "0.25", default-features = false, features = ["bmp"] } diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 984b4c3..bca62e5 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -313,6 +313,8 @@ struct InputControlPacket { #[serde(rename_all = "camelCase")] enum InputControlCommand { SecureAttention, + LockWorkstation, + UnlockWorkstation, } pub fn stopped_capture_status() -> NativeStageStatus { @@ -1493,6 +1495,44 @@ pub fn send_secure_attention_control( layout: &LayoutState, quic_transport: &quic_transport::TransportHandle, device_id: &str, +) -> Result<(), String> { + send_workstation_control( + layout, + quic_transport, + device_id, + InputControlCommand::SecureAttention, + "Ctrl+Alt+Del control is only available for Windows targets.", + ) +} + +/// Sends the lock-follow control to a paired Windows target: ask it to lock +/// its workstation, or to unlock itself with its locally stored password. +pub fn send_workstation_follow_control( + layout: &LayoutState, + quic_transport: &quic_transport::TransportHandle, + device_id: &str, + lock: bool, +) -> Result<(), String> { + let (command, unsupported) = if lock { + ( + InputControlCommand::LockWorkstation, + "Lock follow control is only available for Windows targets.", + ) + } else { + ( + InputControlCommand::UnlockWorkstation, + "Unlock follow control is only available for Windows targets.", + ) + }; + send_workstation_control(layout, quic_transport, device_id, command, unsupported) +} + +fn send_workstation_control( + layout: &LayoutState, + quic_transport: &quic_transport::TransportHandle, + device_id: &str, + command: InputControlCommand, + unsupported_error: &str, ) -> Result<(), String> { let Some(target) = layout .devices @@ -1502,7 +1542,7 @@ pub fn send_secure_attention_control( return Err("target device is not in the layout".into()); }; if target.platform != "windows" { - return Err("Ctrl+Alt+Del control is only available for Windows targets.".into()); + return Err(unsupported_error.into()); } if !target.online || !target.input_ready { return Err("target device is not online and input-ready".into()); @@ -1523,7 +1563,7 @@ pub fn send_secure_attention_control( origin_protocol_version: quic_transport::PROTOCOL_VERSION, cluster_id: layout.cluster_id.clone(), pair_secret: layout.pair_secret.clone(), - command: InputControlCommand::SecureAttention, + command, }; let payload = rmp_serde::to_vec_named(&packet) .map_err(|error| format!("encode input control packet: {error}"))?; @@ -1882,11 +1922,122 @@ fn handle_control_packet( source ); } + InputControlCommand::LockWorkstation => { + #[cfg(target_os = "windows")] + { + // LockWorkStation works from the normal user session — no + // elevation or helper required. The peer is already authorized + // (paired controllers only) before reaching this arm. + use windows_sys::Win32::System::Shutdown::LockWorkStation; + if unsafe { LockWorkStation() } == 0 { + log::warn!( + "LockWorkStation control from {} failed with error {}", + source, + unsafe { windows_sys::Win32::Foundation::GetLastError() } + ); + } + } + + #[cfg(not(target_os = "windows"))] + log::warn!( + "LockWorkstation control from {} ignored on non-Windows target", + source + ); + } + InputControlCommand::UnlockWorkstation => { + #[cfg(target_os = "windows")] + if let Err(error) = unlock_workstation_with_stored_password(layout) { + log::warn!( + "UnlockWorkstation control from {} failed: {}", + source, + error + ); + } + + #[cfg(not(target_os = "windows"))] + log::warn!( + "UnlockWorkstation control from {} ignored on non-Windows target", + source + ); + } } true } +/// Types the locally stored unlock password into the Windows logon UI and +/// presses Enter, so a paired controller can follow its own unlock. The +/// keystrokes go through the regular dispatch path: on the secure desktop that +/// routes to the privileged input service (the same path remote typing uses +/// while the machine is locked), so no extra privileges are needed here. +#[cfg(target_os = "windows")] +fn unlock_workstation_with_stored_password(layout: &LayoutState) -> Result<(), String> { + let blob = layout.unlock_password_blob.trim(); + if blob.is_empty() { + return Err("no unlock password is configured on this device".into()); + } + let password = crate::unprotect_unlock_password(blob)?; + if windows_input_desktop_is_default() { + return Err("workstation is not locked; skipped unlock keystrokes".into()); + } + if !windows_input_pipe_available() { + return Err( + "input service is unavailable; install it to unlock from the lock screen".into(), + ); + } + + // A first Enter brings up the password entry field on the Windows lock + // screen, then we type the password and confirm it. + let type_char = |ch: char| -> Result<(), String> { + let (vk, shift) = crate::windows_input::char_keystroke(ch) + .ok_or_else(|| format!("password contains character {ch:?} that cannot be typed"))?; + if shift { + send_through_input_pipe(&InputCommand::Key { + key_code: crate::windows_input::VK_SHIFT, + down: true, + })?; + } + send_through_input_pipe(&InputCommand::Key { key_code: vk, down: true })?; + send_through_input_pipe(&InputCommand::Key { key_code: vk, down: false })?; + if shift { + send_through_input_pipe(&InputCommand::Key { + key_code: crate::windows_input::VK_SHIFT, + down: false, + })?; + } + Ok(()) + }; + + let enter = || -> Result<(), String> { + send_through_input_pipe(&InputCommand::Key { + key_code: crate::windows_input::VK_RETURN, + down: true, + })?; + send_through_input_pipe(&InputCommand::Key { + key_code: crate::windows_input::VK_RETURN, + down: false, + }) + }; + + enter()?; + thread::sleep(Duration::from_millis(400)); + for ch in password.chars() { + type_char(ch)?; + thread::sleep(Duration::from_millis(12)); + } + thread::sleep(Duration::from_millis(120)); + enter()?; + Ok(()) +} + +/// Sends one key command straight through the privileged input service pipe, +/// bypassing the local-injection fallback: on the lock screen the local path +/// cannot deliver keystrokes, and silently falling back would type nothing. +#[cfg(target_os = "windows")] +fn send_through_input_pipe(command: &InputCommand) -> Result<(), String> { + windows_pipe_dispatcher().send(command) +} + fn packet_authorized(layout: &LayoutState, packet: &InputPacket) -> bool { packet_authorized_fields( layout, @@ -3033,7 +3184,7 @@ fn refresh_windows_input_desktop_cache() -> bool { } #[cfg(target_os = "windows")] -fn windows_input_desktop_is_default() -> bool { +pub fn windows_input_desktop_is_default() -> bool { use windows_sys::Win32::System::StationsAndDesktops::{ CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, DESKTOP_READOBJECTS, UOI_NAME, }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a338f1..f47f871 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -237,6 +237,14 @@ struct LayoutState { edge_switch_hotkey: String, #[serde(default)] screen_switch_hotkeys: ScreenSwitchHotkeys, + /// Controller side: when true, this machine's lock/unlock transitions are + /// mirrored onto its paired Windows targets (lock follow). + #[serde(default)] + lock_follow_enabled: bool, + /// Controlled side: DPAPI-protected local login password used to unlock + /// this workstation when a paired controller follows its own unlock. + #[serde(default)] + unlock_password_blob: String, } /// Cross-platform modifier remapping. Each field names the *logical* modifier @@ -1275,6 +1283,10 @@ fn merge_runtime_owned_layout_fields( // clear them and force the client to be paired again. incoming.cluster_id = current.cluster_id.clone(); incoming.pair_secret = current.pair_secret.clone(); + // The unlock password is set through its own backend command (DPAPI on the + // stored blob); a stale whole-layout snapshot from the frontend must never + // clobber or clear it. + incoming.unlock_password_blob = current.unlock_password_blob.clone(); if current.machine_role == "client" && incoming.machine_role == "client" @@ -2335,6 +2347,205 @@ fn dismiss_pairing_request(state: tauri::State<'_, AppRuntime>) -> Result = None; + loop { + thread::sleep(Duration::from_secs(2)); + let state = app.state::(); + let layout = state.layout_snapshot(); + // Lock follow is a controller-side feature: the machine whose lock + // state is mirrored is the one driving remote input ("server"). + if !layout.lock_follow_enabled || layout.machine_role != "server" { + last_locked = None; + continue; + } + let locked = !windows_input_desktop_is_default(); + if Some(locked) == last_locked { + continue; + } + last_locked = Some(locked); + let Some(transport) = state.quic_transport_handle() else { + continue; + }; + let action = if locked { "lock" } else { "unlock" }; + for device in layout.devices.iter() { + if device.role == "local" + || device.platform != "windows" + || !device.online + || !device.input_ready + { + continue; + } + if let Err(error) = + input::send_workstation_follow_control(&layout, &transport, &device.id, locked) + { + log::warn!("lock-follow {action} to {} failed: {error}", device.id); + } else { + log::info!("lock-follow: sent {action} to {}", device.id); + } + } + } +} + +/// Protects the unlock password with DPAPI so the layout file on disk never +/// contains the plaintext; the blob only decrypts for the same Windows user. +#[cfg(target_os = "windows")] +fn protect_unlock_password(password: &str) -> Result { + use base64::Engine; + use windows_sys::Win32::Security::Cryptography::{ + CryptProtectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, + }; + + if password.is_empty() { + return Ok(String::new()); + } + let mut input: Vec = password.as_bytes().to_vec(); + let mut output = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + let mut blob_in = CRYPT_INTEGER_BLOB { + cbData: input.len() as u32, + pbData: input.as_mut_ptr(), + }; + let ok = unsafe { + CryptProtectData( + &mut blob_in, + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + }; + if ok == 0 { + return Err(format!( + "failed to protect unlock password: {}", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + let protected = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize) }; + let encoded = base64::engine::general_purpose::STANDARD.encode(protected); + unsafe { windows_sys::Win32::Foundation::LocalFree(output.pbData as _) }; + Ok(encoded) +} + +#[cfg(not(target_os = "windows"))] +fn protect_unlock_password(_password: &str) -> Result { + Err("unlock follow is only supported on Windows.".into()) +} + +/// Decrypts a DPAPI-protected unlock password produced by +/// [`protect_unlock_password`]. Only ever used on the receiving (controlled) +/// side to type the password into the local logon UI. +#[cfg(target_os = "windows")] +pub(crate) fn unprotect_unlock_password(blob: &str) -> Result { + use base64::Engine; + use windows_sys::Win32::Security::Cryptography::{ + CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, + }; + + let protected = base64::engine::general_purpose::STANDARD + .decode(blob.trim()) + .map_err(|error| format!("decode unlock password blob: {error}"))?; + if protected.is_empty() { + return Ok(String::new()); + } + let mut blob_in = CRYPT_INTEGER_BLOB { + cbData: protected.len() as u32, + pbData: protected.as_ptr() as *mut _, + }; + let mut output = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + let ok = unsafe { + CryptUnprotectData( + &mut blob_in, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + }; + if ok == 0 { + return Err(format!( + "failed to unprotect unlock password: {}", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + let plain = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize) }; + let password = String::from_utf8_lossy(plain).into_owned(); + unsafe { windows_sys::Win32::Foundation::LocalFree(output.pbData as _) }; + Ok(password) +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn unprotect_unlock_password(_blob: &str) -> Result { + Err("unlock follow is only supported on Windows.".into()) +} + +/// Controller side: enable/disable mirroring this machine's lock/unlock onto +/// the paired Windows targets. +#[tauri::command] +fn set_lock_follow_enabled( + enabled: bool, + state: tauri::State<'_, AppRuntime>, +) -> Result<(), String> { + let updated_layout = { + let mut layout = state + .layout + .lock() + .map_err(|_| "layout state lock poisoned".to_string())?; + layout.lock_follow_enabled = enabled; + layout.clone() + }; + write_layout_to_disk(&state.config_path, &updated_layout) +} + +/// Controlled side: store (DPAPI-protected) or clear the local login password +/// used to unlock this workstation when the controller follows its unlock. +#[tauri::command] +fn set_unlock_password( + password: String, + state: tauri::State<'_, AppRuntime>, +) -> Result<(), String> { + let blob = protect_unlock_password(&password)?; + let updated_layout = { + let mut layout = state + .layout + .lock() + .map_err(|_| "layout state lock poisoned".to_string())?; + layout.unlock_password_blob = blob; + layout.clone() + }; + write_layout_to_disk(&state.config_path, &updated_layout) +} + /// Drop this machine's stored pairing trust so it can be paired afresh. /// /// A client only accepts a new pairing handshake while `pairing_required` @@ -2852,6 +3063,7 @@ pub fn run() { detected_layout, ); app.manage(runtime); + start_lock_follow_monitor(app.handle().clone()); // Eagerly start discovery + input BEFORE the WebView2/frontend is // ready. The old flow waited for the frontend to call @@ -2910,6 +3122,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ load_app_state, + set_lock_follow_enabled, + set_unlock_password, read_runtime_status, read_diagnostic_info, open_log_directory, @@ -3808,6 +4022,8 @@ fn detect_local_layout(app: &AppHandle) -> LayoutState { modifier_map: default_modifier_map(), edge_switch_hotkey: default_edge_switch_hotkey(), screen_switch_hotkeys: ScreenSwitchHotkeys::default(), + lock_follow_enabled: false, + unlock_password_blob: String::new(), devices: vec![Device { id: device_id, name: local_device_name(), @@ -3851,6 +4067,8 @@ fn detect_fallback_layout() -> LayoutState { modifier_map: default_modifier_map(), edge_switch_hotkey: default_edge_switch_hotkey(), screen_switch_hotkeys: ScreenSwitchHotkeys::default(), + lock_follow_enabled: false, + unlock_password_blob: String::new(), } } @@ -3964,6 +4182,8 @@ fn normalize_saved_layout(saved_layout: LayoutState, detected_layout: LayoutStat modifier_map: normalize_modifier_map(&saved_layout.modifier_map), edge_switch_hotkey: normalize_edge_switch_hotkey(&saved_layout.edge_switch_hotkey), screen_switch_hotkeys: saved_layout.screen_switch_hotkeys.clone(), + lock_follow_enabled: saved_layout.lock_follow_enabled, + unlock_password_blob: saved_layout.unlock_password_blob.clone(), } } @@ -6920,6 +7140,8 @@ mod tests { modifier_map: default_modifier_map(), edge_switch_hotkey: default_edge_switch_hotkey(), screen_switch_hotkeys: ScreenSwitchHotkeys::default(), + lock_follow_enabled: false, + unlock_password_blob: String::new(), } } diff --git a/src-tauri/src/windows_input.rs b/src-tauri/src/windows_input.rs index 6cdbaa8..0a3a068 100644 --- a/src-tauri/src/windows_input.rs +++ b/src-tauri/src/windows_input.rs @@ -376,6 +376,31 @@ pub fn inject_key(key_code: u16, down: bool) { } } +pub const VK_SHIFT: u16 = 0x10; +pub const VK_RETURN: u16 = 0x0D; + +/// Maps a (BMP) character to the virtual-key that types it on the active +/// keyboard layout, plus whether Shift must be held. Returns `None` when the +/// layout cannot type the character (e.g. an emoji in the password). +pub fn char_keystroke(ch: char) -> Option<(u16, bool)> { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::VkKeyScanW; + + let code = ch as u32; + if code > 0xFFFF { + return None; + } + let scan = unsafe { VkKeyScanW(code as u16) } as i16; + if scan == -1 { + return None; + } + let vk = (scan & 0xFF) as u16; + if vk == 0xFF { + return None; + } + let shift = ((scan >> 8) & 0x01) != 0; + Some((vk, shift)) +} + fn is_extended_key_vk(vk: u16) -> bool { matches!( vk, diff --git a/src/App.tsx b/src/App.tsx index b056f93..2e20569 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import { saveLayout, sendFilesToDevice, setAutostart, + setUnlockPassword, scanLanPeers, startRuntime, startWindowDrag, @@ -188,6 +189,10 @@ function App() { const [isInputServicePending, setIsInputServicePending] = useState(false); const [inputServiceAction, setInputServiceAction] = useState(null); + const [unlockPasswordDraft, setUnlockPasswordDraft] = useState(""); + const [unlockPasswordStatus, setUnlockPasswordStatus] = useState< + "idle" | "saving" | "saved" | "failed" + >("idle"); const [boardZoom, setBoardZoom] = useState(1); const [manualDeviceName, setManualDeviceName] = useState(""); const [manualDeviceHost, setManualDeviceHost] = useState(""); @@ -1341,6 +1346,25 @@ function App() { })); } + function setLockFollowEnabled(lockFollowEnabled: boolean) { + updateLayout((layoutState) => ({ + ...layoutState, + lockFollowEnabled, + })); + } + + async function saveUnlockPassword() { + setUnlockPasswordStatus("saving"); + try { + await setUnlockPassword(unlockPasswordDraft); + setUnlockPasswordDraft(""); + setUnlockPasswordStatus("saved"); + void loadAppState(); + } catch { + setUnlockPasswordStatus("failed"); + } + } + function setFileTransferEnabled(fileTransferEnabled: boolean) { updateLayout((layoutState) => ({ ...layoutState, @@ -2651,6 +2675,73 @@ function App() { +
+ + {ui.settings.lockFollow} + + ⓘ + + {ui.settings.lockFollowCopy} + + + +
+ + +
+
+
+ + {ui.settings.unlockPassword} + + ⓘ + + {ui.settings.unlockPasswordCopy} + + + +
+ { + setUnlockPasswordDraft(event.target.value); + setUnlockPasswordStatus("idle"); + }} + /> + + {layout.unlockPasswordBlob ? ( + {ui.settings.unlockPasswordSet} + ) : null} + {unlockPasswordStatus === "saved" ? ( + {ui.settings.unlockPasswordSaved} + ) : null} + {unlockPasswordStatus === "failed" ? ( + {ui.settings.unlockPasswordFailed} + ) : null} +
+
{ui.settings.fileTransfer} diff --git a/src/defaultLayout.ts b/src/defaultLayout.ts index 9879c67..06bca0e 100644 --- a/src/defaultLayout.ts +++ b/src/defaultLayout.ts @@ -33,6 +33,8 @@ export const defaultLayout: LayoutState = { modifierMap: { control: 'meta', alt: 'same', meta: 'control' }, edgeSwitchHotkey: 'alt+shift+k', screenSwitchHotkeys: { left: 'alt+left', right: 'alt+right', up: 'alt+up', down: 'alt+down' }, + lockFollowEnabled: false, + unlockPasswordBlob: '', devices: [ { id: 'local-device', diff --git a/src/desktopApi.ts b/src/desktopApi.ts index c051ef2..0580a46 100644 --- a/src/desktopApi.ts +++ b/src/desktopApi.ts @@ -94,6 +94,14 @@ export async function loadAppState(): Promise { return invoke('load_app_state') } +export async function setUnlockPassword(password: string): Promise { + if (!isTauri()) { + return + } + + await invoke('set_unlock_password', { password }) +} + export async function saveLayout(layout: LayoutState): Promise { if (!isTauri()) { return { diff --git a/src/i18n.ts b/src/i18n.ts index f45fc01..2751df2 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -121,6 +121,18 @@ export const TEXT = { fileTransfer: "文件传输 (bate)", fileTransferCopy: "将文件或文件夹拖到相邻屏幕即可跨设备传输,落在哪个设备上就送到哪台。", + lockFollow: "锁屏跟随", + lockFollowCopy: + "仅控制端生效:本机锁屏或解锁时,自动同步锁上或解锁已配对在线的 Windows 被控端。", + unlockPassword: "远程解锁密码", + unlockPasswordCopy: + "仅被控端生效:保存本机 Windows 登录密码(DPAPI 加密,仅本机用户可解密)。控制端解锁跟随时,本机自动输入该密码完成解锁;需先安装锁屏控制服务。", + unlockPasswordPlaceholder: "输入 Windows 登录密码", + unlockPasswordSave: "保存密码", + unlockPasswordSet: "已设置", + unlockPasswordSaving: "正在保存", + unlockPasswordSaved: "密码已保存", + unlockPasswordFailed: "密码保存失败。", modifierTitle: "跨平台改键", modifierCopy: "本机作为控制端时,发往不同系统对端的修饰键映射(仅 Win↔Mac 跨平台时生效)。默认 Ctrl↔Command 对调,让复制、粘贴、全选等快捷键沿用各自系统习惯。", @@ -350,6 +362,18 @@ export const TEXT = { fileTransfer: "File Transfer (bate)", fileTransferCopy: "Drag files or folders onto an adjacent screen to transfer them across devices — wherever they land is where they go.", + lockFollow: "Lock follow", + lockFollowCopy: + "Controller side only: when this machine locks or unlocks, paired online Windows peers are locked or unlocked to match.", + unlockPassword: "Remote unlock password", + unlockPasswordCopy: + "Controlled side only: stores this machine\u0027s Windows login password (DPAPI-encrypted, decryptable only by this Windows user). When the controller follows its unlock, the password is typed automatically; the lock-screen input service is required.", + unlockPasswordPlaceholder: "Windows login password", + unlockPasswordSave: "Save password", + unlockPasswordSet: "Set", + unlockPasswordSaving: "Saving", + unlockPasswordSaved: "Password saved", + unlockPasswordFailed: "Failed to save the password.", modifierTitle: "Cross-platform Keys", modifierCopy: "When this machine controls a peer on a different OS, remap modifier keys (only applies across Win↔Mac). The default swaps Ctrl↔Command so copy, paste, and select-all keep each platform's shortcut habits.", diff --git a/src/types.ts b/src/types.ts index 02a4824..8a41a95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,4 +85,6 @@ export interface LayoutState { modifierMap: ModifierMap edgeSwitchHotkey: string screenSwitchHotkeys: ScreenSwitchHotkeys + lockFollowEnabled: boolean + unlockPasswordBlob: string }