oficina / meet Public

Put the Mac call in a mini window while you are in another app

A Settings scene (Calls → Settings…, ⌘,) with one section: "Show a mini
window when you switch to another app", on by default, and the corner it
sits in, bottom right by default. Dragging the window snaps it to the
nearest corner and stores that corner as the setting.

During a call, the app resigning active shows a non-activating floating
panel on every Space and over full-screen apps, excluded from screen
capture; coming back restores the call window as it was. Clicking the
panel brings Calls forward; hovering shows Mute and Leave. Nothing shows
for a minimised call window, and ⌘H hides everything.

The call window is hidden by transparency, not orderOut: SwiftUI treats
an ordered-out Window as closed, runs onDisappear (which leaves the call)
and then quits the app. A full-screen call window is left in its Space.

The rules live in MiniWindowPolicy and MiniWindowCorner, tested without
a real app; the panel's capture exclusion and renderer teardown are
tested with real windows. CallsInterop's lobby fixtures now also render
the Settings window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9a7e23ebc72d86afa7701068ef5ca5ed97eb9262 parent 3846ca4
8 files changed, +904 −0
  • native/README.md +13 −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 +390 −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 5f6a204..e532539 100644
--- a/native/README.md
+++ b/native/README.md
@@ -91,6 +91,19 @@ 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 the first other participant (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 (`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 77e1955..5e2b507 100644
--- a/native/apple/Sources/CallsInterop/LobbySnapshots.swift
+++ b/native/apple/Sources/CallsInterop/LobbySnapshots.swift
@@ -46,5 +46,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..409f1e3
--- /dev/null
+++ b/native/apple/Sources/CallsKit/MiniWindow.swift
@@ -0,0 +1,390 @@
+#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
+
+ /// The first person who is not you, as the call window's stage shows
+ /// by default. 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; a shared screen should win here once one can be
+ /// received.
+ private var subject: RosterPeer? {
+ call.state?.peers.first { $0.peer != call.state?.peer }
+ }
+
+ var body: some View {
+ ZStack {
+ Group {
+ if let subject {
+ MacVideoTile(track: call.remoteTracks[subject.peer], label: subject.name)
+ } else {
+ 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 1f4daf9..c61ba62 100644
--- a/native/apple/Sources/CallsMac/CallsMac.swift
+++ b/native/apple/Sources/CallsMac/CallsMac.swift
@@ -18,12 +18,14 @@ import WebRTC
ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() }
.onOpenURL { model.openMeetingURL($0) }
.onAppear { CallsAppDelegate.model = model }
+ .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..70493b5
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/MiniWindowTests.swift
@@ -0,0 +1,241 @@
+#if os(macOS)
+ import AppKit
+ import SwiftUI
+ import WebRTC
+ 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