diff --git a/i18n/en.toml b/i18n/en.toml index 2033e6b8..9b60886d 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -259,6 +259,9 @@ return_to_layout_hint = "Right-click or Esc to return to layout" [unlock] highlighted_keys_hint = "Press and hold the highlighted keys" +start_hint = "Start unlocking, then hold the highlighted keys" +start = "Start unlocking" +cancel = "Cancel" [universal_symbols_setup] open_privacy_settings = "Open Privacy Settings" @@ -405,6 +408,7 @@ refresh_device_data_pending_write = "Finish the pending layer write before refre refresh_device_data_missing_device = "Failed to refresh device data: device not found" refresh_device_data_missing_info = "Failed to refresh device data: device information unavailable" refresh_device_data_delete_failed = "Failed to remove cached device data" +unlock_recovery_detected = "Active device unlock recovered — hold the highlighted keys" [dynamic_status] device_not_found = "Device not found" diff --git a/i18n/ru.toml b/i18n/ru.toml index 0a088134..35ee0392 100644 --- a/i18n/ru.toml +++ b/i18n/ru.toml @@ -259,6 +259,9 @@ return_to_layout_hint = "ПКМ или Esc — вернуться к раскл [unlock] highlighted_keys_hint = "Нажмите и удерживайте подсвеченные клавиши" +start_hint = "Начните разблокировку, затем удерживайте подсвеченные клавиши" +start = "Начать разблокировку" +cancel = "Отмена" [universal_symbols_setup] open_privacy_settings = "Открыть настройки приватности" @@ -405,6 +408,7 @@ refresh_device_data_pending_write = "Завершите запись слоя п refresh_device_data_missing_device = "Не удалось обновить данные устройства: устройство не найдено" refresh_device_data_missing_info = "Не удалось обновить данные устройства: информация недоступна" refresh_device_data_delete_failed = "Не удалось удалить сохранённые данные устройства" +unlock_recovery_detected = "Активная разблокировка восстановлена — удерживайте подсвеченные клавиши" [dynamic_status] device_not_found = "Устройство не найдено" diff --git a/src/app_init.rs b/src/app_init.rs index 7c2f423b..f478eb07 100644 --- a/src/app_init.rs +++ b/src/app_init.rs @@ -193,6 +193,7 @@ impl EntropyApp { vial_unlock_total: 50, vial_unlock_last_poll: None, vial_unlock_animation_nonce: 0, + vial_unlock_reconnect_after_completion: false, #[cfg(not(target_arch = "wasm32"))] connect_state: ConnectState::Idle, #[cfg(not(target_arch = "wasm32"))] @@ -230,7 +231,7 @@ impl EntropyApp { .hid_device .as_ref() .and_then(|hid| hid.get_unlock_status().ok()) - .map(|(unlocked, _)| unlocked) + .map(|status| status.unlocked) .map(|unlocked| !unlocked) .unwrap_or(false) } @@ -264,32 +265,28 @@ impl EntropyApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn cancel_vial_unlock(&mut self, suppress_macro_auto_unlock: bool) { - if let Some(hid) = &self.hid_device { - match hid.lock() { - Ok(()) => { - self.status_msg = crate::i18n::tr_catalog( - self.app_settings.language, - "status_messages.device_unlock_cancelled", - ) - .into(); - } - Err(e) => { - self.status_msg = crate::i18n::tr_catalog_format( - self.app_settings.language, - "status_messages.cancel_unlock_failed", - &[("error", &e.to_string())], - ); - } - } + if self.vial_unlock_polling { + log::warn!("Ignored Vial unlock cancellation after UNLOCK_START"); + self.status_msg = crate::i18n::tr_catalog( + self.app_settings.language, + "status_messages.finish_unlock_before_closing", + ) + .into(); + return; } + log::info!("Vial unlock prompt dismissed before UNLOCK_START"); + self.status_msg = crate::i18n::tr_catalog( + self.app_settings.language, + "status_messages.device_unlock_cancelled", + ) + .into(); self.unlock_open = false; self.vial_unlock_polling = false; self.vial_unlock_last_poll = None; + self.vial_unlock_reconnect_after_completion = false; self.pending_layout_indicator_open_after_unlock = false; self.vial_unlock_counter = 0; self.vial_unlock_best = 50; - self.matrix_tester_unlock_prompted = false; - self.matrix_tester_lock_checked = false; if suppress_macro_auto_unlock { self.macro_auto_unlock_cancelled = true; } diff --git a/src/app_state.rs b/src/app_state.rs index adc27e68..6c6597e5 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -369,10 +369,26 @@ pub(crate) struct ConnectResult { pub(crate) supported_qmk_settings: Vec, } +/// Minimal device state available while Vial firmware blocks normal VIA commands. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) struct UnlockRecoveryResult { + pub(crate) device_name: String, + pub(crate) keyboard_id: u64, + pub(crate) hid_device: Option, + pub(crate) layout: KeyboardLayout, + pub(crate) unlock_keys: Vec<(u8, u8)>, +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) enum ConnectOutcome { + Connected(Box), + UnlockRecovery(Box), +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) enum ConnectTaskMessage { Progress(String), - Done(Box>), + Done(Box>), } #[cfg(not(target_arch = "wasm32"))] @@ -2980,6 +2996,8 @@ pub struct EntropyApp { pub(crate) vial_unlock_total: u8, pub(crate) vial_unlock_last_poll: Option, pub(crate) vial_unlock_animation_nonce: u64, + /// Reload full device state after finishing an unlock recovered during connect. + pub(crate) vial_unlock_reconnect_after_completion: bool, } #[cfg(test)] diff --git a/src/hid.rs b/src/hid.rs index ef0a10f6..044f9038 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -742,10 +742,7 @@ fn response_matches_command(command: &[u8], resp: &[u8; MSG_LEN]) -> bool { }; match cmd { - CMD_VIA_GET_PROTOCOL_VERSION => { - resp[0] == CMD_VIA_GET_PROTOCOL_VERSION - && matches!(u16::from_be_bytes([resp[1], resp[2]]), 9 | 0xFFFF) - } + CMD_VIA_GET_PROTOCOL_VERSION => response_matches_via_protocol_version(command, resp), CMD_VIA_GET_LAYER_COUNT => { resp[0] == CMD_VIA_GET_LAYER_COUNT && (1..=32).contains(&resp[1]) } @@ -770,6 +767,16 @@ fn response_matches_command(command: &[u8], resp: &[u8; MSG_LEN]) -> bool { } } +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn response_matches_via_protocol_version(command: &[u8], resp: &[u8]) -> bool { + command.first() == Some(&CMD_VIA_GET_PROTOCOL_VERSION) + && resp.first() == Some(&CMD_VIA_GET_PROTOCOL_VERSION) + && resp + .get(1..3) + .map(|version| matches!(u16::from_be_bytes([version[0], version[1]]), 0 | 9 | 0xFFFF)) + .unwrap_or(false) +} + #[cfg(not(target_arch = "wasm32"))] fn response_matches_vial_command(command: &[u8], resp: &[u8; MSG_LEN]) -> bool { let Some(&subcommand) = command.get(1) else { @@ -1065,6 +1072,22 @@ mod tests { assert!(response_matches_command(&command, &response)); } + #[test] + fn protocol_version_matcher_accepts_active_unlock_zero_and_rejects_unrelated_reports() { + let command = [CMD_VIA_GET_PROTOCOL_VERSION]; + let active_unlock_response = [CMD_VIA_GET_PROTOCOL_VERSION, 0, 0]; + let unrelated_response = [CMD_VIA_GET_LAYER_COUNT, 0, 0]; + + assert!(response_matches_via_protocol_version( + &command, + &active_unlock_response + )); + assert!(!response_matches_via_protocol_version( + &command, + &unrelated_response + )); + } + #[test] fn qmk_settings_set_rejects_stale_get_response() { let mut command = qmk_settings_command(CMD_VIAL_QMK_SETTINGS_SET, 300); diff --git a/src/hid_parse.rs b/src/hid_parse.rs index db7d3eb5..b9665403 100644 --- a/src/hid_parse.rs +++ b/src/hid_parse.rs @@ -154,8 +154,16 @@ pub(crate) fn parse_vialrgb_supported_effects_payload( batch_max } -pub(crate) fn parse_unlock_status_response(resp: &[u8]) -> (bool, Vec<(u8, u8)>) { +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct VialUnlockStatus { + pub(crate) unlocked: bool, + pub(crate) in_progress: bool, + pub(crate) keys: Vec<(u8, u8)>, +} + +pub(crate) fn parse_unlock_status_response(resp: &[u8]) -> VialUnlockStatus { let unlocked = resp.first().copied() == Some(1); + let in_progress = resp.get(1).copied() == Some(1); let mut keys = Vec::new(); let mut i = 2; while i + 1 < resp.len() { @@ -167,13 +175,34 @@ pub(crate) fn parse_unlock_status_response(resp: &[u8]) -> (bool, Vec<(u8, u8)>) keys.push((row, col)); i += 2; } - (unlocked, keys) + VialUnlockStatus { + unlocked, + in_progress, + keys, + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn parses_active_unlock_status_and_keys() { + let mut resp = [0xFFu8; 32]; + resp[0] = 0; + resp[1] = 1; + resp[2..6].copy_from_slice(&[1, 2, 3, 4]); + + assert_eq!( + parse_unlock_status_response(&resp), + VialUnlockStatus { + unlocked: false, + in_progress: true, + keys: vec![(1, 2), (3, 4)], + } + ); + } + #[test] fn parses_and_pads_macro_buffer() { let parsed = parse_macro_buffer(b"one\0two", 4); @@ -300,7 +329,11 @@ mod tests { let resp = [1, 0, 3, 4, 5, 6, 0xff, 0xff, 7, 8]; assert_eq!( parse_unlock_status_response(&resp), - (true, vec![(3, 4), (5, 6)]) + VialUnlockStatus { + unlocked: true, + in_progress: false, + keys: vec![(3, 4), (5, 6)], + } ); } } diff --git a/src/hid_vial.rs b/src/hid_vial.rs index 186e82e0..b90d7a48 100644 --- a/src/hid_vial.rs +++ b/src/hid_vial.rs @@ -1,4 +1,4 @@ -use super::hid_parse::parse_unlock_status_response; +use super::hid_parse::{parse_unlock_status_response, VialUnlockStatus}; use super::hid_protocol::*; use super::HidDevice; use anyhow::{bail, Context, Result}; @@ -81,8 +81,7 @@ impl HidDevice { } /// Check if keyboard is unlocked - /// Returns (unlocked, unlock_keys: Vec<(row,col)>) - pub fn get_unlock_status(&self) -> Result<(bool, Vec<(u8, u8)>)> { + pub fn get_unlock_status(&self) -> Result { let resp = self .usb_send(&[CMD_VIA_VIAL_PREFIX, CMD_VIAL_GET_UNLOCK_STATUS]) .context("failed to read Vial unlock status")?; diff --git a/src/ui/device_connect_apply.rs b/src/ui/device_connect_apply.rs index 1d0a3d67..eacea65b 100644 --- a/src/ui/device_connect_apply.rs +++ b/src/ui/device_connect_apply.rs @@ -341,7 +341,49 @@ impl EntropyApp { self.connect_state = ConnectState::Idle; match result { - Ok(r) => { + Ok(ConnectOutcome::UnlockRecovery(r)) => { + let layer_count = r.layout.layers.len().max(1); + log::warn!( + "Applying Vial unlock recovery for {} (keyboard id {:016X}, {} unlock keys)", + r.device_name, + r.keyboard_id, + r.unlock_keys.len() + ); + self.pending_tap_hold_numeric_writes.clear(); + self.tap_hold_numeric_write_due = None; + self.layer_count = layer_count; + self.firmware = r.layout.firmware; + self.current_device_name = r.device_name.clone(); + self.current_keyboard_id = Some(r.keyboard_id); + self.current_encoder_visibility_id = + encoder_visibility_id(&r.device_name, r.keyboard_id); + self.layout = Some(r.layout); + self.hid_device = r.hid_device; + self.vial_unlock_keys = r.unlock_keys; + self.vial_unlock_polling = true; + self.vial_unlock_counter = 1; + self.vial_unlock_best = 1; + self.vial_unlock_total = 1; + self.vial_unlock_last_poll = Some(std::time::Instant::now()); + self.vial_unlock_animation_nonce = self.vial_unlock_animation_nonce.wrapping_add(1); + self.vial_unlock_reconnect_after_completion = true; + self.unlock_open = true; + self.macro_auto_unlock_cancelled = false; + self.status_msg = crate::i18n::tr_catalog( + self.app_settings.language, + "status_messages.unlock_recovery_detected", + ) + .into(); + if let Some(dev) = self + .selected_device + .and_then(|idx| self.device_manager.devices().get(idx)) + { + self.device_display_names + .insert(dev.display_name_cache_key(), r.device_name); + } + ctx.request_repaint(); + } + Ok(ConnectOutcome::Connected(r)) => { self.pending_tap_hold_numeric_writes.clear(); self.tap_hold_numeric_write_due = None; log::info!( diff --git a/src/ui/device_connect_task.rs b/src/ui/device_connect_task.rs index 7ff9b4e5..9a6f58bf 100644 --- a/src/ui/device_connect_task.rs +++ b/src/ui/device_connect_task.rs @@ -443,6 +443,23 @@ fn supports_vial_macro_ext_keycodes(vial_protocol: u32, json: &serde_json::Value vial_protocol >= 5 && macro_ext_keycodes_disabled_reason(json).is_none() } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ViaConnectMode { + Normal, + RecoverUnlock, + Unsupported, +} + +fn classify_via_connect(via_protocol: u16, unlock_status: Option<(bool, bool)>) -> ViaConnectMode { + if via_protocol == 9 { + ViaConnectMode::Normal + } else if via_protocol == 0 && unlock_status == Some((false, true)) { + ViaConnectMode::RecoverUnlock + } else { + ViaConnectMode::Unsupported + } +} + impl EntropyApp { pub(super) fn refresh_current_device_data(&mut self) { let lang = self.app_settings.language; @@ -561,6 +578,10 @@ impl EntropyApp { self.selected_key_override = 0; self.key_override_pick_target = None; self.reset_matrix_tester_state(); + self.unlock_open = false; + self.vial_unlock_polling = false; + self.vial_unlock_last_poll = None; + self.vial_unlock_reconnect_after_completion = false; let (tx, rx) = mpsc::channel(); let now = std::time::Instant::now(); @@ -574,7 +595,7 @@ impl EntropyApp { let progress = |message: &str| { let _ = tx.send(ConnectTaskMessage::Progress(message.to_owned())); }; - let result = (|| -> Result { + let result = (|| -> Result { use crate::hid::HidDevice; progress("Opening HID device…"); @@ -600,15 +621,33 @@ impl EntropyApp { .map_err(|e| format!("Vial keyboard id read failed: {e}"))?; log::info!("Vial protocol: {vial_protocol}, keyboard id: {keyboard_id:016X}"); let cache_key = device_cache_key(&dev, keyboard_id); - if ![-1i32, 9].contains(&(via_protocol as i32)) { - return Err(format!("Unsupported VIA protocol version: {via_protocol}")); - } if !matches!(vial_protocol, 0..=6) { return Err(format!( "Unsupported Vial protocol version: {vial_protocol}" )); } + let unlock_status = if via_protocol == 0 { + progress("Checking active Vial unlock…"); + log::warn!( + "VIA protocol 0 received from {}; checking Vial unlock state", + dev.name + ); + Some(dev_conn.get_unlock_status().map_err(|e| { + format!("Vial unlock status read failed while VIA protocol is 0: {e}") + })?) + } else { + None + }; + let connect_mode = classify_via_connect( + via_protocol, + unlock_status + .as_ref() + .map(|status| (status.unlocked, status.in_progress)), + ); + if connect_mode == ViaConnectMode::Unsupported { + return Err(format!("Unsupported VIA protocol version: {via_protocol}")); + } progress("Reading firmware version…"); let runtime_firmware_version = match dev_conn.get_firmware_version() { Ok(Some(version)) => Some(version), @@ -658,6 +697,33 @@ impl EntropyApp { } json }; + + progress("Parsing keyboard layout…"); + let mut layout = KeyboardLayout::from_vial_json(&json) + .map_err(|e| format!("Layout parse failed: {e}"))?; + + if connect_mode == ViaConnectMode::RecoverUnlock { + let Some(unlock_status) = unlock_status else { + return Err("Active Vial unlock state disappeared during connect".into()); + }; + let unlock_keys = unlock_status.keys; + log::warn!( + "Recovering active Vial unlock for {} (keyboard id {keyboard_id:016X}, {} unlock keys)", + dev.name, + unlock_keys.len() + ); + progress("Resuming active Vial unlock…"); + return Ok(ConnectOutcome::UnlockRecovery(Box::new( + UnlockRecoveryResult { + device_name: dev.name.clone(), + keyboard_id, + hid_device: Some(dev_conn), + layout, + unlock_keys, + }, + ))); + } + let firmware_version = runtime_firmware_version .clone() .or_else(|| firmware_version_from_vial_json(&json)); @@ -723,10 +789,6 @@ impl EntropyApp { }; let has_qmk_setting = |qsid: u16| supported_qmk_settings.contains(&qsid); - progress("Parsing keyboard layout…"); - let mut layout = KeyboardLayout::from_vial_json(&json) - .map_err(|e| format!("Layout parse failed: {e}"))?; - progress("Reading layer count…"); log::info!("Getting layer count…"); let reported_layer_count = dev_conn @@ -1334,7 +1396,7 @@ impl EntropyApp { }; progress("Applying keyboard layout…"); - Ok(ConnectResult { + Ok(ConnectOutcome::Connected(Box::new(ConnectResult { device_name: dev.name.clone(), keyboard_id, hid_device: Some(dev_conn), @@ -1365,7 +1427,7 @@ impl EntropyApp { layout, layer_count, supported_qmk_settings, - }) + }))) })(); let _ = tx.send(ConnectTaskMessage::Done(Box::new(result))); @@ -1386,6 +1448,42 @@ impl EntropyApp { mod tests { use super::*; + #[test] + fn via_zero_recovers_only_active_vial_unlock() { + assert_eq!( + classify_via_connect(0, Some((false, true))), + ViaConnectMode::RecoverUnlock + ); + assert_eq!( + classify_via_connect(0, Some((false, false))), + ViaConnectMode::Unsupported + ); + assert_eq!( + classify_via_connect(0, Some((true, true))), + ViaConnectMode::Unsupported + ); + assert_eq!(classify_via_connect(0, None), ViaConnectMode::Unsupported); + assert_eq!(classify_via_connect(9, None), ViaConnectMode::Normal); + } + + #[test] + fn active_unlock_via_zero_response_requires_unlock_in_progress() { + let command = [0x01]; // CMD_VIA_GET_PROTOCOL_VERSION + let response = [0x01, 0, 0]; + + assert!(crate::hid::response_matches_via_protocol_version( + &command, &response + )); + assert_eq!( + classify_via_connect(0, Some((false, true))), + ViaConnectMode::RecoverUnlock + ); + assert_eq!( + classify_via_connect(0, Some((false, false))), + ViaConnectMode::Unsupported + ); + } + #[test] fn reported_layer_count_is_never_zero() { assert_eq!(normalize_reported_layer_count(0), 1); diff --git a/src/ui/device_connection.rs b/src/ui/device_connection.rs index 59195bc8..6054419d 100644 --- a/src/ui/device_connection.rs +++ b/src/ui/device_connection.rs @@ -64,6 +64,7 @@ impl EntropyApp { self.unlock_open = false; self.vial_unlock_polling = false; self.vial_unlock_last_poll = None; + self.vial_unlock_reconnect_after_completion = false; self.pending_layout_indicator_open_after_unlock = false; self.keycode_picker.open = false; self.current_device_name.clear(); diff --git a/src/ui/layout_settings_dropdown.rs b/src/ui/layout_settings_dropdown.rs index 2cbaa87d..649fa20c 100644 --- a/src/ui/layout_settings_dropdown.rs +++ b/src/ui/layout_settings_dropdown.rs @@ -133,8 +133,8 @@ impl EntropyApp { let mut lock_label = default_lock_label; if show_lock_item { match self.hid_device.as_ref().map(|hid| hid.get_unlock_status()) { - Some(Ok((unlocked, _keys))) => { - is_unlocked = unlocked; + Some(Ok(status)) => { + is_unlocked = status.unlocked; lock_label = if is_unlocked { crate::i18n::tr_catalog(lang, "ui.lock_keyboard_action") } else { diff --git a/src/vial/unlock.rs b/src/vial/unlock.rs index 1fcc3f82..c1588da8 100644 --- a/src/vial/unlock.rs +++ b/src/vial/unlock.rs @@ -17,57 +17,94 @@ fn unlock_poll_failure_action(disconnected: bool) -> UnlockPollFailureAction { } } +fn should_start_vial_unlock( + unlock_open: bool, + unlock_polling: bool, + start_requested: bool, +) -> bool { + unlock_open && !unlock_polling && start_requested +} + impl EntropyApp { fn stop_vial_unlock_with_status(&mut self, status: impl Into) { self.status_msg = status.into(); self.unlock_open = false; self.vial_unlock_polling = false; self.vial_unlock_last_poll = None; + self.vial_unlock_reconnect_after_completion = false; self.vial_unlock_counter = self.vial_unlock_total; self.vial_unlock_best = self.vial_unlock_total; self.pending_layout_indicator_open_after_unlock = false; } + fn start_vial_unlock(&mut self) { + if !should_start_vial_unlock(self.unlock_open, self.vial_unlock_polling, true) { + return; + } + + let Some(hid) = &self.hid_device else { + self.stop_vial_unlock_with_status(crate::i18n::tr_catalog( + self.app_settings.language, + "status_messages.unlock_cancelled_disconnected", + )); + return; + }; + + match hid.get_unlock_status() { + Ok(status) if status.unlocked => { + self.vial_unlock_keys = status.keys; + self.unlock_open = false; + self.status_msg = crate::i18n::tr_catalog( + self.app_settings.language, + "status_messages.device_unlocked", + ) + .into(); + log::info!("Vial unlock start skipped: device already unlocked"); + return; + } + Ok(status) if status.in_progress => { + self.vial_unlock_keys = status.keys; + self.vial_unlock_polling = true; + self.vial_unlock_counter = 1; + self.vial_unlock_best = 1; + self.vial_unlock_total = 1; + self.vial_unlock_last_poll = Some(std::time::Instant::now()); + self.vial_unlock_animation_nonce = self.vial_unlock_animation_nonce.wrapping_add(1); + self.vial_unlock_reconnect_after_completion = false; + log::warn!("Vial unlock already active; resuming poll without UNLOCK_START"); + return; + } + Ok(status) => self.vial_unlock_keys = status.keys, + Err(e) => log::warn!("Vial unlock status read before start failed: {e}"), + } + + log::info!("Starting Vial unlock after explicit user action"); + match hid.unlock_start() { + Ok(()) => { + self.vial_unlock_polling = true; + self.vial_unlock_counter = 1; + self.vial_unlock_best = 1; + self.vial_unlock_total = 1; + self.vial_unlock_last_poll = Some(std::time::Instant::now()); + self.vial_unlock_animation_nonce = self.vial_unlock_animation_nonce.wrapping_add(1); + self.vial_unlock_reconnect_after_completion = false; + log::info!("Vial UNLOCK_START accepted; polling active session"); + } + Err(e) => { + log::error!("Vial UNLOCK_START failed: {e}"); + self.stop_vial_unlock_with_status(crate::i18n::tr_catalog_format( + self.app_settings.language, + "status_messages.unlock_start_failed", + &[("error", &e.to_string())], + )); + } + } + } + pub(super) fn draw_vial_unlock_overlay(&mut self, ctx: &egui::Context) { // Vial unlock modal if self.unlock_open && self.firmware == FirmwareProtocol::Vial { - // Start unlock if not yet polling - if !self.vial_unlock_polling { - if let Some(hid) = &self.hid_device { - // Get unlock keys from get_unlock_status - if let Ok((_, keys)) = hid.get_unlock_status() { - self.vial_unlock_keys = keys; - } - // Start the unlock process - match hid.unlock_start() { - Ok(()) => { - self.vial_unlock_polling = true; - self.vial_unlock_counter = 1; - self.vial_unlock_best = 1; - self.vial_unlock_total = 1; - // Match Vial GUI: first poll happens after the timer interval, - // so progress starts empty instead of jumping on the same frame. - self.vial_unlock_last_poll = Some(std::time::Instant::now()); - self.vial_unlock_animation_nonce = - self.vial_unlock_animation_nonce.wrapping_add(1); - } - Err(e) => { - self.stop_vial_unlock_with_status(crate::i18n::tr_catalog_format( - self.app_settings.language, - "status_messages.unlock_start_failed", - &[("error", &e.to_string())], - )); - return; - } - } - } else { - self.stop_vial_unlock_with_status(crate::i18n::tr_catalog( - self.app_settings.language, - "status_messages.unlock_cancelled_disconnected", - )); - return; - } - } + let mut reconnect_after_poll = false; // Match Vial's polling cadence. Vial QMK resets the unlock counter whenever // UNLOCK_POLL arrives before its internal ~100ms timer has elapsed, even if the // correct keys are held. Polling too fast makes progress stick near zero. @@ -82,12 +119,16 @@ impl EntropyApp { self.vial_unlock_last_poll = Some(now); if let Some(hid) = &self.hid_device { match hid.unlock_poll() { - Ok((unlocked, _in_progress, counter)) => { + Ok((unlocked, in_progress, counter)) => { self.vial_unlock_counter = counter; if counter > self.vial_unlock_total { self.vial_unlock_total = counter; } if unlocked { + log::info!( + "Vial unlock completed (recovered_session={})", + self.vial_unlock_reconnect_after_completion + ); self.status_msg = crate::i18n::tr_catalog( self.app_settings.language, "status_messages.device_unlocked", @@ -97,12 +138,24 @@ impl EntropyApp { self.vial_unlock_polling = false; self.vial_unlock_last_poll = None; self.macro_auto_unlock_cancelled = false; + reconnect_after_poll = + self.vial_unlock_reconnect_after_completion; + self.vial_unlock_reconnect_after_completion = false; if self.pending_layout_indicator_open_after_unlock { self.pending_layout_indicator_open_after_unlock = false; self.app_settings.sticky_layout_window = true; self.sticky_layout_last_size = None; save_app_settings(&self.app_settings); } + } else if !in_progress { + log::warn!( + "Vial unlock poll reported session ended before unlock; reconnecting" + ); + self.unlock_open = false; + self.vial_unlock_polling = false; + self.vial_unlock_last_poll = None; + self.vial_unlock_reconnect_after_completion = false; + reconnect_after_poll = true; } } Err(e) => match unlock_poll_failure_action( @@ -138,11 +191,29 @@ impl EntropyApp { } ctx.request_repaint_after(std::time::Duration::from_millis(16)); } + if reconnect_after_poll { + let Some(device_idx) = self.selected_device else { + self.stop_vial_unlock_with_status(crate::i18n::tr_catalog( + self.app_settings.language, + "status_messages.unlock_cancelled_disconnected", + )); + return; + }; + log::info!("Reloading device state after Vial unlock recovery"); + self.start_connect(device_idx); + return; + } + if !self.unlock_open { + return; + } // Fullscreen overlay with layout and highlighted keys let unlock_keys = self.vial_unlock_keys.clone(); let counter = self.vial_unlock_counter; let total = self.vial_unlock_total; let layout_options_value = self.layout_options_value; + let waiting_for_start = !self.vial_unlock_polling; + let mut start_requested = false; + let mut cancel_requested = false; egui::Area::new(egui::Id::new("unlock_overlay")) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) @@ -176,6 +247,11 @@ impl EntropyApp { } else { Color32::from_rgb(230, 230, 233) }; + ui.interact( + screen, + egui::Id::new("unlock_overlay_blocker"), + egui::Sense::click_and_drag(), + ); ui.painter().rect_filled(screen, 0.0, screen_bg); let center_x = screen.center().x; @@ -193,53 +269,96 @@ impl EntropyApp { title_color, ); + let subtitle_key = if waiting_for_start { + "unlock.start_hint" + } else { + "unlock.highlighted_keys_hint" + }; ui.painter().text( egui::pos2(center_x, top_y + 30.0), egui::Align2::CENTER_CENTER, - crate::i18n::tr_catalog( - self.app_settings.language, - "unlock.highlighted_keys_hint", - ), + crate::i18n::tr_catalog(self.app_settings.language, subtitle_key), FontId::proportional(14.0), subtitle_color, ); - // Progress bar - let target_progress = if total > 0 { - 1.0 - (counter as f32 / total as f32) + if waiting_for_start { + let controls_rect = egui::Rect::from_center_size( + egui::pos2(center_x, top_y + 78.0), + egui::vec2(320.0, 40.0), + ); + crate::ui_style::allocate_ui_at_rect(ui, controls_rect, |ui| { + ui.horizontal_centered(|ui| { + if crate::ui_style::modern_button( + ui, + crate::i18n::tr_catalog( + self.app_settings.language, + "unlock.cancel", + ), + egui::vec2(120.0, 34.0), + true, + ) + .clicked() + { + cancel_requested = true; + } + ui.add_space(12.0); + if crate::ui_style::modern_button( + ui, + crate::i18n::tr_catalog( + self.app_settings.language, + "unlock.start", + ), + egui::vec2(120.0, 34.0), + true, + ) + .clicked() + { + start_requested = true; + } + }); + }); } else { - 0.0 - }; - let progress = ui.ctx().animate_value_with_time( - egui::Id::new(("vial_unlock_progress", self.vial_unlock_animation_nonce)), - target_progress.clamp(0.0, 1.0), - VIAL_UNLOCK_PROGRESS_ANIMATION_TIME, - ); - let bar_w = 300.0f32; - let bar_h = 12.0f32; - let bar_y = top_y + 55.0; - let bar_rect = egui::Rect::from_min_size( - egui::pos2(center_x - bar_w / 2.0, bar_y), - egui::Vec2::new(bar_w, bar_h), - ); - ui.painter().rect( - bar_rect, - 4.0, - bar_bg, - egui::Stroke::NONE, - egui::StrokeKind::Inside, - ); - let fill_rect = egui::Rect::from_min_size( - bar_rect.min, - egui::Vec2::new(bar_w * progress, bar_h), - ); - ui.painter().rect( - fill_rect, - 4.0, - app_accent(), - egui::Stroke::NONE, - egui::StrokeKind::Inside, - ); + // Progress bar + let target_progress = if total > 0 { + 1.0 - (counter as f32 / total as f32) + } else { + 0.0 + }; + let progress = ui.ctx().animate_value_with_time( + egui::Id::new(( + "vial_unlock_progress", + self.vial_unlock_animation_nonce, + )), + target_progress.clamp(0.0, 1.0), + VIAL_UNLOCK_PROGRESS_ANIMATION_TIME, + ); + let bar_w = 300.0f32; + let bar_h = 12.0f32; + let bar_y = top_y + 55.0; + let bar_rect = egui::Rect::from_min_size( + egui::pos2(center_x - bar_w / 2.0, bar_y), + egui::Vec2::new(bar_w, bar_h), + ); + ui.painter().rect( + bar_rect, + 4.0, + bar_bg, + egui::Stroke::NONE, + egui::StrokeKind::Inside, + ); + let fill_rect = egui::Rect::from_min_size( + bar_rect.min, + egui::Vec2::new(bar_w * progress, bar_h), + ); + ui.painter().rect( + fill_rect, + 4.0, + app_accent(), + egui::Stroke::NONE, + egui::StrokeKind::Inside, + ); + } // Draw layout keys with highlighted unlock keys. Always compute geometry // against the fullscreen unlock overlay: `last_layout_geometry` belongs to @@ -300,6 +419,17 @@ impl EntropyApp { } } }); + + if cancel_requested { + self.cancel_vial_unlock(true); + ctx.request_repaint(); + return; + } + if should_start_vial_unlock(self.unlock_open, self.vial_unlock_polling, start_requested) + { + self.start_vial_unlock(); + ctx.request_repaint(); + } } } } @@ -308,6 +438,14 @@ impl EntropyApp { mod tests { use super::*; + #[test] + fn unlock_waits_for_explicit_start_request() { + assert!(!should_start_vial_unlock(true, false, false)); + assert!(should_start_vial_unlock(true, false, true)); + assert!(!should_start_vial_unlock(false, false, true)); + assert!(!should_start_vial_unlock(true, true, true)); + } + #[test] fn transient_unlock_poll_failure_keeps_session_active() { assert_eq!(