diff --git a/.github/actions/import-macos-signing-certificate/action.yml b/.github/actions/import-macos-signing-certificate/action.yml new file mode 100644 index 00000000..4739548e --- /dev/null +++ b/.github/actions/import-macos-signing-certificate/action.yml @@ -0,0 +1,30 @@ +name: Import macOS signing certificate +description: Imports the release signing certificate and exports its persistent identity. +inputs: + certificate-base64: + required: true + certificate-password: + required: true +runs: + using: composite + steps: + - shell: bash + env: + CERTIFICATE_BASE64: ${{ inputs.certificate-base64 }} + CERTIFICATE_PASSWORD: ${{ inputs.certificate-password }} + run: | + set -euo pipefail + certificate_path="$RUNNER_TEMP/targetbridge-signing.p12" + keychain_path="$RUNNER_TEMP/targetbridge-signing.keychain-db" + keychain_password="$(uuidgen)" + printf '%s' "$CERTIFICATE_BASE64" | base64 -D > "$certificate_path" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" -k "$keychain_path" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security list-keychain -d user -s "$keychain_path" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" + identity="$(security find-identity -v -p codesigning "$keychain_path" | sed -nE 's/.* ([A-F0-9]{40}) .*/\1/p' | head -n1)" + test -n "$identity" + echo "TARGETBRIDGE_CODESIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "Imported TargetBridge signing identity ${identity:0:8}..." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c14519ba..9916328a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI Build Verification on: push: - branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0 ] + branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0, dev-3.6.0 ] pull_request: - branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0 ] + branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0, dev-3.6.0 ] jobs: build-sender: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d693a54a..0ab2c331 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ permissions: jobs: build-sender: name: Build Sender (${{ matrix.os }} - ${{ matrix.arch }}) + env: + TARGETBRIDGE_SIGNING_CONFIGURED: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 != '' }} strategy: matrix: include: @@ -29,10 +31,21 @@ jobs: run: | brew install xcodegen + - name: Import release signing certificate + if: ${{ env.TARGETBRIDGE_SIGNING_CONFIGURED == 'true' }} + uses: ./.github/actions/import-macos-signing-certificate + with: + certificate-base64: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 }} + certificate-password: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_PASSWORD }} + - name: Build Sender App run: | cd TargetBridge-Sender - ./scripts/build_targetbridge_sender_app.sh + TB_BUILD="$(grep -m1 'CURRENT_PROJECT_VERSION:' project.yml | sed -E 's/.*\"([^\"]+)\".*/\1/')" + test -n "$TB_BUILD" + REQUIRE_PERSISTENT_SIGNING=0 + [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && REQUIRE_PERSISTENT_SIGNING=1 + TB_SENDER_BUILD="$TB_BUILD" TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING="$REQUIRE_PERSISTENT_SIGNING" ./scripts/build_targetbridge_sender_app.sh file ../build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Zip App Bundle @@ -55,6 +68,8 @@ jobs: build-receiver: name: Build Receiver (macOS ${{ matrix.arch }}) runs-on: ${{ matrix.os }} + env: + TARGETBRIDGE_SIGNING_CONFIGURED: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 != '' }} strategy: matrix: include: @@ -70,10 +85,21 @@ jobs: run: | brew install ffmpeg sdl2 sdl3 dylibbundler + - name: Import release signing certificate + if: ${{ env.TARGETBRIDGE_SIGNING_CONFIGURED == 'true' }} + uses: ./.github/actions/import-macos-signing-certificate + with: + certificate-base64: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_BASE64 }} + certificate-password: ${{ secrets.TARGETBRIDGE_CODESIGN_P12_PASSWORD }} + - name: Build Receiver App run: | cd TargetBridge-Receiver - ./scripts/build_tbreceiver_c_app.sh + TB_BUILD="$(grep -m1 'CURRENT_PROJECT_VERSION:' ../TargetBridge-Sender/project.yml | sed -E 's/.*\"([^\"]+)\".*/\1/')" + test -n "$TB_BUILD" + REQUIRE_PERSISTENT_SIGNING=0 + [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && REQUIRE_PERSISTENT_SIGNING=1 + APP_BUILD="$TB_BUILD" TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING="$REQUIRE_PERSISTENT_SIGNING" ./scripts/build_tbreceiver_c_app.sh - name: Zip App Bundle run: | diff --git a/README.md b/README.md index 5ce5146c..97e31c27 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,23 @@ and improves the project. A special thank-you for this release goes to: - [@preggocl](https://github.com/preggocl) for the physical Receiver display profile and the software decode fallback. - [@Betafer](https://github.com/Betafer) for input-queue safety, cursor work, idle audio/display handling, watchdog protection, monitor shielding, and build improvements. -## TargetBridge 3.5.2 +## TargetBridge 3.6.0 -TargetBridge 3.5.2 is a focused maintenance update to the Input Dockstation. -When the Receiver is the input master, Shift and Command now remain active for -multi-file selection even when the keyboard is connected to the Receiver and -the mouse or trackpad is used locally on the Sender. This fixes Finder range -selection and non-contiguous selection without changing ordinary local input. +TargetBridge 3.6.0 improves everyday connection setup and makes the display +pipeline more adaptable without changing the stable default workflows: -It also includes the video queue, capture-health and permission-status fixes -prepared for this maintenance line. For the wider 3.5.1 maintenance work, -including Receiver display profiles, idle resource handling, and compatibility -with older Macs: +- remembers a discovered Receiver by its stable identity instead of losing the + selection when its Thunderbolt or network IP changes +- offers per-Receiver H.264, HEVC, or automatic hardware codec selection with + a safe fallback when a selected codec is unavailable +- adds `Full HD` and `Full HD 60` 1:1 stream profiles for non-Retina 1080p + receivers +- uses direct virtual-display capture in a genuinely headless mirror session, + while keeping system-audio relay active +- adds a built-in permission assistant for Screen Recording, Accessibility, + and Input Monitoring + +The release also retains the 3.5 maintenance work: - preserve keyboard and mouse release events when the Receiver input queue is under pressure - preserve native macOS cursor shapes by default, including the screenshot crosshair; the custom overlay is reserved for the explicit large-cursor option @@ -61,8 +66,8 @@ with older Macs: - keep local notification banners behind the active monitor surface across display/fullscreen transitions - package SDL runtimes safely and support explicit build/version overrides -See the [full 3.5.2 release notes](docs/releases/3.5.2.md), the -[3.5.1 release notes](docs/releases/3.5.1.md), and the +See the [full 3.6.0 release notes](docs/releases/3.6.0.md), the +[3.5.2 release notes](docs/releases/3.5.2.md), and the [testing guide](docs/Testing.md), including the new monitor-shield lifecycle test. It also keeps the established multi-Mac workspace features: @@ -91,6 +96,8 @@ It also keeps the established multi-Mac workspace features: - Overview hub: [docs/Features.md](docs/Features.md) - Mirror mode and Extended Desktop: [docs/Features.md#display-modes](docs/Features.md#display-modes) - Guided configuration check: [docs/Features.md#guided-configuration-check](docs/Features.md#guided-configuration-check) +- Stream profiles and per-Receiver codecs: [docs/Features.md#stream-profiles-and-codecs](docs/Features.md#stream-profiles-and-codecs) +- Permission assistant: [docs/Features.md#permission-assistant](docs/Features.md#permission-assistant) - Multi-receiver layouts: [docs/Features.md#multi-receiver-workflows](docs/Features.md#multi-receiver-workflows) - Network Link (experimental): [docs/Features.md#network-link-experimental](docs/Features.md#network-link-experimental) - Audio Relay: [docs/Features.md#audio-relay](docs/Features.md#audio-relay) @@ -151,6 +158,7 @@ If you build from source, app outputs go into `build/` folder. - `Input Dockstation` may also require `Accessibility` and `Input Monitoring`, depending on the active role. - Receiver may require `Accessibility` or `Input Monitoring` when it participates in input relay. - In practice, `Input Dockstation` is a two-sided feature: one Mac captures input, the other injects it, so permissions may be needed on both Sender and Receiver. +- On the Sender, use the `Permissions` toolbar button to review live status and request the native macOS prompts. macOS still requires confirmation from the person using the Mac; TargetBridge cannot grant or overwrite these permissions automatically. Official updates keep a stable app identity so future releases can retain consent already granted to the app installed in `Applications`. See [Signing and permission continuity](docs/Sender-Signing.md). - See [docs/Addons.md#input-dockstation](docs/Addons.md#input-dockstation) for the permission matrix. ## Quick start diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 8660b5a5..9fb161d0 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -16,7 +16,7 @@ CC ?= clang PKG ?= pkgconf -APP_VERSION ?= 3.5.2 +APP_VERSION ?= 3.6.0 APP_BUILD ?= dev MACOSX_DEPLOYMENT_TARGET ?= 11.0 diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index e432ee95..2805f916 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -21,7 +21,7 @@ #include #ifndef TB_RECEIVER_VERSION -#define TB_RECEIVER_VERSION "3.5.2" +#define TB_RECEIVER_VERSION "3.6.0" #endif #ifndef TB_RECEIVER_BUILD diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index 377cf184..43b9edfd 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -88,6 +88,7 @@ struct app { char net_ip_text[64]; char ethernet_ip_text[64]; char wifi_ip_text[64]; + char receiver_id[64]; char display_host[128]; /* short hostname (or hostname+IP), cached at startup */ char status_text[128]; char sender_text[128]; @@ -574,6 +575,41 @@ static void bonjour_deinit(struct app *a) { } } +static int tb_receiver_load_or_create_id(char *dest, size_t size, CFStringRef app_id) { + if (!dest || size == 0) return -1; + dest[0] = '\0'; + if (size < 37 || !app_id) return -1; + + CFStringRef key = CFSTR("receiverID"); + CFPropertyListRef stored = CFPreferencesCopyAppValue(key, app_id); + CFUUIDRef uuid = NULL; + if (stored && CFGetTypeID(stored) == CFStringGetTypeID() && + CFStringGetLength((CFStringRef)stored) == 36) { + uuid = CFUUIDCreateFromString(kCFAllocatorDefault, (CFStringRef)stored); + } + if (stored) CFRelease(stored); + + const Boolean needs_persistence = uuid == NULL; + if (!uuid) uuid = CFUUIDCreate(kCFAllocatorDefault); + if (!uuid) return -1; + CFStringRef value = CFUUIDCreateString(kCFAllocatorDefault, uuid); + CFRelease(uuid); + if (!value) return -1; + + const Boolean copied = CFStringGetCString(value, dest, (CFIndex)size, kCFStringEncodingUTF8); + Boolean persisted = true; + if (copied && dest[0] != '\0' && needs_persistence) { + CFPreferencesSetAppValue(key, value, app_id); + persisted = CFPreferencesAppSynchronize(app_id); + } + CFRelease(value); + if (!copied || !persisted) { + dest[0] = '\0'; + return -1; + } + return dest[0] != '\0' ? 0 : -1; +} + static void on_bonjour_register(DNSServiceRef sdRef, DNSServiceFlags flags, DNSServiceErrorType errorCode, @@ -599,6 +635,9 @@ static void bonjour_update(struct app *a, uint16_t port) { TXTRecordRef txt; TXTRecordCreate(&txt, 0, NULL); TXTRecordSetValue(&txt, "name", (uint8_t)strlen(a->bonjour_name), a->bonjour_name); + if (a->receiver_id[0] != '\0') { + TXTRecordSetValue(&txt, "receiverID", (uint8_t)strlen(a->receiver_id), a->receiver_id); + } TXTRecordSetValue(&txt, "ip", (uint8_t)strlen(a->ip_text), a->ip_text); if (a->tb_ip_text[0] != '\0') { TXTRecordSetValue(&txt, "tbIP", (uint8_t)strlen(a->tb_ip_text), a->tb_ip_text); @@ -1968,6 +2007,12 @@ int main(int argc, char **argv) { memset(&a, 0, sizeof(a)); a.server_fd = -1; a.client_fd = -1; + if (tb_receiver_load_or_create_id(a.receiver_id, sizeof(a.receiver_id), + CFSTR("com.targetbridge.receiver")) == 0) { + fprintf(stderr, "[identity] receiver ID ready\n"); + } else { + fprintf(stderr, "[identity] unable to persist receiver ID; using Bonjour name fallback\n"); + } { char host[96] = {0}; if (gethostname(host, sizeof(host)) != 0 || host[0] == '\0') { diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index 289e46cd..025807ef 100755 --- a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh +++ b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh @@ -8,12 +8,25 @@ BUILD_DIR="${REPO_ROOT}/build" APP_DIR="${BUILD_DIR}/TargetBridge Receiver.app" BIN_NAME="TargetBridgeReceiver" APP_NAME="TargetBridge Receiver" -APP_VERSION="${APP_VERSION:-3.5.2}" +APP_VERSION="${APP_VERSION:-3.6.0}" STAMP="${APP_BUILD:-$(date +%Y%m%d%H%M%S)}" ARCH="$(uname -m)" ICONSET_DIR="$(mktemp -d)" ICON_FILE="${ROOT}/TargetBridgeAssets/Assets.xcassets/AppIcon.appiconset/icon_1024.png" ICNS_PATH="${APP_DIR}/Contents/Resources/TargetBridgeReceiver.icns" +SIGNING_CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/receiver-signing-identity.txt}" + +# Development builds remain possible without a certificate. A configured or +# required release build must never silently fall back to a new ad-hoc identity. +if [[ -n "${TARGETBRIDGE_CODESIGN_IDENTITY:-}" || + -n "${TARGETBRIDGE_SIGNING_CONFIG:-}" || -e "$SIGNING_CONFIG" || + "${TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING:-0}" == 1 ]]; then + "$SCRIPT_DIR/sign_targetbridge_receiver_app.sh" --check-identity +else + echo "WARNING: unconfigured disposable build; this ad-hoc app is not an identity-preserving update." >&2 + export TARGETBRIDGE_CODESIGN_IDENTITY=- + export TARGETBRIDGE_ALLOW_ADHOC=1 +fi cd "$ROOT/TBReceiverC" make clean @@ -108,11 +121,8 @@ cat > "$APP_DIR/Contents/Info.plist" < "$APP_DIR/Contents/PkgInfo" -# Sign each bundled dylib first, then the app -find "$APP_DIR/Contents/Frameworks" -name "*.dylib" | while read dylib; do - codesign --force --sign - "$dylib" >/dev/null 2>&1 || true -done -codesign --force --deep --sign - "$APP_DIR" >/dev/null 2>&1 || true +# Sign the complete bundle only after all runtime dependencies are present. +"$SCRIPT_DIR/sign_targetbridge_receiver_app.sh" "$APP_DIR" # A released bundle must never depend on the Homebrew installation used to # compile it. Check the executable and every bundled dynamic library after diff --git a/TargetBridge-Receiver/scripts/sign_targetbridge_receiver_app.sh b/TargetBridge-Receiver/scripts/sign_targetbridge_receiver_app.sh new file mode 100755 index 00000000..1f38eb09 --- /dev/null +++ b/TargetBridge-Receiver/scripts/sign_targetbridge_receiver_app.sh @@ -0,0 +1,42 @@ +#!/bin/zsh +set -euo pipefail + +fail() { print -u2 -- "Receiver signing: $*"; exit 1; } +APP="${1:-}" +[[ -n "$APP" && $# -eq 1 ]] || fail "Usage: $0 'TargetBridge Receiver.app' | --check-identity" + +CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/receiver-signing-identity.txt}" +IDENTITY="${TARGETBRIDGE_CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" && -f "$CONFIG" ]]; then + IDENTITY=$(<"$CONFIG") +fi +if [[ -z "$IDENTITY" ]]; then + fail "No persistent signing identity. Configure TARGETBRIDGE_CODESIGN_IDENTITY or $CONFIG. Refusing an implicit ad-hoc build." +fi +if [[ "$IDENTITY" == - ]]; then + [[ "${TARGETBRIDGE_ALLOW_ADHOC:-0}" == 1 ]] || fail "Ad-hoc signing requires TARGETBRIDGE_ALLOW_ADHOC=1; updates may lose privacy permissions." + print -u2 -- "WARNING: disposable ad-hoc development build; do not install over a persistently signed Receiver." +else + [[ "$IDENTITY" =~ '^[[:xdigit:]]{40}$' ]] || fail "Use the exact 40-character certificate fingerprint, not a display name" + IDENTITIES=$(/usr/bin/security find-identity -v -p codesigning) + [[ "${(U)IDENTITIES}" == *" ${(U)IDENTITY} "* ]] || fail "Configured certificate is not a valid code-signing identity in this user's Keychain." +fi +[[ "$APP" != --check-identity ]] || { print -- 'Signing identity preflight passed'; exit 0; } + +PLIST="$APP/Contents/Info.plist" +[[ -f "$PLIST" && ! -L "$APP" ]] || fail "Expected a real application bundle" +BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST") +[[ "$BUNDLE_ID" == com.targetbridge.receiver ]] || fail "Wrong bundle identifier: $BUNDLE_ID" + +# Frameworks must carry the same identity before sealing the containing app. +while IFS= read -r dylib; do + /usr/bin/codesign --force --sign "$IDENTITY" "$dylib" +done < <(/usr/bin/find "$APP/Contents/Frameworks" -type f -name '*.dylib' -print) +/usr/bin/codesign --force --sign "$IDENTITY" "$APP" +/usr/bin/codesign --verify --deep --strict --all-architectures "$APP" +DETAILS=$(/usr/bin/codesign -dv "$APP" 2>&1) +if [[ "$IDENTITY" != - && "$DETAILS" == *"Signature=adhoc"* ]]; then + fail "A persistent signature was requested but the result is ad-hoc" +fi +/usr/bin/codesign -d -r- "$APP" 2>&1 +print -- "Receiver signature verified: $APP" diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index d9564927..32dee85c 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -8,12 +8,24 @@ extension CGVirtualDisplaySettings: @unchecked @retroactive Sendable {} /// Pixel size of the mode handed to CGVirtualDisplay. With `settings.hiDPI = true` /// macOS synthesises a strictly 2x backing store, so a mode of (w, h) renders the /// desktop into a (2w, 2h) framebuffer and reports "looks like w x h" in Displays. +/// +/// `isHiDPI == false` asks for a 1x mode instead: the framebuffer equals the mode, +/// and the desktop reports its true size. That is the right choice when the stream +/// already matches a non-Retina receiver panel pixel for pixel, where a HiDPI mode +/// would render every control at 2x on a panel that has no extra pixels to show it. struct TBVirtualDisplayModeSize: Equatable { let width: Int let height: Int + let isHiDPI: Bool - var backingWidth: Int { width * 2 } - var backingHeight: Int { height * 2 } + init(width: Int, height: Int, isHiDPI: Bool = true) { + self.width = width + self.height = height + self.isHiDPI = isHiDPI + } + + var backingWidth: Int { isHiDPI ? width * 2 : width } + var backingHeight: Int { isHiDPI ? height * 2 : height } } struct TBVirtualDisplayIdentity { @@ -84,7 +96,8 @@ final class ReceiverBackedVirtualDisplaySession { // stream exactly, so capture is 1:1 and only the panel-side scale remains. var resolvedMode = modeOverride ?? TBVirtualDisplayModeSize( width: profile.modeWidth, - height: profile.modeHeight + height: profile.modeHeight, + isHiDPI: profile.hiDPI ) // macOS refuses a HiDPI mode whose backing store exceeds the advertised panel. @@ -95,7 +108,11 @@ final class ReceiverBackedVirtualDisplaySession { resolvedMode.backingWidth, resolvedMode.backingHeight, profile.panelWidth, profile.panelHeight ) - resolvedMode = TBVirtualDisplayModeSize(width: profile.modeWidth, height: profile.modeHeight) + resolvedMode = TBVirtualDisplayModeSize( + width: profile.modeWidth, + height: profile.modeHeight, + isHiDPI: profile.hiDPI + ) } let descriptor = CGVirtualDisplayDescriptor() @@ -128,7 +145,7 @@ final class ReceiverBackedVirtualDisplaySession { } let settings = CGVirtualDisplaySettings() - settings.hiDPI = profile.hiDPI + settings.hiDPI = resolvedMode.isHiDPI guard let mode = CGVirtualDisplayMode( width: UInt(resolvedMode.width), height: UInt(resolvedMode.height), @@ -156,6 +173,7 @@ final class ReceiverBackedVirtualDisplaySession { activatePreferredMode(for: display.displayID, mode: resolvedMode, refreshRate: preferredRefreshRate, + hiDPI: profile.hiDPI, savedChoice: savedChoice) virtualDisplay = display @@ -183,13 +201,14 @@ final class ReceiverBackedVirtualDisplaySession { private func activatePreferredMode(for displayID: CGDirectDisplayID, mode: TBVirtualDisplayModeSize, refreshRate: Double, + hiDPI: Bool, savedChoice: TBVirtualDisplayModeMemory.Choice?) -> Bool { let timeout = Date().addingTimeInterval(2.0) while Date() < timeout { var success = false autoreleasepool { let chosenMode = savedChoice.flatMap { savedMode(for: displayID, choice: $0) } - ?? preferredMode(for: displayID, mode: mode, refreshRate: refreshRate) + ?? preferredMode(for: displayID, mode: mode, refreshRate: refreshRate, hiDPI: hiDPI) if let chosenMode { success = CGDisplaySetDisplayMode(displayID, chosenMode, nil) == .success } @@ -223,8 +242,16 @@ final class ReceiverBackedVirtualDisplaySession { return candidates.first } - private func preferredMode(for displayID: CGDirectDisplayID, mode: TBVirtualDisplayModeSize, refreshRate: Double) -> CGDisplayMode? { - guard let modesCF = CGDisplayCopyAllDisplayModes(displayID, nil) else { + /// Select the mode by its backing pixels as well as its logical size. A + /// virtual HiDPI display exposes both a Retina 2x and a low-resolution 1x + /// variant that can report the same logical 2560x1440 size. Choosing only + /// by points made Render Matching restore the blurry 1x variant. + private func preferredMode(for displayID: CGDirectDisplayID, + mode: TBVirtualDisplayModeSize, + refreshRate: Double, + hiDPI: Bool) -> CGDisplayMode? { + let options = [kCGDisplayShowDuplicateLowResolutionModes: kCFBooleanTrue] as CFDictionary + guard let modesCF = CGDisplayCopyAllDisplayModes(displayID, options) else { return nil } let modes = modesCF as? [CGDisplayMode] ?? [] @@ -233,10 +260,16 @@ final class ReceiverBackedVirtualDisplaySession { candidate.width == mode.width && candidate.height == mode.height }.sorted { $0.refreshRate > $1.refreshRate } - if let exactMatch = matchingModes.first(where: { abs($0.refreshRate - refreshRate) < 0.5 }) { + let expectedPixelWidth = hiDPI ? mode.backingWidth : mode.width + let expectedPixelHeight = hiDPI ? mode.backingHeight : mode.height + let backingMatches = matchingModes.filter { candidate in + candidate.pixelWidth == expectedPixelWidth && candidate.pixelHeight == expectedPixelHeight + } + + if let exactMatch = backingMatches.first(where: { abs($0.refreshRate - refreshRate) < 0.5 }) { return exactMatch } - return matchingModes.first + return backingMatches.first ?? matchingModes.first } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 2b2eab72..c4fac75a 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -193,9 +193,24 @@ enum TBSenderAutomation { } let receiver = params["receiver"].flatMap { $0.isEmpty ? nil : $0 } ?? "auto" + let discoveredReceivers = await waitForReceivers( + service, + preferredIdentity: receiver.lowercased() == "auto" + ? session.selectedReceiverID + : receiver + ) if receiver.lowercased() == "auto" { - guard let discovered = await waitForReceiver(service) else { - NSLog("[automation] no receivers discovered; aborting connect") + guard let discovered = automaticReceiver( + in: discoveredReceivers, + preferredIdentity: session.selectedReceiverID + ) else { + if !session.selectedReceiverID.isEmpty { + NSLog("[automation] preferred monitor unavailable or ambiguous; waiting for that monitor") + } else if discoveredReceivers.count > 1 { + NSLog("[automation] multiple receivers discovered and no preferred monitor is selected; waiting for user choice") + } else { + NSLog("[automation] no receivers discovered; aborting connect") + } return nil } if let pathPreference { @@ -211,8 +226,7 @@ enum TBSenderAutomation { } else { service.applyDiscoveredReceiver(discovered, to: session) } - session.selectedReceiverID = discovered.id - } else if let discovered = service.discoveredReceivers.first(where: { matches(receiver, $0) }) { + } else if let discovered = discoveredReceivers.first(where: { matches(receiver, $0) }) { if let pathPreference { guard let selected = await selectConnectionPath( receiver: discovered, @@ -226,8 +240,11 @@ enum TBSenderAutomation { } else { service.applyDiscoveredReceiver(discovered, to: session) } - session.selectedReceiverID = discovered.id } else { + guard !isPersistedReceiverReference(receiver) else { + NSLog("[automation] requested monitor identity is not available; aborting connect") + return nil + } // Treat as a raw IP / hostname (bypasses Bonjour). session.receiverIP = receiver session.selectedReceiverID = "" @@ -329,13 +346,69 @@ enum TBSenderAutomation { return nil } - /// 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) + /// Bonjour results arrive incrementally. Waiting for a short settled window + /// prevents `auto` from connecting to whichever of several displays happens + /// to advertise first. A previously selected stable identity may return + /// immediately because it is unambiguous. + private static func waitForReceivers( + _ service: TBDisplaySenderService, + preferredIdentity: String + ) async -> [TBDiscoveredReceiver] { + var lastSignature: [String] = [] + var unchangedIterations = 0 + var firstNonEmptyIteration: Int? + + for iteration in 0..<24 { + let receivers = service.discoveredReceivers + if !preferredIdentity.isEmpty, + receivers.contains(where: { $0.matchesPersistedIdentity(preferredIdentity) }) { + return receivers + } + + if !receivers.isEmpty { + if firstNonEmptyIteration == nil { firstNonEmptyIteration = iteration } + let signature = receivers + .map { "\($0.stableIdentity)|\($0.preferredIP)" } + .sorted() + if signature == lastSignature { + unchangedIterations += 1 + } else { + lastSignature = signature + unchangedIterations = 0 + } + + if let firstNonEmptyIteration, + iteration - firstNonEmptyIteration >= 6, + unchangedIterations >= 3 { + return receivers + } + } + try? await Task.sleep(nanoseconds: 250_000_000) } - return service.discoveredReceivers.first + return service.discoveredReceivers + } + + /// Automatic startup is safe only when the preferred display is present or + /// exactly one Receiver exists and no preference was saved. With multiple unpaired displays, returning + /// nil is intentional: silently choosing the first one would send the user's + /// desktop to the wrong Mac. + static func automaticReceiver( + in receivers: [TBDiscoveredReceiver], + preferredIdentity: String + ) -> TBDiscoveredReceiver? { + if !preferredIdentity.isEmpty { + let preferred = receivers.filter { + $0.matchesPersistedIdentity(preferredIdentity) + } + return preferred.count == 1 ? preferred[0] : nil + } + return receivers.count == 1 ? receivers[0] : nil + } + + static func isPersistedReceiverReference(_ value: String) -> Bool { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value.hasPrefix("receiver:") || value.hasPrefix("service:") || + value.contains("|") || UUID(uuidString: value) != nil } private static func selectConnectionPath( @@ -425,7 +498,7 @@ enum TBSenderAutomation { // unit-test bundle can exercise them directly. static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { let needle = value.lowercased() - if receiver.id.lowercased() == needle { return true } + if receiver.matchesPersistedIdentity(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 @@ -460,6 +533,8 @@ enum TBSenderAutomation { static func parsePreset(_ value: String) -> TBDisplayCapturePreset? { if let preset = TBDisplayCapturePreset(rawValue: value) { return preset } switch value.lowercased() { + case "1080p", "1080", "fullhd", "1920x1080": return .standard1080p + case "1080p60", "fullhd60", "smooth1080": return .smooth1080p60 case "1440p", "1440", "standard": return .standard1440p case "1440p60", "smooth", "smooth1440": return .smooth1440p60 case "1800p", "1800p60", "smooth1800": return .smooth1800p60 diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift index b3ff066d..1b64444c 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.5.2" - static let buildNumber = "20260810141154" + static let marketingVersion = "3.6.0" + static let buildNumber = "20261005172051" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index de97d340..a1d71141 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -27,12 +27,22 @@ struct TBDisplaySenderContentView: View { .background(Color.black.opacity(0.02)) .task { service.refreshLocalInterfaces() + service.presentPermissionAssistantIfNeeded() } .sheet(isPresented: $showingAbout) { TBDisplaySenderAboutView(service: service) } + .sheet(isPresented: $service.showingPermissionAssistant) { + TBDisplaySenderPermissionAssistant(service: service) + } .toolbar { ToolbarItemGroup(placement: .primaryAction) { + Button { + service.showingPermissionAssistant = true + } label: { + Label(permissionToolbarTitle, systemImage: "checkmark.shield") + } + Button { showingAbout = true } label: { @@ -179,6 +189,16 @@ struct TBDisplaySenderContentView: View { case .chinese: return "关于" } } + + private var permissionToolbarTitle: String { + switch service.language { + case .italian: return "Permessi" + case .english: return "Permissions" + case .german: return "Berechtigungen" + case .french: return "Autorisations" + case .chinese: return "权限" + } + } } private struct TBDisplaySenderSessionCard: View { @@ -615,7 +635,37 @@ private struct TBDisplaySenderSessionSettingsSheet: View { .disabled(session.isConnected || session.isStreaming) } - if session.captureSource == .extendedDesktop { + settingRow( + TBDisplaySenderL10n.videoCompression(service.language), + details: TBDisplaySenderL10n.codecPreferenceHint( + session.codecPreference, + language: service.language + ) + ) { + Picker( + TBDisplaySenderL10n.videoCompression(service.language), + selection: Binding( + get: { session.codecPreference }, + set: { service.setCodecPreference($0, for: session) } + ) + ) { + ForEach(TBDisplayCodecPreference.allCases) { preference in + Text(TBDisplaySenderL10n.codecPreferenceTitle( + preference, + language: service.language + )) + .tag(preference) + } + } + .pickerStyle(.menu) + .disabled(session.isConnected || session.isStreaming) + } + + // A native-scale profile already renders the desktop 1:1 with the + // stream, so render matching has nothing left to do and its HiDPI + // wording would be wrong. Hide the row rather than show a toggle + // that silently does nothing. + if session.captureSource == .extendedDesktop, !session.capturePreset.rendersAtNativeScale { settingRow(renderMatchingTitle, details: renderMatchingDetails) { Toggle("", isOn: $session.matchRenderToStream) .labelsHidden() diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index 2f5fe16d..fb9d20db 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -488,6 +488,28 @@ enum TBDisplaySenderL10n { ]) } + static func videoCompression(_ language: TBDisplaySenderLanguage) -> String { + text("sender.codec.label", language) + } + + static func codecPreferenceTitle( + _ preference: TBDisplayCodecPreference, + language: TBDisplaySenderLanguage + ) -> String { + text("sender.codec.\(preference.rawValue)", language) + } + + static func codecPreferenceHint( + _ preference: TBDisplayCodecPreference, + language: TBDisplaySenderLanguage + ) -> String { + text("sender.codec.hint.\(preference.rawValue)", language) + } + + static func codecUnavailable(_ language: TBDisplaySenderLanguage) -> String { + text("sender.codec.unavailable", language) + } + static func missingScreenRecordingPermission(language: TBDisplaySenderLanguage) -> String { text("sender.error.screen_recording_permission", language) } @@ -559,6 +581,10 @@ enum TBDisplaySenderL10n { extension TBDisplayCapturePreset { func title(_ language: TBDisplaySenderLanguage) -> String { switch self { + case .standard1080p: + return TBDisplaySenderL10n.text("sender.profile.full_hd", language) + case .smooth1080p60: + return TBDisplaySenderL10n.text("sender.profile.full_hd_60", language) case .standard1440p: return TBDisplaySenderL10n.text("sender.profile.standard", language) case .smooth1440p60: diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index 0d4e2fcb..9e2a49b3 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -49,6 +49,7 @@ final class TBDisplaySenderService: ObservableObject { /// Changes whenever the app returns from System Settings so permission cards /// re-evaluate their live TCC state instead of showing a stale warning. @Published private(set) var privacyPermissionsRevision = 0 + @Published var showingPermissionAssistant = false @Published var language: TBDisplaySenderLanguage = .load() { didSet { language.persist() @@ -146,6 +147,43 @@ final class TBDisplaySenderService: ObservableObject { privacyPermissionsRevision &+= 1 } + func presentPermissionAssistantIfNeeded() { + showingPermissionAssistant = !CGPreflightScreenCaptureAccess() + } + + func requestScreenRecordingPermission() { + _ = CGRequestScreenCaptureAccess() + refreshPrivacyPermissions() + } + + func requestAccessibilityPermission() { + let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + refreshPrivacyPermissions() + } + + func requestInputMonitoringPermission() { + _ = CGRequestListenEventAccess() + refreshPrivacyPermissions() + } + + func openScreenRecordingSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") else { + return + } + NSWorkspace.shared.open(url) + } + + func restartAfterPermissionChange() { + let appURL = URL(fileURLWithPath: Bundle.main.bundlePath) + NSWorkspace.shared.openApplication(at: appURL, configuration: .init()) { _, error in + guard error == nil else { return } + DispatchQueue.main.async { + NSApp.terminate(nil) + } + } + } + var anyConnected: Bool { sessions.contains { $0.isConnected || $0.isStreaming } } @@ -222,7 +260,9 @@ final class TBDisplaySenderService: ObservableObject { receiverAddress: session.receiverIP.trimmingCharacters(in: .whitespacesAndNewlines), receiverProfileAvailable: session.receiverSupportsHEVCDecodeHint != nil, receiverSupportsHEVC: session.receiverSupportsHEVCDecodeHint, - requiresHEVC: session.capturePreset.codecName == "HEVC", + requiresHEVC: session.codecPreference == .hevc || ( + session.codecPreference == .automatic && session.capturePreset.codecName == "HEVC" + ), cableRate: session.cableTestResult, requiresSenderInputMonitoring: role == .senderMaster, senderInputMonitoringGranted: localInputMonitoringTrusted, @@ -317,6 +357,7 @@ final class TBDisplaySenderService: ObservableObject { private static let persistedSessionsKey = "fd.tbdisplaysender.sessions.v1" private static let receiverDisplayProfilesKey = "fd.tbdisplaysender.receiverDisplayProfiles.v1" + private static let receiverCodecPreferencesKey = "fd.tbdisplaysender.receiverCodecPreferences.v1" /// Earlier builds persisted `false` when the audio addon had not finished /// loading. Repair that ambiguous state exactly once without making future /// deliberate choices reversible. @@ -342,6 +383,7 @@ final class TBDisplaySenderService: ObservableObject { var inputControlRole: String? var inputBindings: [TBInputBinding]? var matchRenderToStream: Bool? + var codecPreference: String? } private var lastPersistedData: Data? @@ -376,7 +418,8 @@ final class TBDisplaySenderService: ObservableObject { volume: session.volume, inputControlRole: session.inputControlRole.rawValue, inputBindings: session.inputBindings, - matchRenderToStream: session.matchRenderToStream + matchRenderToStream: session.matchRenderToStream, + codecPreference: session.codecPreference.rawValue ) } guard let data = try? JSONEncoder().encode(configs) else { return } @@ -464,6 +507,9 @@ final class TBDisplaySenderService: ObservableObject { session.brightness = config.brightness session.volume = config.volume ?? 0.5 session.matchRenderToStream = config.matchRenderToStream ?? false + session.codecPreference = config.codecPreference + .flatMap(TBDisplayCodecPreference.init(rawValue:)) + ?? .automatic } func refreshLocalInterfaces() { @@ -474,8 +520,10 @@ final class TBDisplaySenderService: ObservableObject { } func applyDiscoveredReceiver(_ receiver: TBDiscoveredReceiver, to session: TBDisplaySenderSession) { + session.selectedReceiverID = receiver.stableIdentity session.receiverIP = receiver.ip(for: session.transportKind) session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + restoreCodecPreference(for: receiver, to: session, defaultToAutomatic: true) if session.localInterfaceIP.isEmpty { session.localInterfaceIP = suggestedInterfaceForNewSession(transportKind: session.transportKind)?.ip ?? availableInterfaces(for: session.transportKind).first?.ip @@ -493,18 +541,57 @@ final class TBDisplaySenderService: ObservableObject { !session.selectedReceiverID.isEmpty && !session.isConnected && !session.isStreaming { - let savedServiceName = String( - session.selectedReceiverID.split(separator: "|", maxSplits: 1).first ?? "" - ) guard let receiver = receivers.first(where: { - $0.id == session.selectedReceiverID || $0.serviceName == savedServiceName + $0.matchesPersistedIdentity(session.selectedReceiverID) }) else { continue } - session.selectedReceiverID = receiver.id + session.selectedReceiverID = receiver.stableIdentity session.receiverIP = receiver.ip(for: session.transportKind) session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + restoreCodecPreference(for: receiver, to: session, defaultToAutomatic: false) + } + } + + func setCodecPreference(_ preference: TBDisplayCodecPreference, for session: TBDisplaySenderSession) { + guard !session.isConnected, !session.isStreaming else { return } + session.codecPreference = preference + guard let key = receiverCodecPreferenceKey(for: session) else { return } + var preferences = persistedCodecPreferences + preferences[key] = preference.rawValue + UserDefaults.standard.set(preferences, forKey: Self.receiverCodecPreferencesKey) + } + + private var persistedCodecPreferences: [String: String] { + UserDefaults.standard.dictionary(forKey: Self.receiverCodecPreferencesKey) as? [String: String] ?? [:] + } + + private func receiverCodecPreferenceKey(for receiver: TBDiscoveredReceiver) -> String { + "service:\(receiver.serviceName)" + } + + private func receiverCodecPreferenceKey(for session: TBDisplaySenderSession) -> String? { + let selectedID = session.selectedReceiverID.trimmingCharacters(in: .whitespacesAndNewlines) + if let serviceName = selectedID.split(separator: "|", maxSplits: 1).first, + !serviceName.isEmpty { + return "service:\(serviceName)" + } + + let receiverIP = session.receiverIP.trimmingCharacters(in: .whitespacesAndNewlines) + return receiverIP.isEmpty ? nil : "ip:\(receiverIP)" + } + + private func restoreCodecPreference( + for receiver: TBDiscoveredReceiver, + to session: TBDisplaySenderSession, + defaultToAutomatic: Bool + ) { + let rawValue = persistedCodecPreferences[receiverCodecPreferenceKey(for: receiver)] + if let rawValue, let preference = TBDisplayCodecPreference(rawValue: rawValue) { + session.codecPreference = preference + } else if defaultToAutomatic { + session.codecPreference = .automatic } } @@ -570,7 +657,9 @@ final class TBDisplaySenderService: ObservableObject { func transportDidChange(for session: TBDisplaySenderSession) { session.localInterfaceIP = defaultLocalInterfaceIP(for: session.transportKind) - if let receiver = discoveredReceivers.first(where: { $0.id == session.selectedReceiverID }) { + if let receiver = discoveredReceivers.first(where: { + $0.matchesPersistedIdentity(session.selectedReceiverID) + }) { session.receiverIP = receiver.ip(for: session.transportKind) } objectWillChange.send() diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderPermissionAssistant.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderPermissionAssistant.swift new file mode 100644 index 00000000..87319b29 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderPermissionAssistant.swift @@ -0,0 +1,75 @@ +import ApplicationServices +import SwiftUI + +struct TBDisplaySenderPermissionAssistant: View { + @ObservedObject var service: TBDisplaySenderService + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Label(copy.title, systemImage: "checkmark.shield") + .font(.title2.weight(.bold)) + + Text(copy.intro) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + permissionRow(copy.screenRecording, granted: CGPreflightScreenCaptureAccess()) { + service.requestScreenRecordingPermission() + } + permissionRow(copy.accessibility, granted: AXIsProcessTrusted()) { + service.requestAccessibilityPermission() + } + permissionRow(copy.inputMonitoring, granted: CGPreflightListenEventAccess()) { + service.requestInputMonitoringPermission() + } + + HStack { + Button(copy.openSettings) { service.openScreenRecordingSettings() } + Button(copy.restart) { service.restartAfterPermissionChange() } + .buttonStyle(.borderedProminent) + Spacer() + Button(copy.done) { service.showingPermissionAssistant = false } + } + } + .padding(24) + .frame(width: 560) + .onAppear { service.refreshPrivacyPermissions() } + } + + private func permissionRow(_ title: String, granted: Bool, request: @escaping () -> Void) -> some View { + HStack(spacing: 12) { + Image(systemName: granted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(granted ? .green : .yellow) + Text(title) + Spacer() + if granted { + Text(copy.granted).foregroundStyle(.secondary) + } else { + Button(copy.allow, action: request).buttonStyle(.bordered) + } + } + } + + private var copy: Copy { + switch service.language { + case .italian: return .init(title: "Permessi di TargetBridge", intro: "macOS richiede la tua conferma una sola volta per ogni app installata. Puoi autorizzare qui oppure aprire direttamente le Impostazioni.", screenRecording: "Registrazione schermo", accessibility: "Accessibilità", inputMonitoring: "Monitoraggio input", granted: "Attivo", allow: "Consenti", openSettings: "Apri Impostazioni", restart: "Riavvia TargetBridge", done: "Fine") + case .english: return .init(title: "TargetBridge Permissions", intro: "macOS requires your confirmation once for each installed app. Approve here or open the matching System Settings page.", screenRecording: "Screen Recording", accessibility: "Accessibility", inputMonitoring: "Input Monitoring", granted: "Granted", allow: "Allow", openSettings: "Open Settings", restart: "Restart TargetBridge", done: "Done") + case .german: return .init(title: "TargetBridge-Berechtigungen", intro: "macOS benötigt deine Bestätigung einmal für jede installierte App. Du kannst hier zustimmen oder die passende Systemeinstellung öffnen.", screenRecording: "Bildschirmaufnahme", accessibility: "Bedienungshilfen", inputMonitoring: "Eingabeüberwachung", granted: "Aktiv", allow: "Erlauben", openSettings: "Einstellungen öffnen", restart: "TargetBridge neu starten", done: "Fertig") + case .french: return .init(title: "Autorisations TargetBridge", intro: "macOS demande votre confirmation une fois pour chaque app installée. Autorisez ici ou ouvrez directement les Réglages correspondants.", screenRecording: "Enregistrement de l’écran", accessibility: "Accessibilité", inputMonitoring: "Surveillance des entrées", granted: "Autorisé", allow: "Autoriser", openSettings: "Ouvrir les réglages", restart: "Redémarrer TargetBridge", done: "Terminé") + case .chinese: return .init(title: "TargetBridge 权限", intro: "macOS 需要你为每个已安装的应用确认一次权限。可在此授权,或直接打开相应的系统设置页面。", screenRecording: "屏幕录制", accessibility: "辅助功能", inputMonitoring: "输入监控", granted: "已授权", allow: "允许", openSettings: "打开设置", restart: "重新启动 TargetBridge", done: "完成") + } + } + + private struct Copy { + let title: String + let intro: String + let screenRecording: String + let accessibility: String + let inputMonitoring: String + let granted: String + let allow: String + let openSettings: String + let restart: String + let done: String + } +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 53bf1712..24ccd111 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -23,6 +23,8 @@ enum TBVideoQueueBudget { } enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { + case standard1080p + case smooth1080p60 case standard1440p case smooth1440p60 case smooth1800p60 @@ -35,6 +37,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var title: String { switch self { + case .standard1080p: + return "Full HD" + case .smooth1080p60: + return "Full HD 60" case .standard1440p: return "Standard" case .smooth1440p60: @@ -54,6 +60,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var description: String { switch self { + case .standard1080p: + return "1920 × 1080" + case .smooth1080p60: + return "1920 × 1080 @ 60" case .standard1440p: return "2560 × 1440" case .smooth1440p60: @@ -73,6 +83,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var width: Int { switch self { + case .standard1080p, .smooth1080p60: + return 1920 case .standard1440p, .smooth1440p60: return 2560 case .smooth1800p60: @@ -88,6 +100,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var height: Int { switch self { + case .standard1080p, .smooth1080p60: + return 1080 case .standard1440p, .smooth1440p60: return 1440 case .smooth1800p60: @@ -103,6 +117,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var averageBitRate: Int { switch self { + case .standard1080p: + return 20_000_000 + case .smooth1080p60: + return 30_000_000 case .standard1440p: return 36_000_000 case .smooth1440p60: @@ -122,7 +140,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var codecName: String { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return "H.264" case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return "HEVC" @@ -131,7 +149,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var codecType: CMVideoCodecType { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return kCMVideoCodecType_H264 case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return kCMVideoCodecType_HEVC @@ -143,9 +161,9 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return parsed } switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return 3 - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: // Five surfaces protect WindowServer from starvation during // high-frame-rate 4K capture while the serial pipeline prevents an @@ -156,6 +174,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var expectedFrameRate: Int { switch self { + case .standard1080p: + return 30 + case .smooth1080p60: + return 60 case .standard1440p: return 30 case .smooth1440p60: @@ -180,6 +202,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxKeyFrameInterval: Int { switch self { + case .standard1080p, .smooth1080p60: + return 60 case .standard1440p: return 60 case .smooth1440p60: @@ -197,6 +221,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxKeyFrameIntervalDuration: Int { switch self { + case .standard1080p: + return 2 + case .smooth1080p60: + return 1 case .standard1440p: return 2 case .smooth1440p60: @@ -210,9 +238,9 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var prioritizeSpeed: Bool { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -226,18 +254,18 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxFrameDelayCount: Int { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return 1 - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return 0 } } var dropsBeforeEncodeWhenBacklogged: Bool { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -251,7 +279,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var captureResolution: SCCaptureResolutionType { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return .nominal case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return .best @@ -260,6 +288,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var virtualDisplayRefreshRate: Double { switch self { + case .standard1080p, .smooth1080p60: + return 60 case .standard1440p: return 60 case .smooth1440p60, .smooth1800p60: @@ -287,6 +317,33 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var renderMatchedDesktopDescription: String { "\(width / 2) × \(height / 2)" } + + /// Virtual display mode for a receiver whose panel already matches the stream + /// pixel for pixel. Rendering 1x keeps the desktop at its true size and makes + /// capture 1:1; the HiDPI variant would halve the logical desktop and draw every + /// control at 2x on a panel with no extra pixels to resolve it. + var nativeScaleDisplayMode: TBVirtualDisplayModeSize { + TBVirtualDisplayModeSize(width: width, height: height, isHiDPI: false) + } + + /// True for profiles that target a receiver panel of exactly this resolution, + /// with no extra pixels for a HiDPI backing store. + /// + /// The receiver advertises a fixed 5120x2880 panel and a 2560x1440 HiDPI mode + /// no matter what it is actually plugged into, so its profile cannot be used to + /// detect a non-Retina panel; the capture profile is the signal instead. A + /// 21.5-inch non-Retina iMac running the 1080p profiles wants a 1920x1080 1x + /// desktop: HiDPI would render the desktop at 960x540 and draw every control at + /// 2x, and the receiver's own 1440p default would rescale twice on the way out. + var rendersAtNativeScale: Bool { + switch self { + case .standard1080p, .smooth1080p60: + return true + case .standard1440p, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, + .native5k, .native5k60Experimental: + return false + } + } } /// A zero-buffer frame-rate ceiling for capture callbacks. Deadlines advance on @@ -330,6 +387,19 @@ struct TBFrameRatePacer { } } +enum TBDisplayCodecPreference: String, CaseIterable, Identifiable, Codable { + case automatic + case h264 + case hevc + + var id: String { rawValue } +} + +struct TBDisplayCodecDecision: Equatable { + let codecType: CMVideoCodecType + let usedFallback: Bool +} + enum TBDisplayCaptureSource: String, CaseIterable, Identifiable { case desktopMirror case extendedDesktop @@ -1100,7 +1170,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } var shortHostName: String? { if let receiver = TBDisplaySenderService.shared.discoveredReceivers.first(where: { - $0.id == selectedReceiverID || + $0.matchesPersistedIdentity(selectedReceiverID) || $0.preferredIP == receiverIP || $0.thunderboltIP == receiverIP || $0.networkIP == receiverIP @@ -1196,6 +1266,17 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } } + @Published var codecPreference: TBDisplayCodecPreference = .automatic { + didSet { + if !isStreaming { + streamResolutionText = TBDisplaySenderL10n.streamSummary( + preset: capturePreset, + source: captureSource, + language: language + ) + } + } + } @Published var captureSource: TBDisplayCaptureSource = .desktopMirror { didSet { if !isStreaming { @@ -1271,9 +1352,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var lastCheckedCursor: NSCursor? private var lastCheckedCursorType: Int = 0 private var baselineDisplayIDs = Set() + private var headlessMirrorUsesVirtualDisplay = false private var cursorDisplayID: CGDirectDisplayID = kCGNullDirectDisplay private var lastCursorPacket: TBMonitorCursor? private var injectedRemoteMouseLocation: CGPoint? + private var cachedLocalInputEventSource: CGEventSource? private var injectedLeftClickTracker = TBInjectedClickStateTracker() private let localPointerModifierBridge = TBLocalPointerModifierBridge() private var injectedCommandDown = false @@ -1287,7 +1370,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u /// While a binding trigger key is held (matched), swallow its key-up so the /// raw trigger key never reaches the slave. private var suppressedTriggerKeyCode: UInt16? - private static var cachedSupportsHEVCHardwareEncode: Bool? + private static var cachedHardwareEncoderSupport: [String: Bool] = [:] private var receivedInputEventCount: UInt64 = 0 var onRemoteSwitchRequest: ((Int) -> Void)? var onRemoteDeactivateInputRequest: (() -> Void)? @@ -1339,9 +1422,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u statusText = state.text(language) } - private static func probeHEVCHardwareEncoderSupport() -> Bool { - if let cachedSupportsHEVCHardwareEncode { - return cachedSupportsHEVCHardwareEncode + private static func probeHardwareEncoderSupport( + codecType: CMVideoCodecType, + width: Int, + height: Int + ) -> Bool { + let cacheKey = "\(codecType)-\(width)x\(height)" + if let cached = cachedHardwareEncoderSupport[cacheKey] { + return cached } let encoderSpecification: CFDictionary = [ @@ -1352,9 +1440,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u var session: VTCompressionSession? let status = VTCompressionSessionCreate( allocator: kCFAllocatorDefault, - width: 1920, - height: 1080, - codecType: kCMVideoCodecType_HEVC, + width: Int32(width), + height: Int32(height), + codecType: codecType, encoderSpecification: encoderSpecification, imageBufferAttributes: nil, compressedDataAllocator: nil, @@ -1367,21 +1455,74 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } let supported = status == noErr - cachedSupportsHEVCHardwareEncode = supported + cachedHardwareEncoderSupport[cacheKey] = supported return supported } - private func resolvedCodecType(for preset: TBDisplayCapturePreset, profile: TBMonitorDisplayProfile?) -> CMVideoCodecType { - switch preset { - case .standard1440p, .smooth1440p60, .smooth1800p60: - let receiverSupportsHEVC = profile?.supportsHEVCDecode ?? receiverSupportsHEVCDecodeHint ?? false - if receiverSupportsHEVC, Self.probeHEVCHardwareEncoderSupport() { - return kCMVideoCodecType_HEVC - } - return kCMVideoCodecType_H264 - case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: - return preset.codecType + static func chooseCodec( + preference: TBDisplayCodecPreference, + preset: TBDisplayCapturePreset, + receiverSupportsHEVC: Bool, + senderSupportsH264: Bool, + senderSupportsHEVC: Bool + ) -> TBDisplayCodecDecision? { + let h264Available = senderSupportsH264 + let hevcAvailable = receiverSupportsHEVC && senderSupportsHEVC + let preferredCodec: CMVideoCodecType + + switch preference { + case .automatic: + preferredCodec = preset.codecType == kCMVideoCodecType_HEVC || hevcAvailable + ? kCMVideoCodecType_HEVC + : kCMVideoCodecType_H264 + case .h264: + preferredCodec = kCMVideoCodecType_H264 + case .hevc: + preferredCodec = kCMVideoCodecType_HEVC + } + + if preferredCodec == kCMVideoCodecType_HEVC, hevcAvailable { + return TBDisplayCodecDecision(codecType: kCMVideoCodecType_HEVC, usedFallback: false) + } + if preferredCodec == kCMVideoCodecType_H264, h264Available { + return TBDisplayCodecDecision(codecType: kCMVideoCodecType_H264, usedFallback: false) } + if preferredCodec == kCMVideoCodecType_HEVC, h264Available { + return TBDisplayCodecDecision(codecType: kCMVideoCodecType_H264, usedFallback: true) + } + if preferredCodec == kCMVideoCodecType_H264, hevcAvailable { + return TBDisplayCodecDecision(codecType: kCMVideoCodecType_HEVC, usedFallback: true) + } + + return nil + } + + private func resolvedCodecDecision( + for preset: TBDisplayCapturePreset, + profile: TBMonitorDisplayProfile? + ) -> TBDisplayCodecDecision? { + let receiverSupportsHEVC = profile?.supportsHEVCDecode + ?? receiverSupportsHEVCDecodeHint + ?? false + return Self.chooseCodec( + preference: codecPreference, + preset: preset, + receiverSupportsHEVC: receiverSupportsHEVC, + senderSupportsH264: Self.probeHardwareEncoderSupport( + codecType: kCMVideoCodecType_H264, + width: preset.width, + height: preset.height + ), + senderSupportsHEVC: Self.probeHardwareEncoderSupport( + codecType: kCMVideoCodecType_HEVC, + width: preset.width, + height: preset.height + ) + ) + } + + private func resolvedCodecType(for preset: TBDisplayCapturePreset, profile: TBMonitorDisplayProfile?) -> CMVideoCodecType { + resolvedCodecDecision(for: preset, profile: profile)?.codecType ?? preset.codecType } private func codecName(for codecType: CMVideoCodecType) -> String { @@ -1760,6 +1901,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u sentSnapshot = 0 sessionAckSent = false baselineDisplayIDs = [] + headlessMirrorUsesVirtualDisplay = false cursorDisplayID = kCGNullDirectDisplay lastCursorPacket = nil captureDisplayText = TBDisplaySenderL10n.captureDisplayNotAvailable(language) @@ -1774,7 +1916,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // changes. Using the address here made macOS forget a receiver's virtual // display identity and saved placement after a wake or reconnect. if let receiver = TBDisplaySenderService.shared.discoveredReceivers.first(where: { - $0.id == selectedReceiverID || + $0.matchesPersistedIdentity(selectedReceiverID) || $0.preferredIP == receiverIP || $0.thunderboltIP == receiverIP || $0.networkIP == receiverIP @@ -1783,9 +1925,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } let selectedID = selectedReceiverID.trimmingCharacters(in: .whitespacesAndNewlines) - if let serviceName = selectedID.split(separator: "|", maxSplits: 1).first, - !serviceName.isEmpty { - return "service:\(serviceName)" + if !selectedID.isEmpty { + if selectedID.hasPrefix("receiver:") || selectedID.hasPrefix("service:") { + return selectedID + } + if let serviceName = selectedID.split(separator: "|", maxSplits: 1).first, + !serviceName.isEmpty { + return "service:\(serviceName)" + } } if let host = shortHostName?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -2117,14 +2264,21 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func localInputEventSource() -> CGEventSource? { + if let cachedLocalInputEventSource { + return cachedLocalInputEventSource + } let source = CGEventSource(stateID: .hidSystemState) source?.localEventsSuppressionInterval = 0 + cachedLocalInputEventSource = source return source } private func logLocalInputInjectionStateIfNeeded(context: String) { - let trusted = AXIsProcessTrusted() - TBInputDebugLog.log("sender input injection state trusted=\(trusted) context=\(context)") + if context == "mouseMove" { + TBInputDebugLog.logInputEvent("sender input injection state trusted=\(AXIsProcessTrusted()) context=\(context)") + } else { + TBInputDebugLog.log("sender input injection state trusted=\(AXIsProcessTrusted()) context=\(context)") + } } private func postLocalMouseMove(dx: Int, dy: Int, type: CGEventType = .mouseMoved, button: CGMouseButton = .left) { @@ -2277,7 +2431,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func applyIncomingInputEvent(_ event: TBMonitorInputEvent, payload: Data) { - TBInputDebugLog.log("sender applying incoming event kind=\(event.kind)") + if event.kind == "move" || event.kind.hasSuffix("Drag") || event.kind == "scroll" { + TBInputDebugLog.logInputEvent("sender applying incoming event kind=\(event.kind)") + } else { + TBInputDebugLog.log("sender applying incoming event kind=\(event.kind)") + } switch event.kind { case "move": postLocalMouseMove(dx: event.dx ?? 0, dy: event.dy ?? 0) @@ -2458,19 +2616,32 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // Wake and hold the graphical session before virtual display setup. self.beginCaptureActivity() self.setStatus(.creatingVirtualDisplay) + self.headlessMirrorUsesVirtualDisplay = false self.baselineDisplayIDs = self.captureSource == .extendedDesktop ? await self.fetchShareableDisplayIDs() : [] let receiverKey = self.extendedDisplayIdentityKey(for: profile) - let modeOverride: TBVirtualDisplayModeSize? = (self.matchRenderToStream && self.captureSource == .extendedDesktop) - ? self.capturePreset.renderMatchedDisplayMode - : nil + // A native-scale profile targets a panel with exactly this many pixels, so + // render the desktop 1x at the stream size: capture is 1:1 and the panel + // shows it at its true size. This takes priority over render matching, + // whose 2x mode would halve the logical desktop for no gain on a panel with + // no extra pixels to resolve it. + let modeOverride: TBVirtualDisplayModeSize? + if self.capturePreset.rendersAtNativeScale { + modeOverride = self.capturePreset.nativeScaleDisplayMode + } else if self.matchRenderToStream && self.captureSource == .extendedDesktop { + modeOverride = self.capturePreset.renderMatchedDisplayMode + } else { + modeOverride = nil + } if let modeOverride { NSLog( - "TargetBridge: render matching on, virtual display mode %dx%d (backing %dx%d) for %dx%d stream", + "TargetBridge: virtual display mode %dx%d %@ (backing %dx%d) for %dx%d stream, panel %dx%d", modeOverride.width, modeOverride.height, + modeOverride.isHiDPI ? "HiDPI" : "1x", modeOverride.backingWidth, modeOverride.backingHeight, - self.capturePreset.width, self.capturePreset.height + self.capturePreset.width, self.capturePreset.height, + profile.panelWidth, profile.panelHeight ) } guard self.session.create( @@ -2485,8 +2656,18 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } if self.captureSource == .desktopMirror { - if CGDisplayIsInMirrorSet(self.session.displayID) == 0 { - let displayReady = await self.waitForOnlineDisplay(self.session.displayID) + let displayReady = await self.waitForOnlineDisplay(self.session.displayID) + self.headlessMirrorUsesVirtualDisplay = displayReady && Self.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: self.session.displayID, + mainDisplayID: CGMainDisplayID(), + onlineDisplayIDs: self.onlineDisplayIDs() + ) + if self.headlessMirrorUsesVirtualDisplay { + NSLog( + "TargetBridge: headless mirror mode uses virtual display %u directly", + self.session.displayID + ) + } else if CGDisplayIsInMirrorSet(self.session.displayID) == 0 { let mirrorConfigured = displayReady && self.configureDesktopMirror(for: self.session.displayID) if !mirrorConfigured { NSLog( @@ -2530,7 +2711,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if self.captureSource == .extendedDesktop { self.scheduleExtendedDesktopRecovery(for: self.session.displayID) - } else if self.captureSource == .desktopMirror { + } else if self.captureSource == .desktopMirror && !self.headlessMirrorUsesVirtualDisplay { self.scheduleDesktopMirrorRecovery(for: self.session.displayID) } @@ -2553,10 +2734,28 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let preset = capturePreset let usesRawNV12 = rawNV12Enabled(for: profile) - let codecType = resolvedCodecType(for: preset, profile: profile) + let codecDecision: TBDisplayCodecDecision + if usesRawNV12 { + codecDecision = TBDisplayCodecDecision(codecType: preset.codecType, usedFallback: false) + } else { + guard let resolvedDecision = resolvedCodecDecision(for: preset, profile: profile) else { + setStatus(.captureError(TBDisplaySenderL10n.codecUnavailable(language))) + TBLog.connection.error( + "capture: no compatible hardware codec preset=\(preset.rawValue, privacy: .public) preference=\(self.codecPreference.rawValue, privacy: .public)" + ) + return false + } + codecDecision = resolvedDecision + } + let codecType = codecDecision.codecType let codecName = usesRawNV12 ? "NV12 RAW" : codecName(for: codecType) activeCodecType = usesRawNV12 ? nil : codecType activeCodecName = codecName + if codecDecision.usedFallback { + TBLog.connection.notice( + "capture: codec fallback preference=\(self.codecPreference.rawValue, privacy: .public) selected=\(codecName, privacy: .public)" + ) + } guard let connection else { return false } // The encode/send pipeline runs entirely on its own serial queue, @@ -2581,11 +2780,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let display: SCDisplay if captureSource == .desktopMirror { - if let mirrorDisplay = try await resolveMirrorCaptureDisplay() { + if headlessMirrorUsesVirtualDisplay { + TBLog.connection.info("capture: headless virtual display uses direct stream id=\(self.session.displayID, privacy: .public)") + return await startDirectDisplayStream(displayID: session.displayID, preset: preset) + } else if let mirrorDisplay = try await resolveMirrorCaptureDisplay() { display = mirrorDisplay } else if let fallbackDisplayID = directMirrorFallbackDisplayID() { TBLog.connection.warning("capture: no virtual ScreenCaptureKit display; using direct fallback id=\(fallbackDisplayID, privacy: .public)") - return startDirectDisplayStream(displayID: fallbackDisplayID, preset: preset) + return await startDirectDisplayStream(displayID: fallbackDisplayID, preset: preset) } else { return false } @@ -2712,7 +2914,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return nil } - private func startDirectDisplayStream(displayID: CGDirectDisplayID, preset: TBDisplayCapturePreset) -> Bool { + private func startDirectDisplayStream(displayID: CGDirectDisplayID, preset: TBDisplayCapturePreset) async -> Bool { guard let pipeline else { return false } let usesCursorOverlay = inputControlRole.usesLowLatencyCursorOverlay( largeCursorEnabled: largeCursor @@ -2733,6 +2935,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } directDisplayStream = directCapture + await startAuxiliaryAudioCapture(preferredDisplayID: displayID) captureDisplayText = TBDisplaySenderL10n.captureDisplayCGDisplayStream(language, id: displayID) isStreaming = true if usesCursorOverlay { startCursorUpdates(displayID: displayID) } @@ -2742,6 +2945,65 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return true } + /// CGDisplayStream is the most reliable video source when the virtual + /// display is the headless Mac's only online display, but it has no audio + /// output. Keep that low-latency video path and run an audio-only + /// ScreenCaptureKit stream beside it when audio relay is enabled. + private func startAuxiliaryAudioCapture(preferredDisplayID: CGDirectDisplayID) async { + guard Self.needsAuxiliaryAudioCapture( + usingDirectDisplayStream: true, + shouldRelayAudio: shouldRelayAudio + ) else { return } + + do { + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: false + ) + guard let display = content.displays.first(where: { $0.displayID == preferredDisplayID }) + ?? content.displays.first else { + TBLog.connection.warning("capture: auxiliary audio unavailable because ScreenCaptureKit exposed no display") + return + } + + let configuration = SCStreamConfiguration() + configuration.width = 2 + configuration.height = 2 + configuration.minimumFrameInterval = CMTime(value: 1, timescale: 1) + configuration.queueDepth = 1 + configuration.showsCursor = false + configuration.capturesAudio = true + configuration.excludesCurrentProcessAudio = true + configuration.sampleRate = 48000 + configuration.channelCount = 2 + + let delegate = CaptureDelegate() + delegate.onAudio = { [weak self] sampleBuffer in + self?.processAudio(sampleBuffer) + } + delegate.onError = { error in + NSLog("TargetBridge: auxiliary audio stream stopped: %@", error.localizedDescription) + } + + let filter = SCContentFilter(display: display, excludingWindows: []) + let stream = SCStream(filter: filter, configuration: configuration, delegate: delegate) + try stream.addStreamOutput( + delegate, + type: .audio, + sampleHandlerQueue: DispatchQueue( + label: "fd.tbmonitor.sender.direct-audio", + qos: .userInteractive + ) + ) + try await stream.startCapture() + captureDelegate = delegate + scStream = stream + TBLog.connection.info("capture: auxiliary audio stream active beside direct video") + } catch { + TBLog.connection.warning("capture: auxiliary audio unavailable: \(error.localizedDescription, privacy: .public)") + } + } + private func activityOptions() -> ProcessInfo.ActivityOptions { var options: ProcessInfo.ActivityOptions = [.userInitiated, .idleSystemSleepDisabled] if preventDisplaySleep { @@ -2896,6 +3158,28 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return false } + static func shouldUseHeadlessVirtualDisplay( + virtualDisplayID: CGDirectDisplayID, + mainDisplayID: CGDirectDisplayID, + onlineDisplayIDs: [CGDirectDisplayID] + ) -> Bool { + guard virtualDisplayID != kCGNullDirectDisplay, + mainDisplayID == virtualDisplayID, + onlineDisplayIDs.contains(virtualDisplayID) + else { return false } + + return onlineDisplayIDs.allSatisfy { + $0 == kCGNullDirectDisplay || $0 == virtualDisplayID + } + } + + static func needsAuxiliaryAudioCapture( + usingDirectDisplayStream: Bool, + shouldRelayAudio: Bool + ) -> Bool { + usingDirectDisplayStream && shouldRelayAudio + } + private func scheduleExtendedDesktopRecovery(for virtualDisplayID: CGDirectDisplayID) { Task { @MainActor [weak self] in guard let self else { return } diff --git a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift index 4e59de86..0e609be8 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift @@ -3,6 +3,7 @@ import Foundation struct TBDiscoveredReceiver: Identifiable, Equatable { let serviceName: String let receiverName: String + let receiverID: String let preferredIP: String let thunderboltIP: String let usbIP: String @@ -18,6 +19,7 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { init( serviceName: String, receiverName: String, + receiverID: String = "", preferredIP: String, thunderboltIP: String, usbIP: String = "", @@ -32,6 +34,7 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { ) { self.serviceName = serviceName self.receiverName = receiverName + self.receiverID = UUID(uuidString: receiverID)?.uuidString ?? "" self.preferredIP = preferredIP self.thunderboltIP = thunderboltIP self.usbIP = usbIP @@ -45,11 +48,37 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { self.hostName = hostName } - var id: String { "\(serviceName)|\(preferredIP)" } + /// SwiftUI selection and persisted sessions both use this stable identity. + /// New receivers advertise a UUID; older receivers fall back to their + /// Bonjour service name so existing installations remain selectable. + var id: String { stableIdentity } - /// Bonjour service names survive link-local IP address changes after wake. - /// Use this only for persisted receiver preferences, not Picker selection. - var stableIdentity: String { "service:\(serviceName)" } + var legacyID: String { "\(serviceName)|\(preferredIP)" } + + /// Stable across network changes, and across renaming for UUID receivers. + var stableIdentity: String { + receiverID.isEmpty ? "service:\(serviceName)" : "receiver:\(receiverID)" + } + + /// Accept the stable UUID, the previous service-name identity, and the + /// oldest `service|ip` picker value. This migrates saved sessions without + /// ever treating a changed IP as a different physical display. + func matchesPersistedIdentity(_ value: String) -> Bool { + let needle = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !needle.isEmpty else { return false } + + if needle == id.lowercased() || + needle == stableIdentity.lowercased() || + needle == legacyID.lowercased() || + needle == receiverID.lowercased() || + needle == serviceName.lowercased() || + needle == "service:\(serviceName)".lowercased() { + return true + } + + let legacyServiceName = needle.split(separator: "|", maxSplits: 1).first.map(String.init) ?? "" + return !legacyServiceName.isEmpty && legacyServiceName == serviceName.lowercased() + } var shortHostName: String? { guard let host = hostName, !host.isEmpty else { return nil } @@ -158,6 +187,7 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { } let receiverName = stringValue("name").isEmpty ? service.name : stringValue("name") + let receiverID = stringValue("receiverID") let receiverIP = stringValue("ip") let thunderboltIP = stringValue("tbIP") let usbIP = stringValue("usbIP") @@ -194,6 +224,7 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { let receiver = TBDiscoveredReceiver( serviceName: service.name, receiverName: receiverName, + receiverID: receiverID, preferredIP: preferredIP, thunderboltIP: thunderboltIP, usbIP: usbIP, @@ -207,7 +238,12 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { hostName: service.hostName ) - if let index = receivers.firstIndex(where: { $0.serviceName == receiver.serviceName }) { + if let index = receivers.firstIndex(where: { + if !receiver.receiverID.isEmpty && !$0.receiverID.isEmpty { + return $0.receiverID == receiver.receiverID + } + return $0.serviceName == receiver.serviceName + }) { receivers[index] = receiver } else { receivers.append(receiver) diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift index 0e90d79a..11e4b16e 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift @@ -10,6 +10,7 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { private func makeReceiver( serviceName: String = "TargetBridge Test-iMac", receiverName: String = "Test-iMac", + receiverID: String = "", preferredIP: String = "192.168.1.64", thunderboltIP: String = "", usbIP: String = "", @@ -22,6 +23,7 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { TBDiscoveredReceiver( serviceName: serviceName, receiverName: receiverName, + receiverID: receiverID, preferredIP: preferredIP, thunderboltIP: thunderboltIP, usbIP: usbIP, @@ -81,21 +83,62 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { // MARK: - Identity - func testIDCombinesServiceNameAndPreferredIP() { + func testLegacyIDCombinesServiceNameAndPreferredIP() { let receiver = makeReceiver(serviceName: "TargetBridge Jonathans-iMac", preferredIP: "192.168.1.64") - XCTAssertEqual(receiver.id, "TargetBridge Jonathans-iMac|192.168.1.64") + XCTAssertEqual(receiver.legacyID, "TargetBridge Jonathans-iMac|192.168.1.64") + XCTAssertEqual(receiver.id, "service:TargetBridge Jonathans-iMac") } func testStableIdentityDoesNotChangeWhenTheReceiverIPChanges() { - let beforeWake = makeReceiver(serviceName: "TargetBridge Jonathans-iMac", preferredIP: "169.254.89.80") - let afterWake = makeReceiver(serviceName: "TargetBridge Jonathans-iMac", preferredIP: "169.254.12.44") + let beforeWake = makeReceiver( + serviceName: "TargetBridge Jonathans-iMac", + receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906", + preferredIP: "169.254.89.80" + ) + let afterWake = makeReceiver( + serviceName: "TargetBridge Jonathans-iMac (2)", + receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906", + preferredIP: "169.254.12.44" + ) - XCTAssertNotEqual(beforeWake.id, afterWake.id) + XCTAssertEqual(beforeWake.id, afterWake.id) XCTAssertEqual(beforeWake.stableIdentity, afterWake.stableIdentity) } + func testDifferentReceiversWithTheSameNameRemainDistinct() { + let first = makeReceiver(receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906") + let second = makeReceiver(receiverID: "B4E28721-22A7-42A9-89D7-70F3DBB0E906") + + XCTAssertNotEqual(first.id, second.id) + } + + func testPersistedIdentityMigratesLegacyServiceAndIPValue() { + let receiver = makeReceiver( + serviceName: "TargetBridge Jonathans-iMac", + receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906", + preferredIP: "169.254.12.44" + ) + + XCTAssertTrue(receiver.matchesPersistedIdentity("receiver:a4e28721-22a7-42a9-89d7-70f3dbb0e906")) + XCTAssertTrue(receiver.matchesPersistedIdentity("A4E28721-22A7-42A9-89D7-70F3DBB0E906")) + XCTAssertTrue(receiver.matchesPersistedIdentity("service:TargetBridge Jonathans-iMac")) + XCTAssertTrue(receiver.matchesPersistedIdentity("TargetBridge Jonathans-iMac|169.254.89.80")) + XCTAssertFalse(receiver.matchesPersistedIdentity("TargetBridge Other-iMac|169.254.89.80")) + } + // MARK: - shortHostName + func testMalformedAdvertisedIDFallsBackToLegacyIdentity() { + XCTAssertEqual(makeReceiver(receiverID: "not-a-uuid").id, + "service:TargetBridge Test-iMac") + } + + func testAdvertisedUUIDCaseDoesNotChangePickerIdentity() { + let uuid = "A4E28721-22A7-42A9-89D7-70F3DBB0E906" + XCTAssertEqual(makeReceiver(receiverID: uuid).id, + makeReceiver(receiverID: uuid.lowercased()).id) + } + func testShortHostNameStripsTrailingDotAndDomain() { let receiver = makeReceiver(hostName: "Jonathans-iMac.local.") XCTAssertEqual(receiver.shortHostName, "Jonathans-iMac") diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index 36d8c9c2..8fe0fd36 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -8,6 +8,64 @@ import XCTest /// silently reroute automation traffic. @MainActor final class TBSenderAutomationParsingTests: XCTestCase { + func testHeadlessMirrorUsesOnlyOnlineVirtualDisplayDirectly() { + XCTAssertTrue( + TBDisplaySenderSession.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: 120, + mainDisplayID: 120, + onlineDisplayIDs: [120] + ) + ) + } + + func testHeadlessMirrorDoesNotBypassMirroringWithPhysicalDisplayOnline() { + XCTAssertFalse( + TBDisplaySenderSession.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: 120, + mainDisplayID: 120, + onlineDisplayIDs: [120, 1] + ) + ) + XCTAssertFalse( + TBDisplaySenderSession.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: 120, + mainDisplayID: 1, + onlineDisplayIDs: [1, 120] + ) + ) + } + + func testHeadlessMirrorRequiresVirtualDisplayToBeOnline() { + XCTAssertFalse( + TBDisplaySenderSession.shouldUseHeadlessVirtualDisplay( + virtualDisplayID: 120, + mainDisplayID: 120, + onlineDisplayIDs: [] + ) + ) + } + + func testDirectHeadlessVideoStartsAuxiliaryAudioOnlyWhenEnabled() { + XCTAssertTrue( + TBDisplaySenderSession.needsAuxiliaryAudioCapture( + usingDirectDisplayStream: true, + shouldRelayAudio: true + ) + ) + XCTAssertFalse( + TBDisplaySenderSession.needsAuxiliaryAudioCapture( + usingDirectDisplayStream: true, + shouldRelayAudio: false + ) + ) + XCTAssertFalse( + TBDisplaySenderSession.needsAuxiliaryAudioCapture( + usingDirectDisplayStream: false, + shouldRelayAudio: true + ) + ) + } + func testReceiverControlKeepsNativeCursorWithoutLargeCursor() { XCTAssertFalse( TBInputControlRole.receiverMaster.usesLowLatencyCursorOverlay( @@ -56,6 +114,39 @@ final class TBSenderAutomationParsingTests: XCTestCase { ) } + func testNativeScaleAppliesOnlyToPanelMatchedProfiles() { + XCTAssertTrue(TBDisplayCapturePreset.standard1080p.rendersAtNativeScale) + XCTAssertTrue(TBDisplayCapturePreset.smooth1080p60.rendersAtNativeScale) + for preset in TBDisplayCapturePreset.allCases where preset.width > 1920 { + XCTAssertFalse( + preset.rendersAtNativeScale, + "\(preset.rawValue) targets a Retina panel and must keep its HiDPI mode" + ) + } + } + + /// A 1x mode must hand CGVirtualDisplay the full stream size with a framebuffer + /// to match, not the halved HiDPI point size. Getting this backwards renders the + /// desktop at 960x540 and draws every control at 2x on a 1080p panel. + func testNativeScaleModeRendersFullSizeWithoutHiDPIBacking() { + let mode = TBDisplayCapturePreset.standard1080p.nativeScaleDisplayMode + XCTAssertEqual(mode.width, 1920) + XCTAssertEqual(mode.height, 1080) + XCTAssertFalse(mode.isHiDPI) + XCTAssertEqual(mode.backingWidth, 1920) + XCTAssertEqual(mode.backingHeight, 1080) + } + + /// Render matching keeps its 2x contract for every non-native-scale profile. + func testRenderMatchedModeKeepsHiDPIBacking() { + let mode = TBDisplayCapturePreset.standard1440p.renderMatchedDisplayMode + XCTAssertEqual(mode.width, 1280) + XCTAssertEqual(mode.height, 720) + XCTAssertTrue(mode.isHiDPI) + XCTAssertEqual(mode.backingWidth, 2560) + XCTAssertEqual(mode.backingHeight, 1440) + } + func testHighFrameRatePresetsUseFiveCaptureSurfaces() { XCTAssertEqual(TBDisplayCapturePreset.standard1440p.queueDepth, 3) XCTAssertEqual(TBDisplayCapturePreset.smooth1440p60.queueDepth, 5) @@ -70,6 +161,98 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertEqual(TBDisplayCapturePreset.native5k.captureRequestFrameRate, 96) } + func testAutomaticCodecUsesH264WhenHEVCIsUnavailable() { + let decision = TBDisplaySenderSession.chooseCodec( + preference: .automatic, + preset: .standard1440p, + receiverSupportsHEVC: false, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(decision?.codecType, kCMVideoCodecType_H264) + XCTAssertEqual(decision?.usedFallback, false) + } + + func testAutomaticCodecUsesHEVCWhenBothMacsSupportIt() { + for preset in [TBDisplayCapturePreset.standard1440p, .retina4k60] { + let decision = TBDisplaySenderSession.chooseCodec( + preference: .automatic, + preset: preset, + receiverSupportsHEVC: true, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(decision?.codecType, kCMVideoCodecType_HEVC) + XCTAssertEqual(decision?.usedFallback, false) + } + } + + func testAutomaticHighResolutionFallsBackToH264WithoutHEVC() { + let decision = TBDisplaySenderSession.chooseCodec( + preference: .automatic, + preset: .retina4k60, + receiverSupportsHEVC: false, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(decision?.codecType, kCMVideoCodecType_H264) + XCTAssertEqual(decision?.usedFallback, true) + } + + func testManualCodecChoicesAreHonouredWhenAvailable() { + let h264 = TBDisplaySenderSession.chooseCodec( + preference: .h264, + preset: .retina4k60, + receiverSupportsHEVC: true, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(h264?.codecType, kCMVideoCodecType_H264) + XCTAssertEqual(h264?.usedFallback, false) + + let hevc = TBDisplaySenderSession.chooseCodec( + preference: .hevc, + preset: .standard1440p, + receiverSupportsHEVC: true, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(hevc?.codecType, kCMVideoCodecType_HEVC) + XCTAssertEqual(hevc?.usedFallback, false) + } + + func testManualCodecFallsBackOnlyToACompatibleAlternative() { + let hevcToH264 = TBDisplaySenderSession.chooseCodec( + preference: .hevc, + preset: .standard1440p, + receiverSupportsHEVC: false, + senderSupportsH264: true, + senderSupportsHEVC: true + ) + XCTAssertEqual(hevcToH264?.codecType, kCMVideoCodecType_H264) + XCTAssertEqual(hevcToH264?.usedFallback, true) + + let h264ToHEVC = TBDisplaySenderSession.chooseCodec( + preference: .h264, + preset: .native5k, + receiverSupportsHEVC: true, + senderSupportsH264: false, + senderSupportsHEVC: true + ) + XCTAssertEqual(h264ToHEVC?.codecType, kCMVideoCodecType_HEVC) + XCTAssertEqual(h264ToHEVC?.usedFallback, true) + } + + func testCodecSelectionFailsCleanlyWhenNoHardwarePathExists() { + XCTAssertNil(TBDisplaySenderSession.chooseCodec( + preference: .automatic, + preset: .native5k, + receiverSupportsHEVC: false, + senderSupportsH264: false, + senderSupportsHEVC: false + )) + } + func testFrameRatePacerSamples75HzInputAt60Hz() { var pacer = TBFrameRatePacer(maximumFrameRate: 60) let emitted = (0..<750).filter { frame in @@ -267,6 +450,7 @@ final class TBSenderAutomationParsingTests: XCTestCase { TBDiscoveredReceiver( serviceName: "TargetBridge Jonathans-iMac", receiverName: "Jonathans-iMac", + receiverID: "A4E28721-22A7-42A9-89D7-70F3DBB0E906", preferredIP: "192.168.1.64", thunderboltIP: "169.254.89.80", usbIP: "169.254.189.3", @@ -299,6 +483,8 @@ final class TBSenderAutomationParsingTests: XCTestCase { } func testMatchesByID() { + XCTAssertTrue(TBSenderAutomation.matches("receiver:A4E28721-22A7-42A9-89D7-70F3DBB0E906", makeReceiver())) + XCTAssertTrue(TBSenderAutomation.matches("A4E28721-22A7-42A9-89D7-70F3DBB0E906", makeReceiver())) XCTAssertTrue(TBSenderAutomation.matches("targetbridge jonathans-imac|192.168.1.64", makeReceiver())) } @@ -307,7 +493,80 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertFalse(TBSenderAutomation.matches("10.0.0.1", makeReceiver())) } + func testAutomaticReceiverUsesPreferredStableIdentityAmongSeveral() { + let preferred = makeReceiver() + let other = TBDiscoveredReceiver( + serviceName: "TargetBridge Other-iMac", + receiverName: "Other-iMac", + receiverID: "other-receiver", + preferredIP: "192.168.1.70", + thunderboltIP: "", + networkIP: "192.168.1.70", + panelSummary: "iMac 4K", + version: "3.5.2", + supportsHEVCDecode: true, + hostName: "Other-iMac.local." + ) + + XCTAssertEqual( + TBSenderAutomation.automaticReceiver( + in: [other, preferred], + preferredIdentity: preferred.stableIdentity + ), + preferred + ) + } + + func testAutomaticReceiverUsesOnlyReceiverWithoutPreference() { + let receiver = makeReceiver() + XCTAssertEqual( + TBSenderAutomation.automaticReceiver(in: [receiver], preferredIdentity: ""), + receiver + ) + } + + func testAutomaticReceiverRefusesAmbiguousFirstChoice() { + let first = makeReceiver() + let second = TBDiscoveredReceiver( + serviceName: "TargetBridge Other-iMac", + receiverName: "Other-iMac", + receiverID: "other-receiver", + preferredIP: "192.168.1.70", + thunderboltIP: "", + networkIP: "192.168.1.70", + panelSummary: "iMac 4K", + version: "3.5.2", + supportsHEVCDecode: true, + hostName: "Other-iMac.local." + ) + + XCTAssertNil( + TBSenderAutomation.automaticReceiver(in: [first, second], preferredIdentity: "") + ) + } + // MARK: - resolveSessionIndex tri-state + + func testAutomaticReceiverDoesNotReplaceMissingPreferredMonitor() { + XCTAssertNil(TBSenderAutomation.automaticReceiver( + in: [makeReceiver()], preferredIdentity: "receiver:B4E28721-22A7-42A9-89D7-70F3DBB0E906")) + } + + func testAutomaticReceiverRejectsAmbiguousLegacyPreference() { + let first = makeReceiver() + XCTAssertNil(TBSenderAutomation.automaticReceiver( + in: [first, first], preferredIdentity: "service:\(first.serviceName)")) + } + + func testMissingIdentityDoesNotBecomeRawHostname() { + for value in ["receiver:unknown", "service:TargetBridge iMac", "iMac|169.254.1.2", + "A4E28721-22A7-42A9-89D7-70F3DBB0E906"] { + XCTAssertTrue(TBSenderAutomation.isPersistedReceiverReference(value)) + } + for value in ["iMac.local", "192.168.1.64", "fe80::1234"] { + XCTAssertFalse(TBSenderAutomation.isPersistedReceiverReference(value)) + } + } // // Returns `nil` = invalid input, `.some(nil)` = target all sessions, // `.some(index)` = zero-based session index. diff --git a/TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift b/TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift index acdef6af..0c6af063 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift @@ -1,37 +1,69 @@ import Foundation -@MainActor -enum TBInputDebugLog { - private static let fileManager = FileManager.default +private final class TBAsyncInputDebugLogWriter: @unchecked Sendable { + private let fileManager = FileManager.default + private let writeQueue = DispatchQueue(label: "fd.tbdisplaysender.input-debug-log", qos: .utility) - private static var logURL: URL { + func enqueue(_ message: String) { + writeQueue.async { [self] in + write(message) + } + } + + private func write(_ message: String) { let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true) - return base + let url = base .appendingPathComponent("TargetBridge", isDirectory: true) .appendingPathComponent("Logs", isDirectory: true) .appendingPathComponent("input-debug.log", isDirectory: false) - } - - static func log(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "\(timestamp) \(message)\n" - let url = logURL let directory = url.deletingLastPathComponent() try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - if let data = line.data(using: .utf8) { - if fileManager.fileExists(atPath: url.path) { - if let handle = try? FileHandle(forWritingTo: url) { - try? handle.seekToEnd() - try? handle.write(contentsOf: data) - try? handle.close() - } - } else { - try? data.write(to: url, options: .atomic) + guard let data = line.data(using: .utf8) else { return } + if fileManager.fileExists(atPath: url.path) { + if let handle = try? FileHandle(forWritingTo: url) { + try? handle.seekToEnd() + try? handle.write(contentsOf: data) + try? handle.close() } + } else { + try? data.write(to: url, options: .atomic) } } +} + +@MainActor +enum TBInputDebugLog { + private static let writer = TBAsyncInputDebugLogWriter() + private static var inputEventCount: UInt64 = 0 + private static let inputEventBurstLimit: UInt64 = 20 + private static let inputEventSampleInterval: UInt64 = 100 + + private static var logURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true) + return base + .appendingPathComponent("TargetBridge", isDirectory: true) + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent("input-debug.log", isDirectory: false) + } + + static func log(_ message: String) { + writer.enqueue(message) + } + + /// Mouse motion can arrive hundreds of times per second. Keep the first few + /// records plus a periodic sample, without putting file I/O on the input path. + static func logInputEvent(_ message: @autoclosure () -> String) { + inputEventCount &+= 1 + let count = inputEventCount + + guard count <= inputEventBurstLimit || count.isMultiple(of: inputEventSampleInterval) else { return } + log(message()) + } static var currentLogPath: String { logURL.path diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 5f896ae7..f66b095e 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 96DDF119DE60DFBA437D32D2 /* en.json in Resources */ = {isa = PBXBuildFile; fileRef = D386D0FCD0F93494220CC3DC /* en.json */; }; 9B07E096AE81E351B6D1E17C /* fr.json in Resources */ = {isa = PBXBuildFile; fileRef = 73EDC0FF3A4EA4DC1ED63528 /* fr.json */; }; A3FD74604472363535D63273 /* TBDisplaySenderSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */; }; + AB66DEF6790324891146EE6E /* TBDisplaySenderPermissionAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DBAD89D0448890049E949 /* TBDisplaySenderPermissionAssistant.swift */; }; B9FA5989F04108E9FBFA8D3B /* TBDisplaySenderApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD235429FA67D231F565C93E /* TBDisplaySenderApp.swift */; }; BB4D986A5A0F32BC1480BD4D /* TBDisplaySenderSurfaceViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */; }; BB61FDD4D950F325E9D2ADAC /* TBInjectedPointerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10EC09EEBA6EA9B2EA6ACD1F /* TBInjectedPointerEvent.swift */; }; @@ -111,6 +112,7 @@ A3F0782C970AA23871AE73A6 /* TBConfigurationDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConfigurationDiagnostics.swift; sourceTree = ""; }; A51B69D318A85FF3061D6ED4 /* input-dockstation.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "input-dockstation.json"; sourceTree = ""; }; A7086193EE5710E2C37F86F1 /* TBConfigurationDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConfigurationDiagnosticsTests.swift; sourceTree = ""; }; + AD2DBAD89D0448890049E949 /* TBDisplaySenderPermissionAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderPermissionAssistant.swift; sourceTree = ""; }; B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderSettingsView.swift; sourceTree = ""; }; C11B4EB862393E5CE95426D4 /* TBCaptureHealthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCaptureHealthTests.swift; sourceTree = ""; }; C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiverBackedVirtualDisplaySession.swift; sourceTree = ""; }; @@ -155,6 +157,7 @@ 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */, 9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */, 4644CFB05098C895378B648A /* TBDisplaySenderManager.swift */, + AD2DBAD89D0448890049E949 /* TBDisplaySenderPermissionAssistant.swift */, 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */, B95D015FB8DF172C7418CB5D /* TBDisplaySenderSettingsView.swift */, 6AC1AF99431D4889F947CE1B /* TBDisplaySenderStatusItemController.swift */, @@ -373,7 +376,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"${TB_SENDER_BUILD:-$(date +%Y%m%d%H%M%S)}\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" <TargetBridge requires screen recording permission to capture the virtual display or mirror the desktop to the iMac. NSAppleEventsUsageDescription TargetBridge uses System Events to run shortcuts selected for Receiver is Master. + NSBonjourServices + + _targetbridge._tcp + + NSLocalNetworkUsageDescription + TargetBridge connects to your receiver over Thunderbolt Bridge or your local network. CFBundleURLTypes diff --git a/TargetBridge-Sender/project.yml b/TargetBridge-Sender/project.yml index 7f3ba020..6c1627f6 100644 --- a/TargetBridge-Sender/project.yml +++ b/TargetBridge-Sender/project.yml @@ -27,8 +27,8 @@ targets: # 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.5.2" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "3.6.0" + CURRENT_PROJECT_VERSION: "20261005172051" SWIFT_VERSION: "6.0" ENABLE_HARDENED_RUNTIME: NO SWIFT_STRICT_CONCURRENCY: minimal @@ -44,7 +44,7 @@ targets: STAMP="${TB_SENDER_BUILD:-$(date +%Y%m%d%H%M%S)}" cat > "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" <&2 + export TARGETBRIDGE_CODESIGN_IDENTITY=- + export TARGETBRIDGE_ALLOW_ADHOC=1 +fi cd "$ROOT" xcodegen generate +# Package a standalone app, not Xcode's preview executor plus unsigned debug +# dylibs. This also lets strict signing verify the same bundle on Xcode 16+. xcodebuild \ -scheme TBDisplaySender \ -configuration "$CONFIGURATION" \ @@ -22,15 +36,28 @@ xcodebuild \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ + ENABLE_DEBUG_DYLIB=NO \ build mkdir -p "$DEST_DIR" -rm -rf "$DEST_APP" -ditto "$SOURCE_APP" "$DEST_APP" +STAGING_DIR=$(mktemp -d "$DEST_DIR/.sender-signing.XXXXXX") +STAGED_APP="$STAGING_DIR/TargetBridge.app" +ditto "$SOURCE_APP" "$STAGED_APP" echo "Cleaning extended attributes..." -xattr -cr "$DEST_APP" || true +xattr -cr "$STAGED_APP" echo "Signing sender application..." -codesign --force --deep --sign - "$DEST_APP" || true +"$SCRIPT_DIR/sign_targetbridge_sender_app.sh" "$STAGED_APP" +if [[ -e "$DEST_APP" ]]; then + # Retain the old artifact inside the staging directory for recovery. + mv "$DEST_APP" "$STAGING_DIR/Previous TargetBridge.app" +fi +if ! mv "$STAGED_APP" "$DEST_APP"; then + if [[ ! -e "$DEST_APP" && -e "$STAGING_DIR/Previous TargetBridge.app" ]]; then + mv "$STAGING_DIR/Previous TargetBridge.app" "$DEST_APP" + fi + echo "Could not activate the signed build; recovery files: $STAGING_DIR" >&2 + exit 1 +fi touch "$DEST_APP" echo "TargetBridge sender built: $DEST_APP" diff --git a/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh b/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh new file mode 100755 index 00000000..88ea4f2d --- /dev/null +++ b/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh @@ -0,0 +1,39 @@ +#!/bin/zsh +set -euo pipefail + +fail() { print -u2 -- "Sender signing: $*"; exit 1; } +APP="${1:-}" +[[ -n "$APP" && $# -eq 1 ]] || fail "Usage: $0 TargetBridge.app | --check-identity" + +CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt}" +IDENTITY="${TARGETBRIDGE_CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" && -f "$CONFIG" ]]; then + IDENTITY=$(<"$CONFIG") +fi +if [[ -z "$IDENTITY" ]]; then + fail "No persistent signing identity. Configure TARGETBRIDGE_CODESIGN_IDENTITY or $CONFIG. See docs/Sender-Signing.md. Refusing an implicit ad-hoc build." +fi +if [[ "$IDENTITY" == - ]]; then + [[ "${TARGETBRIDGE_ALLOW_ADHOC:-0}" == 1 ]] || fail "Ad-hoc signing requires TARGETBRIDGE_ALLOW_ADHOC=1; updates may lose privacy permissions." + print -u2 -- "WARNING: disposable ad-hoc development build; do not install over a persistent signed Sender." +else + [[ "$IDENTITY" =~ '^[[:xdigit:]]{40}$' ]] || fail "Use the exact 40-character certificate fingerprint, not a display name" + IDENTITIES=$(/usr/bin/security find-identity -v -p codesigning) + [[ "${(U)IDENTITIES}" == *" ${(U)IDENTITY} "* ]] || fail "Configured certificate is not a valid code-signing identity in this user's Keychain. Unlock/configure it through macOS; no fallback is allowed." +fi +[[ "$APP" != --check-identity ]] || { print -- 'Signing identity preflight passed'; exit 0; } +PLIST="$APP/Contents/Info.plist" +[[ -f "$PLIST" && ! -L "$APP" ]] || fail "Expected a real application bundle" +BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST") +[[ "$BUNDLE_ID" == com.targetbridge.sender ]] || fail "Wrong bundle identifier: $BUNDLE_ID" + +# Use the system-generated designated requirement. Never weaken it to just a +# bundle identifier: certificate-bound identity is what makes updates trustworthy. +/usr/bin/codesign --force --sign "$IDENTITY" "$APP" +/usr/bin/codesign --verify --deep --strict --all-architectures "$APP" +DETAILS=$(/usr/bin/codesign -dv "$APP" 2>&1) +if [[ "$IDENTITY" != - && "$DETAILS" == *"Signature=adhoc"* ]]; then + fail "A persistent signature was requested but the result is ad-hoc" +fi +/usr/bin/codesign -d -r- "$APP" 2>&1 +print -- "Sender signature verified: $APP" diff --git a/TargetBridge-Sender/scripts/test_receiver_identity.rb b/TargetBridge-Sender/scripts/test_receiver_identity.rb new file mode 100644 index 00000000..111eaf74 --- /dev/null +++ b/TargetBridge-Sender/scripts/test_receiver_identity.rb @@ -0,0 +1,89 @@ +#!/usr/bin/env ruby +# macOS + full Xcode/XCTest. No app is launched and no capture/input permission +# is requested. Extract pure production code; do not duplicate its logic here. +require 'tmpdir' +require 'fileutils' +root = File.expand_path('..', __dir__) +def section(source, first, last) + start = source.index(first) or abort "Missing section: #{first}" + stop = source.index(last, start) or abort "Missing boundary: #{last}" + source[start...stop] +end +Dir.mktmpdir('tb-receiver-identity.') do |dir| + sources = File.join(dir, 'Sources/TargetBridge') + tests = File.join(dir, 'Tests/TargetBridgeTests') + FileUtils.mkdir_p([sources, tests]) + discovery = File.read(File.join(root, 'TBDisplaySender/TBReceiverDiscovery.swift')) + model = section(discovery, 'import Foundation', 'final class TBReceiverDiscovery:') + manager = File.read(File.join(root, 'TBDisplaySender/TBDisplaySenderManager.swift')) + transport = section(manager, 'enum TBTransportKind:', ' func title(') + "}\n" + automation = File.read(File.join(root, 'TBDisplaySender/TBDisplaySenderAutomation.swift')) + selectors = section(automation, ' static func automaticReceiver(', ' private static func selectConnectionPath(') + matching = section(automation, ' static func matches(', ' static func parseTransport(') + File.write(File.join(sources, 'Identity.swift'), model + transport + + "\nenum TBSenderAutomation {\n" + selectors + matching + "}\n") + FileUtils.cp(File.join(root, 'TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift'), tests) + parsing = File.read(File.join(root, 'TBDisplaySenderTests/TBSenderAutomationParsingTests.swift')) + cases = section(parsing, ' private func makeReceiver()', ' func testNoSessionParamTargetsAllSessionsWhenNotCreating()') + File.write(File.join(tests, 'SelectionTests.swift'), + "import XCTest\n@testable import TargetBridge\nfinal class SelectionTests: XCTestCase {\n" + cases + "}\n") + File.write(File.join(dir, 'Package.swift'), <<~'SWIFT') + // swift-tools-version: 5.9 + import PackageDescription + let package = Package(name: "ReceiverIdentityTests", platforms: [.macOS(.v14)], + targets: [.target(name: "TargetBridge"), + .testTarget(name: "TargetBridgeTests", dependencies: ["TargetBridge"])]) + SWIFT + abort 'Identity/selection tests failed' unless system('swift', 'test', '--package-path', dir, + '--scratch-path', File.join(dir, 'build'), '--cache-path', File.join(dir, 'cache'), + '--config-path', File.join(dir, 'config'), '--security-path', File.join(dir, 'security'), + '--disable-sandbox') + + # Exercise the exact persistence function in a unique disposable preferences + # domain, never com.targetbridge.receiver or the running app's preferences. + main = File.read(File.join(root, '../TargetBridge-Receiver/TBReceiverC/src/main.c')) + persist = section(main, 'static int tb_receiver_load_or_create_id(', 'static void on_bonjour_register(') + harness = <<~'C' + #include + #include + #include + #include + #include + #include + C + harness += persist + harness += <<~'C' + int main(void) { + CFStringRef domain = CFStringCreateWithFormat(NULL, NULL, + CFSTR("org.targetbridge.tests.identity.%d"), getpid()); + char first[64], second[64]; + assert(tb_receiver_load_or_create_id(first, sizeof(first), domain) == 0); + assert(strlen(first) == 36); + assert(tb_receiver_load_or_create_id(second, sizeof(second), domain) == 0); + assert(strcmp(first, second) == 0); + CFPreferencesSetAppValue(CFSTR("receiverID"), CFSTR("not-a-uuid"), domain); + assert(tb_receiver_load_or_create_id(second, sizeof(second), domain) == 0); + assert(strlen(second) == 36 && strcmp(first, second) != 0); + CFPreferencesSetAppValue(CFSTR("receiverID"), kCFBooleanTrue, domain); + assert(tb_receiver_load_or_create_id(first, sizeof(first), domain) == 0); + assert(strlen(first) == 36); + CFPreferencesSetAppValue(CFSTR("receiverID"), + CFSTR("a4e28721-22a7-42a9-89d7-70f3dbb0e906"), domain); + assert(tb_receiver_load_or_create_id(first, sizeof(first), domain) == 0); + assert(strcmp(first, "A4E28721-22A7-42A9-89D7-70F3DBB0E906") == 0); + assert(tb_receiver_load_or_create_id(second, 2, domain) == -1 && second[0] == 0); + assert(tb_receiver_load_or_create_id(NULL, 64, domain) == -1); + assert(tb_receiver_load_or_create_id(second, sizeof(second), NULL) == -1); + CFPreferencesSetAppValue(CFSTR("receiverID"), NULL, domain); + assert(CFPreferencesAppSynchronize(domain)); + CFRelease(domain); + puts("PASS: Receiver UUID creation, persistence, normalization, malformed/type recovery and argument guards"); + } + C + c_path = File.join(dir, 'identity.c') + binary = File.join(dir, 'identity-test') + File.write(c_path, harness) + abort 'Persistence test build failed' unless system('xcrun', 'clang', '-Wall', '-Wextra', '-Werror', + c_path, '-framework', 'CoreFoundation', '-o', binary) + abort 'Persistence test failed' unless system(binary) +end diff --git a/TargetBridge-Sender/scripts/test_sender_signing.sh b/TargetBridge-Sender/scripts/test_sender_signing.sh new file mode 100755 index 00000000..86567faa --- /dev/null +++ b/TargetBridge-Sender/scripts/test_sender_signing.sh @@ -0,0 +1,42 @@ +#!/bin/zsh +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/targetbridge-signing-test.XXXXXX") +BASE="${1:?Usage: test_sender_signing.sh signed-TargetBridge.app}" +SIGN="$SCRIPT_DIR/sign_targetbridge_sender_app.sh" +VERIFY="$SCRIPT_DIR/verify_sender_update.sh" +expect_reject() { + local title="$1"; shift + if "$@" >"$WORK/rejection.log" 2>&1; then + print -u2 -- "FAIL: accepted $title"; exit 1 + fi + print -- "PASS: rejected $title" +} +ditto "$BASE" "$WORK/A.app" +ditto "$BASE" "$WORK/B.app" +# Changing a bundle resource changes the sealed CodeDirectory, reproducing the +# identity risk without launching a capture process or requesting permissions. +/usr/libexec/PlistBuddy -c 'Set :CFBundleVersion 999999' "$WORK/B.app/Contents/Info.plist" +zsh "$SIGN" "$WORK/B.app" +zsh "$VERIFY" "$WORK/A.app" "$WORK/B.app" +HASH_A=$(/usr/bin/shasum -a 256 "$WORK/A.app/Contents/MacOS/TargetBridge") +HASH_B=$(/usr/bin/shasum -a 256 "$WORK/B.app/Contents/MacOS/TargetBridge") +[[ "${HASH_A%% *}" != "${HASH_B%% *}" ]] || { print -u2 'FAIL: fixtures have identical signed executables'; exit 1; } +print -- 'PASS: different signed executable hashes, stable identity' +expect_reject 'missing identity' env TARGETBRIDGE_CODESIGN_IDENTITY= TARGETBRIDGE_SIGNING_CONFIG="$WORK/missing" zsh "$SIGN" "$WORK/B.app" +expect_reject 'unavailable identity' env TARGETBRIDGE_CODESIGN_IDENTITY=0000000000000000000000000000000000000000 zsh "$SIGN" "$WORK/B.app" +expect_reject 'implicit ad-hoc signing' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=0 zsh "$SIGN" "$WORK/B.app" +ditto "$WORK/B.app" "$WORK/Adhoc.app" +TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +expect_reject 'ad-hoc update' zsh "$VERIFY" "$WORK/A.app" "$WORK/Adhoc.app" +expect_reject 'ad-hoc baseline' zsh "$VERIFY" "$WORK/Adhoc.app" "$WORK/B.app" +ditto "$WORK/B.app" "$WORK/WrongID.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier com.targetbridge.receiver' "$WORK/WrongID.app/Contents/Info.plist" +expect_reject 'wrong signing bundle ID' zsh "$SIGN" "$WORK/WrongID.app" +expect_reject 'wrong update bundle ID' zsh "$VERIFY" "$WORK/A.app" "$WORK/WrongID.app" +ditto "$WORK/B.app" "$WORK/Tampered.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleVersion 888888' "$WORK/Tampered.app/Contents/Info.plist" +expect_reject 'tampered candidate' zsh "$VERIFY" "$WORK/A.app" "$WORK/Tampered.app" +expect_reject 'tampered installed baseline' zsh "$VERIFY" "$WORK/Tampered.app" "$WORK/B.app" +zsh "$VERIFY" "$WORK/A.app" "$WORK/B.app" +print -- "All signing checks passed. Fixtures retained at $WORK; no installed app or TCC setting changed." diff --git a/TargetBridge-Sender/scripts/test_sender_signing_guards.sh b/TargetBridge-Sender/scripts/test_sender_signing_guards.sh new file mode 100755 index 00000000..6974d0b8 --- /dev/null +++ b/TargetBridge-Sender/scripts/test_sender_signing_guards.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +# Negative-path checks that do not need a trusted signing certificate. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/targetbridge-signing-guards.XXXXXX") +BASE="${1:?Usage: test_sender_signing_guards.sh TargetBridge.app}" +SIGN="$SCRIPT_DIR/sign_targetbridge_sender_app.sh" +VERIFY="$SCRIPT_DIR/verify_sender_update.sh" +reject_with() { + local expected="$1"; shift + if "$@" >"$WORK/rejection.log" 2>&1; then + print -u2 -- "FAIL: unexpectedly accepted $expected"; exit 1 + fi + /usr/bin/grep -Fq -- "$expected" "$WORK/rejection.log" || { /bin/cat "$WORK/rejection.log"; exit 1; } + print -- "PASS: $expected" +} +reject_with 'No persistent signing identity' env TARGETBRIDGE_CODESIGN_IDENTITY= TARGETBRIDGE_SIGNING_CONFIG="$WORK/missing" zsh "$SIGN" --check-identity +reject_with 'exact 40-character certificate fingerprint' env TARGETBRIDGE_CODESIGN_IDENTITY='unverified display name' zsh "$SIGN" --check-identity +reject_with 'not a valid code-signing identity' env TARGETBRIDGE_CODESIGN_IDENTITY=0000000000000000000000000000000000000000 zsh "$SIGN" --check-identity +reject_with 'Ad-hoc signing requires' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=0 zsh "$SIGN" --check-identity +ditto "$BASE" "$WORK/Adhoc.app" +TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +reject_with 'Ad-hoc identity cannot preserve' zsh "$VERIFY" "$WORK/Adhoc.app" "$WORK/Adhoc.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier com.targetbridge.receiver' "$WORK/Adhoc.app/Contents/Info.plist" +reject_with 'Wrong bundle identifier' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +reject_with 'Unexpected bundle ID' zsh "$VERIFY" "$WORK/Adhoc.app" "$BASE" +print -- "7 negative checks passed. Persistent-signature and TCC migration tests still require a valid identity and human consent. Fixtures: $WORK" diff --git a/TargetBridge-Sender/scripts/verify_sender_update.sh b/TargetBridge-Sender/scripts/verify_sender_update.sh new file mode 100755 index 00000000..aa6bcd12 --- /dev/null +++ b/TargetBridge-Sender/scripts/verify_sender_update.sh @@ -0,0 +1,20 @@ +#!/bin/zsh +set -euo pipefail +fail() { print -u2 -- "Sender update rejected: $*"; exit 1; } +[[ $# -eq 2 ]] || fail "Usage: $0 installed.app candidate.app" +OLD="$1" +NEW="$2" +for APP in "$OLD" "$NEW"; do + [[ -f "$APP/Contents/Info.plist" && ! -L "$APP" ]] || fail "Invalid app: $APP" + ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist") + [[ "$ID" == com.targetbridge.sender ]] || fail "Unexpected bundle ID: $ID" + /usr/bin/codesign --verify --deep --strict --all-architectures "$APP" + DETAILS=$(/usr/bin/codesign -dv "$APP" 2>&1) + [[ "$DETAILS" != *"Signature=adhoc"* ]] || fail "Ad-hoc identity cannot preserve update permissions. Migrate once to a persistent signer and reapprove in macOS." +done +REQUIREMENT=$(/usr/bin/codesign -d -r- "$OLD" 2>&1 | /usr/bin/sed -nE 's/^#? ?designated => //p') +[[ -n "$REQUIREMENT" ]] || fail "Missing installed identity requirement" +# '=' marks a literal requirement; without it codesign treats the text as a path. +/usr/bin/codesign --verify --deep --strict --all-architectures -R "=$REQUIREMENT" "$NEW" || fail "Candidate does not satisfy the installed Sender identity" +print -- "PASS: candidate satisfies the installed Sender identity on every architecture." +print -- "This verifies signing continuity, not a TCC grant. Install at /Applications/TargetBridge.app without re-signing." diff --git a/TargetBridge-Shared/Languages/de.json b/TargetBridge-Shared/Languages/de.json index 0075cd5b..ed4335e6 100644 --- a/TargetBridge-Shared/Languages/de.json +++ b/TargetBridge-Shared/Languages/de.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "Manuelle Eingabe", "sender.option.not_detected": "Nicht erkannt", "sender.group.stream_profile": "Stream-Profil", + "sender.codec.label": "Videokomprimierung", + "sender.codec.automatic": "Automatisch (empfohlen)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "Wählt den besten kompatiblen Codec für diesen Mac und den Anzeige-Mac.", + "sender.codec.hint.h264": "Bevorzugt H.264 und wechselt sicher, wenn es für die gewählte Auflösung nicht verfügbar ist.", + "sender.codec.hint.hevc": "Bevorzugt HEVC und wechselt sicher, wenn einer der Macs es nicht verwenden kann.", + "sender.codec.unavailable": "Für diese Auflösung ist kein kompatibler Hardware-Videocodec verfügbar.", "sender.group.display_profiles": "Anzeigeprofile", "sender.display_profiles.hint": "Wähle eine fertige Konfiguration. TargetBridge merkt sich die letzte Auswahl für diesen Empfänger.", "sender.display_profiles.work_5k": "Arbeit 5K", diff --git a/TargetBridge-Shared/Languages/en.json b/TargetBridge-Shared/Languages/en.json index e38314e8..781e04ca 100644 --- a/TargetBridge-Shared/Languages/en.json +++ b/TargetBridge-Shared/Languages/en.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "Manual entry", "sender.option.not_detected": "Not detected", "sender.group.stream_profile": "Stream profile", + "sender.codec.label": "Video compression", + "sender.codec.automatic": "Automatic (recommended)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "Chooses the best compatible codec for this Mac and display Mac.", + "sender.codec.hint.h264": "Prefers H.264 and switches safely if it is unavailable at the selected resolution.", + "sender.codec.hint.hevc": "Prefers HEVC and switches safely if either Mac cannot use it.", + "sender.codec.unavailable": "No compatible hardware video codec is available for this resolution.", "sender.group.display_profiles": "Display profiles", "sender.display_profiles.hint": "Apply a ready-to-use setup. TargetBridge remembers the last choice for this receiver.", "sender.display_profiles.work_5k": "Work 5K", diff --git a/TargetBridge-Shared/Languages/fr.json b/TargetBridge-Shared/Languages/fr.json index e18d886f..cededbdb 100644 --- a/TargetBridge-Shared/Languages/fr.json +++ b/TargetBridge-Shared/Languages/fr.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "Saisie manuelle", "sender.option.not_detected": "Non détecté", "sender.group.stream_profile": "Profil de flux", + "sender.codec.label": "Compression vidéo", + "sender.codec.automatic": "Automatique (recommandé)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "Choisit le meilleur codec compatible avec ce Mac et le Mac utilisé comme écran.", + "sender.codec.hint.h264": "Préfère H.264 et change de manière sûre s’il n’est pas disponible à la résolution choisie.", + "sender.codec.hint.hevc": "Préfère HEVC et change de manière sûre si l’un des deux Mac ne peut pas l’utiliser.", + "sender.codec.unavailable": "Aucun codec vidéo matériel compatible n’est disponible pour cette résolution.", "sender.group.display_profiles": "Profils d’affichage", "sender.display_profiles.hint": "Appliquez une configuration prête à l’emploi. TargetBridge mémorise le dernier choix pour ce receiver.", "sender.display_profiles.work_5k": "Travail 5K", diff --git a/TargetBridge-Shared/Languages/it.json b/TargetBridge-Shared/Languages/it.json index 37bc70f5..72a08d6d 100644 --- a/TargetBridge-Shared/Languages/it.json +++ b/TargetBridge-Shared/Languages/it.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "Inserimento manuale", "sender.option.not_detected": "Non rilevato", "sender.group.stream_profile": "Profilo stream", + "sender.codec.label": "Compressione video", + "sender.codec.automatic": "Automatica (consigliata)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "Sceglie il formato migliore compatibile con questo Mac e con il Mac usato come schermo.", + "sender.codec.hint.h264": "Preferisce H.264 e cambia in modo sicuro se non è disponibile alla risoluzione scelta.", + "sender.codec.hint.hevc": "Preferisce HEVC e cambia in modo sicuro se uno dei due Mac non può usarlo.", + "sender.codec.unavailable": "Nessun formato video hardware compatibile è disponibile per questa risoluzione.", "sender.group.display_profiles": "Profili display", "sender.display_profiles.hint": "Applica una configurazione pronta. TargetBridge ricorda l'ultima scelta per questo receiver.", "sender.display_profiles.work_5k": "Lavoro 5K", diff --git a/TargetBridge-Shared/Languages/zh.json b/TargetBridge-Shared/Languages/zh.json index 6cc99284..16632668 100644 --- a/TargetBridge-Shared/Languages/zh.json +++ b/TargetBridge-Shared/Languages/zh.json @@ -35,6 +35,14 @@ "sender.option.manual_entry": "手动输入", "sender.option.not_detected": "未检测到", "sender.group.stream_profile": "码流配置", + "sender.codec.label": "视频压缩", + "sender.codec.automatic": "自动(推荐)", + "sender.codec.h264": "H.264", + "sender.codec.hevc": "HEVC", + "sender.codec.hint.automatic": "为此 Mac 和用作显示器的 Mac 选择最佳兼容编码。", + "sender.codec.hint.h264": "优先使用 H.264;若所选分辨率不可用,则安全切换。", + "sender.codec.hint.hevc": "优先使用 HEVC;若任一台 Mac 无法使用,则安全切换。", + "sender.codec.unavailable": "此分辨率没有可用的兼容硬件视频编码。", "sender.group.display_profiles": "显示配置", "sender.display_profiles.hint": "一键应用预设。TargetBridge 会记住此 receiver 的上次选择。", "sender.display_profiles.work_5k": "5K 工作", diff --git a/docs/Automation.md b/docs/Automation.md index 49c185c3..b92bd199 100644 --- a/docs/Automation.md +++ b/docs/Automation.md @@ -25,9 +25,9 @@ targetbridge disconnect Options: `--receiver auto|`, `--mode mirror|extended`, `--preset `, `--path auto|wired|thunderbolt|usb|ethernet|wifi`, `--transport tb|net`, `--session N`, `--local-ip `, `--retry`. -Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `retina4k60`, +Presets: `standard1080p`, `smooth1080p60`, `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `retina4k60`, `native5k`, `native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, -`5k`, `5k60`). The `retina4k60` aliases `retina4k`, `4096x2304` and `imac4k` match +`5k`, `5k60`, `1080p`, `1080p60`). The `retina4k60` aliases `retina4k`, `4096x2304` and `imac4k` match the 4096 × 2304 panel in the 21.5-inch Retina 4K iMac without changing the existing `4k` alias for the 3840 × 2160 preset. `native5k60Experimental` is an opt-in HEVC test profile for 5K at 60 FPS; it does not diff --git a/docs/Features.md b/docs/Features.md index 9fbb35d2..2df9f0d3 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -11,14 +11,54 @@ TargetBridge supports two main display modes: Extended layouts can be arranged in macOS **System Settings -> Displays -> Arrange**. TargetBridge remembers the saved arrangement per receiver when possible and restores it on reconnect. +When a receiver is chosen from discovery, TargetBridge stores its stable receiver +identity rather than only its current IP. A Thunderbolt Bridge reconnect or a +new DHCP address therefore does not make the sender forget the selected Mac. + If the receiver panel does not look correct, set the matching resolution on the TargetBridge display in macOS Display Settings. For 27-inch 5K iMac workflows, the `Crisp` or `5K` stream profiles usually pair best with the matching HiDPI arrangement. +The `Full HD` profiles are for non-Retina 1080p receivers, such as every 21.5-inch +iMac that is not a Retina 4K model. They render the virtual display at 1920 × 1080 +1x rather than as a HiDPI mode, so the desktop reaches the panel at its true size +and capture stays 1:1. Because those profiles already render the desktop to match +the stream, the **Match render to stream** toggle does not apply and is hidden when +one is selected. + Related reading: - [docs/QuickStart-EN.md](docs/QuickStart-EN.md) - [docs/QuickStart-IT.md](docs/QuickStart-IT.md) - [docs/QuickStart-ZH.md](docs/QuickStart-ZH.md) +## Stream Profiles and Codecs + +Each session can select **Automatic**, **H.264**, or **HEVC** before it starts. +The choice is saved per discovered receiver. Automatic remains the recommended +option: it checks the receiver capability and the sender hardware encoder at +the selected resolution. If a requested codec is unavailable, TargetBridge +uses a compatible hardware fallback and records that choice in the log. + +For non-Retina 1080p receivers, use **Full HD** or **Full HD 60**. These +profiles create a 1920 × 1080 1x virtual display and avoid the double scaling +that a HiDPI mode would introduce on a 1080p panel. + +In a genuinely headless mirror session, where the virtual display is the only +online display, TargetBridge captures that display directly. A companion +audio-only ScreenCaptureKit stream keeps Audio Relay available on this path. + +## Permission Assistant + +The Sender toolbar includes **Permissions**. It shows the current state of +Screen Recording, Accessibility, and Input Monitoring, can request the native +macOS permission prompts, and opens Privacy & Security when needed. The state +refreshes after returning to TargetBridge from System Settings. + +macOS deliberately requires a user to approve each permission for each +installed application. The assistant streamlines that process but cannot grant +or retain permission on the user's behalf. For Input Dockstation, check the +required permissions on both the Sender and Receiver according to the active +role. + ## Display Profiles Each sender session offers three ready-to-use display profiles before connecting: diff --git a/docs/Sender-Signing.md b/docs/Sender-Signing.md new file mode 100644 index 00000000..c38ceacb --- /dev/null +++ b/docs/Sender-Signing.md @@ -0,0 +1,107 @@ +# Stable application identity and privacy permissions + +macOS recognises updates by the app's code-signing designated requirement, not +just its name or version. An ad-hoc signature binds that requirement to the +binary's hashes. Rebuilding changes those hashes and can invalidate Screen +Recording, Accessibility, and Input Monitoring consent. + +TargetBridge has two applications: Sender (`com.targetbridge.sender`) and +Receiver (`com.targetbridge.receiver`). Both must retain their respective +bundle ID, installation path, and signing identity. This matters whenever the +app has been granted a macOS privacy permission. + +## Build and sign + +Use one persistent certificate and keep `com.targetbridge.sender` as the bundle +identifier. Configure `TARGETBRIDGE_CODESIGN_IDENTITY` with the certificate's +fingerprint, or store that public fingerprint in: + +`~/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt` + +The regular Sender build preserves upstream's disposable ad-hoc CI/development +build when no signing configuration was requested, with a prominent warning. +Set `TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING=1` to require an update-safe build +even on an unconfigured machine. Configuring an identity or a configuration +file also enables strict preflight: a missing/invalid configured identity never +falls back to ad-hoc. Signing errors and verification failures are fatal. +The build stages the candidate +before replacing the previous build, so a missing/locked signing key leaves the +previous artifact intact. + +The packaging build disables Xcode's debug-dylib/preview executor layout, so +the standalone app can be strictly signed without unsigned preview dylibs. +This does not change normal interactive builds in Xcode. + +For private local builds only, a persistent self-signed code-signing identity +can be configured using Apple's Certificate Assistant in Keychain Access. +Any change to its trust settings requires the Mac owner's explicit approval; +limit trust to Code Signing. These build scripts never create/trust certificates, +unlock a Keychain, export keys, or change TCC settings. Do not use an unrestricted +trust-root setup. Verify the chosen fingerprint against the actual certificate. + +Keep the signing Keychain and always sign on that Mac, even when compilation +happens elsewhere. Do not recreate the certificate for each build. A lost key +requires an intentional identity migration and new consent. A local certificate +is not a substitute for Developer ID and notarization for public distribution. + +For public distribution, use Developer ID plus the required notarization +workflow; these scripts alone do not implement notarization. Configure the +same certificate in the `TARGETBRIDGE_CODESIGN_P12_BASE64` and +`TARGETBRIDGE_CODESIGN_P12_PASSWORD` repository secrets before tagging a final +release. The release workflow refuses an exact `vX.Y.Z` release if that +persistent identity is unavailable; prerelease tags remain usable for +disposable CI builds. Do not mix local and upstream signers if preserving +existing consent is required. No Apple Developer membership is provisioned by +these scripts. + +When using the signing helper directly, a disposable development build must opt in to both +`TARGETBRIDGE_CODESIGN_IDENTITY=-` and `TARGETBRIDGE_ALLOW_ADHOC=1`. Never install +it over a Sender that is already persistently signed. + +## Install/update gate + +Before replacing an installed, persistently signed Sender: + +```sh +scripts/verify_sender_update.sh /Applications/TargetBridge.app ../build/TargetBridge.app +``` + +The check rejects ad-hoc signatures, broken bundles, wrong bundle IDs and a +candidate that fails the installed app's designated requirement on any +architecture. Never re-sign, modify Info.plist or otherwise change the bundle +after that check. Preserve the signature with `ditto` and verify it again at the +destination. Keep a recoverable backup outside Applications. + +An initial migration from an ad-hoc app intentionally fails this gate: it is an +identity change, not a seamless update. Back up the installed app, replace it +once with the persistently signed app at `/Applications/TargetBridge.app`, and +approve any requested privacy permissions there. The Receiver follows the same +rule at `/Applications/TargetBridge Receiver.app`. Do not edit TCC databases, +run blanket permission resets, or disable system protections. Once the stable +identity is in place, subsequent signed updates preserve the identity macOS +uses to associate existing consent. + +## Validation + +Test two builds with different executable contents signed with the same +identity: both must pass signature verification and the second must satisfy the +first's designated requirement. Test that an ad-hoc update, a different signer, +wrong bundle ID and a tampered resource are rejected. + +`scripts/test_sender_signing.sh /path/to/signed/TargetBridge.app` covers signing +continuity with a changed sealed bundle, missing/unavailable identities, explicit +ad-hoc opt-in, bundle ID mismatch and tampering. Run it with a configured valid +identity; a refusal at preflight is not a passing integration test. Also test a +separate compiled revision and, when available, a second signer before release. + +Signing continuity is necessary, not proof that TCC granted permissions. The +final acceptance test is a real A-to-B installed update after human consent on +the Sender Mac, checking screen capture and receiver-controlled input without +granting them again. macOS can still ask for consent after a reset, identity +change, certificate loss, or OS privacy-policy change. + +References: +- https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements +- https://developer.apple.com/library/archive/technotes/tn2206/ +- https://developer.apple.com/forums/thread/819406 +- https://support.apple.com/guide/keychain-access/kyca8916/mac diff --git a/docs/releases/3.6.0.md b/docs/releases/3.6.0.md new file mode 100644 index 00000000..f5c75312 --- /dev/null +++ b/docs/releases/3.6.0.md @@ -0,0 +1,45 @@ +# TargetBridge 3.6.0 + +TargetBridge 3.6.0 is a feature release focused on resilient receiver setup, +adaptive streaming, and clearer macOS permission onboarding. + +## Highlights + +- Remember discovered receivers by stable identity, so reconnecting a + Thunderbolt or network interface does not discard the selected receiver. +- Choose Automatic, H.264, or HEVC compression per receiver. Automatic safely + falls back to compatible hardware when the requested codec is unavailable. +- Add Full HD and Full HD 60 stream profiles for non-Retina 1080p receivers. + They use a native 1920 × 1080 1x virtual display to avoid unnecessary scaling. +- Capture the virtual display directly in headless mirror sessions while keeping + Audio Relay available through an auxiliary audio capture. +- Add the Sender permission assistant for Screen Recording, Accessibility, and + Input Monitoring. It requests the macOS prompts and refreshes when returning + from Privacy & Security. + +## Upgrade + +Update both Sender and Receiver together. Replace older copies in Applications +rather than keeping multiple app copies with the same bundle identifier. +The first transition from an older ad-hoc build to a persistently signed release +may require macOS approval once. Subsequent official updates retain the stable +application identity macOS uses for existing permission consent; TargetBridge +never modifies the TCC permission database directly. + +The Sender requires macOS 14 or later. The Receiver supports macOS 11 or later. +For Input Dockstation, check the permission matrix on both Macs. + +## Validation + +The release candidate passes 163 Sender unit tests, the stable receiver +identity test package, and 654 Receiver parser, input-queue, watchdog, and +profile checks. These automated checks do not replace a real two-Mac test of +the selected stream profile, audio relay, and input role. + +## Thanks + +Thank you to [@Betafer](https://github.com/Betafer) for the receiver identity, +codec selection, and headless capture contributions, and to +[@polmarinm](https://github.com/polmarinm) for the native-scale Full HD profile. +Thank you as well to everyone testing TargetBridge on real hardware and sharing +clear reproduction details.