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/Mac/DisplayRoleSection.swift b/Mac/DisplayRoleSection.swift new file mode 100644 index 000000000..857173898 --- /dev/null +++ b/Mac/DisplayRoleSection.swift @@ -0,0 +1,173 @@ +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 + /// 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 { + Section { + Toggle("Use This Mac as a Display", isOn: Binding( + get: { controller.receiverModeEnabled }, + set: { controller.setReceiverModeEnabled($0) })) + if controller.receiverModeEnabled { + 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(receiver.statusColor) + if receiver.lockProblem != nil { + Button("Try Again") { receiver.start() } + .controlSize(.small) + } + } else { + LabeledContent("Status", value: receiver.statusTitle) + } + } + 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: { + 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) + } + } +} + +/// "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 + @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..99112af5c --- /dev/null +++ b/Mac/MacAppRole.swift @@ -0,0 +1,84 @@ +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 + /// 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 + } + + 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 ? [.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, .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. 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", + "com.raisecaterror.meowdisplay.mac.receiver.debug", + ] + + static func isRunning(among runningBundleIdentifiers: [String?]) -> Bool { + 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/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..281ea9afe 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) { @@ -338,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. @@ -402,6 +415,90 @@ 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 + + /// "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) { + let effects = roleState.setReceiverEnabled(enabled, defaults: .standard) + receiverModeEnabled = roleState.receiverEnabled + applyRoleEffects(effects) + } + + func applyLaunchRole() { + 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 { + 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 } + 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.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() + } + } + } + @Published var sessions: [DeviceSession] = [] private var suppressModeRestart = false /// Per-device Mirror/Extend authority (issue #20). Starts from the stored @@ -570,8 +667,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() { @@ -969,7 +1066,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() @@ -1001,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" @@ -1410,7 +1514,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 { @@ -1429,7 +1533,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 { @@ -1504,7 +1610,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) @@ -1537,7 +1643,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 { @@ -1689,15 +1795,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) @@ -1728,8 +1834,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() @@ -1882,7 +1988,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) @@ -1968,7 +2074,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 +2439,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) @@ -3002,7 +3115,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 @@ -3010,7 +3123,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 @@ -3186,7 +3299,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/OverviewSettingsView.swift b/Mac/OverviewSettingsView.swift index 05393fece..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,6 +104,7 @@ struct OverviewSettingsView: View { } } } + DisplayRoleSection(controller: controller, onOpenSettings: onOpenDisplayRole) } .formStyle(.grouped) } 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/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 93d0335d0..b99f2e7af 100644 --- a/MacReceiver/MacReceiver.swift +++ b/MacReceiver/MacReceiver.swift @@ -56,11 +56,42 @@ 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 } + /// 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)? + /// 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? + 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? @@ -72,6 +103,35 @@ final class ReceiverController: ObservableObject { func start() { guard receiver == nil else { return } + guard retiringReceiver == nil else { + 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() + } + 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 @@ -103,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 @@ -184,10 +247,35 @@ 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 || lockProblem != nil { + stopWaitingForOtherReceiver() + lockProblem = nil + statusTitle = String(localized: "Waiting for a Mac…") + statusColor = .secondary + } guard let receiver else { completion?(); return } cancellables.removeAll() if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) } @@ -204,12 +292,23 @@ 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 + // 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() + } + } completion?() } self.receiver = nil connected = false streaming = false + portsUnavailable = false statusTitle = String(localized: "Waiting for a Mac…") statusColor = .secondary mirrorUnavailableOffer = false @@ -315,6 +414,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 +443,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 +469,7 @@ final class ReceiverController: ObservableObject { $0.windowLifecycle.windowClosing() $0.stopObservingWindow() $0.window = nil + $0.onVideoWindowVisibilityChanged?(false) }, ] } diff --git a/MacReceiver/ReceiverDevicesSettingsView.swift b/MacReceiver/ReceiverDevicesSettingsView.swift index a3f135850..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() } })) { @@ -136,11 +149,11 @@ 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 { - !peerID.isEmpty && RemoteEndpointStore.endpoint(forPeerID: peerID) != nil + !peerID.isEmpty && RemoteEndpointStore.endpoint(forPeerID: peerID, role: .receiverRole) != nil } var body: some View { @@ -185,7 +198,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 +210,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 +232,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 @@ -233,7 +246,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] { @@ -263,7 +276,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 +325,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/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/MacReceiver/ReceiverOwnership.swift b/MacReceiver/ReceiverOwnership.swift new file mode 100644 index 000000000..5b4e652e2 --- /dev/null +++ b/MacReceiver/ReceiverOwnership.swift @@ -0,0 +1,261 @@ +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 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 { + private let root: String + private let directories: [String] + private let fileName: String + private var descriptor: Int32 = -1 + + /// `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() + } + + var path: String { + ([root] + directories + [fileName]).joined(separator: "/") + } + + var isHeld: Bool { descriptor >= 0 } + + /// Takes ownership if nobody else has it. Idempotent while held. + func tryAcquire() -> ReceiverOwnershipAttempt { + if isHeld { return .acquired } + 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 + } + 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) + close(descriptor) + descriptor = -1 + } + + 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 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 +/// 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 + /// 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 + /// 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: 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 4ddd91283..32560be19 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: { @@ -475,6 +485,13 @@ struct WithReceiver: View { var body: some View { if let receiver = controller.receiver { content(receiver) + } else if controller.waitingForOtherReceiver || controller.lockProblem != nil { + ReceiverSettingsForm { + ReceiverCaption(verbatim: controller.statusTitle) + if controller.lockProblem != nil { + Button("Try Again") { controller.start() } + } + } } else { ReceiverSettingsForm { ReceiverCaption("The receiver is not running.") 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/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/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/MacTests/ReceiverOwnershipTests.swift b/MacTests/ReceiverOwnershipTests.swift new file mode 100644 index 000000000..2e2e0ecce --- /dev/null +++ b/MacTests/ReceiverOwnershipTests.swift @@ -0,0 +1,469 @@ +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. +/// 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 root: URL! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("ReceiverOwnershipTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } + + override func tearDownWithError() throws { + // 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 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(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 + + 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: - 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() { + 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 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 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 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 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 + + 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() + } +} + +/// 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/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/UnifiedMacAppTests.swift b/MacTests/UnifiedMacAppTests.swift new file mode 100644 index 000000000..bef759b4f --- /dev/null +++ b/MacTests/UnifiedMacAppTests.swift @@ -0,0 +1,216 @@ +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, .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") + } + + func testTurningItOffStopsTheReceiverThenResumesSharing() { + var state = MacAppRoleState(defaults: defaults) + _ = state.setReceiverEnabled(true, defaults: defaults) + XCTAssertEqual(state.setReceiverEnabled(false, defaults: defaults), + [.stopReceiver, .advertiseSender, .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, [.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 testTheStandaloneReceiverRunningMakesTheDisplayRoleWait() { + 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.. 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,11 +90,13 @@ 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( - 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. @@ -110,8 +113,9 @@ 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, - hints: remoteHost == nil ? nil : RemoteEndpointStoreHints()) + store: store, isCurrent: isCurrent, remoteHost: remoteHost, + hints: remoteHost == nil ? nil + : RemoteEndpointStoreHints(role: RolePreferences(trustNamespace: store.namespace))) logFinalized(pending) return pending } @@ -121,7 +125,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 +153,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 +165,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 +185,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 +254,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..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 { @@ -497,13 +517,13 @@ 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 { 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 @@ -541,7 +561,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/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 new file mode 100644 index 000000000..ab2a34ef1 --- /dev/null +++ b/Shared/ReceiverRoleConfiguration.swift @@ -0,0 +1,89 @@ +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 + } +} + +/// 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 +/// 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/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/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 364817dd5..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() } @@ -373,9 +376,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 @@ -406,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, `===` @@ -422,9 +492,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 +529,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 @@ -768,12 +869,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") @@ -1072,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 @@ -1463,7 +1570,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 @@ -1494,9 +1601,13 @@ 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.shared.pin(peerID: $0) } + pairingPrompt.trustPinLookup = { TrustStore.receiverRole.pin(peerID: $0) } pairingObservation = pairingPrompt.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() } @@ -1516,7 +1627,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, @@ -1534,6 +1645,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 @@ -1546,14 +1663,22 @@ final class StreamReceiver: ObservableObject { } func forgetPeer(_ peerID: String) { - TrustStore.shared.forget(peerID: peerID) - IncomingSessionPolicyStore.removePolicy(peerID: peerID) - 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 @@ -1561,7 +1686,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) @@ -1625,7 +1750,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 +1803,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 +1912,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 +2213,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 +2228,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 @@ -2123,7 +2250,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 @@ -2151,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 @@ -2234,7 +2363,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") @@ -2247,7 +2376,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( @@ -2268,16 +2398,30 @@ final class StreamReceiver: ObservableObject { } } } - listener.stateUpdateHandler = { state in - #if DEBUG + let selfBox = self.selfBox + listener.stateUpdateHandler = { [weak listener] state in switch state { - 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 .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 } + 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 .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 { @@ -2440,7 +2584,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) @@ -2603,7 +2747,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 { @@ -4204,7 +4348,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 @@ -4262,7 +4406,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") @@ -4274,7 +4418,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) @@ -4393,7 +4537,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") } @@ -4554,10 +4698,10 @@ 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.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] }, @@ -5323,8 +5467,23 @@ 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 = 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. 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 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/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 d5e1e5618..5148e3ae9 100644 --- a/Shared/WakeTestingView.swift +++ b/Shared/WakeTestingView.swift @@ -10,12 +10,12 @@ 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? { 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/UNIFIED_MAC_APP.md b/UNIFIED_MAC_APP.md new file mode 100644 index 000000000..9dd4a0f62 --- /dev/null +++ b/UNIFIED_MAC_APP.md @@ -0,0 +1,201 @@ +# 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. +- **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`). +- **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. 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 `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. + +## 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, 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. +- **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.*` 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 **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 + - 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 + - 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` (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) + - TCC + - 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 + +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 + - 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. +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, 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. + - **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 + +- **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 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. 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 diff --git a/project.yml b/project.yml index f6c2927f3..121de95b1 100644 --- a/project.yml +++ b/project.yml @@ -60,6 +60,18 @@ targets: sources: - Mac - Shared + # The unified app's "Use This Mac as a Display" role: the Mac + # Receiver's code, minus its own app entry point and bundle resources + # (MeowDisplay keeps one icon, string catalog and Info.plist). The + # standalone receiver target below still builds all of it for macOS + # 12-13. + - path: MacReceiver + excludes: + - OpenSidecarMacReceiverApp.swift + - Info.plist + - "*.entitlements" + - "*.xcstrings" + - Assets.xcassets dependencies: - package: Sparkle - package: SwiftCertificates @@ -77,6 +89,9 @@ targets: NSLocalNetworkUsageDescription: MeowDisplay connects to your iPad or iPhone over the local network in WiFi mode. NSBonjourServices: ["_opensidecar._tcp", "_meowdisp-q._udp", "_opendisplay-pair._tcp", "_opendisplay-mac-pair._tcp"] LSApplicationCategoryType: public.app-category.utilities + # The receiver role keeps its own identity and pins (see + # ReceiverRoleConfiguration / TrustStore.receiverRole). + MeowDisplayReceiverRoleTrust: separate # --- Sparkle auto-update (see RELEASE.md) --- # MeowDisplay's own feed and EdDSA public key — never upstream # OpenDisplay's. Both Mac apps trust the same key; each has its own @@ -180,6 +195,9 @@ targets: - MacReceiver/ReceiverWindowLifecycle.swift - MacReceiver/ReceiverSettingsCategory.swift - MacReceiver/ReceiverInputForwarding.swift + - MacReceiver/ReceiverOwnership.swift + - Shared/ReceiverRoleConfiguration.swift + - Mac/MacAppRole.swift - Shared/ReceiverGesture.swift - Mac/Log.swift - Mac/LogPolicies.swift