diff --git a/TargetBridge-Receiver/TBReceiverC/Makefile b/TargetBridge-Receiver/TBReceiverC/Makefile index 26ca207..d63dfba 100644 --- a/TargetBridge-Receiver/TBReceiverC/Makefile +++ b/TargetBridge-Receiver/TBReceiverC/Makefile @@ -31,7 +31,7 @@ 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 -framework SystemConfiguration +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 -framework IOKit endif C_SRC = src/main.c src/net.c src/decoder.c src/display.c src/input_queue.c src/receiver_profile.c src/tb_i18n.c diff --git a/TargetBridge-Receiver/TBReceiverC/src/display.c b/TargetBridge-Receiver/TBReceiverC/src/display.c index 80bdf1d..8db5c9b 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/display.c +++ b/TargetBridge-Receiver/TBReceiverC/src/display.c @@ -552,6 +552,11 @@ static SDL_Renderer *tb_disp_create_accelerated_renderer(SDL_Window *win) { } struct tb_display *tb_disp_create(int fullscreen) { + /* SDL otherwise disables the screen saver automatically for every + * fullscreen window, overriding the session policy in main.c. Explicit + * SDL_DisableScreenSaver() still honours the user's always-on preference. */ + SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1"); + /* Best-quality scaling (linear filter; Metal backend uses bilinear * regardless but this sets the hint correctly). "best" enables * anisotropic where supported. Must be set BEFORE renderer creation. */ diff --git a/TargetBridge-Receiver/TBReceiverC/src/main.c b/TargetBridge-Receiver/TBReceiverC/src/main.c index f334413..54859c4 100644 --- a/TargetBridge-Receiver/TBReceiverC/src/main.c +++ b/TargetBridge-Receiver/TBReceiverC/src/main.c @@ -28,6 +28,7 @@ #include #include #include +#include /* kAudioObjectPropertyElementMain is the macOS 12+ SDK spelling; older SDKs * only define kAudioObjectPropertyElementMaster (both are numerically 0). */ @@ -54,6 +55,11 @@ * (5 missed heartbeats) means it died without a FIN. */ #define TB_SENDER_IDLE_TIMEOUT_MS 10000 +/* Heartbeats arrive every two seconds. A little scheduling margin makes a + * keyboard-only action on the Sender reliably count as remote user activity. */ +#define TB_REMOTE_INPUT_ACTIVE_WINDOW_SECONDS 3.5 +#define TB_REMOTE_ACTIVITY_SIGNAL_INTERVAL_MS 1000 + struct app { struct tb_display *disp; struct tb_decoder *dec; @@ -80,6 +86,9 @@ struct app { * just a transient probe like a UI-language push). Gates the fullscreen * "connecting" splash so a bare/short-lived connection doesn't flash it. */ int session_active; + int prevent_display_sleep; + IOPMAssertionID remote_activity_assertion_id; + uint64_t last_remote_activity_signal_ms; char ip_text[64]; char tb_ip_text[64]; @@ -172,6 +181,60 @@ static uint64_t now_ms(void) { return (uint64_t)ts.tv_sec * 1000ULL + ts.tv_nsec / 1000000ULL; } +static void tb_receiver_release_remote_activity(struct app *a) { + if (!a || a->remote_activity_assertion_id == kIOPMNullAssertionID) return; + IOPMAssertionRelease(a->remote_activity_assertion_id); + a->remote_activity_assertion_id = kIOPMNullAssertionID; +} + +static void tb_receiver_set_prevent_display_sleep(struct app *a, int prevent) { + if (!a) return; + const int normalized = prevent ? 1 : 0; + if (a->prevent_display_sleep == normalized) return; + + a->prevent_display_sleep = normalized; + if (normalized) { + tb_receiver_release_remote_activity(a); + SDL_DisableScreenSaver(); + fprintf(stderr, "[power] display sleep blocked by sender preference\n"); + } else { + SDL_EnableScreenSaver(); + fprintf(stderr, "[power] normal macOS display sleep enabled during session\n"); + } +} + +static void tb_receiver_note_remote_activity(struct app *a, const char *source) { + if (!a || a->prevent_display_sleep) return; + + const uint64_t activity_ms = now_ms(); + if (a->last_remote_activity_signal_ms > 0 && + activity_ms >= a->last_remote_activity_signal_ms && + activity_ms - a->last_remote_activity_signal_ms < TB_REMOTE_ACTIVITY_SIGNAL_INTERVAL_MS) { + return; + } + + const int display_was_asleep = CGDisplayIsAsleep(CGMainDisplayID()); + + tb_receiver_release_remote_activity(a); + IOPMAssertionID assertion_id = kIOPMNullAssertionID; + IOReturn result = IOPMAssertionDeclareUserActivity( + CFSTR("TargetBridge remote input"), + kIOPMUserActiveLocal, + &assertion_id + ); + if (result == kIOReturnSuccess) { + a->remote_activity_assertion_id = assertion_id; + a->last_remote_activity_signal_ms = activity_ms; + if (display_was_asleep) { + fprintf(stderr, "[power] waking display for remote %s activity\n", + source ? source : "input"); + } + } else { + fprintf(stderr, "[power] unable to report remote activity result=%d\n", + (int)result); + } +} + static void tb_copy_i18n(char *dest, size_t size, const char *key); static void tb_format_i18n(char *dest, size_t size, @@ -1119,6 +1182,7 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud (void)extract_json_bool_field(payload, len, "\"visible\"", &visible); (void)extract_json_int_field(payload, len, "\"type\"", &type); (void)extract_json_bool_field(payload, len, "\"large\"", &large); + tb_receiver_note_remote_activity(a, "pointer"); tb_disp_set_cursor(a->disp, x, y, w, h, visible, type, large); } break; @@ -1186,12 +1250,31 @@ static void on_packet(uint8_t type, const uint8_t *payload, size_t len, void *ud } break; case TB_PKT_INPUT_EVENT: + tb_receiver_note_remote_activity(a, "input"); tb_receiver_apply_input_event(payload, len); break; case TB_PKT_INPUT_CONTROL: tb_receiver_apply_input_control_mode(a, payload, len); break; case TB_PKT_HEARTBEAT: + { + int prevent = 0; + if (extract_json_bool_field(payload, len, "\"preventDisplaySleep\"", &prevent)) { + tb_receiver_set_prevent_display_sleep(a, prevent); + } + + double input_idle_seconds = 0.0; + if (extract_json_double_field(payload, len, "\"inputIdleSeconds\"", &input_idle_seconds) && + input_idle_seconds >= 0.0) { + if (input_idle_seconds <= TB_REMOTE_INPUT_ACTIVE_WINDOW_SECONDS) { + tb_receiver_note_remote_activity(a, "keyboard"); + } else { + /* User-activity declarations wake the panel immediately but + * are not meant to become a second display-sleep timer. */ + tb_receiver_release_remote_activity(a); + } + } + } break; case TB_PKT_TEST_DATA: /* Performance test data; discard */ @@ -1804,6 +1887,9 @@ static void close_client(struct app *a) { a->close_requested = 0; a->have_video_frame = 0; snprintf(a->input_control_mode, sizeof(a->input_control_mode), "off"); + tb_receiver_release_remote_activity(a); + a->last_remote_activity_signal_ms = 0; + a->prevent_display_sleep = 0; SDL_EnableScreenSaver(); tb_receiver_refresh_input_capture(a); tb_disp_set_connection_state(a->disp, 0); @@ -1949,6 +2035,12 @@ int main(int argc, char **argv) { fprintf(stderr, "[main] warning: SDL_OpenAudioDevice failed: %s\n", SDL_GetError()); } + /* SDL starts with screen-saver inhibition enabled. Keep normal macOS display + * sleep available both while idle and, by default, during a session. A new + * Sender heartbeat can explicitly request the old always-on behaviour. */ + 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) { snprintf(a.panel_text, sizeof(a.panel_text), "%u x %u px (%s)", @@ -2041,7 +2133,8 @@ int main(int argc, char **argv) { a.reported_night_shift = -1; /* force one report per session */ a.reported_true_tone = -1; a.last_recv_ms = t; - SDL_DisableScreenSaver(); + a.prevent_display_sleep = 0; + SDL_EnableScreenSaver(); fprintf(stderr, "[main] client connected\n"); tb_parser_free(&a.parser); tb_parser_init(&a.parser, on_packet, &a); @@ -2184,6 +2277,7 @@ int main(int argc, char **argv) { if (a.audio_device != 0) { SDL_CloseAudioDevice(a.audio_device); } + tb_receiver_release_remote_activity(&a); tb_disp_destroy(a.disp); fprintf(stderr, "[main] bye\n"); return 0; diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index 0d4e2fc..9710b4b 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -67,7 +67,7 @@ final class TBDisplaySenderService: ObservableObject { } @Published var preventDisplaySleep: Bool = { if UserDefaults.standard.object(forKey: "fd.tbdisplaysender.preventDisplaySleep") == nil { - return true + return false } return UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.preventDisplaySleep") }() { diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 4479346..344a859 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -1155,7 +1155,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } } @Published var largeCursor: Bool - @Published var preventDisplaySleep: Bool = true + /// Controls whether the receiver panel may sleep. The Sender's capture + /// display is kept awake independently: allowing that virtual display to + /// sleep stops frame delivery and can leave CoreGraphics unable to recreate + /// it after a reconnect. + @Published var preventDisplaySleep: Bool = false @Published var autoRestartOnWake: Bool = true @Published var verboseDisplayLogging: Bool = false { didSet { @@ -1926,9 +1930,18 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func sendHeartbeat() { heartbeatSequence += 1 + let anyInputEvent = CGEventType(rawValue: UInt32.max)! + let inputIdleSeconds = CGEventSource.secondsSinceLastEventType( + .combinedSessionState, + eventType: anyInputEvent + ) guard let packet = TBMonitorProtocol.makeJSONPacket( type: .heartbeat, - value: TBMonitorHeartbeat(sequence: heartbeatSequence) + value: TBMonitorHeartbeat( + sequence: heartbeatSequence, + preventDisplaySleep: preventDisplaySleep, + inputIdleSeconds: inputIdleSeconds + ) ) else { return } send(packet) } @@ -2730,11 +2743,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func activityOptions() -> ProcessInfo.ActivityOptions { - var options: ProcessInfo.ActivityOptions = [.userInitiated, .idleSystemSleepDisabled] - if preventDisplaySleep { - options.insert(.idleDisplaySleepDisabled) - } - return options + [.userInitiated, .idleSystemSleepDisabled, .idleDisplaySleepDisabled] } private func beginCaptureActivity(wakeDisplay: Bool = true) { @@ -2744,7 +2753,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u reason: "TargetBridge streaming active" ) } - guard wakeDisplay, preventDisplaySleep else { return } + guard wakeDisplay else { return } let result = IOPMAssertionDeclareUserActivity( "TargetBridge capture requested" as CFString, diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift index 011d57a..1720011 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBMonitorProtocolTests.swift @@ -196,7 +196,11 @@ final class TBMonitorProtocolTests: XCTestCase { // MARK: - JSON payloads func testJSONPacketRoundTrip() throws { - let heartbeat = TBMonitorHeartbeat(sequence: 42) + let heartbeat = TBMonitorHeartbeat( + sequence: 42, + preventDisplaySleep: false, + inputIdleSeconds: 1.25 + ) guard var buffer = TBMonitorProtocol.makeJSONPacket(type: .heartbeat, value: heartbeat) else { XCTFail("encode failed"); return } @@ -204,7 +208,19 @@ final class TBMonitorProtocolTests: XCTestCase { XCTFail("drain failed"); return } XCTAssertEqual(type, .heartbeat) - XCTAssertEqual(TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload)?.sequence, 42) + let decoded = TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload) + XCTAssertEqual(decoded?.sequence, 42) + XCTAssertEqual(decoded?.preventDisplaySleep, false) + XCTAssertEqual(decoded?.inputIdleSeconds, 1.25) + } + + func testOlderHeartbeatRemainsCompatibleWithSleepActivityFields() { + let payload = Data(#"{"sequence":7}"#.utf8) + let heartbeat = TBMonitorProtocol.decodeJSON(TBMonitorHeartbeat.self, from: payload) + + XCTAssertEqual(heartbeat?.sequence, 7) + XCTAssertNil(heartbeat?.preventDisplaySleep) + XCTAssertNil(heartbeat?.inputIdleSeconds) } func testCursorPayloadPreservesLargeCursorPreference() throws { diff --git a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift index 78dce26..462e1af 100644 --- a/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift +++ b/TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift @@ -63,6 +63,13 @@ struct TBMonitorUILanguageUpdate: Codable { struct TBMonitorHeartbeat: Codable { var sequence: UInt64 + /// Mirrors the Sender's existing display-sleep preference. Optional fields + /// keep the heartbeat wire-compatible with older Sender/Receiver builds. + var preventDisplaySleep: Bool? = nil + /// Seconds since the last real input event in the Sender's login session. + /// The Receiver uses this to wake its physical panel when the headless Mac + /// mini is operated with its own mouse or keyboard. + var inputIdleSeconds: Double? = nil } struct TBMonitorTeardown: Codable { diff --git a/TargetBridge-Shared/Languages/de.json b/TargetBridge-Shared/Languages/de.json index 0075cd5..1028519 100644 --- a/TargetBridge-Shared/Languages/de.json +++ b/TargetBridge-Shared/Languages/de.json @@ -51,7 +51,7 @@ "sender.mode_line_5": "Quelle wählen, dann Bewegungsfluss und Schärfe mit dem Stream-Profil abstimmen.", "sender.toggle.show_menu_bar_icon": "Symbol in Menüleiste anzeigen", "sender.toggle.large_cursor": "Großer Zeiger auf dem Empfänger", - "sender.toggle.prevent_display_sleep": "Bildschirmschoner und Display-Ruhezustand beim Streamen verhindern", + "sender.toggle.prevent_display_sleep": "Ruhezustand des Empfängerdisplays beim Streamen verhindern", "sender.toggle.auto_restart_on_wake": "Aufnahme nach Aufwachen/Entsperren automatisch neu starten", "sender.toggle.stream_audio": "Audio übertragen", "sender.toggle.default_stream_audio": "Audio standardmäßig für neue Sitzungen aktivieren", diff --git a/TargetBridge-Shared/Languages/en.json b/TargetBridge-Shared/Languages/en.json index e38314e..8b21dd3 100644 --- a/TargetBridge-Shared/Languages/en.json +++ b/TargetBridge-Shared/Languages/en.json @@ -51,7 +51,7 @@ "sender.mode_line_5": "Choose the source, then balance motion and clarity with the stream profile.", "sender.toggle.show_menu_bar_icon": "Show icon in top bar", "sender.toggle.large_cursor": "Large cursor on receiver", - "sender.toggle.prevent_display_sleep": "Prevent screensaver / display sleep while streaming", + "sender.toggle.prevent_display_sleep": "Prevent the receiver display from sleeping while streaming", "sender.toggle.auto_restart_on_wake": "Auto-restart capture after wake / unlock", "sender.toggle.stream_audio": "Stream audio", "sender.toggle.default_stream_audio": "Enable audio by default for new sessions", diff --git a/TargetBridge-Shared/Languages/fr.json b/TargetBridge-Shared/Languages/fr.json index e18d886..b216c24 100644 --- a/TargetBridge-Shared/Languages/fr.json +++ b/TargetBridge-Shared/Languages/fr.json @@ -51,7 +51,7 @@ "sender.mode_line_5": "Choisissez la source, puis équilibrez fluidité et clarté avec le profil de flux.", "sender.toggle.show_menu_bar_icon": "Afficher l’icône dans la barre des menus", "sender.toggle.large_cursor": "Grand curseur sur le receiver", - "sender.toggle.prevent_display_sleep": "Empêcher l’économiseur d’écran / la veille de l’écran pendant le flux", + "sender.toggle.prevent_display_sleep": "Empêcher la mise en veille de l’écran récepteur pendant le flux", "sender.toggle.auto_restart_on_wake": "Redémarrer automatiquement la capture après la sortie de veille / le déverrouillage", "sender.toggle.stream_audio": "Diffuser l’audio", "sender.toggle.default_stream_audio": "Activer l’audio par défaut pour les nouvelles sessions", diff --git a/TargetBridge-Shared/Languages/it.json b/TargetBridge-Shared/Languages/it.json index 37bc70f..34127aa 100644 --- a/TargetBridge-Shared/Languages/it.json +++ b/TargetBridge-Shared/Languages/it.json @@ -51,7 +51,7 @@ "sender.mode_line_5": "Scegli la sorgente, poi bilancia fluidità e nitidezza col profilo stream.", "sender.toggle.show_menu_bar_icon": "Mostra icona nella top bar", "sender.toggle.large_cursor": "Cursore ingrandito sul receiver", - "sender.toggle.prevent_display_sleep": "Impedisci screensaver e sospensione display durante lo streaming", + "sender.toggle.prevent_display_sleep": "Impedisci la sospensione dello schermo del ricevitore durante lo streaming", "sender.toggle.auto_restart_on_wake": "Riavvia automaticamente la cattura al risveglio o sblocco", "sender.toggle.stream_audio": "Trasmetti audio", "sender.toggle.default_stream_audio": "Abilita audio di default per le nuove sessioni", diff --git a/TargetBridge-Shared/Languages/zh.json b/TargetBridge-Shared/Languages/zh.json index 6cc9928..e5bb269 100644 --- a/TargetBridge-Shared/Languages/zh.json +++ b/TargetBridge-Shared/Languages/zh.json @@ -51,7 +51,7 @@ "sender.mode_line_5": "先选择来源,再用码流配置在流畅度与清晰度之间取舍。", "sender.toggle.show_menu_bar_icon": "在菜单栏显示图标", "sender.toggle.large_cursor": "在接收端使用大光标", - "sender.toggle.prevent_display_sleep": "传输期间阻止屏幕保护程序和显示器休眠", + "sender.toggle.prevent_display_sleep": "传输期间阻止接收端显示器休眠", "sender.toggle.auto_restart_on_wake": "唤醒或解锁后自动重启捕获", "sender.toggle.stream_audio": "传输音频", "sender.toggle.default_stream_audio": "为新会话默认传输音频",