Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mac/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ tools/upscaleref/up
tools/upscaleref/build-ported.inc
tools/scalecard/scalecard
tools/cursorchurn/dispcap
tools/cursorchurn/curseen
1 change: 1 addition & 0 deletions mac/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ How the macOS zoom behaves and why: `docs/architecture.md`. Keep it in step with
- `python3 scripts/handover_truth.py`: panning with a system alert up on the zoomed screen (Transform, Render drawing
it, `--states zoomalert,handover --tier 250` the hand-over to macOS's zoom); run it after touching `DrawZoom` prompts or the capture.
- `python3 scripts/churn_truth.py [--target ChatGPT]`: the real cursor over an app that sets it on every move (Chromium), measured outside Wind; run it after touching `ClearCursor`.
- `python3 scripts/glimpse_truth.py`: the real cursor ON SCREEN at hover changes (frames counted with `tools/cursorchurn/curseen`); run it after touching `ClearCursor`.
- `python3 scripts/hitch_truth.py`: view stalls as apps open, quit and alerts show, from Wind's `slow tick` lines (`Stall`); run it after touching anything on the main thread's tick path.
- `python3 scripts/backdrop_truth.py`: the Backdrop engine (no capture); `WIND_ENGINE=backdrop` runs a suite on it. ScreenCaptureKit cannot see it (`tools/cursorchurn/dispcap` can).
- `python3 scripts/edge_truth.py`: the pinned view at screen edges on the zoomed screen (edge shake).
Expand Down
88 changes: 87 additions & 1 deletion mac/Sources/Wind/ClearCursor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ final class ClearCursor: @unchecked Sendable {
private var repeatHidden = false // the poll thread's own hide
private var lastForeign = 0.0
private static let repeatWithin = 1.0, busyHold = 0.5
/// The poll's sleep in microseconds: normally, and for 2 s after an app set its cursor. 250 both: an app's
/// hover change showed the real cursor on screen in about 1 of 35 changes at 2000/500 with the hide first,
/// and 0.3 at 250 (scripts/glimpse_truth.py), for no measurable CPU (Wind 3.8% against 3.9% at 3x).
static let pollUs: (UInt32, UInt32) = {
#if WIND_TEST_CHANNEL
if let v = ProcessInfo.processInfo.environment["WIND_CLEAR_POLL_US"]?.split(separator: ",").compactMap({ UInt32($0) }), v.count == 2 { return (v[0], v[1]) }
#endif
return (250, 250)
}()
private var shapeGen = 0
private var ours: UInt32 = 0 // the seed of Wind's own transparent set
private var beat = 0.0 // main thread alive (a timer while active)
Expand Down Expand Up @@ -123,7 +132,9 @@ final class ClearCursor: @unchecked Sendable {
wsLock.lock() // an in-flight set of the poll thread finishes first
(app ?? NSCursor.arrow).set() // while Wind still owns the cursor (over the Dock)
if repeatHidden { CGDisplayShowCursor(CGMainDisplayID()); repeatHidden = false }
if motionHidden { CGDisplayShowCursor(CGMainDisplayID()); motionHidden = false }
wsLock.unlock()
lock.lock(); moving = false; lock.unlock()
SkyLight.updateOwnsCursor() // then give it back, unless Wind still hides it
}

Expand Down Expand Up @@ -214,14 +225,84 @@ final class ClearCursor: @unchecked Sendable {
lock.lock(); repeatAt = 0; lock.unlock()
guard let at = CGEvent(source: nil)?.location,
let e = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: at, mouseButton: .left) else { return }
e.setIntegerValueField(.eventSourceUserData, value: ClearCursor.recheckMark) // not a hand move (pointerMoved)
e.post(tap: .cghidEventTap)
}

// Hidden while the pointer moves, transparent once it rests (owner 2026-11-06). Apps set their cursor on
// mouse moves (hover changes), and even with the hide first and a look every 0.25 ms about 1 in 20 such sets
// reached the screen: the tiny cursor on a frame (a browser's file label: 88 sets, 5 seen in a minute). Hidden
// by the hide count no set shows. SwiftUI ignores hover while hidden (Notification Center's banners), so at
// rest the transparent cursor comes back and the hover is looked at again: SwiftUI hover comes about
// `restDelay` late, AppKit and web hover are unchanged. Event driven: the tap's mouse moves start it, a timer
// ends it; no polling. The hide is sent from the tap before the event reaches the app, on its own queue.
static let recheckMark: Int64 = 0x57494E44 // "WIND": Wind's own zero-distance move
// 60 ms (owner 2026-11-06, was 150): 60 and 40 both 0 frames with hand-like pauses (glimpse_truth --pauses).
static let restDelay: Double = {
#if WIND_TEST_CHANNEL
if let v = ProcessInfo.processInfo.environment["WIND_REST_MS"].flatMap(Double.init) { return v / 1000 }
#endif
return 0.06
}()
private var motionHidden = false // under wsLock
private var moving = false // under lock (short holds only)
private let motionQueue = DispatchQueue(label: "wind.clearcursor.motion", qos: .userInteractive)
private lazy var restTimer: DispatchSourceTimer = {
let t = DispatchSource.makeTimerSource(queue: motionQueue)
t.setEventHandler { [weak self] in self?.rested() }
t.resume()
return t
}()

/// Main thread (the event tap), on every mouse move or drag: cheap when already moving.
func pointerMoved() {
guard isActive else { return }
restTimer.schedule(deadline: .now() + ClearCursor.restDelay, leeway: .milliseconds(5))
lock.lock(); let starting = !moving; moving = true; let s = session; lock.unlock()
if !starting { return }
motionQueue.async { [weak self] in
guard let self else { return }
self.wsLock.lock(); defer { self.wsLock.unlock() }
self.lock.lock(); let ok = self.active && self.session == s; self.lock.unlock()
if ok && !self.motionHidden { CGDisplayHideCursor(CGMainDisplayID()); self.motionHidden = true }
}
}

private func rested() {
// Cleared before the show: a move from now on queues its hide behind this on the same queue.
lock.lock(); moving = false; lock.unlock()
wsLock.lock()
let was = motionHidden
if motionHidden { CGDisplayShowCursor(CGMainDisplayID()); motionHidden = false }
wsLock.unlock()
if was && isActive { recheckHover() }
}

/// A hide around the read of an app's cursor. Its own count, balanced by quickShow whatever stop() did
/// meanwhile (the hide count is per process).
private func quickHide(session s: Int) -> Bool {
wsLock.lock(); defer { wsLock.unlock() }
lock.lock(); let current = active && session == s; lock.unlock()
guard current else { return false }
CGDisplayHideCursor(CGMainDisplayID())
return true
}
private func quickShow() {
wsLock.lock(); CGDisplayShowCursor(CGMainDisplayID()); wsLock.unlock()
}
static let hideFirst: Bool = {
#if WIND_TEST_CHANNEL
if ProcessInfo.processInfo.environment["WIND_HIDE_FIRST"] == "0" { return false }
#endif
return true
}()

private func poll(session s: Int) {
lock.lock(); var last = ours; lock.unlock() // 0 (start's set raced with an app's): look at once
var quick = false, hidden = false
let (slow, fast) = ClearCursor.pollUs
while true {
usleep(quick ? 500 : 2000) // faster while an app is setting its cursor
usleep(quick ? fast : slow) // faster while an app is setting its cursor
lock.lock()
guard active, session == s else { lock.unlock(); return }
let now = CACurrentMediaTime()
Expand All @@ -247,6 +328,11 @@ final class ClearCursor: @unchecked Sendable {
if seed == mine && !wasStalled { continue }
// Someone else set the cursor (or Wind's own set raced with it): keep theirs as the shape.
lock.lock(); foreignCount += 1; lastForeign = now; lock.unlock()
// Hidden first, then read: reading the app's cursor (its image, a WindowServer round trip) took
// longer than the wait between looks, and the app's cursor showed meanwhile, on 1-2 frames at a
// hover change (owner 2026-11-05, a file label in a browser; faster looks changed nothing).
let flash = ClearCursor.hideFirst && !hidden && !wasStalled && quickHide(session: s)
defer { if flash { quickShow() } }
if let c = NSCursor.currentSystem, !isClear(c) {
guard setClear(session: s) else { return } // first: the app's cursor is on screen until then
let key = c.image.tiffRepresentation // pixels: same size and hotspot can be another cursor
Expand Down
1 change: 1 addition & 0 deletions mac/Sources/Wind/Magnifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ final class Magnifier {
return pass

case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
if e.getIntegerValueField(.eventSourceUserData) != ClearCursor.recheckMark { ClearCursor.shared.pointerMoved() }
let dx = Double(e.getIntegerValueField(.mouseEventDeltaX))
let dy = Double(e.getIntegerValueField(.mouseEventDeltaY))
let loc = e.location
Expand Down
2 changes: 1 addition & 1 deletion mac/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.1
0.5.2
12 changes: 12 additions & 0 deletions mac/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,18 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02
sampler at 3x: `tools/cursorchurn` 3.92% -> 0.05-0.08%, the real ChatGPT window 0.98% -> 0.08% (the first
two sets of a burst still show). SwiftUI hover is off only meanwhile; a panel above
`cursorchurn` kept it (checked with the test app only, not with a real Chromium window under it).
- A single cursor change (a hover effect: arrow to pointing hand) still showed the real cursor on screen in 1 or 2 frames on
about half the hovers (owner 2026-11-05, a file label in a browser). The look's own read of the app's cursor (its image, a
WindowServer round trip) took longer than the wait between looks. The poll now hides the real cursor first, reads, sets the
transparent one and shows it again (`quickHide`), and looks every 0.25 ms. `scripts/glimpse_truth.py` counts frames with the
real cursor ON SCREEN (CGDisplayStream with the cursor, Wind's drawn one a magenta probe) over `cursorchurn grey hover`:
about 35 changes per run, 7 frames before (3-12), 3 with the hide first, 0.3 with both. Wind's CPU at 3x: 3.8% -> 3.9%.
- Still not zero: in the owner's browser 88 sets a minute brought 5 flashes they saw (each set exposed 0.2 ms median, against a
6.9 ms refresh). So the real cursor is now HIDDEN while the pointer moves and transparent only at rest (owner 2026-11-06):
the tap's first mouse move hides it (on `motionQueue`, sent before the app gets the event), and 0.06 s without moves shows it
transparent again and posts the zero-distance move (`recheckHover`, marked so the tap ignores it). Apps set their cursor on
moves, so no set meets a shown cursor. SwiftUI hover comes about 0.06 s after the pointer stops (0.15 s until 2026-11-06; 60 and 40 ms both showed 0 frames in `glimpse_truth.py --pauses`, 60 keeps a margin for slow trackpad moves whose events come further apart); AppKit and web hover are
unchanged. Event driven, no polling. `glimpse_truth.py`: 0 frames in 4 runs on both engines; `churn_truth.py` 0.00%.
- Safe by construction: WindowServer drops a dead connection's cursor (a killed Wind leaves the app's
cursor showing, measured), and if Wind's main thread stalls 1.5 s the thread puts the app's cursor back.
- Not usable instead: cursor scale below 1 (draws at 1x), `SLSShieldCursor` (empty stub), cursor
Expand Down
4 changes: 4 additions & 0 deletions mac/scripts/backdrop_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def ws_cpu(secs=4):
v = [l.split() for l in out.splitlines() if "WindowServer" in l]
return float(v[-1][1]) if v else -1

# macOS keeps the dot up a few seconds after any recording ends (a suite run just before this one).
for _ in range(30):
if indicators() == 0: break
time.sleep(1)
W.relaunch_with_channel(direct=True, extra={"engine": "backdrop", "cursor": "wind", "renderScaling": 1})
churn = subprocess.Popen([T + "/cursorchurn", "60"], stdout=subprocess.PIPE, text=True)
x, y, w, h = map(int, churn.stdout.readline().split()); time.sleep(0.8)
Expand Down
9 changes: 5 additions & 4 deletions mac/scripts/churn_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ap = argparse.ArgumentParser()
ap.add_argument("--target", default="churn")
ap.add_argument("--engine", default="draw")
ap.add_argument("--mode", default="", help="cursorchurn mode: hover (a shape change per stripe, set once)")
ap.add_argument("--secs", type=float, default=5)
ap.add_argument("--report-ms", type=int, default=1)
ap.add_argument("--limit", type=float, default=0.2)
Expand Down Expand Up @@ -52,7 +53,7 @@ def vmc(cmd): vm.stdin.write(cmd + "\n"); vm.stdin.flush(); vm.stdout.readline()
if a.target == "churn":
tool = os.path.join(W.ROOT, "tools/cursorchurn/cursorchurn")
subprocess.run([os.path.join(W.ROOT, "tools/cursorchurn/build.sh")], check=True, stdout=subprocess.DEVNULL)
churn = subprocess.Popen([tool, str(a.secs + 12)], stdout=subprocess.PIPE, text=True)
churn = subprocess.Popen([tool, str(a.secs + 12)] + ([a.mode] if a.mode else []), stdout=subprocess.PIPE, text=True)
x, y, w, h = map(int, churn.stdout.readline().split())
cx, cy = x + w // 2, y + h // 2
else:
Expand Down Expand Up @@ -113,11 +114,11 @@ def sample(label):
events.clear()
for k in range(30): vmc(f"move {2 if k % 2 else -2} 0 20 {a.report_ms}")
vmc(f"move 0 12 10 {a.report_ms}") # one quick move onto the panel, about 82 pt, then the hand stops
stopped = time.time(); time.sleep(2.5)
print("stopped at", W.ctl("state")["pointer"], "panel top", py)
stopped = time.time(); at_stop = W.ctl("state")["pointer"]; time.sleep(2.5)
print("stopped at", at_stop, "panel top", py)
got = [t - stopped for t, l in events if l == "SUI hover true"]
W.check("render: hover comes after arriving and stopping, with no further move", bool(got), f"{got[0]:+.2f} s from stopping" if got else "none in 2.5 s")
s = W.ctl("state"); W.check("render: the pointer stayed put", abs(s["pointer"][1] - (py + 22.5)) < 3, f"pointer {s['pointer']}")
s = W.ctl("state"); W.check("render: the pointer stayed put", abs(s["pointer"][0] - at_stop[0]) + abs(s["pointer"][1] - at_stop[1]) < 0.5, f"pointer {at_stop} -> {s['pointer']}")
pp.kill(); W.ctl("level 1")
if churn: churn.kill()
vm.stdin.close()
Expand Down
3 changes: 2 additions & 1 deletion mac/scripts/e2e_settings_mouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ def key(k): ctl(f"post key {k} down"); ctl(f"post key {k} up"); time.sleep(0.25)
vmc(f"move {4 if (k // 25) % 2 == 0 else -4} 1 5 8"); k += 1
n, shown, real = map(int, sp.stdout.read().split())
s = ctl("state"); own1 = (s.get("clearCursorCounts", []) + [0, 0, 0])[:3]
check("render: no real cursor over the zoomed Settings window", n > 400 and shown > n * 0.9 and real <= 2 and s["drawn"],
# Hidden while the pointer moves, transparent at rest (ClearCursor, 2026-11-06): the shown share no longer says anything.
check("render: no real cursor over the zoomed Settings window", n > 400 and real <= 2 and s["drawn"],
f"real cursor in {real}/{n} samples (shown transparent {shown - real}), drawn {s['drawn']}")
check("render: the page's cursor sets are taken by Wind", own1[2] > own0[2], f"own sets {own0[2]} -> {own1[2]}")
ctl("level 1"); time.sleep(0.5)
Expand Down
42 changes: 42 additions & 0 deletions mac/scripts/glimpse_truth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Max Aubert. All rights reserved.
# Proprietary source-available software; see LICENSE. SPDX-License-Identifier: LicenseRef-Wind-Proprietary
"""The tiny real cursor ON SCREEN at hover changes (owner 2026-11-05: a file label in a browser, the real cursor
for 1-2 frames on about half the hovers). tools/cursorchurn `grey hover` is a plain grey window that sets the
pointing hand and the arrow in turn as the pointer crosses its stripes, once per change, like a hover effect.
tools/cursorchurn/curseen captures every frame WITH the cursor and finds the real one at the pointer's desktop
spot; Wind's drawn cursor is a magenta probe (WIND_CURSOR_PROBE) it skips. About 35 changes per sweep at 3x.
PASS: at most --limit frames with the real cursor per engine, and none with no changes at all.
usage: glimpse_truth.py [--engines backdrop,draw] [--limit 2]"""
import argparse, os, random, subprocess, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import windtest as W

ap = argparse.ArgumentParser()
ap.add_argument("--engines", default="backdrop,draw")
ap.add_argument("--limit", type=int, default=2)
ap.add_argument("--pauses", action="store_true", help="stop for 20-200 ms between strokes, like a hand (rest and resume)")
a = ap.parse_args()
T = os.path.join(W.ROOT, "tools/cursorchurn")
subprocess.run([T + "/build.sh"], check=True, stdout=subprocess.DEVNULL)
os.environ["WIND_CURSOR_PROBE"] = "1"
vm = None
for eng in a.engines.split(","):
W.relaunch_with_channel(direct=True, extra={"engine": eng, "cursor": "wind", "renderScaling": 1})
if vm is None:
vm = subprocess.Popen(["sudo", os.path.join(W.ROOT, "tools/vmouse/vmouse")], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
if vm.stdout.readline().strip() != "ready": sys.exit("vmouse not ready")
for mode in ("none", "hover"):
ch = subprocess.Popen([T + "/cursorchurn", "25", "grey"] + ([mode] if mode == "hover" else []), stdout=subprocess.PIPE, text=True)
x, y, w, h = map(int, ch.stdout.readline().split()); time.sleep(0.8)
W.ctl("level 1"); W.ctl(f"warp {x + 300} {y + h // 2}"); time.sleep(0.4); W.ctl("level 3"); time.sleep(1.5)
cs = subprocess.Popen([T + "/curseen", "5"], stdout=subprocess.PIPE, text=True); cs.stdout.readline()
t = time.time(); k = 0
while time.time() - t < 4.8:
vm.stdin.write(f"move {3 if (k // 8) % 2 == 0 else -3} 0 40 1\n"); vm.stdin.flush(); vm.stdout.readline(); k += 1
if a.pauses and k % 8 == 0: time.sleep(random.uniform(0.02, 0.2))
out = cs.stdout.read().split(); frames, seen = int(out[1]), int(out[3])
lim = 0 if mode == "none" else a.limit
W.check(f"{eng}: real cursor frames, {'no cursor changes' if mode == 'none' else 'hover changes'}", frames > 250 and seen <= lim, f"{seen} of {frames} frames")
W.ctl("level 1"); time.sleep(0.4); ch.kill(); time.sleep(0.3)
W.finish()
1 change: 1 addition & 0 deletions mac/tools/cursorchurn/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ cd "${0:A:h}"
swiftc -O cursorchurn.swift -o cursorchurn
echo "built tools/cursorchurn/cursorchurn"
clang -fobjc-arc -O1 -framework Cocoa -framework IOSurface dispcap.m -o dispcap
clang -fobjc-arc -O1 -framework Cocoa -framework IOSurface curseen.m -o curseen
Loading
Loading