From a89938b44428aee4ddaa9b063f1c5095647291a7 Mon Sep 17 00:00:00 2001 From: codex-3720 Date: Sat, 23 May 2026 15:40:15 +0000 Subject: [PATCH] Fix default Dock icon rendering --- .../OpenBridge/Application/AppDelegate.swift | 35 ++++++++++++- macos/OpenBridge/Application/main.swift | 4 ++ .../SettingsManager/Models/AppIcon.swift | 45 +++++++++++++--- .../Suites/AppIconDockUITests.swift | 51 +++++++++++++++++++ .../Suites/AppIconTests.swift | 49 ++++++++++++++++++ 5 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 macos/OpenBridgeUITests/Suites/AppIconDockUITests.swift create mode 100644 macos/OpenBridgeUnitTests/Suites/AppIconTests.swift diff --git a/macos/OpenBridge/Application/AppDelegate.swift b/macos/OpenBridge/Application/AppDelegate.swift index 56c4ffd..3775fed 100644 --- a/macos/OpenBridge/Application/AppDelegate.swift +++ b/macos/OpenBridge/Application/AppDelegate.swift @@ -31,7 +31,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.appearance = SettingsManager.shared.appearance.nsAppearance // Apply persisted app icon on startup - SettingsManager.shared.appIcon.apply() + #if DEBUG + let appIconApplyResult = SettingsManager.shared.appIcon.apply() + writeDefaultAppIconE2EReportIfRequested(applyResult: appIconApplyResult) + #else + SettingsManager.shared.appIcon.apply() + #endif _ = GlobalShortcutManager.shared _ = ChatViewModel.shared @@ -79,6 +84,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { alert.runModal() } + #if DEBUG + private func writeDefaultAppIconE2EReportIfRequested(applyResult: AppIconApplyResult) { + let processInfo = ProcessInfo.processInfo + guard processInfo.arguments.contains("-e2eAssertDefaultAppIconUsesSystemRenderer"), + let reportPath = processInfo.environment["OPENBRIDGE_E2E_APP_ICON_REPORT_PATH"], + !reportPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return + } + + let reportURL = URL(fileURLWithPath: reportPath) + let report = [ + "appIcon=\(SettingsManager.shared.appIcon.rawValue)", + "runtimeIconOverrideCleared=\(!applyResult.didSetRuntimeIconOverride)", + ].joined(separator: "\n") + + do { + try FileManager.default.createDirectory( + at: reportURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "\(report)\n".write(to: reportURL, atomically: true, encoding: .utf8) + } catch { + Logger.app.error("Failed to write app icon E2E report: \(error.localizedDescription)") + } + } + #endif + func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { if terminateImmediately { return .terminateNow } // additional logic goes here diff --git a/macos/OpenBridge/Application/main.swift b/macos/OpenBridge/Application/main.swift index 1a57915..a1133c4 100644 --- a/macos/OpenBridge/Application/main.swift +++ b/macos/OpenBridge/Application/main.swift @@ -31,6 +31,10 @@ private var isPreviewMode: Bool { UserDefaults.standard.set(SystemAccentColor.default.rawValue, forKey: SettingsKeyName.accentColorName.key) } + if arguments.contains("-e2eResetAppIcon") { + UserDefaults.standard.set(AppIcon.default.rawValue, forKey: SettingsKeyName.appIcon.key) + } + SettingsManager.shared.enabledFeatures = SettingsManager.Defaults.enabledFeatures } #endif diff --git a/macos/OpenBridge/Backend/SettingsManager/Models/AppIcon.swift b/macos/OpenBridge/Backend/SettingsManager/Models/AppIcon.swift index 19942ba..de91380 100644 --- a/macos/OpenBridge/Backend/SettingsManager/Models/AppIcon.swift +++ b/macos/OpenBridge/Backend/SettingsManager/Models/AppIcon.swift @@ -8,6 +8,26 @@ import Cocoa import SwiftUI +@MainActor +protocol AppIconApplying { + func setBundleIcon(_ image: NSImage?, forFile path: String) -> Bool + func setApplicationIconImage(_ image: NSImage?) +} + +struct AppIconApplyResult: Equatable { + let didSetRuntimeIconOverride: Bool +} + +struct SystemAppIconApplier: AppIconApplying { + func setBundleIcon(_ image: NSImage?, forFile path: String) -> Bool { + NSWorkspace.shared.setIcon(image, forFile: path, options: []) + } + + func setApplicationIconImage(_ image: NSImage?) { + NSApp.applicationIconImage = image + } +} + enum AppIcon: String, CaseIterable, Identifiable, Codable { case `default` #if DEBUG @@ -59,14 +79,27 @@ enum AppIcon: String, CaseIterable, Identifiable, Codable { } } - func apply() { - let bundlePath = Bundle.main.bundleURL.path - let customIcon: NSImage? = self == .default ? nil : image - if !NSWorkspace.shared.setIcon(customIcon, forFile: bundlePath, options: []) { + @MainActor + @discardableResult + func apply( + to applier: AppIconApplying = SystemAppIconApplier(), + bundlePath: String = Bundle.main.bundleURL.path + ) -> AppIconApplyResult { + if self == .default { + if !applier.setBundleIcon(nil, forFile: bundlePath) { + Logger.app.error("Failed to reset bundle icon for \(bundlePath)") + } + applier.setApplicationIconImage(nil) + return AppIconApplyResult(didSetRuntimeIconOverride: false) + } + + let customIcon = image + if !applier.setBundleIcon(customIcon, forFile: bundlePath) { Logger.app.error("Failed to set bundle icon for \(bundlePath)") } - NSApp.applicationIconImage = nil - NSApp.applicationIconImage = image + applier.setApplicationIconImage(nil) + applier.setApplicationIconImage(customIcon) + return AppIconApplyResult(didSetRuntimeIconOverride: true) } #if DEBUG diff --git a/macos/OpenBridgeUITests/Suites/AppIconDockUITests.swift b/macos/OpenBridgeUITests/Suites/AppIconDockUITests.swift new file mode 100644 index 0000000..ce3f59d --- /dev/null +++ b/macos/OpenBridgeUITests/Suites/AppIconDockUITests.swift @@ -0,0 +1,51 @@ +import XCTest + +final class AppIconDockUITests: XCTestCase { + private var app: XCUIApplication! + private var reportURL: URL! + private var reportDirectory: URL! + + override func setUpWithError() throws { + continueAfterFailure = false + reportDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenBridgeAppIconUITests-\(UUID().uuidString)", isDirectory: true) + reportURL = reportDirectory.appendingPathComponent("app-icon-report.txt", isDirectory: false) + + app = XCUIApplication() + app.launchArguments = [ + "-e2eMode", + "-e2eResetAppIcon", + "-e2eAssertDefaultAppIconUsesSystemRenderer", + ] + app.launchEnvironment["OPENBRIDGE_E2E_APP_ICON_REPORT_PATH"] = reportURL.path + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8)) + } + + override func tearDownWithError() throws { + app?.terminate() + if let reportDirectory { + try? FileManager.default.removeItem(at: reportDirectory) + } + } + + func testDefaultDockIconLaunchLeavesRuntimeOverrideCleared() throws { + let report = try waitForReport(timeout: 8) + + XCTAssertTrue(report.contains("appIcon=default"), report) + XCTAssertTrue(report.contains("runtimeIconOverrideCleared=true"), report) + } + + private func waitForReport(timeout: TimeInterval) throws -> String { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if FileManager.default.fileExists(atPath: reportURL.path) { + return try String(contentsOf: reportURL, encoding: .utf8) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } while Date() < deadline + + XCTFail("Expected app icon E2E report at \(reportURL.path)") + return "" + } +} diff --git a/macos/OpenBridgeUnitTests/Suites/AppIconTests.swift b/macos/OpenBridgeUnitTests/Suites/AppIconTests.swift new file mode 100644 index 0000000..3113f17 --- /dev/null +++ b/macos/OpenBridgeUnitTests/Suites/AppIconTests.swift @@ -0,0 +1,49 @@ +import AppKit +@testable import OpenBridge +import Testing + +@MainActor +struct AppIconTests { + @Test + func `default icon clears runtime override without setting a static image`() { + let applier = RecordingAppIconApplier() + + let result = AppIcon.default.apply(to: applier, bundlePath: "/Applications/OpenBridge.app") + + #expect(applier.bundleIconCalls.count == 1) + #expect(applier.bundleIconCalls[0].path == "/Applications/OpenBridge.app") + #expect(applier.bundleIconCalls[0].image == nil) + #expect(applier.applicationIconImages.count == 1) + #expect(applier.applicationIconImages[0] == nil) + #expect(result.didSetRuntimeIconOverride == false) + } + + @Test + func `alternate icon still installs a runtime image override`() { + let applier = RecordingAppIconApplier() + + let result = AppIcon.appIconBLUEBLOCK.apply(to: applier, bundlePath: "/Applications/OpenBridge.app") + + #expect(applier.bundleIconCalls.count == 1) + #expect(applier.bundleIconCalls[0].image != nil) + #expect(applier.applicationIconImages.count == 2) + #expect(applier.applicationIconImages[0] == nil) + #expect(applier.applicationIconImages[1] != nil) + #expect(result.didSetRuntimeIconOverride) + } +} + +@MainActor +private final class RecordingAppIconApplier: AppIconApplying { + private(set) var bundleIconCalls: [(image: NSImage?, path: String)] = [] + private(set) var applicationIconImages: [NSImage?] = [] + + func setBundleIcon(_ image: NSImage?, forFile path: String) -> Bool { + bundleIconCalls.append((image, path)) + return true + } + + func setApplicationIconImage(_ image: NSImage?) { + applicationIconImages.append(image) + } +}