diff --git a/README.md b/README.md index 5f82a79..233f317 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ TargetBridge 3.0 turns the project into a more complete multi-Mac workspace: - Audio Relay: [docs/Features.md#audio-relay](docs/Features.md#audio-relay) - Input Dockstation, clipboard sync, and master/slave roles: [docs/Features.md#input-dockstation](docs/Features.md#input-dockstation) - Remote brightness control: [docs/Features.md#remote-brightness-control](docs/Features.md#remote-brightness-control) +- CLI & automation (URL scheme, launch args, SSH, login/wake): [docs/Automation.md](docs/Automation.md) - Shared translations and language files: [docs/Features.md#shared-translations](docs/Features.md#shared-translations) - Thunderbolt networking extras (SSH/SFTP, file sharing, Internet Sharing): [docs/Features.md#thunderbolt-networking-extras](docs/Features.md#thunderbolt-networking-extras) @@ -101,6 +102,7 @@ If you build from source, app outputs go into `build/` folder. ## Detailed Documentation - Feature overview: [docs/Features.md](docs/Features.md) +- CLI & automation: [docs/Automation.md](docs/Automation.md) - Addon manifests and capability model: [docs/Addons.md](docs/Addons.md) - Audio transport internals: [docs/audio.md](docs/audio.md) - Hardware, cables, adapters, and Thunderbolt Bridge networking: [docs/Hardware.md](docs/Hardware.md) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift index 1693f73..b91bac4 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift @@ -11,6 +11,10 @@ struct TBDisplaySenderApp: App { .frame(minWidth: 540) .task { statusItemController.activate() + TBSenderAutomation.handleLaunchArguments(CommandLine.arguments) + } + .onOpenURL { url in + TBSenderAutomation.handle(url: url) } } .defaultSize(width: 860, height: 860) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift new file mode 100644 index 0000000..068961f --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -0,0 +1,200 @@ +import AppKit +import Foundation + +// Sender-side automation entry points. +// +// Lets the Sender be driven for scripting / SSH / login & wake automation WITHOUT a +// separate control daemon: it reuses the existing TBDisplaySenderService / session model. +// +// Two equivalent ways in: +// • URL scheme: open "targetbridge://connect?receiver=auto&mode=mirror&preset=native5k" +// open "targetbridge://disconnect" +// • Launch args: TargetBridge --connect --receiver auto --mode mirror --preset native5k +// (handy for a login item / LaunchAgent that should connect on launch) +// +// Both resolve to the same in-process actions on TBDisplaySenderService.shared, so there is +// no parallel connection logic — connect()/stop() are the same paths the GUI uses. +@MainActor +enum TBSenderAutomation { + private static var didHandleLaunchArguments = false + + /// Handle a `targetbridge://` URL (from `.onOpenURL`). + static func handle(url: URL) { + guard url.scheme?.lowercased() == "targetbridge" else { return } + let action = (url.host ?? "").lowercased() + var params: [String: String] = [:] + if let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { + for item in items where item.value != nil { + params[item.name.lowercased()] = item.value + } + } + run(action: action, params: params) + } + + /// Handle process launch arguments. No-op for a normal launch (no `--connect`/`--disconnect`). + /// Runs at most once per process so a second window / state restoration can't re-trigger it. + static func handleLaunchArguments(_ arguments: [String]) { + guard !didHandleLaunchArguments else { return } + didHandleLaunchArguments = true + var action: String? + var params: [String: String] = [:] + var index = 1 + while index < arguments.count { + let arg = arguments[index] + if arg == "--connect" { + action = "connect" + } else if arg == "--disconnect" { + action = "disconnect" + } else if arg.hasPrefix("--") { + let key = String(arg.dropFirst(2)).lowercased() + if index + 1 < arguments.count, !arguments[index + 1].hasPrefix("--") { + params[key] = arguments[index + 1] + index += 1 + } else { + params[key] = "" + } + } + index += 1 + } + guard let action else { return } + run(action: action, params: params) + } + + // MARK: - Dispatch + + private static func run(action: String, params: [String: String]) { + switch action { + case "connect": + Task { await connect(params) } + case "disconnect": + disconnect(params) + default: + NSLog("[automation] unknown action '\(action)' (expected connect|disconnect)") + } + } + + private static func connect(_ params: [String: String]) async { + let service = TBDisplaySenderService.shared + guard let session = resolveSession(service, params["session"]) else { return } + + if let transport = params["transport"] { + session.transportKind = parseTransport(transport) + } + + let receiver = params["receiver"].flatMap { $0.isEmpty ? nil : $0 } ?? "auto" + if receiver.lowercased() == "auto" { + guard let discovered = await waitForReceiver(service) else { + NSLog("[automation] no receivers discovered; aborting connect") + return + } + service.applyDiscoveredReceiver(discovered, to: session) + session.selectedReceiverID = discovered.id + } else if let discovered = service.discoveredReceivers.first(where: { matches(receiver, $0) }) { + service.applyDiscoveredReceiver(discovered, to: session) + session.selectedReceiverID = discovered.id + } else { + // Treat as a raw IP / hostname (bypasses Bonjour). + session.receiverIP = receiver + session.selectedReceiverID = "" + } + + if let localIP = (params["localip"] ?? params["local-ip"]), !localIP.isEmpty { + session.localInterfaceIP = localIP + } + if session.localInterfaceIP.isEmpty { + session.localInterfaceIP = service.defaultLocalInterfaceIP(for: session.transportKind) + } + + if let mode = params["mode"] { + if let source = parseMode(mode) { session.captureSource = source } + else { NSLog("[automation] unknown mode '\(mode)' (ignored)") } + } + if let presetName = params["preset"] { + if let preset = parsePreset(presetName) { session.capturePreset = preset } + else { NSLog("[automation] unknown preset '\(presetName)' (ignored)") } + } + + guard !session.receiverIP.isEmpty else { + NSLog("[automation] no receiver IP resolved; aborting connect") + return + } + guard !session.localInterfaceIP.isEmpty else { + NSLog("[automation] no local interface for transport \(session.transportKind.rawValue); aborting connect") + return + } + NSLog("[automation] connecting to \(session.receiverIP) via \(session.transportKind.rawValue) — \(session.captureSource.rawValue)/\(session.capturePreset.rawValue)") + session.connect() + } + + private static func disconnect(_ params: [String: String]) { + let service = TBDisplaySenderService.shared + if let index = params["session"].flatMap({ Int($0) }), index >= 1, index <= service.sessions.count { + service.sessions[index - 1].stop(persistArrangement: true) + } else { + service.stopAll() + } + } + + // MARK: - Helpers + + private static func resolveSession(_ service: TBDisplaySenderService, _ raw: String?) -> TBDisplaySenderSession? { + if service.sessions.isEmpty { + service.addSession() + } + guard !service.sessions.isEmpty else { return nil } + let index = (raw.flatMap { Int($0) } ?? 1) - 1 + guard index >= 0, index < service.sessions.count else { return service.sessions.first } + return service.sessions[index] + } + + /// Discovery is async (Bonjour); briefly wait for the first receiver to appear. + private static func waitForReceiver(_ service: TBDisplaySenderService) async -> TBDiscoveredReceiver? { + for _ in 0..<20 { + if let first = service.discoveredReceivers.first { return first } + try? await Task.sleep(nanoseconds: 300_000_000) + } + return service.discoveredReceivers.first + } + + private static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { + let needle = value.lowercased() + if receiver.id.lowercased() == needle { return true } + if receiver.receiverName.lowercased() == needle { return true } + if let host = receiver.shortHostName?.lowercased(), host == needle { return true } + return receiver.preferredIP.lowercased() == needle + || receiver.thunderboltIP.lowercased() == needle + || receiver.networkIP.lowercased() == needle + } + + private static func parseTransport(_ value: String) -> TBTransportKind { + switch value.lowercased() { + case "net", "network", "networklink", "link": + return .networkLink + default: + return .thunderboltBridge + } + } + + private static func parseMode(_ value: String) -> TBDisplayCaptureSource? { + switch value.lowercased() { + case "extended", "extend", "extendeddesktop", "ext": + return .extendedDesktop + case "mirror", "mirrored", "desktopmirror": + return .desktopMirror + default: + return TBDisplayCaptureSource(rawValue: value) + } + } + + private static func parsePreset(_ value: String) -> TBDisplayCapturePreset? { + if let preset = TBDisplayCapturePreset(rawValue: value) { return preset } + switch value.lowercased() { + case "1440p", "1440", "standard": return .standard1440p + case "1440p60", "smooth", "smooth1440": return .smooth1440p60 + case "1800p", "1800p60", "smooth1800": return .smooth1800p60 + case "2160p", "2160p60", "4k", "crisp": return .crisp2160p60 + case "5k", "native", "5120x2880": return .native5k + default: return nil + } + } +} diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index cbdf752..6aadd2e 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; }; 7FB34AA1E75B01E7D1EC3E21 /* it.json in Resources */ = {isa = PBXBuildFile; fileRef = 036E14097FA59989FFC456FD /* it.json */; }; 8A729C68ECAC4642FA20A284 /* TBLocalizationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C932E960346E674A0659624 /* TBLocalizationStore.swift */; }; + 8D9D2A40A1CD90BD4D4DFC2C /* TBDisplaySenderAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */; }; 96DDF119DE60DFBA437D32D2 /* en.json in Resources */ = {isa = PBXBuildFile; fileRef = D386D0FCD0F93494220CC3DC /* en.json */; }; A3FD74604472363535D63273 /* TBDisplaySenderSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */; }; B9FA5989F04108E9FBFA8D3B /* TBDisplaySenderApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD235429FA67D231F565C93E /* TBDisplaySenderApp.swift */; }; @@ -51,6 +52,7 @@ 6AC1AF99431D4889F947CE1B /* TBDisplaySenderStatusItemController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderStatusItemController.swift; sourceTree = ""; }; 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputRelayController.swift; sourceTree = ""; }; 80543A7A5A3B583C1543ABC7 /* TBInputDebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputDebugLog.swift; sourceTree = ""; }; + 9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderAutomation.swift; sourceTree = ""; }; 9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderLocalization.swift; sourceTree = ""; }; A3964153C9F34085F90C2C26 /* audio-relay.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "audio-relay.json"; sourceTree = ""; }; A51B69D318A85FF3061D6ED4 /* input-dockstation.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "input-dockstation.json"; sourceTree = ""; }; @@ -82,6 +84,7 @@ C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */, 374B5A5AA247C6922BD9AF72 /* TBDisplaySenderAboutView.swift */, CD235429FA67D231F565C93E /* TBDisplaySenderApp.swift */, + 9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */, CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */, 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */, 9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */, @@ -257,6 +260,7 @@ E81B28F81AE82FF904E0871F /* TBAddonStore.swift in Sources */, E4967472D5CB13242FE2F288 /* TBDisplaySenderAboutView.swift in Sources */, B9FA5989F04108E9FBFA8D3B /* TBDisplaySenderApp.swift in Sources */, + 8D9D2A40A1CD90BD4D4DFC2C /* TBDisplaySenderAutomation.swift in Sources */, 4290B6572B8CA96E01C56425 /* TBDisplaySenderBuildInfo.swift in Sources */, 5C0CB53005B6D67362F14719 /* TBDisplaySenderContentView.swift in Sources */, BBFD76A352FEDC54029B2158 /* TBDisplaySenderLocalization.swift in Sources */, @@ -284,15 +288,13 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = TargetBridge; - INFOPLIST_KEY_NSHumanReadableCopyright = "TargetBridge - Free & Open Source"; - INFOPLIST_KEY_NSScreenCaptureUsageDescription = "TargetBridge requires screen recording permission to capture the virtual display or mirror the desktop to the iMac."; + INFOPLIST_FILE = TargetBridgeSupport/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 3.0; + MARKETING_VERSION = 3.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.targetbridge.sender; PRODUCT_NAME = TargetBridge; SDKROOT = macosx; @@ -310,15 +312,13 @@ CURRENT_PROJECT_VERSION = 1; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = TargetBridge; - INFOPLIST_KEY_NSHumanReadableCopyright = "TargetBridge - Free & Open Source"; - INFOPLIST_KEY_NSScreenCaptureUsageDescription = "TargetBridge requires screen recording permission to capture the virtual display or mirror the desktop to the iMac."; + INFOPLIST_FILE = TargetBridgeSupport/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 3.0; + MARKETING_VERSION = 3.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.targetbridge.sender; PRODUCT_NAME = TargetBridge; SDKROOT = macosx; diff --git a/TargetBridge-Sender/TargetBridgeSupport/Info.plist b/TargetBridge-Sender/TargetBridgeSupport/Info.plist new file mode 100644 index 0000000..72c6cc4 --- /dev/null +++ b/TargetBridge-Sender/TargetBridgeSupport/Info.plist @@ -0,0 +1,25 @@ + + + + + + CFBundleDisplayName + TargetBridge + NSHumanReadableCopyright + TargetBridge - Free & Open Source + NSScreenCaptureUsageDescription + TargetBridge requires screen recording permission to capture the virtual display or mirror the desktop to the iMac. + CFBundleURLTypes + + + CFBundleURLName + com.targetbridge.sender + CFBundleURLSchemes + + targetbridge + + + + + diff --git a/TargetBridge-Sender/project.yml b/TargetBridge-Sender/project.yml index 75872f1..544c507 100644 --- a/TargetBridge-Sender/project.yml +++ b/TargetBridge-Sender/project.yml @@ -24,9 +24,9 @@ targets: base: PRODUCT_NAME: "TargetBridge" PRODUCT_BUNDLE_IDENTIFIER: com.targetbridge.sender - INFOPLIST_KEY_CFBundleDisplayName: "TargetBridge" - INFOPLIST_KEY_NSHumanReadableCopyright: "TargetBridge - Free & Open Source" - INFOPLIST_KEY_NSScreenCaptureUsageDescription: "TargetBridge requires screen recording permission to capture the virtual display or mirror the desktop to the iMac." + # CFBundleDisplayName / NSHumanReadableCopyright / NSScreenCaptureUsageDescription now + # live in TargetBridgeSupport/Info.plist (Xcode drops INFOPLIST_KEY_* usage strings once + # INFOPLIST_FILE is set; the file is the single source of truth for those + the URL scheme). MARKETING_VERSION: "3.0.1" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "6.0" @@ -34,6 +34,8 @@ targets: SWIFT_STRICT_CONCURRENCY: minimal SWIFT_OBJC_BRIDGING_HEADER: TargetBridgeSupport/TargetBridge-Bridging-Header.h GENERATE_INFOPLIST_FILE: YES + # Base plist merged with the auto-generated keys; declares the targetbridge:// URL scheme. + INFOPLIST_FILE: TargetBridgeSupport/Info.plist ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon preBuildScripts: - name: Stamp Sender Build Info diff --git a/cli/targetbridge b/cli/targetbridge new file mode 100755 index 0000000..12cd270 --- /dev/null +++ b/cli/targetbridge @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +# targetbridge — drive the TargetBridge Sender from the command line. +# +# Thin wrapper over the app's `targetbridge://` URL scheme, so it reuses the exact +# in-app connect/disconnect paths (no separate control logic). Works whether the +# Sender is already running or not (LaunchServices launches it on demand). +# +# Usage: +# targetbridge connect [--receiver auto|] [--mode mirror|extended] +# [--preset ] [--transport tb|net] [--session N] [--local-ip ] +# targetbridge disconnect [--session N] +# +# Examples: +# targetbridge connect # auto receiver, app defaults +# targetbridge connect --receiver auto --mode mirror --preset 1440p +# targetbridge connect --receiver 169.254.0.2 --mode extended --preset 5k +# targetbridge disconnect +# +# Presets: standard1440p | smooth1440p60 | smooth1800p60 | crisp2160p60 | native5k +# (aliases: 1440p, 1440p60, 1800p, 4k, 5k) +# +# Remote / SSH: run it on the sender over SSH (the sender must be at a logged-in +# desktop). Because the URL must open in that GUI session, wrap it with launchctl: +# ssh @ "launchctl asuser \$(id -u ) open 'targetbridge://connect?receiver=auto&mode=mirror&preset=1440p'" + +prog="$(basename "$0")" + +usage() { + # Print the leading comment block (from line 4 to the first non-comment line), stripped of '# '. + awk 'NR>3 && /^#/ {sub(/^# ?/, ""); print; next} NR>3 {exit}' "$0" + exit "${1:-0}" +} + +urlencode() { + local s="$1" out="" c i hex + for (( i = 0; i < ${#s}; i++ )); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9.~_-]) out+="$c" ;; + *) printf -v hex '%%%02X' "'$c"; out+="$hex" ;; + esac + done + printf '%s' "$out" +} + +[ $# -ge 1 ] || usage 1 +action="$1"; shift +case "$action" in + connect|disconnect) ;; + -h|--help|help) usage 0 ;; + *) echo "$prog: unknown command '$action'" >&2; usage 1 ;; +esac + +query="" +add() { [ -n "$query" ] && query+="&"; query+="$1=$(urlencode "$2")"; } + +while [ $# -gt 0 ]; do + case "$1" in + --receiver) add receiver "${2:-}"; shift 2 ;; + --mode) add mode "${2:-}"; shift 2 ;; + --preset) add preset "${2:-}"; shift 2 ;; + --transport) add transport "${2:-}"; shift 2 ;; + --session) add session "${2:-}"; shift 2 ;; + --local-ip) add localIP "${2:-}"; shift 2 ;; + -h|--help) usage 0 ;; + *) echo "$prog: unknown option '$1'" >&2; usage 1 ;; + esac +done + +url="targetbridge://$action" +[ -n "$query" ] && url+="?$query" +exec open "$url" diff --git a/docs/Automation.md b/docs/Automation.md new file mode 100644 index 0000000..7c387ac --- /dev/null +++ b/docs/Automation.md @@ -0,0 +1,97 @@ +# CLI & Automation (Sender) + +TargetBridge's Sender can be driven without the GUI — for scripting, SSH, or connecting +automatically on login/wake. It reuses the same in-app connect/disconnect paths the GUI +uses, so there's no separate control logic to keep in sync. + +There are two equivalent entry points, plus a small CLI wrapper: + +## 1. `targetbridge` CLI + +A thin wrapper (in [`cli/targetbridge`](../cli/targetbridge)) over the URL scheme. Put it on your `PATH`: + +```bash +install -m 0755 cli/targetbridge /usr/local/bin/targetbridge # or ~/bin, etc. +``` + +```bash +targetbridge connect # auto-pick receiver, app defaults +targetbridge connect --receiver auto --mode mirror --preset 1440p +targetbridge connect --receiver 169.254.0.2 --mode extended --preset 5k +targetbridge disconnect +``` + +Options: `--receiver auto|`, `--mode mirror|extended`, `--preset `, +`--transport tb|net`, `--session N`, `--local-ip `. +Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `native5k` +(aliases: `1440p`, `1440p60`, `1800p`, `4k`, `5k`). A receiver of `auto` waits briefly for +Bonjour discovery and uses the first receiver found; a raw IP/hostname bypasses discovery. + +It launches the Sender on demand and works whether the app is already running or not. + +## 2. URL scheme + +The CLI just builds and opens a `targetbridge://` URL — you can use these directly +(in Shortcuts, Raycast, a `.command` file, etc.): + +``` +targetbridge://connect?receiver=auto&mode=mirror&preset=native5k +targetbridge://connect?receiver=&mode=extended&preset=1440p&session=1 +targetbridge://disconnect +``` + +```bash +open "targetbridge://connect?receiver=auto&mode=mirror&preset=1440p" +``` + +## 3. Launch arguments (connect on launch / login item) + +Passing `--connect` (and the same options) to the app at launch connects once it's up — +handy for a Login Item or LaunchAgent: + +```bash +open -a TargetBridge --args --connect --receiver auto --mode mirror --preset 1440p +``` + +## Recipes + +**Connect from another Mac over SSH** — the Sender must be at a logged-in desktop with +TargetBridge installed. The URL has to open inside that GUI session, so wrap it with +`launchctl asuser`: + +```bash +ssh @ \ + "launchctl asuser \$(id -u ) open 'targetbridge://connect?receiver=auto&mode=mirror&preset=1440p'" +``` + +**Auto-connect on wake (Hammerspoon, on the sender):** + +```lua +hs.caffeinate.watcher.new(function(e) + local w = hs.caffeinate.watcher + if e == w.systemDidWake or e == w.screensDidUnlock then + hs.timer.doAfter(2, function() + hs.execute("open 'targetbridge://connect?receiver=auto&mode=mirror&preset=1440p'") + end) + end +end):start() +``` + +**Arrange the display after connecting (displayplacer):** TargetBridge restores its saved +extended-desktop arrangement at connect time, so apply your own layout *after* the stream +is up (e.g. poll until the display appears, then run your saved `displayplacer "..."` command). + +## Notes + +- The Sender's capture pipeline requires a logged-in GUI session; these entry points + signal the app inside that session — they do not (and cannot) start screen capture from a + pure headless context. +- `auto` requires the receiver to be discoverable over Bonjour (`_targetbridge._tcp`). On + first use, grant the Sender Screen Recording permission as usual. +- These commands are fire-and-forget: the CLI / `open` returns as soon as the URL is + delivered, which is **not** the same as "streaming established." Check the app (or its + log: `log show --predicate 'eventMessage CONTAINS "[automation]"' --last 2m`) to confirm. +- `connect` without `--session` targets session 1 and updates its saved receiver, same as + changing it in the GUI. +- On a cold launch, `receiver=auto` waits briefly for Bonjour; if discovery is slow on the + first run, pass an explicit `--receiver ` to skip the wait.