From 602e3f4ad3c0436bd06fa7a97b318246e3bdff2a Mon Sep 17 00:00:00 2001 From: Dhruv Suthar Date: Mon, 9 Nov 2026 00:11:20 +0530 Subject: [PATCH] feat: add local JSON control bridge for automation --- .github/workflows/build.yml | 2 + README.md | 6 + Sources/TinyHand/AppDelegate.swift | 14 ++ Sources/TinyHand/ControlBridge.swift | 175 +++++++++++++++++++ Sources/TinyHand/ControlTransport.swift | 220 ++++++++++++++++++++++++ Sources/TinyHand/Preferences.swift | 13 +- Sources/TinyHand/SettingsWindow.swift | 5 + Sources/TinyHand/main.swift | 4 + Tests/ControlBridge/Stubs.swift | 47 +++++ Tests/ControlBridge/main.swift | 78 +++++++++ docs/control-protocol.md | 156 +++++++++++++++++ scripts/lib.sh | 2 + scripts/test-control.sh | 9 + 13 files changed, 729 insertions(+), 2 deletions(-) create mode 100644 Sources/TinyHand/ControlBridge.swift create mode 100644 Sources/TinyHand/ControlTransport.swift create mode 100644 Tests/ControlBridge/Stubs.swift create mode 100644 Tests/ControlBridge/main.swift create mode 100644 docs/control-protocol.md create mode 100755 scripts/test-control.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a1cd008..0137039 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,3 +13,5 @@ jobs: - uses: actions/checkout@v4 - name: Build (universal) run: swift build -c release --arch arm64 --arch x86_64 + - name: Test local control bridge + run: bash scripts/test-control.sh diff --git a/README.md b/README.md index e1f8647..e3a15bb 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Open `TinyHand.dmg`, drag TinyHand to Applications and allow **Accessibility** w While the pointer is on, a small toolbar at the bottom of the screen lets you pick one of 36 hands or a laser dot, mute the tap sound, or exit. +## Automation + +The bundled executable provides a local `--ctl` JSON interface for launchers and +scripts. See [the control protocol](docs/control-protocol.md) for commands, +permissions, compatibility checks, and error handling. + ## License MIT. See [LICENSE](LICENSE). diff --git a/Sources/TinyHand/AppDelegate.swift b/Sources/TinyHand/AppDelegate.swift index aa6dd5f..31ea69d 100644 --- a/Sources/TinyHand/AppDelegate.swift +++ b/Sources/TinyHand/AppDelegate.swift @@ -3,6 +3,7 @@ import ApplicationServices import Carbon.HIToolbox final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + private var controlBridge: ControlBridge? private var statusItem: NSStatusItem! private let overlay = OverlayController() private let settings = SettingsWindow() @@ -38,6 +39,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { setupStatusItem() setupSettings() registerHotKeys() + let bridge = ControlBridge(overlay: overlay, openSettings: { [weak self] in + self?.openSettings() + }, openHandCreator: { [weak self] in + self?.overlay.exit() + self?.settings.showHandCreator() + }) + do { + try bridge.start() + controlBridge = bridge + } catch { + Log.write("control bridge unavailable: \(error)") + } overlay.onNeedsPermission = { [weak self] in self?.welcome.show() @@ -51,6 +64,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } func applicationWillTerminate(_ notification: Notification) { + controlBridge?.stop() overlay.deactivate() } diff --git a/Sources/TinyHand/ControlBridge.swift b/Sources/TinyHand/ControlBridge.swift new file mode 100644 index 0000000..4cec60b --- /dev/null +++ b/Sources/TinyHand/ControlBridge.swift @@ -0,0 +1,175 @@ +import AppKit +import ApplicationServices + +final class ControlBridge { + private let server = ControlServer() + private let overlay: OverlayController + private let openSettings: () -> Void + private let openHandCreator: () -> Void + private let accessibility: () -> Bool + static let operations = ["status", "styles", "on", "off", "toggle", "clear", "style", "laser", "configure", "toggle-sound", "pin", "settings", "custom-hands"] + + init(overlay: OverlayController, accessibility: @escaping () -> Bool = { AXIsProcessTrusted() }, openSettings: @escaping () -> Void, openHandCreator: @escaping () -> Void) { + self.overlay = overlay + self.accessibility = accessibility + self.openSettings = openSettings + self.openHandCreator = openHandCreator + } + + func start() throws { try server.start { [weak self] data in self?.handle(data) ?? Data() } } + func stop() { server.stop() } + + struct Problem: Error { let code: String; let message: String } + private func reject(_ message: String, code: String = "invalid-request") -> Problem { + Problem(code: code, message: message) + } + + func handle(_ data: Data) -> Data { + var response: [String: Any] = ["version": ControlTransport.version] + do { + guard let request = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = request["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(), version.intValue == 1, + version.doubleValue == 1, + let op = request["operation"] as? String, Self.operations.contains(op) else { + throw reject("Unsupported protocol version or operation") + } + var allowed: Set = ["version", "operation"] + if op == "style" { allowed.formUnion(["style", "activate"]) } + if op == "configure" { allowed.insert("settings") } + if op == "pin" { allowed.formUnion(["style", "pinned"]) } + guard Set(request.keys).isSubset(of: allowed) else { throw reject("Unexpected request fields") } + switch op { + case "on": try activate() + case "off": overlay.deactivate() + case "toggle": + if overlay.isActive { overlay.deactivate() } else { try activate() } + case "clear": overlay.clearInk() + case "style": + let style = try readStyle(request["style"]) + let shouldActivate = try readBool(request["activate"] ?? false) + try validateAsset(for: style) + if shouldActivate { try activate() } + overlay.pick(style) + NotificationCenter.default.post(name: Preferences.changed, object: nil) + case "laser": + try activate() + overlay.pick(.laser) + NotificationCenter.default.post(name: Preferences.changed, object: nil) + case "configure": + guard let patch = request["settings"] as? [String: Any], !patch.isEmpty else { throw reject("Settings are required") } + try configure(patch) + case "toggle-sound": Preferences.handSound.toggle() + case "pin": + let style = try readStyle(request["style"]) + let pinned = try readBool(request["pinned"]) + var styles = Preferences.pinnedStyles + if pinned && !styles.contains(style) { + guard styles.count < Preferences.maxPinned else { throw reject("The toolbar already has eight pointers", code: "toolbar-full") } + styles.append(style) + } else if !pinned { + styles.removeAll { $0 == style } + guard !styles.isEmpty else { throw reject("Keep at least one pointer pinned") } + } + Preferences.pinnedStyles = styles + case "settings": openSettings() + case "custom-hands": openHandCreator() + default: break + } + response["ok"] = true + response["state"] = state() + if op == "styles" { response["styles"] = styles() } + } catch { + let problem = error as? Problem + response["ok"] = false + response["error"] = ["code": problem?.code ?? "invalid-request", "message": problem?.message ?? "Malformed control request"] + response["state"] = state() + } + return (try? JSONSerialization.data(withJSONObject: response, options: [.sortedKeys])) ?? Data() + } + + private func activate() throws { + guard accessibility() else { throw reject("Allow TinyHand in System Settings → Privacy & Security → Accessibility", code: "accessibility") } + let wasActive = overlay.isActive + guard overlay.activate() else { + if !wasActive { overlay.deactivate() } + throw reject("TinyHand could not start intercepting mouse input. Relaunch TinyHand and try again.", code: "activation-failed") + } + } + + private func readStyle(_ value: Any?) throws -> PointerStyle { + guard let id = value as? String, let style = PointerStyle(rawValue: id) else { throw reject("This pointer is no longer available. Refresh the pointer list.", code: "unknown-style") } + return style + } + + private func validateAsset(for style: PointerStyle) throws { + if let asset = style.assetName, HandAsset.load(asset) == nil { + throw reject("TinyHand could not load the image for this pointer.", code: "missing-asset") + } + } + + private func readBool(_ value: Any?) throws -> Bool { + guard let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else { throw reject("Expected a boolean") } + return number.boolValue + } + + private func readNumber(_ value: Any) throws -> Double { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), number.doubleValue.isFinite else { throw reject("Expected a finite number") } + return number.doubleValue + } + + /// Validate every field before writing anything; deliver one native settings notification. + private func configure(_ patch: [String: Any]) throws { + let allowed: Set = ["style", "ink", "fade", "size", "shadow", "handSound", "laserSound", "tapSound"] + guard Set(patch.keys).isSubset(of: allowed) else { throw reject("Unsupported setting") } + let style = try patch["style"].map { try readStyle($0) } + if let style { try validateAsset(for: style) } + let ink = patch["ink"].flatMap { ($0 as? String).flatMap(InkChoice.init(rawValue:)) } + if patch["ink"] != nil && ink == nil { throw reject("Unknown ink color") } + let sound = patch["tapSound"].flatMap { ($0 as? String).flatMap(TapSoundChoice.init(rawValue:)) } + if patch["tapSound"] != nil && sound == nil { throw reject("Unknown tap sound") } + let fade = try patch["fade"].map { try readNumber($0) } + if let fade = fade, ![0.0, 3, 6, 10].contains(fade) { throw reject("Fade must be 0, 3, 6, or 10 seconds") } + let size = try patch["size"].map { try readNumber($0) } + if let size = size, !Preferences.pointerSizeRange.contains(CGFloat(size)) { throw reject("Pointer size is outside the native range") } + let shadow = try patch["shadow"].map { try readBool($0) } + let handSound = try patch["handSound"].map { try readBool($0) } + let laserSound = try patch["laserSound"].map { try readBool($0) } + Preferences.performBatch { + if let style = style { PointerStyle.saved = style } + if let ink = ink { Preferences.inkChoice = ink } + if let fade = fade { Preferences.fadeSeconds = fade } + if let size = size { Preferences.pointerSize = CGFloat(size) } + if let shadow = shadow { Preferences.showShadow = shadow } + if let handSound = handSound { Preferences.handSound = handSound } + if let laserSound = laserSound { Preferences.laserSound = laserSound } + if let sound = sound { Preferences.tapSound = sound } + } + } + + private func state() -> [String: Any] { + ["running": true, "active": overlay.isActive, "style": overlay.style.rawValue, + "accessibility": accessibility(), "appVersion": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development", + "capabilities": Self.operations, + "settings": ["ink": Preferences.inkChoice.rawValue, "fade": Preferences.fadeSeconds, + "size": Double(Preferences.pointerSize), "shadow": Preferences.showShadow, + "handSound": Preferences.handSound, "laserSound": Preferences.laserSound, "tapSound": Preferences.tapSound.rawValue], + "sizeRange": [Double(Preferences.pointerSizeRange.lowerBound), Double(Preferences.pointerSizeRange.upperBound)], + "pinned": Preferences.pinnedStyles.map(\.rawValue)] + } + + private func styles() -> [[String: Any]] { + PointerStyle.allCases.map { style in + var item: [String: Any] = ["id": style.rawValue, "name": style.title, "type": style == .laser ? "laser" : style.isCustom ? "custom" : "hand"] + // Use TinyHand's own cropped 128px thumbnails, including custom hands. + if let name = style.assetName, let image = HandAsset.thumbnail(name), + let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) { + // Encode the ID rather than treating custom asset IDs as path components. + let filename = Data(style.rawValue.utf8).map { String(format: "%02x", $0) }.joined() + ".png" + let url = URL(fileURLWithPath: ControlTransport.directory).appendingPathComponent(filename) + if (try? png.write(to: url, options: .atomic)) != nil { item["thumbnail"] = url.path } + } + return item + } + } +} diff --git a/Sources/TinyHand/ControlTransport.swift b/Sources/TinyHand/ControlTransport.swift new file mode 100644 index 0000000..a543639 --- /dev/null +++ b/Sources/TinyHand/ControlTransport.swift @@ -0,0 +1,220 @@ +import AppKit +import Darwin + +/// One newline-delimited JSON request per connection. No network listener. +enum ControlTransport { + static let version = 1 + static let directory: String = { + // confstr returns the same per-user path for LaunchServices and CLI clients. + var path = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard confstr(_CS_DARWIN_USER_TEMP_DIR, &path, path.count) > 0 else { return "" } + #if CONTROL_TEST + let id = "tinyhand-test-\(getpid())" + #else + guard let id = Bundle.main.bundleIdentifier else { return "" } + #endif + return String(cString: path) + id + ".ctl" + }() + static let path = directory + "/s" + static let requestLimit = 16_384 + static let responseLimit = 2_097_152 + + struct Failure: Error { + let message: String + var code = "unavailable" + } + + static func secureDirectory() throws { + guard !directory.isEmpty, + path.utf8CString.count <= MemoryLayout.size(ofValue: sockaddr_un().sun_path) else { + throw Failure(message: "Cannot resolve the TinyHand control endpoint") + } + if mkdir(directory, 0o700) != 0 && errno != EEXIST { + throw Failure(message: "Cannot create control directory") + } + var info = stat() + guard lstat(directory, &info) == 0, info.st_uid == getuid(), + info.st_mode & S_IFMT == S_IFDIR, info.st_mode & 0o777 == 0o700 else { + throw Failure(message: "Unsafe control directory") + } + } + + static func address() -> sockaddr_un { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + addr.sun_len = UInt8(MemoryLayout.size) + withUnsafeMutableBytes(of: &addr.sun_path) { dest in + path.utf8CString.withUnsafeBytes { source in dest.copyBytes(from: source) } + } + return addr + } + + static func configure(_ fd: Int32) { + var timeout = timeval(tv_sec: 1, tv_usec: 500_000) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout))) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout))) + var one: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one))) + _ = fcntl(fd, F_SETFD, FD_CLOEXEC) + } + + static func sameUser(_ fd: Int32) -> Bool { + var uid: uid_t = 0 + var gid: gid_t = 0 + return getpeereid(fd, &uid, &gid) == 0 && uid == getuid() + } + + static func readLine(_ fd: Int32, limit: Int, deadline: DispatchTime) throws -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while data.count <= limit && DispatchTime.now() < deadline { + let count = read(fd, &buffer, min(buffer.count, limit + 1 - data.count)) + if count < 0 && errno == EINTR { continue } + guard count > 0 else { throw Failure(message: "Control connection closed or timed out") } + if let newline = buffer[.. 0 else { throw Failure(message: "Cannot write control response") } + sent += count + } + } + } + + static func request(_ data: Data) throws -> Data { + guard data.count <= requestLimit, !data.contains(10) else { throw Failure(message: "Invalid request size or framing") } + try secureDirectory() + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw Failure(message: "Cannot create control socket") } + defer { close(fd) } + configure(fd) + var addr = address() + let result = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard result == 0 else { + let connectError = errno + if (connectError == ENOENT || connectError == ECONNREFUSED), + let id = Bundle.main.bundleIdentifier, + NSRunningApplication.runningApplications(withBundleIdentifier: id) + .allSatisfy({ $0.processIdentifier == getpid() }) { + throw Failure(message: "TinyHand is not running. Launch the compatible app first.", code: "not-running") + } + throw Failure(message: "Could not connect to TinyHand. Quit any other copies and try again.") + } + guard sameUser(fd) else { throw Failure(message: "Could not verify the TinyHand control peer") } + try writeLine(fd, data: data) + return try readLine(fd, limit: responseLimit, deadline: .now() + .seconds(2)) + } + + static func runCLI(_ arguments: [String]) -> Int32 { + do { + guard arguments.count == 1, let data = arguments[0].data(using: .utf8) else { + throw Failure(message: "Usage: TinyHand --ctl ''") + } + let response = try request(data) + FileHandle.standardOutput.write(response + Data([10])) + let object = try JSONSerialization.jsonObject(with: response) as? [String: Any] + return object?["ok"] as? Bool == true ? 0 : 1 + } catch { + let response: [String: Any] = ["version": version, "ok": false, + "error": ["code": (error as? Failure)?.code ?? "unavailable", "message": (error as? Failure)?.message ?? error.localizedDescription]] + if let data = try? JSONSerialization.data(withJSONObject: response) { + FileHandle.standardOutput.write(data + Data([10])) + } + return 2 + } + } +} + +final class ControlServer { + private var listener: Int32 = -1 + private var lock: Int32 = -1 + private var source: DispatchSourceRead? + private let queue = DispatchQueue(label: "app.tinyhand.control") + + func start(handler: @escaping (Data) -> Data) throws { + try ControlTransport.secureDirectory() + lock = open(ControlTransport.directory + "/server.lock", O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0o600) + guard lock >= 0 else { throw ControlTransport.Failure(message: "Cannot open control lock") } + guard flock(lock, LOCK_EX | LOCK_NB) == 0 else { + close(lock); lock = -1 + throw ControlTransport.Failure(message: "A TinyHand control server is already running") + } + listener = socket(AF_UNIX, SOCK_STREAM, 0) + guard listener >= 0 else { stop(); throw ControlTransport.Failure(message: "Cannot create listener") } + ControlTransport.configure(listener) + unlink(ControlTransport.path) + var addr = ControlTransport.address() + let result = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(listener, $0, socklen_t(MemoryLayout.size)) } + } + guard result == 0, chmod(ControlTransport.path, 0o600) == 0, listen(listener, 8) == 0 else { + stop(); throw ControlTransport.Failure(message: "Cannot bind control socket") + } + guard fcntl(listener, F_SETFL, O_NONBLOCK) == 0 else { + stop(); throw ControlTransport.Failure(message: "Cannot configure the control socket") + } + let fd = listener + let readSource = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + readSource.setEventHandler { + let peer = accept(fd, nil, nil) + guard peer >= 0 else { return } + defer { close(peer) } + guard ControlTransport.sameUser(peer), fcntl(peer, F_SETFL, 0) == 0 else { return } + ControlTransport.configure(peer) + do { + let deadline = DispatchTime.now() + .seconds(1) + let request = try ControlTransport.readLine(peer, limit: ControlTransport.requestLimit, deadline: deadline) + let response = try DispatchQueue.main.sync { + guard DispatchTime.now() < deadline else { + throw ControlTransport.Failure(message: "TinyHand was busy before the request could run") + } + return handler(request) + } + try ControlTransport.writeLine(peer, data: response) + } catch { /* Invalid, oversized, or disconnected clients cannot execute a command. */ } + } + // Release ownership only once pending connection handling has completed. + let lockFD = lock + readSource.setCancelHandler { + close(fd) + unlink(ControlTransport.path) + flock(lockFD, LOCK_UN) + close(lockFD) + } + source = readSource + readSource.resume() + } + + func stop() { + if let source = source { + source.cancel() + self.source = nil + } else { + if listener >= 0 { close(listener) } + if lock >= 0 { flock(lock, LOCK_UN); close(lock) } + } + listener = -1 + lock = -1 + } + + deinit { stop() } +} diff --git a/Sources/TinyHand/Preferences.swift b/Sources/TinyHand/Preferences.swift index 5c04cdc..2801fa0 100644 --- a/Sources/TinyHand/Preferences.swift +++ b/Sources/TinyHand/Preferences.swift @@ -183,9 +183,18 @@ enum Preferences { NotificationCenter.default.post(name: changed, object: nil) } - private static func set(_ value: Any, _ key: String) { + private static var batchDepth = 0 + + static func performBatch(_ body: () -> Void) { + batchDepth += 1 + body() + batchDepth -= 1 + if batchDepth == 0 { NotificationCenter.default.post(name: changed, object: nil) } + } + + private static func set(_ value: Any, _ key: String) { defaults.set(value, forKey: key) - NotificationCenter.default.post(name: changed, object: nil) + if batchDepth == 0 { NotificationCenter.default.post(name: changed, object: nil) } } private static func loadShortcut(_ key: String) -> Shortcut? { diff --git a/Sources/TinyHand/SettingsWindow.swift b/Sources/TinyHand/SettingsWindow.swift index 801406a..b8014a8 100644 --- a/Sources/TinyHand/SettingsWindow.swift +++ b/Sources/TinyHand/SettingsWindow.swift @@ -36,6 +36,11 @@ final class SettingsWindow: NSObject, NSWindowDelegate { window?.makeFirstResponder(nil) } + func showHandCreator() { + model.startMakingHand() + show(.pointers) + } + func snapshot(_ section: SettingsSection = .general, dark: Bool = false) -> CGImage? { if window == nil { build() diff --git a/Sources/TinyHand/main.swift b/Sources/TinyHand/main.swift index 92f758e..8caca05 100644 --- a/Sources/TinyHand/main.swift +++ b/Sources/TinyHand/main.swift @@ -1,6 +1,10 @@ import AppKit import ApplicationServices +if CommandLine.arguments.count > 1 && CommandLine.arguments[1] == "--ctl" { + exit(ControlTransport.runCLI(Array(CommandLine.arguments.dropFirst(2)))) +} + if let idx = CommandLine.arguments.firstIndex(of: "--render-test") { let out = CommandLine.arguments.count > idx + 1 ? CommandLine.arguments[idx + 1] : "/tmp/armtest.png" RenderTest.run(outPath: out) diff --git a/Tests/ControlBridge/Stubs.swift b/Tests/ControlBridge/Stubs.swift new file mode 100644 index 0000000..32b4622 --- /dev/null +++ b/Tests/ControlBridge/Stubs.swift @@ -0,0 +1,47 @@ +import AppKit + +// In-memory native dependencies: tests never touch the installed app, user defaults, or input taps. +enum InkChoice: String { case match, red, orange, yellow, green, blue, purple, white, black } +enum TapSoundChoice: String { case thock, click, pop, wood, boop, squeak } +struct PointerStyle: Hashable { + let rawValue: String + init?(rawValue: String) { + guard ["lavender", "laser", "custom-one"].contains(rawValue) else { return nil } + self.rawValue = rawValue + } + static let laser = PointerStyle(rawValue: "laser")! + static var saved = PointerStyle(rawValue: "lavender")! + static var allCases: [PointerStyle] { [PointerStyle(rawValue: "lavender")!, laser, PointerStyle(rawValue: "custom-one")!] } + var title: String { rawValue } + var isCustom: Bool { rawValue == "custom-one" } + var assetName: String? { nil } +} +enum HandAsset { + static func thumbnail(_ name: String) -> NSImage? { nil } + static func load(_ name: String) -> NSImage? { nil } +} +enum Preferences { + static let changed = Notification.Name("TinyHandPreferencesChanged") + static var inkChoice = InkChoice.match + static var fadeSeconds = 3.0 + static var pointerSize: CGFloat = 0.13 + static var showShadow = true + static var handSound = true + static var laserSound = false + static var tapSound = TapSoundChoice.thock + static let pointerSizeRange: ClosedRange = 0.08...0.20 + static let maxPinned = 8 + static var pinnedStyles = [PointerStyle.saved, PointerStyle.laser] + static var batchCount = 0 + static func performBatch(_ body: () -> Void) { body(); batchCount += 1 } +} +final class OverlayController { + var isActive = false + var canActivate = true + var strokes = 3 + var style: PointerStyle { PointerStyle.saved } + func activate() -> Bool { isActive = true; return canActivate } + func deactivate() { isActive = false } + func clearInk() { strokes = 0 } + func pick(_ style: PointerStyle) { PointerStyle.saved = style } +} diff --git a/Tests/ControlBridge/main.swift b/Tests/ControlBridge/main.swift new file mode 100644 index 0000000..0307bf0 --- /dev/null +++ b/Tests/ControlBridge/main.swift @@ -0,0 +1,78 @@ +import AppKit +import Darwin + +var checks = 0 +func check(_ condition: @autoclosure () -> Bool, _ name: String) { + guard condition() else { fputs("FAIL: \(name)\n", stderr); exit(1) } + checks += 1 +} +let overlay = OverlayController() +var permitted = true +var creatorOpened = false +let bridge = ControlBridge(overlay: overlay, accessibility: { permitted }, openSettings: {}, openHandCreator: { creatorOpened = true }) +func send(_ operation: String, _ fields: [String: Any] = [:]) -> [String: Any] { + var request = fields + request["version"] = 1 + request["operation"] = operation + let data = try! JSONSerialization.data(withJSONObject: request) + return try! JSONSerialization.jsonObject(with: bridge.handle(data)) as! [String: Any] +} +func success(_ response: [String: Any]) -> Bool { response["ok"] as? Bool == true } +check(success(send("custom-hands")) && creatorOpened, "opens native custom hand creator") +check(success(send("on")), "on succeeds") +check(success(send("on")) && overlay.isActive, "on is idempotent") +let selected = overlay.style +check(success(send("clear")) && overlay.strokes == 0 && overlay.isActive && overlay.style == selected, "clear preserves active pointer") +check(success(send("off")) && success(send("off")) && !overlay.isActive, "off is idempotent") +check(success(send("toggle")) && overlay.isActive, "toggle activates") +check(success(send("toggle")) && !overlay.isActive, "toggle deactivates") +permitted = false +check(!success(send("laser")) && overlay.style == selected && !overlay.isActive, "permission failure preserves pointer") +permitted = true +check(success(send("laser")) && overlay.style == .laser && overlay.isActive, "laser activates") +check(Preferences.handSound && !Preferences.laserSound && Preferences.inkChoice == .match, "laser preserves sound and ink") +check(!success(send("style", ["style": "removed", "activate": true])) && overlay.style == .laser, "unknown pointer rejected") +check(success(send("style", ["style": "custom-one", "activate": false])) && overlay.style.isCustom, "custom pointer works") +check(!success(send("configure", ["settings": ["ink": "red", "size": 999]])) && Preferences.inkChoice == .match, "invalid preset changes nothing") +check(!success(send("configure", ["settings": ["handSound": 1]])), "numeric boolean rejected") +check(!success(send("configure", ["settings": ["size": true]])), "boolean size rejected") +check(!success(send("configure", ["settings": ["shell": "anything"]])), "unknown settings rejected") +check(!success(send("clear", ["extra": true])), "unexpected request fields rejected") +check(success(send("configure", ["settings": ["style": "lavender", "ink": "red", "fade": 6, "handSound": false]])), "atomic preset applied") +check(Preferences.batchCount == 1 && overlay.style.rawValue == "lavender" && Preferences.inkChoice == .red && !Preferences.handSound, "one settings batch") +check(success(send("toggle-sound")) && Preferences.handSound && !Preferences.laserSound, "hand sound independent") +check(success(send("pin", ["style": "custom-one", "pinned": true])) && Preferences.pinnedStyles.contains(PointerStyle(rawValue: "custom-one")!), "custom pointer pinned") +let styles = send("styles")["styles"] as! [[String: Any]] +check(styles.count == 3 && styles.contains { $0["type"] as? String == "custom" }, "native style discovery") +overlay.deactivate(); overlay.canActivate = false +check(!success(send("on")) && !overlay.isActive, "input tap failure rolls back activation") + +// Exercise the real private Unix socket with a client on another queue. +let server = ControlServer() +try server.start { bridge.handle($0) } +var secondServerRejected = false +let second = ControlServer() +do { try second.start { $0 } } catch { secondServerRejected = true } +check(secondServerRejected, "second server cannot replace live endpoint") +var info = stat() +check(lstat(ControlTransport.path, &info) == 0 && info.st_mode & 0o777 == 0o600, "socket has private permissions") +DispatchQueue.global().async { + do { + let request = Data("{\"version\":1,\"operation\":\"status\"}".utf8) + let response = try ControlTransport.request(request) + let object = try JSONSerialization.jsonObject(with: response) as! [String: Any] + guard object["ok"] as? Bool == true else { exit(1) } + // An oversized request is rejected before it can reach a native handler. + var rejected = false + do { _ = try ControlTransport.request(Data(repeating: 65, count: ControlTransport.requestLimit + 1)) } catch { rejected = true } + guard rejected else { exit(1) } + DispatchQueue.main.async { + server.stop() + print("Passed \(checks + 2) native bridge checks") + // Allow the cancellation handler to remove its private test socket. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exit(0) } + } + } catch { fputs("Socket test failed: \(error)\n", stderr); exit(1) } +} +DispatchQueue.global().asyncAfter(deadline: .now() + 8) { fputs("Native tests timed out\n", stderr); exit(1) } +dispatchMain() diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 0000000..a4213d7 --- /dev/null +++ b/docs/control-protocol.md @@ -0,0 +1,156 @@ +# Local control protocol + +TinyHand's bundled executable accepts `--ctl` followed by one JSON request. This +allows Raycast, Alfred, scripts, and other local clients to use the existing overlay +and settings without simulating hotkeys or writing preferences from another process. + +The application must already be running. A `--ctl` invocation sends a request to +that instance and exits; it does not create a second overlay or launch the app. + +```sh +APP="/Applications/TinyHand.app" +"$APP/Contents/MacOS/TinyHand" --ctl '{"version":1,"operation":"status"}' +"$APP/Contents/MacOS/TinyHand" --ctl '{"version":1,"operation":"on"}' +"$APP/Contents/MacOS/TinyHand" --ctl '{"version":1,"operation":"style","style":"lavender","activate":true}' +"$APP/Contents/MacOS/TinyHand" --ctl '{"version":1,"operation":"clear"}' +"$APP/Contents/MacOS/TinyHand" --ctl '{"version":1,"operation":"off"}' +``` + +## Compatibility and startup + +Discover the application by bundle identifier `com.kamranahmed.tinyhand`, rather +than assuming the installation path in the examples. Check for integer +`TinyHandControlVersion = 1` in its Info.plist **before** invoking the executable: +older builds may treat unrecognized arguments as a normal app launch. + +When a request returns `not-running`, clients may launch the detected app with +macOS LaunchServices, then retry **status** until it acknowledges readiness. +Use a bounded deadline. An `unavailable` response can mean a starting, busy, or +incompatible running instance; do not spawn additional copies. Keep one copy of +TinyHand running at a time. + +TinyHand still needs its own Accessibility permission to activate input handling. +The IPC client does not need Accessibility permission simply to send commands. +No permission prompts are displayed by the CLI; activation returns an error with +setup guidance when permission is missing. + +## Requests + +Every request requires `version: 1` and an `operation`. Extra fields and unknown +operations are rejected. All app operations execute on the main thread. + +| Operation | Additional fields | Behavior | +| --- | --- | --- | +| `status` | None | Read native state and supported capabilities | +| `styles` | None | Discover built-in hands, laser, and custom hands | +| `on` / `off` | None | Idempotently activate/deactivate | +| `toggle` | None | Toggle the current native state once | +| `clear` | None | Clear ink without changing the pointer or active state | +| `style` | `style` (string), optional `activate` (boolean, default false) | Validate and select a pointer; optionally activate | +| `laser` | None | Activate and select the laser, preserving ink/audio preferences | +| `configure` | `settings` (nonempty object) | Validate all fields, then apply in one settings batch | +| `toggle-sound` | None | Toggle hand sound, leaving laser sound unchanged | +| `pin` | `style` (string), `pinned` (boolean) | Pin/unpin a native toolbar pointer | +| `settings` | None | Open native settings, exiting the overlay as the menu action does | +| `custom-hands` | None | Open the existing custom-hand creator, exiting the overlay | + +Discover style IDs with `styles`; do not infer them from display names. Selection +rejects removed styles or unusable images. Pinning enforces the native maximum of +eight entries and retains at least one pin. + +### Settings batches + +```sh +"$APP/Contents/MacOS/TinyHand" --ctl '{"version":1,"operation":"configure","settings":{"style":"laser","ink":"red","fade":6,"handSound":false}}' +``` + +Allowed settings: + +- `style`: an available pointer ID. +- `ink`: `match`, `red`, `orange`, `yellow`, `green`, `blue`, `purple`, `white`, `black`. +- `fade`: `0` (until cleared), `3`, `6`, or `10` seconds. +- `size`: a finite number within the native `sizeRange` returned by status. +- `shadow`, `handSound`, `laserSound`: booleans. +- `tapSound`: `thock`, `click`, `pop`, `wood`, `boop`, or `squeak`. + +Omitted settings remain unchanged. Every field, including pointer availability, +is validated before any preferences are written. The batch sends one preferences +notification to refresh the running overlay. This supports client-owned presets +without exposing intermediate settings to the event loop. It is not a +crash-durable database transaction. Applying a batch preserves active state. + +## Responses + +Standard output contains one JSON object and a newline. A success includes the +state captured after the operation: + +```json +{ + "version": 1, + "ok": true, + "state": { + "running": true, + "active": false, + "style": "lavender", + "accessibility": true, + "appVersion": "1.0.2", + "capabilities": ["status", "styles", "on", "off", "toggle", "clear", "style", "laser", "configure", "toggle-sound", "pin", "settings", "custom-hands"], + "settings": { + "ink": "match", "fade": 3, "size": 0.13, "shadow": true, + "handSound": true, "laserSound": false, "tapSound": "thock" + }, + "sizeRange": [0.08, 0.2], + "pinned": ["lavender", "glove", "coral", "plush", "robot", "laser"] + } +} +``` + +`styles` additionally returns an array of `{ id, name, type, thumbnail? }` objects. +`type` is `hand`, `custom`, or `laser`. `thumbnail` is an optional local path to +TinyHand's own cropped 128px image. Paths are temporary; clients should rediscover +them and provide a fallback icon. Custom images are never uploaded. + +Errors have `ok: false` and `error: { code, message }`. Native handler errors also +include current `state`. Codes include `invalid-request`, `unknown-style`, +`missing-asset`, `toolbar-full`, `accessibility`, `activation-failed`, +`not-running`, and `unavailable`. Clients should handle unknown codes gracefully. + +Exit codes: **0** acknowledged, **1** native rejection, **2** transport/usage error. +An unavailable connection means the state is unknown, not inactive. + +Do not automatically replay mutations after a timeout or lost connection: the +request may have succeeded before its response was lost. Refresh status first; +prefer explicit `on`/`off` where possible. Replaying `toggle` could reverse the +first action. Clients should also validate the protocol version and returned +state before claiming success. + +## Transport and trust boundary + +The transport uses a Unix domain socket in a private directory under macOS's +per-user temporary directory, scoped by the app's bundle identifier. The directory +must belong to the current user with mode 0700; symlink directories are rejected. +The socket uses mode 0600, and both peers check user identity with `getpeereid`. +An exclusive file lock prevents a second server from replacing a live endpoint. + +Requests are bounded to 16 KiB, responses to 2 MiB on the client, and connection +reads/writes use short deadlines. There is no network port, background state +polling, arbitrary shell execution, unrestricted preference writing, or need for +administrator privileges. Any process running as the same user may use this +interface: this is a same-user boundary, not per-application authorization. + +## Tests + +```sh +bash scripts/test-control.sh +swift build -c release --arch arm64 --arch x86_64 +``` + +The control harness executes the production handler against in-memory native +dependencies, including permission-denial and input-tap-failure cases, and tests +the actual private Unix socket transport. It does not mutate user preferences or +activate the installed app. The full build checks integration with the real +native types. Build CI runs both steps. + +Live validation should additionally cover granted/denied Accessibility, app +startup/shutdown, pointer and ink rendering, custom image addition/removal, rapid +invocations, multiple displays, full-screen apps, and supported macOS versions. diff --git a/scripts/lib.sh b/scripts/lib.sh index 17dd2d6..9033d2f 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -47,6 +47,8 @@ make_bundle() { + TinyHandControlVersion + 1 CFBundleName $APP_NAME CFBundleDisplayName diff --git a/scripts/test-control.sh b/scripts/test-control.sh new file mode 100755 index 0000000..0fb5fa9 --- /dev/null +++ b/scripts/test-control.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tinyhand-tests.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc -D CONTROL_TEST -module-cache-path "$TEST_DIR/cache" \ + "$ROOT/Sources/TinyHand/ControlTransport.swift" "$ROOT/Sources/TinyHand/ControlBridge.swift" \ + "$ROOT/Tests/ControlBridge/Stubs.swift" "$ROOT/Tests/ControlBridge/main.swift" -o "$TEST_DIR/control-tests" +"$TEST_DIR/control-tests"