macOS: use NSDockTilePlugIn to update app icons

This commit is contained in:
Lukas
2025-12-20 16:08:16 +01:00
parent c51f0d745d
commit 45525a0a85
12 changed files with 600 additions and 149 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -10,6 +10,8 @@
29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; };
55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; };
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */ = {isa = PBXBuildFile; fileRef = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; };
A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
@@ -35,6 +37,13 @@
remoteGlobalIDString = A5B30530299BEAAA0047F10C;
remoteInfo = Ghostty;
};
819324672F2502FB00A9ED8F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
proxyType = 1;
remoteGlobalIDString = 8193244C2F24E6C000A9ED8F;
remoteInfo = DockTilePlugin;
};
A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
@@ -44,12 +53,27 @@
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
819324572F24E74E00A9ED8F /* Copy DockTilePlugin */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */,
);
name = "Copy DockTilePlugin";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
29C15B1C2CDC3B2000520DD4 /* bat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = bat; path = "../zig-out/share/bat"; sourceTree = "<group>"; };
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; };
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = "<group>"; };
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
@@ -70,11 +94,24 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/macOS/AppIcon.swift,
"Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift",
Ghostty/SharedPackage.swift,
Helpers/CrossKit.swift,
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/OSColor+Extension.swift",
);
target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */;
};
81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/macOS/AppDelegate.swift,
"App/macOS/AppDelegate+Ghostty.swift",
App/macOS/AppIcon.swift,
App/macOS/main.swift,
App/macOS/MainMenu.xib,
Features/About/About.xib,
@@ -208,7 +245,8 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = "<group>"; };
81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = "<group>"; };
8193245A2F24E7D000A9ED8F /* DockTilePlugIn */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DockTilePlugIn; sourceTree = "<group>"; };
81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = "<group>"; };
A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
@@ -220,6 +258,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
8193244A2F24E6C000A9ED8F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45F02E1F047A0046BD5C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -275,6 +320,7 @@
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */,
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */,
81F82BC72E82815D001EDFA7 /* Sources */,
8193245A2F24E7D000A9ED8F /* DockTilePlugIn */,
A54F45F42E1F047A0046BD5C /* Tests */,
810ACCA02E9D3302004F8F92 /* GhosttyUITests */,
A5D495A3299BECBA00DD1313 /* Frameworks */,
@@ -290,6 +336,7 @@
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */,
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */,
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */,
8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */,
);
name = Products;
sourceTree = "<group>";
@@ -329,6 +376,28 @@
productReference = 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
8193244C2F24E6C000A9ED8F /* DockTilePlugin */ = {
isa = PBXNativeTarget;
buildConfigurationList = 819324512F24E6C000A9ED8F /* Build configuration list for PBXNativeTarget "DockTilePlugin" */;
buildPhases = (
819324492F24E6C000A9ED8F /* Sources */,
8193244A2F24E6C000A9ED8F /* Frameworks */,
8193244B2F24E6C000A9ED8F /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
8193245A2F24E7D000A9ED8F /* DockTilePlugIn */,
);
name = DockTilePlugin;
packageProductDependencies = (
);
productName = DockTilePlugin;
productReference = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */;
productType = "com.apple.product-type.bundle";
};
A54F45F22E1F047A0046BD5C /* GhosttyTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */;
@@ -360,10 +429,12 @@
A5B3052D299BEAAA0047F10C /* Sources */,
A5B3052E299BEAAA0047F10C /* Frameworks */,
A5B3052F299BEAAA0047F10C /* Resources */,
819324572F24E74E00A9ED8F /* Copy DockTilePlugin */,
);
buildRules = (
);
dependencies = (
819324682F2502FB00A9ED8F /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
81F82BC72E82815D001EDFA7 /* Sources */,
@@ -410,6 +481,9 @@
CreatedOnToolsVersion = 26.1;
TestTargetID = A5B30530299BEAAA0047F10C;
};
8193244C2F24E6C000A9ED8F = {
CreatedOnToolsVersion = 26.2;
};
A54F45F22E1F047A0046BD5C = {
CreatedOnToolsVersion = 26.0;
TestTargetID = A5B30530299BEAAA0047F10C;
@@ -441,6 +515,7 @@
targets = (
A5B30530299BEAAA0047F10C /* Ghostty */,
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */,
8193244C2F24E6C000A9ED8F /* DockTilePlugin */,
A54F45F22E1F047A0046BD5C /* GhosttyTests */,
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */,
);
@@ -455,6 +530,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
8193244B2F24E6C000A9ED8F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45F12E1F047A0046BD5C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -523,6 +606,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
819324492F24E6C000A9ED8F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45EF2E1F047A0046BD5C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -552,6 +642,11 @@
target = A5B30530299BEAAA0047F10C /* Ghostty */;
targetProxy = 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */;
};
819324682F2502FB00A9ED8F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */;
targetProxy = 819324672F2502FB00A9ED8F /* PBXContainerItemProxy */;
};
A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A5B30530299BEAAA0047F10C /* Ghostty */;
@@ -684,7 +779,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
@@ -707,6 +802,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
@@ -729,6 +825,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
@@ -737,6 +834,90 @@
};
name = ReleaseLocal;
};
8193244E2F24E6C000A9ED8F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DOCK_TILE_PLUGIN DEBUG";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = plugin;
};
name = Debug;
};
8193244F2F24E6C000A9ED8F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DOCK_TILE_PLUGIN;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = plugin;
};
name = Release;
};
819324502F24E6C000A9ED8F /* ReleaseLocal */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSPrincipalClass = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DOCK_TILE_PLUGIN;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = plugin;
};
name = ReleaseLocal;
};
A54F45F92E1F047A0046BD5C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -1163,6 +1344,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = ReleaseLocal;
};
819324512F24E6C000A9ED8F /* Build configuration list for PBXNativeTarget "DockTilePlugin" */ = {
isa = XCConfigurationList;
buildConfigurations = (
8193244E2F24E6C000A9ED8F /* Debug */,
8193244F2F24E6C000A9ED8F /* Release */,
819324502F24E6C000A9ED8F /* ReleaseLocal */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = ReleaseLocal;
};
A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -928,9 +928,7 @@ class AppDelegate: NSObject,
} else {
GlobalEventTap.shared.disable()
}
Task {
await updateAppIcon(from: config)
}
updateAppIcon(from: config)
}
/// Sync the appearance of our app with the theme specified in the config.
@@ -938,81 +936,15 @@ class AppDelegate: NSObject,
NSApplication.shared.appearance = .init(ghosttyConfig: config)
}
// Using AppIconActor to ensure this work
// happens synchronously in the background
@AppIconActor
private func updateAppIcon(from config: Ghostty.Config) async {
var appIcon: NSImage?
var appIconName: String? = config.macosIcon.rawValue
switch config.macosIcon {
case let icon where icon.assetName != nil:
appIcon = NSImage(named: icon.assetName!)!
case .custom:
if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) {
appIcon = userIcon
appIconName = config.macosCustomIcon
} else {
appIcon = nil // Revert back to official icon if invalid location
appIconName = nil // Discard saved icon name
}
case .customStyle:
// Discard saved icon name
// if no valid colours were found
appIconName = nil
guard let ghostColor = config.macosIconGhostColor else { break }
guard let screenColors = config.macosIconScreenColor else { break }
guard let icon = ColorizedGhosttyIcon(
screenColors: screenColors,
ghostColor: ghostColor,
frame: config.macosIconFrame
).makeImage() else { break }
appIcon = icon
let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString)
appIconName = (colorStrings + [config.macosIconFrame.rawValue])
.joined(separator: "_")
default:
// Discard saved icon name
appIconName = nil
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.standard.removeObject(forKey: "CustomGhosttyIcon")
DispatchQueue.global().async {
UserDefaults.standard.appIcon = Ghostty.CustomAppIcon(config: config)
DistributedNotificationCenter.default()
.postNotificationName(Ghostty.Notification.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true)
}
// Only change the icon if it has actually changed from the current one,
// or if the app build has changed (e.g. after an update that reset the icon)
let cachedIconName = UserDefaults.standard.string(forKey: "CustomGhosttyIcon")
let cachedIconBuild = UserDefaults.standard.string(forKey: "CustomGhosttyIconBuild")
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
let buildChanged = cachedIconBuild != currentBuild
guard cachedIconName != appIconName || buildChanged else {
#if DEBUG
if appIcon == nil {
await MainActor.run {
// Changing the app bundle's icon will corrupt code signing.
// We only use the default blueprint icon for the dock,
// so developers don't need to clean and re-build every time.
NSApplication.shared.applicationIconImage = NSImage(named: "BlueprintImage")
}
}
#endif
return
}
// make it immutable, so Swift 6 won't complain
let newIcon = appIcon
let appPath = Bundle.main.bundlePath
guard NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) else { return }
NSWorkspace.shared.noteFileSystemChanged(appPath)
await MainActor.run {
self.appIcon = newIcon
NSApplication.shared.applicationIconImage = newIcon
}
UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon")
UserDefaults.standard.set(currentBuild, forKey: "CustomGhosttyIconBuild")
}
// MARK: - Restorable State
@@ -1349,8 +1281,3 @@ private enum QuickTerminalState {
/// Controller has been initialized.
case initialized(QuickTerminalController)
}
@globalActor
private actor AppIconActor: GlobalActor {
static let shared = AppIconActor()
}

