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
35 changes: 34 additions & 1 deletion macos/OpenBridge/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions macos/OpenBridge/Application/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 39 additions & 6 deletions macos/OpenBridge/Backend/SettingsManager/Models/AppIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions macos/OpenBridgeUITests/Suites/AppIconDockUITests.swift
Original file line number Diff line number Diff line change
@@ -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 ""
}
}
49 changes: 49 additions & 0 deletions macos/OpenBridgeUnitTests/Suites/AppIconTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading