diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87ce4e77..c14519ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,14 +2,21 @@ name: CI Build Verification on: push: - branches: [ main, 3.1-maint, 3.2-dev, dev-3.3.0 ] + branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0 ] pull_request: - branches: [ main, 3.1-maint, 3.2-dev, dev-3.3.0 ] + branches: [ main, 3.1-maint, maint-3.5, 3.2-dev, dev-3.3.0, dev-3.4.0 ] jobs: build-sender: - name: Build Sender (macOS 15 - Apple Silicon) - runs-on: macos-15 + name: Build Sender (${{ matrix.os }} - ${{ matrix.arch }}) + strategy: + matrix: + include: + - os: macos-15-intel + arch: x86_64 + - os: macos-15 + arch: arm64 + runs-on: ${{ matrix.os }} steps: - name: Checkout Code uses: actions/checkout@v6 @@ -26,12 +33,19 @@ jobs: run: | cd TargetBridge-Sender ./scripts/build_targetbridge_sender_app.sh + file ../build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Run Sender Unit Tests run: | cd TargetBridge-Sender xcodebuild test -project TargetBridge.xcodeproj -scheme TBDisplaySender -destination 'platform=macOS' -derivedDataPath .build/DerivedData + - name: Check Integrated Video Queue Guards + run: ruby TargetBridge-Sender/scripts/test_video_queue_budget.rb + + - name: Check Sender Permission Probe + run: ruby TargetBridge-Sender/scripts/test_permission_status.rb sender build/TargetBridge.app/Contents/MacOS/TargetBridge + build-receiver: name: Build Receiver (${{ matrix.os }} - ${{ matrix.arch }}) strategy: @@ -63,3 +77,6 @@ jobs: run: | cd TargetBridge-Receiver ./scripts/build_tbreceiver_c_app.sh + + - name: Check Receiver Permission Probe + run: ruby TargetBridge-Sender/scripts/test_permission_status.rb receiver 'build/TargetBridge Receiver.app/Contents/MacOS/TargetBridgeReceiver' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49fe24b6..d693a54a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,8 +12,15 @@ permissions: jobs: build-sender: - name: Build Sender (Apple Silicon) - runs-on: macos-15 + name: Build Sender (${{ matrix.os }} - ${{ matrix.arch }}) + strategy: + matrix: + include: + - os: macos-15-intel + arch: x86_64 + - os: macos-15 + arch: arm64 + runs-on: ${{ matrix.os }} steps: - name: Checkout Code uses: actions/checkout@v6 @@ -26,22 +33,23 @@ jobs: run: | cd TargetBridge-Sender ./scripts/build_targetbridge_sender_app.sh + file ../build/TargetBridge.app/Contents/MacOS/TargetBridge | grep -q "${{ matrix.arch }}" - name: Zip App Bundle run: | cd build - zip -r TargetBridge-arm64.app.zip TargetBridge.app + zip -r TargetBridge-${{ matrix.arch }}.app.zip TargetBridge.app - name: Generate build provenance attestation uses: actions/attest@v4 with: - subject-path: build/TargetBridge-arm64.app.zip + subject-path: build/TargetBridge-${{ matrix.arch }}.app.zip - name: Upload Sender Artifact uses: actions/upload-artifact@v7 with: - name: TargetBridge-arm64.app.zip - path: build/TargetBridge-arm64.app.zip + name: TargetBridge-${{ matrix.arch }}.app.zip + path: build/TargetBridge-${{ matrix.arch }}.app.zip retention-days: 1 build-receiver: @@ -93,6 +101,12 @@ jobs: uses: actions/checkout@v6 - name: Download Sender Artifact + uses: actions/download-artifact@v8 + with: + name: TargetBridge-x86_64.app.zip + path: ./artifacts + + - name: Download Sender Artifact (arm64) uses: actions/download-artifact@v8 with: name: TargetBridge-arm64.app.zip @@ -115,6 +129,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ github.ref_name }}" \ + ./artifacts/TargetBridge-x86_64.app.zip \ ./artifacts/TargetBridge-arm64.app.zip \ ./artifacts/TargetBridge-Receiver-x86_64.app.zip \ ./artifacts/TargetBridge-Receiver-arm64.app.zip \ diff --git a/.gitignore b/.gitignore index ee2bb9bf..59b762c8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,8 @@ tbreceiver # C test outputs test_net_parser +test_input_queue +test_idle_watchdog +test_receiver_profile +test_monitor_shield *.dSYM/ diff --git a/README.md b/README.md index 2ad1c2e4..63e083ad 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,44 @@ contributions help keep the project moving forward. [![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github)](https://github.com/sponsors/swellweb) -## TargetBridge 3.3 +## Contributors -TargetBridge 3.3 improves the reliability of the display connection while -keeping the established multi-Mac workspace features: +Thank you to everyone who tests TargetBridge on real hardware, reports issues, +and improves the project. A special thank-you for this release goes to: + +- [@aalpgiray](https://github.com/aalpgiray) for receiver brightness, volume, Night Shift, and True Tone controls. +- [@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.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. + +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: + +- 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 +- advertise the Receiver's actual panel geometry instead of always reporting a fixed 5K profile +- preserve native 5K panel dimensions when macOS uses a scaled HiDPI desktop +- fall back safely to software video decode when VideoToolbox cannot decode a stream on older hardware +- allow display sleep while the Receiver is waiting, including before the first session +- open audio output only when audio arrives and release it when the session ends +- handle watchdog clock underflow without incorrectly dropping the connection +- 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 +[testing guide](docs/Testing.md), including the new monitor-shield lifecycle test. + +It also keeps the established multi-Mac workspace features: - mirror mode and extended desktop mode - multiple receivers from one sender @@ -54,8 +88,9 @@ keeping the established multi-Mac workspace features: - 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) - Input Dockstation, clipboard sync, master/slave roles, and Receiver Master shortcuts: [docs/Features.md#input-dockstation](docs/Features.md#input-dockstation) -- Remote brightness control: [docs/Features.md#remote-brightness-control](docs/Features.md#remote-brightness-control) +- Receiver device controls (brightness, volume, Night Shift, True Tone): [docs/Features.md#receiver-device-controls](docs/Features.md#receiver-device-controls) - Remote connection & automation (URL scheme, launch args, SSH, login/wake): [docs/Automation.md](docs/Automation.md) +- Measured connection-path selection (Thunderbolt, USB/USB4, Ethernet, Wi-Fi): [docs/Automation.md#1-targetbridge-cli](docs/Automation.md#1-targetbridge-cli) - Shared translations (English, Italian, German, French, and Chinese): [docs/Features.md#shared-translations](docs/Features.md#shared-translations) - Thunderbolt networking extras (SSH/SFTP, file sharing, Internet Sharing): [docs/Features.md#thunderbolt-networking-extras](docs/Features.md#thunderbolt-networking-extras) @@ -66,6 +101,7 @@ keeping the established multi-Mac workspace features: - Stream profiles range from `2560 x 1440` to `5120 x 2880` with H.264/HEVC selection based on capability. `5K 60` is an experimental profile for recent Apple Silicon; `5K 48` remains recommended for reliable daily work. See [Display Modes](docs/Features.md#display-modes). - Receiver discovery is automatic over Bonjour. Extended-display arrangement is remembered per receiver when possible. See [Display Modes](docs/Features.md#display-modes). - Thunderbolt Bridge remains the primary low-latency path, with `Network Link` available as an experimental addon-gated transport. See [Network Link](docs/Features.md#network-link-experimental). +- The Sender menu can control a connected receiver's brightness, volume, Night Shift, and True Tone when supported. See [Receiver Device Controls](docs/Features.md#receiver-device-controls). ## Official Addons @@ -77,7 +113,7 @@ TargetBridge now has a conservative manifest-based addon system. Official manife ## Requirements -- Sender: Apple Silicon Mac (M1 or later), macOS 14 Sonoma or later +- Sender: Apple Silicon Mac (M1 or later) or Intel Mac, macOS 14 Sonoma or later. Apple Silicon remains the primary tested path; Intel Sender builds are available for broader testing. - Receiver: Intel or Apple Silicon Mac, macOS 11 Big Sur or later - Thunderbolt cable - See also [docs/Hardware.md](docs/Hardware.md) for hardware details, tested cables, adapters, and Thunderbolt networking ideas. @@ -87,6 +123,7 @@ TargetBridge now has a conservative manifest-based addon system. Official manife **[→ Download latest release (pre-built apps, no Xcode needed)](https://github.com/swellweb/targetBridge/releases/latest)** - `TargetBridge-arm64.app.zip` — Sender (for Apple Silicon Macs) +- `TargetBridge-x86_64.app.zip` — Sender (for Intel Macs) - `TargetBridge-Receiver-arm64.app.zip` — Apple Silicon Receiver (use machine as monitor for sender) - `TargetBridge-Receiver-x86_64.app.zip` — Intel Receiver (use machine as monitor for sender) diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 87051d0b..8660b5a5 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -16,26 +16,26 @@ CC ?= clang PKG ?= pkgconf -APP_VERSION ?= 3.2.0 +APP_VERSION ?= 3.5.2 APP_BUILD ?= dev MACOSX_DEPLOYMENT_TARGET ?= 11.0 export MACOSX_DEPLOYMENT_TARGET CFLAGS += -O2 -g -Wall -Wextra -Wno-unused-parameter -std=c11 -CFLAGS += $(shell $(PKG) --cflags libavcodec libavutil sdl2) +CFLAGS += $(shell $(PKG) --cflags libavcodec libavutil libswscale sdl2) CFLAGS += -DTB_RECEIVER_VERSION=\"$(APP_VERSION)\" -DTB_RECEIVER_BUILD=\"$(APP_BUILD)\" CFLAGS += -DTB_LANGUAGE_SOURCE_DIR=\"$(abspath ../../TargetBridge-Shared/Languages)\" -LDFLAGS += $(shell $(PKG) --libs libavcodec libavutil sdl2) +LDFLAGS += $(shell $(PKG) --libs libavcodec libavutil libswscale sdl2) UNAME_S := $(shell uname -s) ifeq ($(UNAME_S),Darwin) -LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework IOSurface -framework CoreServices -framework CoreAudio +LDFLAGS += -framework ApplicationServices -framework AppKit -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework IOSurface -framework CoreServices -framework CoreAudio -framework SystemConfiguration endif -C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/tb_i18n.c -OBJC_SRC = src/tb_gesture_bridge.m +C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/input_queue.c src/idle_watchdog.c src/receiver_profile.c src/tb_i18n.c +OBJC_SRC = src/tb_gesture_bridge.m src/tb_display_tweaks.m OBJ = $(C_SRC:.c=.o) $(OBJC_SRC:.m=.o) BIN = tbreceiver @@ -51,17 +51,43 @@ $(BIN): $(OBJ) $(CC) $(CFLAGS) -fobjc-arc -c -o $@ $< clean: - rm -f $(OBJ) $(BIN) $(TEST_BIN) + rm -f $(OBJ) $(BIN) $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_IDLE_WATCHDOG_BIN) $(TEST_RECEIVER_PROFILE_BIN) test_monitor_shield # Unit tests for the packet parser. net.c is pure POSIX, so this needs no # ffmpeg/SDL/pkgconf — it runs anywhere, including CI, with no hardware. TEST_CFLAGS = -O2 -g -Wall -Wextra -Wno-unused-parameter -std=c11 +TEST_LDFLAGS = +ifeq ($(UNAME_S),Darwin) +TEST_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration +endif TEST_BIN = test_net_parser +TEST_INPUT_QUEUE_BIN = test_input_queue +TEST_IDLE_WATCHDOG_BIN = test_idle_watchdog +TEST_RECEIVER_PROFILE_BIN = test_receiver_profile + +test_net_parser: tests/test_net_parser.c src/net.c src/net.h src/proto.h + $(CC) $(TEST_CFLAGS) -Isrc tests/test_net_parser.c src/net.c $(TEST_LDFLAGS) -o $@ + +$(TEST_INPUT_QUEUE_BIN): tests/test_input_queue.c src/input_queue.c src/input_queue.h + $(CC) $(TEST_CFLAGS) -Isrc tests/test_input_queue.c src/input_queue.c -o $@ -$(TEST_BIN): tests/test_net_parser.c src/net.c src/net.h src/proto.h - $(CC) $(TEST_CFLAGS) -Isrc tests/test_net_parser.c src/net.c -o $@ +$(TEST_IDLE_WATCHDOG_BIN): tests/test_idle_watchdog.c src/idle_watchdog.c src/idle_watchdog.h + $(CC) $(TEST_CFLAGS) -Isrc tests/test_idle_watchdog.c src/idle_watchdog.c -o $@ -test: $(TEST_BIN) +$(TEST_RECEIVER_PROFILE_BIN): tests/test_receiver_profile.c src/receiver_profile.c src/receiver_profile.h + $(CC) $(TEST_CFLAGS) -Isrc tests/test_receiver_profile.c src/receiver_profile.c -o $@ + +test: $(TEST_BIN) $(TEST_INPUT_QUEUE_BIN) $(TEST_IDLE_WATCHDOG_BIN) $(TEST_RECEIVER_PROFILE_BIN) ./$(TEST_BIN) + ./$(TEST_INPUT_QUEUE_BIN) + ./$(TEST_IDLE_WATCHDOG_BIN) + ./$(TEST_RECEIVER_PROFILE_BIN) + +# AppKit lifecycle coverage requires a logged-in macOS GUI session. +test_monitor_shield: tests/test_monitor_shield.m src/tb_gesture_bridge.m src/tb_gesture_bridge.h + $(CC) -fobjc-arc -framework AppKit -framework CoreGraphics tests/test_monitor_shield.m -o $@ + +test-monitor-shield: test_monitor_shield + ./test_monitor_shield -.PHONY: all clean test +.PHONY: all clean test test-monitor-shield diff --git a/TargetBridge-Receiver/TBReceiverC/src/decoder.c b/TargetBridge-Receiver/TBReceiverC/src/decoder.c index f320f347..dd078d0c 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/decoder.c +++ b/TargetBridge-Receiver/TBReceiverC/src/decoder.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -30,7 +31,11 @@ struct tb_decoder { AVBufferRef *hw_dev; AVFrame *hw_frame; AVFrame *sw_frame; + AVFrame *nv12_frame; AVPacket *pkt; + struct SwsContext *sws; + enum AVPixelFormat hw_pix_fmt; + int using_software_decode; uint8_t *extradata; int extradata_size; @@ -58,15 +63,26 @@ static enum AVHWDeviceType pick_hwdev(void) { #endif } -static enum AVPixelFormat hw_pix_fmt; - static enum AVPixelFormat get_hw_format(AVCodecContext *ctx, const enum AVPixelFormat *pix_fmts) { - (void)ctx; + struct tb_decoder *d = (struct tb_decoder *)ctx->opaque; for (const enum AVPixelFormat *p = pix_fmts; *p != -1; p++) { - if (*p == hw_pix_fmt) return *p; + if (d && *p == d->hw_pix_fmt) return *p; + } + + /* VideoToolbox can advertise a codec but still reject an individual + * stream on older GPUs. FFmpeg calls get_format again with software + * formats in that case; accepting one keeps the session usable. */ + if (pix_fmts && *pix_fmts != AV_PIX_FMT_NONE) { + if (d && !d->using_software_decode) { + d->using_software_decode = 1; + fprintf(stderr, "[dec] hardware decode unavailable for this stream; using software (%s)\n", + av_get_pix_fmt_name(*pix_fmts)); + } + return *pix_fmts; } - fprintf(stderr, "[dec] no HW pix fmt available\n"); + + fprintf(stderr, "[dec] no compatible pixel format available\n"); return AV_PIX_FMT_NONE; } @@ -77,8 +93,9 @@ struct tb_decoder *tb_dec_create(tb_frame_cb cb, void *ud) { d->ud = ud; d->hw_frame = av_frame_alloc(); d->sw_frame = av_frame_alloc(); + d->nv12_frame = av_frame_alloc(); d->pkt = av_packet_alloc(); - if (!d->hw_frame || !d->sw_frame || !d->pkt) { + if (!d->hw_frame || !d->sw_frame || !d->nv12_frame || !d->pkt) { tb_dec_destroy(d); return NULL; } return d; @@ -129,7 +146,9 @@ void tb_dec_destroy(struct tb_decoder *d) { if (d->hw_dev) av_buffer_unref(&d->hw_dev); if (d->hw_frame) av_frame_free(&d->hw_frame); if (d->sw_frame) av_frame_free(&d->sw_frame); + if (d->nv12_frame) av_frame_free(&d->nv12_frame); if (d->pkt) av_packet_free(&d->pkt); + if (d->sws) sws_freeContext(d->sws); av_free(d->extradata); /* allocated with av_malloc */ free(d->scratch); free(d); @@ -202,21 +221,22 @@ static int open_decoder(struct tb_decoder *d) { /* find HW pix fmt that the codec advertises */ enum AVHWDeviceType type = pick_hwdev(); - hw_pix_fmt = AV_PIX_FMT_NONE; + d->hw_pix_fmt = AV_PIX_FMT_NONE; + d->using_software_decode = 0; for (int i = 0;; i++) { const AVCodecHWConfig *cfg = avcodec_get_hw_config(d->codec, i); if (!cfg) break; if ((cfg->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) && cfg->device_type == type) { - hw_pix_fmt = cfg->pix_fmt; + d->hw_pix_fmt = cfg->pix_fmt; break; } } - if (hw_pix_fmt == AV_PIX_FMT_NONE) { + if (d->hw_pix_fmt == AV_PIX_FMT_NONE) { fprintf(stderr, "[dec] WARNING: no HW pix fmt for codec, using SW decode (slow!)\n"); } else { fprintf(stderr, "[dec] HW decode pix_fmt=%s device=%s\n", - av_get_pix_fmt_name(hw_pix_fmt), + av_get_pix_fmt_name(d->hw_pix_fmt), av_hwdevice_get_type_name(type)); } @@ -224,7 +244,7 @@ static int open_decoder(struct tb_decoder *d) { d->ctx = avcodec_alloc_context3(d->codec); if (!d->ctx) return -1; - if (hw_pix_fmt != AV_PIX_FMT_NONE) { + if (d->hw_pix_fmt != AV_PIX_FMT_NONE) { if (!d->hw_dev) { if (av_hwdevice_ctx_create(&d->hw_dev, type, NULL, NULL, 0) < 0) { fprintf(stderr, "[dec] av_hwdevice_ctx_create failed\n"); @@ -233,6 +253,7 @@ static int open_decoder(struct tb_decoder *d) { if (d->hw_dev) { d->ctx->hw_device_ctx = av_buffer_ref(d->hw_dev); d->ctx->get_format = get_hw_format; + d->ctx->opaque = d; } } @@ -345,7 +366,7 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { * access to the IOSurface-backed NV12 planes without the * GPU→staging→CPU copy that av_hwframe_transfer_data performs. * Major win on Intel iMac + Radeon (~6× faster in practice). */ - if (d->hw_frame->format == hw_pix_fmt && d->hw_frame->data[3]) { + if (d->hw_frame->format == d->hw_pix_fmt && d->hw_frame->data[3]) { CVPixelBufferRef pb = (CVPixelBufferRef)d->hw_frame->data[3]; if (CVPixelBufferLockBaseAddress(pb, kCVPixelBufferLock_ReadOnly) == 0) { uint8_t *y = (uint8_t *)CVPixelBufferGetBaseAddressOfPlane(pb, 0); @@ -364,7 +385,7 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { /* Generic path: HW transfer (Linux VAAPI / Windows D3D11VA / SW). */ AVFrame *out = d->hw_frame; - if (d->hw_frame->format == hw_pix_fmt) { + if (d->hw_frame->format == d->hw_pix_fmt) { d->sw_frame->format = AV_PIX_FMT_NV12; if (av_hwframe_transfer_data(d->sw_frame, d->hw_frame, 0) < 0) { fprintf(stderr, "[dec] hwframe_transfer failed\n"); @@ -374,13 +395,31 @@ int tb_dec_feed_frame(struct tb_decoder *d, const uint8_t *avcc, size_t len) { out = d->sw_frame; } - if (out->format != AV_PIX_FMT_NV12) { - fprintf(stderr, "[dec] unexpected pix fmt %d\n", out->format); - } else { + if (out->format == AV_PIX_FMT_NV12) { d->cb(out->data[0], out->linesize[0], out->data[1], out->linesize[1], out->width, out->height, d->ud); + } else { + d->sws = sws_getCachedContext(d->sws, + out->width, out->height, (enum AVPixelFormat)out->format, + out->width, out->height, AV_PIX_FMT_NV12, + SWS_BILINEAR, NULL, NULL, NULL); + av_frame_unref(d->nv12_frame); + d->nv12_frame->format = AV_PIX_FMT_NV12; + d->nv12_frame->width = out->width; + d->nv12_frame->height = out->height; + if (!d->sws || av_frame_get_buffer(d->nv12_frame, 32) < 0) { + fprintf(stderr, "[dec] could not allocate NV12 software frame\n"); + } else if (sws_scale(d->sws, (const uint8_t * const *)out->data, out->linesize, + 0, out->height, d->nv12_frame->data, d->nv12_frame->linesize) <= 0) { + fprintf(stderr, "[dec] software frame conversion failed\n"); + } else { + d->cb(d->nv12_frame->data[0], d->nv12_frame->linesize[0], + d->nv12_frame->data[1], d->nv12_frame->linesize[1], + d->nv12_frame->width, d->nv12_frame->height, d->ud); + } } + av_frame_unref(d->nv12_frame); av_frame_unref(d->sw_frame); av_frame_unref(d->hw_frame); } diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index 6a0790bf..e432ee95 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.2.0" +#define TB_RECEIVER_VERSION "3.5.2" #endif #ifndef TB_RECEIVER_BUILD @@ -40,9 +40,7 @@ struct tb_display { int is_connecting; int input_capture_active; int input_intercept_active; - struct tb_input_event input_events[128]; - int input_head; - int input_tail; + struct tb_input_queue input_queue; uint32_t last_target_switch_tick; uint32_t last_space_switch_tick; uint32_t last_space_gesture_tick; @@ -51,6 +49,7 @@ struct tb_display { int cursor_source_w, cursor_source_h; int cursor_visible; int cursor_type; + int cursor_large; uint32_t last_video_frame_time; int system_cursor_hidden; @@ -182,12 +181,7 @@ static uint16_t tb_disp_mac_keycode_for_sdl_scancode(SDL_Scancode scancode) { static void tb_disp_queue_input_event(struct tb_display *d, const struct tb_input_event *event) { if (!d || !event) return; - int next = (d->input_head + 1) % 128; - if (next == d->input_tail) { - d->input_tail = (d->input_tail + 1) % 128; - } - d->input_events[d->input_head] = *event; - d->input_head = next; + (void)tb_input_queue_push(&d->input_queue, event); } static void tb_disp_destroy_status_texture(struct tb_display *d) { @@ -496,13 +490,17 @@ static void tb_disp_rebuild_status_texture(struct tb_display *d, static void tb_disp_refresh_window_mode(struct tb_display *d) { if (!d || !d->win) return; - if ((d->is_connected || d->is_connecting) && d->preferred_fullscreen) { + const int monitor_shield_active = + (d->is_connected || d->is_connecting) && d->preferred_fullscreen; + + if (monitor_shield_active) { SDL_SetWindowFullscreen(d->win, SDL_WINDOW_FULLSCREEN_DESKTOP); } else { SDL_SetWindowFullscreen(d->win, 0); SDL_SetWindowSize(d->win, 980, 620); SDL_SetWindowPosition(d->win, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED); } + tb_receiver_set_monitor_shield(monitor_shield_active); const int should_hide_cursor = ((d->is_connected || d->is_connecting) && d->preferred_fullscreen) || @@ -746,7 +744,9 @@ static void tb_disp_draw_cursor(struct tb_display *d) { const double sy = (double)out_h / (double)d->cursor_source_h; const int x = (int)((double)d->cursor_x * sx); const int y = (int)((double)d->cursor_y * sy); - const int size = out_w >= 5000 ? 58 : 44; + const int size = d->cursor_large + ? (out_w >= 5000 ? 58 : 44) + : (out_w >= 5000 ? 32 : 24); SDL_BlendMode old_blend = SDL_BLENDMODE_NONE; (void)SDL_GetRenderDrawBlendMode(d->ren, &old_blend); @@ -1143,7 +1143,8 @@ void tb_disp_set_cursor(struct tb_display *d, int x, int y, int source_w, int source_h, int visible, - int type) { + int type, + int large) { if (!d) return; d->cursor_x = x; d->cursor_y = y; @@ -1151,6 +1152,7 @@ void tb_disp_set_cursor(struct tb_display *d, d->cursor_source_h = source_h > 0 ? source_h : 1; d->cursor_visible = visible; d->cursor_type = type; + d->cursor_large = large; uint32_t now = SDL_GetTicks(); if (now - d->last_video_frame_time > 40) { @@ -1240,12 +1242,14 @@ unsigned int tb_disp_poll_actions(struct tb_display *d) { if (ev.button.button == SDL_BUTTON_LEFT) input_event.kind = TB_INPUT_EVENT_LEFT_DOWN; else if (ev.button.button == SDL_BUTTON_RIGHT) input_event.kind = TB_INPUT_EVENT_RIGHT_DOWN; else input_event.kind = TB_INPUT_EVENT_OTHER_DOWN; + input_event.click_count = ev.button.clicks; tb_disp_queue_input_event(d, &input_event); break; case SDL_MOUSEBUTTONUP: if (ev.button.button == SDL_BUTTON_LEFT) input_event.kind = TB_INPUT_EVENT_LEFT_UP; else if (ev.button.button == SDL_BUTTON_RIGHT) input_event.kind = TB_INPUT_EVENT_RIGHT_UP; else input_event.kind = TB_INPUT_EVENT_OTHER_UP; + input_event.click_count = ev.button.clicks; tb_disp_queue_input_event(d, &input_event); break; case SDL_KEYDOWN: @@ -1307,10 +1311,7 @@ unsigned int tb_disp_poll_actions(struct tb_display *d) { int tb_disp_pop_input_event(struct tb_display *d, struct tb_input_event *out) { if (!d || !out) return 0; - if (d->input_tail == d->input_head) return 0; - *out = d->input_events[d->input_tail]; - d->input_tail = (d->input_tail + 1) % 128; - return 1; + return tb_input_queue_pop(&d->input_queue, out); } int tb_disp_get_info(struct tb_display *d, struct tb_display_info *info) { @@ -1323,8 +1324,10 @@ int tb_disp_get_info(struct tb_display *d, struct tb_display_info *info) { SDL_DisplayMode mode; if (SDL_GetCurrentDisplayMode(display_index, &mode) == 0) { - info->active_w = (uint32_t)mode.w; - info->active_h = (uint32_t)mode.h; + info->logical_w = (uint32_t)mode.w; + info->logical_h = (uint32_t)mode.h; + info->active_w = info->logical_w; + info->active_h = info->logical_h; } int window_w = 0, window_h = 0; @@ -1335,12 +1338,17 @@ int tb_disp_get_info(struct tb_display *d, struct tb_display_info *info) { info->drawable_h = (uint32_t)drawable_h; } - /* On Retina/5K macOS displays SDL_GetCurrentDisplayMode may report the - * logical desktop size (for example 2560x1440) while the renderer - * drawable exposes the true backing pixel size (for example 5120x2880). - * For the TB stream UI we want the actual panel pixel size. */ - if (info->drawable_w > info->active_w) info->active_w = info->drawable_w; - if (info->drawable_h > info->active_h) info->active_h = info->drawable_h; + /* SDL reports a logical desktop mode on Retina displays. Query the + * receiver window's NSScreen for native pixels instead of using its + * transient drawable, which is only window-sized before fullscreen. */ + uint32_t native_w = 0, native_h = 0; + if (tb_receiver_content_display_pixels(&native_w, &native_h) == 0) { + info->active_w = native_w; + info->active_h = native_h; + } else { + if (info->drawable_w > info->active_w) info->active_w = info->drawable_w; + if (info->drawable_h > info->active_h) info->active_h = info->drawable_h; + } info->window_w = (uint32_t)window_w; info->window_h = (uint32_t)window_h; diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.h b/TargetBridge-Receiver/TBReceiverC/src/display.h index 0052ab79..901a1e01 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.h +++ b/TargetBridge-Receiver/TBReceiverC/src/display.h @@ -6,9 +6,13 @@ #include #include +#include "input_queue.h" + struct tb_display; struct tb_display_info { + uint32_t logical_w; + uint32_t logical_h; uint32_t active_w; uint32_t active_h; uint32_t window_w; @@ -24,37 +28,6 @@ enum tb_display_action { TB_DISP_ACTION_CYCLE_LANGUAGE = 1 << 1 }; -enum tb_input_event_kind { - TB_INPUT_EVENT_NONE = 0, - TB_INPUT_EVENT_MOVE, - TB_INPUT_EVENT_LEFT_DRAG, - TB_INPUT_EVENT_RIGHT_DRAG, - TB_INPUT_EVENT_OTHER_DRAG, - TB_INPUT_EVENT_SCROLL, - TB_INPUT_EVENT_LEFT_DOWN, - TB_INPUT_EVENT_LEFT_UP, - TB_INPUT_EVENT_RIGHT_DOWN, - TB_INPUT_EVENT_RIGHT_UP, - TB_INPUT_EVENT_OTHER_DOWN, - TB_INPUT_EVENT_OTHER_UP, - TB_INPUT_EVENT_KEY_DOWN, - TB_INPUT_EVENT_KEY_UP, - TB_INPUT_EVENT_SWITCH_PREV_TARGET, - TB_INPUT_EVENT_SWITCH_NEXT_TARGET, - TB_INPUT_EVENT_SWITCH_PREV_SPACE, - TB_INPUT_EVENT_SWITCH_NEXT_SPACE, - TB_INPUT_EVENT_DEACTIVATE_CONTROL -}; - -struct tb_input_event { - enum tb_input_event_kind kind; - int dx; - int dy; - int scroll_x; - int scroll_y; - uint16_t key_code; -}; - struct tb_display *tb_disp_create(int fullscreen); void tb_disp_destroy(struct tb_display *d); void tb_disp_set_connection_state(struct tb_display *d, int connected); @@ -80,7 +53,8 @@ void tb_disp_set_cursor(struct tb_display *d, int x, int y, int source_w, int source_h, int visible, - int type); + int type, + int large); void tb_disp_set_brightness(struct tb_display *d, double level); diff --git a/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.c b/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.c new file mode 100644 index 00000000..04c24d6e --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.c @@ -0,0 +1,19 @@ +#include "idle_watchdog.h" + +enum tb_idle_watchdog_result tb_idle_watchdog_check(uint64_t now_ms, + uint64_t *last_activity_ms, + uint64_t timeout_ms, + uint64_t *elapsed_ms) { + if (!last_activity_ms || !elapsed_ms) return TB_IDLE_WATCHDOG_OK; + + if (now_ms < *last_activity_ms) { + *last_activity_ms = now_ms; + *elapsed_ms = 0; + return TB_IDLE_WATCHDOG_CLOCK_REWOUND; + } + + *elapsed_ms = now_ms - *last_activity_ms; + return *elapsed_ms >= timeout_ms + ? TB_IDLE_WATCHDOG_EXPIRED + : TB_IDLE_WATCHDOG_OK; +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.h b/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.h new file mode 100644 index 00000000..3597316b --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/idle_watchdog.h @@ -0,0 +1,20 @@ +#ifndef TB_IDLE_WATCHDOG_H +#define TB_IDLE_WATCHDOG_H + +#include + +enum tb_idle_watchdog_result { + TB_IDLE_WATCHDOG_OK = 0, + TB_IDLE_WATCHDOG_EXPIRED, + TB_IDLE_WATCHDOG_CLOCK_REWOUND +}; + +/* Compare timestamps without allowing an unsigned subtraction to turn a + * small backward jump into an immediate timeout. A backward jump rebases the + * last-activity timestamp so a genuinely stale connection can still expire. */ +enum tb_idle_watchdog_result tb_idle_watchdog_check(uint64_t now_ms, + uint64_t *last_activity_ms, + uint64_t timeout_ms, + uint64_t *elapsed_ms); + +#endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/input_queue.c b/TargetBridge-Receiver/TBReceiverC/src/input_queue.c new file mode 100644 index 00000000..cfe27ca0 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/input_queue.c @@ -0,0 +1,68 @@ +#include "input_queue.h" + +#include + +static int tb_input_event_is_lossy(enum tb_input_event_kind kind) { + return kind == TB_INPUT_EVENT_MOVE || + kind == TB_INPUT_EVENT_LEFT_DRAG || + kind == TB_INPUT_EVENT_RIGHT_DRAG || + kind == TB_INPUT_EVENT_OTHER_DRAG || + kind == TB_INPUT_EVENT_SCROLL; +} + +static size_t tb_input_queue_index(const struct tb_input_queue *queue, + size_t logical_index) { + return (queue->head + logical_index) % TB_INPUT_QUEUE_CAPACITY; +} + +static int tb_input_queue_remove_oldest_lossy(struct tb_input_queue *queue) { + size_t lossy_index = queue->count; + for (size_t i = 0; i < queue->count; i++) { + if (tb_input_event_is_lossy(queue->events[tb_input_queue_index(queue, i)].kind)) { + lossy_index = i; + break; + } + } + if (lossy_index == queue->count) return 0; + + for (size_t i = lossy_index; i + 1 < queue->count; i++) { + queue->events[tb_input_queue_index(queue, i)] = + queue->events[tb_input_queue_index(queue, i + 1)]; + } + queue->count--; + return 1; +} + +int tb_input_queue_push(struct tb_input_queue *queue, + const struct tb_input_event *event) { + if (!queue || !event) return 0; + + if (queue->count == TB_INPUT_QUEUE_CAPACITY) { + if (!tb_input_queue_remove_oldest_lossy(queue)) { + if (tb_input_event_is_lossy(event->kind)) { + return 0; + } + + /* A queue made entirely of lifecycle events cannot safely discard + * an arbitrary key/button transition. Collapse it to the existing + * fail-safe command so the Sender releases every held input. */ + memset(queue, 0, sizeof(*queue)); + queue->events[0].kind = TB_INPUT_EVENT_DEACTIVATE_CONTROL; + queue->count = 1; + return -1; + } + } + + queue->events[tb_input_queue_index(queue, queue->count)] = *event; + queue->count++; + return 1; +} + +int tb_input_queue_pop(struct tb_input_queue *queue, + struct tb_input_event *event) { + if (!queue || !event || queue->count == 0) return 0; + *event = queue->events[queue->head]; + queue->head = (queue->head + 1) % TB_INPUT_QUEUE_CAPACITY; + queue->count--; + return 1; +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/input_queue.h b/TargetBridge-Receiver/TBReceiverC/src/input_queue.h new file mode 100644 index 00000000..a771cd44 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/input_queue.h @@ -0,0 +1,54 @@ +#ifndef TB_INPUT_QUEUE_H +#define TB_INPUT_QUEUE_H + +#include +#include + +#define TB_INPUT_QUEUE_CAPACITY 128 + +enum tb_input_event_kind { + TB_INPUT_EVENT_NONE = 0, + TB_INPUT_EVENT_MOVE, + TB_INPUT_EVENT_LEFT_DRAG, + TB_INPUT_EVENT_RIGHT_DRAG, + TB_INPUT_EVENT_OTHER_DRAG, + TB_INPUT_EVENT_SCROLL, + TB_INPUT_EVENT_LEFT_DOWN, + TB_INPUT_EVENT_LEFT_UP, + TB_INPUT_EVENT_RIGHT_DOWN, + TB_INPUT_EVENT_RIGHT_UP, + TB_INPUT_EVENT_OTHER_DOWN, + TB_INPUT_EVENT_OTHER_UP, + TB_INPUT_EVENT_KEY_DOWN, + TB_INPUT_EVENT_KEY_UP, + TB_INPUT_EVENT_SWITCH_PREV_TARGET, + TB_INPUT_EVENT_SWITCH_NEXT_TARGET, + TB_INPUT_EVENT_SWITCH_PREV_SPACE, + TB_INPUT_EVENT_SWITCH_NEXT_SPACE, + TB_INPUT_EVENT_DEACTIVATE_CONTROL +}; + +struct tb_input_event { + enum tb_input_event_kind kind; + int dx; + int dy; + int scroll_x; + int scroll_y; + uint16_t key_code; + int click_count; +}; + +struct tb_input_queue { + struct tb_input_event events[TB_INPUT_QUEUE_CAPACITY]; + size_t head; + size_t count; +}; + +/* Returns 1 when the event was queued, 0 when a lossy motion event was + * discarded, and -1 when overload forced a safe control deactivation. */ +int tb_input_queue_push(struct tb_input_queue *queue, + const struct tb_input_event *event); +int tb_input_queue_pop(struct tb_input_queue *queue, + struct tb_input_event *event); + +#endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index 7bc721c6..377cf184 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -16,8 +16,11 @@ #include "net.h" #include "decoder.h" #include "display.h" +#include "idle_watchdog.h" +#include "receiver_profile.h" #include "proto.h" #include "tb_gesture_bridge.h" +#include "tb_display_tweaks.h" #include "tb_i18n.h" #include @@ -64,6 +67,13 @@ struct app { uint64_t last_fps_tick_ms; uint64_t last_fps_count; uint64_t last_ip_check_ms; + /* Last display-tweak state reported to the sender, so changes made on this + * Mac (Control Center, System Settings) propagate back and the sender's + * toggles stay truthful. -1 = nothing sent yet. */ + int reported_night_shift; + int reported_true_tone; + uint64_t last_tweak_poll_ms; + uint64_t last_recv_ms; /* idle watchdog: last time the sender sent anything */ int close_requested; int have_video_frame; @@ -74,7 +84,10 @@ struct app { char ip_text[64]; char tb_ip_text[64]; + char usb_ip_text[64]; char net_ip_text[64]; + char ethernet_ip_text[64]; + char wifi_ip_text[64]; char display_host[128]; /* short hostname (or hostname+IP), cached at startup */ char status_text[128]; char sender_text[128]; @@ -96,6 +109,7 @@ struct app { int input_tap_consumes_events; SDL_AudioDeviceID audio_device; + uint64_t audio_retry_after_ms; uint8_t audio_buf[AUDIO_BUF_CAP]; int audio_buf_head; @@ -191,6 +205,7 @@ static void tb_receiver_set_clipboard_text(const char *text); static int tb_receiver_get_clipboard_text(char *dest, size_t size); static void tb_receiver_send_clipboard_if_changed(struct app *a); static void write_be32(uint8_t *dst, uint32_t value); +static void tb_receiver_send_display_tweaks_if_changed(struct app *a); static int tb_receiver_is_valid_language_pref(const char *language_pref) { return language_pref && @@ -588,9 +603,18 @@ static void bonjour_update(struct app *a, uint16_t port) { if (a->tb_ip_text[0] != '\0') { TXTRecordSetValue(&txt, "tbIP", (uint8_t)strlen(a->tb_ip_text), a->tb_ip_text); } + if (a->usb_ip_text[0] != '\0') { + TXTRecordSetValue(&txt, "usbIP", (uint8_t)strlen(a->usb_ip_text), a->usb_ip_text); + } if (a->net_ip_text[0] != '\0') { TXTRecordSetValue(&txt, "netIP", (uint8_t)strlen(a->net_ip_text), a->net_ip_text); } + if (a->ethernet_ip_text[0] != '\0') { + TXTRecordSetValue(&txt, "ethernetIP", (uint8_t)strlen(a->ethernet_ip_text), a->ethernet_ip_text); + } + if (a->wifi_ip_text[0] != '\0') { + TXTRecordSetValue(&txt, "wifiIP", (uint8_t)strlen(a->wifi_ip_text), a->wifi_ip_text); + } TXTRecordSetValue(&txt, "panel", (uint8_t)strlen(a->panel_text), a->panel_text); TXTRecordSetValue(&txt, "version", (uint8_t)strlen(TB_RECEIVER_VERSION), TB_RECEIVER_VERSION); TXTRecordSetValue(&txt, "supportsHEVCDecode", 1, tb_dec_supports_hevc_hwdecode() ? "1" : "0"); @@ -957,6 +981,62 @@ static void audio_callback(void *userdata, Uint8 *stream, int len) { } } +/* Keep the output device closed while Receiver is waiting for a Sender. On + * macOS an open SDL output device is enough for coreaudiod to assert + * PreventUserIdleSystemSleep, even when the callback only produces silence. + * Opening lazily also means sessions with audio disabled never touch the + * system output device. */ +static int tb_audio_open_for_frame(struct app *a) { + if (!a) return 0; + if (a->audio_device != 0) return 1; + + uint64_t now = now_ms(); + if (now < a->audio_retry_after_ms) return 0; + + SDL_AudioSpec spec; + SDL_zero(spec); + spec.freq = 48000; + spec.format = AUDIO_S16LSB; + spec.channels = 2; + spec.samples = 1024; + spec.callback = audio_callback; + spec.userdata = a; + + SDL_AudioSpec obtained; + SDL_zero(obtained); + a->audio_device = SDL_OpenAudioDevice(NULL, 0, &spec, &obtained, 0); + if (a->audio_device == 0) { + /* Audio packets arrive frequently. Avoid retrying (and logging) for + * every packet if the output device is temporarily unavailable. */ + a->audio_retry_after_ms = now + 5000; + fprintf(stderr, "[main] warning: SDL_OpenAudioDevice failed: %s; retrying in 5s\n", + SDL_GetError()); + return 0; + } + + a->audio_retry_after_ms = 0; + fprintf(stderr, + "[main] SDL audio device opened on first audio frame: " + "48000Hz stereo 16-bit PCM (obtained %d samples)\n", + obtained.samples); + return 1; +} + +static void tb_audio_close_for_idle(struct app *a) { + if (!a) return; + + if (a->audio_device != 0) { + SDL_CloseAudioDevice(a->audio_device); + a->audio_device = 0; + fprintf(stderr, "[main] SDL audio device closed for idle Receiver\n"); + } + + a->audio_buf_head = 0; + a->audio_buf_tail = 0; + a->audio_buf_size = 0; + a->audio_retry_after_ms = 0; +} + /* Drive the receiver's master output volume knob (with the system volume HUD). * level is clamped to 0.0..1.0. Sets the default output device's scalar volume, * preferring the master element and falling back to per-channel when a device @@ -1089,13 +1169,15 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud int h = 0; int visible = 0; int type = 0; + int large = 0; (void)extract_json_int_field(payload, len, "\"x\"", &x); (void)extract_json_int_field(payload, len, "\"y\"", &y); (void)extract_json_int_field(payload, len, "\"width\"", &w); (void)extract_json_int_field(payload, len, "\"height\"", &h); (void)extract_json_bool_field(payload, len, "\"visible\"", &visible); (void)extract_json_int_field(payload, len, "\"type\"", &type); - tb_disp_set_cursor(a->disp, x, y, w, h, visible, type); + (void)extract_json_bool_field(payload, len, "\"large\"", &large); + tb_disp_set_cursor(a->disp, x, y, w, h, visible, type, large); } break; case TB_PKT_BRIGHTNESS: @@ -1105,6 +1187,19 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud tb_disp_set_brightness(a->disp, level); } break; + case TB_PKT_DISPLAY_TWEAKS: + { + /* Absent fields are left alone rather than defaulted, so the sender + * can change one without disturbing the other. */ + int night = 0, tone = 0; + if (extract_json_bool_field(payload, len, "\"nightShift\"", &night)) { + tb_night_shift_set(night); + } + if (extract_json_bool_field(payload, len, "\"trueTone\"", &tone)) { + tb_true_tone_set(tone); + } + } + break; case TB_PKT_CLIPBOARD: { char text[4096]; @@ -1120,7 +1215,10 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud } break; case TB_PKT_AUDIO_FRAME: - if (a->audio_device != 0) { + { + int opened_for_frame = a->audio_device == 0; + if (!tb_audio_open_for_frame(a)) break; + SDL_LockAudioDevice(a->audio_device); // Limit audio backlog to 150ms (150 * 192 = 28800 bytes) to cushion @@ -1146,6 +1244,10 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud } SDL_UnlockAudioDevice(a->audio_device); + + /* SDL opens devices paused. Queue the first packet before + * starting playback so the callback does not begin with silence. */ + if (opened_for_frame) SDL_PauseAudioDevice(a->audio_device, 0); } break; case TB_PKT_INPUT_EVENT: @@ -1265,6 +1367,41 @@ static void tb_receiver_send_input_event(struct app *a, (void)send_all(a->client_fd, pkt, 5 + (size_t)len); } +static void tb_receiver_send_input_button_event(struct app *a, + const char *kind, + int click_count) { + if (!a || a->client_fd < 0) return; + if (strcmp(a->input_control_mode, "receiverMaster") != 0) return; + + if (click_count < 1) click_count = 1; + if (click_count > 3) click_count = 3; + + char json[128]; + int len = snprintf(json, sizeof(json), + "{\"kind\":\"%s\",\"clickCount\":%d}", + kind ? kind : "", + click_count); + if (len <= 0 || (size_t)len >= sizeof(json)) return; + + uint8_t pkt[4 + 1 + sizeof(json)]; + write_be32(pkt, (uint32_t)(1 + len)); + pkt[4] = TB_PKT_INPUT_EVENT; + memcpy(pkt + 5, json, (size_t)len); + a->input_events_sent += 1; + if (tb_should_log_input_event(a->input_events_sent)) { + tb_receiver_input_log("[input][receiver->sender] send #%llu kind=%s dx=%d dy=%d sx=%d sy=%d key=%u mode=%s", + (unsigned long long)a->input_events_sent, + kind ? kind : "?", + 0, + 0, + 0, + 0, + 0, + a->input_control_mode); + } + (void)send_all(a->client_fd, pkt, 5 + (size_t)len); +} + static void tb_receiver_send_target_switch(struct app *a, int direction) { tb_receiver_send_input_event(a, direction < 0 ? "switchPrevTarget" : "switchNextTarget", @@ -1375,27 +1512,45 @@ static CGEventRef tb_receiver_input_tap_callback(CGEventTapProxy proxy, break; } case kCGEventLeftMouseDown: - tb_receiver_send_input_event(a, "leftDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "leftDown", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventLeftMouseUp: - tb_receiver_send_input_event(a, "leftUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "leftUp", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventRightMouseDown: - tb_receiver_send_input_event(a, "rightDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "rightDown", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventRightMouseUp: - tb_receiver_send_input_event(a, "rightUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "rightUp", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventOtherMouseDown: - tb_receiver_send_input_event(a, "otherDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "otherDown", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventOtherMouseUp: - tb_receiver_send_input_event(a, "otherUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event( + a, + "otherUp", + (int)CGEventGetIntegerValueField(event, kCGMouseEventClickState)); should_consume = a->input_tap_consumes_events; break; case kCGEventScrollWheel: { @@ -1615,21 +1770,43 @@ static void tb_receiver_refresh_input_capture(struct app *a) { } } + +/* Report Night Shift / True Tone back to the sender when they change, so its + * menu reflects the panel's real state rather than only what it last asked for. */ +static void tb_receiver_send_display_tweaks_if_changed(struct app *a) { + if (a->client_fd < 0) return; + + const int night = tb_night_shift_supported() ? tb_night_shift_enabled() : 0; + const int tone = tb_true_tone_supported() ? tb_true_tone_enabled() : 0; + if (night == a->reported_night_shift && tone == a->reported_true_tone) return; + + a->reported_night_shift = night; + a->reported_true_tone = tone; + + char json[192]; + int len = snprintf(json, sizeof(json), + "{\"nightShift\":%s,\"trueTone\":%s}", + night ? "true" : "false", + tone ? "true" : "false"); + if (len <= 0 || (size_t)len >= sizeof(json)) return; + + uint8_t pkt[4 + 1 + sizeof(json)]; + write_be32(pkt, (uint32_t)(1 + len)); + pkt[4] = TB_PKT_DISPLAY_TWEAKS; + memcpy(pkt + 5, json, (size_t)len); + (void)send_all(a->client_fd, pkt, 5 + (size_t)len); +} + static void send_receiver_info(struct app *a) { struct tb_display_info info; if (tb_disp_get_info(a->disp, &info) < 0) return; - /* Always advertise the intended iMac target panel, not the transient - * SDL window/debug drawable size. Using the drawable here breaks the - * sender's virtual display creation path when running windowed or on - * scaled desktops because macOS rejects a HiDPI mode larger than the - * advertised backing panel. */ - const uint32_t panel_w = 5120; - const uint32_t panel_h = 2880; - const uint32_t mode_w = 2560; - const uint32_t mode_h = 1440; - const uint32_t capture_w = 2560; - const uint32_t capture_h = 1440; + struct tb_receiver_profile profile; + if (tb_receiver_profile_from_dimensions(info.logical_w, info.logical_h, + info.active_w, info.active_h, + &profile) < 0) { + return; + } char escaped_name[256]; size_t out = 0; @@ -1644,25 +1821,28 @@ static void send_receiver_info(struct app *a) { } escaped_name[out] = '\0'; - char json[768]; + char json[1024]; int json_len = snprintf( json, sizeof(json), "{\"receiverName\":\"%s\",\"panelWidth\":%u,\"panelHeight\":%u," "\"modeWidth\":%u,\"modeHeight\":%u,\"refreshRate\":60," - "\"hiDPI\":true,\"captureWidth\":%u,\"captureHeight\":%u," - "\"supportsHEVCDecode\":%s,\"supportsRawNV12\":true,\"inputMonitoringTrusted\":%s,\"accessibilityTrusted\":%s}", + "\"hiDPI\":%s,\"captureWidth\":%u,\"captureHeight\":%u," + "\"supportsHEVCDecode\":%s,\"supportsRawNV12\":true,\"inputMonitoringTrusted\":%s,\"accessibilityTrusted\":%s," + "\"supportsNightShift\":%s,\"supportsTrueTone\":%s}", escaped_name, - panel_w, - panel_h, - mode_w, - mode_h, - capture_w, - capture_h, + profile.panel_w, + profile.panel_h, + profile.mode_w, + profile.mode_h, + profile.hi_dpi ? "true" : "false", + profile.capture_w, + profile.capture_h, tb_dec_supports_hevc_hwdecode() ? "true" : "false", tb_receiver_input_monitoring_trusted() ? "true" : "false", - tb_receiver_accessibility_trusted() ? "true" : "false" - ); + tb_receiver_accessibility_trusted() ? "true" : "false", + tb_night_shift_supported() ? "true" : "false", + tb_true_tone_supported() ? "true" : "false"); if (json_len <= 0 || (size_t)json_len >= sizeof(json)) return; const size_t packet_len = 4 + 1 + (size_t)json_len; @@ -1675,8 +1855,9 @@ static void send_receiver_info(struct app *a) { if (send_all(a->client_fd, pkt, packet_len) == 0) { fprintf(stderr, - "[main] sent display profile: panel=%ux%u mode=%ux%u hidpi name=%s\n", - panel_w, panel_h, mode_w, mode_h, info.name); + "[main] sent display profile: panel=%ux%u mode=%ux%u hidpi=%d name=%s\n", + profile.panel_w, profile.panel_h, profile.mode_w, profile.mode_h, + profile.hi_dpi, info.name); } free(pkt); } @@ -1691,19 +1872,13 @@ static void close_client(struct app *a) { SDL_EnableScreenSaver(); tb_receiver_refresh_input_capture(a); tb_disp_set_connection_state(a->disp, 0); - tb_disp_set_cursor(a->disp, 0, 0, 1, 1, 0, 0); + tb_disp_set_cursor(a->disp, 0, 0, 1, 1, 0, 0, 0); tb_refresh_idle_localized_strings(a); a->last_clipboard_text[0] = '\0'; tb_parser_free(&a->parser); tb_parser_init(&a->parser, on_packet, a); tb_dec_reset(a->dec); /* fresh decoder for next session */ - if (a->audio_device != 0) { - SDL_LockAudioDevice(a->audio_device); - a->audio_buf_head = 0; - a->audio_buf_tail = 0; - a->audio_buf_size = 0; - SDL_UnlockAudioDevice(a->audio_device); - } + tb_audio_close_for_idle(a); fprintf(stderr, "[main] client disconnected\n"); } @@ -1739,6 +1914,14 @@ static void build_display_host(char *buf, size_t bufsz, const char *ip_fallback, int main(int argc, char **argv) { int fullscreen = 1; for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--permission-status") == 0) { + printf( + "TB_PERMISSION_STATUS:screen_recording=0:accessibility=%d:input_monitoring=%d\n", + tb_receiver_accessibility_trusted(), + tb_receiver_input_monitoring_trusted() + ); + return 0; + } if (strcmp(argv[i], "--windowed") == 0) fullscreen = 0; } @@ -1754,17 +1937,31 @@ int main(int argc, char **argv) { signal(SIGPIPE, SIG_IGN); char tb_ip[64] = {0}; + char usb_ip[64] = {0}; char net_ip[64] = {0}; + char ethernet_ip[64] = {0}; + char wifi_ip[64] = {0}; if (tb_net_get_tb_ip(tb_ip, sizeof(tb_ip)) == 0) { printf("TBReceiver: Thunderbolt Bridge IP = %s\n", tb_ip); } else { printf("TBReceiver: warning, no bridge IP detected (169.254.x.x)\n"); } + if (tb_net_get_link_local_ip(usb_ip, sizeof(usb_ip)) == 0) { + printf("TBReceiver: Direct USB-NCM IP = %s\n", usb_ip); + } else { + printf("TBReceiver: warning, no direct USB-NCM/link-local IP detected\n"); + } if (tb_net_get_lan_ip(net_ip, sizeof(net_ip)) == 0) { printf("TBReceiver: Local network IP = %s\n", net_ip); } else { printf("TBReceiver: warning, no LAN IP detected (RFC1918 IPv4)\n"); } + if (tb_net_get_ethernet_ip(ethernet_ip, sizeof(ethernet_ip)) == 0) { + printf("TBReceiver: Ethernet IP = %s\n", ethernet_ip); + } + if (tb_net_get_wifi_ip(wifi_ip, sizeof(wifi_ip)) == 0) { + printf("TBReceiver: Wi-Fi IP = %s\n", wifi_ip); + } printf("TBReceiver: listening on TCP port %d\n", TB_PORT); struct app a; @@ -1779,14 +1976,21 @@ int main(int argc, char **argv) { snprintf(a.bonjour_name, sizeof(a.bonjour_name), "TargetBridge %s", host); } snprintf(a.tb_ip_text, sizeof(a.tb_ip_text), "%s", tb_ip); + snprintf(a.usb_ip_text, sizeof(a.usb_ip_text), "%s", usb_ip); snprintf(a.net_ip_text, sizeof(a.net_ip_text), "%s", net_ip); - snprintf(a.ip_text, sizeof(a.ip_text), "%s", tb_ip[0] ? tb_ip : (net_ip[0] ? net_ip : tb_i18n_get("receiver.network.not_detected"))); + snprintf(a.ethernet_ip_text, sizeof(a.ethernet_ip_text), "%s", ethernet_ip); + snprintf(a.wifi_ip_text, sizeof(a.wifi_ip_text), "%s", wifi_ip); + snprintf(a.ip_text, sizeof(a.ip_text), "%s", + tb_ip[0] ? tb_ip + : (usb_ip[0] ? usb_ip + : (net_ip[0] ? net_ip : tb_i18n_get("receiver.network.not_detected")))); snprintf(a.language_pref, sizeof(a.language_pref), "%s", startup_language_pref); snprintf(a.input_control_mode, sizeof(a.input_control_mode), "%s", "off"); a.last_input_monitoring_trusted = -1; a.last_accessibility_trusted = -1; tb_refresh_idle_localized_strings(&a); - build_display_host(a.display_host, sizeof(a.display_host), a.ip_text, tb_ip[0] || net_ip[0]); + build_display_host(a.display_host, sizeof(a.display_host), a.ip_text, + tb_ip[0] || usb_ip[0] || net_ip[0]); tb_receiver_apply_language_preference(&a); tb_gesture_bridge_install(tb_receiver_space_switch_callback, &a); tb_gesture_bridge_set_active(0); @@ -1794,23 +1998,11 @@ int main(int argc, char **argv) { a.disp = tb_disp_create(fullscreen); if (!a.disp) { fprintf(stderr, "tb_disp_create failed\n"); return 1; } - /* Open SDL Audio Device */ - SDL_AudioSpec spec; - SDL_zero(spec); - spec.freq = 48000; - spec.format = AUDIO_S16LSB; // 16-bit signed, little-endian PCM - spec.channels = 2; // Stereo - spec.samples = 1024; // Buffer size (approx 21.3ms) - spec.callback = audio_callback; - spec.userdata = &a; - SDL_AudioSpec obtained; - a.audio_device = SDL_OpenAudioDevice(NULL, 0, &spec, &obtained, 0); - if (a.audio_device != 0) { - SDL_PauseAudioDevice(a.audio_device, 0); // Start playing (unpaused) - fprintf(stderr, "[main] SDL audio device opened: 48000Hz stereo 16-bit PCM (obtained %d samples)\n", obtained.samples); - } else { - fprintf(stderr, "[main] warning: SDL_OpenAudioDevice failed: %s\n", SDL_GetError()); - } + /* SDL starts with screen-saver inhibition enabled. A Receiver that has not + * accepted a client yet never reaches close_client(), so release the + * assertion immediately while it is waiting for its first session. */ + SDL_EnableScreenSaver(); + fprintf(stderr, "[main] display sleep enabled while receiver is idle\n"); struct tb_display_info boot_info; if (tb_disp_get_info(a.disp, &boot_info) == 0) { @@ -1845,28 +2037,51 @@ int main(int argc, char **argv) { if (t - a.last_ip_check_ms >= 1000) { char refreshed_tb_ip[64] = {0}; + char refreshed_usb_ip[64] = {0}; char refreshed_net_ip[64] = {0}; + char refreshed_ethernet_ip[64] = {0}; + char refreshed_wifi_ip[64] = {0}; a.last_ip_check_ms = t; (void)tb_net_get_tb_ip(refreshed_tb_ip, sizeof(refreshed_tb_ip)); + (void)tb_net_get_link_local_ip(refreshed_usb_ip, sizeof(refreshed_usb_ip)); (void)tb_net_get_lan_ip(refreshed_net_ip, sizeof(refreshed_net_ip)); + (void)tb_net_get_ethernet_ip(refreshed_ethernet_ip, sizeof(refreshed_ethernet_ip)); + (void)tb_net_get_wifi_ip(refreshed_wifi_ip, sizeof(refreshed_wifi_ip)); - const int have_refreshed_ip = refreshed_tb_ip[0] || refreshed_net_ip[0]; + const int have_refreshed_ip = + refreshed_tb_ip[0] || refreshed_usb_ip[0] || refreshed_net_ip[0]; const char *preferred_ip = refreshed_tb_ip[0] ? refreshed_tb_ip - : (refreshed_net_ip[0] ? refreshed_net_ip - : tb_i18n_get("receiver.network.not_detected")); + : (refreshed_usb_ip[0] ? refreshed_usb_ip + : (refreshed_net_ip[0] ? refreshed_net_ip + : tb_i18n_get("receiver.network.not_detected"))); if (strcmp(a.tb_ip_text, refreshed_tb_ip) != 0 || + strcmp(a.usb_ip_text, refreshed_usb_ip) != 0 || strcmp(a.net_ip_text, refreshed_net_ip) != 0 || + strcmp(a.ethernet_ip_text, refreshed_ethernet_ip) != 0 || + strcmp(a.wifi_ip_text, refreshed_wifi_ip) != 0 || strcmp(a.ip_text, preferred_ip) != 0) { snprintf(a.tb_ip_text, sizeof(a.tb_ip_text), "%s", refreshed_tb_ip); + snprintf(a.usb_ip_text, sizeof(a.usb_ip_text), "%s", refreshed_usb_ip); snprintf(a.net_ip_text, sizeof(a.net_ip_text), "%s", refreshed_net_ip); + snprintf(a.ethernet_ip_text, sizeof(a.ethernet_ip_text), "%s", refreshed_ethernet_ip); + snprintf(a.wifi_ip_text, sizeof(a.wifi_ip_text), "%s", refreshed_wifi_ip); snprintf(a.ip_text, sizeof(a.ip_text), "%s", preferred_ip); build_display_host(a.display_host, sizeof(a.display_host), preferred_ip, have_refreshed_ip); if (refreshed_tb_ip[0] != '\0') { fprintf(stderr, "[main] Thunderbolt Bridge IP = %s\n", refreshed_tb_ip); } + if (refreshed_usb_ip[0] != '\0') { + fprintf(stderr, "[main] Direct USB-NCM IP = %s\n", refreshed_usb_ip); + } if (refreshed_net_ip[0] != '\0') { fprintf(stderr, "[main] Local network IP = %s\n", refreshed_net_ip); } + if (refreshed_ethernet_ip[0] != '\0') { + fprintf(stderr, "[main] Ethernet IP = %s\n", refreshed_ethernet_ip); + } + if (refreshed_wifi_ip[0] != '\0') { + fprintf(stderr, "[main] Wi-Fi IP = %s\n", refreshed_wifi_ip); + } bonjour_update(&a, TB_PORT); } } @@ -1878,6 +2093,8 @@ int main(int argc, char **argv) { a.client_fd = c; a.have_video_frame = 0; a.session_active = 0; + a.reported_night_shift = -1; /* force one report per session */ + a.reported_true_tone = -1; a.last_recv_ms = t; SDL_DisableScreenSaver(); fprintf(stderr, "[main] client connected\n"); @@ -1892,19 +2109,33 @@ int main(int argc, char **argv) { close_client(&a); } else { socket_activity = drain_result; - if (drain_result > 0) a.last_recv_ms = t; + if (drain_result > 0) a.last_recv_ms = now_ms(); if (a.close_requested) { close_client(&a); - } else if (t - a.last_recv_ms >= TB_SENDER_IDLE_TIMEOUT_MS) { - /* The sender streams frames continuously and heartbeats - * every 2s. Total silence means it died without a FIN - * (crash, pulled cable, force sleep). Without this reap, - * the dead fd is held forever and — because the receiver - * is single-client — every future connect is locked out - * until the app is restarted. */ - fprintf(stderr, "[main] no data from sender for %llu ms; closing stale session\n", - (unsigned long long)(t - a.last_recv_ms)); - close_client(&a); + } else { + const uint64_t watchdog_now_ms = now_ms(); + uint64_t idle_ms = 0; + enum tb_idle_watchdog_result watchdog_result = + tb_idle_watchdog_check(watchdog_now_ms, + &a.last_recv_ms, + TB_SENDER_IDLE_TIMEOUT_MS, + &idle_ms); + if (watchdog_result == TB_IDLE_WATCHDOG_CLOCK_REWOUND) { + fprintf(stderr, + "[main] monotonic clock moved backwards; " + "rebasing sender idle watchdog\n"); + } else if (watchdog_result == TB_IDLE_WATCHDOG_EXPIRED) { + /* The sender streams frames continuously and heartbeats + * every 2s. Total silence means it died without a FIN + * (crash, pulled cable, force sleep). Without this reap, + * the dead fd is held forever and — because the receiver + * is single-client — every future connect is locked out + * until the app is restarted. */ + fprintf(stderr, + "[main] no data from sender for %llu ms; closing stale session\n", + (unsigned long long)idle_ms); + close_client(&a); + } } } } @@ -1914,6 +2145,12 @@ int main(int argc, char **argv) { tb_receiver_poll_permissions(&a); } + /* Cheap enough to poll: two private-framework getters, twice a second. */ + if (t - a.last_tweak_poll_ms >= 500) { + a.last_tweak_poll_ms = t; + tb_receiver_send_display_tweaks_if_changed(&a); + } + if (a.client_fd < 0 || !a.session_active) { /* No client, or a connection that hasn't started a real streaming * session (e.g. a transient UI-language push during discovery): @@ -1947,22 +2184,22 @@ int main(int argc, char **argv) { tb_receiver_send_input_event(&a, "scroll", 0, 0, 0, 0, 1, input_event.scroll_x, 1, input_event.scroll_y, 0, 0); break; case TB_INPUT_EVENT_LEFT_DOWN: - tb_receiver_send_input_event(&a, "leftDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "leftDown", input_event.click_count); break; case TB_INPUT_EVENT_LEFT_UP: - tb_receiver_send_input_event(&a, "leftUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "leftUp", input_event.click_count); break; case TB_INPUT_EVENT_RIGHT_DOWN: - tb_receiver_send_input_event(&a, "rightDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "rightDown", input_event.click_count); break; case TB_INPUT_EVENT_RIGHT_UP: - tb_receiver_send_input_event(&a, "rightUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "rightUp", input_event.click_count); break; case TB_INPUT_EVENT_OTHER_DOWN: - tb_receiver_send_input_event(&a, "otherDown", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "otherDown", input_event.click_count); break; case TB_INPUT_EVENT_OTHER_UP: - tb_receiver_send_input_event(&a, "otherUp", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + tb_receiver_send_input_button_event(&a, "otherUp", input_event.click_count); break; case TB_INPUT_EVENT_KEY_DOWN: tb_receiver_send_input_event(&a, "keyDown", 0, 0, 0, 0, 0, 0, 0, 0, 1, input_event.key_code); @@ -2013,9 +2250,7 @@ int main(int argc, char **argv) { bonjour_deinit(&a); tb_parser_free(&a.parser); tb_dec_destroy(a.dec); - if (a.audio_device != 0) { - SDL_CloseAudioDevice(a.audio_device); - } + tb_audio_close_for_idle(&a); tb_disp_destroy(a.disp); fprintf(stderr, "[main] bye\n"); return 0; diff --git a/TargetBridge-Receiver/TBReceiverC/src/net.c b/TargetBridge-Receiver/TBReceiverC/src/net.c index 700b79ad..387d3b6f 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/net.c +++ b/TargetBridge-Receiver/TBReceiverC/src/net.c @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,11 @@ #include #include +#if defined(__APPLE__) +#include +#include +#endif + /* ---- Parser ----------------------------------------------------------- */ void tb_parser_init(struct tb_parser *p, tb_pkt_cb cb, void *ud) { @@ -160,6 +166,76 @@ static int is_rfc1918_ipv4(const char *host) { return a == 172 && b >= 16 && b <= 31; } +enum tb_lan_interface_kind { + TB_LAN_INTERFACE_ETHERNET = 1, + TB_LAN_INTERFACE_WIFI = 2 +}; + +static int interface_matches_kind(const char *name, enum tb_lan_interface_kind kind) { + if (!name || !*name) return 0; +#if defined(__APPLE__) + CFArrayRef interfaces = SCNetworkInterfaceCopyAll(); + if (!interfaces) return 0; + int matched = 0; + CFIndex count = CFArrayGetCount(interfaces); + for (CFIndex index = 0; index < count; index++) { + SCNetworkInterfaceRef interface = (SCNetworkInterfaceRef)CFArrayGetValueAtIndex(interfaces, index); + CFStringRef bsd_name = SCNetworkInterfaceGetBSDName(interface); + CFStringRef interface_type = SCNetworkInterfaceGetInterfaceType(interface); + if (!bsd_name || !interface_type) continue; + + char candidate_name[IFNAMSIZ] = {0}; + if (!CFStringGetCString(bsd_name, candidate_name, sizeof(candidate_name), kCFStringEncodingUTF8)) continue; + if (strcmp(candidate_name, name) != 0) continue; + + CFStringRef expected_type = kind == TB_LAN_INTERFACE_WIFI + ? kSCNetworkInterfaceTypeIEEE80211 + : kSCNetworkInterfaceTypeEthernet; + matched = CFEqual(interface_type, expected_type); + break; + } + CFRelease(interfaces); + return matched; +#else + if (kind == TB_LAN_INTERFACE_WIFI) { + return strncmp(name, "wlan", 4) == 0 || strncmp(name, "wifi", 4) == 0; + } + return strncmp(name, "en", 2) == 0 || strncmp(name, "eth", 3) == 0; +#endif +} + +static int get_lan_ip_for_kind(char *buf, size_t bufsz, enum tb_lan_interface_kind kind) { + struct ifaddrs *ifap = NULL; + if (!buf || bufsz == 0) return -1; + buf[0] = '\0'; + if (getifaddrs(&ifap) != 0) return -1; + + int ret = -1; + for (struct ifaddrs *p = ifap; p; p = p->ifa_next) { + if (!p->ifa_addr) continue; + if (p->ifa_addr->sa_family != AF_INET) continue; + if (!interface_matches_kind(p->ifa_name, kind)) continue; + + char host[NI_MAXHOST]; + if (getnameinfo(p->ifa_addr, sizeof(struct sockaddr_in), + host, sizeof(host), NULL, 0, NI_NUMERICHOST) == 0 && + is_rfc1918_ipv4(host)) { + snprintf(buf, bufsz, "%s", host); + ret = 0; + break; + } + } + freeifaddrs(ifap); + return ret; +} + +int tb_net_is_link_local_ipv4(const char *host) { + unsigned int a = 0, b = 0, c = 0, d = 0; + if (!host) return 0; + if (sscanf(host, "%u.%u.%u.%u", &a, &b, &c, &d) != 4) return 0; + return a == 169 && b == 254 && c <= 255 && d <= 255; +} + int tb_net_get_tb_ip(char *buf, size_t bufsz) { struct ifaddrs *ifap = NULL; if (getifaddrs(&ifap) != 0) return -1; @@ -184,8 +260,10 @@ int tb_net_get_tb_ip(char *buf, size_t bufsz) { return ret; } -int tb_net_get_lan_ip(char *buf, size_t bufsz) { +int tb_net_get_link_local_ip(char *buf, size_t bufsz) { struct ifaddrs *ifap = NULL; + if (!buf || bufsz == 0) return -1; + buf[0] = '\0'; if (getifaddrs(&ifap) != 0) return -1; int ret = -1; @@ -196,14 +274,26 @@ int tb_net_get_lan_ip(char *buf, size_t bufsz) { char host[NI_MAXHOST]; if (getnameinfo(p->ifa_addr, sizeof(struct sockaddr_in), - host, sizeof(host), NULL, 0, NI_NUMERICHOST) == 0) { - if (is_rfc1918_ipv4(host)) { - snprintf(buf, bufsz, "%s", host); - ret = 0; - break; - } + host, sizeof(host), NULL, 0, NI_NUMERICHOST) == 0 && + tb_net_is_link_local_ipv4(host)) { + snprintf(buf, bufsz, "%s", host); + ret = 0; + break; } } freeifaddrs(ifap); return ret; } + +int tb_net_get_lan_ip(char *buf, size_t bufsz) { + if (tb_net_get_ethernet_ip(buf, bufsz) == 0) return 0; + return tb_net_get_wifi_ip(buf, bufsz); +} + +int tb_net_get_ethernet_ip(char *buf, size_t bufsz) { + return get_lan_ip_for_kind(buf, bufsz, TB_LAN_INTERFACE_ETHERNET); +} + +int tb_net_get_wifi_ip(char *buf, size_t bufsz) { + return get_lan_ip_for_kind(buf, bufsz, TB_LAN_INTERFACE_WIFI); +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/net.h b/TargetBridge-Receiver/TBReceiverC/src/net.h index 80e730ad..09082acb 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/net.h +++ b/TargetBridge-Receiver/TBReceiverC/src/net.h @@ -34,8 +34,22 @@ int tb_net_accept (int server_fd); * buf size must be at least INET_ADDRSTRLEN (16) bytes. Returns 0 on success. */ int tb_net_get_tb_ip(char *buf, size_t bufsz); +/* Returns IP address of the first non-Thunderbolt enX/ethX interface using + * IPv4 link-local addressing (169.254/16). macOS exposes direct USB-NCM + * Mac-to-Mac links this way. Returns 0 on success. */ +int tb_net_get_link_local_ip(char *buf, size_t bufsz); + /* Returns IP address of a likely local LAN interface (for example en0/eth0, RFC1918 IPv4). * buf size must be at least INET_ADDRSTRLEN (16) bytes. Returns 0 on success. */ int tb_net_get_lan_ip(char *buf, size_t bufsz); +/* Returns RFC1918 IPv4 addresses for the physical Ethernet and Wi-Fi + * interfaces independently. Interface roles are resolved from macOS + * SystemConfiguration instead of assuming en0/en1 numbering. */ +int tb_net_get_ethernet_ip(char *buf, size_t bufsz); +int tb_net_get_wifi_ip(char *buf, size_t bufsz); + +/* Address classifier exposed for hardware-free unit tests. */ +int tb_net_is_link_local_ipv4(const char *host); + #endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/proto.h b/TargetBridge-Receiver/TBReceiverC/src/proto.h index f71c9f1a..649c829f 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/proto.h +++ b/TargetBridge-Receiver/TBReceiverC/src/proto.h @@ -55,6 +55,10 @@ #define TB_PKT_BRIGHTNESS 0x35 #define TB_PKT_CLIPBOARD 0x36 #define TB_PKT_VOLUME 0x37 +/* Display tweaks on the receiver's panel: JSON {"nightShift":bool,"trueTone":bool}. + * Both are private CoreBrightness features, so the receiver reports whether it + * can honour them in its display profile. */ +#define TB_PKT_DISPLAY_TWEAKS 0x38 #define TB_PKT_TEST_DATA 0x40 #define TB_HDR_BYTES 5 /* 4 length + 1 type */ diff --git a/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.c b/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.c new file mode 100644 index 00000000..4313fab9 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.c @@ -0,0 +1,24 @@ +#include "receiver_profile.h" + +#include + +int tb_receiver_profile_from_dimensions(uint32_t logical_w, + uint32_t logical_h, + uint32_t panel_w, + uint32_t panel_h, + struct tb_receiver_profile *out) { + if (!out || !panel_w || !panel_h) return -1; + + memset(out, 0, sizeof(*out)); + out->panel_w = panel_w; + out->panel_h = panel_h; + out->capture_w = panel_w; + out->capture_h = panel_h; + + const int hi_dpi = logical_w && logical_h && + panel_w >= logical_w * 2 && panel_h >= logical_h * 2; + out->hi_dpi = hi_dpi; + out->mode_w = hi_dpi ? logical_w : panel_w; + out->mode_h = hi_dpi ? logical_h : panel_h; + return 0; +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.h b/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.h new file mode 100644 index 00000000..76039fc2 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/receiver_profile.h @@ -0,0 +1,27 @@ +/* receiver_profile.h — sender-facing geometry derived from the receiver panel. */ + +#ifndef TB_RECEIVER_PROFILE_H +#define TB_RECEIVER_PROFILE_H + +#include + +struct tb_receiver_profile { + uint32_t panel_w; + uint32_t panel_h; + uint32_t mode_w; + uint32_t mode_h; + uint32_t capture_w; + uint32_t capture_h; + int hi_dpi; +}; + +/* Build the DISPLAY_PROFILE dimensions from the receiver's logical desktop + * mode and its native panel pixels. A Retina panel is advertised as HiDPI + * only when its backing dimensions are at least 2x the logical mode. */ +int tb_receiver_profile_from_dimensions(uint32_t logical_w, + uint32_t logical_h, + uint32_t panel_w, + uint32_t panel_h, + struct tb_receiver_profile *out); + +#endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.h b/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.h new file mode 100644 index 00000000..64e409e4 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.h @@ -0,0 +1,28 @@ +/* tb_display_tweaks.h — Night Shift / True Tone on the receiver's own panel. + * Implemented over the private CoreBrightness framework; every entry point is + * safe to call on a Mac that lacks the feature (returns unsupported / -1). */ + +#ifndef TB_DISPLAY_TWEAKS_H +#define TB_DISPLAY_TWEAKS_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* 1 if the panel can do it, 0 otherwise. */ +int tb_night_shift_supported(void); +int tb_true_tone_supported(void); + +/* Current state, so the sender's UI can follow changes made on this Mac. */ +int tb_true_tone_enabled(void); +int tb_night_shift_enabled(void); + +/* 0 on success, -1 when unavailable. */ +int tb_night_shift_set(int enabled); +int tb_true_tone_set(int enabled); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.m b/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.m new file mode 100644 index 00000000..316cbc77 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_display_tweaks.m @@ -0,0 +1,135 @@ +/* tb_display_tweaks.m — Night Shift and True Tone on the receiver's panel. + * + * Both live in the private CoreBrightness framework, so they are reached the + * same way this app already reaches DisplayServices for brightness: dlopen the + * framework and go through the Objective-C runtime, so a missing class or a + * renamed selector degrades to "unsupported" instead of breaking the build or + * crashing on a future macOS. + * + * Night Shift is CBBlueLightClient (setEnabled:). + * True Tone is CBTrueToneClient, which has no setter — it is activate/deactivate. + */ + +#import +#import +#include + +#include "tb_display_tweaks.h" + +static void tb_load_core_brightness(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + dlopen("/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness", + RTLD_LAZY); + }); +} + +/* Call a no-argument BOOL getter, returning 0 if the selector is absent. */ +static int tb_bool_getter(id obj, SEL sel) { + if (!obj || ![obj respondsToSelector:sel]) return 0; + IMP imp = [obj methodForSelector:sel]; + if (!imp) return 0; + BOOL (*fn)(id, SEL) = (BOOL (*)(id, SEL))imp; + return fn(obj, sel) ? 1 : 0; +} + +/* ---- Night Shift ------------------------------------------------------- */ + +static id tb_blue_light_client(void) { + static id client = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + tb_load_core_brightness(); + Class cls = NSClassFromString(@"CBBlueLightClient"); + if (cls) client = [[cls alloc] init]; + }); + return client; +} + +int tb_night_shift_supported(void) { + return tb_bool_getter(tb_blue_light_client(), @selector(supported)); +} + +int tb_night_shift_enabled(void) { + id c = tb_blue_light_client(); + SEL sel = @selector(getBlueLightStatus:); + if (!c || ![c respondsToSelector:sel]) return 0; + IMP imp = [c methodForSelector:sel]; + if (!imp) return 0; + + /* CBBlueLightClient has no `enabled` getter — only getBlueLightStatus:, + * which fills a private struct whose first two bytes are BOOL active then + * BOOL enabled. The layout is not API, so read defensively: a generously + * oversized zeroed buffer means a larger struct on some macOS version + * cannot overflow, and a changed layout misreports the state rather than + * crashing. Worst case the menu shows a stale toggle. */ + unsigned char status[256]; + memset(status, 0, sizeof(status)); + BOOL (*fn)(id, SEL, void *) = (BOOL (*)(id, SEL, void *))imp; + if (!fn(c, sel, status)) return 0; + return status[1] ? 1 : 0; +} + +int tb_night_shift_set(int enabled) { + id c = tb_blue_light_client(); + SEL sel = @selector(setEnabled:); + if (!c || ![c respondsToSelector:sel]) return -1; + IMP imp = [c methodForSelector:sel]; + if (!imp) return -1; + BOOL (*fn)(id, SEL, BOOL) = (BOOL (*)(id, SEL, BOOL))imp; + fn(c, sel, enabled ? YES : NO); + return 0; +} + +/* ---- True Tone -------------------------------------------------------- */ + +static id tb_true_tone_client(void) { + static id client = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + tb_load_core_brightness(); + Class cls = NSClassFromString(@"CBTrueToneClient"); + if (cls) client = [[cls alloc] init]; + }); + return client; +} + +int tb_true_tone_supported(void) { + id c = tb_true_tone_client(); + if (!c) return 0; + /* `supported` means the hardware has it; `available` means it can be used + * right now — it goes unavailable under some display profiles. Both must + * hold before offering the toggle. */ + return tb_bool_getter(c, @selector(supported)) && tb_bool_getter(c, @selector(available)); +} + +int tb_true_tone_enabled(void) { + return tb_bool_getter(tb_true_tone_client(), @selector(enabled)); +} + +int tb_true_tone_set(int enabled) { + id c = tb_true_tone_client(); + if (!c) return -1; + + /* setEnabled: is the real switch. activate/deactivate look like the obvious + * pair and are not: measured on macOS 15, deactivate leaves `enabled` + * reporting 1 and the panel unchanged — they activate the *client session*, + * not the feature. Keep them only as a fallback for an OS that lacks the + * setter, so a missing selector degrades instead of failing outright. */ + SEL set = @selector(setEnabled:); + if ([c respondsToSelector:set]) { + IMP imp = [c methodForSelector:set]; + if (imp) { + BOOL (*fn)(id, SEL, BOOL) = (BOOL (*)(id, SEL, BOOL))imp; + return fn(c, set, enabled ? YES : NO) ? 0 : -1; + } + } + + SEL sel = enabled ? @selector(activate) : @selector(deactivate); + if (![c respondsToSelector:sel]) return -1; + IMP imp = [c methodForSelector:sel]; + if (!imp) return -1; + void (*fn)(id, SEL) = (void (*)(id, SEL))imp; + fn(c, sel); + return 0; +} diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h index 5ad9e151..37cde0eb 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.h @@ -1,6 +1,8 @@ #ifndef TB_GESTURE_BRIDGE_H #define TB_GESTURE_BRIDGE_H +#include + typedef void (*tb_gesture_space_switch_callback)(int direction, void *context); void tb_gesture_bridge_install(tb_gesture_space_switch_callback callback, void *context); @@ -10,4 +12,12 @@ void tb_gesture_bridge_set_active(int active); * Space (or can't be determined), 0 if it is on a different Space. */ int tb_window_on_active_space(void *sdl_window); +/* Keep the fullscreen monitor surface above Notification Center while a + * session is active, restoring the normal window level on Stop/disconnect. */ +void tb_receiver_set_monitor_shield(int active); + +/* Return the native pixel dimensions of the screen that hosts the receiver + * window. This avoids treating a windowed SDL drawable as the panel size. */ +int tb_receiver_content_display_pixels(uint32_t *width, uint32_t *height); + #endif diff --git a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m index a5577316..250272c7 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m +++ b/TargetBridge-Receiver/TBReceiverC/src/tb_gesture_bridge.m @@ -1,8 +1,183 @@ #import "tb_gesture_bridge.h" #import +#import #include +static NSWindow *tb_receiver_content_window(void) { + NSWindow *content = nil; + CGFloat best_area = 0.0; + for (NSWindow *window in NSApp.windows) { + if (!window.isVisible) continue; + NSSize size = window.frame.size; + CGFloat area = size.width * size.height; + if (area < 200.0 * 200.0) continue; + if (area > best_area) { + best_area = area; + content = window; + } + } + return content; +} + +int tb_receiver_content_display_pixels(uint32_t *width, uint32_t *height) { + if (!width || !height) return -1; + *width = 0; + *height = 0; + + @autoreleasepool { + NSWindow *content = tb_receiver_content_window(); + NSScreen *screen = content.screen ?: NSScreen.mainScreen; + NSNumber *screen_number = screen.deviceDescription[@"NSScreenNumber"]; + if (!screen_number) return -1; + + CGDirectDisplayID display = (CGDirectDisplayID)screen_number.unsignedIntValue; + CGDisplayModeRef mode = CGDisplayCopyDisplayMode(display); + const size_t pixels_w = mode + ? CGDisplayModeGetPixelWidth(mode) + : CGDisplayPixelsWide(display); + const size_t pixels_h = mode + ? CGDisplayModeGetPixelHeight(mode) + : CGDisplayPixelsHigh(display); + if (mode) CFRelease(mode); + if (!pixels_w || !pixels_h) return -1; + + *width = (uint32_t)pixels_w; + *height = (uint32_t)pixels_h; + return 0; + } +} + +static BOOL g_monitor_shield_active = NO; +static uint64_t g_monitor_shield_generation = 0; +static NSMutableArray *g_monitor_shield_observers = nil; + +static NSWindowLevel tb_monitor_shield_level(void) { + NSWindowLevel status_level = + (NSWindowLevel)CGWindowLevelForKey(kCGStatusWindowLevelKey); + NSWindowLevel popup_level = + (NSWindowLevel)CGWindowLevelForKey(kCGPopUpMenuWindowLevelKey); + + /* Notification Center banners use the status-window level. A banner that + * arrives after the Receiver can therefore cover it when both windows + * share that level. Use the first free level above status windows, while + * deliberately staying below pop-up menus and the screen saver/lock UI. */ + return status_level + 1 < popup_level ? status_level + 1 : status_level; +} + +static void tb_apply_monitor_shield(void) { + NSWindow *content = tb_receiver_content_window(); + if (!content) return; + + /* Keep local banners and the menu bar behind the active monitor surface + * without using invasive private or screen-saver window levels. */ + NSWindowLevel desired = g_monitor_shield_active + ? tb_monitor_shield_level() + : NSNormalWindowLevel; + BOOL level_changed = content.level != desired; + if (level_changed) { + content.level = desired; + } + if (g_monitor_shield_active && level_changed) { + /* Do not make the Receiver key, but keep it first within its level if + * macOS or SDL has reordered windows during a fullscreen transition. */ + [content orderFrontRegardless]; + } +} + +static void tb_schedule_monitor_shield_reapply(uint64_t generation, + int64_t delay_ms) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay_ms * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + if (g_monitor_shield_active && + generation == g_monitor_shield_generation) { + tb_apply_monitor_shield(); + } + }); +} + +static void tb_install_monitor_shield_observers(void) { + if (g_monitor_shield_observers) return; + + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + NSArray *names = @[ + NSWindowDidEnterFullScreenNotification, + NSWindowDidExitFullScreenNotification, + NSWindowDidChangeScreenNotification, + NSWindowDidChangeBackingPropertiesNotification, + NSApplicationDidChangeScreenParametersNotification, + ]; + g_monitor_shield_observers = [NSMutableArray arrayWithCapacity:names.count]; + + for (NSNotificationName name in names) { + id token = [center addObserverForName:name + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification *notification) { + if (!g_monitor_shield_active) return; + + id object = notification.object; + if ([object isKindOfClass:NSWindow.class] && + object != tb_receiver_content_window()) { + return; + } + + /* Apply after AppKit finishes dispatching the transition event, + * then once more after its final window-server reconciliation. */ + uint64_t generation = g_monitor_shield_generation; + tb_schedule_monitor_shield_reapply(generation, 0); + tb_schedule_monitor_shield_reapply(generation, 250); + }]; + [g_monitor_shield_observers addObject:token]; + } +} + +static void tb_remove_monitor_shield_observers(void) { + if (!g_monitor_shield_observers) return; + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + for (id token in g_monitor_shield_observers) { + [center removeObserver:token]; + } + g_monitor_shield_observers = nil; +} + +void tb_receiver_set_monitor_shield(int active) { + @autoreleasepool { + BOOL normalized = active ? YES : NO; + BOOL changed = normalized != g_monitor_shield_active; + // Repeated input-state refreshes must not invalidate pending retries. + if (!changed) { + tb_apply_monitor_shield(); + return; + } + g_monitor_shield_active = normalized; + uint64_t generation = ++g_monitor_shield_generation; + if (normalized) { + tb_install_monitor_shield_observers(); + } else { + tb_remove_monitor_shield_observers(); + } + tb_apply_monitor_shield(); + + /* SDL can return before AppKit and the window server finish the Space + * transition. Use bounded retries; no polling remains after 2.5 s. */ + if (normalized) { + tb_schedule_monitor_shield_reapply(generation, 250); + tb_schedule_monitor_shield_reapply(generation, 1000); + tb_schedule_monitor_shield_reapply(generation, 2500); + } + + if (changed) { + fprintf(stderr, + "[display] monitor shield=%d level=%ld\n", + normalized ? 1 : 0, + (long)(normalized + ? tb_monitor_shield_level() + : NSNormalWindowLevel)); + } + } +} + int tb_window_on_active_space(void *sdl_window) { /* Whether the receiver's content window is on the Space the user is * currently viewing. Used to gate receiverMaster forwarding so local work @@ -17,16 +192,9 @@ int tb_window_on_active_space(void *sdl_window) { * remains the active application on another Space. */ (void)sdl_window; - NSWindow *content = nil; - CGFloat best_area = 0.0; - NSArray *windows = [NSApp windows]; - for (NSWindow *w in windows) { - if (!w.isVisible) continue; - NSSize s = w.frame.size; - CGFloat area = s.width * s.height; - if (area < 200.0 * 200.0) continue; /* skip tiny status/aux windows */ - if (area > best_area) { best_area = area; content = w; } - } + NSWindow *content = tb_receiver_content_window(); + CGFloat best_area = content ? content.frame.size.width * content.frame.size.height : 0.0; + NSArray *windows = NSApp.windows; /* Fail open (forward) only when we genuinely can't find a content window. */ int on_active = content ? (content.isOnActiveSpace ? 1 : 0) : 1; diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_idle_watchdog.c b/TargetBridge-Receiver/TBReceiverC/tests/test_idle_watchdog.c new file mode 100644 index 00000000..3defc418 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_idle_watchdog.c @@ -0,0 +1,44 @@ +#include "idle_watchdog.h" + +#include +#include + +static int checks; +static int failures; + +#define CHECK(condition) do { \ + checks++; \ + if (!(condition)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + } \ +} while (0) + +int main(void) { + uint64_t last = 1000; + uint64_t elapsed = UINT64_MAX; + + CHECK(tb_idle_watchdog_check(10999, &last, 10000, &elapsed) == TB_IDLE_WATCHDOG_OK); + CHECK(elapsed == 9999); + CHECK(last == 1000); + + CHECK(tb_idle_watchdog_check(11000, &last, 10000, &elapsed) == TB_IDLE_WATCHDOG_EXPIRED); + CHECK(elapsed == 10000); + + /* Regression for issue #156: 52 ms backwards must not become + * UINT64_MAX - 51 and close an active session. */ + last = 5000; + elapsed = UINT64_MAX; + CHECK(tb_idle_watchdog_check(4948, &last, 10000, &elapsed) == + TB_IDLE_WATCHDOG_CLOCK_REWOUND); + CHECK(elapsed == 0); + CHECK(last == 4948); + + CHECK(tb_idle_watchdog_check(14947, &last, 10000, &elapsed) == TB_IDLE_WATCHDOG_OK); + CHECK(elapsed == 9999); + CHECK(tb_idle_watchdog_check(14948, &last, 10000, &elapsed) == TB_IDLE_WATCHDOG_EXPIRED); + CHECK(elapsed == 10000); + + printf("idle watchdog tests: %d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_input_queue.c b/TargetBridge-Receiver/TBReceiverC/tests/test_input_queue.c new file mode 100644 index 00000000..4bc6778e --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_input_queue.c @@ -0,0 +1,121 @@ +#include "input_queue.h" + +#include +#include + +static int checks = 0; +static int failures = 0; + +#define CHECK(condition, message) do { \ + checks++; \ + if (!(condition)) { \ + fprintf(stderr, "FAIL: %s\n", message); \ + failures++; \ + } \ +} while (0) + +static struct tb_input_event event(enum tb_input_event_kind kind, int value) { + struct tb_input_event result; + memset(&result, 0, sizeof(result)); + result.kind = kind; + result.dx = value; + result.key_code = (uint16_t)value; + return result; +} + +int main(void) { + struct tb_input_queue queue = {0}; + struct tb_input_event out; + int saw_down = 0; + int saw_up = 0; + + struct tb_input_event first = event(TB_INPUT_EVENT_KEY_DOWN, 12); + struct tb_input_event second = event(TB_INPUT_EVENT_KEY_UP, 12); + CHECK(tb_input_queue_push(&queue, &first) == 1, "first event queued"); + CHECK(tb_input_queue_push(&queue, &second) == 1, "second event queued"); + CHECK(tb_input_queue_pop(&queue, &out) == 1 && out.kind == TB_INPUT_EVENT_KEY_DOWN, + "queue preserves FIFO order"); + CHECK(tb_input_queue_pop(&queue, &out) == 1 && out.kind == TB_INPUT_EVENT_KEY_UP, + "queue preserves key release"); + + /* Exercise the same overflow policy after the circular head has wrapped. */ + memset(&queue, 0, sizeof(queue)); + for (int i = 0; i < 16; i++) { + struct tb_input_event warmup = event(TB_INPUT_EVENT_MOVE, i); + CHECK(tb_input_queue_push(&queue, &warmup) == 1, "wrap warmup queued"); + } + for (int i = 0; i < 16; i++) { + CHECK(tb_input_queue_pop(&queue, &out) == 1, "wrap warmup popped"); + } + for (int i = 0; i < TB_INPUT_QUEUE_CAPACITY - 1; i++) { + struct tb_input_event wrapped_move = event(TB_INPUT_EVENT_MOVE, 1000 + i); + CHECK(tb_input_queue_push(&queue, &wrapped_move) == 1, "wrapped motion queued"); + } + CHECK(tb_input_queue_push(&queue, &first) == 1, "wrapped key down fills queue"); + CHECK(tb_input_queue_push(&queue, &second) == 1, + "wrapped overflow preserves key release"); + while (tb_input_queue_pop(&queue, &out)) { + if (out.kind == TB_INPUT_EVENT_KEY_DOWN) saw_down++; + if (out.kind == TB_INPUT_EVENT_KEY_UP) saw_up++; + } + CHECK(saw_down == 1 && saw_up == 1, + "wrapped eviction preserves lifecycle order"); + + memset(&queue, 0, sizeof(queue)); + for (int i = 0; i < TB_INPUT_QUEUE_CAPACITY - 1; i++) { + struct tb_input_event move = event(TB_INPUT_EVENT_MOVE, i); + CHECK(tb_input_queue_push(&queue, &move) == 1, "motion burst queued"); + } + CHECK(tb_input_queue_push(&queue, &first) == 1, "key down fills queue"); + CHECK(tb_input_queue_push(&queue, &second) == 1, + "key up evicts motion instead of being discarded"); + saw_down = 0; + saw_up = 0; + while (tb_input_queue_pop(&queue, &out)) { + if (out.kind == TB_INPUT_EVENT_KEY_DOWN) saw_down++; + if (out.kind == TB_INPUT_EVENT_KEY_UP) saw_up++; + } + CHECK(saw_down == 1, "key down survives motion overflow"); + CHECK(saw_up == 1, "key up survives motion overflow"); + + memset(&queue, 0, sizeof(queue)); + for (int i = 0; i < TB_INPUT_QUEUE_CAPACITY - 1; i++) { + struct tb_input_event move = event(TB_INPUT_EVENT_MOVE, i); + CHECK(tb_input_queue_push(&queue, &move) == 1, "mouse motion burst queued"); + } + struct tb_input_event left_down = event(TB_INPUT_EVENT_LEFT_DOWN, 0); + struct tb_input_event left_up = event(TB_INPUT_EVENT_LEFT_UP, 0); + CHECK(tb_input_queue_push(&queue, &left_down) == 1, "mouse down fills queue"); + CHECK(tb_input_queue_push(&queue, &left_up) == 1, + "mouse up evicts motion instead of being discarded"); + saw_down = 0; + saw_up = 0; + while (tb_input_queue_pop(&queue, &out)) { + if (out.kind == TB_INPUT_EVENT_LEFT_DOWN) saw_down++; + if (out.kind == TB_INPUT_EVENT_LEFT_UP) saw_up++; + } + CHECK(saw_down == 1, "mouse down survives motion overflow"); + CHECK(saw_up == 1, "mouse up survives motion overflow"); + + memset(&queue, 0, sizeof(queue)); + for (int i = 0; i < TB_INPUT_QUEUE_CAPACITY; i++) { + struct tb_input_event critical = event(TB_INPUT_EVENT_KEY_DOWN, i); + CHECK(tb_input_queue_push(&queue, &critical) == 1, "critical event queued"); + } + struct tb_input_event move = event(TB_INPUT_EVENT_MOVE, 5); + CHECK(tb_input_queue_push(&queue, &move) == 0, + "lossy motion is discarded before a lifecycle event"); + CHECK(queue.count == TB_INPUT_QUEUE_CAPACITY, + "discarded motion leaves critical queue intact"); + + CHECK(tb_input_queue_push(&queue, &second) == -1, + "critical-only overflow requests safe deactivation"); + CHECK(queue.count == 1, "unsafe backlog is collapsed"); + CHECK(tb_input_queue_pop(&queue, &out) == 1 && + out.kind == TB_INPUT_EVENT_DEACTIVATE_CONTROL, + "overflow emits the existing release-all command"); + CHECK(tb_input_queue_pop(&queue, &out) == 0, "queue is empty after fail-safe"); + + printf("input queue tests: %d checks, %d failures\n", checks, failures); + return failures == 0 ? 0 : 1; +} diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_monitor_shield.m b/TargetBridge-Receiver/TBReceiverC/tests/test_monitor_shield.m new file mode 100644 index 00000000..5544bc75 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_monitor_shield.m @@ -0,0 +1,54 @@ +#import "../src/tb_gesture_bridge.m" +#include + +static void pump(double seconds) { + NSDate *end = [NSDate dateWithTimeIntervalSinceNow:seconds]; + while ([end timeIntervalSinceNow] > 0) { + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:end]; + } +} + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + NSWindow *window = [[NSWindow alloc] + initWithContentRect:NSMakeRect(0, 0, 320, 240) + styleMask:NSWindowStyleMaskTitled + backing:NSBackingStoreBuffered defer:NO]; + window.releasedWhenClosed = NO; + [window orderFrontRegardless]; + + tb_receiver_set_monitor_shield(1); + uint64_t generation = g_monitor_shield_generation; + assert(g_monitor_shield_observers.count == 5); + assert(window.level == tb_monitor_shield_level()); + for (int i = 0; i < 20; i++) tb_receiver_set_monitor_shield(1); + assert(generation == g_monitor_shield_generation); + assert(g_monitor_shield_observers.count == 5); + + window.level = NSNormalWindowLevel; + pump(0.35); + assert(window.level == tb_monitor_shield_level()); + + tb_receiver_set_monitor_shield(0); + assert(g_monitor_shield_observers == nil); + assert(window.level == NSNormalWindowLevel); + pump(2.3); + assert(window.level == NSNormalWindowLevel); + + tb_receiver_set_monitor_shield(1); + pump(2.6); + window.level = NSNormalWindowLevel; + [[NSNotificationCenter defaultCenter] + postNotificationName:NSWindowDidChangeBackingPropertiesNotification + object:window]; + pump(0.35); + assert(window.level == tb_monitor_shield_level()); + tb_receiver_set_monitor_shield(0); + assert(window.level == NSNormalWindowLevel); + assert(g_monitor_shield_observers == nil); + [window close]; + puts("monitor shield lifecycle: passed"); + } + return 0; +} diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_net_parser.c b/TargetBridge-Receiver/TBReceiverC/tests/test_net_parser.c index 18b0afaf..0c6f38d6 100644 --- a/TargetBridge-Receiver/TBReceiverC/tests/test_net_parser.c +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_net_parser.c @@ -73,6 +73,16 @@ static size_t build_packet(uint8_t *buf, uint8_t type, const void *payload, size /* ---- tests ------------------------------------------------------------- */ +static void test_link_local_ipv4_classifier(void) { + CHECK(tb_net_is_link_local_ipv4("169.254.189.3"), "USB-NCM link-local address"); + CHECK(tb_net_is_link_local_ipv4("169.254.0.1"), "link-local lower boundary"); + CHECK(tb_net_is_link_local_ipv4("169.254.255.254"), "link-local upper boundary"); + CHECK(!tb_net_is_link_local_ipv4("169.255.1.1"), "outside link-local prefix"); + CHECK(!tb_net_is_link_local_ipv4("10.77.77.2"), "private LAN is not link-local"); + CHECK(!tb_net_is_link_local_ipv4("not-an-ip"), "invalid address rejected"); + CHECK(!tb_net_is_link_local_ipv4(NULL), "NULL address rejected"); +} + static void test_single_packet_whole_feed(void) { struct tb_parser p; tb_parser_init(&p, capture_cb, NULL); @@ -213,6 +223,7 @@ static void test_large_payload_roundtrip(void) { } int main(void) { + test_link_local_ipv4_classifier(); test_single_packet_whole_feed(); test_byte_by_byte_feed(); test_two_contiguous_packets(); diff --git a/TargetBridge-Receiver/TBReceiverC/tests/test_receiver_profile.c b/TargetBridge-Receiver/TBReceiverC/tests/test_receiver_profile.c new file mode 100644 index 00000000..7241d961 --- /dev/null +++ b/TargetBridge-Receiver/TBReceiverC/tests/test_receiver_profile.c @@ -0,0 +1,61 @@ +#include "../src/receiver_profile.h" + +#include + +static int failures = 0; +static int checks = 0; + +#define CHECK(condition, message) do { \ + checks++; \ + if (!(condition)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d — %s\\n", __FILE__, __LINE__, message); \ + } \ +} while (0) + +static void test_retina_5k(void) { + struct tb_receiver_profile p; + CHECK(tb_receiver_profile_from_dimensions(2560, 1440, 5120, 2880, &p) == 0, + "5K profile builds"); + CHECK(p.hi_dpi, "5K panel is HiDPI"); + CHECK(p.mode_w == 2560 && p.mode_h == 1440, "5K uses logical mode"); + CHECK(p.capture_w == 5120 && p.capture_h == 2880, "5K capture uses native pixels"); +} + +static void test_retina_4k(void) { + struct tb_receiver_profile p; + CHECK(tb_receiver_profile_from_dimensions(2048, 1152, 4096, 2304, &p) == 0, + "4K profile builds"); + CHECK(p.hi_dpi, "4K panel is HiDPI"); + CHECK(p.mode_w == 2048 && p.mode_h == 1152, "4K uses logical mode"); +} + +static void test_non_retina_1080p(void) { + struct tb_receiver_profile p; + CHECK(tb_receiver_profile_from_dimensions(1920, 1080, 1920, 1080, &p) == 0, + "1080p profile builds"); + CHECK(!p.hi_dpi, "1080p panel is not HiDPI"); + CHECK(p.mode_w == 1920 && p.mode_h == 1080, "1080p uses native mode"); + CHECK(p.capture_w == 1920 && p.capture_h == 1080, "1080p capture uses native pixels"); +} + +static void test_invalid_panel(void) { + struct tb_receiver_profile p; + CHECK(tb_receiver_profile_from_dimensions(1920, 1080, 0, 1080, &p) < 0, + "zero panel width is rejected"); + CHECK(tb_receiver_profile_from_dimensions(1920, 1080, 1920, 1080, NULL) < 0, + "NULL output is rejected"); +} + +int main(void) { + test_retina_5k(); + test_retina_4k(); + test_non_retina_1080p(); + test_invalid_panel(); + if (!failures) { + printf("receiver profile tests: %d checks passed\n", checks); + return 0; + } + fprintf(stderr, "receiver profile tests: %d/%d checks FAILED\n", failures, checks); + return 1; +} diff --git a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh index 17b46249..289e46cd 100755 --- a/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh +++ b/TargetBridge-Receiver/scripts/build_tbreceiver_c_app.sh @@ -8,8 +8,8 @@ BUILD_DIR="${REPO_ROOT}/build" APP_DIR="${BUILD_DIR}/TargetBridge Receiver.app" BIN_NAME="TargetBridgeReceiver" APP_NAME="TargetBridge Receiver" -APP_VERSION="3.3.0" -STAMP="$(date +%Y%m%d%H%M%S)" +APP_VERSION="${APP_VERSION:-3.5.2}" +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" @@ -28,9 +28,10 @@ chmod +x "$APP_DIR/Contents/MacOS/$BIN_NAME" cp "$REPO_ROOT/TargetBridge-Shared/Languages/"*.json "$APP_DIR/Contents/Resources/Languages/" # Bundle dylib dependencies (ffmpeg and SDL) inside the .app. -# Homebrew's SDL2 is currently sdl2-compat, which loads SDL3 at runtime via -# dlopen. dylibbundler cannot discover that dynamic dependency, so copy it -# explicitly to keep the released receiver self-contained. +# Some Homebrew installations provide sdl2-compat, which loads SDL3 at runtime +# through dlopen. dylibbundler cannot discover that dependency, while genuine +# SDL2 does not need SDL3 at all. Inspect the bundled SDL2 artifact instead of +# assuming which formula Homebrew resolved on the build Mac. mkdir -p "$APP_DIR/Contents/Frameworks" if ! command -v dylibbundler &>/dev/null; then echo "Installing dylibbundler..." @@ -42,12 +43,21 @@ dylibbundler -od -b \ -p @executable_path/../Frameworks/ \ >/dev/null 2>&1 -SDL3_DYLIB="$(brew --prefix sdl3)/lib/libSDL3.dylib" -if [[ ! -f "$SDL3_DYLIB" ]]; then - echo "SDL3 runtime library not found: $SDL3_DYLIB" >&2 - exit 1 +SDL2_IN_APP="$(find "$APP_DIR/Contents/Frameworks" -type f -name 'libSDL2*.dylib' -print -quit)" +if [[ -n "$SDL2_IN_APP" ]] && strings -a "$SDL2_IN_APP" | grep -q 'libSDL3'; then + SDL3_PREFIX="$(brew --prefix sdl3 2>/dev/null || true)" + SDL3_DYLIB="${SDL3_PREFIX}/lib/libSDL3.dylib" + if [[ -z "$SDL3_PREFIX" || ! -f "$SDL3_DYLIB" ]]; then + echo "SDL3 runtime library is required by bundled sdl2-compat but was not found" >&2 + exit 1 + fi + cp -L "$SDL3_DYLIB" "$APP_DIR/Contents/Frameworks/libSDL3.dylib" + install_name_tool \ + -id "@executable_path/../Frameworks/libSDL3.dylib" \ + "$APP_DIR/Contents/Frameworks/libSDL3.dylib" +else + echo "Bundled SDL2 is native; SDL3 runtime is not required." fi -cp -L "$SDL3_DYLIB" "$APP_DIR/Contents/Frameworks/libSDL3.dylib" if [[ -f "$ICON_FILE" ]]; then mkdir -p "${ICONSET_DIR}/TargetBridgeReceiver.iconset" @@ -103,6 +113,24 @@ 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 + +# A released bundle must never depend on the Homebrew installation used to +# compile it. Check the executable and every bundled dynamic library after +# dylibbundler and the optional SDL3 copy have completed. +MACHO_FILES=( + "$APP_DIR/Contents/MacOS/$BIN_NAME" + "$APP_DIR/Contents/Frameworks/"*.dylib(N) +) +EXTERNAL_HOMEBREW_REFS="$( + otool -L "${MACHO_FILES[@]}" 2>/dev/null | + awk '$1 ~ /^\/(usr\/local|opt\/homebrew)\// { print }' +)" +if [[ -n "$EXTERNAL_HOMEBREW_REFS" ]]; then + echo "Receiver bundle still contains external Homebrew references:" >&2 + echo "$EXTERNAL_HOMEBREW_REFS" >&2 + exit 1 +fi + xattr -cr "$APP_DIR" >/dev/null 2>&1 || true rm -rf "$ICONSET_DIR" diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index 7f1281c0..d9564927 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -107,6 +107,16 @@ final class ReceiverBackedVirtualDisplaySession { descriptor.maxPixelsWide = UInt32(profile.panelWidth) descriptor.maxPixelsHigh = UInt32(profile.panelHeight) + // A virtual display without chromaticity metadata can receive a generic + // ColorSync profile. In mirror mode that makes macOS render the same + // desktop differently from the built-in Display P3 panel. Advertise the + // iMac's wide-gamut SDR space explicitly so capture is colour-managed + // before it enters the 8-bit NV12 video pipeline. + descriptor.whitePoint = CGPoint(x: 0.3125, y: 0.3291) // D65 + descriptor.redPrimary = CGPoint(x: 0.6797, y: 0.3203) + descriptor.greenPrimary = CGPoint(x: 0.2559, y: 0.6983) + descriptor.bluePrimary = CGPoint(x: 0.1494, y: 0.0557) + let ppi = 218.0 descriptor.sizeInMillimeters = CGSize( width: Double(profile.panelWidth) / ppi * 25.4, diff --git a/TargetBridge-Sender/TBDisplaySender/TBCaptureHealth.swift b/TargetBridge-Sender/TBDisplaySender/TBCaptureHealth.swift new file mode 100644 index 00000000..f657c3d6 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBCaptureHealth.swift @@ -0,0 +1,83 @@ +import Foundation +import CoreGraphics +import CoreMedia +import ScreenCaptureKit + +/// Capture health is not FPS: event-driven capture may remain idle indefinitely. +/// Owned by the pipeline under its snapshot lock. Times use monotonic uptime. +struct TBCaptureHealth { + enum State: String { + case starting, active, idle, blank, suspended, stopped, unknown + + init(_ status: SCFrameStatus) { + switch status { + case .complete, .started: self = .active + case .idle: self = .idle + case .blank: self = .blank + case .suspended: self = .suspended + case .stopped: self = .stopped + @unknown default: self = .unknown + } + } + + init(_ status: CGDisplayStreamFrameStatus) { + switch status { + case .frameComplete: self = .active + case .frameIdle: self = .idle + case .frameBlank: self = .blank + case .stopped: self = .stopped + @unknown default: self = .unknown + } + } + + init(sampleBuffer: CMSampleBuffer) { + guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) + as? [[SCStreamFrameInfo: Any]], + let raw = attachments.first?[SCStreamFrameInfo.status] as? Int, + let status = SCFrameStatus(rawValue: raw) else { + self = .unknown + return + } + self.init(status) + } + + var canContainFrame: Bool { self == .active || self == .unknown } + var isQuiescent: Bool { self == .idle || self == .blank || self == .suspended } + } + + private(set) var state: State = .starting + private(set) var frameCount: UInt64 = 0 + private(set) var idleCount: UInt64 = 0 + private(set) var lastFrameAt: TimeInterval? + private var progressAt: TimeInterval + + init(now: TimeInterval) { progressAt = now } + + mutating func observe(_ next: State, now: TimeInterval) { + // A resumed stream gets a fresh grace period, but repeated callbacks + // without usable frames must not hide a genuine capture failure. + if state.isQuiescent && !next.isQuiescent && next != .stopped { + progressAt = now + } + state = next + if next == .idle { idleCount &+= 1 } + } + + mutating func recordFrame(now: TimeInterval) { + state = .active + lastFrameAt = now + progressAt = now + frameCount &+= 1 + } + + func shouldRestart(now: TimeInterval, timeout: TimeInterval = 8) -> Bool { + if state == .stopped { return true } + // Blank/suspended are deliberate states, not failures; don't wake a + // sleeping display in a restart loop. System-wake recovery is separate. + if state == .blank || state == .suspended { return false } + // An idle event is a state transition, NOT a periodic heartbeat promise. + // Require a real first frame so initial idle cannot mask a failed start. + if state == .idle && lastFrameAt != nil { return false } + return now - progressAt >= timeout + } +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplayProfile.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplayProfile.swift index 225990c7..e0f4f33b 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplayProfile.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplayProfile.swift @@ -4,7 +4,10 @@ struct TBDisplayProfileSettings: Equatable { let captureSource: TBDisplayCaptureSource let capturePreset: TBDisplayCapturePreset let matchRenderToStream: Bool - let audioEnabled: Bool + /// `nil` keeps the user's existing audio preference when a display profile + /// is applied. A display profile should tune video without silently + /// disabling audio for future sessions. + let audioEnabled: Bool? } enum TBDisplayProfile: String, CaseIterable, Identifiable, Codable { @@ -21,14 +24,14 @@ enum TBDisplayProfile: String, CaseIterable, Identifiable, Codable { captureSource: .extendedDesktop, capturePreset: .native5k, matchRenderToStream: true, - audioEnabled: false + audioEnabled: nil ) case .lowLatency: return TBDisplayProfileSettings( captureSource: .desktopMirror, capturePreset: .smooth1440p60, matchRenderToStream: false, - audioEnabled: false + audioEnabled: nil ) case .presentation: return TBDisplayProfileSettings( diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift index b91bac48..a96a66a4 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderApp.swift @@ -1,9 +1,35 @@ +import AppKit +import ApplicationServices +import Darwin import SwiftUI @main struct TBDisplaySenderApp: App { - @StateObject private var service = TBDisplaySenderService.shared - private let statusItemController = TBDisplaySenderStatusItemController(service: TBDisplaySenderService.shared) + @StateObject private var service: TBDisplaySenderService + private let statusItemController: TBDisplaySenderStatusItemController + + @MainActor + init() { + // This probe is intentionally handled before constructing the shared + // service. The Setup Assistant can inspect the exact installed binary + // without opening a window, starting capture or attaching a display. + if CommandLine.arguments.contains("--permission-status") { + let screenRecording = CGPreflightScreenCaptureAccess() ? 1 : 0 + let accessibility = AXIsProcessTrusted() ? 1 : 0 + let inputMonitoring = CGPreflightListenEventAccess() ? 1 : 0 + print("TB_PERMISSION_STATUS:screen_recording=\(screenRecording):accessibility=\(accessibility):input_monitoring=\(inputMonitoring)") + fflush(stdout) + Darwin.exit(EXIT_SUCCESS) + } + + let sharedService = TBDisplaySenderService.shared + _service = StateObject(wrappedValue: sharedService) + statusItemController = TBDisplaySenderStatusItemController(service: sharedService) + + // LaunchAgent automation must not depend on SwiftUI restoring or showing + // the main window on a headless Mac. + TBSenderAutomation.handleLaunchArguments(CommandLine.arguments) + } var body: some Scene { WindowGroup("TargetBridge", id: "main") { @@ -11,13 +37,20 @@ struct TBDisplaySenderApp: App { .frame(minWidth: 540) .task { statusItemController.activate() - TBSenderAutomation.handleLaunchArguments(CommandLine.arguments) } .onOpenURL { url in TBSenderAutomation.handle(url: url) } } .defaultSize(width: 860, height: 860) + .commands { + CommandGroup(replacing: .appTermination) { + Button(TBDisplaySenderL10n.quitApp(service.language)) { + service.quitAfterUserRequest() + } + .keyboardShortcut("q") + } + } Settings { TBDisplaySenderSettingsView(service: service) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 8ecee060..2b2eab72 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -17,6 +17,66 @@ import Foundation @MainActor enum TBSenderAutomation { private static var didHandleLaunchArguments = false + private static var continuousConnectTask: Task? + + static func senderEnabledFlagURL( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + homeDirectory + .appendingPathComponent("Library/Application Support/TargetBridge/Sender", isDirectory: true) + .appendingPathComponent("enabled", isDirectory: false) + } + + static func suspendAutomaticReconnectAfterUserStop( + enabledFlagURL: URL? = nil, + fileManager: FileManager = .default + ) { + suspendAutomaticReconnect( + enabledFlagURL: enabledFlagURL, + fileManager: fileManager, + logReason: "user stopped session" + ) + } + + static func suspendAutomaticReconnectForRequiredPermission( + enabledFlagURL: URL? = nil, + fileManager: FileManager = .default + ) { + suspendAutomaticReconnect( + enabledFlagURL: enabledFlagURL, + fileManager: fileManager, + logReason: "screen recording permission required" + ) + } + + static func suspendAutomaticReconnectAfterCaptureFailure( + enabledFlagURL: URL? = nil, + fileManager: FileManager = .default + ) { + suspendAutomaticReconnect( + enabledFlagURL: enabledFlagURL, + fileManager: fileManager, + logReason: "capture produced no frames" + ) + } + + private static func suspendAutomaticReconnect( + enabledFlagURL: URL?, + fileManager: FileManager, + logReason: String + ) { + continuousConnectTask?.cancel() + continuousConnectTask = nil + let marker = enabledFlagURL ?? senderEnabledFlagURL() + do { + if fileManager.fileExists(atPath: marker.path) { + try fileManager.removeItem(at: marker) + } + NSLog("[automation] \(logReason); automatic reconnect suspended") + } catch { + NSLog("[automation] unable to clear automatic reconnect marker: \(error.localizedDescription)") + } + } /// Handle a `targetbridge://` URL (from `.onOpenURL`). static func handle(url: URL) { @@ -65,17 +125,54 @@ enum TBSenderAutomation { private static func run(action: String, params: [String: String]) { switch action { case "connect": - Task { await connect(params) } + continuousConnectTask?.cancel() + if flagEnabled(params["retry"] ?? params["autoreconnect"] ?? params["auto-reconnect"]) { + continuousConnectTask = Task { await connectContinuously(params) } + } else { + continuousConnectTask = nil + Task { _ = await connect(params) } + } case "disconnect": + continuousConnectTask?.cancel() + continuousConnectTask = nil disconnect(params) default: NSLog("[automation] unknown action '\(action)' (expected connect|disconnect)") } } - private static func connect(_ params: [String: String]) async { + private static func connectContinuously(_ params: [String: String]) async { + var attempt = 0 + while !Task.isCancelled { + attempt += 1 + NSLog("[automation] automatic connection attempt \(attempt)") + + if let session = await connect(params) { + if await waitForConnection(session) { + NSLog("[automation] automatic connection active; monitoring link") + while !Task.isCancelled && (session.isConnected || session.isStreaming) { + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + guard !Task.isCancelled else { return } + NSLog("[automation] connection lost; retrying") + } else { + NSLog("[automation] connection attempt did not become active; retrying") + } + + if !session.isConnected && !session.isStreaming { + session.stopForAutomaticReconnect() + } + } + + guard !Task.isCancelled else { return } + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + } + + @discardableResult + private static func connect(_ params: [String: String]) async -> TBDisplaySenderSession? { let service = TBDisplaySenderService.shared - guard let session = resolveSession(service, params["session"]) else { return } + guard let session = resolveSession(service, params["session"]) else { return nil } service.refreshLocalInterfaces() @@ -83,16 +180,52 @@ enum TBSenderAutomation { session.transportKind = parseTransport(transport) } + let rawPath = params["path"] ?? params["connection-path"] ?? params["connection"] + let pathPreference: TBConnectionPathPreference? + if let rawPath { + guard let parsed = TBConnectionPathPreference.parse(rawPath) else { + NSLog("[automation] unknown connection path '\(rawPath)'; aborting connect") + return nil + } + pathPreference = parsed + } else { + pathPreference = nil + } + let receiver = params["receiver"].flatMap { $0.isEmpty ? nil : $0 } ?? "auto" if receiver.lowercased() == "auto" { guard let discovered = await waitForReceiver(service) else { NSLog("[automation] no receivers discovered; aborting connect") - return + return nil + } + if let pathPreference { + guard let selected = await selectConnectionPath( + receiver: discovered, + preference: pathPreference, + requestedLocalIP: params["localip"] ?? params["local-ip"] + ) else { + NSLog("[automation] no working \(pathPreference.rawValue) path; aborting connect") + return nil + } + apply(selected, receiver: discovered, to: session) + } else { + service.applyDiscoveredReceiver(discovered, to: session) } - service.applyDiscoveredReceiver(discovered, to: session) session.selectedReceiverID = discovered.id } else if let discovered = service.discoveredReceivers.first(where: { matches(receiver, $0) }) { - service.applyDiscoveredReceiver(discovered, to: session) + if let pathPreference { + guard let selected = await selectConnectionPath( + receiver: discovered, + preference: pathPreference, + requestedLocalIP: params["localip"] ?? params["local-ip"] + ) else { + NSLog("[automation] no working \(pathPreference.rawValue) path to \(receiver); aborting connect") + return nil + } + apply(selected, receiver: discovered, to: session) + } else { + service.applyDiscoveredReceiver(discovered, to: session) + } session.selectedReceiverID = discovered.id } else { // Treat as a raw IP / hostname (bypasses Bonjour). @@ -100,7 +233,9 @@ enum TBSenderAutomation { session.selectedReceiverID = "" } - if let localIP = (params["localip"] ?? params["local-ip"]), !localIP.isEmpty { + if pathPreference == nil, + let localIP = (params["localip"] ?? params["local-ip"]), + !localIP.isEmpty { session.localInterfaceIP = localIP } if session.localInterfaceIP.isEmpty { @@ -118,14 +253,15 @@ enum TBSenderAutomation { guard !session.receiverIP.isEmpty else { NSLog("[automation] no receiver IP resolved; aborting connect") - return + return nil } guard !session.localInterfaceIP.isEmpty else { NSLog("[automation] no local interface for transport \(session.transportKind.rawValue); aborting connect") - return + return nil } - NSLog("[automation] connecting to \(session.receiverIP) via \(session.transportKind.rawValue) — \(session.captureSource.rawValue)/\(session.capturePreset.rawValue)") + NSLog("[automation] connecting to \(session.receiverIP) from \(session.localInterfaceIP) via \(session.transportKind.rawValue) — \(session.captureSource.rawValue)/\(session.capturePreset.rawValue)") session.connect() + return session } private static func disconnect(_ params: [String: String]) { @@ -202,6 +338,89 @@ enum TBSenderAutomation { return service.discoveredReceivers.first } + private static func selectConnectionPath( + receiver: TBDiscoveredReceiver, + preference: TBConnectionPathPreference, + requestedLocalIP: String? + ) async -> TBConnectionMeasurement? { + let allInterfaces = TBConnectionDiagnostics.currentIPv4Interfaces() + let eligibleInterfaces: [TBConnectionDiagnostics.LocalInterface] + if let requestedLocalIP, !requestedLocalIP.isEmpty { + eligibleInterfaces = allInterfaces.filter { $0.ip == requestedLocalIP } + } else { + eligibleInterfaces = allInterfaces + } + + let allCandidates = TBConnectionDiagnostics.connectionCandidates( + receiver: receiver, + interfaces: eligibleInterfaces, + hardwareKinds: TBConnectionDiagnostics.hardwarePathKinds() + ) + let candidates = allCandidates.filter { preference.allows($0.kind) } + guard !candidates.isEmpty else { + NSLog("[automation] no candidate interfaces/endpoints for path \(preference.rawValue)") + return nil + } + + let measurements = await Task.detached(priority: .userInitiated) { + var values: [TBConnectionMeasurement] = [] + for candidate in candidates { + guard !Task.isCancelled else { break } + NSLog("[automation] probing \(candidate.kind.rawValue): \(candidate.localIP) (\(candidate.localInterfaceName)) -> \(candidate.receiverIP)") + do { + let measurement = try TBConnectionDiagnostics.probe(candidate) + NSLog( + "[automation] path \(candidate.kind.rawValue) measured \(String(format: "%.3f", measurement.throughputGbps)) Gbit/s, \(String(format: "%.2f", measurement.connectLatencyMilliseconds)) ms" + ) + values.append(measurement) + } catch { + NSLog("[automation] path \(candidate.kind.rawValue) unavailable: \(error.localizedDescription)") + } + usleep(100_000) + } + return values + }.value + + guard let selected = TBConnectionDiagnostics.selectBestMeasurement( + measurements, + preference: preference + ) else { return nil } + NSLog( + "[automation] selected \(selected.candidate.kind.rawValue) on \(selected.candidate.localInterfaceName) at \(String(format: "%.3f", selected.throughputGbps)) Gbit/s" + ) + return selected + } + + private static func apply( + _ measurement: TBConnectionMeasurement, + receiver: TBDiscoveredReceiver, + to session: TBDisplaySenderSession + ) { + session.transportKind = measurement.candidate.transportKind + session.localInterfaceIP = measurement.candidate.localIP + session.receiverIP = measurement.candidate.receiverIP + session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + } + + private static func waitForConnection(_ session: TBDisplaySenderSession) async -> Bool { + for _ in 0..<40 { + if session.isConnected || session.isStreaming { return true } + guard !Task.isCancelled else { return false } + try? await Task.sleep(nanoseconds: 250_000_000) + } + return session.isConnected || session.isStreaming + } + + static func flagEnabled(_ value: String?) -> Bool { + guard let value else { return false } + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "0", "false", "no", "off": + return false + default: + return true + } + } + // The pure parsing helpers below are `internal` (not `private`) so the // unit-test bundle can exercise them directly. static func matches(_ value: String, _ receiver: TBDiscoveredReceiver) -> Bool { @@ -211,7 +430,11 @@ enum TBSenderAutomation { if let host = receiver.shortHostName?.lowercased(), host == needle { return true } return receiver.preferredIP.lowercased() == needle || receiver.thunderboltIP.lowercased() == needle + || receiver.usbIP.lowercased() == needle + || receiver.ethernetIP.lowercased() == needle + || receiver.wifiIP.lowercased() == needle || receiver.networkIP.lowercased() == needle + || receiver.resolvedIPv4Addresses.contains(where: { $0.lowercased() == needle }) } static func parseTransport(_ value: String) -> TBTransportKind { @@ -241,6 +464,7 @@ enum TBSenderAutomation { case "1440p60", "smooth", "smooth1440": return .smooth1440p60 case "1800p", "1800p60", "smooth1800": return .smooth1800p60 case "2160p", "2160p60", "4k", "crisp": return .crisp2160p60 + case "retina4k", "retina4k60", "4096x2304", "imac4k": return .retina4k60 case "5k60", "native5k60": return .native5k60Experimental case "5k", "native", "5120x2880": return .native5k default: return nil diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift index 0060966e..b3ff066d 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { - static let marketingVersion = "3.3.0" - static let buildNumber = "20260728035336" + static let marketingVersion = "3.5.2" + static let buildNumber = "20260810141154" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index 73a1ef3c..2f5fe16d 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -567,6 +567,8 @@ extension TBDisplayCapturePreset { return TBDisplaySenderL10n.text("sender.profile.smooth_plus", language) case .crisp2160p60: return TBDisplaySenderL10n.text("sender.profile.crisp", language) + case .retina4k60: + return TBDisplaySenderL10n.text("sender.profile.retina_4k", language) case .native5k: return TBDisplaySenderL10n.text("sender.profile.native_5k", language) case .native5k60Experimental: diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index d8ee01ea..0d4e2fcb 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -102,6 +102,7 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } } + private var userQuitPending = false private var sessionCancellables: [UUID: AnyCancellable] = [:] private let receiverDiscovery = TBReceiverDiscovery() private let addonStore = TBAddonStore.shared @@ -116,6 +117,7 @@ final class TBDisplaySenderService: ObservableObject { discoveryCancellable = receiverDiscovery.$receivers.sink { [weak self] receivers in guard let self else { return } discoveredReceivers = receivers + reconcileDiscoveredReceivers(receivers) pushLanguageUpdateToDiscoveredReceivers() objectWillChange.send() } @@ -240,14 +242,14 @@ final class TBDisplaySenderService: ObservableObject { largeCursor: largeCursor, preventDisplaySleep: preventDisplaySleep, autoRestartOnWake: autoRestartOnWake, - audioEnabled: audioEnabled && audioRelayAvailable, + audioEnabled: audioEnabled, verboseDisplayLogging: verboseDisplayLogging ) if let previous = sessions.last { session.capturePreset = previous.capturePreset session.captureSource = previous.captureSource session.transportKind = previous.transportKind - session.audioEnabled = audioRelayAvailable && previous.audioEnabled + session.audioEnabled = previous.audioEnabled session.inputGestureMode = previous.inputGestureMode } session.audioAddonAvailable = audioRelayAvailable @@ -271,15 +273,54 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } - func stopAll() { - sessions.forEach { $0.persistExtendedDisplayArrangementSnapshot() } - sessions.forEach { $0.stop(persistArrangement: false) } + func stopAll(completion: (@MainActor @Sendable () -> Void)? = nil) { + // A manual stop is an operator decision, not a transient disconnect. + TBSenderAutomation.suspendAutomaticReconnectAfterUserStop() + let sessionsToStop = sessions + sessionsToStop.forEach { $0.persistExtendedDisplayArrangementSnapshot() } + guard !sessionsToStop.isEmpty else { + completion?() + return + } + + var remaining = sessionsToStop.count + sessionsToStop.forEach { session in + session.stop(persistArrangement: false) { + remaining -= 1 + if remaining == 0 { + completion?() + } + } + } + } + + func quitAfterUserRequest() { + guard !userQuitPending else { return } + userQuitPending = true + + // Allow the final teardown reason to reach the Receiver before quitting. + stopAll { [weak self] in + self?.finishUserRequestedQuit() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.finishUserRequestedQuit() + } + } + + private func finishUserRequestedQuit() { + guard userQuitPending else { return } + userQuitPending = false + NSApp.terminate(nil) } // MARK: - Session persistence private static let persistedSessionsKey = "fd.tbdisplaysender.sessions.v1" private static let receiverDisplayProfilesKey = "fd.tbdisplaysender.receiverDisplayProfiles.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. + private static let audioPreferenceRepairKey = "fd.tbdisplaysender.audioPreferenceRepaired.v2" /// Snapshot of the user-configurable settings for a single session. Transient /// runtime state (connection, FPS, …) is intentionally excluded — only the @@ -354,6 +395,7 @@ final class TBDisplaySenderService: ObservableObject { return } + let repairPending = !UserDefaults.standard.bool(forKey: Self.audioPreferenceRepairKey) lastPersistedData = data for config in configs { let session = TBDisplaySenderSession( @@ -361,7 +403,7 @@ final class TBDisplaySenderService: ObservableObject { largeCursor: largeCursor, preventDisplaySleep: preventDisplaySleep, autoRestartOnWake: autoRestartOnWake, - audioEnabled: audioEnabled && audioRelayAvailable, + audioEnabled: audioEnabled, verboseDisplayLogging: verboseDisplayLogging ) apply(config, to: session) @@ -369,6 +411,14 @@ final class TBDisplaySenderService: ObservableObject { attachSession(session) sessions.append(session) } + if repairPending { + UserDefaults.standard.set(true, forKey: Self.audioPreferenceRepairKey) + // The repaired value was applied before the session observer was + // attached, so persist it explicitly rather than waiting for a + // later user action or letting the next launch restore `false`. + lastPersistedData = nil + persistSessions() + } // Enforce the single-master invariant: only one session may hold a // non-`off` input role. (Persisted data should already satisfy this, but // restore sets roles directly, so guard against stale/edited defaults.) @@ -407,7 +457,10 @@ final class TBDisplaySenderService: ObservableObject { session.receiverIP = config.receiverIP session.selectedReceiverID = config.selectedReceiverID session.localInterfaceIP = config.localInterfaceIP - session.audioEnabled = config.audioEnabled && audioRelayAvailable + session.audioEnabled = Self.restoredAudioEnabled( + from: config.audioEnabled, + repairPending: !UserDefaults.standard.bool(forKey: Self.audioPreferenceRepairKey) + ) session.brightness = config.brightness session.volume = config.volume ?? 0.5 session.matchRenderToStream = config.matchRenderToStream ?? false @@ -432,6 +485,29 @@ final class TBDisplaySenderService: ObservableObject { objectWillChange.send() } + /// Bonjour can rediscover the same receiver with a new address after a + /// cable reconnect or wake. Keep selected receivers on their active + /// transport address, while leaving manually entered addresses untouched. + private func reconcileDiscoveredReceivers(_ receivers: [TBDiscoveredReceiver]) { + for session in sessions where + !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 + }) else { + continue + } + + session.selectedReceiverID = receiver.id + session.receiverIP = receiver.ip(for: session.transportKind) + session.receiverSupportsHEVCDecodeHint = receiver.supportsHEVCDecode + } + } + func applyDisplayProfile(_ profile: TBDisplayProfile, to session: TBDisplaySenderSession) { guard !session.isConnected, !session.isStreaming else { return } @@ -439,7 +515,9 @@ final class TBDisplaySenderService: ObservableObject { session.captureSource = settings.captureSource session.capturePreset = settings.capturePreset session.matchRenderToStream = settings.matchRenderToStream - session.audioEnabled = settings.audioEnabled && audioRelayAvailable + if let audioEnabled = settings.audioEnabled { + session.audioEnabled = audioEnabled + } guard let receiverKey = receiverProfileKey(for: session) else { return } var profiles = persistedDisplayProfiles @@ -620,9 +698,6 @@ final class TBDisplaySenderService: ObservableObject { for session in sessions { session.audioAddonAvailable = audioEnabled - if !audioEnabled { - session.audioEnabled = false - } if !networkLinkEnabled, session.transportKind == .networkLink { session.transportKind = .thunderboltBridge } @@ -637,6 +712,10 @@ final class TBDisplaySenderService: ObservableObject { sessions.forEach { $0.updateInputControlMode() } } + nonisolated static func restoredAudioEnabled(from persistedValue: Bool, repairPending: Bool) -> Bool { + repairPending && !persistedValue ? true : persistedValue + } + private func updateInputRelayController() { guard inputDockstationAvailable, let session = sessions.first(where: { $0.inputControlRole == .senderMaster }) diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index f90aa88b..53bf1712 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -6,15 +6,28 @@ import Darwin import Foundation import AVFoundation import IOSurface +import IOKit.pwr_mgt import Network @preconcurrency import ScreenCaptureKit import VideoToolbox +/// Reserve network capacity before encoding. Once encoded, even a non-keyframe +/// may be a reference for subsequent frames and must not be discarded locally. +enum TBVideoQueueBudget { + static func canEncode(pending: Int, inFlight: Int, packetLimit: Int, encodeLimit: Int) -> Bool { + let limit = min(64, max(1, packetLimit)) + let encoding = min(64, max(1, encodeLimit)) + return pending >= 0 && inFlight >= 0 && pending < limit && + inFlight < encoding && inFlight < limit - pending + } +} + enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { case standard1440p case smooth1440p60 case smooth1800p60 case crisp2160p60 + case retina4k60 case native5k case native5k60Experimental @@ -30,6 +43,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return "Smooth+" case .crisp2160p60: return "Crisp" + case .retina4k60: + return "Retina 4K" case .native5k: return "5K" case .native5k60Experimental: @@ -47,6 +62,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return "3200 × 1800 @ 60" case .crisp2160p60: return "3840 × 2160 @ 60" + case .retina4k60: + return "4096 × 2304 @ 60" case .native5k: return "5120 × 2880 @ 48" case .native5k60Experimental: @@ -62,6 +79,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 3200 case .crisp2160p60: return 3840 + case .retina4k60: + return 4096 case .native5k, .native5k60Experimental: return 5120 } @@ -75,6 +94,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 1800 case .crisp2160p60: return 2160 + case .retina4k60: + return 2304 case .native5k, .native5k60Experimental: return 2880 } @@ -90,6 +111,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 78_000_000 case .crisp2160p60: return 105_000_000 + case .retina4k60: + return 120_000_000 case .native5k: return 120_000_000 case .native5k60Experimental: @@ -101,7 +124,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p, .smooth1440p60, .smooth1800p60: return "H.264" - case .crisp2160p60, .native5k, .native5k60Experimental: + case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return "HEVC" } } @@ -110,7 +133,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p, .smooth1440p60, .smooth1800p60: return kCMVideoCodecType_H264 - case .crisp2160p60, .native5k, .native5k60Experimental: + case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return kCMVideoCodecType_HEVC } } @@ -119,7 +142,16 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { if let envVal = ProcessInfo.processInfo.environment["QD"], let parsed = Int(envVal) { return parsed } - return 2 + switch self { + case .standard1440p: + return 3 + case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, + .native5k, .native5k60Experimental: + // Five surfaces protect WindowServer from starvation during + // high-frame-rate 4K capture while the serial pipeline prevents an + // application-side frame backlog. + return 5 + } } var expectedFrameRate: Int { @@ -130,7 +162,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 60 case .smooth1800p60: return 60 - case .crisp2160p60: + case .crisp2160p60, .retina4k60: return 60 case .native5k: return 48 @@ -139,6 +171,13 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { } } + /// ScreenCaptureKit treats `minimumFrameInterval` as a throttle rather than + /// a target cadence. Requesting headroom avoids losing refreshes to scheduler + /// tolerance; `TBFrameRatePacer` applies the exact output ceiling later. + var captureRequestFrameRate: Int { + expectedFrameRate >= 48 ? expectedFrameRate * 2 : expectedFrameRate + } + var maxKeyFrameInterval: Int { switch self { case .standard1440p: @@ -147,7 +186,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 60 case .smooth1800p60: return 60 - case .crisp2160p60: + case .crisp2160p60, .retina4k60: return 60 case .native5k: return 48 @@ -162,7 +201,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 2 case .smooth1440p60: return 1 - case .smooth1800p60, .crisp2160p60: + case .smooth1800p60, .crisp2160p60, .retina4k60: return 1 case .native5k, .native5k60Experimental: return 1 @@ -173,7 +212,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .native5k, .native5k60Experimental: + case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -189,7 +228,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p: return 1 - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .native5k, .native5k60Experimental: + case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return 0 } } @@ -198,7 +237,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .native5k, .native5k60Experimental: + case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -214,7 +253,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { switch self { case .standard1440p, .smooth1440p60, .smooth1800p60: return .nominal - case .crisp2160p60, .native5k, .native5k60Experimental: + case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return .best } } @@ -225,7 +264,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return 60 case .smooth1440p60, .smooth1800p60: return 60 - case .crisp2160p60: + case .crisp2160p60, .retina4k60: return 60 case .native5k: return 48 @@ -250,6 +289,47 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { } } +/// A zero-buffer frame-rate ceiling for capture callbacks. Deadlines advance on +/// the ideal output timeline, allowing 75 Hz input to be sampled evenly at 60 Hz. +/// Long idle gaps reset the cadence instead of accumulating burst credit. +struct TBFrameRatePacer { + private let frameInterval: CMTime + private let deadlineTolerance = CMTime(value: 1, timescale: 10_000) + private var nextDeadline: CMTime? + + init(maximumFrameRate: Int) { + frameInterval = maximumFrameRate > 0 + ? CMTime(value: 1, timescale: CMTimeScale(maximumFrameRate)) + : .invalid + } + + mutating func shouldEmit(presentationTime: CMTime) -> Bool { + guard frameInterval.isValid, presentationTime.isValid, + !presentationTime.isIndefinite else { return true } + + guard let deadline = nextDeadline else { + nextDeadline = CMTimeAdd(presentationTime, frameInterval) + return true + } + + if CMTimeCompare(presentationTime, CMTimeSubtract(deadline, frameInterval)) < 0 { + nextDeadline = CMTimeAdd(presentationTime, frameInterval) + return true + } + + guard CMTimeCompare(CMTimeAdd(presentationTime, deadlineTolerance), deadline) >= 0 else { + return false + } + + if CMTimeCompare(CMTimeSubtract(presentationTime, deadline), frameInterval) > 0 { + nextDeadline = CMTimeAdd(presentationTime, frameInterval) + } else { + nextDeadline = CMTimeAdd(deadline, frameInterval) + } + return true + } +} + enum TBDisplayCaptureSource: String, CaseIterable, Identifiable { case desktopMirror case extendedDesktop @@ -281,6 +361,21 @@ enum TBInputControlRole: String, CaseIterable, Identifiable { case receiverMaster var id: String { rawValue } + + func usesLowLatencyCursorOverlay(largeCursorEnabled: Bool) -> Bool { + // ScreenCaptureKit preserves every native macOS cursor shape, including + // temporary system cursors such as the screenshot crosshair. The custom + // overlay is reserved for the explicit large-cursor accessibility option. + largeCursorEnabled + } + + func changesCursorCaptureMode( + from previousRole: TBInputControlRole, + largeCursorEnabled: Bool + ) -> Bool { + usesLowLatencyCursorOverlay(largeCursorEnabled: largeCursorEnabled) + != previousRole.usesLowLatencyCursorOverlay(largeCursorEnabled: largeCursorEnabled) + } } enum TBInputGestureMode: String, CaseIterable, Identifiable { @@ -326,6 +421,7 @@ private final class TBDirectDisplayStreamCapture { // Delivered on `queue` — the pipeline's own serial queue — so encode // runs here, off the main thread, with no extra hop. guard let self else { return } + self.pipeline.observeCaptureStatus(TBCaptureHealth.State(status)) if status == .stopped { // The stream has fully drained; no further frames will arrive, so // it is now safe to release the stream and drop the self-retain. @@ -365,7 +461,7 @@ private final class TBDirectDisplayStreamCapture { /// dedicated serial queue, off the main thread. SwiftUI layout (or any other /// main-thread work) therefore cannot stall frame delivery. All mutable encode /// state is confined to `queue`; the two values the main thread polls -/// (`sentFrames`, `lastCaptureFrameAt`) are guarded by a small lock instead of +/// (`sentFrames`, capture health) are guarded by a small lock instead of /// a per-frame hop back to main. private final class TBVideoPipeline: @unchecked Sendable { let queue = DispatchQueue(label: "fd.tbmonitor.sender.pipeline", qos: .userInteractive) @@ -385,13 +481,18 @@ private final class TBVideoPipeline: @unchecked Sendable { private var inFlightEncodeFrames = 0 private var displayStreamFrameSequence: CMTimeValue = 0 private var lastEncodedDisplayPTS: CMTime? + private var frameRatePacer: TBFrameRatePacer private var ackSent: Bool private var running = false // Read from the main thread (fps timer / watchdog); guarded by `lock`. private let lock = NSLock() private var _sentFrames = 0 - private var _lastCaptureFrameAt = Date() + private var capturedFrames = 0 + private var droppedByFrameRatePacer = 0 + private var droppedBeforeEncodeFrames = 0 + private var droppedAfterEncodeFrames = 0 + private var captureHealth = TBCaptureHealth(now: ProcessInfo.processInfo.systemUptime) init(preset: TBDisplayCapturePreset, codecType: CMVideoCodecType, @@ -407,6 +508,7 @@ private final class TBVideoPipeline: @unchecked Sendable { self.displayName = displayName self.displayID = displayID self.usesRawNV12 = usesRawNV12 + self.frameRatePacer = TBFrameRatePacer(maximumFrameRate: preset.expectedFrameRate) self.ackSent = ackAlreadySent self.onFirstFrame = onFirstFrame } @@ -447,17 +549,35 @@ private final class TBVideoPipeline: @unchecked Sendable { return _sentFrames } - var lastCaptureFrameAtSnapshot: Date { + var captureHealthSnapshot: TBCaptureHealth { + lock.lock(); defer { lock.unlock() } + return captureHealth + } + + func observeCaptureStatus(_ state: TBCaptureHealth.State) { lock.lock(); defer { lock.unlock() } - return _lastCaptureFrameAt + captureHealth.observe(state, now: ProcessInfo.processInfo.systemUptime) } - func diagnosticsSnapshot() -> (pending: Int, inFlight: Int, ptsSeq: CMTimeValue) { - queue.sync { (pending: pendingVideoPackets, inFlight: inFlightEncodeFrames, ptsSeq: displayStreamFrameSequence) } + func diagnosticsSnapshot() -> (pending: Int, inFlight: Int, ptsSeq: CMTimeValue, captured: Int, droppedPacing: Int, droppedPre: Int, droppedPost: Int) { + queue.sync { + ( + pending: pendingVideoPackets, + inFlight: inFlightEncodeFrames, + ptsSeq: displayStreamFrameSequence, + captured: capturedFrames, + droppedPacing: droppedByFrameRatePacer, + droppedPre: droppedBeforeEncodeFrames, + droppedPost: droppedAfterEncodeFrames + ) + } } private func markCaptureFrame() { - lock.lock(); _lastCaptureFrameAt = Date(); lock.unlock() + capturedFrames += 1 + lock.lock() + captureHealth.recordFrame(now: ProcessInfo.processInfo.systemUptime) + lock.unlock() } // MARK: - Encoder setup (on `queue`) @@ -532,31 +652,50 @@ private final class TBVideoPipeline: @unchecked Sendable { /// direct Thunderbolt Bridge link comfortably sustains. /// SCStream capture path. Must be dispatched onto `queue` by the caller. func encode(_ sampleBuffer: CMSampleBuffer) { + guard running, let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } markCaptureFrame() + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + guard frameRatePacer.shouldEmit(presentationTime: pts) else { + droppedByFrameRatePacer += 1 + return + } if usesRawNV12 { sendRawFrame(sampleBuffer) return } - guard running, let encoder = vtEncoder, - let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) - else { return } - if preset.dropsBeforeEncodeWhenBacklogged, - (pendingVideoPackets >= preset.maxPendingVideoPackets || - inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { + guard let encoder = vtEncoder else { return } + if !TBVideoQueueBudget.canEncode(pending: pendingVideoPackets, + inFlight: inFlightEncodeFrames, + packetLimit: preset.maxPendingVideoPackets, + encodeLimit: preset.maxInFlightEncodeFrames) { + droppedBeforeEncodeFrames += 1 return } - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) encode(pixelBuffer: pixelBuffer, presentationTimeStamp: pts, using: encoder) } /// CGDisplayStream capture path. Delivered directly on `queue` by /// `TBDirectDisplayStreamCapture`. func encodeDisplaySurface(_ surface: IOSurfaceRef, displayTime: UInt64) { - markCaptureFrame() guard running, let encoder = vtEncoder else { return } - if preset.dropsBeforeEncodeWhenBacklogged, - (pendingVideoPackets >= preset.maxPendingVideoPackets || - inFlightEncodeFrames >= preset.maxInFlightEncodeFrames) { + markCaptureFrame() + + var pts = displayTime != 0 + ? CMClockMakeHostTimeFromSystemUnits(displayTime) + : CMClockGetTime(CMClockGetHostTimeClock()) + if let last = lastEncodedDisplayPTS, CMTimeCompare(pts, last) <= 0 { + // VTCompressionSession requires strictly increasing PTS. + pts = CMTimeAdd(last, CMTime(value: 1, timescale: 600)) + } + guard frameRatePacer.shouldEmit(presentationTime: pts) else { + droppedByFrameRatePacer += 1 + return + } + if !TBVideoQueueBudget.canEncode(pending: pendingVideoPackets, + inFlight: inFlightEncodeFrames, + packetLimit: preset.maxPendingVideoPackets, + encodeLimit: preset.maxInFlightEncodeFrames) { + droppedBeforeEncodeFrames += 1 return } @@ -583,13 +722,6 @@ private final class TBVideoPipeline: @unchecked Sendable { // frame-counter PTS would drift away from real wall-clock time over a // long session and pace the receiver progressively wrong. displayTime is // in mach-absolute units, the same host clock the SCStream path uses. - var pts = displayTime != 0 - ? CMClockMakeHostTimeFromSystemUnits(displayTime) - : CMClockGetTime(CMClockGetHostTimeClock()) - if let last = lastEncodedDisplayPTS, CMTimeCompare(pts, last) <= 0 { - // VTCompressionSession requires strictly increasing PTS. - pts = CMTimeAdd(last, CMTime(value: 1, timescale: 600)) - } lastEncodedDisplayPTS = pts encode(pixelBuffer: pixelBuffer, presentationTimeStamp: pts, using: encoder) } @@ -630,9 +762,8 @@ private final class TBVideoPipeline: @unchecked Sendable { let notSync = attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false let isKeyframe = !notSync - if !isKeyframe, pendingVideoPackets >= preset.maxPendingVideoPackets { - return - } + // Capacity was reserved before encoding. Preserve the complete encoded + // reference chain; dropping here corrupts HEVC/H.264 until the next IDR. if isKeyframe, let format = CMSampleBufferGetFormatDescription(sampleBuffer), @@ -661,7 +792,10 @@ private final class TBVideoPipeline: @unchecked Sendable { let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } // Backpressure: never pile frames on top of a network that can't keep up. - if pendingVideoPackets >= preset.maxPendingVideoPackets { return } + if pendingVideoPackets >= preset.maxPendingVideoPackets { + droppedBeforeEncodeFrames += 1 + return + } guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2 else { return } CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) @@ -1006,6 +1140,20 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u sendVolumeUpdate() } } + /// Night Shift / True Tone on the receiver's own panel. Only offered when + /// the receiver reports it can honour them (both are private CoreBrightness + /// features, and True Tone needs supporting hardware). + @Published var nightShiftEnabled = false { + didSet { if !adoptingReportedTweaks { sendDisplayTweaks() } } + } + @Published var trueToneEnabled = false { + didSet { if !adoptingReportedTweaks { sendDisplayTweaks() } } + } + /// Set while adopting state the receiver reported, so the didSet observers + /// above don't echo it back and start a loop. + private var adoptingReportedTweaks = false + @Published var receiverSupportsNightShift = false + @Published var receiverSupportsTrueTone = false var audioAddonAvailable = true var receiverSupportsHEVCDecodeHint: Bool? var receiverInputMonitoringTrustedHint: Bool? @@ -1064,6 +1212,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } @Published var inputControlRole: TBInputControlRole = .off { didSet { + let cursorCaptureModeChanged = inputControlRole.changesCursorCaptureMode( + from: oldValue, + largeCursorEnabled: largeCursor + ) inputRelayActive = (inputControlRole == .senderMaster) if inputControlRole != .receiverMaster { injectedRemoteMouseLocation = nil @@ -1072,6 +1224,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u remoteHeldModifierKeyCodes.removeAll() suppressedTriggerKeyCode = nil } + if cursorCaptureModeChanged, isStreaming { + restartCaptureNow() + } } } @Published var inputGestureMode: TBInputGestureMode = .native @@ -1096,6 +1251,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var sentSnapshot = 0 private var sessionAckSent = false + private var captureBlockedByScreenRecordingPermission = false private var fpsTimer: Timer? private var heartbeatTimer: Timer? private var firstFrameTimer: Timer? @@ -1111,6 +1267,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var heartbeatSequence: UInt64 = 0 private var statusState: TBDisplaySenderStatusState = .ready private var streamingActivity: NSObjectProtocol? + private var displayWakeAssertionID = IOPMAssertionID(0) private var lastCheckedCursor: NSCursor? private var lastCheckedCursorType: Int = 0 private var baselineDisplayIDs = Set() @@ -1118,6 +1275,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private var lastCursorPacket: TBMonitorCursor? private var injectedRemoteMouseLocation: CGPoint? private var injectedLeftClickTracker = TBInjectedClickStateTracker() + private let localPointerModifierBridge = TBLocalPointerModifierBridge() private var injectedCommandDown = false private var injectedShiftDown = false private var injectedOptionDown = false @@ -1135,6 +1293,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u var onRemoteDeactivateInputRequest: (() -> Void)? nonisolated(unsafe) private var wakeObservers: [NSObjectProtocol] = [] private var isRestartingCaptureAfterWake = false + private var captureRestartGeneration: UInt64 = 0 + private var lastLoggedCaptureHealthState: TBCaptureHealth.State? nonisolated(unsafe) private var displayReconfigurationCallbackRegistered = false private var verboseLoggingTimer: Timer? private var captureHealthWatchdog: Timer? @@ -1151,28 +1311,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private final class CaptureDelegate: NSObject, SCStreamOutput, SCStreamDelegate { var onFrame: ((CMSampleBuffer) -> Void)? + var onFrameStatus: ((TBCaptureHealth.State) -> Void)? var onAudio: ((CMSampleBuffer) -> Void)? var onError: ((Error) -> Void)? - private static func shouldProcessFrame(_ sampleBuffer: CMSampleBuffer) -> Bool { - guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) - as? [[SCStreamFrameInfo: Any]], - let rawStatus = attachments.first?[SCStreamFrameInfo.status] as? Int, - let status = SCFrameStatus(rawValue: rawStatus) - else { - return true - } - - switch status { - case .complete, .started: - return true - case .idle, .blank, .suspended, .stopped: - return false - @unknown default: - return true - } - } - nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { @@ -1181,7 +1323,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } guard type == .screen else { return } - guard Self.shouldProcessFrame(sampleBuffer) else { return } + let state = TBCaptureHealth.State(sampleBuffer: sampleBuffer) + onFrameStatus?(state) + guard state.canContainFrame else { return } onFrame?(sampleBuffer) } @@ -1235,7 +1379,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return kCMVideoCodecType_HEVC } return kCMVideoCodecType_H264 - case .crisp2160p60, .native5k, .native5k60Experimental: + case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return preset.codecType } } @@ -1520,19 +1664,45 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } - func stop(persistArrangement: Bool = true) { - stop(resetStatusTo: .stopped, persistArrangement: persistArrangement) + func stop( + persistArrangement: Bool = true, + completion: (@MainActor @Sendable () -> Void)? = nil + ) { + TBSenderAutomation.suspendAutomaticReconnectAfterUserStop() + stop( + resetStatusTo: .stopped, + persistArrangement: persistArrangement, + teardownReason: "sender_user_stop", + teardownCompletion: completion + ) + } + + /// Internal retry stop. Unlike a GUI Stop, this must not disable the + /// LaunchAgent marker that represents the user's monitor-mode choice. + func stopForAutomaticReconnect() { + stop( + resetStatusTo: .stopped, + persistArrangement: false, + teardownReason: "sender_internal_stop" + ) } func persistExtendedDisplayArrangementSnapshot() { persistExtendedDisplayArrangementIfNeeded() } - private func stop(resetStatusTo status: TBDisplaySenderStatusState?, persistArrangement: Bool = true) { + private func stop( + resetStatusTo status: TBDisplaySenderStatusState?, + persistArrangement: Bool = true, + teardownReason: String = "sender_internal_stop", + teardownCompletion: (@MainActor @Sendable () -> Void)? = nil + ) { + let connectionToClose = connection + captureRestartGeneration &+= 1 + isRestartingCaptureAfterWake = false if persistArrangement { persistExtendedDisplayArrangementIfNeeded() } - sendTeardown(reason: "sender_stop") connectTimeoutWorkItem?.cancel() connectTimeoutWorkItem = nil heartbeatTimer?.invalidate() @@ -1557,19 +1727,20 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u scStream = nil } captureDelegate = nil - if let activity = streamingActivity { - ProcessInfo.processInfo.endActivity(activity) - streamingActivity = nil - } + endCaptureActivity() pipeline?.stop() pipeline = nil releaseInjectedModifiersIfNeeded() remoteHeldModifierKeyCodes.removeAll() injectedLeftClickTracker.reset() suppressedTriggerKeyCode = nil - connection?.stateUpdateHandler = nil - connection?.cancel() + connectionToClose?.stateUpdateHandler = nil connection = nil + sendTeardownAndClose( + reason: teardownReason, + over: connectionToClose, + completion: teardownCompletion + ) let currentSession = session Task { @MainActor in currentSession.destroy() @@ -1599,8 +1770,35 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u /// (distinct per machine even when two identical iMacs report the same SDL /// display name), falling back to the receiver-reported name. private func receiverIdentityDiscriminator(for profile: TBMonitorDisplayProfile) -> String { - let trimmedIP = receiverIP.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedIP.isEmpty ? profile.receiverName : trimmedIP + // Bonjour keeps the service name stable across Thunderbolt link-local IP + // 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.preferredIP == receiverIP || + $0.thunderboltIP == receiverIP || + $0.networkIP == receiverIP + }) { + return receiver.stableIdentity + } + + let selectedID = selectedReceiverID.trimmingCharacters(in: .whitespacesAndNewlines) + if let serviceName = selectedID.split(separator: "|", maxSplits: 1).first, + !serviceName.isEmpty { + return "service:\(serviceName)" + } + + if let host = shortHostName?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty { + return "host:\(host)" + } + + let receiverName = profile.receiverName.trimmingCharacters(in: .whitespacesAndNewlines) + if !receiverName.isEmpty { + return "receiver:\(receiverName)" + } + + return "address:\(receiverIP.trimmingCharacters(in: .whitespacesAndNewlines))" } /// Key used to derive the extended-desktop virtual display identity. Shares @@ -1701,6 +1899,22 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u send(packet) } + private func applyReportedDisplayTweaks(_ tweaks: TBMonitorDisplayTweaks) { + guard nightShiftEnabled != tweaks.nightShift || trueToneEnabled != tweaks.trueTone else { return } + adoptingReportedTweaks = true + nightShiftEnabled = tweaks.nightShift + trueToneEnabled = tweaks.trueTone + adoptingReportedTweaks = false + } + + private func sendDisplayTweaks() { + guard let packet = TBMonitorProtocol.makeJSONPacket( + type: .displayTweaks, + value: TBMonitorDisplayTweaks(nightShift: nightShiftEnabled, trueTone: trueToneEnabled) + ) else { return } + send(packet) + } + private func sendVolumeUpdate() { guard let packet = TBMonitorProtocol.makeJSONPacket( type: .volume, @@ -1726,12 +1940,39 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u send(packet) } - private func sendTeardown(reason: String) { + private func sendTeardownAndClose( + reason: String, + over connection: NWConnection?, + completion: (@MainActor @Sendable () -> Void)? = nil + ) { + guard let connection else { + completion?() + return + } guard let packet = TBMonitorProtocol.makeJSONPacket( type: .teardown, value: TBMonitorTeardown(reason: reason) - ) else { return } - send(packet) + ) else { + connection.cancel() + completion?() + return + } + + // Preserve the explicit user/internal reason before closing the socket. + // Otherwise the Receiver cannot distinguish Stop from a dropped cable. + connection.send( + content: packet, + contentContext: .finalMessage, + isComplete: true, + completion: .contentProcessed { _ in + connection.cancel() + guard let completion else { return } + Task { @MainActor in completion() } + } + ) + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) { + connection.cancel() + } } private func receiveLoop(on connection: NWConnection) { @@ -1799,7 +2040,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u releaseInjectedModifiersIfNeeded() onRemoteDeactivateInputRequest?() } else { - applyIncomingInputEvent(event) + applyIncomingInputEvent(event, payload: payload) } } case .heartbeat: @@ -1808,6 +2049,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u setStatus(.receiverTerminatedSession) stop(resetStatusTo: nil) return + case .displayTweaks: + // Receiver reporting its real state (it may have been changed on + // that Mac directly). Adopt it without sending anything back — + // the didSet observers would otherwise bounce it straight to the + // receiver and the two could ping-pong. + if let tweaks = TBMonitorProtocol.decodeJSON(TBMonitorDisplayTweaks.self, from: payload) { + applyReportedDisplayTweaks(tweaks) + } case .clipboard: if let clipboard = TBMonitorProtocol.decodeJSON(TBMonitorClipboard.self, from: payload) { let pasteboard = NSPasteboard.general @@ -1887,7 +2136,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if shouldWarp { CGWarpMouseCursorPosition(target) } - guard let event = CGEvent(mouseEventSource: localInputEventSource(), mouseType: type, mouseCursorPosition: target, mouseButton: button) else { return } + guard let event = TBInjectedPointerEvent.mouse(source: localInputEventSource(), type: type, + position: target, button: button, modifiers: currentInjectedModifierFlags()) else { return } event.setIntegerValueField(.mouseEventDeltaX, value: Int64(dx)) event.setIntegerValueField(.mouseEventDeltaY, value: Int64(dy)) event.post(tap: .cghidEventTap) @@ -1899,18 +2149,22 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let frame = screenFrame(containing: target), target.x <= frame.minX || target.x >= frame.maxX - 1 || target.y <= frame.minY || target.y >= frame.maxY - 1, - let edgeEvent = CGEvent(mouseEventSource: localInputEventSource(), mouseType: .mouseMoved, mouseCursorPosition: target, mouseButton: button) { + let edgeEvent = TBInjectedPointerEvent.mouse(source: localInputEventSource(), type: .mouseMoved, + position: target, button: button, modifiers: currentInjectedModifierFlags()) { edgeEvent.setIntegerValueField(.mouseEventDeltaX, value: Int64(dx)) edgeEvent.setIntegerValueField(.mouseEventDeltaY, value: Int64(dy)) edgeEvent.post(tap: .cghidEventTap) } } - private func postLocalMouseButton(type: CGEventType, button: CGMouseButton) { + private func postLocalMouseButton(type: CGEventType, button: CGMouseButton, clickCount: Int? = nil) { logLocalInputInjectionStateIfNeeded(context: "mouseButton") guard let current = injectedRemoteMouseLocation ?? currentLocalMouseLocation() else { return } - guard let event = CGEvent(mouseEventSource: localInputEventSource(), mouseType: type, mouseCursorPosition: current, mouseButton: button) else { return } - if button == .left { + guard let event = TBInjectedPointerEvent.mouse(source: localInputEventSource(), type: type, + position: current, button: button, modifiers: currentInjectedModifierFlags()) else { return } + if let clickCount { + event.setIntegerValueField(.mouseEventClickState, value: Int64(min(max(clickCount, 1), 3))) + } else if button == .left { let clickState: Int if type == .leftMouseDown { clickState = injectedLeftClickTracker.registerClick( @@ -1928,14 +2182,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func postLocalScroll(scrollX: Int, scrollY: Int) { logLocalInputInjectionStateIfNeeded(context: "scroll") - guard let event = CGEvent( - scrollWheelEvent2Source: localInputEventSource(), - units: .line, - wheelCount: 2, - wheel1: Int32(scrollY), - wheel2: Int32(scrollX), - wheel3: 0 - ) else { return } + guard let event = TBInjectedPointerEvent.scroll(source: localInputEventSource(), + x: Int32(scrollX), y: Int32(scrollY), modifiers: currentInjectedModifierFlags()) else { return } event.post(tap: .cghidEventTap) } @@ -1949,6 +2197,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u case 57: injectedCapsDown = isDown default: break } + if TBInputBindingEngine.modifierBit(for: keyCode) != nil { + let flags = currentInjectedModifierFlags() + localPointerModifierBridge.update(inputControlRole == .receiverMaster ? flags : []) + TBInputDebugLog.log("sender remote modifier key=\(keyCode) down=\(isDown) flags=\(flags.rawValue)") + } guard let event = CGEvent(keyboardEventSource: localInputEventSource(), virtualKey: CGKeyCode(keyCode), keyDown: isDown) else { return } event.flags = currentInjectedModifierFlags() event.post(tap: .cghidEventTap) @@ -1995,6 +2248,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u postLocalKey(keyCode: 57, isDown: false) injectedCapsDown = false } + localPointerModifierBridge.stop() } private func postLocalSpaceSwitch(direction: Int) { @@ -2022,7 +2276,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u controlUp.post(tap: .cghidEventTap) } - private func applyIncomingInputEvent(_ event: TBMonitorInputEvent) { + private func applyIncomingInputEvent(_ event: TBMonitorInputEvent, payload: Data) { TBInputDebugLog.log("sender applying incoming event kind=\(event.kind)") switch event.kind { case "move": @@ -2034,17 +2288,23 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u case "otherDrag": postLocalMouseMove(dx: event.dx ?? 0, dy: event.dy ?? 0, type: .otherMouseDragged, button: .center) case "leftDown": - postLocalMouseButton(type: .leftMouseDown, button: .left) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .leftMouseDown, button: .left, clickCount: buttonEvent?.clickCount) case "leftUp": - postLocalMouseButton(type: .leftMouseUp, button: .left) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .leftMouseUp, button: .left, clickCount: buttonEvent?.clickCount) case "rightDown": - postLocalMouseButton(type: .rightMouseDown, button: .right) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .rightMouseDown, button: .right, clickCount: buttonEvent?.clickCount) case "rightUp": - postLocalMouseButton(type: .rightMouseUp, button: .right) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .rightMouseUp, button: .right, clickCount: buttonEvent?.clickCount) case "otherDown": - postLocalMouseButton(type: .otherMouseDown, button: .center) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .otherMouseDown, button: .center, clickCount: buttonEvent?.clickCount) case "otherUp": - postLocalMouseButton(type: .otherMouseUp, button: .center) + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + postLocalMouseButton(type: .otherMouseUp, button: .center, clickCount: buttonEvent?.clickCount) case "scroll": postLocalScroll(scrollX: event.scrollX ?? 0, scrollY: event.scrollY ?? 0) case "keyDown": @@ -2169,6 +2429,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u if let accessibilityTrusted = profile.accessibilityTrusted { receiverAccessibilityTrustedHint = accessibilityTrusted } + receiverSupportsNightShift = profile.supportsNightShift ?? false + receiverSupportsTrueTone = profile.supportsTrueTone ?? false receiverPanelText = TBDisplaySenderL10n.receiverSummary(profile, language: language) sendHello() sendInputControlModeUpdate() @@ -2192,8 +2454,13 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } + // A headless Sender may still expose a sleeping physical display. + // Wake and hold the graphical session before virtual display setup. + self.beginCaptureActivity() self.setStatus(.creatingVirtualDisplay) - self.baselineDisplayIDs = await self.fetchShareableDisplayIDs() + 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 @@ -2218,13 +2485,15 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } if self.captureSource == .desktopMirror { - let displayReady = await self.waitForOnlineDisplay(self.session.displayID) - let mirrorConfigured = displayReady && self.configureDesktopMirror(for: self.session.displayID) - if !mirrorConfigured { - NSLog( - "TargetBridge: unable to enable mirror mode for virtual display %u on first attempt; scheduling retry", - self.session.displayID - ) + if CGDisplayIsInMirrorSet(self.session.displayID) == 0 { + let displayReady = await self.waitForOnlineDisplay(self.session.displayID) + let mirrorConfigured = displayReady && self.configureDesktopMirror(for: self.session.displayID) + if !mirrorConfigured { + NSLog( + "TargetBridge: unable to enable mirror mode for virtual display %u on first attempt; scheduling retry", + self.session.displayID + ) + } } } self.virtualDisplayText = TBDisplaySenderL10n.virtualDisplaySummary( @@ -2241,10 +2510,21 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // armed against a session that has already delivered frames — it then // tears down a healthy stream ~4s in. See onFirstFrame wiring below. self.sessionAckSent = false + self.captureBlockedByScreenRecordingPermission = false self.setStatus(.startingCapture(self.capturePreset.description, self.captureSource)) let started = await self.startCapture(for: profile) guard started else { - self.stop(resetStatusTo: nil) + if self.captureBlockedByScreenRecordingPermission { + self.captureBlockedByScreenRecordingPermission = false + TBSenderAutomation.suspendAutomaticReconnectForRequiredPermission() + self.stop( + resetStatusTo: nil, + persistArrangement: false, + teardownReason: "sender_user_stop" + ) + } else { + self.stop(resetStatusTo: nil) + } return } @@ -2261,6 +2541,16 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func startCapture(for profile: TBMonitorDisplayProfile) async -> Bool { do { + guard CGPreflightScreenCaptureAccess() else { + captureBlockedByScreenRecordingPermission = true + _ = CGRequestScreenCaptureAccess() + setStatus(.captureDesktopError( + TBDisplaySenderL10n.missingScreenRecordingPermission(language: language) + )) + TBLog.connection.error("capture: screen recording permission missing; automatic reconnect suspended") + return false + } + let preset = capturePreset let usesRawNV12 = rawNV12Enabled(for: profile) let codecType = resolvedCodecType(for: preset, profile: profile) @@ -2291,11 +2581,14 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u let display: SCDisplay if captureSource == .desktopMirror { - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) - guard let mainDisplay = content.displays.first(where: { $0.displayID == CGMainDisplayID() }) 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) + } else { return false } - display = mainDisplay } else { let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) if session.displayID != kCGNullDirectDisplay, @@ -2306,16 +2599,20 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } + let usesCursorOverlay = inputControlRole.usesLowLatencyCursorOverlay( + largeCursorEnabled: largeCursor + ) let configuration = SCStreamConfiguration() configuration.width = preset.width configuration.height = preset.height - configuration.minimumFrameInterval = CMTime(value: 1, timescale: Int32(preset.expectedFrameRate)) + configuration.minimumFrameInterval = CMTime(value: 1, timescale: Int32(preset.captureRequestFrameRate)) configuration.queueDepth = preset.queueDepth configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange - configuration.showsCursor = !largeCursor + configuration.shouldBeOpaque = true + configuration.showsCursor = !usesCursorOverlay configuration.scalesToFit = true configuration.captureResolution = preset.captureResolution - configuration.capturesAudio = true + configuration.capturesAudio = shouldRelayAudio configuration.excludesCurrentProcessAudio = true configuration.sampleRate = 48000 configuration.channelCount = 2 @@ -2328,15 +2625,21 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u ) let delegate = CaptureDelegate() + delegate.onFrameStatus = { state in + pipeline.observeCaptureStatus(state) + } delegate.onFrame = { sampleBuffer in - pipeline.queue.async { pipeline.encode(sampleBuffer) } + // ScreenCaptureKit already invokes this closure on pipeline.queue. + // Encode immediately so its IOSurface returns to WindowServer + // without an extra dispatch hop. + pipeline.encode(sampleBuffer) } delegate.onAudio = { [weak self] sampleBuffer in self?.processAudio(sampleBuffer) } delegate.onError = { [weak self] error in Task { @MainActor [weak self] in - guard let self else { return } + guard let self, self.pipeline === pipeline else { return } self.setStatus(.captureError(self.formattedCaptureErrorMessage(for: error))) self.stop(resetStatusTo: nil) } @@ -2349,21 +2652,20 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u try stream.addStreamOutput( delegate, type: .screen, - sampleHandlerQueue: DispatchQueue(label: "fd.tbmonitor.sender.capture", qos: .userInteractive) - ) - try stream.addStreamOutput( - delegate, - type: .audio, - sampleHandlerQueue: DispatchQueue(label: "fd.tbmonitor.sender.audio", qos: .userInteractive) + sampleHandlerQueue: pipeline.queue ) + if shouldRelayAudio { + try stream.addStreamOutput( + delegate, + type: .audio, + sampleHandlerQueue: DispatchQueue(label: "fd.tbmonitor.sender.audio", qos: .userInteractive) + ) + } try await stream.startCapture() scStream = stream isStreaming = true - if largeCursor { startCursorUpdates(displayID: display.displayID) } - streamingActivity = ProcessInfo.processInfo.beginActivity( - options: activityOptions(), - reason: "TargetBridge streaming active" - ) + if usesCursorOverlay { startCursorUpdates(displayID: display.displayID) } + beginCaptureActivity(wakeDisplay: false) startFPSTimer() startCaptureWatchdog() return true @@ -2377,8 +2679,44 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } + /// Waking a headless graphical session is asynchronous. Poll briefly for a + /// receiver-native ScreenCaptureKit surface before using the direct fallback. + private func resolveMirrorCaptureDisplay() async throws -> SCDisplay? { + for attempt in 0..<30 { + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) + if session.displayID != kCGNullDirectDisplay, + let virtualDisplay = content.displays.first(where: { $0.displayID == session.displayID }) { + TBLog.connection.info("capture: using receiver-native mirrored display id=\(virtualDisplay.displayID, privacy: .public)") + return virtualDisplay + } + if let mainDisplay = content.displays.first(where: { $0.displayID == CGMainDisplayID() }) { + TBLog.connection.info("capture: using main display id=\(mainDisplay.displayID, privacy: .public)") + return mainDisplay + } + if attempt == 0 { + TBLog.connection.info("capture: graphical session is waking; waiting for a shareable display") + } + try await Task.sleep(nanoseconds: 100_000_000) + } + return nil + } + + private func directMirrorFallbackDisplayID() -> CGDirectDisplayID? { + if session.displayID != kCGNullDirectDisplay, CGDisplayIsOnline(session.displayID) != 0 { + return session.displayID + } + let mainDisplayID = CGMainDisplayID() + if mainDisplayID != kCGNullDirectDisplay, CGDisplayIsOnline(mainDisplayID) != 0 { + return mainDisplayID + } + return nil + } + private func startDirectDisplayStream(displayID: CGDirectDisplayID, preset: TBDisplayCapturePreset) -> Bool { guard let pipeline else { return false } + let usesCursorOverlay = inputControlRole.usesLowLatencyCursorOverlay( + largeCursorEnabled: largeCursor + ) let codecName = activeCodecName ?? codecName(for: activeCodecType ?? preset.codecType) streamResolutionText = TBDisplaySenderL10n.streamSummary( preset: preset, @@ -2390,18 +2728,15 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u // Deliver frames straight onto the pipeline's own queue — the handler // runs there, so encode happens off the main thread with no extra hop. let directCapture = TBDirectDisplayStreamCapture(pipeline: pipeline, queue: pipeline.queue) - guard directCapture.start(displayID: displayID, preset: preset, showCursor: !largeCursor) else { + guard directCapture.start(displayID: displayID, preset: preset, showCursor: !usesCursorOverlay) else { return false } directDisplayStream = directCapture captureDisplayText = TBDisplaySenderL10n.captureDisplayCGDisplayStream(language, id: displayID) isStreaming = true - if largeCursor { startCursorUpdates(displayID: displayID) } - streamingActivity = ProcessInfo.processInfo.beginActivity( - options: activityOptions(), - reason: "TargetBridge streaming active" - ) + if usesCursorOverlay { startCursorUpdates(displayID: displayID) } + beginCaptureActivity(wakeDisplay: false) startFPSTimer() startCaptureWatchdog() return true @@ -2415,8 +2750,42 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return options } + private func beginCaptureActivity(wakeDisplay: Bool = true) { + if streamingActivity == nil { + streamingActivity = ProcessInfo.processInfo.beginActivity( + options: activityOptions(), + reason: "TargetBridge streaming active" + ) + } + guard wakeDisplay, preventDisplaySleep else { return } + + let result = IOPMAssertionDeclareUserActivity( + "TargetBridge capture requested" as CFString, + kIOPMUserActiveLocal, + &displayWakeAssertionID + ) + if result == kIOReturnSuccess { + TBLog.connection.info("capture: requested graphical-session wake") + } else { + TBLog.connection.error("capture: unable to wake graphical session result=\(result, privacy: .public)") + } + } + + private func endCaptureActivity() { + if displayWakeAssertionID != 0 { + IOPMAssertionRelease(displayWakeAssertionID) + displayWakeAssertionID = 0 + } + if let activity = streamingActivity { + ProcessInfo.processInfo.endActivity(activity) + streamingActivity = nil + } + } + private func waitForCaptureDisplay() async throws -> SCDisplay { - let targetDisplayID = (captureSource == .desktopMirror) ? CGMainDisplayID() : session.displayID + let targetDisplayID = session.displayID != kCGNullDirectDisplay + ? session.displayID + : CGMainDisplayID() return try await waitForVirtualDisplay( matching: targetDisplayID, baselineDisplayIDs: baselineDisplayIDs @@ -2732,7 +3101,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u width: capturePreset.width, height: capturePreset.height, visible: false, - type: 0 + type: 0, + large: false ) lastCursorPacket = cursor if let packet = TBMonitorProtocol.makeJSONPacket(type: .cursor, value: cursor) { @@ -2748,7 +3118,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u return } - guard largeCursor, isStreaming, cursorDisplayID != kCGNullDirectDisplay else { return } + let usesCursorOverlay = inputControlRole.usesLowLatencyCursorOverlay( + largeCursorEnabled: largeCursor + ) + guard usesCursorOverlay, isStreaming, cursorDisplayID != kCGNullDirectDisplay else { return } startCursorUpdates(displayID: cursorDisplayID) } @@ -2813,7 +3186,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u width: capturePreset.width, height: capturePreset.height, visible: visible, - type: getCurrentCursorType() + type: getCurrentCursorType(), + large: largeCursor ) if !force, let previous = lastCursorPacket { @@ -2822,7 +3196,8 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u previous.visible == cursor.visible, previous.width == cursor.width, previous.height == cursor.height, - previous.type == cursor.type { + previous.type == cursor.type, + previous.large == cursor.large { return } } @@ -2923,6 +3298,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func startCaptureWatchdog() { captureHealthWatchdog?.invalidate() + lastLoggedCaptureHealthState = nil captureHealthWatchdog = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.checkCaptureHealth() @@ -2937,26 +3313,43 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func checkCaptureHealth() { guard isStreaming, activeProfile != nil, !isRestartingCaptureAfterWake, let pipeline else { return } - let elapsed = Date().timeIntervalSince(pipeline.lastCaptureFrameAtSnapshot) - guard elapsed >= 8.0 else { return } - NSLog("TargetBridge: capture watchdog tripped — %.1fs since last frame, soft restart", elapsed) - scheduleCaptureRestart(reason: "watchdog (\(Int(elapsed))s without frames)", delaySeconds: 0.5) + let health = pipeline.captureHealthSnapshot + let now = ProcessInfo.processInfo.systemUptime + if health.state != lastLoggedCaptureHealthState { + lastLoggedCaptureHealthState = health.state + NSLog("TargetBridge: capture health state=%@ frames=%llu idleEvents=%llu", health.state.rawValue, health.frameCount, health.idleCount) + } + guard health.shouldRestart(now: now) else { return } + NSLog("TargetBridge: capture watchdog tripped — state=%@ frames=%llu idleEvents=%llu", health.state.rawValue, health.frameCount, health.idleCount) + scheduleCaptureRestart(reason: "watchdog (\(health.state.rawValue))", delaySeconds: 0.5, onlyIfUnhealthy: true) } private func logStreamSnapshot() { guard verboseDisplayLogging else { return } let online = onlineDisplayIDs() let virtualOnline = online.contains(session.displayID) - let diag = pipeline?.diagnosticsSnapshot() ?? (pending: 0, inFlight: 0, ptsSeq: 0) + let diag = pipeline?.diagnosticsSnapshot() ?? ( + pending: 0, + inFlight: 0, + ptsSeq: 0, + captured: 0, + droppedPacing: 0, + droppedPre: 0, + droppedPost: 0 + ) NSLog( - "TargetBridge: stream snapshot streaming=%@ fps=%d virtualID=%u online=%@ pendingPackets=%d inFlightEncode=%d ptsSeq=%lld", + "TargetBridge: stream snapshot streaming=%@ fps=%d virtualID=%u online=%@ pendingPackets=%d inFlightEncode=%d ptsSeq=%lld captured=%d droppedPacing=%d droppedPre=%d droppedPost=%d", isStreaming ? "yes" : "no", liveMetrics.senderFPS, session.displayID, virtualOnline ? "yes" : "no", diag.pending, diag.inFlight, - diag.ptsSeq + diag.ptsSeq, + diag.captured, + diag.droppedPacing, + diag.droppedPre, + diag.droppedPost ) } @@ -2973,21 +3366,30 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u isStreaming && activeProfile != nil && !isRestartingCaptureAfterWake } - private func scheduleCaptureRestart(reason: String, delaySeconds: Double) { - guard isStreaming, !isRestartingCaptureAfterWake, let profile = activeProfile else { return } + private func scheduleCaptureRestart(reason: String, delaySeconds: Double, onlyIfUnhealthy: Bool = false) { + guard isStreaming, !isRestartingCaptureAfterWake, let profile = activeProfile, let scheduledPipeline = pipeline else { return } isRestartingCaptureAfterWake = true - NSLog("TargetBridge: \(reason) — soft restart of capture pipeline") + captureRestartGeneration &+= 1 + let generation = captureRestartGeneration Task { @MainActor [weak self] in if delaySeconds > 0 { try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) } - guard let self else { return } - guard self.isStreaming, self.activeProfile?.receiverName == profile.receiverName else { - self.isRestartingCaptureAfterWake = false + guard let self, self.captureRestartGeneration == generation else { return } + defer { + if self.captureRestartGeneration == generation { + self.isRestartingCaptureAfterWake = false + } + } + // A delayed recovery must never restart a replacement connection, + // or interrupt a stream that recovered during the grace period. + guard self.isStreaming, self.pipeline === scheduledPipeline else { return } + if onlyIfUnhealthy && !scheduledPipeline.captureHealthSnapshot.shouldRestart(now: ProcessInfo.processInfo.systemUptime) { + NSLog("TargetBridge: capture watchdog recovery cancelled — capture is healthy") return } + NSLog("TargetBridge: \(reason) — soft restart of capture pipeline") await self.softRestartCapture(for: profile) - self.isRestartingCaptureAfterWake = false } } @@ -3013,10 +3415,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u scStream = nil } captureDelegate = nil - if let activity = streamingActivity { - ProcessInfo.processInfo.endActivity(activity) - streamingActivity = nil - } + endCaptureActivity() pipeline?.stop() pipeline = nil isStreaming = false @@ -3026,6 +3425,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u cursorDisplayID = kCGNullDirectDisplay lastCursorPacket = nil + beginCaptureActivity() let started = await startCapture(for: profile) if !started { NSLog("TargetBridge: soft restart after wake failed — falling back to full stop") @@ -3078,12 +3478,17 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u guard isStreaming, !sessionAckSent else { return } let sentFrames = self.pipeline?.sentFramesSnapshot ?? 0 TBLog.connection.error("capture: first-frame timeout preset=\(self.capturePreset.rawValue, privacy: .public) source=\(String(describing: self.captureSource), privacy: .public) connected=\(self.isConnected, privacy: .public) sentFrames=\(sentFrames, privacy: .public)") - if self.capturePreset == .native5k || self.capturePreset == .native5k60Experimental { + if self.capturePreset == .retina4k60 || self.capturePreset == .native5k || self.capturePreset == .native5k60Experimental { setStatus(.hevcNoFrames) } else { setStatus(.noFirstFrame) } - stop(resetStatusTo: nil) + TBSenderAutomation.suspendAutomaticReconnectAfterCaptureFailure() + stop( + resetStatusTo: nil, + persistArrangement: false, + teardownReason: "sender_user_stop" + ) } } } @@ -3127,12 +3532,16 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func processAudio(_ sampleBuffer: CMSampleBuffer) { - guard audioEnabled else { return } + guard shouldRelayAudio else { return } guard let data = audioConverter.convert(sampleBuffer: sampleBuffer) else { return } let packet = TBMonitorProtocol.makePacket(type: .audioFrame, payload: data) send(packet) } + private var shouldRelayAudio: Bool { + audioEnabled && audioAddonAvailable + } + private func send(_ packet: Data) { connection?.send(content: packet, completion: .contentProcessed({ _ in })) } @@ -3224,17 +3633,26 @@ private final class SBAudioConverter: Sendable { guard status == noErr else { return nil } - let firstBufferPtr = withUnsafeMutablePointer(to: &ablPointer.pointee.mBuffers) { $0 } - let buffers = UnsafeBufferPointer(start: firstBufferPtr, count: channelCount) + let sourceBuffers = UnsafeMutableAudioBufferListPointer(ablPointer) + let destinationBuffers = UnsafeMutableAudioBufferListPointer(inputBuffer.mutableAudioBufferList) + guard sourceBuffers.count == destinationBuffers.count else { return nil } + + // ScreenCaptureKit may supply either one interleaved buffer or one + // buffer per channel. Copy the actual AudioBufferList layout rather + // than assuming a non-interleaved Float32 input. + for index in 0.. 0 else { return nil } + memcpy(destinationData, sourceData, byteCount) + if byteCount < destinationByteCount { + memset(destinationData.advanced(by: byteCount), 0, destinationByteCount - byteCount) } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift index e86f08fd..353892bb 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderStatusItemController.swift @@ -7,6 +7,11 @@ final class TBDisplaySenderStatusItemController: NSObject { nonisolated(unsafe) private var statusItem: NSStatusItem? private var cancellables = Set() private var hasActivated = false + private var pendingMenuAction: (@MainActor @Sendable () -> Void)? + // Retains the target objects for the current menu's sliders (NSSlider holds + // its target weakly). Cleared and rebuilt each time the menu opens. + private var sliderTargets: [TBMenuSliderTarget] = [] + private var toggleRows: [TBMenuToggleRowView] = [] init(service: TBDisplaySenderService) { self.service = service @@ -94,6 +99,8 @@ final class TBDisplaySenderStatusItemController: NSObject { private func rebuildMenuItems(in menu: NSMenu) { menu.removeAllItems() + sliderTargets.removeAll() + toggleRows.removeAll() let titleItem = NSMenuItem(title: "TargetBridge", action: nil, keyEquivalent: "") titleItem.isEnabled = false @@ -103,17 +110,59 @@ final class TBDisplaySenderStatusItemController: NSObject { statusItem.isEnabled = false menu.addItem(statusItem) - if !service.localInterfaces.isEmpty { - let ipItem = NSMenuItem(title: TBDisplaySenderL10n.topBarIP(service.language, service.localInterfaceSummaryText), action: nil, keyEquivalent: "") - ipItem.isEnabled = false - menu.addItem(ipItem) - } - - for session in service.sessions { - let line = "\(service.sessionTitle(for: session)): \(session.statusText)" - let sessionItem = NSMenuItem(title: line, action: nil, keyEquivalent: "") - sessionItem.isEnabled = false - menu.addItem(sessionItem) + // Brightness / volume sliders for each connected session. Reliable and + // native-feeling: they drive the receiver directly via the existing + // brightness/volume path — no event taps, no cursor/keyboard routing. + let connectedSessions = service.sessions.filter { $0.isConnected } + if !connectedSessions.isEmpty { + menu.addItem(.separator()) + for session in connectedSessions { + if connectedSessions.count > 1 { + let header = NSMenuItem(title: service.sessionTitle(for: session), action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + } + menu.addItem(makeSliderItem(symbol: "sun.min.fill", + trailingSymbol: "sun.max.fill", + label: brightnessMenuLabel(), + value: session.brightness) { [weak session] value in + session?.brightness = value + }) + // No volume slider: with the TargetBridge audio device selected, + // macOS's own Sound slider and the F11/F12 keys already drive the + // receiver's hardware volume, so a second control here would just + // be a duplicate that can disagree with the system one. + var toggles: [TBMenuToggleSpec] = [] + if session.receiverSupportsNightShift { + toggles.append(TBMenuToggleSpec( + symbol: "sun.lefthalf.filled", + title: nightShiftMenuLabel(), + stateText: session.nightShiftEnabled ? onWord() : offWord(), + isOn: session.nightShiftEnabled) { [weak session] on in + session?.nightShiftEnabled = on + }) + } + if session.receiverSupportsTrueTone { + toggles.append(TBMenuToggleSpec( + symbol: "sun.max.fill", + title: trueToneMenuLabel(), + stateText: session.trueToneEnabled ? onWord() : offWord(), + isOn: session.trueToneEnabled) { [weak session] on in + session?.trueToneEnabled = on + }) + } + if !toggles.isEmpty { + let row = TBMenuToggleRowView(specs: toggles, + width: TBMenuMetrics.width, + leadingInset: TBMenuMetrics.inset) + row.onWord = onWord() + row.offWord = offWord() + let item = NSMenuItem() + item.view = row + menu.addItem(item) + toggleRows.append(row) + } + } } menu.addItem(.separator()) @@ -143,6 +192,26 @@ final class TBDisplaySenderStatusItemController: NSObject { stopAllItem.isEnabled = service.anyConnected menu.addItem(stopAllItem) + // The verbose connection/IP details live behind a submenu so the default + // menu stays focused on the useful actions. + if !service.localInterfaces.isEmpty || !service.sessions.isEmpty { + let infoItem = NSMenuItem(title: connectionInfoMenuLabel(), action: nil, keyEquivalent: "") + let infoSubmenu = NSMenu() + if !service.localInterfaces.isEmpty { + let ipItem = NSMenuItem(title: TBDisplaySenderL10n.topBarIP(service.language, service.localInterfaceSummaryText), action: nil, keyEquivalent: "") + ipItem.isEnabled = false + infoSubmenu.addItem(ipItem) + } + for session in service.sessions { + let line = "\(service.sessionTitle(for: session)): \(session.statusText)" + let sessionItem = NSMenuItem(title: line, action: nil, keyEquivalent: "") + sessionItem.isEnabled = false + infoSubmenu.addItem(sessionItem) + } + infoItem.submenu = infoSubmenu + menu.addItem(infoItem) + } + let hideItem = NSMenuItem( title: TBDisplaySenderL10n.hideMenuBarIcon(service.language), action: #selector(hideStatusItem), @@ -158,14 +227,146 @@ final class TBDisplaySenderStatusItemController: NSObject { menu.addItem(quitItem) } - // Menu-item handlers run while the menu is still dismissing. Doing work - // synchronously here (activating the app, ordering windows front, mutating - // observed session state) interrupts the menu window's fade-out: its alpha - // animates to 0 but the window is never closed, leaving an invisible - // menu-layer window that swallows clicks at the menu's footprint. Deferring - // to the next runloop tick lets the menu fully tear down first. - private func runAfterMenuDismissal(_ action: @escaping () -> Void) { - DispatchQueue.main.async(execute: action) + /// Builds a menu row with an icon and a 0…1 slider whose changes are pushed + /// live to `onChange` (which sets `session.brightness`/`.volume`, forwarding + /// to the receiver). + /// Brightness row: a small glyph, the system slider, and a larger glyph + /// closing the row. The trailing icon is what stops the track running to the + /// edge of the menu. + /// + /// Stock NSSlider, deliberately, after two attempts at Control Center's + /// accent-filled track. `trackFillColor` is set below and menus ignore it. + /// Overriding NSSliderCell.drawBar does produce the fill, but any drawing + /// override opts the cell out of AppKit's modern slider rendering and the + /// knob loses its pressed-state translucency — more noticeable than a grey + /// track, since it makes the control feel wrong rather than just look plain. + private func makeSliderItem(symbol: String, trailingSymbol: String, label: String, + value: Double, onChange: @escaping (Double) -> Void) -> NSMenuItem { + let width = TBMenuMetrics.width + let height: CGFloat = 28 + let inset = TBMenuMetrics.inset + let leadingIcon: CGFloat = 13 + let trailingIcon: CGFloat = 17 + let gap: CGFloat = 8 + let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height)) + + func glyph(_ name: String, size: CGFloat, x: CGFloat) -> NSImageView { + let view = NSImageView(frame: NSRect(x: x, y: (height - size) / 2, width: size, height: size)) + view.image = NSImage(systemSymbolName: name, accessibilityDescription: label) + // Decorative: the slider itself carries the name, so reading these + // as well would announce the row three times. + view.setAccessibilityElement(false) + view.contentTintColor = .secondaryLabelColor + view.imageScaling = .scaleProportionallyUpOrDown + return view + } + + container.addSubview(glyph(symbol, size: leadingIcon, x: inset)) + container.addSubview(glyph(trailingSymbol, size: trailingIcon, + x: width - inset - trailingIcon)) + + let sliderX = inset + leadingIcon + gap + let sliderWidth = width - sliderX - gap - trailingIcon - inset + let slider = NSSlider(frame: NSRect(x: sliderX, y: (height - 19) / 2, + width: sliderWidth, height: 19)) + slider.minValue = 0 + slider.maxValue = 1 + slider.doubleValue = value + slider.isContinuous = true + // Set even though menus currently ignore it: harmless, and it is the + // supported way to get an accent-filled track if that changes. + slider.trackFillColor = .controlAccentColor + slider.controlSize = .small + slider.setAccessibilityLabel(label) + let target = TBMenuSliderTarget(onChange) + slider.target = target + slider.action = #selector(TBMenuSliderTarget.changed(_:)) + sliderTargets.append(target) + container.addSubview(slider) + + let item = NSMenuItem() + item.view = container + item.toolTip = label + return item + } + + private func brightnessMenuLabel() -> String { + switch service.language { + case .italian: return "Luminosità" + case .french: return "Luminosité" + case .english: return "Brightness" + case .german: return "Helligkeit" + case .chinese: return "亮度" + } + } + + private func onWord() -> String { + switch service.language { + case .italian: return "Attivo" + case .english: return "On" + case .german: return "Ein" + case .chinese: return "开" + case .french: return "Activé" + } + } + + private func offWord() -> String { + switch service.language { + case .italian: return "Non attivo" + case .english: return "Off" + case .german: return "Aus" + case .chinese: return "关" + case .french: return "Désactivé" + } + } + + private func nightShiftMenuLabel() -> String { + switch service.language { + case .italian: return "Night Shift" + case .english: return "Night Shift" + case .german: return "Night Shift" + case .chinese: return "夜览" + case .french: return "Night Shift" + } + } + + private func trueToneMenuLabel() -> String { + switch service.language { + case .italian: return "True Tone" + case .english: return "True Tone" + case .german: return "True Tone" + case .chinese: return "原彩显示" + case .french: return "True Tone" + } + } + + private func volumeMenuLabel() -> String { + switch service.language { + case .italian: return "Volume" + case .french: return "Volume" + case .english: return "Volume" + case .german: return "Lautstärke" + case .chinese: return "音量" + } + } + + private func connectionInfoMenuLabel() -> String { + switch service.language { + case .italian: return "Info connessione" + case .french: return "Infos de connexion" + case .english: return "Connection info" + case .german: return "Verbindungsinfo" + case .chinese: return "连接信息" + } + } + + // AppKit's menu uses a nested tracking loop. Run state changes only after it + // positively reports closure, avoiding an invisible menu window that can + // remain above the streamed desktop and swallow clicks. + private func runAfterMenuDismissal( + _ action: @escaping @MainActor @Sendable () -> Void + ) { + pendingMenuAction = action } @objc @@ -201,8 +402,8 @@ final class TBDisplaySenderStatusItemController: NSObject { @objc private func quitApp() { - runAfterMenuDismissal { - NSApp.terminate(nil) + runAfterMenuDismissal { [service] in + service.quitAfterUserRequest() } } } @@ -211,4 +412,25 @@ extension TBDisplaySenderStatusItemController: NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { rebuildMenuItems(in: menu) } + + func menuDidClose(_ menu: NSMenu) { + guard let action = pendingMenuAction else { return } + pendingMenuAction = nil + DispatchQueue.main.async(execute: action) + } +} + +/// Target for a menu slider. NSSlider holds its target weakly, so the controller +/// retains these for the lifetime of the open menu. +@MainActor +final class TBMenuSliderTarget: NSObject { + private let onChange: (Double) -> Void + + init(_ onChange: @escaping (Double) -> Void) { + self.onChange = onChange + } + + @objc func changed(_ sender: NSSlider) { + onChange(sender.doubleValue) + } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift b/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift new file mode 100644 index 00000000..e30df305 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBInjectedPointerEvent.swift @@ -0,0 +1,24 @@ +import CoreGraphics + +/// Pointer events must carry the relayed keyboard state, not the sender's HID flags. +enum TBInjectedPointerEvent { + static let marker: Int64 = 0x5442504F494E5445 + + static func mouse(source: CGEventSource?, type: CGEventType, position: CGPoint, + button: CGMouseButton, modifiers: CGEventFlags) -> CGEvent? { + guard let event = CGEvent(mouseEventSource: source, mouseType: type, + mouseCursorPosition: position, mouseButton: button) else { return nil } + event.flags = modifiers + event.setIntegerValueField(.eventSourceUserData, value: marker) + return event + } + + static func scroll(source: CGEventSource?, x: Int32, y: Int32, + modifiers: CGEventFlags) -> CGEvent? { + guard let event = CGEvent(scrollWheelEvent2Source: source, units: .line, + wheelCount: 2, wheel1: y, wheel2: x, wheel3: 0) else { return nil } + event.flags = modifiers + event.setIntegerValueField(.eventSourceUserData, value: marker) + return event + } +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifierBridge.swift b/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifierBridge.swift new file mode 100644 index 00000000..34abfde1 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifierBridge.swift @@ -0,0 +1,63 @@ +import CoreGraphics +import Foundation + +@MainActor +final class TBLocalPointerModifierBridge { + private var modifiers = TBLocalPointerModifiers() + // Mutated on the main actor; deinit only invalidates/removes the CF resources. + nonisolated(unsafe) private var tap: CFMachPort? + nonisolated(unsafe) private var source: CFRunLoopSource? + + func update(_ flags: CGEventFlags) { + modifiers.remoteFlags = flags.intersection(TBLocalPointerModifiers.supported) + guard !modifiers.remoteFlags.isEmpty else { stop(); return } + guard tap == nil else { return } + let mask = TBLocalPointerModifiers.eventTypes.reduce(CGEventMask(0)) { $0 | (1 << $1.rawValue) } + guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, + options: .defaultTap, eventsOfInterest: mask, callback: { _, type, event, context in + guard let context else { return Unmanaged.passUnretained(event) } + // The tap is serviced exclusively by the main run loop. + MainActor.assumeIsolated { + let bridge = Unmanaged.fromOpaque(context).takeUnretainedValue() + bridge.handle(type: type, event: event) + } + return Unmanaged.passUnretained(event) + }, userInfo: Unmanaged.passUnretained(self).toOpaque()) else { + TBInputDebugLog.log("sender local-pointer modifier bridge unavailable; check Accessibility") + return + } + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + CFMachPortInvalidate(tap) + return + } + self.tap = tap + self.source = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + TBInputDebugLog.log("sender local-pointer modifier bridge active") + } + + func stop() { + modifiers.remoteFlags = [] + if let tap { CFMachPortInvalidate(tap) } + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + tap = nil + source = nil + } + + private func handle(type: CGEventType, event: CGEvent) { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + } else if modifiers.apply(to: event), type == .leftMouseDown { + let flags = modifiers.remoteFlags.rawValue + Task { @MainActor in + TBInputDebugLog.log("sender local click with remote modifiers=\(flags)") + } + } + } + + deinit { + if let tap { CFMachPortInvalidate(tap) } + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + } +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifiers.swift b/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifiers.swift new file mode 100644 index 00000000..5ee29cb0 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBLocalPointerModifiers.swift @@ -0,0 +1,22 @@ +import CoreGraphics + +/// Combines a remote keyboard with a local mouse, without rewriting key events. +struct TBLocalPointerModifiers { + static let supported: CGEventFlags = [.maskShift, .maskCommand, .maskAlternate, .maskControl] + static let eventTypes: [CGEventType] = [ + .leftMouseDown, .leftMouseUp, .leftMouseDragged, + .rightMouseDown, .rightMouseUp, .rightMouseDragged, + .otherMouseDown, .otherMouseUp, .otherMouseDragged + ] + var remoteFlags: CGEventFlags = [] + + @discardableResult + func apply(to event: CGEvent) -> Bool { + guard Self.eventTypes.contains(event.type), + event.getIntegerValueField(.eventSourceUserData) != TBInjectedPointerEvent.marker else { return false } + let combined = event.flags.union(remoteFlags.intersection(Self.supported)) + guard combined != event.flags else { return false } + event.flags = combined + return true + } +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBMenuMetrics.swift b/TargetBridge-Sender/TBDisplaySender/TBMenuMetrics.swift new file mode 100644 index 00000000..0bf7f158 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBMenuMetrics.swift @@ -0,0 +1,24 @@ +import AppKit + +/// Geometry shared by the menu-bar panel's custom rows. +/// +/// A menu takes the width of its widest item, so with only text items ours came +/// out around 240 pt — narrow enough to read as cramped beside the system's own +/// panels. Control Center's modules are ~340 pt, measured from the Sound and +/// Display panels on macOS 15. +/// +/// The content view is set below that, because a menu draws its own chrome +/// around the item — a 340 pt view produced a visibly wider panel than Apple's. +/// This is the width of the row, not of the panel the user sees; the value was +/// settled by comparing the two panels on screen rather than by calculation. +/// +/// Apple publishes no metrics for those panels — they are system UI, not a +/// component we can adopt — so this is deliberate imitation rather than a +/// documented standard. Two differences cannot be closed: menu items reserve a +/// leading column for checkmarks, so text rows sit further right than Control +/// Center's labels, and Control Center is a floating panel that draws its own +/// rounded background where a menu draws its own chrome. +enum TBMenuMetrics { + static let width: CGFloat = 300 + static let inset: CGFloat = 14 +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBMenuToggleRowView.swift b/TargetBridge-Sender/TBDisplaySender/TBMenuToggleRowView.swift new file mode 100644 index 00000000..755005ea --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySender/TBMenuToggleRowView.swift @@ -0,0 +1,167 @@ +import AppKit + +/// One circular toggle in the row. +struct TBMenuToggleSpec { + let symbol: String + let title: String + /// Shown under the title, e.g. "On" / "Off" — mirrors Control Center. + let stateText: String + let isOn: Bool + let onToggle: (Bool) -> Void +} + +/// A row of circular on/off buttons laid out like Control Center's display +/// panel: filled accent circle when on, muted circle when off, with the name and +/// current state stacked underneath. +/// +/// Built as a custom menu-item view rather than checkable NSMenuItems because +/// these are display *modes* the user flips repeatedly while looking at the +/// screen, and the Apple layout shows state at a glance without opening a +/// submenu. Menus don't lay out subviews for us, so all geometry is explicit. +final class TBMenuToggleRowView: NSView { + + private var buttons: [NSButton] = [] + private var stateFields: [NSTextField] = [] + private var specs: [TBMenuToggleSpec] = [] + + private enum Metrics { + static let circle: CGFloat = 38 + static let gap: CGFloat = 4 // circle -> title + static let titleHeight: CGFloat = 14 + static let stateHeight: CGFloat = 13 + static let topPadding: CGFloat = 6 + static let bottomPadding: CGFloat = 8 + static var height: CGFloat { + topPadding + circle + gap + titleHeight + stateHeight + bottomPadding + } + } + + static var preferredHeight: CGFloat { Metrics.height } + + init(specs: [TBMenuToggleSpec], width: CGFloat, leadingInset: CGFloat) { + self.specs = specs + super.init(frame: NSRect(x: 0, y: 0, width: width, height: Metrics.height)) + build(width: width, leadingInset: leadingInset) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + private func build(width: CGFloat, leadingInset: CGFloat) { + guard !specs.isEmpty else { return } + + let usable = width - leadingInset * 2 + let cellWidth = usable / CGFloat(specs.count) + + for (index, spec) in specs.enumerated() { + let cellX = leadingInset + cellWidth * CGFloat(index) + let centerX = cellX + cellWidth / 2 + + // Buttons are laid out from the top down; AppKit's origin is bottom + // left, so each y is measured back from the view's height. + let circleY = Metrics.height - Metrics.topPadding - Metrics.circle + + let button = NSButton(frame: NSRect(x: centerX - Metrics.circle / 2, + y: circleY, + width: Metrics.circle, + height: Metrics.circle)) + button.isBordered = false + button.bezelStyle = .regularSquare + button.title = "" + button.wantsLayer = true + button.layer?.cornerRadius = Metrics.circle / 2 + button.imagePosition = .imageOnly + button.setButtonType(.momentaryChange) + + let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium) + button.image = NSImage(systemSymbolName: spec.symbol, + accessibilityDescription: spec.title)? + .withSymbolConfiguration(config) + // A toggle, not a plain button: the checkbox role is what makes + // VoiceOver announce the on/off state, which is otherwise only + // conveyed by the caption underneath. + button.setAccessibilityLabel(spec.title) + button.setAccessibilityRole(.checkBox) + button.setAccessibilityValue(NSNumber(value: spec.isOn)) + + button.tag = index + button.target = self + button.action = #selector(tapped(_:)) + applyStyle(to: button, isOn: spec.isOn) + addSubview(button) + buttons.append(button) + + let titleY = circleY - Metrics.gap - Metrics.titleHeight + // The captions repeat what the button already announces, so keep + // them out of the accessibility tree rather than reading twice. + addSubview(label(spec.title, + frame: NSRect(x: cellX, y: titleY, width: cellWidth, height: Metrics.titleHeight), + font: .systemFont(ofSize: 11, weight: .medium), + color: .labelColor)) + + let stateField = label(spec.stateText, + frame: NSRect(x: cellX, y: titleY - Metrics.stateHeight, + width: cellWidth, height: Metrics.stateHeight), + font: .systemFont(ofSize: 10, weight: .regular), + color: .secondaryLabelColor) + addSubview(stateField) + stateFields.append(stateField) + } + } + + private func label(_ text: String, frame: NSRect, font: NSFont, color: NSColor) -> NSTextField { + let field = NSTextField(labelWithString: text) + field.setAccessibilityElement(false) + field.frame = frame + field.font = font + field.textColor = color + field.alignment = .center + field.lineBreakMode = .byTruncatingTail + return field + } + + private func applyStyle(to button: NSButton, isOn: Bool) { + // Accent fill + white glyph when on; muted fill + normal glyph when off, + // matching Control Center rather than inventing a colour scheme. + button.layer?.backgroundColor = isOn + ? NSColor.controlAccentColor.cgColor + : NSColor.quaternaryLabelColor.withAlphaComponent(0.22).cgColor + button.contentTintColor = isOn ? .white : .labelColor + } + + @objc private func tapped(_ sender: NSButton) { + let index = sender.tag + guard specs.indices.contains(index) else { return } + + let newState = !isOn(index) + states[index] = newState + applyStyle(to: sender, isOn: newState) + sender.setAccessibilityValue(NSNumber(value: newState)) + // Reflect the change immediately; the menu usually closes on click, but + // if it stays open the row should not show a stale state. + if let field = stateField(at: index) { + field.stringValue = newState ? onWord : offWord + } + specs[index].onToggle(newState) + } + + // MARK: - Live state + + private lazy var states: [Int: Bool] = { + var map: [Int: Bool] = [:] + for (i, s) in specs.enumerated() { map[i] = s.isOn } + return map + }() + + private func isOn(_ index: Int) -> Bool { + states[index] ?? specs[index].isOn + } + + /// Words used when a tap updates the state line. Set by the owner so the + /// row stays localised without knowing about the localisation layer. + var onWord = "On" + var offWord = "Off" + + private func stateField(at index: Int) -> NSTextField? { + stateFields.indices.contains(index) ? stateFields[index] : nil + } +} diff --git a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift index 6b15edbd..4e59de86 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBReceiverDiscovery.swift @@ -5,14 +5,52 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { let receiverName: String let preferredIP: String let thunderboltIP: String + let usbIP: String let networkIP: String + let ethernetIP: String + let wifiIP: String + let resolvedIPv4Addresses: [String] let panelSummary: String let version: String let supportsHEVCDecode: Bool let hostName: String? + init( + serviceName: String, + receiverName: String, + preferredIP: String, + thunderboltIP: String, + usbIP: String = "", + networkIP: String, + ethernetIP: String = "", + wifiIP: String = "", + resolvedIPv4Addresses: [String] = [], + panelSummary: String, + version: String, + supportsHEVCDecode: Bool, + hostName: String? + ) { + self.serviceName = serviceName + self.receiverName = receiverName + self.preferredIP = preferredIP + self.thunderboltIP = thunderboltIP + self.usbIP = usbIP + self.networkIP = networkIP + self.ethernetIP = ethernetIP + self.wifiIP = wifiIP + self.resolvedIPv4Addresses = resolvedIPv4Addresses + self.panelSummary = panelSummary + self.version = version + self.supportsHEVCDecode = supportsHEVCDecode + self.hostName = hostName + } + var id: String { "\(serviceName)|\(preferredIP)" } + /// 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 shortHostName: String? { guard let host = hostName, !host.isEmpty else { return nil } let stripped = host.hasSuffix(".") ? String(host.dropLast()) : host @@ -21,26 +59,40 @@ struct TBDiscoveredReceiver: Identifiable, Equatable { return String(first) } - func ip(for transportKind: TBTransportKind) -> String { + func ip(for transportKind: TBTransportKind, localInterfaceIP: String = "") -> String { switch transportKind { case .thunderboltBridge: return !thunderboltIP.isEmpty ? thunderboltIP : preferredIP case .networkLink: + if localInterfaceIP.hasPrefix("169.254."), !usbIP.isEmpty { + return usbIP + } return !networkIP.isEmpty ? networkIP : preferredIP } } var displayText: String { + var addresses: [(label: String, ip: String)] = [] + var seenIPs = Set() + func appendAddress(_ label: String, _ ip: String) { + guard !ip.isEmpty, seenIPs.insert(ip).inserted else { return } + addresses.append((label, ip)) + } + appendAddress("TB", thunderboltIP) + appendAddress("USB", usbIP) + appendAddress("ETH", ethernetIP) + appendAddress("Wi-Fi", wifiIP) + appendAddress("NET", networkIP) + let addressSummary: String - switch (thunderboltIP.isEmpty, networkIP.isEmpty) { - case (false, false): - addressSummary = "TB \(thunderboltIP) · NET \(networkIP)" - case (false, true): - addressSummary = thunderboltIP - case (true, false): - addressSummary = networkIP - case (true, true): + if addresses.isEmpty { addressSummary = preferredIP + } else if addresses.count == 1 { + addressSummary = addresses[0].ip + } else { + addressSummary = addresses + .map { "\($0.label) \($0.ip)" } + .joined(separator: " · ") } let name: String @@ -108,8 +160,18 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { let receiverName = stringValue("name").isEmpty ? service.name : stringValue("name") let receiverIP = stringValue("ip") let thunderboltIP = stringValue("tbIP") + let usbIP = stringValue("usbIP") let networkIP = stringValue("netIP") - let preferredIP = !receiverIP.isEmpty ? receiverIP : (!thunderboltIP.isEmpty ? thunderboltIP : networkIP) + let ethernetIP = stringValue("ethernetIP") + let wifiIP = stringValue("wifiIP") + let resolvedIPv4Addresses = resolvedIPv4Addresses(from: service) + let preferredIP = !receiverIP.isEmpty + ? receiverIP + : (!thunderboltIP.isEmpty + ? thunderboltIP + : (!usbIP.isEmpty + ? usbIP + : (!networkIP.isEmpty ? networkIP : (resolvedIPv4Addresses.first ?? "")))) guard !preferredIP.isEmpty else { return } let panelName = stringValue("panel") @@ -134,14 +196,18 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { receiverName: receiverName, preferredIP: preferredIP, thunderboltIP: thunderboltIP, + usbIP: usbIP, networkIP: networkIP, + ethernetIP: ethernetIP, + wifiIP: wifiIP, + resolvedIPv4Addresses: resolvedIPv4Addresses, panelSummary: panelSummary, version: version, supportsHEVCDecode: supportsHEVCDecode, hostName: service.hostName ) - if let index = receivers.firstIndex(where: { $0.id == receiver.id }) { + if let index = receivers.firstIndex(where: { $0.serviceName == receiver.serviceName }) { receivers[index] = receiver } else { receivers.append(receiver) @@ -154,6 +220,39 @@ final class TBReceiverDiscovery: NSObject, ObservableObject { } } + private func resolvedIPv4Addresses(from service: NetService) -> [String] { + guard let addresses = service.addresses else { return [] } + var result: [String] = [] + var seen = Set() + + for addressData in addresses { + let address: String? = addressData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress, + rawBuffer.count >= MemoryLayout.size + else { return nil } + let socketAddress = baseAddress.assumingMemoryBound(to: sockaddr.self) + guard socketAddress.pointee.sa_family == UInt8(AF_INET) else { return nil } + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let length = socklen_t(min(rawBuffer.count, Int(socketAddress.pointee.sa_len))) + guard getnameinfo( + socketAddress, + length, + &host, + socklen_t(host.count), + nil, + 0, + NI_NUMERICHOST + ) == 0 else { return nil } + return String(cString: host) + } + if let address, seen.insert(address).inserted { + result.append(address) + } + } + + return result.sorted() + } + private func removeService(_ service: NetService) { services.removeValue(forKey: service.name) receivers.removeAll { $0.serviceName == service.name } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBCaptureHealthTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBCaptureHealthTests.swift new file mode 100644 index 00000000..66f6eb18 --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBCaptureHealthTests.swift @@ -0,0 +1,172 @@ +import XCTest +import CoreGraphics +import CoreMedia +import CoreVideo +import ScreenCaptureKit +@testable import TargetBridge + +final class TBCaptureHealthTests: XCTestCase { + func testStartupTimeoutWithoutCallbacks() { + let health = TBCaptureHealth(now: 100) + XCTAssertFalse(health.shouldRestart(now: 107.999)) + XCTAssertTrue(health.shouldRestart(now: 108)) + } + + func testActiveCaptureStillDetectsSilentStall() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 2) + XCTAssertFalse(health.shouldRestart(now: 9.9)) + XCTAssertTrue(health.shouldRestart(now: 10)) + } + + func testSingleIdleEventCanRemainHealthyForHours() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.idle, now: 2) + XCTAssertFalse(health.shouldRestart(now: 10)) + XCTAssertFalse(health.shouldRestart(now: 86_400)) + XCTAssertEqual(health.frameCount, 1) + XCTAssertEqual(health.idleCount, 1) + } + + func testRepeatedIdleDoesNotInflateFrameCount() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + for time in 2...600 { health.observe(.idle, now: Double(time)) } + XCTAssertFalse(health.shouldRestart(now: 900)) + XCTAssertEqual(health.frameCount, 1) + XCTAssertEqual(health.idleCount, 599) + } + + func testIdleCannotMaskMissingFirstFrame() { + var health = TBCaptureHealth(now: 0) + for time in 1...9 { health.observe(.idle, now: Double(time)) } + XCTAssertTrue(health.shouldRestart(now: 10)) + XCTAssertNil(health.lastFrameAt) + } + + func testNewFramesResumeAfterLongIdle() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.idle, now: 2) + health.observe(.active, now: 600) + health.recordFrame(now: 600) + XCTAssertEqual(health.state, .active) + XCTAssertEqual(health.frameCount, 2) + XCTAssertFalse(health.shouldRestart(now: 607.9)) + XCTAssertTrue(health.shouldRestart(now: 608)) + } + + func testResumedStatusGetsGraceButCannotMaskMissingFrames() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.idle, now: 2) + for time in 600...607 { health.observe(.active, now: Double(time)) } + XCTAssertFalse(health.shouldRestart(now: 607.9)) + XCTAssertTrue(health.shouldRestart(now: 608)) + } + + func testBlankDoesNotTriggerWakeLoop() { + var health = TBCaptureHealth(now: 0) + health.observe(.blank, now: 1) + XCTAssertFalse(health.shouldRestart(now: 3600)) + health.observe(.active, now: 3601) + XCTAssertFalse(health.shouldRestart(now: 3608)) + XCTAssertTrue(health.shouldRestart(now: 3609)) + } + + func testSuspendedDoesNotTriggerWakeLoop() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.suspended, now: 2) + XCTAssertFalse(health.shouldRestart(now: 3600)) + health.recordFrame(now: 3601) + XCTAssertFalse(health.shouldRestart(now: 3602)) + } + + func testStoppedAfterIdleRequiresRecovery() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.idle, now: 2) + health.observe(.stopped, now: 3) + XCTAssertTrue(health.shouldRestart(now: 3)) + } + + func testUnknownStatusDoesNotDisableWatchdog() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + health.observe(.unknown, now: 7) + XCTAssertTrue(health.shouldRestart(now: 9)) + } + + func testFreshPipelineDoesNotInheritOldIdleState() { + var old = TBCaptureHealth(now: 0) + old.recordFrame(now: 1) + old.observe(.idle, now: 2) + let fresh = TBCaptureHealth(now: 100) + XCTAssertFalse(old.shouldRestart(now: 200)) + XCTAssertTrue(fresh.shouldRestart(now: 108)) + XCTAssertEqual(fresh.frameCount, 0) + } + + func testRecoveryRecheckCancelsAfterAFrameArrives() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + XCTAssertTrue(health.shouldRestart(now: 10)) + health.recordFrame(now: 10.2) + XCTAssertFalse(health.shouldRestart(now: 10.5)) + } + + func testRecoveryRecheckCancelsAfterIdleNotification() { + var health = TBCaptureHealth(now: 0) + health.recordFrame(now: 1) + XCTAssertTrue(health.shouldRestart(now: 10)) + health.observe(.idle, now: 10.2) + XCTAssertFalse(health.shouldRestart(now: 10.5)) + } + + func testScreenCaptureKitMappingsAndFiltering() { + for (status, expected) in [(SCFrameStatus.complete, TBCaptureHealth.State.active), + (.started, .active), (.idle, .idle), (.blank, .blank), + (.suspended, .suspended), (.stopped, .stopped)] { + XCTAssertEqual(TBCaptureHealth.State(status), expected) + XCTAssertEqual(expected.canContainFrame, expected == .active) + } + XCTAssertTrue(TBCaptureHealth.State.unknown.canContainFrame) + } + + func testDirectDisplayStreamMappingsAndFiltering() { + for (status, expected) in [(CGDisplayStreamFrameStatus.frameComplete, TBCaptureHealth.State.active), + (.frameIdle, .idle), (.frameBlank, .blank), (.stopped, .stopped)] { + XCTAssertEqual(TBCaptureHealth.State(status), expected) + XCTAssertEqual(expected.canContainFrame, expected == .active) + } + } + + func testRealSampleAttachmentParsing() throws { + var pixel: CVPixelBuffer? + XCTAssertEqual(CVPixelBufferCreate(kCFAllocatorDefault, 8, 8, + kCVPixelFormatType_32BGRA, nil, &pixel), kCVReturnSuccess) + var format: CMVideoFormatDescription? + XCTAssertEqual(CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, imageBuffer: try XCTUnwrap(pixel), + formatDescriptionOut: &format), noErr) + var timing = CMSampleTimingInfo(duration: .invalid, + presentationTimeStamp: .zero, decodeTimeStamp: .invalid) + var sample: CMSampleBuffer? + XCTAssertEqual(CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, imageBuffer: try XCTUnwrap(pixel), + formatDescription: try XCTUnwrap(format), sampleTiming: &timing, + sampleBufferOut: &sample), noErr) + let buffer = try XCTUnwrap(sample) + XCTAssertEqual(TBCaptureHealth.State(sampleBuffer: buffer), .unknown) + let array = try XCTUnwrap(CMSampleBufferGetSampleAttachmentsArray(buffer, createIfNecessary: true)) + let dict = unsafeBitCast(CFArrayGetValueAtIndex(array, 0), to: NSMutableDictionary.self) + for status in [SCFrameStatus.complete, .idle, .blank, .started, .suspended, .stopped] { + dict[SCStreamFrameInfo.status] = status.rawValue + XCTAssertEqual(TBCaptureHealth.State(sampleBuffer: buffer), TBCaptureHealth.State(status)) + } + dict[SCStreamFrameInfo.status] = 999 + XCTAssertEqual(TBCaptureHealth.State(sampleBuffer: buffer), .unknown) + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift index b35b2fff..d4adbee9 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBConnectionDiagnosticsTests.swift @@ -11,6 +11,7 @@ final class TBConnectionDiagnosticsTests: XCTestCase { private let interfaces: [Interface] = [ Interface(name: "en0", ip: "192.168.1.225"), Interface(name: "bridge0", ip: "169.254.109.86"), + Interface(name: "en8", ip: "169.254.190.84"), ] // MARK: - interfaceName(forLocalIP:) @@ -32,6 +33,20 @@ final class TBConnectionDiagnosticsTests: XCTestCase { XCTAssertNil(TBConnectionDiagnostics.interfaceName(forLocalIP: "169.254.109.86", in: [])) } + // MARK: - Direct USB-NCM interface classification + + func testDirectLinkInterfaceAcceptsUSBNCMLinkLocalAddress() { + XCTAssertTrue(TBConnectionDiagnostics.isDirectLinkInterface(name: "en8", ip: "169.254.190.84")) + XCTAssertTrue(TBConnectionDiagnostics.isDirectLinkInterface(name: "eth2", ip: "169.254.1.2")) + } + + func testDirectLinkInterfaceRejectsThunderboltLANAndMalformedAddresses() { + XCTAssertFalse(TBConnectionDiagnostics.isDirectLinkInterface(name: "bridge0", ip: "169.254.109.86")) + XCTAssertFalse(TBConnectionDiagnostics.isDirectLinkInterface(name: "en8", ip: "192.168.178.93")) + XCTAssertFalse(TBConnectionDiagnostics.isDirectLinkInterface(name: "en8", ip: "169.254.300.1")) + XCTAssertFalse(TBConnectionDiagnostics.isDirectLinkInterface(name: "en8", ip: "169.254.1")) + } + // MARK: - scopedReceiverHost /// The Thunderbolt Bridge case: both ends self-assign 169.254.x, the @@ -70,6 +85,17 @@ final class TBConnectionDiagnosticsTests: XCTestCase { ) } + func testUSBNCMLinkLocalReceiverIsNotGivenAnIPv4ZoneSuffix() { + XCTAssertEqual( + TBConnectionDiagnostics.scopedReceiverHost( + receiverIP: "169.254.189.3", + localIP: "169.254.190.84", + interfaces: interfaces + ), + "169.254.189.3" + ) + } + func testAlreadyScopedReceiverIsUnchanged() { XCTAssertEqual( TBConnectionDiagnostics.scopedReceiverHost( @@ -119,4 +145,138 @@ final class TBConnectionDiagnosticsTests: XCTestCase { XCTAssertFalse(iface.ip.hasPrefix("127."), "loopback must be excluded, found \(iface.ip) on \(iface.name)") } } + + // MARK: - Automatic path candidates and ranking + + func testPathPreferenceAcceptsUSB4AndUserFacingAliases() { + XCTAssertEqual(TBConnectionPathPreference.parse("auto"), .automatic) + XCTAssertEqual(TBConnectionPathPreference.parse("wired"), .wired) + XCTAssertEqual(TBConnectionPathPreference.parse("Thunderbolt Bridge"), .thunderbolt) + XCTAssertEqual(TBConnectionPathPreference.parse("USB4"), .usb) + XCTAssertEqual(TBConnectionPathPreference.parse("USB-C"), .usb) + XCTAssertEqual(TBConnectionPathPreference.parse("LAN"), .ethernet) + XCTAssertEqual(TBConnectionPathPreference.parse("wireless"), .wifi) + XCTAssertNil(TBConnectionPathPreference.parse("fibre-channel")) + } + + private func candidate( + _ kind: TBConnectionPathKind, + localIP: String, + receiverIP: String + ) -> TBConnectionCandidate { + TBConnectionCandidate( + kind: kind, + localInterfaceName: kind == .thunderbolt ? "bridge0" : "en8", + localIP: localIP, + receiverIP: receiverIP + ) + } + + func testCandidateDiscoverySeparatesThunderboltUSBEthernetAndWiFi() { + let receiver = TBDiscoveredReceiver( + serviceName: "TargetBridge iMac", + receiverName: "iMac", + preferredIP: "192.168.178.101", + thunderboltIP: "169.254.50.32", + usbIP: "169.254.190.85", + networkIP: "192.168.178.101", + ethernetIP: "10.77.77.2", + wifiIP: "192.168.178.101", + resolvedIPv4Addresses: ["169.254.50.32", "169.254.190.85", "10.77.77.2", "192.168.178.101"], + panelSummary: "iMac 4K", + version: "3.2.1", + supportsHEVCDecode: true, + hostName: "iMac.local." + ) + let local = [ + Interface(name: "bridge0", ip: "169.254.50.31"), + Interface(name: "en8", ip: "169.254.190.84"), + Interface(name: "en0", ip: "10.77.77.1"), + Interface(name: "en1", ip: "192.168.178.93"), + ] + let candidates = TBConnectionDiagnostics.connectionCandidates( + receiver: receiver, + interfaces: local, + hardwareKinds: ["en0": .ethernet, "en1": .wifi] + ) + + XCTAssertTrue(candidates.contains(candidate(.thunderbolt, localIP: "169.254.50.31", receiverIP: "169.254.50.32"))) + XCTAssertTrue(candidates.contains(candidate(.usb, localIP: "169.254.190.84", receiverIP: "169.254.190.85"))) + XCTAssertTrue(candidates.contains { + $0.kind == .ethernet && $0.localIP == "10.77.77.1" && $0.receiverIP == "10.77.77.2" + }) + XCTAssertTrue(candidates.contains { + $0.kind == .wifi && $0.localIP == "192.168.178.93" && $0.receiverIP == "192.168.178.101" + }) + } + + func testAutoSelectionLetsGigabitEthernetBeatSlowerUSB() { + let usb = TBConnectionMeasurement( + candidate: candidate(.usb, localIP: "169.254.1.1", receiverIP: "169.254.1.2"), + throughputGbps: 0.48, + connectLatencyMilliseconds: 0.20 + ) + let ethernet = TBConnectionMeasurement( + candidate: candidate(.ethernet, localIP: "10.77.77.1", receiverIP: "10.77.77.2"), + throughputGbps: 0.94, + connectLatencyMilliseconds: 0.35 + ) + XCTAssertEqual( + TBConnectionDiagnostics.selectBestMeasurement([usb, ethernet], preference: .automatic), + ethernet + ) + } + + func testAutoSelectionChoosesMeasuredFastestPhysicalPath() { + let thunderbolt = TBConnectionMeasurement( + candidate: candidate(.thunderbolt, localIP: "169.254.50.31", receiverIP: "169.254.50.32"), + throughputGbps: 4.2, + connectLatencyMilliseconds: 0.16 + ) + let ethernet = TBConnectionMeasurement( + candidate: candidate(.ethernet, localIP: "10.77.77.1", receiverIP: "10.77.77.2"), + throughputGbps: 0.94, + connectLatencyMilliseconds: 0.22 + ) + XCTAssertEqual( + TBConnectionDiagnostics.selectBestMeasurement([ethernet, thunderbolt], preference: .automatic), + thunderbolt + ) + } + + func testManualSelectionFiltersOutFasterOtherPaths() { + let usb = TBConnectionMeasurement( + candidate: candidate(.usb, localIP: "169.254.1.1", receiverIP: "169.254.1.2"), + throughputGbps: 0.42, + connectLatencyMilliseconds: 0.20 + ) + let thunderbolt = TBConnectionMeasurement( + candidate: candidate(.thunderbolt, localIP: "169.254.2.1", receiverIP: "169.254.2.2"), + throughputGbps: 7.0, + connectLatencyMilliseconds: 0.10 + ) + XCTAssertEqual( + TBConnectionDiagnostics.selectBestMeasurement([thunderbolt, usb], preference: .usb), + usb + ) + XCTAssertNil(TBConnectionDiagnostics.selectBestMeasurement([thunderbolt], preference: .wifi)) + } + + func testWiredModeNeverFallsBackToFasterWiFi() { + let ethernet = TBConnectionMeasurement( + candidate: candidate(.ethernet, localIP: "10.77.77.1", receiverIP: "10.77.77.2"), + throughputGbps: 0.94, + connectLatencyMilliseconds: 0.22 + ) + let wifi = TBConnectionMeasurement( + candidate: candidate(.wifi, localIP: "192.168.178.93", receiverIP: "192.168.178.101"), + throughputGbps: 1.20, + connectLatencyMilliseconds: 0.15 + ) + XCTAssertEqual( + TBConnectionDiagnostics.selectBestMeasurement([wifi, ethernet], preference: .wired), + ethernet + ) + XCTAssertNil(TBConnectionDiagnostics.selectBestMeasurement([wifi], preference: .wired)) + } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBDisplayProfileTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBDisplayProfileTests.swift index bba96370..eeebfcf1 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBDisplayProfileTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBDisplayProfileTests.swift @@ -8,7 +8,7 @@ final class TBDisplayProfileTests: XCTestCase { XCTAssertEqual(settings.captureSource, .extendedDesktop) XCTAssertEqual(settings.capturePreset, .native5k) XCTAssertTrue(settings.matchRenderToStream) - XCTAssertFalse(settings.audioEnabled) + XCTAssertNil(settings.audioEnabled) } func testLowLatencyPrioritizesSmoothVideoWithoutAudio() { @@ -17,7 +17,7 @@ final class TBDisplayProfileTests: XCTestCase { XCTAssertEqual(settings.captureSource, .desktopMirror) XCTAssertEqual(settings.capturePreset, .smooth1440p60) XCTAssertFalse(settings.matchRenderToStream) - XCTAssertFalse(settings.audioEnabled) + XCTAssertNil(settings.audioEnabled) } func testPresentationUsesACompatibleMirrorProfileWithAudio() { @@ -25,6 +25,12 @@ final class TBDisplayProfileTests: XCTestCase { XCTAssertEqual(settings.captureSource, .desktopMirror) XCTAssertEqual(settings.capturePreset, .standard1440p) - XCTAssertTrue(settings.audioEnabled) + XCTAssertEqual(settings.audioEnabled, true) + } + + func testAudioPreferenceRepairRestoresOnlyTheAmbiguousDisabledState() { + XCTAssertTrue(TBDisplaySenderService.restoredAudioEnabled(from: false, repairPending: true)) + XCTAssertFalse(TBDisplaySenderService.restoredAudioEnabled(from: false, repairPending: false)) + XCTAssertTrue(TBDisplaySenderService.restoredAudioEnabled(from: true, repairPending: false)) } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBInjectedPointerEventTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBInjectedPointerEventTests.swift new file mode 100644 index 00000000..9ab9b32a --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBInjectedPointerEventTests.swift @@ -0,0 +1,73 @@ +import CoreGraphics +import XCTest +@testable import TargetBridge + +final class TBInjectedPointerEventTests: XCTestCase { + private func mouse(_ type: CGEventType, _ flags: CGEventFlags, + button: CGMouseButton = .left) throws -> CGEvent { + try XCTUnwrap(TBInjectedPointerEvent.mouse(source: CGEventSource(stateID: .hidSystemState), + type: type, position: CGPoint(x: 120, y: 240), button: button, modifiers: flags)) + } + + func testShiftAndCommandReachBothHalvesOfClick() throws { + for flags: CGEventFlags in [.maskShift, .maskCommand, [.maskShift, .maskCommand]] { + for type: CGEventType in [.leftMouseDown, .leftMouseUp] { + let event = try mouse(type, flags) + XCTAssertEqual(event.flags, flags) + XCTAssertEqual(event.type, type) + } + } + } + + func testRepeatedSelectionClicksAndModifierRelease() throws { + // The remote key state stays held across clicks, then becomes empty on key-up. + for flags: CGEventFlags in [.maskCommand, .maskCommand, .maskShift, []] { + XCTAssertEqual(try mouse(.leftMouseDown, flags).flags, flags) + XCTAssertEqual(try mouse(.leftMouseUp, flags).flags, flags) + } + } + + func testAllDragButtonsPreserveModifiers() throws { + let flags: CGEventFlags = [.maskCommand, .maskShift] + for (type, button) in [(CGEventType.leftMouseDragged, CGMouseButton.left), + (.rightMouseDragged, .right), (.otherMouseDragged, .center)] { + let event = try mouse(type, flags, button: button) + XCTAssertEqual(event.flags, flags) + XCTAssertEqual(event.type, type) + XCTAssertEqual(event.location, CGPoint(x: 120, y: 240)) + } + } + + func testPointerMotionIncludingEdgeMovesPreservesModifiers() throws { + let flags: CGEventFlags = [.maskShift, .maskAlternate] + let event = try mouse(.mouseMoved, flags) + event.setIntegerValueField(.mouseEventDeltaX, value: -3) + event.setIntegerValueField(.mouseEventDeltaY, value: 7) + XCTAssertEqual(event.flags, flags) + XCTAssertEqual(event.getIntegerValueField(.mouseEventDeltaX), -3) + XCTAssertEqual(event.getIntegerValueField(.mouseEventDeltaY), 7) + } + + func testRightAndMiddleClicksAndDoubleClickStateStayIntact() throws { + for (type, button) in [(CGEventType.rightMouseDown, CGMouseButton.right), + (.rightMouseUp, .right), (.otherMouseDown, .center), + (.otherMouseUp, .center), (.leftMouseDown, .left)] { + let event = try mouse(type, .maskControl, button: button) + event.setIntegerValueField(.mouseEventClickState, value: 2) + XCTAssertEqual(event.flags, .maskControl) + XCTAssertEqual(event.getIntegerValueField(.mouseEventClickState), 2) + XCTAssertEqual(event.getIntegerValueField(.mouseEventButtonNumber), Int64(button.rawValue)) + } + } + + func testScrollPreservesOnlyRemoteFlagsAndClearsThemAfterRelease() throws { + for flags: CGEventFlags in [.maskAlternate, .maskShift, [.maskShift, .maskCommand], []] { + let event = try XCTUnwrap(TBInjectedPointerEvent.scroll( + source: CGEventSource(stateID: .hidSystemState), x: 2, y: -3, modifiers: flags)) + XCTAssertEqual(event.flags, flags) + XCTAssertFalse(event.flags.contains(.maskSecondaryFn)) + XCTAssertEqual(event.getIntegerValueField(.scrollWheelEventDeltaAxis1), -3) + XCTAssertEqual(event.getIntegerValueField(.scrollWheelEventDeltaAxis2), 2) + } + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBLocalPointerModifiersTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBLocalPointerModifiersTests.swift new file mode 100644 index 00000000..a40239de --- /dev/null +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBLocalPointerModifiersTests.swift @@ -0,0 +1,73 @@ +import CoreGraphics +import XCTest +@testable import TargetBridge + +final class TBLocalPointerModifiersTests: XCTestCase { + private func pointer(_ type: CGEventType = .leftMouseDown, + flags: CGEventFlags = []) throws -> CGEvent { + let event = try XCTUnwrap(CGEvent(mouseEventSource: nil, mouseType: type, + mouseCursorPosition: CGPoint(x: 120, y: 240), mouseButton: .left)) + event.flags = flags + return event + } + + func testRemoteShiftAndCommandReachLocalClicksAndDrags() throws { + for flags: CGEventFlags in [.maskShift, .maskCommand, [.maskShift, .maskCommand]] { + let state = TBLocalPointerModifiers(remoteFlags: flags) + for type in TBLocalPointerModifiers.eventTypes { + let event = try pointer(type) + XCTAssertTrue(state.apply(to: event)) + XCTAssertEqual(event.flags, flags) + XCTAssertEqual(event.type, type) + } + } + } + + func testHeldModifierPersistsAcrossClicksThenReleaseClearsIt() throws { + var state = TBLocalPointerModifiers(remoteFlags: [.maskShift, .maskCommand]) + for _ in 0..<3 { + XCTAssertTrue(state.apply(to: try pointer())) + } + state.remoteFlags = .maskShift + let stillShift = try pointer() + state.apply(to: stillShift) + XCTAssertEqual(stillShift.flags, .maskShift) + state.remoteFlags = [] + let released = try pointer() + XCTAssertFalse(state.apply(to: released)) + XCTAssertEqual(released.flags, []) + } + + func testPreservesPhysicalModifiersPositionAndClickCount() throws { + let event = try pointer(flags: [.maskAlternate, .maskAlphaShift]) + event.setIntegerValueField(.mouseEventClickState, value: 2) + TBLocalPointerModifiers(remoteFlags: .maskShift).apply(to: event) + XCTAssertEqual(event.flags, [.maskAlternate, .maskAlphaShift, .maskShift]) + XCTAssertEqual(event.location, CGPoint(x: 120, y: 240)) + XCTAssertEqual(event.getIntegerValueField(.mouseEventClickState), 2) + } + + func testRemoteFnAndUnsupportedFlagsNeverLeakIntoLocalClicks() throws { + let event = try pointer() + TBLocalPointerModifiers(remoteFlags: [.maskShift, .maskSecondaryFn, .maskAlphaShift, + .maskNumericPad]).apply(to: event) + XCTAssertEqual(event.flags, .maskShift) + } + + func testKeyboardMotionAndScrollAreNotRewritten() throws { + let state = TBLocalPointerModifiers(remoteFlags: [.maskShift, .maskCommand]) + for type: CGEventType in [.keyDown, .keyUp, .flagsChanged, .mouseMoved, .scrollWheel] { + let event = try pointer() + event.type = type + XCTAssertFalse(state.apply(to: event)) + XCTAssertEqual(event.flags, []) + } + } + + func testQueuedRelayedClickKeepsOriginalModifierSnapshot() throws { + let event = try XCTUnwrap(TBInjectedPointerEvent.mouse(source: nil, type: .leftMouseDown, + position: .zero, button: .left, modifiers: .maskShift)) + XCTAssertFalse(TBLocalPointerModifiers(remoteFlags: .maskCommand).apply(to: event)) + XCTAssertEqual(event.flags, .maskShift) + } +} diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift index f2e32046..011d57a3 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -207,6 +207,61 @@ final class TBMonitorProtocolTests: XCTestCase { XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload)?.sequence, 42) } + func testCursorPayloadPreservesLargeCursorPreference() throws { + let cursor = TBMonitorCursor( + x: 120, + y: 80, + width: 2560, + height: 1440, + visible: true, + type: 0, + large: false + ) + guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .cursor, value: cursor), + let (type, payload) = try TBMonitorProtocol.drainPacket(from: &buffer) + else { + XCTFail("cursor did not encode and drain") + return + } + + XCTAssertEqual(type, .cursor) + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorCursor.self, from: payload)?.large, false) + } + + func testInputButtonEventRoundTripPreservesClickCount() throws { + let buttonEvent = TBMonitorInputButtonEvent(kind: "leftDown", clickCount: 2) + guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .inputEvent, value: buttonEvent), + let (type, payload) = try TBMonitorProtocol.drainPacket(from: &buffer) + else { + XCTFail("button event did not encode and drain") + return + } + + XCTAssertEqual(type, .inputEvent) + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload)?.kind, "leftDown") + XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload)?.clickCount, 2) + } + + func testInputButtonEventWithoutClickCountRemainsCompatible() { + let payload = Data(#"{"kind":"leftDown"}"#.utf8) + + let buttonEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputButtonEvent.self, from: payload) + XCTAssertEqual(buttonEvent?.kind, "leftDown") + XCTAssertNil(buttonEvent?.clickCount) + } + + func testGenericInputEventIgnoresButtonOnlyMetadata() { + let payload = Data(#"{"kind":"leftDown","clickCount":2}"#.utf8) + + let genericEvent = TBMonitorProtocol.decodeJSON(TBMonitorInputEvent.self, from: payload) + XCTAssertEqual(genericEvent?.kind, "leftDown") + XCTAssertNil(genericEvent?.dx) + XCTAssertNil(genericEvent?.dy) + XCTAssertNil(genericEvent?.scrollX) + XCTAssertNil(genericEvent?.scrollY) + XCTAssertNil(genericEvent?.keyCode) + } + // MARK: - Hand-rolled input-event encoder parity // // `makeInputEventPacket` documents this invariant: "emits the same JSON shape diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift index 3ed1c843..0e90d79a 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBReceiverDiscoveryModelTests.swift @@ -12,6 +12,7 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { receiverName: String = "Test-iMac", preferredIP: String = "192.168.1.64", thunderboltIP: String = "", + usbIP: String = "", networkIP: String = "", panelSummary: String = "", version: String = "3.1.0", @@ -23,6 +24,7 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { receiverName: receiverName, preferredIP: preferredIP, thunderboltIP: thunderboltIP, + usbIP: usbIP, networkIP: networkIP, panelSummary: panelSummary, version: version, @@ -53,6 +55,30 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { XCTAssertEqual(receiver.ip(for: .networkLink), "169.254.89.80") } + func testNetworkTransportUsesUSBIPForLinkLocalLocalInterface() { + let receiver = makeReceiver( + preferredIP: "169.254.189.3", + usbIP: "169.254.189.3", + networkIP: "192.168.178.101" + ) + XCTAssertEqual( + receiver.ip(for: .networkLink, localInterfaceIP: "169.254.190.84"), + "169.254.189.3" + ) + } + + func testNetworkTransportKeepsLANIPForLANLocalInterface() { + let receiver = makeReceiver( + preferredIP: "169.254.189.3", + usbIP: "169.254.189.3", + networkIP: "192.168.178.101" + ) + XCTAssertEqual( + receiver.ip(for: .networkLink, localInterfaceIP: "192.168.178.93"), + "192.168.178.101" + ) + } + // MARK: - Identity func testIDCombinesServiceNameAndPreferredIP() { @@ -60,6 +86,14 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { XCTAssertEqual(receiver.id, "TargetBridge Jonathans-iMac|192.168.1.64") } + 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") + + XCTAssertNotEqual(beforeWake.id, afterWake.id) + XCTAssertEqual(beforeWake.stableIdentity, afterWake.stableIdentity) + } + // MARK: - shortHostName func testShortHostNameStripsTrailingDotAndDomain() { @@ -77,10 +111,11 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { func testDisplayTextShowsBothTransportsWhenAvailable() { let receiver = makeReceiver( thunderboltIP: "169.254.89.80", + usbIP: "169.254.189.3", networkIP: "192.168.1.64", hostName: "Jonathans-iMac.local." ) - XCTAssertEqual(receiver.displayText, "Jonathans-iMac (TB 169.254.89.80 · NET 192.168.1.64)") + XCTAssertEqual(receiver.displayText, "Jonathans-iMac (TB 169.254.89.80 · USB 169.254.189.3 · NET 192.168.1.64)") } func testDisplayTextSingleTransportOnly() { @@ -97,4 +132,23 @@ final class TBReceiverDiscoveryModelTests: XCTestCase { let receiver = makeReceiver(networkIP: "192.168.1.64", panelSummary: "iMac 5K (5120x2880)") XCTAssertEqual(receiver.displayText, "192.168.1.64 · iMac 5K (5120x2880)") } + + func testDisplayTextDistinguishesEthernetAndWiFiWhenAdvertised() { + let receiver = TBDiscoveredReceiver( + serviceName: "TargetBridge iMac", + receiverName: "iMac", + preferredIP: "192.168.178.101", + thunderboltIP: "", + usbIP: "", + networkIP: "192.168.178.101", + ethernetIP: "10.77.77.2", + wifiIP: "192.168.178.101", + resolvedIPv4Addresses: ["10.77.77.2", "192.168.178.101"], + panelSummary: "", + version: "3.2.1", + supportsHEVCDecode: true, + hostName: "iMac.local." + ) + XCTAssertEqual(receiver.displayText, "iMac (ETH 10.77.77.2 · Wi-Fi 192.168.178.101)") + } } diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index be5cf09e..36d8c9c2 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -1,3 +1,4 @@ +import CoreMedia import XCTest @testable import TargetBridge @@ -7,6 +8,156 @@ import XCTest /// silently reroute automation traffic. @MainActor final class TBSenderAutomationParsingTests: XCTestCase { + func testReceiverControlKeepsNativeCursorWithoutLargeCursor() { + XCTAssertFalse( + TBInputControlRole.receiverMaster.usesLowLatencyCursorOverlay( + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.senderMaster.usesLowLatencyCursorOverlay( + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.off.usesLowLatencyCursorOverlay( + largeCursorEnabled: false + ) + ) + XCTAssertTrue( + TBInputControlRole.off.usesLowLatencyCursorOverlay( + largeCursorEnabled: true + ) + ) + + XCTAssertFalse( + TBInputControlRole.receiverMaster.changesCursorCaptureMode( + from: .off, + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.off.changesCursorCaptureMode( + from: .receiverMaster, + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.senderMaster.changesCursorCaptureMode( + from: .off, + largeCursorEnabled: false + ) + ) + XCTAssertFalse( + TBInputControlRole.receiverMaster.changesCursorCaptureMode( + from: .off, + largeCursorEnabled: true + ) + ) + } + + func testHighFrameRatePresetsUseFiveCaptureSurfaces() { + XCTAssertEqual(TBDisplayCapturePreset.standard1440p.queueDepth, 3) + XCTAssertEqual(TBDisplayCapturePreset.smooth1440p60.queueDepth, 5) + XCTAssertEqual(TBDisplayCapturePreset.retina4k60.queueDepth, 5) + XCTAssertEqual(TBDisplayCapturePreset.native5k60Experimental.queueDepth, 5) + } + + func testHighFrameRatePresetsRequestCaptureHeadroom() { + XCTAssertEqual(TBDisplayCapturePreset.standard1440p.captureRequestFrameRate, 30) + XCTAssertEqual(TBDisplayCapturePreset.smooth1440p60.captureRequestFrameRate, 120) + XCTAssertEqual(TBDisplayCapturePreset.retina4k60.captureRequestFrameRate, 120) + XCTAssertEqual(TBDisplayCapturePreset.native5k.captureRequestFrameRate, 96) + } + + func testFrameRatePacerSamples75HzInputAt60Hz() { + var pacer = TBFrameRatePacer(maximumFrameRate: 60) + let emitted = (0..<750).filter { frame in + pacer.shouldEmit(presentationTime: CMTime(seconds: Double(frame) / 75.0, preferredTimescale: 60_000)) + } + XCTAssertEqual(emitted.count, 600) + } + + func testFrameRatePacerPreservesInputsAtOrBelowCeiling() { + for inputRate in [30, 60] { + var pacer = TBFrameRatePacer(maximumFrameRate: 60) + let emitted = (0..<(inputRate * 10)).filter { frame in + pacer.shouldEmit(presentationTime: CMTime(seconds: Double(frame) / Double(inputRate), preferredTimescale: 60_000)) + } + XCTAssertEqual(emitted.count, inputRate * 10) + } + } + + func testFrameRatePacerDoesNotAccumulateBurstCreditAfterIdleGap() { + var pacer = TBFrameRatePacer(maximumFrameRate: 60) + XCTAssertTrue(pacer.shouldEmit(presentationTime: CMTime(seconds: 0, preferredTimescale: 60_000))) + XCTAssertTrue(pacer.shouldEmit(presentationTime: CMTime(seconds: 1, preferredTimescale: 60_000))) + XCTAssertFalse(pacer.shouldEmit(presentationTime: CMTime(seconds: 1 + 1.0 / 75.0, preferredTimescale: 60_000))) + } + + func testSenderEnabledFlagUsesSelectedHomeDirectory() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("targetbridge-automation-path-test", isDirectory: true) + XCTAssertEqual( + TBSenderAutomation.senderEnabledFlagURL(homeDirectory: root).path, + root.appendingPathComponent( + "Library/Application Support/TargetBridge/Sender/enabled", + isDirectory: false + ).path + ) + } + + func testUserStopRemovesAutomaticReconnectMarker() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("targetbridge-user-stop-\(UUID().uuidString)", isDirectory: true) + let marker = root.appendingPathComponent("enabled") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + XCTAssertTrue(FileManager.default.createFile(atPath: marker.path, contents: Data())) + + TBSenderAutomation.suspendAutomaticReconnectAfterUserStop(enabledFlagURL: marker) + + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + try? FileManager.default.removeItem(at: root) + } + + func testRequiredPermissionRemovesAutomaticReconnectMarker() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("targetbridge-permission-stop-\(UUID().uuidString)", isDirectory: true) + let marker = root.appendingPathComponent("enabled") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + XCTAssertTrue(FileManager.default.createFile(atPath: marker.path, contents: Data())) + + TBSenderAutomation.suspendAutomaticReconnectForRequiredPermission(enabledFlagURL: marker) + + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + try? FileManager.default.removeItem(at: root) + } + + func testCaptureFailureRemovesAutomaticReconnectMarker() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("targetbridge-capture-stop-\(UUID().uuidString)", isDirectory: true) + let marker = root.appendingPathComponent("enabled") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + XCTAssertTrue(FileManager.default.createFile(atPath: marker.path, contents: Data())) + + TBSenderAutomation.suspendAutomaticReconnectAfterCaptureFailure(enabledFlagURL: marker) + + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + try? FileManager.default.removeItem(at: root) + } + + func testAutomationFlagsEnableOnPresenceOrTruthyValues() { + for value in ["", "1", "true", "yes", "on", "unexpected"] { + XCTAssertTrue(TBSenderAutomation.flagEnabled(value), "value \(value)") + } + } + + func testAutomationFlagsDisableOnMissingOrExplicitFalseValues() { + XCTAssertFalse(TBSenderAutomation.flagEnabled(nil)) + for value in ["0", "false", "FALSE", "no", "off", " Off "] { + XCTAssertFalse(TBSenderAutomation.flagEnabled(value), "value \(value)") + } + } // MARK: - parseTransport @@ -55,6 +206,7 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1440p60"), .smooth1440p60) XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1800p60"), .smooth1800p60) XCTAssertEqual(TBSenderAutomation.parsePreset("crisp2160p60"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("retina4k60"), .retina4k60) XCTAssertEqual(TBSenderAutomation.parsePreset("native5k"), .native5k) XCTAssertEqual(TBSenderAutomation.parsePreset("native5k60Experimental"), .native5k60Experimental) } @@ -67,6 +219,10 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertEqual(TBSenderAutomation.parsePreset("1800p"), .smooth1800p60) XCTAssertEqual(TBSenderAutomation.parsePreset("4k"), .crisp2160p60) XCTAssertEqual(TBSenderAutomation.parsePreset("crisp"), .crisp2160p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("retina4k"), .retina4k60) + XCTAssertEqual(TBSenderAutomation.parsePreset("RETINA4K60"), .retina4k60) + XCTAssertEqual(TBSenderAutomation.parsePreset("4096x2304"), .retina4k60) + XCTAssertEqual(TBSenderAutomation.parsePreset("imac4k"), .retina4k60) XCTAssertEqual(TBSenderAutomation.parsePreset("5k60"), .native5k60Experimental) XCTAssertEqual(TBSenderAutomation.parsePreset("native5k60"), .native5k60Experimental) XCTAssertEqual(TBSenderAutomation.parsePreset("5k"), .native5k) @@ -93,6 +249,18 @@ final class TBSenderAutomationParsingTests: XCTestCase { XCTAssertEqual(preset.averageBitRate, 150_000_000) } + func testRetina4KPresetMatches215InchIMacPanel() { + let preset = TBDisplayCapturePreset.retina4k60 + + XCTAssertEqual(preset.width, 4096) + XCTAssertEqual(preset.height, 2304) + XCTAssertEqual(preset.expectedFrameRate, 60) + XCTAssertEqual(preset.virtualDisplayRefreshRate, 60) + XCTAssertEqual(preset.averageBitRate, 120_000_000) + XCTAssertEqual(preset.codecName, "HEVC") + XCTAssertEqual(preset.renderMatchedDesktopDescription, "2048 × 1152") + } + // MARK: - matches (receiver selection for --receiver ) private func makeReceiver() -> TBDiscoveredReceiver { @@ -101,7 +269,11 @@ final class TBSenderAutomationParsingTests: XCTestCase { receiverName: "Jonathans-iMac", preferredIP: "192.168.1.64", thunderboltIP: "169.254.89.80", + usbIP: "169.254.189.3", networkIP: "192.168.1.64", + ethernetIP: "10.77.77.2", + wifiIP: "192.168.1.64", + resolvedIPv4Addresses: ["172.20.10.2"], panelSummary: "iMac 5K", version: "3.1.0", supportsHEVCDecode: true, @@ -121,6 +293,9 @@ final class TBSenderAutomationParsingTests: XCTestCase { func testMatchesByAnyAdvertisedIP() { XCTAssertTrue(TBSenderAutomation.matches("192.168.1.64", makeReceiver()), "preferred/network IP") XCTAssertTrue(TBSenderAutomation.matches("169.254.89.80", makeReceiver()), "thunderbolt IP") + XCTAssertTrue(TBSenderAutomation.matches("169.254.189.3", makeReceiver()), "direct USB IP") + XCTAssertTrue(TBSenderAutomation.matches("10.77.77.2", makeReceiver()), "Ethernet IP") + XCTAssertTrue(TBSenderAutomation.matches("172.20.10.2", makeReceiver()), "resolved Bonjour IP") } func testMatchesByID() { diff --git a/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift b/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift index dd9f0265..0cc279ab 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBConnectionDiagnostics.swift @@ -1,6 +1,98 @@ import Foundation +import Darwin import Network import os +import SystemConfiguration + +enum TBConnectionPathPreference: String, CaseIterable { + case automatic + case wired + case thunderbolt + case usb + case ethernet + case wifi + + static func parse(_ value: String?) -> TBConnectionPathPreference? { + guard let value else { return nil } + switch value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") { + case "auto", "automatic", "automatico", "best", "alwaysavailable", "sempredisponibile": return .automatic + case "wired", "cable", "cabled", "cableonly", "solocavo", "onlycable": return .wired + case "tb", "thunderbolt", "thunderboltbridge", "bridge": return .thunderbolt + case "usb", "usb4", "usbc", "directusb": return .usb + case "ethernet", "eth", "lan": return .ethernet + case "wifi", "wireless", "wlan": return .wifi + default: return nil + } + } + + func allows(_ kind: TBConnectionPathKind) -> Bool { + switch self { + case .automatic: + return true + case .wired: + return kind != .wifi + case .thunderbolt, .usb, .ethernet, .wifi: + return kind.rawValue == rawValue + } + } +} + +enum TBConnectionPathKind: String, CaseIterable { + case thunderbolt + case usb + case ethernet + case wifi + + var transportKind: TBTransportKind { + self == .thunderbolt ? .thunderboltBridge : .networkLink + } + + fileprivate var tieBreakPriority: Int { + switch self { + case .thunderbolt: return 4 + case .usb: return 3 + case .ethernet: return 2 + case .wifi: return 1 + } + } +} + +struct TBConnectionCandidate: Hashable { + let kind: TBConnectionPathKind + let localInterfaceName: String + let localIP: String + let receiverIP: String + + var transportKind: TBTransportKind { kind.transportKind } + var id: String { "\(kind.rawValue)|\(localInterfaceName)|\(localIP)|\(receiverIP)" } +} + +struct TBConnectionMeasurement: Equatable { + let candidate: TBConnectionCandidate + let throughputGbps: Double + let connectLatencyMilliseconds: Double +} + +enum TBConnectionProbeError: LocalizedError { + case invalidAddress(String) + case interfaceUnavailable(String) + case socketFailure(String) + case timeout + + var errorDescription: String? { + switch self { + case .invalidAddress(let value): return "Invalid IPv4 address: \(value)" + case .interfaceUnavailable(let value): return "Interface unavailable: \(value)" + case .socketFailure(let value): return value + case .timeout: return "Connection-path probe timed out" + } + } +} /// Unified-logging entry points for the sender. `log stream --predicate /// 'subsystem == "com.targetbridge.sender"'` (or Console.app) shows the @@ -32,9 +124,338 @@ enum TBConnectionDiagnostics { return interfaces.first(where: { $0.ip == localIP })?.name } - /// For a link-local (`169.254.x`) receiver address, returns - /// `"%"` so the dial is scoped to the interface that owns - /// `localIP`. Everything else is returned unchanged. + /// Direct Mac-to-Mac USB connections are exposed by macOS as USB-NCM + /// Ethernet interfaces (normally enX) with an IPv4 link-local address. + /// Keep bridgeX reserved for the Thunderbolt Bridge transport. + static func isDirectLinkInterface(name: String, ip: String) -> Bool { + guard name.hasPrefix("en") || name.hasPrefix("eth") else { return false } + let octets = ip.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4, + let first = Int(octets[0]), + let second = Int(octets[1]), + octets.dropFirst(2).allSatisfy({ component in + guard let value = Int(component) else { return false } + return (0...255).contains(value) + }) + else { + return false + } + return first == 169 && second == 254 + } + + static func isIPv4LinkLocal(_ ip: String) -> Bool { + let octets = ipv4Octets(ip) + return octets?.count == 4 && octets?[0] == 169 && octets?[1] == 254 + } + + static func isPrivateIPv4(_ ip: String) -> Bool { + guard let octets = ipv4Octets(ip) else { return false } + return octets[0] == 10 + || (octets[0] == 172 && (16...31).contains(octets[1])) + || (octets[0] == 192 && octets[1] == 168) + } + + private static func ipv4Octets(_ ip: String) -> [Int]? { + let components = ip.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 4 else { return nil } + let octets = components.compactMap { Int($0) } + guard octets.count == 4, octets.allSatisfy({ (0...255).contains($0) }) else { return nil } + return octets + } + + /// Maps BSD interface names to their physical role using SystemConfiguration. + /// This avoids assuming that Wi-Fi is always en0/en1, which changes between Macs. + static func hardwarePathKinds() -> [String: TBConnectionPathKind] { + guard let all = SCNetworkInterfaceCopyAll() as? [SCNetworkInterface] else { return [:] } + var result: [String: TBConnectionPathKind] = [:] + for interface in all { + guard let bsdNameValue = SCNetworkInterfaceGetBSDName(interface), + let type = SCNetworkInterfaceGetInterfaceType(interface) + else { continue } + let bsdName = bsdNameValue as String + if CFEqual(type, kSCNetworkInterfaceTypeIEEE80211) { + result[bsdName] = .wifi + } else if CFEqual(type, kSCNetworkInterfaceTypeEthernet) { + result[bsdName] = .ethernet + } + } + return result + } + + static func pathKind( + for interface: LocalInterface, + hardwareKinds: [String: TBConnectionPathKind] + ) -> TBConnectionPathKind? { + if interface.name.hasPrefix("bridge"), isIPv4LinkLocal(interface.ip) { + return .thunderbolt + } + if isDirectLinkInterface(name: interface.name, ip: interface.ip) { + return .usb + } + if hardwareKinds[interface.name] == .wifi, isPrivateIPv4(interface.ip) { + return .wifi + } + if hardwareKinds[interface.name] == .ethernet, isPrivateIPv4(interface.ip) { + return .ethernet + } + // Some third-party USB Ethernet adapters are absent from the SC inventory. + if (interface.name.hasPrefix("en") || interface.name.hasPrefix("eth")), isPrivateIPv4(interface.ip) { + return .ethernet + } + return nil + } + + /// Builds every plausible local/remote pair. Real probing decides which pair + /// is alive; a cable type is never presumed to be faster merely from its name. + static func connectionCandidates( + receiver: TBDiscoveredReceiver, + interfaces: [LocalInterface], + hardwareKinds: [String: TBConnectionPathKind] + ) -> [TBConnectionCandidate] { + let allReceiverIPs = uniqueIPv4Addresses([ + receiver.thunderboltIP, + receiver.usbIP, + receiver.ethernetIP, + receiver.wifiIP, + receiver.networkIP, + receiver.preferredIP, + ] + receiver.resolvedIPv4Addresses) + + var candidates: [TBConnectionCandidate] = [] + var seen = Set() + for interface in interfaces { + guard let kind = pathKind(for: interface, hardwareKinds: hardwareKinds) else { continue } + let explicitIP: String + switch kind { + case .thunderbolt: explicitIP = receiver.thunderboltIP + case .usb: explicitIP = receiver.usbIP + case .ethernet: explicitIP = receiver.ethernetIP + case .wifi: explicitIP = receiver.wifiIP + } + + let addressPool: [String] + if kind == .thunderbolt || kind == .usb { + addressPool = uniqueIPv4Addresses([explicitIP] + allReceiverIPs.filter(isIPv4LinkLocal)) + } else { + let privateAddresses = allReceiverIPs.filter(isPrivateIPv4) + let sameSubnet = privateAddresses.filter { likelySameIPv4Subnet(interface.ip, $0) } + // Prefer a same-/24 endpoint. If Bonjour only exposes a different + // subnet, retain it as a probe candidate so routed LANs still work. + addressPool = uniqueIPv4Addresses( + [explicitIP] + (sameSubnet.isEmpty ? privateAddresses : sameSubnet) + ) + } + + for receiverIP in addressPool where receiverIP != interface.ip { + let candidate = TBConnectionCandidate( + kind: kind, + localInterfaceName: interface.name, + localIP: interface.ip, + receiverIP: receiverIP + ) + if seen.insert(candidate.id).inserted { + candidates.append(candidate) + } + } + } + + return candidates.sorted { + if $0.kind.tieBreakPriority != $1.kind.tieBreakPriority { + return $0.kind.tieBreakPriority > $1.kind.tieBreakPriority + } + if $0.localInterfaceName != $1.localInterfaceName { + return $0.localInterfaceName < $1.localInterfaceName + } + return $0.receiverIP < $1.receiverIP + } + } + + static func selectBestMeasurement( + _ measurements: [TBConnectionMeasurement], + preference: TBConnectionPathPreference + ) -> TBConnectionMeasurement? { + let eligible: [TBConnectionMeasurement] + eligible = measurements.filter { preference.allows($0.candidate.kind) } + guard let fastest = eligible.max(by: { $0.throughputGbps < $1.throughputGbps }) else { return nil } + + // Results within 10% are effectively tied for a short startup probe. + // In that narrow band prefer lower latency, then the more direct medium. + let competitive = eligible.filter { + $0.throughputGbps >= fastest.throughputGbps * 0.90 + } + return competitive.sorted { + let latencyDifference = abs($0.connectLatencyMilliseconds - $1.connectLatencyMilliseconds) + if latencyDifference > 0.25 { + return $0.connectLatencyMilliseconds < $1.connectLatencyMilliseconds + } + if $0.candidate.kind.tieBreakPriority != $1.candidate.kind.tieBreakPriority { + return $0.candidate.kind.tieBreakPriority > $1.candidate.kind.tieBreakPriority + } + return $0.throughputGbps > $1.throughputGbps + }.first + } + + /// Sends a bounded stream of protocol-valid TEST_DATA packets through one + /// explicitly bound interface. The receiver already discards this packet + /// type, so the probe measures the real path without starting video capture. + static func probe( + _ candidate: TBConnectionCandidate, + port: UInt16 = TBMonitorProtocol.port, + payloadBytes: Int = 16 * 1024 * 1024, + timeout: TimeInterval = 3.0 + ) throws -> TBConnectionMeasurement { + let fd = Darwin.socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) + guard fd >= 0 else { throw socketError("socket") } + defer { Darwin.close(fd) } + + var noSignal: Int32 = 1 + guard setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size(ofValue: noSignal))) == 0 else { + throw socketError("setsockopt(SO_NOSIGPIPE)") + } + + let interfaceIndex = if_nametoindex(candidate.localInterfaceName) + guard interfaceIndex != 0 else { throw TBConnectionProbeError.interfaceUnavailable(candidate.localInterfaceName) } + var boundIndex = interfaceIndex + guard setsockopt(fd, IPPROTO_IP, IP_BOUND_IF, &boundIndex, socklen_t(MemoryLayout.size(ofValue: boundIndex))) == 0 else { + throw socketError("setsockopt(IP_BOUND_IF)") + } + + var localAddress = try socketAddress(ip: candidate.localIP, port: 0) + let bindResult = withUnsafePointer(to: &localAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { throw socketError("bind") } + + let originalFlags = fcntl(fd, F_GETFL, 0) + guard originalFlags >= 0, fcntl(fd, F_SETFL, originalFlags | O_NONBLOCK) == 0 else { + throw socketError("fcntl") + } + + var remoteAddress = try socketAddress(ip: candidate.receiverIP, port: port) + let connectStarted = DispatchTime.now().uptimeNanoseconds + let connectResult = withUnsafePointer(to: &remoteAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + if connectResult != 0 { + guard errno == EINPROGRESS else { throw socketError("connect") } + try waitForSocket(fd, events: Int16(POLLOUT), timeout: timeout) + var socketStatus: Int32 = 0 + var socketStatusLength = socklen_t(MemoryLayout.size(ofValue: socketStatus)) + guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketStatus, &socketStatusLength) == 0 else { + throw socketError("getsockopt(SO_ERROR)") + } + guard socketStatus == 0 else { + throw TBConnectionProbeError.socketFailure("connect: \(String(cString: strerror(socketStatus)))") + } + } + let connectedAt = DispatchTime.now().uptimeNanoseconds + + let chunkBytes = min(256 * 1024, max(1, payloadBytes)) + let payload = Data(repeating: 0xA5, count: chunkBytes) + let fullPacket = TBMonitorProtocol.makePacket(type: .testData, payload: payload) + let sendStarted = DispatchTime.now().uptimeNanoseconds + let deadline = sendStarted + UInt64(timeout * 1_000_000_000) + var sentPayloadBytes = 0 + + while sentPayloadBytes < payloadBytes { + let remaining = payloadBytes - sentPayloadBytes + let packet = remaining >= chunkBytes + ? fullPacket + : TBMonitorProtocol.makePacket(type: .testData, payload: Data(repeating: 0xA5, count: remaining)) + try sendAll(packet, socket: fd, deadline: deadline) + sentPayloadBytes += min(chunkBytes, remaining) + } + let finishedAt = DispatchTime.now().uptimeNanoseconds + let sendSeconds = max(Double(finishedAt - sendStarted) / 1_000_000_000.0, 0.000_001) + let throughput = (Double(sentPayloadBytes) * 8.0) / 1_000_000_000.0 / sendSeconds + let latency = Double(connectedAt - connectStarted) / 1_000_000.0 + return TBConnectionMeasurement( + candidate: candidate, + throughputGbps: throughput, + connectLatencyMilliseconds: latency + ) + } + + private static func uniqueIPv4Addresses(_ values: [String]) -> [String] { + var result: [String] = [] + var seen = Set() + for value in values where ipv4Octets(value) != nil && seen.insert(value).inserted { + result.append(value) + } + return result + } + + private static func likelySameIPv4Subnet(_ lhs: String, _ rhs: String) -> Bool { + guard let left = ipv4Octets(lhs), let right = ipv4Octets(rhs) else { return false } + return left[0] == right[0] && left[1] == right[1] && left[2] == right[2] + } + + private static func socketAddress(ip: String, port: UInt16) throws -> sockaddr_in { + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + guard ip.withCString({ inet_pton(AF_INET, $0, &address.sin_addr) }) == 1 else { + throw TBConnectionProbeError.invalidAddress(ip) + } + return address + } + + private static func waitForSocket(_ fd: Int32, events: Int16, timeout: TimeInterval) throws { + var descriptor = pollfd(fd: fd, events: events, revents: 0) + let milliseconds = Int32(max(1, min(timeout * 1000.0, Double(Int32.max)))) + while true { + let result = Darwin.poll(&descriptor, 1, milliseconds) + if result > 0 { + guard (descriptor.revents & (Int16(POLLERR) | Int16(POLLHUP) | Int16(POLLNVAL))) == 0 else { + throw TBConnectionProbeError.socketFailure("socket became unavailable") + } + return + } + if result == 0 { throw TBConnectionProbeError.timeout } + if errno != EINTR { throw socketError("poll") } + } + } + + private static func sendAll(_ data: Data, socket fd: Int32, deadline: UInt64) throws { + try data.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { return } + var offset = 0 + while offset < buffer.count { + let now = DispatchTime.now().uptimeNanoseconds + if now >= deadline { + throw TBConnectionProbeError.timeout + } + let sent = Darwin.send(fd, baseAddress.advanced(by: offset), buffer.count - offset, 0) + if sent > 0 { + offset += sent + } else if sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { + let waitStarted = DispatchTime.now().uptimeNanoseconds + guard waitStarted < deadline else { throw TBConnectionProbeError.timeout } + let remainingSeconds = Double(deadline - waitStarted) / 1_000_000_000.0 + try waitForSocket(fd, events: Int16(POLLOUT), timeout: max(0.001, remainingSeconds)) + } else if sent < 0 && errno == EINTR { + continue + } else { + throw socketError("send") + } + } + } + } + + private static func socketError(_ operation: String) -> TBConnectionProbeError { + TBConnectionProbeError.socketFailure("\(operation): \(String(cString: strerror(errno)))") + } + + /// For a link-local (`169.254.x`) receiver reached through bridgeX, + /// returns `"%"` so the Thunderbolt dial is scoped to the + /// interface that owns `localIP`. Direct USB-NCM enX links remain + /// unscoped: macOS installs a host route for the USB peer and Network.framework + /// does not reliably accept an IPv4 `%enX` zone suffix. /// /// Why: macOS keeps a single routing-table entry for all of /// 169.254.0.0/16, pointing at the primary interface (usually Wi-Fi). A @@ -49,6 +470,7 @@ enum TBConnectionDiagnostics { ) -> String { guard receiverIP.hasPrefix("169.254."), !receiverIP.contains("%") else { return receiverIP } guard let name = interfaceName(forLocalIP: localIP, in: interfaces) else { return receiverIP } + guard name.hasPrefix("bridge") else { return receiverIP } return "\(receiverIP)%\(name)" } diff --git a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift index fb3ff858..78dce26c 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift @@ -17,6 +17,8 @@ enum TBMonitorPacketType: UInt8 { case brightness = 0x35 case clipboard = 0x36 case volume = 0x37 + /// Night Shift / True Tone on the receiver's panel. + case displayTweaks = 0x38 case testData = 0x40 } @@ -44,6 +46,9 @@ struct TBMonitorDisplayProfile: Codable { var supportsRawNV12: Bool? var inputMonitoringTrusted: Bool? var accessibilityTrusted: Bool? + /// Optional so older receivers still decode; absent means "cannot". + var supportsNightShift: Bool? + var supportsTrueTone: Bool? } struct TBMonitorCreateSessionAck: Codable { @@ -71,6 +76,7 @@ struct TBMonitorCursor: Codable { var height: Int var visible: Bool var type: Int + var large: Bool } struct TBMonitorInputEvent: Codable { @@ -82,6 +88,11 @@ struct TBMonitorInputEvent: Codable { var keyCode: UInt16? } +struct TBMonitorInputButtonEvent: Codable { + var kind: String + var clickCount: Int? +} + struct TBMonitorInputControlMode: Codable { var mode: String } @@ -94,6 +105,11 @@ struct TBMonitorVolume: Codable { var level: Double } +struct TBMonitorDisplayTweaks: Codable { + var nightShift: Bool + var trueTone: Bool +} + struct TBMonitorClipboard: Codable { var text: String } diff --git a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj index 5e28fcb1..5f896ae7 100644 --- a/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj +++ b/TargetBridge-Sender/TargetBridge.xcodeproj/project.pbxproj @@ -13,13 +13,16 @@ 15D142162EDD10261447EC43 /* TBInputBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37515A84B71F4B19C8EB65B4 /* TBInputBindingTests.swift */; }; 198725EB3B2C7662DBB0B8D0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1819A55F8D993D9347F0E407 /* Assets.xcassets */; }; 233BAEE5ABED3F316F9D3D12 /* input-dockstation.json in Resources */ = {isa = PBXBuildFile; fileRef = A51B69D318A85FF3061D6ED4 /* input-dockstation.json */; }; + 24481F31E6A64909C94CEB56 /* TBLocalPointerModifierBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 051ACEA420DA015B4F108362 /* TBLocalPointerModifierBridge.swift */; }; 2819CE06D15CA7EDD1069062 /* TBConfigurationDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F0782C970AA23871AE73A6 /* TBConfigurationDiagnostics.swift */; }; 2C4E1C76CA81F640C700F6BF /* TBMonitorProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */; }; 337BD561622AA19F43D3B95B /* ReceiverBackedVirtualDisplaySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */; }; 4290B6572B8CA96E01C56425 /* TBDisplaySenderBuildInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB4DF6C72B5C50C29AE6E45 /* TBDisplaySenderBuildInfo.swift */; }; 4A8719A8C13A6560F4F0EB0D /* TBSenderAutomationParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */; }; 4B770D1232C53EF66F011F7F /* TBAddonManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */; }; + 4C42F1437E28DD5FA01998CF /* TBLocalPointerModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFC1E2E64342FDE66C3AAC8 /* TBLocalPointerModifiers.swift */; }; 4C59B780CEA3CC5F563BC86F /* TBInjectedClickStateTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4134F78ABD695DF9DB5D485B /* TBInjectedClickStateTracker.swift */; }; + 50C6F9D35F964ABF03193C5B /* TBCaptureHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8E93993FCAE2FCBA71C45B /* TBCaptureHealth.swift */; }; 5109DFCF1BFD76222E7B4BDD /* TBDisplayProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D419C88EEEA66EE0829BA975 /* TBDisplayProfileTests.swift */; }; 55163F49604063EA7E86D079 /* TBConnectionDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6EB41D253E67E350F5E25D /* TBConnectionDiagnostics.swift */; }; 58980FE95A48D8F8EDB42055 /* TBVirtualDisplayModeMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */; }; @@ -27,18 +30,24 @@ 5C0CB53005B6D67362F14719 /* TBDisplaySenderContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186812C77BFF98DBE0118100 /* TBDisplaySenderContentView.swift */; }; 69C27FB23C2EF4078DD3D2F5 /* TBReceiverDiscoveryModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */; }; 6A54029FE56DEECA996A07B4 /* TBDisplaySenderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */; }; + 6DB5F29F60382A0CA97568F3 /* TBMenuMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08C140ECFEDAE11AD68D12B /* TBMenuMetrics.swift */; }; + 6FBBB12A4345F23FAD561DC9 /* TBInjectedPointerEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FECDAFCED370C001F6BBB7 /* TBInjectedPointerEventTests.swift */; }; 7528751850893B52660870E4 /* TBConfigurationDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7086193EE5710E2C37F86F1 /* TBConfigurationDiagnosticsTests.swift */; }; + 762884127C8093D3E9797790 /* TBMenuToggleRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */; }; 7F103818097EEBD191C5ADBF /* TBConnectionDiagnosticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */; }; 7F1AE9053DA59169FB761FAA /* de.json in Resources */ = {isa = PBXBuildFile; fileRef = C3658E86F5BE2168F4591491 /* de.json */; }; 7FB34AA1E75B01E7D1EC3E21 /* it.json in Resources */ = {isa = PBXBuildFile; fileRef = 036E14097FA59989FFC456FD /* it.json */; }; + 83FB3853596BEDEFAC5E129A /* TBCaptureHealthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11B4EB862393E5CE95426D4 /* TBCaptureHealthTests.swift */; }; 8A729C68ECAC4642FA20A284 /* TBLocalizationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C932E960346E674A0659624 /* TBLocalizationStore.swift */; }; 8D544352D7E2E226D551E46E /* TBInputBindingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */; }; 8D9D2A40A1CD90BD4D4DFC2C /* TBDisplaySenderAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */; }; + 969232A8C441AEFC4A09459C /* TBLocalPointerModifiersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AD26344132A9583BF34ED8 /* TBLocalPointerModifiersTests.swift */; }; 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 */; }; 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 */; }; BBFD76A352FEDC54029B2158 /* TBDisplaySenderLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */; }; BF146F68BB365CFE5AA9A1BD /* check_localizations.sh in Resources */ = {isa = PBXBuildFile; fileRef = 10E53D6EF9DCC519EEBFA5A2 /* check_localizations.sh */; }; C1A77120267A12D9E355DCEA /* network-link.json in Resources */ = {isa = PBXBuildFile; fileRef = 6579B499CBC11196966318EA /* network-link.json */; }; @@ -65,10 +74,14 @@ /* Begin PBXFileReference section */ 036E14097FA59989FFC456FD /* it.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = it.json; sourceTree = ""; }; + 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuToggleRowView.swift; sourceTree = ""; }; + 051ACEA420DA015B4F108362 /* TBLocalPointerModifierBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalPointerModifierBridge.swift; sourceTree = ""; }; 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscovery.swift; sourceTree = ""; }; 06D011FF8AF7BB6D26EDC25B /* TBAddonStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonStore.swift; sourceTree = ""; }; + 0AFC1E2E64342FDE66C3AAC8 /* TBLocalPointerModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalPointerModifiers.swift; sourceTree = ""; }; 0B483A658326FE85720BCBE2 /* TBDisplaySenderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderService.swift; sourceTree = ""; }; 10E53D6EF9DCC519EEBFA5A2 /* check_localizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = check_localizations.sh; sourceTree = ""; }; + 10EC09EEBA6EA9B2EA6ACD1F /* TBInjectedPointerEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedPointerEvent.swift; sourceTree = ""; }; 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBReceiverDiscoveryModelTests.swift; sourceTree = ""; }; 17FD38CAA4E2CF350CC210D2 /* TBDisplaySender.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TBDisplaySender.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1819A55F8D993D9347F0E407 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -76,12 +89,14 @@ 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputBindingsView.swift; sourceTree = ""; }; 374B5A5AA247C6922BD9AF72 /* TBDisplaySenderAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderAboutView.swift; sourceTree = ""; }; 37515A84B71F4B19C8EB65B4 /* TBInputBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputBindingTests.swift; sourceTree = ""; }; + 3E8E93993FCAE2FCBA71C45B /* TBCaptureHealth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCaptureHealth.swift; sourceTree = ""; }; 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConnectionDiagnosticsTests.swift; sourceTree = ""; }; 4134F78ABD695DF9DB5D485B /* TBInjectedClickStateTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedClickStateTracker.swift; sourceTree = ""; }; 4644CFB05098C895378B648A /* TBDisplaySenderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderManager.swift; sourceTree = ""; }; 4A72C42968FA2A6B2A0515B9 /* TBInputBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputBinding.swift; sourceTree = ""; }; 4C932E960346E674A0659624 /* TBLocalizationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalizationStore.swift; sourceTree = ""; }; 4E4C95A1820A99E5DAED17DE /* TBDisplayProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplayProfile.swift; sourceTree = ""; }; + 56AD26344132A9583BF34ED8 /* TBLocalPointerModifiersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBLocalPointerModifiersTests.swift; sourceTree = ""; }; 59E4D00FB492A41B5218CD52 /* TBAddonManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBAddonManifest.swift; sourceTree = ""; }; 6579B499CBC11196966318EA /* network-link.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "network-link.json"; sourceTree = ""; }; 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBVirtualDisplayModeMemory.swift; sourceTree = ""; }; @@ -89,6 +104,7 @@ 73EDC0FF3A4EA4DC1ED63528 /* fr.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fr.json; sourceTree = ""; }; 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputRelayController.swift; sourceTree = ""; }; 80543A7A5A3B583C1543ABC7 /* TBInputDebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInputDebugLog.swift; sourceTree = ""; }; + 99FECDAFCED370C001F6BBB7 /* TBInjectedPointerEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedPointerEventTests.swift; sourceTree = ""; }; 9CEA5EB707F01E1708DA9170 /* TBDisplaySenderAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderAutomation.swift; sourceTree = ""; }; 9D063CAADA68CC48C54157FB /* TBDisplaySenderLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBDisplaySenderLocalization.swift; sourceTree = ""; }; A3964153C9F34085F90C2C26 /* audio-relay.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "audio-relay.json"; sourceTree = ""; }; @@ -96,6 +112,7 @@ 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 = ""; }; 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 = ""; }; C3658E86F5BE2168F4591491 /* de.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = de.json; sourceTree = ""; }; C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMonitorProtocolTests.swift; sourceTree = ""; }; @@ -109,6 +126,7 @@ DFB8219C144AA88D16BB4F59 /* TBDisplaySenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TBDisplaySenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBSenderAutomationParsingTests.swift; sourceTree = ""; }; EF17070AC1C6F9E9F3B4DFDF /* TBInjectedClickStateTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBInjectedClickStateTrackerTests.swift; sourceTree = ""; }; + F08C140ECFEDAE11AD68D12B /* TBMenuMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBMenuMetrics.swift; sourceTree = ""; }; FD6EB41D253E67E350F5E25D /* TBConnectionDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBConnectionDiagnostics.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -127,6 +145,7 @@ isa = PBXGroup; children = ( C242540F9DE94E1364F106F4 /* ReceiverBackedVirtualDisplaySession.swift */, + 3E8E93993FCAE2FCBA71C45B /* TBCaptureHealth.swift */, A3F0782C970AA23871AE73A6 /* TBConfigurationDiagnostics.swift */, 4E4C95A1820A99E5DAED17DE /* TBDisplayProfile.swift */, 374B5A5AA247C6922BD9AF72 /* TBDisplaySenderAboutView.swift */, @@ -141,9 +160,14 @@ 6AC1AF99431D4889F947CE1B /* TBDisplaySenderStatusItemController.swift */, DD33D06A2FA6BA7E075A9E46 /* TBDisplaySenderSurfaceViews.swift */, 4134F78ABD695DF9DB5D485B /* TBInjectedClickStateTracker.swift */, + 10EC09EEBA6EA9B2EA6ACD1F /* TBInjectedPointerEvent.swift */, 4A72C42968FA2A6B2A0515B9 /* TBInputBinding.swift */, 20FE01BF2EA2F329442890AD /* TBInputBindingsView.swift */, 7E3DC447E3E8E522565E725B /* TBInputRelayController.swift */, + 051ACEA420DA015B4F108362 /* TBLocalPointerModifierBridge.swift */, + 0AFC1E2E64342FDE66C3AAC8 /* TBLocalPointerModifiers.swift */, + F08C140ECFEDAE11AD68D12B /* TBMenuMetrics.swift */, + 040912357013932AE29CFD6A /* TBMenuToggleRowView.swift */, 06BABA1447D1E8DD50A6C2F0 /* TBReceiverDiscovery.swift */, 6630386DB9C3D210849E6211 /* TBVirtualDisplayModeMemory.swift */, ); @@ -153,11 +177,14 @@ 578C8E4277884577DC1F2820 /* TBDisplaySenderTests */ = { isa = PBXGroup; children = ( + C11B4EB862393E5CE95426D4 /* TBCaptureHealthTests.swift */, A7086193EE5710E2C37F86F1 /* TBConfigurationDiagnosticsTests.swift */, 405452C4DA622D0B8887623E /* TBConnectionDiagnosticsTests.swift */, D419C88EEEA66EE0829BA975 /* TBDisplayProfileTests.swift */, EF17070AC1C6F9E9F3B4DFDF /* TBInjectedClickStateTrackerTests.swift */, + 99FECDAFCED370C001F6BBB7 /* TBInjectedPointerEventTests.swift */, 37515A84B71F4B19C8EB65B4 /* TBInputBindingTests.swift */, + 56AD26344132A9583BF34ED8 /* TBLocalPointerModifiersTests.swift */, C9583EB47BCD79FA719EEA77 /* TBMonitorProtocolTests.swift */, 17D2B1A011B9446090DA60A7 /* TBReceiverDiscoveryModelTests.swift */, ED08E3D2FCC8194F8B8185C9 /* TBSenderAutomationParsingTests.swift */, @@ -346,7 +373,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nSTAMP=\"$(date +%Y%m%d%H%M%S)\"\ncat > \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < \"${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift\" < "${SRCROOT}/TBDisplaySender/TBDisplaySenderBuildInfo.swift" <' unless + ARGV.length == 2 && %w[sender receiver].include?(role) && File.executable?(executable) + +2.times do + Tempfile.create('tb-permission-stdout') do |output| + Tempfile.create('tb-permission-stderr') do |errors| + pid = Process.spawn(executable, '--permission-status', out: output, err: errors) + status = nil + begin + Timeout.timeout(10) { _, status = Process.wait2(pid) } + rescue Timeout::Error + Process.kill('KILL', pid) rescue Errno::ESRCH + Process.wait(pid) rescue Errno::ECHILD + abort "#{role}: permission probe did not exit within 10 seconds" + end + output.rewind + errors.rewind + text = output.read + abort "#{role}: probe failed: #{errors.read}" unless status.success? + match = /\ATB_PERMISSION_STATUS:screen_recording=([01]):accessibility=([01]):input_monitoring=([01])\n\z/.match(text) + abort "#{role}: unexpected probe output: #{text.inspect}" unless match + abort 'receiver: screen recording must be zero (not applicable)' if role == 'receiver' && match[1] != '0' + end + end +end +puts "PASS #{role}: two bounded permission probes returned valid status and exited" diff --git a/TargetBridge-Sender/scripts/test_video_queue_budget.rb b/TargetBridge-Sender/scripts/test_video_queue_budget.rb new file mode 100644 index 00000000..6895851a --- /dev/null +++ b/TargetBridge-Sender/scripts/test_video_queue_budget.rb @@ -0,0 +1,46 @@ +# Execute the production policy, plus source guards for both capture paths. +# Synthetic counters only; no display capture, network, credentials or input. +require 'tmpdir' +root = File.expand_path('..', __dir__) +source = File.read(File.join(root, 'TBDisplaySender/TBDisplaySenderService.swift')) +policy = source[/enum TBVideoQueueBudget \{.*?(?=\nenum TBDisplayCapturePreset)/m] +abort 'missing production budget policy' unless policy +abort 'both capture paths must reserve capacity' unless source.scan('if !TBVideoQueueBudget.canEncode(').size == 2 +capture = source[/func encode\(_ sampleBuffer: CMSampleBuffer\).*?(?= \/\/\/ CGDisplayStream)/m] +abort 'missing usable-frame guard before health accounting' unless capture&.match?( + /guard running, let pixelBuffer = CMSampleBufferGetImageBuffer\(sampleBuffer\) else \{ return \}\s+markCaptureFrame\(\)/ +) +abort 'capture budget must follow the usable-frame guard' unless capture.index('markCaptureFrame()') < capture.index('TBVideoQueueBudget.canEncode(') +encoded = source[/private func handleEncoded\(.*?(?= \/\/\/ Raw passthrough)/m] +abort 'encoded frames are still dropped' if encoded.include?('droppedAfterEncodeFrames +=') +tests = <<~'SWIFT' +import Foundation +func admit(_ pending: Int, _ flight: Int, _ limit: Int = 3, _ enc: Int = 2) -> Bool { + TBVideoQueueBudget.canEncode(pending: pending, inFlight: flight, packetLimit: limit, encodeLimit: enc) +} +precondition(admit(0, 0) && admit(1, 1) && !admit(2, 1) && !admit(0, 2)) +precondition(!admit(-1, 0) && !admit(0, -1) && !admit(Int.max, 0)) +precondition(admit(0, 0, 0, 0) && !admit(1, 0, 0, 0)) +precondition(!admit(64, 0, Int.max, Int.max)) +// Explore all legal transitions: capture, encoder success/failure, transport ACK. +// No post-encode discard is needed, including when ACKs are delayed indefinitely. +for limit in 1...12 { + for enc in 1...12 { + for pending in 0...limit { + for flight in 0...(limit - pending) { + if admit(pending, flight, limit, enc) { + precondition(pending + flight + 1 <= limit && flight + 1 <= enc) + } + if flight > 0 { precondition((pending + 1) + (flight - 1) <= limit) } + } + } + } +} +print("PASS production video budget: both paths, bounded reservations, delayed ACKs, encode failure, invalid limits; no post-encode discard") +SWIFT +Dir.mktmpdir('tb-video-budget.') do |dir| + file = File.join(dir, 'main.swift') + File.write(file, policy + tests) + abort 'compile failed' unless system('swiftc', '-module-cache-path', File.join(dir, 'cache'), file, '-o', File.join(dir, 'test')) + abort 'tests failed' unless system(File.join(dir, 'test')) +end diff --git a/TargetBridge-Shared/Languages/de.json b/TargetBridge-Shared/Languages/de.json index c46f289c..0075cd5b 100644 --- a/TargetBridge-Shared/Languages/de.json +++ b/TargetBridge-Shared/Languages/de.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", "sender.profile.crisp": "Crisp", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60 (Experimentell)", "sender.capture_display.na": "n/a", diff --git a/TargetBridge-Shared/Languages/en.json b/TargetBridge-Shared/Languages/en.json index 02d725dd..e38314e8 100644 --- a/TargetBridge-Shared/Languages/en.json +++ b/TargetBridge-Shared/Languages/en.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", "sender.profile.crisp": "Crisp", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60 (Experimental)", "sender.capture_display.na": "n/a", diff --git a/TargetBridge-Shared/Languages/fr.json b/TargetBridge-Shared/Languages/fr.json index ad603af7..e18d886f 100644 --- a/TargetBridge-Shared/Languages/fr.json +++ b/TargetBridge-Shared/Languages/fr.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Fluide", "sender.profile.smooth_plus": "Fluide+", "sender.profile.crisp": "Net", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60 (Expérimental)", "sender.capture_display.na": "n/d", diff --git a/TargetBridge-Shared/Languages/it.json b/TargetBridge-Shared/Languages/it.json index 8849e096..37bc70f5 100644 --- a/TargetBridge-Shared/Languages/it.json +++ b/TargetBridge-Shared/Languages/it.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", "sender.profile.crisp": "Crisp", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60 (Sperimentale)", "sender.capture_display.na": "n/a", diff --git a/TargetBridge-Shared/Languages/zh.json b/TargetBridge-Shared/Languages/zh.json index c575f1b8..6cc99284 100644 --- a/TargetBridge-Shared/Languages/zh.json +++ b/TargetBridge-Shared/Languages/zh.json @@ -136,6 +136,7 @@ "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", "sender.profile.crisp": "Crisp", + "sender.profile.retina_4k": "Retina 4K", "sender.profile.native_5k": "5K", "sender.profile.native_5k_60_experimental": "5K 60(实验性)", "sender.capture_display.na": "n/a", diff --git a/cli/targetbridge b/cli/targetbridge index 12cd2703..794a64c6 100755 --- a/cli/targetbridge +++ b/cli/targetbridge @@ -9,16 +9,17 @@ set -euo pipefail # # Usage: # targetbridge connect [--receiver auto|] [--mode mirror|extended] -# [--preset ] [--transport tb|net] [--session N] [--local-ip ] +# [--preset ] [--path auto|wired|thunderbolt|usb|ethernet|wifi] +# [--transport tb|net] [--session N] [--local-ip ] [--retry] # targetbridge disconnect [--session N] # # Examples: # targetbridge connect # auto receiver, app defaults -# targetbridge connect --receiver auto --mode mirror --preset 1440p +# targetbridge connect --receiver auto --mode mirror --preset 1440p --path auto # targetbridge connect --receiver 169.254.0.2 --mode extended --preset 5k # targetbridge disconnect # -# Presets: standard1440p | smooth1440p60 | smooth1800p60 | crisp2160p60 | native5k +# Presets: standard1440p | smooth1440p60 | smooth1800p60 | crisp2160p60 | retina4k60 | native5k # (aliases: 1440p, 1440p60, 1800p, 4k, 5k) # # Remote / SSH: run it on the sender over SSH (the sender must be at a logged-in @@ -61,9 +62,11 @@ while [ $# -gt 0 ]; do --receiver) add receiver "${2:-}"; shift 2 ;; --mode) add mode "${2:-}"; shift 2 ;; --preset) add preset "${2:-}"; shift 2 ;; + --path) add path "${2:-}"; shift 2 ;; --transport) add transport "${2:-}"; shift 2 ;; --session) add session "${2:-}"; shift 2 ;; --local-ip) add localIP "${2:-}"; shift 2 ;; + --retry) add retry ""; shift ;; -h|--help) usage 0 ;; *) echo "$prog: unknown option '$1'" >&2; usage 1 ;; esac diff --git a/docs/Automation.md b/docs/Automation.md index e5b5fca8..49c185c3 100644 --- a/docs/Automation.md +++ b/docs/Automation.md @@ -17,19 +17,34 @@ install -m 0755 cli/targetbridge /usr/local/bin/targetbridge # or ~/bin, etc. ```bash targetbridge connect # auto-pick receiver, app defaults -targetbridge connect --receiver auto --mode mirror --preset 1440p +targetbridge connect --receiver auto --mode mirror --preset 1440p --path auto targetbridge connect --receiver 169.254.0.2 --mode extended --preset 5k targetbridge disconnect ``` Options: `--receiver auto|`, `--mode mirror|extended`, `--preset `, -`--transport tb|net`, `--session N`, `--local-ip `. -Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `native5k`, -`native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, `5k`, `5k60`). +`--path auto|wired|thunderbolt|usb|ethernet|wifi`, `--transport tb|net`, `--session N`, +`--local-ip `, `--retry`. +Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `retina4k60`, +`native5k`, `native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, +`5k`, `5k60`). 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 replace the stable 5K 48 FPS profile. A receiver of `auto` waits briefly for Bonjour discovery and uses the first receiver found; a raw IP/hostname bypasses discovery. +With `--path auto`, the Sender discovers the receiver addresses advertised for +Thunderbolt Bridge, direct USB/USB4 networking, Ethernet and Wi-Fi, then performs a +short bounded transfer over each working route and selects the measured fastest one. +Use `--path wired` to exclude Wi-Fi, or choose a specific path to force it. + +On macOS a direct USB-C/USB4 connection between two Macs is exposed as a USB-NCM +network interface, usually with a self-assigned `169.254.x.x` address. TargetBridge +continues to send its encoded network stream over that interface: this does not turn +the iMac into a native USB-C/DisplayPort video sink, and the reported USB4 link speed +is not the same as the application-level video throughput. + It launches the Sender on demand and works whether the app is already running or not. ## 2. URL scheme @@ -56,6 +71,38 @@ handy for a Login Item or LaunchAgent: open -a TargetBridge --args --connect --receiver auto --mode mirror --preset 1440p ``` +Add `--retry` for monitor mode: the Sender keeps watching the active session and retries +after transient startup, cable or Receiver failures. An explicit GUI Stop, Command-Q or +Quit from the menu bar cancels that loop and removes +`~/Library/Application Support/TargetBridge/Sender/enabled`. A LaunchAgent can use that +file as a `PathState` marker so crash recovery remains automatic while a user Stop remains +stopped. Packaging such a LaunchAgent is intentionally outside this change. + +## Read-only permission diagnostics + +Run the executable inside the app you want to check, rather than `open -a`: + +```bash +"/Applications/TargetBridge.app/Contents/MacOS/TargetBridge" --permission-status +"/Applications/TargetBridge Receiver.app/Contents/MacOS/TargetBridgeReceiver" --permission-status +``` + +Each command prints one line and exits without opening the normal UI, creating a +virtual display, starting capture, or requesting permissions: + +```text +TB_PERMISSION_STATUS:screen_recording=1:accessibility=1:input_monitoring=1 +``` + +The values above are an example: `1` means granted and `0` means not granted. +The Receiver always reports `screen_recording=0` because it does not capture the +screen; that value does not indicate a missing Receiver permission. + +Results apply to the exact binary and execution context being checked. A temporary +build or an SSH/terminal launch can differ from the installed app's normal launch. +This command neither grants permissions nor proves that a full session will work. +Check both Macs in their intended roles when troubleshooting remote input. + ## Recipes **Connect from another Mac over SSH** — the Sender must be at a logged-in desktop with diff --git a/docs/Features.md b/docs/Features.md index 6d8dc791..9fbb35d2 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -129,13 +129,18 @@ Related reading: - [docs/Addons.md#input-dockstation](docs/Addons.md#input-dockstation) -## Remote Brightness Control +## Receiver Device Controls -The sender can adjust receiver panel brightness directly from the session UI. +The Sender menu can adjust a connected receiver's panel brightness and volume +directly from the session controls. When the receiver hardware supports them, +the same menu also offers Night Shift and True Tone toggles. -This makes it easier to treat the receiver as part of the same workspace without manually opening receiver-side display settings each time. +This makes it easier to treat the receiver as part of the same workspace +without manually opening receiver-side display settings each time. Unsupported +controls are simply not shown. -The current implementation sends brightness updates over the session protocol and applies them on the receiver side. +The Sender sends the selected setting over the session protocol and the +Receiver applies it locally. ## Shared Translations diff --git a/docs/Testing.md b/docs/Testing.md index 5a437b8c..085f3d1d 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -84,6 +84,20 @@ open build/TargetBridge.app # sender: pick the LAN interface, enter the Mac's This exercises the true capture → encode → decode → render path minus the Thunderbolt link itself. +## 6. Monitor shield lifecycle (macOS GUI session) + +```bash +cd TargetBridge-Receiver/TBReceiverC +make test-monitor-shield +``` + +Opens a small test window and checks repeated activation, delayed window-level +restoration, backing-property notifications, observer cleanup, and cancellation +of pending retries after stopping. Requires AppKit and a logged-in GUI session, +but no screen recording or input permissions. This is separate from `make test` +so the portable test suite remains usable without a GUI. It does not replace +live verification of notification suppression during a real stream. + ## Debugging a live connection The sender logs its connection lifecycle (dial target, interface, waiting/ diff --git a/docs/releases/3.5.1.md b/docs/releases/3.5.1.md new file mode 100644 index 00000000..4946a968 --- /dev/null +++ b/docs/releases/3.5.1.md @@ -0,0 +1,44 @@ +# TargetBridge 3.5.1 + +A maintenance update to 3.5.0, developed on `maint-3.5` without changing `main`. +Update both Sender and Receiver for the complete set of fixes. + +## Display and input + +- 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 used only when the large-cursor option is enabled. +- Report the physical Receiver panel geometry and preserve native 5K dimensions when the desktop is scaled with HiDPI. +- Fall back to software decoding when VideoToolbox cannot decode a stream on older hardware. Performance depends on the Mac and stream profile. + +## Receiver lifecycle + +- Allow display sleep while waiting for a Sender, including before the first connection (#171). +- Open audio output only when an audio frame arrives and close it when the session ends, avoiding an idle audio device that prevents sleep (#172). +- Guard the idle watchdog against clock underflow (#173). +- Keep local notification banners behind the active monitor surface during fullscreen and display transitions (#186). Repeated activation preserves pending retries; teardown removes observers and invalidates stale retries. + +This release does not change the active-session display-sleep policy. The separate active-session sleep proposal is not included. + +## Packaging and validation + +- Package SDL runtimes and their dependencies safely (#189, incorporating #181 and #188). +- Support explicit version/build overrides and configurable Sender build configuration (#180). +- Add a documented AppKit monitor-shield lifecycle test: [Testing](../Testing.md#6-monitor-shield-lifecycle-macos-gui-session). +- Keep existing shared translations validated for English, Italian, German, French, and Chinese. + +The integration passed Sender and Receiver CI on Apple Silicon and Intel, 111 local Sender unit tests, and 654 Receiver checks on each architecture. Monitor-shield lifecycle tests passed on both Macs. A real Thunderbolt 5K session, stop/reconnect, idle resource release, watchdog timeout and malformed/partial-packet recovery were tested. The maintainer also reported that the installed test build appeared to work correctly in normal use. These checks are not a guarantee of constant FPS or compatibility with every Mac. + +## Downloads and upgrade + +- `TargetBridge-arm64.app.zip`: Sender for Apple Silicon. +- `TargetBridge-x86_64.app.zip`: Sender for Intel. +- `TargetBridge-Receiver-arm64.app.zip`: Receiver for Apple Silicon. +- `TargetBridge-Receiver-x86_64.app.zip`: Receiver for Intel. + +Sender requires macOS 14 or later; Receiver requires macOS 11 or later. Replace the existing app in Applications rather than keeping multiple copies. If macOS no longer recognizes a permission after the update, re-enable it for that installed copy and reopen the app. Screen Recording is needed on the Sender; Input Dockstation permissions must be checked on both Macs according to their roles. See [Input Dockstation](../Features.md#input-dockstation). + +## Thanks + +Thanks to [@Betafer](https://github.com/Betafer) for the focused input, lifecycle and build improvements, and [@preggocl](https://github.com/preggocl) for the Receiver display-profile and decoding compatibility work. Thanks also to everyone testing on real Macs, reporting problems, contributing code and supporting the project. + +[Full comparison with 3.5.0](https://github.com/swellweb/targetBridge/compare/v3.5.0...v3.5.1) diff --git a/docs/releases/3.5.2.md b/docs/releases/3.5.2.md new file mode 100644 index 00000000..90e48598 --- /dev/null +++ b/docs/releases/3.5.2.md @@ -0,0 +1,55 @@ +# TargetBridge 3.5.2 + +A focused maintenance update to 3.5.1, developed on `maint-3.5` without +changing `main`. Update both Sender and Receiver so the installed applications +remain on the same version. + +## Input Dockstation + +- Fix Shift-click range selection and Command-click non-contiguous selection + when the Receiver keyboard is the input master and the mouse or trackpad is + used locally on the Sender. +- Preserve the receiver-held modifier state only for local pointer clicks and + drags while Receiver Master is active. Releasing the modifier, stopping the + session, changing role, or switching target removes that state immediately. +- Keep relayed pointer events independent, so their original modifier snapshot + is not rewritten by the local-pointer compatibility path. +- Exclude Fn, Caps Lock and unrelated keyboard, pointer-motion, and scroll + events from the compatibility path, preventing stale modifier shortcuts. + +## Included maintenance fixes + +- Reserve video queue capacity before encoding reference frames, helping avoid + pressure-induced stalls. +- Distinguish an idle capture from a stalled pipeline, reducing false watchdog + disconnects when the desktop is static. +- Expose read-only permission probes for Sender and Receiver diagnostics. + +## Validation + +The release branch passed Sender and Receiver CI on Apple Silicon and Intel. +The Sender completed 140 unit tests, including coverage for remote Shift and +Command applied to local pointer events. The maintainer verified a real +Thunderbolt session and multi-file selection using the Receiver keyboard with +the Sender mouse. This does not guarantee compatibility with every Mac, +application, or input device. + +## Downloads and upgrade + +- `TargetBridge-arm64.app.zip`: Sender for Apple Silicon. +- `TargetBridge-x86_64.app.zip`: Sender for Intel. +- `TargetBridge-Receiver-arm64.app.zip`: Receiver for Apple Silicon. +- `TargetBridge-Receiver-x86_64.app.zip`: Receiver for Intel. + +Sender requires macOS 14 or later; Receiver requires macOS 11 or later. Replace +the existing app in Applications rather than keeping multiple copies. A rebuilt +app may need Screen Recording or Input Dockstation permissions re-enabled for +that installed copy. See [Input Dockstation](../Features.md#input-dockstation). + +## Thanks + +Thanks to [@Betafer](https://github.com/Betafer) for the video, capture-health, +and permission diagnostics work included in this maintenance line, and to +everyone who tests TargetBridge on real Macs and reports issues. + +[Full comparison with 3.5.1](https://github.com/swellweb/targetBridge/compare/v3.5.1...v3.5.2)