oficina / meet Public

Calls Mac: every press on the mini window does something

Paul, 5 Oct: "Sometimes I can't click the mini window to either bring
the call to the front or move it around."

CallsInterop --mini-clicks puts a real mini window on screen in an app
that is not active, and presses it through AppKit (sendEvent, so hit-
testing, first mouse, the surface's tracking loop and SwiftUI's buttons
all take part) at 200, 320 and 480 and as the tab. Before this commit 11
of 45 presses did nothing: a click or drag on the Muted mark (there
whenever you are muted and the controls are hidden), on the connecting
ear, and any click on the collapsed tab's microphone, names or waveform.
They were hit-testable SwiftUI views with no action, so the press went
to SwiftUI and was lost before the surface could take it as a click or a
move. They are marks, not controls, and now let presses through
(allowsHitTesting(false)); the tab's contents likewise. 45 of 45.

Also, for what the harness cannot reach (the window server):
- Bringing the call forward no longer trusts one NSApp.activate: since
  macOS 14 the system may refuse it, and a click in a non-activating
  panel does not activate the app by itself. Asked again as the running
  application after 0.25 s, then the call window is put back in front
  regardless.
- The panel is reasserted in front, at its level, opaque and taking the
  mouse, on show, on a Space change and whenever another app comes
  forward (its floating windows can cover this one).
- A second show dismisses a panel still up, rather than leaving an
  orphan over the new one.
- Hover follows the pointer (mouseMoved, and on show), so a window that
  appears under the pointer has its controls.
- os_log category "mini": every mouse-down with what it landed on, the
  app's and window's state, the press's outcome, and each activation step.

