From 3bb7cc09bbdb02001cb78bb020c1ff108496f3b6 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 10 Aug 2026 20:32:05 +0100 Subject: [PATCH 1/2] Fix Windows modifier overlay startup --- src-tauri/src/lib.rs | 10 ++ src-tauri/src/modifier_overlay.rs | 183 ++++++++++++++++++++++++++---- src/ModifierOverlay.test.tsx | 59 +++++++++- src/ModifierOverlay.tsx | 6 +- 4 files changed, 232 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d835f73..b55bde7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -376,6 +376,15 @@ fn modifier_overlay_ready( overlay.ready(window.label()) } +#[tauri::command] +fn modifier_overlay_present( + window: tauri::WebviewWindow, + overlay: State<'_, modifier_overlay::ModifierOverlay>, + revision: u64, +) -> Result<(), String> { + overlay.present(window.label(), revision) +} + #[tauri::command] fn forget_device(model: State<'_, AppModel>, device_id: String) -> Result { { @@ -1257,6 +1266,7 @@ pub fn run() { reject_pairing, disconnect_all, modifier_overlay_ready, + modifier_overlay_present, forget_device, save_settings, set_telemetry_consent, diff --git a/src-tauri/src/modifier_overlay.rs b/src-tauri/src/modifier_overlay.rs index b90b77f..087791b 100644 --- a/src-tauri/src/modifier_overlay.rs +++ b/src-tauri/src/modifier_overlay.rs @@ -1,8 +1,12 @@ -use std::sync::{Arc, Mutex}; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; use serde::Serialize; use tauri::{ - Emitter, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder, + utils::config::Color, Emitter, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, + WebviewWindowBuilder, }; use crate::input::ModifierKey; @@ -14,6 +18,8 @@ const OVERLAY_WINDOW_LABEL: &str = "modifier-overlay"; const OVERLAY_WIDTH: f64 = 480.0; const OVERLAY_HEIGHT: f64 = 70.0; const OVERLAY_MARGIN: f64 = 16.0; +const WINDOWS_BOOTSTRAP_POSITION: f64 = -32_000.0; +const READINESS_TIMEOUT: Duration = Duration::from_secs(5); pub trait ModifierKeyOverlayNotifier: Send + Sync { fn set_active_modifiers(&self, active_modifiers: &[ModifierKey]); @@ -44,11 +50,25 @@ struct ModifierOverlayState { revision: u64, active_modifiers: Vec, ready: bool, + readiness_failure_reported: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct BootstrapPolicy { + visible: bool, + position: Option<(f64, f64)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PresentationAction { + Ignore, + Show, } impl ModifierOverlay { pub fn install(app: tauri::AppHandle, shared: SharedModel) -> Result { - let window = WebviewWindowBuilder::new( + let policy = current_bootstrap_policy(); + let mut builder = WebviewWindowBuilder::new( &app, OVERLAY_WINDOW_LABEL, WebviewUrl::App("index.html?view=modifier-overlay".into()), @@ -64,21 +84,26 @@ impl ModifierOverlay { .skip_taskbar(true) .focusable(false) .focused(false) - .visible(false) - .build() - .map_err(|error| error.to_string())?; + .background_color(Color(0, 0, 0, 0)) + .visible(policy.visible); + if let Some((x, y)) = policy.position { + builder = builder.position(x, y); + } + let window = builder.build().map_err(|error| error.to_string())?; configure_platform_window(&window)?; window .set_ignore_cursor_events(true) .map_err(|error| error.to_string())?; - Ok(Self { + let overlay = Self { inner: Arc::new(ModifierOverlayInner { app, shared, window, state: Mutex::new(ModifierOverlayState::default()), }), - }) + }; + overlay.start_readiness_watchdog(); + Ok(overlay) } pub fn notifier(&self) -> Arc { @@ -98,19 +123,46 @@ impl ModifierOverlay { state.ready = true; snapshot(&state) }; - if !snapshot.labels.is_empty() { - self.position_window()?; + if snapshot.labels.is_empty() { + self.inner + .window + .hide() + .map_err(|error| error.to_string())?; } Ok(snapshot) } + pub fn present(&self, window_label: &str, revision: u64) -> Result<(), String> { + if window_label != OVERLAY_WINDOW_LABEL { + return Err( + "Modifier overlay presentation is only available to its overlay window.".into(), + ); + } + let action = { + let state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + presentation_action(&state, revision) + }; + if action == PresentationAction::Show { + self.position_window()?; + self.inner + .window + .show() + .map_err(|error| error.to_string())?; + } + Ok(()) + } + pub fn end_session(&self) { self.end_control_session(); } fn update(&self, active_modifiers: &[ModifierKey]) { let normalized = canonical_modifiers(active_modifiers); - let (snapshot, ready) = { + let snapshot = { let mut state = self .inner .state @@ -121,7 +173,7 @@ impl ModifierOverlay { } state.active_modifiers = normalized; state.revision = state.revision.wrapping_add(1); - (snapshot(&state), state.ready) + snapshot(&state) }; if let Err(error) = self .inner @@ -134,18 +186,31 @@ impl ModifierOverlay { if let Err(error) = self.inner.window.hide() { self.report_failure(&error.to_string()); } - } else if ready { - if let Err(error) = self.position_window().and_then(|_| { - self.inner - .window - .show() - .map_err(|window_error| window_error.to_string()) - }) { - self.report_failure(&error); - } } } + fn start_readiness_watchdog(&self) { + let overlay = self.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(READINESS_TIMEOUT).await; + let should_report = { + let mut state = overlay + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let should_report = readiness_watchdog_should_report(&state); + if should_report { + state.readiness_failure_reported = true; + } + should_report + }; + if should_report { + overlay.report_failure("the overlay page did not initialize in time"); + } + }); + } + fn position_window(&self) -> Result<(), String> { let cursor = self .inner @@ -187,6 +252,36 @@ impl ModifierOverlay { } } +fn current_bootstrap_policy() -> BootstrapPolicy { + bootstrap_policy(cfg!(target_os = "windows")) +} + +fn bootstrap_policy(is_windows: bool) -> BootstrapPolicy { + if is_windows { + BootstrapPolicy { + visible: true, + position: Some((WINDOWS_BOOTSTRAP_POSITION, WINDOWS_BOOTSTRAP_POSITION)), + } + } else { + BootstrapPolicy { + visible: false, + position: None, + } + } +} + +fn presentation_action(state: &ModifierOverlayState, revision: u64) -> PresentationAction { + if state.ready && revision == state.revision && !state.active_modifiers.is_empty() { + PresentationAction::Show + } else { + PresentationAction::Ignore + } +} + +fn readiness_watchdog_should_report(state: &ModifierOverlayState) -> bool { + !state.ready && !state.readiness_failure_reported +} + #[cfg(target_os = "macos")] fn configure_platform_window(window: &WebviewWindow) -> Result<(), String> { window @@ -344,4 +439,50 @@ mod tests { PhysicalPosition::new(1424, 16) ); } + + #[test] + fn windows_bootstraps_visible_and_offscreen() { + assert_eq!( + bootstrap_policy(true), + BootstrapPolicy { + visible: true, + position: Some((WINDOWS_BOOTSTRAP_POSITION, WINDOWS_BOOTSTRAP_POSITION)), + } + ); + assert_eq!( + bootstrap_policy(false), + BootstrapPolicy { + visible: false, + position: None, + } + ); + } + + #[test] + fn presentation_requires_ready_current_nonempty_state() { + let mut state = ModifierOverlayState { + revision: 4, + active_modifiers: vec![ModifierKey::Ctrl], + ..ModifierOverlayState::default() + }; + assert_eq!(presentation_action(&state, 4), PresentationAction::Ignore); + + state.ready = true; + assert_eq!(presentation_action(&state, 3), PresentationAction::Ignore); + assert_eq!(presentation_action(&state, 4), PresentationAction::Show); + + state.active_modifiers.clear(); + assert_eq!(presentation_action(&state, 4), PresentationAction::Ignore); + } + + #[test] + fn readiness_watchdog_reports_only_once_before_ready() { + let mut state = ModifierOverlayState::default(); + assert!(readiness_watchdog_should_report(&state)); + state.readiness_failure_reported = true; + assert!(!readiness_watchdog_should_report(&state)); + state.readiness_failure_reported = false; + state.ready = true; + assert!(!readiness_watchdog_should_report(&state)); + } } diff --git a/src/ModifierOverlay.test.tsx b/src/ModifierOverlay.test.tsx index 4c99921..255bf9e 100644 --- a/src/ModifierOverlay.test.tsx +++ b/src/ModifierOverlay.test.tsx @@ -1,12 +1,26 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { isModifierOverlayRoute, + ModifierOverlay, ModifierOverlayView, newestModifierSnapshot, } from "./ModifierOverlay"; +const { invokeMock, listenMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + listenMock: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); + describe("modifier overlay", () => { + beforeEach(() => { + invokeMock.mockReset(); + listenMock.mockReset(); + }); + it("selects only the dedicated overlay route", () => { expect(isModifierOverlayRoute("?view=modifier-overlay")).toBe(true); expect(isModifierOverlayRoute("?view=settings")).toBe(false); @@ -38,4 +52,45 @@ describe("modifier overlay", () => { labels: ["Command"], }); }); + + it("registers the listener before requesting initial state", async () => { + const calls: string[] = []; + listenMock.mockImplementation(async () => { + calls.push("listen"); + return vi.fn(); + }); + invokeMock.mockImplementation(async (command: string) => { + calls.push(command); + if (command === "modifier_overlay_ready") { + return { revision: 2, labels: [] }; + } + return undefined; + }); + + render(); + + await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("modifier_overlay_ready")); + expect(calls.slice(0, 2)).toEqual(["listen", "modifier_overlay_ready"]); + }); + + it("requests presentation only after nonempty content is rendered", async () => { + listenMock.mockResolvedValue(vi.fn()); + invokeMock.mockImplementation(async (command: string) => { + if (command === "modifier_overlay_ready") { + return { revision: 6, labels: ["Ctrl", "Shift"] }; + } + if (command === "modifier_overlay_present") { + expect(screen.getByRole("status", { name: "Active modifiers" })).toHaveTextContent( + "CtrlShift", + ); + } + return undefined; + }); + + render(); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith("modifier_overlay_present", { revision: 6 }); + }); + }); }); diff --git a/src/ModifierOverlay.tsx b/src/ModifierOverlay.tsx index 04a62b6..cfa7b94 100644 --- a/src/ModifierOverlay.tsx +++ b/src/ModifierOverlay.tsx @@ -1,6 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import { useEffect, useLayoutEffect, useState } from "react"; import "./modifier-overlay.css"; @@ -66,8 +65,9 @@ export function ModifierOverlay() { }, []); useLayoutEffect(() => { - const window = getCurrentWindow(); - void (snapshot.labels.length > 0 ? window.show() : window.hide()).catch(() => undefined); + if (snapshot.labels.length > 0) { + void invoke("modifier_overlay_present", { revision: snapshot.revision }).catch(() => undefined); + } }, [snapshot]); return ; From b488bf50634bb0accd31acf431c4fee6fa7dc021 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 10 Aug 2026 20:43:03 +0100 Subject: [PATCH 2/2] Serialize modifier overlay visibility --- src-tauri/src/modifier_overlay.rs | 64 +++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/modifier_overlay.rs b/src-tauri/src/modifier_overlay.rs index 087791b..24c72cc 100644 --- a/src-tauri/src/modifier_overlay.rs +++ b/src-tauri/src/modifier_overlay.rs @@ -43,6 +43,7 @@ struct ModifierOverlayInner { shared: SharedModel, window: WebviewWindow, state: Mutex, + window_actions: Mutex<()>, } #[derive(Default)] @@ -61,6 +62,7 @@ struct BootstrapPolicy { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PresentationAction { + Hide, Ignore, Show, } @@ -100,6 +102,7 @@ impl ModifierOverlay { shared, window, state: Mutex::new(ModifierOverlayState::default()), + window_actions: Mutex::new(()), }), }; overlay.start_readiness_watchdog(); @@ -124,10 +127,7 @@ impl ModifierOverlay { snapshot(&state) }; if snapshot.labels.is_empty() { - self.inner - .window - .hide() - .map_err(|error| error.to_string())?; + self.hide_if_current_empty(snapshot.revision)?; } Ok(snapshot) } @@ -138,6 +138,11 @@ impl ModifierOverlay { "Modifier overlay presentation is only available to its overlay window.".into(), ); } + let _window_action = self + .inner + .window_actions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let action = { let state = self .inner @@ -183,12 +188,35 @@ impl ModifierOverlay { self.report_failure(&error.to_string()); } if snapshot.labels.is_empty() { - if let Err(error) = self.inner.window.hide() { - self.report_failure(&error.to_string()); + if let Err(error) = self.hide_if_current_empty(snapshot.revision) { + self.report_failure(&error); } } } + fn hide_if_current_empty(&self, revision: u64) -> Result<(), String> { + let _window_action = self + .inner + .window_actions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let action = { + let state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + presentation_action(&state, revision) + }; + if action == PresentationAction::Hide { + self.inner + .window + .hide() + .map_err(|error| error.to_string())?; + } + Ok(()) + } + fn start_readiness_watchdog(&self) { let overlay = self.clone(); tauri::async_runtime::spawn(async move { @@ -271,7 +299,12 @@ fn bootstrap_policy(is_windows: bool) -> BootstrapPolicy { } fn presentation_action(state: &ModifierOverlayState, revision: u64) -> PresentationAction { - if state.ready && revision == state.revision && !state.active_modifiers.is_empty() { + if revision != state.revision { + return PresentationAction::Ignore; + } + if state.active_modifiers.is_empty() { + PresentationAction::Hide + } else if state.ready { PresentationAction::Show } else { PresentationAction::Ignore @@ -318,6 +351,11 @@ impl ModifierKeyOverlayNotifier for ModifierOverlay { } } } + let _window_action = self + .inner + .window_actions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Err(error) = self.inner.window.hide() { self.report_failure(&error.to_string()); } @@ -472,7 +510,19 @@ mod tests { assert_eq!(presentation_action(&state, 4), PresentationAction::Show); state.active_modifiers.clear(); + assert_eq!(presentation_action(&state, 4), PresentationAction::Hide); + } + + #[test] + fn stale_empty_revision_cannot_hide_newer_nonempty_state() { + let state = ModifierOverlayState { + revision: 5, + active_modifiers: vec![ModifierKey::Shift], + ready: true, + ..ModifierOverlayState::default() + }; assert_eq!(presentation_action(&state, 4), PresentationAction::Ignore); + assert_eq!(presentation_action(&state, 5), PresentationAction::Show); } #[test]