From 2fd04372f6ee6ca0fdb116981196119b9500c7f3 Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Tue, 12 May 2026 18:51:45 -0400 Subject: [PATCH 1/8] fix: auto-end Stay when meeting ends --- apps/desktop/src-tauri/tests/commands.rs | 25 ++++++++++++ crates/stay-core/src/meeting.rs | 20 ++++++++++ crates/stay-core/src/session.rs | 10 +++++ crates/stay-core/tests/session_policy.rs | 48 ++++++++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/apps/desktop/src-tauri/tests/commands.rs b/apps/desktop/src-tauri/tests/commands.rs index bdcf8d6..7a40de1 100644 --- a/apps/desktop/src-tauri/tests/commands.rs +++ b/apps/desktop/src-tauri/tests/commands.rs @@ -103,6 +103,31 @@ fn command_helpers_emit_lock_command_when_locked_focus_changes() { assert!(matches!(response.view, GuardView::Locked { .. })); } +#[test] +fn command_helpers_emit_stop_guarding_when_meeting_ends() { + let state = AppState::default(); + + set_pin_inner(&state, "4821").unwrap(); + observe_focus_inner( + &state, + Some(WindowSnapshot::new("Arc", "Design Review - Google Meet").with_window_id("arc-meet")), + ) + .unwrap(); + accept_stay_inner(&state).unwrap(); + + let response = observe_focus_inner( + &state, + Some(WindowSnapshot::new("Arc", "New Tab").with_window_id("arc-meet")), + ) + .unwrap(); + + assert!(matches!( + response.commands.as_slice(), + [GuardCommand::StopGuarding] + )); + assert!(matches!(response.view, GuardView::Idle { .. })); +} + #[test] fn command_helpers_reject_pin_before_configuration() { let state = AppState::default(); diff --git a/crates/stay-core/src/meeting.rs b/crates/stay-core/src/meeting.rs index 2c6bf29..25deb03 100644 --- a/crates/stay-core/src/meeting.rs +++ b/crates/stay-core/src/meeting.rs @@ -146,6 +146,18 @@ impl MeetingClassifier { same_app && (same_title || same_process || !is_browser_app(&candidate.window.app_name)) } + + pub(crate) fn has_meeting_ended( + &self, + candidate: &MeetingCandidate, + window: &WindowSnapshot, + ) -> bool { + if self.is_stay_window(window) || !is_same_observed_window(&candidate.window, window) { + return false; + } + + !matches!(self.classify(window), Some(current) if current.app == candidate.app) + } } fn text_contains(value: &str, needles: &[&str]) -> bool { @@ -168,3 +180,11 @@ fn is_browser_app(app_name: &str) -> bool { .iter() .any(|browser| app.contains(browser)) } + +fn is_same_observed_window(left: &WindowSnapshot, right: &WindowSnapshot) -> bool { + if left.window_id.is_none() || right.window_id.is_none() { + return false; + } + + left.window_id == right.window_id && left.app_name_normalized() == right.app_name_normalized() +} diff --git a/crates/stay-core/src/session.rs b/crates/stay-core/src/session.rs index a9d9648..8dc7ee5 100644 --- a/crates/stay-core/src/session.rs +++ b/crates/stay-core/src/session.rs @@ -328,6 +328,11 @@ impl FocusGuard { session: GuardSession, window: WindowSnapshot, ) -> Vec { + if self.classifier.has_meeting_ended(&session.meeting, &window) { + self.phase = GuardPhase::Idle; + return vec![GuardCommand::StopGuarding]; + } + if self .classifier .is_same_meeting_window(&session.meeting, &window) @@ -359,6 +364,11 @@ impl FocusGuard { last_error: Option, window: WindowSnapshot, ) -> Vec { + if self.classifier.has_meeting_ended(&session.meeting, &window) { + self.phase = GuardPhase::Idle; + return vec![GuardCommand::StopGuarding]; + } + if self .classifier .is_same_meeting_window(&session.meeting, &window) diff --git a/crates/stay-core/tests/session_policy.rs b/crates/stay-core/tests/session_policy.rs index f644c87..5c00f4b 100644 --- a/crates/stay-core/tests/session_policy.rs +++ b/crates/stay-core/tests/session_policy.rs @@ -133,6 +133,54 @@ fn app_authorization_clears_when_guarding_stops() { )); } +#[test] +fn stops_guarding_when_protected_meeting_window_stops_matching_meeting() { + let mut guard = guard_with_pin(); + let meeting = window("Arc", "Design Review - Google Meet").with_window_id("arc-meet"); + + guard.observe_focus(Some(meeting)); + guard.accept_stay().unwrap(); + + let commands = guard.observe_focus(Some(window("Arc", "New Tab").with_window_id("arc-meet"))); + + assert!(matches!(commands.as_slice(), [GuardCommand::StopGuarding])); + assert!(matches!(guard.view(), GuardView::Idle { .. })); +} + +#[test] +fn stops_locked_session_when_protected_meeting_window_stops_matching_meeting() { + let mut guard = guard_with_pin(); + let meeting = window("Arc", "Design Review - Google Meet").with_window_id("arc-meet"); + + guard.observe_focus(Some(meeting)); + guard.accept_stay().unwrap(); + guard.observe_focus(Some(window("Slack", "Messages"))); + + let commands = guard.observe_focus(Some(window("Arc", "New Tab").with_window_id("arc-meet"))); + + assert!(matches!(commands.as_slice(), [GuardCommand::StopGuarding])); + assert!(matches!(guard.view(), GuardView::Idle { .. })); +} + +#[test] +fn switching_to_another_browser_window_still_locks_guarded_meeting() { + let mut guard = guard_with_pin(); + let meeting = window("Arc", "Design Review - Google Meet").with_window_id("arc-meet"); + + guard.observe_focus(Some(meeting)); + guard.accept_stay().unwrap(); + + let commands = guard.observe_focus(Some( + window("Arc", "Project notes").with_window_id("arc-notes"), + )); + + assert!(matches!( + commands.as_slice(), + [GuardCommand::ShowLock { focused, .. }] if focused.title == "Project notes" + )); + assert!(matches!(guard.view(), GuardView::Locked { .. })); +} + #[test] fn carries_focused_window_bounds_into_lock_state() { let mut guard = guard_with_pin(); From a1e9fa157385b71c811b29d9bada9f88b7ac06c6 Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Tue, 12 May 2026 19:10:02 -0400 Subject: [PATCH 2/8] fix: recognize native meeting app end states --- apps/desktop/src-tauri/tests/commands.rs | 25 ++++++++++++ crates/stay-core/src/meeting.rs | 48 +++++++++++++++++++++--- crates/stay-core/tests/session_policy.rs | 43 +++++++++++++++++++++ 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/tests/commands.rs b/apps/desktop/src-tauri/tests/commands.rs index 7a40de1..3de00af 100644 --- a/apps/desktop/src-tauri/tests/commands.rs +++ b/apps/desktop/src-tauri/tests/commands.rs @@ -128,6 +128,31 @@ fn command_helpers_emit_stop_guarding_when_meeting_ends() { assert!(matches!(response.view, GuardView::Idle { .. })); } +#[test] +fn command_helpers_emit_stop_guarding_when_native_meeting_app_returns_home() { + let state = AppState::default(); + + set_pin_inner(&state, "4821").unwrap(); + observe_focus_inner( + &state, + Some(WindowSnapshot::new("zoom.us", "Weekly Team Sync").with_window_id("zoom-meeting")), + ) + .unwrap(); + accept_stay_inner(&state).unwrap(); + + let response = observe_focus_inner( + &state, + Some(WindowSnapshot::new("zoom.us", "Zoom Workplace").with_window_id("zoom-home")), + ) + .unwrap(); + + assert!(matches!( + response.commands.as_slice(), + [GuardCommand::StopGuarding] + )); + assert!(matches!(response.view, GuardView::Idle { .. })); +} + #[test] fn command_helpers_reject_pin_before_configuration() { let state = AppState::default(); diff --git a/crates/stay-core/src/meeting.rs b/crates/stay-core/src/meeting.rs index 25deb03..7e1ca69 100644 --- a/crates/stay-core/src/meeting.rs +++ b/crates/stay-core/src/meeting.rs @@ -108,6 +108,10 @@ impl MeetingClassifier { return None; }; + if is_known_non_meeting_shell(&meeting_app, &title) { + return None; + } + Some(MeetingCandidate { app: meeting_app, window: window.clone(), @@ -152,11 +156,30 @@ impl MeetingClassifier { candidate: &MeetingCandidate, window: &WindowSnapshot, ) -> bool { - if self.is_stay_window(window) || !is_same_observed_window(&candidate.window, window) { + if self.is_stay_window(window) { return false; } - !matches!(self.classify(window), Some(current) if current.app == candidate.app) + let current_is_same_meeting_app = + matches!(self.classify(window), Some(current) if current.app == candidate.app); + + if is_same_observed_window(&candidate.window, window) { + return !current_is_same_meeting_app; + } + + if is_browser_app(&candidate.window.app_name) { + return false; + } + + if candidate.window.app_name_normalized() != window.app_name_normalized() { + return false; + } + + if has_distinct_window_ids(&candidate.window, window) { + return true; + } + + !current_is_same_meeting_app } } @@ -181,10 +204,23 @@ fn is_browser_app(app_name: &str) -> bool { .any(|browser| app.contains(browser)) } -fn is_same_observed_window(left: &WindowSnapshot, right: &WindowSnapshot) -> bool { - if left.window_id.is_none() || right.window_id.is_none() { - return false; +fn is_known_non_meeting_shell(app: &MeetingApp, title: &str) -> bool { + match app { + MeetingApp::Zoom => text_contains(title, &["zoom workplace"]), + MeetingApp::MicrosoftTeams => title == "microsoft teams", + MeetingApp::Webex => title == "webex", + MeetingApp::SlackHuddle => !text_contains(title, &["huddle"]), + MeetingApp::FaceTime | MeetingApp::GoogleMeet => false, } +} + +fn is_same_observed_window(left: &WindowSnapshot, right: &WindowSnapshot) -> bool { + left.window_id.is_some() + && right.window_id.is_some() + && left.window_id == right.window_id + && left.app_name_normalized() == right.app_name_normalized() +} - left.window_id == right.window_id && left.app_name_normalized() == right.app_name_normalized() +fn has_distinct_window_ids(left: &WindowSnapshot, right: &WindowSnapshot) -> bool { + left.window_id.is_some() && right.window_id.is_some() && left.window_id != right.window_id } diff --git a/crates/stay-core/tests/session_policy.rs b/crates/stay-core/tests/session_policy.rs index 5c00f4b..5402960 100644 --- a/crates/stay-core/tests/session_policy.rs +++ b/crates/stay-core/tests/session_policy.rs @@ -147,6 +147,38 @@ fn stops_guarding_when_protected_meeting_window_stops_matching_meeting() { assert!(matches!(guard.view(), GuardView::Idle { .. })); } +#[test] +fn stops_guarding_when_native_meeting_app_moves_to_different_window() { + let mut guard = guard_with_pin(); + let meeting = window("zoom.us", "Weekly Team Sync").with_window_id("zoom-meeting"); + + guard.observe_focus(Some(meeting)); + guard.accept_stay().unwrap(); + + let commands = guard.observe_focus(Some( + window("zoom.us", "Zoom Workplace").with_window_id("zoom-home"), + )); + + assert!(matches!(commands.as_slice(), [GuardCommand::StopGuarding])); + assert!(matches!(guard.view(), GuardView::Idle { .. })); +} + +#[test] +fn stops_guarding_when_native_meeting_window_returns_to_app_home() { + let mut guard = guard_with_pin(); + let meeting = window("zoom.us", "Weekly Team Sync").with_window_id("zoom-meeting"); + + guard.observe_focus(Some(meeting)); + guard.accept_stay().unwrap(); + + let commands = guard.observe_focus(Some( + window("zoom.us", "Zoom Workplace").with_window_id("zoom-meeting"), + )); + + assert!(matches!(commands.as_slice(), [GuardCommand::StopGuarding])); + assert!(matches!(guard.view(), GuardView::Idle { .. })); +} + #[test] fn stops_locked_session_when_protected_meeting_window_stops_matching_meeting() { let mut guard = guard_with_pin(); @@ -305,6 +337,17 @@ fn detects_google_meet_inside_browser_title() { assert_eq!(candidate.app, MeetingApp::GoogleMeet); } +#[test] +fn ignores_zoom_home_window_as_meeting() { + let classifier = MeetingClassifier::default(); + + assert!( + classifier + .classify(&window("zoom.us", "Zoom Workplace")) + .is_none() + ); +} + #[test] fn dismissing_candidate_suppresses_that_window_until_it_changes() { let mut guard = guard_with_pin(); From 844593f4ef785d050b4f3edd6b011cbfc1fa251d Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Tue, 12 May 2026 20:51:33 -0400 Subject: [PATCH 3/8] fix: ignore native app home shells --- apps/desktop/src-tauri/tests/commands.rs | 26 +++++++++++- crates/stay-core/src/meeting.rs | 53 ++++++++++++++++++++---- crates/stay-core/tests/session_policy.rs | 38 ++++++++++++++--- 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src-tauri/tests/commands.rs b/apps/desktop/src-tauri/tests/commands.rs index 3de00af..dd2e0d6 100644 --- a/apps/desktop/src-tauri/tests/commands.rs +++ b/apps/desktop/src-tauri/tests/commands.rs @@ -128,6 +128,30 @@ fn command_helpers_emit_stop_guarding_when_meeting_ends() { assert!(matches!(response.view, GuardView::Idle { .. })); } +#[test] +fn command_helpers_hide_prompt_when_native_meeting_app_returns_home_before_acceptance() { + let state = AppState::default(); + + set_pin_inner(&state, "4821").unwrap(); + observe_focus_inner( + &state, + Some(WindowSnapshot::new("zoom.us", "Weekly Team Sync").with_window_id("zoom-meeting")), + ) + .unwrap(); + + let response = observe_focus_inner( + &state, + Some(WindowSnapshot::new("zoom.us", "Home").with_window_id("zoom-meeting")), + ) + .unwrap(); + + assert!(matches!( + response.commands.as_slice(), + [GuardCommand::HidePrompt] + )); + assert!(matches!(response.view, GuardView::Idle { .. })); +} + #[test] fn command_helpers_emit_stop_guarding_when_native_meeting_app_returns_home() { let state = AppState::default(); @@ -142,7 +166,7 @@ fn command_helpers_emit_stop_guarding_when_native_meeting_app_returns_home() { let response = observe_focus_inner( &state, - Some(WindowSnapshot::new("zoom.us", "Zoom Workplace").with_window_id("zoom-home")), + Some(WindowSnapshot::new("zoom.us", "Home").with_window_id("zoom-home")), ) .unwrap(); diff --git a/crates/stay-core/src/meeting.rs b/crates/stay-core/src/meeting.rs index 7e1ca69..8a350b2 100644 --- a/crates/stay-core/src/meeting.rs +++ b/crates/stay-core/src/meeting.rs @@ -128,11 +128,8 @@ impl MeetingClassifier { return false; } - if candidate.window.window_id.is_some() - && window.window_id.is_some() - && candidate.window.window_id == window.window_id - { - return true; + if is_same_observed_window(&candidate.window, window) { + return matches!(self.classify(window), Some(current) if current.app == candidate.app); } let Some(current_candidate) = self.classify(window) else { @@ -205,15 +202,53 @@ fn is_browser_app(app_name: &str) -> bool { } fn is_known_non_meeting_shell(app: &MeetingApp, title: &str) -> bool { + if title.is_empty() { + return true; + } + match app { - MeetingApp::Zoom => text_contains(title, &["zoom workplace"]), - MeetingApp::MicrosoftTeams => title == "microsoft teams", - MeetingApp::Webex => title == "webex", + MeetingApp::Zoom => { + text_contains(title, &["zoom workplace"]) || title_matches(title, ZOOM_SHELL_TITLES) + } + MeetingApp::MicrosoftTeams => title_matches(title, TEAMS_SHELL_TITLES), + MeetingApp::Webex => title_matches(title, WEBEX_SHELL_TITLES), MeetingApp::SlackHuddle => !text_contains(title, &["huddle"]), - MeetingApp::FaceTime | MeetingApp::GoogleMeet => false, + MeetingApp::FaceTime => title_matches(title, FACETIME_SHELL_TITLES), + MeetingApp::GoogleMeet => false, } } +const ZOOM_SHELL_TITLES: &[&str] = &[ + "home", + "team chat", + "meetings", + "calendar", + "mail", + "whiteboards", + "clips", + "contacts", + "settings", +]; + +const TEAMS_SHELL_TITLES: &[&str] = &[ + "microsoft teams", + "teams", + "activity", + "chat", + "calendar", + "calls", + "files", + "apps", +]; + +const WEBEX_SHELL_TITLES: &[&str] = &["webex", "meetings", "messaging", "calling", "contacts"]; + +const FACETIME_SHELL_TITLES: &[&str] = &["facetime"]; + +fn title_matches(title: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| title == *needle) +} + fn is_same_observed_window(left: &WindowSnapshot, right: &WindowSnapshot) -> bool { left.window_id.is_some() && right.window_id.is_some() diff --git a/crates/stay-core/tests/session_policy.rs b/crates/stay-core/tests/session_policy.rs index 5402960..56035b8 100644 --- a/crates/stay-core/tests/session_policy.rs +++ b/crates/stay-core/tests/session_policy.rs @@ -147,6 +147,21 @@ fn stops_guarding_when_protected_meeting_window_stops_matching_meeting() { assert!(matches!(guard.view(), GuardView::Idle { .. })); } +#[test] +fn hides_candidate_when_native_meeting_app_returns_home_before_acceptance() { + let mut guard = guard_with_pin(); + let meeting = window("zoom.us", "Weekly Team Sync").with_window_id("zoom-meeting"); + + guard.observe_focus(Some(meeting)); + + let commands = guard.observe_focus(Some( + window("zoom.us", "Home").with_window_id("zoom-meeting"), + )); + + assert!(matches!(commands.as_slice(), [GuardCommand::HidePrompt])); + assert!(matches!(guard.view(), GuardView::Idle { .. })); +} + #[test] fn stops_guarding_when_native_meeting_app_moves_to_different_window() { let mut guard = guard_with_pin(); @@ -341,11 +356,24 @@ fn detects_google_meet_inside_browser_title() { fn ignores_zoom_home_window_as_meeting() { let classifier = MeetingClassifier::default(); - assert!( - classifier - .classify(&window("zoom.us", "Zoom Workplace")) - .is_none() - ); + for title in [ + "", + "Home", + "Zoom Workplace", + "Team Chat", + "Meetings", + "Calendar", + "Mail", + "Whiteboards", + "Clips", + "Contacts", + "Settings", + ] { + assert!( + classifier.classify(&window("zoom.us", title)).is_none(), + "expected Zoom shell title {title:?} to be ignored" + ); + } } #[test] From ac2cca5a26831e324c9cdc3768b7c1c36d32d6e1 Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Tue, 12 May 2026 21:03:23 -0400 Subject: [PATCH 4/8] fix: hide idle window after setup --- apps/desktop/src-tauri/src/lib.rs | 85 +++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 39dfd68..03eb4c0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -73,8 +73,14 @@ fn current_state(state: State<'_, AppState>) -> Result { } #[tauri::command] -fn set_pin(pin: String, state: State<'_, AppState>) -> Result { - set_pin_inner(&state, &pin) +fn set_pin( + pin: String, + state: State<'_, AppState>, + window: WebviewWindow, +) -> Result { + let response = set_pin_inner(&state, &pin)?; + apply_window_response(&window, &response).map_err(|error| error.to_string())?; + Ok(response) } #[tauri::command] @@ -83,7 +89,7 @@ fn accept_stay( window: WebviewWindow, ) -> Result { let response = accept_stay_inner(&state)?; - apply_window_commands(&window, &response.commands).map_err(|error| error.to_string())?; + apply_window_response(&window, &response).map_err(|error| error.to_string())?; Ok(response) } @@ -93,7 +99,7 @@ fn dismiss_candidate( window: WebviewWindow, ) -> Result { let response = dismiss_candidate_inner(&state)?; - apply_window_commands(&window, &response.commands).map_err(|error| error.to_string())?; + apply_window_response(&window, &response).map_err(|error| error.to_string())?; Ok(response) } @@ -103,7 +109,7 @@ fn stop_guarding( window: WebviewWindow, ) -> Result { let response = stop_guarding_inner(&state)?; - apply_window_commands(&window, &response.commands).map_err(|error| error.to_string())?; + apply_window_response(&window, &response).map_err(|error| error.to_string())?; Ok(response) } @@ -114,7 +120,7 @@ fn submit_pin( window: WebviewWindow, ) -> Result { let response = submit_pin_inner(&state, &pin)?; - apply_window_commands(&window, &response.commands).map_err(|error| error.to_string())?; + apply_window_response(&window, &response).map_err(|error| error.to_string())?; Ok(response) } @@ -242,7 +248,12 @@ pub fn run() { .manage(AppState::default()) .setup(|app| { if let Some(window) = app.get_webview_window("main") { - let _ = position_top_right(&window); + let state = app.state::(); + if current_state_inner(&state).is_ok_and(|view| should_hide_main_window(&view)) { + let _ = window.hide(); + } else { + let _ = position_top_right(&window); + } } spawn_focus_loop(app.handle().clone()); Ok(()) @@ -271,7 +282,7 @@ fn spawn_focus_loop(app: tauri::AppHandle) { && !response.commands.is_empty() { if let Some(window) = app.get_webview_window("main") { - let _ = apply_window_commands(&window, &response.commands); + let _ = apply_window_response(&window, &response); } let _ = app.emit("stay-state-changed", response); } @@ -395,7 +406,15 @@ fn position_monitor_overlay(window: &WebviewWindow) -> tauri::Result<()> { Ok(()) } -fn apply_window_commands(window: &WebviewWindow, commands: &[GuardCommand]) -> tauri::Result<()> { +fn apply_window_response(window: &WebviewWindow, response: &CommandResponse) -> tauri::Result<()> { + apply_window_commands(window, &response.commands, &response.view) +} + +fn apply_window_commands( + window: &WebviewWindow, + commands: &[GuardCommand], + view: &GuardView, +) -> tauri::Result<()> { if let Some(focused) = commands.iter().find_map(|command| { if let GuardCommand::ShowLock { focused, .. } = command { Some(focused) @@ -418,19 +437,47 @@ fn apply_window_commands(window: &WebviewWindow, commands: &[GuardCommand]) -> t return Ok(()); } + if commands + .iter() + .any(|command| matches!(command, GuardCommand::ShowPrompt { .. })) + { + hide_guard_border(window.app_handle())?; + position_top_right(window)?; + return Ok(()); + } + if commands.iter().any(|command| { matches!( command, - GuardCommand::ShowPrompt { .. } | GuardCommand::HidePrompt | GuardCommand::StopGuarding + GuardCommand::HidePrompt | GuardCommand::StopGuarding ) }) { hide_guard_border(window.app_handle())?; - position_top_right(window)?; + if should_hide_main_window(view) { + window.hide()?; + } else { + position_top_right(window)?; + } + return Ok(()); + } + + if should_hide_main_window(view) { + hide_guard_border(window.app_handle())?; + window.hide()?; } Ok(()) } +fn should_hide_main_window(view: &GuardView) -> bool { + matches!( + view, + GuardView::Idle { + pin_configured: true + } + ) +} + fn show_guard_border(app: &AppHandle, anchor: &WebviewWindow) -> tauri::Result<()> { let border = match app.get_webview_window(GUARD_BORDER_LABEL) { Some(border) => border, @@ -695,4 +742,20 @@ mod tests { } ); } + + #[test] + fn hides_main_window_only_when_ready_and_idle() { + assert!(should_hide_main_window(&GuardView::Idle { + pin_configured: true + })); + assert!(!should_hide_main_window(&GuardView::Idle { + pin_configured: false + })); + assert!(!should_hide_main_window(&GuardView::MeetingCandidate { + candidate: MeetingClassifier::default() + .classify(&WindowSnapshot::new("zoom.us", "Zoom Meeting")) + .unwrap(), + pin_configured: true, + })); + } } From 85b2f0926e2707ec141955dc6fd892adfae20169 Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Tue, 12 May 2026 22:04:24 -0400 Subject: [PATCH 5/8] fix: recover empty macOS meeting titles --- Cargo.lock | 71 ++++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 32 ++++++--- crates/stay-platform/Cargo.toml | 3 + crates/stay-platform/src/active_window.rs | 79 +++++++++++++++++++++++ 4 files changed, 176 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22ae755..1bc64b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,28 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accessibility" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac9f33ffc1ef16eddb2451c03c983e56a5182ac760c3f2733da55ba8f48eac4" +dependencies = [ + "accessibility-sys", + "cocoa", + "core-foundation 0.10.1", + "objc", + "thiserror 1.0.69", +] + +[[package]] +name = "accessibility-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a6a8e90a1d8b96a48249e7c8f5b4058447bea8847280db7bfccb6dcab6b8e1" +dependencies = [ + "core-foundation-sys", +] + [[package]] name = "active-win-pos-rs" version = "0.10.1" @@ -172,6 +194,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -391,6 +419,35 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "cocoa" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +dependencies = [ + "bitflags 2.11.1", + "block", + "cocoa-foundation", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "objc", +] + [[package]] name = "combine" version = "4.6.7" @@ -450,6 +507,19 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -3292,6 +3362,7 @@ dependencies = [ name = "stay-platform" version = "0.1.0" dependencies = [ + "accessibility", "active-win-pos-rs", "serde_json", "stay-core", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 03eb4c0..36d299c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -250,7 +250,7 @@ pub fn run() { if let Some(window) = app.get_webview_window("main") { let state = app.state::(); if current_state_inner(&state).is_ok_and(|view| should_hide_main_window(&view)) { - let _ = window.hide(); + let _ = hide_main_window(&window); } else { let _ = position_top_right(&window); } @@ -299,8 +299,7 @@ fn position_top_right(window: &WebviewWindow) -> tauri::Result<()> { let geometry = compact_window_geometry(*monitor.position(), *monitor.size()); window.set_size(tauri::Size::Physical(geometry.size))?; window.set_position(tauri::Position::Physical(geometry.position))?; - window.show()?; - Ok(()) + show_main_window(window, true) } fn position_guarding_handle(window: &WebviewWindow) -> tauri::Result<()> { @@ -311,8 +310,7 @@ fn position_guarding_handle(window: &WebviewWindow) -> tauri::Result<()> { let geometry = guarding_handle_geometry(*monitor.position(), *monitor.size()); window.set_size(tauri::Size::Physical(geometry.size))?; window.set_position(tauri::Position::Physical(geometry.position))?; - window.show()?; - Ok(()) + show_main_window(window, false) } fn compact_window_geometry( @@ -402,7 +400,24 @@ fn position_monitor_overlay(window: &WebviewWindow) -> tauri::Result<()> { width: monitor_size.width, height: monitor_size.height, }))?; + show_main_window(window, true) +} + +fn show_main_window(window: &WebviewWindow, focus: bool) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + window.app_handle().show()?; + window.set_always_on_top(true)?; window.show()?; + if focus { + window.set_focus()?; + } + Ok(()) +} + +fn hide_main_window(window: &WebviewWindow) -> tauri::Result<()> { + window.hide()?; + #[cfg(target_os = "macos")] + window.app_handle().hide()?; Ok(()) } @@ -454,7 +469,7 @@ fn apply_window_commands( }) { hide_guard_border(window.app_handle())?; if should_hide_main_window(view) { - window.hide()?; + hide_main_window(window)?; } else { position_top_right(window)?; } @@ -463,7 +478,7 @@ fn apply_window_commands( if should_hide_main_window(view) { hide_guard_border(window.app_handle())?; - window.hide()?; + hide_main_window(window)?; } Ok(()) @@ -540,8 +555,7 @@ fn position_focused_window_overlay( } }; - window.show()?; - Ok(()) + show_main_window(window, true) } fn focused_overlay_geometry(focused: &LockedFocus) -> Option { diff --git a/crates/stay-platform/Cargo.toml b/crates/stay-platform/Cargo.toml index 2222c21..57b94b4 100644 --- a/crates/stay-platform/Cargo.toml +++ b/crates/stay-platform/Cargo.toml @@ -12,5 +12,8 @@ active-win-pos-rs.workspace = true stay-core = { path = "../stay-core" } thiserror.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +accessibility = "0.2.0" + [dev-dependencies] serde_json.workspace = true diff --git a/crates/stay-platform/src/active_window.rs b/crates/stay-platform/src/active_window.rs index 6bd53e4..d8e20d3 100644 --- a/crates/stay-platform/src/active_window.rs +++ b/crates/stay-platform/src/active_window.rs @@ -21,6 +21,7 @@ impl FocusProvider for ActiveWinFocusProvider { fn active_window(&self) -> Result, FocusError> { get_active_window() .map(active_window_to_snapshot) + .map(enrich_active_window_snapshot) .map(Some) .map_err(|()| FocusError::Unavailable) } @@ -41,3 +42,81 @@ pub fn active_window_to_snapshot(window: ActiveWindow) -> WindowSnapshot { }), } } + +#[cfg(target_os = "macos")] +fn enrich_active_window_snapshot(snapshot: WindowSnapshot) -> WindowSnapshot { + enrich_active_window_snapshot_with(snapshot, macos_accessibility::focused_window_title) +} + +#[cfg(target_os = "macos")] +fn enrich_active_window_snapshot_with( + mut snapshot: WindowSnapshot, + focused_window_title: impl FnOnce(i32) -> Option, +) -> WindowSnapshot { + if !snapshot.title.trim().is_empty() { + return snapshot; + } + + let Some(process_id) = snapshot + .process_id + .and_then(|value| i32::try_from(value).ok()) + else { + return snapshot; + }; + + if let Some(title) = focused_window_title(process_id) + && !title.trim().is_empty() + { + snapshot.title = title; + } + + snapshot +} + +#[cfg(not(target_os = "macos"))] +fn enrich_active_window_snapshot(snapshot: WindowSnapshot) -> WindowSnapshot { + snapshot +} + +#[cfg(target_os = "macos")] +mod macos_accessibility { + use accessibility::{AXUIElement, AXUIElementAttributes}; + + pub fn focused_window_title(process_id: i32) -> Option { + let app = AXUIElement::application(process_id); + let window = app.focused_window().or_else(|_| app.main_window()).ok()?; + + window.title().ok().map(|title| title.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "macos")] + #[test] + fn enriches_empty_macos_title_from_accessibility() { + let snapshot = WindowSnapshot::new("zoom.us", "").with_process_id(1006); + + let snapshot = + enrich_active_window_snapshot_with(snapshot, |process_id| match process_id { + 1006 => Some("Zoom Meeting".to_string()), + _ => None, + }); + + assert_eq!(snapshot.title, "Zoom Meeting"); + } + + #[cfg(target_os = "macos")] + #[test] + fn keeps_existing_macos_title_without_accessibility_lookup() { + let snapshot = WindowSnapshot::new("zoom.us", "Home").with_process_id(1006); + + let snapshot = enrich_active_window_snapshot_with(snapshot, |_| { + panic!("title lookup should not run when the provider already returned a title") + }); + + assert_eq!(snapshot.title, "Home"); + } +} From 7f9b7156250ea62c973ff6fdb7092ef19626929f Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Wed, 13 May 2026 19:50:39 -0400 Subject: [PATCH 6/8] fix: debounce native meeting end signals --- apps/desktop/src-tauri/tests/commands.rs | 12 +++++++ crates/stay-core/src/meeting.rs | 9 +++++ crates/stay-core/src/session.rs | 43 +++++++++++++++++++++--- crates/stay-core/tests/session_policy.rs | 40 ++++++++++++++++++++-- 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src-tauri/tests/commands.rs b/apps/desktop/src-tauri/tests/commands.rs index dd2e0d6..dffd803 100644 --- a/apps/desktop/src-tauri/tests/commands.rs +++ b/apps/desktop/src-tauri/tests/commands.rs @@ -170,6 +170,18 @@ fn command_helpers_emit_stop_guarding_when_native_meeting_app_returns_home() { ) .unwrap(); + assert!(matches!( + response.commands.as_slice(), + [GuardCommand::ShowLock { .. }] + )); + assert!(matches!(response.view, GuardView::Locked { .. })); + + let response = observe_focus_inner( + &state, + Some(WindowSnapshot::new("zoom.us", "Home").with_window_id("zoom-home")), + ) + .unwrap(); + assert!(matches!( response.commands.as_slice(), [GuardCommand::StopGuarding] diff --git a/crates/stay-core/src/meeting.rs b/crates/stay-core/src/meeting.rs index 8a350b2..780855f 100644 --- a/crates/stay-core/src/meeting.rs +++ b/crates/stay-core/src/meeting.rs @@ -178,6 +178,15 @@ impl MeetingClassifier { !current_is_same_meeting_app } + + pub(crate) fn has_definitive_meeting_end( + &self, + candidate: &MeetingCandidate, + window: &WindowSnapshot, + ) -> bool { + self.has_meeting_ended(candidate, window) + && is_same_observed_window(&candidate.window, window) + } } fn text_contains(value: &str, needles: &[&str]) -> bool { diff --git a/crates/stay-core/src/session.rs b/crates/stay-core/src/session.rs index 8dc7ee5..25da4d6 100644 --- a/crates/stay-core/src/session.rs +++ b/crates/stay-core/src/session.rs @@ -103,6 +103,7 @@ enum GuardPhase { struct GuardSession { meeting: MeetingCandidate, authorized_app_keys: HashSet, + pending_end_key: Option, } impl GuardSession { @@ -110,6 +111,7 @@ impl GuardSession { Self { meeting, authorized_app_keys: HashSet::new(), + pending_end_key: None, } } @@ -121,6 +123,17 @@ impl GuardSession { self.authorized_app_keys .contains(&window.app_identity_key()) } + + fn clear_pending_end(&mut self) { + self.pending_end_key = None; + } + + fn confirm_or_track_pending_end(&mut self, window: &WindowSnapshot) -> bool { + let key = window.identity_key(); + let confirmed = self.pending_end_key.as_deref() == Some(key.as_str()); + self.pending_end_key = Some(key); + confirmed + } } pub struct FocusGuard { @@ -325,10 +338,10 @@ impl FocusGuard { fn observe_from_guarding( &mut self, - session: GuardSession, + mut session: GuardSession, window: WindowSnapshot, ) -> Vec { - if self.classifier.has_meeting_ended(&session.meeting, &window) { + if self.should_stop_for_meeting_end(&mut session, &window) { self.phase = GuardPhase::Idle; return vec![GuardCommand::StopGuarding]; } @@ -337,10 +350,12 @@ impl FocusGuard { .classifier .is_same_meeting_window(&session.meeting, &window) { + self.phase = GuardPhase::Guarding(session); return Vec::new(); } if session.is_app_authorized(&window) { + self.phase = GuardPhase::Guarding(session); return Vec::new(); } @@ -358,13 +373,13 @@ impl FocusGuard { fn observe_from_locked( &mut self, - session: GuardSession, + mut session: GuardSession, focused: WindowSnapshot, failed_attempts: u32, last_error: Option, window: WindowSnapshot, ) -> Vec { - if self.classifier.has_meeting_ended(&session.meeting, &window) { + if self.should_stop_for_meeting_end(&mut session, &window) { self.phase = GuardPhase::Idle; return vec![GuardCommand::StopGuarding]; } @@ -404,4 +419,24 @@ impl FocusGuard { }; vec![command] } + + fn should_stop_for_meeting_end( + &self, + session: &mut GuardSession, + window: &WindowSnapshot, + ) -> bool { + if !self.classifier.has_meeting_ended(&session.meeting, window) { + session.clear_pending_end(); + return false; + } + + if self + .classifier + .has_definitive_meeting_end(&session.meeting, window) + { + return true; + } + + session.confirm_or_track_pending_end(window) + } } diff --git a/crates/stay-core/tests/session_policy.rs b/crates/stay-core/tests/session_policy.rs index 56035b8..146168d 100644 --- a/crates/stay-core/tests/session_policy.rs +++ b/crates/stay-core/tests/session_policy.rs @@ -163,21 +163,55 @@ fn hides_candidate_when_native_meeting_app_returns_home_before_acceptance() { } #[test] -fn stops_guarding_when_native_meeting_app_moves_to_different_window() { +fn native_meeting_app_end_signal_requires_confirmation_on_different_window() { let mut guard = guard_with_pin(); let meeting = window("zoom.us", "Weekly Team Sync").with_window_id("zoom-meeting"); + let home = window("zoom.us", "Zoom Workplace").with_window_id("zoom-home"); guard.observe_focus(Some(meeting)); guard.accept_stay().unwrap(); - let commands = guard.observe_focus(Some( - window("zoom.us", "Zoom Workplace").with_window_id("zoom-home"), + let commands = guard.observe_focus(Some(home.clone())); + + assert!(matches!( + commands.as_slice(), + [GuardCommand::ShowLock { focused, .. }] if focused.title == "Zoom Workplace" )); + assert!(matches!(guard.view(), GuardView::Locked { .. })); + + let commands = guard.observe_focus(Some(home)); assert!(matches!(commands.as_slice(), [GuardCommand::StopGuarding])); assert!(matches!(guard.view(), GuardView::Idle { .. })); } +#[test] +fn transient_native_meeting_app_end_signal_does_not_stop_guarding() { + let mut guard = guard_with_pin(); + let meeting = window("zoom.us", "Weekly Team Sync").with_window_id("zoom-meeting"); + let home = window("zoom.us", "Zoom Workplace").with_window_id("zoom-home"); + + guard.observe_focus(Some(meeting)); + guard.accept_stay().unwrap(); + + guard.observe_focus(Some(home.clone())); + let commands = guard.observe_focus(Some(window("Slack", "Messages"))); + + assert!(matches!( + commands.as_slice(), + [GuardCommand::ShowLock { focused, .. }] if focused.app_name == "Slack" + )); + assert!(matches!(guard.view(), GuardView::Locked { .. })); + + let commands = guard.observe_focus(Some(home)); + + assert!(matches!( + commands.as_slice(), + [GuardCommand::ShowLock { focused, .. }] if focused.title == "Zoom Workplace" + )); + assert!(matches!(guard.view(), GuardView::Locked { .. })); +} + #[test] fn stops_guarding_when_native_meeting_window_returns_to_app_home() { let mut guard = guard_with_pin(); From d9afb2652f6d47738e0c6d0ab072cf1a95f889d8 Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Wed, 13 May 2026 19:56:19 -0400 Subject: [PATCH 7/8] fix: satisfy rust clippy manual contains --- crates/stay-core/src/meeting.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/stay-core/src/meeting.rs b/crates/stay-core/src/meeting.rs index 780855f..83cbe80 100644 --- a/crates/stay-core/src/meeting.rs +++ b/crates/stay-core/src/meeting.rs @@ -255,7 +255,7 @@ const WEBEX_SHELL_TITLES: &[&str] = &["webex", "meetings", "messaging", "calling const FACETIME_SHELL_TITLES: &[&str] = &["facetime"]; fn title_matches(title: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| title == *needle) + needles.contains(&title) } fn is_same_observed_window(left: &WindowSnapshot, right: &WindowSnapshot) -> bool { From 7f74484aa6747cf0c8b5ccd1768b705bb1e92358 Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Wed, 13 May 2026 20:01:38 -0400 Subject: [PATCH 8/8] ci: stabilize rust checks --- .github/workflows/ci.yml | 2 ++ crates/stay-platform/src/active_window.rs | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01fc42f..7ef2da4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: - name: Cache Cargo uses: Swatinem/rust-cache@v2 + with: + cache-bin: false - name: Format run: cargo fmt --all -- --check diff --git a/crates/stay-platform/src/active_window.rs b/crates/stay-platform/src/active_window.rs index d8e20d3..46f6d3d 100644 --- a/crates/stay-platform/src/active_window.rs +++ b/crates/stay-platform/src/active_window.rs @@ -90,11 +90,10 @@ mod macos_accessibility { } } -#[cfg(test)] +#[cfg(all(test, target_os = "macos"))] mod tests { use super::*; - #[cfg(target_os = "macos")] #[test] fn enriches_empty_macos_title_from_accessibility() { let snapshot = WindowSnapshot::new("zoom.us", "").with_process_id(1006); @@ -108,7 +107,6 @@ mod tests { assert_eq!(snapshot.title, "Zoom Meeting"); } - #[cfg(target_os = "macos")] #[test] fn keeps_existing_macos_title_without_accessibility_lookup() { let snapshot = WindowSnapshot::new("zoom.us", "Home").with_process_id(1006);