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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TargetBridge-Receiver/TBReceiverC/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions TargetBridge-Receiver/TBReceiverC/src/display.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
96 changes: 95 additions & 1 deletion TargetBridge-Receiver/TBReceiverC/src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
#include <CoreFoundation/CoreFoundation.h>
#include <CoreGraphics/CoreGraphics.h>
#include <CoreAudio/CoreAudio.h>
#include <IOKit/pwr_mgt/IOPMLib.h>

/* kAudioObjectPropertyElementMain is the macOS 12+ SDK spelling; older SDKs
* only define kAudioObjectPropertyElementMaster (both are numerically 0). */
Expand All @@ -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;
Expand All @@ -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];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}() {
Expand Down
25 changes: 17 additions & 8 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,31 @@ 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
}
guard let (type, payload) = try TBMonitorProtocol.drainPacket(from: &buffer) else {
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 {
Expand Down
7 changes: 7 additions & 0 deletions TargetBridge-Sender/TBDisplayShared/TBMonitorProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion TargetBridge-Shared/Languages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion TargetBridge-Shared/Languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion TargetBridge-Shared/Languages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion TargetBridge-Shared/Languages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion TargetBridge-Shared/Languages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "为新会话默认传输音频",
Expand Down
Loading