Skip to content
Merged
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
191 changes: 114 additions & 77 deletions BitMatch/App/BitMatchApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ struct BitMatchApp: App {
}

var body: some Scene {
WindowGroup {
WindowGroup(id: "main") {
Group {
#if DEBUG
if launchesInterfaceLab { InterfaceLabView() }
Expand All @@ -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))
Expand Down Expand Up @@ -165,6 +167,16 @@ struct BitMatchApp: App {
#endif
}

MenuBarExtra {
MacMenuBarCompanion(
coordinator: environment.coordinator,
appDelegate: appDelegate
)
} label: {
MacMenuBarStatusLabel(coordinator: environment.coordinator)
}
.menuBarExtraStyle(.window)

Settings {
PreferencesWindow(
coordinator: environment.coordinator,
Expand All @@ -176,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 {
Expand Down Expand Up @@ -281,7 +320,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?

Expand Down Expand Up @@ -317,23 +357,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.")
Expand All @@ -348,7 +405,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}

private enum ExitAction {
case close(NSWindow)
case quit(NSApplication)
}

Expand All @@ -366,23 +422,19 @@ 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:
self.cancelAndSettle(action: action)
}
}

// 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)
Expand All @@ -398,9 +450,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 {
Expand All @@ -415,24 +465,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
Expand Down
Loading
Loading