diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48079012..821fa6ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,10 +71,35 @@ jobs: run: cargo xtask test # The app bundle, signed ad hoc: catches a broken Info.plist, icon or - # signature before a release does. + # signature before a release does. This runner's Xcode has no actool + # 26, so this is the bundle without the Icon Composer icon. - name: Bundle run: cargo xtask bundle + bundle-macos-26: + name: Bundle (macOS 26) + # The bundle with the Icon Composer icon: Xcode 26's actool compiles it + # into Assets.car, and the plist names it, which the job checks. + runs-on: macos-26 + steps: + - uses: actions/checkout@v5 + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97" + + - uses: Swatinem/rust-cache@v2 + + - name: Bundle + run: cargo xtask bundle + + - name: Icon catalog + run: | + app=target/bundle/disktree.app + test -f "$app/Contents/Resources/Assets.car" + test -f "$app/Contents/Resources/AppIcon.icns" + test "$(plutil -extract CFBundleIconName raw "$app/Contents/Info.plist")" = Icon + windows: name: Lint and test (Windows) runs-on: windows-2025 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ca399cd..775ae3f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,8 +126,10 @@ jobs: matrix: include: # `xtask bundle` names the zip after the machine it runs on, so - # each architecture builds on its own. - - { runner: macos-15, arch: aarch64 } + # each architecture builds on its own. macOS 26's Xcode compiles + # the Icon Composer icon into the bundle; there is no macOS 26 + # Intel runner, so that build ships the legacy icon alone. + - { runner: macos-26, arch: aarch64 } - { runner: macos-15-intel, arch: x86_64 } runs-on: ${{ matrix.runner }} env: diff --git a/AGENTS.md b/AGENTS.md index 0342b30c..ad3eae06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ make install # ~/.local: binary, desktop entry, icon make install PREFIX=/usr/local # system-wide (needs root) make uninstall make bundle # macOS: target/bundle/disktree.app and its zip +make dock-icons # macOS 26: redraw the Dock icon of an unbundled run make lint # rustfmt --check, then clippy --all-targets -D warnings make test # core and window-harness tests make ci # lint, then test @@ -37,6 +38,21 @@ On macOS `make install` installs `target/bundle/disktree.app` into `packaging/macos/` (the `Info.plist` template and the icon's SVG). The bundle identifier there keys every macOS permission grant; do not change it lightly. +The macOS icon has two sources in `packaging/macos/`, and each macOS reads +the one made for it. `AppIcon.svg` is the mark on Apple's legacy 824-in-1024 +tile; the bundle draws `AppIcon.icns` from it, which macOS 15 and earlier +draw literally. `AppIcon.icon` with `AppGlyph.svg` is the Icon Composer +package: when Xcode 26's actool is at hand `cargo xtask bundle` compiles it +into `Assets.car` and names it with `CFBundleIconName`, and macOS 26 draws +that with its own shape, glass edge and Dark, Clear and Tinted looks. Handed +only the `.icns`, macOS 26 normalizes it instead, and 26.6.2 has drawn such a +padded icns on a light plate, so a bundle without the catalog says so. A run +outside the bundle (`make run`, `cargo run`) has no icon for macOS to draw, +so `dock_icon.rs` paints one: `DockIcon.png`, or on macOS 26 +`DockIcon-glass.png`, the shipped icon as that macOS itself draws it, since +Liquid Glass is never applied to an image set at run time. `make dock-icons` +redraws both on a macOS 26 with Xcode 26; run it after either SVG changes. + `cargo xtask lint` is the gate. It must be green before anything is called done, and it must not fix anything: a red local run is the same signal CI gives. On Windows, where there is no make, run `cargo xtask lint`, `cargo xtask test` @@ -138,6 +154,7 @@ and `cargo build --release` directly; CI runs the gate on both systems. | the mosaic's painting or labels | `crates/disktree-app/src/treemap_view.rs` | | layout of a screen | `crates/disktree-app/src/views.rs` | | colours derived from the theme | `crates/disktree-app/src/palette.rs` | +| the macOS icon, in any of its looks | `packaging/macos/` — the SVGs and `AppIcon.icon`, then `make dock-icons` | ## Verification expectations diff --git a/Cargo.lock b/Cargo.lock index dde47dd2..98093cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,7 +1427,11 @@ dependencies = [ "dunce", "gpui-kit", "gpui-omarchy", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "rustc-hash 2.1.3", + "rustix", "tempfile", "windows-sys 0.61.2", ] diff --git a/Makefile b/Makefile index 75fc361b..c30e96e7 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ define check_install_path fi endef -.PHONY: help build run install install-scan uninstall bundle lint test test-ssh ci fmt clean +.PHONY: help build run install install-scan uninstall bundle dock-icons lint test test-ssh ci fmt clean help: @echo "disktree" @@ -58,6 +58,7 @@ help: @echo " make install install to $(PREFIX): binary, desktop entry, icon" @echo " (macOS: disktree.app into $(APPS))" @echo " make bundle macOS: build target/bundle/disktree.app and its zip" + @echo " make dock-icons macOS 26: redraw the Dock icon of an unbundled run" @echo " make uninstall remove what install put there" @echo " make lint rustfmt --check and clippy -D warnings" @echo " make test core and window-harness tests" @@ -153,5 +154,11 @@ endif bundle: $(CARGO) xtask bundle +# The Dock icon a run outside the bundle shows, in the flat look of macOS 15 +# and the Liquid Glass of macOS 26, which only that macOS can draw: needs +# macOS 26 and Xcode 26. Run after either SVG in packaging/macos changes. +dock-icons: + swift packaging/macos/render-dock-icons.swift + clean: $(CARGO) clean diff --git a/crates/disktree-app/Cargo.toml b/crates/disktree-app/Cargo.toml index 4bc758a2..bda218ac 100644 --- a/crates/disktree-app/Cargo.toml +++ b/crates/disktree-app/Cargo.toml @@ -22,6 +22,27 @@ dunce = "1.0.5" rustc-hash = { workspace = true } tempfile = "3" +[target.'cfg(target_os = "macos")'.dependencies] +# AppKit for the Dock icon of a run outside the app bundle (dock_icon.rs): +# the crates and versions GPUI already builds, with only the classes used. +objc2 = "0.6" +# The kernel release, which says which macOS this is whatever SDK linked it. +rustix = { version = "1.1", features = ["system"] } +objc2-app-kit = { version = "0.3", default-features = false, features = [ + "std", + "NSApplication", + "NSImage", + "NSResponder", +] } +objc2-foundation = { version = "0.3", default-features = false, features = [ + "std", + "NSBundle", + "NSData", + "NSGeometry", + "NSProcessInfo", + "NSString", +] } + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_System_Console"] } diff --git a/crates/disktree-app/src/dock_icon.rs b/crates/disktree-app/src/dock_icon.rs new file mode 100644 index 00000000..88a66294 --- /dev/null +++ b/crates/disktree-app/src/dock_icon.rs @@ -0,0 +1,113 @@ +//! The Dock icon of a run outside the app bundle. +//! +//! `make run`, `cargo run` and the bare release binary all start disktree +//! with no bundle around it, so macOS has no icon to draw and the Dock shows +//! the generic executable. `AppKit` lets a running app replace its Dock icon +//! with an image, so the app carries its own and paints it whenever the +//! bundle it runs in, if any, names none. +//! +//! The image is drawn literally. macOS 26 gives Liquid Glass only to a +//! bundle's Icon Composer icon and has no API to ask for it at run time, so +//! the glass is baked into the image instead: `DockIcon-glass.png` is the +//! shipped icon as macOS 26 itself draws it, rendered by +//! `packaging/macos/render-dock-icons.swift`. macOS 15 and earlier draw a +//! flat icon on Apple's legacy tile, and get `DockIcon.png`, which is that. + +#![allow( + unsafe_code, + reason = "one AppKit setter objc2 marks unsafe; the call says why it is \ + sound" +)] + +use objc2::{AllocAnyThread as _, MainThreadMarker}; +use objc2_app_kit::{NSApplication, NSImage}; +use objc2_foundation::{NSBundle, NSData, NSString}; + +const FLAT: &[u8] = include_bytes!("../../../packaging/macos/DockIcon.png"); +const GLASS: &[u8] = + include_bytes!("../../../packaging/macos/DockIcon-glass.png"); + +/// Give the Dock an icon unless the bundle the app runs in has one. +/// +/// For the main thread, once `AppKit` is up: the closure `Application::run` +/// calls back is that, and by then the Dock tile exists to paint. Anywhere +/// else this does nothing. +pub fn install() { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + if bundle_names_an_icon() { + return; + } + let data = NSData::with_bytes(embedded(draws_liquid_glass())); + let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) else { + return; + }; + let app = NSApplication::sharedApplication(mtm); + // SAFETY: the setter is main-thread only, which `mtm` proves, and takes + // an image AppKit retains for itself. + unsafe { app.setApplicationIconImage(Some(&image)) }; +} + +/// Whether the main bundle names an icon for macOS to draw. Outside a bundle +/// `NSBundle.mainBundle` is the executable's directory and names nothing. +fn bundle_names_an_icon() -> bool { + let bundle = NSBundle::mainBundle(); + ["CFBundleIconName", "CFBundleIconFile"].iter().any(|key| { + bundle + .objectForInfoDictionaryKey(&NSString::from_str(key)) + .is_some() + }) +} + +/// Whether this Mac draws app icons in Liquid Glass: macOS 26 and later, +/// which is Darwin 25. +/// +/// The kernel release, not `NSProcessInfo`: macOS 26 reports itself as 16.0 +/// to a binary linked against an older SDK, which is what a Mac with the +/// Command Line Tools or Xcode 16 builds. +fn draws_liquid_glass() -> bool { + darwin_major(&rustix::system::uname().release().to_string_lossy()) + .is_some_and(|major| major >= 25) +} + +/// The major version of a Darwin release such as `25.6.0`. +fn darwin_major(release: &str) -> Option { + release.split('.').next()?.parse().ok() +} + +/// The icon for the look this Mac gives its icons. +const fn embedded(liquid_glass: bool) -> &'static [u8] { + if liquid_glass { GLASS } else { FLAT } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Both files decode, and to the size the renderer promises: the Dock + /// draws at most 128 pt, twice that on a Retina display. + #[test] + fn both_dock_icons_are_512_pixel_pngs() { + for liquid_glass in [false, true] { + let bytes = embedded(liquid_glass); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n")); + let image = NSImage::initWithData( + NSImage::alloc(), + &NSData::with_bytes(bytes), + ) + .expect("a decodable PNG"); + let size = image.size(); + assert_eq!((size.width, size.height), (512., 512.)); + } + // The glass one is a render of the flat one, never the same file. + assert_ne!(embedded(true), embedded(false)); + } + + #[test] + fn darwin_25_is_the_first_to_draw_liquid_glass() { + assert_eq!(darwin_major("25.6.0"), Some(25)); + assert_eq!(darwin_major("24.6.0"), Some(24)); + assert_eq!(darwin_major(""), None); + } +} diff --git a/crates/disktree-app/src/main.rs b/crates/disktree-app/src/main.rs index 904a9319..2d396044 100644 --- a/crates/disktree-app/src/main.rs +++ b/crates/disktree-app/src/main.rs @@ -13,6 +13,8 @@ mod app_menu; mod appearance; +#[cfg(target_os = "macos")] +mod dock_icon; mod git; mod marks; mod palette; @@ -183,6 +185,8 @@ fn run() -> Result<()> { .run(move |cx| { gpui_omarchy::init(cx); app_menu::install(cx); + #[cfg(target_os = "macos")] + dock_icon::install(); let home = std::env::home_dir(); let native_look = appearance::follows_system(home.as_deref()); if native_look { diff --git a/packaging/macos/AppGlyph.svg b/packaging/macos/AppGlyph.svg new file mode 100644 index 00000000..171e6b27 --- /dev/null +++ b/packaging/macos/AppGlyph.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/macos/AppIcon.icon/icon.json b/packaging/macos/AppIcon.icon/icon.json new file mode 100644 index 00000000..496f6f42 --- /dev/null +++ b/packaging/macos/AppIcon.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill" : { + "solid" : "srgb:0.10196,0.10588,0.14902,1.00000" + }, + "groups" : [ + { + "name" : "Mark", + "layers" : [ + { + "name" : "glyph", + "image-name" : "glyph.png", + "fill" : "automatic", + "glass" : false, + "hidden" : false, + "blend-mode" : "normal" + } + ], + "lighting" : "individual", + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : false, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/packaging/macos/DockIcon-glass.png b/packaging/macos/DockIcon-glass.png new file mode 100644 index 00000000..0e205c55 Binary files /dev/null and b/packaging/macos/DockIcon-glass.png differ diff --git a/packaging/macos/DockIcon.png b/packaging/macos/DockIcon.png new file mode 100644 index 00000000..ccf997f9 Binary files /dev/null and b/packaging/macos/DockIcon.png differ diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 7e87cda5..18060bf2 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -13,8 +13,13 @@ disktree CFBundleExecutable disktree + CFBundleIconFile AppIcon + @ICON_CATALOG@ CFBundlePackageType APPL CFBundleInfoDictionaryVersion diff --git a/packaging/macos/render-dock-icons.swift b/packaging/macos/render-dock-icons.swift new file mode 100644 index 00000000..2de040ce --- /dev/null +++ b/packaging/macos/render-dock-icons.swift @@ -0,0 +1,266 @@ +#!/usr/bin/env swift + +// The Dock icon a run outside the app bundle shows, in both the looks a Mac +// can give it. `make dock-icons` runs this; the app embeds what it writes +// (`crates/disktree-app/src/dock_icon.rs`). +// +// packaging/macos/DockIcon.png AppIcon.svg as it is: the tile on +// Apple's 824-in-1024 legacy canvas, +// drawn literally by macOS 15 and +// earlier. +// packaging/macos/DockIcon-glass.png The same icon as macOS 26 draws it +// from AppIcon.icon: its own shape, +// the glass edge and the mark's shadow. +// +// macOS 26 gives Liquid Glass only to an app bundle's Icon Composer icon, +// never to an image set at runtime, and has no API to ask for it. So the +// glass is rendered here, by the system itself: AppIcon.icon is compiled with +// actool into a throwaway bundle, and what Finder and the Dock would draw for +// that bundle is read back through NSWorkspace. That draws in the icon style +// this Mac is set to, so the script refuses to run unless the Default style +// is on, and refuses on a Mac older than 26 or with an older actool. +// +// Both files are 512 px: the Dock draws at most 128 pt, twice that on a Retina +// display, and the glass render carries gradients that would make a 1024 px +// file four times the size for nothing. + +import AppKit +import Foundation + +let macosDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() +let tileSVG = macosDir.appendingPathComponent("AppIcon.svg") +let glyphSVG = macosDir.appendingPathComponent("AppGlyph.svg") +let package = macosDir.appendingPathComponent("AppIcon.icon", isDirectory: true) +let flatOut = macosDir.appendingPathComponent("DockIcon.png") +let glassOut = macosDir.appendingPathComponent("DockIcon-glass.png") + +/// The icon is rendered on the canvas the bundle's icons use, then scaled. +let canvas = 1024 +let output = 512 +/// Where the legacy tile sits on that canvas, as AppIcon.svg draws it. The +/// glass render must land there too, or the two files would not line up +/// between one macOS and the next. +let tileOrigin = 100 +let tileSize = 824 + +func fail(_ message: String) -> Never { + FileHandle.standardError.write("render-dock-icons: \(message)\n".data(using: .utf8)!) + exit(1) +} + +func run(_ executable: String, _ arguments: [String]) -> (status: Int32, output: Data) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { try process.run() } catch { fail("cannot run \(executable): \(error)") } + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (process.terminationStatus, output) +} + +// MARK: - Preconditions, before anything is written + +// The bundle's LSMinimumSystemVersion, which `cargo xtask bundle` compiles +// the package for too, so both compiles make the same catalog. Read from +// the template by hand: its placeholders keep it from parsing as a plist. +let plistTemplate = macosDir.appendingPathComponent("Info.plist.in") +guard let template = try? String(contentsOf: plistTemplate, encoding: .utf8), + let pattern = try? Regex( + #"LSMinimumSystemVersion\s*([^<]+)"#), + let match = template.firstMatch(of: pattern), + let version = match.output[1].substring +else { + fail("cannot read LSMinimumSystemVersion from \(plistTemplate.path)") +} +let deploymentTarget = String(version) + +let os = ProcessInfo.processInfo.operatingSystemVersion +guard os.majorVersion >= 26 else { + fail("the glass icon is drawn by macOS 26 or later; this is \(os.majorVersion).\(os.minorVersion)") +} +if NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency + || NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast +{ + fail("turn off Reduce Transparency and Increase Contrast (Accessibility > Display) first: icons render with them") +} +let actoolVersion = run("/usr/bin/xcrun", ["actool", "--version"]) +guard actoolVersion.status == 0, + let plist = try? PropertyListSerialization.propertyList(from: actoolVersion.output, format: nil) as? [String: Any], + let info = plist["com.apple.actool.version"] as? [String: Any], + let short = info["short-bundle-version"] as? String, + let major = Int(short.split(separator: ".").first ?? ""), major >= 26 +else { + fail("needs Xcode 26's actool; select it with xcode-select or DEVELOPER_DIR") +} + +// MARK: - Helpers + +func rasterize(_ svg: URL, to png: URL, pixels: Int) { + // The same tool `cargo xtask bundle` draws the iconset with, so the flat + // Dock icon is the 512 px rep of the shipped AppIcon.icns, pixel for pixel. + let result = run("/usr/bin/sips", ["-s", "format", "png", "-z", "\(pixels)", "\(pixels)", svg.path, "--out", png.path]) + guard result.status == 0 else { + fail("sips could not draw \(svg.lastPathComponent):\n\(String(decoding: result.output, as: UTF8.self))") + } +} + +func bitmap(_ pixels: Int) -> NSBitmapImageRep { + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { fail("cannot create a \(pixels) px bitmap") } + rep.size = NSSize(width: pixels, height: pixels) + return rep +} + +func draw(_ image: NSImage, at pixels: Int) -> NSBitmapImageRep { + let rep = bitmap(pixels) + NSGraphicsContext.saveGraphicsState() + guard let context = NSGraphicsContext(bitmapImageRep: rep) else { fail("cannot draw") } + NSGraphicsContext.current = context + context.imageInterpolation = .high + let frame = NSRect(x: 0, y: 0, width: pixels, height: pixels) + NSColor.clear.setFill() + frame.fill() + image.draw(in: frame, from: .zero, operation: .sourceOver, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + return rep +} + +/// The opaque bounds, top-down: (minX, minY, maxX, maxY). +func opaqueBounds(_ rep: NSBitmapImageRep) -> (Int, Int, Int, Int) { + var minX = rep.pixelsWide, minY = rep.pixelsHigh, maxX = -1, maxY = -1 + for y in 0.. 0.5 { + minX = min(minX, x); maxX = max(maxX, x); minY = min(minY, y); maxY = max(maxY, y) + } + } + return (minX, minY, maxX, maxY) +} + +func srgb(_ rep: NSBitmapImageRep, x: Int, y: Int) -> [Int] { + guard let c = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return [] } + return [c.redComponent, c.greenComponent, c.blueComponent, c.alphaComponent].map { Int(($0 * 255).rounded()) } +} + +func write(_ rep: NSBitmapImageRep, to url: URL) { + guard let data = rep.representation(using: .png, properties: [:]) else { fail("cannot encode \(url.lastPathComponent)") } + do { try data.write(to: url, options: .atomic) } catch { fail("cannot write \(url.path): \(error)") } + print(" \(url.lastPathComponent) (\(rep.pixelsWide) px, \(data.count) bytes)") +} + +// MARK: - The glass render + +let work = FileManager.default.temporaryDirectory + .appendingPathComponent("disktree-dock-icons-\(UUID().uuidString)", isDirectory: true) +defer { try? FileManager.default.removeItem(at: work) } + +/// What macOS draws for an app that ships AppIcon.icon. +/// +/// The bundle path and identifier are new for every run: icon services caches +/// by path, and a reused path returns whatever it drew there before. The +/// bundle gets a real executable, because one without is drawn with the +/// "cannot open" badge. +func renderGlass() -> NSImage { + let fm = FileManager.default + // actool resolves `--app-icon Icon` by the package's basename: under any + // other name it exits 0 and writes no icon at all. + let icon = work.appendingPathComponent("Icon.icon", isDirectory: true) + let compiled = work.appendingPathComponent("compiled", isDirectory: true) + let bundle = work.appendingPathComponent("disktree-\(UUID().uuidString).app", isDirectory: true) + let contents = bundle.appendingPathComponent("Contents", isDirectory: true) + let resources = contents.appendingPathComponent("Resources", isDirectory: true) + let executables = contents.appendingPathComponent("MacOS", isDirectory: true) + do { + try fm.createDirectory(at: work, withIntermediateDirectories: true) + try fm.copyItem(at: package, to: icon) + try fm.createDirectory(at: icon.appendingPathComponent("Assets"), withIntermediateDirectories: true) + for dir in [compiled, resources, executables] { + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + } catch { fail("cannot stage the render bundle: \(error)") } + rasterize(glyphSVG, to: icon.appendingPathComponent("Assets/glyph.png"), pixels: canvas) + + // The invocation `cargo xtask bundle` uses, so this is the catalog the + // shipped app carries. + let compile = run("/usr/bin/xcrun", [ + "actool", icon.path, + "--compile", compiled.path, + "--output-format", "human-readable-text", + "--notices", "--warnings", + "--output-partial-info-plist", compiled.appendingPathComponent("Info.plist").path, + "--app-icon", "Icon", + "--include-all-app-icons", + "--enable-on-demand-resources", "NO", + "--development-region", "en", + "--target-device", "mac", + "--minimum-deployment-target", deploymentTarget, + "--platform", "macosx", + ]) + let catalog = compiled.appendingPathComponent("Assets.car") + guard compile.status == 0, fm.fileExists(atPath: catalog.path) else { + fail("actool did not compile AppIcon.icon:\n\(String(decoding: compile.output, as: UTF8.self))") + } + let info: [String: Any] = [ + "CFBundleExecutable": "stub", + "CFBundleIconName": "Icon", + "CFBundleIdentifier": "io.github.tobi.disktree.dock-icon-render.\(UUID().uuidString)", + "CFBundleName": "disktree", + "CFBundlePackageType": "APPL", + ] + do { + try fm.copyItem(at: catalog, to: resources.appendingPathComponent("Assets.car")) + try fm.copyItem(at: URL(fileURLWithPath: "/usr/bin/true"), to: executables.appendingPathComponent("stub")) + let plist = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try plist.write(to: contents.appendingPathComponent("Info.plist")) + } catch { fail("cannot assemble the render bundle: \(error)") } + return NSWorkspace.shared.icon(forFile: bundle.path) +} + +// MARK: - Render, check, then write + +let flatPNG = work.appendingPathComponent("flat.png") +do { try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) } catch { fail("\(error)") } +rasterize(tileSVG, to: flatPNG, pixels: canvas) +guard let flatImage = NSImage(contentsOf: flatPNG) else { fail("cannot read the flat render") } +let flat = draw(flatImage, at: canvas) +let glass = draw(renderGlass(), at: canvas) + +// The glass tile must sit where the flat one does, within a pixel of +// antialiasing, so the Dock icon does not jump between macOS versions. +let expected = (tileOrigin, tileOrigin, tileOrigin + tileSize - 1, tileOrigin + tileSize - 1) +for (name, rep) in [("AppIcon.svg", flat), ("the glass render", glass)] { + let b = opaqueBounds(rep) + let drift = [b.0 - expected.0, b.1 - expected.1, b.2 - expected.2, b.3 - expected.3] + guard drift.allSatisfy({ abs($0) <= 1 }) else { + fail("\(name) covers \(b), not the \(expected) tile the Dock expects") + } +} + +// The Default icon style keeps the mark's own colours; Clear and Tinted +// replace them, and Dark darkens the tile. Two probes that hold whatever +// size AppGlyph.svg draws the mark at, as long as it is centred: the +// canvas centre, which is the mark's dark green block, and the tile 50 px +// in from its left edge, outside the mark and clear of the glass rim. +// Lighting shifts them a little; a style change replaces them. +for probe in [(x: 512, y: 512, label: "green block"), (x: 150, y: 512, label: "tile")] { + let authored = srgb(flat, x: probe.x, y: probe.y) + let rendered = srgb(glass, x: probe.x, y: probe.y) + guard authored.count == 4, rendered.count == 4, + zip(authored, rendered).allSatisfy({ abs($0 - $1) <= 32 }) + else { + fail("the glass \(probe.label) renders \(rendered), not \(authored): set System Settings > Appearance > Icon & widget style to Default") + } +} + +// sips at the output size, as the bundle's iconset is drawn, rather than a +// resample of the 1024 px check render. +rasterize(tileSVG, to: flatOut, pixels: output) +print(" \(flatOut.lastPathComponent) (\(output) px)") +let glassImage = NSImage(size: NSSize(width: canvas, height: canvas)) +glassImage.addRepresentation(glass) +write(draw(glassImage, at: output), to: glassOut) diff --git a/xtask/src/bundle.rs b/xtask/src/bundle.rs index 63e31134..44431e63 100644 --- a/xtask/src/bundle.rs +++ b/xtask/src/bundle.rs @@ -1,7 +1,8 @@ //! `cargo xtask bundle`: the macOS app, from a release build. //! //! Plain Apple tools and nothing to install: `sips` and `iconutil` draw the -//! icon from its SVG, `codesign` signs, `ditto` zips the way Finder does +//! icon from its SVG, `actool` compiles its Icon Composer package when +//! Xcode 26's is there, `codesign` signs, `ditto` zips the way Finder does //! (keeping the signature's extended attributes), and `notarytool` and //! `stapler` notarize. Everything lands in `target/bundle/`. //! @@ -112,11 +113,21 @@ pub fn bundle(args: &[String]) -> Result<(), String> { // Local symbols only: the backtrace of a panic still names functions. tool(Command::new("strip").arg("-x").arg(&binary))?; + icon( + &root.join("packaging/macos/AppIcon.svg"), + &out, + &resources.join("AppIcon.icns"), + )?; + let catalog = icon_catalog(&root, &out, &resources)?; + let plist = fs::read_to_string(root.join("packaging/macos/Info.plist.in")) .map_err(|err| format!("cannot read the Info.plist template: {err}"))?; write( &contents.join("Info.plist"), - &plist.replace("@VERSION@", version), + &plist.replace("@VERSION@", version).replace( + ICON_CATALOG_MARKER, + if catalog { ICON_CATALOG_KEYS } else { "" }, + ), )?; tool( Command::new("plutil") @@ -124,12 +135,6 @@ pub fn bundle(args: &[String]) -> Result<(), String> { .arg(contents.join("Info.plist")), )?; - icon( - &root.join("packaging/macos/AppIcon.svg"), - &out, - &resources.join("AppIcon.icns"), - )?; - sign(&app, options.identity.as_deref())?; let zip = out.join(format!( @@ -186,6 +191,114 @@ fn icon(svg: &Path, scratch: &Path, icns: &Path) -> Result<(), String> { ) } +/// The token in the plist template that the catalog's keys replace. Left +/// empty without a catalog, its line keeps only its indent. +const ICON_CATALOG_MARKER: &str = "@ICON_CATALOG@"; +/// What macOS 26 reads the icon by: the name actool gave it in `Assets.car`. +const ICON_CATALOG_KEYS: &str = + "CFBundleIconName\n\tIcon"; + +/// `Assets.car` from the Icon Composer package, when Xcode 26's actool is at +/// hand; whether it was made. +/// +/// macOS 26 draws an app's icon from this catalog: its own shape, the glass +/// edge, and the Dark, Clear and Tinted looks. Handed only a legacy `.icns` +/// it normalizes that instead, and 26.6.2 has drawn a padded one on a light +/// plate. macOS 15 and earlier never open the catalog and keep reading +/// `AppIcon.icns`, which has every size they draw; actool derives an icns +/// too, but with four sizes, so it is left where it was compiled. Without +/// that actool the bundle ships the icns alone, and says so: a Mac with the +/// Command Line Tools, or an older Xcode, still builds. +fn icon_catalog( + root: &Path, + scratch: &Path, + resources: &Path, +) -> Result { + match actool_version() { + Some(major) if major >= 26 => {} + found => { + let found = found.map_or_else( + || "no actool".to_owned(), + |major| format!("actool {major}"), + ); + println!( + "note: {found}; macOS 26 will draw AppIcon.icns without \ + Liquid Glass. Xcode 26 compiles packaging/macos/AppIcon.icon." + ); + return Ok(false); + } + } + + // actool resolves `--app-icon Icon` by the package's basename: under any + // other name it exits 0 and writes no icon at all. So the package is + // copied under that name, and gets its layer drawn from the SVG there. + let package = scratch.join("Icon.icon"); + let compiled = scratch.join("AppIcon.catalog"); + for dir in [&package, &compiled] { + if dir.exists() { + fs::remove_dir_all(dir).map_err(|err| { + format!("cannot clear {}: {err}", dir.display()) + })?; + } + } + let assets = package.join("Assets"); + for dir in [&assets, &compiled] { + fs::create_dir_all(dir) + .map_err(|err| format!("cannot create {}: {err}", dir.display()))?; + } + copy( + &root.join("packaging/macos/AppIcon.icon/icon.json"), + &package.join("icon.json"), + )?; + tool( + Command::new("sips") + .args(["-s", "format", "png", "-z", "1024", "1024"]) + .arg(root.join("packaging/macos/AppGlyph.svg")) + .arg("--out") + .arg(assets.join("glyph.png")) + .stdout(Stdio::null()), + )?; + // The invocation electron-builder settled on for the same package, so + // the catalog is the one other apps on the Mac ship. + tool( + Command::new("xcrun") + .arg("actool") + .arg(&package) + .arg("--compile") + .arg(&compiled) + .args(["--output-format", "human-readable-text"]) + .args(["--notices", "--warnings"]) + .arg("--output-partial-info-plist") + .arg(compiled.join("Info.plist")) + .args(["--app-icon", "Icon", "--include-all-app-icons"]) + .args(["--enable-on-demand-resources", "NO"]) + .args(["--development-region", "en"]) + .args(["--target-device", "mac"]) + .args(["--minimum-deployment-target", DEPLOYMENT_TARGET]) + .args(["--platform", "macosx"]), + )?; + copy(&compiled.join("Assets.car"), &resources.join("Assets.car"))?; + Ok(true) +} + +/// The major version of the actool `xcrun` finds, if it finds one. +fn actool_version() -> Option { + let output = Command::new("xcrun") + .args(["actool", "--version"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + // A plist, with the version under `short-bundle-version`. + let text = String::from_utf8_lossy(&output.stdout); + let after = text.split("short-bundle-version").nth(1)?; + let value = after.split("").nth(1)?.split("").next()?; + value.split('.').next()?.parse().ok() +} + /// Sign the bundle, then check the signature holds. /// /// A real identity gets the hardened runtime and a secure timestamp, both of @@ -336,4 +449,28 @@ mod tests { plist.contains(&format!("{DEPLOYMENT_TARGET}")) ); } + + /// The catalog's keys go in only when the catalog does: a bundle that + /// names an icon it does not carry shows none on macOS 26. + #[test] + fn the_plist_template_takes_the_catalog_keys_from_the_bundle() { + let plist = include_str!("../../packaging/macos/Info.plist.in"); + assert!(plist.contains(ICON_CATALOG_MARKER)); + assert!(!plist.contains("CFBundleIconName")); + let without = plist.replace(ICON_CATALOG_MARKER, ""); + assert!(!without.contains(ICON_CATALOG_MARKER)); + let with = plist.replace(ICON_CATALOG_MARKER, ICON_CATALOG_KEYS); + assert!(with.contains("CFBundleIconName\n\tIcon")); + } + + /// The package names the layer the bundle draws from the SVG, and fills + /// the tile with the mark's own background. + #[test] + fn the_icon_package_is_the_glyph_on_the_marks_background() { + let manifest = + include_str!("../../packaging/macos/AppIcon.icon/icon.json"); + assert!(manifest.contains(r#""image-name" : "glyph.png""#)); + // #1a1b26, as assets/disktree.svg paints it. + assert!(manifest.contains("srgb:0.10196,0.10588,0.14902,1.00000")); + } }