From 1a0632e565e1fe3660e5a9bb08ed64ff882cc909 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:04:00 +0530 Subject: [PATCH 01/11] refactor: let a Mac app host the receiver role with its own trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare for one Mac app with both roles. TrustStore gains namespaces: shared keeps the original Keychain names, so every existing identity and pin is found as before, and TrustStore.receiverRole is the store the receiver path (StreamReceiver, its pairing and QUIC listeners, receiver settings) now uses. It is the same object as shared everywhere except an app that declares MeowDisplayReceiverRoleTrust = separate, where the receiver role gets its own identity and pins — the separation the two standalone apps had. PairingNetwork takes the role's store. The receiver's Auto-Reconnect setting gets a role-scoped key in that app, and SameAppPeerFilter lets each role ignore the other's advertisements. The iPhone/iPad app and standalone receiver behave exactly as before. --- MacReceiver/ReceiverDevicesSettingsView.swift | 4 +- .../ReceiverOverviewSettingsView.swift | 2 +- MacTests/OwnerAuthTests.swift | 7 +- Shared/PairingNetwork.swift | 21 +++--- Shared/QUICReceiverSession.swift | 4 +- Shared/ReceiverRoleConfiguration.swift | 51 +++++++++++++ Shared/StreamReceiver.swift | 54 +++++++++----- Shared/TrustStore.swift | 73 ++++++++++++++----- Shared/WakeTestingView.swift | 2 +- project.yml | 1 + 10 files changed, 161 insertions(+), 58 deletions(-) create mode 100644 Shared/ReceiverRoleConfiguration.swift diff --git a/MacReceiver/ReceiverDevicesSettingsView.swift b/MacReceiver/ReceiverDevicesSettingsView.swift index a3f135850..332b89189 100644 --- a/MacReceiver/ReceiverDevicesSettingsView.swift +++ b/MacReceiver/ReceiverDevicesSettingsView.swift @@ -136,7 +136,7 @@ private struct ReceiverRemoteEndpointEditor: View { @State private var savedConfirmation = false private var pairedMacs: [(peerID: String, displayName: String)] { - TrustStore.shared.pinnedPeers() + TrustStore.receiverRole.pinnedPeers() } private var selectedPeerHasSavedEndpoint: Bool { @@ -233,7 +233,7 @@ private struct ReceiverRemoteEndpointEditor: View { @MainActor enum ReceiverMacList { static func paired() -> [(peerID: String, displayName: String)] { - TrustStore.shared.pinnedPeers() + TrustStore.receiverRole.pinnedPeers() } static func unpairedNearby(_ receiver: StreamReceiver) -> [NWBrowser.Result] { diff --git a/MacReceiver/ReceiverOverviewSettingsView.swift b/MacReceiver/ReceiverOverviewSettingsView.swift index 9d63f7bb1..9724ee0c8 100644 --- a/MacReceiver/ReceiverOverviewSettingsView.swift +++ b/MacReceiver/ReceiverOverviewSettingsView.swift @@ -25,7 +25,7 @@ private struct ReceiverOverviewPage: View { private var connectedMacName: String? { guard receiver.connected, let peerID = receiver.authenticatedPeerID else { return nil } - return TrustStore.shared.pinnedPeers().first { $0.peerID == peerID }?.displayName + return TrustStore.receiverRole.pinnedPeers().first { $0.peerID == peerID }?.displayName } /// Compatibility copy from the sender (issue #132) — surfaced inline; diff --git a/MacTests/OwnerAuthTests.swift b/MacTests/OwnerAuthTests.swift index 53d04a477..9a12742cb 100644 --- a/MacTests/OwnerAuthTests.swift +++ b/MacTests/OwnerAuthTests.swift @@ -214,13 +214,14 @@ final class OwnerAuthTests: XCTestCase { XCTAssertLessThanOrEqual(OwnerAuthPolicy.reason(peerName: String(repeating: "x", count: 500)).count, 90) } - /// Both apps must actually wire the real authenticator into the prompt. + /// Both apps must actually wire the real authenticator into the prompt, + /// and each role's prompt must consult that role's own trust store. func testAppsInstallTheRealAuthenticator() throws { let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() - for path in ["Shared/StreamReceiver.swift", "Mac/OpenSidecarMacApp.swift"] { + for (path, store) in [("Shared/StreamReceiver.swift", "receiverRole"), ("Mac/OpenSidecarMacApp.swift", "shared")] { let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) XCTAssertTrue(source.contains("ownerAuthenticator = LocalOwnerAuthenticator()"), path) - XCTAssertTrue(source.contains("trustPinLookup = { TrustStore.shared.pin(peerID: $0) }"), path) + XCTAssertTrue(source.contains("trustPinLookup = { TrustStore.\(store).pin(peerID: $0) }"), path) } } diff --git a/Shared/PairingNetwork.swift b/Shared/PairingNetwork.swift index bd54655c2..139aa162a 100644 --- a/Shared/PairingNetwork.swift +++ b/Shared/PairingNetwork.swift @@ -69,6 +69,7 @@ enum PairingNetwork { allowIdentityChange: Bool = false, isCurrent: @escaping @Sendable () -> Bool = { true }, remoteHost: String? = nil, attemptToken: UUID = UUID(), + store: TrustStore = .shared, onHandshakeReached: (@Sendable () -> Void)? = nil) async throws -> PendingPairing { let (transport, gate, slot) = await beginAttempt(connection: connection, prompt: prompt, token: attemptToken) defer { Task { @MainActor in prompt.attemptEnded(token: attemptToken) } } @@ -76,7 +77,7 @@ enum PairingNetwork { defer { timeout.cancel() } try await waitUntilReady(connection) Log.info("pairDebug: initiator connected") - guard let spki = TrustStore.shared.ownSPKI() else { throw PairingError.invalidKey } + guard let spki = store.ownSPKI() else { throw PairingError.invalidKey } let handshake = PairingHandshake(role: .initiator, deviceID: localID, displayName: localName, identitySPKI: spki) // v13 commit-then-reveal: commit to our real hello first, so the @@ -89,7 +90,7 @@ enum PairingNetwork { if let expectedPeerID, peerHello.deviceID != expectedPeerID { throw PairingError.identityChanged } try await PairingFraming.send(.helloReveal(handshake.localHello), on: connection) let result = try handshake.result(peerHello: peerHello) - var pending = classify(result.pending) + var pending = classify(result.pending, store: store) try PairingClassifier.check(pending, allowIdentityChange: allowIdentityChange) if let remoteHost { pending.changesRemoteEndpoint = RemoteHintChange.wouldChange( @@ -110,7 +111,7 @@ enum PairingNetwork { }, onPeerAbort: { Task { @MainActor in prompt.peerAborted(token: attemptToken) } }, onCommitting: { Task { @MainActor in prompt.markCommitting(token: attemptToken) } }, - store: TrustStore.shared, isCurrent: isCurrent, remoteHost: remoteHost, + store: store, isCurrent: isCurrent, remoteHost: remoteHost, hints: remoteHost == nil ? nil : RemoteEndpointStoreHints()) logFinalized(pending) return pending @@ -121,7 +122,8 @@ enum PairingNetwork { allowIdentityChange: Bool = false, isCurrent: @escaping @Sendable () -> Bool = { true }, attemptToken: UUID = UUID(), - throttle: PairingCommitThrottleGate = .shared) async throws -> PendingPairing { + throttle: PairingCommitThrottleGate = .shared, + store: TrustStore = .shared) async throws -> PendingPairing { let (transport, gate, slot) = await beginAttempt(connection: connection, prompt: prompt, token: attemptToken) defer { Task { @MainActor in prompt.attemptEnded(token: attemptToken) } } let timeout = pairingTimeout(for: connection) @@ -148,7 +150,7 @@ enum PairingNetwork { // this defer already resolved the throttle on success. defer { if !throttleResolved { Task { await throttle.revealFailed() } } } - guard let spki = TrustStore.shared.ownSPKI() else { throw PairingError.invalidKey } + guard let spki = store.ownSPKI() else { throw PairingError.invalidKey } let handshake = PairingHandshake(role: .responder, deviceID: localID, displayName: localName, identitySPKI: spki) guard isCurrent(), !gate.isCancelled else { throw PairingError.rejected } @@ -160,7 +162,7 @@ enum PairingNetwork { throw PairingError.commitmentMismatch } let result = try handshake.result(peerHello: peerHello) - let pending = classify(result.pending) + let pending = classify(result.pending, store: store) try PairingClassifier.check(pending, allowIdentityChange: allowIdentityChange) guard isCurrent(), !gate.isCancelled else { throw PairingError.rejected } // Reveal matched, hello validated, guards passed: this attempt no @@ -180,7 +182,7 @@ enum PairingNetwork { }, onPeerAbort: { Task { @MainActor in prompt.peerAborted(token: attemptToken) } }, onCommitting: { Task { @MainActor in prompt.markCommitting(token: attemptToken) } }, - store: TrustStore.shared, isCurrent: isCurrent) + store: store, isCurrent: isCurrent) // The ceremony reached authenticated trust completion (commit // verified and `PairingFinalizer` ran inside `PairingSessionCore.run`) // for THIS admitted attempt: only now is prior failure history @@ -249,8 +251,9 @@ enum PairingNetwork { /// gates whether the request is shown, only what the prompt says. An /// existing pin for this peer ID is exactly the case the UI must call /// out (re-pair, or identity-changed), never silently skip. - private static func classify(_ pending: PendingPairing) -> PendingPairing { - PairingClassifier.classify(pending, existingPin: TrustStore.shared.pin(peerID: pending.peerID)) + /// `store` is the role's trust store (see `TrustStore.receiverRole`). + private static func classify(_ pending: PendingPairing, store: TrustStore) -> PendingPairing { + PairingClassifier.classify(pending, existingPin: store.pin(peerID: pending.peerID)) } private static func waitUntilReady(_ connection: NWConnection) async throws { diff --git a/Shared/QUICReceiverSession.swift b/Shared/QUICReceiverSession.swift index f1a644039..0c7145da9 100644 --- a/Shared/QUICReceiverSession.swift +++ b/Shared/QUICReceiverSession.swift @@ -497,7 +497,7 @@ enum QUICReceiverListener { guard QUICRuntimeAvailability.isAvailable, !context.hasCurrent(), let quic = TLSConfigurator.pinnedQUICOptions( identity: identity, - pinnedSPKIs: { TrustStore.shared.allPinnedPeerSPKIs() }, + pinnedSPKIs: { TrustStore.receiverRole.allPinnedPeerSPKIs() }, isListener: true, queue: queue) else { return } configureStreamLimits(quic) do { @@ -541,7 +541,7 @@ enum QUICReceiverListener { Log.info("QUIC listener failed: \(error)") guard let listener, context.clearIfCurrent(listener) else { return } queue.asyncAfter(deadline: .now() + 1) { - guard let identity = TrustStore.shared.ownIdentity() else { return } + guard let identity = TrustStore.receiverRole.ownIdentity() else { return } startIfNeeded( context: context, identity: identity, queue: queue, pairingSuppressionState: pairingSuppressionState, diff --git a/Shared/ReceiverRoleConfiguration.swift b/Shared/ReceiverRoleConfiguration.swift new file mode 100644 index 000000000..26af683f4 --- /dev/null +++ b/Shared/ReceiverRoleConfiguration.swift @@ -0,0 +1,51 @@ +import Foundation + +/// How the receiver role is isolated inside the app that hosts it. The iPhone +/// and iPad app and the standalone Mac Receiver are receivers only: their +/// receiver role IS the app, so it keeps the original Keychain names and +/// settings keys. The unified Mac app hosts both roles in one bundle and +/// declares `MeowDisplayReceiverRoleTrust` = `separate` in its Info.plist, so +/// its receiver role gets its own identity, pins and the few settings whose +/// names the sender role also uses. +enum ReceiverRoleConfiguration { + static let infoKey = "MeowDisplayReceiverRoleTrust" + + static let separateTrust = separateTrust(infoDictionary: Bundle.main.infoDictionary) + + static func separateTrust(infoDictionary: [String: Any]?) -> Bool { + infoDictionary?[infoKey] as? String == "separate" + } + + /// A receiver-role settings key. Unchanged in a receiver-only app; + /// prefixed in the unified app, where the sender role owns the plain name. + static func defaultsKey(_ key: String) -> String { + defaultsKey(key, separate: separateTrust) + } + + static func defaultsKey(_ key: String, separate: Bool) -> String { + separate ? "receiver.\(key)" : key + } +} + +/// The unified app's two roles advertise on the same network under +/// different install ids. Neither may discover, list, pair with or dial the +/// other: the sender would otherwise see "this Mac" as a nearby display and +/// the receiver would see it as a Mac to pair with. +enum SameAppPeerFilter { + /// Whether a discovered service (its TXT `id`) is this app's other role. + static func isSameApp(txtID: String?, ownIDs: Set) -> Bool { + guard let txtID else { return false } + return ownIDs.contains(txtID) + } + + static func excludingSameApp(_ results: [T], ownIDs: Set, txtID: (T) -> String?) -> [T] { + guard !ownIDs.isEmpty else { return results } + return results.filter { !isSameApp(txtID: txtID($0), ownIDs: ownIDs) } + } + + /// The receiver role's advertised id in this app, if it ever created + /// one (it is created on the receiver's first start, never by reading). + static func receiverRoleInstallID(defaults: UserDefaults = .standard) -> String? { + ReceiverRoleConfiguration.separateTrust ? defaults.string(forKey: "installID") : nil + } +} diff --git a/Shared/StreamReceiver.swift b/Shared/StreamReceiver.swift index 364817dd5..892cbf248 100644 --- a/Shared/StreamReceiver.swift +++ b/Shared/StreamReceiver.swift @@ -768,12 +768,15 @@ final class StreamReceiver: ObservableObject { /// Wake & Connect on top of them — always bypass it. iOS's Home screen /// and Settings both bind this same published property, so the two stay /// in sync with no separate storage. - @Published var autoReconnectEnabled = UserDefaults.standard.object(forKey: "autoReconnectEnabled") == nil - || UserDefaults.standard.bool(forKey: "autoReconnectEnabled") { + static let autoReconnectKey = ReceiverRoleConfiguration.defaultsKey("autoReconnectEnabled") + // The unified Mac app's sender role owns the plain key (its own + // Auto-Reconnect), so the receiver role's is scoped there. + @Published var autoReconnectEnabled = UserDefaults.standard.object(forKey: StreamReceiver.autoReconnectKey) == nil + || UserDefaults.standard.bool(forKey: StreamReceiver.autoReconnectKey) { didSet { guard autoReconnectEnabled != oldValue else { return } reconnectContext.update { $0.autoReconnectPreferenceEnabled = autoReconnectEnabled } - UserDefaults.standard.set(autoReconnectEnabled, forKey: "autoReconnectEnabled") + UserDefaults.standard.set(autoReconnectEnabled, forKey: Self.autoReconnectKey) Log.info("reconnectPolicy: autoReconnect enabled=\(autoReconnectEnabled)") if autoReconnectEnabled { Log.info("reconnectPolicy: reenabled reevaluatingAvailability") @@ -1463,7 +1466,7 @@ final class StreamReceiver: ObservableObject { init(displayLayer: AVSampleBufferDisplayLayer, deviceKind: String, fallbackServiceName: String, maxEncodeWide: Int? = nil, maxEncodeHigh: Int? = nil, maxFPS: Int? = nil, - isPeerPinned: @escaping @Sendable (String) -> Bool = { TrustStore.shared.hasPin(peerID: $0) }) { + isPeerPinned: @escaping @Sendable (String) -> Bool = { TrustStore.receiverRole.hasPin(peerID: $0) }) { self.displayLayer = displayLayer self.deviceKind = deviceKind self.fallbackServiceName = fallbackServiceName @@ -1496,7 +1499,7 @@ final class StreamReceiver: ObservableObject { tlsListenerState.quic.setAvailabilityObserver { helloState.setQUICAvailable($0) } uiSink.target = self pairingPrompt.ownerAuthenticator = LocalOwnerAuthenticator() - pairingPrompt.trustPinLookup = { TrustStore.shared.pin(peerID: $0) } + pairingPrompt.trustPinLookup = { TrustStore.receiverRole.pin(peerID: $0) } pairingObservation = pairingPrompt.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() } @@ -1516,7 +1519,7 @@ final class StreamReceiver: ObservableObject { let advertisedProtocolVersion = self.advertisedProtocolVersion let selfBox = self.selfBox queue.async { - TrustStore.shared.refreshSnapshot() + TrustStore.receiverRole.refreshSnapshot() Self.startTLSListener( queue: queue, tlsListenerState: tlsListenerState, pairingSuppressionState: pairingSuppressionState, @@ -1546,7 +1549,7 @@ final class StreamReceiver: ObservableObject { } func forgetPeer(_ peerID: String) { - TrustStore.shared.forget(peerID: peerID) + TrustStore.receiverRole.forget(peerID: peerID) IncomingSessionPolicyStore.removePolicy(peerID: peerID) InputAutoRequestStore.remove(peerID: peerID) let uiSink = self.uiSink @@ -1625,7 +1628,8 @@ final class StreamReceiver: ObservableObject { let paired = try await PairingNetwork.runInitiator( connection: connection, localID: Self.installID, localName: serviceName, prompt: pairingPrompt, - expectedPeerID: expectedPeerID, allowIdentityChange: false) + expectedPeerID: expectedPeerID, allowIdentityChange: false, + store: .receiverRole) pairingPrompt.finish(String(localized: "Paired with \(paired.peerName)")) notePairingSucceeded() finishExplicitPairing(success: true) @@ -1677,6 +1681,7 @@ final class StreamReceiver: ObservableObject { localName: serviceName, prompt: pairingPrompt, allowIdentityChange: false, isCurrent: { validity.isValid }, remoteHost: endpoint.host, attemptToken: token, + store: .receiverRole, onHandshakeReached: { handshakeReached.set() }) Log.info("remotePairing: attempt generation=\(generation) completed peer=\(paired.peerID)") scheduleTLSListenerRefresh() @@ -1785,7 +1790,7 @@ final class StreamReceiver: ObservableObject { func pairingMacIsPaired(_ result: NWBrowser.Result) -> Bool { guard case .bonjour(let txt) = result.metadata, let id = txt["id"] else { return false } - return TrustStore.shared.hasPin(peerID: id) + return TrustStore.receiverRole.hasPin(peerID: id) } /// The peer install ID a discovered Mac's Bonjour TXT record advertises — @@ -2086,10 +2091,10 @@ final class StreamReceiver: ObservableObject { /// the `pipeline` actor reference, `queue`, and plain wire-identity /// values, so its `newConnectionHandler`/`stateUpdateHandler`/retry /// closures below carry the same zero-`StreamReceiver` capture graph. - /// Trust material stays live and self-free via `TrustStore.shared`/ + /// Trust material stays live and self-free via `TrustStore.receiverRole`/ /// `TLSConfigurator` (both already self-free singletons/statics) — in /// particular `pinnedSPKIs` stays a live closure calling - /// `TrustStore.shared.allPinnedPeerSPKIs()` at handshake time, never a + /// `TrustStore.receiverRole.allPinnedPeerSPKIs()` at handshake time, never a /// snapshotted array, so a pin added mid-session is honored without a /// listener rebuild. The `guard !hasCurrent()` and `install(_:)` below /// happen synchronously with no `await`/`Task`/queue hop between them, @@ -2101,16 +2106,16 @@ final class StreamReceiver: ObservableObject { ) { // The QUIC sibling first: it keeps its own "already listening" guard, // so this also re-arms QUIC whenever the TLS listener is re-armed. - if let identity = TrustStore.shared.ownIdentity() { + if let identity = TrustStore.receiverRole.ownIdentity() { QUICReceiverListener.startIfNeeded( context: tlsListenerState.quic, identity: identity, queue: queue, pairingSuppressionState: pairingSuppressionState, advertisementState: advertisementState, pipeline: pipeline, installID: installID, advertisedProtocolVersion: advertisedProtocolVersion) } - guard !tlsListenerState.hasCurrent(), let identity = TrustStore.shared.ownIdentity(), + guard !tlsListenerState.hasCurrent(), let identity = TrustStore.receiverRole.ownIdentity(), let tls = TLSConfigurator.mutualTLSOptions( identity: identity, - pinnedSPKIs: { TrustStore.shared.allPinnedPeerSPKIs() }, + pinnedSPKIs: { TrustStore.receiverRole.allPinnedPeerSPKIs() }, isListener: true, queue: queue) else { Log.info("secure listener unavailable — refusing network media") return @@ -2247,7 +2252,8 @@ final class StreamReceiver: ObservableObject { do { let paired = try await PairingNetwork.runResponder( connection: connection, localID: installID, - localName: localName, prompt: prompt, allowIdentityChange: false) + localName: localName, prompt: prompt, allowIdentityChange: false, + store: .receiverRole) await prompt.finish(String(localized: "Paired with \(paired.peerName)")) await uiSink.publishPairingSucceeded() Self.finishExplicitPairing( @@ -2440,7 +2446,7 @@ final class StreamReceiver: ObservableObject { // TLS metadata for TCP, QUIC metadata for a QUIC Control stream — // the same pinned SPKI either way (`TLSConfigurator`). guard let spki = TLSConfigurator.authenticatedPeerSPKI(of: connection) else { return nil } - return TrustStore.shared.peerID(forSPKI: spki) + return TrustStore.receiverRole.peerID(forSPKI: spki) } // MARK: - Liveness (ping + watchdog) @@ -4393,7 +4399,7 @@ final class StreamReceiver: ObservableObject { } private func peerName(_ peerID: String?) -> String { - peerID.flatMap { id in TrustStore.shared.pinnedPeers().first { $0.peerID == id }?.displayName } + peerID.flatMap { id in TrustStore.receiverRole.pinnedPeers().first { $0.peerID == id }?.displayName } ?? String(localized: "Your Mac") } @@ -4556,8 +4562,8 @@ final class StreamReceiver: ObservableObject { private static func requestRemoteConnect(peerID: String, intent: SessionInvitationIntent, queue: DispatchQueue) { guard let hint = RemoteEndpointStore.connectRequestEndpoint(forPeerID: peerID), let port = NWEndpoint.Port(rawValue: hint.port), - let pin = TrustStore.shared.pin(peerID: peerID), - let identity = TrustStore.shared.ownIdentity(), + let pin = TrustStore.receiverRole.pin(peerID: peerID), + let identity = TrustStore.receiverRole.ownIdentity(), let tls = TLSConfigurator.mutualTLSOptions( identity: identity, pinnedSPKIs: { [pin] }, @@ -5324,9 +5330,17 @@ final class StreamReceiver: ObservableObject { /// `private(set)`-admitting shape as `applyAuthenticatedPeerIDUpdate` /// above. @MainActor func applyDiscoveredMacsUpdate(_ results: [NWBrowser.Result]) { - discoveredMacs = results + discoveredMacs = SameAppPeerFilter.excludingSameApp(results, ownIDs: Self.sameAppSenderIDs) { result in + if case .bonjour(let txt) = result.metadata { return txt["id"] } + return nil + } } + /// In the unified Mac app, this Mac's own sender role — never a Mac to + /// pair with. Empty in a receiver-only app. + private static let sameAppSenderIDs: Set = ReceiverRoleConfiguration.separateTrust + ? Set([TrustStore.shared.installID()].compactMap { $0 }) : [] + /// `pairingSuccessCount`'s external-boundary mutator for the pairing /// listener's `newConnectionHandler`/nested `Task` — see `ReceiverUISink. /// publishPairingSucceeded`, its sole caller. Exact same +1 as diff --git a/Shared/TrustStore.swift b/Shared/TrustStore.swift index 046ca88c1..2c5cbc052 100644 --- a/Shared/TrustStore.swift +++ b/Shared/TrustStore.swift @@ -41,7 +41,39 @@ import SwiftASN1 /// together with either. All three locks and `shared` are `let`s; `shared`'s /// one-time initialization is guaranteed by Swift's `static let` semantics. final class TrustStore: PeerTrustStoring, @unchecked Sendable { - static let shared = TrustStore() + /// Which Keychain items a store owns. Each namespace is a complete, + /// independent identity + pin set: nothing pinned in one authorizes a + /// peer in another. + struct Namespace: Equatable, Sendable { + let identityLabel: String + let pinService: String + + /// The original names — every existing install's identity and pins. + static let standard = Namespace(identityLabel: WireCrypto.identityKeychainLabel, + pinService: WireCrypto.pinKeychainService) + + /// The unified Mac app's receiver role ("Use This Mac as a Display"): + /// its own identity and pins, so pairing a Mac as this Mac's receiver + /// never makes it a trusted sender here, or the other way round — + /// the same separation the two standalone apps had. + static let macReceiverRole = Namespace(identityLabel: WireCrypto.identityKeychainLabel + ".receiver", + pinService: WireCrypto.pinKeychainService + ".receiver") + } + + /// The sender's (and the iPhone/iPad app's) store: unchanged names, so + /// existing identities and pins are found exactly as before. + static let shared = TrustStore(namespace: .standard) + + /// The store the receiver role uses (`StreamReceiver`, its pairing and + /// QUIC listener, receiver settings). The same object as `shared` in + /// every app except one that declares `MeowDisplayReceiverRoleTrust` = + /// `separate` in its Info.plist (the unified Mac app), where the receiver + /// role gets its own namespace. Read once, from the bundle, so it can't + /// change while anything holds keys from it. + static let receiverRole: TrustStore = ReceiverRoleConfiguration.separateTrust + ? TrustStore(namespace: .macReceiverRole) : shared + + let namespace: Namespace /// Serializes own-identity lookup + generation (`ownIdentity`, /// `installID`). Held across Keychain I/O. @@ -59,7 +91,8 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { /// Keychain I/O on the queue. private var pinState = PinSnapshotState() - private init() { + private init(namespace: Namespace) { + self.namespace = namespace refreshSnapshot() } @@ -118,7 +151,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { func pin(peerID: String) -> Data? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.pinKeychainService, + kSecAttrService: namespace.pinService, kSecAttrAccount: peerID, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, @@ -155,7 +188,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { } let deleteQuery: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.pinKeychainService, + kSecAttrService: namespace.pinService, kSecAttrAccount: peerID, kSecUseDataProtectionKeychain: true, ] @@ -163,7 +196,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { let addQuery: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.pinKeychainService, + kSecAttrService: namespace.pinService, kSecAttrAccount: peerID, kSecAttrLabel: displayName, kSecValueData: spki, @@ -187,7 +220,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { defer { pinWriteLock.unlock() } let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.pinKeychainService, + kSecAttrService: namespace.pinService, kSecAttrAccount: peerID, kSecUseDataProtectionKeychain: true, ] @@ -207,22 +240,22 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { let deletions: [[CFString: Any]] = [ [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.pinKeychainService, + kSecAttrService: namespace.pinService, kSecUseDataProtectionKeychain: true, ], [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.identityKeychainLabel, + kSecAttrService: namespace.identityLabel, kSecUseDataProtectionKeychain: true, ], [ kSecClass: kSecClassCertificate, - kSecAttrLabel: WireCrypto.identityKeychainLabel, + kSecAttrLabel: namespace.identityLabel, kSecUseDataProtectionKeychain: true, ], [ kSecClass: kSecClassKey, - kSecAttrApplicationTag: Data(WireCrypto.identityKeychainLabel.utf8), + kSecAttrApplicationTag: Data(namespace.identityLabel.utf8), kSecUseDataProtectionKeychain: true, ], ] @@ -241,7 +274,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { func pinnedPeers() -> [(peerID: String, displayName: String)] { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.pinKeychainService, + kSecAttrService: namespace.pinService, kSecReturnAttributes: true, kSecMatchLimit: kSecMatchLimitAll, kSecUseDataProtectionKeychain: true, @@ -315,7 +348,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { private func readPinsFromKeychain() -> [PinSnapshotState.Pin] { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.pinKeychainService, + kSecAttrService: namespace.pinService, kSecReturnAttributes: true, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitAll, @@ -374,7 +407,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { private func queryIdentity() -> SecIdentity? { let query: [CFString: Any] = [ kSecClass: kSecClassIdentity, - kSecAttrLabel: WireCrypto.identityKeychainLabel, + kSecAttrLabel: namespace.identityLabel, kSecReturnRef: true, kSecMatchLimit: kSecMatchLimitOne, kSecUseDataProtectionKeychain: true, @@ -392,7 +425,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { private func queryInstallID() -> String? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.identityKeychainLabel, + kSecAttrService: namespace.identityLabel, kSecAttrAccount: WireCrypto.installIDAccount, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, @@ -466,7 +499,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { var status = SecItemAdd([ kSecClass: kSecClassKey, kSecValueRef: secKey, - kSecAttrApplicationTag: Data(WireCrypto.identityKeychainLabel.utf8), + kSecAttrApplicationTag: Data(namespace.identityLabel.utf8), kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecUseDataProtectionKeychain: true, ] as CFDictionary, nil) @@ -483,7 +516,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { status = SecItemAdd([ kSecClass: kSecClassCertificate, kSecValueRef: secCert, - kSecAttrLabel: WireCrypto.identityKeychainLabel, + kSecAttrLabel: namespace.identityLabel, kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecUseDataProtectionKeychain: true, ] as CFDictionary, nil) @@ -496,7 +529,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { // 6. Persist installID in the SAME namespace (id+key die together). status = SecItemAdd([ kSecClass: kSecClassGenericPassword, - kSecAttrService: WireCrypto.identityKeychainLabel, + kSecAttrService: namespace.identityLabel, kSecAttrAccount: WireCrypto.installIDAccount, kSecValueData: Data(installID.utf8), kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, @@ -514,7 +547,7 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { var out: CFTypeRef? status = SecItemCopyMatching([ kSecClass: kSecClassIdentity, - kSecAttrLabel: WireCrypto.identityKeychainLabel, + kSecAttrLabel: namespace.identityLabel, kSecReturnRef: true, kSecMatchLimit: kSecMatchLimitOne, kSecUseDataProtectionKeychain: true, @@ -540,12 +573,12 @@ final class TrustStore: PeerTrustStoring, @unchecked Sendable { private func deletePartialIdentityItems() { SecItemDelete([ kSecClass: kSecClassKey, - kSecAttrApplicationTag: Data(WireCrypto.identityKeychainLabel.utf8), + kSecAttrApplicationTag: Data(namespace.identityLabel.utf8), kSecUseDataProtectionKeychain: true, ] as CFDictionary) SecItemDelete([ kSecClass: kSecClassCertificate, - kSecAttrLabel: WireCrypto.identityKeychainLabel, + kSecAttrLabel: namespace.identityLabel, kSecUseDataProtectionKeychain: true, ] as CFDictionary) } diff --git a/Shared/WakeTestingView.swift b/Shared/WakeTestingView.swift index d5e1e5618..71d44a712 100644 --- a/Shared/WakeTestingView.swift +++ b/Shared/WakeTestingView.swift @@ -10,7 +10,7 @@ struct WakeTestingView: View { @State private var sendResult: String? private var pairedPeers: [(peerID: String, displayName: String)] { - TrustStore.shared.pinnedPeers() + TrustStore.receiverRole.pinnedPeers() } private var selectedMetadata: WakeMetadata? { diff --git a/project.yml b/project.yml index f6c2927f3..c6caea2f6 100644 --- a/project.yml +++ b/project.yml @@ -180,6 +180,7 @@ targets: - MacReceiver/ReceiverWindowLifecycle.swift - MacReceiver/ReceiverSettingsCategory.swift - MacReceiver/ReceiverInputForwarding.swift + - Shared/ReceiverRoleConfiguration.swift - Shared/ReceiverGesture.swift - Mac/Log.swift - Mac/LogPolicies.swift From c9e8248f96bb3cef2c33784a4c4ff951edbe38fb Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:04:10 +0530 Subject: [PATCH 02/11] feat(mac): use this Mac as a display from MeowDisplay itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeowDisplay.app now also contains the Mac Receiver (its code, not its app entry point or bundle resources) behind one switch, Use This Mac as a Display, in Overview and the menu bar. It is off by default, so an existing install never starts advertising a receiver it didn't ask for. Exactly one role has live sessions: turning it on ends this Mac's sharing sessions without suppressing their reconnect and starts the receiver; while on, startSession — the gate every auto-connect, Connect, receiver connect request and Remote knock goes through — refuses, and turning it off stops the receiver and lets the devices reconnect. That rules out a Mac streaming to itself or two Macs streaming into each other. Each role also filters the other's Bonjour advertisements, the app becomes a regular Command-Tab-able app while the display window is up, quitting sends the receiver's closing message first, and the role doesn't start while the standalone MeowDisplay Receiver is running. The sender's bundle id, macOS 14 floor, Keychain names, settings and Sparkle feed are unchanged; the standalone receiver still builds for macOS 12 with its own id and feed. --- Mac/DisplayRoleSection.swift | 57 ++++++++++ Mac/MacAppRole.swift | 64 ++++++++++++ Mac/MenuBarQuickView.swift | 2 + Mac/OpenSidecarMacApp.swift | 100 +++++++++++++++++- Mac/OverviewSettingsView.swift | 1 + MacReceiver/MacReceiver.swift | 7 ++ MacTests/UnifiedMacAppTests.swift | 167 ++++++++++++++++++++++++++++++ project.yml | 16 +++ 8 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 Mac/DisplayRoleSection.swift create mode 100644 Mac/MacAppRole.swift create mode 100644 MacTests/UnifiedMacAppTests.swift diff --git a/Mac/DisplayRoleSection.swift b/Mac/DisplayRoleSection.swift new file mode 100644 index 000000000..1d25d958e --- /dev/null +++ b/Mac/DisplayRoleSection.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// "Use This Mac as a Display": the unified app's receiver role, one switch +/// away from the sender. Off by default; while on, this Mac shares nothing +/// and waits for another Mac to connect to it as a display. +struct DisplayRoleSection: View { + @ObservedObject var controller: SenderController + @ObservedObject private var receiver = ReceiverController.shared + + var body: some View { + Section { + Toggle("Use This Mac as a Display", isOn: Binding( + get: { controller.receiverModeEnabled }, + set: { controller.setReceiverModeEnabled($0) })) + if let notice = controller.receiverModeNotice { + Text(notice) + .font(.caption) + .foregroundStyle(.orange) + } + if controller.receiverModeEnabled { + LabeledContent("Status", value: receiver.statusTitle) + if receiver.streaming { + Button("Show Window") { receiver.showWindow() } + } + } + } header: { + Text("This Mac as a Display") + } footer: { + Text(controller.receiverModeEnabled + ? "Another Mac can connect and use this Mac as a display, with this Mac's keyboard and trackpad once it allows control. Sharing this Mac to your devices is paused until you turn this off." + : "Let another Mac use this Mac as a display. Sharing this Mac to your devices pauses while it's on.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +/// The same switch in the menu bar's quick view. +struct DisplayRoleMenuToggle: View { + @ObservedObject var controller: SenderController + @ObservedObject private var receiver = ReceiverController.shared + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Toggle("Use This Mac as a Display", isOn: Binding( + get: { controller.receiverModeEnabled }, + set: { controller.setReceiverModeEnabled($0) })) + .toggleStyle(.switch) + .controlSize(.small) + if controller.receiverModeEnabled { + Text(receiver.statusTitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Mac/MacAppRole.swift b/Mac/MacAppRole.swift new file mode 100644 index 000000000..3a0898710 --- /dev/null +++ b/Mac/MacAppRole.swift @@ -0,0 +1,64 @@ +import Foundation + +/// The unified MeowDisplay app's two roles: Share This Mac (the sender, as +/// it always was) and Use This Mac as a Display (the Mac Receiver, hosted in +/// the same bundle). Exactly one has live sessions at a time: while this Mac +/// is a display, the sender starts no sessions — no auto-connect, Connect, +/// receiver connect request or Remote knock — and it resumes when the +/// display role is turned off. That rules out a Mac streaming to itself, two +/// Macs streaming into each other, and input looping between them, without +/// tearing down the sender's listeners or pairing. +struct MacAppRoleState: Equatable { + enum Effect: Equatable { + /// End every sender session without suppressing auto-reconnect, so + /// the devices come back by themselves when the role switches back. + case endSenderSessions + case startReceiver + /// Stop the receiver, sending `closing` to a connected sender. + case stopReceiver + /// Let the sender reconnect its devices. + case resumeSender + } + + static let defaultsKey = "receiverModeEnabled" + + private(set) var receiverEnabled: Bool + + init(receiverEnabled: Bool) { + self.receiverEnabled = receiverEnabled + } + + init(defaults: UserDefaults) { + receiverEnabled = defaults.bool(forKey: Self.defaultsKey) + } + + /// Whether the sender may start (or keep) a session. + var senderMayConnect: Bool { !receiverEnabled } + + /// What launching does: the display role comes back only if the user + /// left it on. Off by default — a sender install never advertises a + /// receiver it didn't ask for. + var launchEffects: [Effect] { receiverEnabled ? [.startReceiver] : [] } + + mutating func setReceiverEnabled(_ enabled: Bool, defaults: UserDefaults) -> [Effect] { + guard enabled != receiverEnabled else { return [] } + receiverEnabled = enabled + defaults.set(enabled, forKey: Self.defaultsKey) + return enabled ? [.endSenderSessions, .startReceiver] : [.stopReceiver, .resumeSender] + } +} + +/// The standalone MeowDisplay Receiver (still shipped for macOS 12–13, and +/// installed by existing users) listens on the same ports and advertises the +/// same services. Two receivers on one Mac would race for them, so the +/// unified app's display role doesn't start while it runs. +enum StandaloneReceiverConflict { + static let bundleIdentifiers: Set = [ + "com.raisecaterror.meowdisplay.mac.receiver", + "com.raisecaterror.meowdisplay.mac.receiver.debug", + ] + + static func isRunning(among runningBundleIdentifiers: [String?]) -> Bool { + runningBundleIdentifiers.contains { $0.map(bundleIdentifiers.contains) ?? false } + } +} diff --git a/Mac/MenuBarQuickView.swift b/Mac/MenuBarQuickView.swift index 0b291763f..59388f6e0 100644 --- a/Mac/MenuBarQuickView.swift +++ b/Mac/MenuBarQuickView.swift @@ -66,6 +66,8 @@ struct MenuBarQuickView: View { } } + DisplayRoleMenuToggle(controller: controller) + Button("Open Settings…") { MacSettingsWindow.show() } .controlSize(.small) diff --git a/Mac/OpenSidecarMacApp.swift b/Mac/OpenSidecarMacApp.swift index 456b0f628..d8d4eab4a 100644 --- a/Mac/OpenSidecarMacApp.swift +++ b/Mac/OpenSidecarMacApp.swift @@ -64,6 +64,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if !StartAtLoginPolicy.wasLaunchedAsLoginItem(), !presentation.showsMenuBarIcon { MacSettingsWindow.show() } + // Use This Mac as a Display comes back only if the user left it on. + controller.applyLaunchRole() + } + + // Quitting while this Mac is a display: tell the sending Mac we're + // closing (it ends the session instead of redialing a dead peer) before + // the process goes — the standalone receiver's quit path, unchanged. + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard ReceiverController.shared.active else { return .terminateNow } + ReceiverController.shared.stop { + DispatchQueue.main.async { NSApp.reply(toApplicationShouldTerminate: true) } + } + return .terminateLater } func applicationWillTerminate(_ notification: Notification) { @@ -402,6 +415,74 @@ final class SenderController: ObservableObject { keepMacAvailableActive = false } + // MARK: - Use This Mac as a Display + + /// Which role has live sessions (see `MacAppRoleState`). + private var roleState = MacAppRoleState(defaults: .standard) + @Published private(set) var receiverModeEnabled = MacAppRoleState(defaults: .standard).receiverEnabled + /// Why the display role couldn't be turned on, until the next attempt. + @Published private(set) var receiverModeNotice: String? + + /// "Use This Mac as a Display". Turning it on ends this Mac's sharing + /// sessions (their devices reconnect when it's turned off) and starts the + /// receiver; turning it off stops the receiver and lets the sender + /// reconnect. + func setReceiverModeEnabled(_ enabled: Bool) { + if enabled, StandaloneReceiverConflict.isRunning( + among: NSWorkspace.shared.runningApplications.map(\.bundleIdentifier)) { + receiverModeNotice = String(localized: "Quit MeowDisplay Receiver first — it's already using this Mac as a display.") + return + } + receiverModeNotice = nil + let effects = roleState.setReceiverEnabled(enabled, defaults: .standard) + receiverModeEnabled = roleState.receiverEnabled + applyRoleEffects(effects) + } + + func applyLaunchRole() { + if roleState.receiverEnabled, StandaloneReceiverConflict.isRunning( + among: NSWorkspace.shared.runningApplications.map(\.bundleIdentifier)) { + Log.info("roleDebug: standalone receiver running — display role not started at launch") + receiverModeNotice = String(localized: "Quit MeowDisplay Receiver first — it's already using this Mac as a display.") + setReceiverModeEnabled(false) + return + } + applyRoleEffects(roleState.launchEffects) + } + + private func applyRoleEffects(_ effects: [MacAppRoleState.Effect]) { + for effect in effects { + switch effect { + case .endSenderSessions: + Log.info("roleDebug: ending \(sessions.count) sender session(s) — this Mac becomes a display") + for session in sessions { + // Not `disconnect(_:)`: that suppresses auto-reconnect, + // and these devices should come back with the role. + session.sender.disconnect { [weak self, weak session] in + guard let self, let session else { return } + self.end(session) + } + } + case .startReceiver: + let receiver = ReceiverController.shared + // An accessory (menu-bar-only) app can't be Command-Tabbed + // to or away from; while the display window is up the app is + // a regular one, so Command-Tab is always the way out. + receiver.onVideoWindowVisibilityChanged = { [weak self] visible in + guard let self else { return } + NSApp.setActivationPolicy(visible || self.presentation.showsDockIcon ? .regular : .accessory) + } + receiver.start() + Log.info("roleDebug: receiver started") + case .stopReceiver: + ReceiverController.shared.stop() + Log.info("roleDebug: receiver stopped") + case .resumeSender: + scheduleAutoConnect() + } + } + } + @Published var sessions: [DeviceSession] = [] private var suppressModeRestart = false /// Per-device Mirror/Extend authority (issue #20). Starts from the stored @@ -969,7 +1050,11 @@ final class SenderController: ObservableObject { browser.browseResultsChangedHandler = { [weak self] results, _ in DispatchQueue.main.async { guard let self else { return } - self.discovered = Array(results) + // This Mac's own "Use This Mac as a Display" role is never + // a device to list or dial. + let ownReceiverIDs = Set([SameAppPeerFilter.receiverRoleInstallID()].compactMap { $0 }) + self.discovered = SameAppPeerFilter.excludingSameApp(Array(results), ownIDs: ownReceiverIDs, + txtID: self.txtID(of:)) self.handleReceiverConnectRequests(in: self.discovered) self.failoverPendingSessions() self.endSessionsWhoseServiceVanished() @@ -1429,7 +1514,9 @@ final class SenderController: ObservableObject { browser.browseResultsChangedHandler = { [weak self] results, changes in Task { @MainActor [weak self] in guard let self else { return } - self.receiverPairingResults = Array(results) + let ownReceiverIDs = Set([SameAppPeerFilter.receiverRoleInstallID()].compactMap { $0 }) + self.receiverPairingResults = SameAppPeerFilter.excludingSameApp( + Array(results), ownIDs: ownReceiverIDs, txtID: self.txtID(of:)) #if DEBUG Log.info("pairing browser changed: results=\(results.count) changes=\(changes.count)") for result in results { @@ -1968,7 +2055,7 @@ final class SenderController: ObservableObject { } private func autoConnect() { - guard autoConnectEnabled else { return } + guard autoConnectEnabled, roleState.senderMayConnect else { return } dedupeSessions() let candidates = autoConnectCandidates autoConnectPolicy.updateAvailableIdentifiers( @@ -2333,6 +2420,13 @@ final class SenderController: ObservableObject { needsSenderApproval: Bool = false, continuation: SessionContinuation? = nil) -> Bool { let targetIdentifiers = identifiers(for: target) + // Use This Mac as a Display: the sender starts nothing meanwhile — + // every path that can start a session funnels through here. + guard roleState.senderMayConnect else { + Log.info("roleDebug: sender session to \(target.sessionID) refused — this Mac is being used as a display") + autoConnectPolicy.finish(attempt) + return false + } guard !autoConnectPolicy.isPairing(targetIdentifiers) else { Log.info("pairDebug: media auto-connect suppressed reason=pairingInProgress") autoConnectPolicy.finish(attempt) diff --git a/Mac/OverviewSettingsView.swift b/Mac/OverviewSettingsView.swift index 05393fece..d85685731 100644 --- a/Mac/OverviewSettingsView.swift +++ b/Mac/OverviewSettingsView.swift @@ -103,6 +103,7 @@ struct OverviewSettingsView: View { } } } + DisplayRoleSection(controller: controller) } .formStyle(.grouped) } diff --git a/MacReceiver/MacReceiver.swift b/MacReceiver/MacReceiver.swift index 93d0335d0..5638ca2e1 100644 --- a/MacReceiver/MacReceiver.swift +++ b/MacReceiver/MacReceiver.swift @@ -56,6 +56,9 @@ final class ReceiverController: ObservableObject { /// window may be closed (the headless-Mirror offer) — the app delegate /// brings it up. var onNeedsAttention: (() -> Void)? + /// The video window appeared (`true`) or went away. The unified app + /// uses it to be a regular, Command-Tab-able app while it's a display. + var onVideoWindowVisibilityChanged: ((Bool) -> Void)? var active: Bool { receiver != nil } @@ -315,6 +318,7 @@ final class ReceiverController: ObservableObject { window = w observeWindow(w) input.attach(to: w) + onVideoWindowVisibilityChanged?(true) enterFullscreen = windowLifecycle.windowCreated() } // Resizes keep the stream's shape; re-set on every show because a @@ -343,8 +347,10 @@ final class ReceiverController: ObservableObject { // closing a full screen window reports its exit. windowLifecycle.windowClosing() stopObservingWindow() + let hadWindow = window != nil window?.close() window = nil + if hadWindow { onVideoWindowVisibilityChanged?(false) } } /// Records the user's green-button choice, and forgets the window when @@ -367,6 +373,7 @@ final class ReceiverController: ObservableObject { $0.windowLifecycle.windowClosing() $0.stopObservingWindow() $0.window = nil + $0.onVideoWindowVisibilityChanged?(false) }, ] } diff --git a/MacTests/UnifiedMacAppTests.swift b/MacTests/UnifiedMacAppTests.swift new file mode 100644 index 000000000..5ae5fc9b8 --- /dev/null +++ b/MacTests/UnifiedMacAppTests.swift @@ -0,0 +1,167 @@ +import XCTest + +/// The unified MeowDisplay app: the sender and "Use This Mac as a Display" +/// in one bundle. Pins what keeps existing installs safe — the sender's +/// identity, pins, settings, bundle id and feed are untouched — and the rules +/// that keep the two roles apart. These run in a hostless, unsigned test +/// bundle: they prove the configuration and decision logic, not live +/// Keychain isolation, TCC or networking on a signed build (see +/// UNIFIED_MAC_APP.md's physical checklist). +final class UnifiedMacAppTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "UnifiedMacAppTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + // MARK: - Roles + + func testDisplayRoleIsOffForEveryExistingInstall() { + let state = MacAppRoleState(defaults: defaults) + XCTAssertFalse(state.receiverEnabled) + XCTAssertTrue(state.senderMayConnect) + XCTAssertEqual(state.launchEffects, [], "a sender install never starts advertising a receiver by itself") + } + + func testTurningTheDisplayRoleOnEndsSharingThenStartsTheReceiver() { + var state = MacAppRoleState(defaults: defaults) + XCTAssertEqual(state.setReceiverEnabled(true, defaults: defaults), [.endSenderSessions, .startReceiver]) + XCTAssertFalse(state.senderMayConnect) + XCTAssertEqual(state.setReceiverEnabled(true, defaults: defaults), [], "idempotent") + } + + func testTurningItOffStopsTheReceiverThenResumesSharing() { + var state = MacAppRoleState(defaults: defaults) + _ = state.setReceiverEnabled(true, defaults: defaults) + XCTAssertEqual(state.setReceiverEnabled(false, defaults: defaults), [.stopReceiver, .resumeSender]) + XCTAssertTrue(state.senderMayConnect) + XCTAssertEqual(state.setReceiverEnabled(false, defaults: defaults), []) + } + + func testTheChoiceSurvivesARelaunch() { + var state = MacAppRoleState(defaults: defaults) + _ = state.setReceiverEnabled(true, defaults: defaults) + let relaunched = MacAppRoleState(defaults: defaults) + XCTAssertTrue(relaunched.receiverEnabled) + XCTAssertEqual(relaunched.launchEffects, [.startReceiver]) + XCTAssertFalse(relaunched.senderMayConnect, "no sender session can start before the receiver is up") + } + + func testRapidTogglingAlwaysEndsInOneConsistentRole() { + var state = MacAppRoleState(defaults: defaults) + var receiverRunning = false + for index in 0..<50 { + for effect in state.setReceiverEnabled(index.isMultiple(of: 3), defaults: defaults) { + switch effect { + case .startReceiver: XCTAssertFalse(receiverRunning); receiverRunning = true + case .stopReceiver: XCTAssertTrue(receiverRunning); receiverRunning = false + case .endSenderSessions, .resumeSender: break + } + } + XCTAssertEqual(receiverRunning, state.receiverEnabled) + XCTAssertEqual(state.senderMayConnect, !receiverRunning) + } + } + + func testTheStandaloneReceiverRunningBlocksTheDisplayRole() { + XCTAssertTrue(StandaloneReceiverConflict.isRunning( + among: ["com.apple.finder", nil, "com.raisecaterror.meowdisplay.mac.receiver"])) + XCTAssertTrue(StandaloneReceiverConflict.isRunning(among: ["com.raisecaterror.meowdisplay.mac.receiver.debug"])) + XCTAssertFalse(StandaloneReceiverConflict.isRunning( + among: ["com.raisecaterror.meowdisplay.mac", "com.raisecaterror.meowdisplay.ios", nil])) + } + + // MARK: - Trust stays per role + + func testTheSendersKeychainNamesAreExactlyTheOriginalOnes() { + // Every existing sender identity and pin lives under these names; a + // change here would orphan them and force every user to re-pair. + XCTAssertEqual(TrustStore.Namespace.standard.identityLabel, "com.opendisplay.identity.v1") + XCTAssertEqual(TrustStore.Namespace.standard.pinService, "com.opendisplay.trust.v1") + XCTAssertEqual(TrustStore.shared.namespace, .standard) + } + + func testTheReceiverRoleHasItsOwnIdentityAndPins() { + let receiver = TrustStore.Namespace.macReceiverRole + XCTAssertNotEqual(receiver.identityLabel, TrustStore.Namespace.standard.identityLabel) + XCTAssertNotEqual(receiver.pinService, TrustStore.Namespace.standard.pinService) + XCTAssertFalse(receiver.pinService == TrustStore.Namespace.standard.identityLabel + || receiver.identityLabel == TrustStore.Namespace.standard.pinService) + } + + func testOnlyTheUnifiedAppSeparatesTheReceiverRole() { + XCTAssertTrue(ReceiverRoleConfiguration.separateTrust(infoDictionary: ["MeowDisplayReceiverRoleTrust": "separate"])) + XCTAssertFalse(ReceiverRoleConfiguration.separateTrust(infoDictionary: [:]), "iPhone/iPad, standalone receiver") + XCTAssertFalse(ReceiverRoleConfiguration.separateTrust(infoDictionary: nil)) + XCTAssertFalse(ReceiverRoleConfiguration.separateTrust(infoDictionary: ["MeowDisplayReceiverRoleTrust": "shared"])) + // This hostless bundle declares nothing: the receiver path keeps the + // shared store, as the iPhone/iPad app and standalone receiver do. + XCTAssertTrue(TrustStore.receiverRole === TrustStore.shared) + } + + func testOnlyTheReceiverRolesReconnectSettingMovesInTheUnifiedApp() { + XCTAssertEqual(ReceiverRoleConfiguration.defaultsKey("autoReconnectEnabled", separate: false), "autoReconnectEnabled") + XCTAssertEqual(ReceiverRoleConfiguration.defaultsKey("autoReconnectEnabled", separate: true), + "receiver.autoReconnectEnabled") + XCTAssertEqual(StreamReceiver.autoReconnectKey, "autoReconnectEnabled", "unchanged outside the unified app") + } + + // MARK: - No self-discovery + + func testEachRoleNeverSeesTheOther() { + let results = ["IPHONE", "OWN-RECEIVER", nil, "OTHER-MAC"] + XCTAssertEqual(SameAppPeerFilter.excludingSameApp(results, ownIDs: ["OWN-RECEIVER"], txtID: { $0 }), + ["IPHONE", nil, "OTHER-MAC"]) + XCTAssertEqual(SameAppPeerFilter.excludingSameApp(results, ownIDs: [], txtID: { $0 }), results) + XCTAssertFalse(SameAppPeerFilter.isSameApp(txtID: nil, ownIDs: ["X"]), "an id-less legacy service is kept") + } + + func testAReceiverOnlyAppHasNoSameAppPeer() { + defaults.set("SOME-ID", forKey: "installID") + XCTAssertNil(SameAppPeerFilter.receiverRoleInstallID(defaults: defaults)) + } + + // MARK: - Packaging + + private static let repoRoot = URL(fileURLWithPath: #filePath) + .resolvingSymlinksInPath().deletingLastPathComponent().deletingLastPathComponent() + + private func targetBlock(_ name: String) throws -> String { + let spec = try String(contentsOf: Self.repoRoot.appendingPathComponent("project.yml"), encoding: .utf8) + let lines = spec.components(separatedBy: "\n") + let targets = try XCTUnwrap(lines.firstIndex(of: "targets:")) + let start = try XCTUnwrap(lines[targets...].firstIndex(of: " \(name):"), name) + let end = lines[(start + 1)...].firstIndex { $0.hasPrefix(" ") && !$0.hasPrefix(" ") && $0.hasSuffix(":") } + ?? lines.endIndex + return lines[start.. Date: Sun, 25 Oct 2026 18:05:18 +0530 Subject: [PATCH 03/11] docs: describe the unified Mac app, its migration and checklist Record why there were two Mac apps, the measured case against one macOS 12+ bundle, how the roles and their trust are kept apart, what existing sender and receiver users see, rollback, and what still needs physical testing. --- ARCHITECTURE.md | 5 +- UNIFIED_MAC_APP.md | 127 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 UNIFIED_MAC_APP.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 932808793..4f2060b98 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,9 +43,9 @@ tracked — edit `project.yml`, never the generated project. | Target | Sources | Role | | --- | --- | --- | -| `OpenSidecarMac` | `Mac/`, `Shared/` | Mac Sender app | +| `OpenSidecarMac` | `Mac/`, `Shared/`, `MacReceiver/` (minus its entry point and resources) | MeowDisplay: the sender, plus Use This Mac as a Display ([UNIFIED_MAC_APP.md](UNIFIED_MAC_APP.md)) | | `OpenSidecariOS` | `iOS/`, `Shared/` (bundles `iOS/PrivacyInfo.xcprivacy`) | iPhone/iPad Receiver app | -| `OpenSidecarMacReceiver` | `MacReceiver/`, `Shared/`, a few `Mac/` utilities | Mac Receiver app | +| `OpenSidecarMacReceiver` | `MacReceiver/`, `Shared/`, a few `Mac/` utilities | Standalone Mac Receiver app, for macOS 12–13 and existing users | | `OpenSidecarMacTests` | `MacTests/` plus selected pure-logic files | Hostless unit tests (run by `.github/workflows/tests.yml`) | The `OpenSidecar*` names are inherited from OpenDisplay and are kept for @@ -389,3 +389,4 @@ hints and an in-memory cooldown. USB never uses QUIC. - [TECHNICAL_NOTES.md](TECHNICAL_NOTES.md) — platform constraints behind the design - [MULTI_DEVICE.md](MULTI_DEVICE.md) — per-device Mirror/Extend and its physical test checklist - [RECEIVER_INPUT.md](RECEIVER_INPUT.md) — Mac Receiver keyboard and trackpad input, and its physical test checklist +- [UNIFIED_MAC_APP.md](UNIFIED_MAC_APP.md) — one Mac app for both roles: design, migration and its physical test checklist diff --git a/UNIFIED_MAC_APP.md b/UNIFIED_MAC_APP.md new file mode 100644 index 000000000..06ae7f3ce --- /dev/null +++ b/UNIFIED_MAC_APP.md @@ -0,0 +1,127 @@ +# One Mac app: Share This Mac and Use This Mac as a Display + +MeowDisplay.app shares this Mac to iPhones, iPads and other Macs, as it +always has. It can now also be the display: **Use This Mac as a Display** +(Overview, or the menu bar) turns on the Mac Receiver role inside the same +app, with the receiving keyboard and trackpad control from +[RECEIVER_INPUT.md](RECEIVER_INPUT.md). The standalone **MeowDisplay +Receiver** keeps shipping for Macs on macOS 12–13. + +Distribution stays signed, notarized direct download with Sparkle. Mac App +Store distribution is out of scope. + +## Why there were two Mac apps + +| Reason | Fundamental? | +| --- | --- | +| The sender needs macOS 14 (ScreenCaptureKit, SMAppService, SwiftUI APIs), while the receiver runs on macOS 12 so old Macs can be displays. | **Yes**, for one bundle covering macOS 12–13 (see below). | +| The sender is a menu-bar (`LSUIElement`) app; the receiver is a windowed app. | No. An accessory app can switch to a regular one while a window is up. | +| Each app keeps its pairing identity in a Keychain access group named after its own bundle id. | No for new installs. It does mean a receiver's existing pairings can't simply carry over (see Migration). | +| Separate bundle ids, Sparkle feeds and release jobs. | No. Historical: the receiver started as a separate target (#145, upstream). | +| Two `@main` entry points and `AppDelegate`s. | No. These are the only symbol clashes between `Mac/` and `MacReceiver/`. | +| Both roles in one process share `TrustStore.shared` and one settings domain. | No, but it needed real work: see Trust. | + +## Options + +**1. Unified macOS 14+ app + standalone receiver for macOS 12–13 (chosen).** +- **Approach:** the sender target also compiles `MacReceiver/` (minus its app entry point and resources). +- **Complexity:** no availability gating, because receiver code written for 12 compiles unchanged at 14. The only side effect is a handful of deprecation warnings (`onChange(of:perform:)`). +- **Builds and tests:** everything in this repository's CI already exercises both targets. + +**2. One macOS 12+ bundle with the sender gated to macOS 14.** Rejected, on +measured evidence. Compiling the current sender at +`MACOSX_DEPLOYMENT_TARGET=12.0` fails with **127 distinct availability errors +across 16 files**, and Swift stops after the first wave, so the real count is +higher: + +| APIs | Errors | +| --- | --- | +| macOS 13 (grouped `Form` style, `SMAppService`, newer SwiftUI) | 85 | +| macOS 14 | 16 | +| ScreenCaptureKit 12.3 | 3 | +| Existing macOS 26/27 paths whose `#available` checks assume a 14 floor | 23 | + +Making it work would mean: +- gating nearly every sender type and view with `@available(macOS 14, *)` +- a launch path that branches between two apps' worth of UI +- a sender whose every future change has to stay gated + +None of the macOS 12–13 paths can be tested here. The receiver users on those +Macs gain nothing over the standalone receiver they already have. + +## How the prototype works + +- **One bundle:** `com.raisecaterror.meowdisplay.mac`, macOS 14, still `LSUIElement`, with the sender's feed and entitlements. `project.yml` adds `MacReceiver/` to the sender target, excluding `OpenSidecarMacReceiverApp.swift`, `Info.plist`, the entitlements, string catalogs and asset catalog. +- **Roles** (`Mac/MacAppRole.swift`): + - Exactly one role has live sessions. + - Turning the display role on ends the sender's sessions without suppressing reconnect, then starts the receiver. + - While it's on, `startSession` refuses. Every auto-connect, Connect, receiver connect request and Remote knock goes through that gate. + - Turning it off stops the receiver and lets devices reconnect. + - This rules out a Mac streaming to itself, two Macs streaming into each other, and input looping between them, without tearing down the sender's listeners or pairing. Simultaneous roles were judged not worth those risks. +- **Off by default:** an existing install never starts advertising a receiver. The choice is remembered across launches. +- **Trust** (`TrustStore.Namespace`, `ReceiverRoleConfiguration`): the receiver role has its own identity and pins under `com.opendisplay.identity.v1.receiver` / `com.opendisplay.trust.v1.receiver`. That's the same separation the two apps had: + - Pairing a Mac as this Mac's display never makes it a trusted sender here, and the reverse. + - The sender's Keychain names are unchanged and pinned by a test. + - The iPhone/iPad app and the standalone receiver don't declare `MeowDisplayReceiverRoleTrust`, so for them `TrustStore.receiverRole` is `TrustStore.shared` and nothing changes. +- **Settings:** the only key both roles used, `autoReconnectEnabled`, is `receiver.autoReconnectEnabled` for the receiver role in this app. The receiver's other keys (`receiverName`, `installID`, `audioPreferred`, `avSyncOffsetMs`, `showAnalytics`) don't collide with the sender's. +- **No self-discovery:** the sender ignores this app's receiver in its discovery and pairing browsers, and the receiver ignores this app's sender in its Mac-pairing browser (`SameAppPeerFilter`). +- **Window and focus:** while the display window is up the app is a regular app (Dock, Command-Tab), so Command-Tab is always a way out. It returns to the user's presentation when the window goes away. +- **Quit:** with the display role on, quitting sends the receiver's `closing` first, as the standalone receiver does. +- **Standalone receiver running:** the role refuses to start (and says why), because both would race for ports 9001/9002 and the same Bonjour services. +- **Unchanged:** pairing, TLS 1.3, pinning, per-session input authorization and the wire protocol. + +## Existing users and migration + +**Sender (MeowDisplay 1.20.0) users: no migration.** The update arrives +through the existing `appcast.xml` feed. +- **Same bundle id and signing team:** existing Screen Recording/Accessibility grants still apply, because TCC keys them to the bundle id and designated requirement. The Keychain group, identity, pins and settings are untouched. +- **New receiver identity:** the receiver role creates its own identity only the first time the user turns it on. + +**Standalone receiver users:** +- Nothing is replaced or removed. The receiver's own feed (`appcast-receiver.xml`) keeps updating it, and macOS 12–13 Macs keep using it. +- On macOS 14+, the unified app's display role is a fresh install of the receiver role: **each sending Mac pairs with it once**. Its identity and pins live in the standalone receiver's Keychain group (`…mac.receiver`). macOS doesn't let another bundle id read that group, and the unified app does not try to. +- A later, optional migration could add a shared access group to both apps. An updated standalone receiver would then hand its identity over once. That copies a private key between apps, so it needs its own security review; the prototype doesn't do it. +- Receiver settings are cosmetic and re-set in seconds. They are not imported: they live in the other app's preferences domain. +- If both are installed on one Mac, the unified app's display role won't start while the standalone receiver runs. Users pick one. + +**Release:** +- **Versions:** the unified app continues the sender's version line (1.21.0 onward); the standalone receiver continues its own. +- **Release workflow and Fastlane:** they build the same two targets as today. Nothing in `release.yml`, Fastlane or the feeds needs to change for the prototype. Release notes should point macOS 14+ receiver users to the unified app. + +**Rollback:** +- Downgrading to 1.20.0 is safe. 1.20.0 ignores the receiver-role Keychain items and the `receiverModeEnabled`, `receiver.autoReconnectEnabled` and `installID` settings, and its sender data was never modified. +- If the role misbehaves after release, ship a sender update with the switch hidden. The receiver-role data stays in its own namespace. + +## Verified (automated, this branch) + +- **macOS suite:** `xcodebuild test -scheme OpenSidecarMac` passes **1960 tests, 0 failures**. That's 1946 on #25's head plus 14 new in `UnifiedMacAppTests`: + - role transitions, persistence and rapid toggling + - the sender's original Keychain names + - receiver-role separation, and receiver-only apps unchanged + - the role-scoped settings key + - same-app discovery filtering + - standalone-receiver conflict + - packaging (bundle id, floor, feed, excludes, standalone receiver at 12.0) +- **Builds:** `OpenSidecarMacReceiver` (macOS 12 target) and `OpenSidecariOS` (generic device) both build. The unified bundle has the sender's id, `LSMinimumSystemVersion` 14.0, `LSUIElement`, `appcast.xml`, `MeowDisplayReceiverRoleTrust = separate`, and the receiver code linked in. +- **Not covered by automation:** live Keychain isolation (the unsigned test bundle has no Keychain entitlement), TCC, Bonjour on a network, and the window and activation-policy behavior. + +## Physical checklist + +Needs two signed builds of this branch (Developer ID or team-signed Debug), on +Mac A (sender) and Mac B (macOS 14+, to be the display). + +1. **Upgrade in place.** On a Mac running 1.20.0 with paired iPhones, install this build: devices reconnect without re-pairing, and Screen Recording/Accessibility stay granted. +2. **Off by default.** Mac B shows nothing on the network until Use This Mac as a Display is on. Mac A's Devices list never shows Mac A itself. +3. **Pair and stream.** On Mac A, pair with Mac B's display role (one code confirmation), connect, Extend, then Mirror. The window goes full screen, Command-Tab works, and #25's control flow works. +4. **Roles exclusive.** With Mac B's display role on, Mac B's own iPhone doesn't connect to Mac B as a sender. Turning the role off brings the iPhone back without re-pairing. +5. **Trust separate.** A Mac paired only with Mac B's sender can't stream to Mac B's display role without pairing to it. +6. **Quit.** Quit Mac B mid-stream: Mac A ends the session rather than redialing. +7. **Standalone receiver.** With MeowDisplay Receiver running on Mac B, the switch refuses with its message. +8. **Rollback.** Reinstall 1.20.0 on Mac A: everything works as before. + +## Remaining work before release + +- **Receiver settings:** the display role's own settings (name, paired senders and Forget, audio, A/V sync) aren't in the unified Settings yet. The prototype uses defaults; pairing prompts and session approvals do appear. +- **Mirror-unavailable offer:** the "Use Extend" alert for a headless sending Mac isn't shown in the unified app yet. That Mirror attempt still fails cleanly when the offer times out. +- **Localization:** the receiver's strings aren't in MeowDisplay's string catalog yet. They're English-only today anyway. +- **Optional migration:** the shared-access-group migration for standalone receiver users, if wanted. From 532380f1a435d9a2d6698bf04063a147b6dd0bd4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:23:09 +0530 Subject: [PATCH 04/11] fix(security): keep each role's approval policy and peer data separate In the unified Mac app both roles read and wrote one "Automatically Allow Connections" key and one per-peer policy dictionary. The sender's setting (on by default) therefore auto-approved any paired Mac connecting to this Mac as a display, and the receiver role's choices changed the sender's. IncomingSessionPolicyStore, RemoteEndpointStore and WakeMetadataStore now take an explicit RolePreferences at every call site. The sender and the receiver-only apps (iPhone/iPad, standalone Mac Receiver) keep the original key names and defaults. The unified app's receiver role uses receiver.- prefixed keys, and its automatic approval defaults to off. Pairing records routing hints in the role of the trust store it pinned into, and the receiver's settings toggle binds the same key the receiver decides with. InputAutoRequestStore's receiver-only key is scoped the same way. RolePreferencesIsolationTests reproduce the leak (12 of 14 failed with the shared keys) and pass with the fix. --- Mac/OpenSidecarMacApp.swift | 32 +-- Mac/RemoteAccessSettingsView.swift | 10 +- Mac/RouteOverridesView.swift | 6 +- MacReceiver/ReceiverDevicesSettingsView.swift | 14 +- MacTests/DemoSessionTests.swift | 2 +- MacTests/ForgetDeviceCleanupTests.swift | 20 +- MacTests/RemoteEndpointStoreTests.swift | 42 ++-- MacTests/RolePreferencesIsolationTests.swift | 212 ++++++++++++++++++ .../SessionAuthorityRevocationTests.swift | 8 +- MacTests/SessionInvitationTests.swift | 70 +++--- .../StreamReceiverSessionAuthorityTests.swift | 46 ++-- MacTests/ThirdQARefinementTests.swift | 2 +- MacTests/WakeOnLANTests.swift | 24 +- Shared/PairingNetwork.swift | 7 +- Shared/ReceiverInputPrompt.swift | 3 +- Shared/ReceiverRoleConfiguration.swift | 38 ++++ Shared/RemoteEndpointStore.swift | 39 ++-- Shared/RemotePairing.swift | 5 +- Shared/SessionInvitation.swift | 72 ++++-- Shared/SessionInvitationViews.swift | 14 +- Shared/StreamReceiver.swift | 14 +- Shared/WakeConnectCoordinator.swift | 2 +- Shared/WakeOnLAN.swift | 27 +-- Shared/WakeTestingView.swift | 2 +- iOS/OpenSidecarPhoneApp.swift | 6 +- iOS/RemoteAccessSettingsView.swift | 10 +- 26 files changed, 511 insertions(+), 216 deletions(-) create mode 100644 MacTests/RolePreferencesIsolationTests.swift diff --git a/Mac/OpenSidecarMacApp.swift b/Mac/OpenSidecarMacApp.swift index d8d4eab4a..d2b318889 100644 --- a/Mac/OpenSidecarMacApp.swift +++ b/Mac/OpenSidecarMacApp.swift @@ -651,8 +651,8 @@ final class SenderController: ObservableObject { /// Incoming session requests from paired receivers connect without a /// prompt unless a per-device policy overrides this. Not input /// permission, not Auto-Reconnect. - @Published var automaticallyAllowConnections = IncomingSessionPolicyStore.automaticallyAllow() { - didSet { IncomingSessionPolicyStore.setAutomaticallyAllow(automaticallyAllowConnections) } + @Published var automaticallyAllowConnections = IncomingSessionPolicyStore.automaticallyAllow(role: .sender) { + didSet { IncomingSessionPolicyStore.setAutomaticallyAllow(automaticallyAllowConnections, role: .sender) } } @Published var allowInput = InputPolicy.allowsInput() { @@ -1495,7 +1495,7 @@ final class SenderController: ObservableObject { if let localResult = discovered.first(where: { txtID(of: $0) == peerID }) { if dial(.wifi(localResult), via: "local Bonjour") { return } } - if RemoteEndpointStore.endpoint(forPeerID: peerID) != nil { + if RemoteEndpointStore.endpoint(forPeerID: peerID, role: .sender) != nil { if dial(.remote(peerID: peerID), via: "persisted Remote hint") { return } } guard let observedHost else { @@ -1591,7 +1591,7 @@ final class SenderController: ObservableObject { if localStarted { continue } // A present-but-blocked/unusable local record must not end // route evaluation: fall through to a configured Remote hint. - let endpointFound = RemoteEndpointStore.endpoint(forPeerID: peerID) != nil + let endpointFound = RemoteEndpointStore.endpoint(forPeerID: peerID, role: .sender) != nil guard endpointFound else { continue } connect(to: .remote(peerID: peerID), userInitiated: true, initiator: .receiver, needsSenderApproval: needsApproval) @@ -1624,7 +1624,7 @@ final class SenderController: ObservableObject { if let hostOverride, let portOverride { resolvedHost = hostOverride resolvedPort = portOverride - } else if let hint = RemoteEndpointStore.endpoint(forPeerID: peerID) { + } else if let hint = RemoteEndpointStore.endpoint(forPeerID: peerID, role: .sender) { resolvedHost = hint.host resolvedPort = hint.port } else { @@ -1776,15 +1776,15 @@ final class SenderController: ObservableObject { func forgetPairing(peerID: String) { Log.info("trustDebug: forgetPairingCalled peerID=\(peerID)") Log.info("trustDebug: trustBefore=\(TrustStore.shared.hasPin(peerID: peerID))") - Log.info("trustDebug: remoteEndpointBefore=\(RemoteEndpointStore.endpoint(forPeerID: peerID) != nil)") - Log.info("trustDebug: wakeMetadataBefore=\(WakeMetadataStore.metadata(forPeerID: peerID) != nil)") + Log.info("trustDebug: remoteEndpointBefore=\(RemoteEndpointStore.endpoint(forPeerID: peerID, role: .sender) != nil)") + Log.info("trustDebug: wakeMetadataBefore=\(WakeMetadataStore.metadata(forPeerID: peerID, role: .sender) != nil)") ForgetDeviceAction.perform( peerID: peerID, forgetTrust: { TrustStore.shared.forget(peerID: $0) }, - removeRemoteEndpoint: { RemoteEndpointStore.removeEndpoint(forPeerID: $0) }, - removeWakeMetadata: { WakeMetadataStore.removeMetadata(forPeerID: $0) }, + removeRemoteEndpoint: { RemoteEndpointStore.removeEndpoint(forPeerID: $0, role: .sender) }, + removeWakeMetadata: { WakeMetadataStore.removeMetadata(forPeerID: $0, role: .sender) }, removeInputAuthorization: { ReceiverInputAuthorizationStore.removePolicy(peerID: $0) }, - removeSessionPolicy: { IncomingSessionPolicyStore.removePolicy(peerID: $0) }, + removeSessionPolicy: { IncomingSessionPolicyStore.removePolicy(peerID: $0, role: .sender) }, removeNetworkTransportState: { NetworkTransportPreferenceStore.remove(peerID: $0) PeerQUICCapabilityStore.remove(peerID: $0) @@ -1815,8 +1815,8 @@ final class SenderController: ObservableObject { Log.info("trustDebug: trustDeleteResult=\(!trustAfter)") Log.info("trustDebug: trustAfter=\(trustAfter)") Log.info("trustDebug: pinnedPeersContainsAfter=\(TrustStore.shared.pinnedPeers().contains { $0.peerID == peerID })") - Log.info("trustDebug: remoteEndpointAfter=\(RemoteEndpointStore.endpoint(forPeerID: peerID) != nil)") - Log.info("trustDebug: wakeMetadataAfter=\(WakeMetadataStore.metadata(forPeerID: peerID) != nil)") + Log.info("trustDebug: remoteEndpointAfter=\(RemoteEndpointStore.endpoint(forPeerID: peerID, role: .sender) != nil)") + Log.info("trustDebug: wakeMetadataAfter=\(WakeMetadataStore.metadata(forPeerID: peerID, role: .sender) != nil)") Log.info("deviceUI: knownPeer removed peerID=\(peerID)") pairingMessage = "Device forgotten" objectWillChange.send() @@ -1969,7 +1969,7 @@ final class SenderController: ObservableObject { // hint for, and only when no better local candidate for the same // logical device exists (the priority sort + de-dupe in autoConnect() // handles that). - candidates += RemoteEndpointStore.allPeerIDs().compactMap { peerID -> AutoConnectCandidate? in + candidates += RemoteEndpointStore.allPeerIDs(role: .sender).compactMap { peerID -> AutoConnectCandidate? in guard TrustStore.shared.hasPin(peerID: peerID) else { return nil } let target = ConnectionTarget.remote(peerID: peerID) let logicalID = logicalID(for: target) @@ -3096,7 +3096,7 @@ final class SenderController: ObservableObject { /// `ReceiverInputAuthorizationStore.setPolicy`. @discardableResult func sessionPolicy(peerID: String) -> IncomingSessionPeerPolicy? { - IncomingSessionPolicyStore.policy(peerID: peerID) + IncomingSessionPolicyStore.policy(peerID: peerID, role: .sender) } /// Same self-authorization guard as `setInputPolicy`: while any device @@ -3104,7 +3104,7 @@ final class SenderController: ObservableObject { @discardableResult func setSessionPolicy(_ policy: IncomingSessionPeerPolicy?, peerID: String) -> Bool { guard policy != .alwaysAllow || !anySessionHasEffectiveInput else { return false } - IncomingSessionPolicyStore.setPolicy(policy, peerID: peerID) + IncomingSessionPolicyStore.setPolicy(policy, peerID: peerID, role: .sender) if policy == .blocked { revokeIncomingSessionAuthority(peerID: peerID) } objectWillChange.send() return true @@ -3280,7 +3280,7 @@ final class SenderController: ObservableObject { // after it was raised: a late answer neither admits nor persists. guard PendingApprovalRevalidation.isStillAuthorized( isPinned: TrustStore.shared.hasPin(peerID: approval.peerID), - currentPolicy: IncomingSessionPolicyStore.policy(peerID: approval.peerID)) else { + currentPolicy: IncomingSessionPolicyStore.policy(peerID: approval.peerID, role: .sender)) else { Log.info("sessionInvite: late answer for revoked peer \(approval.peerID) ignored id=\(approval.id)") session.sender.resolveSenderApproval(accept: false) disconnect(session) diff --git a/Mac/RemoteAccessSettingsView.swift b/Mac/RemoteAccessSettingsView.swift index 09dde9d98..133824987 100644 --- a/Mac/RemoteAccessSettingsView.swift +++ b/Mac/RemoteAccessSettingsView.swift @@ -67,7 +67,7 @@ struct RemoteEndpointEditorView: View { } private var selectedPeerHasSavedEndpoint: Bool { - !peerID.isEmpty && RemoteEndpointStore.endpoint(forPeerID: peerID) != nil + !peerID.isEmpty && RemoteEndpointStore.endpoint(forPeerID: peerID, role: .sender) != nil } private var statusText: String? { @@ -134,7 +134,7 @@ struct RemoteEndpointEditorView: View { private func loadSavedEndpoint(for peerID: String) { errorMessage = nil savedConfirmation = false - guard let hint = RemoteEndpointStore.endpoint(forPeerID: peerID) else { + guard let hint = RemoteEndpointStore.endpoint(forPeerID: peerID, role: .sender) else { host = "" port = String(WireCrypto.remoteRequestPort) return @@ -146,11 +146,11 @@ struct RemoteEndpointEditorView: View { private func save() { switch RemoteEndpointValidation.validate(host: host, port: port) { case .success(let value): - RemoteEndpointStore.setEndpoint(value.host, port: value.port, forPeerID: peerID) + RemoteEndpointStore.setEndpoint(value.host, port: value.port, forPeerID: peerID, role: .sender) // Read back from the canonical store rather than trusting the // local write — if persistence actually failed, show that // instead of claiming success. - guard let readBack = RemoteEndpointStore.endpoint(forPeerID: peerID), + guard let readBack = RemoteEndpointStore.endpoint(forPeerID: peerID, role: .sender), readBack.host == value.host, readBack.port == value.port else { errorMessage = String(localized: "Couldn't save this endpoint. Try again.") return @@ -170,7 +170,7 @@ struct RemoteEndpointEditorView: View { } private func remove() { - RemoteEndpointStore.removeEndpoint(forPeerID: peerID) + RemoteEndpointStore.removeEndpoint(forPeerID: peerID, role: .sender) host = "" port = String(WireCrypto.remoteRequestPort) errorMessage = nil diff --git a/Mac/RouteOverridesView.swift b/Mac/RouteOverridesView.swift index ebc462017..844adada4 100644 --- a/Mac/RouteOverridesView.swift +++ b/Mac/RouteOverridesView.swift @@ -45,12 +45,12 @@ struct RemoteEndpointDebugView: View { HStack { Button("Save") { guard !peerID.isEmpty, let portNum = UInt16(port), !host.isEmpty else { return } - RemoteEndpointStore.setEndpoint(host, port: portNum, forPeerID: peerID) + RemoteEndpointStore.setEndpoint(host, port: portNum, forPeerID: peerID, role: .sender) } .disabled(peerID.isEmpty || host.isEmpty || UInt16(port) == nil) Button("Forget") { guard !peerID.isEmpty else { return } - RemoteEndpointStore.removeEndpoint(forPeerID: peerID) + RemoteEndpointStore.removeEndpoint(forPeerID: peerID, role: .sender) host = "" port = "9001" } @@ -62,7 +62,7 @@ struct RemoteEndpointDebugView: View { // this view previously left the fields blank/stale here, which is // what made a successful Save look like it hadn't persisted. .onChange(of: peerID) { _, newValue in - guard let hint = RemoteEndpointStore.endpoint(forPeerID: newValue) else { + guard let hint = RemoteEndpointStore.endpoint(forPeerID: newValue, role: .sender) else { host = "" port = "9001" return diff --git a/MacReceiver/ReceiverDevicesSettingsView.swift b/MacReceiver/ReceiverDevicesSettingsView.swift index 332b89189..833175751 100644 --- a/MacReceiver/ReceiverDevicesSettingsView.swift +++ b/MacReceiver/ReceiverDevicesSettingsView.swift @@ -140,7 +140,7 @@ private struct ReceiverRemoteEndpointEditor: View { } private var selectedPeerHasSavedEndpoint: Bool { - !peerID.isEmpty && RemoteEndpointStore.endpoint(forPeerID: peerID) != nil + !peerID.isEmpty && RemoteEndpointStore.endpoint(forPeerID: peerID, role: .receiverRole) != nil } var body: some View { @@ -185,7 +185,7 @@ private struct ReceiverRemoteEndpointEditor: View { private func loadSavedEndpoint(for peerID: String) { errorMessage = nil savedConfirmation = false - guard let hint = RemoteEndpointStore.endpoint(forPeerID: peerID) else { + guard let hint = RemoteEndpointStore.endpoint(forPeerID: peerID, role: .receiverRole) else { host = "" port = String(WireCrypto.remoteRequestPort) return @@ -197,8 +197,8 @@ private struct ReceiverRemoteEndpointEditor: View { private func save() { switch RemoteEndpointValidation.validate(host: host, port: port) { case .success(let value): - RemoteEndpointStore.setEndpoint(value.host, port: value.port, forPeerID: peerID) - guard let readBack = RemoteEndpointStore.endpoint(forPeerID: peerID), + RemoteEndpointStore.setEndpoint(value.host, port: value.port, forPeerID: peerID, role: .receiverRole) + guard let readBack = RemoteEndpointStore.endpoint(forPeerID: peerID, role: .receiverRole), readBack.host == value.host, readBack.port == value.port else { errorMessage = String(localized: "Couldn't save this endpoint. Try again.") return @@ -219,7 +219,7 @@ private struct ReceiverRemoteEndpointEditor: View { /// Removing the saved endpoint never touches the pairing itself. private func remove() { - RemoteEndpointStore.removeEndpoint(forPeerID: peerID) + RemoteEndpointStore.removeEndpoint(forPeerID: peerID, role: .receiverRole) host = "" port = String(WireCrypto.remoteRequestPort) errorMessage = nil @@ -263,7 +263,7 @@ struct ReceiverPairedMacRow: View { var body: some View { let isConnected = receiver.connected && receiver.authenticatedPeerID == peerID let isNearby = receiver.discoveredMacs.contains { receiver.pairingMacPeerID($0) == peerID } - let canWake = WakeMetadataStore.metadata(forPeerID: peerID)?.broadcastAddress != nil + let canWake = WakeMetadataStore.metadata(forPeerID: peerID, role: .receiverRole)?.broadcastAddress != nil HStack(alignment: .firstTextBaseline) { Circle() .fill(isConnected ? Color.green : Color.secondary.opacity(0.5)) @@ -312,7 +312,7 @@ struct ReceiverPairedMacRow: View { } } if isNearby { return String(localized: "Paired · Nearby") } - if RemoteEndpointStore.endpoint(forPeerID: peerID) != nil { return String(localized: "Paired · Remote Access") } + if RemoteEndpointStore.endpoint(forPeerID: peerID, role: .receiverRole) != nil { return String(localized: "Paired · Remote Access") } return String(localized: "Paired · Not nearby") } } diff --git a/MacTests/DemoSessionTests.swift b/MacTests/DemoSessionTests.swift index ba0dae781..3130b901a 100644 --- a/MacTests/DemoSessionTests.swift +++ b/MacTests/DemoSessionTests.swift @@ -23,7 +23,7 @@ final class DemoSessionTests: XCTestCase { XCTAssertFalse(TrustStore.shared.hasPin(peerID: DemoSession.peerID)) XCTAssertFalse(TrustStore.shared.pinnedPeers().contains { $0.peerID == DemoSession.peerID }) - XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: DemoSession.peerID)) + XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: DemoSession.peerID, role: .receiverRole)) XCTAssertFalse(InputAutoRequestStore.isEnabled(peerID: DemoSession.peerID)) XCTAssertEqual(standardDefaultsSnapshot(), before) } diff --git a/MacTests/ForgetDeviceCleanupTests.swift b/MacTests/ForgetDeviceCleanupTests.swift index 295207698..a86e9d9f1 100644 --- a/MacTests/ForgetDeviceCleanupTests.swift +++ b/MacTests/ForgetDeviceCleanupTests.swift @@ -29,23 +29,23 @@ final class ForgetDeviceCleanupTests: XCTestCase { let forgotten = peerID() let kept = peerID() - RemoteEndpointStore.setEndpoint("100.1.1.1", port: 9001, forPeerID: forgotten) - RemoteEndpointStore.setEndpoint("100.2.2.2", port: 9001, forPeerID: kept) + RemoteEndpointStore.setEndpoint("100.1.1.1", port: 9001, forPeerID: forgotten, role: .sender) + RemoteEndpointStore.setEndpoint("100.2.2.2", port: 9001, forPeerID: kept, role: .sender) WakeMetadataStore.setMetadata( WakeMetadata(macAddress: "aa:aa:aa:aa:aa:aa", interfaceName: "en0", - updatedAt: Date()), forPeerID: forgotten) + updatedAt: Date()), forPeerID: forgotten, role: .sender) WakeMetadataStore.setMetadata( WakeMetadata(macAddress: "bb:bb:bb:bb:bb:bb", interfaceName: "en0", - updatedAt: Date()), forPeerID: kept) + updatedAt: Date()), forPeerID: kept, role: .sender) // Mirrors the cleanup forgetPairing(peerID:) performs. - RemoteEndpointStore.removeEndpoint(forPeerID: forgotten) - WakeMetadataStore.removeMetadata(forPeerID: forgotten) + RemoteEndpointStore.removeEndpoint(forPeerID: forgotten, role: .sender) + WakeMetadataStore.removeMetadata(forPeerID: forgotten, role: .sender) - XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: forgotten)) - XCTAssertNil(WakeMetadataStore.metadata(forPeerID: forgotten)) - XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: kept)?.host, "100.2.2.2") - XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: kept)?.macAddress, "bb:bb:bb:bb:bb:bb") + XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: forgotten, role: .sender)) + XCTAssertNil(WakeMetadataStore.metadata(forPeerID: forgotten, role: .sender)) + XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: kept, role: .sender)?.host, "100.2.2.2") + XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: kept, role: .sender)?.macAddress, "bb:bb:bb:bb:bb:bb") } func testForgetActionUsesTheExactStablePeerIDForEveryCleanup() { diff --git a/MacTests/RemoteEndpointStoreTests.swift b/MacTests/RemoteEndpointStoreTests.swift index a387bd098..357607103 100644 --- a/MacTests/RemoteEndpointStoreTests.swift +++ b/MacTests/RemoteEndpointStoreTests.swift @@ -12,55 +12,55 @@ final class RemoteEndpointStoreTests: XCTestCase { func testRoundTripsHostAndPort() { let peer = peerID() - XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: peer)) - RemoteEndpointStore.setEndpoint("100.101.102.103", port: 9001, forPeerID: peer) - let hint = RemoteEndpointStore.endpoint(forPeerID: peer) + XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: peer, role: .sender)) + RemoteEndpointStore.setEndpoint("100.101.102.103", port: 9001, forPeerID: peer, role: .sender) + let hint = RemoteEndpointStore.endpoint(forPeerID: peer, role: .sender) XCTAssertEqual(hint?.host, "100.101.102.103") XCTAssertEqual(hint?.port, 9001) } func testConnectRequestUsesDedicatedAuthenticatedRequestPort() { let peer = peerID() - RemoteEndpointStore.setEndpoint("100.101.102.103", port: WireCrypto.tlsPort, forPeerID: peer) + RemoteEndpointStore.setEndpoint("100.101.102.103", port: WireCrypto.tlsPort, forPeerID: peer, role: .sender) - let requestEndpoint = RemoteEndpointStore.connectRequestEndpoint(forPeerID: peer) + let requestEndpoint = RemoteEndpointStore.connectRequestEndpoint(forPeerID: peer, role: .sender) XCTAssertEqual(requestEndpoint?.host, "100.101.102.103") XCTAssertEqual(requestEndpoint?.port, WireCrypto.remoteRequestPort) XCTAssertNotEqual(requestEndpoint?.port, WireCrypto.tlsPort) } func testConnectRequestEndpointRequiresConfiguredHint() { - XCTAssertNil(RemoteEndpointStore.connectRequestEndpoint(forPeerID: peerID())) + XCTAssertNil(RemoteEndpointStore.connectRequestEndpoint(forPeerID: peerID(), role: .sender)) } func testChangingTheAddressForTheSamePeerIDOverwritesOnlyTheHint() { let peer = peerID() - RemoteEndpointStore.setEndpoint("100.1.1.1", port: 9001, forPeerID: peer) - RemoteEndpointStore.setEndpoint("100.2.2.2", port: 9001, forPeerID: peer) + RemoteEndpointStore.setEndpoint("100.1.1.1", port: 9001, forPeerID: peer, role: .sender) + RemoteEndpointStore.setEndpoint("100.2.2.2", port: 9001, forPeerID: peer, role: .sender) // Only one hint per peer ID — a changed address replaces the hint, // it never creates a second identity to reconcile. - XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer)?.host, "100.2.2.2") - XCTAssertEqual(RemoteEndpointStore.allPeerIDs().filter { $0 == peer }.count, 1) + XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer, role: .sender)?.host, "100.2.2.2") + XCTAssertEqual(RemoteEndpointStore.allPeerIDs(role: .sender).filter { $0 == peer }.count, 1) } func testRemoveEndpointClearsTheHintWithoutAffectingOtherPeers() { let peerA = peerID() let peerB = peerID() - RemoteEndpointStore.setEndpoint("100.1.1.1", port: 9001, forPeerID: peerA) - RemoteEndpointStore.setEndpoint("100.2.2.2", port: 9001, forPeerID: peerB) - RemoteEndpointStore.removeEndpoint(forPeerID: peerA) - XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: peerA)) - XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peerB)?.host, "100.2.2.2") + RemoteEndpointStore.setEndpoint("100.1.1.1", port: 9001, forPeerID: peerA, role: .sender) + RemoteEndpointStore.setEndpoint("100.2.2.2", port: 9001, forPeerID: peerB, role: .sender) + RemoteEndpointStore.removeEndpoint(forPeerID: peerA, role: .sender) + XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: peerA, role: .sender)) + XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peerB, role: .sender)?.host, "100.2.2.2") } func testSerializationSurvivesAFreshLoadFromUserDefaults() { // Simulates a relaunch: no in-memory cache, only what's on disk. let peer = peerID() - RemoteEndpointStore.setEndpoint("100.5.5.5", port: 8123, forPeerID: peer) + RemoteEndpointStore.setEndpoint("100.5.5.5", port: 8123, forPeerID: peer, role: .sender) // A fresh `load()` inside `endpoint(forPeerID:)` re-reads UserDefaults // every call — there is no separate cache to go stale. - XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer)?.host, "100.5.5.5") - XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer)?.port, 8123) + XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer, role: .sender)?.host, "100.5.5.5") + XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer, role: .sender)?.port, 8123) } func testDisplayNameChangesDoNotAffectTheStoredEntry() { @@ -68,10 +68,10 @@ final class RemoteEndpointStoreTests: XCTestCase { // it has no notion of display name at all, so a renamed device // (same peerID) keeps its saved endpoint automatically. let peer = peerID() - RemoteEndpointStore.setEndpoint("100.6.6.6", port: 9001, forPeerID: peer) + RemoteEndpointStore.setEndpoint("100.6.6.6", port: 9001, forPeerID: peer, role: .sender) // "Renaming" is simulated by simply re-reading with the same peerID — // proving the lookup key is peerID alone, never a name. - XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer)?.host, "100.6.6.6") + XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer, role: .sender)?.host, "100.6.6.6") } func testMalformedOrFutureSchemaDataIsDiscardedRatherThanMisread() { @@ -81,6 +81,6 @@ final class RemoteEndpointStoreTests: XCTestCase { struct FutureHint: Codable { var version = 99; var host = "100.9.9.9"; var port: UInt16 = 9001 } let raw = try! JSONEncoder().encode([peer: FutureHint()]) UserDefaults.standard.set(raw, forKey: "remoteEndpointHints.v1") - XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: peer)) + XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: peer, role: .sender)) } } diff --git a/MacTests/RolePreferencesIsolationTests.swift b/MacTests/RolePreferencesIsolationTests.swift new file mode 100644 index 000000000..43931ac13 --- /dev/null +++ b/MacTests/RolePreferencesIsolationTests.swift @@ -0,0 +1,212 @@ +import XCTest + +/// The unified Mac app's sender and receiver roles keep separate approval +/// policy, per-Mac overrides and peer routing data. The original bug: both +/// roles read and wrote one "Automatically Allow Connections" key, so the +/// sender's (on by default) silently auto-approved any paired Mac that +/// connected to this Mac as a display. +/// +/// `.sender` and a receiver-only app's receiver role share the original keys, +/// so the iPhone/iPad app and the standalone Mac Receiver are unchanged. +final class RolePreferencesIsolationTests: XCTestCase { + private let sender = RolePreferences.sender + private let displayRole = RolePreferences(isSeparateReceiverRole: true) + private let receiverOnlyApp = RolePreferences(isSeparateReceiverRole: false) + + private var defaults: UserDefaults! + private var suiteName: String! + private var standardPeers: [String] = [] + + override func setUp() { + super.setUp() + suiteName = "RolePreferencesIsolationTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + for peer in standardPeers { + for role in [sender, displayRole] { + RemoteEndpointStore.removeEndpoint(forPeerID: peer, role: role) + WakeMetadataStore.removeMetadata(forPeerID: peer, role: role) + } + } + super.tearDown() + } + + private func standardPeer() -> String { + let peer = "role-isolation-\(UUID().uuidString)" + standardPeers.append(peer) + return peer + } + + // MARK: - Automatically Allow Connections + + func testSenderAutomaticApprovalNeverApprovesTheDisplayRole() { + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: sender, defaults: defaults) + + XCTAssertFalse(IncomingSessionPolicyStore.automaticallyAllow(role: displayRole, defaults: defaults)) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "paired-mac", intent: .automatic, + role: displayRole, defaults: defaults), + .decline, "a sending Mac's background attempt must not be admitted silently") + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "paired-mac", intent: .manual, + role: displayRole, defaults: defaults), + .askUser, "an explicit request asks this Mac's user") + } + + func testSenderDefaultOnDoesNotApproveTheDisplayRole() { + // A 1.20.0 install never wrote the key: the sender's ON default. + XCTAssertTrue(IncomingSessionPolicyStore.automaticallyAllow(role: sender, defaults: defaults)) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "paired-mac", intent: .automatic, + role: displayRole, defaults: defaults), .decline) + } + + func testDisplayRoleAutomaticApprovalNeverChangesTheSender() { + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: sender, defaults: defaults) + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: displayRole, defaults: defaults) + + XCTAssertFalse(IncomingSessionPolicyStore.automaticallyAllow(role: sender, defaults: defaults)) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "iphone", intent: .automatic, + role: sender, defaults: defaults), .decline) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "mac", intent: .automatic, + role: displayRole, defaults: defaults), .accept) + } + + func testDefaults() { + XCTAssertTrue(IncomingSessionPolicyStore.automaticallyAllowDefault(sender), "sender unchanged") + XCTAssertTrue(IncomingSessionPolicyStore.automaticallyAllowDefault(receiverOnlyApp), + "iPhone/iPad and standalone Mac Receiver unchanged") + XCTAssertFalse(IncomingSessionPolicyStore.automaticallyAllowDefault(displayRole), + "the unified app's display role asks until the user opts in") + } + + func testKeyNames() { + XCTAssertEqual(IncomingSessionPolicyStore.automaticallyAllowKey(sender), "incomingSessionAutomaticallyAllow", + "existing sender preferences are read under their original name") + XCTAssertEqual(IncomingSessionPolicyStore.automaticallyAllowKey(receiverOnlyApp), + "incomingSessionAutomaticallyAllow") + XCTAssertEqual(IncomingSessionPolicyStore.automaticallyAllowKey(displayRole), + "receiver.incomingSessionAutomaticallyAllow") + } + + func testExistingPreferencesStayWithTheSenderAndReceiverOnlyApps() { + // What an existing install has on disk (written by 1.20.0, or by the + // iPhone app / standalone receiver in their own domains). + defaults.set(false, forKey: "incomingSessionAutomaticallyAllow") + defaults.set(["peer": "blocked"], forKey: "incomingSessionPeerPolicy.v1") + + for role in [sender, receiverOnlyApp] { + XCTAssertFalse(IncomingSessionPolicyStore.automaticallyAllow(role: role, defaults: defaults)) + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "peer", role: role, defaults: defaults), .blocked) + } + XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "peer", role: displayRole, defaults: defaults)) + XCTAssertFalse(IncomingSessionPolicyStore.automaticallyAllow(role: displayRole, defaults: defaults)) + } + + // MARK: - Per-Mac overrides + + /// A peer picks the install id it pairs with, so the same id can be + /// pinned in both roles: its overrides must still not cross. + func testPerPeerPolicyIsPerRoleEvenForTheSamePeerID() { + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: displayRole, defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "same-id", role: sender, defaults: defaults) + + XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "same-id", role: displayRole, defaults: defaults)) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "same-id", intent: .automatic, + role: displayRole, defaults: defaults), .decline) + + IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "same-id", role: displayRole, defaults: defaults) + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "same-id", role: sender, defaults: defaults), + .alwaysAllow, "blocking a Mac as a display doesn't block it as a receiver") + + IncomingSessionPolicyStore.removePolicy(peerID: "same-id", role: sender, defaults: defaults) + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "same-id", role: displayRole, defaults: defaults), + .blocked, "forgetting it in the sender doesn't lift the display role's block") + } + + // MARK: - Role switching and relaunch + + func testSwitchingRolesAndRelaunchingNeverRestoreADisplayApproval() { + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: sender, defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "mac", role: sender, defaults: defaults) + + var state = MacAppRoleState(defaults: defaults) + for enabled in [true, false, true, false, true] { + _ = state.setReceiverEnabled(enabled, defaults: defaults) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "mac", intent: .automatic, + role: displayRole, defaults: defaults), .decline) + } + + let relaunched = UserDefaults(suiteName: suiteName)! + XCTAssertTrue(MacAppRoleState(defaults: relaunched).receiverEnabled) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "mac", intent: .automatic, + role: displayRole, defaults: relaunched), .decline) + XCTAssertTrue(IncomingSessionPolicyStore.automaticallyAllow(role: sender, defaults: relaunched), + "the sender keeps its own choice") + } + + // MARK: - Authorization entry points + + func testLegacySenderAdmissionUsesTheDisplayRolePolicy() { + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: sender, defaults: defaults) + XCTAssertFalse(ReceiverSessionAdmission.admitsLegacySender(peerID: "old-mac", role: displayRole, + defaults: defaults), + "a pre-pv-21 Mac can't be asked, so it's refused rather than auto-admitted") + + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: displayRole, defaults: defaults) + XCTAssertTrue(ReceiverSessionAdmission.admitsLegacySender(peerID: "old-mac", role: displayRole, + defaults: defaults)) + } + + func testReceiverConnectRequestsUseTheSenderPolicy() { + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: sender, defaults: defaults) + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: displayRole, defaults: defaults) + XCTAssertEqual(ReceiverRequestAdmission.decide(peerID: "ipad", intent: .automatic, existing: nil, + defaults: defaults), .drop) + XCTAssertEqual(ReceiverRequestAdmission.decide(peerID: "ipad", intent: .manual, existing: nil, + defaults: defaults), .start(needsSenderApproval: true)) + } + + // MARK: - Routing hints + + func testRemoteEndpointsArePerRole() { + let peer = standardPeer() + RemoteEndpointStore.setEndpoint("100.64.0.1", port: 9001, forPeerID: peer, role: sender) + + XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: peer, role: displayRole)) + XCTAssertFalse(RemoteEndpointStore.allPeerIDs(role: displayRole).contains(peer)) + XCTAssertNil(RemoteEndpointStore.connectRequestEndpoint(forPeerID: peer, role: displayRole), + "the display role never knocks on an address the sender saved") + + RemoteEndpointStore.setEndpoint("100.64.0.2", port: 9001, forPeerID: peer, role: displayRole) + XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer, role: sender)?.host, "100.64.0.1", + "pairing the display role can't redirect the sender's dial") + RemoteEndpointStore.removeEndpoint(forPeerID: peer, role: sender) + XCTAssertEqual(RemoteEndpointStore.endpoint(forPeerID: peer, role: displayRole)?.host, "100.64.0.2") + XCTAssertFalse(RemoteEndpointStore.allPeerIDs(role: sender).contains(peer), + "the sender never auto-connects to a Mac the display role saved") + } + + func testWakeMetadataIsPerRole() { + let peer = standardPeer() + let metadata = WakeMetadata(macAddress: "aa:bb:cc:dd:ee:ff", interfaceName: "en0", + ipv4: "192.168.1.5", subnetMask: "255.255.255.0", + broadcastAddress: "192.168.1.255", updatedAt: Date()) + WakeMetadataStore.setMetadata(metadata, forPeerID: peer, role: displayRole) + XCTAssertNil(WakeMetadataStore.metadata(forPeerID: peer, role: sender)) + WakeMetadataStore.removeMetadata(forPeerID: peer, role: sender) + XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: peer, role: displayRole), metadata) + } + + func testPairingRecordsHintsInTheTrustNamespacesRole() { + XCTAssertEqual(RolePreferences(trustNamespace: .standard), sender) + XCTAssertEqual(RolePreferences(trustNamespace: .macReceiverRole), displayRole) + XCTAssertEqual(RemoteEndpointStoreHints(role: displayRole).role, displayRole) + } + + func testThisTestBundleIsAReceiverOnlyConfiguration() { + // No `MeowDisplayReceiverRoleTrust` in a hostless test bundle: the + // receiver role resolves to the original keys, as on iPhone. + XCTAssertEqual(RolePreferences.receiverRole, receiverOnlyApp) + } +} diff --git a/MacTests/SessionAuthorityRevocationTests.swift b/MacTests/SessionAuthorityRevocationTests.swift index 2c7accd5a..709f18188 100644 --- a/MacTests/SessionAuthorityRevocationTests.swift +++ b/MacTests/SessionAuthorityRevocationTests.swift @@ -62,9 +62,9 @@ final class SessionAuthorityRevocationTests: XCTestCase { let frames: [Data?] = [nil, RemoteConnectRequestIntent.frame(for: .automatic)] let policies: [IncomingSessionPeerPolicy?] = [nil, .alwaysAllow, .blocked] for autoAllow in [true, false] { - IncomingSessionPolicyStore.setAutomaticallyAllow(autoAllow, defaults: defaults) + IncomingSessionPolicyStore.setAutomaticallyAllow(autoAllow, role: .receiverRole, defaults: defaults) for policy in policies { - IncomingSessionPolicyStore.setPolicy(policy, peerID: "P", defaults: defaults) + IncomingSessionPolicyStore.setPolicy(policy, peerID: "P", role: .receiverRole, defaults: defaults) for frame in frames { let intent = RemoteConnectRequestIntent.intent(fromFrame: frame) let action = ReceiverRequestAdmission.decide(peerID: "P", intent: intent, existing: nil, @@ -76,8 +76,8 @@ final class SessionAuthorityRevocationTests: XCTestCase { } } // The only way to the prompt is an explicit request. - IncomingSessionPolicyStore.setAutomaticallyAllow(false, defaults: defaults) - IncomingSessionPolicyStore.setPolicy(nil, peerID: "P", defaults: defaults) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole, defaults: defaults) + IncomingSessionPolicyStore.setPolicy(nil, peerID: "P", role: .receiverRole, defaults: defaults) XCTAssertEqual(ReceiverRequestAdmission.decide( peerID: "P", intent: RemoteConnectRequestIntent.intent(fromFrame: RemoteConnectRequestIntent.frame(for: .manual)), existing: nil, defaults: defaults), .start(needsSenderApproval: true)) diff --git a/MacTests/SessionInvitationTests.swift b/MacTests/SessionInvitationTests.swift index ce4c56594..46278312c 100644 --- a/MacTests/SessionInvitationTests.swift +++ b/MacTests/SessionInvitationTests.swift @@ -19,7 +19,7 @@ final class SessionInvitationTests: XCTestCase { // MARK: - Incoming policy func testAutomaticallyAllowDefaultsOn() { - XCTAssertTrue(IncomingSessionPolicyStore.automaticallyAllow(defaults: defaults)) + XCTAssertTrue(IncomingSessionPolicyStore.automaticallyAllow(role: .sender, defaults: defaults)) } func testPolicyMatrix() { @@ -38,54 +38,54 @@ final class SessionInvitationTests: XCTestCase { } func testStoreDecisionsFollowPersistedPolicy() { - XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .manual, defaults: defaults), .accept) - IncomingSessionPolicyStore.setAutomaticallyAllow(false, defaults: defaults) - XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .manual, defaults: defaults), .askUser) - XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .automatic, defaults: defaults), .decline) - IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "a", defaults: defaults) - XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .automatic, defaults: defaults), .accept) - IncomingSessionPolicyStore.setAutomaticallyAllow(true, defaults: defaults) - IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "a", defaults: defaults) - XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .manual, defaults: defaults), .block) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .manual, role: .sender, defaults: defaults), .accept) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .sender, defaults: defaults) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .manual, role: .sender, defaults: defaults), .askUser) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .automatic, role: .sender, defaults: defaults), .decline) + IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "a", role: .sender, defaults: defaults) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .automatic, role: .sender, defaults: defaults), .accept) + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: .sender, defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "a", role: .sender, defaults: defaults) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: "a", intent: .manual, role: .sender, defaults: defaults), .block) } func testReturningToDefaultAndForgetRemoveOverride() { - IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "a", defaults: defaults) - IncomingSessionPolicyStore.setPolicy(nil, peerID: "a", defaults: defaults) - XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "a", defaults: defaults)) + IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "a", role: .sender, defaults: defaults) + IncomingSessionPolicyStore.setPolicy(nil, peerID: "a", role: .sender, defaults: defaults) + XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "a", role: .sender, defaults: defaults)) - IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "a", defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "a", role: .sender, defaults: defaults) var removed: [String] = [] ForgetDeviceAction.perform( peerID: "a", forgetTrust: { _ in }, removeRemoteEndpoint: { _ in }, removeWakeMetadata: { _ in }, removeSessionPolicy: { removed.append($0) - IncomingSessionPolicyStore.removePolicy(peerID: $0, defaults: self.defaults) + IncomingSessionPolicyStore.removePolicy(peerID: $0, role: .sender, defaults: self.defaults) }) XCTAssertEqual(removed, ["a"]) - XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "a", defaults: defaults)) + XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "a", role: .sender, defaults: defaults)) } func testPoliciesPersistAcrossStoreReads() { - IncomingSessionPolicyStore.setAutomaticallyAllow(false, defaults: defaults) - IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "a", defaults: defaults) - IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "b", defaults: defaults) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .sender, defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "a", role: .sender, defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "b", role: .sender, defaults: defaults) let reopened = UserDefaults(suiteName: suiteName)! - XCTAssertFalse(IncomingSessionPolicyStore.automaticallyAllow(defaults: reopened)) - XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "a", defaults: reopened), .alwaysAllow) - XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "b", defaults: reopened), .blocked) + XCTAssertFalse(IncomingSessionPolicyStore.automaticallyAllow(role: .sender, defaults: reopened)) + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "a", role: .sender, defaults: reopened), .alwaysAllow) + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "b", role: .sender, defaults: reopened), .blocked) } func testPolicyIsKeyedByStablePeerIDOnly() { // Names and addresses never enter the store; only the pinned install ID. - IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "install-1", defaults: defaults) - XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "Jai's iPad", defaults: defaults)) - XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "install-1", defaults: defaults), .blocked) + IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "install-1", role: .sender, defaults: defaults) + XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "Jai's iPad", role: .sender, defaults: defaults)) + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: "install-1", role: .sender, defaults: defaults), .blocked) } func testUnauthenticatedPeerNeverPrompts() { - IncomingSessionPolicyStore.setAutomaticallyAllow(false, defaults: defaults) - XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: nil, intent: .manual, defaults: defaults), .decline) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .sender, defaults: defaults) + XCTAssertEqual(IncomingSessionPolicyStore.decision(peerID: nil, intent: .manual, role: .sender, defaults: defaults), .decline) } // MARK: - Roles @@ -144,8 +144,8 @@ final class SessionInvitationTests: XCTestCase { func testRejectForSessionPersistsNothing() { let plan = SessionApprovalPlan.plan(for: SenderApprovalDecision.rejectForSession) - if let policy = plan.persistPolicy { IncomingSessionPolicyStore.setPolicy(policy, peerID: "a", defaults: defaults) } - XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "a", defaults: defaults)) + if let policy = plan.persistPolicy { IncomingSessionPolicyStore.setPolicy(policy, peerID: "a", role: .sender, defaults: defaults) } + XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: "a", role: .sender, defaults: defaults)) } // MARK: - Pending lifecycle @@ -260,12 +260,12 @@ final class SessionInvitationTests: XCTestCase { func testLegacySenderAdmittedOnlyWhenBackgroundAttemptWouldBe() { XCTAssertTrue(ReceiverSessionAdmission.admitsLegacySender(peerID: "mac", defaults: defaults)) - IncomingSessionPolicyStore.setAutomaticallyAllow(false, defaults: defaults) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .sender, defaults: defaults) XCTAssertFalse(ReceiverSessionAdmission.admitsLegacySender(peerID: "mac", defaults: defaults)) - IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "mac", defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "mac", role: .sender, defaults: defaults) XCTAssertTrue(ReceiverSessionAdmission.admitsLegacySender(peerID: "mac", defaults: defaults)) - IncomingSessionPolicyStore.setAutomaticallyAllow(true, defaults: defaults) - IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "mac", defaults: defaults) + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: .sender, defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.blocked, peerID: "mac", role: .sender, defaults: defaults) XCTAssertFalse(ReceiverSessionAdmission.admitsLegacySender(peerID: "mac", defaults: defaults)) } @@ -283,7 +283,7 @@ final class SessionInvitationTests: XCTestCase { func testAllowPermanentlyDoesNotTouchInputPolicy() { let plan = SessionApprovalPlan.plan(for: SenderApprovalDecision.allowPermanently) - if let policy = plan.persistPolicy { IncomingSessionPolicyStore.setPolicy(policy, peerID: "a", defaults: defaults) } + if let policy = plan.persistPolicy { IncomingSessionPolicyStore.setPolicy(policy, peerID: "a", role: .sender, defaults: defaults) } XCTAssertEqual(ReceiverInputAuthorizationStore.policy(peerID: "a", defaults: defaults), .ask) } @@ -327,7 +327,7 @@ final class SessionInvitationTests: XCTestCase { func testAllowPermanentlyDoesNotPersistMode() { let plan = SessionApprovalPlan.plan(for: SenderApprovalDecision.allowPermanently) - if let policy = plan.persistPolicy { IncomingSessionPolicyStore.setPolicy(policy, peerID: "a", defaults: defaults) } + if let policy = plan.persistPolicy { IncomingSessionPolicyStore.setPolicy(policy, peerID: "a", role: .sender, defaults: defaults) } let domain = defaults.persistentDomain(forName: suiteName) ?? [:] XCTAssertEqual(domain.count, 1, "only the connection policy is stored") XCTAssertEqual(domain.values.first as? [String: String], ["a": "alwaysAllow"]) diff --git a/MacTests/StreamReceiverSessionAuthorityTests.swift b/MacTests/StreamReceiverSessionAuthorityTests.swift index 4163b6eec..88ab01aac 100644 --- a/MacTests/StreamReceiverSessionAuthorityTests.swift +++ b/MacTests/StreamReceiverSessionAuthorityTests.swift @@ -21,17 +21,17 @@ import os @MainActor final class StreamReceiverSessionAuthorityTests: XCTestCase { private let savedAutomaticallyAllow: Any? = - UserDefaults.standard.object(forKey: IncomingSessionPolicyStore.automaticallyAllowKey) + UserDefaults.standard.object(forKey: IncomingSessionPolicyStore.automaticallyAllowKey(.receiverRole)) private var peers: [String] = [] private let pinned = OSAllocatedUnfairLock>(initialState: []) override func tearDown() async throws { if let savedAutomaticallyAllow { - UserDefaults.standard.set(savedAutomaticallyAllow, forKey: IncomingSessionPolicyStore.automaticallyAllowKey) + UserDefaults.standard.set(savedAutomaticallyAllow, forKey: IncomingSessionPolicyStore.automaticallyAllowKey(.receiverRole)) } else { - UserDefaults.standard.removeObject(forKey: IncomingSessionPolicyStore.automaticallyAllowKey) + UserDefaults.standard.removeObject(forKey: IncomingSessionPolicyStore.automaticallyAllowKey(.receiverRole)) } - for peer in peers { IncomingSessionPolicyStore.removePolicy(peerID: peer) } + for peer in peers { IncomingSessionPolicyStore.removePolicy(peerID: peer, role: .receiverRole) } try await super.tearDown() } @@ -40,10 +40,10 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { func testForgetDismissesPromptsAndRevokesRememberedAcceptance() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(true) + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("accepted", .sender, .automatic), authenticatedPeerID: peer) _ = await probe(receiver) - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("prompt", .sender, .manual), authenticatedPeerID: peer) var state = await probe(receiver) XCTAssertTrue(state.admitted) @@ -59,7 +59,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { receiver.respondToSessionInvitation(id: "prompt", decision: .allowPermanently) state = await probe(receiver) XCTAssertFalse(state.admitted, "a late Allow after Forget must not admit") - XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: peer), "a late Allow after Forget must not persist Always Allow") + XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: peer, role: .receiverRole), "a late Allow after Forget must not persist Always Allow") // The session the Mac had accepted can no longer continue without a decision. receiver.receiveControlMessageForTesting(try invite("accepted", .sender, .automatic), authenticatedPeerID: peer) @@ -70,7 +70,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { func testLateAllowAfterBlockIsInertAndKeepsTheBlock() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("prompt", .sender, .manual), authenticatedPeerID: peer) var state = await probe(receiver) XCTAssertEqual(state.pendingApprovalIDs, ["prompt"]) @@ -82,14 +82,14 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { receiver.respondToSessionInvitation(id: "prompt", decision: .allowPermanently) state = await probe(receiver) XCTAssertFalse(state.admitted) - XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: peer), .blocked, + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: peer, role: .receiverRole), .blocked, "a late Allow Permanently must never overwrite Block with Always Allow") } func testBlockRevokesTheRememberedAcceptance() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(true) + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("accepted", .sender, .automatic), authenticatedPeerID: peer) var state = await probe(receiver) XCTAssertTrue(state.admitted) @@ -101,7 +101,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { // Unblocking later must not quietly resume the old acceptance. receiver.setIncomingSessionPolicy(nil, peerID: peer) - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("accepted", .sender, .automatic), authenticatedPeerID: peer) state = await probe(receiver) XCTAssertFalse(state.admitted) @@ -112,24 +112,24 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { func testAllowLandingAfterABlockWasPersistedIsInert() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("prompt", .sender, .manual), authenticatedPeerID: peer) var state = await probe(receiver) XCTAssertEqual(state.pendingApprovalIDs, ["prompt"]) - IncomingSessionPolicyStore.setPolicy(.blocked, peerID: peer) + IncomingSessionPolicyStore.setPolicy(.blocked, peerID: peer, role: .receiverRole) receiver.respondToSessionInvitation(id: "prompt", decision: .allowPermanently) state = await probe(receiver) XCTAssertFalse(state.admitted) XCTAssertEqual(state.pendingApprovalIDs, []) - XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: peer), .blocked) + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: peer, role: .receiverRole), .blocked) } /// Forget's pin removal lands before its revocation reaches the prompt. func testAllowLandingAfterThePinWasRemovedIsInert() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("prompt", .sender, .manual), authenticatedPeerID: peer) var state = await probe(receiver) XCTAssertEqual(state.pendingApprovalIDs, ["prompt"]) @@ -138,7 +138,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { receiver.respondToSessionInvitation(id: "prompt", decision: .allowPermanently) state = await probe(receiver) XCTAssertFalse(state.admitted) - XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: peer)) + XCTAssertNil(IncomingSessionPolicyStore.policy(peerID: peer, role: .receiverRole)) } /// Positive control for the late-answer tests: still pinned, not @@ -146,7 +146,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { func testAllowWhileStillAuthorizedAdmits() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("prompt", .sender, .manual), authenticatedPeerID: peer) var state = await probe(receiver) XCTAssertEqual(state.pendingApprovalIDs, ["prompt"]) @@ -154,13 +154,13 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { receiver.respondToSessionInvitation(id: "prompt", decision: .allowPermanently) state = await probe(receiver) XCTAssertTrue(state.admitted) - XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: peer), .alwaysAllow) + XCTAssertEqual(IncomingSessionPolicyStore.policy(peerID: peer, role: .receiverRole), .alwaysAllow) } func testInvitationWithoutAPinnedPeerIsDeclinedEvenWhenAutomaticallyAllowed() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(true) + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("unpinned", .sender, .automatic), authenticatedPeerID: nil) var state = await probe(receiver) @@ -179,7 +179,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { func testCancelWithdrawsTheConnectRequestAndItsRecoveryRun() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.connectPrimary(peerID: peer, mode: .extend) var state = await waitForProbe(receiver) { $0.connectRequestToken != nil && $0.phase == .reconnecting } @@ -229,7 +229,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { func testOwnStandingRequestIsAnsweredWithoutASecondPrompt() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.connectPrimary(peerID: peer, mode: .mirror) _ = await waitForProbe(receiver) { $0.connectRequestToken != nil && $0.hasOutgoingSessionRequest } @@ -249,7 +249,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { func testAdmittedSessionEndsTheOwnRequestWindow() async throws { let peer = makePinnedPeer() let receiver = makeReceiver() - IncomingSessionPolicyStore.setAutomaticallyAllow(true) + IncomingSessionPolicyStore.setAutomaticallyAllow(true, role: .receiverRole) receiver.connectPrimary(peerID: peer, mode: .mirror) var state = await waitForProbe(receiver) { $0.connectRequestToken != nil && $0.hasOutgoingSessionRequest } XCTAssertEqual(state.requestedMode, .mirror) @@ -260,7 +260,7 @@ final class StreamReceiverSessionAuthorityTests: XCTestCase { XCTAssertFalse(state.hasOutgoingSessionRequest) XCTAssertNil(state.requestedMode) - IncomingSessionPolicyStore.setAutomaticallyAllow(false) + IncomingSessionPolicyStore.setAutomaticallyAllow(false, role: .receiverRole) receiver.receiveControlMessageForTesting(try invite("later", .receiver, .manual), authenticatedPeerID: peer) state = await probe(receiver) XCTAssertEqual(state.pendingApprovalIDs, ["later"], "the shortcut must not survive the session that answered it") diff --git a/MacTests/ThirdQARefinementTests.swift b/MacTests/ThirdQARefinementTests.swift index 184cde6e6..f78dd1955 100644 --- a/MacTests/ThirdQARefinementTests.swift +++ b/MacTests/ThirdQARefinementTests.swift @@ -39,7 +39,7 @@ final class ThirdQARefinementTests: XCTestCase { func testConnectionAlwaysAllowIsNotConsentToRequestInput() throws { let (defaults, suite) = try makeDefaults() defer { defaults.removePersistentDomain(forName: suite) } - IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "mac", defaults: defaults) + IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "mac", role: .receiverRole, defaults: defaults) XCTAssertFalse(InputAutoRequestStore.isEnabled(peerID: "mac", defaults: defaults)) XCTAssertFalse(InputAutoRequestPolicy.shouldAutoRequest( peerOptedIn: InputAutoRequestStore.isEnabled(peerID: "mac", defaults: defaults))) diff --git a/MacTests/WakeOnLANTests.swift b/MacTests/WakeOnLANTests.swift index bfc70fc45..2864736ca 100644 --- a/MacTests/WakeOnLANTests.swift +++ b/MacTests/WakeOnLANTests.swift @@ -50,10 +50,10 @@ final class WakeMetadataStoreTests: XCTestCase { let metadata = WakeMetadata(macAddress: "aa:bb:cc:dd:ee:ff", interfaceName: "en0", ipv4: "192.168.1.5", subnetMask: "255.255.255.0", broadcastAddress: "192.168.1.255", updatedAt: Date()) - XCTAssertNil(WakeMetadataStore.metadata(forPeerID: peer)) - WakeMetadataStore.setMetadata(metadata, forPeerID: peer) - XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: peer)?.macAddress, "aa:bb:cc:dd:ee:ff") - XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: peer)?.broadcastAddress, "192.168.1.255") + XCTAssertNil(WakeMetadataStore.metadata(forPeerID: peer, role: .sender)) + WakeMetadataStore.setMetadata(metadata, forPeerID: peer, role: .sender) + XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: peer, role: .sender)?.macAddress, "aa:bb:cc:dd:ee:ff") + XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: peer, role: .sender)?.broadcastAddress, "192.168.1.255") } func testChangedAddressOverwritesOnlyTheHintForThatPeer() { @@ -64,9 +64,9 @@ final class WakeMetadataStoreTests: XCTestCase { let second = WakeMetadata(macAddress: "bb:bb:bb:bb:bb:bb", interfaceName: "en1", ipv4: "10.0.0.5", subnetMask: nil, broadcastAddress: nil, updatedAt: Date()) - WakeMetadataStore.setMetadata(first, forPeerID: peer) - WakeMetadataStore.setMetadata(second, forPeerID: peer) - XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: peer)?.macAddress, "bb:bb:bb:bb:bb:bb") + WakeMetadataStore.setMetadata(first, forPeerID: peer, role: .sender) + WakeMetadataStore.setMetadata(second, forPeerID: peer, role: .sender) + XCTAssertEqual(WakeMetadataStore.metadata(forPeerID: peer, role: .sender)?.macAddress, "bb:bb:bb:bb:bb:bb") } func testRemoveMetadataClearsOnlyThatPeer() { @@ -74,11 +74,11 @@ final class WakeMetadataStoreTests: XCTestCase { let peerB = peerID() let metadata = WakeMetadata(macAddress: "aa:aa:aa:aa:aa:aa", interfaceName: "en0", ipv4: nil, subnetMask: nil, broadcastAddress: nil, updatedAt: Date()) - WakeMetadataStore.setMetadata(metadata, forPeerID: peerA) - WakeMetadataStore.setMetadata(metadata, forPeerID: peerB) - WakeMetadataStore.removeMetadata(forPeerID: peerA) - XCTAssertNil(WakeMetadataStore.metadata(forPeerID: peerA)) - XCTAssertNotNil(WakeMetadataStore.metadata(forPeerID: peerB)) + WakeMetadataStore.setMetadata(metadata, forPeerID: peerA, role: .sender) + WakeMetadataStore.setMetadata(metadata, forPeerID: peerB, role: .sender) + WakeMetadataStore.removeMetadata(forPeerID: peerA, role: .sender) + XCTAssertNil(WakeMetadataStore.metadata(forPeerID: peerA, role: .sender)) + XCTAssertNotNil(WakeMetadataStore.metadata(forPeerID: peerB, role: .sender)) } } diff --git a/Shared/PairingNetwork.swift b/Shared/PairingNetwork.swift index 139aa162a..a5d52efd1 100644 --- a/Shared/PairingNetwork.swift +++ b/Shared/PairingNetwork.swift @@ -94,7 +94,9 @@ enum PairingNetwork { try PairingClassifier.check(pending, allowIdentityChange: allowIdentityChange) if let remoteHost { pending.changesRemoteEndpoint = RemoteHintChange.wouldChange( - existing: RemoteEndpointStore.endpoint(forPeerID: pending.peerID), newHost: remoteHost) + existing: RemoteEndpointStore.endpoint(forPeerID: pending.peerID, + role: RolePreferences(trustNamespace: store.namespace)), + newHost: remoteHost) } onHandshakeReached?() // A cancelled/superseded attempt must never surface a SAS prompt. @@ -112,7 +114,8 @@ enum PairingNetwork { onPeerAbort: { Task { @MainActor in prompt.peerAborted(token: attemptToken) } }, onCommitting: { Task { @MainActor in prompt.markCommitting(token: attemptToken) } }, store: store, isCurrent: isCurrent, remoteHost: remoteHost, - hints: remoteHost == nil ? nil : RemoteEndpointStoreHints()) + hints: remoteHost == nil ? nil + : RemoteEndpointStoreHints(role: RolePreferences(trustNamespace: store.namespace))) logFinalized(pending) return pending } diff --git a/Shared/ReceiverInputPrompt.swift b/Shared/ReceiverInputPrompt.swift index da9c746b6..534be3730 100644 --- a/Shared/ReceiverInputPrompt.swift +++ b/Shared/ReceiverInputPrompt.swift @@ -133,7 +133,8 @@ enum InputAutoRequestPolicy { /// peer ID like `IncomingSessionPolicyStore`. Off unless the user turns it /// on; forgetting the Mac removes it. enum InputAutoRequestStore { - static let defaultsKey = "inputAutoRequestPeers.v1" + // Receiver-only; scoped like every receiver-role key in the unified app. + static let defaultsKey = ReceiverRoleConfiguration.defaultsKey("inputAutoRequestPeers.v1") static func isEnabled(peerID: String?, defaults: UserDefaults = .standard) -> Bool { guard let peerID else { return false } diff --git a/Shared/ReceiverRoleConfiguration.swift b/Shared/ReceiverRoleConfiguration.swift index 26af683f4..ab2a34ef1 100644 --- a/Shared/ReceiverRoleConfiguration.swift +++ b/Shared/ReceiverRoleConfiguration.swift @@ -27,6 +27,44 @@ enum ReceiverRoleConfiguration { } } +/// Whose settings a per-role store reads and writes. Stores that hold a +/// role's authorization decisions or peer data (`IncomingSessionPolicyStore`, +/// `RemoteEndpointStore`, `WakeMetadataStore`) take one explicitly, so every +/// call site names its role. +/// +/// The sender and the receiver-only apps (iPhone/iPad, standalone Mac +/// Receiver) use the original key names, exactly as before. The unified Mac +/// app's receiver role uses its own keys: an approval, a per-Mac policy or a +/// routing hint set for one role is never read by the other, whatever peer +/// ids the two roles see. Separate install ids alone don't guarantee that: a +/// peer chooses the id it pairs with. +struct RolePreferences: Equatable, Sendable { + /// `true` only for the unified Mac app's receiver role. + let isSeparateReceiverRole: Bool + + /// The Mac sender: original key names. + static let sender = RolePreferences(isSeparateReceiverRole: false) + + /// This app's receiver role. The same key space as `sender` in a + /// receiver-only app (it has no sender), separate in the unified app. + static let receiverRole = RolePreferences(isSeparateReceiverRole: ReceiverRoleConfiguration.separateTrust) + + init(isSeparateReceiverRole: Bool) { + self.isSeparateReceiverRole = isSeparateReceiverRole + } + + /// The settings that belong with a trust namespace, for code handed a + /// role's `TrustStore`: pairing records a routing hint beside the pin it + /// just made. + init(trustNamespace: TrustStore.Namespace) { + isSeparateReceiverRole = trustNamespace == .macReceiverRole + } + + func key(_ name: String) -> String { + ReceiverRoleConfiguration.defaultsKey(name, separate: isSeparateReceiverRole) + } +} + /// The unified app's two roles advertise on the same network under /// different install ids. Neither may discover, list, pair with or dial the /// other: the sender would otherwise see "this Mac" as a nearby display and diff --git a/Shared/RemoteEndpointStore.swift b/Shared/RemoteEndpointStore.swift index abe483293..e83b0d640 100644 --- a/Shared/RemoteEndpointStore.swift +++ b/Shared/RemoteEndpointStore.swift @@ -7,8 +7,12 @@ import Foundation /// whether a connection is trusted. A changed or attacker-supplied address /// cannot promote itself to a trusted peer; it can only be dialed, and the /// pinned mutual-TLS handshake either accepts or terminates it. +/// +/// Hints are per role (`RolePreferences`): in the unified Mac app the +/// sender's hints point at receivers and the receiver role's at sending +/// Macs, and neither role dials, lists or forgets the other's. enum RemoteEndpointStore { - private static let defaultsKey = "remoteEndpointHints.v1" + private static func defaultsKey(_ role: RolePreferences) -> String { role.key("remoteEndpointHints.v1") } /// Bump if the persisted shape changes; `load()` discards anything from /// an older/newer version rather than guessing at a migration, mirroring @@ -21,47 +25,48 @@ enum RemoteEndpointStore { var port: UInt16 } - private static func load() -> [String: Hint] { - guard let data = UserDefaults.standard.data(forKey: defaultsKey), + private static func load(_ role: RolePreferences) -> [String: Hint] { + guard let data = UserDefaults.standard.data(forKey: defaultsKey(role)), let decoded = try? JSONDecoder().decode([String: Hint].self, from: data) else { return [:] } return decoded.filter { $0.value.version == schemaVersion } } - private static func save(_ hints: [String: Hint]) { + private static func save(_ hints: [String: Hint], _ role: RolePreferences) { guard let data = try? JSONEncoder().encode(hints) else { return } - UserDefaults.standard.set(data, forKey: defaultsKey) + UserDefaults.standard.set(data, forKey: defaultsKey(role)) } /// The last known Tailscale (or other Remote-route) address for a paired /// peer, keyed by its stable install ID — never by anything derived from /// the network address itself. - static func endpoint(forPeerID peerID: String) -> (host: String, port: UInt16)? { - guard let hint = load()[peerID] else { return nil } + static func endpoint(forPeerID peerID: String, role: RolePreferences) -> (host: String, port: UInt16)? { + guard let hint = load(role)[peerID] else { return nil } return (hint.host, hint.port) } /// The saved port is the media connection port. Remote Wake & Connect /// knocks use the Mac's separate authenticated request listener instead. /// Keep the host hint, but never reuse the media port for the knock. - static func connectRequestEndpoint(forPeerID peerID: String) -> (host: String, port: UInt16)? { - guard let hint = endpoint(forPeerID: peerID) else { return nil } + static func connectRequestEndpoint(forPeerID peerID: String, + role: RolePreferences) -> (host: String, port: UInt16)? { + guard let hint = endpoint(forPeerID: peerID, role: role) else { return nil } return (hint.host, WireCrypto.remoteRequestPort) } - static func setEndpoint(_ host: String, port: UInt16, forPeerID peerID: String) { - var hints = load() + static func setEndpoint(_ host: String, port: UInt16, forPeerID peerID: String, role: RolePreferences) { + var hints = load(role) hints[peerID] = Hint(host: host, port: port) - save(hints) + save(hints, role) } - static func removeEndpoint(forPeerID peerID: String) { - var hints = load() + static func removeEndpoint(forPeerID peerID: String, role: RolePreferences) { + var hints = load(role) hints.removeValue(forKey: peerID) - save(hints) + save(hints, role) } - static func allPeerIDs() -> [String] { - Array(load().keys) + static func allPeerIDs(role: RolePreferences) -> [String] { + Array(load(role).keys) } } diff --git a/Shared/RemotePairing.swift b/Shared/RemotePairing.swift index c61489022..3375b74ab 100644 --- a/Shared/RemotePairing.swift +++ b/Shared/RemotePairing.swift @@ -236,8 +236,11 @@ protocol RemoteEndpointHinting { } struct RemoteEndpointStoreHints: RemoteEndpointHinting { + /// The role whose pin this hint sits beside. + let role: RolePreferences + func setEndpoint(_ host: String, port: UInt16, forPeerID peerID: String) { - RemoteEndpointStore.setEndpoint(host, port: port, forPeerID: peerID) + RemoteEndpointStore.setEndpoint(host, port: port, forPeerID: peerID, role: role) } } diff --git a/Shared/SessionInvitation.swift b/Shared/SessionInvitation.swift index 38d82edcb..7df613f21 100644 --- a/Shared/SessionInvitation.swift +++ b/Shared/SessionInvitation.swift @@ -164,43 +164,63 @@ enum IncomingSessionPolicy { /// Persistent incoming-invitation policy, keyed only by the stable peer /// install ID `TrustStore` pins (never a name, address, or unauthenticated -/// claim). Stores exactly two things: the global "Automatically Allow -/// Connections" preference and per-peer overrides. Pending requests, one-off -/// rejections, chosen display modes and input permission are deliberately -/// never stored here. Forget/unpair must call `removePolicy(peerID:)`. +/// claim). Stores exactly two things per role: the global "Automatically +/// Allow Connections" preference and per-peer overrides. Pending requests, +/// one-off rejections, chosen display modes and input permission are +/// deliberately never stored here. Forget/unpair must call +/// `removePolicy(peerID:role:)`. +/// +/// Every call names its role (`RolePreferences`). In the unified Mac app the +/// sender's preference governs receivers asking this Mac to share, and the +/// receiver role's governs Macs asking to use this Mac as a display: one must +/// never approve for the other. enum IncomingSessionPolicyStore { - static let automaticallyAllowKey = "incomingSessionAutomaticallyAllow" - private static let policiesKey = "incomingSessionPeerPolicy.v1" + static func automaticallyAllowKey(_ role: RolePreferences) -> String { + role.key("incomingSessionAutomaticallyAllow") + } + + private static func policiesKey(_ role: RolePreferences) -> String { + role.key("incomingSessionPeerPolicy.v1") + } + + /// ON for the sender and the receiver-only apps, as it always was. OFF + /// for the unified Mac app's receiver role: a Mac connecting to this Mac + /// as a display asks until the user turns it on for that role. + static func automaticallyAllowDefault(_ role: RolePreferences) -> Bool { + !role.isSeparateReceiverRole + } - /// Defaults to ON. - static func automaticallyAllow(defaults: UserDefaults = .standard) -> Bool { - defaults.object(forKey: automaticallyAllowKey) as? Bool ?? true + static func automaticallyAllow(role: RolePreferences, defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: automaticallyAllowKey(role)) as? Bool ?? automaticallyAllowDefault(role) } - static func setAutomaticallyAllow(_ value: Bool, defaults: UserDefaults = .standard) { - defaults.set(value, forKey: automaticallyAllowKey) + static func setAutomaticallyAllow(_ value: Bool, role: RolePreferences, defaults: UserDefaults = .standard) { + defaults.set(value, forKey: automaticallyAllowKey(role)) } - static func policy(peerID: String, defaults: UserDefaults = .standard) -> IncomingSessionPeerPolicy? { - (defaults.dictionary(forKey: policiesKey)?[peerID] as? String).flatMap(IncomingSessionPeerPolicy.init(rawValue:)) + static func policy(peerID: String, role: RolePreferences, + defaults: UserDefaults = .standard) -> IncomingSessionPeerPolicy? { + (defaults.dictionary(forKey: policiesKey(role))?[peerID] as? String) + .flatMap(IncomingSessionPeerPolicy.init(rawValue:)) } /// `nil` returns the peer to Default. - static func setPolicy(_ policy: IncomingSessionPeerPolicy?, peerID: String, defaults: UserDefaults = .standard) { - var policies = defaults.dictionary(forKey: policiesKey) as? [String: String] ?? [:] + static func setPolicy(_ policy: IncomingSessionPeerPolicy?, peerID: String, role: RolePreferences, + defaults: UserDefaults = .standard) { + var policies = defaults.dictionary(forKey: policiesKey(role)) as? [String: String] ?? [:] policies[peerID] = policy?.rawValue - defaults.set(policies, forKey: policiesKey) + defaults.set(policies, forKey: policiesKey(role)) } - static func removePolicy(peerID: String, defaults: UserDefaults = .standard) { - setPolicy(nil, peerID: peerID, defaults: defaults) + static func removePolicy(peerID: String, role: RolePreferences, defaults: UserDefaults = .standard) { + setPolicy(nil, peerID: peerID, role: role, defaults: defaults) } - static func decision(peerID: String?, intent: SessionInvitationIntent, + static func decision(peerID: String?, intent: SessionInvitationIntent, role: RolePreferences, defaults: UserDefaults = .standard) -> IncomingSessionDecision { let decision = IncomingSessionPolicy.resolve( - peerPolicy: peerID.flatMap { policy(peerID: $0, defaults: defaults) }, - automaticallyAllow: automaticallyAllow(defaults: defaults), intent: intent) + peerPolicy: peerID.flatMap { policy(peerID: $0, role: role, defaults: defaults) }, + automaticallyAllow: automaticallyAllow(role: role, defaults: defaults), intent: intent) // Without an authenticated identity there is nobody to name in a // prompt and no per-device policy to apply: never ask. if peerID == nil, decision == .askUser { return .decline } @@ -334,7 +354,9 @@ enum ReceiverRequestAdmission { /// started or dropped, never turned into a prompt. static func decide(peerID: String, intent: SessionInvitationIntent, existing: ExistingSession?, defaults: UserDefaults = .standard) -> Action { - decide(policy: IncomingSessionPolicyStore.decision(peerID: peerID, intent: intent, defaults: defaults), + // The Mac Sender's own policy: this is a receiver asking it to share. + decide(policy: IncomingSessionPolicyStore.decision(peerID: peerID, intent: intent, role: .sender, + defaults: defaults), existing: existing) } @@ -595,7 +617,9 @@ struct ReceiverSessionAdmission: Equatable, Sendable { /// admitted only if this receiver would accept a background attempt from /// it anyway. Anything stricter fails closed — an older peer must never /// silently bypass the user's approval preference. - static func admitsLegacySender(peerID: String?, defaults: UserDefaults = .standard) -> Bool { - IncomingSessionPolicyStore.decision(peerID: peerID, intent: .automatic, defaults: defaults) == .accept + static func admitsLegacySender(peerID: String?, role: RolePreferences = .receiverRole, + defaults: UserDefaults = .standard) -> Bool { + IncomingSessionPolicyStore.decision(peerID: peerID, intent: .automatic, role: role, + defaults: defaults) == .accept } } diff --git a/Shared/SessionInvitationViews.swift b/Shared/SessionInvitationViews.swift index 30c73b687..5364dd570 100644 --- a/Shared/SessionInvitationViews.swift +++ b/Shared/SessionInvitationViews.swift @@ -17,7 +17,7 @@ struct IncomingSessionPolicyPicker: View { self.title = title self.peerID = peerID self.receiver = receiver - _policy = State(initialValue: IncomingSessionPolicyStore.policy(peerID: peerID)) + _policy = State(initialValue: IncomingSessionPolicyStore.policy(peerID: peerID, role: .receiverRole)) } var body: some View { @@ -34,9 +34,17 @@ struct IncomingSessionPolicyPicker: View { } } -/// The global incoming-invitation preference, with its explanation. +/// The receiver role's global incoming-invitation preference, with its +/// explanation. Bound to the same role-scoped key and default that +/// `StreamReceiver` decides with, never the Mac sender's. struct AutomaticallyAllowConnectionsToggle: View { - @AppStorage(IncomingSessionPolicyStore.automaticallyAllowKey) private var automaticallyAllow = true + @AppStorage private var automaticallyAllow: Bool + + init() { + _automaticallyAllow = AppStorage( + wrappedValue: IncomingSessionPolicyStore.automaticallyAllowDefault(.receiverRole), + IncomingSessionPolicyStore.automaticallyAllowKey(.receiverRole)) + } var body: some View { VStack(alignment: .leading, spacing: 4) { diff --git a/Shared/StreamReceiver.swift b/Shared/StreamReceiver.swift index 892cbf248..8673c7963 100644 --- a/Shared/StreamReceiver.swift +++ b/Shared/StreamReceiver.swift @@ -1550,7 +1550,7 @@ final class StreamReceiver: ObservableObject { func forgetPeer(_ peerID: String) { TrustStore.receiverRole.forget(peerID: peerID) - IncomingSessionPolicyStore.removePolicy(peerID: peerID) + IncomingSessionPolicyStore.removePolicy(peerID: peerID, role: .receiverRole) InputAutoRequestStore.remove(peerID: peerID) let uiSink = self.uiSink DispatchQueue.main.async { uiSink.publishLastForgottenPeerID(peerID) } @@ -1564,7 +1564,7 @@ final class StreamReceiver: ObservableObject { /// acceptance, or this device's own pending request can no longer admit /// that Mac. func setIncomingSessionPolicy(_ policy: IncomingSessionPeerPolicy?, peerID: String) { - IncomingSessionPolicyStore.setPolicy(policy, peerID: peerID) + IncomingSessionPolicyStore.setPolicy(policy, peerID: peerID, role: .receiverRole) guard policy == .blocked else { return } Log.info("sessionInvite: blocked peer=\(peerID)") revokeSessionAuthority(peerID: peerID, trustRemoved: false) @@ -2609,7 +2609,7 @@ final class StreamReceiver: ObservableObject { subnetMask: obj["subnet"] as? String, broadcastAddress: obj["broadcast"] as? String, updatedAt: Date()) - WakeMetadataStore.setMetadata(metadata, forPeerID: peerID) + WakeMetadataStore.setMetadata(metadata, forPeerID: peerID, role: .receiverRole) case WireMessage.promoteInteractiveWakeResult: let text: String if obj["success"] as? Bool == true { @@ -4210,7 +4210,7 @@ final class StreamReceiver: ObservableObject { sendControl(SessionInvitationResponse(id: invitation.id, result: .declined).message) return } - let policy = IncomingSessionPolicyStore.decision(peerID: peerID, intent: invitation.intent) + let policy = IncomingSessionPolicyStore.decision(peerID: peerID, intent: invitation.intent, role: .receiverRole) let decision: IncomingSessionDecision if policy == .block { decision = .block @@ -4268,7 +4268,7 @@ final class StreamReceiver: ObservableObject { // since then withdrew it (normally by removing the prompt, but the // pin or policy change can land first): the answer is inert — no // policy persisted, nothing admitted. - let currentPolicy = IncomingSessionPolicyStore.policy(peerID: approval.peerID) + let currentPolicy = IncomingSessionPolicyStore.policy(peerID: approval.peerID, role: .receiverRole) guard PendingApprovalRevalidation.isStillAuthorized( isPinned: isPeerPinned(approval.peerID), currentPolicy: currentPolicy) else { Log.info("sessionInvite: id=\(approval.id) answer ignored reason=revoked") @@ -4280,7 +4280,7 @@ final class StreamReceiver: ObservableObject { let plan = SessionApprovalPlan.plan(for: decision) if let policy = plan.persistPolicy { // Connection policy only; never a display mode or input. - IncomingSessionPolicyStore.setPolicy(policy, peerID: approval.peerID) + IncomingSessionPolicyStore.setPolicy(policy, peerID: approval.peerID, role: .receiverRole) } if plan.accept { admitSession(invitationID: approval.id, peerID: approval.peerID) @@ -4560,7 +4560,7 @@ final class StreamReceiver: ObservableObject { // needs no `self` capture, matching the "static helper + stable owner" // idiom used by `startTLSListener` above. private static func requestRemoteConnect(peerID: String, intent: SessionInvitationIntent, queue: DispatchQueue) { - guard let hint = RemoteEndpointStore.connectRequestEndpoint(forPeerID: peerID), + guard let hint = RemoteEndpointStore.connectRequestEndpoint(forPeerID: peerID, role: .receiverRole), let port = NWEndpoint.Port(rawValue: hint.port), let pin = TrustStore.receiverRole.pin(peerID: peerID), let identity = TrustStore.receiverRole.ownIdentity(), diff --git a/Shared/WakeConnectCoordinator.swift b/Shared/WakeConnectCoordinator.swift index 3191a5183..06e93e800 100644 --- a/Shared/WakeConnectCoordinator.swift +++ b/Shared/WakeConnectCoordinator.swift @@ -135,7 +135,7 @@ final class WakeConnectCoordinator: ObservableObject { private func sendWOLBurst() { guard let peerID = activePeerID, - let metadata = WakeMetadataStore.metadata(forPeerID: peerID), + let metadata = WakeMetadataStore.metadata(forPeerID: peerID, role: .receiverRole), let broadcast = metadata.broadcastAddress else { return } func sendOne() { guard attempt.recordWOLSent() else { return } diff --git a/Shared/WakeOnLAN.swift b/Shared/WakeOnLAN.swift index c05a78754..0d1368e23 100644 --- a/Shared/WakeOnLAN.swift +++ b/Shared/WakeOnLAN.swift @@ -25,35 +25,36 @@ struct WakeMetadata: Codable, Equatable { /// `RemoteEndpointStore`'s pattern exactly: keyed by stable peer install ID, /// never by the network address itself, and discarding anything from an /// incompatible schema rather than guessing at its shape. +/// Per role (`RolePreferences`), like `RemoteEndpointStore`. enum WakeMetadataStore { - private static let defaultsKey = "wakeMetadataHints.v1" + private static func defaultsKey(_ role: RolePreferences) -> String { role.key("wakeMetadataHints.v1") } - private static func load() -> [String: WakeMetadata] { - guard let data = UserDefaults.standard.data(forKey: defaultsKey), + private static func load(_ role: RolePreferences) -> [String: WakeMetadata] { + guard let data = UserDefaults.standard.data(forKey: defaultsKey(role)), let decoded = try? JSONDecoder().decode([String: WakeMetadata].self, from: data) else { return [:] } return decoded.filter { $0.value.version == WakeMetadata.schemaVersion } } - private static func save(_ hints: [String: WakeMetadata]) { + private static func save(_ hints: [String: WakeMetadata], _ role: RolePreferences) { guard let data = try? JSONEncoder().encode(hints) else { return } - UserDefaults.standard.set(data, forKey: defaultsKey) + UserDefaults.standard.set(data, forKey: defaultsKey(role)) } - static func metadata(forPeerID peerID: String) -> WakeMetadata? { - load()[peerID] + static func metadata(forPeerID peerID: String, role: RolePreferences) -> WakeMetadata? { + load(role)[peerID] } - static func setMetadata(_ metadata: WakeMetadata, forPeerID peerID: String) { - var hints = load() + static func setMetadata(_ metadata: WakeMetadata, forPeerID peerID: String, role: RolePreferences) { + var hints = load(role) hints[peerID] = metadata - save(hints) + save(hints, role) } - static func removeMetadata(forPeerID peerID: String) { - var hints = load() + static func removeMetadata(forPeerID peerID: String, role: RolePreferences) { + var hints = load(role) hints.removeValue(forKey: peerID) - save(hints) + save(hints, role) } } diff --git a/Shared/WakeTestingView.swift b/Shared/WakeTestingView.swift index 71d44a712..5148e3ae9 100644 --- a/Shared/WakeTestingView.swift +++ b/Shared/WakeTestingView.swift @@ -15,7 +15,7 @@ struct WakeTestingView: View { private var selectedMetadata: WakeMetadata? { guard !selectedPeerID.isEmpty else { return nil } - return WakeMetadataStore.metadata(forPeerID: selectedPeerID) + return WakeMetadataStore.metadata(forPeerID: selectedPeerID, role: .receiverRole) } var body: some View { diff --git a/iOS/OpenSidecarPhoneApp.swift b/iOS/OpenSidecarPhoneApp.swift index adc0fe9cd..4c1a0b8d9 100644 --- a/iOS/OpenSidecarPhoneApp.swift +++ b/iOS/OpenSidecarPhoneApp.swift @@ -1342,7 +1342,7 @@ struct IdleView: View { /// Paired Macs that have a saved Remote endpoint, with display names. private var remoteMacs: [(peerID: String, displayName: String)] { _ = remoteRefresh - let configured = Set(RemoteEndpointStore.allPeerIDs()) + let configured = Set(RemoteEndpointStore.allPeerIDs(role: .receiverRole)) return TrustStore.shared.pinnedPeers().filter { configured.contains($0.peerID) } } @@ -1415,7 +1415,7 @@ struct IdleView: View { Menu { Button("Edit Remote Details…") { presentRemoteSettings(peerID: peerID) } Button("Remove Remote Details", role: .destructive) { - RemoteEndpointStore.removeEndpoint(forPeerID: peerID) + RemoteEndpointStore.removeEndpoint(forPeerID: peerID, role: .receiverRole) remoteRefresh += 1 } } label: { @@ -1481,7 +1481,7 @@ struct IdleView: View { .font(.caption) .buttonStyle(.borderless) } - } else if WakeMetadataStore.metadata(forPeerID: peerID)?.broadcastAddress != nil { + } else if WakeMetadataStore.metadata(forPeerID: peerID, role: .receiverRole)?.broadcastAddress != nil { Button { wakeConnect.begin(peerID: peerID) } label: { diff --git a/iOS/RemoteAccessSettingsView.swift b/iOS/RemoteAccessSettingsView.swift index 00dbb7ee9..03fb12e50 100644 --- a/iOS/RemoteAccessSettingsView.swift +++ b/iOS/RemoteAccessSettingsView.swift @@ -45,7 +45,7 @@ struct RemoteEndpointEditorView: View { } private var selectedPeerHasSavedEndpoint: Bool { - !peerID.isEmpty && RemoteEndpointStore.endpoint(forPeerID: peerID) != nil + !peerID.isEmpty && RemoteEndpointStore.endpoint(forPeerID: peerID, role: .receiverRole) != nil } var body: some View { @@ -96,7 +96,7 @@ struct RemoteEndpointEditorView: View { private func loadSavedEndpoint(for peerID: String) { errorMessage = nil savedConfirmation = false - guard let hint = RemoteEndpointStore.endpoint(forPeerID: peerID) else { + guard let hint = RemoteEndpointStore.endpoint(forPeerID: peerID, role: .receiverRole) else { host = "" port = String(WireCrypto.remoteRequestPort) return @@ -108,8 +108,8 @@ struct RemoteEndpointEditorView: View { private func save() { switch RemoteEndpointValidation.validate(host: host, port: port) { case .success(let value): - RemoteEndpointStore.setEndpoint(value.host, port: value.port, forPeerID: peerID) - guard let readBack = RemoteEndpointStore.endpoint(forPeerID: peerID), + RemoteEndpointStore.setEndpoint(value.host, port: value.port, forPeerID: peerID, role: .receiverRole) + guard let readBack = RemoteEndpointStore.endpoint(forPeerID: peerID, role: .receiverRole), readBack.host == value.host, readBack.port == value.port else { errorMessage = String(localized: "Couldn't save this endpoint. Try again.") return @@ -132,7 +132,7 @@ struct RemoteEndpointEditorView: View { /// the Mac remains paired and reachable locally exactly as before (see /// `ForgetDeviceAction` on the Mac side for the same distinction). private func remove() { - RemoteEndpointStore.removeEndpoint(forPeerID: peerID) + RemoteEndpointStore.removeEndpoint(forPeerID: peerID, role: .receiverRole) host = "" port = String(WireCrypto.remoteRequestPort) errorMessage = nil From 334880fdf98fbcd16fa0b4810de993016701a836 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:31:52 +0530 Subject: [PATCH 05/11] feat(mac): settings for "Use This Mac as a Display" and safer role switching Settings gains a "This Mac as a Display" page, separate from the sender's pages: the role switch and status, the receiver name, the sending Macs paired with this role (Automatically Allow Connections, each Mac's Connection Requests, Connect, Forget) and audio with A/V Sync. Its sections are the standalone receiver's own, extracted so both apps share them (ReceiverPairedMacsSection, ReceiverAudioSection, ReceiverNameField). With the role off, paired Macs can still be forgotten (StreamReceiver. removeStoredTrust) without putting this Mac on the network. Overview links to the page. - The Mirror-unavailable "Use Extend" offer is back: the receiver's attention hook opens Settings, which shows the standalone receiver's alert (now MirrorUnavailableOfferAlert). - Changing Dock/menu-bar presentation while the display window is up no longer drops the app out of Command-Tab (MacAppActivationPolicy). - Turning the role off and straight back on waits for the old receiver to release its listeners before starting a new one. - The receiver's own-sender discovery filter reads the sender's id on every update instead of caching a possibly missing one. Tests cover the activation rule, the settings page and search, and guard that the receiver's settings never reach the sender's trust or keys. --- Mac/DisplayRoleSection.swift | 113 +++++++++++++++++- Mac/MacAppRole.swift | 10 ++ Mac/MacSettingsView.swift | 9 +- Mac/OpenSidecarMacApp.swift | 21 +++- Mac/OverviewSettingsView.swift | 3 +- Mac/SettingsCategory.swift | 7 +- MacReceiver/MacReceiver.swift | 18 ++- MacReceiver/ReceiverDevicesSettingsView.swift | 81 +++++++------ MacReceiver/ReceiverSettingsWindow.swift | 18 ++- .../ReceiverStreamingSettingsView.swift | 19 +-- MacReceiver/ReceiverSystemSettingsView.swift | 5 +- MacTests/UnifiedMacAppTests.swift | 42 +++++++ Shared/StreamReceiver.swift | 22 +++- 13 files changed, 307 insertions(+), 61 deletions(-) diff --git a/Mac/DisplayRoleSection.swift b/Mac/DisplayRoleSection.swift index 1d25d958e..3cd49289d 100644 --- a/Mac/DisplayRoleSection.swift +++ b/Mac/DisplayRoleSection.swift @@ -5,6 +5,8 @@ import SwiftUI /// and waits for another Mac to connect to it as a display. struct DisplayRoleSection: View { @ObservedObject var controller: SenderController + /// Overview's link to the "This Mac as a Display" settings page. + var onOpenSettings: (() -> Void)? @ObservedObject private var receiver = ReceiverController.shared var body: some View { @@ -19,8 +21,18 @@ struct DisplayRoleSection: View { } if controller.receiverModeEnabled { LabeledContent("Status", value: receiver.statusTitle) - if receiver.streaming { - Button("Show Window") { receiver.showWindow() } + } + if receiver.streaming || onOpenSettings != nil { + HStack { + if receiver.streaming { + Button("Show Window") { receiver.showWindow() } + .controlSize(.small) + } + if let onOpenSettings { + Button("Display Settings…", action: onOpenSettings) + .controlSize(.small) + } + Spacer() } } } header: { @@ -35,6 +47,103 @@ struct DisplayRoleSection: View { } } +/// "This Mac as a Display" in MeowDisplay's Settings: the receiver role's +/// own settings, on one page apart from the sender's. Built from the +/// standalone receiver's sections, so both manage the role identically: +/// status, name, the sending Macs paired with this role (Automatically Allow +/// Connections, each Mac's Connection Requests, Connect, Forget) and audio +/// with A/V Sync. Everything here reads and writes the receiver role's own +/// trust and settings (`TrustStore.receiverRole`, `RolePreferences.receiverRole`). +struct DisplayRoleSettingsView: View { + @ObservedObject var controller: SenderController + @ObservedObject private var receiverController = ReceiverController.shared + + var body: some View { + Form { + DisplayRoleSection(controller: controller) + + Section { + ReceiverNameField { receiverController.setAdvertisedName($0) } + } header: { + Text("Name") + } footer: { + DisplayRoleCaption("How this Mac appears in the sending Mac's Devices list.") + } + + if let receiver = receiverController.receiver, let wakeConnect = receiverController.wakeConnect { + ReceiverPairedMacsSection(receiver: receiver, wakeConnect: wakeConnect) + ReceiverAudioSection(receiver: receiver, controller: receiverController) + } else { + DisplayRoleStoredMacsSection() + } + } + .formStyle(.grouped) + } +} + +/// The display role's paired Macs while the role is off: still listed and +/// forgettable, so trust can be revoked without first putting this Mac on +/// the network as a display. Nothing is live, so Forget is the stored part +/// of the receiver's Forget (`StreamReceiver.removeStoredTrust`). +private struct DisplayRoleStoredMacsSection: View { + @State private var forgetConfirmation = PeerForgetPrompt() + @State private var trustRefresh = 0 + + private var pairedMacs: [(peerID: String, displayName: String)] { + _ = trustRefresh + return TrustStore.receiverRole.pinnedPeers() + } + + var body: some View { + Section { + if pairedMacs.isEmpty { + DisplayRoleCaption("No paired Macs") + } else { + AutomaticallyAllowConnectionsToggle() + ForEach(pairedMacs, id: \.peerID) { mac in + HStack { + Text(verbatim: mac.displayName) + Spacer() + Button("Forget…", role: .destructive) { + forgetConfirmation.request(peerID: mac.peerID, name: mac.displayName) + } + .controlSize(.small) + } + } + } + } header: { + Text("Paired Macs") + } footer: { + DisplayRoleCaption("Macs paired with this Mac as a display — separate from the devices this Mac shares to. Turn on Use This Mac as a Display to connect, pair, set each Mac's connection requests, or change audio.") + } + .alert(Text("Forget “\(forgetConfirmation.candidate?.name ?? String(localized: "This Mac"))”?"), + isPresented: Binding(get: { forgetConfirmation.isPresented }, + set: { if !$0 { forgetConfirmation.cancel() } })) { + Button("Forget", role: .destructive) { + forgetConfirmation.confirm { peerID in + StreamReceiver.removeStoredTrust(peerID: peerID) + trustRefresh &+= 1 + } + } + Button("Cancel", role: .cancel) { forgetConfirmation.cancel() } + } message: { + Text("That Mac will need to pair with this Mac again before using it as a display.") + } + } +} + +private struct DisplayRoleCaption: View { + let text: LocalizedStringKey + + init(_ text: LocalizedStringKey) { self.text = text } + + var body: some View { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + } +} + /// The same switch in the menu bar's quick view. struct DisplayRoleMenuToggle: View { @ObservedObject var controller: SenderController diff --git a/Mac/MacAppRole.swift b/Mac/MacAppRole.swift index 3a0898710..8ede27ccf 100644 --- a/Mac/MacAppRole.swift +++ b/Mac/MacAppRole.swift @@ -62,3 +62,13 @@ enum StandaloneReceiverConflict { runningBundleIdentifiers.contains { $0.map(bundleIdentifiers.contains) ?? false } } } + +/// The unified app is a menu-bar (accessory) app unless the user chose a +/// Dock icon. While the display role's video window is up it is a regular +/// app, so it has a Dock icon and Command-Tab can leave the full-screen +/// display; changing the presentation setting meanwhile must not undo that. +enum MacAppActivationPolicy { + static func isRegular(showsDockIcon: Bool, displayWindowVisible: Bool) -> Bool { + showsDockIcon || displayWindowVisible + } +} diff --git a/Mac/MacSettingsView.swift b/Mac/MacSettingsView.swift index deb90d1ef..0fd2b093d 100644 --- a/Mac/MacSettingsView.swift +++ b/Mac/MacSettingsView.swift @@ -437,7 +437,8 @@ struct MacSettingsDetailView: View { permissions: permissions, onOpenDisplays: { navigationModel.navigateTo(.displays) }, onOpenDevices: { navigationModel.navigateTo(.devices) }, - onOpenSystem: { navigationModel.navigateTo(.system) } + onOpenSystem: { navigationModel.navigateTo(.system) }, + onOpenDisplayRole: { navigationModel.navigateTo(.displayRole) } ) case .displays: DisplaysSettingsView(controller: controller) @@ -449,6 +450,8 @@ struct MacSettingsDetailView: View { DevicesSettingsView(controller: controller) case .remoteAccess: RemoteAccessSettingsView(controller: controller) + case .displayRole: + DisplayRoleSettingsView(controller: controller) case .system: SystemSettingsView(controller: controller, permissions: permissions, updater: updater) #if DEBUG @@ -465,6 +468,10 @@ struct MacSettingsDetailView: View { .id(SettingsDetailIdentity(category: navigationModel.current, rootGeneration: navigationModel.rootGeneration)) .frame(minWidth: 500, minHeight: 450) .background(DetailNavigationKeyboardShortcuts(navigationModel: navigationModel)) + // "Use This Mac as a Display": the sending Mac is headless and Mirror + // was asked for. Shown on whichever page is open (onNeedsAttention + // brings this window up), as the standalone receiver does. + .modifier(MirrorUnavailableOfferAlert(controller: ReceiverController.shared)) } } diff --git a/Mac/OpenSidecarMacApp.swift b/Mac/OpenSidecarMacApp.swift index d2b318889..9b6c7ea71 100644 --- a/Mac/OpenSidecarMacApp.swift +++ b/Mac/OpenSidecarMacApp.swift @@ -351,7 +351,7 @@ final class SenderController: ObservableObject { didSet { guard presentation != oldValue else { return } UserDefaults.standard.set(presentation.rawValue, forKey: "presentation") - NSApp.setActivationPolicy(presentation.showsDockIcon ? .regular : .accessory) + applyActivationPolicy() // Live switch: create/destroy the single status item right here, // rather than only reading the setting at launch — this is the // fix for the mode never actually gaining a menu-bar icon. @@ -450,6 +450,18 @@ final class SenderController: ObservableObject { applyRoleEffects(roleState.launchEffects) } + /// Whether the display role's video window is on screen. + private var displayWindowVisible = false + + /// Dock and Command-Tab: the user's presentation choice, overridden to a + /// regular app while the display window is up so Command-Tab is always a + /// way out of it (`MacAppActivationPolicy`). + private func applyActivationPolicy() { + NSApp.setActivationPolicy(MacAppActivationPolicy.isRegular( + showsDockIcon: presentation.showsDockIcon, displayWindowVisible: displayWindowVisible) + ? .regular : .accessory) + } + private func applyRoleEffects(_ effects: [MacAppRoleState.Effect]) { for effect in effects { switch effect { @@ -470,8 +482,13 @@ final class SenderController: ObservableObject { // a regular one, so Command-Tab is always the way out. receiver.onVideoWindowVisibilityChanged = { [weak self] visible in guard let self else { return } - NSApp.setActivationPolicy(visible || self.presentation.showsDockIcon ? .regular : .accessory) + self.displayWindowVisible = visible + self.applyActivationPolicy() } + // The Mirror-unavailable offer: bring Settings up, where its + // alert shows (MirrorUnavailableOfferAlert), as the + // standalone receiver does with its own window. + receiver.onNeedsAttention = { MacSettingsWindow.show() } receiver.start() Log.info("roleDebug: receiver started") case .stopReceiver: diff --git a/Mac/OverviewSettingsView.swift b/Mac/OverviewSettingsView.swift index d85685731..aa528a0dc 100644 --- a/Mac/OverviewSettingsView.swift +++ b/Mac/OverviewSettingsView.swift @@ -9,6 +9,7 @@ struct OverviewSettingsView: View { let onOpenDisplays: () -> Void let onOpenDevices: () -> Void let onOpenSystem: () -> Void + let onOpenDisplayRole: () -> Void var body: some View { Form { @@ -103,7 +104,7 @@ struct OverviewSettingsView: View { } } } - DisplayRoleSection(controller: controller) + DisplayRoleSection(controller: controller, onOpenSettings: onOpenDisplayRole) } .formStyle(.grouped) } diff --git a/Mac/SettingsCategory.swift b/Mac/SettingsCategory.swift index 3154253d3..507d4d004 100644 --- a/Mac/SettingsCategory.swift +++ b/Mac/SettingsCategory.swift @@ -5,7 +5,7 @@ import Combine /// used across Mac Receiver and iOS where it applies — see their own /// category types for the (smaller) sets that make sense there. enum SettingsCategory: String, CaseIterable, Identifiable { - case overview, displays, streaming, input, devices, remoteAccess, system + case overview, displays, streaming, input, devices, remoteAccess, displayRole, system #if DEBUG case developer #endif @@ -20,6 +20,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .input: return String(localized: "Input") case .devices: return String(localized: "Devices") case .remoteAccess: return String(localized: "Remote Access") + case .displayRole: return String(localized: "This Mac as a Display") case .system: return String(localized: "System") #if DEBUG case .developer: return String(localized: "Developer") @@ -35,6 +36,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .input: return "hand.tap" case .devices: return "list.bullet.rectangle" case .remoteAccess: return "network" + case .displayRole: return "display.and.arrow.down" case .system: return "gearshape" #if DEBUG case .developer: return "wrench.and.screwdriver" @@ -94,6 +96,9 @@ struct SettingsSearchIndex { keywords = ["devices", "device", "ipad", "iphone", "connected", "known devices", "pair"] case .remoteAccess: keywords = ["remote", "remote access", "tailscale", "magicdns", "endpoint", "private network"] + case .displayRole: + keywords = ["use this mac as a display", "receiver", "display role", "paired macs", "forget", + "receiver name", "audio", "a/v sync", "automatically allow"] case .system: keywords = ["system", "preferences", "settings", "meowdisplay", "app", "permissions", "available"] #if DEBUG diff --git a/MacReceiver/MacReceiver.swift b/MacReceiver/MacReceiver.swift index 5638ca2e1..76b2a6b91 100644 --- a/MacReceiver/MacReceiver.swift +++ b/MacReceiver/MacReceiver.swift @@ -64,6 +64,10 @@ final class ReceiverController: ObservableObject { private var window: NSWindow? private var retiringReceiver: StreamReceiver? + /// `start()` arrived while the previous receiver was still tearing down + /// (MeowDisplay's display role turned off and straight back on). It + /// runs once that receiver has released its listeners and services. + private var startWhenRetired = false private var cancellables = Set() private var sleepActivity: NSObjectProtocol? private var screenObserver: NSObjectProtocol? @@ -75,6 +79,10 @@ final class ReceiverController: ObservableObject { func start() { guard receiver == nil else { return } + guard retiringReceiver == nil else { + startWhenRetired = true + return + } // 4096x2304 is H.264's practical hardware-decode ceiling: end-to-end // playback stops below 5120 wide on every Mac measured, including an // M4 Pro — a format limit, not an age one. A 5K/6K panel still gets @@ -191,6 +199,7 @@ final class ReceiverController: ObservableObject { /// to a live sender (or a second has passed) — the quit path waits on it /// so the sender ends its session instead of retrying a dead peer. func stop(completion: (@Sendable () -> Void)? = nil) { + startWhenRetired = false guard let receiver else { completion?(); return } cancellables.removeAll() if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) } @@ -207,7 +216,14 @@ final class ReceiverController: ObservableObject { // observing it are released as soon as `receiver` goes nil below. retiringReceiver = receiver receiver.stop { [weak self] in - Task { @MainActor in self?.retiringReceiver = nil } + Task { @MainActor in + guard let self else { return } + self.retiringReceiver = nil + if self.startWhenRetired { + self.startWhenRetired = false + self.start() + } + } completion?() } self.receiver = nil diff --git a/MacReceiver/ReceiverDevicesSettingsView.swift b/MacReceiver/ReceiverDevicesSettingsView.swift index 833175751..89bf0e8f6 100644 --- a/MacReceiver/ReceiverDevicesSettingsView.swift +++ b/MacReceiver/ReceiverDevicesSettingsView.swift @@ -20,15 +20,6 @@ struct ReceiverDevicesSettingsView: View { private struct ReceiverDevicesPage: View { @ObservedObject var receiver: StreamReceiver @ObservedObject var wakeConnect: WakeConnectCoordinator - @State private var forgetConfirmation = PeerForgetPrompt() - // TrustStore isn't observable; bumped after a local Forget. Pairing - // successes and forgets also republish the receiver itself. - @State private var trustRefresh = 0 - - private var pairedMacs: [(peerID: String, displayName: String)] { - _ = trustRefresh - return ReceiverMacList.paired() - } private var unpairedNearby: [NWBrowser.Result] { ReceiverMacList.unpairedNearby(receiver) @@ -36,31 +27,7 @@ private struct ReceiverDevicesPage: View { var body: some View { ReceiverSettingsForm { - Section { - if let status = receiver.pairingPrompt.status { - ReceiverCaption(verbatim: status) - } - if pairedMacs.isEmpty { - ReceiverCaption("No paired Macs") - } else { - AutomaticallyAllowConnectionsToggle() - ForEach(pairedMacs, id: \.peerID) { mac in - ReceiverPairedMacRow(receiver: receiver, wakeConnect: wakeConnect, - peerID: mac.peerID, name: mac.displayName) { - Button("Forget…", role: .destructive) { - forgetConfirmation.request(peerID: mac.peerID, name: mac.displayName) - } - .controlSize(.small) - } - IncomingSessionPolicyPicker(peerID: mac.peerID, receiver: receiver) - .controlSize(.small) - } - } - } header: { - Text("Paired Macs") - } footer: { - ReceiverCaption("Connect asks that Mac to start streaming to this one. Connection Requests: Default follows Automatically Allow Connections; blocking keeps the Mac paired. Forgetting a Mac removes its pairing; you'll need to pair again before connecting.") - } + ReceiverPairedMacsSection(receiver: receiver, wakeConnect: wakeConnect) Section { if unpairedNearby.isEmpty { @@ -76,6 +43,52 @@ private struct ReceiverDevicesPage: View { ReceiverCaption("Macs on this network running MeowDisplay. Pairing shows a code on both Macs — confirm only if they match. You can also start pairing from the other Mac's Devices list.") } } + } +} + +/// The Macs this receiver trusts, with Automatically Allow Connections, each +/// Mac's Connection Requests override, Connect and Forget. Shared by the +/// standalone receiver's Devices page and MeowDisplay's "This Mac as a +/// Display" page, so both manage the receiver role's trust the same way. +struct ReceiverPairedMacsSection: View { + @ObservedObject var receiver: StreamReceiver + @ObservedObject var wakeConnect: WakeConnectCoordinator + @State private var forgetConfirmation = PeerForgetPrompt() + // TrustStore isn't observable; bumped after a local Forget. Pairing + // successes and forgets also republish the receiver itself. + @State private var trustRefresh = 0 + + private var pairedMacs: [(peerID: String, displayName: String)] { + _ = trustRefresh + return ReceiverMacList.paired() + } + + var body: some View { + Section { + if let status = receiver.pairingPrompt.status { + ReceiverCaption(verbatim: status) + } + if pairedMacs.isEmpty { + ReceiverCaption("No paired Macs") + } else { + AutomaticallyAllowConnectionsToggle() + ForEach(pairedMacs, id: \.peerID) { mac in + ReceiverPairedMacRow(receiver: receiver, wakeConnect: wakeConnect, + peerID: mac.peerID, name: mac.displayName) { + Button("Forget…", role: .destructive) { + forgetConfirmation.request(peerID: mac.peerID, name: mac.displayName) + } + .controlSize(.small) + } + IncomingSessionPolicyPicker(peerID: mac.peerID, receiver: receiver) + .controlSize(.small) + } + } + } header: { + Text("Paired Macs") + } footer: { + ReceiverCaption("Connect asks that Mac to start streaming to this one. Connection Requests: Default follows Automatically Allow Connections; blocking keeps the Mac paired. Forgetting a Mac removes its pairing; you'll need to pair again before connecting.") + } .alert(Text("Forget “\(forgetConfirmation.candidate?.name ?? String(localized: "This Mac"))”?"), isPresented: Binding(get: { forgetConfirmation.isPresented }, set: { if !$0 { forgetConfirmation.cancel() } })) { diff --git a/MacReceiver/ReceiverSettingsWindow.swift b/MacReceiver/ReceiverSettingsWindow.swift index 4ddd91283..feca41310 100644 --- a/MacReceiver/ReceiverSettingsWindow.swift +++ b/MacReceiver/ReceiverSettingsWindow.swift @@ -386,10 +386,20 @@ struct ReceiverSettingsDetailView: View { } .frame(minWidth: 500, minHeight: 450) .background(ReceiverNavigationKeyboardShortcuts(navigationModel: navigationModel)) - // The sender is headless, so Mirror has nothing to capture. "Use - // Extend" sends the existing displayModeRequest; the sender decides. - .alert("Mirror isn’t available", - isPresented: Binding(get: { controller.mirrorUnavailableOffer }, set: { _ in })) { + .modifier(MirrorUnavailableOfferAlert(controller: controller)) + } +} + +/// The sender is headless, so Mirror has nothing to capture. "Use Extend" +/// sends the existing displayModeRequest; the sender decides. Withdrawn by +/// the sender (or its timeout), the alert goes away by itself. On the +/// standalone receiver's Settings window and on MeowDisplay's Settings. +struct MirrorUnavailableOfferAlert: ViewModifier { + @ObservedObject var controller: ReceiverController + + func body(content: Content) -> some View { + content.alert("Mirror isn’t available", + isPresented: Binding(get: { controller.mirrorUnavailableOffer }, set: { _ in })) { Button("Cancel", role: .cancel) { controller.declineMirrorUnavailableOffer() } Button("Use Extend") { controller.acceptMirrorUnavailableOffer() } } message: { diff --git a/MacReceiver/ReceiverStreamingSettingsView.swift b/MacReceiver/ReceiverStreamingSettingsView.swift index 9354ba02a..b39c4af66 100644 --- a/MacReceiver/ReceiverStreamingSettingsView.swift +++ b/MacReceiver/ReceiverStreamingSettingsView.swift @@ -18,7 +18,6 @@ struct ReceiverStreamingSettingsView: View { private struct ReceiverStreamingPage: View { @ObservedObject var receiver: StreamReceiver @ObservedObject var controller: ReceiverController - @AppStorage(ReceiverController.audioPreferredKey) private var audioPreferred = false var body: some View { ReceiverSettingsForm { @@ -70,7 +69,7 @@ private struct ReceiverStreamingPage: View { } } - audioSection + ReceiverAudioSection(receiver: receiver, controller: controller) if receiver.connected, receiver.videoSize != .zero { Section("Current Stream") { @@ -123,12 +122,18 @@ private struct ReceiverStreamingPage: View { ? String(localized: "Waiting for Mac") : String(localized: "Unavailable")) } } +} - /// The receiver's own audio opt-in, plus the receiver-local A/V Sync - /// offset and Resync — the same controls, range and step - /// (`AVSyncOffset`) as the iPhone app. - @ViewBuilder - private var audioSection: some View { +/// The receiver's own audio opt-in, plus the receiver-local A/V Sync offset +/// and Resync — the same controls, range and step (`AVSyncOffset`) as the +/// iPhone app. Shared by the standalone receiver's Streaming page and +/// MeowDisplay's "This Mac as a Display" page. +struct ReceiverAudioSection: View { + @ObservedObject var receiver: StreamReceiver + @ObservedObject var controller: ReceiverController + @AppStorage(ReceiverController.audioPreferredKey) private var audioPreferred = false + + var body: some View { Section { VStack(alignment: .leading, spacing: 4) { Toggle("Audio", isOn: Binding( diff --git a/MacReceiver/ReceiverSystemSettingsView.swift b/MacReceiver/ReceiverSystemSettingsView.swift index 58d69823f..4e1c08055 100644 --- a/MacReceiver/ReceiverSystemSettingsView.swift +++ b/MacReceiver/ReceiverSystemSettingsView.swift @@ -82,8 +82,9 @@ private struct ReceiverSystemPage: View { /// The advertised-name editor — kept out of any high-frequency observed /// object so streaming updates can't rebuild it mid-edit (same reasoning as -/// the iOS DeviceNameField). -private struct ReceiverNameField: View { +/// the iOS DeviceNameField). Also on MeowDisplay's "This Mac as a Display" +/// page. +struct ReceiverNameField: View { @AppStorage("receiverName") private var name = Host.current().localizedName ?? "Mac" let onChange: (String) -> Void diff --git a/MacTests/UnifiedMacAppTests.swift b/MacTests/UnifiedMacAppTests.swift index 5ae5fc9b8..9250d26ea 100644 --- a/MacTests/UnifiedMacAppTests.swift +++ b/MacTests/UnifiedMacAppTests.swift @@ -164,4 +164,46 @@ final class UnifiedMacAppTests: XCTestCase { XCTAssertTrue(receiver.contains("SUFeedURL: https://meowdisplay.app/appcast-receiver.xml")) XCTAssertFalse(receiver.contains("MeowDisplayReceiverRoleTrust"), "its users keep their identity and pins") } + + // MARK: - Dock and Command-Tab + + func testTheDisplayWindowAlwaysMakesTheAppRegular() { + XCTAssertFalse(MacAppActivationPolicy.isRegular(showsDockIcon: false, displayWindowVisible: false), + "menu-bar-only, as the user chose") + XCTAssertTrue(MacAppActivationPolicy.isRegular(showsDockIcon: true, displayWindowVisible: false)) + XCTAssertTrue(MacAppActivationPolicy.isRegular(showsDockIcon: false, displayWindowVisible: true), + "Command-Tab must leave a full-screen display window, whatever the presentation setting") + XCTAssertTrue(MacAppActivationPolicy.isRegular(showsDockIcon: true, displayWindowVisible: true)) + } + + // MARK: - Settings + + func testTheDisplayRoleHasItsOwnSettingsPage() { + let categories = Array(SettingsCategory.allCases) + let index = categories.firstIndex(of: .displayRole) + XCTAssertNotNil(index) + XCTAssertEqual(index.flatMap { categories.indices.contains($0 + 1) ? categories[$0 + 1] : nil }, .system, + "beside the sender's pages, before System") + XCTAssertEqual(SettingsCategory.displayRole.label, "This Mac as a Display") + XCTAssertFalse(SettingsCategory.displayRole.systemImage.isEmpty) + for query in ["This Mac as a Display", "paired macs", "receiver name"] { + XCTAssertEqual(SettingsSearchIndex.shared.search(query: query).first?.category, .displayRole, query) + } + } + + /// The display role's settings page and the receiver views it reuses + /// only ever touch the receiver role's trust and settings. + func testReceiverSettingsNeverReachTheSendersTrustOrKeys() throws { + let files = ["Mac/DisplayRoleSection.swift", "Shared/SessionInvitationViews.swift"] + + (try FileManager.default.contentsOfDirectory(atPath: Self.repoRoot.appendingPathComponent("MacReceiver").path)) + .filter { $0.hasSuffix(".swift") }.map { "MacReceiver/\($0)" } + for file in files { + let source = try String(contentsOf: Self.repoRoot.appendingPathComponent(file), encoding: .utf8) + for forbidden in ["TrustStore.shared", "role: .sender", "\"incomingSessionAutomaticallyAllow\"", + "\"incomingSessionPeerPolicy.v1\"", "\"remoteEndpointHints.v1\"", + "\"wakeMetadataHints.v1\""] { + XCTAssertFalse(source.contains(forbidden), "\(file) uses \(forbidden)") + } + } + } } diff --git a/Shared/StreamReceiver.swift b/Shared/StreamReceiver.swift index 8673c7963..f4419745f 100644 --- a/Shared/StreamReceiver.swift +++ b/Shared/StreamReceiver.swift @@ -1549,14 +1549,22 @@ final class StreamReceiver: ObservableObject { } func forgetPeer(_ peerID: String) { - TrustStore.receiverRole.forget(peerID: peerID) - IncomingSessionPolicyStore.removePolicy(peerID: peerID, role: .receiverRole) - InputAutoRequestStore.remove(peerID: peerID) + Self.removeStoredTrust(peerID: peerID) let uiSink = self.uiSink DispatchQueue.main.async { uiSink.publishLastForgottenPeerID(peerID) } revokeSessionAuthority(peerID: peerID, trustRemoved: true) } + /// What Forget removes from storage: the receiver role's pin for the + /// peer and its per-peer preferences. `forgetPeer` also revokes live + /// authority; with no receiver running (the unified Mac app's display + /// role turned off) there is none, and this alone is a complete Forget. + static func removeStoredTrust(peerID: String) { + TrustStore.receiverRole.forget(peerID: peerID) + IncomingSessionPolicyStore.removePolicy(peerID: peerID, role: .receiverRole) + InputAutoRequestStore.remove(peerID: peerID) + } + /// The per-Mac Connection Requests override (`IncomingSessionPolicyPicker`). /// Block is a revocation, not only a preference for next time: it does /// everything Forget does except removing trust (see @@ -5337,9 +5345,11 @@ final class StreamReceiver: ObservableObject { } /// In the unified Mac app, this Mac's own sender role — never a Mac to - /// pair with. Empty in a receiver-only app. - private static let sameAppSenderIDs: Set = ReceiverRoleConfiguration.separateTrust - ? Set([TrustStore.shared.installID()].compactMap { $0 }) : [] + /// pair with. Empty in a receiver-only app. Read on every update, not + /// once: the sender's identity may not exist yet the first time. + private static var sameAppSenderIDs: Set { + ReceiverRoleConfiguration.separateTrust ? Set([TrustStore.shared.installID()].compactMap { $0 }) : [] + } /// `pairingSuccessCount`'s external-boundary mutator for the pairing /// listener's `newConnectionHandler`/nested `Task` — see `ReceiverUISink. From da559f97582702ce1dfc1291b4a03f90f4f3f742 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:32:29 +0530 Subject: [PATCH 06/11] docs: describe role-scoped approval, the display settings page and the updated checklist --- UNIFIED_MAC_APP.md | 88 +++++++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/UNIFIED_MAC_APP.md b/UNIFIED_MAC_APP.md index 06ae7f3ce..56458c2dc 100644 --- a/UNIFIED_MAC_APP.md +++ b/UNIFIED_MAC_APP.md @@ -63,9 +63,23 @@ Macs gain nothing over the standalone receiver they already have. - Pairing a Mac as this Mac's display never makes it a trusted sender here, and the reverse. - The sender's Keychain names are unchanged and pinned by a test. - The iPhone/iPad app and the standalone receiver don't declare `MeowDisplayReceiverRoleTrust`, so for them `TrustStore.receiverRole` is `TrustStore.shared` and nothing changes. -- **Settings:** the only key both roles used, `autoReconnectEnabled`, is `receiver.autoReconnectEnabled` for the receiver role in this app. The receiver's other keys (`receiverName`, `installID`, `audioPreferred`, `avSyncOffsetMs`, `showAnalytics`) don't collide with the sender's. +- **Approval policy and peer data** (`RolePreferences`): each role keeps its own "Automatically Allow Connections", per-Mac Connection Requests overrides, Remote endpoint hints and Wake metadata. In this app the receiver role's keys are `receiver.`-prefixed (`receiver.incomingSessionAutomaticallyAllow`, `receiver.incomingSessionPeerPolicy.v1`, `receiver.remoteEndpointHints.v1`, `receiver.wakeMetadataHints.v1`, `receiver.inputAutoRequestPeers.v1`, `receiver.autoReconnectEnabled`). + - Every call to those stores names its role, so the compiler catches a call site that doesn't. + - The receiver role's automatic approval is **off by default**: a Mac connecting to this Mac as a display asks first, until the user turns it on for that role. The sender's stays on by default, as in 1.20.0. + - Separate install ids weren't enough on their own: a peer chooses the id it pairs with, so one id can be pinned in both roles. + - The sender, the iPhone/iPad app and the standalone receiver keep the original key names, so their saved choices are read exactly as before. +- **Other settings:** the receiver's remaining keys (`receiverName`, `installID`, `audioPreferred`, `avSyncOffsetMs`, `showAnalytics`, `receiverFullscreen`, `receiverControlPreferences.v1`) are used only by the receiver role. The sender's per-device keys (`extendShape.*`, `receiverMaxFPS.*`, `installIDByUDID`) are used only by the sender. Cat Mode is shared, and is cosmetic. +- **Settings page:** **This Mac as a Display** in MeowDisplay's Settings (and a link from Overview) holds the role's own settings, apart from the sender's pages: + - the switch and status + - name + - paired sending Macs, with Automatically Allow Connections, each Mac's Connection Requests, Connect and Forget + - audio and A/V Sync + + Its sections are the standalone receiver's own, shared between both apps. With the role off, paired Macs are still listed and can be forgotten, without putting this Mac on the network. - **No self-discovery:** the sender ignores this app's receiver in its discovery and pairing browsers, and the receiver ignores this app's sender in its Mac-pairing browser (`SameAppPeerFilter`). -- **Window and focus:** while the display window is up the app is a regular app (Dock, Command-Tab), so Command-Tab is always a way out. It returns to the user's presentation when the window goes away. +- **Window and focus:** while the display window is up the app is a regular app (Dock, Command-Tab), so Command-Tab is always a way out. Changing the Dock/menu-bar setting meanwhile doesn't undo that. It returns to the user's presentation when the window goes away. +- **Mirror unavailable:** when the sending Mac is headless and Mirror was asked for, Settings opens with the standalone receiver's "Use Extend" offer. +- **Off and straight back on:** the new receiver starts only after the old one has released its listeners and network services. - **Quit:** with the display role on, quitting sends the receiver's `closing` first, as the standalone receiver does. - **Standalone receiver running:** the role refuses to start (and says why), because both would race for ports 9001/9002 and the same Bonjour services. - **Unchanged:** pairing, TLS 1.3, pinning, per-session input authorization and the wire protocol. @@ -89,39 +103,63 @@ through the existing `appcast.xml` feed. - **Release workflow and Fastlane:** they build the same two targets as today. Nothing in `release.yml`, Fastlane or the feeds needs to change for the prototype. Release notes should point macOS 14+ receiver users to the unified app. **Rollback:** -- Downgrading to 1.20.0 is safe. 1.20.0 ignores the receiver-role Keychain items and the `receiverModeEnabled`, `receiver.autoReconnectEnabled` and `installID` settings, and its sender data was never modified. +- Downgrading to 1.20.0 is safe. 1.20.0 ignores the receiver-role Keychain items and the `receiverModeEnabled`, `receiver.*` and `installID` settings, and its sender data was never modified. - If the role misbehaves after release, ship a sender update with the switch hidden. The receiver-role data stays in its own namespace. ## Verified (automated, this branch) -- **macOS suite:** `xcodebuild test -scheme OpenSidecarMac` passes **1960 tests, 0 failures**. That's 1946 on #25's head plus 14 new in `UnifiedMacAppTests`: - - role transitions, persistence and rapid toggling - - the sender's original Keychain names - - receiver-role separation, and receiver-only apps unchanged - - the role-scoped settings key - - same-app discovery filtering - - standalone-receiver conflict - - packaging (bundle id, floor, feed, excludes, standalone receiver at 12.0) -- **Builds:** `OpenSidecarMacReceiver` (macOS 12 target) and `OpenSidecariOS` (generic device) both build. The unified bundle has the sender's id, `LSMinimumSystemVersion` 14.0, `LSUIElement`, `appcast.xml`, `MeowDisplayReceiverRoleTrust = separate`, and the receiver code linked in. -- **Not covered by automation:** live Keychain isolation (the unsigned test bundle has no Keychain entitlement), TCC, Bonjour on a network, and the window and activation-policy behavior. +- **macOS suite:** `xcodebuild test -scheme OpenSidecarMac` passes **1977 tests, 0 failures**. That's 1946 on #25's head plus 31 new: + - `RolePreferencesIsolationTests` (14): the approval-policy leak in both directions, the receiver role's own default, original key names for existing installs, per-Mac overrides with the same peer id in both roles, role switching and relaunch, both authorization entry points, and per-role endpoint and Wake data. With the keys still shared, 12 of the 14 failed. + - `UnifiedMacAppTests` (17): + - role transitions, persistence and rapid toggling + - the sender's original Keychain names + - receiver-role separation, and receiver-only apps unchanged + - role-scoped settings keys + - same-app discovery filtering + - standalone-receiver conflict + - packaging (bundle id, floor, feed, excludes, standalone receiver at 12.0) + - the Dock/Command-Tab rule + - the settings page and its search + - a guard that the receiver's settings code never uses the sender's trust store or keys +- **Builds:** `OpenSidecarMac`, `OpenSidecarMacReceiver` (macOS 12 target) and `OpenSidecariOS` (generic device) all build. The unified bundle has the sender's id, `LSMinimumSystemVersion` 14.0, `LSUIElement`, `MeowDisplayReceiverRoleTrust = separate`, and the receiver code linked in. +- **Not covered by automation:** + - live Keychain isolation (the unsigned test bundle has no Keychain entitlement) + - TCC + - Bonjour on a network + - window and activation-policy behavior + - the receiver restart timing + - the SwiftUI pages themselves ## Physical checklist -Needs two signed builds of this branch (Developer ID or team-signed Debug), on -Mac A (sender) and Mac B (macOS 14+, to be the display). +Needs two signed builds of this branch (Developer ID or team-signed Debug), +on Mac A (sender) and Mac B (macOS 14+, to be the display). Test #24's +per-device Mirror/Extend and #25's input on the same builds. -1. **Upgrade in place.** On a Mac running 1.20.0 with paired iPhones, install this build: devices reconnect without re-pairing, and Screen Recording/Accessibility stay granted. -2. **Off by default.** Mac B shows nothing on the network until Use This Mac as a Display is on. Mac A's Devices list never shows Mac A itself. +1. **Upgrade in place.** On a Mac running 1.20.0 with paired iPhones, install this build: + - devices reconnect without re-pairing + - Screen Recording/Accessibility stay granted + - "Automatically Allow Connections" and per-device choices are unchanged +2. **Off by default.** Mac B shows nothing on the network until Use This Mac as a Display is on. Mac A's Devices list never shows Mac A itself, and Mac B's Nearby Macs never shows Mac B. 3. **Pair and stream.** On Mac A, pair with Mac B's display role (one code confirmation), connect, Extend, then Mirror. The window goes full screen, Command-Tab works, and #25's control flow works. -4. **Roles exclusive.** With Mac B's display role on, Mac B's own iPhone doesn't connect to Mac B as a sender. Turning the role off brings the iPhone back without re-pairing. -5. **Trust separate.** A Mac paired only with Mac B's sender can't stream to Mac B's display role without pairing to it. -6. **Quit.** Quit Mac B mid-stream: Mac A ends the session rather than redialing. -7. **Standalone receiver.** With MeowDisplay Receiver running on Mac B, the switch refuses with its message. -8. **Rollback.** Reinstall 1.20.0 on Mac A: everything works as before. +4. **Approval is per role.** With Mac B's sender "Automatically Allow Connections" on (the default), Mac A's automatic connection to Mac B's display role is **not** admitted silently: Mac B asks, or declines a background attempt. Turn it on in **This Mac as a Display**: Mac A connects without asking, and Mac B's sender setting is unchanged. +5. **Per-Mac choices are per role.** Block Mac A on the display page. Mac B's sender settings for its devices don't change. Forget Mac A there with the role off, then turn the role on: Mac A must pair again. +6. **Settings page.** + - Rename the display: Mac A's list updates. + - Audio on: sound plays on Mac B, and A/V Sync and Resync work. + - Reopen Settings after relaunch: all of it is remembered. +7. **Roles exclusive.** With Mac B's display role on, Mac B's own iPhone doesn't connect to Mac B as a sender. Turning the role off brings the iPhone back without re-pairing. +8. **Trust separate.** A Mac paired only with Mac B's sender can't stream to Mac B's display role without pairing to it. +9. **Rapid switching.** Turn the role off and on three times quickly, then connect from Mac A: it pairs or connects normally, with no "address in use" in the log. +10. **Mirror unavailable.** Close Mac A's lid (on power, with Mac A's display role off), ask for Mirror from Mac B: Settings opens on Mac B with "Use Extend", and Use Extend works. +11. **Dock and Command-Tab.** In menu-bar-only mode, stream to Mac B: the Dock icon appears and Command-Tab leaves the window. Switch presentation in Settings meanwhile: Command-Tab still works. End the stream: the Dock icon goes away. +12. **Quit.** Quit Mac B mid-stream: Mac A ends the session rather than redialing. +13. **Standalone receiver.** With MeowDisplay Receiver running on Mac B, the switch refuses with its message, at launch and when toggled. +14. **Rollback.** Reinstall 1.20.0 on Mac A: everything works as before. ## Remaining work before release -- **Receiver settings:** the display role's own settings (name, paired senders and Forget, audio, A/V sync) aren't in the unified Settings yet. The prototype uses defaults; pairing prompts and session approvals do appear. -- **Mirror-unavailable offer:** the "Use Extend" alert for a headless sending Mac isn't shown in the unified app yet. That Mirror attempt still fails cleanly when the offer times out. -- **Localization:** the receiver's strings aren't in MeowDisplay's string catalog yet. They're English-only today anyway. +- **Hardware QA** of the checklist above, with #24 and #25. +- **String catalog:** the receiver's strings and the new page's strings aren't in MeowDisplay's catalog yet. Xcode adds them on the next IDE build. The catalogs are English-only, so nothing user-visible changes meanwhile. +- **Standalone receiver started second:** the unified app refuses to start its display role while the standalone receiver runs, but the standalone receiver doesn't check the other way round. Both would then compete for the same ports. Users pick one; a later standalone update could check too. - **Optional migration:** the shared-access-group migration for standalone receiver users, if wanted. From d91ccdd9a3326820bea4f290377172fa1803db98 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:47:52 +0530 Subject: [PATCH 07/11] fix(receiver): one Mac receiver owns the ports, in either launch order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeowDisplay's display role and the standalone MeowDisplay Receiver bind the same ports and Bonjour services. A second bind fails with EADDRINUSE (measured; the port is never shared), but that failure was silent: the second receiver retried forever while saying "Waiting for a Mac…", and its pairing listener never retried, so pairing stayed dead after the first quit. Both now take ReceiverOwnershipLock (a kernel flock on /Users/Shared/.meowdisplay-receiver.lock, released by the kernel on quit or crash) before creating any listener. Whichever starts second binds and advertises nothing, shows what to quit, checks again every 2 s and starts by itself; the owner releases only after its listeners closed. MeowDisplay also waits while an already-released standalone receiver (no lock) runs. A stopped StreamReceiver is retired, so a listener retry scheduled before stop() can't reopen a port, and the pairing listener retries a failed bind like the TLS listener. --- MacReceiver/MacReceiver.swift | 51 +++++++ MacReceiver/ReceiverOwnership.swift | 100 +++++++++++++ MacReceiver/ReceiverSettingsWindow.swift | 4 + MacTests/ReceiverOwnershipTests.swift | 174 +++++++++++++++++++++++ Shared/QUICReceiverSession.swift | 24 +++- Shared/StreamReceiver.swift | 82 +++++++++-- project.yml | 1 + 7 files changed, 426 insertions(+), 10 deletions(-) create mode 100644 MacReceiver/ReceiverOwnership.swift create mode 100644 MacTests/ReceiverOwnershipTests.swift diff --git a/MacReceiver/MacReceiver.swift b/MacReceiver/MacReceiver.swift index 76b2a6b91..0bb4e91ac 100644 --- a/MacReceiver/MacReceiver.swift +++ b/MacReceiver/MacReceiver.swift @@ -62,6 +62,20 @@ final class ReceiverController: ObservableObject { var active: Bool { receiver != nil } + /// Another receiver owns this Mac's receiver ports (`ReceiverOwnershipLock`): + /// `start()` is waiting for it and retries by itself. `statusTitle` + /// says what to do. + @Published private(set) var waitingForOtherReceiver = false + /// What the user can do about the other receiver. The standalone + /// receiver's wording by default; MeowDisplay sets its own. + var otherReceiverMessage = String(localized: "MeowDisplay is already using this Mac as a display. Turn off Use This Mac as a Display there, or quit it, and this receiver starts by itself.") + /// A receiver that predates the ownership lock is running (MeowDisplay + /// checks for already-released MeowDisplay Receiver builds by bundle id). Nil: none to + /// check. + var otherReceiverRunning: (() -> Bool)? + private let ownership = ReceiverOwnershipLock() + private var ownershipRetry: Timer? + private var window: NSWindow? private var retiringReceiver: StreamReceiver? /// `start()` arrived while the previous receiver was still tearing down @@ -83,6 +97,17 @@ final class ReceiverController: ObservableObject { startWhenRetired = true return } + // Never bind or advertise next to another receiver: whichever + // started second waits here, with a status saying why, until the + // owner lets go. + let decision = ReceiverStartGate.decide(otherReceiverRunning: otherReceiverRunning?() ?? false) { + ownership.tryAcquire() + } + guard decision == .start else { + waitForOtherReceiver() + return + } + stopWaitingForOtherReceiver() // 4096x2304 is H.264's practical hardware-decode ceiling: end-to-end // playback stops below 5120 wide on every Mac measured, including an // M4 Pro — a format limit, not an age one. A 5K/6K panel still gets @@ -195,11 +220,34 @@ final class ReceiverController: ObservableObject { Log.info("receiver mode started (advertising \"\(receiver.serviceName)\")") } + private func waitForOtherReceiver() { + if !waitingForOtherReceiver { Log.info("receiver mode waiting: another receiver owns this Mac's receiver ports") } + waitingForOtherReceiver = true + statusTitle = otherReceiverMessage + statusColor = .orange + guard ownershipRetry == nil else { return } + ownershipRetry = Timer.scheduledTimer(withTimeInterval: ReceiverStartGate.retryInterval, + repeats: true) { [weak self] _ in + Task { @MainActor in self?.start() } + } + } + + private func stopWaitingForOtherReceiver() { + ownershipRetry?.invalidate() + ownershipRetry = nil + waitingForOtherReceiver = false + } + /// Leave receiver mode. `completion` fires once "closing" has gone out /// to a live sender (or a second has passed) — the quit path waits on it /// so the sender ends its session instead of retrying a dead peer. func stop(completion: (@Sendable () -> Void)? = nil) { startWhenRetired = false + if waitingForOtherReceiver { + stopWaitingForOtherReceiver() + statusTitle = String(localized: "Waiting for a Mac…") + statusColor = .secondary + } guard let receiver else { completion?(); return } cancellables.removeAll() if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) } @@ -219,6 +267,9 @@ final class ReceiverController: ObservableObject { Task { @MainActor in guard let self else { return } self.retiringReceiver = nil + // Only now have its listeners and services gone: a waiting + // receiver may take over. + self.ownership.release() if self.startWhenRetired { self.startWhenRetired = false self.start() diff --git a/MacReceiver/ReceiverOwnership.swift b/MacReceiver/ReceiverOwnership.swift new file mode 100644 index 000000000..4d1a81c8b --- /dev/null +++ b/MacReceiver/ReceiverOwnership.swift @@ -0,0 +1,100 @@ +import Darwin +import Foundation + +/// Which Mac receiver owns this Mac's receiver ports (TLS and QUIC 9001, +/// pairing 9002) and Bonjour services. MeowDisplay's "Use This Mac as a +/// Display" role and the standalone MeowDisplay Receiver both take it before +/// creating any listener, so whichever starts second never binds, advertises +/// or races for a connection: it waits, says why, and starts by itself once +/// the owner lets go. +/// +/// A kernel `flock` on one file, not a process-name check: it holds across +/// launch order, Debug and Release builds and user accounts (the ports are +/// system-wide), and the kernel drops it if the owner quits or crashes, so a +/// stale owner can't block anyone. +final class ReceiverOwnershipLock { + /// System-wide, so receivers in other user sessions see it too. + static let sharedPath = "/Users/Shared/.meowdisplay-receiver.lock" + + private let paths: [String] + private var descriptor: Int32 = -1 + + /// `paths` are tried in order; the per-user fallback is used only if the + /// shared one can't be opened. + init(paths: [String] = ReceiverOwnershipLock.defaultPaths) { + self.paths = paths + } + + static var defaultPaths: [String] { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + let fallback = support?.appendingPathComponent("MeowDisplay/receiver.lock").path + return [sharedPath] + [fallback].compactMap { $0 } + } + + var isHeld: Bool { descriptor >= 0 } + + /// Takes ownership if nobody else has it. Idempotent while held. + func tryAcquire() -> ReceiverOwnershipAttempt { + if isHeld { return .acquired } + for path in paths { + let directory = (path as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + // Read-only is enough to lock, and works on a file another + // account created. + let fd = open(path, O_RDONLY | O_CREAT | O_CLOEXEC, 0o644) + guard fd >= 0 else { continue } + if flock(fd, LOCK_EX | LOCK_NB) == 0 { + descriptor = fd + return .acquired + } + let lockError = errno + close(fd) + if lockError == EWOULDBLOCK { return .heldByAnotherReceiver } + } + // No lock file could be opened at all: don't make this Mac unusable + // as a display over a file-system problem. Listener binds still fail + // cleanly (EADDRINUSE) if the ports are taken. + return .unavailable + } + + func release() { + guard isHeld else { return } + flock(descriptor, LOCK_UN) + close(descriptor) + descriptor = -1 + } + + deinit { release() } +} + +enum ReceiverOwnershipAttempt: Equatable { + case acquired + case heldByAnotherReceiver + /// The lock file couldn't be opened anywhere. + case unavailable +} + +/// Whether a Mac receiver may start now. Pure, so both launch orders are +/// testable without a second app. +enum ReceiverStartGate { + enum Decision: Equatable { + case start + /// Another receiver owns the ports: show `ReceiverController`'s + /// conflict message and try again shortly. + case waitForOtherReceiver + } + + /// `otherReceiverRunning`: a receiver that predates the lock (an + /// already-released MeowDisplay Receiver) seen by its bundle id. It binds + /// without asking, so starting next to it would only fail to bind. + static func decide(otherReceiverRunning: Bool, attempt: () -> ReceiverOwnershipAttempt) -> Decision { + if otherReceiverRunning { return .waitForOtherReceiver } + switch attempt() { + case .acquired, .unavailable: return .start + case .heldByAnotherReceiver: return .waitForOtherReceiver + } + } + + /// How often a waiting receiver checks again. + static let retryInterval: TimeInterval = 2 +} diff --git a/MacReceiver/ReceiverSettingsWindow.swift b/MacReceiver/ReceiverSettingsWindow.swift index feca41310..05d1a04b6 100644 --- a/MacReceiver/ReceiverSettingsWindow.swift +++ b/MacReceiver/ReceiverSettingsWindow.swift @@ -485,6 +485,10 @@ struct WithReceiver: View { var body: some View { if let receiver = controller.receiver { content(receiver) + } else if controller.waitingForOtherReceiver { + ReceiverSettingsForm { + ReceiverCaption(verbatim: controller.statusTitle) + } } else { ReceiverSettingsForm { ReceiverCaption("The receiver is not running.") diff --git a/MacTests/ReceiverOwnershipTests.swift b/MacTests/ReceiverOwnershipTests.swift new file mode 100644 index 000000000..28f3e74ae --- /dev/null +++ b/MacTests/ReceiverOwnershipTests.swift @@ -0,0 +1,174 @@ +import Network +import XCTest + +/// Two Mac receivers on one Mac — MeowDisplay's "Use This Mac as a Display" +/// role and the standalone MeowDisplay Receiver — in either launch order. +/// Each lock instance stands in for one app: `flock` locks belong to an open +/// file, so two instances conflict exactly as two processes do. +final class ReceiverOwnershipTests: XCTestCase { + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ReceiverOwnershipTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + private var lockPath: String { directory.appendingPathComponent("receiver.lock").path } + + private func app() -> ReceiverOwnershipLock { ReceiverOwnershipLock(paths: [lockPath]) } + + // MARK: - Launch orders + + func testDisplayRoleFirstThenStandaloneReceiver() { + let displayRole = app(), standalone = app() + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { displayRole.tryAcquire() }, .start) + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { standalone.tryAcquire() }, + .waitForOtherReceiver, "the second receiver never binds or advertises") + XCTAssertFalse(standalone.isHeld) + + displayRole.release() // role turned off, after its listeners closed + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { standalone.tryAcquire() }, .start, + "the waiting receiver takes over on its next retry") + } + + func testStandaloneReceiverFirstThenDisplayRole() { + let standalone = app(), displayRole = app() + XCTAssertEqual(standalone.tryAcquire(), .acquired) + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: true) { displayRole.tryAcquire() }, + .waitForOtherReceiver) + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { displayRole.tryAcquire() }, + .waitForOtherReceiver, "the lock alone also keeps it waiting") + standalone.release() // quit + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { displayRole.tryAcquire() }, .start) + } + + func testAReceiverWithoutTheLockStillMakesTheDisplayRoleWait() { + // Already-released MeowDisplay Receiver builds bind without the lock: seen by bundle + // id, and the lock isn't even taken while it runs. + let displayRole = app() + var attempted = false + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: true) { + attempted = true + return displayRole.tryAcquire() + }, .waitForOtherReceiver) + XCTAssertFalse(attempted) + XCTAssertFalse(displayRole.isHeld) + } + + // MARK: - The lock + + func testAQuitOrCrashedOwnerNeverBlocksTheNextReceiver() { + var owner: ReceiverOwnershipLock? = app() + XCTAssertEqual(owner?.tryAcquire(), .acquired) + let next = app() + XCTAssertEqual(next.tryAcquire(), .heldByAnotherReceiver) + owner = nil // the kernel drops a flock with its file, as on quit or crash + XCTAssertEqual(next.tryAcquire(), .acquired) + } + + func testAcquiringIsIdempotentWhileHeld() { + let owner = app() + XCTAssertEqual(owner.tryAcquire(), .acquired) + XCTAssertEqual(owner.tryAcquire(), .acquired) + owner.release() + owner.release() + XCTAssertFalse(owner.isHeld) + } + + func testAReadOnlyLockFileFromAnotherAccountStillWorks() throws { + FileManager.default.createFile(atPath: lockPath, contents: nil, + attributes: [.posixPermissions: 0o444]) + let first = app(), second = app() + XCTAssertEqual(first.tryAcquire(), .acquired) + XCTAssertEqual(second.tryAcquire(), .heldByAnotherReceiver) + } + + func testFallsBackWhenTheSharedLocationIsUnusable() { + let unusable = "/dev/null/meowdisplay/receiver.lock" + let first = ReceiverOwnershipLock(paths: [unusable, lockPath]) + let second = ReceiverOwnershipLock(paths: [unusable, lockPath]) + XCTAssertEqual(first.tryAcquire(), .acquired) + XCTAssertEqual(second.tryAcquire(), .heldByAnotherReceiver) + } + + func testNoUsableLockFileDoesNotStopTheReceiver() { + let lock = ReceiverOwnershipLock(paths: ["/dev/null/meowdisplay/receiver.lock"]) + XCTAssertEqual(lock.tryAcquire(), .unavailable) + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { lock.tryAcquire() }, .start, + "a file-system problem must not make this Mac unusable as a display") + } + + func testTheDefaultLockIsSystemWide() { + XCTAssertEqual(ReceiverOwnershipLock.defaultPaths.first, "/Users/Shared/.meowdisplay-receiver.lock", + "the ports are system-wide, so the owner must be too") + } + + // MARK: - Listener ownership after stop + + func testAStoppedReceiverNeverReopensAListener() throws { + let tls = TLSListenerState() + let first = try NWListener(using: .tcp) + XCTAssertTrue(tls.install(first)) + tls.retire() + XCTAssertTrue(tls.isRetired()) + // A retry scheduled before stop() (after a failed bind) arrives late. + XCTAssertFalse(tls.install(try NWListener(using: .tcp))) + XCTAssertTrue(tls.currentListener() === first, "the refused listener is never installed") + + let quic = QUICReceiverContext() + quic.retire() + XCTAssertFalse(quic.install(try NWListener(using: .udp))) + XCTAssertFalse(quic.hasCurrent()) + + let pairing = PairingListenerState() + pairing.retire() + XCTAssertFalse(pairing.install(try NWListener(using: .tcp))) + XCTAssertFalse(pairing.hasCurrent()) + } + + func testAFailedPairingListenerIsClearedSoItCanRetry() throws { + let pairing = PairingListenerState() + let failed = try NWListener(using: .tcp) + XCTAssertTrue(pairing.install(failed)) + XCTAssertFalse(pairing.clearIfCurrent(try NWListener(using: .tcp)), "a stale failure clears nothing") + XCTAssertTrue(pairing.clearIfCurrent(failed)) + XCTAssertFalse(pairing.hasCurrent(), "startPairingListener's guard lets the retry through") + } + + // MARK: - What a second bind actually does + + /// Why ownership is needed at all, measured rather than assumed: with + /// the receivers' own listener parameters (`allowLocalEndpointReuse`), + /// a second process binding the same port fails with EADDRINUSE — it + /// neither shares the port nor takes it over. Without the lock that + /// failure would be silent and retried forever. + func testASecondBindOnABusyReceiverPortFails() throws { + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + let owner = try NWListener(using: parameters) + let ready = expectation(description: "owner ready") + owner.stateUpdateHandler = { if case .ready = $0 { ready.fulfill() } } + owner.newConnectionHandler = { $0.cancel() } + owner.start(queue: .main) + wait(for: [ready], timeout: 5) + defer { owner.cancel() } + let port = try XCTUnwrap(owner.port) + + let second = try NWListener(using: parameters, on: port) + let failed = expectation(description: "second bind fails") + second.stateUpdateHandler = { state in + if case .failed(let error) = state, case .posix(let code) = error, code == .EADDRINUSE { + failed.fulfill() + } + } + second.newConnectionHandler = { $0.cancel() } + second.start(queue: .main) + wait(for: [failed], timeout: 5) + second.cancel() + } +} diff --git a/Shared/QUICReceiverSession.swift b/Shared/QUICReceiverSession.swift index 0c7145da9..1a7aa67b4 100644 --- a/Shared/QUICReceiverSession.swift +++ b/Shared/QUICReceiverSession.swift @@ -65,9 +65,29 @@ final class QUICReceiverContext: @unchecked Sendable { return current } - func install(_ listener: NWListener) { + /// Set once by `StreamReceiver.stop()`: this receiver is finished, so + /// a retry scheduled before the stop (after a failed bind, for example) + /// must never open a listener again. + private var retired = false + + func retire() { + lock.lock(); defer { lock.unlock() } + retired = true + } + + func isRetired() -> Bool { + lock.lock(); defer { lock.unlock() } + return retired + } + + /// Installs `listener` as current, or refuses (and returns `false`) once + /// retired; the caller then drops the listener without starting it. + @discardableResult + func install(_ listener: NWListener) -> Bool { lock.lock(); defer { lock.unlock() } + guard !retired else { return false } current = listener + return true } func isCurrent(_ listener: NWListener) -> Bool { @@ -503,7 +523,7 @@ enum QUICReceiverListener { do { let params = listenerParameters(quic: quic) let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: QUICTransport.udpPort)!) - context.install(listener) + guard context.install(listener) else { return } listener.service = quicService(advertisementState: advertisementState, installID: installID, protocolVersion: advertisedProtocolVersion) let groups = context.groups diff --git a/Shared/StreamReceiver.swift b/Shared/StreamReceiver.swift index f4419745f..8b3846714 100644 --- a/Shared/StreamReceiver.swift +++ b/Shared/StreamReceiver.swift @@ -373,9 +373,29 @@ final class TLSListenerState: @unchecked Sendable { return current } - func install(_ listener: NWListener) { + /// Set once by `StreamReceiver.stop()`: this receiver is finished, so + /// a retry scheduled before the stop (after a failed bind, for example) + /// must never open a listener again. + private var retired = false + + func retire() { lock.lock(); defer { lock.unlock() } + retired = true + } + + func isRetired() -> Bool { + lock.lock(); defer { lock.unlock() } + return retired + } + + /// Installs `listener` as current, or refuses (and returns `false`) once + /// retired; the caller then drops the listener without starting it. + @discardableResult + func install(_ listener: NWListener) -> Bool { + lock.lock(); defer { lock.unlock() } + guard !retired else { return false } current = listener + return true } /// Exact `===` identity check — no generation token, matching the @@ -422,9 +442,29 @@ final class PairingListenerState: @unchecked Sendable { return current != nil } - func install(_ listener: NWListener) { + /// Set once by `StreamReceiver.stop()`: this receiver is finished, so + /// a retry scheduled before the stop (after a failed bind, for example) + /// must never open a listener again. + private var retired = false + + func retire() { lock.lock(); defer { lock.unlock() } + retired = true + } + + func isRetired() -> Bool { + lock.lock(); defer { lock.unlock() } + return retired + } + + /// Installs `listener` as current, or refuses (and returns `false`) once + /// retired; the caller then drops the listener without starting it. + @discardableResult + func install(_ listener: NWListener) -> Bool { + lock.lock(); defer { lock.unlock() } + guard !retired else { return false } current = listener + return true } /// Exact `===` identity check — matches the original @@ -439,6 +479,17 @@ final class PairingListenerState: @unchecked Sendable { return current } + /// Clears the stored reference only if it is still exactly `listener`, + /// as `TLSListenerState.clearIfCurrent`. + @discardableResult + func clearIfCurrent(_ listener: NWListener) -> Bool { + lock.lock() + let matched = current === listener + if matched { current = nil } + lock.unlock() + return matched + } + func cancelCurrent() { lock.lock() let listener = current @@ -1537,6 +1588,12 @@ final class StreamReceiver: ObservableObject { /// app calls this when the user leaves receiver mode or quits; the /// instance is discarded afterwards (start() re-arms if it isn't). func stop(completion: (@Sendable () -> Void)? = nil) { + // Final: no listener of this receiver may open again, even from a + // retry already scheduled. The close below still cancels the open + // ones after "closing" has gone out. + tlsListenerState.retire() + tlsListenerState.quic.retire() + pairingListenerState.retire() let pipeline = self.pipeline Task { await pipeline.teardownForStop() } let selfBox = self.selfBox @@ -2136,7 +2193,7 @@ final class StreamReceiver: ObservableObject { params.serviceClass = .interactiveVideo let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: WireCrypto.tlsPort)!) - tlsListenerState.install(listener) + guard tlsListenerState.install(listener) else { return } listener.service = advertisementState.mediaService( installID: installID, protocolVersion: advertisedProtocolVersion) listener.newConnectionHandler = { [weak listener] connection in @@ -2247,7 +2304,7 @@ final class StreamReceiver: ObservableObject { params.allowLocalEndpointReuse = true let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: WireCrypto.pairingPort)!) - pairingListenerState.install(listener) + guard pairingListenerState.install(listener) else { return } listener.service = advertisedPairingService #if DEBUG Log.info("pairing listener starting: type=_opendisplay-pair._tcp port=\(WireCrypto.pairingPort) id=\(Self.installID) peerToPeer=true") @@ -2282,16 +2339,25 @@ final class StreamReceiver: ObservableObject { } } } - listener.stateUpdateHandler = { state in - #if DEBUG + let selfBox = self.selfBox + listener.stateUpdateHandler = { [weak listener] state in switch state { + case .failed(let error): + Log.info("pairing listener failed: \(error)") + // Same recovery as the TLS listener: a port that was + // busy (another receiver, a sleep/wake rebind) is tried + // again, so pairing comes back with the media listener. + guard let listener, pairingListenerState.clearIfCurrent(listener) else { return } + queue.asyncAfter(deadline: .now() + 1) { + selfBox.currentOnQueue()?.startPairingListener() + } + #if DEBUG case .ready: Log.info("pairing listener ready: port=\(WireCrypto.pairingPort) type=_opendisplay-pair._tcp") - case .failed(let error): Log.info("pairing listener failed: \(error)") case .waiting(let error): Log.info("pairing listener waiting: \(error)") case .cancelled: Log.info("pairing listener cancelled") + #endif default: break } - #endif } listener.start(queue: queue) } catch { diff --git a/project.yml b/project.yml index e43c9ac62..121de95b1 100644 --- a/project.yml +++ b/project.yml @@ -195,6 +195,7 @@ targets: - MacReceiver/ReceiverWindowLifecycle.swift - MacReceiver/ReceiverSettingsCategory.swift - MacReceiver/ReceiverInputForwarding.swift + - MacReceiver/ReceiverOwnership.swift - Shared/ReceiverRoleConfiguration.swift - Mac/MacAppRole.swift - Shared/ReceiverGesture.swift From 02c605a454fb3fb6397e8bb21596e941ec1dfdfd Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:47:52 +0530 Subject: [PATCH 08/11] fix(mac): don't advertise this Mac as a sender while it's a display With "Use This Mac as a Display" on, the sender kept advertising _opendisplay-mac-pair._tcp, so other receivers listed this Mac as one that would share with them, and their Connect went nowhere. The role now withdraws that advertisement and restores it when turned off; receivers use it only for listing and pairing, so reconnect is unaffected. The display role no longer refuses to turn on (or switches itself off at launch) while MeowDisplay Receiver runs: it stays on, says what to quit, and starts by itself afterwards. --- Mac/DisplayRoleSection.swift | 14 ++++++++------ Mac/MacAppRole.swift | 18 +++++++++++++---- Mac/OpenSidecarMacApp.swift | 32 ++++++++++++++++--------------- MacTests/UnifiedMacAppTests.swift | 15 +++++++++++---- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/Mac/DisplayRoleSection.swift b/Mac/DisplayRoleSection.swift index 3cd49289d..8048f2644 100644 --- a/Mac/DisplayRoleSection.swift +++ b/Mac/DisplayRoleSection.swift @@ -14,13 +14,15 @@ struct DisplayRoleSection: View { Toggle("Use This Mac as a Display", isOn: Binding( get: { controller.receiverModeEnabled }, set: { controller.setReceiverModeEnabled($0) })) - if let notice = controller.receiverModeNotice { - Text(notice) - .font(.caption) - .foregroundStyle(.orange) - } if controller.receiverModeEnabled { - LabeledContent("Status", value: receiver.statusTitle) + if receiver.waitingForOtherReceiver { + // What to quit; the role starts by itself afterwards. + Text(receiver.statusTitle) + .font(.caption) + .foregroundStyle(.orange) + } else { + LabeledContent("Status", value: receiver.statusTitle) + } } if receiver.streaming || onOpenSettings != nil { HStack { diff --git a/Mac/MacAppRole.swift b/Mac/MacAppRole.swift index 8ede27ccf..99112af5c 100644 --- a/Mac/MacAppRole.swift +++ b/Mac/MacAppRole.swift @@ -13,9 +13,15 @@ struct MacAppRoleState: Equatable { /// End every sender session without suppressing auto-reconnect, so /// the devices come back by themselves when the role switches back. case endSenderSessions + /// Stop advertising this Mac as a Mac to pair with and connect to + /// (`_opendisplay-mac-pair._tcp`): while it's a display, other + /// receivers shouldn't list it as a Mac that will share with them. + case withdrawSenderAdvertisement case startReceiver /// Stop the receiver, sending `closing` to a connected sender. case stopReceiver + /// Advertise the sender again. + case advertiseSender /// Let the sender reconnect its devices. case resumeSender } @@ -38,20 +44,24 @@ struct MacAppRoleState: Equatable { /// What launching does: the display role comes back only if the user /// left it on. Off by default — a sender install never advertises a /// receiver it didn't ask for. - var launchEffects: [Effect] { receiverEnabled ? [.startReceiver] : [] } + var launchEffects: [Effect] { receiverEnabled ? [.withdrawSenderAdvertisement, .startReceiver] : [] } mutating func setReceiverEnabled(_ enabled: Bool, defaults: UserDefaults) -> [Effect] { guard enabled != receiverEnabled else { return [] } receiverEnabled = enabled defaults.set(enabled, forKey: Self.defaultsKey) - return enabled ? [.endSenderSessions, .startReceiver] : [.stopReceiver, .resumeSender] + return enabled + ? [.endSenderSessions, .withdrawSenderAdvertisement, .startReceiver] + : [.stopReceiver, .advertiseSender, .resumeSender] } } /// The standalone MeowDisplay Receiver (still shipped for macOS 12–13, and /// installed by existing users) listens on the same ports and advertises the -/// same services. Two receivers on one Mac would race for them, so the -/// unified app's display role doesn't start while it runs. +/// same services. Current versions take `ReceiverOwnershipLock` like the +/// display role does; already-released builds don't, so while any version +/// runs the display role waits (`ReceiverController.otherReceiverRunning`) +/// rather than fail to bind next to it. enum StandaloneReceiverConflict { static let bundleIdentifiers: Set = [ "com.raisecaterror.meowdisplay.mac.receiver", diff --git a/Mac/OpenSidecarMacApp.swift b/Mac/OpenSidecarMacApp.swift index 9b6c7ea71..281ea9afe 100644 --- a/Mac/OpenSidecarMacApp.swift +++ b/Mac/OpenSidecarMacApp.swift @@ -420,33 +420,21 @@ final class SenderController: ObservableObject { /// Which role has live sessions (see `MacAppRoleState`). private var roleState = MacAppRoleState(defaults: .standard) @Published private(set) var receiverModeEnabled = MacAppRoleState(defaults: .standard).receiverEnabled - /// Why the display role couldn't be turned on, until the next attempt. - @Published private(set) var receiverModeNotice: String? /// "Use This Mac as a Display". Turning it on ends this Mac's sharing /// sessions (their devices reconnect when it's turned off) and starts the /// receiver; turning it off stops the receiver and lets the sender /// reconnect. + /// While MeowDisplay Receiver runs, the role stays on but waits + /// (`ReceiverController.waitingForOtherReceiver`), saying so, and starts + /// by itself once it quits. func setReceiverModeEnabled(_ enabled: Bool) { - if enabled, StandaloneReceiverConflict.isRunning( - among: NSWorkspace.shared.runningApplications.map(\.bundleIdentifier)) { - receiverModeNotice = String(localized: "Quit MeowDisplay Receiver first — it's already using this Mac as a display.") - return - } - receiverModeNotice = nil let effects = roleState.setReceiverEnabled(enabled, defaults: .standard) receiverModeEnabled = roleState.receiverEnabled applyRoleEffects(effects) } func applyLaunchRole() { - if roleState.receiverEnabled, StandaloneReceiverConflict.isRunning( - among: NSWorkspace.shared.runningApplications.map(\.bundleIdentifier)) { - Log.info("roleDebug: standalone receiver running — display role not started at launch") - receiverModeNotice = String(localized: "Quit MeowDisplay Receiver first — it's already using this Mac as a display.") - setReceiverModeEnabled(false) - return - } applyRoleEffects(roleState.launchEffects) } @@ -489,11 +477,22 @@ final class SenderController: ObservableObject { // alert shows (MirrorUnavailableOfferAlert), as the // standalone receiver does with its own window. receiver.onNeedsAttention = { MacSettingsWindow.show() } + receiver.otherReceiverMessage = String(localized: "MeowDisplay Receiver is already using this Mac as a display. Quit it, and this Mac becomes a display here by itself.") + receiver.otherReceiverRunning = { + StandaloneReceiverConflict.isRunning( + among: NSWorkspace.shared.runningApplications.map(\.bundleIdentifier)) + } receiver.start() Log.info("roleDebug: receiver started") case .stopReceiver: ReceiverController.shared.stop() Log.info("roleDebug: receiver stopped") + case .withdrawSenderAdvertisement: + pairingListener?.cancel() + pairingListener = nil + Log.info("roleDebug: sender advertisement withdrawn — this Mac is a display") + case .advertiseSender: + startPairingListener() case .resumeSender: scheduleAutoConnect() } @@ -1103,6 +1102,9 @@ final class SenderController: ObservableObject { } private func startPairingListener() { + // Not while this Mac is a display (see `MacAppRoleState.Effect`), + // and never twice. + guard roleState.senderMayConnect, pairingListener == nil else { return } guard let localID = TrustStore.shared.installID() else { Log.info("pairing listener not started: local Keychain identity/install ID unavailable") pairingMessage = "Pairing listener unavailable" diff --git a/MacTests/UnifiedMacAppTests.swift b/MacTests/UnifiedMacAppTests.swift index 9250d26ea..bef759b4f 100644 --- a/MacTests/UnifiedMacAppTests.swift +++ b/MacTests/UnifiedMacAppTests.swift @@ -33,7 +33,9 @@ final class UnifiedMacAppTests: XCTestCase { func testTurningTheDisplayRoleOnEndsSharingThenStartsTheReceiver() { var state = MacAppRoleState(defaults: defaults) - XCTAssertEqual(state.setReceiverEnabled(true, defaults: defaults), [.endSenderSessions, .startReceiver]) + XCTAssertEqual(state.setReceiverEnabled(true, defaults: defaults), + [.endSenderSessions, .withdrawSenderAdvertisement, .startReceiver], + "other receivers stop listing this Mac as one that will share with them") XCTAssertFalse(state.senderMayConnect) XCTAssertEqual(state.setReceiverEnabled(true, defaults: defaults), [], "idempotent") } @@ -41,7 +43,8 @@ final class UnifiedMacAppTests: XCTestCase { func testTurningItOffStopsTheReceiverThenResumesSharing() { var state = MacAppRoleState(defaults: defaults) _ = state.setReceiverEnabled(true, defaults: defaults) - XCTAssertEqual(state.setReceiverEnabled(false, defaults: defaults), [.stopReceiver, .resumeSender]) + XCTAssertEqual(state.setReceiverEnabled(false, defaults: defaults), + [.stopReceiver, .advertiseSender, .resumeSender]) XCTAssertTrue(state.senderMayConnect) XCTAssertEqual(state.setReceiverEnabled(false, defaults: defaults), []) } @@ -51,27 +54,31 @@ final class UnifiedMacAppTests: XCTestCase { _ = state.setReceiverEnabled(true, defaults: defaults) let relaunched = MacAppRoleState(defaults: defaults) XCTAssertTrue(relaunched.receiverEnabled) - XCTAssertEqual(relaunched.launchEffects, [.startReceiver]) + XCTAssertEqual(relaunched.launchEffects, [.withdrawSenderAdvertisement, .startReceiver]) XCTAssertFalse(relaunched.senderMayConnect, "no sender session can start before the receiver is up") } func testRapidTogglingAlwaysEndsInOneConsistentRole() { var state = MacAppRoleState(defaults: defaults) var receiverRunning = false + var senderAdvertised = true for index in 0..<50 { for effect in state.setReceiverEnabled(index.isMultiple(of: 3), defaults: defaults) { switch effect { case .startReceiver: XCTAssertFalse(receiverRunning); receiverRunning = true case .stopReceiver: XCTAssertTrue(receiverRunning); receiverRunning = false + case .withdrawSenderAdvertisement: XCTAssertTrue(senderAdvertised); senderAdvertised = false + case .advertiseSender: XCTAssertFalse(senderAdvertised); senderAdvertised = true case .endSenderSessions, .resumeSender: break } } XCTAssertEqual(receiverRunning, state.receiverEnabled) XCTAssertEqual(state.senderMayConnect, !receiverRunning) + XCTAssertEqual(senderAdvertised, !receiverRunning, "never advertised as a sender while a display") } } - func testTheStandaloneReceiverRunningBlocksTheDisplayRole() { + func testTheStandaloneReceiverRunningMakesTheDisplayRoleWait() { XCTAssertTrue(StandaloneReceiverConflict.isRunning( among: ["com.apple.finder", nil, "com.raisecaterror.meowdisplay.mac.receiver"])) XCTAssertTrue(StandaloneReceiverConflict.isRunning(among: ["com.raisecaterror.meowdisplay.mac.receiver.debug"])) From a5aa1a98b901456bbc29a7aa77b8c50c9b02328f Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:47:52 +0530 Subject: [PATCH 09/11] docs: two-receiver ownership, sender advertising while a display, and checklist --- UNIFIED_MAC_APP.md | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/UNIFIED_MAC_APP.md b/UNIFIED_MAC_APP.md index 56458c2dc..be9e20b48 100644 --- a/UNIFIED_MAC_APP.md +++ b/UNIFIED_MAC_APP.md @@ -77,11 +77,19 @@ Macs gain nothing over the standalone receiver they already have. Its sections are the standalone receiver's own, shared between both apps. With the role off, paired Macs are still listed and can be forgotten, without putting this Mac on the network. - **No self-discovery:** the sender ignores this app's receiver in its discovery and pairing browsers, and the receiver ignores this app's sender in its Mac-pairing browser (`SameAppPeerFilter`). +- **Not advertised as a sender while a display:** with the role on, the sender withdraws its `_opendisplay-mac-pair._tcp` advertisement, so other receivers' Nearby lists don't offer this Mac to share with them. It comes back when the role is turned off. + - Receivers use that advertisement only for listing and starting pairing; reconnect doesn't depend on it. + - Paired receivers still list this Mac from their pins. A Connect or Remote knock from them is refused by the role gate, as before. - **Window and focus:** while the display window is up the app is a regular app (Dock, Command-Tab), so Command-Tab is always a way out. Changing the Dock/menu-bar setting meanwhile doesn't undo that. It returns to the user's presentation when the window goes away. - **Mirror unavailable:** when the sending Mac is headless and Mirror was asked for, Settings opens with the standalone receiver's "Use Extend" offer. -- **Off and straight back on:** the new receiver starts only after the old one has released its listeners and network services. +- **Off and straight back on:** the new receiver starts only after the old one has released its listeners and network services. A stopped receiver is retired: a listener retry scheduled before the stop can't reopen a port. - **Quit:** with the display role on, quitting sends the receiver's `closing` first, as the standalone receiver does. -- **Standalone receiver running:** the role refuses to start (and says why), because both would race for ports 9001/9002 and the same Bonjour services. +- **Two receivers on one Mac** (`ReceiverOwnershipLock`): the display role and the standalone receiver both take one kernel file lock (`/Users/Shared/.meowdisplay-receiver.lock`) before opening any listener or advertising anything. + - Whichever starts second, in either order, binds nothing. It shows what to quit, checks again every 2 seconds, and starts by itself once the other lets go. + - The owner releases the lock only after its listeners have closed. The kernel releases it if the owner quits or crashes, so a stale lock can't block anyone. + - Already-released MeowDisplay Receiver builds predate the lock, so the display role also waits while any standalone receiver is running (bundle id). + - Never needed for safety: a second bind on the same port fails with "address in use" rather than sharing it (measured). The lock makes that failure explicit and recoverable instead of a silent retry loop. + - The receiver's pairing listener now retries a failed bind like its media listener, so pairing comes back once the port frees. - **Unchanged:** pairing, TLS 1.3, pinning, per-session input authorization and the wire protocol. ## Existing users and migration @@ -96,7 +104,7 @@ through the existing `appcast.xml` feed. - On macOS 14+, the unified app's display role is a fresh install of the receiver role: **each sending Mac pairs with it once**. Its identity and pins live in the standalone receiver's Keychain group (`…mac.receiver`). macOS doesn't let another bundle id read that group, and the unified app does not try to. - A later, optional migration could add a shared access group to both apps. An updated standalone receiver would then hand its identity over once. That copies a private key between apps, so it needs its own security review; the prototype doesn't do it. - Receiver settings are cosmetic and re-set in seconds. They are not imported: they live in the other app's preferences domain. -- If both are installed on one Mac, the unified app's display role won't start while the standalone receiver runs. Users pick one. +- If both are installed on one Mac, only one is a display at a time. The other waits and says what to quit. **Release:** - **Versions:** the unified app continues the sender's version line (1.21.0 onward); the standalone receiver continues its own. @@ -108,7 +116,7 @@ through the existing `appcast.xml` feed. ## Verified (automated, this branch) -- **macOS suite:** `xcodebuild test -scheme OpenSidecarMac` passes **1977 tests, 0 failures**. That's 1946 on #25's head plus 31 new: +- **macOS suite:** `xcodebuild test -scheme OpenSidecarMac` passes **1989 tests, 0 failures**. That's 1946 on #25's head plus 43 new: - `RolePreferencesIsolationTests` (14): the approval-policy leak in both directions, the receiver role's own default, original key names for existing installs, per-Mac overrides with the same peer id in both roles, role switching and relaunch, both authorization entry points, and per-role endpoint and Wake data. With the keys still shared, 12 of the 14 failed. - `UnifiedMacAppTests` (17): - role transitions, persistence and rapid toggling @@ -117,10 +125,20 @@ through the existing `appcast.xml` feed. - role-scoped settings keys - same-app discovery filtering - standalone-receiver conflict + - sender advertisement withdrawn exactly while a display, through rapid toggling - packaging (bundle id, floor, feed, excludes, standalone receiver at 12.0) - the Dock/Command-Tab rule - the settings page and its search - a guard that the receiver's settings code never uses the sender's trust store or keys + - `ReceiverOwnershipTests` (12): + - both launch orders + - a receiver without the lock + - a quit or crashed owner + - read-only and unusable lock files + - retired listeners never reopening + - the pairing listener's retry + - a real second bind failing with "address in use" +- **Two processes:** a command-line build of `ReceiverOwnership.swift` was run as two processes, in both orders and with the owner killed with `kill -9`. The second always waited, then started within one check of the owner going away. - **Builds:** `OpenSidecarMac`, `OpenSidecarMacReceiver` (macOS 12 target) and `OpenSidecariOS` (generic device) all build. The unified bundle has the sender's id, `LSMinimumSystemVersion` 14.0, `LSUIElement`, `MeowDisplayReceiverRoleTrust = separate`, and the receiver code linked in. - **Not covered by automation:** - live Keychain isolation (the unsigned test bundle has no Keychain entitlement) @@ -128,6 +146,7 @@ through the existing `appcast.xml` feed. - Bonjour on a network - window and activation-policy behavior - the receiver restart timing + - the two real apps side by side (not launched here, to avoid a second sender next to the running MeowDisplay) - the SwiftUI pages themselves ## Physical checklist @@ -141,6 +160,8 @@ per-device Mirror/Extend and #25's input on the same builds. - Screen Recording/Accessibility stay granted - "Automatically Allow Connections" and per-device choices are unchanged 2. **Off by default.** Mac B shows nothing on the network until Use This Mac as a Display is on. Mac A's Devices list never shows Mac A itself, and Mac B's Nearby Macs never shows Mac B. + - With Mac B's role on, an iPhone's (or Mac A's receiver) Nearby list no longer shows Mac B as a Mac to share from. + - Turn the role off: Mac B appears again, and its paired devices reconnect. 3. **Pair and stream.** On Mac A, pair with Mac B's display role (one code confirmation), connect, Extend, then Mirror. The window goes full screen, Command-Tab works, and #25's control flow works. 4. **Approval is per role.** With Mac B's sender "Automatically Allow Connections" on (the default), Mac A's automatic connection to Mac B's display role is **not** admitted silently: Mac B asks, or declines a background attempt. Turn it on in **This Mac as a Display**: Mac A connects without asking, and Mac B's sender setting is unchanged. 5. **Per-Mac choices are per role.** Block Mac A on the display page. Mac B's sender settings for its devices don't change. Forget Mac A there with the role off, then turn the role on: Mac A must pair again. @@ -154,12 +175,17 @@ per-device Mirror/Extend and #25's input on the same builds. 10. **Mirror unavailable.** Close Mac A's lid (on power, with Mac A's display role off), ask for Mirror from Mac B: Settings opens on Mac B with "Use Extend", and Use Extend works. 11. **Dock and Command-Tab.** In menu-bar-only mode, stream to Mac B: the Dock icon appears and Command-Tab leaves the window. Switch presentation in Settings meanwhile: Command-Tab still works. End the stream: the Dock icon goes away. 12. **Quit.** Quit Mac B mid-stream: Mac A ends the session rather than redialing. -13. **Standalone receiver.** With MeowDisplay Receiver running on Mac B, the switch refuses with its message, at launch and when toggled. +13. **Standalone receiver, both orders.** + - **Standalone first:** with MeowDisplay Receiver (this branch's build) running on Mac B, turn the role on. It shows "MeowDisplay Receiver is already using this Mac as a display…" and binds nothing (`lsof -i :9001` shows only the standalone receiver). Quit the standalone receiver: within a few seconds the role starts, and Mac A can pair and connect. + - **Display role first:** with the role on, open the standalone receiver. Its status says MeowDisplay is using this Mac as a display; Mac A keeps streaming to the role. Turn the role off: the standalone receiver starts by itself and is pairable. + - Repeat the first order with an already-released MeowDisplay Receiver: the role waits the same way. + - Relaunch MeowDisplay while the standalone receiver runs: the switch stays on and waiting. 14. **Rollback.** Reinstall 1.20.0 on Mac A: everything works as before. ## Remaining work before release - **Hardware QA** of the checklist above, with #24 and #25. - **String catalog:** the receiver's strings and the new page's strings aren't in MeowDisplay's catalog yet. Xcode adds them on the next IDE build. The catalogs are English-only, so nothing user-visible changes meanwhile. -- **Standalone receiver started second:** the unified app refuses to start its display role while the standalone receiver runs, but the standalone receiver doesn't check the other way round. Both would then compete for the same ports. Users pick one; a later standalone update could check too. +- **An already-released MeowDisplay Receiver opened second:** it predates the lock and can't be changed. Its own binds fail, so it doesn't disturb the running display role, but it shows no explanation. When the role turns off, its media listener takes the ports, while its pairing listener (unlike this branch's) doesn't retry. Updating the standalone receiver to this branch's version fixes both. +- **Another app on ports 9001/9002:** a non-MeowDisplay app holding them still just causes the existing silent bind retries. - **Optional migration:** the shared-access-group migration for standalone receiver users, if wanted. From a0bd044ea8f84aa528f3a87662d273c97ad4e200 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:26:02 +0530 Subject: [PATCH 10/11] fix(security): keep the receiver lock in the user's own folder and fail closed The ownership lock lived in /Users/Shared, where another account could pre-create it, plant a symlink, or hold it to block this Mac's display. - Lock at ~/Library/Application Support/MeowDisplay/receiver.lock, reached with openat(O_DIRECTORY | O_NOFOLLOW) one component at a time from the account's home directory. Every folder must be this user's and not writable by others; the file is opened O_NOFOLLOW | O_NONBLOCK, created 0600 and must be a single-link regular file this user owns (fstat). One location, no fallback, and release never unlinks it. - An unusable lock no longer lets the receiver start: it says what's wrong and where, and Try Again checks again. No automatic retry. - Media and pairing listener bind failures are reported (ReceiverPortHealth) so a port held by another account or app shows as "in use" instead of "Waiting for a Mac". - Tests use a real second process for the lock (both orders, normal exit, SIGKILL, release and reacquire) and for a taken port, plus symlink, folder symlink, hard link, FIFO, foreign owner and permission cases. --- Mac/DisplayRoleSection.swift | 11 +- MacReceiver/MacReceiver.swift | 39 ++- MacReceiver/ReceiverOwnership.swift | 233 ++++++++++++--- MacReceiver/ReceiverSettingsWindow.swift | 5 +- MacTests/ReceiverOwnershipTests.swift | 351 +++++++++++++++++++++-- Shared/ReceiverUISink.swift | 5 + Shared/StreamReceiver.swift | 71 ++++- 7 files changed, 641 insertions(+), 74 deletions(-) diff --git a/Mac/DisplayRoleSection.swift b/Mac/DisplayRoleSection.swift index 8048f2644..857173898 100644 --- a/Mac/DisplayRoleSection.swift +++ b/Mac/DisplayRoleSection.swift @@ -15,11 +15,16 @@ struct DisplayRoleSection: View { get: { controller.receiverModeEnabled }, set: { controller.setReceiverModeEnabled($0) })) if controller.receiverModeEnabled { - if receiver.waitingForOtherReceiver { - // What to quit; the role starts by itself afterwards. + if receiver.statusNeedsAttention { + // What to quit or fix; the role starts by itself + // afterwards, except after a lock problem. Text(receiver.statusTitle) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(receiver.statusColor) + if receiver.lockProblem != nil { + Button("Try Again") { receiver.start() } + .controlSize(.small) + } } else { LabeledContent("Status", value: receiver.statusTitle) } diff --git a/MacReceiver/MacReceiver.swift b/MacReceiver/MacReceiver.swift index 0bb4e91ac..b99f2e7af 100644 --- a/MacReceiver/MacReceiver.swift +++ b/MacReceiver/MacReceiver.swift @@ -73,6 +73,16 @@ final class ReceiverController: ObservableObject { /// checks for already-released MeowDisplay Receiver builds by bundle id). Nil: none to /// check. var otherReceiverRunning: (() -> Bool)? + /// The ownership lock can't be used safely, so the receiver didn't + /// start. `statusTitle` says what to fix; it doesn't retry by itself — + /// `start()` again (Try Again) once it's fixed. + @Published private(set) var lockProblem: ReceiverLockProblem? + /// A receiver port is failing to bind (another user's receiver or an + /// unrelated app holds it); `statusTitle` says which. + @Published private(set) var portsUnavailable = false + /// Something is in the way of being a display: show `statusTitle` as an + /// explanation, not a plain status. + var statusNeedsAttention: Bool { waitingForOtherReceiver || lockProblem != nil || portsUnavailable } private let ownership = ReceiverOwnershipLock() private var ownershipRetry: Timer? @@ -103,11 +113,25 @@ final class ReceiverController: ObservableObject { let decision = ReceiverStartGate.decide(otherReceiverRunning: otherReceiverRunning?() ?? false) { ownership.tryAcquire() } - guard decision == .start else { + switch decision { + case .start: + break + case .waitForOtherReceiver: + lockProblem = nil waitForOtherReceiver() return + case .cannotStart(let problem): + // Never run without ownership: a second receiver could then + // bind next to this one. Not retried by itself. + stopWaitingForOtherReceiver() + if lockProblem != problem { Log.info("receiver mode not started: ownership lock unusable") } + lockProblem = problem + statusTitle = problem.message + statusColor = .red + return } stopWaitingForOtherReceiver() + lockProblem = nil // 4096x2304 is H.264's practical hardware-decode ceiling: end-to-end // playback stops below 5120 wide on every Mac measured, including an // M4 Pro — a format limit, not an age one. A 5K/6K panel still gets @@ -139,10 +163,13 @@ final class ReceiverController: ObservableObject { .store(in: &cancellables) receiver.$session .removeDuplicates() + .combineLatest(receiver.$portFailures.removeDuplicates()) .receive(on: DispatchQueue.main) - .sink { [weak self] session in - self?.statusTitle = receiver.canonicalPhaseTitle - self?.statusColor = Self.statusColor(for: session.phase) + .sink { [weak self] session, portFailures in + let portMessage = session.phase == .connected ? nil : ReceiverPortMessage.text(for: portFailures) + self?.portsUnavailable = portMessage != nil + self?.statusTitle = portMessage ?? receiver.canonicalPhaseTitle + self?.statusColor = portMessage == nil ? Self.statusColor(for: session.phase) : .orange } .store(in: &cancellables) receiver.$mirrorUnavailable @@ -243,8 +270,9 @@ final class ReceiverController: ObservableObject { /// so the sender ends its session instead of retrying a dead peer. func stop(completion: (@Sendable () -> Void)? = nil) { startWhenRetired = false - if waitingForOtherReceiver { + if waitingForOtherReceiver || lockProblem != nil { stopWaitingForOtherReceiver() + lockProblem = nil statusTitle = String(localized: "Waiting for a Mac…") statusColor = .secondary } @@ -280,6 +308,7 @@ final class ReceiverController: ObservableObject { self.receiver = nil connected = false streaming = false + portsUnavailable = false statusTitle = String(localized: "Waiting for a Mac…") statusColor = .secondary mirrorUnavailableOffer = false diff --git a/MacReceiver/ReceiverOwnership.swift b/MacReceiver/ReceiverOwnership.swift index 4d1a81c8b..5b4e652e2 100644 --- a/MacReceiver/ReceiverOwnership.swift +++ b/MacReceiver/ReceiverOwnership.swift @@ -9,26 +9,49 @@ import Foundation /// the owner lets go. /// /// A kernel `flock` on one file, not a process-name check: it holds across -/// launch order, Debug and Release builds and user accounts (the ports are -/// system-wide), and the kernel drops it if the owner quits or crashes, so a -/// stale owner can't block anyone. +/// launch order and Debug and Release builds, and the kernel drops it if the +/// owner quits or crashes, so a stale owner can't block anyone. The file is +/// never unlinked: a waiting receiver must keep locking the same file the +/// owner holds. +/// +/// The file is `~/Library/Application Support/MeowDisplay/receiver.lock`, +/// reached one component at a time from the home directory without +/// following a symlink, and every directory on the way must belong to this +/// user and be writable by nobody else, so no other account can swap a +/// component, plant a link, or hold the lock to keep this Mac from being a +/// display. If anything there is unexpected the receiver doesn't start +/// (`ReceiverLockProblem` says what to fix) rather than run unowned. +/// +/// Per user: receivers in two different login sessions don't see each +/// other's lock. The ports are system-wide, so there the listener bind is +/// the final word: the second one's bind fails ("address in use") and +/// `ReceiverController` says the port is taken. final class ReceiverOwnershipLock { - /// System-wide, so receivers in other user sessions see it too. - static let sharedPath = "/Users/Shared/.meowdisplay-receiver.lock" - - private let paths: [String] + private let root: String + private let directories: [String] + private let fileName: String private var descriptor: Int32 = -1 - /// `paths` are tried in order; the per-user fallback is used only if the - /// shared one can't be opened. - init(paths: [String] = ReceiverOwnershipLock.defaultPaths) { - self.paths = paths + /// `directories` are created under `root` as needed; `root` itself is + /// trusted to be this user's (the home directory in production). + init(root: String = ReceiverOwnershipLock.homeDirectory, + directories: [String] = ["Library", "Application Support", "MeowDisplay"], + fileName: String = "receiver.lock") { + self.root = root + self.directories = directories + self.fileName = fileName + } + + /// From the account database, not `$HOME`. + static var homeDirectory: String { + if let entry = getpwuid(getuid()), let directory = entry.pointee.pw_dir { + return String(cString: directory) + } + return NSHomeDirectory() } - static var defaultPaths: [String] { - let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - let fallback = support?.appendingPathComponent("MeowDisplay/receiver.lock").path - return [sharedPath] + [fallback].compactMap { $0 } + var path: String { + ([root] + directories + [fileName]).joined(separator: "/") } var isHeld: Bool { descriptor >= 0 } @@ -36,27 +59,25 @@ final class ReceiverOwnershipLock { /// Takes ownership if nobody else has it. Idempotent while held. func tryAcquire() -> ReceiverOwnershipAttempt { if isHeld { return .acquired } - for path in paths { - let directory = (path as NSString).deletingLastPathComponent - try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) - // Read-only is enough to lock, and works on a file another - // account created. - let fd = open(path, O_RDONLY | O_CREAT | O_CLOEXEC, 0o644) - guard fd >= 0 else { continue } - if flock(fd, LOCK_EX | LOCK_NB) == 0 { - descriptor = fd - return .acquired - } - let lockError = errno - close(fd) - if lockError == EWOULDBLOCK { return .heldByAnotherReceiver } + let fd: Int32 + switch openLockFile() { + case .success(let opened): fd = opened + case .failure(let problem): + Log.info("receiver ownership lock unusable: \(problem.path): \(problem.reason)") + return .unusable(problem) + } + if flock(fd, LOCK_EX | LOCK_NB) == 0 { + descriptor = fd + return .acquired } - // No lock file could be opened at all: don't make this Mac unusable - // as a display over a file-system problem. Listener binds still fail - // cleanly (EADDRINUSE) if the ports are taken. - return .unavailable + let lockError = errno + close(fd) + if lockError == EWOULDBLOCK { return .heldByAnotherReceiver } + return .unusable(ReceiverLockProblem(path: path, reason: .system(lockError))) } + /// Unlocks and closes. The file stays: unlinking it would let the next + /// receiver lock a new file while a waiting one still holds the old. func release() { guard isHeld else { return } flock(descriptor, LOCK_UN) @@ -65,13 +86,130 @@ final class ReceiverOwnershipLock { } deinit { release() } + + // MARK: - Opening the file safely + + private func openLockFile() -> Result { + var current = root + var directory = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard directory >= 0 else { return .failure(problem(at: current, errno: errno)) } + defer { close(directory) } + if let reason = Self.validateDirectory(directory) { + return .failure(ReceiverLockProblem(path: current, reason: reason)) + } + for name in directories { + current += "/" + name + if mkdirat(directory, name, 0o700) != 0, errno != EEXIST { + return .failure(problem(at: current, errno: errno)) + } + // Never through a symlink: each step is relative to the + // directory already checked. + let next = openat(directory, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard next >= 0 else { + let error = errno + // Only to name the problem: a symlinked folder fails with + // ENOTDIR here, not ELOOP. + var info = stat() + if fstatat(directory, name, &info, AT_SYMLINK_NOFOLLOW) == 0, info.st_mode & S_IFMT == S_IFLNK { + return .failure(ReceiverLockProblem(path: current, reason: .symbolicLink)) + } + return .failure(problem(at: current, errno: error)) + } + close(directory) + directory = next + if let reason = Self.validateDirectory(directory) { + return .failure(ReceiverLockProblem(path: current, reason: reason)) + } + } + current += "/" + fileName + // O_NONBLOCK: a FIFO planted here must not hang the app on open. + let fd = openat(directory, fileName, O_RDONLY | O_CREAT | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC, 0o600) + guard fd >= 0 else { return .failure(problem(at: current, errno: errno)) } + var info = stat() + guard fstat(fd, &info) == 0 else { + let error = errno + close(fd) + return .failure(ReceiverLockProblem(path: current, reason: .system(error))) + } + if let reason = Self.validateFile(info) { + close(fd) + return .failure(ReceiverLockProblem(path: current, reason: reason)) + } + // An older build's file may be 0644; it's ours, so tighten it. + if info.st_mode & 0o077 != 0 { fchmod(fd, 0o600) } + return .success(fd) + } + + private func problem(at path: String, errno code: Int32) -> ReceiverLockProblem { + switch code { + case ELOOP: ReceiverLockProblem(path: path, reason: .symbolicLink) + case ENOTDIR: ReceiverLockProblem(path: path, reason: .notADirectory) + case EACCES, EPERM, EROFS: ReceiverLockProblem(path: path, reason: .permissionDenied) + default: ReceiverLockProblem(path: path, reason: .system(code)) + } + } + + private static func validateDirectory(_ fd: Int32) -> ReceiverLockProblem.Reason? { + var info = stat() + guard fstat(fd, &info) == 0 else { return .system(errno) } + return validateDirectory(info) + } + + /// A directory this user owns that nobody else can write to. + static func validateDirectory(_ info: stat, uid: uid_t = getuid()) -> ReceiverLockProblem.Reason? { + guard info.st_mode & S_IFMT == S_IFDIR else { return .notADirectory } + guard info.st_uid == uid else { return .ownedByAnotherUser } + guard info.st_mode & 0o022 == 0 else { return .writableByOthers } + return nil + } + + /// A plain file this user owns, with no other name (a hard link could + /// point it at a file elsewhere). + static func validateFile(_ info: stat, uid: uid_t = getuid()) -> ReceiverLockProblem.Reason? { + guard info.st_mode & S_IFMT == S_IFREG else { return .notARegularFile } + guard info.st_uid == uid else { return .ownedByAnotherUser } + guard info.st_nlink == 1 else { return .hardLinked } + return nil + } } enum ReceiverOwnershipAttempt: Equatable { case acquired case heldByAnotherReceiver - /// The lock file couldn't be opened anywhere. - case unavailable + /// The lock file can't be used safely; nothing was locked. + case unusable(ReceiverLockProblem) +} + +/// Why the ownership lock couldn't be used, and where. +struct ReceiverLockProblem: Error, Equatable { + enum Reason: Equatable { + case symbolicLink + case notADirectory + case notARegularFile + case ownedByAnotherUser + case writableByOthers + case hardLinked + case permissionDenied + case system(Int32) + } + + let path: String + let reason: Reason + + /// What the user sees: what's wrong, where, and what to do. + var message: String { + let what: String = switch reason { + case .symbolicLink: String(localized: "is a symbolic link") + case .notADirectory: String(localized: "is not a folder") + case .notARegularFile: String(localized: "is not a regular file") + case .ownedByAnotherUser: String(localized: "belongs to another user") + case .writableByOthers: String(localized: "can be changed by other users") + case .hardLinked: String(localized: "has more than one name (a hard link)") + case .permissionDenied: String(localized: "can't be opened (permission denied)") + case .system(let code): String(cString: strerror(code)) + } + return String(localized: "This Mac can't be a display: \(path) \(what). MeowDisplay uses it to make sure only one receiver runs at a time. Remove or fix it, then click Try Again.") + } } /// Whether a Mac receiver may start now. Pure, so both launch orders are @@ -82,6 +220,9 @@ enum ReceiverStartGate { /// Another receiver owns the ports: show `ReceiverController`'s /// conflict message and try again shortly. case waitForOtherReceiver + /// Ownership can't be established safely: don't start, don't retry + /// by itself, say what to fix. + case cannotStart(ReceiverLockProblem) } /// `otherReceiverRunning`: a receiver that predates the lock (an @@ -90,11 +231,31 @@ enum ReceiverStartGate { static func decide(otherReceiverRunning: Bool, attempt: () -> ReceiverOwnershipAttempt) -> Decision { if otherReceiverRunning { return .waitForOtherReceiver } switch attempt() { - case .acquired, .unavailable: return .start + case .acquired: return .start case .heldByAnotherReceiver: return .waitForOtherReceiver + case .unusable(let problem): return .cannotStart(problem) } } /// How often a waiting receiver checks again. static let retryInterval: TimeInterval = 2 } + +/// The status line for receiver ports that fail to bind (`ReceiverPortHealth`). +enum ReceiverPortMessage { + /// What a failing bind means for this Mac, or nil when every receiver + /// port is listening. The listeners keep retrying, so it clears itself. + static func text(for failures: [ReceiverPortFailure]) -> String? { + if let media = failures.first(where: { $0.port == WireCrypto.tlsPort }) { + return media.addressInUse + ? String(localized: "Port \(String(media.port)) is in use by another app or another user on this Mac, so no Mac can connect. Quit it, and this Mac becomes available by itself.") + : String(localized: "Port \(String(media.port)) couldn't be opened, so no Mac can connect. MeowDisplay keeps trying.") + } + if let pairing = failures.first(where: { $0.port == WireCrypto.pairingPort }) { + return pairing.addressInUse + ? String(localized: "Port \(String(pairing.port)) is in use by another app or another user on this Mac, so new Macs can't pair. Paired Macs can still connect.") + : String(localized: "Port \(String(pairing.port)) couldn't be opened, so new Macs can't pair. Paired Macs can still connect.") + } + return nil + } +} diff --git a/MacReceiver/ReceiverSettingsWindow.swift b/MacReceiver/ReceiverSettingsWindow.swift index 05d1a04b6..32560be19 100644 --- a/MacReceiver/ReceiverSettingsWindow.swift +++ b/MacReceiver/ReceiverSettingsWindow.swift @@ -485,9 +485,12 @@ struct WithReceiver: View { var body: some View { if let receiver = controller.receiver { content(receiver) - } else if controller.waitingForOtherReceiver { + } else if controller.waitingForOtherReceiver || controller.lockProblem != nil { ReceiverSettingsForm { ReceiverCaption(verbatim: controller.statusTitle) + if controller.lockProblem != nil { + Button("Try Again") { controller.start() } + } } } else { ReceiverSettingsForm { diff --git a/MacTests/ReceiverOwnershipTests.swift b/MacTests/ReceiverOwnershipTests.swift index 28f3e74ae..2e2e0ecce 100644 --- a/MacTests/ReceiverOwnershipTests.swift +++ b/MacTests/ReceiverOwnershipTests.swift @@ -1,26 +1,55 @@ +import Darwin import Network import XCTest /// Two Mac receivers on one Mac — MeowDisplay's "Use This Mac as a Display" /// role and the standalone MeowDisplay Receiver — in either launch order. -/// Each lock instance stands in for one app: `flock` locks belong to an open -/// file, so two instances conflict exactly as two processes do. +/// In-process, each lock instance stands in for one app: `flock` locks +/// belong to an open file, so two instances conflict exactly as two +/// processes do. `LockHolder` checks that against a real second process. final class ReceiverOwnershipTests: XCTestCase { - private var directory: URL! + private var root: URL! override func setUpWithError() throws { - directory = FileManager.default.temporaryDirectory + root = FileManager.default.temporaryDirectory .appendingPathComponent("ReceiverOwnershipTests-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) } override func tearDownWithError() throws { - try? FileManager.default.removeItem(at: directory) + // Undo any permissions a test took away, so the folder can go. + if let items = FileManager.default.enumerator(atPath: root.path) { + for case let item as String in items { + chmod(root.appendingPathComponent(item).path, 0o700) + } + } + try? FileManager.default.removeItem(at: root) } - private var lockPath: String { directory.appendingPathComponent("receiver.lock").path } + private let directories = ["Application Support", "MeowDisplay"] + private var lockDirectory: String { root.appendingPathComponent("Application Support/MeowDisplay").path } + private var lockPath: String { lockDirectory + "/receiver.lock" } - private func app() -> ReceiverOwnershipLock { ReceiverOwnershipLock(paths: [lockPath]) } + private func app() -> ReceiverOwnershipLock { + ReceiverOwnershipLock(root: root.path, directories: directories) + } + + private func makeLockDirectory() throws { + try FileManager.default.createDirectory(atPath: lockDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } + + private func reason(_ attempt: ReceiverOwnershipAttempt) -> ReceiverLockProblem.Reason? { + if case .unusable(let problem) = attempt { return problem.reason } + return nil + } + + private func mode(_ path: String) -> mode_t { + var info = stat() + XCTAssertEqual(lstat(path, &info), 0) + return info.st_mode & 0o777 + } // MARK: - Launch orders @@ -60,6 +89,42 @@ final class ReceiverOwnershipTests: XCTestCase { XCTAssertFalse(displayRole.isHeld) } + // MARK: - Another process + + func testAnotherProcessHoldingTheLockMakesThisReceiverWait() throws { + XCTAssertEqual(app().tryAcquire(), .acquired) // creates the file; released at once + let other = try LockHolder(path: lockPath) + XCTAssertTrue(other.holds) + let receiver = app() + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { receiver.tryAcquire() }, + .waitForOtherReceiver) + other.quit() // a normal exit + XCTAssertEqual(receiver.tryAcquire(), .acquired) + XCTAssertTrue(FileManager.default.fileExists(atPath: lockPath)) + } + + func testThisReceiverFirstKeepsAnotherProcessOut() throws { + let receiver = app() + XCTAssertEqual(receiver.tryAcquire(), .acquired) + XCTAssertFalse(try LockHolder(path: lockPath).holds, "the other process can't take it") + receiver.release() + XCTAssertTrue(FileManager.default.fileExists(atPath: lockPath), "release never unlinks the file") + let other = try LockHolder(path: lockPath) + XCTAssertTrue(other.holds, "after release it can") + XCTAssertEqual(receiver.tryAcquire(), .heldByAnotherReceiver) + other.quit() + XCTAssertEqual(receiver.tryAcquire(), .acquired, "and this one reacquires once it's gone") + } + + func testAKilledOwnerNeverBlocksTheNextReceiver() throws { + XCTAssertEqual(app().tryAcquire(), .acquired) + let other = try LockHolder(path: lockPath) + XCTAssertTrue(other.holds) + XCTAssertEqual(app().tryAcquire(), .heldByAnotherReceiver) + other.kill() // SIGKILL: no cleanup ran, the kernel dropped the lock + XCTAssertEqual(app().tryAcquire(), .acquired) + } + // MARK: - The lock func testAQuitOrCrashedOwnerNeverBlocksTheNextReceiver() { @@ -80,32 +145,177 @@ final class ReceiverOwnershipTests: XCTestCase { XCTAssertFalse(owner.isHeld) } - func testAReadOnlyLockFileFromAnotherAccountStillWorks() throws { - FileManager.default.createFile(atPath: lockPath, contents: nil, - attributes: [.posixPermissions: 0o444]) - let first = app(), second = app() - XCTAssertEqual(first.tryAcquire(), .acquired) - XCTAssertEqual(second.tryAcquire(), .heldByAnotherReceiver) + func testCreatesAPrivateFolderAndFile() { + XCTAssertEqual(app().tryAcquire(), .acquired) + XCTAssertEqual(app().path, lockPath) + XCTAssertEqual(mode(lockDirectory), 0o700) + XCTAssertEqual(mode(lockPath) & 0o077, 0, "nobody else can read or write it") + } + + func testAnExistingLockFileIsReusedAndTightened() throws { + try makeLockDirectory() + FileManager.default.createFile(atPath: lockPath, contents: nil, attributes: [.posixPermissions: 0o644]) + let owner = app() + XCTAssertEqual(owner.tryAcquire(), .acquired) + XCTAssertEqual(mode(lockPath), 0o600) + XCTAssertEqual(app().tryAcquire(), .heldByAnotherReceiver, "the same file, not a replacement") + } + + func testASymlinkAtTheLockPathIsRefused() throws { + try makeLockDirectory() + let target = root.appendingPathComponent("planted-target").path + try FileManager.default.createSymbolicLink(atPath: lockPath, withDestinationPath: target) + XCTAssertEqual(reason(app().tryAcquire()), .symbolicLink) + XCTAssertFalse(FileManager.default.fileExists(atPath: target), "nothing created through the link") + + // Pointing at a real file of this user's doesn't make it acceptable. + FileManager.default.createFile(atPath: target, contents: nil) + XCTAssertEqual(reason(app().tryAcquire()), .symbolicLink) + } + + func testASymlinkedFolderIsRefused() throws { + let elsewhere = root.appendingPathComponent("elsewhere") + try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try FileManager.default.createDirectory(at: root.appendingPathComponent("Application Support"), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try FileManager.default.createSymbolicLink(atPath: lockDirectory, withDestinationPath: elsewhere.path) + let attempt = app().tryAcquire() + XCTAssertEqual(reason(attempt), .symbolicLink) + if case .unusable(let problem) = attempt { XCTAssertEqual(problem.path, lockDirectory) } + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: elsewhere.path), [], + "nothing created through the link") + } + + func testAHardLinkedLockFileIsRefused() throws { + try makeLockDirectory() + let other = root.appendingPathComponent("other").path + FileManager.default.createFile(atPath: other, contents: nil, attributes: [.posixPermissions: 0o600]) + try FileManager.default.linkItem(atPath: other, toPath: lockPath) + XCTAssertEqual(reason(app().tryAcquire()), .hardLinked) + } + + func testANonRegularLockFileIsRefused() throws { + try makeLockDirectory() + try FileManager.default.createDirectory(atPath: lockPath, withIntermediateDirectories: false) + XCTAssertEqual(reason(app().tryAcquire()), .notARegularFile) + + // A FIFO would block a plain open() until a writer came along. + try FileManager.default.removeItem(atPath: lockPath) + XCTAssertEqual(mkfifo(lockPath, 0o600), 0) + XCTAssertEqual(reason(app().tryAcquire()), .notARegularFile) + } + + func testAFileWhereAFolderShouldBeIsRefused() throws { + try FileManager.default.createDirectory(at: root.appendingPathComponent("Application Support"), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + FileManager.default.createFile(atPath: lockDirectory, contents: nil) + XCTAssertEqual(reason(app().tryAcquire()), .notADirectory) + } + + func testAFolderOthersCanWriteToIsRefused() throws { + try makeLockDirectory() + XCTAssertEqual(chmod(lockDirectory, 0o777), 0) + XCTAssertEqual(reason(app().tryAcquire()), .writableByOthers) + XCTAssertFalse(FileManager.default.fileExists(atPath: lockPath), "nothing created there") } - func testFallsBackWhenTheSharedLocationIsUnusable() { - let unusable = "/dev/null/meowdisplay/receiver.lock" - let first = ReceiverOwnershipLock(paths: [unusable, lockPath]) - let second = ReceiverOwnershipLock(paths: [unusable, lockPath]) - XCTAssertEqual(first.tryAcquire(), .acquired) - XCTAssertEqual(second.tryAcquire(), .heldByAnotherReceiver) + func testAnUnwritableFolderIsReportedNotIgnored() throws { + try makeLockDirectory() + XCTAssertEqual(chmod(lockDirectory, 0o500), 0) + let attempt = app().tryAcquire() + XCTAssertEqual(reason(attempt), .permissionDenied) + XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { attempt }, + .cannotStart(ReceiverLockProblem(path: lockPath, reason: .permissionDenied)), + "never starts as though it owned the ports") } - func testNoUsableLockFileDoesNotStopTheReceiver() { - let lock = ReceiverOwnershipLock(paths: ["/dev/null/meowdisplay/receiver.lock"]) - XCTAssertEqual(lock.tryAcquire(), .unavailable) - XCTAssertEqual(ReceiverStartGate.decide(otherReceiverRunning: false) { lock.tryAcquire() }, .start, - "a file-system problem must not make this Mac unusable as a display") + func testAFolderOwnedByAnotherUserIsRefused() { + // /Library belongs to root. + let lock = ReceiverOwnershipLock(root: "/Library", directories: ["MeowDisplay-should-not-exist"]) + XCTAssertEqual(reason(lock.tryAcquire()), .ownedByAnotherUser) + XCTAssertFalse(FileManager.default.fileExists(atPath: "/Library/MeowDisplay-should-not-exist")) } - func testTheDefaultLockIsSystemWide() { - XCTAssertEqual(ReceiverOwnershipLock.defaultPaths.first, "/Users/Shared/.meowdisplay-receiver.lock", - "the ports are system-wide, so the owner must be too") + func testFilesAndFoldersOfAnotherUserAreRefused() { + var file = stat() + file.st_mode = S_IFREG | 0o600 + file.st_uid = getuid() + file.st_nlink = 1 + XCTAssertNil(ReceiverOwnershipLock.validateFile(file)) + XCTAssertEqual(ReceiverOwnershipLock.validateFile(file, uid: getuid() + 1), .ownedByAnotherUser) + + var folder = stat() + folder.st_mode = S_IFDIR | 0o700 + folder.st_uid = getuid() + XCTAssertNil(ReceiverOwnershipLock.validateDirectory(folder)) + XCTAssertEqual(ReceiverOwnershipLock.validateDirectory(folder, uid: getuid() + 1), .ownedByAnotherUser) + folder.st_mode = S_IFDIR | 0o720 + XCTAssertEqual(ReceiverOwnershipLock.validateDirectory(folder), .writableByOthers) + } + + func testTheDefaultLockIsInThisUsersOwnLibrary() { + let path = ReceiverOwnershipLock().path + XCTAssertEqual(path, ReceiverOwnershipLock.homeDirectory + "/Library/Application Support/MeowDisplay/receiver.lock") + XCTAssertFalse(path.hasPrefix("/Users/Shared"), "never a directory other accounts can write to") + } + + // MARK: - What the user sees + + func testProblemsAreExplainedWithWhatToDo() { + let problem = ReceiverLockProblem(path: "/x/receiver.lock", reason: .symbolicLink) + XCTAssertTrue(problem.message.contains("/x/receiver.lock")) + XCTAssertTrue(problem.message.contains("Try Again")) + + XCTAssertNil(ReceiverPortMessage.text(for: [])) + let media = ReceiverPortMessage.text(for: [ + ReceiverPortFailure(port: WireCrypto.pairingPort, addressInUse: true), + ReceiverPortFailure(port: WireCrypto.tlsPort, addressInUse: true), + ]) + XCTAssertEqual(media?.contains("9001"), true, "the media port matters most") + XCTAssertEqual(media?.contains("in use"), true) + let pairing = ReceiverPortMessage.text(for: [ + ReceiverPortFailure(port: WireCrypto.pairingPort, addressInUse: true), + ]) + XCTAssertEqual(pairing?.contains("9002"), true) + XCTAssertEqual(pairing?.contains("Paired Macs can still connect"), true) + let other = ReceiverPortMessage.text(for: [ + ReceiverPortFailure(port: WireCrypto.tlsPort, addressInUse: false), + ]) + XCTAssertEqual(other?.contains("couldn't be opened"), true) + } + + // MARK: - A port taken by another app + + /// Another process (standing in for another user's receiver or an + /// unrelated app) holds a port; the receivers' own listener parameters + /// fail to bind it, and `ReceiverPortHealth` reports exactly that until + /// the listener is ready. An ephemeral port, never 9001/9002, so a + /// running MeowDisplay is undisturbed. + func testAPortTakenByAnotherProcessIsReportedAsInUse() throws { + let other = try PortHolder() + let health = ReceiverPortHealth() + let reported = expectation(description: "reported") + health.setObserver { failures in + if failures == [ReceiverPortFailure(port: WireCrypto.tlsPort, addressInUse: true)] { reported.fulfill() } + } + + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + let listener = try NWListener(using: parameters, on: try XCTUnwrap(NWEndpoint.Port(rawValue: other.port))) + listener.stateUpdateHandler = { state in + if case .failed(let error) = state { health.record(port: WireCrypto.tlsPort, error: error) } + } + listener.newConnectionHandler = { $0.cancel() } + listener.start(queue: .main) + wait(for: [reported], timeout: 5) + listener.cancel() + + health.clear(port: WireCrypto.tlsPort) + XCTAssertEqual(health.current(), [], "a listener that comes up clears it") + other.quit() } // MARK: - Listener ownership after stop @@ -172,3 +382,88 @@ final class ReceiverOwnershipTests: XCTestCase { second.cancel() } } + +/// A separate process taking the same `flock(2)` on the same file, the way +/// the other receiver app does: Perl's `flock` is the system call. +private final class LockHolder { + private let process = Process() + private let input = Pipe() + /// It got the lock (and holds it until `quit()` or `kill()`); false: the + /// lock was taken and it exited. + private(set) var holds = false + + init(path: String) throws { + process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + process.arguments = ["-e", """ + use Fcntl qw(:flock); + open(my $f, "<", $ARGV[0]) or exit 2; + flock($f, LOCK_EX | LOCK_NB) or exit 3; + $| = 1; print "locked\\n"; + ; + """, path] + let output = Pipe() + process.standardInput = input + process.standardOutput = output + try process.run() + let line = String(decoding: output.fileHandleForReading.availableData, as: UTF8.self) + holds = line == "locked\n" + if !holds { + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 3, "exits because the lock is taken, nothing else") + } + } + + /// Exits normally. + func quit() { + try? input.fileHandleForWriting.close() + process.waitUntilExit() + } + + /// Dies without any chance to clean up. + func kill() { + Darwin.kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + XCTAssertEqual(process.terminationReason, .uncaughtSignal) + } + + deinit { + if process.isRunning { kill() } + } +} + +/// A separate process listening on an ephemeral TCP port, dual-stack like +/// an `NWListener`. +private final class PortHolder { + private let process = Process() + private let input = Pipe() + private(set) var port: UInt16 = 0 + + init() throws { + process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + process.arguments = ["-e", """ + use Socket qw(:all); + socket(my $s, AF_INET6, SOCK_STREAM, 0) or exit 2; + setsockopt($s, IPPROTO_IPV6, IPV6_V6ONLY, 0); + bind($s, pack_sockaddr_in6(0, IN6ADDR_ANY)) or exit 3; + listen($s, 5) or exit 4; + my ($port) = unpack_sockaddr_in6(getsockname($s)); + $| = 1; print "$port\\n"; + ; + """] + let output = Pipe() + process.standardInput = input + process.standardOutput = output + try process.run() + let line = String(decoding: output.fileHandleForReading.availableData, as: UTF8.self) + port = try XCTUnwrap(UInt16(line.trimmingCharacters(in: .whitespacesAndNewlines))) + } + + func quit() { + try? input.fileHandleForWriting.close() + process.waitUntilExit() + } + + deinit { + if process.isRunning { process.terminate() } + } +} diff --git a/Shared/ReceiverUISink.swift b/Shared/ReceiverUISink.swift index b12aafcec..3bc53ee36 100644 --- a/Shared/ReceiverUISink.swift +++ b/Shared/ReceiverUISink.swift @@ -98,6 +98,11 @@ final class ReceiverUISink: Sendable { target?.applyDiscoveredMacsUpdate(results) } + /// `ReceiverPortHealth`'s observer: which receiver ports fail to bind. + func publishPortFailures(_ failures: [ReceiverPortFailure]) { + target?.applyPortFailures(failures) + } + /// Mirrors `StreamReceiver.notePairingSucceeded()` for the pairing /// listener's `newConnectionHandler`/nested `Task` (Receiver /// Swift6-Pairing-Listener) — the one boundary crossing that closure diff --git a/Shared/StreamReceiver.swift b/Shared/StreamReceiver.swift index 8b3846714..91a8eb3ff 100644 --- a/Shared/StreamReceiver.swift +++ b/Shared/StreamReceiver.swift @@ -362,6 +362,9 @@ final class TLSListenerState: @unchecked Sendable { /// tears down the secure TCP listener reaches QUIC through the same /// owner — the two can never follow different lifecycles. let quic = QUICReceiverContext() + /// The receiver ports whose bind failed, shared by the TLS and pairing + /// listeners for the same reason `quic` lives here. + let ports = ReceiverPortHealth() func hasCurrent() -> Bool { lock.lock(); defer { lock.unlock() } @@ -426,6 +429,53 @@ final class TLSListenerState: @unchecked Sendable { } } +/// A receiver port whose listener couldn't bind, as the bind reported it. +struct ReceiverPortFailure: Equatable, Sendable { + let port: UInt16 + /// Something else holds the port (another user's receiver, another app). + let addressInUse: Bool +} + +/// Which receiver ports are failing to bind right now — the final word when +/// something the Mac receiver's ownership lock can't see (a receiver in +/// another login session, an unrelated app) holds 9001 or 9002. The TLS and +/// pairing listeners record a failure and clear it once ready; their +/// existing retries are unchanged and end when the receiver is retired. +/// Same lock idiom as `TLSListenerState`; `observer` runs outside the lock. +final class ReceiverPortHealth: @unchecked Sendable { + private let lock = NSLock() + private var failures: [UInt16: ReceiverPortFailure] = [:] + private var observer: (@Sendable ([ReceiverPortFailure]) -> Void)? + + func setObserver(_ observer: @escaping @Sendable ([ReceiverPortFailure]) -> Void) { + lock.lock(); defer { lock.unlock() } + self.observer = observer + } + + func record(port: UInt16, error: NWError) { + var addressInUse = false + if case .posix(let code) = error, code == .EADDRINUSE { addressInUse = true } + update(port, ReceiverPortFailure(port: port, addressInUse: addressInUse)) + } + + func clear(port: UInt16) { update(port, nil) } + + func current() -> [ReceiverPortFailure] { + lock.lock(); defer { lock.unlock() } + return failures.values.sorted { $0.port < $1.port } + } + + private func update(_ port: UInt16, _ failure: ReceiverPortFailure?) { + lock.lock() + guard failures[port] != failure else { lock.unlock(); return } + failures[port] = failure + let snapshot = failures.values.sorted { $0.port < $1.port } + let observer = self.observer + lock.unlock() + observer?(snapshot) + } +} + /// Lock-protected sole authoritative owner of the currently installed /// pairing listener's identity — nothing more. Same shape as /// `TLSListenerState` above (a plain reference swap under the lock, `===` @@ -1126,6 +1176,9 @@ final class StreamReceiver: ObservableObject { private var pairingObservation: AnyCancellable? private let pairingSuppressionState = PairingSuppressionState() @MainActor @Published private(set) var discoveredMacs: [NWBrowser.Result] = [] + /// Receiver ports that are failing to bind (`ReceiverPortHealth`), so + /// the UI can say a port is taken instead of only "Waiting for a Mac…". + @MainActor @Published private(set) var portFailures: [ReceiverPortFailure] = [] private var macPairingBrowser: NWBrowser? /// Bumped only after a pairing (any transport) truly finalized: both /// confirmations, commit exchange and the TrustStore pin. Drives the @@ -1548,6 +1601,10 @@ final class StreamReceiver: ObservableObject { selfBox.install(self) let helloState = self.helloState tlsListenerState.quic.setAvailabilityObserver { helloState.setQUICAvailable($0) } + let uiSink = self.uiSink + tlsListenerState.ports.setObserver { failures in + DispatchQueue.main.async { uiSink.publishPortFailures(failures) } + } uiSink.target = self pairingPrompt.ownerAuthenticator = LocalOwnerAuthenticator() pairingPrompt.trustPinLookup = { TrustStore.receiverRole.pin(peerID: $0) } @@ -2221,9 +2278,11 @@ final class StreamReceiver: ObservableObject { switch state { case .ready: Log.info("reconnectDebug: listenerReady TLS port=\(WireCrypto.tlsPort)") + tlsListenerState.ports.clear(port: WireCrypto.tlsPort) case .failed(let error): Log.info("secure listener failed: \(error)") guard let listener, tlsListenerState.clearIfCurrent(listener) else { return } + tlsListenerState.ports.record(port: WireCrypto.tlsPort, error: error) // Fixed short backoff: mirrors the plaintext listener's // retry cadence and avoids a busy loop if the port stays // unavailable for a moment (e.g. right after a sleep/wake @@ -2348,11 +2407,16 @@ final class StreamReceiver: ObservableObject { // busy (another receiver, a sleep/wake rebind) is tried // again, so pairing comes back with the media listener. guard let listener, pairingListenerState.clearIfCurrent(listener) else { return } + tlsListenerState.ports.record(port: WireCrypto.pairingPort, error: error) queue.asyncAfter(deadline: .now() + 1) { selfBox.currentOnQueue()?.startPairingListener() } + case .ready: + tlsListenerState.ports.clear(port: WireCrypto.pairingPort) + #if DEBUG + Log.info("pairing listener ready: port=\(WireCrypto.pairingPort) type=_opendisplay-pair._tcp") + #endif #if DEBUG - case .ready: Log.info("pairing listener ready: port=\(WireCrypto.pairingPort) type=_opendisplay-pair._tcp") case .waiting(let error): Log.info("pairing listener waiting: \(error)") case .cancelled: Log.info("pairing listener cancelled") #endif @@ -5403,6 +5467,11 @@ final class StreamReceiver: ObservableObject { /// publishDiscoveredMacs`, its sole caller. Same single-assignment, /// `private(set)`-admitting shape as `applyAuthenticatedPeerIDUpdate` /// above. + /// `ReceiverUISink.publishPortFailures`, its sole caller. + @MainActor func applyPortFailures(_ failures: [ReceiverPortFailure]) { + portFailures = failures + } + @MainActor func applyDiscoveredMacsUpdate(_ results: [NWBrowser.Result]) { discoveredMacs = SameAppPeerFilter.excludingSameApp(results, ownIDs: Self.sameAppSenderIDs) { result in if case .bonjour(let txt) = result.metadata { return txt["id"] } From 879da2c8708494a9cd326fd287276840cdf41eab Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:26:02 +0530 Subject: [PATCH 11/11] docs: per-user receiver lock, fail-closed start and cross-user limits --- UNIFIED_MAC_APP.md | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/UNIFIED_MAC_APP.md b/UNIFIED_MAC_APP.md index be9e20b48..9dd4a0f62 100644 --- a/UNIFIED_MAC_APP.md +++ b/UNIFIED_MAC_APP.md @@ -84,11 +84,19 @@ Macs gain nothing over the standalone receiver they already have. - **Mirror unavailable:** when the sending Mac is headless and Mirror was asked for, Settings opens with the standalone receiver's "Use Extend" offer. - **Off and straight back on:** the new receiver starts only after the old one has released its listeners and network services. A stopped receiver is retired: a listener retry scheduled before the stop can't reopen a port. - **Quit:** with the display role on, quitting sends the receiver's `closing` first, as the standalone receiver does. -- **Two receivers on one Mac** (`ReceiverOwnershipLock`): the display role and the standalone receiver both take one kernel file lock (`/Users/Shared/.meowdisplay-receiver.lock`) before opening any listener or advertising anything. - - Whichever starts second, in either order, binds nothing. It shows what to quit, checks again every 2 seconds, and starts by itself once the other lets go. - - The owner releases the lock only after its listeners have closed. The kernel releases it if the owner quits or crashes, so a stale lock can't block anyone. +- **Two receivers on one Mac** (`ReceiverOwnershipLock`): the display role and the standalone receiver both take one kernel `flock` on `~/Library/Application Support/MeowDisplay/receiver.lock` before opening any listener or advertising anything. Neither app is sandboxed, so both use the same file. + - Whichever starts second, in either order, binds nothing. It shows what to quit, checks again every 2 seconds until it starts or is turned off (`stop()` cancels the check), and starts by itself once the other lets go. + - The owner releases the lock only after its listeners have closed, and keeps the descriptor open the whole time it owns the ports. The kernel releases it if the owner quits or crashes, so a stale lock can't block anyone. Release never unlinks the file: a waiting receiver must keep locking the same file. - Already-released MeowDisplay Receiver builds predate the lock, so the display role also waits while any standalone receiver is running (bundle id). - Never needed for safety: a second bind on the same port fails with "address in use" rather than sharing it (measured). The lock makes that failure explicit and recoverable instead of a silent retry loop. +- **The lock file can't be planted or swapped.** A predictable name in the user's home isn't safe by itself, so: + - The path is walked from the home directory (from the account database, not `$HOME`) one component at a time with `openat(O_DIRECTORY | O_NOFOLLOW)`. No component is ever followed through a symlink. Missing folders are created `0700`. + - Every folder on the way (`~`, `Library`, `Application Support`, `MeowDisplay`) must be owned by this user and not group- or world-writable (`fstat` on the open descriptor). So nobody else can replace a component between the checks and the open. + - The file is opened with `O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC` and created `0600`. A planted FIFO can't hang the open. Then `fstat` requires a regular file owned by this user with one link. An older build's `0644` file is tightened to `0600`. + - There's one location and no fallback. A second location would let two receivers lock different files. +- **Fails closed.** If the lock can't be used safely (symlink, wrong owner, writable by others, not a regular file, hard link, permission denied, any other error), the receiver **does not start**. It never runs as though it owned the ports. The status says what's wrong and where. It isn't retried by itself; **Try Again** (role settings, or the standalone receiver's settings) checks again after the user fixes it. Turning the role off clears it. +- **Ports taken by something else** (`ReceiverPortHealth`): the lock is per user, so it can't see a receiver in another login session or an unrelated app. There, the actual bind is the final word. When the media (9001) or pairing (9002) listener fails to bind, the status says which port is in use and what that means ("no Mac can connect", or "new Macs can't pair"). The existing 1-second listener retries continue and end when the receiver stops (a retired receiver never reopens a listener). The message clears itself once the listener is ready. QUIC (UDP 9001) failing alone isn't reported: TCP still carries the session. +- **Cross-user limitation (accepted):** two macOS accounts logged in at once don't coordinate through the lock. Whichever binds second shows the port-in-use status and waits for the port. MeowDisplay doesn't try to see, stop or work around another user's processes, and has no privileged helper for this. - The receiver's pairing listener now retries a failed bind like its media listener, so pairing comes back once the port frees. - **Unchanged:** pairing, TLS 1.3, pinning, per-session input authorization and the wire protocol. @@ -116,7 +124,7 @@ through the existing `appcast.xml` feed. ## Verified (automated, this branch) -- **macOS suite:** `xcodebuild test -scheme OpenSidecarMac` passes **1989 tests, 0 failures**. That's 1946 on #25's head plus 43 new: +- **macOS suite:** `xcodebuild test -scheme OpenSidecarMac` passes **2002 tests, 0 failures**. That's 1946 on #25's head plus 56 new: - `RolePreferencesIsolationTests` (14): the approval-policy leak in both directions, the receiver role's own default, original key names for existing installs, per-Mac overrides with the same peer id in both roles, role switching and relaunch, both authorization entry points, and per-role endpoint and Wake data. With the keys still shared, 12 of the 14 failed. - `UnifiedMacAppTests` (17): - role transitions, persistence and rapid toggling @@ -130,15 +138,15 @@ through the existing `appcast.xml` feed. - the Dock/Command-Tab rule - the settings page and its search - a guard that the receiver's settings code never uses the sender's trust store or keys - - `ReceiverOwnershipTests` (12): - - both launch orders - - a receiver without the lock - - a quit or crashed owner - - read-only and unusable lock files - - retired listeners never reopening - - the pairing listener's retry - - a real second bind failing with "address in use" -- **Two processes:** a command-line build of `ReceiverOwnership.swift` was run as two processes, in both orders and with the owner killed with `kill -9`. The second always waited, then started within one check of the owner going away. + - `ReceiverOwnershipTests` (25): + - both launch orders, and a receiver without the lock + - **a real second process** (`flock(2)` on the same file) in both orders: holding the lock, then exiting normally, being killed with `SIGKILL`, and release and reacquisition (the file is never unlinked) + - an existing file reused and tightened to `0600`; a new folder `0700` and file `0600` + - refused, without creating anything through it: a symlink at the lock path (dangling or not), a symlinked folder, a hard-linked file, a folder or FIFO at the file's name, a file where a folder belongs, a folder others can write to, an unwritable folder, a folder owned by root, and files and folders of another uid (`fstat` checks) + - an unusable lock never lets the receiver start + - the status messages for lock problems and taken ports + - a port held by another process: the receivers' listener parameters fail with "address in use" and `ReceiverPortHealth` reports it until cleared (an ephemeral port, so a running MeowDisplay isn't disturbed) + - retired listeners never reopening, the pairing listener's retry, and a real second bind failing with "address in use" - **Builds:** `OpenSidecarMac`, `OpenSidecarMacReceiver` (macOS 12 target) and `OpenSidecariOS` (generic device) all build. The unified bundle has the sender's id, `LSMinimumSystemVersion` 14.0, `LSUIElement`, `MeowDisplayReceiverRoleTrust = separate`, and the receiver code linked in. - **Not covered by automation:** - live Keychain isolation (the unsigned test bundle has no Keychain entitlement) @@ -180,6 +188,8 @@ per-device Mirror/Extend and #25's input on the same builds. - **Display role first:** with the role on, open the standalone receiver. Its status says MeowDisplay is using this Mac as a display; Mac A keeps streaming to the role. Turn the role off: the standalone receiver starts by itself and is pairable. - Repeat the first order with an already-released MeowDisplay Receiver: the role waits the same way. - Relaunch MeowDisplay while the standalone receiver runs: the switch stays on and waiting. + - **Unsafe lock:** quit both, replace `~/Library/Application Support/MeowDisplay/receiver.lock` with a symlink (`ln -sf /tmp/x …`), turn the role on: it doesn't start, says the file is a symbolic link, and binds nothing. Remove the link, click Try Again: it starts. + - **Port taken:** with the standalone receiver running in a second macOS account (fast user switching), turn the role on in the first: the status says port 9001 is in use. Quit the other account's receiver: the role comes up by itself. 14. **Rollback.** Reinstall 1.20.0 on Mac A: everything works as before. ## Remaining work before release @@ -187,5 +197,5 @@ per-device Mirror/Extend and #25's input on the same builds. - **Hardware QA** of the checklist above, with #24 and #25. - **String catalog:** the receiver's strings and the new page's strings aren't in MeowDisplay's catalog yet. Xcode adds them on the next IDE build. The catalogs are English-only, so nothing user-visible changes meanwhile. - **An already-released MeowDisplay Receiver opened second:** it predates the lock and can't be changed. Its own binds fail, so it doesn't disturb the running display role, but it shows no explanation. When the role turns off, its media listener takes the ports, while its pairing listener (unlike this branch's) doesn't retry. Updating the standalone receiver to this branch's version fixes both. -- **Another app on ports 9001/9002:** a non-MeowDisplay app holding them still just causes the existing silent bind retries. +- **Another account or app on ports 9001/9002:** shown as "port in use" and retried every second while the receiver is on. The lock doesn't coordinate accounts (by design, see above). - **Optional migration:** the shared-access-group migration for standalone receiver users, if wanted.