Fix Dock persistence for manual app icons (#2360)

This commit is contained in:
Austin Wang 2026-03-30 03:34:35 -07:00 committed by GitHub
parent 2c5c4fcf8d
commit 0666a98ae9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 367 additions and 13 deletions

View file

@ -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 = "<group>"; };
A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; };
A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = "<group>"; };
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; };
D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = "<group>"; };
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; };
A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; };
A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; };
A5001225 /* SocketControlSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlSettings.swift; sourceTree = "<group>"; };
@ -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 = "<group>"; };
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 = "<group>"; };
B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = "<group>"; };
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = "<group>"; };
@ -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 = (

View file

@ -35,6 +35,8 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSDockTilePlugIn</key>
<string>CmuxDockTilePlugin.plugin</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>NSHumanReadableCopyright</key>

View file

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

View file

@ -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()
}
}

View file

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