View File

@@ -0,0 +1,187 @@
import AppKit
import System
#if !DOCK_TILE_PLUGIN
import GhosttyKit
#endif
extension Ghostty {
/// For DockTilePlugin to generate icon
/// without relying on ``Ghostty/Ghostty/Config``
enum CustomAppIcon: Equatable, Codable {
case official
case blueprint
case chalkboard
case glass
case holographic
case microchip
case paper
case retro
case xray
/// Save image data to avoid sandboxing issues
case custom(fileData: Data)
case customStyle(ghostColorHex: String, screenColorHexes: [String], iconFrame: Ghostty.MacOSIconFrame)
/// Restore the icon from previously saved values
init?(string: String) {
switch string {
case MacOSIcon.official.rawValue:
self = .official
case MacOSIcon.blueprint.rawValue:
self = .blueprint
case MacOSIcon.chalkboard.rawValue:
self = .chalkboard
case MacOSIcon.glass.rawValue:
self = .glass
case MacOSIcon.holographic.rawValue:
self = .holographic
case MacOSIcon.microchip.rawValue:
self = .microchip
case MacOSIcon.paper.rawValue:
self = .paper
case MacOSIcon.retro.rawValue:
self = .retro
case MacOSIcon.xray.rawValue:
self = .xray
default:
/*
let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString)
appIconName = (colorStrings + [config.macosIconFrame.rawValue])
.joined(separator: "_")
*/
var parts = string.split(separator: "_").map(String.init)
if
let _ = parts.first.flatMap(NSColor.init(hex:)),
let frame = parts.last.flatMap(Ghostty.MacOSIconFrame.init(rawValue:))
{
let ghostC = parts.removeFirst()
_ = parts.removeLast()
self = .customStyle(
ghostColorHex: ghostC,
screenColorHexes: parts,
iconFrame: frame
)
} else {
// Due to sandboxing with `com.apple.dock.external.extra.arm64`,
// we cant restore custom icon file automatically.
// The user must open the app to update it.
return nil
}
}
}
func image(in bundle: Bundle) -> NSImage? {
switch self {
case .official:
return nil
case .blueprint:
return bundle.image(forResource: "BlueprintImage")!
case .chalkboard:
return bundle.image(forResource: "ChalkboardImage")!
case .glass:
return bundle.image(forResource: "GlassImage")!
case .holographic:
return bundle.image(forResource: "HolographicImage")!
case .microchip:
return bundle.image(forResource: "MicrochipImage")!
case .paper:
return bundle.image(forResource: "PaperImage")!
case .retro:
return bundle.image(forResource: "RetroImage")!
case .xray:
return bundle.image(forResource: "XrayImage")!
case let .custom(file):
if let userIcon = NSImage(data: file) {
return userIcon
} else {
return nil
}
case let .customStyle(ghostColorHex, screenColorHexes, macosIconFrame):
let screenColors = screenColorHexes.compactMap(NSColor.init(hex:))
guard
let ghostColor = NSColor(hex: ghostColorHex),
let icon = ColorizedGhosttyIcon(
screenColors: screenColors,
ghostColor: ghostColor,
frame: macosIconFrame
).makeImage(in: bundle)
else {
return nil
}
return icon
}
}
}
}
#if !DOCK_TILE_PLUGIN
extension Ghostty.CustomAppIcon {
init?(config: Ghostty.Config) {
switch config.macosIcon {
case .official:
return nil
case .blueprint:
self = .blueprint
case .chalkboard:
self = .chalkboard
case .glass:
self = .glass
case .holographic:
self = .holographic
case .microchip:
self = .microchip
case .paper:
self = .paper
case .retro:
self = .retro
case .xray:
self = .xray
case .custom:
if let data = try? Data(contentsOf: URL(filePath: config.macosCustomIcon, relativeTo: nil)) {
self = .custom(fileData: data)
} else {
return nil
}
case .customStyle:
// Discard saved icon name
// if no valid colours were found
guard
let ghostColor = config.macosIconGhostColor?.hexString,
let screenColors = config.macosIconScreenColor?.compactMap(\.hexString)
else {
return nil
}
self = .customStyle(ghostColorHex: ghostColor, screenColorHexes: screenColors, iconFrame: config.macosIconFrame)
}
}
}
#endif
extension UserDefaults {
var appIcon: Ghostty.CustomAppIcon? {
get {
defer {
removeObject(forKey: "CustomGhosttyIcon")
}
if let previous = string(forKey: "CustomGhosttyIcon"), let newIcon = Ghostty.CustomAppIcon(string: previous) {
// update new storage once
self.appIcon = newIcon
return newIcon
}
guard let data = data(forKey: "NewCustomGhosttyIcon") else {
return nil
}
return try? JSONDecoder().decode(Ghostty.CustomAppIcon.self, from: data)
}
set {
guard let newData = try? JSONEncoder().encode(newValue) else {
return
}
set(newData, forKey: "NewCustomGhosttyIcon")
}
}
}
extension Ghostty.Notification {
static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange")
}

