macos: DockTilePlugin finds app bundle via .app suffix

Fixes #11029 (probably)

If you renamed the app bundle, the prior check would infinite loop due
to the combination of two bugs: invalid termination checks and
hardcoding "Ghostty.app"
This commit is contained in:
Mitchell Hashimoto
2026-02-26 19:58:21 -08:00
parent f19d847f95
commit 3ee63035d3

View File

@@ -17,14 +17,30 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
private var iconChangeObserver: Any?
/// 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 {
url.deleteLastPathComponent()
/// 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
}
return url.path
}
/// The primary NSDockTilePlugin function.
@@ -54,27 +70,32 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
return
}
let appBundlePath = self.ghosttyAppPath
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
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.ghosttyAppPath
let appBundlePath = self.ghosttyAppURL?.path
let appIcon: NSImage
if #available(macOS 26.0, *) {
// Reset to the default (glassy) icon.
NSWorkspace.shared.setIcon(nil, forFile: appBundlePath)
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")!
#else
// Get the composed icon from the app bundle.
if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath)
if let appBundlePath,
let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath)
.bestRepresentation(
for: CGRect(origin: .zero, size: dockTile.size),
context: nil,
@@ -91,11 +112,15 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
} else {
// 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)
if let appBundlePath {
NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath)
}
}
// Notify Finder/Dock so icon caches refresh immediately.
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
if let appBundlePath {
NSWorkspace.shared.noteFileSystemChanged(appBundlePath)
}
dockTile.setIcon(appIcon)
}
}