From d88ceb129ff8c3d6c4355a6638d4eb75b57bf739 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Fri, 17 Jul 2026 23:52:37 +0000 Subject: [PATCH] macpal: hand off control through Deskflow --- macpal/Resources/Info.plist | 4 ++-- macpal/Sources/DeskflowHandoff.swift | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macpal/Sources/GlowController.swift | 8 ++++++++ macpal/Sources/PhysicalTrackpad.swift | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macpal/Sources/main.swift | 11 +++++++++++ slab/deskflow-handoff/README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ slab/deskflow-handoff/deploy.fish | 30 ++++++++++++++++++++++++++++++ slab/deskflow-handoff/deskflow-claim-control | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/deskflow-handoff/deskflow-role-runner | 28 ++++++++++++++++++++++++++++ slab/deskflow-handoff/deskflow-role-watchdog | 35 +++++++++++++++++++++++++++++++++++ slab/deskflow-handoff/deskflow-server.conf | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/deskflow-handoff/deskflow-set-role | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/deskflow-handoff/deskflow-start | 29 +++++++++++++++++++++++++++++ slab/deskflow-handoff/install.sh | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 14 file(s) changed, 853 insertion(s)(+), 2 deletion(s)(-) diff --git a/macpal/Resources/Info.plist b/macpal/Resources/Info.plist --- a/macpal/Resources/Info.plist +++ b/macpal/Resources/Info.plist @@ -15,9 +15,9 @@ AppIcon CFBundlePackageType APPL CFBundleShortVersionString - 0.2.3 + 0.2.5 CFBundleVersion - 5 + 7 LSMinimumSystemVersion 13.0 LSUIElement diff --git a/macpal/Sources/DeskflowHandoff.swift b/macpal/Sources/DeskflowHandoff.swift new file mode 100644 --- /dev/null +++ b/macpal/Sources/DeskflowHandoff.swift @@ -0,0 +1,95 @@ +import AppKit + +// Promotes this machine to Deskflow server when its physical trackpad is used. +// The fleet fan-out lives in deskflow-claim-control so MacPal stays a small UI +// observer and role changes remain testable from the command line. +#if !MAC_APP_STORE +final class DeskflowHandoff { + var onControlAcquired: (() -> Void)? + + private let claimPath = NSString(string: "~/.local/bin/deskflow-claim-control").expandingTildeInPath + private let statePath = NSString(string: "~/.config/slab/deskflow.json").expandingTildeInPath + private var claimInFlight = false + private var lastAttempt = Date.distantPast + private var gestureStart: CGPoint? + private var gestureLatest: CGPoint? + + init?() { + guard FileManager.default.isExecutableFile(atPath: claimPath) else { return nil } + PhysicalTrackpad.shared.onTouchBegan = { [weak self] point in self?.touchBegan(at: point) } + PhysicalTrackpad.shared.onFrame = { [weak self] point in self?.capture(point) } + guard PhysicalTrackpad.shared.start() else { return nil } + } + + private func currentRole() -> String? { + guard let data = FileManager.default.contents(atPath: statePath), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return object["role"] as? String + } + + private func touchBegan(at point: CGPoint) { + gestureStart = point + gestureLatest = point + guard currentRole() != "server", !claimInFlight, + Date().timeIntervalSince(lastAttempt) > 1.5 else { return } + claimInFlight = true + lastAttempt = Date() + + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: claimPath) + process.standardOutput = output + process.standardError = output + process.terminationHandler = { [weak self] process in + let data = output.fileHandleForReading.readDataToEndOfFile() + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + DispatchQueue.main.async { + guard let self else { return } + 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) + } + } + } else { + NSLog("MacPal Deskflow handoff failed (%d): %@", process.terminationStatus, message) + } + } + } + do { try process.run() } + catch { + claimInFlight = false + NSLog("MacPal Deskflow handoff could not start: %@", error.localizedDescription) + } + } + + private func capture(_ point: CGPoint?) { + guard claimInFlight, let point else { return } + 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, + 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) + event.post(tap: .cghidEventTap) + NSLog("MacPal Deskflow handoff: replayed side bump dx=%lld", delta) + } +} +#endif diff --git a/macpal/Sources/GlowController.swift b/macpal/Sources/GlowController.swift --- a/macpal/Sources/GlowController.swift +++ b/macpal/Sources/GlowController.swift @@ -143,6 +143,14 @@ self.win?.orderOut(nil) } } + /// Controller-role feedback is intentionally distinct from an ordinary + /// cursor crossover: the same edge bloom plus one short local glass ding. + /// Called only after the role fan-out has completed successfully. + func controlAcquired() { + flash() + NSSound(named: NSSound.Name("Glass"))?.play() + } + // LEAVE mid-flash: kill the light instantly — it never trails the cursor. private func cut() { flashSeq += 1 diff --git a/macpal/Sources/PhysicalTrackpad.swift b/macpal/Sources/PhysicalTrackpad.swift new file mode 100644 --- /dev/null +++ b/macpal/Sources/PhysicalTrackpad.swift @@ -0,0 +1,97 @@ +import Foundation + +// Global, focus-independent physical trackpad contact frames. Deskflow's +// injected pointer events never appear here, which makes this a reliable +// controller-claim signal on Neo and Blueberry. +#if !MAC_APP_STORE + +private struct HandoffMTPoint { var x: Float; var y: Float } +private struct HandoffMTReadout { var position: HandoffMTPoint; var velocity: HandoffMTPoint } +private struct HandoffMTTouch { + var frame: Int32 + var timestamp: Double + var identifier: Int32 + var state: Int32 + var fingerID: Int32 + var handID: Int32 + var normalized: HandoffMTReadout + var size: Float + var zero1: Int32 + var angle: Float + var majorAxis: Float + var minorAxis: Float + var absolute: HandoffMTReadout + var zero2a: Int32 + var zero2b: Int32 + var zDensity: Float +} + +private typealias HandoffMTCallback = @convention(c) ( + UnsafeMutableRawPointer?, UnsafeMutableRawPointer?, Int32, Double, Int32 +) -> Int32 + +private func handoffMTFrame( + _ device: UnsafeMutableRawPointer?, _ contacts: UnsafeMutableRawPointer?, + _ count: Int32, _ timestamp: Double, _ frame: Int32 +) -> Int32 { + let typed = contacts?.assumingMemoryBound(to: HandoffMTTouch.self) + var point: CGPoint? + if let typed { + for i in 0.. Void)? + var onFrame: ((CGPoint?) -> Void)? + + private var framework: UnsafeMutableRawPointer? + private var devices: [UnsafeMutableRawPointer] = [] + private var wasTouching = false + + private typealias CreateList = @convention(c) () -> Unmanaged? + private typealias Register = @convention(c) (UnsafeMutableRawPointer, HandoffMTCallback) -> Void + private typealias Start = @convention(c) (UnsafeMutableRawPointer, Int32) -> Void + + @discardableResult + func start() -> Bool { + guard framework == nil else { return !devices.isEmpty } + let path = "/System/Library/PrivateFrameworks/MultitouchSupport.framework/MultitouchSupport" + guard let handle = dlopen(path, RTLD_NOW), + let createSymbol = dlsym(handle, "MTDeviceCreateList"), + let registerSymbol = dlsym(handle, "MTRegisterContactFrameCallback"), + let startSymbol = dlsym(handle, "MTDeviceStart") else { + NSLog("MacPal Deskflow handoff: physical trackpad API unavailable") + return false + } + framework = handle + let create = unsafeBitCast(createSymbol, to: CreateList.self) + let register = unsafeBitCast(registerSymbol, to: Register.self) + let startDevice = unsafeBitCast(startSymbol, to: Start.self) + guard let list = create()?.takeRetainedValue() else { return false } + for i in 0..&2 + exit 78 +fi + +VALUES=() +while IFS= read -r value; do + VALUES+=("$value") +done < <(/usr/bin/python3 - "$CONFIG" "$HOME/.config/slab/deskflow.json" <<'PY' +import json, sys, time +with open(sys.argv[1]) as f: + config = json.load(f) +try: + with open(sys.argv[2]) as f: + state = json.load(f) +except Exception: + state = {} +print(str(config.get("controller", False)).lower()) +print(state.get("role", "")) +print(config.get("address", "")) +print(time.time_ns()) +for peer in config.get("clients", []): + print(peer) +PY +) +CONTROLLER=${VALUES[0]:-false} +if [[ "$CONTROLLER" != "true" ]]; then + echo "deskflow claim: this machine is not a controller" >&2 + exit 77 +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 +fi +LOCAL_OUT="" +PEER_OUT="" +cleanup() { + [[ -n "$LOCAL_OUT" ]] && rm -f "$LOCAL_OUT" + [[ -n "$PEER_OUT" ]] && rm -f "$PEER_OUT" + rmdir "$LOCK" 2>/dev/null || true +} +trap cleanup EXIT + +ADDRESS=${VALUES[2]:-} +EPOCH=${VALUES[3]:-0} +PEERS=("${VALUES[@]:4}") + +if [[ ${#PEERS[@]} -lt 1 ]]; then + echo "deskflow claim: no peer controller configured" >&2 + exit 78 +fi + +mkdir -p "$HOME/.ssh" +SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=4 + -o ControlMaster=auto -o ControlPersist=600 + -o "ControlPath=$HOME/.ssh/deskflow-%C") + +# Flip the local core and the other controller concurrently. The client will +# 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) +"$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 & +PEER_PID=$! + +LOG="$HOME/Library/Logs/deskflow-handoff.log" +for peer in "${PEERS[@]:1}"; do + nohup ssh -n "${SSH_OPTS[@]}" "$peer" \ + '~/.local/bin/deskflow-set-role client '"$ADDRESS $EPOCH" >> "$LOG" 2>&1 & +done + +LOCAL_STATUS=0 +PEER_STATUS=0 +wait "$LOCAL_PID" || LOCAL_STATUS=$? +wait "$PEER_PID" || PEER_STATUS=$? +cat "$LOCAL_OUT" +cat "$PEER_OUT" +if [[ "$LOCAL_STATUS" != "0" || "$PEER_STATUS" != "0" ]]; then + echo "deskflow claim: controller pair did not converge" >&2 + exit 74 +fi + +echo "changed server; peer controller now uses $ADDRESS" diff --git a/slab/deskflow-handoff/deskflow-role-runner b/slab/deskflow-handoff/deskflow-role-runner new file mode 100644 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-role-runner @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +STATE="$HOME/.config/slab/deskflow.json" +HANDOFF="$HOME/.config/slab/deskflow-handoff.json" +CORE="/Applications/Deskflow.app/Contents/MacOS/deskflow-core" +ROLE="client" +CONTROLLER="false" + +if [[ -f "$STATE" ]]; then + ROLE=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("role", "client"))' "$STATE" 2>/dev/null || echo client) +fi +if [[ -f "$HANDOFF" ]]; then + CONTROLLER=$(/usr/bin/python3 -c 'import json,sys; print(str(json.load(open(sys.argv[1])).get("controller", False)).lower())' "$HANDOFF" 2>/dev/null || echo false) +fi + +case "$ROLE" in + server) + exec "$CORE" server -s "$HOME/Library/Deskflow/Deskflow-server-role.conf" + ;; + client) + exec "$CORE" client -s "$HOME/Library/Deskflow/Deskflow-client-role.conf" + ;; + *) + echo "deskflow-role-runner: invalid role: $ROLE" >&2 + exit 64 + ;; +esac diff --git a/slab/deskflow-handoff/deskflow-role-watchdog b/slab/deskflow-handoff/deskflow-role-watchdog new file mode 100644 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-role-watchdog @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +LABEL="computer.aesthetic.deskflow" +UID_=$(id -u) +STATE="$HOME/.config/slab/deskflow.json" +MISS="$HOME/.deskflow-role-watchdog.miss" + +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=0 +if [[ "$ROLE" == "server" ]]; then + pgrep -f "deskflow-core server" >/dev/null && nc -z -G3 127.0.0.1 24800 >/dev/null 2>&1 && healthy=1 +else + line=$(netstat -an 2>/dev/null | grep -E '\.[0-9]+ +[^ ]+\.24800 +ESTABLISHED' | head -1 || true) + if pgrep -f "deskflow-core client" >/dev/null && [[ -n "$line" ]]; then + recvq=$(awk '{print $2}' <<<"$line") + [[ "$recvq" == "0" ]] && healthy=1 + fi +fi + +if [[ "$healthy" == "1" ]]; then + rm -f "$MISS" + exit 0 +fi + +# Two-strike grace avoids bouncing the core during a normal role transition. +if [[ ! -f "$MISS" ]]; then + touch "$MISS" + exit 0 +fi + +rm -f "$MISS" +/bin/launchctl kickstart -k "gui/${UID_}/${LABEL}" 2>/dev/null +logger -t deskflow-role-watchdog "$ROLE core unhealthy — kicked $LABEL" diff --git a/slab/deskflow-handoff/deskflow-server.conf b/slab/deskflow-handoff/deskflow-server.conf new file mode 100644 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-server.conf @@ -0,0 +1,60 @@ +section: screens + blueberry.local: + halfDuplexCapsLock = false + halfDuplexNumLock = false + halfDuplexScrollLock = false + xtestIsXineramaUnaware = false + switchCorners = none + switchCornerSize = 0 + chicken.local: + halfDuplexCapsLock = false + halfDuplexNumLock = false + halfDuplexScrollLock = false + xtestIsXineramaUnaware = false + switchCorners = none + switchCornerSize = 0 + neo: + halfDuplexCapsLock = false + halfDuplexNumLock = false + halfDuplexScrollLock = false + xtestIsXineramaUnaware = false + switchCorners = none + switchCornerSize = 0 + panda.local: + halfDuplexCapsLock = false + halfDuplexNumLock = false + halfDuplexScrollLock = false + xtestIsXineramaUnaware = false + switchCorners = none + switchCornerSize = 0 +end + +section: aliases +end + +section: links + chicken.local: + right = panda.local + down = neo + panda.local: + left = chicken.local + down = blueberry.local + neo: + up = chicken.local + right = blueberry.local + blueberry.local: + up = panda.local + left = neo +end + +section: options + protocol = barrier + relativeMouseMoves = false + win32KeepForeground = false + defaultLockToScreenState = false + disableLockToScreen = false + clipboardSharing = true + clipboardSharingSize = 3072 + switchCorners = none + switchCornerSize = 0 +end diff --git a/slab/deskflow-handoff/deskflow-set-role b/slab/deskflow-handoff/deskflow-set-role new file mode 100644 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-set-role @@ -0,0 +1,152 @@ +#!/bin/bash +set -euo pipefail + +ROLE=${1:-} +SERVER_HOST=${2:-} +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" +LABEL="computer.aesthetic.deskflow" +UID_=$(id -u) + +case "$ROLE" in + server) ;; + client) + if [[ -z "$SERVER_HOST" ]]; then + echo "usage: deskflow-set-role client SERVER_HOST" >&2 + exit 64 + fi + ;; + *) + echo "usage: deskflow-set-role server | client SERVER_HOST" >&2 + exit 64 + ;; +esac + +mkdir -p "$HOME/.config/slab" "$HOME/Library/Deskflow" + +if ! [[ "$EPOCH" =~ ^[0-9]+$ ]]; then + echo "deskflow-set-role: invalid claim generation" >&2 + exit 64 +fi +CURRENT_EPOCH=$(cat "$EPOCH_FILE" 2>/dev/null || echo 0) +if [[ "$EPOCH" != "0" && "$CURRENT_EPOCH" =~ ^[0-9]+$ && "$EPOCH" -lt "$CURRENT_EPOCH" ]]; then + echo "deskflow-set-role: ignored stale generation $EPOCH (current $CURRENT_EPOCH)" >&2 + exit 73 +fi +if [[ "$EPOCH" != "0" ]]; then + printf '%s\n' "$EPOCH" > "$EPOCH_FILE.tmp.$$" + mv "$EPOCH_FILE.tmp.$$" "$EPOCH_FILE" +fi + +CURRENT_HOST=$(sed -n 's/^remoteHost=//p' "$CLIENT_CONF" 2>/dev/null | head -1) +META=() +TMP="$STATE.tmp.$$" +META_FILE="$STATE.meta.$$" +/usr/bin/python3 - "$STATE" "$HOME/.config/slab/deskflow-handoff.json" "$TMP" "$META_FILE" "$ROLE" <<'PY' +import json, sys +state_path, handoff_path, dst, meta_path, role = sys.argv[1:] +try: + with open(state_path) as f: + state = json.load(f) +except Exception: + state = {} +try: + with open(handoff_path) as f: + handoff = json.load(f) +except Exception: + handoff = {} +with open(meta_path, "w") as f: + f.write(str(state.get("role", "")) + "\n") + f.write(str(handoff.get("controller", False)).lower() + "\n") + f.write(str(handoff.get("screenName", "")) + "\n") +state.update({ + "enabled": True, + "role": role, + "label": state.get("label", "Deskflow"), + "agent": "computer.aesthetic.deskflow", +}) +with open(dst, "w") as f: + json.dump(state, f, indent=2, sort_keys=True) + f.write("\n") +PY +while IFS= read -r value; do + META+=("$value") +done < "$META_FILE" +rm -f "$META_FILE" +mv "$TMP" "$STATE" +CURRENT_ROLE=${META[0]:-} +CONTROLLER=${META[1]:-false} +SCREEN_NAME=${META[2]:-} + +if [[ "$ROLE" == "client" ]]; then + if [[ -z "$SCREEN_NAME" ]]; then + SCREEN_NAME=$(scutil --get LocalHostName 2>/dev/null || hostname) + fi + TMP="$CLIENT_CONF.tmp.$$" + cat > "$TMP" </dev/null; then + echo "unchanged $ROLE" + exit 0 + elif [[ "$ROLE" == "server" ]] && \ + pgrep -f "deskflow-core server -s $HOME/Library/Deskflow/Deskflow-server-role.conf" >/dev/null; then + echo "unchanged $ROLE" + exit 0 + fi +fi + +if [[ "$ROLE" == "server" ]]; then + DESIRED="deskflow-core server -s $HOME/Library/Deskflow/Deskflow-server-role.conf" +else + DESIRED="deskflow-core client -s $HOME/Library/Deskflow/Deskflow-client-role.conf" +fi + +# `kickstart -k` asks the Cocoa core to exit and can block for its full ~10s +# shutdown timeout. Kill only this exact launchd job; KeepAlive immediately +# re-execs the role runner against the state/config written above. +if [[ "$ROLE" == "client" ]]; then + # Headless display clients can report an invalid screen shape while their + # monitor is asleep. A brief user-activity pulse makes the first edge usable. + /usr/bin/caffeinate -u -t 2 >/dev/null 2>&1 & +fi +/bin/launchctl kill SIGKILL "gui/${UID_}/${LABEL}" 2>/dev/null || true +sleep 0.02 +/bin/launchctl kickstart "gui/${UID_}/${LABEL}" 2>/dev/null || true +for attempt in {1..40}; do + if pgrep -f "$DESIRED" >/dev/null; then + echo "changed $ROLE${SERVER_HOST:+ $SERVER_HOST}" + exit 0 + fi + sleep 0.05 +done + +/bin/launchctl kickstart "gui/${UID_}/${LABEL}" 2>/dev/null || { + /bin/launchctl bootstrap "gui/${UID_}" "$HOME/Library/LaunchAgents/${LABEL}.plist" +} +for attempt in {1..40}; do + if pgrep -f "$DESIRED" >/dev/null; then + echo "changed $ROLE${SERVER_HOST:+ $SERVER_HOST}" + exit 0 + fi + sleep 0.05 +done + +echo "deskflow-set-role: $ROLE core did not start" >&2 +exit 70 diff --git a/slab/deskflow-handoff/deskflow-start b/slab/deskflow-handoff/deskflow-start new file mode 100644 --- /dev/null +++ b/slab/deskflow-handoff/deskflow-start @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail + +UID_=$(id -u) +MAIN="$HOME/Library/LaunchAgents/computer.aesthetic.deskflow.plist" +WATCHDOG="$HOME/Library/LaunchAgents/computer.aesthetic.deskflow-watchdog.plist" + +bootstrap_retry() { + local plist=$1 + local attempt + for attempt in 1 2 3 4 5 6 7 8; do + if launchctl bootstrap "gui/${UID_}" "$plist" 2>/dev/null; then + return 0 + fi + sleep 0.4 + done + echo "could not bootstrap $plist" >&2 + return 1 +} + +launchctl enable "gui/${UID_}/computer.aesthetic.deskflow" +launchctl enable "gui/${UID_}/computer.aesthetic.deskflow-watchdog" +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 + /usr/bin/caffeinate -u -t 2 >/dev/null 2>&1 & +fi +bootstrap_retry "$MAIN" +bootstrap_retry "$WATCHDOG" diff --git a/slab/deskflow-handoff/install.sh b/slab/deskflow-handoff/install.sh new file mode 100644 --- /dev/null +++ b/slab/deskflow-handoff/install.sh @@ -0,0 +1,160 @@ +#!/bin/bash +set -euo pipefail + +MACHINE="" +SCREEN_NAME="" +ADDRESS="" +ROLE="client" +SERVER_HOST="" +CONTROLLER=false +CLIENTS="" +DEFER_START=false +TRUSTED_SERVERS="" +TRUSTED_CLIENTS="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --machine) MACHINE=$2; shift 2 ;; + --screen-name) SCREEN_NAME=$2; shift 2 ;; + --address) ADDRESS=$2; shift 2 ;; + --role) ROLE=$2; shift 2 ;; + --server-host) SERVER_HOST=$2; shift 2 ;; + --controller) CONTROLLER=true; shift ;; + --clients) CLIENTS=$2; shift 2 ;; + --defer-start) DEFER_START=true; shift ;; + --trusted-servers) TRUSTED_SERVERS=$2; shift 2 ;; + --trusted-clients) TRUSTED_CLIENTS=$2; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 64 ;; + esac +done + +if [[ -z "$MACHINE" || -z "$SCREEN_NAME" || -z "$ADDRESS" ]]; then + echo "usage: install.sh --machine NAME --screen-name NAME --address IP [--controller --clients a,b,c] [--role server|client --server-host IP]" >&2 + exit 64 +fi +if [[ -z "$SERVER_HOST" ]]; then SERVER_HOST="$ADDRESS"; fi + +HERE=$(cd "$(dirname "$0")" && pwd) +UID_=$(id -u) +mkdir -p "$HOME/.local/bin" "$HOME/.config/slab" "$HOME/Library/Deskflow" "$HOME/Library/LaunchAgents" +test -f "$HOME/.config/slab/deskflow-role-epoch" || printf '0\n' > "$HOME/.config/slab/deskflow-role-epoch" + +write_fingerprints() { + local path=$1 + local csv=$2 + local fingerprint + : > "$path" + IFS=',' read -r -a fingerprints <<<"$csv" + for fingerprint in "${fingerprints[@]}"; do + if [[ -n "$fingerprint" ]]; then + printf 'v2:sha256:%s\n' "$(printf '%s' "$fingerprint" | tr '[:upper:]' '[:lower:]')" >> "$path" + fi + done +} + +for file in deskflow-role-runner deskflow-set-role deskflow-claim-control deskflow-role-watchdog deskflow-start; do + cp "$HERE/$file" "$HOME/.local/bin/$file" + chmod 755 "$HOME/.local/bin/$file" +done +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" +if [[ -n "$TRUSTED_SERVERS" ]]; then + write_fingerprints "$HOME/Library/Deskflow/tls/trusted-servers" "$TRUSTED_SERVERS" +fi +if [[ -n "$TRUSTED_CLIENTS" ]]; then + write_fingerprints "$HOME/Library/Deskflow/tls/trusted-clients" "$TRUSTED_CLIENTS" +fi + +cat > "$HOME/Library/Deskflow/Deskflow-server-role.conf" < "$HOME/Library/Deskflow/Deskflow-client-role.conf" </dev/null || true +/usr/libexec/PlistBuddy -c "Delete :ProgramArguments" "$PLIST" 2>/dev/null || true +/usr/libexec/PlistBuddy -c "Add :ProgramArguments array" "$PLIST" +if [[ "$CONTROLLER" == "true" ]]; then + /usr/libexec/PlistBuddy -c "Add :ProgramArguments:0 string $HOME/.local/bin/deskflow-role-runner" "$PLIST" +else + /usr/libexec/PlistBuddy -c "Add :ProgramArguments:0 string /Applications/Deskflow.app/Contents/MacOS/deskflow-core" "$PLIST" + /usr/libexec/PlistBuddy -c "Add :ProgramArguments:1 string $ROLE" "$PLIST" + /usr/libexec/PlistBuddy -c "Add :ProgramArguments:2 string -s" "$PLIST" + /usr/libexec/PlistBuddy -c "Add :ProgramArguments:3 string $HOME/Library/Deskflow/Deskflow-${ROLE}-role.conf" "$PLIST" +fi +/usr/libexec/PlistBuddy -c "Set :KeepAlive true" "$PLIST" +/usr/libexec/PlistBuddy -c "Set :RunAtLoad true" "$PLIST" +/usr/libexec/PlistBuddy -c "Set :StandardOutPath $HOME/Library/Logs/deskflow-core.log" "$PLIST" +/usr/libexec/PlistBuddy -c "Set :StandardErrorPath $HOME/Library/Logs/deskflow-core.log" "$PLIST" +/usr/libexec/PlistBuddy -c "Set :ThrottleInterval 1" "$PLIST" + +STANDBY="$HOME/Library/LaunchAgents/computer.aesthetic.deskflow-standby-server.plist" +launchctl bootout "gui/${UID_}/computer.aesthetic.deskflow-standby-server" 2>/dev/null || true +rm -f "$HOME/.config/slab/deskflow-standby-enabled" "$STANDBY" + +for old in computer.aesthetic.deskflow-watchdog computer.aesthetic.deskflow-server-watchdog; do + launchctl bootout "gui/${UID_}/${old}" 2>/dev/null || true +done +launchctl disable "gui/${UID_}/computer.aesthetic.deskflow-server-watchdog" 2>/dev/null || true + +WATCHDOG="$HOME/Library/LaunchAgents/computer.aesthetic.deskflow-watchdog.plist" +cat > "$WATCHDOG" < + + +Labelcomputer.aesthetic.deskflow-watchdog +ProgramArguments$HOME/.local/bin/deskflow-role-watchdog +RunAtLoad +StartInterval45 +StandardOutPath$HOME/Library/Logs/deskflow-watchdog.log +StandardErrorPath$HOME/Library/Logs/deskflow-watchdog.log + +EOF + +launchctl bootout "gui/${UID_}/computer.aesthetic.deskflow" 2>/dev/null || true +if [[ "$DEFER_START" != "true" ]]; then + "$HOME/.local/bin/deskflow-start" +fi + +echo "installed Deskflow handoff on $MACHINE ($ROLE)" -- tangled.sh