View File

@@ -11,13 +11,13 @@ struct ColorizedGhosttyIcon {
let frame: Ghostty.MacOSIconFrame
/// Make a custom colorized ghostty icon.
func makeImage() -> NSImage? {
func makeImage(in bundle: Bundle) -> NSImage? {
// All of our layers (not in order)
guard let screen = NSImage(named: "CustomIconScreen") else { return nil }
guard let screenMask = NSImage(named: "CustomIconScreenMask") else { return nil }
guard let ghost = NSImage(named: "CustomIconGhost") else { return nil }
guard let crt = NSImage(named: "CustomIconCRT") else { return nil }
guard let gloss = NSImage(named: "CustomIconGloss") else { return nil }
guard let screen = bundle.image(forResource: "CustomIconScreen") else { return nil }
guard let screenMask = bundle.image(forResource: "CustomIconScreenMask") else { return nil }
guard let ghost = bundle.image(forResource: "CustomIconGhost") else { return nil }
guard let crt = bundle.image(forResource: "CustomIconCRT") else { return nil }
guard let gloss = bundle.image(forResource: "CustomIconGloss") else { return nil }
let baseName = switch frame {
case .aluminum: "CustomIconBaseAluminum"
@@ -25,7 +25,7 @@ struct ColorizedGhosttyIcon {
case .chrome: "CustomIconBaseChrome"
case .plastic: "CustomIconBasePlastic"
}
guard let base = NSImage(named: baseName) else { return nil }
guard let base = bundle.image(forResource: baseName) else { return nil }
// Apply our color in various ways to our layers.
// NOTE: These functions are not built-in, they're implemented as an extension

View File

@@ -8,6 +8,6 @@ struct ColorizedGhosttyIconView: View {
screenColors: [.purple, .blue],
ghostColor: .yellow,
frame: .aluminum
).makeImage()!)
).makeImage(in: .main)!)
}
}

