From 06084cd840daa053d80e56c78b03490b36dd67cd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 10:37:46 -0800 Subject: [PATCH] macos: various dock tile cleanups --- .../Custom App Icon/DockTilePlugin.swift | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift index a3e094f96..6c5abc198 100644 --- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -17,26 +17,8 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { private var iconChangeObserver: Any? - func setDockTile(_ dockTile: NSDockTile?) { - guard let dockTile, let ghosttyUserDefaults else { - iconChangeObserver = nil - return - } - // Try to restore the previous icon on launch. - iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) - - iconChangeObserver = DistributedNotificationCenter.default().publisher(for: .ghosttyIconDidChange) - .map { [weak self] _ in - self?.ghosttyUserDefaults?.appIcon - } - .receive(on: DispatchQueue.global()) - .sink { [weak self] newIcon in - guard let self else { return } - iconDidChange(newIcon, dockTile: dockTile) - } - } - - func getGhosttyAppPath() -> String { + /// The path to the Ghostty.app, determined based on the bundle path of this plugin. + var ghosttyAppPath: String { var url = pluginBundle.bundleURL // Remove "/Contents/PlugIns/DockTilePlugIn.bundle" from the bundle URL to reach Ghostty.app. while url.lastPathComponent != "Ghostty.app", !url.lastPathComponent.isEmpty { @@ -45,31 +27,59 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { return url.path } - func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { + /// The primary NSDockTilePlugin function. + func setDockTile(_ dockTile: NSDockTile?) { + // If no dock tile or no access to Ghostty defaults, we can't do anything. + guard let dockTile, let ghosttyUserDefaults else { + iconChangeObserver = nil + return + } + + // Try to restore the previous icon on launch. + iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) + + // Setup a new observer for when the icon changes so we can update. This message + // is sent by the primary Ghostty app. + iconChangeObserver = DistributedNotificationCenter + .default() + .publisher(for: .ghosttyIconDidChange) + .map { [weak self] _ in self?.ghosttyUserDefaults?.appIcon } + .receive(on: DispatchQueue.global()) + .sink { [weak self] newIcon in self?.iconDidChange(newIcon, dockTile: dockTile) } + } + + private func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { guard let appIcon = newIcon?.image(in: pluginBundle) else { resetIcon(dockTile: dockTile) return } - let appBundlePath = getGhosttyAppPath() + + let appBundlePath = self.ghosttyAppPath NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } - func resetIcon(dockTile: NSDockTile) { - let appBundlePath = getGhosttyAppPath() + /// Reset the application icon and dock tile icon to the default. + private func resetIcon(dockTile: NSDockTile) { + let appBundlePath = self.ghosttyAppPath let appIcon: NSImage if #available(macOS 26.0, *) { // Reset to the default (glassy) icon. NSWorkspace.shared.setIcon(nil, forFile: appBundlePath) + #if DEBUG - // Use the `Blueprint` icon to - // distinguish Debug from Release builds. + // Use the `Blueprint` icon to distinguish Debug from Release builds. appIcon = pluginBundle.image(forResource: "BlueprintImage")! #else // Get the composed icon from the app bundle. - if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath).bestRepresentation(for: CGRect(origin: .zero, size: dockTile.size), context: nil, hints: nil) { + if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath) + .bestRepresentation( + for: CGRect(origin: .zero, size: dockTile.size), + context: nil, + hints: nil + ) { appIcon = NSImage(size: dockTile.size) appIcon.addRepresentation(iconRep) } else { @@ -79,12 +89,12 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { } #endif } else { - // Use the bundled icon to keep the corner radius - // consistent with other apps. + // Use the bundled icon to keep the corner radius consistent with pre-Tahoe apps. appIcon = pluginBundle.image(forResource: "AppIconImage")! NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) } + // Notify Finder/Dock so icon caches refresh immediately. NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } @@ -103,4 +113,6 @@ private extension NSDockTile { } } +// This is required because of the DispatchQueue call above. This doesn't +// feel right but I don't know a better way to solve this. extension NSDockTile: @unchecked @retroactive Sendable {}