Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,28 @@ struct TBVirtualDisplayIdentity {
usesDedicatedArrangementIdentity: false
)

static func extendedDesktop() -> TBVirtualDisplayIdentity {
let random = UInt32.random(in: 0x0100...0xFFFE)
static func extendedDesktop(for profile: TBMonitorDisplayProfile) -> TBVirtualDisplayIdentity {
// Deterministic identity per receiver so macOS retains window placement
// and the saved extended-desktop arrangement across reconnects.
let key = "\(profile.receiverName)|\(profile.panelWidth)x\(profile.panelHeight)"
let hash = djb2(key)
let productLow = (hash & 0x00FF) | 0x01
let serialLow = (hash & 0xFFFE) | 0x0100
return TBVirtualDisplayIdentity(
productID: 0x6000 | (random & 0x00FF),
serialNumber: 0x2027_0000 | random,
productID: 0x6000 | productLow,
serialNumber: 0x2027_0000 | UInt32(serialLow),
displayNamePrefix: "TB Extend",
usesDedicatedArrangementIdentity: true
)
}

private static func djb2(_ input: String) -> UInt32 {
var hash: UInt32 = 5381
for byte in input.utf8 {
hash = hash &* 33 &+ UInt32(byte)
}
return hash
}
}

@MainActor
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
enum TBDisplaySenderBuildInfo {
static let marketingVersion = "2.0"
static let buildNumber = "20260521211758"
static let buildNumber = "20260531002036"
static let versionDisplay = "\(marketingVersion) + build \(buildNumber)"
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ struct TBDisplaySenderContentView: View {

Toggle(TBDisplaySenderL10n.largeCursor(service.language), isOn: $service.largeCursor)
.disabled(service.anyConnected)

Toggle(TBDisplaySenderL10n.preventDisplaySleep(service.language), isOn: $service.preventDisplaySleep)

Toggle(TBDisplaySenderL10n.autoRestartOnWake(service.language), isOn: $service.autoRestartOnWake)

Toggle(TBDisplaySenderL10n.verboseDisplayLogging(service.language), isOn: $service.verboseDisplayLogging)
}
}
}
Expand Down Expand Up @@ -229,7 +235,11 @@ private struct TBDisplaySenderSessionCard: View {
}
.onChange(of: session.selectedReceiverID) { _, newValue in
guard let receiver = service.discoveredReceivers.first(where: { $0.id == newValue }) else { return }
service.applyDiscoveredReceiver(receiver, to: session)
// Defer the mutation past SwiftUI's current view-update phase to avoid
// "Publishing changes from within view updates is not allowed".
DispatchQueue.main.async {
service.applyDiscoveredReceiver(receiver, to: session)
}
}
.disabled(session.isConnected || session.isStreaming)
}
Expand Down Expand Up @@ -336,6 +346,12 @@ private struct TBDisplaySenderSessionCard: View {
.buttonStyle(.bordered)
.disabled(session.isConnected || session.isStreaming || session.isCableTesting || trimmedReceiverIP.isEmpty || session.localTBIP.isEmpty)

Button(TBDisplaySenderL10n.restartCaptureButton(service.language)) {
session.restartCaptureNow()
}
.buttonStyle(.bordered)
.disabled(!session.canRestartCapture)

Button(TBDisplaySenderL10n.removeSessionButton(service.language)) {
service.removeSession(session)
}
Expand All @@ -354,7 +370,9 @@ private struct TBDisplaySenderSessionCard: View {
infoRow(TBDisplaySenderL10n.receiverLabel(service.language), session.receiverPanelText)
infoRow(TBDisplaySenderL10n.virtualDisplayLabel(service.language), session.virtualDisplayText)
infoRow(TBDisplaySenderL10n.streamLabel(service.language), session.streamResolutionText)
infoRow(TBDisplaySenderL10n.fpsLabel(service.language), "\(session.senderFPS)")
// Observes the dedicated metrics object so the ~1 Hz FPS tick
// re-renders only this row, not the whole session card / window.
SessionMonitorFPSRow(label: TBDisplaySenderL10n.fpsLabel(service.language), metrics: session.liveMetrics)
infoRow("Capture", session.captureDisplayText)
infoRow("State", session.displayStateText)
}
Expand Down Expand Up @@ -505,6 +523,26 @@ private struct TBDisplaySenderSessionCard: View {
}
}

/// FPS readout that observes only `TBSessionLiveMetrics`. Isolating it here means
/// the once-per-second FPS update invalidates just this small row instead of the
/// entire session card (and, via the manager bubble-up, the whole window).
private struct SessionMonitorFPSRow: View {
let label: String
@ObservedObject var metrics: TBSessionLiveMetrics

var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 14) {
Text(label)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 138, alignment: .leading)
Text("\(metrics.senderFPS)")
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}

private struct SurfaceCard<Content: View>: View {
@ViewBuilder let content: Content

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,38 @@ enum TBDisplaySenderL10n {
}
}