View File

@@ -2,23 +2,6 @@ import os
import SwiftUI
import GhosttyKit
struct Ghostty {
// The primary logger used by the GhosttyKit libraries.
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: "ghostty"
)
// All the notifications that will be emitted will be put here.
struct Notification {}
// The user notification category identifier
static let userNotificationCategory = "com.mitchellh.ghostty.userNotification"
// The user notification "Show" action
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show"
}
// MARK: C Extensions
/// A command is fully self-contained so it is Sendable.
@@ -317,45 +300,6 @@ extension Ghostty {
}
}
/// macos-icon
enum MacOSIcon: String, Sendable {
case official
case blueprint
case chalkboard
case glass
case holographic
case microchip
case paper
case retro
case xray
case custom
case customStyle = "custom-style"
/// Bundled asset name for built-in icons
var assetName: String? {
switch self {
case .official: return nil
case .blueprint: return "BlueprintImage"
case .chalkboard: return "ChalkboardImage"
case .microchip: return "MicrochipImage"
case .glass: return "GlassImage"
case .holographic: return "HolographicImage"
case .paper: return "PaperImage"
case .retro: return "RetroImage"
case .xray: return "XrayImage"
case .custom, .customStyle: return nil
}
}
}
/// macos-icon-frame
enum MacOSIconFrame: String {
case aluminum
case beige
case plastic
case chrome
}
/// Enum for the macos-window-buttons config option
enum MacOSWindowButtons: String {
case visible

View File

@@ -0,0 +1,60 @@
import Foundation
import os
enum Ghostty {
// The primary logger used by the GhosttyKit libraries.
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: "ghostty"
)
// All the notifications that will be emitted will be put here.
struct Notification {}
// The user notification category identifier
static let userNotificationCategory = "com.mitchellh.ghostty.userNotification"
// The user notification "Show" action
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show"
}
extension Ghostty {
/// macos-icon
enum MacOSIcon: String, Sendable {
case official
case blueprint
case chalkboard
case glass
case holographic
case microchip
case paper
case retro
case xray
case custom
case customStyle = "custom-style"
/// Bundled asset name for built-in icons
var assetName: String? {
switch self {
case .official: return nil
case .blueprint: return "BlueprintImage"
case .chalkboard: return "ChalkboardImage"
case .microchip: return "MicrochipImage"
case .glass: return "GlassImage"
case .holographic: return "HolographicImage"
case .paper: return "PaperImage"
case .retro: return "RetroImage"
case .xray: return "XrayImage"
case .custom, .customStyle: return nil
}
}
}
/// macos-icon-frame
enum MacOSIconFrame: String, Codable {
case aluminum
case beige
case plastic
case chrome
}
}

View File

@@ -1,5 +1,7 @@
import Foundation
#if !DOCK_TILE_PLUGIN
import GhosttyKit
#endif
extension OSColor {
var isLightColor: Bool {
@@ -92,7 +94,7 @@ extension OSColor {
}
// MARK: Ghostty Types
#if !DOCK_TILE_PLUGIN
extension OSColor {
/// Create a color from a Ghostty color.
convenience init(ghostty: ghostty_config_color_s) {
@@ -102,3 +104,4 @@ extension OSColor {
self.init(red: red, green: green, blue: blue, alpha: 1)
}
}
#endif

View File

@@ -0,0 +1,20 @@
@testable import Ghostty
import Testing
struct CustomIconTests {
@Test func migration() {
#expect(Ghostty.CustomAppIcon.blueprint == Ghostty.CustomAppIcon(string: "blueprint"))
#expect(nil == Ghostty.CustomAppIcon(string: "~/downloads/some/file.png"))
#expect(nil == Ghostty.CustomAppIcon(string: "#B0260C"))
#expect(nil == Ghostty.CustomAppIcon(string: "plastic"))
#expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: [], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_plastic"))
#expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: ["#4F2C27"], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_#4F2C27_plastic"))
#expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: ["#4F2C27", "#B0260C"], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_#4F2C27_#B0260C_plastic"))
}
}

View File

@@ -42,6 +42,8 @@ extend-ignore-re = [
"draw[0-9A-F]+(_[0-9A-F]+)?\\(",
# Ignore test data in src/input/paste.zig
"\"hel\\\\x",
# Ignore long hex-like IDs such as 815E26BA2EF1E00F005C67B1
"[0-9A-F]{12,}",
]
[default.extend-words]