oficina / meet Public

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>
Paul Campbell pushed by paul@keymail.dev dcbe0356f50e174c2d09ee42f1e9ca6351638479 parent bdb2f7aparent 9a7e23e
8 files changed, +916 −0
  • native/README.md +16 −0
  • native/apple/Sources/CallsInterop/LobbySnapshots.swift +1 −0
  • native/apple/Sources/CallsInterop/SettingsSnapshots.swift +39 −0
  • native/apple/Sources/CallsKit/CallsSettingsView.swift +42 −0
  • native/apple/Sources/CallsKit/MiniWindow.swift +399 −0
  • native/apple/Sources/CallsKit/MiniWindowRules.swift +176 −0
  • native/apple/Sources/CallsMac/CallsMac.swift +2 −0
  • native/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