Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Sources/KeyboardShortcutSettingsFileStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -148,6 +155,7 @@ final class CmuxSettingsFileStore {

bootstrapPrimaryTemplateIfNeeded()
reload()
isInitializationComplete = true
guard startWatching else { return }

primaryWatcher = ShortcutSettingsFileWatcher(path: primaryPath, fileManager: fileManager) { [weak self] in
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions cmuxTests/WorkspaceUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down