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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
200 changes: 200 additions & 0 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
16 changes: 8 additions & 8 deletions TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -51,6 +52,7 @@
6AC1AF99431D4889F947CE1B /* TBDisplaySenderStatusItemController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderStatusItemController.swift; sourceTree = "<group>"; };
7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputRelayController.swift; sourceTree = "<group>"; };
80543A7A5A3B583C1543ABC7 /* TBInputDebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputDebugLog.swift; sourceTree = "<group>"; };
9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderAutomation.swift; sourceTree = "<group>"; };
9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderLocalization.swift; sourceTree = "<group>"; };
A3964153C9F34085F90C2C26 /* audio-relay.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "audio-relay.json"; sourceTree = "<group>"; };
A51B69D318A85FF3061D6ED4 /* input-dockstation.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "input-dockstation.json"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions TargetBridge-Sender/TargetBridgeSupport/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Merged with Xcode's auto-generated Info.plist (GENERATE_INFOPLIST_FILE).
Holds the URL scheme + the string keys Xcode no longer injects once INFOPLIST_FILE is set. -->
<key>CFBundleDisplayName</key>
<string>TargetBridge</string>
<key>NSHumanReadableCopyright</key>
<string>TargetBridge - Free &amp; Open Source</string>
<key>NSScreenCaptureUsageDescription</key>
<string>TargetBridge requires screen recording permission to capture the virtual display or mirror the desktop to the iMac.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.targetbridge.sender</string>
<key>CFBundleURLSchemes</key>
<array>
<string>targetbridge</string>
</array>
</dict>
</array>
</dict>
</plist>
8 changes: 5 additions & 3 deletions TargetBridge-Sender/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ 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"
ENABLE_HARDENED_RUNTIME: NO
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
Expand Down
Loading