diff --git a/SCORE.md b/SCORE.md index 566de26ba2..272d0f18fd 100644 --- a/SCORE.md +++ b/SCORE.md @@ -122,6 +122,12 @@ This is critical because `lib/pmove.mjs` is shared physics: client (lith) and se - [ ] KidLisp GPU compositing: render effects on GPU buffer, recompose with CPU renderer **Host Tooling (slab/)** — @jeffrey's macOS host, not a deployed service +- **Unipointer** (`slab/deskflow-handoff/UNIPOINTER.md`) — canonical identifier + for the Fuser seat's one logical pointer. Neo and Blueberry can exchange the + physical Deskflow controller role without changing the unipointer's active + machine or display-local position. Code and agents should use the literal + identifier `unipointer` and state-record kind `unipointer-state`; every fleet + host exposes `~/.local/bin/unipointer` for versioned JSON state. - `slab/menubar-swift/` — native Swift menubar daemon (launchd `computer.slab.menubar`). Shows live Claude-session status, themes each Terminal.app/iTerm2 window by session state (working/awaiting/complete), tiles all windows into one grid, tints the desktop, and serves passphrases over a unix socket. Built + installed locally with `slab/menubar-swift/install.sh` (`swift build -c release` → universal arm64+x86_64 binary → app bundle + launchd agent); there is no remote deploy. - **Tiling auto-fits the type:** `tileNow` sizes each Terminal window's font to the grid (Far/Near/Tiny modes) and drives `View ▸ Default Font Size` per window so a live window actually adopts it — a per-window zoom otherwise silently overrides the profile font and is invisible to AppleScript. Floors keep it legible (Far 10 / Near 9 / Tiny 8). iTerm2 has no AppleScript font property, so it tiles by bounds only. - **Prompt rocks** (`slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift`) — the tumbling little stones parked at the top-right of each terminal window, one per live Claude session. Each rock is a 3D sigil rendered from the session's `sessionId + prompt` seed (so it re-forms when the session moves to a new prompt), lit by a shared global sun that tracks local time of day, wearing a pet name in bubble lettering. Its *motion* is the status channel — spin speed and direction encode working/awaiting/complete; being read by a peer makes it blink and rattle. Hovering one reveals a bubble summarizing the prompt (a cached one-line `claude -p haiku` inference). They're borderless click-through `.floating` windows, so hit-testing has to check occlusion by hand: a rock only answers the pointer while it's on screen *and* its terminal is still the topmost normal window under the cursor. diff --git a/macpal/Sources/DeskflowHandoff.swift b/macpal/Sources/DeskflowHandoff.swift index 19678b39b8..cae3e7fbd1 100644 --- a/macpal/Sources/DeskflowHandoff.swift +++ b/macpal/Sources/DeskflowHandoff.swift @@ -5,6 +5,21 @@ import AppKit // observer and role changes remain testable from the command line. #if !MAC_APP_STORE final class DeskflowHandoff { + private static let unipointerIdentifier = "unipointer" + private static let unipointerStateKind = "unipointer-state" + + private struct UnipointerState { + let screen: String + let normalizedX: CGFloat + let normalizedY: CGFloat + let x: CGFloat + let y: CGFloat + let frameX: CGFloat + let frameY: CGFloat + let frameWidth: CGFloat + let frameHeight: CGFloat + } + var onControlAcquired: (() -> Void)? private let claimPath = NSString(string: "~/.local/bin/deskflow-claim-control").expandingTildeInPath @@ -50,12 +65,26 @@ final class DeskflowHandoff { self.claimInFlight = false if process.terminationStatus == 0, self.currentRole() == "server" { NSLog("MacPal Deskflow handoff: %@", message) - self.onControlAcquired?() - if let start = self.gestureStart, let latest = self.gestureLatest { - let dx = latest.x - start.x - let dy = latest.y - start.y - DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in - self?.replaySideBump(dx: dx, dy: dy) + let preservedScreen = self.preservedScreen(in: message) + let unipointer = self.unipointerState(in: message) + let routeDelay = self.routeToPreservedScreen(preservedScreen) + let gesture = self.gestureStart.flatMap { start in + self.gestureLatest.map { latest in + (dx: latest.x - start.x, dy: latest.y - start.y) + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + routeDelay + 0.02) { [weak self] in + guard let self else { return } + self.restoreUnipointer(unipointer) { [weak self] in + guard let self else { return } + if let gesture { + self.replayGesture(dx: gesture.dx, dy: gesture.dy) + } + // Position and motion are the critical path; the + // glow/sound may lazily load afterward. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in + self?.onControlAcquired?() + } } } } else { @@ -75,21 +104,181 @@ final class DeskflowHandoff { gestureLatest = point } - /// Preserve the horizontal intent made while the Deskflow cores were - /// changing roles. Posting one HID mouse-move with the original direction - /// lets Deskflow see the outward edge delta that would otherwise vanish. - private func replaySideBump(dx: CGFloat, dy: CGFloat) { - guard abs(dx) >= 0.018, abs(dx) > abs(dy) * 1.15, + private func preservedScreen(in output: String) -> String? { + output.split(separator: "\n").reversed().compactMap { line in + let prefix = "active-screen " + guard line.hasPrefix(prefix) else { return nil } + return String(line.dropFirst(prefix.count)) + }.first + } + + private func localScreenName() -> String? { + let path = NSString(string: "~/.config/slab/deskflow-handoff.json").expandingTildeInPath + guard let data = FileManager.default.contents(atPath: path), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return object["screenName"] as? String + } + + private func handoffConfig() -> [String: Any]? { + let path = NSString(string: "~/.config/slab/deskflow-handoff.json").expandingTildeInPath + guard let data = FileManager.default.contents(atPath: path) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private func unipointerState(in output: String) -> UnipointerState? { + let prefix = "unipointer-state " + for line in output.split(separator: "\n").reversed() where line.hasPrefix(prefix) { + let rest = line.dropFirst(prefix.count) + guard let split = rest.firstIndex(of: " ") else { continue } + let screen = String(rest[.. String? { + guard screen != localScreenName(), + let clients = handoffConfig()?["clients"] as? [String] else { return nil } + let token = screen.replacingOccurrences(of: ".local", with: "").lowercased() + return clients.first { $0.lowercased().contains(token) } + } + + private func readUnipointer(on screen: String, completion: @escaping (UnipointerState?) -> Void) { + let process = Process() + let output = Pipe() + if let peer = peer(for: screen) { + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = [ + "-o", "BatchMode=yes", "-o", "ConnectTimeout=2", + "-o", "ControlMaster=auto", "-o", "ControlPersist=600", + "-o", "ControlPath=\(NSHomeDirectory())/.ssh/deskflow-%C", + peer, "~/.local/bin/unipointer", + ] + } else { + process.executableURL = URL(fileURLWithPath: NSHomeDirectory() + "/.local/bin/unipointer") + } + process.standardOutput = output + process.standardError = FileHandle.nullDevice + DispatchQueue.global(qos: .userInteractive).async { + do { + try process.run() + process.waitUntilExit() + let data = output.fileHandleForReading.readDataToEndOfFile() + let json = String(data: data, encoding: .utf8) ?? "" + let state = self.unipointerState(in: "unipointer-state \(screen) \(json)") + DispatchQueue.main.async { completion(state) } + } catch { + DispatchQueue.main.async { completion(nil) } + } + } + } + + private func restoreUnipointer(_ saved: UnipointerState?, completion: @escaping () -> Void) { + guard let saved else { completion(); return } + readUnipointer(on: saved.screen) { [weak self] current in + guard let self, let current else { completion(); return } + let desiredX = current.frameX + saved.normalizedX * current.frameWidth + let desiredY = current.frameY + saved.normalizedY * current.frameHeight + let deltaX = desiredX - current.x + let deltaY = -(desiredY - current.y) // CGEvent Y grows downward; AppKit grows upward. + self.postMotion(dx: deltaX, dy: deltaY) + NSLog("MacPal unipointer: restored %@ to %.3f,%.3f (delta %.1f,%.1f)", + saved.screen, saved.normalizedX, saved.normalizedY, deltaX, deltaY) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.015, execute: completion) + } + } + + /// Re-enter the old active destination by crossing the same physical edges + /// the hand would use. This avoids a synthetic-key translation layer and + /// keeps the handoff behavior identical to ordinary Deskflow mousing. + private func routeToPreservedScreen(_ screen: String?) -> TimeInterval { + guard let local = localScreenName(), let screen, screen != local else { return 0 } + let steps: [CGVector] + switch (local, screen) { + case ("neo", "chicken.local"): steps = [CGVector(dx: 0, dy: -4096)] + case ("neo", "panda.local"): + steps = [CGVector(dx: 0, dy: -4096), CGVector(dx: 4096, dy: 0)] + case ("neo", "blueberry.local"): steps = [CGVector(dx: 4096, dy: 0)] + case ("blueberry.local", "panda.local"): steps = [CGVector(dx: 0, dy: -4096)] + case ("blueberry.local", "chicken.local"): + steps = [CGVector(dx: 0, dy: -4096), CGVector(dx: -4096, dy: 0)] + case ("blueberry.local", "neo"): steps = [CGVector(dx: -4096, dy: 0)] + default: return 0 + } + for (index, step) in steps.enumerated() { + let post: () -> Void = { [weak self] in + self?.postEdge(step) + } + if index == 0 { post() } + else { + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(index) * 0.035, execute: post + ) + } + } + NSLog("MacPal Deskflow handoff: routing %@ -> %@ in %d edge(s)", + local, screen, steps.count) + return Double(steps.count) * 0.035 + } + + private func postEdge(_ delta: CGVector) { + postMotion(dx: delta.dx, dy: delta.dy) + } + + private func postMotion(dx: CGFloat, dy: CGFloat) { + guard let position = CGEvent(source: nil)?.location, + let event = CGEvent(mouseEventSource: nil, + mouseType: .mouseMoved, + mouseCursorPosition: CGPoint(x: position.x + dx, + y: position.y + dy), + mouseButton: .left) else { return } + event.setIntegerValueField(.mouseEventDeltaX, value: Int64(dx.rounded())) + event.setIntegerValueField(.mouseEventDeltaY, value: Int64(dy.rounded())) + event.post(tap: .cghidEventTap) + } + + /// Preserve the physical gesture made while the Deskflow cores were + /// changing roles. Posting one HID move after restoring the active screen + /// makes the first wiggle land on the same remote machine. + private func replayGesture(dx: CGFloat, dy: CGFloat) { + guard max(abs(dx), abs(dy)) >= 0.006, let position = CGEvent(source: nil)?.location, let event = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: position, mouseButton: .left) else { return } - let delta: Int64 = dx < 0 ? -48 : 48 - event.setIntegerValueField(.mouseEventDeltaX, value: delta) - event.setIntegerValueField(.mouseEventDeltaY, value: 0) + func scaled(_ value: CGFloat) -> Int64 { + let raw = Int64((value * 1600).rounded()) + return max(-96, min(96, raw)) + } + let deltaX = scaled(dx) + let deltaY = scaled(-dy) + event.setIntegerValueField(.mouseEventDeltaX, value: deltaX) + event.setIntegerValueField(.mouseEventDeltaY, value: deltaY) event.post(tap: .cghidEventTap) - NSLog("MacPal Deskflow handoff: replayed side bump dx=%lld", delta) + NSLog("MacPal Deskflow handoff: replayed gesture dx=%lld dy=%lld", deltaX, deltaY) } } #endif diff --git a/slab/deskflow-handoff/README.md b/slab/deskflow-handoff/README.md index 4d03dc1b30..9a17bfdd89 100644 --- a/slab/deskflow-handoff/README.md +++ b/slab/deskflow-handoff/README.md @@ -9,6 +9,11 @@ macOS's private `MultitouchSupport` framework. Remote Deskflow motion does not produce those contact frames, so moving the shared pointer cannot accidentally claim control. +The shared logical pointer is canonically named `unipointer`; its wire record is +`unipointer-state`. See `UNIPOINTER.md`. The name is intentionally independent +of Neo, Blueberry, and Deskflow so other seat software can adopt the same +continuity contract. + Installed components: - `~/.local/bin/deskflow-role-runner` — launchd entry point; starts the core in @@ -19,29 +24,50 @@ Installed components: three machines. - `~/.local/bin/deskflow-role-watchdog` — health check that follows the current role instead of assuming a permanent server or client. +- `~/.local/bin/deskflow-seat-ready` — wakes the local panel and repairs an + unhealthy core; `--fleet` reconciles and wakes the full controller/client + topology. An Aqua LaunchAgent runs the local mode after every login. On + macOS it also brings `awdl0` down to prevent Apple Wireless Direct Link from + time-slicing the Wi-Fi radio and making the remote cursor stutter. Install + `deskflow-awdl.sudoers` at `/etc/sudoers.d/deskflow-awdl` (mode `0440`) so + that workaround is non-interactive. AirDrop, Sidecar, and other AWDL features + remain unavailable until AWDL is restored or the Mac restarts. The installer also provisions both eligible servers' TLS fingerprints on every client and all three client fingerprints on each controller. A TCP socket alone is not considered a successful handoff; Deskflow must complete its mutual trust check. -Controller-to-controller commands use pinned Tailscale addresses. That peer is -switched synchronously; Chicken and Panda retarget in detached jobs so their SSH -latency never delays the local confirmation sound and flash. +Deskflow transport uses each machine's stable Tailscale address. On the Fuser +Wi-Fi this keeps Chicken and Panda pointer latency far steadier than the direct +access-point route. Control and wake SSH still use `.local` hostnames on the +shared seat LAN, and Neo's existing `computer.aesthetic.deskflow-tailscale-ensure` +agent heals a stopped tailnet before it can strand the clients. The peer +controller is switched synchronously; Chicken and Panda retarget in detached +jobs so their SSH latency never delays the local confirmation sound and flash. The new server and old controller restart concurrently, with 50 ms readiness polling and a persistent SSH control socket. This avoids serially paying two core-start and SSH handshakes on every touch. -MacPal records the horizontal trackpad motion made during the role change. Once -the new server is live it replays one small HID edge delta in the original -direction, so the first side bump is not swallowed. Client starts also pulse -macOS user activity briefly; this wakes headless displays before Deskflow asks -for their screen shape. +MacPal records the full trackpad gesture made during the role change. Once the +new server has restored the active destination it replays that two-axis HID +motion, so the first wiggle is not swallowed. Client starts also pulse macOS +user activity briefly; this wakes headless displays before Deskflow asks for +their screen shape. + +The yielding controller also reports its active screen inside the same SSH call +that changes its role. The new controller waits only for that one client to +reconnect, traverses the shortest path through the same 2×2 screen geometry, +restores the cursor's captured client-local coordinates, and replays the full +trackpad gesture. A handoff therefore preserves both destination and position: +touching Blueberry while Neo is driving Chicken continues driving Chicken from +the same point. Every claim carries a nanosecond generation. Role setters reject older -generations, so delayed background work from a previous touch cannot override a -newer touch on the other controller. +generations, and each host serializes the generation check through core restart, +so simultaneous trackpad touches cannot complete out of order or leave both +controllers in server mode. `install.sh` installs one machine. `deploy.fish` installs the four-machine Neo/Blueberry/Chicken/Panda topology from Neo. diff --git a/slab/deskflow-handoff/UNIPOINTER.md b/slab/deskflow-handoff/UNIPOINTER.md new file mode 100644 index 0000000000..14225f0ce6 --- /dev/null +++ b/slab/deskflow-handoff/UNIPOINTER.md @@ -0,0 +1,45 @@ +# Unipointer + +`unipointer` is the canonical identifier for the single logical pointer shared across the +Fuser seat. Neo and Blueberry may exchange the physical controller role, but +the unipointer keeps its active machine, display-local position, and physical +gesture continuity. + +The associated state-record kind is `unipointer-state`. These two literal, +lowercase identifiers are the stable discovery surface for application code, +host automation, and LLM agents. Do not invent a machine-specific name for the +pointer: there is one unipointer even when its physical controller changes. + +## CLI contract + +Every installed fleet host exposes: + +```sh +~/.local/bin/unipointer +``` + +It prints one compact JSON object. Version 1 contains global AppKit coordinates, +the containing display frame, and display-normalized coordinates: + +```json +{"frame":{"height":900,"width":1440,"x":0,"y":0},"identifier":"unipointer","kind":"unipointer-state","normalized":{"x":0.5,"y":0.5},"version":1,"x":720,"y":450} +``` + +`unipointer --identifier` prints only `unipointer` for lightweight capability +discovery. `unipointer state` is an explicit alias for the default JSON query. + +Deskflow controller handoff transports this as an `unipointer-state` record: + +```text +unipointer-state chicken.local {"version":1,...} +``` + +Consumers should require `identifier == "unipointer"`, select records with +`kind == "unipointer-state"`, require a supported `version`, and use +`normalized` values when source and destination display geometries differ. + +## Invariant + +Changing physical trackpads may change the Deskflow server. It must not change +the unipointer's active screen or apparent display-local position. Motion made +during the controller swap is applied after that state is restored. diff --git a/slab/deskflow-handoff/deploy.fish b/slab/deskflow-handoff/deploy.fish index c116795589..d7f7202df6 100755 --- a/slab/deskflow-handoff/deploy.fish +++ b/slab/deskflow-handoff/deploy.fish @@ -5,24 +5,29 @@ set neo_fp 76208504df49a431b6183cadb6478ba8bc43b1f14d7f12e4fa4772d48404834c set blueberry_fp 0f3ad9948c903ef4881a3e7092ea511572b4d57bbe12abac2993bab7252d0ce1 set chicken_fp 381baab53079460f450bddf13243742c6694882367cad52f2410d5f92067918a set panda_fp 268c2fff5eb113a66d7b13b51d858630b034b04f547579b41ec9b111882a5803 +set blueberry_ssh jas@blueberry.local +set chicken_ssh fusermacminichicken@chicken.local +set panda_ssh fusermacminipanda@panda.local -for host in neo blueberry chicken panda +for host in $blueberry_ssh $chicken_ssh $panda_ssh ssh $host "rm -rf $stage; mkdir -p $stage" or exit 1 - scp -q $here/deskflow-* $here/install.sh $host:$stage/ + scp -q $here/deskflow-* $here/unipointer.swift $here/install.sh $host:$stage/ or exit 1 end -ssh neo "bash $stage/install.sh --defer-start --machine neo --screen-name neo --address 100.108.5.81 --controller --clients jas@100.79.75.53,chicken,panda --role server --server-host 100.108.5.81 --trusted-servers $blueberry_fp --trusted-clients $blueberry_fp,$chicken_fp,$panda_fp" +bash $here/install.sh --defer-start --machine neo --screen-name neo --address 100.108.5.81 --controller --clients $blueberry_ssh,$chicken_ssh,$panda_ssh --role server --server-host 100.108.5.81 --trusted-servers $blueberry_fp --trusted-clients $blueberry_fp,$chicken_fp,$panda_fp or exit 1 -ssh blueberry "bash $stage/install.sh --defer-start --machine blueberry --screen-name blueberry.local --address 100.79.75.53 --controller --clients jas@100.108.5.81,chicken,panda --role client --server-host 100.108.5.81 --trusted-servers $neo_fp --trusted-clients $neo_fp,$chicken_fp,$panda_fp" +ssh $blueberry_ssh "bash $stage/install.sh --defer-start --machine blueberry --screen-name blueberry.local --address 100.79.75.53 --controller --clients jas@neo.local,chicken,panda --role client --server-host 100.108.5.81 --trusted-servers $neo_fp --trusted-clients $neo_fp,$chicken_fp,$panda_fp" or exit 1 -ssh chicken "bash $stage/install.sh --defer-start --machine chicken --screen-name chicken.local --address 100.98.158.126 --role client --server-host 100.108.5.81 --trusted-servers $neo_fp,$blueberry_fp" +ssh $chicken_ssh "bash $stage/install.sh --defer-start --machine chicken --screen-name chicken.local --address 100.98.158.126 --role client --server-host 100.108.5.81 --trusted-servers $neo_fp,$blueberry_fp" or exit 1 -ssh panda "bash $stage/install.sh --defer-start --machine panda --screen-name panda.local --address 100.88.155.94 --role client --server-host 100.108.5.81 --trusted-servers $neo_fp,$blueberry_fp" +ssh $panda_ssh "bash $stage/install.sh --defer-start --machine panda --screen-name panda.local --address 100.88.155.94 --role client --server-host 100.108.5.81 --trusted-servers $neo_fp,$blueberry_fp" or exit 1 -for host in neo blueberry chicken panda +~/.local/bin/deskflow-start +or exit 1 +for host in $blueberry_ssh $chicken_ssh $panda_ssh ssh $host '~/.local/bin/deskflow-start' or exit 1 end diff --git a/slab/deskflow-handoff/deskflow-active-screen b/slab/deskflow-handoff/deskflow-active-screen new file mode 100755 index 0000000000..35ffa21fe1 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-active-screen @@ -0,0 +1,36 @@ +#!/usr/bin/python3 +"""Print the active screen from the current Deskflow server session.""" + +import json +import os +import re + +home = os.path.expanduser("~") +handoff_path = os.path.join(home, ".config/slab/deskflow-handoff.json") +log_path = os.path.join(home, "Library/Logs/deskflow-core.log") + +try: + with open(handoff_path, encoding="utf-8") as source: + local_screen = str(json.load(source).get("screenName", "")) +except Exception: + local_screen = "" + +active = local_screen +try: + with open(log_path, "rb") as source: + source.seek(0, os.SEEK_END) + size = source.tell() + source.seek(max(0, size - 2_000_000)) + text = source.read().decode("utf-8", errors="replace") + + # Ignore switches from older server lifetimes in the shared rolling log. + starts = list(re.finditer(r"(?:IPC|NOTE): started server", text)) + session = text[starts[-1].start():] if starts else text + switches = re.findall(r'switch from "[^"]+" to "([^"]+)"', session) + if switches: + active = switches[-1] +except Exception: + pass + +if re.fullmatch(r"[A-Za-z0-9._-]+", active): + print(active) diff --git a/slab/deskflow-handoff/deskflow-awdl.sudoers b/slab/deskflow-handoff/deskflow-awdl.sudoers new file mode 100644 index 0000000000..f520519074 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-awdl.sudoers @@ -0,0 +1 @@ +%admin ALL=(root) NOPASSWD: /sbin/ifconfig awdl0 down diff --git a/slab/deskflow-handoff/deskflow-claim-control b/slab/deskflow-handoff/deskflow-claim-control index ea200faa80..8cbac22f3d 100755 --- a/slab/deskflow-handoff/deskflow-claim-control +++ b/slab/deskflow-handoff/deskflow-claim-control @@ -25,6 +25,7 @@ except Exception: print(str(config.get("controller", False)).lower()) print(state.get("role", "")) print(config.get("address", "")) +print(config.get("screenName", "")) print(time.time_ns()) for peer in config.get("clients", []): print(peer) @@ -37,11 +38,6 @@ if [[ "$CONTROLLER" != "true" ]]; then fi ROLE=${VALUES[1]:-} -if [[ "$ROLE" == "server" ]]; then - echo "unchanged server" - exit 0 -fi - if ! mkdir "$LOCK" 2>/dev/null; then echo "deskflow claim: already switching" >&2 exit 75 @@ -56,8 +52,9 @@ cleanup() { trap cleanup EXIT ADDRESS=${VALUES[2]:-} -EPOCH=${VALUES[3]:-0} -PEERS=("${VALUES[@]:4}") +SCREEN_NAME=${VALUES[3]:-} +EPOCH=${VALUES[4]:-0} +PEERS=("${VALUES[@]:5}") if [[ ${#PEERS[@]} -lt 1 ]]; then echo "deskflow claim: no peer controller configured" >&2 @@ -73,10 +70,20 @@ SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=4 # retry briefly if it wins the race and reaches the new server before it binds. LOCAL_OUT=$(mktemp /tmp/deskflow-local.XXXXXX) PEER_OUT=$(mktemp /tmp/deskflow-peer.XXXXXX) +CORE_LOG="$HOME/Library/Logs/deskflow-core.log" +# A byte offset lets the reconnect poll seek directly to newly appended log +# data. A line offset made BSD tail rescan the full, long-lived core log on +# every 25 ms poll and could stretch a three-second handoff into minutes. +SERVER_LOG_START=$(stat -f %z "$CORE_LOG" 2>/dev/null || echo 0) "$SET_ROLE" server "" "$EPOCH" > "$LOCAL_OUT" 2>&1 & LOCAL_PID=$! -ssh "${SSH_OPTS[@]}" "${PEERS[0]}" \ - '~/.local/bin/deskflow-set-role client '"$ADDRESS $EPOCH" > "$PEER_OUT" 2>&1 & +if [[ "$ROLE" == "server" ]]; then + ssh "${SSH_OPTS[@]}" "${PEERS[0]}" \ + '~/.local/bin/deskflow-set-role client '"$ADDRESS $EPOCH" > "$PEER_OUT" 2>&1 & +else + ssh "${SSH_OPTS[@]}" "${PEERS[0]}" \ + '~/.local/bin/deskflow-yield-control '"$ADDRESS $EPOCH" > "$PEER_OUT" 2>&1 & +fi PEER_PID=$! LOG="$HOME/Library/Logs/deskflow-handoff.log" @@ -96,4 +103,36 @@ if [[ "$LOCAL_STATUS" != "0" || "$PEER_STATUS" != "0" ]]; then exit 74 fi -echo "changed server; peer controller now uses $ADDRESS" +# Preserve the old server's active destination. The MacPal touch observer reads +# this marker, emits the target's private hotkey on the new server, and replays +# the gesture that happened while the cores were swapping. Wait only for that +# one client; other fleet reconnections remain fully asynchronous. +if [[ "$ROLE" != "server" ]]; then + ACTIVE_SCREEN=$(sed -n 's/^active-screen //p' "$PEER_OUT" | tail -1) + if [[ -n "$ACTIVE_SCREEN" && "$ACTIVE_SCREEN" != "$SCREEN_NAME" ]]; then + /usr/bin/python3 - "$CORE_LOG" "$SERVER_LOG_START" "$ACTIVE_SCREEN" <<'PY' || true +import os, sys, time + +path, offset, screen = sys.argv[1], int(sys.argv[2]), sys.argv[3] +needle = f'client "{screen}" has connected' +deadline = time.monotonic() + 3.0 +buffer = "" +with open(path, errors="replace") as log: + log.seek(offset) + while time.monotonic() < deadline: + chunk = log.read() + if chunk: + buffer = (buffer + chunk)[-8192:] + if needle in buffer: + raise SystemExit(0) + time.sleep(0.025) +raise SystemExit(1) +PY + fi +fi + +if [[ "$ROLE" == "server" ]]; then + echo "reconciled server; peer controller now uses $ADDRESS" +else + echo "changed server; peer controller now uses $ADDRESS" +fi diff --git a/slab/deskflow-handoff/deskflow-seat-ready b/slab/deskflow-handoff/deskflow-seat-ready new file mode 100755 index 0000000000..67162f4fb9 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-seat-ready @@ -0,0 +1,103 @@ +#!/bin/bash +set -euo pipefail + +HANDOFF="$HOME/.config/slab/deskflow-handoff.json" +STATE="$HOME/.config/slab/deskflow.json" +LABEL="computer.aesthetic.deskflow" +UID_=$(id -u) +MODE=${1:-local} + +role() { + /usr/bin/python3 -c \ + 'import json,sys; print(json.load(open(sys.argv[1])).get("role", "client"))' \ + "$STATE" 2>/dev/null || echo client +} + +healthy() { + local current_role=$1 + if [[ "$current_role" == "server" ]]; then + pgrep -f "deskflow-core server" >/dev/null && \ + nc -z -G2 127.0.0.1 24800 >/dev/null 2>&1 + else + pgrep -f "deskflow-core client" >/dev/null && \ + netstat -an 2>/dev/null | grep -E '\.[0-9]+ +[^ ]+\.24800 +ESTABLISHED' >/dev/null + fi +} + +low_latency_wifi() { + # Deskflow's macOS workaround for cursor stalls: keep Apple Wireless + # Direct Link from time-slicing the Wi-Fi radio while the seat is active. + # A narrowly scoped sudoers rule installed by this setup makes the command + # non-interactive; on an unconfigured host this remains a harmless no-op. + /usr/bin/sudo -n /sbin/ifconfig awdl0 down >/dev/null 2>&1 || true +} + +wake_local() { + local current_role + current_role=$(role) + + low_latency_wifi + + # Wake the physical panel before Deskflow asks WindowServer for its shape. + /usr/bin/caffeinate -u -t 15 >/dev/null 2>&1 & + + if ! healthy "$current_role"; then + /bin/launchctl kickstart -k "gui/${UID_}/${LABEL}" 2>/dev/null || { + /bin/launchctl bootstrap "gui/${UID_}" \ + "$HOME/Library/LaunchAgents/${LABEL}.plist" 2>/dev/null || true + } + fi + + for _ in {1..15}; do + if healthy "$current_role"; then + echo "seat ready: $(hostname -s) $current_role" + return 0 + fi + sleep 1 + done + + echo "seat ready: $(hostname -s) $current_role unhealthy" >&2 + return 1 +} + +if [[ "$MODE" != "--fleet" ]]; then + wake_local + exit $? +fi + +if [[ ! -f "$HANDOFF" ]]; then + echo "seat ready: no handoff config" >&2 + exit 78 +fi + +controller=$(/usr/bin/python3 -c \ + 'import json,sys; print(str(json.load(open(sys.argv[1])).get("controller", False)).lower())' \ + "$HANDOFF") +if [[ "$controller" != "true" ]]; then + wake_local + exit $? +fi + +# Claim-control also reconciles clients when this host is already the server. +"$HOME/.local/bin/deskflow-claim-control" +wake_local + +peers=() +while IFS= read -r peer; do + [[ -n "$peer" ]] && peers+=("$peer") +done < <(/usr/bin/python3 -c \ + 'import json,sys; print("\n".join(json.load(open(sys.argv[1])).get("clients", [])))' \ + "$HANDOFF") + +pids=() +for peer in "${peers[@]}"; do + ssh -o BatchMode=yes -o ConnectTimeout=4 "$peer" \ + '~/.local/bin/deskflow-seat-ready' & + pids+=("$!") +done + +status=0 +for pid in "${pids[@]}"; do + wait "$pid" || status=1 +done +exit "$status" diff --git a/slab/deskflow-handoff/deskflow-set-role b/slab/deskflow-handoff/deskflow-set-role index 97bd018813..24d93d1f9e 100755 --- a/slab/deskflow-handoff/deskflow-set-role +++ b/slab/deskflow-handoff/deskflow-set-role @@ -7,6 +7,7 @@ EPOCH=${3:-0} STATE="$HOME/.config/slab/deskflow.json" CLIENT_CONF="$HOME/Library/Deskflow/Deskflow-client-role.conf" EPOCH_FILE="$HOME/.config/slab/deskflow-role-epoch" +ROLE_LOCK="$HOME/.config/slab/deskflow-set-role.lock" LABEL="computer.aesthetic.deskflow" UID_=$(id -u) @@ -26,6 +27,26 @@ esac mkdir -p "$HOME/.config/slab" "$HOME/Library/Deskflow" +# Claims from the two physical controllers can overlap. Serialize the epoch +# check, state write, and core restart as one transaction; otherwise an older +# claim that passed the check first can finish last and resurrect split-brain. +LOCKED=false +for _ in {1..500}; do + if /usr/bin/shlock -f "$ROLE_LOCK" -p "$$"; then + LOCKED=true + break + fi + sleep 0.01 +done +if [[ "$LOCKED" != "true" ]]; then + echo "deskflow-set-role: role transaction lock timed out" >&2 + exit 75 +fi +cleanup_role_lock() { + rm -f "$ROLE_LOCK" +} +trap cleanup_role_lock EXIT + if ! [[ "$EPOCH" =~ ^[0-9]+$ ]]; then echo "deskflow-set-role: invalid claim generation" >&2 exit 64 diff --git a/slab/deskflow-handoff/deskflow-start b/slab/deskflow-handoff/deskflow-start index ca354c52e4..bae27bd6e4 100755 --- a/slab/deskflow-handoff/deskflow-start +++ b/slab/deskflow-handoff/deskflow-start @@ -4,6 +4,7 @@ set -euo pipefail UID_=$(id -u) MAIN="$HOME/Library/LaunchAgents/computer.aesthetic.deskflow.plist" WATCHDOG="$HOME/Library/LaunchAgents/computer.aesthetic.deskflow-watchdog.plist" +SEAT_READY="$HOME/Library/LaunchAgents/computer.aesthetic.seat-ready.plist" bootstrap_retry() { local plist=$1 @@ -20,6 +21,7 @@ bootstrap_retry() { launchctl enable "gui/${UID_}/computer.aesthetic.deskflow" launchctl enable "gui/${UID_}/computer.aesthetic.deskflow-watchdog" +launchctl enable "gui/${UID_}/computer.aesthetic.seat-ready" ROLE=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("role", "client"))' "$HOME/.config/slab/deskflow.json" 2>/dev/null || echo client) if [[ "$ROLE" == "client" ]]; then @@ -27,3 +29,5 @@ if [[ "$ROLE" == "client" ]]; then fi bootstrap_retry "$MAIN" bootstrap_retry "$WATCHDOG" +launchctl bootout "gui/${UID_}/computer.aesthetic.seat-ready" 2>/dev/null || true +bootstrap_retry "$SEAT_READY" diff --git a/slab/deskflow-handoff/deskflow-yield-control b/slab/deskflow-handoff/deskflow-yield-control new file mode 100755 index 0000000000..52e7e2e413 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-yield-control @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +SERVER_HOST=${1:-} +EPOCH=${2:-0} +if [[ -z "$SERVER_HOST" ]]; then + echo "usage: deskflow-yield-control SERVER_HOST [EPOCH]" >&2 + exit 64 +fi + +# Capture this before set-role stops the old server. Keeping both operations in +# one SSH call avoids adding a network round trip to the handoff path. +ACTIVE_SCREEN=$("$HOME/.local/bin/deskflow-active-screen" 2>/dev/null || true) +UNIPOINTER_STATE="" +if [[ -n "$ACTIVE_SCREEN" ]]; then + POINTER_PEER=$(/usr/bin/python3 - "$HOME/.config/slab/deskflow-handoff.json" "$ACTIVE_SCREEN" <<'PY' +import json, sys +path, screen = sys.argv[1:] +try: + with open(path) as source: + config = json.load(source) +except Exception: + config = {} +if screen != config.get("screenName", ""): + token = screen.removesuffix(".local").lower() + for peer in config.get("clients", []): + if token and token in peer.lower(): + print(peer) + break +PY +) + if [[ -n "$POINTER_PEER" ]]; then + UNIPOINTER_STATE=$(ssh -o BatchMode=yes -o ConnectTimeout=2 \ + -o ControlMaster=auto -o ControlPersist=600 \ + -o "ControlPath=$HOME/.ssh/deskflow-%C" "$POINTER_PEER" \ + '~/.local/bin/unipointer' 2>/dev/null || true) + else + UNIPOINTER_STATE=$("$HOME/.local/bin/unipointer" 2>/dev/null || true) + fi +fi +"$HOME/.local/bin/deskflow-set-role" client "$SERVER_HOST" "$EPOCH" +[[ -n "$ACTIVE_SCREEN" ]] && echo "active-screen $ACTIVE_SCREEN" +[[ -n "$UNIPOINTER_STATE" ]] && echo "unipointer-state $ACTIVE_SCREEN $UNIPOINTER_STATE" diff --git a/slab/deskflow-handoff/install.sh b/slab/deskflow-handoff/install.sh index bd58e51372..becb140f24 100755 --- a/slab/deskflow-handoff/install.sh +++ b/slab/deskflow-handoff/install.sh @@ -52,10 +52,12 @@ write_fingerprints() { done } -for file in deskflow-role-runner deskflow-set-role deskflow-claim-control deskflow-role-watchdog deskflow-start; do +for file in deskflow-role-runner deskflow-set-role deskflow-claim-control deskflow-role-watchdog deskflow-seat-ready deskflow-active-screen deskflow-yield-control deskflow-start; do cp "$HERE/$file" "$HOME/.local/bin/$file" chmod 755 "$HOME/.local/bin/$file" done +/usr/bin/xcrun swiftc -O "$HERE/unipointer.swift" \ + -o "$HOME/.local/bin/unipointer" rm -f "$HOME/.local/bin/deskflow-role-idle" cp "$HERE/deskflow-server.conf" "$HOME/Library/Deskflow/deskflow-handoff-server.conf" mkdir -p "$HOME/Library/Deskflow/tls" @@ -152,6 +154,21 @@ cat > "$WATCHDOG" < EOF +SEAT_READY="$HOME/Library/LaunchAgents/computer.aesthetic.seat-ready.plist" +cat > "$SEAT_READY" < + + +Labelcomputer.aesthetic.seat-ready +ProgramArguments$HOME/.local/bin/deskflow-seat-ready +LimitLoadToSessionTypeAqua +ProcessTypeInteractive +RunAtLoad +StandardOutPath$HOME/Library/Logs/seat-ready.log +StandardErrorPath$HOME/Library/Logs/seat-ready.log + +EOF + launchctl bootout "gui/${UID_}/computer.aesthetic.deskflow" 2>/dev/null || true if [[ "$DEFER_START" != "true" ]]; then "$HOME/.local/bin/deskflow-start" diff --git a/slab/deskflow-handoff/unipointer.swift b/slab/deskflow-handoff/unipointer.swift new file mode 100644 index 0000000000..a6ffa5d0dd --- /dev/null +++ b/slab/deskflow-handoff/unipointer.swift @@ -0,0 +1,51 @@ +import AppKit +import Foundation + +let identifier = "unipointer" +let stateKind = "unipointer-state" + +switch CommandLine.arguments.dropFirst().first { +case "id", "--id", "--identifier": + print(identifier) + exit(0) +case "help", "--help", "-h": + print("usage: unipointer [state|--identifier]") + print(" state print the versioned unipointer-state JSON (default)") + print(" --identifier print the canonical identifier: unipointer") + exit(0) +case nil, "state", "--state": + break +default: + fputs("unipointer: unknown command\n", stderr) + exit(64) +} + +// `unipointer` is the fleet cursor-state front door. Keep stdout machine-readable +// so host tooling and agents can use it without scraping Deskflow logs. +let point = NSEvent.mouseLocation +guard let screen = NSScreen.screens.first(where: { NSMouseInRect(point, $0.frame, false) }) + ?? NSScreen.main else { + fputs("unipointer: no active screen\n", stderr) + exit(1) +} +let frame = screen.frame +let payload: [String: Any] = [ + "identifier": identifier, + "kind": stateKind, + "version": 1, + "x": point.x, + "y": point.y, + "frame": [ + "x": frame.minX, + "y": frame.minY, + "width": frame.width, + "height": frame.height, + ], + "normalized": [ + "x": (point.x - frame.minX) / frame.width, + "y": (point.y - frame.minY) / frame.height, + ], +] +let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) +FileHandle.standardOutput.write(data) +FileHandle.standardOutput.write(Data("\n".utf8)) diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index fba95bbd35..7a2a6cf160 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -1075,6 +1075,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ShellRunner.runShellAsync(cmd) } + @objc func deskflowWakeSeat() { + ShellRunner.runAsync(Paths.deskflowSeatReady, args: ["--fleet"]) { [weak self] in + DispatchQueue.main.async { self?.refresh() } + } + } + @objc func openDeskflowLog() { ShellRunner.run("/usr/bin/open", args: ["-a", "Console", Paths.deskflowLog]) } diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 0712873c1f..8925e39329 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -1057,6 +1057,7 @@ enum MenuBuilder { stop.isEnabled = d.running sub.addItem(stop) sub.addItem(item("Restart", selector: #selector(AppDelegate.deskflowRestart), target: target)) + sub.addItem(item("Wake Seat", selector: #selector(AppDelegate.deskflowWakeSeat), target: target)) sub.addItem(.separator()) sub.addItem(item("Open Deskflow log", selector: #selector(AppDelegate.openDeskflowLog), target: target)) sub.addItem(item("Edit Deskflow config", selector: #selector(AppDelegate.openDeskflowConfig), target: target)) diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift index d0dbffe750..59cd1f9cab 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -68,6 +68,14 @@ enum Paths { /// { "enabled": true, "role": "server", "label": "Deskflow", /// "agent": "computer.aesthetic.deskflow" } static var deskflowConfig: String { "\(home)/.config/slab/deskflow.json" } + /// Fleet identity + screen name written by deskflow-handoff/install.sh. + static var deskflowHandoffConfig: String { "\(home)/.config/slab/deskflow-handoff.json" } + /// The shared links graph installed on every Deskflow host. + static var deskflowServerConfig: String { "\(home)/Library/Deskflow/deskflow-handoff-server.conf" } + /// Carries the active controller address while this host is a client. + static var deskflowClientRoleConfig: String { "\(home)/Library/Deskflow/Deskflow-client-role.conf" } + /// Wake/reconcile the local Deskflow seat or, on a controller, the fleet. + static var deskflowSeatReady: String { "\(slabBin)/deskflow-seat-ready" } /// Where the Deskflow server/client LaunchAgent writes its log (matches /// the StandardOutPath in computer.aesthetic.deskflow.plist). static var deskflowLog: String { "\(home)/Library/Logs/deskflow-core.log" }