| 1 | #if os(macOS) |
| 2 | import RastrilloMacUpdates |
| 3 | #endif |
| 4 | import AppKit |
| 5 | @preconcurrency import CallsBridge |
| 6 | @_spi(Fixtures) import CallsKit |
| 7 | import SwiftUI |
| 8 | import LiveKitWebRTC |
| 9 | |
| 10 | @main struct CallsMacApp: App { |
| 11 | #if os(macOS) |
| 12 | // Quiet: an update downloads in the background and installs when Calls quits, |
| 13 | // and nothing it finds opens a window. `isBusy` follows the call, so no check |
| 14 | // or download starts during one and the restart waits until it ends. |
| 15 | @State private var updater = NativeUpdater(policy: .quiet) |
| 16 | #endif |
| 17 | @NSApplicationDelegateAdaptor(CallsAppDelegate.self) private var appDelegate |
| 18 | @StateObject private var model = CallsModel() |
| 19 | var body: some Scene { |
| 20 | Window("Calls", id: "main") { |
| 21 | ContentView(model: model, updater: updater).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| 22 | .onOpenURL { model.openMeetingURL($0) } |
| 23 | .onAppear { |
| 24 | CallsAppDelegate.model = model |
| 25 | model.restore() |
| 26 | AudioWarmUp.afterLaunch() |
| 27 | } |
| 28 | .miniWindow(for: model) |
| 29 | .onChange(of: model.joining, initial: true) { updater.isBusy = model.joining } |
| 30 | } |
| 31 | .defaultSize(width: 1000, height: 760) |
| 32 | .commands { |
| 33 | CommandGroup(replacing: .newItem) {} |
| 34 | NativeUpdateCommands(updater: updater) |
| 35 | CommandMenu("Call") { CallMenuItems(model: model) } |
| 36 | } |
| 37 | Settings { |
| 38 | CallsSettingsView( |
| 39 | model: model, |
| 40 | automaticUpdates: updater.canInstallUpdatesAutomatically |
| 41 | ? Binding(get: { updater.automaticallyInstallsUpdates }, set: { updater.automaticallyInstallsUpdates = $0 }) |
| 42 | : nil) |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | final class CallsAppDelegate: NSObject, NSApplicationDelegate { |
| 48 | /// Set by the app so Apple's answer can reach the session that asked. |
| 49 | /// Weak-ish by convention rather than by keyword: the model outlives |
| 50 | /// the delegate for the whole life of the process. |
| 51 | @MainActor static var model: CallsModel? |
| 52 | |
| 53 | func application( |
| 54 | _ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data |
| 55 | ) { |
| 56 | Task { @MainActor in CallsAppDelegate.model?.registerDevice(token: token) } |
| 57 | } |
| 58 | |
| 59 | func application( |
| 60 | _ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error |
| 61 | ) { |
| 62 | // Expected on any build without the push entitlement, which is every |
| 63 | // ad-hoc signed local build. Silent: the person did not ask for this |
| 64 | // and the consequence is simply no push. |
| 65 | } |
| 66 | |
| 67 | /// Quitting mid-call leaves the call first. Without this the process |
| 68 | /// ended with its participation still in the room, inside the server's |
| 69 | /// thirty-second resume window, and the next launch — an update's |
| 70 | /// relaunch, most often — joined as a second participation of the same |
| 71 | /// session and was told "This meeting is open elsewhere". (The core now |
| 72 | /// takes its own place back in that case too; leaving is still the |
| 73 | /// right thing to do, because the others in the call see you go at |
| 74 | /// once rather than thirty seconds later.) The leave is given three |
| 75 | /// seconds; the core's own request gives up after two. |
| 76 | func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { |
| 77 | guard let model = CallsAppDelegate.model, model.joining else { return .terminateNow } |
| 78 | var replied = false |
| 79 | let reply = { @MainActor in |
| 80 | guard !replied else { return } |
| 81 | replied = true |
| 82 | sender.reply(toApplicationShouldTerminate: true) |
| 83 | } |
| 84 | Task { @MainActor in |
| 85 | await model.leaveNow() |
| 86 | reply() |
| 87 | } |
| 88 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { MainActor.assumeIsolated { reply() } } |
| 89 | return .terminateLater |
| 90 | } |
| 91 | |
| 92 | func applicationDidFinishLaunching(_ notification: Notification) { |
| 93 | // On macOS 26 and later a bundled app's Dock icon comes from Assets.car, |
| 94 | // which the system masks and renders in the person's icon style. Setting |
| 95 | // a picture here would replace that with a flat PNG, so only a run |
| 96 | // without the bundle's icon (swift run) or an older macOS sets one. |
| 97 | if #available(macOS 26, *), Bundle.main.object(forInfoDictionaryKey: "CFBundleIconName") != nil { |
| 98 | return |
| 99 | } |
| 100 | if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png") |
| 101 | ?? Bundle.module.url(forResource: "MacIcon", withExtension: "png") |
| 102 | { |
| 103 | NSApp.applicationIconImage = NSImage(contentsOf: url) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /// The Call menu. Mute is here so that its shortcut can be found, and so |
| 109 | /// that there is one when Space belongs to a text field or a button. Copy |
| 110 | /// Invite Link puts the meeting's link on the clipboard without the share |
| 111 | /// sheet. Both work from the mini window too: the menu is the app's. |
| 112 | struct CallMenuItems: View { |
| 113 | @ObservedObject var model: CallsModel |
| 114 | var body: some View { CallMenuButtons(call: model.call, inCall: model.joining) } |
| 115 | } |
| 116 | |
| 117 | private struct CallMenuButtons: View { |
| 118 | @ObservedObject var call: Call |
| 119 | let inCall: Bool |
| 120 | var body: some View { |
| 121 | ForEach(CallMenuItem.allCases, id: \.self) { item in |
| 122 | switch item { |
| 123 | case .mute: |
| 124 | Button(item.title(muted: call.muted)) { call.mute() } |
| 125 | .keyboardShortcut(item.shortcut) |
| 126 | .disabled(!inCall) |
| 127 | case .copyInviteLink: |
| 128 | Button(item.title(muted: call.muted)) { |
| 129 | Task { await call.copyInviteLink(inCall: inCall) } |
| 130 | } |
| 131 | .keyboardShortcut(item.shortcut) |
| 132 | .disabled( |
| 133 | InviteLinkCopy.rule(inCall: inCall, phase: call.state?.phase, standing: call.standing) |
| 134 | == .unavailable) |
| 135 | } |
| 136 | } |
| 137 | Divider() |
| 138 | // Spec §15.26: during a call, and for ten minutes after it. |
| 139 | Button("Report a Problem\u{2026}") { call.reportProblem() } |
| 140 | .disabled(!call.canReportProblem) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | struct ContentView: View { |
| 145 | @ObservedObject var model: CallsModel |
| 146 | let updater: NativeUpdater |
| 147 | var body: some View { |
| 148 | Group { |
| 149 | if let fixture = MacCallFixtureView.requested { |
| 150 | MacCallFixtureView(fixture: fixture) |
| 151 | } else if model.joining { |
| 152 | // The layout pads itself: the classic view sits in a margin, the |
| 153 | // full-window one goes to the window's edges. |
| 154 | MacCallView(call: model.call, leave: model.leave, rejoin: model.join) |
| 155 | } else { |
| 156 | // The home screen is the only place an update is mentioned: never the call. |
| 157 | CallsLobby(model: model).safeAreaInset(edge: .bottom) { |
| 158 | if updater.canInstallPendingUpdate { NativeUpdateNotice(updater: updater).padding(.bottom, 12) } |
| 159 | } |
| 160 | } |
| 161 | }.tint(CallsAppearance.accent) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | /// The full-window call with no server behind it, in the real window, to |
| 166 | /// see the title bar, the traffic lights and the drag strip for real. |
| 167 | /// Debug builds only: `-CallsCallFixture focus` (or grid, screen, |
| 168 | /// notices), optionally `-CallsFixtureBackdrop black|white|busy`, |
| 169 | /// `-CallsFixtureSelf muted|cameraOff|sharing` and `-CallsFixtureControls |
| 170 | /// shown|hidden`; without that last, the pointer decides as in a call. |
| 171 | struct MacCallFixtureView: View { |
| 172 | static var requested: CallFixture? { |
| 173 | #if DEBUG |
| 174 | UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:)) |
| 175 | #else |
| 176 | nil |
| 177 | #endif |
| 178 | } |
| 179 | |
| 180 | let fixture: CallFixture |
| 181 | @StateObject private var call = Call(playAudio: false) |
| 182 | /// `-CallsFixtureJoinAfter <s>` starts on a stand-in for the lobby and |
| 183 | /// joins after that long; `-CallsFixtureLeaveAfter <s>` goes back to it: |
| 184 | /// the window's title bar has to come and go with the call. |
| 185 | @State private var inCall = UserDefaults.standard.object(forKey: "CallsFixtureJoinAfter") == nil |
| 186 | |
| 187 | var body: some View { |
| 188 | Group { |
| 189 | if inCall { |
| 190 | callView |
| 191 | } else { |
| 192 | Text("Lobby stand-in").frame(maxWidth: .infinity, maxHeight: .infinity) |
| 193 | } |
| 194 | } |
| 195 | .task { |
| 196 | let defaults = UserDefaults.standard |
| 197 | if defaults.object(forKey: "CallsFixtureJoinAfter") != nil { |
| 198 | try? await Task.sleep(for: .seconds(defaults.double(forKey: "CallsFixtureJoinAfter"))) |
| 199 | inCall = true |
| 200 | } |
| 201 | if defaults.object(forKey: "CallsFixtureLeaveAfter") != nil { |
| 202 | try? await Task.sleep(for: .seconds(defaults.double(forKey: "CallsFixtureLeaveAfter"))) |
| 203 | inCall = false |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | private var callView: some View { |
| 209 | let defaults = UserDefaults.standard |
| 210 | return MacCallView( |
| 211 | call: call, grid: fixture == .grid, |
| 212 | controlsShown: defaults.string(forKey: "CallsFixtureControls").map { $0 == "shown" }, |
| 213 | leave: {}, rejoin: {} |
| 214 | ) |
| 215 | .environment( |
| 216 | \.fixtureBackdrop, |
| 217 | defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:))) |
| 218 | .onAppear { |
| 219 | fixture.apply(to: call) |
| 220 | defaults.string(forKey: "CallsFixtureSelf").flatMap(FixtureSelf.init(rawValue:))? |
| 221 | .apply(to: call) |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | |