| 1 | import AppKit |
| 2 | import CallsKit |
| 3 | import SwiftUI |
| 4 | |
| 5 | /// Offline visual fixtures of the shipping lobby. Only built into the test executable. |
| 6 | /// |
| 7 | /// Every model reads its server from a throwaway defaults suite, so a |
| 8 | /// fixture neither sees nor changes the setting of whoever runs it. |
| 9 | @MainActor enum LobbySnapshots { |
| 10 | static func save(to directory: String) async throws { |
| 11 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 12 | let folder = URL(fileURLWithPath: directory) |
| 13 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 14 | let suite = "CallsInterop.LobbySnapshots" |
| 15 | guard let defaults = UserDefaults(suiteName: suite) else { return } |
| 16 | defaults.removePersistentDomain(forName: suite) |
| 17 | defer { defaults.removePersistentDomain(forName: suite) } |
| 18 | for scheme in [ColorScheme.light, .dark] { |
| 19 | for state in [ |
| 20 | "welcome", "signin", "home", "ready", "error", "expired", "link-other", "visiting", |
| 21 | ] { |
| 22 | let model = CallsModel(defaults: defaults) |
| 23 | if state == "signin" { |
| 24 | model.authenticating = true |
| 25 | model.confirmationCode = "A1B2-C3D4" |
| 26 | model.signin = URL(string: "https://example.invalid") |
| 27 | } |
| 28 | if state == "home" { |
| 29 | model.signedIn = true |
| 30 | model.canStart = true |
| 31 | model.origin = "https://calls.oficina.works" |
| 32 | } |
| 33 | if state == "ready" { |
| 34 | model.signedIn = true |
| 35 | model.canStart = true |
| 36 | model.origin = "https://calls.oficina.works" |
| 37 | model.room = "team-catchup" |
| 38 | } |
| 39 | if state == "error" { |
| 40 | model.error = "Could not reach calls.oficina.works. Choose Sign in to try again." |
| 41 | } |
| 42 | if state == "expired" { |
| 43 | model.error = "Your sign-in to calls.oficina.works has ended. Choose Sign in to continue." |
| 44 | } |
| 45 | if state == "link-other" { |
| 46 | model.pendingLink = "https://calls.example.org/j/invitation" |
| 47 | } |
| 48 | if state == "visiting" { |
| 49 | model.signedIn = true |
| 50 | model.visiting = true |
| 51 | model.origin = "https://calls.example.org" |
| 52 | model.room = "abc123" |
| 53 | } |
| 54 | try await render( |
| 55 | CallsLobby(model: model), scheme: scheme, size: CGSize(width: 900, height: 720), |
| 56 | to: folder.appendingPathComponent("\(state)-\(name(scheme)).png")) |
| 57 | } |
| 58 | // The same signed-out home at iPhone width, with the Change button |
| 59 | // the phone's settings sheet is reached by. |
| 60 | try await render( |
| 61 | CallsLobby(model: CallsModel(defaults: defaults), openSettings: {}), scheme: scheme, |
| 62 | size: CGSize(width: 390, height: 780), |
| 63 | to: folder.appendingPathComponent("phone-welcome-\(name(scheme)).png")) |
| 64 | try await render( |
| 65 | GuestJoinView(model: CallsModel(defaults: defaults)) {}, scheme: scheme, size: nil, |
| 66 | to: folder.appendingPathComponent("guest-\(name(scheme)).png")) |
| 67 | try await render( |
| 68 | GuestJoinView( |
| 69 | model: CallsModel(defaults: defaults), link: "calls.example.org/settings", problem: true |
| 70 | ) {}, scheme: scheme, size: nil, |
| 71 | to: folder.appendingPathComponent("guest-invalid-\(name(scheme)).png")) |
| 72 | try await render( |
| 73 | ServerSettingsSheet(model: CallsModel(defaults: defaults)) {}, scheme: scheme, |
| 74 | size: CGSize(width: 390, height: 520), |
| 75 | to: folder.appendingPathComponent("phone-settings-\(name(scheme)).png")) |
| 76 | } |
| 77 | try await SettingsSnapshots.save(to: folder) |
| 78 | try await MiniWindowSnapshots.save(to: folder) |
| 79 | try await CallSnapshots.save(to: folder) |
| 80 | } |
| 81 | |
| 82 | static func name(_ scheme: ColorScheme) -> String { scheme == .dark ? "dark" : "light" } |
| 83 | |
| 84 | /// Draws a view off screen into a PNG. A nil size takes the view's own. |
| 85 | static func render(_ content: some View, scheme: ColorScheme, size: CGSize?, to file: URL) |
| 86 | async throws |
| 87 | { |
| 88 | let view = NSHostingView( |
| 89 | rootView: content.background(scheme == .dark ? Color(white: 0.12) : Color(white: 0.98)) |
| 90 | .environment(\.colorScheme, scheme)) |
| 91 | let frame = size ?? view.fittingSize |
| 92 | view.frame.size = frame |
| 93 | let window = NSWindow( |
| 94 | contentRect: NSRect(origin: CGPoint(x: -2000, y: -2000), size: frame), |
| 95 | styleMask: [.borderless], backing: .buffered, defer: false) |
| 96 | window.isReleasedWhenClosed = false |
| 97 | window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) |
| 98 | window.contentView = view |
| 99 | window.orderFront(nil) |
| 100 | try await Task.sleep(for: .milliseconds(300)) |
| 101 | view.layoutSubtreeIfNeeded() |
| 102 | guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { |
| 103 | throw CocoaError(.fileWriteUnknown) |
| 104 | } |
| 105 | view.cacheDisplay(in: view.bounds, to: bitmap) |
| 106 | guard let data = bitmap.representation(using: .png, properties: [:]) else { |
| 107 | throw CocoaError(.fileWriteUnknown) |
| 108 | } |
| 109 | try data.write(to: file) |
| 110 | window.close() |
| 111 | } |
| 112 | } |
| 113 | |