Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions macos/DockTilePlugIn/DockTilePlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import AppKit

/// This class lives as long as the app is in the Dock.
/// If the user pins the app to the Dock, it will not be deallocated.
/// Be careful when storing state in this class.
class DockTilePlugin: NSObject, NSDockTilePlugIn {
private let pluginBundle = Bundle(for: DockTilePlugin.self)
#if DEBUG
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug")
#else
private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty")
#endif

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: Ghostty.Notification.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 {
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 {
url.deleteLastPathComponent()
}
return url.path
}

func iconDidChange(_ newIcon: Ghostty.CustomAppIcon?, dockTile: NSDockTile) {
guard let appIcon = newIcon?.image(in: pluginBundle) else {
resetIcon(dockTile: dockTile)
return
}
let appBundlePath = getGhosttyAppPath()
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)

dockTile.setIcon(appIcon)
}

func resetIcon(dockTile: NSDockTile) {
let appBundlePath = getGhosttyAppPath()
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.
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) {
appIcon = NSImage(size: dockTile.size)
appIcon.addRepresentation(iconRep)
} else {
// If something unexpected happens on macOS 26,
// fall back to a bundled icon.
appIcon = pluginBundle.image(forResource: "AppIconImage")!
}
#endif
} else {
// Use the bundled icon to keep the corner radius
// consistent with other apps.
appIcon = pluginBundle.image(forResource: "AppIconImage")!
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
}
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
dockTile.setIcon(appIcon)
}
}

private extension NSDockTile {
func setIcon(_ newIcon: NSImage) {
// Update the Dock tile on the main thread.
DispatchQueue.main.async {
let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size))
iconView.wantsLayer = true
iconView.image = newIcon
self.contentView = iconView
self.display()
}
}
}

extension NSDockTile: @unchecked @retroactive Sendable {}

#if DEBUG
private extension NSAlert {
static func notify(_ message: String, image: NSImage?) {
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = message
alert.icon = image
_ = alert.runModal()
}
}
}
#endif

2 changes: 2 additions & 0 deletions macos/Ghostty-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSDockTilePlugIn</key>
<string>DockTilePlugin.plugin</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
Expand Down
Loading