diff --git a/Cargo.lock b/Cargo.lock index 6f69461..08ac5cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1663,6 +1663,7 @@ dependencies = [ "gpui_automation", "libc", "log", + "objc2 0.6.4", "png 0.18.1", "pretty_assertions", "resvg 0.46.0", diff --git a/crates/dv/Cargo.toml b/crates/dv/Cargo.toml index b5feba1..5737451 100644 --- a/crates/dv/Cargo.toml +++ b/crates/dv/Cargo.toml @@ -47,6 +47,7 @@ gui = [ "dep:gpui", "dep:gpui_platform", "dep:libc", + "dep:objc2", "dep:velopack", "dep:windows-sys", ] @@ -118,6 +119,9 @@ gpui_platform = { workspace = true, optional = true, features = ["wayland", "x11 # Metal toolchain (Xcode's is a separate download). [target.'cfg(target_os = "macos")'.dependencies] gpui_platform = { workspace = true, optional = true, features = ["font-kit", "runtime_shaders"] } +# The app's icon in the Dock and the app switcher when it runs outside an app bundle +# (`dock_icon.rs`, PLAN §13.1): the app's, never the headless build's. +objc2 = { workspace = true, optional = true } # dv.exe's and dv-gui.exe's icon and version resource on Windows (`build.rs`); a no-op elsewhere. [build-dependencies] diff --git a/crates/dv/src/app.rs b/crates/dv/src/app.rs index e655f5e..76b2332 100644 --- a/crates/dv/src/app.rs +++ b/crates/dv/src/app.rs @@ -159,6 +159,9 @@ fn start(startup: StartOptions, cx: &mut App) -> Result<()> { // An installed copy's updates, in the background (PLAN D9); none under automation. #[cfg(any(windows, target_os = "macos"))] crate::updates::start(automation, main.shell.clone(), cx); + // A development build has no bundle to take its Dock icon from. + #[cfg(target_os = "macos")] + crate::dock_icon::set_unless_bundled(); // A background run must never take the foreground from the person at the machine. if !dv_ui::automation::is_background() { cx.activate(true); diff --git a/crates/dv/src/dock_icon.rs b/crates/dv/src/dock_icon.rs new file mode 100644 index 0000000..64377a3 --- /dev/null +++ b/crates/dv/src/dock_icon.rs @@ -0,0 +1,75 @@ +//! The app's icon in the Dock and the app switcher on macOS when dv runs outside an app bundle +//! (a development build, `cargo run`, `target/release/dv`; PLAN §13.1). AppKit takes an app's +//! icon from its bundle's `CFBundleIconFile`, so a bare executable shows the generic one; this +//! hands AppKit the bundled icon itself (`-[NSApplication setApplicationIconImage:]`), the same +//! `resources/macos/dv.icns` the packaged app's bundle carries. A packaged build runs from its +//! bundle and is left alone. + +use std::ffi::{CStr, c_char, c_void}; + +use objc2::runtime::AnyObject; +use objc2::{class, msg_send}; + +/// The icon the packaged app's bundle carries (`script/package`). +const ICNS: &[u8] = include_bytes!("../resources/macos/dv.icns"); + +/// Gives the app the bundled icon when it isn't running from an app bundle (module docs). +/// Nothing happens when AppKit can't read it. +pub fn set_unless_bundled() { + // SAFETY: plain AppKit and Foundation messages on the main thread, where gpui runs the app; + // every object returned is checked for nil before use, and `ICNS` outlives the copy + // `dataWithBytes:length:` makes of it. + unsafe { + let bundle: *mut AnyObject = msg_send![class!(NSBundle), mainBundle]; + if bundle.is_null() { + return; + } + let path: *mut AnyObject = msg_send![bundle, bundlePath]; + if !path.is_null() { + let utf8: *const c_char = msg_send![path, UTF8String]; + if !utf8.is_null() && is_app_bundle(&CStr::from_ptr(utf8).to_string_lossy()) { + return; + } + } + let data: *mut AnyObject = msg_send![ + class!(NSData), + dataWithBytes: ICNS.as_ptr().cast::(), + length: ICNS.len() + ]; + if data.is_null() { + return; + } + let image: *mut AnyObject = msg_send![class!(NSImage), alloc]; + let image: *mut AnyObject = msg_send![image, initWithData: data]; + if image.is_null() { + log::warn!("AppKit couldn't read the app icon"); + return; + } + let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + let _: () = msg_send![app, setApplicationIconImage: image]; + } +} + +/// Whether `bundle_path` (`-[NSBundle bundlePath]` of the main bundle) is an app bundle: for a +/// bare executable it is the executable's folder. +fn is_app_bundle(bundle_path: &str) -> bool { + bundle_path.trim_end_matches('/').ends_with(".app") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_an_app_bundle_keeps_its_own_icon() { + assert!(is_app_bundle("/Applications/dv.app")); + assert!(is_app_bundle("/Applications/dv.app/")); + assert!(!is_app_bundle("/Users/kyle/code/dv/target/release")); + assert!(!is_app_bundle("/Users/kyle/apps")); + } + + #[test] + fn the_embedded_icon_is_an_icns_file() { + assert_eq!(&ICNS[..4], b"icns"); + } +} diff --git a/crates/dv/src/lib.rs b/crates/dv/src/lib.rs index 73fa415..4f69709 100644 --- a/crates/dv/src/lib.rs +++ b/crates/dv/src/lib.rs @@ -25,6 +25,8 @@ mod cli; mod crash; #[cfg(feature = "gui")] mod data_dir; +#[cfg(all(target_os = "macos", feature = "gui"))] +mod dock_icon; #[cfg(feature = "gui")] mod fatal; #[cfg(all(any(windows, target_os = "macos"), feature = "gui"))] diff --git a/docs/PLAN.md b/docs/PLAN.md index a8d1376..14afd44 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -2352,6 +2352,10 @@ setup**. `dv.exe`'s, and `--help` / `--version` show their text in an information box and exit 0. Both carry dv's icon (DESIGN §1.7) as resource 1, which gpui loads as the window's icon, and a version resource (`crates/dv/build.rs`, `embed-resource`, Windows only). + On macOS an app's Dock and app-switcher icon comes from its bundle, which only the packaged app + has, so a development build run outside one (`cargo run`, `target/release/dv`) gives AppKit the + bundle's icon itself at startup (`dock_icon.rs`: `crates/dv/resources/macos/dv.icns` through + `-[NSApplication setApplicationIconImage:]`), leaving a bundled app's alone. A Windows child inherits every inheritable handle of its parent, the caller's stdout pipe among them, so the first makes its own standard handles non-inheritable before it starts the second: a caller capturing the output (`$(dv open .)`, an agent's shell tool) sees its end at