mirror of
https://github.com/feschber/lan-mouse.git
synced 2026-05-08 07:08:05 +03:00
macos: fold relaunch prompt into the warning row instead of a toast
The cut-off toast UX ("Accessibility granted. Relaunch Lan Mouse so
capture and emulat…") was unreadable in a compact window and split
the "grant" and "relaunch" flows into two disconnected surfaces. Fold
everything into the existing warning row with state-dependent content:
- AX missing:
title = "input capture is disabled"
subtitle = "grant Accessibility permission to enable"
button = "Grant" → opens System Settings → Accessibility
- AX granted, daemon still bailed:
title = "relaunch required"
subtitle = "Accessibility granted — restart to activate capture
and emulation"
button = "Relaunch" → spawns a fresh bundle via `open` after
a 1s delay, then quits.
- Both active: row hidden.
The emulation_status_row is kept hidden on macOS because capture and
emulation share the same TCC gate — a single row is sufficient and
two identical-looking warnings were noisy. `handle_emulation` still
exists for the non-macOS platforms where the rows are distinct.
Side effects:
- `relaunch_bundle` moved from lib.rs to macos_privacy so imp.rs can
call it from the row button handler.
- AX watcher callback shrinks to `window.present()` +
`refresh_capture_emulation_status()`; the toast-based dialog is
gone along with its helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Ferdinand Schober
parent
2dc9ebb6cd
commit
5d7d14fbf7
@@ -50,7 +50,7 @@
|
|||||||
<child>
|
<child>
|
||||||
<object class="AdwActionRow" id="capture_status_row">
|
<object class="AdwActionRow" id="capture_status_row">
|
||||||
<property name="title">input capture is disabled</property>
|
<property name="title">input capture is disabled</property>
|
||||||
<property name="subtitle">required for outgoing connections — click Reenable to grant permission</property>
|
<property name="subtitle">required for outgoing and incoming connections</property>
|
||||||
<property name="icon-name">dialog-warning-symbolic</property>
|
<property name="icon-name">dialog-warning-symbolic</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkButton" id="input_capture_button">
|
<object class="GtkButton" id="input_capture_button">
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
<child>
|
<child>
|
||||||
<object class="AdwActionRow" id="emulation_status_row">
|
<object class="AdwActionRow" id="emulation_status_row">
|
||||||
<property name="title">input emulation is disabled</property>
|
<property name="title">input emulation is disabled</property>
|
||||||
<property name="subtitle">required for incoming connections — click Reenable to grant permission</property>
|
<property name="subtitle">required for incoming connections</property>
|
||||||
<property name="icon-name">dialog-warning-symbolic</property>
|
<property name="icon-name">dialog-warning-symbolic</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkButton" id="input_emulation_button">
|
<object class="GtkButton" id="input_emulation_button">
|
||||||
|
|||||||
@@ -201,17 +201,16 @@ fn build_ui(app: &Application) {
|
|||||||
// First-launch TCC prompts. No-op when already granted.
|
// First-launch TCC prompts. No-op when already granted.
|
||||||
macos_privacy::fire_initial_prompts();
|
macos_privacy::fire_initial_prompts();
|
||||||
// If Accessibility wasn't granted at startup, watch for the grant
|
// If Accessibility wasn't granted at startup, watch for the grant
|
||||||
// and prompt the user to relaunch when it lands. The daemon
|
// and switch the status row into its "relaunch required" state
|
||||||
// subprocess initialized without AX (bailed with "accessibility
|
// when it lands. The daemon subprocess initialized without AX
|
||||||
// permission is required") and can't recover without a restart,
|
// (bailed with "accessibility permission is required") and can't
|
||||||
// so a live AX toggle without a relaunch leaves the app in a
|
// recover without a restart, so a live AX toggle without a
|
||||||
// broken state otherwise.
|
// relaunch leaves the app in a broken state otherwise.
|
||||||
let app_weak = app.downgrade();
|
|
||||||
let window_weak = window.downgrade();
|
let window_weak = window.downgrade();
|
||||||
macos_privacy::watch_for_accessibility_grant(move || {
|
macos_privacy::watch_for_accessibility_grant(move || {
|
||||||
if let (Some(app), Some(window)) = (app_weak.upgrade(), window_weak.upgrade())
|
if let Some(window) = window_weak.upgrade() {
|
||||||
{
|
window.present();
|
||||||
show_macos_relaunch_dialog(&app, &window);
|
window.refresh_capture_emulation_status();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -267,65 +266,3 @@ fn build_ui(app: &Application) {
|
|||||||
window.present();
|
window.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn show_macos_relaunch_dialog(app: &Application, window: &Window) {
|
|
||||||
// Present the window so the toast is visible — on macOS the main
|
|
||||||
// window starts hidden (LSUIElement accessory app), so a toast
|
|
||||||
// otherwise fires into a surface the user can't see.
|
|
||||||
window.present();
|
|
||||||
|
|
||||||
// Refresh the capture/emulation status rows so the yellow
|
|
||||||
// "Reenable" warning disappears. It was showing because the daemon
|
|
||||||
// reports capture/emulation inactive; now that AX is granted the
|
|
||||||
// Relaunch toast is the right prompt and the warning is redundant.
|
|
||||||
window.refresh_capture_emulation_status();
|
|
||||||
|
|
||||||
let toast = adw::Toast::builder()
|
|
||||||
.title(
|
|
||||||
"Accessibility granted. Relaunch Lan Mouse so capture and \
|
|
||||||
emulation can initialize.",
|
|
||||||
)
|
|
||||||
.button_label("Relaunch")
|
|
||||||
.priority(adw::ToastPriority::High)
|
|
||||||
// 0 => never auto-dismiss. Relaunch is mandatory for things to
|
|
||||||
// work, so don't let the user miss the action.
|
|
||||||
.timeout(0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let app = app.clone();
|
|
||||||
toast.connect_button_clicked(move |_| {
|
|
||||||
relaunch_macos_bundle();
|
|
||||||
app.quit();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.add_toast(toast);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn relaunch_macos_bundle() {
|
|
||||||
// Resolve the .app bundle path from the current executable: it lives
|
|
||||||
// at <bundle>/Contents/MacOS/lan-mouse, so three parents up is the
|
|
||||||
// bundle root we hand to `open`.
|
|
||||||
let Ok(exe) = std::env::current_exe() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(bundle) = exe
|
|
||||||
.parent()
|
|
||||||
.and_then(std::path::Path::parent)
|
|
||||||
.and_then(std::path::Path::parent)
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fire `sleep 1 && open <bundle>` in a detached shell so the new
|
|
||||||
// instance starts *after* we've quit — otherwise Launch Services
|
|
||||||
// reactivates the existing process instead of launching a fresh one,
|
|
||||||
// and the stale IPC socket would block the new daemon subprocess.
|
|
||||||
// The trailing `&` backgrounds the command, and we don't wait on the
|
|
||||||
// spawn, so the shell is adopted by launchd after we exit.
|
|
||||||
let cmd = format!("(sleep 1 && open {bundle:?}) &", bundle = bundle);
|
|
||||||
let _ = std::process::Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(cmd)
|
|
||||||
.spawn();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -104,6 +104,33 @@ pub fn open_accessibility_settings() {
|
|||||||
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
|
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn a fresh instance of the current `.app` bundle via Launch Services
|
||||||
|
/// after a 1-second delay, so the new instance starts *after* the current
|
||||||
|
/// process has exited — otherwise Launch Services reactivates the existing
|
||||||
|
/// process instead of launching a fresh one, and the stale IPC socket
|
||||||
|
/// would block the new daemon subprocess. The caller is responsible for
|
||||||
|
/// quitting the current process (e.g. `Application::quit()`) after this.
|
||||||
|
pub fn relaunch_bundle() {
|
||||||
|
// Resolve the .app bundle path from the current executable: it lives
|
||||||
|
// at <bundle>/Contents/MacOS/lan-mouse, so three parents up is the
|
||||||
|
// bundle root we hand to `open`.
|
||||||
|
let Ok(exe) = std::env::current_exe() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(bundle) = exe
|
||||||
|
.parent()
|
||||||
|
.and_then(std::path::Path::parent)
|
||||||
|
.and_then(std::path::Path::parent)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trailing `&` backgrounds the sleep+open so our shell call returns
|
||||||
|
// immediately; the spawned shell is adopted by launchd once we exit.
|
||||||
|
let cmd = format!("(sleep 1 && open {bundle:?}) &");
|
||||||
|
let _ = Command::new("sh").arg("-c").arg(cmd).spawn();
|
||||||
|
}
|
||||||
|
|
||||||
/// Make sure the app appears in System Settings → Privacy → Input Monitoring.
|
/// Make sure the app appears in System Settings → Privacy → Input Monitoring.
|
||||||
///
|
///
|
||||||
/// `CGRequestListenEventAccess()` is *supposed* to register the app in the
|
/// `CGRequestListenEventAccess()` is *supposed* to register the app in the
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ use crate::{
|
|||||||
|
|
||||||
use super::{client_object::ClientObject, client_row::ClientRow};
|
use super::{client_object::ClientObject, client_row::ClientRow};
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn set_button_content_label(button: >k::Button, label: &str) {
|
||||||
|
// The Reenable/Grant/Relaunch button wraps its icon+label in an
|
||||||
|
// AdwButtonContent (see window.ui). Walk into it and swap the label
|
||||||
|
// rather than GtkButton::set_label, which would replace the content
|
||||||
|
// widget and drop the icon.
|
||||||
|
if let Some(content) = button.child().and_downcast::<adw::ButtonContent>() {
|
||||||
|
content.set_label(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
glib::wrapper! {
|
glib::wrapper! {
|
||||||
pub struct Window(ObjectSubclass<imp::Window>)
|
pub struct Window(ObjectSubclass<imp::Window>)
|
||||||
@extends adw::ApplicationWindow, gtk::Window, gtk::Widget,
|
@extends adw::ApplicationWindow, gtk::Window, gtk::Widget,
|
||||||
@@ -459,24 +470,52 @@ impl Window {
|
|||||||
let capture = self.imp().capture_active.get();
|
let capture = self.imp().capture_active.get();
|
||||||
let emulation = self.imp().emulation_active.get();
|
let emulation = self.imp().emulation_active.get();
|
||||||
|
|
||||||
// On macOS the yellow "Reenable" row only makes sense when
|
|
||||||
// Accessibility is actually missing. When AX is granted but
|
|
||||||
// capture/emulation are still off, the daemon simply hasn't been
|
|
||||||
// restarted yet — the Relaunch toast covers that state, and a
|
|
||||||
// yellow "grant permission" warning on top of it would be
|
|
||||||
// redundant and confusing.
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let show_warning = !crate::macos_privacy::accessibility_granted();
|
{
|
||||||
#[cfg(not(target_os = "macos"))]
|
// On macOS, capture and emulation share the same TCC gate
|
||||||
let show_warning = true;
|
// (Accessibility). Collapse to a single warning row —
|
||||||
|
// emulation_status_row stays hidden and capture_status_row
|
||||||
|
// doubles as the shared status indicator. Its text and
|
||||||
|
// button mutate based on whether we're waiting for AX or
|
||||||
|
// waiting for the user to relaunch the app.
|
||||||
|
let anything_off = !capture || !emulation;
|
||||||
|
self.imp().emulation_status_row.set_visible(false);
|
||||||
|
self.imp().capture_status_row.set_visible(anything_off);
|
||||||
|
self.imp().capture_emulation_group.set_visible(anything_off);
|
||||||
|
|
||||||
let show_capture_row = !capture && show_warning;
|
if anything_off {
|
||||||
let show_emulation_row = !emulation && show_warning;
|
self.update_macos_warning_row_text();
|
||||||
self.imp().capture_status_row.set_visible(show_capture_row);
|
}
|
||||||
self.imp().emulation_status_row.set_visible(show_emulation_row);
|
}
|
||||||
self.imp()
|
|
||||||
.capture_emulation_group
|
#[cfg(not(target_os = "macos"))]
|
||||||
.set_visible(show_capture_row || show_emulation_row);
|
{
|
||||||
|
self.imp().capture_status_row.set_visible(!capture);
|
||||||
|
self.imp().emulation_status_row.set_visible(!emulation);
|
||||||
|
self.imp()
|
||||||
|
.capture_emulation_group
|
||||||
|
.set_visible(!capture || !emulation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn update_macos_warning_row_text(&self) {
|
||||||
|
let row = &self.imp().capture_status_row;
|
||||||
|
let button = &self.imp().input_capture_button;
|
||||||
|
|
||||||
|
if crate::macos_privacy::accessibility_granted() {
|
||||||
|
// AX granted but capture/emulation still off → the daemon
|
||||||
|
// subprocess bailed at startup and needs a fresh process to
|
||||||
|
// re-initialize with the new grant in place.
|
||||||
|
row.set_title("relaunch required");
|
||||||
|
row.set_subtitle("Accessibility granted — restart to activate capture and emulation");
|
||||||
|
set_button_content_label(button, "Relaunch");
|
||||||
|
} else {
|
||||||
|
// AX missing → send the user to System Settings.
|
||||||
|
row.set_title("input capture is disabled");
|
||||||
|
row.set_subtitle("grant Accessibility permission to enable");
|
||||||
|
set_button_content_label(button, "Grant");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn set_authorized_keys(&self, fingerprints: HashMap<String, String>) {
|
pub(super) fn set_authorized_keys(&self, fingerprints: HashMap<String, String>) {
|
||||||
|
|||||||
@@ -10,16 +10,6 @@ use lan_mouse_ipc::{DEFAULT_PORT, FrontendRequestWriter};
|
|||||||
|
|
||||||
use crate::authorization_window::AuthorizationWindow;
|
use crate::authorization_window::AuthorizationWindow;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn open_accessibility_if_missing(ctx: &str) {
|
|
||||||
if crate::macos_privacy::accessibility_granted() {
|
|
||||||
log::info!("{ctx}: Accessibility already granted, retry only");
|
|
||||||
} else {
|
|
||||||
log::info!("{ctx}: opening Accessibility pane");
|
|
||||||
crate::macos_privacy::open_accessibility_settings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(CompositeTemplate, Default)]
|
#[derive(CompositeTemplate, Default)]
|
||||||
#[template(resource = "/de/feschber/LanMouse/window.ui")]
|
#[template(resource = "/de/feschber/LanMouse/window.ui")]
|
||||||
pub struct Window {
|
pub struct Window {
|
||||||
@@ -152,15 +142,32 @@ impl Window {
|
|||||||
|
|
||||||
#[template_callback]
|
#[template_callback]
|
||||||
fn handle_emulation(&self) {
|
fn handle_emulation(&self) {
|
||||||
#[cfg(target_os = "macos")]
|
// On macOS the emulation_status_row is hidden — capture_status_row
|
||||||
open_accessibility_if_missing("Reenable emulation");
|
// acts as the shared warning (see update_capture_emulation_status).
|
||||||
|
// This handler still fires for the non-macOS platforms where the
|
||||||
|
// emulation row is distinct.
|
||||||
self.obj().request_emulation();
|
self.obj().request_emulation();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[template_callback]
|
#[template_callback]
|
||||||
fn handle_capture(&self) {
|
fn handle_capture(&self) {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
open_accessibility_if_missing("Reenable capture");
|
{
|
||||||
|
use crate::macos_privacy;
|
||||||
|
if macos_privacy::accessibility_granted() {
|
||||||
|
// AX granted but the row is still visible => the daemon
|
||||||
|
// subprocess bailed before AX was in place and needs a
|
||||||
|
// fresh process. Quit + relaunch via Launch Services.
|
||||||
|
log::info!("capture row clicked in relaunch-required state");
|
||||||
|
macos_privacy::relaunch_bundle();
|
||||||
|
if let Some(app) = self.obj().application() {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!("capture row clicked in AX-missing state, opening pane");
|
||||||
|
macos_privacy::open_accessibility_settings();
|
||||||
|
}
|
||||||
self.obj().request_capture();
|
self.obj().request_capture();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user