Merge branch 'feat/mac-mini-window' into integrate/native-2026-09-23
Puts the Mac call in a small floating window while the person is in another app. Conflict resolved in CallsMac.swift: the call window keeps the remember-signin branch's onAppear (model.restore()) and gains .miniWindow(for: model). Follow-ups for the other merged branches: - MiniWindow's subject is now a StageItem from Stage.focus(chosen: nil), the call window's own default: the newest shared screen (remoteScreens, captioned as the stage captions it), else the first remote participant, else your own camera. Camera tiles pass muted:/cameraOff:. - MiniWindowTests imports LiveKitWebRTC rather than WebRTC. - native/README.md says what the mini window shows now, and that a shared display excludes the whole Calls application, which keeps the mini window (title "Calls mini window") out as well as sharingType = .none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+916
−0
native/README.md+16 −0native/apple/Sources/CallsInterop/LobbySnapshots.swift+1 −0native/apple/Sources/CallsInterop/SettingsSnapshots.swift+39 −0native/apple/Sources/CallsKit/CallsSettingsView.swift+42 −0native/apple/Sources/CallsKit/MiniWindow.swift+399 −0native/apple/Sources/CallsKit/MiniWindowRules.swift+176 −0native/apple/Sources/CallsMac/CallsMac.swift+2 −0native/apple/Tests/CallsKitTests/MiniWindowTests.swift+241 −0
diff --git a/native/README.md b/native/README.md| index 12e44df..6534b2f 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -238,6 +238,22 @@ so tabbing away does not produce a pile of notifications about people who |
| have been waiting a while. Only an arrival during an absence is news, and |
| leaving the queue and knocking again is heard again. |
| +On Mac, **switching to another app during a call** puts the call in a small |
| +floating window, bottom right by default, and coming back to Calls restores the |
| +call window as it was. It is the first setting in **Calls → Settings…**, on by |
| +default, with the corner beside it; dragging the window snaps it to the nearest |
| +corner and remembers that one. Clicking the window brings Calls back; hovering |
| +shows Mute and Leave. It shows what the call window's stage would show by |
| +default: the newest shared screen, otherwise the first other participant with |
| +their muted and camera-off state (there is no active-speaker tracking yet), or |
| +your own camera while you are alone. It is not |
| +shown for a minimised call window, and ⌘H hides it with the rest of the app. The |
| +call window is hidden by transparency rather than ordered out, because SwiftUI |
| +treats an ordered-out window as closed, which would leave the call. The mini |
| +window is excluded from screen capture: a shared display leaves out the whole |
| +Calls application (macOS 14.4 and later), and the window also sets |
| +`sharingType = .none`, so it never appears in a screen this app shares. |
| + |
| Members can use **Share** in the call to retrieve the current invitation, copy |
| it, or open the system sharing menu. This needs the server's |
| `POST /m/{sqid}/share` endpoint and the encrypted-invitation migration. |
diff --git a/native/apple/Sources/CallsInterop/LobbySnapshots.swift b/native/apple/Sources/CallsInterop/LobbySnapshots.swift| index 2a19b4f..7691373 100644 |
| --- a/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| @@ -53,5 +53,6 @@ import SwiftUI |
| window.close() |
| } |
| } |
| + try await SettingsSnapshots.save(to: folder) |
| } |
| } |
diff --git a/native/apple/Sources/CallsInterop/SettingsSnapshots.swift b/native/apple/Sources/CallsInterop/SettingsSnapshots.swift| new file mode 100644 |
| index 0000000..a9c71f0 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsInterop/SettingsSnapshots.swift |
| @@ -0,0 +1,39 @@ |
| +import AppKit |
| +import CallsKit |
| +import SwiftUI |
| + |
| +/// Offline visual fixtures of Calls → Settings…, beside the lobby's. The |
| +/// window is drawn with a throwaway defaults suite so a fixture never |
| +/// changes the preferences of the person running it. |
| +@MainActor enum SettingsSnapshots { |
| + static func save(to folder: URL) async throws { |
| + let suite = "CallsInterop.SettingsSnapshots" |
| + guard let defaults = UserDefaults(suiteName: suite) else { return } |
| + defer { defaults.removePersistentDomain(forName: suite) } |
| + for scheme in [ColorScheme.light, .dark] { |
| + let view = NSHostingView( |
| + rootView: CallsSettingsView().defaultAppStorage(defaults) |
| + .environment(\.colorScheme, scheme)) |
| + view.frame.size = view.fittingSize |
| + let window = NSWindow( |
| + contentRect: NSRect(origin: CGPoint(x: -2000, y: -2000), size: view.fittingSize), |
| + 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("settings-\(scheme == .dark ? "dark" : "light").png")) |
| + window.close() |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/CallsSettingsView.swift b/native/apple/Sources/CallsKit/CallsSettingsView.swift| new file mode 100644 |
| index 0000000..c7bfa51 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/CallsSettingsView.swift |
| @@ -0,0 +1,42 @@ |
| +#if os(macOS) |
| + import SwiftUI |
| + |
| + /// Calls → Settings…. One section for now, because there is one thing |
| + /// worth choosing; the next preference belongs beside it, not in a tab. |
| + public struct CallsSettingsView: View { |
| + @AppStorage(MiniWindowPreferences.enabledKey) private var miniWindow = |
| + MiniWindowPreferences.defaultEnabled |
| + @AppStorage(MiniWindowPreferences.cornerKey) private var corner = |
| + MiniWindowPreferences.defaultCorner |
| + |
| + public init() {} |
| + |
| + public var body: some View { |
| + Form { |
| + Section { |
| + Toggle(isOn: $miniWindow) { |
| + Text("Show a mini window when you switch to another app") |
| + Text( |
| + "During a call, a small window of the call stays on top of other apps. The full call returns when you come back to Calls." |
| + ) |
| + } |
| + Picker(selection: $corner) { |
| + ForEach(MiniWindowCorner.allCases) { corner in |
| + Text(corner.title).tag(corner) |
| + } |
| + } label: { |
| + Text("Mini window position") |
| + Text("You can also drag the mini window to another corner.") |
| + } |
| + .disabled(!miniWindow) |
| + } header: { |
| + Text("During a call") |
| + } |
| + } |
| + .formStyle(.grouped) |
| + .frame(width: 480) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .tint(CallsAppearance.accent) |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| new file mode 100644 |
| index 0000000..bcf8172 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -0,0 +1,399 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import Combine |
| + import SwiftUI |
| + |
| + extension View { |
| + /// Puts the call into a small floating window while the person is in |
| + /// another app, and back into this view when they return. Apply it to |
| + /// the root of the call window: that window is the one it hides. |
| + public func miniWindow(for model: CallsModel) -> some View { |
| + modifier(MiniWindowModifier(model: model)) |
| + } |
| + } |
| + |
| + private struct MiniWindowModifier: ViewModifier { |
| + @ObservedObject var model: CallsModel |
| + @StateObject private var controller = MiniWindowController() |
| + |
| + func body(content: Content) -> some View { |
| + content.background( |
| + WindowReader { window in controller.attach(model: model, mainWindow: window) }) |
| + } |
| + } |
| + |
| + /// Watches the app come and go, asks MiniWindowPolicy what that means, |
| + /// and moves the windows it says to. It holds no rules of its own. |
| + @MainActor final class MiniWindowController: ObservableObject { |
| + private(set) var policy = MiniWindowPolicy() |
| + private let preferences: MiniWindowPreferences |
| + private weak var model: CallsModel? |
| + private weak var mainWindow: NSWindow? |
| + private var observers: [NSObjectProtocol] = [] |
| + private var joining: AnyCancellable? |
| + private(set) var panel: MiniWindowPanel? |
| + |
| + init(preferences: MiniWindowPreferences = MiniWindowPreferences()) { |
| + self.preferences = preferences |
| + let center = NotificationCenter.default |
| + let events: [(Notification.Name, @MainActor (MiniWindowController) -> Void)] = [ |
| + (NSApplication.didResignActiveNotification, { $0.resignedActive() }), |
| + (NSApplication.didBecomeActiveNotification, { $0.apply($0.policy.becameActive()) }), |
| + (NSApplication.willHideNotification, { $0.apply($0.policy.willHide()) }), |
| + (NSApplication.didUnhideNotification, { $0.policy.didUnhide() }), |
| + ] |
| + observers = events.map { name, handle in |
| + center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in |
| + MainActor.assumeIsolated { |
| + guard let self else { return } |
| + handle(self) |
| + } |
| + } |
| + } |
| + } |
| + |
| + deinit { observers.forEach(NotificationCenter.default.removeObserver) } |
| + |
| + func attach(model: CallsModel, mainWindow: NSWindow?) { |
| + if let mainWindow { self.mainWindow = mainWindow } |
| + guard self.model !== model else { return } |
| + self.model = model |
| + joining = model.$joining.removeDuplicates().sink { [weak self] joining in |
| + guard !joining, let self else { return } |
| + self.apply(self.policy.callEnded()) |
| + } |
| + } |
| + |
| + private func resignedActive() { |
| + // One turn of the run loop first: a ⌘H or a quick switch straight |
| + // back should not flash a window up and down again. |
| + DispatchQueue.main.async { [weak self] in |
| + guard let self, !NSApp.isActive, !NSApp.isHidden else { return } |
| + let window = self.mainWindow |
| + self.apply( |
| + self.policy.resignedActive( |
| + .init( |
| + enabled: self.preferences.enabled, |
| + inCall: self.model?.joining ?? false, |
| + mainWindowVisible: window?.isVisible ?? false, |
| + mainWindowMiniaturized: window?.isMiniaturized ?? false, |
| + mainWindowFullScreen: window?.styleMask.contains(.fullScreen) ?? false))) |
| + } |
| + } |
| + |
| + func apply(_ action: MiniWindowPolicy.Action) { |
| + switch action { |
| + case .none: break |
| + case .show(let hideMain): |
| + guard let model else { return } |
| + let screen = mainWindow?.screen ?? NSScreen.main |
| + let panel = MiniWindowPanel( |
| + call: model.call, corner: preferences.corner, screen: screen, |
| + leave: { [weak model] in model?.leave() }, |
| + open: { NSApp.activate(ignoringOtherApps: true) }, |
| + moved: { [weak self] in self?.snap() }) |
| + self.panel = panel |
| + panel.orderFrontRegardless() |
| + if hideMain, let mainWindow { Self.conceal(mainWindow, true) } |
| + case .restore(let activate): |
| + panel?.dismiss() |
| + panel = nil |
| + guard let mainWindow else { return } |
| + Self.conceal(mainWindow, false) |
| + // Forward unless the person came back by clicking another of this |
| + // app's windows, such as chat in its own window: that one keeps focus. |
| + if activate, NSApp.keyWindow == nil || NSApp.keyWindow === mainWindow { |
| + mainWindow.makeKeyAndOrderFront(nil) |
| + } |
| + } |
| + } |
| + |
| + /// Hides the call window by making it transparent and letting clicks |
| + /// through, not by ordering it out. SwiftUI treats an ordered-out |
| + /// `Window` as closed: the view's onDisappear runs, which leaves the |
| + /// call, and the app then quits for want of a window. Transparent, the |
| + /// window keeps its frame, its Space, its place in the stacking order |
| + /// and all of its state, so putting it back is exactly as it was. |
| + private static func conceal(_ window: NSWindow, _ hidden: Bool) { |
| + window.alphaValue = hidden ? 0 : 1 |
| + window.ignoresMouseEvents = hidden |
| + } |
| + |
| + /// A drag ends in the nearest corner, and that corner becomes the |
| + /// setting — the same one the Settings window shows — so the window |
| + /// is where it was left next time, until a corner is picked there. |
| + private func snap() { |
| + guard let panel, let screen = panel.screen ?? NSScreen.main else { return } |
| + let corner = MiniWindowCorner.nearest(to: panel.frame, in: screen.visibleFrame) |
| + preferences.corner = corner |
| + panel.place(corner, on: screen, animated: true) |
| + } |
| + } |
| + |
| + /// The floating window. Non-activating, so clicking its controls does |
| + /// not drag the whole app forward; clicking anywhere else does, on |
| + /// purpose, because that is how you get back to the call. |
| + final class MiniWindowPanel: NSPanel { |
| + private let content: MiniWindowContent |
| + |
| + init( |
| + call: Call, corner: MiniWindowCorner, screen: NSScreen?, |
| + leave: @escaping () -> Void, open: @escaping () -> Void, moved: @escaping () -> Void |
| + ) { |
| + content = MiniWindowContent(call: call) |
| + super.init( |
| + contentRect: NSRect(origin: .zero, size: MiniWindowRules.size), |
| + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) |
| + isReleasedWhenClosed = false |
| + isFloatingPanel = true |
| + level = .floating |
| + hidesOnDeactivate = false |
| + becomesKeyOnlyIfNeeded = true |
| + // Every Space, and over a full-screen app, because the whole point |
| + // is that it follows the person to wherever they went. |
| + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] |
| + // Never in anyone's screen capture, including this app's own shared |
| + // screen: nobody in the call needs to see themselves in a corner. |
| + sharingType = .none |
| + backgroundColor = .clear |
| + isOpaque = false |
| + hasShadow = true |
| + title = "Calls mini window" |
| + let hosting = MiniWindowHostingView( |
| + rootView: MiniCallView(content: content, leave: leave, open: open, moved: moved)) |
| + hosting.hover = content |
| + contentView = hosting |
| + if let screen { place(corner, on: screen, animated: false) } |
| + } |
| + |
| + func place(_ corner: MiniWindowCorner, on screen: NSScreen, animated: Bool) { |
| + let frame = corner.frame(size: MiniWindowRules.size, in: screen.visibleFrame) |
| + if animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { |
| + NSAnimationContext.runAnimationGroup { context in |
| + context.duration = 0.2 |
| + animator().setFrame(frame, display: true) |
| + } |
| + } else { |
| + setFrame(frame, display: true) |
| + } |
| + } |
| + |
| + /// Takes the video out before the window goes, so every renderer this |
| + /// window added to a track is removed from it now and not whenever the |
| + /// hosting view happens to be released. The call window's own |
| + /// renderers are separate objects and are untouched. |
| + func dismiss() { |
| + content.call = nil |
| + contentView?.layoutSubtreeIfNeeded() |
| + contentView?.displayIfNeeded() |
| + orderOut(nil) |
| + contentView = nil |
| + close() |
| + } |
| + } |
| + |
| + @MainActor final class MiniWindowContent: ObservableObject { |
| + @Published var call: Call? |
| + @Published var hovering = false |
| + init(call: Call) { self.call = call } |
| + } |
| + |
| + /// Takes the first click even though the app is not active — otherwise |
| + /// the first click on Mute would only wake the window — and tracks the |
| + /// pointer the same way, since SwiftUI's hover follows the key window. |
| + private final class MiniWindowHostingView: NSHostingView<MiniCallView> { |
| + weak var hover: MiniWindowContent? |
| + private var tracking: NSTrackingArea? |
| + |
| + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } |
| + |
| + override func updateTrackingAreas() { |
| + super.updateTrackingAreas() |
| + if let tracking { removeTrackingArea(tracking) } |
| + let area = NSTrackingArea( |
| + rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], |
| + owner: self) |
| + addTrackingArea(area) |
| + tracking = area |
| + } |
| + |
| + override func mouseEntered(with event: NSEvent) { |
| + super.mouseEntered(with: event) |
| + hover?.hovering = true |
| + } |
| + |
| + override func mouseExited(with event: NSEvent) { |
| + super.mouseExited(with: event) |
| + hover?.hovering = false |
| + } |
| + } |
| + |
| + struct MiniCallView: View { |
| + @ObservedObject var content: MiniWindowContent |
| + let leave: () -> Void |
| + let open: () -> Void |
| + let moved: () -> Void |
| + @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| + |
| + var body: some View { |
| + ZStack { |
| + if let call = content.call { |
| + MiniCallStage(call: call, showingControls: showingControls, leave: leave) |
| + } else { |
| + Color.black |
| + } |
| + } |
| + .frame(width: MiniWindowRules.size.width, height: MiniWindowRules.size.height) |
| + .background(MiniWindowSurface(open: open, moved: moved)) |
| + .clipShape(RoundedRectangle(cornerRadius: 16)) |
| + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: showingControls) |
| + .tint(CallsAppearance.accent) |
| + } |
| + |
| + /// VoiceOver users get the controls all the time: hover is not a thing |
| + /// a screen reader does. |
| + private var showingControls: Bool { |
| + content.hovering || NSWorkspace.shared.isVoiceOverEnabled |
| + } |
| + } |
| + |
| + private struct MiniCallStage: View { |
| + @ObservedObject var call: Call |
| + let showingControls: Bool |
| + let leave: () -> Void |
| + |
| + /// What the call window's stage shows when nobody has chosen: the |
| + /// newest shared screen, since a person who shares is asking to be |
| + /// looked at, and otherwise the first person who is not you. Nobody |
| + /// else there: your own camera, since a window of nothing reads as a |
| + /// broken one. No active-speaker tracking exists yet to do better. |
| + private var subject: StageItem? { |
| + let me = call.state?.peer |
| + let peers = (call.state?.peers ?? []).map(\.peer).filter { $0 != me } |
| + return Stage.focus(chosen: nil, peers: peers, screens: call.screens) |
| + } |
| + |
| + var body: some View { |
| + ZStack { |
| + Group { |
| + switch subject { |
| + case .screen(let peer): |
| + MacVideoTile( |
| + track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), |
| + screen: true) |
| + case .camera(let peer): |
| + MacVideoTile( |
| + track: call.remoteTracks[peer], label: call.name(of: peer), |
| + muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer)) |
| + case nil: |
| + MacVideoTile( |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff) |
| + } |
| + } |
| + // The tile is a picture, not a target: clicks and drags go through |
| + // to the surface underneath. |
| + .allowsHitTesting(false) |
| + VStack { |
| + HStack(spacing: 8) { |
| + if call.muted { |
| + Label("Muted", systemImage: "mic.slash.fill").labelStyle(.iconOnly) |
| + .font(.callout.bold()).foregroundStyle(.white) |
| + .padding(8).background(.black.opacity(0.65), in: Circle()) |
| + .accessibilityLabel("Your microphone is muted") |
| + } |
| + Spacer() |
| + if showingControls { |
| + 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) : CallsAppearance.accent, |
| + emphasized: true) |
| + ) |
| + .help(call.muted ? "Unmute microphone" : "Mute microphone") |
| + Button( |
| + "Leave meeting", systemImage: "phone.down.fill", role: .destructive, |
| + action: leave |
| + ) |
| + .buttonStyle(CallActionStyle(color: .red, emphasized: true)) |
| + .help("Leave meeting") |
| + } |
| + } |
| + Spacer() |
| + }.padding(10) |
| + } |
| + } |
| + } |
| + |
| + /// The window's background: a click brings Calls forward, a drag moves |
| + /// the window and then snaps it to a corner. Handled here rather than |
| + /// with isMovableByWindowBackground so the two can be told apart. |
| + private struct MiniWindowSurface: NSViewRepresentable { |
| + let open: () -> Void |
| + let moved: () -> Void |
| + |
| + func makeNSView(context: Context) -> SurfaceView { SurfaceView() } |
| + func updateNSView(_ view: SurfaceView, context: Context) { |
| + view.open = open |
| + view.moved = moved |
| + } |
| + |
| + final class SurfaceView: NSView { |
| + var open: () -> Void = {} |
| + var moved: () -> Void = {} |
| + |
| + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } |
| + override func isAccessibilityElement() -> Bool { true } |
| + override func accessibilityRole() -> NSAccessibility.Role? { .button } |
| + override func accessibilityLabel() -> String? { "Return to the call window" } |
| + override func accessibilityPerformPress() -> Bool { |
| + open() |
| + return true |
| + } |
| + |
| + override func mouseDown(with event: NSEvent) { |
| + guard let window else { return } |
| + let start = NSEvent.mouseLocation |
| + let origin = window.frame.origin |
| + var dragging = false |
| + while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { |
| + let now = NSEvent.mouseLocation |
| + let dx = now.x - start.x |
| + let dy = now.y - start.y |
| + // A few points of slack, so a click with a shaky hand is still |
| + // a click and not a very short drag. |
| + if !dragging, hypot(dx, dy) > 4 { dragging = true } |
| + if dragging { window.setFrameOrigin(NSPoint(x: origin.x + dx, y: origin.y + dy)) } |
| + if next.type == .leftMouseUp { break } |
| + } |
| + if dragging { moved() } else { open() } |
| + } |
| + } |
| + } |
| + |
| + /// Finds the NSWindow a SwiftUI view is in, once it is in one. |
| + private struct WindowReader: NSViewRepresentable { |
| + let found: (NSWindow?) -> Void |
| + |
| + func makeNSView(context: Context) -> ReaderView { |
| + let view = ReaderView() |
| + view.found = found |
| + return view |
| + } |
| + func updateNSView(_ view: ReaderView, context: Context) { |
| + view.found = found |
| + if let window = view.window { found(window) } |
| + } |
| + |
| + final class ReaderView: NSView { |
| + var found: (NSWindow?) -> Void = { _ in } |
| + override func viewDidMoveToWindow() { |
| + super.viewDidMoveToWindow() |
| + if let window { found(window) } |
| + } |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/CallsKit/MiniWindowRules.swift b/native/apple/Sources/CallsKit/MiniWindowRules.swift| new file mode 100644 |
| index 0000000..35e7b1d |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/MiniWindowRules.swift |
| @@ -0,0 +1,176 @@ |
| +#if os(macOS) |
| + import CoreGraphics |
| + import Foundation |
| + |
| + /// Where the mini window sits on screen. Four corners and nothing in |
| + /// between, like FaceTime's and iOS's picture in picture: a window that |
| + /// can rest anywhere ends up resting over whatever you were about to |
| + /// click, and a corner is a place you can find again without looking. |
| + public enum MiniWindowCorner: String, CaseIterable, Identifiable, Sendable { |
| + case bottomRight, bottomLeft, topRight, topLeft |
| + |
| + public var id: String { rawValue } |
| + |
| + public var title: String { |
| + switch self { |
| + case .bottomRight: "Bottom right" |
| + case .bottomLeft: "Bottom left" |
| + case .topRight: "Top right" |
| + case .topLeft: "Top left" |
| + } |
| + } |
| + |
| + var right: Bool { self == .bottomRight || self == .topRight } |
| + var top: Bool { self == .topRight || self == .topLeft } |
| + |
| + /// The window's frame in this corner of `visible`, which is a screen's |
| + /// `visibleFrame`: already clear of the menu bar and the Dock, so the |
| + /// inset is only breathing room. AppKit's origin is bottom-left. |
| + public func frame(size: CGSize, in visible: CGRect, inset: CGFloat = MiniWindowRules.inset) |
| + -> CGRect |
| + { |
| + CGRect( |
| + x: right ? visible.maxX - inset - size.width : visible.minX + inset, |
| + y: top ? visible.maxY - inset - size.height : visible.minY + inset, |
| + width: size.width, height: size.height) |
| + } |
| + |
| + /// The corner a dragged window belongs to: whichever side of the |
| + /// screen's middle its own middle landed on. |
| + public static func nearest(to frame: CGRect, in visible: CGRect) -> MiniWindowCorner { |
| + let right = frame.midX >= visible.midX |
| + let top = frame.midY >= visible.midY |
| + switch (top, right) { |
| + case (false, true): return .bottomRight |
| + case (false, false): return .bottomLeft |
| + case (true, true): return .topRight |
| + case (true, false): return .topLeft |
| + } |
| + } |
| + } |
| + |
| + public enum MiniWindowRules { |
| + /// 16:9 at the width of a small picture in picture: big enough to |
| + /// read a face, small enough to leave the app you went to usable. |
| + public static let size = CGSize(width: 320, height: 180) |
| + public static let inset: CGFloat = 20 |
| + } |
| + |
| + /// The two preferences, read and written in one place so the Settings |
| + /// window and the mini window cannot disagree about a key or a default. |
| + /// |
| + /// On by default, because the person who asked for it switches apps |
| + /// during most calls, and a setting nobody knows about that is off is a |
| + /// feature that does not exist. |
| + public struct MiniWindowPreferences { |
| + public static let enabledKey = "miniWindow.enabled" |
| + public static let cornerKey = "miniWindow.corner" |
| + public static let defaultEnabled = true |
| + public static let defaultCorner = MiniWindowCorner.bottomRight |
| + |
| + private let defaults: UserDefaults |
| + public init(defaults: UserDefaults = .standard) { self.defaults = defaults } |
| + |
| + public var enabled: Bool { |
| + get { defaults.object(forKey: Self.enabledKey) as? Bool ?? Self.defaultEnabled } |
| + nonmutating set { defaults.set(newValue, forKey: Self.enabledKey) } |
| + } |
| + |
| + public var corner: MiniWindowCorner { |
| + get { |
| + defaults.string(forKey: Self.cornerKey).flatMap(MiniWindowCorner.init(rawValue:)) |
| + ?? Self.defaultCorner |
| + } |
| + nonmutating set { defaults.set(newValue.rawValue, forKey: Self.cornerKey) } |
| + } |
| + } |
| + |
| + /// When the mini window comes and goes. Kept apart from the windows it |
| + /// moves, like KnockWatch, because this is the part with rules in it and |
| + /// the windows need a real app to exist. |
| + /// |
| + /// The rules: |
| + /// - It appears only when the app stops being frontmost during a call, |
| + /// with the setting on and the call window on screen. A minimised or |
| + /// closed call window is one the person already put away themselves. |
| + /// - ⌘H wins. Hiding the app is asking for all of it to go, so a hidden |
| + /// app shows nothing, and a mini window already up is put away before |
| + /// the hide so that unhiding brings back the ordinary window. AppKit's |
| + /// order between "will hide" and "resigned active" is not something to |
| + /// rely on, so both orders end the same way. |
| + /// - Coming back to the app, by any route, restores the call window. |
| + /// - The call ending puts it away too — someone who pressed Leave in the |
| + /// mini window is done, and the lobby is waiting for them underneath. |
| + public struct MiniWindowPolicy: Equatable { |
| + public struct Conditions: Equatable { |
| + public var enabled: Bool |
| + public var inCall: Bool |
| + public var mainWindowVisible: Bool |
| + public var mainWindowMiniaturized: Bool |
| + public var mainWindowFullScreen: Bool |
| + |
| + public init( |
| + enabled: Bool, inCall: Bool, mainWindowVisible: Bool, |
| + mainWindowMiniaturized: Bool = false, mainWindowFullScreen: Bool = false |
| + ) { |
| + self.enabled = enabled |
| + self.inCall = inCall |
| + self.mainWindowVisible = mainWindowVisible |
| + self.mainWindowMiniaturized = mainWindowMiniaturized |
| + self.mainWindowFullScreen = mainWindowFullScreen |
| + } |
| + } |
| + |
| + public enum Action: Equatable { |
| + case none |
| + /// Show the mini window. `hideMain` is false for a full-screen call |
| + /// window: hiding one leaves its Space black and empty, and it is |
| + /// already out of the way in a Space of its own. |
| + case show(hideMain: Bool) |
| + /// Close the mini window and put the call window back as it was. |
| + /// `activate` is false when the app is not coming forward — the call |
| + /// ended from the mini window, or the app is being hidden — so the |
| + /// window returns where it was in the stack, behind whatever the |
| + /// person is doing, rather than jumping on top of it. |
| + case restore(activate: Bool) |
| + } |
| + |
| + public private(set) var showing = false |
| + /// Whether the main window was hidden, as opposed to left alone in |
| + /// its full-screen Space. |
| + public private(set) var mainHidden = false |
| + public private(set) var appHidden = false |
| + |
| + public init() {} |
| + |
| + public mutating func resignedActive(_ conditions: Conditions) -> Action { |
| + guard !showing, !appHidden, conditions.enabled, conditions.inCall, |
| + conditions.mainWindowVisible, !conditions.mainWindowMiniaturized |
| + else { return .none } |
| + showing = true |
| + mainHidden = !conditions.mainWindowFullScreen |
| + return .show(hideMain: mainHidden) |
| + } |
| + |
| + public mutating func becameActive() -> Action { |
| + appHidden = false |
| + return restore(activate: true) |
| + } |
| + |
| + public mutating func willHide() -> Action { |
| + appHidden = true |
| + return restore(activate: false) |
| + } |
| + |
| + public mutating func didUnhide() { appHidden = false } |
| + |
| + public mutating func callEnded() -> Action { restore(activate: false) } |
| + |
| + private mutating func restore(activate: Bool) -> Action { |
| + guard showing else { return .none } |
| + showing = false |
| + mainHidden = false |
| + return .restore(activate: activate) |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index 253ca5d..4ca4e22 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -21,12 +21,14 @@ import LiveKitWebRTC |
| CallsAppDelegate.model = model |
| model.restore() |
| } |
| + .miniWindow(for: model) |
| } |
| .defaultSize(width: 1000, height: 760) |
| .commands { |
| CommandGroup(replacing: .newItem) {} |
| NativeUpdateCommands(updater: updater) |
| } |
| + Settings { CallsSettingsView() } |
| } |
| } |
diff --git a/native/apple/Tests/CallsKitTests/MiniWindowTests.swift b/native/apple/Tests/CallsKitTests/MiniWindowTests.swift| new file mode 100644 |
| index 0000000..64328d4 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/MiniWindowTests.swift |
| @@ -0,0 +1,241 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + import LiveKitWebRTC |
| + import XCTest |
| + |
| + @testable import CallsKit |
| + |
| + /// The mini window's rules, held without a real call or a person |
| + /// switching apps: the defaults, where each corner lands, and when the |
| + /// window comes and goes. |
| + final class MiniWindowPreferencesTests: XCTestCase { |
| + private func defaults() -> UserDefaults { |
| + let name = "MiniWindowTests.\(UUID().uuidString)" |
| + let defaults = UserDefaults(suiteName: name)! |
| + addTeardownBlock { defaults.removePersistentDomain(forName: name) } |
| + return defaults |
| + } |
| + |
| + func testOnAndBottomRightUntilSomeoneSaysOtherwise() { |
| + let preferences = MiniWindowPreferences(defaults: defaults()) |
| + XCTAssertTrue(preferences.enabled) |
| + XCTAssertEqual(preferences.corner, .bottomRight) |
| + } |
| + |
| + func testChoicesPersist() { |
| + let store = defaults() |
| + MiniWindowPreferences(defaults: store).enabled = false |
| + MiniWindowPreferences(defaults: store).corner = .topLeft |
| + XCTAssertFalse(MiniWindowPreferences(defaults: store).enabled) |
| + XCTAssertEqual(MiniWindowPreferences(defaults: store).corner, .topLeft) |
| + } |
| + |
| + func testAnUnknownCornerFallsBackToTheDefault() { |
| + let store = defaults() |
| + store.set("middle", forKey: MiniWindowPreferences.cornerKey) |
| + XCTAssertEqual(MiniWindowPreferences(defaults: store).corner, .bottomRight) |
| + } |
| + |
| + func testTheCornersAreOfferedInTheOrderTheSettingShowsThem() { |
| + XCTAssertEqual( |
| + MiniWindowCorner.allCases.map(\.title), |
| + ["Bottom right", "Bottom left", "Top right", "Top left"]) |
| + } |
| + } |
| + |
| + final class MiniWindowCornerTests: XCTestCase { |
| + // A 1440×900 screen with a 25pt menu bar and a 70pt Dock along the |
| + // bottom: the visible frame AppKit reports for it. |
| + private let visible = CGRect(x: 0, y: 70, width: 1440, height: 805) |
| + private let size = MiniWindowRules.size |
| + |
| + func testEachCornerSitsInsetFromTheVisibleFrame() { |
| + XCTAssertEqual( |
| + MiniWindowCorner.bottomRight.frame(size: size, in: visible), |
| + CGRect(x: 1100, y: 90, width: 320, height: 180)) |
| + XCTAssertEqual( |
| + MiniWindowCorner.bottomLeft.frame(size: size, in: visible), |
| + CGRect(x: 20, y: 90, width: 320, height: 180)) |
| + XCTAssertEqual( |
| + MiniWindowCorner.topRight.frame(size: size, in: visible), |
| + CGRect(x: 1100, y: 675, width: 320, height: 180)) |
| + XCTAssertEqual( |
| + MiniWindowCorner.topLeft.frame(size: size, in: visible), |
| + CGRect(x: 20, y: 675, width: 320, height: 180)) |
| + } |
| + |
| + func testASecondScreenLeftOfTheMainOneHasANegativeOrigin() { |
| + let left = CGRect(x: -1920, y: 0, width: 1920, height: 1055) |
| + XCTAssertEqual( |
| + MiniWindowCorner.bottomRight.frame(size: size, in: left), |
| + CGRect(x: -340, y: 20, width: 320, height: 180)) |
| + XCTAssertEqual( |
| + MiniWindowCorner.topLeft.frame(size: size, in: left), |
| + CGRect(x: -1900, y: 855, width: 320, height: 180)) |
| + } |
| + |
| + func testTheWindowIs16By9() { |
| + XCTAssertEqual(size.width / size.height, 16 / 9, accuracy: 0.001) |
| + } |
| + |
| + func testADraggedWindowSnapsToTheCornerItsMiddleIsNearest() { |
| + func dropped(_ x: CGFloat, _ y: CGFloat) -> MiniWindowCorner { |
| + .nearest(to: CGRect(origin: CGPoint(x: x, y: y), size: size), in: visible) |
| + } |
| + XCTAssertEqual(dropped(900, 120), .bottomRight) |
| + XCTAssertEqual(dropped(300, 120), .bottomLeft) |
| + XCTAssertEqual(dropped(900, 600), .topRight) |
| + XCTAssertEqual(dropped(100, 600), .topLeft) |
| + // Half off the screen still belongs to the corner it is nearest. |
| + XCTAssertEqual(dropped(1300, -50), .bottomRight) |
| + // Every corner's own frame snaps back to that corner. |
| + for corner in MiniWindowCorner.allCases { |
| + XCTAssertEqual(.nearest(to: corner.frame(size: size, in: visible), in: visible), corner) |
| + } |
| + } |
| + } |
| + |
| + final class MiniWindowPolicyTests: XCTestCase { |
| + private let inCall = MiniWindowPolicy.Conditions( |
| + enabled: true, inCall: true, mainWindowVisible: true) |
| + |
| + func testSwitchingAwayDuringACallShowsItAndComingBackRestores() { |
| + var policy = MiniWindowPolicy() |
| + XCTAssertEqual(policy.resignedActive(inCall), .show(hideMain: true)) |
| + XCTAssertTrue(policy.showing) |
| + XCTAssertEqual(policy.becameActive(), .restore(activate: true)) |
| + XCTAssertFalse(policy.showing) |
| + // Coming back again with nothing up is nothing to do. |
| + XCTAssertEqual(policy.becameActive(), .none) |
| + } |
| + |
| + func testASecondResignWhileShowingDoesNotOpenAnother() { |
| + var policy = MiniWindowPolicy() |
| + _ = policy.resignedActive(inCall) |
| + XCTAssertEqual(policy.resignedActive(inCall), .none) |
| + } |
| + |
| + func testNothingAppearsOutsideACallOrWithTheSettingOff() { |
| + var policy = MiniWindowPolicy() |
| + var conditions = inCall |
| + conditions.inCall = false |
| + XCTAssertEqual(policy.resignedActive(conditions), .none) |
| + conditions = inCall |
| + conditions.enabled = false |
| + XCTAssertEqual(policy.resignedActive(conditions), .none) |
| + XCTAssertFalse(policy.showing) |
| + } |
| + |
| + func testACallWindowPutAwayByThePersonStaysAway() { |
| + var policy = MiniWindowPolicy() |
| + var conditions = inCall |
| + conditions.mainWindowMiniaturized = true |
| + XCTAssertEqual(policy.resignedActive(conditions), .none) |
| + conditions = inCall |
| + conditions.mainWindowVisible = false |
| + XCTAssertEqual(policy.resignedActive(conditions), .none) |
| + } |
| + |
| + func testAFullScreenCallWindowIsLeftInItsSpace() { |
| + var policy = MiniWindowPolicy() |
| + var conditions = inCall |
| + conditions.mainWindowFullScreen = true |
| + XCTAssertEqual(policy.resignedActive(conditions), .show(hideMain: false)) |
| + XCTAssertFalse(policy.mainHidden) |
| + XCTAssertEqual(policy.becameActive(), .restore(activate: true)) |
| + } |
| + |
| + func testHidingTheAppBeforeItResignsShowsNothing() { |
| + var policy = MiniWindowPolicy() |
| + XCTAssertEqual(policy.willHide(), .none) |
| + XCTAssertEqual(policy.resignedActive(inCall), .none) |
| + policy.didUnhide() |
| + XCTAssertEqual(policy.becameActive(), .none) |
| + // And once it is back, switching away works as before. |
| + XCTAssertEqual(policy.resignedActive(inCall), .show(hideMain: true)) |
| + } |
| + |
| + func testHidingTheAppAfterItResignedPutsTheCallWindowBackFirst() { |
| + var policy = MiniWindowPolicy() |
| + _ = policy.resignedActive(inCall) |
| + XCTAssertEqual(policy.willHide(), .restore(activate: false)) |
| + XCTAssertEqual(policy.resignedActive(inCall), .none) |
| + } |
| + |
| + func testTheCallEndingPutsItAwayWithoutPullingTheAppForward() { |
| + var policy = MiniWindowPolicy() |
| + _ = policy.resignedActive(inCall) |
| + XCTAssertEqual(policy.callEnded(), .restore(activate: false)) |
| + XCTAssertEqual(policy.becameActive(), .none) |
| + XCTAssertEqual(policy.callEnded(), .none) |
| + } |
| + } |
| + |
| + final class MiniWindowPanelTests: XCTestCase { |
| + @MainActor func testThePanelFloatsEverywhereAndStaysOutOfScreenCaptures() throws { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + let screen = try XCTUnwrap(NSScreen.main) |
| + let panel = MiniWindowPanel( |
| + call: Call(playAudio: false), corner: .topLeft, screen: screen, |
| + leave: {}, open: {}, moved: {}) |
| + XCTAssertEqual(panel.sharingType, .none) |
| + XCTAssertEqual(panel.level, .floating) |
| + XCTAssertFalse(panel.hidesOnDeactivate) |
| + XCTAssertTrue(panel.styleMask.contains(.nonactivatingPanel)) |
| + XCTAssertTrue(panel.collectionBehavior.contains(.canJoinAllSpaces)) |
| + XCTAssertTrue(panel.collectionBehavior.contains(.fullScreenAuxiliary)) |
| + XCTAssertEqual( |
| + panel.frame, MiniWindowCorner.topLeft.frame(size: MiniWindowRules.size, in: screen.visibleFrame)) |
| + panel.place(.bottomRight, on: screen, animated: false) |
| + XCTAssertEqual( |
| + panel.frame, |
| + MiniWindowCorner.bottomRight.frame(size: MiniWindowRules.size, in: screen.visibleFrame)) |
| + panel.dismiss() |
| + XCTAssertNil(panel.contentView) |
| + XCTAssertFalse(panel.isVisible) |
| + } |
| + |
| + /// The mechanism dismiss() relies on to hand renderers back: once the |
| + /// content stops naming a track, SwiftUI takes the video view out of |
| + /// the hierarchy on the next layout, which is when MacVideoTile |
| + /// removes its renderer from the track. |
| + @MainActor func testTakingTheVideoOutRemovesItsRendererAtOnce() { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + let factory = RTCPeerConnectionFactory() |
| + let track = factory.videoTrack(with: factory.videoSource(), trackId: "mini-test") |
| + let holder = Holder(track: track) |
| + let window = NSWindow( |
| + contentRect: NSRect(origin: .zero, size: MiniWindowRules.size), |
| + styleMask: [.borderless], backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.contentView = NSHostingView(rootView: HolderView(holder: holder)) |
| + window.orderFrontRegardless() |
| + window.contentView?.layoutSubtreeIfNeeded() |
| + XCTAssertEqual(renderers(in: window.contentView), 1) |
| + holder.track = nil |
| + window.contentView?.layoutSubtreeIfNeeded() |
| + window.contentView?.displayIfNeeded() |
| + XCTAssertEqual(renderers(in: window.contentView), 0) |
| + window.close() |
| + } |
| + |
| + private func renderers(in view: NSView?) -> Int { |
| + guard let view else { return 0 } |
| + return (view is RTCMTLNSVideoView ? 1 : 0) |
| + + view.subviews.reduce(0) { $0 + renderers(in: $1) } |
| + } |
| + |
| + @MainActor private final class Holder: ObservableObject { |
| + @Published var track: RTCVideoTrack? |
| + init(track: RTCVideoTrack) { self.track = track } |
| + } |
| + |
| + private struct HolderView: View { |
| + @ObservedObject var holder: Holder |
| + var body: some View { |
| + if let track = holder.track { MacVideoTile(track: track, label: "Bea") } else { Color.black } |
| + } |
| + } |
| + } |
| +#endif |