From 903b0504e020afaca534a5be9299763da58775a2 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Fri, 24 Apr 2026 02:09:12 -0500 Subject: [PATCH] macos: run as LSUIElement menubar app with NSStatusItem Ship Lan Mouse on macOS as an accessory app (no Dock icon, no main window on launch) with a status-bar item for show/quit. Closing the window hides it instead of quitting so the menu bar stays the primary surface. - build-aux/macos-lsui-element.plist: LSUIElement=true plus NSInputMonitoringUsageDescription and NSAppleEventsUsageDescription (merged into Info.plist via cargo-bundle's osx_info_plist_exts). - lan-mouse-gtk/src/macos_status_item.rs: NSStatusItem setup via raw objc_msgSend FFI. Loads a bundled 22pt PNG as a template image so it auto-tints for light/dark menu bars. - scripts/makeicns.sh: emit Contents/Resources/menubar-template.png from the existing SVG. - scripts/copy-macos-dylib.sh: flatten cargo-bundle's preserved target/ subdir under Resources so NSBundle pathForResource: finds the template image. - lan-mouse-gtk/src/lib.rs: register the new modules, set up a Cmd+Q-wired quit action, configure bundle env vars (schemas, XDG_DATA_DIRS, GTK_DATA_PREFIX) when running from inside the .app, and filter the known upstream Gtk theme-parser warning spam. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + Cargo.toml | 2 + README.md | 3 +- build-aux/macos-lsui-element.plist | 6 + lan-mouse-gtk/src/lib.rs | 79 +++++++ lan-mouse-gtk/src/macos_status_item.rs | 283 +++++++++++++++++++++++++ scripts/copy-macos-dylib.sh | 43 ++++ scripts/makeicns.sh | 54 ++++- 8 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 build-aux/macos-lsui-element.plist create mode 100644 lan-mouse-gtk/src/macos_status_item.rs 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> = const { RefCell::new(None) }; +} + +pub fn setup(app: &adw::Application, window: &Window) { + log::debug!("macos_status_item::setup entered"); + STATUS_ITEM.with(|item| { + let already_initialized = item.borrow().is_some(); + if already_initialized { + let mut cell = item.borrow_mut(); + if let Some(existing) = cell.as_mut() { + existing.app.set(Some(app)); + existing.window.set(Some(window)); + } + return; + } + + unsafe { + let hold = app.hold(); + + let ns_app = msg_send_id(class(c"NSApplication"), sel(c"sharedApplication")); + assert!(!ns_app.is_null(), "NSApplication sharedApplication returned null"); + msg_send_bool_usize(ns_app, sel(c"setActivationPolicy:"), 1); + + let delegate = new_delegate(); + let menu = menu(&[ + menu_item(c"Open Lan Mouse", c"showLanMouse:"), + separator_item(), + menu_item(c"Quit Lan Mouse", c"quitLanMouse:"), + ]); + + let status_bar = msg_send_id(class(c"NSStatusBar"), sel(c"systemStatusBar")); + assert!(!status_bar.is_null(), "NSStatusBar systemStatusBar returned null"); + let status_item = msg_send_id_f64(status_bar, sel(c"statusItemWithLength:"), -1.0); + assert!(!status_item.is_null(), "statusItemWithLength returned null"); + // Retain so the status item survives autorelease pool drain. + let status_item = msg_send_id(status_item, sel(c"retain")); + + let button = msg_send_id(status_item, sel(c"button")); + assert!(!button.is_null(), "NSStatusItem.button was null"); + set_button_image(button); + msg_send_void_id(button, sel(c"setToolTip:"), nsstring(c"Lan Mouse")); + msg_send_void_id(status_item, sel(c"setMenu:"), menu); + + for item in menu_items(menu) { + msg_send_void_id(item, sel(c"setTarget:"), delegate); + } + + log::debug!("macos_status_item ready at {:p}", status_item); + + item.replace(Some(StatusItem { + app: app.downgrade(), + window: window.downgrade(), + _hold: hold, + _delegate: delegate, + _status_item: status_item, + })); + } + }); +} + +// Prefer a pre-rendered template PNG (black silhouette with alpha) so macOS +// auto-tints the glyph to match the menu bar in light and dark modes. +// Falls back to the full-color icns, then to "LM" text. +unsafe fn set_button_image(button: Id) { + if let Some(image) = load_menubar_template() { + msg_send_void_bool(image, sel(c"setTemplate:"), 1); + msg_send_void_id(button, sel(c"setImage:"), image); + return; + } + if let Some(image) = load_app_icon() { + msg_send_void_id(button, sel(c"setImage:"), image); + return; + } + log::warn!("no menu bar image available; falling back to text title"); + msg_send_void_id(button, sel(c"setTitle:"), nsstring(c"LM")); +} + +unsafe fn load_menubar_template() -> Option { + load_resource_image(c"menubar-template", c"png", MENUBAR_ICON_SIZE) +} + +unsafe fn load_app_icon() -> Option { + load_resource_image(c"icon", c"icns", MENUBAR_ICON_SIZE) +} + +unsafe fn load_resource_image(name: &CStr, ext: &CStr, size_pt: c_double) -> Option { + let bundle = msg_send_id(class(c"NSBundle"), sel(c"mainBundle")); + if bundle.is_null() { + return None; + } + let path = msg_send_id_id_id( + bundle, + sel(c"pathForResource:ofType:"), + nsstring(name), + nsstring(ext), + ); + if path.is_null() { + return None; + } + let image = msg_send_id_id( + msg_send_id(class(c"NSImage"), sel(c"alloc")), + sel(c"initWithContentsOfFile:"), + path, + ); + if image.is_null() { + return None; + } + // Render at menu bar height; 22pt is the full status bar icon height. + msg_send_void_size(image, sel(c"setSize:"), size_pt, size_pt); + Some(image) +} + +const MENUBAR_ICON_SIZE: c_double = 22.0; + +unsafe fn menu(items: &[Id]) -> Id { + let menu = msg_send_id(msg_send_id(class(c"NSMenu"), sel(c"alloc")), sel(c"init")); + for item in items { + msg_send_void_id(menu, sel(c"addItem:"), *item); + } + menu +} + +unsafe fn menu_item(title: &CStr, action: &CStr) -> Id { + msg_send_id_id_sel_id( + msg_send_id(class(c"NSMenuItem"), sel(c"alloc")), + sel(c"initWithTitle:action:keyEquivalent:"), + nsstring(title), + sel(action), + nsstring(c""), + ) +} + +unsafe fn separator_item() -> Id { + msg_send_id(class(c"NSMenuItem"), sel(c"separatorItem")) +} + +unsafe fn menu_items(menu: Id) -> Vec { + let count = msg_send_usize(menu, sel(c"numberOfItems")); + (0..count) + .map(|idx| msg_send_id_usize(menu, sel(c"itemAtIndex:"), idx)) + .collect() +} + +unsafe fn new_delegate() -> Id { + let class = delegate_class(); + msg_send_id(msg_send_id(class, sel(c"alloc")), sel(c"init")) +} + +fn delegate_class() -> Class { + static CLASS: OnceLock = OnceLock::new(); + + *CLASS.get_or_init(|| unsafe { + let superclass = class(c"NSObject"); + let class_name = CString::new("LanMouseStatusItemDelegate").unwrap(); + let class = objc_allocateClassPair(superclass, class_name.as_ptr(), 0); + assert!(!class.is_null(), "failed to allocate status item delegate"); + + class_addMethod( + class, + sel(c"showLanMouse:"), + show_lan_mouse as *const c_void, + c"v@:@".as_ptr(), + ); + class_addMethod( + class, + sel(c"quitLanMouse:"), + quit_lan_mouse as *const c_void, + c"v@:@".as_ptr(), + ); + objc_registerClassPair(class); + class as usize + }) as Class +} + +extern "C" fn show_lan_mouse(_this: Id, _cmd: Sel, _sender: Id) { + STATUS_ITEM.with(|item| { + let item = item.borrow(); + let Some(item) = item.as_ref() else { + return; + }; + if let Some(window) = item.window.upgrade() { + window.present(); + } + + unsafe { + let ns_app = msg_send_id(class(c"NSApplication"), sel(c"sharedApplication")); + msg_send_void_bool(ns_app, sel(c"activateIgnoringOtherApps:"), 1); + } + }); +} + +extern "C" fn quit_lan_mouse(_this: Id, _cmd: Sel, _sender: Id) { + STATUS_ITEM.with(|item| { + if let Some(app) = item.borrow().as_ref().and_then(|item| item.app.upgrade()) { + app.quit(); + } + }); +} + +unsafe fn class(name: &CStr) -> Class { + let class = objc_getClass(name.as_ptr()); + assert!(!class.is_null(), "missing Objective-C class {name:?}"); + class +} + +unsafe fn sel(name: &CStr) -> Sel { + sel_registerName(name.as_ptr()) +} + +unsafe fn nsstring(value: &CStr) -> Id { + msg_send_id_ptr( + class(c"NSString"), + sel(c"stringWithUTF8String:"), + value.as_ptr(), + ) +} + +#[link(name = "objc")] +extern "C" { + fn objc_allocateClassPair(superclass: Class, name: *const c_char, extra_bytes: usize) -> Class; + fn objc_getClass(name: *const c_char) -> Class; + fn objc_registerClassPair(class: Class); + fn sel_registerName(name: *const c_char) -> Sel; + fn class_addMethod(class: Class, name: Sel, imp: *const c_void, types: *const c_char) -> Bool; +} + +#[link(name = "AppKit", kind = "framework")] +extern "C" {} + +#[link(name = "objc")] +extern "C" { + #[link_name = "objc_msgSend"] + fn msg_send_id(receiver: Id, selector: Sel) -> Id; + #[link_name = "objc_msgSend"] + fn msg_send_id_f64(receiver: Id, selector: Sel, value: c_double) -> Id; + #[link_name = "objc_msgSend"] + fn msg_send_id_id_sel_id(receiver: Id, selector: Sel, a: Id, b: Sel, c: Id) -> Id; + #[link_name = "objc_msgSend"] + fn msg_send_id_id_id(receiver: Id, selector: Sel, a: Id, b: Id) -> Id; + #[link_name = "objc_msgSend"] + fn msg_send_id_id(receiver: Id, selector: Sel, a: Id) -> Id; + #[link_name = "objc_msgSend"] + fn msg_send_void_size(receiver: Id, selector: Sel, width: c_double, height: c_double); + #[link_name = "objc_msgSend"] + fn msg_send_id_ptr(receiver: Id, selector: Sel, value: *const c_char) -> Id; + #[link_name = "objc_msgSend"] + fn msg_send_id_usize(receiver: Id, selector: Sel, value: usize) -> Id; + #[link_name = "objc_msgSend"] + fn msg_send_usize(receiver: Id, selector: Sel) -> usize; + #[link_name = "objc_msgSend"] + fn msg_send_void_bool(receiver: Id, selector: Sel, value: Bool); + #[link_name = "objc_msgSend"] + fn msg_send_void_id(receiver: Id, selector: Sel, value: Id); + #[link_name = "objc_msgSend"] + fn msg_send_bool_usize(receiver: Id, selector: Sel, value: usize) -> Bool; +} diff --git a/scripts/copy-macos-dylib.sh b/scripts/copy-macos-dylib.sh index f2ba501..84fb4d9 100755 --- a/scripts/copy-macos-dylib.sh +++ b/scripts/copy-macos-dylib.sh @@ -43,6 +43,9 @@ bundle_path=$(dirname "$(dirname "$(dirname "$exec_path")")") # Path to the Frameworks directory fwks_path="$bundle_path/Contents/Frameworks" mkdir -p "$fwks_path" +# Path to bundled GTK/GSettings data +resources_path="$bundle_path/Contents/Resources" +share_path="$resources_path/share" # Copy and fix references for a binary (executable or dylib) # @@ -58,6 +61,10 @@ fix_references() { libs=$(otool -L "$bin" | awk -v homebrew="$homebrew_path" '$0 ~ homebrew {print $1}') echo "$libs" | while IFS= read -r old_path; do + if [ -z "$old_path" ]; then + continue + fi + local base_name="$(basename "$old_path")" local dest="$fwks_path/$base_name" @@ -81,6 +88,42 @@ fix_references() { fix_references "$exec_path" +copy_runtime_data() { + mkdir -p "$share_path" + + if [ -d "$homebrew_path/share/glib-2.0/schemas" ]; then + mkdir -p "$share_path/glib-2.0" + rm -rf "$share_path/glib-2.0/schemas" + cp -RL "$homebrew_path/share/glib-2.0/schemas" "$share_path/glib-2.0/schemas" + if command -v glib-compile-schemas >/dev/null 2>&1; then + glib-compile-schemas "$share_path/glib-2.0/schemas" + elif [ -x "$homebrew_path/bin/glib-compile-schemas" ]; then + "$homebrew_path/bin/glib-compile-schemas" "$share_path/glib-2.0/schemas" + fi + fi + + if [ -d "$homebrew_path/share/gtk-4.0" ]; then + rm -rf "$share_path/gtk-4.0" + cp -RL "$homebrew_path/share/gtk-4.0" "$share_path/gtk-4.0" + fi + + if [ -d "$homebrew_path/share/icons/Adwaita" ]; then + mkdir -p "$share_path/icons" + rm -rf "$share_path/icons/Adwaita" + cp -RL "$homebrew_path/share/icons/Adwaita" "$share_path/icons/Adwaita" + fi +} + +copy_runtime_data + +# cargo-bundle preserves the source path under Contents/Resources (so +# `target/menubar-template.png` lands at `Resources/target/...`). Flatten it +# so NSBundle pathForResource: finds the file at the Resources root. +if [ -f "$resources_path/target/menubar-template.png" ]; then + mv "$resources_path/target/menubar-template.png" "$resources_path/menubar-template.png" + rmdir "$resources_path/target" 2>/dev/null || true +fi + # Ensure the main executable has our Frameworks path in its RPATH if ! otool -l "$exec_path" | grep -q "@executable_path/../Frameworks"; then echo "Adding RPATH to $exec_path" diff --git a/scripts/makeicns.sh b/scripts/makeicns.sh index d9cfbbc..a820898 100755 --- a/scripts/makeicns.sh +++ b/scripts/makeicns.sh @@ -67,12 +67,13 @@ magick -size ${CANVAS}x${CANVAS} xc:none \ # 3) Composite the artwork onto the background, centered inside the content area. magick "$workdir/background.png" \ "$workdir/content.png" -geometry +${CONTENT_OFFSET}+${CONTENT_OFFSET} -composite \ - "$workdir/icon-1024.png" + -colorspace sRGB -type TrueColorAlpha PNG32:"$workdir/icon-1024.png" # 4) Generate each iconset size from the master so all sizes share the same # squircle proportions and look consistent at every resolution. for size in 1024 512 256 128 64 32 16; do - magick "$workdir/icon-1024.png" -resize ${size}x${size} "$workdir/${size}.png" + magick "$workdir/icon-1024.png" -resize ${size}x${size} \ + -colorspace sRGB -type TrueColorAlpha PNG32:"$workdir/${size}.png" done cp "$workdir/1024.png" "$iconset"/icon_512x512@2x.png @@ -86,4 +87,51 @@ cp "$workdir/32.png" "$iconset"/icon_32x32.png cp "$workdir/32.png" "$iconset"/icon_16x16@2x.png cp "$workdir/16.png" "$iconset"/icon_16x16.png -iconutil -c icns "$iconset" -o "$icns" +mkdir -p "$(dirname "$icns")" + +# Menu bar template icon: flatten all RGB channels to 0 (black) while keeping +# alpha so the artwork reads as a clean silhouette. NSStatusBarButton tints +# template images to match the menu bar appearance in light and dark modes. +menubar_template="$(dirname "$icns")/menubar-template.png" +rsvg-convert -w 44 -h 44 "$svg" -o "$workdir/menubar-44.png" +magick "$workdir/menubar-44.png" -channel RGB -evaluate set 0 +channel \ + "$menubar_template" + +if ! iconutil -c icns "$iconset" -o "$icns"; then + if ! command -v perl >/dev/null 2>&1; then + echo "iconutil failed and perl is not available for the fallback icns writer" >&2 + exit 1 + fi + + echo "iconutil rejected the iconset; writing icns directly" >&2 + perl - "$icns" "$iconset" <<'PERL' +use strict; +use warnings; + +my ($icns, $iconset) = @ARGV; +my @icons = ( + [ 'icp4', "$iconset/icon_16x16.png" ], + [ 'ic11', "$iconset/icon_16x16\@2x.png" ], + [ 'icp5', "$iconset/icon_32x32.png" ], + [ 'ic12', "$iconset/icon_32x32\@2x.png" ], + [ 'ic07', "$iconset/icon_128x128.png" ], + [ 'ic13', "$iconset/icon_128x128\@2x.png" ], + [ 'ic08', "$iconset/icon_256x256.png" ], + [ 'ic14', "$iconset/icon_256x256\@2x.png" ], + [ 'ic09', "$iconset/icon_512x512.png" ], + [ 'ic10', "$iconset/icon_512x512\@2x.png" ], +); + +my $body = ''; +for my $icon (@icons) { + my ($type, $path) = @$icon; + open my $fh, '<:raw', $path or die "$path: $!"; + local $/; + my $png = <$fh>; + $body .= $type . pack('N', length($png) + 8) . $png; +} + +open my $out, '>:raw', $icns or die "$icns: $!"; +print {$out} 'icns' . pack('N', length($body) + 8) . $body; +PERL +fi