| 1 | import AppKit |
| 2 | @_spi(Fixtures) import CallsKit |
| 3 | import SwiftUI |
| 4 | |
| 5 | /// Offline visual fixtures of the Mac call window, full-window and |
| 6 | /// classic, from described calls (see CallFixture): controls shown and |
| 7 | /// hidden, Focus and Grid, a shared screen, the Devices panel open, and |
| 8 | /// the notices that stay when the controls go. The tiles show their |
| 9 | /// placeholders — an off-screen capture draws no Metal video anyway. |
| 10 | @MainActor enum CallSnapshots { |
| 11 | static func save(to folder: URL) async throws { |
| 12 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 13 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 14 | let size = CGSize(width: 1000, height: 640) |
| 15 | for scheme in [ColorScheme.light, .dark] { |
| 16 | let suffix = LobbySnapshots.name(scheme) |
| 17 | func shot( |
| 18 | _ name: String, _ fixture: CallFixture, grid: Bool = false, shown: Bool = true, |
| 19 | devices: Bool = false, chat: Bool = false, style: CallLayoutStyle = .fullWindow, |
| 20 | backdrop: FixtureBackdrop? = nil, me: FixtureSelf? = nil |
| 21 | ) async throws { |
| 22 | let call = Call(playAudio: false) |
| 23 | fixture.apply(to: call) |
| 24 | me?.apply(to: call) |
| 25 | try await LobbySnapshots.render( |
| 26 | MacCallView( |
| 27 | call: call, grid: grid, showingChat: chat, showingDevices: devices, style: style, |
| 28 | controlsShown: shown, leave: {}, rejoin: {} |
| 29 | ).tint(CallsAppearance.accent).environment(\.fixtureBackdrop, backdrop), |
| 30 | scheme: scheme, size: size, |
| 31 | to: folder.appendingPathComponent("mac-\(name)-\(suffix).png")) |
| 32 | } |
| 33 | try await shot("focus-controls", .focus) |
| 34 | try await shot("focus-hidden", .focus, shown: false) |
| 35 | try await shot("grid-controls", .grid, grid: true) |
| 36 | try await shot("grid-hidden", .grid, grid: true, shown: false) |
| 37 | try await shot("screen-controls", .screen) |
| 38 | try await shot("screen-hidden", .screen, shown: false) |
| 39 | try await shot("devices", .focus, devices: true) |
| 40 | try await shot("chat", .focus, chat: true) |
| 41 | try await shot("notices-hidden", .notices, shown: false) |
| 42 | // 2026-10-02: no title pill. A named meeting's title is centred in |
| 43 | // the title bar's strip and fades with the controls; an automatic |
| 44 | // one ("Call, 24 Sep 10:21", the focus fixture) is not shown. |
| 45 | try await shot("named-controls", .named) |
| 46 | try await shot("named-hidden", .named, shown: false) |
| 47 | try await shot("named-white", .named, backdrop: .white) |
| 48 | try await shot("named-busy", .named, backdrop: .busy) |
| 49 | try await shot("classic-focus", .focus, style: .classic) |
| 50 | try await shot("classic-grid", .grid, grid: true, style: .classic) |
| 51 | // The self view in the top-left corner: below the title bar's strip |
| 52 | // (there is no pill to keep clear of since 2026-10-02). |
| 53 | let corner = UserDefaults.standard.string(forKey: PiPCorner.key) |
| 54 | UserDefaults.standard.set(PiPCorner.topLeading.rawValue, forKey: PiPCorner.key) |
| 55 | try await shot("pip-top-leading-controls", .focus) |
| 56 | try await shot("pip-top-leading-hidden", .focus, shown: false) |
| 57 | UserDefaults.standard.set(corner, forKey: PiPCorner.key) |
| 58 | // The overlay over the worst pictures there are for it, in each of |
| 59 | // your own states: it should look the same in light and dark. |
| 60 | for backdrop in FixtureBackdrop.allCases { |
| 61 | for me in FixtureSelf.allCases { |
| 62 | try await shot("contrast-\(backdrop.rawValue)-\(me.rawValue)", .focus, backdrop: backdrop, me: me) |
| 63 | } |
| 64 | try await shot("contrast-\(backdrop.rawValue)-notices", .notices, backdrop: backdrop) |
| 65 | } |
| 66 | try await shot("contrast-busy-hidden", .focus, shown: false, backdrop: .busy) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | /// Spec §15.24: two people who have just joined — Samuel still |
| 71 | /// connecting and unable to hear you yet, Bea's browser blocking sound in |
| 72 | /// place of that — in Grid, Speaker and Classic, light and dark, over a |
| 73 | /// busy picture, and the mini window and its tab with the small ear for |
| 74 | /// someone it is not showing. |
| 75 | static func saveConnecting(to folder: URL) async throws { |
| 76 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 77 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 78 | let size = CGSize(width: 1000, height: 640) |
| 79 | for scheme in [ColorScheme.light, .dark] { |
| 80 | let suffix = LobbySnapshots.name(scheme) |
| 81 | func shot( |
| 82 | _ name: String, grid: Bool = false, style: CallLayoutStyle = .fullWindow, |
| 83 | backdrop: FixtureBackdrop? = nil |
| 84 | ) async throws { |
| 85 | let call = Call(playAudio: false) |
| 86 | CallFixture.connecting.apply(to: call) |
| 87 | try await LobbySnapshots.render( |
| 88 | MacCallView( |
| 89 | call: call, grid: grid, showingChat: false, showingDevices: false, style: style, |
| 90 | controlsShown: true, leave: {}, rejoin: {} |
| 91 | ).tint(CallsAppearance.accent).environment(\.fixtureBackdrop, backdrop), |
| 92 | scheme: scheme, size: size, |
| 93 | to: folder.appendingPathComponent("mac-connecting-\(name)-\(suffix).png")) |
| 94 | } |
| 95 | try await shot("grid", grid: true) |
| 96 | try await shot("speaker") |
| 97 | try await shot("classic-grid", grid: true, style: .classic) |
| 98 | try await shot("busy-grid", grid: true, backdrop: .busy) |
| 99 | for hovering in [false, true] { |
| 100 | let call = Call(playAudio: false) |
| 101 | CallFixture.connecting.apply(to: call) |
| 102 | let width = MiniWindowRules.size.width |
| 103 | try await LobbySnapshots.render( |
| 104 | MiniWindowSnapshot.window(call: call, hovering: hovering), scheme: scheme, |
| 105 | size: CGSize(width: width, height: width / MiniWindowRules.aspect), |
| 106 | to: folder.appendingPathComponent("mac-connecting-mini\(hovering ? "-hover" : "")-\(suffix).png")) |
| 107 | } |
| 108 | let call = Call(playAudio: false) |
| 109 | CallFixture.connecting.apply(to: call) |
| 110 | try await LobbySnapshots.render( |
| 111 | MiniWindowSnapshot.tab(call: call), scheme: scheme, size: MiniWindowTab.size, |
| 112 | to: folder.appendingPathComponent("mac-connecting-tab-\(suffix).png")) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// The glass and the glow as the window server draws them (0.3.7). An |
| 117 | /// off-screen bitmap has no Liquid Glass and no backdrop to blur, so |
| 118 | /// these are real windows, parked below the desktop where nobody sees |
| 119 | /// them, and captured by `screencapture -l`: the control bar over black, |
| 120 | /// white and a busy frame, in glass and in the material older systems |
| 121 | /// draw; the Devices panel; the names glowing at four levels; the tab. |
| 122 | static func saveGlass(to folder: URL) async throws { |
| 123 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 124 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 125 | let size = CGSize(width: 1000, height: 640) |
| 126 | func shot( |
| 127 | _ name: String, _ fixture: CallFixture, grid: Bool = false, devices: Bool = false, |
| 128 | glass: Bool = true, backdrop: FixtureBackdrop? = nil, me: FixtureSelf? = nil, |
| 129 | speaking: FixtureSpeaking? = nil |
| 130 | ) async throws { |
| 131 | let call = Call(playAudio: false) |
| 132 | fixture.apply(to: call) |
| 133 | me?.apply(to: call) |
| 134 | speaking?.apply(to: call) |
| 135 | try await renderLive( |
| 136 | MacCallView( |
| 137 | call: call, grid: grid, showingDevices: devices, style: .fullWindow, |
| 138 | controlsShown: true, leave: {}, rejoin: {} |
| 139 | ) |
| 140 | .tint(CallsAppearance.accent) |
| 141 | .environment(\.fixtureBackdrop, backdrop) |
| 142 | .environment(\.callOverlayGlass, glass), |
| 143 | size: size, to: folder.appendingPathComponent("mac-\(name).png")) |
| 144 | } |
| 145 | for glass in [true, false] { |
| 146 | let kind = glass ? "glass" : "fallback" |
| 147 | for backdrop in FixtureBackdrop.allCases { |
| 148 | try await shot("bar-\(kind)-\(backdrop.rawValue)", .focus, glass: glass, backdrop: backdrop) |
| 149 | } |
| 150 | try await shot("bar-\(kind)-busy-muted", .focus, glass: glass, backdrop: .busy, me: .muted) |
| 151 | try await shot("bar-\(kind)-busy-sharing", .focus, glass: glass, backdrop: .busy, me: .sharing) |
| 152 | try await shot("devices-\(kind)", .focus, devices: true, glass: glass, backdrop: .busy) |
| 153 | } |
| 154 | for level in FixtureSpeaking.allCases { |
| 155 | try await shot("glow-\(level.rawValue)", .grid, grid: true, speaking: level) |
| 156 | try await shot("glow-focus-\(level.rawValue)", .focus, speaking: level) |
| 157 | } |
| 158 | // The warm rim on a name at each level over the pictures it has to |
| 159 | // read on, for comparing with the mock-up (0.4.3). |
| 160 | for level in FixtureSpeaking.allCases { |
| 161 | try await renderLive( |
| 162 | WarmRimGallery(level: level).background(Color.black).environment(\.colorScheme, .dark), |
| 163 | size: CGSize(width: 380, height: 670), |
| 164 | to: folder.appendingPathComponent("mac-rim-\(level.rawValue).png")) |
| 165 | } |
| 166 | for level in [FixtureSpeaking.silent, .high] { |
| 167 | let call = Call(playAudio: false) |
| 168 | CallFixture.focus.apply(to: call) |
| 169 | level.apply(to: call) |
| 170 | try await renderLive( |
| 171 | ZStack { |
| 172 | FixtureBackdrop.busy.view |
| 173 | MiniWindowSnapshot.tab(call: call) |
| 174 | }, |
| 175 | size: CGSize(width: MiniWindowTab.size.width + 40, height: MiniWindowTab.size.height + 40), |
| 176 | to: folder.appendingPathComponent("mac-tab-\(level.rawValue).png")) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | /// Speaker view and the viewer's order (0.4.0), as the window server |
| 181 | /// draws them: a call of four in Speaker view with Tom speaking and the |
| 182 | /// other three in the filmstrip (controls up and down, and at the |
| 183 | /// smallest window, 760 × 540), Li pinned, a shared screen with the |
| 184 | /// sharer's camera in the filmstrip, a drag half way in Grid, and Grid |
| 185 | /// after a reorder. |
| 186 | static func saveSpeaker(to folder: URL) async throws { |
| 187 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 188 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 189 | func shot( |
| 190 | _ name: String, _ fixture: CallFixture = .grid, grid: Bool = false, shown: Bool = true, |
| 191 | size: CGSize = CGSize(width: 1000, height: 640), speaker: String? = "tom", |
| 192 | pinned: StageItem? = nil, lift: String? = nil, order: [String] = [] |
| 193 | ) async throws { |
| 194 | let call = Call(playAudio: false) |
| 195 | fixture.apply(to: call) |
| 196 | call.levels.speaker.showFixture(speaker) |
| 197 | call.arrangement.showPinned(pinned) |
| 198 | call.arrangement.showLift(lift) |
| 199 | call.arrangement.showOrder(order) |
| 200 | try await renderLive( |
| 201 | MacCallView( |
| 202 | call: call, grid: grid, style: .fullWindow, controlsShown: shown, leave: {}, rejoin: {} |
| 203 | ) |
| 204 | .tint(CallsAppearance.accent), |
| 205 | size: size, to: folder.appendingPathComponent("mac-\(name).png")) |
| 206 | } |
| 207 | try await shot("speaker-filmstrip-controls") |
| 208 | try await shot("speaker-filmstrip-hidden", shown: false) |
| 209 | try await shot("speaker-filmstrip-760x540", size: CGSize(width: 760, height: 540)) |
| 210 | try await shot("speaker-pinned", pinned: .camera("li")) |
| 211 | try await shot("speaker-screen", .screen, shown: false) |
| 212 | try await shot("speaker-drag", lift: "me") |
| 213 | try await shot("grid-drag", grid: true, lift: "li") |
| 214 | try await shot("grid-reordered", grid: true, order: ["li", "tom", "me", "ana"]) |
| 215 | try await shot("speaker-two", .focus, shown: false, speaker: nil) |
| 216 | } |
| 217 | |
| 218 | /// A real window below the desktop, captured by the window server. |
| 219 | static func renderLive(_ content: some View, size: CGSize, to file: URL) async throws { |
| 220 | let view = NSHostingView(rootView: content.environment(\.colorScheme, .dark)) |
| 221 | view.frame.size = size |
| 222 | let window = NSWindow( |
| 223 | contentRect: NSRect(origin: CGPoint(x: 0, y: 0), size: size), |
| 224 | styleMask: [.borderless], backing: .buffered, defer: false) |
| 225 | window.isReleasedWhenClosed = false |
| 226 | window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) |
| 227 | window.appearance = NSAppearance(named: .darkAqua) |
| 228 | window.contentView = view |
| 229 | window.orderBack(nil) |
| 230 | try await Task.sleep(for: .milliseconds(600)) |
| 231 | view.layoutSubtreeIfNeeded() |
| 232 | let capture = Process() |
| 233 | capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") |
| 234 | capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), file.path] |
| 235 | try capture.run() |
| 236 | capture.waitUntilExit() |
| 237 | window.close() |
| 238 | guard capture.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } |
| 239 | } |
| 240 | } |
| 241 | |