From 45525a0a85a7ef318ca0962941aa5afc00f50e1a Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sat, 20 Dec 2025 16:08:16 +0100 Subject: [PATCH 1/9] macOS: use `NSDockTilePlugIn` to update app icons --- macos/DockTilePlugIn/DockTilePlugin.swift | 115 +++++++++++ macos/Ghostty-Info.plist | 2 + macos/Ghostty.xcodeproj/project.pbxproj | 195 +++++++++++++++++- macos/Sources/App/macOS/AppDelegate.swift | 91 +------- macos/Sources/App/macOS/AppIcon.swift | 187 +++++++++++++++++ .../ColorizedGhosttyIcon.swift | 14 +- .../ColorizedGhosttyIconView.swift | 2 +- macos/Sources/Ghostty/Package.swift | 56 ----- macos/Sources/Ghostty/SharedPackage.swift | 60 ++++++ .../Extensions/OSColor+Extension.swift | 5 +- macos/Tests/CustomIconTests.swift | 20 ++ typos.toml | 2 + 12 files changed, 600 insertions(+), 149 deletions(-) create mode 100644 macos/DockTilePlugIn/DockTilePlugin.swift create mode 100644 macos/Sources/App/macOS/AppIcon.swift create mode 100644 macos/Sources/Ghostty/SharedPackage.swift create mode 100644 macos/Tests/CustomIconTests.swift diff --git a/macos/DockTilePlugIn/DockTilePlugin.swift b/macos/DockTilePlugIn/DockTilePlugin.swift new file mode 100644 index 000000000..f711bfd2d --- /dev/null +++ b/macos/DockTilePlugIn/DockTilePlugin.swift @@ -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 + diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 5960dc0e7..4896681b9 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -2,6 +2,8 @@ + NSDockTilePlugIn + DockTilePlugin.plugin CFBundleDocumentTypes diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index d34dfa257..724bf86a7 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 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 = ""; }; A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; }; A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = ""; }; @@ -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 = ""; }; - 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; + 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DockTilePlugIn; sourceTree = ""; }; + 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* 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 = ""; @@ -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 = ( diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 582af1746..2b2c1b7d1 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -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() -} diff --git a/macos/Sources/App/macOS/AppIcon.swift b/macos/Sources/App/macOS/AppIcon.swift new file mode 100644 index 000000000..3dd8cdd98 --- /dev/null +++ b/macos/Sources/App/macOS/AppIcon.swift @@ -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 can’t 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") +} diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift index e58699cff..df24477d4 100644 --- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift @@ -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 diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift index 8fbebfdc8..7271c595f 100644 --- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift @@ -8,6 +8,6 @@ struct ColorizedGhosttyIconView: View { screenColors: [.purple, .blue], ghostColor: .yellow, frame: .aluminum - ).makeImage()!) + ).makeImage(in: .main)!) } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 1e92eb8a1..30c355327 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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 diff --git a/macos/Sources/Ghostty/SharedPackage.swift b/macos/Sources/Ghostty/SharedPackage.swift new file mode 100644 index 000000000..3e591a57b --- /dev/null +++ b/macos/Sources/Ghostty/SharedPackage.swift @@ -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 + } +} diff --git a/macos/Sources/Helpers/Extensions/OSColor+Extension.swift b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift index 54b3e1fab..67246bcf5 100644 --- a/macos/Sources/Helpers/Extensions/OSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift @@ -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 diff --git a/macos/Tests/CustomIconTests.swift b/macos/Tests/CustomIconTests.swift new file mode 100644 index 000000000..df6310bbf --- /dev/null +++ b/macos/Tests/CustomIconTests.swift @@ -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")) + } +} diff --git a/typos.toml b/typos.toml index ad167f06e..3c7cd75f2 100644 --- a/typos.toml +++ b/typos.toml @@ -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] From a79557f5214bda88de13a9de2c109ad9020ee7d6 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:26:20 +0100 Subject: [PATCH 2/9] macOS: stop cycling icons when AboutWindow is closed and start cycling with current icon --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Features/About/AboutController.swift | 8 ++- .../Features/About/AboutViewModel.swift | 40 +++++++++++++++ .../Features/About/CyclingIconView.swift | 50 ++++++------------- 4 files changed, 64 insertions(+), 35 deletions(-) create mode 100644 macos/Sources/Features/About/AboutViewModel.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 724bf86a7..17c6f46e6 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -117,6 +117,7 @@ Features/About/About.xib, Features/About/AboutController.swift, Features/About/AboutView.swift, + Features/About/AboutViewModel.swift, Features/About/CyclingIconView.swift, "Features/App Intents/CloseTerminalIntent.swift", "Features/App Intents/CommandPaletteIntent.swift", diff --git a/macos/Sources/Features/About/AboutController.swift b/macos/Sources/Features/About/AboutController.swift index 2f494f12c..6f4cccf6d 100644 --- a/macos/Sources/Features/About/AboutController.swift +++ b/macos/Sources/Features/About/AboutController.swift @@ -5,19 +5,21 @@ import SwiftUI class AboutController: NSWindowController, NSWindowDelegate { static let shared: AboutController = AboutController() + private let viewModel = AboutViewModel() override var windowNibName: NSNib.Name? { "About" } override func windowDidLoad() { guard let window = window else { return } window.center() window.isMovableByWindowBackground = true - window.contentView = NSHostingView(rootView: AboutView()) + window.contentView = NSHostingView(rootView: AboutView().environmentObject(viewModel)) } // MARK: - Functions func show() { window?.makeKeyAndOrderFront(nil) + viewModel.startCyclingIcons() } func hide() { @@ -38,4 +40,8 @@ class AboutController: NSWindowController, NSWindowDelegate { @objc func cancel(_ sender: Any?) { close() } + + func windowWillClose(_ notification: Notification) { + viewModel.stopCyclingIcons() + } } diff --git a/macos/Sources/Features/About/AboutViewModel.swift b/macos/Sources/Features/About/AboutViewModel.swift new file mode 100644 index 000000000..dc0d38c21 --- /dev/null +++ b/macos/Sources/Features/About/AboutViewModel.swift @@ -0,0 +1,40 @@ +import Combine + +class AboutViewModel: ObservableObject { + @Published var currentIcon: Ghostty.MacOSIcon? + @Published var isHovering: Bool = false + + private var timerCancellable: AnyCancellable? + + private let icons: [Ghostty.MacOSIcon] = [ + .official, + .blueprint, + .chalkboard, + .microchip, + .glass, + .holographic, + .paper, + .retro, + .xray, + ] + + func startCyclingIcons() { + timerCancellable = Timer.publish(every: 3, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self, !isHovering else { return } + advanceToNextIcon() + } + } + + func stopCyclingIcons() { + timerCancellable = nil + currentIcon = nil + } + + func advanceToNextIcon() { + let currentIndex = currentIcon.flatMap(icons.firstIndex(of:)) ?? 0 + let nextIndex = icons.indexWrapping(after: currentIndex) + currentIcon = icons[nextIndex] + } +} diff --git a/macos/Sources/Features/About/CyclingIconView.swift b/macos/Sources/Features/About/CyclingIconView.swift index 4274278e0..c2a860ff7 100644 --- a/macos/Sources/Features/About/CyclingIconView.swift +++ b/macos/Sources/Features/About/CyclingIconView.swift @@ -1,50 +1,38 @@ import SwiftUI import GhosttyKit +import Combine /// A view that cycles through Ghostty's official icon variants. struct CyclingIconView: View { - @State private var currentIcon: Ghostty.MacOSIcon = .official - @State private var isHovering: Bool = false - - private let icons: [Ghostty.MacOSIcon] = [ - .official, - .blueprint, - .chalkboard, - .microchip, - .glass, - .holographic, - .paper, - .retro, - .xray, - ] - private let timerPublisher = Timer.publish(every: 3, on: .main, in: .common) + @EnvironmentObject var viewModel: AboutViewModel var body: some View { ZStack { - iconView(for: currentIcon) - .id(currentIcon) + iconView(for: viewModel.currentIcon) + .id(viewModel.currentIcon) } - .animation(.easeInOut(duration: 0.5), value: currentIcon) + .animation(.easeInOut(duration: 0.5), value: viewModel.currentIcon) .frame(height: 128) - .onReceive(timerPublisher.autoconnect()) { _ in - if !isHovering { - advanceToNextIcon() - } - } .onHover { hovering in - isHovering = hovering + viewModel.isHovering = hovering } .onTapGesture { - advanceToNextIcon() + viewModel.advanceToNextIcon() + } + .contextMenu { + if let currentIcon = viewModel.currentIcon { + Button("Copy Icon Config") { + NSPasteboard.general.setString("macos-icon = \(currentIcon.rawValue)", forType: .string) + } + } } - .help("macos-icon = \(currentIcon.rawValue)") .accessibilityLabel("Ghostty Application Icon") .accessibilityHint("Click to cycle through icon variants") } @ViewBuilder - private func iconView(for icon: Ghostty.MacOSIcon) -> some View { - let iconImage: Image = switch icon.assetName { + private func iconView(for icon: Ghostty.MacOSIcon?) -> some View { + let iconImage: Image = switch icon?.assetName { case let assetName?: Image(assetName) case nil: ghosttyIconImage() } @@ -53,10 +41,4 @@ struct CyclingIconView: View { .resizable() .aspectRatio(contentMode: .fit) } - - private func advanceToNextIcon() { - let currentIndex = icons.firstIndex(of: currentIcon) ?? 0 - let nextIndex = icons.indexWrapping(after: currentIndex) - currentIcon = icons[nextIndex] - } } From 2c28c27ca52f130fa743ae3314cdbb0cd5ebd710 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 13:14:49 -0800 Subject: [PATCH 3/9] moving lots of files, removing unused stuff --- macos/Ghostty.xcodeproj/project.pbxproj | 40 ++-- macos/Sources/App/macOS/AppDelegate.swift | 5 +- macos/Sources/App/macOS/AppIcon.swift | 187 ------------------ .../Features/Dock Tile Plugin/AppIcon.swift | 144 ++++++++++++++ .../Dock Tile Plugin}/DockTilePlugin.swift | 29 +-- .../Notification+AppIcon.swift | 5 + .../UserDefaults+AppIcon.swift | 37 ++++ ...ackage.swift => Ghostty.ConfigTypes.swift} | 21 +- .../{Package.swift => GhosttyPackage.swift} | 8 + .../Sources/Ghostty/GhosttyPackageMeta.swift | 16 ++ 10 files changed, 248 insertions(+), 244 deletions(-) delete mode 100644 macos/Sources/App/macOS/AppIcon.swift create mode 100644 macos/Sources/Features/Dock Tile Plugin/AppIcon.swift rename macos/{DockTilePlugIn => Sources/Features/Dock Tile Plugin}/DockTilePlugin.swift (85%) create mode 100644 macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift create mode 100644 macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift rename macos/Sources/Ghostty/{SharedPackage.swift => Ghostty.ConfigTypes.swift} (65%) rename macos/Sources/Ghostty/{Package.swift => GhosttyPackage.swift} (98%) create mode 100644 macos/Sources/Ghostty/GhosttyPackageMeta.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 17c6f46e6..27f9d0902 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -97,9 +97,13 @@ 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - App/macOS/AppIcon.swift, "Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift", - Ghostty/SharedPackage.swift, + "Features/Dock Tile Plugin/AppIcon.swift", + "Features/Dock Tile Plugin/DockTilePlugin.swift", + "Features/Dock Tile Plugin/Notification+AppIcon.swift", + "Features/Dock Tile Plugin/UserDefaults+AppIcon.swift", + Ghostty/Ghostty.ConfigTypes.swift, + Ghostty/GhosttyPackageMeta.swift, Helpers/CrossKit.swift, "Helpers/Extensions/NSImage+Extension.swift", "Helpers/Extensions/OSColor+Extension.swift", @@ -111,7 +115,6 @@ 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, @@ -139,6 +142,10 @@ "Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift", "Features/Command Palette/CommandPalette.swift", "Features/Command Palette/TerminalCommandPalette.swift", + "Features/Dock Tile Plugin/AppIcon.swift", + "Features/Dock Tile Plugin/DockTilePlugin.swift", + "Features/Dock Tile Plugin/Notification+AppIcon.swift", + "Features/Dock Tile Plugin/UserDefaults+AppIcon.swift", "Features/Global Keybinds/GlobalEventTap.swift", Features/QuickTerminal/QuickTerminal.xib, Features/QuickTerminal/QuickTerminalController.swift, @@ -238,6 +245,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( App/iOS/iOSApp.swift, + "Features/Dock Tile Plugin/DockTilePlugin.swift", "Ghostty/Surface View/SurfaceView_UIKit.swift", ); target = A5B30530299BEAAA0047F10C /* Ghostty */; @@ -246,7 +254,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = ""; }; - 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DockTilePlugIn; sourceTree = ""; }; 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -321,7 +328,6 @@ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */, 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */, 81F82BC72E82815D001EDFA7 /* Sources */, - 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */, A54F45F42E1F047A0046BD5C /* Tests */, 810ACCA02E9D3302004F8F92 /* GhosttyUITests */, A5D495A3299BECBA00DD1313 /* Frameworks */, @@ -389,9 +395,6 @@ ); dependencies = ( ); - fileSystemSynchronizedGroups = ( - 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */, - ); name = DockTilePlugin; packageProductDependencies = ( ); @@ -845,13 +848,14 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; 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; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -873,13 +877,14 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; 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; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -901,13 +906,14 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; 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; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 2b2c1b7d1..b9aab0ac4 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -928,6 +928,7 @@ class AppDelegate: NSObject, } else { GlobalEventTap.shared.disable() } + updateAppIcon(from: config) } @@ -941,9 +942,9 @@ class AppDelegate: NSObject, // 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) + UserDefaults.standard.appIcon = AppIcon(config: config) DistributedNotificationCenter.default() - .postNotificationName(Ghostty.Notification.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) + .postNotificationName(.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) } } diff --git a/macos/Sources/App/macOS/AppIcon.swift b/macos/Sources/App/macOS/AppIcon.swift deleted file mode 100644 index 3dd8cdd98..000000000 --- a/macos/Sources/App/macOS/AppIcon.swift +++ /dev/null @@ -1,187 +0,0 @@ -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 can’t 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") -} diff --git a/macos/Sources/Features/Dock Tile Plugin/AppIcon.swift b/macos/Sources/Features/Dock Tile Plugin/AppIcon.swift new file mode 100644 index 000000000..52ab9ef95 --- /dev/null +++ b/macos/Sources/Features/Dock Tile Plugin/AppIcon.swift @@ -0,0 +1,144 @@ +import AppKit +import System + +/// The icon style for the Ghostty App. +enum AppIcon: Equatable, Codable { + case official + case blueprint + case chalkboard + case glass + case holographic + case microchip + case paper + case retro + case xray + /// Save full image data to avoid sandboxing issues + case custom(fileData: Data) + case customStyle(ghostColorHex: String, screenColorHexes: [String], iconFrame: Ghostty.MacOSIconFrame) + +#if !DOCK_TILE_PLUGIN + 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 + + /// Restore the icon from previously saved values + init?(string: String) { + switch string { + case Ghostty.MacOSIcon.official.rawValue: + self = .official + case Ghostty.MacOSIcon.blueprint.rawValue: + self = .blueprint + case Ghostty.MacOSIcon.chalkboard.rawValue: + self = .chalkboard + case Ghostty.MacOSIcon.glass.rawValue: + self = .glass + case Ghostty.MacOSIcon.holographic.rawValue: + self = .holographic + case Ghostty.MacOSIcon.microchip.rawValue: + self = .microchip + case Ghostty.MacOSIcon.paper.rawValue: + self = .paper + case Ghostty.MacOSIcon.retro.rawValue: + self = .retro + case Ghostty.MacOSIcon.xray.rawValue: + self = .xray + default: + 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 can’t 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 + } + } +} diff --git a/macos/DockTilePlugIn/DockTilePlugin.swift b/macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift similarity index 85% rename from macos/DockTilePlugIn/DockTilePlugin.swift rename to macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift index f711bfd2d..ad750376d 100644 --- a/macos/DockTilePlugIn/DockTilePlugin.swift +++ b/macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift @@ -1,10 +1,14 @@ 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 { + // WARNING: An instance of this class is alive as long as Ghostty's icon is + // in the doc (running or not!), so keep any state and processing to a + // minimum to respect resource usage. + private let pluginBundle = Bundle(for: DockTilePlugin.self) + + // Separate defaults based on debug vs release builds so we can test icons + // without messing up releases. #if DEBUG private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug") #else @@ -21,7 +25,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { // Try to restore the previous icon on launch. iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) - iconChangeObserver = DistributedNotificationCenter.default().publisher(for: Ghostty.Notification.ghosttyIconDidChange) + iconChangeObserver = DistributedNotificationCenter.default().publisher(for: .ghosttyIconDidChange) .map { [weak self] _ in self?.ghosttyUserDefaults?.appIcon } @@ -41,7 +45,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { return url.path } - func iconDidChange(_ newIcon: Ghostty.CustomAppIcon?, dockTile: NSDockTile) { + func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { guard let appIcon = newIcon?.image(in: pluginBundle) else { resetIcon(dockTile: dockTile) return @@ -80,6 +84,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { appIcon = pluginBundle.image(forResource: "AppIconImage")! NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) } + NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } @@ -99,17 +104,3 @@ private extension NSDockTile { } 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 - diff --git a/macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift b/macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift new file mode 100644 index 000000000..e492f1a77 --- /dev/null +++ b/macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift @@ -0,0 +1,5 @@ +import AppKit + +extension Notification.Name { + static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange") +} diff --git a/macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift b/macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift new file mode 100644 index 000000000..cce8e24a4 --- /dev/null +++ b/macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift @@ -0,0 +1,37 @@ +import AppKit + +extension UserDefaults { + private static let customIconKeyOld = "CustomGhosttyIcon" + private static let customIconKeyNew = "CustomGhosttyIcon2" + + var appIcon: AppIcon? { + get { + // Always remove our old pre-docktileplugin values. + defer { + removeObject(forKey: Self.customIconKeyOld) + } + + // If we have an old, pre-docktileplugin value, then we parse the + // the old value (try) and set it. + if let previous = string(forKey: Self.customIconKeyOld), let newIcon = AppIcon(string: previous) { + // update new storage once + self.appIcon = newIcon + return newIcon + } + + // Check if we have the new key for our dock tile plugin format. + guard let data = data(forKey: Self.customIconKeyNew) else { + return nil + } + return try? JSONDecoder().decode(AppIcon.self, from: data) + } + + set { + guard let newData = try? JSONEncoder().encode(newValue) else { + return + } + + set(newData, forKey: Self.customIconKeyNew) + } + } +} diff --git a/macos/Sources/Ghostty/SharedPackage.swift b/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift similarity index 65% rename from macos/Sources/Ghostty/SharedPackage.swift rename to macos/Sources/Ghostty/Ghostty.ConfigTypes.swift index 3e591a57b..8c559fad2 100644 --- a/macos/Sources/Ghostty/SharedPackage.swift +++ b/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift @@ -1,22 +1,5 @@ -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" -} +// This file contains the configuration types for Ghostty so that alternate targets +// can get typed information without depending on all the dependencies of GhosttyKit. extension Ghostty { /// macos-icon diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/GhosttyPackage.swift similarity index 98% rename from macos/Sources/Ghostty/Package.swift rename to macos/Sources/Ghostty/GhosttyPackage.swift index 30c355327..03211862f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -11,6 +11,14 @@ extension ghostty_command_s: @unchecked @retroactive Sendable {} /// may be unsafe but the value itself is safe to send across threads. extension ghostty_surface_t: @unchecked @retroactive Sendable {} +extension Ghostty { + // 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: Build Info extension Ghostty { diff --git a/macos/Sources/Ghostty/GhosttyPackageMeta.swift b/macos/Sources/Ghostty/GhosttyPackageMeta.swift new file mode 100644 index 000000000..8e035c323 --- /dev/null +++ b/macos/Sources/Ghostty/GhosttyPackageMeta.swift @@ -0,0 +1,16 @@ +import Foundation +import os + +// This defines the minimal information required so all other files can do +// `extension Ghostty` to add more to it. This purposely has minimal +// dependencies so things like our dock tile plugin can use it. +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 {} +} From 4b1178e4f647119b93b004e86f95f2d99485468f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 13:17:24 -0800 Subject: [PATCH 4/9] macos: rename a bunch of files --- macos/Ghostty.xcodeproj/project.pbxproj | 26 +++++++++---------- .../AppIcon.swift | 0 .../ColorizedGhosttyIcon.swift | 0 .../ColorizedGhosttyIconImage.swift | 0 .../ColorizedGhosttyIconView.swift | 0 .../DockTilePlugin.swift | 0 .../Extensions}/Notification+AppIcon.swift | 0 .../Extensions}/UserDefaults+AppIcon.swift | 0 8 files changed, 13 insertions(+), 13 deletions(-) rename macos/Sources/Features/{Dock Tile Plugin => Custom App Icon}/AppIcon.swift (100%) rename macos/Sources/Features/{Colorized Ghostty Icon => Custom App Icon}/ColorizedGhosttyIcon.swift (100%) rename macos/Sources/Features/{Colorized Ghostty Icon => Custom App Icon}/ColorizedGhosttyIconImage.swift (100%) rename macos/Sources/Features/{Colorized Ghostty Icon => Custom App Icon}/ColorizedGhosttyIconView.swift (100%) rename macos/Sources/Features/{Dock Tile Plugin => Custom App Icon}/DockTilePlugin.swift (100%) rename macos/Sources/Features/{Dock Tile Plugin => Custom App Icon/Extensions}/Notification+AppIcon.swift (100%) rename macos/Sources/Features/{Dock Tile Plugin => Custom App Icon/Extensions}/UserDefaults+AppIcon.swift (100%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 27f9d0902..8871343c3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -97,11 +97,11 @@ 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift", - "Features/Dock Tile Plugin/AppIcon.swift", - "Features/Dock Tile Plugin/DockTilePlugin.swift", - "Features/Dock Tile Plugin/Notification+AppIcon.swift", - "Features/Dock Tile Plugin/UserDefaults+AppIcon.swift", + "Features/Custom App Icon/AppIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIcon.swift", + "Features/Custom App Icon/DockTilePlugin.swift", + "Features/Custom App Icon/Extensions/Notification+AppIcon.swift", + "Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift", Ghostty/Ghostty.ConfigTypes.swift, Ghostty/GhosttyPackageMeta.swift, Helpers/CrossKit.swift, @@ -137,15 +137,15 @@ Features/ClipboardConfirmation/ClipboardConfirmation.xib, Features/ClipboardConfirmation/ClipboardConfirmationController.swift, Features/ClipboardConfirmation/ClipboardConfirmationView.swift, - "Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift", - "Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift", - "Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift", "Features/Command Palette/CommandPalette.swift", "Features/Command Palette/TerminalCommandPalette.swift", - "Features/Dock Tile Plugin/AppIcon.swift", - "Features/Dock Tile Plugin/DockTilePlugin.swift", - "Features/Dock Tile Plugin/Notification+AppIcon.swift", - "Features/Dock Tile Plugin/UserDefaults+AppIcon.swift", + "Features/Custom App Icon/AppIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIconImage.swift", + "Features/Custom App Icon/ColorizedGhosttyIconView.swift", + "Features/Custom App Icon/DockTilePlugin.swift", + "Features/Custom App Icon/Extensions/Notification+AppIcon.swift", + "Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift", "Features/Global Keybinds/GlobalEventTap.swift", Features/QuickTerminal/QuickTerminal.xib, Features/QuickTerminal/QuickTerminalController.swift, @@ -245,7 +245,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( App/iOS/iOSApp.swift, - "Features/Dock Tile Plugin/DockTilePlugin.swift", + "Features/Custom App Icon/DockTilePlugin.swift", "Ghostty/Surface View/SurfaceView_UIKit.swift", ); target = A5B30530299BEAAA0047F10C /* Ghostty */; diff --git a/macos/Sources/Features/Dock Tile Plugin/AppIcon.swift b/macos/Sources/Features/Custom App Icon/AppIcon.swift similarity index 100% rename from macos/Sources/Features/Dock Tile Plugin/AppIcon.swift rename to macos/Sources/Features/Custom App Icon/AppIcon.swift diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift similarity index 100% rename from macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift rename to macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift similarity index 100% rename from macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift rename to macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconView.swift similarity index 100% rename from macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift rename to macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconView.swift diff --git a/macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift similarity index 100% rename from macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift rename to macos/Sources/Features/Custom App Icon/DockTilePlugin.swift diff --git a/macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/Notification+AppIcon.swift similarity index 100% rename from macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift rename to macos/Sources/Features/Custom App Icon/Extensions/Notification+AppIcon.swift diff --git a/macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift similarity index 100% rename from macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift rename to macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift From f831f68f1aab3148e8d46362cb9991425e62f395 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:51:30 +0100 Subject: [PATCH 5/9] macOS: update AppIcon encoding - make `ColorizedGhosttyIcon` codable - remove deprecated string encoding introduced in tip --- .../Features/Custom App Icon/AppIcon.swift | 78 +++---------------- .../ColorizedGhosttyIcon.swift | 35 ++++++++- .../Extensions/UserDefaults+AppIcon.swift | 8 -- 3 files changed, 44 insertions(+), 77 deletions(-) diff --git a/macos/Sources/Features/Custom App Icon/AppIcon.swift b/macos/Sources/Features/Custom App Icon/AppIcon.swift index 52ab9ef95..296bd10fe 100644 --- a/macos/Sources/Features/Custom App Icon/AppIcon.swift +++ b/macos/Sources/Features/Custom App Icon/AppIcon.swift @@ -13,9 +13,9 @@ enum AppIcon: Equatable, Codable { case retro case xray /// Save full image data to avoid sandboxing issues - case custom(fileData: Data) - case customStyle(ghostColorHex: String, screenColorHexes: [String], iconFrame: Ghostty.MacOSIconFrame) - + case custom(_ iconFile: Data) + case customStyle(_ icon: ColorizedGhosttyIcon) + #if !DOCK_TILE_PLUGIN init?(config: Ghostty.Config) { switch config.macosIcon { @@ -39,7 +39,7 @@ enum AppIcon: Equatable, Codable { self = .xray case .custom: if let data = try? Data(contentsOf: URL(filePath: config.macosCustomIcon, relativeTo: nil)) { - self = .custom(fileData: data) + self = .custom(data) } else { return nil } @@ -47,59 +47,16 @@ enum AppIcon: Equatable, Codable { // Discard saved icon name // if no valid colours were found guard - let ghostColor = config.macosIconGhostColor?.hexString, - let screenColors = config.macosIconScreenColor?.compactMap(\.hexString) + let ghostColor = config.macosIconGhostColor, + let screenColors = config.macosIconScreenColor else { return nil } - self = .customStyle(ghostColorHex: ghostColor, screenColorHexes: screenColors, iconFrame: config.macosIconFrame) + self = .customStyle(ColorizedGhosttyIcon(screenColors: screenColors, ghostColor: ghostColor, frame: config.macosIconFrame)) } } #endif - /// Restore the icon from previously saved values - init?(string: String) { - switch string { - case Ghostty.MacOSIcon.official.rawValue: - self = .official - case Ghostty.MacOSIcon.blueprint.rawValue: - self = .blueprint - case Ghostty.MacOSIcon.chalkboard.rawValue: - self = .chalkboard - case Ghostty.MacOSIcon.glass.rawValue: - self = .glass - case Ghostty.MacOSIcon.holographic.rawValue: - self = .holographic - case Ghostty.MacOSIcon.microchip.rawValue: - self = .microchip - case Ghostty.MacOSIcon.paper.rawValue: - self = .paper - case Ghostty.MacOSIcon.retro.rawValue: - self = .retro - case Ghostty.MacOSIcon.xray.rawValue: - self = .xray - default: - 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 can’t 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: @@ -121,24 +78,9 @@ enum AppIcon: Equatable, Codable { 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 + return NSImage(data: file) + case let .customStyle(customIcon): + return customIcon.makeImage(in: bundle) } } } diff --git a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift index df24477d4..62f58a063 100644 --- a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift +++ b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift @@ -1,6 +1,33 @@ import Cocoa -struct ColorizedGhosttyIcon { +struct ColorizedGhosttyIcon: Codable, Equatable { + init(screenColors: [NSColor], ghostColor: NSColor, frame: Ghostty.MacOSIconFrame) { + self.screenColors = screenColors + self.ghostColor = ghostColor + self.frame = frame + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let screenColorHexes = try container.decode([String].self, forKey: .screenColors) + let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) + let ghostColorHex = try container.decode(String.self, forKey: .ghostColor) + guard let ghostColor = NSColor(hex: ghostColorHex) else { + throw NSError(domain: "Custom Icon Error", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to decode ghost color from \(ghostColorHex)" + ]) + } + let frame = try container.decode(Ghostty.MacOSIconFrame.self, forKey: .frame) + self.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(screenColors.compactMap(\.hexString), forKey: .screenColors) + try container.encode(ghostColor.hexString, forKey: .ghostColor) + try container.encode(frame, forKey: .frame) + } + /// The colors that make up the gradient of the screen. let screenColors: [NSColor] @@ -10,6 +37,12 @@ struct ColorizedGhosttyIcon { /// The frame type to use let frame: Ghostty.MacOSIconFrame + private enum CodingKeys: String, CodingKey { + case screenColors + case ghostColor + case frame + } + /// Make a custom colorized ghostty icon. func makeImage(in bundle: Bundle) -> NSImage? { // All of our layers (not in order) diff --git a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift index cce8e24a4..9478cc5c3 100644 --- a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift +++ b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift @@ -11,14 +11,6 @@ extension UserDefaults { removeObject(forKey: Self.customIconKeyOld) } - // If we have an old, pre-docktileplugin value, then we parse the - // the old value (try) and set it. - if let previous = string(forKey: Self.customIconKeyOld), let newIcon = AppIcon(string: previous) { - // update new storage once - self.appIcon = newIcon - return newIcon - } - // Check if we have the new key for our dock tile plugin format. guard let data = data(forKey: Self.customIconKeyNew) else { return nil From c72788894e11c0d60368ba138f3f346b8c1eb145 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:13:24 +0100 Subject: [PATCH 6/9] ci: fix linting and delete non-useful tests --- macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../Features/Custom App Icon/AppIcon.swift | 2 +- .../Custom App Icon/DockTilePlugin.swift | 6 +++--- .../Extensions/UserDefaults+AppIcon.swift | 8 ++++---- macos/Tests/CustomIconTests.swift | 20 ------------------- 5 files changed, 9 insertions(+), 29 deletions(-) delete mode 100644 macos/Tests/CustomIconTests.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index b9aab0ac4..028d4506c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -928,7 +928,7 @@ class AppDelegate: NSObject, } else { GlobalEventTap.shared.disable() } - + updateAppIcon(from: config) } diff --git a/macos/Sources/Features/Custom App Icon/AppIcon.swift b/macos/Sources/Features/Custom App Icon/AppIcon.swift index 296bd10fe..13c6b83a1 100644 --- a/macos/Sources/Features/Custom App Icon/AppIcon.swift +++ b/macos/Sources/Features/Custom App Icon/AppIcon.swift @@ -56,7 +56,7 @@ enum AppIcon: Equatable, Codable { } } #endif - + func image(in bundle: Bundle) -> NSImage? { switch self { case .official: diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift index ad750376d..a3e094f96 100644 --- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -4,9 +4,9 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { // WARNING: An instance of this class is alive as long as Ghostty's icon is // in the doc (running or not!), so keep any state and processing to a // minimum to respect resource usage. - + private let pluginBundle = Bundle(for: DockTilePlugin.self) - + // Separate defaults based on debug vs release builds so we can test icons // without messing up releases. #if DEBUG @@ -84,7 +84,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { appIcon = pluginBundle.image(forResource: "AppIconImage")! NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) } - + NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } diff --git a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift index 9478cc5c3..d15644c93 100644 --- a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift +++ b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift @@ -3,26 +3,26 @@ import AppKit extension UserDefaults { private static let customIconKeyOld = "CustomGhosttyIcon" private static let customIconKeyNew = "CustomGhosttyIcon2" - + var appIcon: AppIcon? { get { // Always remove our old pre-docktileplugin values. defer { removeObject(forKey: Self.customIconKeyOld) } - + // Check if we have the new key for our dock tile plugin format. guard let data = data(forKey: Self.customIconKeyNew) else { return nil } return try? JSONDecoder().decode(AppIcon.self, from: data) } - + set { guard let newData = try? JSONEncoder().encode(newValue) else { return } - + set(newData, forKey: Self.customIconKeyNew) } } diff --git a/macos/Tests/CustomIconTests.swift b/macos/Tests/CustomIconTests.swift deleted file mode 100644 index df6310bbf..000000000 --- a/macos/Tests/CustomIconTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -@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")) - } -} From f451ea8e4603a63058a9f019623a8d18b103b98f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 10:20:25 -0800 Subject: [PATCH 7/9] macos: move icon codable/equatable to extension --- .../ColorizedGhosttyIcon.swift | 95 ++++++++++++------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift index 62f58a063..99d684369 100644 --- a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift +++ b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift @@ -1,33 +1,6 @@ import Cocoa -struct ColorizedGhosttyIcon: Codable, Equatable { - init(screenColors: [NSColor], ghostColor: NSColor, frame: Ghostty.MacOSIconFrame) { - self.screenColors = screenColors - self.ghostColor = ghostColor - self.frame = frame - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let screenColorHexes = try container.decode([String].self, forKey: .screenColors) - let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) - let ghostColorHex = try container.decode(String.self, forKey: .ghostColor) - guard let ghostColor = NSColor(hex: ghostColorHex) else { - throw NSError(domain: "Custom Icon Error", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to decode ghost color from \(ghostColorHex)" - ]) - } - let frame = try container.decode(Ghostty.MacOSIconFrame.self, forKey: .frame) - self.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(screenColors.compactMap(\.hexString), forKey: .screenColors) - try container.encode(ghostColor.hexString, forKey: .ghostColor) - try container.encode(frame, forKey: .frame) - } - +struct ColorizedGhosttyIcon { /// The colors that make up the gradient of the screen. let screenColors: [NSColor] @@ -37,12 +10,6 @@ struct ColorizedGhosttyIcon: Codable, Equatable { /// The frame type to use let frame: Ghostty.MacOSIconFrame - private enum CodingKeys: String, CodingKey { - case screenColors - case ghostColor - case frame - } - /// Make a custom colorized ghostty icon. func makeImage(in bundle: Bundle) -> NSImage? { // All of our layers (not in order) @@ -86,3 +53,63 @@ struct ColorizedGhosttyIcon: Codable, Equatable { ]) } } + +// MARK: Codable + +extension ColorizedGhosttyIcon: Codable { + private enum CodingKeys: String, CodingKey { + case version + case screenColors + case ghostColor + case frame + + static let currentVersion: Int = 1 + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // If no version exists then this is the legacy v0 format. + let version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 0 + guard version == 0 || version == CodingKeys.currentVersion else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unsupported ColorizedGhosttyIcon version: \(version)" + ) + ) + } + + let screenColorHexes = try container.decode([String].self, forKey: .screenColors) + let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) + let ghostColorHex = try container.decode(String.self, forKey: .ghostColor) + guard let ghostColor = NSColor(hex: ghostColorHex) else { + throw DecodingError.dataCorruptedError( + forKey: .ghostColor, + in: container, + debugDescription: "Failed to decode ghost color from \(ghostColorHex)" + ) + } + let frame = try container.decode(Ghostty.MacOSIconFrame.self, forKey: .frame) + self.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(CodingKeys.currentVersion, forKey: .version) + try container.encode(screenColors.compactMap(\.hexString), forKey: .screenColors) + try container.encode(ghostColor.hexString, forKey: .ghostColor) + try container.encode(frame, forKey: .frame) + } + +} + +// MARK: Equatable + +extension ColorizedGhosttyIcon: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.frame == rhs.frame && + lhs.screenColors.compactMap(\.hexString) == rhs.screenColors.compactMap(\.hexString) && + lhs.ghostColor.hexString == rhs.ghostColor.hexString + } +} From eaf7d8a012636d51aa304d59147ea656e1edd615 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 10:26:11 -0800 Subject: [PATCH 8/9] macos: icon tests --- macos/Tests/ColorizedGhosttyIconTests.swift | 144 ++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 macos/Tests/ColorizedGhosttyIconTests.swift diff --git a/macos/Tests/ColorizedGhosttyIconTests.swift b/macos/Tests/ColorizedGhosttyIconTests.swift new file mode 100644 index 000000000..bf2963f33 --- /dev/null +++ b/macos/Tests/ColorizedGhosttyIconTests.swift @@ -0,0 +1,144 @@ +import AppKit +import Foundation +import Testing +@testable import Ghostty + +struct ColorizedGhosttyIconTests { + private func makeIcon( + screenColors: [NSColor] = [ + NSColor(hex: "#112233")!, + NSColor(hex: "#AABBCC")!, + ], + ghostColor: NSColor = NSColor(hex: "#445566")!, + frame: Ghostty.MacOSIconFrame = .aluminum + ) -> ColorizedGhosttyIcon { + .init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) + } + + // MARK: - Codable + + @Test func codableRoundTripPreservesIcon() throws { + let icon = makeIcon(frame: .chrome) + let data = try JSONEncoder().encode(icon) + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + + #expect(decoded == icon) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + #expect(decoded.ghostColor.hexString == "#445566") + #expect(decoded.frame == .chrome) + } + + @Test func encodingWritesVersionAndHexColors() throws { + let icon = makeIcon(frame: .plastic) + let data = try JSONEncoder().encode(icon) + + let payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(payload["version"] as? Int == 1) + #expect(payload["screenColors"] as? [String] == ["#112233", "#AABBCC"]) + #expect(payload["ghostColor"] as? String == "#445566") + #expect(payload["frame"] as? String == "plastic") + } + + @Test func decodesLegacyV0PayloadWithoutVersion() throws { + let data = Data(""" + { + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "#445566", + "frame": "beige" + } + """.utf8) + + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + #expect(decoded.ghostColor.hexString == "#445566") + #expect(decoded.frame == .beige) + } + + @Test func decodingUnsupportedVersionThrowsDataCorrupted() { + let data = Data(""" + { + "version": 99, + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "#445566", + "frame": "chrome" + } + """.utf8) + + do { + _ = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + Issue.record("Expected decode to fail for unsupported version") + } catch let DecodingError.dataCorrupted(context) { + #expect(context.debugDescription.contains("Unsupported ColorizedGhosttyIcon version")) + } catch { + Issue.record("Expected DecodingError.dataCorrupted, got: \(error)") + } + } + + @Test func decodingInvalidGhostColorThrows() { + let data = Data(""" + { + "version": 1, + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "not-a-color", + "frame": "chrome" + } + """.utf8) + + do { + _ = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + Issue.record("Expected decode to fail for invalid ghost color") + } catch let DecodingError.dataCorrupted(context) { + #expect(context.debugDescription.contains("Failed to decode ghost color")) + } catch { + Issue.record("Expected DecodingError.dataCorrupted, got: \(error)") + } + } + + @Test func decodingInvalidScreenColorsDropsInvalidEntries() throws { + let data = Data(""" + { + "version": 1, + "screenColors": ["#112233", "invalid", "#AABBCC"], + "ghostColor": "#445566", + "frame": "chrome" + } + """.utf8) + + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + } + + // MARK: - Equatable + + @Test func equatableUsesHexColorAndFrameValues() { + let lhs = makeIcon( + screenColors: [ + NSColor(red: 0x11 / 255.0, green: 0x22 / 255.0, blue: 0x33 / 255.0, alpha: 1.0), + NSColor(red: 0xAA / 255.0, green: 0xBB / 255.0, blue: 0xCC / 255.0, alpha: 1.0), + ], + ghostColor: NSColor(red: 0x44 / 255.0, green: 0x55 / 255.0, blue: 0x66 / 255.0, alpha: 1.0), + frame: .chrome + ) + let rhs = makeIcon(frame: .chrome) + + #expect(lhs == rhs) + } + + @Test func equatableReturnsFalseForDifferentFrame() { + let lhs = makeIcon(frame: .aluminum) + let rhs = makeIcon(frame: .chrome) + #expect(lhs != rhs) + } + + @Test func equatableReturnsFalseForDifferentScreenColors() { + let lhs = makeIcon(screenColors: [NSColor(hex: "#112233")!, NSColor(hex: "#AABBCC")!]) + let rhs = makeIcon(screenColors: [NSColor(hex: "#112233")!, NSColor(hex: "#CCBBAA")!]) + #expect(lhs != rhs) + } + + @Test func equatableReturnsFalseForDifferentGhostColor() { + let lhs = makeIcon(ghostColor: NSColor(hex: "#445566")!) + let rhs = makeIcon(ghostColor: NSColor(hex: "#665544")!) + #expect(lhs != rhs) + } +} From 06084cd840daa053d80e56c78b03490b36dd67cd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 10:37:46 -0800 Subject: [PATCH 9/9] macos: various dock tile cleanups --- .../Custom App Icon/DockTilePlugin.swift | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift index a3e094f96..6c5abc198 100644 --- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -17,26 +17,8 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { 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: .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 { + /// 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 { @@ -45,31 +27,59 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { return url.path } - func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { + /// The primary NSDockTilePlugin function. + func setDockTile(_ dockTile: NSDockTile?) { + // If no dock tile or no access to Ghostty defaults, we can't do anything. + guard let dockTile, let ghosttyUserDefaults else { + iconChangeObserver = nil + return + } + + // Try to restore the previous icon on launch. + iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) + + // Setup a new observer for when the icon changes so we can update. This message + // is sent by the primary Ghostty app. + iconChangeObserver = DistributedNotificationCenter + .default() + .publisher(for: .ghosttyIconDidChange) + .map { [weak self] _ in self?.ghosttyUserDefaults?.appIcon } + .receive(on: DispatchQueue.global()) + .sink { [weak self] newIcon in self?.iconDidChange(newIcon, dockTile: dockTile) } + } + + private func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { guard let appIcon = newIcon?.image(in: pluginBundle) else { resetIcon(dockTile: dockTile) return } - let appBundlePath = getGhosttyAppPath() + + let appBundlePath = self.ghosttyAppPath NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } - func resetIcon(dockTile: NSDockTile) { - let appBundlePath = getGhosttyAppPath() + /// Reset the application icon and dock tile icon to the default. + private func resetIcon(dockTile: NSDockTile) { + let appBundlePath = self.ghosttyAppPath 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. + // 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) { + 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 { @@ -79,12 +89,12 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { } #endif } else { - // Use the bundled icon to keep the corner radius - // consistent with other apps. + // 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) } + // Notify Finder/Dock so icon caches refresh immediately. NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } @@ -103,4 +113,6 @@ private extension NSDockTile { } } +// This is required because of the DispatchQueue call above. This doesn't +// feel right but I don't know a better way to solve this. extension NSDockTile: @unchecked @retroactive Sendable {}