static func preventDisplaySleep(_ language: TBDisplaySenderLanguage) -> String {
switch language {
case .italian: return "Impedisci screensaver e sospensione display durante lo streaming"
case .english: return "Prevent screensaver / display sleep while streaming"
case .german: return "Bildschirmschoner und Display-Ruhezustand beim Streamen verhindern"
}
}

static func autoRestartOnWake(_ language: TBDisplaySenderLanguage) -> String {
switch language {
case .italian: return "Riavvia automaticamente la cattura al risveglio"
case .english: return "Auto-restart capture after wake / unlock"
case .german: return "Aufnahme nach Aufwachen/Entsperren automatisch neu starten"
}
}

static func restartCaptureButton(_ language: TBDisplaySenderLanguage) -> String {
switch language {
case .italian: return "Riavvia cattura"
case .english: return "Restart capture"
case .german: return "Aufnahme neu starten"
}
}

static func verboseDisplayLogging(_ language: TBDisplaySenderLanguage) -> String {
switch language {
case .italian: return "Diagnostica display in Console (verboso)"
case .english: return "Log virtual display events to Console (verbose)"
case .german: return "Virtuelle Display-Ereignisse in Console protokollieren (ausführlich)"
}
}

static func showMainWindow(_ language: TBDisplaySenderLanguage) -> String {
switch language {
case .italian: return "Mostra finestra principale"
Expand Down
33 changes: 25 additions & 8 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,31 @@ final class TBDisplaySenderService: ObservableObject {
didSet {
language.persist()
sessions.forEach { $0.language = language }
objectWillChange.send()
}
}
@Published var showsMenuBarIcon = true
@Published var largeCursor: Bool = UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.largeCursor") {
didSet {
UserDefaults.standard.set(largeCursor, forKey: "fd.tbdisplaysender.largeCursor")
sessions.forEach { $0.largeCursor = largeCursor }
objectWillChange.send()
}
}
@Published var preventDisplaySleep: Bool = UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.preventDisplaySleep") {
didSet {
UserDefaults.standard.set(preventDisplaySleep, forKey: "fd.tbdisplaysender.preventDisplaySleep")
sessions.forEach { $0.preventDisplaySleep = preventDisplaySleep }
}
}
@Published var autoRestartOnWake: Bool = UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.autoRestartOnWake") {
didSet {
UserDefaults.standard.set(autoRestartOnWake, forKey: "fd.tbdisplaysender.autoRestartOnWake")
sessions.forEach { $0.autoRestartOnWake = autoRestartOnWake }
}
}
@Published var verboseDisplayLogging: Bool = UserDefaults.standard.bool(forKey: "fd.tbdisplaysender.verboseDisplayLogging") {
didSet {
UserDefaults.standard.set(verboseDisplayLogging, forKey: "fd.tbdisplaysender.verboseDisplayLogging")
sessions.forEach { $0.verboseDisplayLogging = verboseDisplayLogging }
}
}

Expand All @@ -40,7 +56,6 @@ final class TBDisplaySenderService: ObservableObject {
discoveryCancellable = receiverDiscovery.$receivers.sink { [weak self] receivers in
guard let self else { return }
discoveredReceivers = receivers
objectWillChange.send()
}
refreshBridgeInterfaces()
addSession()
Expand Down Expand Up @@ -70,7 +85,13 @@ final class TBDisplaySenderService: ObservableObject {
}

func addSession() {
let session = TBDisplaySenderSession(language: language, largeCursor: largeCursor)
let session = TBDisplaySenderSession(
language: language,
largeCursor: largeCursor,
preventDisplaySleep: preventDisplaySleep,
autoRestartOnWake: autoRestartOnWake,
verboseDisplayLogging: verboseDisplayLogging
)
if let previous = sessions.last {
session.capturePreset = previous.capturePreset
session.captureSource = previous.captureSource
Expand All @@ -80,7 +101,6 @@ final class TBDisplaySenderService: ObservableObject {
}
attachSession(session)
sessions.append(session)
objectWillChange.send()
}

func removeSession(_ session: TBDisplaySenderSession) {
Expand All @@ -89,7 +109,6 @@ final class TBDisplaySenderService: ObservableObject {
sessions.removeAll { $0.id == session.id }
sessionCancellables.removeValue(forKey: session.id)
normalizeSessionInterfaces()
objectWillChange.send()
}

func stopAll() {
Expand All @@ -101,15 +120,13 @@ final class TBDisplaySenderService: ObservableObject {
bridgeInterfaces = detectBridgeInterfaces()
receiverDiscovery.refresh()
normalizeSessionInterfaces()
objectWillChange.send()
}

func applyDiscoveredReceiver(_ receiver: TBDiscoveredReceiver, to session: TBDisplaySenderSession) {
session.receiverIP = receiver.receiverIP
if session.localTBIP.isEmpty {
session.localTBIP = suggestedInterfaceForNewSession()?.ip ?? bridgeInterfaces.first?.ip ?? ""
}
objectWillChange.send()
}

func sessionTitle(for session: TBDisplaySenderSession) -> String {
Expand Down
Loading