From 67270ed144e8c2866b89a3d4abe1599785276a7b Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 02:44:51 -0400 Subject: [PATCH 1/3] Add Mac menu bar transfer companion --- BitMatch/App/BitMatchApp.swift | 90 ++++---- BitMatch/App/MacMenuBarCompanion.swift | 193 ++++++++++++++++++ BitMatch/Views/TransferExitGuardPolicy.swift | 16 +- BitMatchTests/MacMenuBarCompanionTests.swift | 23 +++ .../TransferExitGuardPolicyTests.swift | 61 +++++- 5 files changed, 332 insertions(+), 51 deletions(-) create mode 100644 BitMatch/App/MacMenuBarCompanion.swift create mode 100644 BitMatchTests/MacMenuBarCompanionTests.swift diff --git a/BitMatch/App/BitMatchApp.swift b/BitMatch/App/BitMatchApp.swift index 376b57fb..73b4be00 100644 --- a/BitMatch/App/BitMatchApp.swift +++ b/BitMatch/App/BitMatchApp.swift @@ -74,7 +74,7 @@ struct BitMatchApp: App { } var body: some Scene { - WindowGroup { + WindowGroup(id: "main") { Group { #if DEBUG if launchesInterfaceLab { InterfaceLabView() } @@ -165,6 +165,16 @@ struct BitMatchApp: App { #endif } + MenuBarExtra { + MacMenuBarCompanion( + coordinator: environment.coordinator, + appDelegate: appDelegate + ) + } label: { + MacMenuBarStatusLabel(coordinator: environment.coordinator) + } + .menuBarExtraStyle(.window) + Settings { PreferencesWindow( coordinator: environment.coordinator, @@ -281,7 +291,8 @@ extension FocusedValues { final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var coordinator: SharedAppCoordinator? private weak var contentWindow: NSWindow? - private weak var windowAllowedToClose: NSWindow? + /// Keep the actual SwiftUI window mounted while a transfer runs out of sight. + private var hiddenTransferWindow: NSWindow? private var exitGuard = TransferExitGuardStateMachine() private var exitGuardAlert: NSAlert? @@ -317,23 +328,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } func windowShouldClose(_ sender: NSWindow) -> Bool { - if windowAllowedToClose === sender { - windowAllowedToClose = nil - return true - } - switch exitGuard.request(needsGuard: shouldAskToStopTransfer) { - case .allowNow: + switch MacMainWindowClosePolicy.action(transferRequiresSettlement: shouldAskToStopTransfer) { + case .close: return true - case .presentPrompt: - presentExitGuard(for: .close(sender)) - return false - case .keepWaiting: - NSSound.beep() - exitGuardAlert?.window.makeKeyAndOrderFront(nil) + case .hideAndContinue: + hiddenTransferWindow = sender + sender.orderOut(nil) return false } } + func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + if contentWindow === window { contentWindow = nil } + if hiddenTransferWindow === window { hiddenTransferWindow = nil } + } + + /// Used by the menu bar and Dock. Focus the mounted view (whether visible + /// or hidden) instead of asking WindowGroup to create another one. + @discardableResult + func focusExistingContentWindow() -> Bool { + guard let window = hiddenTransferWindow ?? contentWindow else { return false } + hiddenTransferWindow = nil + if window.isMiniaturized { window.deminiaturize(nil) } + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + return true + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + // We handled an existing window ourselves. Otherwise let AppKit and + // WindowGroup create the main window after an ordinary idle close. + return !focusExistingContentWindow() + } + private var shouldAskToStopTransfer: Bool { guard let coordinator else { assertionFailure("The app delegate must receive the shared coordinator before a window can close or the app can quit.") @@ -348,7 +376,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } private enum ExitAction { - case close(NSWindow) case quit(NSApplication) } @@ -366,16 +393,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.exitGuardAlert = nil let choice: TransferExitGuardStateMachine.Choice = response == .alertSecondButtonReturn ? .stopTransfer : .keepCopying - let actionKind: TransferExitGuardStateMachine.Action - switch action { - case .quit: actionKind = .quit - case .close: actionKind = .closeWindow - } - switch self.exitGuard.resolve(choice, for: actionKind) { + switch self.exitGuard.resolve(choice, for: .quit) { case .denyExit: - if case .quit(let application) = action { - application.reply(toApplicationShouldTerminate: false) - } + if case .quit(let application) = action { application.reply(toApplicationShouldTerminate: false) } case .allowExit: self.finish(action: action, allowExit: true) case .requestCancellation: @@ -383,6 +403,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + // Quit can come from the menu bar while the transfer window is hidden. + // Bring it back before attaching the settlement sheet. + _ = focusExistingContentWindow() let window = contentWindow ?? Self.contentWindowCandidate() if let window { alert.beginSheetModal(for: window, completionHandler: finish) @@ -398,9 +421,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { guard let coordinator else { throw SharedAppCoordinator.CancellationSettlementError.journalRecordMissing } - let origin: TransferCancelOrigin - switch action { case .quit: origin = .appQuit; case .close: origin = .windowClose } - try await coordinator.cancelOperationAndWaitForSettlement(origin: origin) + try await coordinator.cancelOperationAndWaitForSettlement(origin: .appQuit) _ = exitGuard.settlementFinished(success: true) finish(action: action, allowExit: true) } catch { @@ -415,24 +436,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { switch action { case .quit(let application): application.reply(toApplicationShouldTerminate: allowExit) - case .close(let window): - guard allowExit else { return } - close(window) - } - } - - private func close(_ window: NSWindow) { - windowAllowedToClose = window - window.performClose(nil) - // `performClose` normally re-enters `windowShouldClose` synchronously. - // Clear defensively if AppKit declines before asking the delegate. - DispatchQueue.main.async { [weak self, weak window] in - guard let self, let window, self.windowAllowedToClose === window else { return } - self.windowAllowedToClose = nil } } private func presentSettlementError(_ message: String) { + _ = focusExistingContentWindow() let alert = NSAlert() alert.messageText = "Could Not Save the Interrupted Transfer" alert.informativeText = message diff --git a/BitMatch/App/MacMenuBarCompanion.swift b/BitMatch/App/MacMenuBarCompanion.swift new file mode 100644 index 00000000..64c52e27 --- /dev/null +++ b/BitMatch/App/MacMenuBarCompanion.swift @@ -0,0 +1,193 @@ +import AppKit +import BitMatchEngine +import SwiftUI + +/// A read-only, small window onto the same operation shown in BitMatch. +/// Nothing here starts, pauses, or settles a transfer. +@MainActor +struct MacMenuBarCompanion: View { + @ObservedObject private var coordinator: SharedAppCoordinator + @ObservedObject private var stateService: OperationStateService + @ObservedObject private var liveProgress: LiveProgressFeed + @ObservedObject private var journal: LocalTransferJournal + @Environment(\.openWindow) private var openWindow + private let appDelegate: AppDelegate + + init(coordinator: SharedAppCoordinator, appDelegate: AppDelegate) { + self.coordinator = coordinator + self.stateService = coordinator.stateService + self.liveProgress = coordinator.liveProgress + self.journal = coordinator.transferJournal + self.appDelegate = appDelegate + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("BitMatch") + .font(.headline) + + if coordinator.isOperationInProgress && coordinator.currentMode == .copyAndVerify { + activeTransfer + } else if coordinator.queueIsRunning { + Label("Preparing the next card", systemImage: "hourglass") + .foregroundStyle(.secondary) + } else { + Text("No transfer running") + .foregroundStyle(.secondary) + } + + if let recent = MenuBarRecentTransfer.latest(in: journal.records) { + Divider() + VStack(alignment: .leading, spacing: 3) { + Text("Last transfer") + .font(.caption) + .foregroundStyle(.secondary) + Text(recent.cardName) + .lineLimit(1) + Text(recent.status) + .font(.caption) + .foregroundStyle(recent.needsAttention ? Color.orange : Color.secondary) + } + .accessibilityElement(children: .combine) + } + + Divider() + HStack { + Button("Open BitMatch") { + if !appDelegate.focusExistingContentWindow() { + openWindow(id: "main") + NSApplication.shared.activate(ignoringOtherApps: true) + } + } + .keyboardShortcut(.return, modifiers: []) + Spacer() + Button("Quit") { NSApplication.shared.terminate(nil) } + } + } + .padding(16) + .frame(width: 320, alignment: .leading) + } + + private var activeTransfer: some View { + let presentation = TransferProgressPresentation.make(coordinator: coordinator) + return VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text(presentation.title).font(.headline) + Spacer() + if let percent = presentation.percentText { + Text(percent).monospacedDigit().foregroundStyle(.secondary) + } + } + if let fraction = presentation.fraction { + ProgressView(value: fraction) + .accessibilityLabel(presentation.title) + .accessibilityValue(presentation.percentText ?? "") + } else { + ProgressView().accessibilityLabel(presentation.title) + } + if let detail = presentation.detail { + Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + if !presentation.destinations.isEmpty { + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(presentation.destinations) { destination in + HStack(spacing: 8) { + Text(destination.name).lineLimit(1) + Spacer(minLength: 4) + Text(MenuBarDestinationLabel.text(for: destination.state)) + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + } + } + } + .frame(maxHeight: 145) + } + if let issueLine = presentation.issueLine { + Text(issueLine).font(.caption).foregroundStyle(.orange) + } + } + } +} + +/// The menu bar item always occupies the same width; progress never claims a +/// green verdict, including while report generation holds it near 100%. +@MainActor +struct MacMenuBarStatusLabel: View { + @ObservedObject private var coordinator: SharedAppCoordinator + @ObservedObject private var stateService: OperationStateService + @ObservedObject private var liveProgress: LiveProgressFeed + + init(coordinator: SharedAppCoordinator) { + self.coordinator = coordinator + self.stateService = coordinator.stateService + self.liveProgress = coordinator.liveProgress + } + + var body: some View { + Group { + if coordinator.isOperationInProgress && coordinator.currentMode == .copyAndVerify { + let presentation = TransferProgressPresentation.make(coordinator: coordinator) + ZStack { + Circle().stroke(Color.secondary.opacity(0.4), lineWidth: 2.5) + if let fraction = presentation.fraction { + Circle() + .trim(from: 0, to: fraction) + .stroke(Color.primary, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } else { + Circle().fill(Color.primary).frame(width: 4, height: 4) + } + } + .accessibilityLabel("BitMatch, \(presentation.title), \(presentation.percentText ?? "progress not yet measured")") + } else { + Image(systemName: "circle.hexagongrid.fill") + .resizable() + .scaledToFit() + .accessibilityLabel("BitMatch") + } + } + .frame(width: 18, height: 18) + } +} + +enum MenuBarDestinationLabel { + static func text(for state: DestinationProgressRow.State) -> String { + switch state { + case .waiting: "Waiting" + case .copying: "Copying" + case .copied: "Processed · result pending" + case .verifying: "Verifying" + } + } +} + +struct MenuBarRecentTransfer: Equatable { + let cardName: String + let status: String + let needsAttention: Bool + + static func latest(in records: [LocalTransferRecord]) -> Self? { + guard let record = records + .filter({ $0.state != .queued && $0.state != .running }) + .max(by: { ($0.endedAt ?? $0.createdAt) < ($1.endedAt ?? $1.createdAt) }) else { + return nil + } + guard let result = status(for: record.state) else { return nil } + return Self(cardName: record.title, status: result.text, needsAttention: result.needsAttention) + } + + static func status(for state: LocalTransferState) -> (text: String, needsAttention: Bool)? { + switch state { + case .completed: ("Completed · review results in BitMatch", false) + case .issues: ("Needs review", true) + case .failed: ("Transfer failed", true) + case .cancelled: ("Transfer cancelled", true) + case .interrupted: ("Transfer interrupted", true) + case .queued, .running: nil + } + } +} diff --git a/BitMatch/Views/TransferExitGuardPolicy.swift b/BitMatch/Views/TransferExitGuardPolicy.swift index be7e524b..36f3a12c 100644 --- a/BitMatch/Views/TransferExitGuardPolicy.swift +++ b/BitMatch/Views/TransferExitGuardPolicy.swift @@ -10,9 +10,23 @@ enum TransferExitGuardPolicy { isCopyAndVerifyMode: Bool, lastOperationWasCompare: Bool ) -> Bool { - (isOperationInProgress || queueIsRunning) + // A queue can be preparing its next card while a prior Compare is + // still the last recorded operation. It still requires settlement. + queueIsRunning || (isOperationInProgress && isCopyAndVerifyMode && !lastOperationWasCompare + ) + } +} + +/// A running transfer belongs to the app, not to the main window's view. +/// Closing that window keeps its SwiftUI tree and the shared coordinator alive; +/// Quit still goes through `TransferExitGuardPolicy` and journal settlement. +enum MacMainWindowClosePolicy { + enum Action: Equatable { case close, hideAndContinue } + + static func action(transferRequiresSettlement: Bool) -> Action { + transferRequiresSettlement ? .hideAndContinue : .close } } diff --git a/BitMatchTests/MacMenuBarCompanionTests.swift b/BitMatchTests/MacMenuBarCompanionTests.swift new file mode 100644 index 00000000..b705eb70 --- /dev/null +++ b/BitMatchTests/MacMenuBarCompanionTests.swift @@ -0,0 +1,23 @@ +import Testing +@testable import BitMatch +import BitMatchEngine + +struct MacMenuBarCompanionTests { + @Test func destinationCopyCountNeverBecomesAVerifiedVerdict() { + #expect(MenuBarDestinationLabel.text(for: .waiting) == "Waiting") + #expect(MenuBarDestinationLabel.text(for: .copying) == "Copying") + // The engine counts both successful and failed files as processed. + #expect(MenuBarDestinationLabel.text(for: .copied) == "Processed · result pending") + #expect(MenuBarDestinationLabel.text(for: .verifying) == "Verifying") + } + + @Test func recentStatusUsesSavedTerminalStateAndMakesNoSafetyClaim() { + #expect(MenuBarRecentTransfer.status(for: .queued)?.text == nil) + #expect(MenuBarRecentTransfer.status(for: .running)?.text == nil) + #expect(MenuBarRecentTransfer.status(for: .completed)?.text == "Completed · review results in BitMatch") + #expect(MenuBarRecentTransfer.status(for: .issues)?.needsAttention == true) + #expect(MenuBarRecentTransfer.status(for: .failed)?.text == "Transfer failed") + #expect(MenuBarRecentTransfer.status(for: .cancelled)?.text == "Transfer cancelled") + #expect(MenuBarRecentTransfer.status(for: .interrupted)?.text == "Transfer interrupted") + } +} diff --git a/BitMatchTests/TransferExitGuardPolicyTests.swift b/BitMatchTests/TransferExitGuardPolicyTests.swift index 9c7260e6..4f4fc51d 100644 --- a/BitMatchTests/TransferExitGuardPolicyTests.swift +++ b/BitMatchTests/TransferExitGuardPolicyTests.swift @@ -1,3 +1,5 @@ +import AppKit +import Foundation import Testing @testable import BitMatch @@ -34,6 +36,13 @@ struct TransferExitGuardPolicyTests { isCopyAndVerifyMode: true, lastOperationWasCompare: false )) + // The last Compare marker can remain during queue preflight. + #expect(TransferExitGuardPolicy.shouldAsk( + isOperationInProgress: false, + queueIsRunning: true, + isCopyAndVerifyMode: false, + lastOperationWasCompare: true + )) } @Test func repeatedQuitKeepsOnePromptAndOneSettlement() { @@ -47,25 +56,59 @@ struct TransferExitGuardPolicyTests { #expect(state.request(needsGuard: true) == .presentPrompt) } - @Test func keepCopyingCancelsQuitAndWindowClose() { + @Test func closingActiveTransferHidesWhileQuitStillPrompts() { + #expect(MacMainWindowClosePolicy.action(transferRequiresSettlement: true) == .hideAndContinue) + #expect(MacMainWindowClosePolicy.action(transferRequiresSettlement: false) == .close) + var quit = TransferExitGuardStateMachine() #expect(quit.request(needsGuard: true) == .presentPrompt) #expect(quit.resolve(.keepCopying, for: .quit) == .denyExit) #expect(quit.state == .idle) - - var close = TransferExitGuardStateMachine() - #expect(close.request(needsGuard: true) == .presentPrompt) - #expect(close.resolve(.keepCopying, for: .closeWindow) == .denyExit) - #expect(close.state == .idle) - // The window may now be gone, but Cmd-Q still uses coordinator state. - #expect(close.request(needsGuard: true) == .presentPrompt) + #expect(quit.request(needsGuard: true) == .presentPrompt) } @Test func failedPersistenceDeniesExitAndRearmsTheGuard() { var state = TransferExitGuardStateMachine() #expect(state.request(needsGuard: true) == .presentPrompt) - #expect(state.resolve(.stopTransfer, for: .closeWindow) == .requestCancellation) + #expect(state.resolve(.stopTransfer, for: .quit) == .requestCancellation) #expect(state.settlementFinished(success: false) == .denyExit) #expect(state.request(needsGuard: true) == .presentPrompt) } + + @Test @MainActor func activeWindowClosesToHideAndReopensWithSameCoordinator() { + let coordinator = SharedAppCoordinator( + platformManager: MacOSPlatformManager.shared, + defaults: .isolatedWorkflowDefaults() + ) + coordinator.isOperationInProgress = true + let delegate = AppDelegate() + delegate.coordinator = coordinator + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 400, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.delegate = delegate + delegate.registerContentWindow(window) + window.makeKeyAndOrderFront(nil) + #expect(delegate.focusExistingContentWindow()) + #expect(window.isVisible) + window.performClose(nil) + + #expect(!window.isVisible) + #expect(coordinator.isOperationInProgress) + #expect(delegate.focusExistingContentWindow()) + #expect(window.isVisible) + #expect(delegate.coordinator === coordinator) + + window.performClose(nil) + #expect(!delegate.applicationShouldHandleReopen(NSApplication.shared, hasVisibleWindows: false)) + #expect(window.isVisible) + + coordinator.isOperationInProgress = false + window.performClose(nil) + #expect(!delegate.focusExistingContentWindow()) + #expect(delegate.applicationShouldHandleReopen(NSApplication.shared, hasVisibleWindows: false)) + } } From 77982aa9554fdff0d37786fa6cb65283a9f9cbdc Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 03:01:22 -0400 Subject: [PATCH 2/3] Test hidden Mac transfers through completion and reopen --- .../LocalTransferQueueIntegrationTests.swift | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/BitMatchTests/LocalTransferQueueIntegrationTests.swift b/BitMatchTests/LocalTransferQueueIntegrationTests.swift index 7faba6c3..51d84732 100644 --- a/BitMatchTests/LocalTransferQueueIntegrationTests.swift +++ b/BitMatchTests/LocalTransferQueueIntegrationTests.swift @@ -1,6 +1,8 @@ +import AppKit import Foundation import XCTest import CryptoKit +import Testing @testable import BitMatch import BitMatchEngine @@ -1556,6 +1558,74 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { } } +@MainActor +struct HiddenTransferMenuBarTests { + @Test func hiddenWindowKeepsRealTransferRunningAndReopensItsResult() async throws { + let f = try QueueFixture() + defer { f.cleanup() } + let journal = LocalTransferJournal(fileURL: f.journalURL) + let id = try journal.enqueue( + sourceURL: f.source, destinationURLs: [f.destination], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(makeReport: false), + generateASCMHL: false + ) + let pipeline = TransferPipeline(fileSystem: MacOSFileSystemService.shared, checksum: ChecksumEngine.shared) + let operations = GatedRealQueueOperations(pipeline: pipeline) + let coordinator = SharedAppCoordinator( + platformManager: QueuePlatformManager(fileOperations: operations), + transferJournal: journal, defaults: f.defaults + ) + let delegate = AppDelegate() + delegate.coordinator = coordinator + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 400, height: 300), + styleMask: [.titled, .closable], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.delegate = delegate + delegate.registerContentWindow(window) + window.makeKeyAndOrderFront(nil) + defer { + window.delegate = nil + window.close() + } + + coordinator.startQueue() + let started = await waitUntil(timeout: .seconds(15)) { + await operations.starts.count == 1 && coordinator.isOperationInProgress + } + #expect(started) + #expect(window.isVisible) + window.performClose(nil) + #expect(!window.isVisible) + #expect(coordinator.queueIsRunning) + #expect(coordinator.isOperationInProgress) + + await operations.release() + let finished = await waitUntil(timeout: .seconds(15)) { + journal.records.first(where: { $0.id == id })?.state == .completed + && !coordinator.queueIsRunning && !coordinator.isOperationInProgress + } + #expect(finished) + let record = try #require(journal.records.first(where: { $0.id == id })) + #expect(record.state == .completed) + #expect(record.results.count == 1) + let row = try #require(record.results.first) + #expect(row.isSuccessStatus) + let destinationPath = try #require(row.destinationPath) + let copied = try Data(contentsOf: URL(fileURLWithPath: destinationPath)) + let original = try Data(contentsOf: f.source.appendingPathComponent("clip.mov")) + #expect(copied == original) + let persisted = try JSONDecoder().decode([LocalTransferRecord].self, from: Data(contentsOf: f.journalURL)) + #expect(persisted.first?.state == .completed) + + #expect(delegate.focusExistingContentWindow()) + #expect(window.isVisible) + #expect(delegate.coordinator === coordinator) + #expect(journal.records.first(where: { $0.id == id })?.state == .completed) + } +} + private final class QuickQueueRecordingOperations: FileOperationsService, @unchecked Sendable { func performFileOperation( sourceURL: URL, destinationURLs: [URL], verificationMode: VerificationMode, @@ -1744,6 +1814,31 @@ private final class QueueRecordingOperations: FileOperationsService, @unchecked func resumeOperation() async {} } +private final class GatedRealQueueOperations: FileOperationsService, @unchecked Sendable { + private let gate = QueueGate(blocked: true) + private let pipeline: TransferPipeline + + init(pipeline: TransferPipeline) { self.pipeline = pipeline } + var starts: [QueueStartSnapshot] { get async { await gate.starts } } + func release() async { await gate.release() } + + func performFileOperation(sourceURL: URL, destinationURLs: [URL], verificationMode: VerificationMode, + settings: CameraLabelSettings, estimatedTotalBytes: Int64?, + progressCallback: @escaping ProgressCallback, onFileResult: FileResultCallback?) async throws -> FileOperation { + await gate.record(QueueStartSnapshot(source: sourceURL, destinations: destinationURLs, + label: settings.label, mode: verificationMode)) + return try await pipeline.performFileOperation( + sourceURL: sourceURL, destinationURLs: destinationURLs, verificationMode: verificationMode, + settings: settings, estimatedTotalBytes: estimatedTotalBytes, + progressCallback: progressCallback, onFileResult: onFileResult + ) + } + + func cancelOperation() { pipeline.cancelOperation() } + func pauseOperation() async { await pipeline.pauseOperation() } + func resumeOperation() async { await pipeline.resumeOperation() } +} + private final class QueuePlatformManager: PlatformManager { nonisolated let fileSystem: FileSystemService = FakeFileSystemService() nonisolated let checksum: ChecksumService = QueueChecksumService() From 1e14feb39ca4390cd46c5cfb087f4d23da136e40 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 09:37:49 -0400 Subject: [PATCH 3/3] Refine menu bar icon and bind the main window precisely --- BitMatch/App/BitMatchApp.swift | 101 ++++++++++++------- BitMatch/App/MacMenuBarCompanion.swift | 2 +- BitMatchTests/MacMenuBarCompanionTests.swift | 18 ++++ 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/BitMatch/App/BitMatchApp.swift b/BitMatch/App/BitMatchApp.swift index 73b4be00..f49de4c1 100644 --- a/BitMatch/App/BitMatchApp.swift +++ b/BitMatch/App/BitMatchApp.swift @@ -83,9 +83,11 @@ struct BitMatchApp: App { ContentView(environment: environment).preferredColorScheme(.dark) #endif } + .background(MainWindowAccessor { window in + setupWindow(window) + }) .onAppear { appDelegate.coordinator = environment.coordinator - setupWindow() } } .windowToolbarStyle(.unified(showsTitle: false)) @@ -186,48 +188,75 @@ struct BitMatchApp: App { } } - private func setupWindow() { + private func setupWindow(_ mainWindow: NSWindow) { DispatchQueue.main.async { - if let window = AppDelegate.contentWindowCandidate() { - // Configure window appearance - window.title = "BitMatch" - window.titlebarAppearsTransparent = true - window.titleVisibility = .hidden - if WindowPresentationPolicy.allowsManualResizing { - window.styleMask.insert(.resizable) - } - window.isMovableByWindowBackground = true - window.backgroundColor = .windowBackgroundColor - - // Start compact, then let the workbench grow into a proper review surface. - window.setContentSize(NSSize(width: WindowPresentationPolicy.initialWidth, height: WindowPresentationPolicy.initialHeight)) - window.minSize = NSSize(width: WindowPresentationPolicy.minimumWidth, height: WindowPresentationPolicy.minimumHeight) - window.maxSize = NSSize(width: WindowPresentationPolicy.maximumWidth, height: WindowPresentationPolicy.maximumHeight) - - // Make window fully opaque - window.isOpaque = true - window.alphaValue = 1.0 - window.hasShadow = true - - // Set window level - window.level = .normal - window.delegate = appDelegate + let window = mainWindow + if window.delegate === appDelegate { appDelegate.registerContentWindow(window) - - let savedFrame = UserDefaults.standard.dictionary(forKey: "BitMatch.windowFrame") - let hasSavedPlacement = savedFrame?["x"] as? CGFloat != nil - && savedFrame?["y"] as? CGFloat != nil - if WindowPresentationPolicy.shouldCenterWindow( - hasSavedPlacement: hasSavedPlacement, - isInterfaceLab: launchesInterfaceLab - ) { - window.center() - } + return + } + // The view belongs to this WindowGroup scene. A Settings window + // can become key before this deferred setup runs, so configure + // the captured scene window rather than searching all app windows. + window.title = "BitMatch" + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + if WindowPresentationPolicy.allowsManualResizing { + window.styleMask.insert(.resizable) + } + window.isMovableByWindowBackground = true + window.backgroundColor = .windowBackgroundColor + + // Start compact, then let the workbench grow into a proper review surface. + window.setContentSize(NSSize(width: WindowPresentationPolicy.initialWidth, height: WindowPresentationPolicy.initialHeight)) + window.minSize = NSSize(width: WindowPresentationPolicy.minimumWidth, height: WindowPresentationPolicy.minimumHeight) + window.maxSize = NSSize(width: WindowPresentationPolicy.maximumWidth, height: WindowPresentationPolicy.maximumHeight) + + window.isOpaque = true + window.alphaValue = 1.0 + window.hasShadow = true + window.level = .normal + window.delegate = appDelegate + appDelegate.registerContentWindow(window) + + let savedFrame = UserDefaults.standard.dictionary(forKey: "BitMatch.windowFrame") + let hasSavedPlacement = savedFrame?["x"] as? CGFloat != nil + && savedFrame?["y"] as? CGFloat != nil + if WindowPresentationPolicy.shouldCenterWindow( + hasSavedPlacement: hasSavedPlacement, + isInterfaceLab: launchesInterfaceLab + ) { + window.center() } } } } +/// Reports the NSWindow that actually hosts the main SwiftUI scene. Looking +/// up the key window here can select Settings or another ordinary window. +struct MainWindowAccessor: NSViewRepresentable { + let onWindow: @MainActor (NSWindow) -> Void + + func makeNSView(context: Context) -> WindowObserverView { + let view = WindowObserverView() + view.onWindow = onWindow + return view + } + + func updateNSView(_ view: WindowObserverView, context: Context) { + view.onWindow = onWindow + } + + final class WindowObserverView: NSView { + var onWindow: (@MainActor (NSWindow) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let window { onWindow?(window) } + } + } +} + /// File menu transfer commands. The composer is always present, so there is /// no New Transfer command; Eject exists only for a safe, removable card. struct OperationCommands: Commands { diff --git a/BitMatch/App/MacMenuBarCompanion.swift b/BitMatch/App/MacMenuBarCompanion.swift index 64c52e27..698a19b2 100644 --- a/BitMatch/App/MacMenuBarCompanion.swift +++ b/BitMatch/App/MacMenuBarCompanion.swift @@ -144,7 +144,7 @@ struct MacMenuBarStatusLabel: View { } .accessibilityLabel("BitMatch, \(presentation.title), \(presentation.percentText ?? "progress not yet measured")") } else { - Image(systemName: "circle.hexagongrid.fill") + Image(systemName: "square.on.square") .resizable() .scaledToFit() .accessibilityLabel("BitMatch") diff --git a/BitMatchTests/MacMenuBarCompanionTests.swift b/BitMatchTests/MacMenuBarCompanionTests.swift index b705eb70..60dce3fb 100644 --- a/BitMatchTests/MacMenuBarCompanionTests.swift +++ b/BitMatchTests/MacMenuBarCompanionTests.swift @@ -1,8 +1,26 @@ +import AppKit import Testing @testable import BitMatch import BitMatchEngine struct MacMenuBarCompanionTests { + @Test @MainActor func mainWindowAccessorReportsItsHostInsteadOfAnotherWindow() { + let decoy = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) + let main = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) + decoy.isReleasedWhenClosed = false + main.isReleasedWhenClosed = false + defer { decoy.close(); main.close() } + + var reported: NSWindow? + let observer = MainWindowAccessor.WindowObserverView() + observer.onWindow = { reported = $0 } + decoy.makeKeyAndOrderFront(nil) + main.contentView?.addSubview(observer) + #expect(reported === main) + } + @Test func destinationCopyCountNeverBecomesAVerifiedVerdict() { #expect(MenuBarDestinationLabel.text(for: .waiting) == "Waiting") #expect(MenuBarDestinationLabel.text(for: .copying) == "Copying")