diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5dc245ec..1710f987 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -26,6 +26,8 @@ A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; + D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; + D1320AA0D1320AA0D1320AA2 /* CmuxDockTilePlugin.plugin in Copy Dock Tile Plugin */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; @@ -154,6 +156,17 @@ name = "Copy CLI"; runOnlyForDeploymentPostprocessing = 0; }; + D1320AA0D1320AA0D1320AA6 /* Copy Dock Tile Plugin */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D1320AA0D1320AA0D1320AA2 /* CmuxDockTilePlugin.plugin in Copy Dock Tile Plugin */, + ); + name = "Copy Dock Tile Plugin"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXContainerItemProxy section */ @@ -178,6 +191,13 @@ remoteGlobalIDString = A5001050 /* GhosttyTabs */; remoteInfo = GhosttyTabs; }; + D1320AA0D1320AA0D1320AA3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A5001070 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D1320AA0D1320AA0D1320AA8 /* CmuxDockTilePlugin */; + remoteInfo = CmuxDockTilePlugin; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -202,7 +222,8 @@ A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; - A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; + D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = ""; }; + A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; A5001225 /* SocketControlSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlSettings.swift; sourceTree = ""; }; @@ -258,6 +279,7 @@ A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; + D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CmuxDockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = ""; }; B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = ""; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; @@ -339,6 +361,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D1320AA0D1320AA0D1320AA7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXResourcesBuildPhase section */ @@ -369,6 +398,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D1320AA0D1320AA0D1320AA9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -435,6 +471,7 @@ A5001225 /* SocketControlSettings.swift */, A5001600 /* SentryHelper.swift */, A5001620 /* AppleScriptSupport.swift */, + D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -501,6 +538,7 @@ children = ( A5001000 /* cmux.app */, B9000004A1B2C3D4E5F60719 /* cmux */, + D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */, 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */, F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */, ); @@ -576,6 +614,7 @@ A5001051 /* Sources */, A5001030 /* Frameworks */, A5001102 /* Resources */, + D1320AA0D1320AA0D1320AA6 /* Copy Dock Tile Plugin */, A5001020 /* Embed Frameworks */, B900000AA1B2C3D4E5F60719 /* Copy CLI */, A5001300A1B2C3D4E5F60719 /* Copy Ghostty Resources */, @@ -584,6 +623,7 @@ ); dependencies = ( B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */, + D1320AA0D1320AA0D1320AB1 /* PBXTargetDependency */, ); packageProductDependencies = ( A5001231 /* Sparkle */, @@ -597,6 +637,23 @@ productReference = A5001000 /* cmux.app */; productType = "com.apple.product-type.application"; }; + D1320AA0D1320AA0D1320AA8 /* CmuxDockTilePlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = D1320AA0D1320AA0D1320AB4 /* Build configuration list for PBXNativeTarget "CmuxDockTilePlugin" */; + buildPhases = ( + D1320AA0D1320AA0D1320AB0 /* Sources */, + D1320AA0D1320AA0D1320AA7 /* Frameworks */, + D1320AA0D1320AA0D1320AA9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CmuxDockTilePlugin; + productName = CmuxDockTilePlugin; + productReference = D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */; + productType = "com.apple.product-type.bundle"; + }; B9000005A1B2C3D4E5F60719 /* cmux-cli */ = { isa = PBXNativeTarget; buildConfigurationList = B9000007A1B2C3D4E5F60719 /* Build configuration list for PBXNativeTarget "cmux-cli" */; @@ -700,6 +757,7 @@ projectRoot = ""; targets = ( A5001050 /* GhosttyTabs */, + D1320AA0D1320AA0D1320AA8 /* CmuxDockTilePlugin */, B9000005A1B2C3D4E5F60719 /* cmux-cli */, CB450DF0F0B3839599082C4D /* cmuxUITests */, F1000004A1B2C3D4E5F60718 /* cmuxTests */, @@ -769,8 +827,16 @@ A5001610 /* SessionPersistence.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, - A5001652 /* CmuxConfigExecutor.swift in Sources */, - A5001654 /* CmuxDirectoryTrust.swift in Sources */, + A5001652 /* CmuxConfigExecutor.swift in Sources */, + A5001654 /* CmuxDirectoryTrust.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D1320AA0D1320AA0D1320AB0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -860,6 +926,11 @@ target = B9000005A1B2C3D4E5F60719 /* cmux-cli */; targetProxy = B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */; }; + D1320AA0D1320AA0D1320AB1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D1320AA0D1320AA0D1320AA8 /* CmuxDockTilePlugin */; + targetProxy = D1320AA0D1320AA0D1320AA3 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1004,6 +1075,55 @@ }; name = Release; }; + D1320AA0D1320AA0D1320AB2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 78; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "cmux Dock Tile Plugin"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.63.1; + PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.docktileplugin.debug; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_VERSION = 5.0; + WRAPPER_EXTENSION = plugin; + }; + name = Debug; + }; + D1320AA0D1320AA0D1320AB3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 78; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "cmux Dock Tile Plugin"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.63.1; + PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.docktileplugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + WRAPPER_EXTENSION = plugin; + }; + name = Release; + }; B9000008A1B2C3D4E5F60719 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1192,6 +1312,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D1320AA0D1320AA0D1320AB4 /* Build configuration list for PBXNativeTarget "CmuxDockTilePlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D1320AA0D1320AA0D1320AB2 /* Debug */, + D1320AA0D1320AA0D1320AB3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "cmuxUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Resources/Info.plist b/Resources/Info.plist index c96a632f..f41ba036 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -35,6 +35,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + NSDockTilePlugIn + CmuxDockTilePlugin.plugin LSApplicationCategoryType public.app-category.developer-tools NSHumanReadableCopyright diff --git a/Sources/AppIconDockTilePlugin.swift b/Sources/AppIconDockTilePlugin.swift new file mode 100644 index 00000000..47f68526 --- /dev/null +++ b/Sources/AppIconDockTilePlugin.swift @@ -0,0 +1,120 @@ +import AppKit + +private let cmuxAppIconDidChangeNotification = Notification.Name("com.cmuxterm.appIconDidChange") +private let cmuxAppIconModeKey = "appIconMode" + +private enum DockTileAppIconMode: String { + case automatic + case light + case dark + + init(defaultsValue: String?) { + self = Self(rawValue: defaultsValue ?? "") ?? .automatic + } + + var imageName: NSImage.Name? { + switch self { + case .automatic: + return nil + case .light: + return NSImage.Name("AppIconLight") + case .dark: + return NSImage.Name("AppIconDark") + } + } +} + +final class CmuxDockTilePlugin: NSObject, NSDockTilePlugIn { + // The plugin can stay alive while the app remains in the Dock, even after quit. + // Keep the state minimal and derive everything from the enclosing app bundle. + private let pluginBundle = Bundle(for: CmuxDockTilePlugin.self) + private var iconChangeObserver: NSObjectProtocol? + + deinit { + if let iconChangeObserver { + DistributedNotificationCenter.default().removeObserver(iconChangeObserver) + } + } + + func setDockTile(_ dockTile: NSDockTile?) { + if let iconChangeObserver { + DistributedNotificationCenter.default().removeObserver(iconChangeObserver) + self.iconChangeObserver = nil + } + + guard let dockTile else { return } + updateDockTile(dockTile) + + iconChangeObserver = DistributedNotificationCenter.default().addObserver( + forName: cmuxAppIconDidChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + guard let self else { return } + self.updateDockTile(dockTile) + } + } + + private var appBundleURL: URL? { + Self.appBundleURL(for: pluginBundle.bundleURL) + } + + private var appBundle: Bundle? { + guard let appBundleURL else { return nil } + return Bundle(url: appBundleURL) + } + + private var appDefaults: UserDefaults? { + guard let bundleIdentifier = appBundle?.bundleIdentifier else { return nil } + return UserDefaults(suiteName: bundleIdentifier) + } + + private func updateDockTile(_ dockTile: NSDockTile) { + let mode = DockTileAppIconMode(defaultsValue: appDefaults?.string(forKey: cmuxAppIconModeKey)) + guard let imageName = mode.imageName, + let icon = appBundle?.image(forResource: imageName) else { + dockTile.showDefaultAppIcon() + return + } + + dockTile.showIcon(icon) + } + + /// Determine the enclosing app bundle for the dock tile plugin bundle. + static func appBundleURL(for pluginBundleURL: URL) -> URL? { + var url = pluginBundleURL + while true { + if url.pathExtension.compare("app", options: .caseInsensitive) == .orderedSame { + return url + } + + let parent = url.deletingLastPathComponent() + if parent.path == url.path { + return nil + } + + url = parent + } + } +} + +private extension NSDockTile { + func showDefaultAppIcon() { + DispatchQueue.main.async { + self.contentView = nil + self.display() + } + } + + func showIcon(_ newIcon: NSImage) { + 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 {} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index c16f88b5..7a24a3ed 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3699,6 +3699,41 @@ enum AppIconMode: String, CaseIterable, Identifiable { enum AppIconSettings { static let modeKey = "appIconMode" static let defaultMode: AppIconMode = .automatic + private static let dockTileIconDidChangeNotification = Notification.Name("com.cmuxterm.appIconDidChange") + + struct Environment { + let imageForMode: (AppIconMode) -> NSImage? + let setApplicationIconImage: (NSImage) -> Void + let startAppearanceObservation: () -> Void + let stopAppearanceObservation: () -> Void + let notifyDockTilePlugin: () -> Void + + static func live() -> Self { + Self( + imageForMode: { mode in + guard let imageName = mode.imageName else { return nil } + return NSImage(named: imageName) + }, + setApplicationIconImage: { icon in + NSApplication.shared.applicationIconImage = icon + }, + startAppearanceObservation: { + AppIconAppearanceObserver.shared.startObserving() + }, + stopAppearanceObservation: { + AppIconAppearanceObserver.shared.stopObserving() + }, + notifyDockTilePlugin: { + DistributedNotificationCenter.default().postNotificationName( + AppIconSettings.dockTileIconDidChangeNotification, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + ) + } + } static func resolvedMode(defaults: UserDefaults = .standard) -> AppIconMode { guard let raw = defaults.string(forKey: modeKey), @@ -3708,21 +3743,21 @@ enum AppIconSettings { return mode } - static func applyIcon(_ mode: AppIconMode) { + static func applyIcon(_ mode: AppIconMode, environment: Environment = .live()) { switch mode { case .automatic: - AppIconAppearanceObserver.shared.startObserving() + environment.startAppearanceObservation() case .light: - AppIconAppearanceObserver.shared.stopObserving() - if let icon = NSImage(named: "AppIconLight") { - NSApplication.shared.applicationIconImage = icon - } + environment.stopAppearanceObservation() + guard let icon = environment.imageForMode(.light) else { return } + environment.setApplicationIconImage(icon) case .dark: - AppIconAppearanceObserver.shared.stopObserving() - if let icon = NSImage(named: "AppIconDark") { - NSApplication.shared.applicationIconImage = icon - } + environment.stopAppearanceObservation() + guard let icon = environment.imageForMode(.dark) else { return } + environment.setApplicationIconImage(icon) } + + environment.notifyDockTilePlugin() } } diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index dff427f6..6d6ca4f4 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -13,6 +13,74 @@ import UserNotifications @testable import cmux #endif +@MainActor +final class AppIconSettingsTests: XCTestCase { + func testApplyDarkSetsRuntimeIconAndNotifiesDockTilePlugin() { + let expectedIcon = NSImage(size: NSSize(width: 16, height: 16)) + var receivedRuntimeIcon: NSImage? + var dockTileNotificationCount = 0 + var startObservationCallCount = 0 + var stopObservationCallCount = 0 + + let environment = AppIconSettings.Environment( + imageForMode: { mode in + XCTAssertEqual(mode, .dark) + return expectedIcon + }, + setApplicationIconImage: { icon in + receivedRuntimeIcon = icon + }, + startAppearanceObservation: { + startObservationCallCount += 1 + }, + stopAppearanceObservation: { + stopObservationCallCount += 1 + }, + notifyDockTilePlugin: { + dockTileNotificationCount += 1 + } + ) + + AppIconSettings.applyIcon(.dark, environment: environment) + + XCTAssertTrue(receivedRuntimeIcon === expectedIcon) + XCTAssertEqual(dockTileNotificationCount, 1) + XCTAssertEqual(startObservationCallCount, 0) + XCTAssertEqual(stopObservationCallCount, 1) + } + + func testApplyAutomaticStartsObservationAndNotifiesDockTilePlugin() { + var dockTileNotificationCount = 0 + var startObservationCallCount = 0 + var stopObservationCallCount = 0 + + let environment = AppIconSettings.Environment( + imageForMode: { mode in + XCTFail("Automatic mode should not request a manual icon image: \(mode.rawValue)") + return nil + }, + setApplicationIconImage: { _ in + XCTFail("Automatic mode should delegate live updates to the appearance observer") + }, + startAppearanceObservation: { + startObservationCallCount += 1 + }, + stopAppearanceObservation: { + stopObservationCallCount += 1 + }, + notifyDockTilePlugin: { + dockTileNotificationCount += 1 + } + ) + + AppIconSettings.applyIcon(.automatic, environment: environment) + + XCTAssertEqual(dockTileNotificationCount, 1) + XCTAssertEqual(startObservationCallCount, 1) + XCTAssertEqual(stopObservationCallCount, 0) + } +} + @MainActor final class NotificationDockBadgeTests: XCTestCase { private final class NotificationSettingsAlertSpy: NSAlert {