macOS: fix App Icon update in Finder (#12344)

Looks like `NSWorkspace.shared.setIcon` can only be called from the main
App, DockTilePlugin is sandboxed and doesn't have the permission to
`file-write-finderinfo`.

<img width="1186" height="144" alt="image"
src="https://github.com/user-attachments/assets/e5ea4f1c-718c-493a-bda2-32787881881e"
/>


It works fine in debug, but not in release. This fixes #11489
This commit is contained in:
Mitchell Hashimoto
2026-04-20 11:52:43 -07:00
committed by GitHub
5 changed files with 30 additions and 62 deletions

View File

@@ -151,8 +151,7 @@ class AppDelegate: NSObject,
/// Signals
private var signals: [DispatchSourceSignal] = []
/// The custom app icon image that is currently in use.
@Published private(set) var appIcon: NSImage?
private let appIconUpdater = AppIconUpdater()
@MainActor private lazy var menuShortcutManager = Ghostty.MenuShortcutManager()
@@ -847,13 +846,8 @@ class AppDelegate: NSObject,
}
private func updateAppIcon(from config: Ghostty.Config) {
// Since this is called after `DockTilePlugin` has been running,
// clean it up here to trigger a correct update of the current config.
UserDefaults.ghostty.removeObject(forKey: "CustomGhosttyIcon")
DispatchQueue.global().async {
UserDefaults.ghostty.appIcon = AppIcon(config: config)
DistributedNotificationCenter.default()
.postNotificationName(.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true)
Task.detached {
await self.appIconUpdater.update(icon: AppIcon(config: config))
}
}

View File

@@ -2,7 +2,7 @@ import AppKit
import System
/// The icon style for the Ghostty App.
enum AppIcon: Equatable, Codable {
enum AppIcon: Equatable, Codable, Sendable {
case official
case blueprint
case chalkboard
@@ -84,3 +84,26 @@ enum AppIcon: Equatable, Codable {
}
}
}
#if !DOCK_TILE_PLUGIN
/// Making sure that `NSWorkspace.shared.setIcon` executes on only one thread at a time
actor AppIconUpdater {
func update(icon: AppIcon?) {
UserDefaults.ghostty.appIcon = icon
// Notify DockTilePlugin to update dock icon
DistributedNotificationCenter.default()
.postNotificationName(
.ghosttyIconDidChange,
object: nil,
userInfo: nil,
deliverImmediately: true,
)
NSWorkspace.shared.setIcon(
icon?.image(in: .main),
forFile: Bundle.main.bundlePath,
)
NSWorkspace.shared.noteFileSystemChanged(Bundle.main.bundlePath)
}
}
#endif

View File

@@ -4,12 +4,6 @@ extension View {
/// Returns the ghostty icon to use for views.
func ghosttyIconImage() -> Image {
#if os(macOS)
// If we have a specific icon set, then use that
if let delegate = NSApplication.shared.delegate as? AppDelegate,
let nsImage = delegate.appIcon {
return Image(nsImage: nsImage)
}
// Grab the icon from the running application. This is the best way
// I've found so far to get the proper icon for our current icon
// tinting and so on with macOS Tahoe

View File

@@ -17,32 +17,6 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
private var iconChangeObserver: Any?
/// The URL to the enclosing app bundle, determined from the plugin bundle path.
var ghosttyAppURL: URL? {
Self.appBundleURL(for: pluginBundle.bundleURL)
}
/// Determine the enclosing app bundle for the dock tile plugin bundle.
///
/// We intentionally avoid matching a specific bundle name (such as
/// "Ghostty.app") so renaming the app in Finder still works.
static func appBundleURL(for pluginBundleURL: URL) -> URL? {
var url = pluginBundleURL
while true {
if url.pathExtension.compare("app", options: .caseInsensitive) == .orderedSame {
return url
}
let parent = url.deletingLastPathComponent()
if parent.path == url.path {
// Safety stop: this should only happen at filesystem root.
return nil
}
url = parent
}
}
/// The primary NSDockTilePlugin function.
func setDockTile(_ dockTile: NSDockTile?) {
// If no dock tile or no access to Ghostty defaults, we can't do anything.
@@ -70,25 +44,13 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
return
}
if let appBundleURL = self.ghosttyAppURL {
let appBundlePath = appBundleURL.path
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
}
dockTile.setIcon(appIcon)
}
/// Reset the application icon and dock tile icon to the default.
private func resetIcon(dockTile: NSDockTile) {
let appBundlePath = self.ghosttyAppURL?.path
let appIcon: NSImage?
if #available(macOS 26.0, *) {
// Reset to the default (glassy) icon.
if let appBundlePath {
NSWorkspace.shared.setIcon(nil, forFile: appBundlePath)
}
#if DEBUG
// Use the `Blueprint` icon to distinguish Debug from Release builds.
appIcon = pluginBundle.image(forResource: "BlueprintImage")!
@@ -99,14 +61,6 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
} else {
// Use the bundled icon to keep the corner radius consistent with pre-Tahoe apps.
appIcon = pluginBundle.image(forResource: "AppIconImage")!
if let appBundlePath {
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
}
}
// Notify Finder/Dock so icon caches refresh immediately.
if let appBundlePath {
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
}
dockTile.setIcon(appIcon)
}

View File

@@ -1,5 +1,8 @@
import AppKit
extension Notification.Name {
/// Distributed Notification for DockTilePlugin to update icon
///
/// Ghostty -> DockTilePlugin
static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange")
}