Refine Meet native calls for Mac, iPhone and iPad
Add remote focus with local thumbnail, grid switching, compact colored controls, persistent chat drafts and a separate Mac chat window. Derive padded rounded Mac artwork.
Co-Authored-By: Codex <codex~paul@keymail.dev>
22 files changed,
+904
−258
native/DESIGN.md+70 −0native/README.md+8 −1native/apple/Package.swift+2 −1native/apple/Sources/MeetInterop/Interop.swift+55 −8native/apple/Sources/MeetInterop/LobbySnapshots.swift+50 −0native/apple/Sources/MeetKit/CallActionStyle.swift+22 −0native/apple/Sources/MeetKit/CallControl.swift+20 −0native/apple/Sources/MeetKit/ChatDraft.swift+7 −0native/apple/Sources/MeetKit/ChatView.swift+12 −6native/apple/Sources/MeetKit/MacCallView.swift+194 −0native/apple/Sources/MeetKit/MacChatWindow.swift+39 −0native/apple/Sources/MeetKit/MacVideoTile.swift+89 −0native/apple/Sources/MeetKit/MeetAppearance.swift+7 −0native/apple/Sources/MeetKit/MeetLobby.swift+105 −0native/apple/Sources/MeetKit/MeetingShareButton.swift+1 −1native/apple/Sources/MeetMac/MeetMac.swift+8 −125native/apple/Sources/MeetMac/Resources/MacIcon.pngbinarynative/apple/Tests/MeetKitTests/ChatWindowTests.swift+36 −0native/ios/Sources/MeetPhone.swift+149 −112native/scripts/build-mac.sh+1 −0native/scripts/bundle-mac.py+5 −4native/scripts/generate-mac-icon.swift+24 −0
diff --git a/native/DESIGN.md b/native/DESIGN.md| new file mode 100644 |
| index 0000000..59ea5c9 |
| --- /dev/null |
| +++ b/native/DESIGN.md |
| @@ -0,0 +1,70 @@ |
| +# Meet native design |
| + |
| +The first Oficina native design pass uses Meet's existing teal identity, |
| +quiet surfaces, clear type hierarchy and native controls. Apple's communication |
| +and productivity apps are references for interaction quality; Oficina retains |
| +its own artwork, wording and colour. Mac and phone share the joining ceremony |
| +while their call layouts suit the available space. |
| + |
| +## First pass |
| + |
| +- `MeetLobby` renders welcome, browser approval, ready-to-join and error states |
| + on Mac, iPhone and iPad. The confirmation code and browser approval remain |
| + part of the existing authentication flow. |
| +- `MacCallView` defaults to a remote participant filling the stage with a local |
| + thumbnail. Focus/Grid switches layouts; a participant menu selects the focus |
| + in group calls. Video crops to fill while preserving its aspect ratio. Chat |
| + starts hidden and can open as a sidebar or a separate window; its draft survives |
| + moving and closing. The compact, coloured icon controls remain below the stage. On iPhone, chat remains a sheet and controls remain |
| + in the bottom safe area. |
| +- Video names sit on an opaque dark label over the image. Microphone and camera |
| + actions use both text and changing symbols, so colour is never the only cue. |
| +- `MeetAppearance` and `CallControl` live in MeetKit for both Apple clients. |
| + These are Meet presentation choices. General native infrastructure belongs |
| + in Rastrillo Native; Oficina's shared visual components belong in Estilo as |
| + subsequent apps establish what they need in common. |
| + |
| +## Visual review |
| + |
| +After `swift build --package-path native/apple`, render the shipping lobby: |
| + |
| +```sh |
| +native/apple/.build/debug/MeetInterop --lobby-snapshots native/build/polish |
| +``` |
| + |
| +This generates light and dark welcome, approval, ready and error fixtures |
| +without a server, real account or device permissions. The fixture entry point |
| +is only in MeetInterop, never the application. |
| + |
| +`TestNativeMacChat` also captures `native/build/call-focus.png`, `call-chat.png` and `call-grid.png` using the |
| +shipping Mac call view and messages received from a real browser participant. |
| +The off-screen bitmap capture does not capture Metal video frames; media |
| +continuity is verified separately by the existing decoded-frame/audio counters. |
| + |
| +The first pass was checked with Mac and iOS Simulator builds, the iPhone |
| +invalid-link UI test, and native/browser authentication, media, chat and sharing |
| +checks. Lobby review includes light/dark Mac states and iPhone at standard and |
| +accessibility text sizes. Physical-device call controls, VoiceOver, camera and |
| +microphone behaviour still need hands-on acceptance. |
| + |
| +## Next iterations |
| + |
| +Camera preview and device selection before joining, remote camera-off signalling, |
| +automatic speaker selection, and keyboard commands are follow-up product work. |
| +Those should be designed alongside the relevant media and lifecycle behaviour. |
| + |
| +Run `swift test --package-path native/apple` for chat-window lifecycle and draft |
| +retention checks. Window closure never invokes the meeting leave action. |
| + |
| +## iPhone and iPad call treatment · 2026-09-12 |
| + |
| +The iOS call stage now defaults to remote focus with a local thumbnail, offers |
| +Focus/Grid and participant selection, and uses the same compact colored icon |
| +control vocabulary. Chat is a native sheet with unread count and a retained |
| +draft. iOS continues to end calls when backgrounded; native screen sharing and |
| +background-call continuity remain follow-up media work. |
| + |
| +Safari member/guest sign-in and the real iPhone simulator native-media tests |
| +passed after this change. The signed development app was built and installed on |
| +Paul's paired iPhone. That installation does not substitute for a physical |
| +camera/microphone and interruption acceptance call. |
diff --git a/native/README.md b/native/README.md| index c096b7c..b69787a 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -96,5 +96,12 @@ See [VERIFICATION.md](VERIFICATION.md) for the completed gate record and accepta |
| The iPhone asset catalog and Mac application resource use the approved Oficina |
| Meet icon. Both are generated copies of Estilo's `brand/png/meet-1024.png`; |
| update them with `python3 brand/distribute.py meet /path/to/meet` from Estilo. |
| -The Mac sets its Dock icon from the packaged resource on launch. The Lucide |
| +The Mac derives `MacIcon.png` with rounded corners and transparent padding using |
| +`native/scripts/generate-mac-icon.swift`. Its Finder ICNS and runtime Dock icon |
| +both use that Mac variant; the square master remains unchanged. The Lucide |
| notice is retained in `native/LICENSE.lucide`. |
| + |
| +### Native interface |
| + |
| +See [DESIGN.md](DESIGN.md) for the first Oficina native visual direction, shared |
| +views, screenshot workflow and remaining interaction work. |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| index a933498..546f1d8 100644 |
| --- a/native/apple/Package.swift |
| +++ b/native/apple/Package.swift |
| @@ -16,7 +16,8 @@ let package = Package( |
| .binaryTarget(name: "MeetBridge", path: "../build/MeetBridge.xcframework"), |
| .target( |
| name: "MeetKit", dependencies: ["MeetBridge", .product(name: "WebRTC", package: "WebRTC")]), |
| - .executableTarget(name: "MeetMac", dependencies: ["MeetKit"], resources: [.copy("Resources/AppIcon.png")]), |
| + .executableTarget(name: "MeetMac", dependencies: ["MeetKit"], resources: [.copy("Resources/AppIcon.png"), .copy("Resources/MacIcon.png")]), |
| .executableTarget(name: "MeetInterop", dependencies: ["MeetKit"]), |
| + .testTarget(name: "MeetKitTests", dependencies: ["MeetKit"]), |
| ] |
| ) |
diff --git a/native/apple/Sources/MeetInterop/Interop.swift b/native/apple/Sources/MeetInterop/Interop.swift| index 465a6a6..ec757d2 100644 |
| --- a/native/apple/Sources/MeetInterop/Interop.swift |
| +++ b/native/apple/Sources/MeetInterop/Interop.swift |
| @@ -1,6 +1,6 @@ |
| +import AppKit |
| import Foundation |
| import MeetKit |
| -import AppKit |
| import SwiftUI |
| // A separate test executable: synthetic video and fixture credentials are never |
| @@ -14,6 +14,10 @@ struct Input: Decodable { |
| } |
| @main struct Interop { |
| @MainActor static func main() async { |
| + if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--lobby-snapshots" { |
| + do { try await LobbySnapshots.save(to: CommandLine.arguments[2]) } catch { exit(1) } |
| + return |
| + } |
| guard let line = readLine(), |
| let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)), |
| let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1" |
| @@ -40,6 +44,7 @@ struct Input: Decodable { |
| } |
| if command == "snapshot" { |
| let saved = await preview?.save() ?? false |
| + _ = await preview?.saveCall(call: call) |
| print("{\"chatSnapshotSaved\":\(saved)}") |
| fflush(stdout) |
| } |
| @@ -85,8 +90,10 @@ struct Input: Decodable { |
| init(call: Call, path: String) { |
| NSApplication.shared.setActivationPolicy(.prohibited) |
| self.path = path |
| - view = NSHostingView(rootView: AnyView( |
| - ChatView(call: call).padding(12).background(Color.white).environment(\.colorScheme, .light))) |
| + view = NSHostingView( |
| + rootView: AnyView( |
| + ChatView(call: call).padding(12).background(Color.white).environment(\.colorScheme, .light)) |
| + ) |
| window = NSWindow( |
| contentRect: NSRect(x: -2000, y: -2000, width: 600, height: 200), |
| styleMask: [.borderless], backing: .buffered, defer: false) |
| @@ -99,18 +106,58 @@ struct Input: Decodable { |
| guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } |
| view.cacheDisplay(in: view.bounds, to: bitmap) |
| guard let data = bitmap.representation(using: .png, properties: [:]) else { return false } |
| - do { try data.write(to: URL(fileURLWithPath: path)); return true } catch { return false } |
| + do { |
| + try data.write(to: URL(fileURLWithPath: path)) |
| + return true |
| + } catch { return false } |
| + } |
| + func saveCall(call: Call) async -> Bool { |
| + var saved = true |
| + for (name, grid, chat) in [ |
| + ("call-focus.png", false, false), |
| + ("call-chat.png", false, true), |
| + ("call-grid.png", true, false), |
| + ] { |
| + view.rootView = AnyView( |
| + MacCallView(call: call, grid: grid, showingChat: chat, leave: {}).id(name) |
| + .padding(20).tint(MeetAppearance.accent).background(Color.white) |
| + .environment(\.colorScheme, .light)) |
| + window.setContentSize(NSSize(width: 900, height: 540)) |
| + try? await Task.sleep(for: .milliseconds(300)) |
| + saved = saveImage(named: name) && saved |
| + } |
| + view.rootView = AnyView( |
| + ChatView(call: call).padding(12) |
| + .background(Color.white).environment(\.colorScheme, .light)) |
| + window.setContentSize(NSSize(width: 600, height: 200)) |
| + return saved |
| + } |
| + private func saveImage(named name: String) -> Bool { |
| + view.layoutSubtreeIfNeeded() |
| + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } |
| + view.cacheDisplay(in: view.bounds, to: bitmap) |
| + guard let data = bitmap.representation(using: .png, properties: [:]) else { return false } |
| + let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent(name) |
| + do { |
| + try data.write(to: target) |
| + return true |
| + } catch { return false } |
| } |
| func saveInvitation(call: Call) async -> Bool { |
| - view.rootView = AnyView(MeetingInvitationView(call: call, close: {}) |
| - .background(Color.white).environment(\.colorScheme, .light)) |
| + view.rootView = AnyView( |
| + MeetingInvitationView(call: call, close: {}) |
| + .background(Color.white).environment(\.colorScheme, .light)) |
| window.setContentSize(NSSize(width: 440, height: 340)) |
| try? await Task.sleep(for: .milliseconds(250)) |
| view.layoutSubtreeIfNeeded() |
| guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false } |
| view.cacheDisplay(in: view.bounds, to: bitmap) |
| guard let data = bitmap.representation(using: .png, properties: [:]) else { return false } |
| - let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent("share-meeting.png") |
| - do { try data.write(to: target); return true } catch { return false } |
| + let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent( |
| + "share-meeting.png") |
| + do { |
| + try data.write(to: target) |
| + return true |
| + } catch { return false } |
| } |
| } |
diff --git a/native/apple/Sources/MeetInterop/LobbySnapshots.swift b/native/apple/Sources/MeetInterop/LobbySnapshots.swift| new file mode 100644 |
| index 0000000..e9a5d5f |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetInterop/LobbySnapshots.swift |
| @@ -0,0 +1,50 @@ |
| +import AppKit |
| +import MeetKit |
| +import SwiftUI |
| + |
| +/// Offline visual fixtures of the shipping lobby. Only built into the test executable. |
| +@MainActor enum LobbySnapshots { |
| + static func save(to directory: String) async throws { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + let folder = URL(fileURLWithPath: directory) |
| + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| + for scheme in [ColorScheme.light, .dark] { |
| + for state in ["welcome", "signin", "ready", "error"] { |
| + let model = MeetModel() |
| + if state == "signin" { |
| + model.authenticating = true |
| + model.confirmationCode = "A1B2-C3D4" |
| + model.signin = URL(string: "https://example.invalid") |
| + } |
| + if state == "ready" { |
| + model.signedIn = true |
| + model.room = "team-catchup" |
| + } |
| + if state == "error" { model.error = "Enter a full meeting or invitation link." } |
| + let view = NSHostingView( |
| + rootView: MeetLobby(model: model).background( |
| + scheme == .dark ? Color(white: 0.12) : Color(white: 0.98) |
| + ).environment(\.colorScheme, scheme)) |
| + let window = NSWindow( |
| + contentRect: NSRect(x: -2000, y: -2000, width: 900, height: 720), |
| + styleMask: [.borderless], backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) |
| + window.contentView = view |
| + window.orderFront(nil) |
| + try await Task.sleep(for: .milliseconds(300)) |
| + view.layoutSubtreeIfNeeded() |
| + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { |
| + throw CocoaError(.fileWriteUnknown) |
| + } |
| + view.cacheDisplay(in: view.bounds, to: bitmap) |
| + guard let data = bitmap.representation(using: .png, properties: [:]) else { |
| + throw CocoaError(.fileWriteUnknown) |
| + } |
| + try data.write( |
| + to: folder.appendingPathComponent("\(state)-\(scheme == .dark ? "dark" : "light").png")) |
| + window.close() |
| + } |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/CallActionStyle.swift b/native/apple/Sources/MeetKit/CallActionStyle.swift| new file mode 100644 |
| index 0000000..3a1bd4e |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/CallActionStyle.swift |
| @@ -0,0 +1,22 @@ |
| +import SwiftUI |
| + |
| +/// Compact call controls retain their Button labels for VoiceOver and tooltips. |
| +public struct CallActionStyle: ButtonStyle { |
| + public var color: Color |
| + public var emphasized: Bool |
| + @Environment(\.isEnabled) private var isEnabled |
| + |
| + public init(color: Color = MeetAppearance.accent, emphasized: Bool = false) { |
| + self.color = color |
| + self.emphasized = emphasized |
| + } |
| + |
| + public func makeBody(configuration: Configuration) -> some View { |
| + configuration.label.labelStyle(.iconOnly).font(.title3) |
| + .frame(width: 44, height: 44) |
| + .foregroundStyle(emphasized ? .white : color) |
| + .background(emphasized ? color : color.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) |
| + .opacity(!isEnabled ? 0.4 : configuration.isPressed ? 0.65 : 1) |
| + .contentShape(RoundedRectangle(cornerRadius: 14)) |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/CallControl.swift b/native/apple/Sources/MeetKit/CallControl.swift| new file mode 100644 |
| index 0000000..139bb51 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/CallControl.swift |
| @@ -0,0 +1,20 @@ |
| +import SwiftUI |
| + |
| +/// A labelled call action with a stable target and a visible on/off state. |
| +public struct CallControl: View { |
| + private let title: LocalizedStringKey |
| + private let symbol: String |
| + private let action: () -> Void |
| + |
| + public init(_ title: LocalizedStringKey, systemImage: String, action: @escaping () -> Void) { |
| + self.title = title |
| + self.symbol = systemImage |
| + self.action = action |
| + } |
| + |
| + public var body: some View { |
| + Button(action: action) { |
| + Label(title, systemImage: symbol).frame(minHeight: 32) |
| + }.buttonStyle(.bordered).controlSize(.large) |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/ChatDraft.swift b/native/apple/Sources/MeetKit/ChatDraft.swift| new file mode 100644 |
| index 0000000..738e445 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/ChatDraft.swift |
| @@ -0,0 +1,7 @@ |
| +import Combine |
| + |
| +/// One draft survives moving chat between the sidebar and its own window. |
| +@MainActor public final class ChatDraft: ObservableObject { |
| + @Published public var text = "" |
| + public init() {} |
| +} |
diff --git a/native/apple/Sources/MeetKit/ChatView.swift b/native/apple/Sources/MeetKit/ChatView.swift| index dde36d3..8f99bd6 100644 |
| --- a/native/apple/Sources/MeetKit/ChatView.swift |
| +++ b/native/apple/Sources/MeetKit/ChatView.swift |
| @@ -4,9 +4,12 @@ import SwiftUI |
| /// signaling stream reconnects; only sending depends on current admission. |
| @MainActor public struct ChatView: View { |
| @ObservedObject private var call: Call |
| - @State private var message = "" |
| + @StateObject private var draft: ChatDraft |
| - public init(call: Call) { self.call = call } |
| + public init(call: Call, draft: ChatDraft? = nil) { |
| + self.call = call |
| + _draft = StateObject(wrappedValue: draft ?? ChatDraft()) |
| + } |
| public var body: some View { |
| VStack(alignment: .leading, spacing: 12) { |
| @@ -19,6 +22,8 @@ import SwiftUI |
| } |
| ForEach(Array(call.chat.enumerated()), id: \.offset) { _, line in |
| Text(line).textSelection(.enabled) |
| + .padding(10).frame(maxWidth: .infinity, alignment: .leading) |
| + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10)) |
| } |
| Color.clear.frame(height: 1).id("chat-bottom") |
| }.frame(maxWidth: .infinity, alignment: .leading) |
| @@ -35,18 +40,19 @@ import SwiftUI |
| .font(.footnote).foregroundStyle(.secondary) |
| } |
| HStack { |
| - TextField("Message to everyone", text: $message) |
| + TextField("Message to everyone", text: $draft.text) |
| .textFieldStyle(.roundedBorder) |
| .accessibilityIdentifier("chatComposer") |
| .onSubmit(send) |
| - Button("Send", action: send) |
| - .disabled(message.isEmpty || call.state?.phase != "admitted") |
| + Button("Send", systemImage: "arrow.up.circle.fill", action: send) |
| + .buttonStyle(.borderedProminent) |
| + .disabled(draft.text.isEmpty || call.state?.phase != "admitted") |
| } |
| } |
| } |
| private func send() { |
| guard call.state?.phase == "admitted" else { return } |
| - if call.sendChat(message) { message = "" } |
| + if call.sendChat(draft.text) { draft.text = "" } |
| } |
| } |
diff --git a/native/apple/Sources/MeetKit/MacCallView.swift b/native/apple/Sources/MeetKit/MacCallView.swift| new file mode 100644 |
| index 0000000..66dcb4b |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/MacCallView.swift |
| @@ -0,0 +1,194 @@ |
| +#if os(macOS) |
| + import SwiftUI |
| + |
| + public struct MacCallView: View { |
| + @ObservedObject private var call: Call |
| + private let leave: () -> Void |
| + @State private var grid: Bool |
| + @State private var showingChat: Bool |
| + @State private var selectedPeer: String? |
| + @State private var readChatCount = 0 |
| + @StateObject private var chatWindow = MacChatWindow() |
| + |
| + public init( |
| + call: Call, grid: Bool = false, showingChat: Bool = false, leave: @escaping () -> Void |
| + ) { |
| + self.call = call |
| + self.leave = leave |
| + _grid = State(initialValue: grid) |
| + _showingChat = State(initialValue: showingChat) |
| + } |
| + |
| + public var body: some View { |
| + VStack(spacing: 14) { |
| + HStack { |
| + VStack(alignment: .leading, spacing: 4) { |
| + Text(call.title).font(.title2.bold()).lineLimit(1) |
| + Text(call.status).font(.callout).foregroundStyle(.secondary) |
| + .accessibilityAddTraits(.updatesFrequently) |
| + } |
| + Spacer() |
| + if !grid, peers.count > 1 { |
| + Menu { |
| + ForEach(peers) { peer in |
| + Button(peer.name) { selectedPeer = peer.peer } |
| + } |
| + } label: { |
| + Label("Choose participant", systemImage: "person.2") |
| + } |
| + .help("Choose the participant to show on the main stage") |
| + } |
| + Picker("Video layout", selection: $grid) { |
| + Label("Focus", systemImage: "rectangle.inset.bottomtrailing").tag(false) |
| + Label("Grid", systemImage: "square.grid.2x2").tag(true) |
| + }.pickerStyle(.segmented).fixedSize() |
| + } |
| + if call.floor { |
| + Text("Direct connections only. People on some networks may be unable to connect.") |
| + .font(.callout).foregroundStyle(.secondary).frame( |
| + maxWidth: .infinity, alignment: .leading) |
| + } |
| + HStack(alignment: .top, spacing: 16) { |
| + stage.frame(maxWidth: .infinity, maxHeight: .infinity) |
| + if showingChat { |
| + VStack(spacing: 14) { |
| + HStack { |
| + Text("Chat").font(.headline) |
| + Spacer() |
| + Button("Open chat in a window", systemImage: "arrow.up.forward.app") { |
| + showingChat = false |
| + chatWindow.open(call: call) |
| + }.help("Open chat in a separate window") |
| + Button("Hide chat", systemImage: "xmark") { showingChat = false } |
| + .help("Hide chat") |
| + }.labelStyle(.iconOnly).buttonStyle(.borderless) |
| + ChatView(call: call, draft: chatWindow.draft) |
| + }.padding(16).frame(width: 280) |
| + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16)) |
| + } |
| + }.frame(maxWidth: .infinity, maxHeight: .infinity) |
| + ForEach(call.state?.waiting ?? []) { peer in |
| + HStack { |
| + Text("\(peer.name) is waiting") |
| + Spacer() |
| + Button("Let in") { call.admit(peer.peer) }.buttonStyle(.borderedProminent) |
| + } |
| + } |
| + if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| + controls |
| + } |
| + .onAppear { |
| + if showingChat { readChatCount = call.receivedChatCount } |
| + } |
| + .onChange(of: call.receivedChatCount) { _, count in |
| + if showingChat || chatWindow.isOpen { readChatCount = count } |
| + } |
| + .onChange(of: showingChat) { _, visible in |
| + if visible { readChatCount = call.receivedChatCount } |
| + } |
| + .onChange(of: chatWindow.isOpen) { _, visible in |
| + if visible { readChatCount = call.receivedChatCount } |
| + } |
| + .onDisappear { chatWindow.close() } |
| + } |
| + |
| + private var peers: [RosterPeer] { |
| + call.state?.peers.filter { $0.peer != call.state?.peer } ?? [] |
| + } |
| + private var focusedPeer: RosterPeer? { peers.first { $0.peer == selectedPeer } ?? peers.first } |
| + |
| + @ViewBuilder private var stage: some View { |
| + if grid { |
| + ScrollView { |
| + LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) { |
| + MacVideoTile( |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff |
| + ) |
| + .aspectRatio(16 / 9, contentMode: .fit) |
| + ForEach(peers) { peer in |
| + MacVideoTile(track: call.remoteTracks[peer.peer], label: peer.name) |
| + .aspectRatio(16 / 9, contentMode: .fit) |
| + } |
| + } |
| + } |
| + } else { |
| + GeometryReader { geometry in |
| + ZStack(alignment: .bottomTrailing) { |
| + if let peer = focusedPeer { |
| + MacVideoTile(track: call.remoteTracks[peer.peer], label: peer.name) |
| + } else { |
| + ContentUnavailableView( |
| + "Waiting for others", systemImage: "person.2", |
| + description: Text("Your meeting is ready. Others will appear here when they join.") |
| + ) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + .background(MeetAppearance.background, in: RoundedRectangle(cornerRadius: 16)) |
| + } |
| + MacVideoTile( |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff |
| + ) |
| + .frame( |
| + width: min(180, geometry.size.width * 0.3), |
| + height: min(135, geometry.size.height * 0.3) |
| + ) |
| + .shadow(color: .black.opacity(0.25), radius: 8, y: 3).padding(14) |
| + } |
| + } |
| + } |
| + } |
| + |
| + private var controls: some View { |
| + HStack(spacing: 12) { |
| + Button( |
| + call.muted ? "Unmute microphone" : "Mute microphone", |
| + systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| + ) { call.mute() } |
| + .buttonStyle( |
| + CallActionStyle( |
| + color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : MeetAppearance.accent, |
| + emphasized: call.muted) |
| + ) |
| + .help(call.muted ? "Unmute microphone" : "Mute microphone") |
| + Button( |
| + call.cameraOff ? "Turn camera on" : "Turn camera off", |
| + systemImage: call.cameraOff ? "video.slash.fill" : "video.fill" |
| + ) { call.toggleCamera() } |
| + .buttonStyle( |
| + CallActionStyle( |
| + color: call.cameraOff ? Color(red: 0.65, green: 0.30, blue: 0.02) : .blue, |
| + emphasized: call.cameraOff) |
| + ) |
| + .help(call.cameraOff ? "Turn camera on" : "Turn camera off") |
| + MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) |
| + .help("Share meeting link") |
| + Spacer() |
| + Button(chatTitle, systemImage: "bubble.left.and.bubble.right.fill") { |
| + if chatWindow.isOpen { chatWindow.close() } |
| + showingChat.toggle() |
| + }.buttonStyle(CallActionStyle(color: .purple, emphasized: showingChat || chatWindow.isOpen)) |
| + .help(chatTitle) |
| + .overlay(alignment: .topTrailing) { |
| + if unread > 0 { |
| + Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| + .padding(4).background(.purple, in: Capsule()).offset(x: 5, y: -5) |
| + .accessibilityHidden(true) |
| + } |
| + } |
| + .contextMenu { |
| + Button("Open chat in a window", systemImage: "arrow.up.forward.app") { |
| + showingChat = false |
| + chatWindow.open(call: call) |
| + } |
| + } |
| + Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| + .buttonStyle(CallActionStyle(color: .red, emphasized: true)).help("Leave meeting") |
| + }.frame(maxWidth: .infinity) |
| + } |
| + private var unread: Int { max(0, call.receivedChatCount - readChatCount) } |
| + private var chatTitle: String { |
| + chatWindow.isOpen |
| + ? "Move chat into the sidebar" |
| + : unread > 0 ? "Chat (\(unread) new)" : showingChat ? "Hide chat" : "Show chat" |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/MeetKit/MacChatWindow.swift b/native/apple/Sources/MeetKit/MacChatWindow.swift| new file mode 100644 |
| index 0000000..ff2e873 |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/MacChatWindow.swift |
| @@ -0,0 +1,39 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + |
| + @MainActor final class MacChatWindow: NSObject, ObservableObject, NSWindowDelegate { |
| + @Published private(set) var isOpen = false |
| + let draft = ChatDraft() |
| + private var window: NSWindow? |
| + |
| + func open(call: Call) { |
| + if let window { |
| + window.makeKeyAndOrderFront(nil) |
| + return |
| + } |
| + let window = NSWindow( |
| + contentRect: NSRect(x: 0, y: 0, width: 380, height: 520), |
| + styleMask: [.titled, .closable, .miniaturizable, .resizable], |
| + backing: .buffered, defer: false) |
| + window.title = "Chat — \(call.title)" |
| + window.minSize = NSSize(width: 300, height: 300) |
| + window.isReleasedWhenClosed = false |
| + window.contentView = NSHostingView( |
| + rootView: ChatView(call: call, draft: draft) |
| + .padding(20).tint(MeetAppearance.accent)) |
| + window.delegate = self |
| + self.window = window |
| + isOpen = true |
| + window.center() |
| + window.makeKeyAndOrderFront(nil) |
| + } |
| + |
| + func close() { window?.close() } |
| + |
| + func windowWillClose(_ notification: Notification) { |
| + window = nil |
| + isOpen = false |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/MeetKit/MacVideoTile.swift b/native/apple/Sources/MeetKit/MacVideoTile.swift| new file mode 100644 |
| index 0000000..061b5de |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/MacVideoTile.swift |
| @@ -0,0 +1,89 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + import WebRTC |
| + |
| + struct MacVideoTile: View { |
| + let track: RTCVideoTrack? |
| + let label: String |
| + var mirrored = false |
| + var cameraOff = false |
| + |
| + var body: some View { |
| + ZStack { |
| + Color.black |
| + if let track, !cameraOff { |
| + MacVideoView(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| + } else { |
| + VStack(spacing: 12) { |
| + Image(systemName: cameraOff ? "video.slash" : "person.crop.circle") |
| + .font(.largeTitle) |
| + Text(cameraOff ? "Camera off" : "Waiting for video").font(.callout) |
| + }.foregroundStyle(.white.opacity(0.8)) |
| + } |
| + } |
| + .overlay(alignment: .bottomLeading) { |
| + Text(label).font(.callout.bold()).foregroundStyle(.white) |
| + .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6) |
| + .background(.black.opacity(0.65), in: Capsule()).padding(10) |
| + } |
| + .clipShape(RoundedRectangle(cornerRadius: 16)) |
| + .accessibilityElement(children: .ignore) |
| + .accessibilityLabel(label) |
| + .accessibilityValue( |
| + cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on") |
| + } |
| + } |
| + |
| + private struct MacVideoView: NSViewRepresentable { |
| + let track: RTCVideoTrack |
| + func makeNSView(context: Context) -> FillVideoView { FillVideoView() } |
| + func updateNSView(_ view: FillVideoView, context: Context) { view.attach(track) } |
| + static func dismantleNSView(_ view: FillVideoView, coordinator: ()) { view.detach() } |
| + } |
| + |
| + /// The renderer keeps its source aspect ratio while the container crops overflow. |
| + private final class FillVideoView: NSView, RTCVideoViewDelegate { |
| + private let renderer = RTCMTLNSVideoView(frame: .zero) |
| + private var track: RTCVideoTrack? |
| + private var videoSize = NSSize(width: 16, height: 9) |
| + |
| + override init(frame: NSRect) { |
| + super.init(frame: frame) |
| + wantsLayer = true |
| + layer?.masksToBounds = true |
| + renderer.delegate = self |
| + addSubview(renderer) |
| + } |
| + required init?(coder: NSCoder) { nil } |
| + |
| + func attach(_ next: RTCVideoTrack) { |
| + guard track !== next else { return } |
| + detach() |
| + track = next |
| + next.add(renderer) |
| + } |
| + func detach() { |
| + track?.remove(renderer) |
| + track = nil |
| + } |
| + |
| + nonisolated func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { |
| + DispatchQueue.main.async { [weak self] in |
| + guard let self, size.width > 0, size.height > 0 else { return } |
| + self.videoSize = size |
| + self.needsLayout = true |
| + } |
| + } |
| + |
| + override func layout() { |
| + super.layout() |
| + let scale = max(bounds.width / videoSize.width, bounds.height / videoSize.height) |
| + let size = NSSize(width: videoSize.width * scale, height: videoSize.height * scale) |
| + renderer.frame = NSRect( |
| + x: (bounds.width - size.width) / 2, |
| + y: (bounds.height - size.height) / 2, |
| + width: size.width, height: size.height) |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/MeetKit/MeetAppearance.swift b/native/apple/Sources/MeetKit/MeetAppearance.swift| new file mode 100644 |
| index 0000000..d23660c |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/MeetAppearance.swift |
| @@ -0,0 +1,7 @@ |
| +import SwiftUI |
| + |
| +/// Meet's first native palette; app-specific until a second app adopts it. |
| +public enum MeetAppearance { |
| + public static let accent = Color(red: 0.03, green: 0.49, blue: 0.54) |
| + public static let background = Color.primary.opacity(0.035) |
| +} |
diff --git a/native/apple/Sources/MeetKit/MeetLobby.swift b/native/apple/Sources/MeetKit/MeetLobby.swift| new file mode 100644 |
| index 0000000..d824e8c |
| --- /dev/null |
| +++ b/native/apple/Sources/MeetKit/MeetLobby.swift |
| @@ -0,0 +1,105 @@ |
| +import SwiftUI |
| + |
| +/// The same joining ceremony on Mac, iPhone and iPad. Authentication stays in the model. |
| +@MainActor public struct MeetLobby: View { |
| + @ObservedObject private var model: MeetModel |
| + @Environment(\.colorScheme) private var colorScheme |
| + |
| + public init(model: MeetModel) { self.model = model } |
| + |
| + public var body: some View { |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 28) { |
| + VStack(alignment: .leading, spacing: 16) { |
| + Image(systemName: "video.fill") |
| + .font(.largeTitle).foregroundStyle(.tint) |
| + .padding(18).background(.tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 22)) |
| + .accessibilityHidden(true) |
| + Text("OFICINA MEET").font(.caption.bold()).tracking(2).foregroundStyle(.secondary) |
| + Text(heading).font(.largeTitle.bold()).fixedSize(horizontal: false, vertical: true) |
| + Text(subheading).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) |
| + } |
| + VStack(alignment: .leading, spacing: 20) { |
| + if model.authenticating { |
| + ProgressView("Waiting for sign-in") |
| + if !model.confirmationCode.isEmpty { |
| + VStack(alignment: .leading, spacing: 10) { |
| + Text(model.confirmationCode).font(.title.monospaced().bold()) |
| + .textSelection(.enabled) |
| + Text("Check that your browser shows this same code before confirming.") |
| + .font(.callout).foregroundStyle(.secondary) |
| + }.frame(maxWidth: .infinity, alignment: .leading).padding(16) |
| + .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) |
| + } |
| + if let url = model.signin { |
| + Button("Open browser again", systemImage: "arrow.up.forward.app") { |
| + Task { _ = await model.openBrowser(url) } |
| + }.buttonStyle(.borderedProminent) |
| + } |
| + Button("Cancel", action: model.cancelSignIn).buttonStyle(.bordered) |
| + } else if model.signedIn { |
| + Label("Meeting \(model.room)", systemImage: "video") |
| + .font(.headline).textSelection(.enabled) |
| + Label("Your camera and microphone will be requested when you join.", systemImage: "mic") |
| + .font(.callout).foregroundStyle(.secondary) |
| + Button("Join meeting", systemImage: "video.fill", action: model.join) |
| + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| + Button("Choose another meeting") { model.signedIn = false } |
| + .buttonStyle(.bordered) |
| + #if os(iOS) |
| + Text("Keep Meet open during your call. Switching to another app ends the call.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + #endif |
| + } else { |
| + VStack(alignment: .leading, spacing: 10) { |
| + Text("Meeting link").font(.headline) |
| + TextField("Meeting or invitation link", text: $model.address) |
| + .textFieldStyle(.roundedBorder) |
| + .accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink") |
| + #if os(iOS) |
| + .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() |
| + #endif |
| + .onSubmit(model.connect) |
| + } |
| + Button("Continue", systemImage: "arrow.right", action: model.connect) |
| + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| + .disabled(model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| + Text("Use the meeting or invitation link shared with you.") |
| + .font(.callout).foregroundStyle(.secondary) |
| + } |
| + if !model.error.isEmpty { |
| + Label(model.error, systemImage: "exclamationmark.circle.fill") |
| + .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) |
| + .accessibilityLabel("Error: \(model.error)") |
| + } |
| + } |
| + .controlSize(.large).frame(maxWidth: .infinity, alignment: .leading).padding(24) |
| + .background( |
| + colorScheme == .dark ? Color.white.opacity(0.045) : Color.white, |
| + in: RoundedRectangle(cornerRadius: 20) |
| + ) |
| + .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(.primary.opacity(0.08)) } |
| + Label("A little closer, wherever you are.", systemImage: "sparkle") |
| + .font(.callout).foregroundStyle(.secondary) |
| + }.frame(maxWidth: 480, alignment: .leading).padding(32) |
| + .frame(maxWidth: .infinity, minHeight: 540) |
| + } |
| + .background(MeetAppearance.background) |
| + .tint(MeetAppearance.accent) |
| + } |
| + |
| + private var heading: LocalizedStringKey { |
| + if model.authenticating { return "One more step." } |
| + if model.signedIn { return "Ready to join" } |
| + return "Meet face to face." |
| + } |
| + |
| + private var subheading: LocalizedStringKey { |
| + if model.authenticating { |
| + return |
| + "Open the emailed link in your browser, then confirm “Sign in to Meet app”. We’ll continue here automatically." |
| + } |
| + if model.signedIn { return "You’re signed in. Join when you’re ready." } |
| + return "A place for your team to come together." |
| + } |
| +} |
diff --git a/native/apple/Sources/MeetKit/MeetingShareButton.swift b/native/apple/Sources/MeetKit/MeetingShareButton.swift| index cefebe8..2038ef0 100644 |
| --- a/native/apple/Sources/MeetKit/MeetingShareButton.swift |
| +++ b/native/apple/Sources/MeetKit/MeetingShareButton.swift |
| @@ -14,7 +14,7 @@ import SwiftUI |
| public var body: some View { |
| if call.standing == "member" { |
| - Button("Share") { showing = true } |
| + Button("Share", systemImage: "square.and.arrow.up") { showing = true } |
| .accessibilityLabel("Share meeting link") |
| .sheet(isPresented: $showing) { |
| MeetingInvitationView(call: call, close: { showing = false }) |
diff --git a/native/apple/Sources/MeetMac/MeetMac.swift b/native/apple/Sources/MeetMac/MeetMac.swift| index 8967035..54d21c1 100644 |
| --- a/native/apple/Sources/MeetMac/MeetMac.swift |
| +++ b/native/apple/Sources/MeetMac/MeetMac.swift |
| @@ -11,14 +11,16 @@ import WebRTC |
| Window("Meet", id: "main") { |
| ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| } |
| + .defaultSize(width: 1000, height: 760) |
| .commands { CommandGroup(replacing: .newItem) {} } |
| } |
| } |
| final class MeetAppDelegate: NSObject, NSApplicationDelegate { |
| func applicationDidFinishLaunching(_ notification: Notification) { |
| - if let url = Bundle.main.url(forResource: "AppIcon", withExtension: "png") |
| - ?? Bundle.module.url(forResource: "AppIcon", withExtension: "png") { |
| + if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png") |
| + ?? Bundle.module.url(forResource: "MacIcon", withExtension: "png") |
| + { |
| NSApp.applicationIconImage = NSImage(contentsOf: url) |
| } |
| } |
| @@ -27,131 +29,12 @@ final class MeetAppDelegate: NSObject, NSApplicationDelegate { |
| struct ContentView: View { |
| @ObservedObject var model: MeetModel |
| var body: some View { |
| - VStack(alignment: .leading, spacing: 20) { |
| + Group { |
| if model.joining { |
| - CallView(call: model.call, leave: model.leave) |
| - } else if model.authenticating { |
| - Text("Finish signing in in your browser").font(.title2) |
| - Text( |
| - "Open the emailed link normally, then confirm “Sign in to Meet app”. Meet will continue here automatically." |
| - ).foregroundStyle(.secondary) |
| - ProgressView() |
| - if !model.confirmationCode.isEmpty { |
| - Text(model.confirmationCode).font(.title.monospaced()).textSelection(.enabled) |
| - Text("Check that your browser shows this same code before confirming.") |
| - } |
| - if let url = model.signin { |
| - Button("Open browser again") { NSWorkspace.shared.open(url) } |
| - } |
| - Button("Cancel", action: model.cancelSignIn) |
| - Spacer() |
| - } else if model.signedIn { |
| - Text("Ready to join").font(.largeTitle) |
| - Text("Your camera and microphone will be requested when you join.") |
| - Text("Meeting \(model.room)").foregroundStyle(.secondary) |
| - Button("Join meeting", action: model.join).buttonStyle(.borderedProminent) |
| - Button("Choose another meeting") { model.signedIn = false } |
| + MacCallView(call: model.call, leave: model.leave).padding(24) |
| } else { |
| - Text("Meet").font(.largeTitle.bold()) |
| - Text("Join your team’s meeting from your Mac.").foregroundStyle(.secondary) |
| - Text("Meeting link").font(.headline) |
| - TextField("Meeting or invitation link", text: $model.address).textFieldStyle(.roundedBorder) |
| - .onSubmit(model.connect) |
| - Button("Continue", action: model.connect).buttonStyle(.borderedProminent) |
| - Spacer() |
| + MeetLobby(model: model) |
| } |
| - if !model.error.isEmpty { |
| - Text(model.error).foregroundStyle(.red).accessibilityLabel("Error: \(model.error)") |
| - } |
| - }.padding(24) |
| - } |
| -} |
| -struct CallView: View { |
| - @ObservedObject var call: Call |
| - let leave: () -> Void |
| - var body: some View { |
| - VStack(alignment: .leading, spacing: 16) { |
| - HStack { |
| - Text(call.title).font(.title.bold()) |
| - Spacer() |
| - Text(call.status).accessibilityAddTraits(.updatesFrequently) |
| - } |
| - Text(call.standing.capitalized).font(.callout).foregroundStyle(.secondary) |
| - if call.floor { |
| - Text("Direct connections only. People on some networks may be unable to connect.").font( |
| - .callout |
| - ).foregroundStyle(.secondary) |
| - } |
| - ScrollView { |
| - LazyVGrid(columns: [GridItem(.adaptive(minimum: 260))], spacing: 16) { |
| - if let local = call.localTrack { VideoTile(track: local, label: "You", mirrored: true) } |
| - ForEach(call.state?.peers.filter { $0.peer != call.state?.peer } ?? []) { peer in |
| - if let track = call.remoteTracks[peer.peer] { |
| - VideoTile(track: track, label: peer.name) |
| - } else { |
| - VStack { |
| - ProgressView() |
| - Text(peer.name) |
| - }.frame(height: 180) |
| - } |
| - } |
| - } |
| - } |
| - ForEach(call.state?.waiting ?? []) { peer in |
| - HStack { |
| - Text("\(peer.name) is waiting") |
| - Button("Let in") { call.admit(peer.peer) } |
| - } |
| - } |
| - if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| - HStack { |
| - Button(call.muted ? "Unmute microphone" : "Mute microphone") { call.mute() } |
| - Button(call.cameraOff ? "Turn camera on" : "Turn camera off") { call.toggleCamera() } |
| - MeetingShareButton(call: call) |
| - Spacer() |
| - Button("Leave meeting", role: .destructive, action: leave) |
| - } |
| - Text("Chat").font(.headline) |
| - ChatView(call: call).frame(minHeight: 120, maxHeight: 180) |
| - } |
| - } |
| -} |
| -struct VideoTile: View { |
| - let track: RTCVideoTrack |
| - let label: String |
| - var mirrored = false |
| - var body: some View { |
| - VStack(alignment: .leading) { |
| - VideoView(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1).frame(height: 200).background( |
| - .black |
| - ).clipShape( |
| - RoundedRectangle(cornerRadius: 12)) |
| - Text(label).font(.headline) |
| - }.accessibilityElement(children: .combine) |
| - } |
| -} |
| -struct VideoView: NSViewRepresentable { |
| - let track: RTCVideoTrack |
| - func makeCoordinator() -> Coordinator { Coordinator() } |
| - func makeNSView(context: Context) -> RTCMTLNSVideoView { |
| - let view = RTCMTLNSVideoView(frame: .zero) |
| - context.coordinator.attach(track, to: view) |
| - return view |
| - } |
| - func updateNSView(_ view: RTCMTLNSVideoView, context: Context) { |
| - context.coordinator.attach(track, to: view) |
| - } |
| - static func dismantleNSView(_ view: RTCMTLNSVideoView, coordinator: Coordinator) { |
| - coordinator.track?.remove(view) |
| - } |
| - final class Coordinator { |
| - var track: RTCVideoTrack? |
| - func attach(_ next: RTCVideoTrack, to view: RTCMTLNSVideoView) { |
| - if track !== next { |
| - track?.remove(view) |
| - track = next |
| - next.add(view) |
| - } |
| - } |
| + }.tint(MeetAppearance.accent) |
| } |
| } |
diff --git a/native/apple/Sources/MeetMac/Resources/MacIcon.png b/native/apple/Sources/MeetMac/Resources/MacIcon.png| new file mode 100644 |
| index 0000000..a4b6202 |
| Binary files /dev/null and b/native/apple/Sources/MeetMac/Resources/MacIcon.png differ |
diff --git a/native/apple/Tests/MeetKitTests/ChatWindowTests.swift b/native/apple/Tests/MeetKitTests/ChatWindowTests.swift| new file mode 100644 |
| index 0000000..e346caf |
| --- /dev/null |
| +++ b/native/apple/Tests/MeetKitTests/ChatWindowTests.swift |
| @@ -0,0 +1,36 @@ |
| +import AppKit |
| +import XCTest |
| + |
| +@testable import MeetKit |
| + |
| +final class ChatWindowTests: XCTestCase { |
| + @MainActor func testClosingAndReopeningRetainsDraftWithoutDuplicatingWindow() { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + let call = Call(playAudio: false) |
| + let presentation = MacChatWindow() |
| + presentation.draft.text = "Keep this unsent message" |
| + presentation.open(call: call) |
| + XCTAssertTrue(presentation.isOpen) |
| + let first = NSApp.windows.first { $0.title == "Chat — Meet" } |
| + XCTAssertNotNil(first) |
| + presentation.open(call: call) |
| + XCTAssertEqual(NSApp.windows.filter { $0.title == "Chat — Meet" }.count, 1) |
| + presentation.close() |
| + XCTAssertFalse(presentation.isOpen) |
| + XCTAssertEqual(presentation.draft.text, "Keep this unsent message") |
| + presentation.open(call: call) |
| + XCTAssertTrue(presentation.isOpen) |
| + XCTAssertEqual(presentation.draft.text, "Keep this unsent message") |
| + presentation.close() |
| + } |
| + |
| + @MainActor func testWindowCloseButtonUpdatesPresentationState() { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + let presentation = MacChatWindow() |
| + presentation.open(call: Call(playAudio: false)) |
| + let window = NSApp.windows.first { $0.title == "Chat — Meet" } |
| + XCTAssertNotNil(window) |
| + window?.performClose(nil) |
| + XCTAssertFalse(presentation.isOpen) |
| + } |
| +} |
diff --git a/native/ios/Sources/MeetPhone.swift b/native/ios/Sources/MeetPhone.swift| index f273899..adf6cdb 100644 |
| --- a/native/ios/Sources/MeetPhone.swift |
| +++ b/native/ios/Sources/MeetPhone.swift |
| @@ -29,49 +29,9 @@ struct PhoneHome: View { |
| if model.joining { |
| PhoneCall(call: model.call, leave: model.leave) |
| } else { |
| - ScrollView { |
| - VStack(alignment: .leading, spacing: 24) { |
| - if model.authenticating { |
| - Text("Finish signing in in your browser").font(.title2.bold()) |
| - Text( |
| - "Open the emailed link normally. Compare this code with your browser before approving sign-in, then return to Meet." |
| - ) |
| - if !model.confirmationCode.isEmpty { |
| - Text(model.confirmationCode).font(.largeTitle.monospaced()).textSelection( |
| - .enabled) |
| - } |
| - ProgressView("Waiting for sign-in") |
| - if let url = model.signin { |
| - Button("Open browser again") { Task { _ = await model.openBrowser(url) } } |
| - } |
| - Button("Cancel", action: model.cancelSignIn) |
| - } else if model.signedIn { |
| - Text("Ready to join").font(.largeTitle.bold()) |
| - Text("Your front camera and microphone will be requested when you join.") |
| - Text("Meeting \(model.room)").foregroundStyle(.secondary) |
| - Button("Join meeting", action: model.join).buttonStyle(.borderedProminent) |
| - Button("Choose another meeting") { model.signedIn = false } |
| - Text("Keep Meet open during your call. Switching to another app ends the call.") |
| - .font(.footnote).foregroundStyle(.secondary) |
| - } else { |
| - Text("Meet face to face").font(.largeTitle.bold()) |
| - Text("Join your team’s meeting from your iPhone.").foregroundStyle(.secondary) |
| - VStack(alignment: .leading, spacing: 8) { |
| - Text("Meeting link").font(.headline) |
| - TextField("Meeting or invitation link", text: $model.address) |
| - .textFieldStyle(.roundedBorder).keyboardType(.URL) |
| - .textInputAutocapitalization(.never).autocorrectionDisabled() |
| - .accessibilityIdentifier("meetingLink").onSubmit(model.connect) |
| - } |
| - Button("Continue", action: model.connect).buttonStyle(.borderedProminent) |
| - } |
| - if !model.error.isEmpty { |
| - Text(model.error).foregroundStyle(.red).accessibilityLabel("Error: \(model.error)") |
| - } |
| - }.frame(maxWidth: 600, alignment: .leading).padding(24) |
| - } |
| + MeetLobby(model: model) |
| } |
| - }.navigationTitle("Meet").navigationBarTitleDisplayMode(.inline) |
| + }.tint(MeetAppearance.accent).navigationTitle("Meet").navigationBarTitleDisplayMode(.inline) |
| } |
| } |
| } |
| @@ -81,91 +41,168 @@ struct PhoneCall: View { |
| let leave: () -> Void |
| @State private var showingChat = false |
| @State private var readChatCount = 0 |
| + @State private var grid = false |
| + @State private var selectedPeer: String? |
| + @StateObject private var draft = ChatDraft() |
| + |
| + private var peers: [RosterPeer] { call.state?.peers.filter { $0.peer != call.state?.peer } ?? [] } |
| + private var focused: RosterPeer? { peers.first { $0.peer == selectedPeer } ?? peers.first } |
| + |
| var body: some View { |
| - ScrollView { |
| - VStack(alignment: .leading, spacing: 16) { |
| - Text(call.title).font(.title2.bold()) |
| - Text(call.status).accessibilityAddTraits(.updatesFrequently) |
| - Text(call.standing.capitalized).foregroundStyle(.secondary) |
| - if call.floor { |
| - Text("Direct connections only. Some networks may be unable to connect.").font(.footnote) |
| - } |
| - if let local = call.localTrack { |
| - PhoneVideoTile(track: local, label: "You", mirrored: true) |
| + VStack(spacing: 12) { |
| + HStack { |
| + VStack(alignment: .leading, spacing: 4) { |
| + Text(call.title).font(.headline).lineLimit(2) |
| + Text(call.status).font(.caption).foregroundStyle(.secondary) |
| } |
| - ForEach(call.state?.peers.filter { $0.peer != call.state?.peer } ?? []) { peer in |
| - if let track = call.remoteTracks[peer.peer] { |
| - PhoneVideoTile(track: track, label: peer.name) |
| - } else { |
| - HStack { |
| - ProgressView() |
| - Text(peer.name) |
| - }.frame(minHeight: 80) |
| + Spacer() |
| + Menu { |
| + Picker("Video layout", selection: $grid) { |
| + Label("Focus", systemImage: "rectangle.inset.bottomtrailing").tag(false) |
| + Label("Grid", systemImage: "square.grid.2x2").tag(true) |
| + } |
| + if peers.count > 1 { |
| + Section("Focus on") { |
| + ForEach(peers) { peer in |
| + Button(peer.name) { |
| + selectedPeer = peer.peer |
| + grid = false |
| + } |
| + } |
| + } |
| + } |
| + } label: { |
| + Label( |
| + "Video layout", systemImage: grid ? "square.grid.2x2" : "rectangle.inset.bottomtrailing" |
| + ) |
| + .labelStyle(.iconOnly).frame(width: 44, height: 44) |
| + }.accessibilityIdentifier("videoLayout") |
| + } |
| + if call.floor { |
| + Text("Direct connections only. Some networks may be unable to connect.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + } |
| + if grid { |
| + ScrollView { |
| + LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) { |
| + PhoneVideoTile( |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff |
| + ) |
| + .aspectRatio(4 / 3, contentMode: .fit) |
| + ForEach(peers) { peer in |
| + PhoneVideoTile(track: call.remoteTracks[peer.peer], label: peer.name) |
| + .aspectRatio(4 / 3, contentMode: .fit) |
| + } |
| } |
| } |
| - ForEach(call.state?.waiting ?? []) { person in |
| - HStack { |
| - Text("\(person.name) is waiting") |
| - Spacer() |
| - Button("Let in") { call.admit(person.peer) } |
| + } else { |
| + GeometryReader { geometry in |
| + ZStack(alignment: .bottomTrailing) { |
| + if let peer = focused { |
| + PhoneVideoTile(track: call.remoteTracks[peer.peer], label: peer.name) |
| + } else { |
| + ContentUnavailableView( |
| + "Waiting for others", systemImage: "person.2", |
| + description: Text("Others will appear here when they join.") |
| + ) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + .background(MeetAppearance.background, in: RoundedRectangle(cornerRadius: 18)) |
| + } |
| + PhoneVideoTile( |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff |
| + ) |
| + .frame( |
| + width: min(160, geometry.size.width * 0.32), |
| + height: min(120, geometry.size.height * 0.28) |
| + ) |
| + .shadow(color: .black.opacity(0.25), radius: 6, y: 2).padding(12) |
| } |
| } |
| - if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| - }.frame(maxWidth: 700).padding() |
| - }.safeAreaInset(edge: .bottom) { |
| - VStack(spacing: 12) { |
| - HStack { |
| - Button(chatButtonTitle) { |
| + } |
| + if !(call.state?.waiting.isEmpty ?? true) { |
| + ScrollView { |
| + ForEach(call.state?.waiting ?? []) { person in |
| + HStack { |
| + Text("\(person.name) is waiting") |
| + Spacer() |
| + Button("Let in") { call.admit(person.peer) } |
| + } |
| + } |
| + }.frame(maxHeight: 90) |
| + } |
| + if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| + }.padding(.horizontal, 16).padding(.top, 8) |
| + .safeAreaInset(edge: .bottom) { |
| + HStack(spacing: 14) { |
| + Button( |
| + call.muted ? "Unmute microphone" : "Mute microphone", |
| + systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| + ) { call.mute() } |
| + .buttonStyle( |
| + CallActionStyle( |
| + color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : MeetAppearance.accent, |
| + emphasized: call.muted)) |
| + Button( |
| + call.cameraOff ? "Turn camera on" : "Turn camera off", |
| + systemImage: call.cameraOff ? "video.slash.fill" : "video.fill" |
| + ) { call.toggleCamera() } |
| + .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff)) |
| + MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) |
| + Spacer(minLength: 0) |
| + Button(chatButtonTitle, systemImage: "bubble.left.and.bubble.right.fill") { |
| readChatCount = call.receivedChatCount |
| showingChat = true |
| - }.buttonStyle(.bordered).accessibilityIdentifier("openChat") |
| - MeetingShareButton(call: call).buttonStyle(.bordered) |
| - } |
| - ViewThatFits(in: .horizontal) { |
| - HStack { mediaControls } |
| - VStack(alignment: .leading) { mediaControls } |
| - } |
| - Button("Leave meeting", role: .destructive, action: leave).buttonStyle(.bordered) |
| - }.frame(maxWidth: .infinity).padding().background(.bar) |
| - } |
| - .sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) { |
| - NavigationStack { |
| - ChatView(call: call).padding() |
| - .navigationTitle("Chat").navigationBarTitleDisplayMode(.inline) |
| - .toolbar { |
| - ToolbarItem(placement: .confirmationAction) { |
| - Button("Done") { showingChat = false } |
| + }.buttonStyle(CallActionStyle(color: .purple)).accessibilityIdentifier("openChat") |
| + .overlay(alignment: .topTrailing) { |
| + if unread > 0 { |
| + Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| + .padding(4).background(.purple, in: Capsule()).accessibilityHidden(true) |
| + } |
| } |
| - } |
| - }.presentationDetents([.medium, .large]) |
| - } |
| - .onChange(of: call.receivedChatCount) { _, count in |
| - if showingChat { readChatCount = count } |
| - } |
| - } |
| - @ViewBuilder private var mediaControls: some View { |
| - Button(call.muted ? "Unmute microphone" : "Mute microphone") { call.mute() }.buttonStyle( |
| - .bordered) |
| - Button(call.cameraOff ? "Turn camera on" : "Turn camera off") { call.toggleCamera() } |
| - .buttonStyle(.bordered) |
| - } |
| - private var chatButtonTitle: String { |
| - let unread = max(0, call.receivedChatCount - readChatCount) |
| - return unread == 0 ? "Chat" : "Chat (\(unread) new)" |
| + Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| + .buttonStyle(CallActionStyle(color: .red, emphasized: true)) |
| + }.padding(16).background(.bar) |
| + } |
| + .sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) { |
| + NavigationStack { |
| + ChatView(call: call, draft: draft).padding() |
| + .navigationTitle("Chat").navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .confirmationAction) { Button("Done") { showingChat = false } } |
| + } |
| + }.presentationDetents([.medium, .large]) |
| + } |
| + .onChange(of: call.receivedChatCount) { _, count in |
| + if showingChat { readChatCount = count } |
| + } |
| } |
| + private var unread: Int { max(0, call.receivedChatCount - readChatCount) } |
| + private var chatButtonTitle: String { unread == 0 ? "Chat" : "Chat (\(unread) new)" } |
| } |
| struct PhoneVideoTile: View { |
| - let track: RTCVideoTrack |
| + let track: RTCVideoTrack? |
| let label: String |
| var mirrored = false |
| + var cameraOff = false |
| var body: some View { |
| - VStack(alignment: .leading) { |
| - PhoneVideo(track: track) |
| - .scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| - .frame(height: 220).background(.black).clipShape(RoundedRectangle(cornerRadius: 12)) |
| - Text(label).font(.headline) |
| - }.accessibilityElement(children: .combine) |
| + ZStack { |
| + Color.black |
| + if let track, !cameraOff { |
| + PhoneVideo(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| + } else { |
| + Image(systemName: cameraOff ? "video.slash" : "person.crop.circle") |
| + .font(.largeTitle).foregroundStyle(.white.opacity(0.8)) |
| + } |
| + } |
| + .overlay(alignment: .bottomLeading) { |
| + Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1) |
| + .padding(.horizontal, 10).padding(.vertical, 6) |
| + .background(.black.opacity(0.65), in: Capsule()).padding(8) |
| + } |
| + .clipShape(RoundedRectangle(cornerRadius: 18)) |
| + .accessibilityElement(children: .ignore).accessibilityLabel(label) |
| + .accessibilityValue(cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on") |
| } |
| } |
| struct PhoneVideo: UIViewRepresentable { |
| @@ -173,7 +210,7 @@ struct PhoneVideo: UIViewRepresentable { |
| func makeCoordinator() -> Coordinator { Coordinator() } |
| func makeUIView(context: Context) -> RTCMTLVideoView { |
| let view = RTCMTLVideoView(frame: .zero) |
| - view.videoContentMode = .scaleAspectFit |
| + view.videoContentMode = .scaleAspectFill |
| context.coordinator.attach(track, to: view) |
| return view |
| } |
diff --git a/native/scripts/build-mac.sh b/native/scripts/build-mac.sh| index 34b731e..841baed 100755 |
| --- a/native/scripts/build-mac.sh |
| +++ b/native/scripts/build-mac.sh |
| @@ -2,6 +2,7 @@ |
| set -eu |
| cd "$(dirname "$0")/.." |
| ./scripts/build-bridge.sh |
| +swift scripts/generate-mac-icon.swift |
| swift build --package-path apple |
| python3 scripts/bundle-mac.py |
| codesign --force --deep --sign - build/Meet.app |
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py| index 12eb118..52d2e6f 100644 |
| --- a/native/scripts/bundle-mac.py |
| +++ b/native/scripts/bundle-mac.py |
| @@ -10,8 +10,9 @@ bundle = root / 'build/Meet.app/Contents' |
| (bundle / 'Frameworks').mkdir(parents=True, exist_ok=True) |
| (bundle / 'Resources').mkdir(parents=True, exist_ok=True) |
| shutil.copy2(root / 'apple/.build/debug/MeetMac', bundle / 'MacOS/MeetMac') |
| -icon = root / 'apple/Sources/MeetMac/Resources/AppIcon.png' |
| -shutil.copy2(icon, bundle / 'Resources/AppIcon.png') |
| +subprocess.run(['swift', str(root / 'scripts/generate-mac-icon.swift')], check=True) |
| +icon = root / 'apple/Sources/MeetMac/Resources/MacIcon.png' |
| +shutil.copy2(icon, bundle / 'Resources/MacIcon.png') |
| shutil.copy2(root / 'LICENSE.lucide', bundle / 'Resources/LICENSE.lucide') |
| # Finder reads the ICNS before the app launches; the PNG also supplies the |
| # runtime Dock icon. Generate each required representation from the master. |
| @@ -24,12 +25,12 @@ for size in (16, 32, 128, 256, 512): |
| str(icon), '--out', str(iconset / name)], |
| check=True, stdout=subprocess.DEVNULL) |
| subprocess.run(['iconutil', '-c', 'icns', str(iconset), '-o', |
| - str(bundle / 'Resources/AppIcon.icns')], check=True) |
| + str(bundle / 'Resources/MeetMac.icns')], check=True) |
| info = { |
| 'CFBundleName': 'Meet', 'CFBundleDisplayName': 'Meet', |
| 'CFBundleIdentifier': 'net.amadan.oficina.meet', |
| 'CFBundleExecutable': 'MeetMac', 'CFBundlePackageType': 'APPL', |
| - 'CFBundleIconFile': 'AppIcon.icns', |
| + 'CFBundleIconFile': 'MeetMac.icns', |
| 'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1', |
| 'LSMinimumSystemVersion': '14.0', 'NSHighResolutionCapable': True, |
| 'NSCameraUsageDescription': 'Meet uses your camera to share video in a meeting.', |
diff --git a/native/scripts/generate-mac-icon.swift b/native/scripts/generate-mac-icon.swift| new file mode 100644 |
| index 0000000..37de2dc |
| --- /dev/null |
| +++ b/native/scripts/generate-mac-icon.swift |
| @@ -0,0 +1,24 @@ |
| +import AppKit |
| + |
| +// macOS displays the supplied silhouette; unlike iOS it does not mask an opaque master. |
| +// Keep the approved square master intact and derive a padded Mac icon from it. |
| +let native = URL(fileURLWithPath: #filePath).standardizedFileURL |
| + .deletingLastPathComponent().deletingLastPathComponent() |
| +let resources = native.appendingPathComponent("apple/Sources/MeetMac/Resources") |
| +guard let source = NSImage(contentsOf: resources.appendingPathComponent("AppIcon.png")), |
| + let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024, |
| + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, |
| + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0), |
| + let context = NSGraphicsContext(bitmapImageRep: bitmap) |
| +else { fatalError("Cannot load or render Meet's icon") } |
| +NSGraphicsContext.saveGraphicsState() |
| +NSGraphicsContext.current = context |
| +context.imageInterpolation = .high |
| +let tile = NSRect(x: 100, y: 100, width: 824, height: 824) |
| +NSBezierPath(roundedRect: tile, xRadius: 184, yRadius: 184).addClip() |
| +source.draw(in: tile, from: .zero, operation: .copy, fraction: 1) |
| +NSGraphicsContext.restoreGraphicsState() |
| +guard let png = bitmap.representation(using: .png, properties: [:]) else { |
| + fatalError("Cannot encode Meet's icon") |
| +} |
| +try png.write(to: resources.appendingPathComponent("MacIcon.png")) |