MiniWindowGesture holds the arbitration (click within 4 pt, else move
from the middle or resize from an edge; the tab never resizes; a click
on an edge is a click), with MiniWindowGestureTests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e616f9065e27a1554c276ffaa148f739cc66700d parent f31f2eb
10 files changed, +530 −83
  • native/apple/Sources/CallsInterop/Interop.swift +16 −0
  • native/apple/Sources/CallsKit/MiniWindow.swift +141 −16
  • native/apple/Sources/CallsKit/MiniWindowProbe.swift +196 −0
  • native/apple/Sources/CallsKit/MiniWindowRules.swift +40 −0
  • native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift +63 −0
  • test/browser/call.mjs +1 −67
  • test/browser/native-motion.mjs +2 −0
  • test/browser/native-screen.mjs +2 −0
  • test/browser/native.mjs +2 −0
  • test/browser/synthetic-media.mjs +67 −0
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 6abf335..a3cec4a 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -52,6 +52,22 @@ struct Input: Decodable {
} catch { exit(1) }
return
}
+ if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--mini-clicks" {
+ // Under NSApplication's own run loop, as the app is: the surface
+ // tracks a press with a nested event loop, which Swift's async main
+ // alone does not survive.
+ NSApplication.shared.setActivationPolicy(.accessory)
+ Task { @MainActor in
+ let call = Call(playAudio: false)
+ CallFixture.grid.apply(to: call)
+ let results = await MiniWindowProbe.run(call: call)
+ for r in results { print((r.passed ? "ok " : "FAIL ") + r.name + (r.passed ? "" : " — " + r.detail)) }
+ print("mini-clicks: \(results.filter(\.passed).count) of \(results.count) presses did what they should")
+ fflush(stdout)
+ exit(results.allSatisfy(\.passed) ? 0 : 1)
+ }
+ NSApplication.shared.run()
+ }
if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--stage-snapshots" {
do {
try await StageSnapshots.save(to: URL(fileURLWithPath: CommandLine.arguments[2]))
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift
index d4af785..ed55282 100644
--- a/native/apple/Sources/CallsKit/MiniWindow.swift
+++ b/native/apple/Sources/CallsKit/MiniWindow.swift
@@ -1,8 +1,18 @@
#if os(macOS)
import AppKit
import Combine
+ import OSLog
import SwiftUI
+ /// The mini window's decisions, for a "sometimes" (2026-10-05, Paul:
+ /// "Sometimes I can't click the mini window"): every press, what was
+ /// under it, what it was taken as, and whether bringing the call forward
+ /// worked. `log show --predicate 'subsystem == "net.amadan.oficina.calls"
+ /// && category == "mini"' --info`.
+ enum MiniLog {
+ static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "mini")
+ }
+
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
@@ -66,9 +76,26 @@
}
}
}
+ // Another app coming forward can bring its own floating windows
+ // over this one, and a Space change or a full-screen app can leave
+ // it behind: put it back in front, taking clicks, each time.
+ let workspace = NSWorkspace.shared.notificationCenter
+ for name in [
+ NSWorkspace.activeSpaceDidChangeNotification, NSWorkspace.didActivateApplicationNotification,
+ ] {
+ observers.append(
+ workspace.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in
+ MainActor.assumeIsolated { self?.panel?.reassert(because: note.name.rawValue) }
+ })
+ }
}
- deinit { observers.forEach(NotificationCenter.default.removeObserver) }
+ deinit {
+ for observer in observers {
+ NotificationCenter.default.removeObserver(observer)
+ NSWorkspace.shared.notificationCenter.removeObserver(observer)
+ }
+ }
func attach(model: CallsModel, mainWindow: NSWindow?) {
if let mainWindow { self.mainWindow = mainWindow }
@@ -105,11 +132,17 @@
case .none: break
case .show(let hideMain):
guard let model else { return }
+ // Never two: a panel left behind by a show without a restore would
+ // sit over the new one, its clicks going to a window nobody sees.
+ if let stale = panel {
+ MiniLog.logger.info("show: dismissing a panel still up")
+ stale.dismiss()
+ }
let panel = MiniWindowPanel(
call: model.call,
actions: MiniWindowActions(
leave: { [weak model] in model?.leave() },
- open: { NSApp.activate(ignoringOtherApps: true) },
+ open: { [weak self] in self?.bringCallForward() },
moved: { [weak self] option in self?.dropped(option: option) },
resized: { [weak self] in self?.resized() },
collapse: { [weak self] in self?.collapse() },
@@ -117,7 +150,7 @@
tabReleased: { [weak self] in self?.tabReleased() }))
self.panel = panel
layout(panel, on: mainWindow?.screen ?? NSScreen.main)
- panel.orderFrontRegardless()
+ panel.reassert(because: "show")
if hideMain, let mainWindow { Self.conceal(mainWindow, true) }
case .restore(let activate):
panel?.dismiss()
@@ -132,6 +165,34 @@
}
}
+ /// A click on the mini window: the call, forward. Since macOS 14 an
+ /// app's request to activate itself is a request the system may turn
+ /// down, and a click in a non-activating panel does not activate the
+ /// app by itself, so a refusal left the person clicking a window that
+ /// did nothing. Asked for twice, the second time as the running
+ /// application, and if the app is still not active the call window is
+ /// put back and brought in front anyway, so a click always shows the
+ /// call. Every step is logged.
+ func bringCallForward() {
+ MiniLog.logger.info(
+ "open: activating (active=\(NSApp.isActive), frontmost=\(NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "none", privacy: .public))"
+ )
+ if #available(macOS 14, *) { NSApp.activate() } else { NSApp.activate(ignoringOtherApps: true) }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ guard let self, self.panel != nil else { return }
+ if NSApp.isActive { return }
+ MiniLog.logger.info("open: not active after 0.25 s; asking again as the running application")
+ NSRunningApplication.current.activate(options: [.activateAllWindows])
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ guard let self, self.panel != nil else { return }
+ if NSApp.isActive { return }
+ MiniLog.logger.error("open: activation refused twice; showing the call window in front regardless")
+ self.apply(self.policy.becameActive())
+ self.mainWindow?.orderFrontRegardless()
+ }
+ }
+ }
+
/// 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
@@ -287,6 +348,33 @@
/// window's controls out of reach of the keyboard altogether.
override var canBecomeKey: Bool { true }
+ /// In front, at its level, visible and taking the mouse: what a click
+ /// on it needs, put back whenever something may have changed it.
+ func reassert(because reason: String) {
+ if ignoresMouseEvents || alphaValue < 1 || level != .floating {
+ MiniLog.logger.info(
+ "reassert (\(reason, privacy: .public)): mouse ignored=\(self.ignoresMouseEvents), alpha=\(self.alphaValue), level=\(self.level.rawValue)"
+ )
+ }
+ ignoresMouseEvents = false
+ alphaValue = 1
+ level = .floating
+ orderFrontRegardless()
+ (contentView as? MiniWindowHostingView)?.syncHover()
+ }
+
+ /// Every press, logged with what it landed on, before AppKit sends it
+ /// there.
+ override func sendEvent(_ event: NSEvent) {
+ if event.type == .leftMouseDown, let content = contentView {
+ let hit = content.hitTest(content.convert(event.locationInWindow, from: nil))
+ MiniLog.logger.info(
+ "mouse down at \(Int(event.locationInWindow.x)),\(Int(event.locationInWindow.y)) in \(Int(self.frame.width))x\(Int(self.frame.height)) (\(self.collapsed ? "tab" : "window", privacy: .public)) on \(hit.map { String(describing: type(of: $0)) } ?? "nothing", privacy: .public); app active=\(NSApp.isActive), key=\(self.isKeyWindow), clicks=\(event.clickCount)"
+ )
+ }
+ super.sendEvent(event)
+ }
+
func expand(to frame: CGRect, animated: Bool) {
content.collapsed = false
expandedFrame = frame
@@ -345,12 +433,26 @@
super.updateTrackingAreas()
if let tracking { removeTrackingArea(tracking) }
let area = NSTrackingArea(
- rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+ rect: .zero, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect],
owner: self)
addTrackingArea(area)
tracking = area
}
+ /// A window that appears under the pointer, or moves under it, gets no
+ /// mouseEntered, and its controls would stay hidden until the pointer
+ /// left and came back: hover follows where the pointer is instead.
+ func syncHover() {
+ guard let window else { return }
+ let inside = window.frame.contains(NSEvent.mouseLocation)
+ if hover?.hovering != inside { hover?.hovering = inside }
+ }
+
+ override func mouseMoved(with event: NSEvent) {
+ super.mouseMoved(with: event)
+ if hover?.hovering == false { hover?.hovering = true }
+ }
+
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
hover?.hovering = true
@@ -460,12 +562,17 @@
.font(.callout.bold()).foregroundStyle(.white)
.padding(8).callOverlaySurface(Circle())
.accessibilityLabel("Your microphone is muted")
+ // A mark, not a control: a press on it is a press on the
+ // window (MiniWindowGesture), not swallowed by SwiftUI.
+ .allowsHitTesting(false)
}
Spacer()
// Spec §15.24, for the people this window is not showing: the
// one it shows says it on its own tile. Top right, clear of
// the tile's Muted badge.
ConnectingDot(names: offStage.map { call.name(of: $0) })
+ // Its names are in VoiceOver's label; a press goes through.
+ .allowsHitTesting(false)
if showingControls {
Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") {
actions.collapse()
@@ -540,6 +647,13 @@
Spacer(minLength: 0)
SpeakingGlyph(speaking: call.levels.anyoneSpeaking, glow: call.levels.othersGlow)
}
+ // Nothing in the tab is a control of its own: every press on it,
+ // on the microphone, the names or the waveform included, is the
+ // surface's, so a click always opens and a drag always moves. Until
+ // 2026-10-05 a press on the names or a mark went to SwiftUI, which
+ // had nothing to do with it, and was lost (CallsInterop
+ // --mini-clicks).
+ .allowsHitTesting(false)
.padding(.horizontal, 7)
.frame(width: MiniWindowTab.size.width, height: MiniWindowTab.size.height)
.background(
@@ -684,18 +798,18 @@
override func mouseDown(with event: NSEvent) {
guard let window else { return }
- let start = NSEvent.mouseLocation
+ let surface: MiniWindowGesture.Surface = mode == .window ? .window : .tab
+ let start = window.convertPoint(toScreen: event.locationInWindow)
let startFrame = window.frame
- let grabbed = handle(at: convert(event.locationInWindow, from: nil))
+ let grabbed = MiniWindowGesture.grab(
+ at: convert(event.locationInWindow, from: nil), in: bounds.size, surface: surface)
let visible = window.screen?.visibleFrame ?? startFrame
var dragging = false
while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) {
- let now = NSEvent.mouseLocation
+ let now = Self.screenLocation(of: next)
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, MiniWindowGesture.dragged(dx: dx, dy: dy) { dragging = true }
if dragging {
if grabbed.isEmpty {
window.setFrameOrigin(NSPoint(x: startFrame.minX + dx, y: startFrame.minY + dy))
@@ -708,14 +822,25 @@
}
if next.type == .leftMouseUp { break }
}
- if !dragging {
- click(event.clickCount)
- } else if grabbed.isEmpty {
- dropped(NSEvent.modifierFlags.contains(.option))
- } else {
- resized()
+ let outcome = MiniWindowGesture.outcome(
+ grabbed: grabbed, dragged: dragging, clickCount: event.clickCount,
+ option: NSEvent.modifierFlags.contains(.option) || event.modifierFlags.contains(.option))
+ MiniLog.logger.info("\(surface.rawValue, privacy: .public) press: \(String(describing: outcome), privacy: .public)")
+ switch outcome {
+ case .click(let count): click(count)
+ case .move(let option): dropped(option)
+ case .resize: resized()
}
}
+
+ /// Where the pointer is for an event of a drag. The window moves
+ /// under the pointer as it is dragged, so a real event's position in
+ /// the window is stale by the time it is read: the pointer's own
+ /// location is used. An event made with no window (CallsInterop's
+ /// --mini-clicks) carries screen coordinates and is read as it is.
+ static func screenLocation(of event: NSEvent) -> CGPoint {
+ event.windowNumber == 0 && event.window == nil ? event.locationInWindow : NSEvent.mouseLocation
+ }
}
}
diff --git a/native/apple/Sources/CallsKit/MiniWindowProbe.swift b/native/apple/Sources/CallsKit/MiniWindowProbe.swift
new file mode 100644
index 0000000..4ab9c71
--- /dev/null
+++ b/native/apple/Sources/CallsKit/MiniWindowProbe.swift
@@ -0,0 +1,196 @@
+#if os(macOS)
+ import AppKit
+ import SwiftUI
+
+ /// The mini window pressed as a person presses it, for CallsInterop's
+ /// --mini-clicks (2026-10-05, Paul: "Sometimes I can't click the mini
+ /// window"). A real panel, on screen, in an app that is not active (the
+ /// harness never activates), with a call behind it; presses made as
+ /// NSEvents and sent through the window, so AppKit's hit-testing, first
+ /// mouse, the surface's tracking loop and SwiftUI's buttons all take
+ /// part. What each press did is read from the actions it called and
+ /// from the window's frame. This Mac refuses screen capture and event
+ /// posting to this process, so these presses do not pass through the
+ /// window server; everything after it they do.
+ @_spi(Fixtures) @MainActor public enum MiniWindowProbe {
+ public struct Result: Sendable {
+ public let name: String
+ public let passed: Bool
+ public let detail: String
+ }
+
+ public static func run(call: Call) async -> [Result] {
+ var results: [Result] = []
+ for width in [MiniWindowRules.minimumWidth, MiniWindowRules.size.width, 480] {
+ results += await pressWindow(call: call, width: width)
+ }
+ results += await pressTab(call: call)
+ return results
+ }
+
+ static func trace(_ line: String) {
+ FileHandle.standardError.write(Data(("mini-clicks: " + line + "\n").utf8))
+ }
+
+ private final class Recorder {
+ var calls: [String] = []
+ }
+
+ private static func panel(call: Call, recorder: Recorder) -> MiniWindowPanel {
+ MiniWindowPanel(
+ call: call,
+ actions: MiniWindowActions(
+ leave: { recorder.calls.append("leave") },
+ open: { recorder.calls.append("open") },
+ moved: { option in recorder.calls.append(option ? "moved-option" : "moved") },
+ resized: { recorder.calls.append("resized") },
+ collapse: { recorder.calls.append("collapse") },
+ expand: { recorder.calls.append("expand") },
+ tabReleased: { recorder.calls.append("tabReleased") }))
+ }
+
+ private static func pressWindow(call: Call, width: CGFloat) async -> [Result] {
+ let recorder = Recorder()
+ let size = CGSize(width: width, height: width / MiniWindowRules.aspect)
+ let panel = panel(call: call, recorder: recorder)
+ let visible = NSScreen.main?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1440, height: 900)
+ let home = CGRect(x: visible.midX - size.width / 2, y: visible.midY - size.height / 2, width: size.width, height: size.height)
+ panel.expand(to: home, animated: false)
+ panel.reassert(because: "probe")
+ try? await Task.sleep(for: .milliseconds(300))
+ var results: [Result] = []
+ let tag = "\(Int(width))"
+ trace("window \(tag) up at \(panel.frame)")
+ func check(_ name: String, _ expected: String, _ act: () async -> Void, frame: ((CGRect) -> Bool)? = nil) async {
+ panel.expand(to: home, animated: false)
+ recorder.calls = []
+ trace("\(tag) \(name)")
+ await act()
+ try? await Task.sleep(for: .milliseconds(80))
+ let got = recorder.calls.joined(separator: ",")
+ let frameOK = frame?(panel.frame) ?? true
+ results.append(
+ Result(
+ name: "\(tag) \(name)", passed: got == expected && frameOK,
+ detail: "expected \(expected), got [\(got)], frame \(panel.frame)"))
+ }
+ let w = size.width, h = size.height
+ // Hover off: the picture alone.
+ panel.content.hovering = false
+ try? await Task.sleep(for: .milliseconds(100))
+ await check("click in the middle brings the call forward", "open") { await click(panel, at: CGPoint(x: w / 2, y: h / 2)) }
+ await check("click on the name brings the call forward", "open") { await click(panel, at: CGPoint(x: 24, y: 16)) }
+ await check("click near the top left brings the call forward", "open") { await click(panel, at: CGPoint(x: 20, y: h - 20)) }
+ await check("a shaky click is still a click", "open") {
+ await press(panel, from: CGPoint(x: w / 2, y: h / 2), by: CGSize(width: 3, height: -2))
+ }
+ await check("a drag from the middle moves it", "moved", {
+ await press(panel, from: CGPoint(x: w / 2, y: h / 2), by: CGSize(width: 60, height: -40))
+ }, frame: { abs($0.minX - (home.minX + 60)) < 1 && abs($0.minY - (home.minY - 40)) < 1 })
+ await check("a drag from the right edge resizes it", "resized", {
+ await press(panel, from: CGPoint(x: w - 3, y: h / 2), by: CGSize(width: 40, height: 0))
+ }, frame: { $0.width > w + 20 || w >= MiniWindowRules.size(width: 10_000, in: visible).width })
+ await check("a drag from the bottom left corner resizes it", "resized", {
+ await press(panel, from: CGPoint(x: 3, y: 3), by: CGSize(width: -30, height: -20))
+ }, frame: { $0.width > w + 10 || w >= MiniWindowRules.size(width: 10_000, in: visible).width })
+ // Marks over the picture are not controls: muted, and someone
+ // still connecting (the ear), a press on them is the window's.
+ call.showFixtureSelf(muted: true, cameraOff: false, sharingScreen: false)
+ call.showFixtureConnecting(["li"])
+ try? await Task.sleep(for: .milliseconds(200))
+ await check("click on the Muted mark brings the call forward", "open") { await click(panel, at: CGPoint(x: 10 + 16, y: h - 10 - 16)) }
+ await check("click on the connecting ear brings the call forward", "open") { await click(panel, at: CGPoint(x: w - 10 - 16, y: h - 10 - 16)) }
+ await check("a drag from the Muted mark moves it", "moved") {
+ await press(panel, from: CGPoint(x: 10 + 16, y: h - 10 - 16), by: CGSize(width: -50, height: -30))
+ }
+ call.showFixtureSelf(muted: false, cameraOff: false, sharingScreen: false)
+ call.showFixtureConnecting([])
+ // Hover on: the controls (44 pt, 8 apart, 10 in from the top right:
+ // Collapse, Mute, Leave) answer the first click.
+ panel.content.hovering = true
+ try? await Task.sleep(for: .milliseconds(200))
+ let row = h - 10 - 22
+ await check("first click on Leave", "leave") { await click(panel, at: CGPoint(x: w - 10 - 22, y: row), control: true) }
+ await check("first click on Collapse", "collapse") { await click(panel, at: CGPoint(x: w - 10 - 44 - 8 - 44 - 8 - 22, y: row), control: true) }
+ let before = call.muted
+ await click(panel, at: CGPoint(x: w - 10 - 44 - 8 - 22, y: row), control: true)
+ try? await Task.sleep(for: .milliseconds(80))
+ results.append(Result(name: "\(tag) first click on Mute", passed: call.muted != before, detail: "muted \(before) -> \(call.muted)"))
+ if call.muted != before { call.showFixtureSelf(muted: before, cameraOff: false, sharingScreen: false) }
+ await check("click in the middle with the controls up", "open") { await click(panel, at: CGPoint(x: w / 2, y: h / 2)) }
+ panel.dismiss()
+ return results
+ }
+
+ private static func pressTab(call: Call) async -> [Result] {
+ let recorder = Recorder()
+ let panel = panel(call: call, recorder: recorder)
+ let visible = NSScreen.main?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1440, height: 900)
+ let expanded = CGRect(origin: CGPoint(x: visible.maxX - 340, y: visible.minY + 20), size: MiniWindowRules.size)
+ let tab = MiniWindowTab.dock(expanded, in: visible).1
+ var results: [Result] = []
+ func reset() {
+ panel.collapse(to: tab, expanded: expanded)
+ recorder.calls = []
+ }
+ reset()
+ panel.reassert(because: "probe")
+ try? await Task.sleep(for: .milliseconds(300))
+ let middle = CGPoint(x: tab.width / 2, y: tab.height / 2)
+ await click(panel, at: middle)
+ try? await Task.sleep(for: .milliseconds(80))
+ results.append(Result(name: "tab click opens the window", passed: recorder.calls == ["expand"], detail: "\(recorder.calls)"))
+ reset()
+ await click(panel, at: middle, count: 2)
+ try? await Task.sleep(for: .milliseconds(80))
+ results.append(Result(name: "tab double click opens the call", passed: recorder.calls.last == "open", detail: "\(recorder.calls)"))
+ reset()
+ await press(panel, from: CGPoint(x: 3, y: middle.y), by: CGSize(width: -120, height: 30))
+ try? await Task.sleep(for: .milliseconds(80))
+ results.append(Result(name: "tab drag, even from its edge, is a drag", passed: recorder.calls == ["tabReleased"], detail: "\(recorder.calls)"))
+ panel.dismiss()
+ return results
+ }
+
+ private static func click(_ panel: NSWindow, at point: CGPoint, count: Int = 1, control: Bool = false) async {
+ for n in 1...count {
+ await press(panel, from: point, by: .zero, clickCount: n, control: control)
+ }
+ }
+
+ /// A press at `point` in the window, moved by `by` in steps, released:
+ /// the down sent through the window as AppKit would, the drags and
+ /// the up queued for the surface's tracking loop, in screen points.
+ /// A control's up goes to the window, where SwiftUI's button reads it;
+ /// the surface's tracking loop reads screen points from window-less
+ /// events (SurfaceView.screenLocation).
+ private static func press(
+ _ panel: NSWindow, from point: CGPoint, by: CGSize, clickCount: Int = 1, control: Bool = false
+ ) async {
+ let screenStart = panel.convertPoint(toScreen: point)
+ let now = ProcessInfo.processInfo.systemUptime
+ func event(_ type: NSEvent.EventType, at location: CGPoint, window: Bool) -> NSEvent? {
+ NSEvent.mouseEvent(
+ with: type, location: location, modifierFlags: [], timestamp: now,
+ windowNumber: window ? panel.windowNumber : 0, context: nil, eventNumber: 0,
+ clickCount: clickCount, pressure: type == .leftMouseUp ? 0 : 1)
+ }
+ let steps = by == .zero ? 0 : 5
+ for i in stride(from: 1, through: steps, by: 1) {
+ let f = CGFloat(i) / CGFloat(steps)
+ if let drag = event(.leftMouseDragged, at: CGPoint(x: screenStart.x + by.width * f, y: screenStart.y + by.height * f), window: false) {
+ NSApp.postEvent(drag, atStart: false)
+ }
+ }
+ if control, let up = event(.leftMouseUp, at: point, window: true) {
+ NSApp.postEvent(up, atStart: false)
+ } else if let up = event(.leftMouseUp, at: CGPoint(x: screenStart.x + by.width, y: screenStart.y + by.height), window: false) {
+ NSApp.postEvent(up, atStart: false)
+ }
+ if let down = event(.leftMouseDown, at: point, window: true) { panel.sendEvent(down) }
+ // A SwiftUI button acts on the up, which it reads from the queue
+ // itself; let the run loop turn.
+ try? await Task.sleep(for: .milliseconds(60))
+ }
+ }
+#endif
diff --git a/native/apple/Sources/CallsKit/MiniWindowRules.swift b/native/apple/Sources/CallsKit/MiniWindowRules.swift
index 20a9c8f..2e421b3 100644
--- a/native/apple/Sources/CallsKit/MiniWindowRules.swift
+++ b/native/apple/Sources/CallsKit/MiniWindowRules.swift
@@ -507,4 +507,44 @@
return .restore(activate: activate)
}
}
+
+/// What one press on the mini window's surface is (2026-10-05, Paul:
+/// "Sometimes I can't click the mini window to either bring the call to
+/// the front or move it around"): apart from any view, so tests hold it.
+/// A press that ends within `slack` of where it began is a click, which
+/// brings the call forward (the tab: opens the window; twice, the call).
+/// One that travels further is a drag: from an edge or corner of the
+/// expanded window it resizes, from anywhere else it moves. The tab has
+/// no edges to resize by. Controls are SwiftUI buttons over the surface
+/// and never reach it.
+public enum MiniWindowGesture {
+ public enum Surface: String, Sendable { case window, tab }
+ public enum Outcome: Equatable, Sendable {
+ case click(count: Int)
+ case move(option: Bool)
+ case resize(MiniWindowResize.Handle)
+ }
+
+ /// How far a press may wander and still be a click: a shaky hand's.
+ public static let slack: CGFloat = 4
+
+ /// The edges a press at `point` takes hold of: none on the tab, and none
+ /// on a window too small for its grips to leave a middle to move by.
+ public static func grab(at point: CGPoint, in size: CGSize, surface: Surface) -> MiniWindowResize.Handle {
+ guard surface == .window, size.width > 4 * MiniWindowResize.grip,
+ size.height > 4 * MiniWindowResize.grip
+ else { return [] }
+ return MiniWindowResize.handle(at: point, in: size)
+ }
+
+ public static func dragged(dx: CGFloat, dy: CGFloat) -> Bool { hypot(dx, dy) > slack }
+
+ /// The press's outcome once the button is up.
+ public static func outcome(
+ grabbed: MiniWindowResize.Handle, dragged: Bool, clickCount: Int, option: Bool
+ ) -> Outcome {
+ guard dragged else { return .click(count: max(1, clickCount)) }
+ return grabbed.isEmpty ? .move(option: option) : .resize(grabbed)
+ }
+}
#endif
diff --git a/native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift b/native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift
new file mode 100644
index 0000000..3bdff64
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift
@@ -0,0 +1,63 @@
+#if os(macOS)
+ import CoreGraphics
+ import XCTest
+
+ @testable import CallsKit
+
+ /// One press on the mini window (2026-10-05, Paul: "Sometimes I can't
+ /// click the mini window to either bring the call to the front or move
+ /// it around"): click, move or resize, and on which surface.
+ final class MiniWindowGestureTests: XCTestCase {
+ let window = CGSize(width: 200, height: 112.5)
+
+ func testAPressThatStaysPutIsAClick() {
+ XCTAssertFalse(MiniWindowGesture.dragged(dx: 0, dy: 0))
+ XCTAssertFalse(MiniWindowGesture.dragged(dx: 3, dy: -2), "a shaky hand")
+ XCTAssertEqual(
+ MiniWindowGesture.outcome(grabbed: [], dragged: false, clickCount: 1, option: false), .click(count: 1))
+ XCTAssertEqual(
+ MiniWindowGesture.outcome(grabbed: [.right], dragged: false, clickCount: 2, option: false), .click(count: 2),
+ "a click on an edge is still a click: it brings the call forward")
+ }
+
+ func testAPressThatTravelsIsADrag() {
+ XCTAssertTrue(MiniWindowGesture.dragged(dx: 5, dy: 0))
+ XCTAssertEqual(
+ MiniWindowGesture.outcome(grabbed: [], dragged: true, clickCount: 1, option: true), .move(option: true))
+ XCTAssertEqual(
+ MiniWindowGesture.outcome(grabbed: [.left, .bottom], dragged: true, clickCount: 1, option: false),
+ .resize([.left, .bottom]))
+ }
+
+ func testTheMiddleMovesAndTheEdgesResize() {
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 100, y: 56), in: window, surface: .window), [])
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 197, y: 56), in: window, surface: .window), [.right])
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 2, y: 2), in: window, surface: .window), [.left, .bottom])
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 198, y: 110), in: window, surface: .window), [.right, .top])
+ }
+
+ func testEvenTheSmallestWindowIsMostlyForMoving() {
+ // Grips are 8 pt: at 200 × 112.5 the middle that moves is the rest.
+ var moving = 0
+ var total = 0
+ for x in stride(from: 0.0, through: 200, by: 4) {
+ for y in stride(from: 0.0, through: 112, by: 4) {
+ total += 1
+ if MiniWindowGesture.grab(at: CGPoint(x: x, y: y), in: window, surface: .window).isEmpty { moving += 1 }
+ }
+ }
+ XCTAssertGreaterThan(Double(moving) / Double(total), 0.7, "about 79% by area")
+ }
+
+ func testTheTabNeverResizes() {
+ let tab = MiniWindowTab.size
+ for point in [CGPoint(x: 1, y: 1), CGPoint(x: tab.width - 1, y: tab.height / 2), CGPoint(x: tab.width / 2, y: tab.height / 2)] {
+ XCTAssertEqual(MiniWindowGesture.grab(at: point, in: tab, surface: .tab), [], "\(point)")
+ }
+ }
+
+ func testAWindowTooSmallForGripsHasNone() {
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 1, y: 1), in: CGSize(width: 30, height: 20), surface: .window), [])
+ }
+ }
+#endif
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index f19ca8b..2470819 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -31,6 +31,7 @@
// candidates are the only ones there are, and an obfuscated one connects
// to nobody; and --auto-accept-this-tab-capture answers getDisplayMedia
// with no picker, which the fake-ui flag does not cover (§5.4).
+import { syntheticMedia } from './synthetic-media.mjs';
import { writeSync, mkdirSync, writeFileSync } from 'node:fs';
import { inflateSync } from 'node:zlib';
import { createInterface } from 'node:readline';
@@ -351,73 +352,6 @@ async function newContext(label, { cookie = null, javaScriptEnabled = true, via
return ctx;
}
-// syntheticMedia is an init script, used only when CALLS_SYNTHETIC_MEDIA
-// is set: for a machine where Chromium's fake devices cannot be used.
-// Chromium asks macOS for camera and microphone access even for them, on
-// behalf of whatever launched it, and when that process has never been
-// granted it (TCC "not determined") nobody can answer and getUserMedia
-// never settles — every scenario then stops at "Connecting to the call."
-// (2026-10-05, from an agent's shell). This draws the camera and the
-// screen on canvases instead (a head and shoulders on a gradient, moving;
-// a 16:10 document) and makes the microphone a quiet tone. Scenarios that
-// assert on Chromium's own fake device names do not hold under it: it is
-// a stand-in, said so in the run, never the gate's normal way.
-const syntheticMedia = () => {
- const md = navigator.mediaDevices;
- if (!md) return;
- const drawn = (w, h, draw) => {
- const c = document.createElement('canvas');
- c.width = w;
- c.height = h;
- const g = c.getContext('2d');
- let t = 0;
- draw(g, w, h, 0);
- setInterval(() => draw(g, w, h, ++t), 50);
- return c.captureStream(20).getVideoTracks()[0];
- };
- const camera = () => drawn(640, 360, (g, w, h, t) => {
- const sky = g.createLinearGradient(0, 0, w, h);
- sky.addColorStop(0, '#6d8fb3');
- sky.addColorStop(1, '#2f3d2a');
- g.fillStyle = sky;
- g.fillRect(0, 0, w, h);
- g.fillStyle = '#c99a78';
- g.beginPath(); g.ellipse(w / 2, h * 0.36, w * 0.09, h * 0.2, 0, 0, Math.PI * 2); g.fill();
- g.fillStyle = '#7a3b2e';
- g.beginPath(); g.ellipse(w / 2, h * 1.02, w * 0.24, h * 0.42, 0, 0, Math.PI * 2); g.fill();
- g.fillStyle = '#ffffff';
- g.fillRect((t * 7) % w, h - 8, 40, 4);
- });
- const screen = () => drawn(1280, 800, (g, w, h, t) => {
- g.fillStyle = '#f4f1ea';
- g.fillRect(0, 0, w, h);
- g.fillStyle = '#1f6f8b';
- g.fillRect(0, 0, w, 48);
- g.fillStyle = '#c0392b';
- g.fillRect(w - 160, h - 120, 120, 80);
- g.fillStyle = '#333';
- for (let y = 90; y < h - 40; y += 28) g.fillRect(60, y, w - 120 - ((y * 37) % 300), 10);
- g.fillStyle = '#f39c12';
- g.fillRect((t * 9) % w, 56, 30, 30);
- });
- const tone = () => {
- const ac = new AudioContext();
- const o = ac.createOscillator();
- const gain = ac.createGain();
- gain.gain.value = 0.02;
- const out = ac.createMediaStreamDestination();
- o.connect(gain).connect(out);
- o.start();
- return out.stream.getAudioTracks()[0];
- };
- md.getUserMedia = async (c = {}) => {
- const tracks = [];
- if (c.audio) tracks.push(tone());
- if (c.video) tracks.push(camera());
- return new MediaStream(tracks);
- };
- md.getDisplayMedia = async () => new MediaStream([screen()]);
-};
function watch(page, label) {
const errs = consoleErrors.get(label);
diff --git a/test/browser/native-motion.mjs b/test/browser/native-motion.mjs
index f0ab777..ea4f4b2 100644
--- a/test/browser/native-motion.mjs
+++ b/test/browser/native-motion.mjs
@@ -14,6 +14,7 @@
// MOTION_MODES comma-separated modes, default "balanced,detail,motion"
// MOTION_UPLINKS comma-separated caps in bit/s, 0 for none, default "0,1500000"
// MOTION_ASSERT "0" to measure without asserting
+import { syntheticMedia } from './synthetic-media.mjs';
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
@@ -31,6 +32,7 @@ const browser = await chromium.launch({ headless: true, args: [
'--mute-audio',
] });
const context = await browser.newContext({ permissions: ['camera', 'microphone'] });
+if (process.env.CALLS_SYNTHETIC_MEDIA) await context.addInitScript(syntheticMedia);
await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
const page = await context.newPage();
await page.addInitScript(() => {
diff --git a/test/browser/native-screen.mjs b/test/browser/native-screen.mjs
index c4551d3..6e9a618 100644
--- a/test/browser/native-screen.mjs
+++ b/test/browser/native-screen.mjs
@@ -41,6 +41,7 @@
// browser's ladder has to restore it (or say it cannot)
//
// Run by TestNativeMacScreenRecovery (make native-browser).
+import { syntheticMedia } from './synthetic-media.mjs';
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
@@ -67,6 +68,7 @@ const browser = await chromium.launch({ headless: true, args: [
// blob, which the page's own policy (default-src 'self') would refuse.
// The gate's, never the page's.
const context = await browser.newContext({ permissions: ['camera', 'microphone'], bypassCSP: true });
+if (process.env.CALLS_SYNTHETIC_MEDIA) await context.addInitScript(syntheticMedia);
await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
const page = await context.newPage();
await page.addInitScript(() => {
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index f5a85ca..a10abfd 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -1,3 +1,4 @@
+import { syntheticMedia } from './synthetic-media.mjs';
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
@@ -14,6 +15,7 @@ const browser = await chromium.launch({ headless: true, args: [
'--auto-accept-this-tab-capture',
] });
const context = await browser.newContext({ permissions: ['camera', 'microphone'] });
+if (process.env.CALLS_SYNTHETIC_MEDIA) await context.addInitScript(syntheticMedia);
await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
const page = await context.newPage();
await page.addInitScript(() => {
diff --git a/test/browser/synthetic-media.mjs b/test/browser/synthetic-media.mjs
new file mode 100644
index 0000000..8dc6d5a
--- /dev/null
+++ b/test/browser/synthetic-media.mjs
@@ -0,0 +1,67 @@
+// syntheticMedia is an init script, used only when CALLS_SYNTHETIC_MEDIA
+// is set: for a machine where Chromium's fake devices cannot be used.
+// Chromium asks macOS for camera and microphone access even for them, on
+// behalf of whatever launched it, and when that process has never been
+// granted it (TCC "not determined") nobody can answer and getUserMedia
+// never settles — every scenario then stops at "Connecting to the call."
+// (2026-10-05, from an agent's shell). This draws the camera and the
+// screen on canvases instead (a head and shoulders on a gradient, moving;
+// a 16:10 document) and makes the microphone a quiet tone. Scenarios that
+// assert on Chromium's own fake device names do not hold under it: it is
+// a stand-in, said so in the run, never the gate's normal way.
+export const syntheticMedia = () => {
+ const md = navigator.mediaDevices;
+ if (!md) return;
+ const drawn = (w, h, draw) => {
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const g = c.getContext('2d');
+ let t = 0;
+ draw(g, w, h, 0);
+ setInterval(() => draw(g, w, h, ++t), 50);
+ return c.captureStream(20).getVideoTracks()[0];
+ };
+ const camera = () => drawn(640, 360, (g, w, h, t) => {
+ const sky = g.createLinearGradient(0, 0, w, h);
+ sky.addColorStop(0, '#6d8fb3');
+ sky.addColorStop(1, '#2f3d2a');
+ g.fillStyle = sky;
+ g.fillRect(0, 0, w, h);
+ g.fillStyle = '#c99a78';
+ g.beginPath(); g.ellipse(w / 2, h * 0.36, w * 0.09, h * 0.2, 0, 0, Math.PI * 2); g.fill();
+ g.fillStyle = '#7a3b2e';
+ g.beginPath(); g.ellipse(w / 2, h * 1.02, w * 0.24, h * 0.42, 0, 0, Math.PI * 2); g.fill();
+ g.fillStyle = '#ffffff';
+ g.fillRect((t * 7) % w, h - 8, 40, 4);
+ });
+ const screen = () => drawn(1280, 800, (g, w, h, t) => {
+ g.fillStyle = '#f4f1ea';
+ g.fillRect(0, 0, w, h);
+ g.fillStyle = '#1f6f8b';
+ g.fillRect(0, 0, w, 48);
+ g.fillStyle = '#c0392b';
+ g.fillRect(w - 160, h - 120, 120, 80);
+ g.fillStyle = '#333';
+ for (let y = 90; y < h - 40; y += 28) g.fillRect(60, y, w - 120 - ((y * 37) % 300), 10);
+ g.fillStyle = '#f39c12';
+ g.fillRect((t * 9) % w, 56, 30, 30);
+ });
+ const tone = () => {
+ const ac = new AudioContext();
+ const o = ac.createOscillator();
+ const gain = ac.createGain();
+ gain.gain.value = 0.02;
+ const out = ac.createMediaStreamDestination();
+ o.connect(gain).connect(out);
+ o.start();
+ return out.stream.getAudioTracks()[0];
+ };
+ md.getUserMedia = async (c = {}) => {
+ const tracks = [];
+ if (c.audio) tracks.push(tone());
+ if (c.video) tracks.push(camera());
+ return new MediaStream(tracks);
+ };
+ md.getDisplayMedia = async () => new MediaStream([screen()]);
+};