diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift index 6c5abc198..990cd8bb2 100644 --- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -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) } }