Skip to content

Commit f6b98b9

Browse files
committed
macOS: use NSDockTilePlugIn to update app icons
1 parent 3a89c8a commit f6b98b9

File tree

12 files changed

+593
-152
lines changed

12 files changed

+593
-152
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import AppKit
2+
3+
/// This class lives as long as the app is in the Dock.
4+
/// If the user pins the app to the Dock, it will not be deallocated.
5+
/// Be careful when storing state in this class.
6+
class DockTilePlugin: NSObject, NSDockTilePlugIn {
7+
private let pluginBundle = Bundle(for: DockTilePlugin.self)
8+
#if DEBUG
9+
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug")
10+
#else
11+
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty")
12+
#endif
13+
14+
private var iconChangeObserver: Any?
15+
16+
func setDockTile(_ dockTile: NSDockTile?) {
17+
guard let dockTile, let ghosttyUserDefaults else {
18+
iconChangeObserver = nil
19+
return
20+
}
21+
// Try to restore the previous icon on launch.
22+
iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile)
23+
24+
iconChangeObserver = DistributedNotificationCenter.default().publisher(for: Ghostty.Notification.ghosttyIconDidChange)
25+
.map { [weak self] _ in
26+
self?.ghosttyUserDefaults?.appIcon
27+
}
28+
.receive(on: DispatchQueue.global())
29+
.sink { [weak self] newIcon in
30+
guard let self else { return }
31+
iconDidChange(newIcon, dockTile: dockTile)
32+
}
33+
}
34+
35+
func getGhosttyAppPath() -> String {
36+
var url = pluginBundle.bundleURL
37+
// Remove "/Contents/PlugIns/DockTilePlugIn.bundle" from the bundle URL to reach Ghostty.app.
38+
while url.lastPathComponent != "Ghostty.app", !url.lastPathComponent.isEmpty {
39+
url.deleteLastPathComponent()
40+
}
41+
return url.path
42+
}
43+
44+
func iconDidChange(_ newIcon: Ghostty.CustomAppIcon?, dockTile: NSDockTile) {
45+
guard let appIcon = newIcon?.image(in: pluginBundle) else {
46+
resetIcon(dockTile: dockTile)
47+
return
48+
}
49+
let appBundlePath = getGhosttyAppPath()
50+
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
51+
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
52+
53+
dockTile.setIcon(appIcon)
54+
}
55+
56+
func resetIcon(dockTile: NSDockTile) {
57+
let appBundlePath = getGhosttyAppPath()
58+
let appIcon: NSImage
59+
if #available(macOS 26.0, *) {
60+
// Reset to the default (glassy) icon.
61+
NSWorkspace.shared.setIcon(nil, forFile: appBundlePath)
62+
#if DEBUG
63+
// Use the `Blueprint` icon to
64+
// distinguish Debug from Release builds.
65+
appIcon = pluginBundle.image(forResource: "BlueprintImage")!
66+
#else
67+
// Get the composed icon from the app bundle.
68+
if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath).bestRepresentation(for: CGRect(origin: .zero, size: dockTile.size), context: nil, hints: nil) {
69+
appIcon = NSImage(size: dockTile.size)
70+
appIcon.addRepresentation(iconRep)
71+
} else {
72+
// If something unexpected happens on macOS 26,
73+
// fall back to a bundled icon.
74+
appIcon = pluginBundle.image(forResource: "AppIconImage")!
75+
}
76+
#endif
77+
} else {
78+
// Use the bundled icon to keep the corner radius
79+
// consistent with other apps.
80+
appIcon = pluginBundle.image(forResource: "AppIconImage")!
81+
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
82+
}
83+
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
84+
dockTile.setIcon(appIcon)
85+
}
86+
}
87+
88+
private extension NSDockTile {
89+
func setIcon(_ newIcon: NSImage) {
90+
// Update the Dock tile on the main thread.
91+
DispatchQueue.main.async {
92+
let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size))
93+
iconView.wantsLayer = true
94+
iconView.image = newIcon
95+
self.contentView = iconView
96+
self.display()
97+
}
98+
}
99+
}
100+
101+
extension NSDockTile: @unchecked @retroactive Sendable {}
102+
103+
#if DEBUG
104+
private extension NSAlert {
105+
static func notify(_ message: String, image: NSImage?) {
106+
DispatchQueue.main.async {
107+
let alert = NSAlert()
108+
alert.messageText = message
109+
alert.icon = image
110+
_ = alert.runModal()
111+
}
112+
}
113+
}
114+
#endif
115+

macos/Ghostty-Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>NSDockTilePlugIn</key>
6+
<string>DockTilePlugin.plugin</string>
57
<key>CFBundleDocumentTypes</key>
68
<array>
79
<dict>

0 commit comments

Comments
 (0)