mirror of
https://github.com/feschber/lan-mouse.git
synced 2026-05-08 07:08:05 +03:00
macos: prompt to relaunch after live Accessibility grant
The daemon subprocess initializes at startup and bails immediately if
Accessibility is missing ("accessibility permission is required"). If
the user then grants AX mid-session via the system prompt, the daemon
has no way to retry from its bailed state — capture and emulation
stay dead until the next restart. Make the GUI watch for the AX
transition and surface a toast with a "Relaunch" button that quits
the app and spawns a fresh instance via Launch Services.
While here:
- Route capture/emulation "missing pane" fallback to the Accessibility
pane instead of the Input Monitoring / Post Event panes when AX is
already granted. On macOS 13+ those separate grants auto-confer via
Accessibility and the bundle typically isn't listed in the IM pane
at all, so the old navigation was a dead end.
- Reword the status-row subtitles so the action is clearer: the user
now sees "click Reenable to grant permission" instead of a generic
"required for outgoing connections".
- Bump libadwaita feature flag to v1_2 for AdwToast button signals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Ferdinand Schober
parent
cbdb86ce05
commit
b3cade9bac
@@ -8,7 +8,7 @@ repository = "https://github.com/feschber/lan-mouse"
|
||||
|
||||
[dependencies]
|
||||
gtk = { package = "gtk4", version = "0.9.0", features = ["v4_2"] }
|
||||
adw = { package = "libadwaita", version = "0.7.0", features = ["v1_1"] }
|
||||
adw = { package = "libadwaita", version = "0.7.0", features = ["v1_2"] }
|
||||
async-channel = { version = "2.1.1" }
|
||||
hostname = "0.4.0"
|
||||
log = "0.4.20"
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<child>
|
||||
<object class="AdwActionRow" id="capture_status_row">
|
||||
<property name="title">input capture is disabled</property>
|
||||
<property name="subtitle">required for outgoing and incoming connections</property>
|
||||
<property name="subtitle">required for outgoing connections — click Reenable to grant permission</property>
|
||||
<property name="icon-name">dialog-warning-symbolic</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="input_capture_button">
|
||||
@@ -76,7 +76,7 @@
|
||||
<child>
|
||||
<object class="AdwActionRow" id="emulation_status_row">
|
||||
<property name="title">input emulation is disabled</property>
|
||||
<property name="subtitle">required for incoming connections</property>
|
||||
<property name="subtitle">required for incoming connections — click Reenable to grant permission</property>
|
||||
<property name="icon-name">dialog-warning-symbolic</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="input_emulation_button">
|
||||
|
||||
@@ -200,6 +200,20 @@ fn build_ui(app: &Application) {
|
||||
macos_status_item::setup(app, &window);
|
||||
// First-launch TCC prompts. No-op when already granted.
|
||||
macos_privacy::fire_initial_prompts();
|
||||
// If Accessibility wasn't granted at startup, watch for the grant
|
||||
// and prompt the user to relaunch when it lands. The daemon
|
||||
// subprocess initialized without AX (bailed with "accessibility
|
||||
// permission is required") and can't recover without a restart,
|
||||
// so a live AX toggle without a relaunch leaves the app in a
|
||||
// broken state otherwise.
|
||||
let app_weak = app.downgrade();
|
||||
let window_weak = window.downgrade();
|
||||
macos_privacy::watch_for_accessibility_grant(move || {
|
||||
if let (Some(app), Some(window)) = (app_weak.upgrade(), window_weak.upgrade())
|
||||
{
|
||||
show_macos_relaunch_dialog(&app, &window);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
glib::spawn_future_local(clone!(
|
||||
@@ -252,3 +266,60 @@ fn build_ui(app: &Application) {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
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();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use std::ffi::{c_uchar, c_void};
|
||||
use std::process::Command;
|
||||
use std::sync::Once;
|
||||
|
||||
use gtk::glib;
|
||||
|
||||
// Apple declares `AXIsProcessTrusted` as returning `Boolean` (`unsigned char`),
|
||||
// NOT C's `bool`. Rust's `bool` has a strict bit pattern (0 or 1) so binding
|
||||
// a `Boolean`-returning function as `-> bool` is technically UB if Apple ever
|
||||
@@ -83,6 +85,13 @@ pub fn post_event_granted() -> bool {
|
||||
raw != 0
|
||||
}
|
||||
|
||||
// Variants `InputMonitoring` and `PostEvent` are currently never returned
|
||||
// by `missing_capture_pane` / `missing_emulation_pane` — on macOS 13+ those
|
||||
// categories auto-grant via Accessibility and the bundle typically isn't
|
||||
// listed in those separate panes, so routing users there is a dead end.
|
||||
// Kept in the enum so older-macOS behavior can be restored without a
|
||||
// structural change.
|
||||
#[allow(dead_code)]
|
||||
pub enum CapturePane {
|
||||
Accessibility,
|
||||
InputMonitoring,
|
||||
@@ -90,6 +99,7 @@ pub enum CapturePane {
|
||||
None,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum EmulationPane {
|
||||
Accessibility,
|
||||
PostEvent,
|
||||
@@ -100,7 +110,13 @@ pub fn missing_capture_pane() -> CapturePane {
|
||||
if !accessibility_granted() {
|
||||
CapturePane::Accessibility
|
||||
} else if !input_monitoring_granted() {
|
||||
CapturePane::InputMonitoring
|
||||
// On macOS 13+, Accessibility trust confers the listen-only
|
||||
// event-tap privilege that Input Monitoring gates, and on Sequoia
|
||||
// the bundle typically isn't listed in the Input Monitoring pane
|
||||
// at all. The actionable fix when IM preflight is still 0 is to
|
||||
// re-toggle Accessibility, so send the user there rather than to
|
||||
// an empty IM list.
|
||||
CapturePane::Accessibility
|
||||
} else {
|
||||
CapturePane::None
|
||||
}
|
||||
@@ -110,12 +126,41 @@ pub fn missing_emulation_pane() -> EmulationPane {
|
||||
if !accessibility_granted() {
|
||||
EmulationPane::Accessibility
|
||||
} else if !post_event_granted() {
|
||||
EmulationPane::PostEvent
|
||||
// Post Event is nested under Accessibility on modern macOS and
|
||||
// auto-grants alongside it. Point the user back to Accessibility
|
||||
// for the same reason as the capture case above.
|
||||
EmulationPane::Accessibility
|
||||
} else {
|
||||
EmulationPane::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll for an Accessibility grant transition. Starts a 1-second GLib
|
||||
/// timer that fires `on_granted` once, the first time
|
||||
/// `AXIsProcessTrusted()` returns true. A no-op if AX is already granted.
|
||||
///
|
||||
/// We rely on polling rather than AXObserver because the AX notification
|
||||
/// API requires a trusted process to subscribe — the precondition we're
|
||||
/// waiting for. This runs on the GTK main thread (via timeout_add_seconds_local).
|
||||
pub fn watch_for_accessibility_grant<F>(mut on_granted: F)
|
||||
where
|
||||
F: FnMut() + 'static,
|
||||
{
|
||||
if accessibility_granted() {
|
||||
return;
|
||||
}
|
||||
log::info!("watching for Accessibility grant");
|
||||
glib::timeout_add_seconds_local(1, move || {
|
||||
if accessibility_granted() {
|
||||
log::info!("Accessibility granted; firing relaunch prompt");
|
||||
on_granted();
|
||||
glib::ControlFlow::Break
|
||||
} else {
|
||||
glib::ControlFlow::Continue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn open_accessibility_settings() {
|
||||
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
|
||||
}
|
||||
|
||||
@@ -432,6 +432,10 @@ impl Window {
|
||||
|
||||
pub(super) fn show_toast(&self, msg: &str) {
|
||||
let toast = adw::Toast::new(msg);
|
||||
self.add_toast(toast);
|
||||
}
|
||||
|
||||
pub(super) fn add_toast(&self, toast: adw::Toast) {
|
||||
let toast_overlay = &self.imp().toast_overlay;
|
||||
toast_overlay.add_toast(toast);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user