diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 7c055ffb4b60..5ba0d4c77efe 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -132,6 +132,13 @@ final class CmuxSettingsFileStore { private var activeManagedUserDefaults: [String: ManagedSettingsValue] = [:] private var activeManagedCustomSettings = ManagedCustomSettings() private var isApplyingManagedSettings = false + // Set to true after the first reload() in init() completes. Used to defer + // AppIcon application: on macOS 26 (build 25D771280a+), applicationDidFinishLaunching + // can fire before cmuxApp.init() body finishes, so isApplicationFinishedLaunching() + // returns true while we're still in App.init() re-entrancy — calling NSApplication + // APIs at that point crashes. AppDelegate.ensureApplicationIcon() handles the initial + // apply once it's truly safe. + private var isInitializationComplete = false private(set) var activeSourcePath: String? init( @@ -148,6 +155,7 @@ final class CmuxSettingsFileStore { bootstrapPrimaryTemplateIfNeeded() reload() + isInitializationComplete = true guard startWatching else { return } primaryWatcher = ShortcutSettingsFileWatcher(path: primaryPath, fileManager: fileManager) { [weak self] in @@ -1189,6 +1197,7 @@ final class CmuxSettingsFileStore { let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system LanguageSettings.apply(language) case AppIconSettings.modeKey: + guard isInitializationComplete else { break } AppIconSettings.applyIcon(AppIconSettings.resolvedMode()) default: break @@ -1235,6 +1244,7 @@ final class CmuxSettingsFileStore { let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system LanguageSettings.apply(language) case AppIconSettings.modeKey: + guard isInitializationComplete else { break } AppIconSettings.applyIcon(AppIconSettings.resolvedMode()) default: break diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index f6c3708f4f0c..38fc47cdad4f 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -844,6 +844,73 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertEqual(dockTileNotificationCount, 0) } + func testSettingsFileStoreDoesNotApplyAppIconDuringInitEvenIfLaunchIsAlreadyFinished() throws { + // Regression test for macOS 26 (build 25D771280a+) where applicationDidFinishLaunching + // fires before cmuxApp.init() body completes, causing isApplicationFinishedLaunching() + // to return true while CmuxSettingsFileStore.init() is still running. Without the + // isInitializationComplete guard, applyIcon(.automatic) would crash by calling + // NSApplication APIs that are unsafe during App.init() re-entrancy. + let defaults = UserDefaults.standard + let previousMode = defaults.object(forKey: AppIconSettings.modeKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousMode { + defaults.set(previousMode, forKey: AppIconSettings.modeKey) + } else { + defaults.removeObject(forKey: AppIconSettings.modeKey) + } + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + AppIconSettings.resetLiveEnvironmentProviderForTesting() + } + + defaults.removeObject(forKey: AppIconSettings.modeKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "appIcon": "automatic" + } + } + """, + to: settingsFileURL + ) + + var startObservationCallCount = 0 + var stopObservationCallCount = 0 + var runtimeIconSetCount = 0 + AppIconSettings.setLiveEnvironmentProviderForTesting { + AppIconSettings.Environment( + // Simulate macOS 26: launch is already "finished" before store init runs. + isApplicationFinishedLaunching: { true }, + imageForMode: { _ in nil }, + setApplicationIconImage: { _ in runtimeIconSetCount += 1 }, + startAppearanceObservation: { startObservationCallCount += 1 }, + stopAppearanceObservation: { stopObservationCallCount += 1 }, + notifyDockTilePlugin: {} + ) + } + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(startObservationCallCount, 0, "startAppearanceObservation must not be called during store init") + XCTAssertEqual(stopObservationCallCount, 0) + XCTAssertEqual(runtimeIconSetCount, 0) + } + func testSettingsFileStoreRejectsModifierFreeFirstStroke() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) }