diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index 59f488a..e3afe45 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -18,11 +18,17 @@ struct TBVirtualDisplayIdentity { usesDedicatedArrangementIdentity: false ) - static func extendedDesktop(for profile: TBMonitorDisplayProfile) -> TBVirtualDisplayIdentity { + static func extendedDesktop(receiverKey: String) -> 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) + // + // `receiverKey` must uniquely identify the receiver (the caller derives it + // from the connection address, matching the saved-arrangement key). Keying + // on the receiver-reported display name alone is not enough: identical iMac + // models report the same SDL display name and the same hard-coded panel + // size, so two of them would derive the same identity and macOS would + // refuse to create the second virtual display. + let hash = djb2(receiverKey) let productLow = (hash & 0x00FF) | 0x01 let serialLow = (hash & 0xFFFE) | 0x0100 return TBVirtualDisplayIdentity( diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift index d16f038..c68b2d1 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderBuildInfo.swift @@ -1,5 +1,5 @@ enum TBDisplaySenderBuildInfo { static let marketingVersion = "3.0" - static let buildNumber = "20260527131918" + static let buildNumber = "20260605143719" static let versionDisplay = "\(marketingVersion) + build \(buildNumber)" } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift index e85b6b7..8f31d16 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift @@ -121,7 +121,7 @@ final class TBDisplaySenderService: ObservableObject { } refreshLocalInterfaces() addonStore.refresh() - addSession() + restorePersistedSessions() startClipboardMonitoring() } @@ -213,6 +213,7 @@ final class TBDisplaySenderService: ObservableObject { } attachSession(session) sessions.append(session) + schedulePersist() objectWillChange.send() } @@ -223,6 +224,7 @@ final class TBDisplaySenderService: ObservableObject { sessionCancellables.removeValue(forKey: session.id) normalizeAddonState() normalizeSessionInterfaces() + schedulePersist() objectWillChange.send() } @@ -231,6 +233,114 @@ final class TBDisplaySenderService: ObservableObject { sessions.forEach { $0.stop(persistArrangement: false) } } + // MARK: - Session persistence + + private static let persistedSessionsKey = "fd.tbdisplaysender.sessions.v1" + + /// Snapshot of the user-configurable settings for a single session. Runtime + /// state (connection, input master role, FPS, …) is intentionally excluded — + /// only the choices the user makes in the UI are remembered across launches. + private struct PersistedSession: Codable { + var transportKind: String + var localInterfaceIP: String + var receiverIP: String + var selectedReceiverID: String + var capturePreset: String + var captureSource: String + var audioEnabled: Bool + var brightness: Double + var inputGestureMode: String + } + + private var lastPersistedData: Data? + private var persistScheduled = false + + /// Coalesces the many synchronous `objectWillChange` notifications a single + /// user action produces into one write. Runs on the next main-loop tick, so + /// it observes the post-change values rather than the pre-change ones that + /// `objectWillChange` fires with. + private func schedulePersist() { + guard !persistScheduled else { return } + persistScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.persistScheduled = false + self.persistSessions() + } + } + + private func persistSessions() { + let configs = sessions.map { session in + PersistedSession( + transportKind: session.transportKind.rawValue, + localInterfaceIP: session.localInterfaceIP, + receiverIP: session.receiverIP, + selectedReceiverID: session.selectedReceiverID, + capturePreset: session.capturePreset.rawValue, + captureSource: session.captureSource.rawValue, + audioEnabled: session.audioEnabled, + brightness: session.brightness, + inputGestureMode: session.inputGestureMode.rawValue + ) + } + guard let data = try? JSONEncoder().encode(configs) else { return } + // Streaming churns `objectWillChange` constantly; skip redundant writes. + guard data != lastPersistedData else { return } + lastPersistedData = data + UserDefaults.standard.set(data, forKey: Self.persistedSessionsKey) + } + + private func restorePersistedSessions() { + guard let data = UserDefaults.standard.data(forKey: Self.persistedSessionsKey), + let configs = try? JSONDecoder().decode([PersistedSession].self, from: data), + !configs.isEmpty + else { + addSession() + return + } + + lastPersistedData = data + for config in configs { + let session = TBDisplaySenderSession( + language: language, + largeCursor: largeCursor, + preventDisplaySleep: preventDisplaySleep, + autoRestartOnWake: autoRestartOnWake, + audioEnabled: audioEnabled && audioRelayAvailable, + verboseDisplayLogging: verboseDisplayLogging + ) + apply(config, to: session) + session.audioAddonAvailable = audioRelayAvailable + attachSession(session) + sessions.append(session) + } + // Drop transports/audio for addons that are no longer enabled and make + // sure every restored interface still exists on this machine. + normalizeAddonState() + normalizeSessionInterfaces() + objectWillChange.send() + } + + private func apply(_ config: PersistedSession, to session: TBDisplaySenderSession) { + if let transport = TBTransportKind(rawValue: config.transportKind) { + session.transportKind = transport + } + if let preset = TBDisplayCapturePreset(rawValue: config.capturePreset) { + session.capturePreset = preset + } + if let source = TBDisplayCaptureSource(rawValue: config.captureSource) { + session.captureSource = source + } + if let gesture = TBInputGestureMode(rawValue: config.inputGestureMode) { + session.inputGestureMode = gesture + } + session.receiverIP = config.receiverIP + session.selectedReceiverID = config.selectedReceiverID + session.localInterfaceIP = config.localInterfaceIP + session.audioEnabled = config.audioEnabled && audioRelayAvailable + session.brightness = config.brightness + } + func refreshLocalInterfaces() { localInterfaces = detectLocalInterfaces() receiverDiscovery.refresh() @@ -324,6 +434,7 @@ final class TBDisplaySenderService: ObservableObject { } sessionCancellables[session.id] = session.objectWillChange.sink { [weak self] _ in self?.updateInputRelayController() + self?.schedulePersist() self?.objectWillChange.send() } } diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index ee4b0d5..48fcdf3 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -237,12 +237,12 @@ enum TBDisplayCaptureSource: String, CaseIterable, Identifiable { } } - func virtualDisplayIdentity(for profile: TBMonitorDisplayProfile) -> TBVirtualDisplayIdentity { + func virtualDisplayIdentity(receiverKey: String) -> TBVirtualDisplayIdentity { switch self { case .desktopMirror: return .desktopMirror case .extendedDesktop: - return .extendedDesktop(for: profile) + return .extendedDesktop(receiverKey: receiverKey) } } } @@ -1414,11 +1414,23 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u displayStateText = TBDisplaySenderL10n.displayStateNotAvailable(language) } + /// Stable per-receiver discriminator: the connection address when known + /// (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 + } + + /// Key used to derive the extended-desktop virtual display identity. Shares + /// the same receiver discriminator as the saved-arrangement key so a given + /// receiver maps to one stable virtual display identity across reconnects. + private func extendedDisplayIdentityKey(for profile: TBMonitorDisplayProfile) -> String { + "\(receiverIdentityDiscriminator(for: profile))|\(profile.panelWidth)x\(profile.panelHeight)" + } + private func extendedArrangementDefaultsKey(for profile: TBMonitorDisplayProfile) -> String { - let receiverIdentity = receiverIP.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? profile.receiverName - : receiverIP - let normalizedIdentity = receiverIdentity.replacingOccurrences( + let normalizedIdentity = receiverIdentityDiscriminator(for: profile).replacingOccurrences( of: #"[^A-Za-z0-9._-]+"#, with: "-", options: .regularExpression @@ -1825,10 +1837,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u self.setStatus(.creatingVirtualDisplay) self.baselineDisplayIDs = await self.fetchShareableDisplayIDs() + let receiverKey = self.extendedDisplayIdentityKey(for: profile) guard self.session.create( from: profile, refreshRate: self.capturePreset.virtualDisplayRefreshRate, - identity: self.captureSource.virtualDisplayIdentity(for: profile) + identity: self.captureSource.virtualDisplayIdentity(receiverKey: receiverKey) ) else { self.setStatus(.virtualDisplayCreationFailed) self.stop(resetStatusTo: nil) @@ -1850,6 +1863,13 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u ) self.displayStateText = self.describeDisplayState(for: self.session.displayID) + // Reset the first-frame flag BEFORE capture starts. startCapture() is + // async and frames can begin flowing (firing handleFirstEncodedFrame, + // which sets sessionAckSent = true) during its suspension. Resetting + // afterward would clobber that true back to false, leaving the watchdog + // 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.setStatus(.startingCapture(self.capturePreset.description, self.captureSource)) let started = await self.startCapture(for: profile) guard started else { @@ -1863,7 +1883,6 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u self.scheduleDesktopMirrorRecovery(for: self.session.displayID) } - self.sessionAckSent = false self.setStatus(.captureStartedWaitingFirstFrame) self.startFirstFrameWatchdog() } @@ -2650,6 +2669,10 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u } private func startFirstFrameWatchdog() { + // If the first encoded frame already arrived (handleFirstEncodedFrame ran + // while startCapture was still suspended), there is nothing to watch for — + // arming would only leave a no-op timer dangling for 4s. + guard !sessionAckSent else { return } firstFrameTimer?.invalidate() firstFrameTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { [weak self] _ in guard let self else { return }