diff --git a/Cargo.lock b/Cargo.lock
index 876cf16..b241e01 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1682,6 +1682,8 @@ dependencies = [
"ashpd",
"async-trait",
"bitflags 2.11.0",
+ "core-foundation",
+ "core-foundation-sys",
"core-graphics",
"futures",
"input-event",
diff --git a/Cargo.toml b/Cargo.toml
index b482e5e..dc673fa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -96,3 +96,5 @@ rdp_emulation = ["input-emulation/remote_desktop_portal"]
name = "Lan Mouse"
icon = ["target/icon.icns"]
identifier = "de.feschber.LanMouse"
+osx_info_plist_exts = ["build-aux/macos-lsui-element.plist"]
+resources = ["target/menubar-template.png"]
diff --git a/README.md b/README.md
index d96607d..78834c3 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,7 @@ dnf install lan-mouse
- Unzip it
- Remove the quarantine with `xattr -rd com.apple.quarantine "Lan Mouse.app"`
- Launch the app
+- Use the menu bar item to open the settings window or quit Lan Mouse. Bundled macOS builds run as a menu bar app and do not keep a Dock icon visible.
- Grant accessibility permissions in System Preferences
@@ -475,4 +476,4 @@ The following sections detail the emulation and capture backends provided by lan
- `libei`: This backend uses [libei](https://gitlab.freedesktop.org/libinput/libei) and is supported by GNOME >= 45 or KDE Plasma >= 6.1.
- `windows`: Backend for input capture on Windows.
- `macos`: Backend for input capture on MacOS.
-- `x11`: TODO (not yet supported)
\ No newline at end of file
+- `x11`: TODO (not yet supported)
diff --git a/build-aux/macos-lsui-element.plist b/build-aux/macos-lsui-element.plist
new file mode 100644
index 0000000..4a60be7
--- /dev/null
+++ b/build-aux/macos-lsui-element.plist
@@ -0,0 +1,6 @@
+ LSUIElement
+
+ NSInputMonitoringUsageDescription
+ Lan Mouse needs Input Monitoring access to capture keyboard and mouse input and forward it to remote machines on your network.
+ NSAppleEventsUsageDescription
+ Lan Mouse uses Apple Events to deliver synthesized keyboard and mouse events to the system.
diff --git a/lan-mouse-gtk/src/lib.rs b/lan-mouse-gtk/src/lib.rs
index 9908e05..6204879 100644
--- a/lan-mouse-gtk/src/lib.rs
+++ b/lan-mouse-gtk/src/lib.rs
@@ -4,6 +4,10 @@ mod client_row;
mod fingerprint_window;
mod key_object;
mod key_row;
+#[cfg(target_os = "macos")]
+mod macos_privacy;
+#[cfg(target_os = "macos")]
+mod macos_status_item;
mod window;
use std::{env, process, str};
@@ -47,6 +51,12 @@ pub fn run() -> Result<(), GtkError> {
}
fn gtk_main() -> glib::ExitCode {
+ #[cfg(target_os = "macos")]
+ {
+ configure_macos_bundle_environment();
+ install_macos_gtk_log_filter();
+ }
+
gio::resources_register_include!("lan-mouse.gresource").expect("Failed to register resources.");
let app = Application::builder()
@@ -64,6 +74,64 @@ fn gtk_main() -> glib::ExitCode {
app.run_with_args(&args)
}
+#[cfg(target_os = "macos")]
+fn install_macos_gtk_log_filter() {
+ glib::log_set_writer_func(|level, fields| {
+ if level == glib::LogLevel::Warning && is_gtk_theme_parser_warning(fields) {
+ return glib::LogWriterOutput::Handled;
+ }
+
+ glib::log_writer_default(level, fields)
+ });
+}
+
+#[cfg(target_os = "macos")]
+fn is_gtk_theme_parser_warning(fields: &[glib::LogField<'_>]) -> bool {
+ let mut domain = None;
+ let mut message = None;
+
+ for field in fields {
+ match field.key() {
+ "GLIB_DOMAIN" => domain = field.value_str(),
+ "MESSAGE" => message = field.value_str(),
+ _ => {}
+ }
+ }
+
+ domain == Some("Gtk")
+ && message.is_some_and(|message| message.starts_with("Theme parser warning: gtk.css:"))
+}
+
+#[cfg(target_os = "macos")]
+fn configure_macos_bundle_environment() {
+ let Ok(exe) = env::current_exe() else {
+ return;
+ };
+ let Some(contents) = exe
+ .parent()
+ .and_then(|dir| dir.parent())
+ .map(std::path::Path::to_owned)
+ else {
+ return;
+ };
+
+ let share = contents.join("Resources").join("share");
+ if !share.exists() {
+ return;
+ }
+
+ let schemas = share.join("glib-2.0").join("schemas");
+ if schemas.exists() {
+ env::set_var("GSETTINGS_SCHEMA_DIR", schemas);
+ }
+
+ env::set_var("XDG_DATA_DIRS", &share);
+ env::set_var(
+ "GTK_DATA_PREFIX",
+ contents.join("Resources").to_string_lossy().as_ref(),
+ );
+}
+
fn load_icons() {
let display = &Display::default().expect("Could not connect to a display.");
let icon_theme = IconTheme::for_display(display);
@@ -123,6 +191,16 @@ fn build_ui(app: &Application) {
});
let window = Window::new(app, frontend_tx);
+ #[cfg(target_os = "macos")]
+ {
+ window.connect_close_request(|window| {
+ window.set_visible(false);
+ glib::Propagation::Stop
+ });
+ macos_status_item::setup(app, &window);
+ // First-launch TCC prompts. No-op when already granted.
+ macos_privacy::fire_initial_prompts();
+ }
glib::spawn_future_local(clone!(
#[weak]
@@ -171,5 +249,6 @@ fn build_ui(app: &Application) {
}
));
+ #[cfg(not(target_os = "macos"))]
window.present();
}
diff --git a/lan-mouse-gtk/src/macos_status_item.rs b/lan-mouse-gtk/src/macos_status_item.rs
new file mode 100644
index 0000000..d8aed2e
--- /dev/null
+++ b/lan-mouse-gtk/src/macos_status_item.rs
@@ -0,0 +1,283 @@
+#![allow(clashing_extern_declarations)]
+
+use std::{
+ cell::RefCell,
+ ffi::{CStr, CString, c_char, c_double, c_void},
+ sync::OnceLock,
+};
+
+use adw::prelude::*;
+use gtk::{gio, glib};
+
+use crate::window::Window;
+
+type Id = *mut c_void;
+type Class = *mut c_void;
+type Sel = *mut c_void;
+type Bool = i8;
+
+struct StatusItem {
+ app: glib::WeakRef,
+ window: glib::WeakRef,
+ _hold: gio::ApplicationHoldGuard,
+ _delegate: Id,
+ _status_item: Id,
+}
+
+thread_local! {
+ static STATUS_ITEM: RefCell