mirror of
https://github.com/feschber/lan-mouse.git
synced 2026-05-08 15:18:05 +03:00
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) <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1682,6 +1682,8 @@ dependencies = [
|
||||
"ashpd",
|
||||
"async-trait",
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"core-graphics",
|
||||
"futures",
|
||||
"input-event",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
</details>
|
||||
@@ -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)
|
||||
- `x11`: TODO (not yet supported)
|
||||
|
||||
6
build-aux/macos-lsui-element.plist
Normal file
6
build-aux/macos-lsui-element.plist
Normal file
@@ -0,0 +1,6 @@
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSInputMonitoringUsageDescription</key>
|
||||
<string>Lan Mouse needs Input Monitoring access to capture keyboard and mouse input and forward it to remote machines on your network.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Lan Mouse uses Apple Events to deliver synthesized keyboard and mouse events to the system.</string>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
283
lan-mouse-gtk/src/macos_status_item.rs
Normal file
283
lan-mouse-gtk/src/macos_status_item.rs
Normal file
@@ -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<adw::Application>,
|
||||
window: glib::WeakRef<Window>,
|
||||
_hold: gio::ApplicationHoldGuard,
|
||||
_delegate: Id,
|
||||
_status_item: Id,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static STATUS_ITEM: RefCell<Option<StatusItem>> = 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<Id> {
|
||||
load_resource_image(c"menubar-template", c"png", MENUBAR_ICON_SIZE)
|
||||
}
|
||||
|
||||
unsafe fn load_app_icon() -> Option<Id> {
|
||||
load_resource_image(c"icon", c"icns", MENUBAR_ICON_SIZE)
|
||||
}
|
||||
|
||||
unsafe fn load_resource_image(name: &CStr, ext: &CStr, size_pt: c_double) -> Option<Id> {
|
||||
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<Id> {
|
||||
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<usize> = 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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user