| 1 | import AppKit |
| 2 | @_spi(Fixtures) import CallsKit |
| 3 | import Foundation |
| 4 | import LiveKitWebRTC |
| 5 | import ScreenCaptureKit |
| 6 | import SwiftUI |
| 7 | |
| 8 | // A separate test executable: synthetic video and fixture credentials are never |
| 9 | // compiled into CallsMac. Input travels on stdin, not argv or a saved file. |
| 10 | struct Input: Decodable { |
| 11 | let origin: String |
| 12 | let room: String |
| 13 | let cookies: String |
| 14 | let chatSnapshot: String? |
| 15 | let invitation: String? |
| 16 | } |
| 17 | @main struct Interop { |
| 18 | @MainActor static var screen: SyntheticScreen? |
| 19 | @MainActor static var display: DisplayCapture? |
| 20 | @MainActor static var webrtcLogger: LKRTCCallbackLogger? |
| 21 | @MainActor static let cameras = SyntheticCameras() |
| 22 | // A defaults domain of its own, so the gate's camera choices are never |
| 23 | // remembered into anyone's real preferences. |
| 24 | @MainActor static let camera = ManagedCamera( |
| 25 | backend: cameras, defaults: UserDefaults(suiteName: "calls-interop-\(UUID().uuidString)")!) |
| 26 | @MainActor static func main() async { |
| 27 | if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--lobby-snapshots" { |
| 28 | do { try await LobbySnapshots.save(to: CommandLine.arguments[2]) } catch { exit(1) } |
| 29 | return |
| 30 | } |
| 31 | if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--call-snapshots" { |
| 32 | do { |
| 33 | try await CallSnapshots.save(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| 34 | } catch { exit(1) } |
| 35 | return |
| 36 | } |
| 37 | if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--connecting-snapshots" { |
| 38 | do { |
| 39 | try await CallSnapshots.saveConnecting(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| 40 | } catch { exit(1) } |
| 41 | return |
| 42 | } |
| 43 | if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--glass-snapshots" { |
| 44 | do { |
| 45 | try await CallSnapshots.saveGlass(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| 46 | } catch { exit(1) } |
| 47 | return |
| 48 | } |
| 49 | if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--speaker-snapshots" { |
| 50 | do { |
| 51 | try await CallSnapshots.saveSpeaker(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| 52 | } catch { exit(1) } |
| 53 | return |
| 54 | } |
| 55 | if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--mini-clicks" { |
| 56 | // Under NSApplication's own run loop, as the app is: the surface |
| 57 | // tracks a press with a nested event loop, which Swift's async main |
| 58 | // alone does not survive. |
| 59 | NSApplication.shared.setActivationPolicy(.accessory) |
| 60 | Task { @MainActor in |
| 61 | let call = Call(playAudio: false) |
| 62 | CallFixture.grid.apply(to: call) |
| 63 | let results = await MiniWindowProbe.run(call: call) |
| 64 | for r in results { print((r.passed ? "ok " : "FAIL ") + r.name + (r.passed ? "" : " — " + r.detail)) } |
| 65 | print("mini-clicks: \(results.filter(\.passed).count) of \(results.count) presses did what they should") |
| 66 | fflush(stdout) |
| 67 | exit(results.allSatisfy(\.passed) ? 0 : 1) |
| 68 | } |
| 69 | NSApplication.shared.run() |
| 70 | } |
| 71 | if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--stage-snapshots" { |
| 72 | do { |
| 73 | try await StageSnapshots.save(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| 74 | } catch { exit(1) } |
| 75 | return |
| 76 | } |
| 77 | if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--bar-layout" { |
| 78 | exit(await BarLayoutHarness.run(to: URL(fileURLWithPath: CommandLine.arguments[2])) ? 0 : 1) |
| 79 | } |
| 80 | if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-start-stall" { |
| 81 | exit(await AudioStartStallHarness.run() ? 0 : 1) |
| 82 | } |
| 83 | if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-selftest" { |
| 84 | exit(await AudioSelfTestHarness.run() ? 0 : 1) |
| 85 | } |
| 86 | guard let line = readLine(), |
| 87 | let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)), |
| 88 | let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1" |
| 89 | else { exit(2) } |
| 90 | // Decoders the gate can wedge (spec §15.22), set before the call's |
| 91 | // factory is made; only for the scenarios that ask, so every other |
| 92 | // gate decodes exactly as the apps do. |
| 93 | if ProcessInfo.processInfo.environment["CALLS_DECODER_WEDGE"] == "1" { DecoderWedge.enabled = true } |
| 94 | let call = Call(playAudio: false, audioInput: SyntheticVoice()) |
| 95 | if ProcessInfo.processInfo.environment["CALLS_SCREEN_WATCHDOG"] == "0" { Call.screenWatchdogEnabled = false } |
| 96 | // Quality reports every N seconds rather than thirty (spec §15.26), so |
| 97 | // the gate sees one inside its call. A number of seconds, at least one. |
| 98 | if let seconds = ProcessInfo.processInfo.environment["CALLS_QUALITY_INTERVAL"].flatMap(Double.init), seconds >= 1 { |
| 99 | Call.qualityInterval = seconds |
| 100 | } |
| 101 | // WebRTC's own log, filtered to the video encoder, into a file: for |
| 102 | // diagnosing the gate's screen scenarios by hand. Never credentials: |
| 103 | // the filter keeps encoder lines only. |
| 104 | if let base = ProcessInfo.processInfo.environment["CALLS_WEBRTC_LOG"] { |
| 105 | let path = "\(base).\(getpid())" |
| 106 | FileManager.default.createFile(atPath: path, contents: nil) |
| 107 | let handle = FileHandle(forWritingAtPath: path) |
| 108 | let logger = LKRTCCallbackLogger() |
| 109 | logger.severity = .info |
| 110 | let everything = ProcessInfo.processInfo.environment["CALLS_WEBRTC_LOG_ALL"] == "1" |
| 111 | logger.start { message in |
| 112 | let lower = message.lowercased() |
| 113 | guard everything || ["h264", "encod", "videotoolbox", "vtcompression", "resolution", "adapt", "quality"].contains(where: lower.contains) |
| 114 | else { return } |
| 115 | let stamp = String(format: "%.3f ", Date().timeIntervalSince1970) |
| 116 | handle?.write(Data((stamp + message).utf8)) |
| 117 | } |
| 118 | Interop.webrtcLogger = logger |
| 119 | } |
| 120 | // 0.4.1's motion sizing, without the H.264 level cap, for measuring it. |
| 121 | if ProcessInfo.processInfo.environment["CALLS_SCREEN_UNCAPPED"] == "1" { ScreenSendPolicy.levelCapped = false } |
| 122 | let preview = input.chatSnapshot.map { ChatPreview(call: call, path: $0) } |
| 123 | do { |
| 124 | try await call.join( |
| 125 | origin: input.origin, room: input.room, cookies: input.cookies, capture: Interop.camera) |
| 126 | } catch { |
| 127 | print("FAIL native start") |
| 128 | exit(1) |
| 129 | } |
| 130 | Task.detached { |
| 131 | while let command = readLine() { |
| 132 | if command == "leave" { |
| 133 | await call.leave() |
| 134 | exit(0) |
| 135 | } |
| 136 | if command == "mute" { |
| 137 | await call.mute() |
| 138 | print("{\"muted\":\(await call.muted)}") |
| 139 | fflush(stdout) |
| 140 | } |
| 141 | if command == "chat" { |
| 142 | await call.sendChat("native chat probe") |
| 143 | print("{\"chatCommandHandled\":true}") |
| 144 | fflush(stdout) |
| 145 | } |
| 146 | if command == "snapshot" { |
| 147 | let saved = await preview?.save() ?? false |
| 148 | _ = await preview?.saveCall(call: call) |
| 149 | print("{\"chatSnapshotSaved\":\(saved)}") |
| 150 | fflush(stdout) |
| 151 | } |
| 152 | if command == "share" { |
| 153 | await call.loadInvitation() |
| 154 | let invitation = await call.invitationURL?.absoluteString |
| 155 | let refused = await !call.invitationError.isEmpty |
| 156 | let ready = invitation != nil && invitation == input.invitation |
| 157 | print("{\"invitationReady\":\(ready),\"invitationRefused\":\(refused)}") |
| 158 | fflush(stdout) |
| 159 | } |
| 160 | if command == "audio-default" || command == "audio-missing" { |
| 161 | let succeeded = await MainActor.run { |
| 162 | do { |
| 163 | try call.audio.use(input: command == "audio-default" ? "" : "no-such-device") |
| 164 | try call.audio.use(output: "") |
| 165 | return true |
| 166 | } catch { return false } |
| 167 | } |
| 168 | print("{\"audioSwitched\":\(succeeded)}") |
| 169 | fflush(stdout) |
| 170 | } |
| 171 | // The camera stops sending, and the watchdog has to notice and |
| 172 | // restart it: frames come back on the next open. |
| 173 | if command == "camera-stall" { |
| 174 | await MainActor.run { Interop.cameras.stalled = true } |
| 175 | print("{\"cameraStalled\":true}") |
| 176 | fflush(stdout) |
| 177 | } |
| 178 | if command.hasPrefix("camera ") { |
| 179 | let id = String(command.dropFirst("camera ".count)) |
| 180 | await call.useCamera(id) |
| 181 | let now = await call.currentCamera ?? "" |
| 182 | let note = await call.deviceNote |
| 183 | print("{\"camera\":\"\(now)\",\"cameraRefused\":\(note.isEmpty ? "false" : "true")}") |
| 184 | fflush(stdout) |
| 185 | } |
| 186 | // §5.4, sending. `screen-end` is the capture ending on its own, |
| 187 | // which is the path the system's stop control takes. |
| 188 | if command == "screen-on" { |
| 189 | let screen = await SyntheticScreen() |
| 190 | await MainActor.run { Interop.screen = screen } |
| 191 | await call.shareScreen(screen) |
| 192 | let on = await call.sharingScreen |
| 193 | print("{\"screenSharing\":\(on)}") |
| 194 | fflush(stdout) |
| 195 | } |
| 196 | // "Optimise for", as the device panel sets it. |
| 197 | if command.hasPrefix("screen-mode ") { |
| 198 | let mode = ScreenShareMode(rawValue: String(command.dropFirst("screen-mode ".count))) |
| 199 | if let mode { await MainActor.run { call.screenMode = mode } } |
| 200 | print("{\"screenMode\":\"\(mode?.rawValue ?? "")\"}") |
| 201 | fflush(stdout) |
| 202 | } |
| 203 | // The motion gate: a 1080p page scrolling at 30 fps. |
| 204 | if command == "screen-motion" { |
| 205 | let screen = await SyntheticScreen(motion: true) |
| 206 | await MainActor.run { Interop.screen = screen } |
| 207 | await call.shareScreen(screen) |
| 208 | let on = await call.sharingScreen |
| 209 | print("{\"screenSharing\":\(on)}") |
| 210 | fflush(stdout) |
| 211 | } |
| 212 | // A thin uplink, for the same gate: `uplink 1200000`, or `uplink 0` |
| 213 | // to lift it. |
| 214 | if command.hasPrefix("uplink ") { |
| 215 | let bps = Int(command.dropFirst("uplink ".count)) ?? 0 |
| 216 | await call.limitUplink(bps > 0 ? bps : nil) |
| 217 | print("{\"uplink\":\(bps)}") |
| 218 | fflush(stdout) |
| 219 | } |
| 220 | if command == "screen-off" { |
| 221 | await call.stopSharingScreen() |
| 222 | print("{\"screenSharing\":false}") |
| 223 | fflush(stdout) |
| 224 | } |
| 225 | // The synthetic screen stops producing anything, as a capture |
| 226 | // with every surface held does; the watchdog has to restart it. |
| 227 | if command == "screen-stall" { |
| 228 | await MainActor.run { Interop.screen?.stall() } |
| 229 | print("{\"screenStalled\":true}") |
| 230 | fflush(stdout) |
| 231 | } |
| 232 | // Every screen encoding switched off behind the watchdog's back: |
| 233 | // frames go in and nothing comes out. |
| 234 | if command == "screen-encoder-stall" { |
| 235 | await call.stallScreenEncoderForTest() |
| 236 | print("{\"encoderStalled\":true}") |
| 237 | fflush(stdout) |
| 238 | } |
| 239 | // The real thing: this Mac's main display through ScreenCaptureKit |
| 240 | // and the hardware encoder, at the given queue depth. Needs Screen |
| 241 | // Recording permission for the process running the gate. |
| 242 | if command.hasPrefix("screen-display") { |
| 243 | let depth = Int(command.split(separator: " ").dropFirst().first ?? "") ?? 6 |
| 244 | await MainActor.run { MacScreenCapture.queueDepth = depth } |
| 245 | let display = await DisplayCapture() |
| 246 | await MainActor.run { Interop.display = display } |
| 247 | await call.shareScreen(display) |
| 248 | let on = await call.sharingScreen |
| 249 | print("{\"screenSharing\":\(on)}") |
| 250 | fflush(stdout) |
| 251 | } |
| 252 | // The receiving side's watch treats every screen as frozen. |
| 253 | if command.hasPrefix("screen-freeze-test ") { |
| 254 | let ms = Double(command.dropFirst("screen-freeze-test ".count)) ?? 0 |
| 255 | await call.freezeScreenWatchForTest(seconds: ms / 1000) |
| 256 | print("{\"screenFrozenForTest\":true}") |
| 257 | fflush(stdout) |
| 258 | } |
| 259 | // §15.22: this app's screen decoders stop putting out pictures |
| 260 | // while packets keep arriving — a wedge no keyframe mends and a |
| 261 | // new transceiver does. `sticky` wedges the new ones too. |
| 262 | if command == "screen-decoder-wedge" || command == "screen-decoder-wedge sticky" { |
| 263 | let n = DecoderWedge.wedgeScreens(sticky: command.hasSuffix("sticky")) |
| 264 | print("{\"decoderWedged\":\(n)}") |
| 265 | fflush(stdout) |
| 266 | } |
| 267 | if command == "screen-decoder-release" { |
| 268 | DecoderWedge.release() |
| 269 | print("{\"decoderReleased\":true}") |
| 270 | fflush(stdout) |
| 271 | } |
| 272 | // "Copy call diagnostics", as the panel would copy it. |
| 273 | if command == "diagnostics" { |
| 274 | let text = await call.audioDiagnosticsText() |
| 275 | if let data = try? JSONSerialization.data(withJSONObject: ["diagnostics": text]), |
| 276 | let line = String(data: data, encoding: .utf8) |
| 277 | { |
| 278 | print(line) |
| 279 | fflush(stdout) |
| 280 | } |
| 281 | } |
| 282 | if command == "screen-reshare-test" { |
| 283 | await call.reshareScreenForTest() |
| 284 | print("{\"reshared\":true}") |
| 285 | fflush(stdout) |
| 286 | } |
| 287 | if command == "screen-end" { |
| 288 | await Interop.screen?.end() |
| 289 | let on = await call.sharingScreen |
| 290 | print("{\"screenSharing\":\(on)}") |
| 291 | fflush(stdout) |
| 292 | } |
| 293 | if command == "share-snapshot" { |
| 294 | let saved = await preview?.saveInvitation(call: call) ?? false |
| 295 | print("{\"invitationSnapshotSaved\":\(saved)}") |
| 296 | fflush(stdout) |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | // Long enough for the member scenario, screens and all, and for the |
| 301 | // motion gate's three modes: the gate always says `leave` long before |
| 302 | // this runs out. |
| 303 | // CALLS_INTEROP_SECONDS for the soaks, which outlast that (§15.22). |
| 304 | let life = Int(ProcessInfo.processInfo.environment["CALLS_INTEROP_SECONDS"] ?? "") ?? 400 |
| 305 | for _ in 0..<life { |
| 306 | try? await Task.sleep(nanoseconds: 1_000_000_000) |
| 307 | let stats = await call.sampleStats() |
| 308 | if let data = try? JSONSerialization.data(withJSONObject: [ |
| 309 | "phase": call.state?.phase ?? "", "peer": call.state?.peer ?? "", "stats": stats, |
| 310 | "receivedChatProbe": call.chat.contains { $0.hasSuffix(": browser chat probe") }, |
| 311 | "sentChatProbe": call.chat.contains("You: native chat probe"), |
| 312 | "receivedChatCount": call.receivedChatCount, |
| 313 | // §15.19: remote peers this app has been told are muted. |
| 314 | "remoteMuted": (call.state?.peers ?? []).filter { |
| 315 | $0.peer != call.state?.peer && call.isMuted($0.peer) |
| 316 | }.count, |
| 317 | // §5.4: what the stage would caption each screen, in the order |
| 318 | // they started. The words, not a count: the caption is the only |
| 319 | // place a screen says whose it is. |
| 320 | "screenCaptions": call.screens.map { screenCaption(call.name(of: $0)) }, |
| 321 | "sharingScreen": call.sharingScreen, |
| 322 | // §15.22: the sharer's notice, word for word. |
| 323 | "screenRecovery": call.screenRecovery, |
| 324 | // §15.23: "Reconnecting…" said for the call, and on how many tiles. |
| 325 | "reconnecting": call.reconnecting, |
| 326 | "reconnectingPeers": call.reconnectingPeers.count, |
| 327 | "syntheticScreenFrames": Interop.screen?.produced ?? 0, |
| 328 | "cameraOpens": Interop.camera.opens, |
| 329 | "cameraStatus": { |
| 330 | switch call.cameraStatus { |
| 331 | case .live?: return "live" |
| 332 | case .starting?: return "starting" |
| 333 | case .noSignal?: return "no-signal" |
| 334 | case .disconnected?: return "disconnected" |
| 335 | case .failed?: return "failed" |
| 336 | case .noCamera?: return "none" |
| 337 | case nil: return "" |
| 338 | } |
| 339 | }(), |
| 340 | ]), let text = String(data: data, encoding: .utf8) { |
| 341 | print(text) |
| 342 | fflush(stdout) |
| 343 | } |
| 344 | } |
| 345 | await call.leave() |
| 346 | exit(0) |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | /// The main display, captured by the shipping MacScreenCapture with no |
| 351 | /// picker: the gate's way to put ScreenCaptureKit, the frame handling and |
| 352 | /// the hardware encoder under test together. |
| 353 | @MainActor final class DisplayCapture: ScreenCapture { |
| 354 | let mac = MacScreenCapture() |
| 355 | func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws { |
| 356 | let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) |
| 357 | guard let display = content.displays.first else { throw CancellationError() } |
| 358 | try await mac.start(display: display, source: source, ended: ended) |
| 359 | } |
| 360 | func stop() { mac.stop() } |
| 361 | var counts: ScreenCaptureCounts { mac.counts } |
| 362 | func restart() async -> Bool { await mac.restart() } |
| 363 | var frameSize: (width: Int, height: Int)? { mac.frameSize } |
| 364 | } |
| 365 | |
| 366 | // Render the shipping chat view against real received messages. This fixture |
| 367 | // window sits off-screen and never activates or interrupts the user's call. |
| 368 | @MainActor private final class ChatPreview { |
| 369 | let window: NSWindow |
| 370 | let view: NSHostingView<AnyView> |
| 371 | let path: String |
| 372 | init(call: Call, path: String) { |
| 373 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 374 | self.path = path |
| 375 | view = NSHostingView( |
| 376 | rootView: AnyView( |
| 377 | ChatView(call: call).padding(12).background(Color.white).environment(\.colorScheme, .light)) |
| 378 | ) |
| 379 | window = NSWindow( |
| 380 | contentRect: NSRect(x: -2000, y: -2000, width: 600, height: 200), |
| 381 | styleMask: [.borderless], backing: .buffered, defer: false) |
| 382 | window.isReleasedWhenClosed = false |
| 383 | window.contentView = view |
| 384 | window.orderFront(nil) |
| 385 | } |
| 386 | func save() -> Bool { |
| 387 | view.layoutSubtreeIfNeeded() |
| 388 | guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } |
| 389 | view.cacheDisplay(in: view.bounds, to: bitmap) |
| 390 | guard let data = bitmap.representation(using: .png, properties: [:]) else { return false } |
| 391 | do { |
| 392 | try data.write(to: URL(fileURLWithPath: path)) |
| 393 | return true |
| 394 | } catch { return false } |
| 395 | } |
| 396 | func saveCall(call: Call) async -> Bool { |
| 397 | var saved = true |
| 398 | for (name, grid, chat) in [ |
| 399 | ("call-focus.png", false, false), |
| 400 | ("call-chat.png", false, true), |
| 401 | ("call-grid.png", true, false), |
| 402 | ] { |
| 403 | view.rootView = AnyView( |
| 404 | MacCallView(call: call, grid: grid, showingChat: chat, style: .classic, leave: {}).id(name) |
| 405 | .padding(20).tint(CallsAppearance.accent).background(Color.white) |
| 406 | .environment(\.colorScheme, .light)) |
| 407 | window.setContentSize(NSSize(width: 900, height: 540)) |
| 408 | try? await Task.sleep(for: .milliseconds(300)) |
| 409 | saved = saveImage(named: name) && saved |
| 410 | } |
| 411 | view.rootView = AnyView( |
| 412 | VStack(alignment: .leading, spacing: 0) { |
| 413 | Text("Devices").font(.headline).padding(.horizontal, 16).padding(.top, 16) |
| 414 | DevicePanel(call: call) |
| 415 | Spacer(minLength: 0) |
| 416 | }.frame(width: 300) |
| 417 | .background(Color.white).environment(\.colorScheme, .light)) |
| 418 | window.setContentSize(NSSize(width: 300, height: 340)) |
| 419 | try? await Task.sleep(for: .milliseconds(300)) |
| 420 | saved = saveImage(named: "call-devices.png") && saved |
| 421 | view.rootView = AnyView( |
| 422 | ChatView(call: call).padding(12) |
| 423 | .background(Color.white).environment(\.colorScheme, .light)) |
| 424 | window.setContentSize(NSSize(width: 600, height: 200)) |
| 425 | return saved |
| 426 | } |
| 427 | private func saveImage(named name: String) -> Bool { |
| 428 | view.layoutSubtreeIfNeeded() |
| 429 | guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } |
| 430 | view.cacheDisplay(in: view.bounds, to: bitmap) |
| 431 | guard let data = bitmap.representation(using: .png, properties: [:]) else { return false } |
| 432 | let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent(name) |
| 433 | do { |
| 434 | try data.write(to: target) |
| 435 | return true |
| 436 | } catch { return false } |
| 437 | } |
| 438 | func saveInvitation(call: Call) async -> Bool { |
| 439 | view.rootView = AnyView( |
| 440 | MeetingInvitationView(call: call, close: {}) |
| 441 | .background(Color.white).environment(\.colorScheme, .light)) |
| 442 | window.setContentSize(NSSize(width: 440, height: 340)) |
| 443 | try? await Task.sleep(for: .milliseconds(250)) |
| 444 | view.layoutSubtreeIfNeeded() |
| 445 | guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } |
| 446 | view.cacheDisplay(in: view.bounds, to: bitmap) |
| 447 | guard let data = bitmap.representation(using: .png, properties: [:]) else { return false } |
| 448 | let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent( |
| 449 | "share-meeting.png") |
| 450 | do { |
| 451 | try data.write(to: target) |
| 452 | return true |
| 453 | } catch { return false } |
| 454 | } |
| 455 | } |
| 456 | |