#if os(macOS) import RastrilloMacUpdates #endif import AppKit @preconcurrency import CallsBridge @_spi(Fixtures) import CallsKit import SwiftUI import LiveKitWebRTC @main struct CallsMacApp: App { #if os(macOS) // Quiet: an update downloads in the background and installs when Calls quits, // and nothing it finds opens a window. `isBusy` follows the call, so no check // or download starts during one and the restart waits until it ends. @State private var updater = NativeUpdater(policy: .quiet) #endif @NSApplicationDelegateAdaptor(CallsAppDelegate.self) private var appDelegate @StateObject private var model = CallsModel() var body: some Scene { Window("Calls", id: "main") { ContentView(model: model, updater: updater).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } .onOpenURL { model.openMeetingURL($0) } .onAppear { CallsAppDelegate.model = model model.restore() AudioWarmUp.afterLaunch() } .miniWindow(for: model) .onChange(of: model.joining, initial: true) { updater.isBusy = model.joining } } .defaultSize(width: 1000, height: 760) .commands { CommandGroup(replacing: .newItem) {} NativeUpdateCommands(updater: updater) CommandMenu("Call") { CallMenuItems(model: model) } } Settings { CallsSettingsView( model: model, automaticUpdates: updater.canInstallUpdatesAutomatically ? Binding(get: { updater.automaticallyInstallsUpdates }, set: { updater.automaticallyInstallsUpdates = $0 }) : nil) } } } final class CallsAppDelegate: NSObject, NSApplicationDelegate { /// Set by the app so Apple's answer can reach the session that asked. /// Weak-ish by convention rather than by keyword: the model outlives /// the delegate for the whole life of the process. @MainActor static var model: CallsModel? func application( _ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data ) { Task { @MainActor in CallsAppDelegate.model?.registerDevice(token: token) } } func application( _ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { // Expected on any build without the push entitlement, which is every // ad-hoc signed local build. Silent: the person did not ask for this // and the consequence is simply no push. } /// Quitting mid-call leaves the call first. Without this the process /// ended with its participation still in the room, inside the server's /// thirty-second resume window, and the next launch — an update's /// relaunch, most often — joined as a second participation of the same /// session and was told "This meeting is open elsewhere". (The core now /// takes its own place back in that case too; leaving is still the /// right thing to do, because the others in the call see you go at /// once rather than thirty seconds later.) The leave is given three /// seconds; the core's own request gives up after two. func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard let model = CallsAppDelegate.model, model.joining else { return .terminateNow } var replied = false let reply = { @MainActor in guard !replied else { return } replied = true sender.reply(toApplicationShouldTerminate: true) } Task { @MainActor in await model.leaveNow() reply() } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { MainActor.assumeIsolated { reply() } } return .terminateLater } func applicationDidFinishLaunching(_ notification: Notification) { // On macOS 26 and later a bundled app's Dock icon comes from Assets.car, // which the system masks and renders in the person's icon style. Setting // a picture here would replace that with a flat PNG, so only a run // without the bundle's icon (swift run) or an older macOS sets one. if #available(macOS 26, *), Bundle.main.object(forInfoDictionaryKey: "CFBundleIconName") != nil { return } if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png") ?? Bundle.module.url(forResource: "MacIcon", withExtension: "png") { NSApp.applicationIconImage = NSImage(contentsOf: url) } } } /// The Call menu. Mute is here so that its shortcut can be found, and so /// that there is one when Space belongs to a text field or a button. Copy /// Invite Link puts the meeting's link on the clipboard without the share /// sheet. Both work from the mini window too: the menu is the app's. struct CallMenuItems: View { @ObservedObject var model: CallsModel var body: some View { CallMenuButtons(call: model.call, inCall: model.joining) } } private struct CallMenuButtons: View { @ObservedObject var call: Call let inCall: Bool var body: some View { ForEach(CallMenuItem.allCases, id: \.self) { item in switch item { case .mute: Button(item.title(muted: call.muted)) { call.mute() } .keyboardShortcut(item.shortcut) .disabled(!inCall) case .copyInviteLink: Button(item.title(muted: call.muted)) { Task { await call.copyInviteLink(inCall: inCall) } } .keyboardShortcut(item.shortcut) .disabled( InviteLinkCopy.rule(inCall: inCall, phase: call.state?.phase, standing: call.standing) == .unavailable) } } Divider() // Spec §15.26: during a call, and for ten minutes after it. Button("Report a Problem\u{2026}") { call.reportProblem() } .disabled(!call.canReportProblem) } } struct ContentView: View { @ObservedObject var model: CallsModel let updater: NativeUpdater var body: some View { Group { if let fixture = MacCallFixtureView.requested { MacCallFixtureView(fixture: fixture) } else if model.joining { // The layout pads itself: the classic view sits in a margin, the // full-window one goes to the window's edges. MacCallView(call: model.call, leave: model.leave, rejoin: model.join) } else { // The home screen is the only place an update is mentioned: never the call. CallsLobby(model: model).safeAreaInset(edge: .bottom) { if updater.canInstallPendingUpdate { NativeUpdateNotice(updater: updater).padding(.bottom, 12) } } } }.tint(CallsAppearance.accent) } } /// The full-window call with no server behind it, in the real window, to /// see the title bar, the traffic lights and the drag strip for real. /// Debug builds only: `-CallsCallFixture focus` (or grid, screen, /// notices), optionally `-CallsFixtureBackdrop black|white|busy`, /// `-CallsFixtureSelf muted|cameraOff|sharing` and `-CallsFixtureControls /// shown|hidden`; without that last, the pointer decides as in a call. struct MacCallFixtureView: View { static var requested: CallFixture? { #if DEBUG UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:)) #else nil #endif } let fixture: CallFixture @StateObject private var call = Call(playAudio: false) /// `-CallsFixtureJoinAfter ` starts on a stand-in for the lobby and /// joins after that long; `-CallsFixtureLeaveAfter ` goes back to it: /// the window's title bar has to come and go with the call. @State private var inCall = UserDefaults.standard.object(forKey: "CallsFixtureJoinAfter") == nil var body: some View { Group { if inCall { callView } else { Text("Lobby stand-in").frame(maxWidth: .infinity, maxHeight: .infinity) } } .task { let defaults = UserDefaults.standard if defaults.object(forKey: "CallsFixtureJoinAfter") != nil { try? await Task.sleep(for: .seconds(defaults.double(forKey: "CallsFixtureJoinAfter"))) inCall = true } if defaults.object(forKey: "CallsFixtureLeaveAfter") != nil { try? await Task.sleep(for: .seconds(defaults.double(forKey: "CallsFixtureLeaveAfter"))) inCall = false } } } private var callView: some View { let defaults = UserDefaults.standard return MacCallView( call: call, grid: fixture == .grid, controlsShown: defaults.string(forKey: "CallsFixtureControls").map { $0 == "shown" }, leave: {}, rejoin: {} ) .environment( \.fixtureBackdrop, defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:))) .onAppear { fixture.apply(to: call) defaults.string(forKey: "CallsFixtureSelf").flatMap(FixtureSelf.init(rawValue:))? .apply(to: call) } } }