diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9362e52..4033515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,13 @@ concurrency: jobs: build-and-test: name: Build & Test - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_16.2.app + run: sudo xcode-select -s /Applications/Xcode_26.6.app - name: Show versions run: | @@ -43,18 +43,25 @@ jobs: -destination 'platform=macOS' \ CODE_SIGNING_ALLOWED=NO + appcast: + name: Appcast + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Check appcast URLs point at their own release tags + run: python3 scripts/appcast-tool.py check appcast.xml "$GITHUB_REPOSITORY" + lint: name: SwiftLint - runs-on: macos-15 - # Non-blocking while the team tunes the ruleset on a previously-unlinted - # codebase. Flip to a required check (and add --strict) once it's green. - continue-on-error: true + runs-on: macos-26 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install SwiftLint run: brew install swiftlint - name: Run SwiftLint - run: swiftlint lint --reporter github-actions-logging + run: swiftlint lint --strict --reporter github-actions-logging diff --git a/.swiftlint.yml b/.swiftlint.yml index c12aa1e..fe0b4e4 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,7 +2,7 @@ # # Intentionally conservative: this is the first lint pass over an existing # codebase, so the noisier opinionated rules are disabled and thresholds are -# relaxed. Tighten over time, then make the CI lint job a required check. +# relaxed. CI runs it with --strict, so any violation fails the build. included: - VirtualMirror diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efd853..dea6368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Errors now show in the receiver window, with a Retry button. This covers the AirPlay port already being in use (e.g. a second copy of VirtualMirror or another receiver), Local Network access being denied, and Bonjour advertising failing. Previously the window showed "waiting" forever while the iPhone failed to connect. +- Audio recovers by itself when the Mac's output device changes (AirPods, monitor speakers, unplugging headphones). Previously it stayed silent until mirroring restarted. +- 10 new tests covering the session lifecycle: ending, eviction and TEARDOWN handling, including an end-to-end test over a real listener. The suite now has 58 tests. +- A CI check that every appcast download URL points at its own release, plus `scripts/appcast-tool.py` to fix and verify the appcast (`check --online` also requests every URL). + +### Changed + +- Updated Sparkle from 2.9.0 to 2.10.0. This includes upstream security fixes to the installer and to delta updates, and brings update windows to the front correctly in menu-bar apps. +- The GetParameter volume query now reports the receiver's real volume, in AirPlay's dB scale, instead of a fixed value. +- CI now runs on `macos-26` with Xcode 26.6. +- The project now builds in **Swift 6 language mode**, so the compiler checks for data races at compile time. Types that do all their work on one serial queue are marked `@unchecked Sendable`, with a comment explaining why each is safe. The video decoder's error counter, which the decode callback updated with no lock, is now locked. +- SwiftLint is clean and now a required CI check (`--strict`). +- The in-app Help and the README now cover the error screen and Retry, Local Network access, a second device taking over a receiver, audio following output-device changes, and per-receiver ports. The README no longer says the pairing identity is stored in the Keychain, which stopped being true in 0.4.0. +- The release script: + - uploads the delta updates alongside the DMG + - publishes the GitHub release before pushing the appcast, so an update is never advertised before its download exists + - runs Sparkle's tools from the same version that is linked into the app + - validates the DMG's notarization ticket + - checks every appcast URL after publishing + +### Fixed + +- The window no longer stays on "Mirroring" with a frozen last frame after the sender stops mirroring. This covers iOS 27, which ends a session with a TEARDOWN that doesn't name a stream. +- When a second device (or any other TCP client) connected to a receiver, the first session was dropped without the UI noticing. The window could then sit on a frozen frame or on "Connecting" indefinitely. +- The 30-second "Connecting" timeout now also closes the stuck connection, so the sender gives up cleanly. +- Fixed potential crashes when closing or restarting a receiver, or when a sender reconnected while streaming. Video, audio and Bonjour resources were torn down on one thread while another was still using them. +- Each iPhone lock/unlock no longer leaks timing-sync network connections. +- Commands from the menu-bar Command Palette now act on the most recently focused receiver instead of the first one, when no receiver window is focused. +- Delta updates now actually work: their files had never been uploaded, and the appcast pointed older versions' downloads at the wrong release. +- A rejected pair-verify now stops processing any further requests already buffered on that connection. + ## [0.4.0] - 2026-05-31 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6e179cd..8f25c9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ xcodebuild build -project VirtualMirror.xcodeproj -scheme VirtualMirror -configu Open in Xcode: `open VirtualMirror.xcodeproj` - Deployment target: macOS 14.0 -- Swift version: 5.0 +- Swift version: 6.0 (Swift 6 language mode, complete concurrency checking) - Uses C bridging header for FairPlay code (`VirtualMirror/FairPlay/BridgingHeader.h`) ## Architecture diff --git a/README.md b/README.md index e18948a..fafca02 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ AirPlay screen mirroring receiver for macOS. Mirror your iPhone or iPad screen t ## Features - **Screen Mirroring** — Receive AirPlay mirroring from any iPhone or iPad -- **Audio** — Full audio passthrough with adjustable volume control +- **Audio** — Full audio passthrough with adjustable volume control; follows you when you switch output devices - **Rotation** — Seamlessly handles device rotation and lock/unlock - **Low Latency** — Hardware-accelerated H.264 decoding via VideoToolbox - **Menu Bar** — Runs quietly in the menu bar with live connection status - **Auto-Update** — Built-in update checking via Sparkle -- **Secure** — Cryptographic identity stored in macOS Keychain; full AirPlay pairing and FairPlay DRM support +- **Private** — Fresh pairing identity per receiver, generated in memory and never persisted; full AirPlay pairing and FairPlay DRM support ## Download @@ -68,16 +68,18 @@ If the macOS built-in **AirPlay Receiver** is enabled, your iPhone may connect t ### Connection drops or won't connect - Make sure your iPhone/iPad and Mac are on the **same Wi-Fi network** -- Restart VirtualMirror from the menu bar (Quit, then relaunch) -- Check that no firewall is blocking port **47000** +- If the window shows an error (port already in use, no Local Network access), fix the cause it names and click **Retry** +- Use **Restart This Receiver** from the Command Palette (⌘K), or quit from the menu bar and relaunch +- Check that no firewall is blocking VirtualMirror — the first receiver listens on port **47000**, each additional one 1000 higher (48000, 49000, …) +- A second device mirroring to the same receiver replaces the first; open another receiver (⌘N) to show both ### Local network permission -The first time you run it, macOS may ask VirtualMirror for permission to find devices on your local network. Allow it — AirPlay discovery needs it. Each receiver generates a fresh pairing identity in memory; nothing is persisted. +The first time you run it, macOS asks VirtualMirror for permission to find devices on your local network. Allow it — AirPlay discovery needs it. If you declined, enable it in **System Settings > Privacy & Security > Local Network**, then click **Retry** in the receiver window. Each receiver generates a fresh pairing identity in memory; nothing is persisted. ## Building from Source -Requires macOS 14.0+ and Xcode 15+. +Requires Xcode 26+ (Swift 6 language mode). The app runs on macOS 14.0+. ```bash git clone https://github.com/souriscloud/VirtualMirror.git diff --git a/VirtualMirror.xcodeproj/project.pbxproj b/VirtualMirror.xcodeproj/project.pbxproj index 0544511..bcfe339 100644 --- a/VirtualMirror.xcodeproj/project.pbxproj +++ b/VirtualMirror.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ T1000004 /* HTTPResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T2000004; }; T1000005 /* HTTPParserHardeningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T2000005; }; T1000006 /* PairVerifyHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T2000006; }; + T1000007 /* SessionLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T2000007; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -102,6 +103,7 @@ T2000004 /* HTTPResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponseTests.swift; sourceTree = ""; }; T2000005 /* HTTPParserHardeningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPParserHardeningTests.swift; sourceTree = ""; }; T2000006 /* PairVerifyHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairVerifyHandlerTests.swift; sourceTree = ""; }; + T2000007 /* SessionLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLifecycleTests.swift; sourceTree = ""; }; T3000001 /* VirtualMirrorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VirtualMirrorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -149,6 +151,7 @@ T1000004 /* HTTPResponseTests.swift in Sources */, T1000005 /* HTTPParserHardeningTests.swift in Sources */, T1000006 /* PairVerifyHandlerTests.swift in Sources */, + T1000007 /* SessionLifecycleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -287,6 +290,7 @@ T2000004 /* HTTPResponseTests.swift */, T2000005 /* HTTPParserHardeningTests.swift */, T2000006 /* PairVerifyHandlerTests.swift */, + T2000007 /* SessionLifecycleTests.swift */, ); path = VirtualMirrorTests; sourceTree = ""; @@ -550,7 +554,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "VirtualMirror/FairPlay/BridgingHeader.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -579,7 +583,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "VirtualMirror/FairPlay/BridgingHeader.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -595,7 +599,7 @@ PRODUCT_BUNDLE_IDENTIFIER = cloud.souris.virtualmirror.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VirtualMirror.app/Contents/MacOS/VirtualMirror"; }; name = Debug; @@ -612,7 +616,7 @@ PRODUCT_BUNDLE_IDENTIFIER = cloud.souris.virtualmirror.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VirtualMirror.app/Contents/MacOS/VirtualMirror"; }; name = Release; @@ -655,7 +659,7 @@ repositoryURL = "https://github.com/sparkle-project/Sparkle"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.6.0; + minimumVersion = 2.10.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/VirtualMirror.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/VirtualMirror.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6e450d9..542ae1a 100644 --- a/VirtualMirror.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/VirtualMirror.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", - "version" : "2.9.0" + "revision" : "eef1a539a373c1f1a320624b1130fc5de7b2e100", + "version" : "2.10.0" } } ], diff --git a/VirtualMirror/AboutView.swift b/VirtualMirror/AboutView.swift index a7ac906..fbb9685 100644 --- a/VirtualMirror/AboutView.swift +++ b/VirtualMirror/AboutView.swift @@ -138,7 +138,7 @@ private struct AckSection: View { let title: String let description: String let license: String - var url: String? = nil + var url: String? var body: some View { VStack(alignment: .leading, spacing: 4) { diff --git a/VirtualMirror/AirPlay/AirPlayConfig.swift b/VirtualMirror/AirPlay/AirPlayConfig.swift index 8347a10..c66b002 100644 --- a/VirtualMirror/AirPlay/AirPlayConfig.swift +++ b/VirtualMirror/AirPlay/AirPlayConfig.swift @@ -92,19 +92,19 @@ struct AirPlayConfig { "overscanned": NSNumber(value: false), "refreshRate": NSNumber(value: 1.0 / 60.0), "maxFPS": NSNumber(value: 30 as UInt64), - ] as [String : Any] + ] as [String: Any] ], "audioFormats": [ [ "type": NSNumber(value: 100 as UInt64), "audioInputFormats": NSNumber(value: 0x03FFFFFC as UInt64), "audioOutputFormats": NSNumber(value: 0x03FFFFFC as UInt64), - ] as [String : Any], + ] as [String: Any], [ "type": NSNumber(value: 101 as UInt64), "audioInputFormats": NSNumber(value: 0x03FFFFFC as UInt64), "audioOutputFormats": NSNumber(value: 0x03FFFFFC as UInt64), - ] as [String : Any], + ] as [String: Any], ], "audioLatencies": [ [ @@ -112,13 +112,13 @@ struct AirPlayConfig { "audioType": "default", "inputLatencyMicros": NSNumber(value: 0 as UInt64), "outputLatencyMicros": NSNumber(value: false), - ] as [String : Any], + ] as [String: Any], [ "type": NSNumber(value: 101 as UInt64), "audioType": "default", "inputLatencyMicros": NSNumber(value: 0 as UInt64), "outputLatencyMicros": NSNumber(value: false), - ] as [String : Any], + ] as [String: Any], ], ] do { diff --git a/VirtualMirror/AirPlay/AirPlayConnection.swift b/VirtualMirror/AirPlay/AirPlayConnection.swift index d038a05..000fb57 100644 --- a/VirtualMirror/AirPlay/AirPlayConnection.swift +++ b/VirtualMirror/AirPlay/AirPlayConnection.swift @@ -2,7 +2,9 @@ import Foundation import Network import os -class AirPlayConnection { +// @unchecked Sendable: all mutable state is confined to `queue` (the +// NWConnection's callback queue; public entry points hop onto it). +final class AirPlayConnection: @unchecked Sendable { /// AirPlay RTSP stream type identifiers (the `type` field in a SETUP stream dict). private enum StreamType { static let screenMirror = 110 @@ -14,6 +16,23 @@ class AirPlayConnection { private weak var manager: AirPlayManager? var onClose: (() -> Void)? + /// Identifies this connection's session to the manager, so events from a + /// connection that's being replaced can't clobber the current session's UI. + private let sessionID = UUID() + + /// Every piece of mutable state here — request handling, stream setup and + /// teardown, volume — runs on this serial queue. The NWConnection delivers + /// its callbacks here, and the public entry points hop onto it. + private let queue = DispatchQueue(label: "cloud.souris.virtualmirror.airplay-connection", qos: .userInteractive) + /// Set once the connection has been torn down and the manager told. + private var finished = false + /// Set by RECORD. A later screen-mirror SETUP on the same connection (the + /// sender re-establishing streams after a full TEARDOWN) resumes mirroring + /// in the UI without waiting for a RECORD that may not come again. + private var hasRecorded = false + /// The sender's name from session SETUP, for the UI. + private var senderName: String? + private var buffer = Data() /// This connection's receiver identity (ports, device ID, signing key). @@ -64,27 +83,42 @@ class AirPlayConnection { self?.receiveData() case .failed(let error): self?.logger.error("Connection failed: \(error)") - self?.close() + self?.finish() case .cancelled: self?.logger.info("Connection cancelled") - self?.manager?.didDisconnect() - self?.onClose?() + self?.finish() default: break } } - connection.start(queue: .global(qos: .userInteractive)) + connection.start(queue: queue) } + /// Closes the connection from outside (eviction by a newer sender, receiver + /// shutdown). The block holds a strong reference, so the manager is always + /// told the session ended even if the caller drops its last reference now. func close() { + queue.async { self.finish() } + } + + /// Tears everything down exactly once and reports the end of the session. + /// Must run on `queue`. + private func finish() { + guard !finished else { return } + finished = true connection.cancel() shutdownAllResources() + manager?.didEndSession(sessionID) + onClose?() + onClose = nil } /// Sets the audio volume on the active audio stream receiver. func setVolume(_ volume: Float) { - currentVolume = volume - audioStreamReceiver?.volume = volume + queue.async { + self.currentVolume = volume + self.audioStreamReceiver?.volume = volume + } } /// Full shutdown: stops all listeners, receivers, and helper services. @@ -119,22 +153,26 @@ class AirPlayConnection { if isComplete { self.logger.info("Connection closed by remote") - self.close() + self.finish() return } if let error = error { self.logger.error("Receive error: \(error)") - self.close() + self.finish() return } + guard !self.finished else { return } + self.receiveData() } } private func processBuffer() { - while !buffer.isEmpty { + // Stop as soon as a handler closes the connection (e.g. a rejected + // pair-verify) — don't act on requests still sitting in the buffer. + while !buffer.isEmpty && !finished { switch HTTPParser.parse(data: buffer) { case .needsMore: return @@ -144,7 +182,7 @@ class AirPlayConnection { case .error(let message): logger.error("HTTP parse error: \(message)") buffer.removeAll() - close() + finish() return } } @@ -259,7 +297,7 @@ class AirPlayConnection { case .reject: logger.error("pair-verify rejected — closing connection") sendResponse(HTTPResponse.build(status: 470, statusText: "Connection Authorization Required", cseq: request.cseq)) - close() + finish() } } @@ -353,7 +391,8 @@ class AirPlayConnection { // Extract device name for UI state if let name = plist["name"] as? String { logger.info("Device: \(name)") - manager?.didStartConnecting(deviceName: name) + senderName = name + manager?.didStartConnecting(session: sessionID, deviceName: name) } // Read the iPhone's timing port and start active NTP sync @@ -393,6 +432,9 @@ class AirPlayConnection { streamConnectionID = UInt64(connID) } startMirrorStream(streamConnectionID: streamConnectionID) + if hasRecorded { + manager?.didStartMirroring(session: sessionID, deviceName: senderName) + } responseStreams.append([ "type": StreamType.screenMirror, "dataPort": Int(videoStreamPort), @@ -416,7 +458,8 @@ class AirPlayConnection { private func handleRecord(_ request: HTTPRequest) { logger.info("Handling RECORD - mirroring started!") - manager?.didStartMirroring() + hasRecorded = true + manager?.didStartMirroring(session: sessionID, deviceName: senderName) let headers = [ "Audio-Latency": "11025", "Audio-Jack-Status": "connected; type=analog" @@ -439,7 +482,7 @@ class AirPlayConnection { var responseBody = "" if paramName.contains("volume") { - responseBody = "volume: 0.500000\r\n" + responseBody = String(format: "volume: %.6f\r\n", AirPlayConnection.airPlayVolume(fromLinear: currentVolume)) } // Respond with the parameter value sendResponse(HTTPResponse.ok( @@ -453,34 +496,55 @@ class AirPlayConnection { private func handleTeardown(_ request: HTTPRequest) { logger.info("TEARDOWN") - // Parse the TEARDOWN body — it may specify which streams to tear down - if let plist = try? PropertyListSerialization.propertyList(from: request.body, options: [], format: nil) as? [String: Any] { - if let streams = plist["streams"] as? [[String: Any]] { - // Selective teardown: only reset the specified stream types - for stream in streams { - let type = stream["type"] as? Int ?? -1 - logger.debug("Tearing down stream type: \(type)") - if type == StreamType.screenMirror { - mirrorStreamReceiver?.resetStream() - } else if type == StreamType.audio { - audioStreamReceiver?.resetStream() - } + switch AirPlayConnection.teardownScope(body: request.body) { + case .streams(let types): + // Selective teardown: only reset the specified stream types + for type in types { + logger.debug("Tearing down stream type: \(type)") + if type == StreamType.screenMirror { + mirrorStreamReceiver?.resetStream() + } else if type == StreamType.audio { + audioStreamReceiver?.resetStream() } - } else { - // Full teardown — reset all stream resources but keep listeners - resetStreamResources() } - } else { - // No body or unparseable — reset everything + case .session: + // Full teardown (no stream list): the sender has ended the session. + // Reset stream resources but keep the listeners and the control + // connection — the sender may SETUP again on it (lock/unlock) — and + // tell the manager, so the UI doesn't sit on the last frame. iOS 27 + // sends only this typeless TEARDOWN when mirroring stops, with no + // preceding type-110 teardown, and may keep the connection open. resetStreamResources() + // Reset the decoder here, in order on this queue, so it happens + // before any re-SETUP on this connection configures a new stream. + manager?.videoDecoder.reset() + manager?.didEndSession(sessionID) } - // Do NOT call manager?.didDisconnect() here — the control connection is still alive. - // The sender will issue a new SETUP after rotation/lock. didDisconnect() is called - // when the NWConnection itself closes (in the .cancelled state handler). sendResponse(HTTPResponse.ok(cseq: request.cseq, isRTSP: true)) } + enum TeardownScope: Equatable { + /// Only these stream types (e.g. 110 screen mirror, 96 audio). + case streams([Int]) + /// The whole session: no body, an unparseable body, or no stream list. + case session + } + + static func teardownScope(body: Data) -> TeardownScope { + guard let plist = try? PropertyListSerialization.propertyList(from: body, options: [], format: nil) as? [String: Any], + let streams = plist["streams"] as? [[String: Any]] else { + return .session + } + return .streams(streams.map { $0["type"] as? Int ?? -1 }) + } + + /// AirPlay expresses volume in dB: -30 (quietest) … 0 (full), -144 = muted. + static func airPlayVolume(fromLinear volume: Float) -> Double { + guard volume > 0 else { return -144 } + return max(-30, min(0, 20 * log10(Double(volume)))) + } + // MARK: - Mirror Stream private func startMirrorStream(streamConnectionID: UInt64) { @@ -551,7 +615,7 @@ class AirPlayConnection { // down so resources are reclaimed and the manager sees the drop, // rather than leaving a half-dead connection waiting for input. self?.logger.error("Send error: \(error) — closing connection") - self?.close() + self?.finish() } }) } diff --git a/VirtualMirror/AirPlay/AirPlayServer.swift b/VirtualMirror/AirPlay/AirPlayServer.swift index 38d3aae..31c97e9 100644 --- a/VirtualMirror/AirPlay/AirPlayServer.swift +++ b/VirtualMirror/AirPlay/AirPlayServer.swift @@ -1,14 +1,22 @@ import Foundation import Network +import dnssd import os -class AirPlayServer { +// @unchecked Sendable: `connections` is guarded by `connectionQueue`; the +// listener and `onFailure` are set up on the main actor before `start()` and +// only torn down there. +final class AirPlayServer: @unchecked Sendable { private let logger = Logger(subsystem: "cloud.souris.virtualmirror", category: "AirPlayServer") private var listener: NWListener? private var connections: [ObjectIdentifier: AirPlayConnection] = [:] private let connectionQueue = DispatchQueue(label: "cloud.souris.virtualmirror.server.connections") private weak var manager: AirPlayManager? + /// Called (once, from a network queue) if the listener can't be created or + /// fails, e.g. the port is taken or Local Network access is denied. + var onFailure: ((NWError) -> Void)? + init(manager: AirPlayManager) { self.manager = manager } @@ -19,11 +27,13 @@ class AirPlayServer { params.allowLocalEndpointReuse = true guard let nwPort = NWEndpoint.Port(rawValue: port) else { logger.error("Invalid port: \(port)") + onFailure?(.posix(.EINVAL)) return } listener = try NWListener(using: params, on: nwPort) } catch { logger.error("Failed to create listener: \(error)") + onFailure?(error as? NWError ?? .posix(.EINVAL)) return } @@ -33,6 +43,14 @@ class AirPlayServer { self?.logger.info("Server listening on port \(port)") case .failed(let error): self?.logger.error("Server failed: \(error)") + self?.onFailure?(error) + case .waiting(let error): + // Usually transient, except a Local Network privacy denial, + // which won't resolve until the user changes the setting. + self?.logger.warning("Server waiting: \(error)") + if case .dns(let code) = error, code == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { + self?.onFailure?(error) + } default: break } @@ -48,11 +66,19 @@ class AirPlayServer { func stop() { listener?.cancel() listener = nil - connectionQueue.sync { - for conn in connections.values { - conn.close() - } + closeAllConnections() + } + + /// Closes every control connection. Each connection still reports its own + /// end of session to the manager: `close()` keeps it alive until it has. + func closeAllConnections() { + let closing = connectionQueue.sync { + let all = Array(connections.values) connections.removeAll() + return all + } + for conn in closing { + conn.close() } } @@ -69,14 +95,9 @@ class AirPlayServer { // Single-device mode: close any existing connections before accepting a new one. // This handles the case where an iPhone reconnects (rotation, lock/unlock) and // the old connection hasn't fully torn down yet. - connectionQueue.sync { - if !connections.isEmpty { - logger.info("Closing \(self.connections.count) existing connection(s) for new session") - for conn in connections.values { - conn.close() - } - connections.removeAll() - } + if connectionQueue.sync(execute: { !connections.isEmpty }) { + logger.info("Closing existing connection(s) for new session") + closeAllConnections() } let connection = AirPlayConnection( @@ -89,7 +110,7 @@ class AirPlayServer { } connection.onClose = { [weak self] in - self?.connectionQueue.sync { + _ = self?.connectionQueue.sync { self?.connections.removeValue(forKey: id) } } diff --git a/VirtualMirror/AirPlay/AirPlayService.swift b/VirtualMirror/AirPlay/AirPlayService.swift index ac03a6d..1e60e88 100644 --- a/VirtualMirror/AirPlay/AirPlayService.swift +++ b/VirtualMirror/AirPlay/AirPlayService.swift @@ -4,7 +4,7 @@ import os /// Advertises AirPlay and RAOP Bonjour services using the DNS-SD C API. /// Replaces the deprecated NetService-based implementation. -class AirPlayService { +final class AirPlayService { private let logger = Logger(subsystem: "cloud.souris.virtualmirror", category: "AirPlayService") private var airplayRef: DNSServiceRef? @@ -15,6 +15,25 @@ class AirPlayService { private var airplaySource: DispatchSourceRead? private var raopSource: DispatchSourceRead? + /// Called with the DNS-SD error code if a registration fails, synchronously + /// or later in the registration callback (e.g. Local Network access denied). + var onFailure: ((DNSServiceErrorType) -> Void)? + + /// Context handed to the C registration callback. Retained until the + /// source's cancel handler runs, which is after the last callback can fire. + private final class RegistrationContext { + let onFailure: ((DNSServiceErrorType) -> Void)? + init(onFailure: ((DNSServiceErrorType) -> Void)?) { + self.onFailure = onFailure + } + } + + private static let registerCallback: DNSServiceRegisterReply = { _, _, errorCode, _, _, _, context in + guard errorCode != kDNSServiceErr_NoError, let context else { return } + let registration = Unmanaged.fromOpaque(context).takeUnretainedValue() + registration.onFailure?(errorCode) + } + func startAdvertising(identity: ReceiverIdentity) { let port = Int(identity.ports.airplay) logger.info("Starting Bonjour advertisement for \"\(identity.name)\" on port \(port)") @@ -24,6 +43,7 @@ class AirPlayService { // Advertise _airplay._tcp let airplayTXT = AirPlayConfig.airplayTXTRecord(identity) var airplayServiceRef: DNSServiceRef? + let airplayContext = Unmanaged.passRetained(RegistrationContext(onFailure: onFailure)) let airplayErr = DNSServiceRegister( &airplayServiceRef, 0, // flags @@ -35,21 +55,24 @@ class AirPlayService { CFSwapInt16HostToBig(port16), UInt16(airplayTXT.count), airplayTXT, - nil, // callback (not needed) - nil // context + AirPlayService.registerCallback, + airplayContext.toOpaque() ) if airplayErr == kDNSServiceErr_NoError, let ref = airplayServiceRef { airplayRef = ref - airplaySource = createDispatchSource(for: ref, label: "AirPlay") + airplaySource = createDispatchSource(for: ref, label: "AirPlay", context: airplayContext) logger.info("AirPlay Bonjour service registered") } else { + airplayContext.release() logger.error("Failed to register AirPlay service: \(airplayErr)") + onFailure?(airplayErr) } // Advertise _raop._tcp with deviceID@name format let raopName = "\(identity.deviceID.replacingOccurrences(of: ":", with: ""))@\(identity.name)" let raopTXT = AirPlayConfig.raopTXTRecord(identity) var raopServiceRef: DNSServiceRef? + let raopContext = Unmanaged.passRetained(RegistrationContext(onFailure: onFailure)) let raopErr = DNSServiceRegister( &raopServiceRef, 0, @@ -61,15 +84,17 @@ class AirPlayService { CFSwapInt16HostToBig(port16), UInt16(raopTXT.count), raopTXT, - nil, - nil + AirPlayService.registerCallback, + raopContext.toOpaque() ) if raopErr == kDNSServiceErr_NoError, let ref = raopServiceRef { raopRef = ref - raopSource = createDispatchSource(for: ref, label: "RAOP") + raopSource = createDispatchSource(for: ref, label: "RAOP", context: raopContext) logger.info("RAOP Bonjour service registered") } else { + raopContext.release() logger.error("Failed to register RAOP service: \(raopErr)") + onFailure?(raopErr) } } @@ -83,25 +108,22 @@ class AirPlayService { func stopAdvertising() { logger.info("Stopping Bonjour advertisement") + // Each source's cancel handler deallocates its DNSServiceRef (and the + // callback context), so a ref is never freed while an event handler on + // dnssdQueue could still be using it. A ref is only stored with a source. airplaySource?.cancel() raopSource?.cancel() airplaySource = nil raopSource = nil - if let ref = airplayRef { - DNSServiceRefDeallocate(ref) - airplayRef = nil - } - if let ref = raopRef { - DNSServiceRefDeallocate(ref) - raopRef = nil - } + airplayRef = nil + raopRef = nil } // MARK: - DNS-SD Event Processing /// Creates a dispatch source to process DNS-SD socket events. /// Without this, the registration may not complete on some systems. - private func createDispatchSource(for ref: DNSServiceRef, label: String) -> DispatchSourceRead { + private func createDispatchSource(for ref: DNSServiceRef, label: String, context: Unmanaged) -> DispatchSourceRead { let fd = DNSServiceRefSockFD(ref) let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: dnssdQueue) source.setEventHandler { [weak self] in @@ -111,7 +133,8 @@ class AirPlayService { } } source.setCancelHandler { - // Cancel handler intentionally empty — cleanup is in stopAdvertising() + DNSServiceRefDeallocate(ref) + context.release() } source.resume() return source diff --git a/VirtualMirror/AirPlay/NTPTimingServer.swift b/VirtualMirror/AirPlay/NTPTimingServer.swift index 63f75c5..2cc4a6a 100644 --- a/VirtualMirror/AirPlay/NTPTimingServer.swift +++ b/VirtualMirror/AirPlay/NTPTimingServer.swift @@ -6,10 +6,16 @@ import os /// Handles both: /// 1. Responding to timing requests from the iPhone (passive) /// 2. Actively sending timing requests to the iPhone every 3 seconds (active) -class NTPTimingServer { +/// +/// @unchecked Sendable: all mutable state is confined to `stateQueue`. +final class NTPTimingServer: @unchecked Sendable { private let logger = Logger(subsystem: "cloud.souris.virtualmirror", category: "NTPTiming") private var listener: NWListener? private var activeConnection: NWConnection? + /// Inbound flows accepted by the listener (the sender's timing requests). + /// Tracked so `stop()` can cancel them — otherwise each session SETUP + /// (every lock/unlock) would leak the previous session's flows. + private var passiveConnections: [ObjectIdentifier: NWConnection] = [:] private var activeSyncTimer: DispatchSourceTimer? /// Serial queue protecting mutable state (sequenceNumber, connections, timer). private let stateQueue = DispatchQueue(label: "cloud.souris.virtualmirror.ntptiming", qos: .userInteractive) @@ -21,6 +27,10 @@ class NTPTimingServer { } func start() { + stateQueue.sync { _start() } + } + + private func _start() { do { let params = NWParameters.udp params.allowLocalEndpointReuse = true @@ -50,6 +60,10 @@ class NTPTimingServer { /// Start actively sending NTP timing requests to the iPhone's timing port. /// This is critical for keeping the AirPlay session alive. func startActiveSync(remoteHost: NWEndpoint.Host, remotePort: UInt16) { + stateQueue.sync { _startActiveSync(remoteHost: remoteHost, remotePort: remotePort) } + } + + private func _startActiveSync(remoteHost: NWEndpoint.Host, remotePort: UInt16) { logger.info("Starting active NTP sync to \(String(describing: remoteHost)):\(remotePort)") guard let nwPort = NWEndpoint.Port(rawValue: remotePort) else { @@ -88,6 +102,10 @@ class NTPTimingServer { activeConnection = nil listener?.cancel() listener = nil + for conn in passiveConnections.values { + conn.cancel() + } + passiveConnections.removeAll() } } @@ -151,9 +169,16 @@ class NTPTimingServer { // MARK: - Passive NTP (respond to iPhone's requests) private func handleConnection(_ conn: NWConnection) { + let id = ObjectIdentifier(conn) + passiveConnections[id] = conn conn.stateUpdateHandler = { [weak self] state in - if case .ready = state { + switch state { + case .ready: self?.receiveTimingRequest(on: conn) + case .failed, .cancelled: + self?.passiveConnections.removeValue(forKey: id) + default: + break } } conn.start(queue: stateQueue) diff --git a/VirtualMirror/AirPlay/ReceiverIdentity.swift b/VirtualMirror/AirPlay/ReceiverIdentity.swift index 1182451..dc93f18 100644 --- a/VirtualMirror/AirPlay/ReceiverIdentity.swift +++ b/VirtualMirror/AirPlay/ReceiverIdentity.swift @@ -9,7 +9,10 @@ import Security /// Ephemeral by design: a fresh device ID, signing key, and UUIDs are generated /// on creation and live only for the lifetime of this object — nothing is /// persisted, so each receiver is a clean slate every launch. -final class ReceiverIdentity { +/// +/// @unchecked Sendable: everything is immutable except `name`, which is +/// guarded by `nameLock`. +final class ReceiverIdentity: @unchecked Sendable { struct Ports { let airplay: UInt16 let video: UInt16 diff --git a/VirtualMirror/AirPlay/ReceiverRegistry.swift b/VirtualMirror/AirPlay/ReceiverRegistry.swift index 0fad436..dab1f32 100644 --- a/VirtualMirror/AirPlay/ReceiverRegistry.swift +++ b/VirtualMirror/AirPlay/ReceiverRegistry.swift @@ -14,6 +14,11 @@ final class ReceiverRegistry { private(set) var managers: [AirPlayManager] = [] private var usedSlots: Set = [] private var windowMap: [ObjectIdentifier: AirPlayManager] = [:] + /// The receiver whose window was most recently key. Commands invoked while + /// no receiver window is key (the status-bar menu or palette with the app + /// inactive) act on this one rather than on an arbitrary first receiver. + private weak var lastFocusedManager: AirPlayManager? + private var keyObservers: [ObjectIdentifier: NSObjectProtocol] = [:] /// Bridge to SwiftUI's `openWindow`, captured when a window appears. var openWindowAction: (() -> Void)? @@ -45,11 +50,29 @@ final class ReceiverRegistry { manager.stop() usedSlots.remove(manager.identity.slot) managers.removeAll { $0 === manager } + for (key, value) in windowMap where value === manager { + if let observer = keyObservers.removeValue(forKey: key) { + NotificationCenter.default.removeObserver(observer) + } + } windowMap = windowMap.filter { $0.value !== manager } + if lastFocusedManager === manager { lastFocusedManager = nil } } func bind(window: NSWindow, to manager: AirPlayManager) { - windowMap[ObjectIdentifier(window)] = manager + let key = ObjectIdentifier(window) + windowMap[key] = manager + if window.isKeyWindow { lastFocusedManager = manager } + if let old = keyObservers.removeValue(forKey: key) { + NotificationCenter.default.removeObserver(old) + } + keyObservers[key] = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self, weak manager] _ in + MainActor.assumeIsolated { self?.lastFocusedManager = manager } + } } func window(for manager: AirPlayManager) -> NSWindow? { @@ -59,9 +82,11 @@ final class ReceiverRegistry { return nil } - /// The receiver whose window is frontmost (falls back to the only/first one). + /// The receiver whose window is key, else the one most recently key, else + /// the first. var focusedManager: AirPlayManager? { if let key = NSApp.keyWindow, let m = windowMap[ObjectIdentifier(key)] { return m } + if let last = lastFocusedManager, managers.contains(where: { $0 === last }) { return last } return managers.first } diff --git a/VirtualMirror/AirPlayManager.swift b/VirtualMirror/AirPlayManager.swift index 8413a96..cdf3ca9 100644 --- a/VirtualMirror/AirPlayManager.swift +++ b/VirtualMirror/AirPlayManager.swift @@ -1,5 +1,7 @@ import Foundation import SwiftUI +import Network +import dnssd import os enum AirPlayState: Equatable { @@ -59,6 +61,11 @@ class AirPlayManager: ObservableObject { self.displayName = identity.name } + /// The control connection that currently owns the UI state. Session events + /// from any other connection (e.g. one being evicted by a newer sender) are + /// ignored, so a late disconnect can't knock a new session back to idle. + private var activeSession: UUID? + /// Watchdog timer that fires if the state stays in `.connecting` too long. private var connectingTimeoutTask: Task? /// How long to wait in `.connecting` before reverting to `.idle`. @@ -100,14 +107,24 @@ class AirPlayManager: ObservableObject { mirroringStartedAt = nil } + /// Starts listening and advertising. Idempotent — SwiftUI may call it from + /// `onAppear` more than once, and a second listener/advertisement would leak. func start() { + guard airPlayServer == nil else { return } logger.info("Starting AirPlay services for \"\(self.identity.name)\" on port \(self.identity.ports.airplay)") state = .idle + let port = identity.ports.airplay airPlayServer = AirPlayServer(manager: self) - airPlayServer?.start(port: identity.ports.airplay) + airPlayServer?.onFailure = { [weak self] error in + self?.didEncounterError(AirPlayManager.listenerErrorMessage(error, port: port)) + } + airPlayServer?.start(port: port) airPlayService = AirPlayService() + airPlayService?.onFailure = { [weak self] code in + self?.didEncounterError(AirPlayManager.bonjourErrorMessage(code)) + } airPlayService?.startAdvertising(identity: identity) } @@ -130,53 +147,101 @@ class AirPlayManager: ObservableObject { func stop() { logger.info("Stopping AirPlay services") + stopServices() + state = .idle + } + + private func stopServices() { + connectingTimeoutTask?.cancel() stopStatsPolling() + activeSession = nil airPlayService?.stopAdvertising() airPlayServer?.stop() airPlayService = nil airPlayServer = nil - state = .idle } - nonisolated func didStartConnecting(deviceName: String) { + // MARK: - Session events (called from connection queues) + + nonisolated func didStartConnecting(session: UUID, deviceName: String) { Task { @MainActor in + if case .error = self.state { return } + self.activeSession = session self.connectingTimeoutTask?.cancel() self.stopStatsPolling() self.state = .connecting(deviceName) - // Start watchdog — revert to idle if RECORD never arrives + // Watchdog — if RECORD never arrives, drop the stuck handshake so the + // sender gives up cleanly, and go back to waiting. self.connectingTimeoutTask = Task { @MainActor in try? await Task.sleep(for: .seconds(AirPlayManager.connectingTimeout)) - guard !Task.isCancelled, case .connecting = self.state else { return } - self.logger.warning("Connecting timeout — reverting to idle") + guard !Task.isCancelled, case .connecting = self.state, self.activeSession == session else { return } + self.logger.warning("Connecting timeout — closing connection and reverting to idle") + self.airPlayServer?.closeAllConnections() + self.activeSession = nil self.state = .idle } } } - nonisolated func didStartMirroring() { + nonisolated func didStartMirroring(session: UUID, deviceName: String? = nil) { Task { @MainActor in + if case .error = self.state { return } + self.activeSession = session self.connectingTimeoutTask?.cancel() - let name = self.state.deviceName ?? "Unknown" + let name = deviceName ?? self.state.deviceName ?? "Unknown" self.state = .mirroring(name) self.startStatsPolling() } } - nonisolated func didDisconnect() { + /// The sender ended the session (a full TEARDOWN) or its control connection + /// closed. Returns to waiting unless another session has since taken over. + nonisolated func didEndSession(_ session: UUID) { Task { @MainActor in + guard self.activeSession == nil || self.activeSession == session else { return } + if case .error = self.state { return } + self.activeSession = nil self.connectingTimeoutTask?.cancel() self.stopStatsPolling() self.state = .idle - self.videoDecoder.reset() + // The decoder is deliberately not reset here: this runs later, on + // main, and could wipe a stream the same connection has already set + // up again. The connection resets it in order on its own queue. } } + /// A service-level failure (listener or Bonjour). Stops listening and + /// advertising so senders don't see a receiver that can't accept them, and + /// shows the error with a Retry button. nonisolated func didEncounterError(_ message: String) { Task { @MainActor in - self.connectingTimeoutTask?.cancel() - self.stopStatsPolling() + if case .error = self.state { return } + self.logger.error("Receiver error: \(message)") + self.stopServices() self.state = .error(message) } } + + // MARK: - Error messages + + nonisolated static func listenerErrorMessage(_ error: NWError, port: UInt16) -> String { + if case .posix(let code) = error, code == .EADDRINUSE { + return "Port \(port) is already in use. Another copy of VirtualMirror or another AirPlay receiver may be running — quit it and click Retry." + } + if case .dns(let code) = error, code == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { + return localNetworkDeniedMessage + } + return "Couldn't listen for AirPlay connections on port \(port): \(error.localizedDescription)" + } + + nonisolated static func bonjourErrorMessage(_ code: DNSServiceErrorType) -> String { + if code == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { + return localNetworkDeniedMessage + } + return "Couldn't advertise this receiver on the network (Bonjour error \(code))." + } + + nonisolated static let localNetworkDeniedMessage = + "VirtualMirror doesn't have Local Network access, so iPhones and iPads can't find it. Allow it in System Settings › Privacy & Security › Local Network, then click Retry." } diff --git a/VirtualMirror/Audio/AudioPlayer.swift b/VirtualMirror/Audio/AudioPlayer.swift index dab27fa..4aa4f62 100644 --- a/VirtualMirror/Audio/AudioPlayer.swift +++ b/VirtualMirror/Audio/AudioPlayer.swift @@ -1,14 +1,26 @@ import Foundation -import AVFoundation +@preconcurrency import AVFoundation import os /// Plays decoded PCM audio through the default audio output using AVAudioEngine. -class AudioPlayer { +/// +/// Not thread-safe on its own: every call must come from `queue` (the owning +/// AudioStreamReceiver's state queue). Engine configuration-change +/// notifications are hopped onto that queue too. +/// +/// @unchecked Sendable: every call happens on `queue` (see above). +final class AudioPlayer: @unchecked Sendable { private let logger = Logger(subsystem: "cloud.souris.virtualmirror", category: "AudioPlayer") + private let queue: DispatchQueue private var engine: AVAudioEngine? private var playerNode: AVAudioPlayerNode? private var audioFormat: AVAudioFormat? private var isRunning = false + private var configurationObserver: NSObjectProtocol? + + init(queue: DispatchQueue) { + self.queue = queue + } /// Volume level (0.0 = silent, 1.0 = full). Applied to the player node. var volume: Float = 1.0 { @@ -47,6 +59,7 @@ class AudioPlayer { self.engine = eng self.playerNode = node isRunning = true + observeConfigurationChanges(of: eng) logger.info("Audio player started: \(sampleRate) Hz, \(channels) ch, volume=\(self.volume)") } catch { logger.error("Failed to start audio engine: \(error)") @@ -82,7 +95,37 @@ class AudioPlayer { playerNode.scheduleBuffer(buffer, completionHandler: nil) } + /// The engine stops itself when the output device changes (AirPods, a + /// monitor's speakers, unplugging headphones). Restart it so audio carries + /// on instead of staying silent until the next stream SETUP. + private func observeConfigurationChanges(of engine: AVAudioEngine) { + let engineID = ObjectIdentifier(engine) + configurationObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: engine, + queue: nil + ) { [weak self] _ in + guard let self else { return } + self.queue.async { self.restartAfterConfigurationChange(of: engineID) } + } + } + + private func restartAfterConfigurationChange(of changedEngine: ObjectIdentifier) { + guard isRunning, let engine, ObjectIdentifier(engine) == changedEngine, let playerNode else { return } + logger.info("Audio output configuration changed — restarting engine") + do { + try engine.start() + playerNode.play() + } catch { + logger.error("Failed to restart audio engine after configuration change: \(error)") + } + } + func stop() { + if let observer = configurationObserver { + NotificationCenter.default.removeObserver(observer) + configurationObserver = nil + } if isRunning { playerNode?.stop() engine?.stop() diff --git a/VirtualMirror/Audio/AudioStreamReceiver.swift b/VirtualMirror/Audio/AudioStreamReceiver.swift index c06055b..8b69085 100644 --- a/VirtualMirror/Audio/AudioStreamReceiver.swift +++ b/VirtualMirror/Audio/AudioStreamReceiver.swift @@ -17,12 +17,16 @@ import os /// - Control socket (port 47104): receives sync/timing packets from the iPhone /// and sends resend requests back. The iPhone sends type 0x54 sync packets here /// before real audio data flows on the data port. -class AudioStreamReceiver { +/// +/// @unchecked Sendable: all mutable state is confined to `stateQueue`. +final class AudioStreamReceiver: @unchecked Sendable { private let logger = Logger(subsystem: "cloud.souris.virtualmirror", category: "AudioStream") - /// Serial queue for all audio stream state (packet processing, dedup, sync). - /// Both data and control dispatch sources dispatch onto this queue. - private let stateQueue = DispatchQueue(label: "cloud.souris.virtualmirror.audiostream", qos: .userInteractive) + /// Serial queue for all audio stream state (packet processing, dedup, sync, + /// decoder and player). Both dispatch sources run on it, and the public entry + /// points (called from the control connection) hop onto it, so SETUP/TEARDOWN + /// can't dispose the decoder or engine while a packet is being processed. + private let stateQueue: DispatchQueue // Data socket (receives RTP audio packets) private var dataSocket: Int32 = -1 @@ -35,12 +39,19 @@ class AudioStreamReceiver { let controlPort: UInt16 private let audioDecoder = AudioDecoder() - private let audioPlayer = AudioPlayer() + private let audioPlayer: AudioPlayer + + /// Whether incoming packets should be processed. Cleared on reset/stop so + /// packets that arrive between sessions are drained and dropped. The read + /// sources themselves live as long as their sockets: a source must be + /// cancelled (and its cancel handler run) before its fd may be closed. + private var streamActive = false /// Volume level (0.0 = silent, 1.0 = full). Forwarded to AudioPlayer. var volume: Float = 1.0 { didSet { - audioPlayer.volume = volume + let volume = volume + stateQueue.async { self.audioPlayer.volume = volume } } } @@ -56,7 +67,7 @@ class AudioStreamReceiver { private var packetCount = 0 private var controlPacketCount = 0 - + // Sync state — the iPhone sends type 0x54 sync packets on the control port. // UxPlay gates audio playback on receiving the first sync. private var initialSyncReceived = false @@ -82,6 +93,9 @@ class AudioStreamReceiver { init(port: UInt16, controlPort: UInt16) { self.port = port self.controlPort = controlPort + let queue = DispatchQueue(label: "cloud.souris.virtualmirror.audiostream", qos: .userInteractive) + self.stateQueue = queue + self.audioPlayer = AudioPlayer(queue: queue) } /// Configures the audio stream from the SETUP plist stream dictionary. @@ -89,6 +103,12 @@ class AudioStreamReceiver { /// key = SHA-512(fairplayKey || ecdhSecret)[0..15] /// iv = eiv from session SETUP (raw, 16 bytes) func configure(streamInfo: [String: Any], fairplayKey: Data?, ecdhSecret: Data?, eiv: Data?) { + stateQueue.sync { + _configure(streamInfo: streamInfo, fairplayKey: fairplayKey, ecdhSecret: ecdhSecret, eiv: eiv) + } + } + + private func _configure(streamInfo: [String: Any], fairplayKey: Data?, ecdhSecret: Data?, eiv: Data?) { // Extract audio parameters if let ct = streamInfo["ct"] as? Int { logger.debug("Audio compression type: \(ct)") @@ -139,12 +159,13 @@ class AudioStreamReceiver { } /// Resets the stream for a new session (rotation/reconnect). - /// Keeps sockets alive but resets dispatch sources, decoder, and player. + /// Keeps sockets alive but resets stream state, decoder, and player. func resetStream() { - dataReadSource?.cancel() - dataReadSource = nil - controlReadSource?.cancel() - controlReadSource = nil + stateQueue.sync { _resetStream() } + } + + private func _resetStream() { + streamActive = false packetCount = 0 controlPacketCount = 0 initialSyncReceived = false @@ -163,8 +184,11 @@ class AudioStreamReceiver { /// Starts both UDP socket listeners. Idempotent — no-ops if already running. func start() { - startDataSocket() - startControlSocket() + stateQueue.sync { + startDataSocket() + startControlSocket() + streamActive = true + } } private func startDataSocket() { @@ -259,8 +283,9 @@ class AudioStreamReceiver { fileDescriptor: dataSocket, queue: stateQueue ) + let fd = dataSocket source.setEventHandler { [weak self] in self?.readDataPackets() } - source.setCancelHandler { [weak self] in self?.logger.debug("Audio data dispatch source cancelled") } + source.setCancelHandler { Darwin.close(fd) } source.resume() dataReadSource = source } @@ -271,8 +296,9 @@ class AudioStreamReceiver { fileDescriptor: controlSocket, queue: stateQueue ) + let fd = controlSocket source.setEventHandler { [weak self] in self?.readControlPackets() } - source.setCancelHandler { [weak self] in self?.logger.debug("Audio control dispatch source cancelled") } + source.setCancelHandler { Darwin.close(fd) } source.resume() controlReadSource = source } @@ -297,6 +323,7 @@ class AudioStreamReceiver { } break } + guard streamActive else { continue } let data = Data(buffer.prefix(bytesRead)) processRTPPacket(data) } @@ -315,6 +342,7 @@ class AudioStreamReceiver { } } guard bytesRead > 0 else { break } + guard streamActive else { continue } // Learn the remote control address from the first packet if remoteControlAddr == nil { @@ -330,18 +358,27 @@ class AudioStreamReceiver { /// Full shutdown: stops sockets, dispatch sources, decoder, and player. func stop() { - dataReadSource?.cancel() - dataReadSource = nil - controlReadSource?.cancel() - controlReadSource = nil - if dataSocket >= 0 { + stateQueue.sync { _stop() } + } + + private func _stop() { + streamActive = false + // Each source's cancel handler closes its socket; only close directly + // when no source was ever attached to it. + if let source = dataReadSource { + source.cancel() + } else if dataSocket >= 0 { Darwin.close(dataSocket) - dataSocket = -1 } - if controlSocket >= 0 { + if let source = controlReadSource { + source.cancel() + } else if controlSocket >= 0 { Darwin.close(controlSocket) - controlSocket = -1 } + dataReadSource = nil + controlReadSource = nil + dataSocket = -1 + controlSocket = -1 audioPlayer.stop() audioDecoder.stop() decryptKey = nil diff --git a/VirtualMirror/ContentView.swift b/VirtualMirror/ContentView.swift index c9859f6..9ab0ba2 100644 --- a/VirtualMirror/ContentView.swift +++ b/VirtualMirror/ContentView.swift @@ -34,7 +34,7 @@ struct ContentView: View { .foregroundColor(.white) } - case .mirroring(_): + case .mirroring: MirroringView(airPlayManager: airPlayManager) case .error(let message): diff --git a/VirtualMirror/Support/CommandPalette.swift b/VirtualMirror/Support/CommandPalette.swift index 6677b6a..4d1833f 100644 --- a/VirtualMirror/Support/CommandPalette.swift +++ b/VirtualMirror/Support/CommandPalette.swift @@ -50,8 +50,11 @@ enum CommandMatcher { var s = 0 let lowTitle = title.lowercased(), lowNeedle = needle.lowercased() - if lowTitle.hasPrefix(lowNeedle) { s += 200 - (title.count - needle.count) } - else if lowTitle.contains(lowNeedle) { s += 80 } + if lowTitle.hasPrefix(lowNeedle) { + s += 200 - (title.count - needle.count) + } else if lowTitle.contains(lowNeedle) { + s += 80 + } var ni2 = 0, hi2 = 0, run = 0 var prev: Character = " " diff --git a/VirtualMirror/Support/GitHubFeedback.swift b/VirtualMirror/Support/GitHubFeedback.swift index cc5772e..ed840ec 100644 --- a/VirtualMirror/Support/GitHubFeedback.swift +++ b/VirtualMirror/Support/GitHubFeedback.swift @@ -98,6 +98,6 @@ enum GitHubFeedback { URL(string: "https://github.com/\(repo)/issues") } - static let kofiURL = URL(string: "https://ko-fi.com/souriscloud")! - static let repoURL = URL(string: "https://github.com/\(repo)")! + static let kofiURL = URL(string: "https://ko-fi.com/souriscloud")! // swiftlint:disable:this force_unwrapping + static let repoURL = URL(string: "https://github.com/\(repo)")! // swiftlint:disable:this force_unwrapping } diff --git a/VirtualMirror/Support/HelpView.swift b/VirtualMirror/Support/HelpView.swift index c5a9a86..dd8b170 100644 --- a/VirtualMirror/Support/HelpView.swift +++ b/VirtualMirror/Support/HelpView.swift @@ -83,7 +83,7 @@ enum HelpTopic: String, CaseIterable, Identifiable { private struct HelpHeader: View { let icon: String let title: String - var subtitle: String? = nil + var subtitle: String? var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 10) { @@ -138,7 +138,7 @@ private struct StepRow: View { } } -private func Para(_ text: String) -> some View { +private func para(_ text: String) -> some View { Text(text) .font(.callout) .foregroundStyle(.secondary) @@ -178,7 +178,7 @@ private struct HelpWelcome: View { VStack(alignment: .leading, spacing: 22) { HelpHeader(icon: "sparkles", title: "Welcome to VirtualMirror", subtitle: "AirPlay screen mirroring for your Mac.") - Para("VirtualMirror turns your Mac into an AirPlay receiver — mirror an iPhone or iPad screen to a native window, no Apple TV required. It runs quietly in the menu bar and shows live connection status.") + para("VirtualMirror turns your Mac into an AirPlay receiver — mirror an iPhone or iPad screen to a native window, no Apple TV required. It runs quietly in the menu bar and shows live connection status.") VStack(alignment: .leading, spacing: 14) { HelpPoint(icon: "iphone.gen3", lead: "Mirror any iPhone or iPad", body_: "Pick VirtualMirror from Screen Mirroring in Control Center and your device appears in a window on the Mac.") @@ -203,7 +203,7 @@ private struct HelpMirroring: View { var body: some View { VStack(alignment: .leading, spacing: 22) { HelpHeader(icon: "iphone.gen3", title: "Start Mirroring") - Para("Make sure your iPhone/iPad and this Mac are on the same Wi-Fi network, then:") + para("Make sure your iPhone/iPad and this Mac are on the same Wi-Fi network, then:") VStack(alignment: .leading, spacing: 12) { StepRow(number: 1, text: "Launch VirtualMirror — it appears in the menu bar and shows a waiting screen.") StepRow(number: 2, text: "On your iPhone or iPad, open Control Center.") @@ -225,7 +225,7 @@ private struct HelpAudio: View { HelpHeader(icon: "speaker.wave.2.fill", title: "Audio & Volume") VStack(alignment: .leading, spacing: 14) { HelpPoint(icon: "speaker.wave.2.fill", lead: "Audio passthrough", - body_: "Sound from the mirrored device plays through your Mac's current output.") + body_: "Sound from the mirrored device plays through your Mac's current output — and follows along if you switch it (AirPods, speakers, headphones) mid-session.") HelpPoint(icon: "slider.horizontal.3", lead: "Volume slider", body_: "Hover over the mirroring window to reveal the volume slider.") HelpPoint(icon: "speaker.slash.fill", lead: "Mute", @@ -264,12 +264,16 @@ private struct HelpTroubleshooting: View { VStack(alignment: .leading, spacing: 14) { HelpPoint(icon: "airplayvideo", lead: "VirtualMirror isn't in the list", body_: "macOS has its own built-in AirPlay Receiver. If it's on, your device may connect to it instead. Turn it off in System Settings → General → AirDrop & Handoff → AirPlay Receiver.") + HelpPoint(icon: "exclamationmark.triangle", lead: "The window shows an error", + body_: "If a receiver can't start — its port is taken by another copy of VirtualMirror or another AirPlay receiver, or it's missing Local Network access — the window says why. Fix the cause, then click Retry.") HelpPoint(icon: "wifi", lead: "Won't connect or keeps dropping", - body_: "Confirm both devices are on the same Wi-Fi network. Restart VirtualMirror (Quit from the menu bar, then relaunch).") + body_: "Confirm both devices are on the same Wi-Fi network. If it's still stuck, use Restart This Receiver from the command palette (⌘K), or quit from the menu bar and relaunch.") + HelpPoint(icon: "iphone.and.arrow.forward", lead: "Another device took over", + body_: "Each receiver shows one device at a time, so a second device mirroring to the same receiver replaces the first. To show both, open another receiver with ⌘N.") HelpPoint(icon: "lock.shield", lead: "Blocked by a firewall", - body_: "VirtualMirror listens on port 47000. If you run a firewall, allow incoming connections to it.") + body_: "The first receiver listens on port 47000, and each additional one 1000 higher (48000, 49000, …). If you run a firewall, allow incoming connections to VirtualMirror.") HelpPoint(icon: "dot.radiowaves.left.and.right", lead: "Local network permission", - body_: "The first time, macOS may ask VirtualMirror for permission to find devices on your local network — allow it so AirPlay discovery works.") + body_: "The first time, macOS asks VirtualMirror for permission to find devices on your local network — allow it so AirPlay discovery works. If you declined, turn it on in System Settings → Privacy & Security → Local Network, then click Retry.") } } } @@ -295,7 +299,7 @@ private struct HelpPrivacy: View { var body: some View { VStack(alignment: .leading, spacing: 22) { HelpHeader(icon: "lock.shield", title: "Privacy & Security") - Para("VirtualMirror is a local AirPlay receiver. The video and audio stream goes straight from your device to this Mac over your network — there's no cloud, no account, and no telemetry.") + para("VirtualMirror is a local AirPlay receiver. The video and audio stream goes straight from your device to this Mac over your network — there's no cloud, no account, and no telemetry.") VStack(alignment: .leading, spacing: 14) { HelpPoint(icon: "person.badge.shield.checkmark", lead: "Fresh identity per receiver", body_: "Each receiver generates its own pairing identity on the spot — nothing is persisted, so it's a clean slate every launch.") @@ -312,7 +316,7 @@ private struct HelpSupport: View { var body: some View { VStack(alignment: .leading, spacing: 22) { HelpHeader(icon: "heart.fill", title: "Support & Feedback") - Para("VirtualMirror is free and open source, built by one person. Bug reports and ideas genuinely shape it — and if it saves you time, a coffee keeps it going.") + para("VirtualMirror is free and open source, built by one person. Bug reports and ideas genuinely shape it — and if it saves you time, a coffee keeps it going.") VStack(alignment: .leading, spacing: 12) { Button { FeedbackWindowController.shared.showWindow() diff --git a/VirtualMirror/Video/MirrorStreamReceiver.swift b/VirtualMirror/Video/MirrorStreamReceiver.swift index fb8c0b6..cf3a426 100644 --- a/VirtualMirror/Video/MirrorStreamReceiver.swift +++ b/VirtualMirror/Video/MirrorStreamReceiver.swift @@ -2,7 +2,8 @@ import Foundation import Network import os -class MirrorStreamReceiver { +// @unchecked Sendable: all mutable state is confined to `queue`. +final class MirrorStreamReceiver: @unchecked Sendable { private let logger = Logger(subsystem: "cloud.souris.virtualmirror", category: "MirrorStream") private var listener: NWListener? private var connection: NWConnection? @@ -11,6 +12,12 @@ class MirrorStreamReceiver { private var videoDecryptor = VideoDecryptor() private var buffer = Data() + /// All state above is owned by this queue: the listener, the data connection + /// and its receive callbacks run on it, and the public entry points (called + /// from the control connection's queue) hop onto it. This keeps SETUP/TEARDOWN + /// from swapping the decryptor or buffer out from under an in-flight packet. + private let queue = DispatchQueue(label: "cloud.souris.virtualmirror.mirror-stream", qos: .userInteractive) + // Packet header size private static let headerSize = 128 /// Upper bound on a single video payload. A larger value means our header @@ -35,9 +42,12 @@ class MirrorStreamReceiver { self.videoDecoder = videoDecoder } - /// Configures encryption keys for the current stream. - /// Called during SETUP — no concurrent streaming at this point. + /// Configures encryption keys for the current stream (during SETUP). func configureEncryption(fairplayKey: Data?, ecdhSecret: Data?, streamConnectionID: UInt64) { + queue.sync { _configureEncryption(fairplayKey: fairplayKey, ecdhSecret: ecdhSecret, streamConnectionID: streamConnectionID) } + } + + private func _configureEncryption(fairplayKey: Data?, ecdhSecret: Data?, streamConnectionID: UInt64) { if let fpKey = fairplayKey, let ecdh = ecdhSecret { let (derivedKey, derivedIV) = StreamKeyDerivation.deriveMirrorKeys(fairplayKey: fpKey, ecdhSecret: ecdh, streamConnectionID: streamConnectionID) currentDecryptKey = derivedKey @@ -65,8 +75,12 @@ class MirrorStreamReceiver { /// Resets the stream for a new session (rotation/reconnect). /// Closes the current data connection and clears the buffer, but /// keeps the listener alive so the new connection can bind immediately. - /// Called during TEARDOWN/SETUP — no concurrent streaming at this point. + /// Called during TEARDOWN/SETUP. func resetStream() { + queue.sync { _resetStream() } + } + + private func _resetStream() { connectionGeneration &+= 1 connection?.cancel() connection = nil @@ -79,6 +93,10 @@ class MirrorStreamReceiver { } func start() { + queue.sync { _start() } + } + + private func _start() { guard listener == nil else { logger.debug("Mirror stream listener already running on port \(self.port)") return @@ -112,10 +130,15 @@ class MirrorStreamReceiver { self?.handleMirrorConnection(nwConnection) } - listener?.start(queue: .global(qos: .userInteractive)) + listener?.start(queue: queue) } func stop() { + queue.sync { _stop() } + } + + private func _stop() { + connectionGeneration &+= 1 connection?.cancel() listener?.cancel() connection = nil @@ -154,7 +177,7 @@ class MirrorStreamReceiver { } } - nwConnection.start(queue: .global(qos: .userInteractive)) + nwConnection.start(queue: queue) } private func receiveData(generation: UInt64) { diff --git a/VirtualMirror/Video/VideoDecoder.swift b/VirtualMirror/Video/VideoDecoder.swift index 01feb3e..6d28f4e 100644 --- a/VirtualMirror/Video/VideoDecoder.swift +++ b/VirtualMirror/Video/VideoDecoder.swift @@ -3,7 +3,10 @@ import VideoToolbox import CoreMedia import os -class VideoDecoder: ObservableObject { +// @unchecked Sendable: the decompression session state is guarded by +// `sessionLock`, stats and the error counter by `statsLock`, and +// `latestSampleBuffer` is only written on the main thread. +final class VideoDecoder: ObservableObject, @unchecked Sendable { private let logger = Logger(subsystem: "cloud.souris.virtualmirror", category: "VideoDecoder") private var decompressionSession: VTDecompressionSession? @@ -16,6 +19,13 @@ class VideoDecoder: ObservableObject { private var lastSPS: Data? private var lastPPS: Data? + /// Guards the session state above. `reset()` runs on the control connection + /// (and on main at disconnect) while `configureWithAVCC`/`decodeVideoData` + /// run on the mirror stream's queue; without this, a reset could invalidate + /// the session mid-decode. The VT output callback doesn't take this lock, so + /// holding it across `VTDecompressionSessionDecodeFrame` can't deadlock. + private let sessionLock = NSLock() + // Output: decoded sample buffers for display @Published var latestSampleBuffer: CMSampleBuffer? @@ -32,6 +42,7 @@ class VideoDecoder: ObservableObject { /// Resets the decoder state so it can be reconfigured with new codec parameters. /// Called when the mirroring session reconnects (e.g. after rotation or lock/unlock). func reset() { + sessionLock.lock(); defer { sessionLock.unlock() } if let session = decompressionSession { VTDecompressionSessionInvalidate(session) decompressionSession = nil @@ -41,8 +52,7 @@ class VideoDecoder: ObservableObject { lastSPS = nil lastPPS = nil frameCount = 0 - decodeErrorCount = 0 - statsLock.lock(); stats = Stats(); statsLock.unlock() + statsLock.lock(); stats = Stats(); decodeErrorCount = 0; statsLock.unlock() DispatchQueue.main.async { [weak self] in self?.latestSampleBuffer = nil } @@ -50,8 +60,9 @@ class VideoDecoder: ObservableObject { } func configureWithAVCC(_ avccData: Data) { + sessionLock.lock(); defer { sessionLock.unlock() } // Reset error counter so first errors after codec change are always logged - decodeErrorCount = 0 + statsLock.lock(); decodeErrorCount = 0; statsLock.unlock() // Parse avcC (ISO 14496-15) format: // byte 0: version (1) @@ -198,9 +209,18 @@ class VideoDecoder: ObservableObject { } private var frameCount = 0 + /// Log throttling only. Touched from the VT output callback's thread as + /// well as the decode path, so it lives under `statsLock`. private var decodeErrorCount = 0 + private func countDecodeError() -> Int { + statsLock.lock(); defer { statsLock.unlock() } + decodeErrorCount += 1 + return decodeErrorCount + } + func decodeVideoData(_ data: Data, timestamp: UInt64) { + sessionLock.lock(); defer { sessionLock.unlock() } guard let session = decompressionSession, let formatDesc = formatDescription else { logger.warning("Decoder not ready (session=\(self.decompressionSession != nil), fmt=\(self.formatDescription != nil)), dropping \(data.count) bytes") return @@ -281,25 +301,23 @@ class VideoDecoder: ObservableObject { guard let self = self else { return } guard status == noErr, let pixelBuffer = pixelBuffer else { if status != noErr { - // Benign race on decodeErrorCount from VT's callback thread — - // only used for log throttling, not correctness. - self.decodeErrorCount += 1 - if self.decodeErrorCount <= 5 || self.decodeErrorCount % 100 == 0 { - self.logger.error("Decode error: \(status) (count: \(self.decodeErrorCount))") + let count = self.countDecodeError() + if count <= 5 || count % 100 == 0 { + self.logger.error("Decode error: \(status) (count: \(count))") } } return } - self.decodeErrorCount = 0 + self.statsLock.lock(); self.decodeErrorCount = 0; self.statsLock.unlock() // Create a new CMSampleBuffer from the decoded pixel buffer for display self.createDisplaySampleBuffer(from: pixelBuffer, time: presentationTime) } if decodeStatus != noErr { - decodeErrorCount += 1 - if decodeErrorCount <= 5 || decodeErrorCount % 100 == 0 { - logger.error("VTDecompressionSessionDecodeFrame failed: \(decodeStatus) (count: \(self.decodeErrorCount))") + let count = countDecodeError() + if count <= 5 || count % 100 == 0 { + logger.error("VTDecompressionSessionDecodeFrame failed: \(decodeStatus) (count: \(count))") } } } @@ -334,9 +352,11 @@ class VideoDecoder: ObservableObject { guard let buffer = sampleBuffer else { return } - // Publish on main thread for SwiftUI + // Publish on main thread for SwiftUI. The buffer is fully built and + // never mutated after this point, so handing it across is safe. + nonisolated(unsafe) let frame = buffer DispatchQueue.main.async { [weak self] in - self?.latestSampleBuffer = buffer + self?.latestSampleBuffer = frame } } diff --git a/VirtualMirror/VirtualMirrorApp.swift b/VirtualMirror/VirtualMirrorApp.swift index 78e9774..602657e 100644 --- a/VirtualMirror/VirtualMirrorApp.swift +++ b/VirtualMirror/VirtualMirrorApp.swift @@ -232,6 +232,7 @@ extension AppDelegate: NSMenuDelegate { // MARK: - About Window Controller +@MainActor class AboutWindowController { static let shared = AboutWindowController() private var window: NSWindow? diff --git a/VirtualMirrorTests/HTTPResponseTests.swift b/VirtualMirrorTests/HTTPResponseTests.swift index 4fc8790..e9f7ea0 100644 --- a/VirtualMirrorTests/HTTPResponseTests.swift +++ b/VirtualMirrorTests/HTTPResponseTests.swift @@ -3,45 +3,45 @@ import XCTest final class HTTPResponseTests: XCTestCase { - func testOkResponseContainsStatus200() { + func testOkResponseContainsStatus200() throws { let response = HTTPResponse.ok(cseq: "1") - let str = String(data: response, encoding: .utf8)! + let str = try XCTUnwrap(String(data: response, encoding: .utf8)) XCTAssertTrue(str.hasPrefix("HTTP/1.1 200 OK\r\n")) XCTAssertTrue(str.contains("CSeq: 1")) XCTAssertTrue(str.contains("Server: AirTunes/220.68")) } - func testRTSPResponse() { + func testRTSPResponse() throws { let response = HTTPResponse.ok(cseq: "5", isRTSP: true) - let str = String(data: response, encoding: .utf8)! + let str = try XCTUnwrap(String(data: response, encoding: .utf8)) XCTAssertTrue(str.hasPrefix("RTSP/1.0 200 OK\r\n")) } - func testResponseWithBody() { + func testResponseWithBody() throws { let body = Data("test body".utf8) let response = HTTPResponse.ok(cseq: "2", body: body, contentType: "text/plain") - let str = String(data: response, encoding: .utf8)! + let str = try XCTUnwrap(String(data: response, encoding: .utf8)) XCTAssertTrue(str.contains("Content-Length: 9")) XCTAssertTrue(str.contains("Content-Type: text/plain")) XCTAssertTrue(str.hasSuffix("test body")) } - func testBplistResponse() { + func testBplistResponse() throws { let body = Data([0x62, 0x70, 0x6C, 0x69, 0x73, 0x74]) // "bplist" let response = HTTPResponse.okBplist(cseq: "3", body: body) - let str = String(data: response, encoding: .utf8)! + let str = try XCTUnwrap(String(data: response, encoding: .utf8)) XCTAssertTrue(str.contains("Content-Type: application/x-apple-binary-plist")) } - func testErrorResponse() { + func testErrorResponse() throws { let response = HTTPResponse.build(status: 400, statusText: "Bad Request", cseq: "4") - let str = String(data: response, encoding: .utf8)! + let str = try XCTUnwrap(String(data: response, encoding: .utf8)) XCTAssertTrue(str.hasPrefix("HTTP/1.1 400 Bad Request\r\n")) } - func testResponseWithoutCseq() { + func testResponseWithoutCseq() throws { let response = HTTPResponse.ok() - let str = String(data: response, encoding: .utf8)! + let str = try XCTUnwrap(String(data: response, encoding: .utf8)) XCTAssertFalse(str.contains("CSeq")) } } diff --git a/VirtualMirrorTests/PairVerifyHandlerTests.swift b/VirtualMirrorTests/PairVerifyHandlerTests.swift index d409a9b..fab183f 100644 --- a/VirtualMirrorTests/PairVerifyHandlerTests.swift +++ b/VirtualMirrorTests/PairVerifyHandlerTests.swift @@ -31,7 +31,7 @@ final class PairVerifyHandlerTests: XCTestCase { XCTAssertNotNil(handler.derivedSharedSecret, "ECDH shared secret should be available after stage 1") } - func testStage1SharedSecretMatchesClientSide() { + func testStage1SharedSecretMatchesClientSide() throws { let handler = PairVerifyHandler(identity: ReceiverIdentity(slot: 0, name: "Test")) let clientPriv = Curve25519.KeyAgreement.PrivateKey() @@ -41,8 +41,8 @@ final class PairVerifyHandlerTests: XCTestCase { // The first 32 bytes of the response are the server's ephemeral public key. let serverPubData = response.prefix(32) - let serverPub = try! Curve25519.KeyAgreement.PublicKey(rawRepresentation: serverPubData) - let clientSideSecret = try! clientPriv.sharedSecretFromKeyAgreement(with: serverPub) + let serverPub = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: serverPubData) + let clientSideSecret = try clientPriv.sharedSecretFromKeyAgreement(with: serverPub) let clientSideBytes = clientSideSecret.withUnsafeBytes { Data($0) } XCTAssertEqual(handler.derivedSharedSecret, clientSideBytes, diff --git a/VirtualMirrorTests/SessionLifecycleTests.swift b/VirtualMirrorTests/SessionLifecycleTests.swift new file mode 100644 index 0000000..0bd0c32 --- /dev/null +++ b/VirtualMirrorTests/SessionLifecycleTests.swift @@ -0,0 +1,216 @@ +import XCTest +import Network +import os +import dnssd +@testable import VirtualMirror + +/// Session lifecycle: the UI must always return to waiting when a session ends, +/// and events from a replaced connection must not clobber the current one. +@MainActor +final class SessionLifecycleTests: XCTestCase { + + // MARK: - Manager session ownership + + func testEndOfSessionReturnsToIdle() async throws { + let manager = makeManager() + let session = UUID() + manager.didStartConnecting(session: session, deviceName: "iPhone") + try await waitForState(manager, .connecting("iPhone")) + manager.didStartMirroring(session: session) + try await waitForState(manager, .mirroring("iPhone")) + manager.didEndSession(session) + try await waitForState(manager, .idle) + } + + func testEndOfReplacedSessionIsIgnored() async throws { + let manager = makeManager() + let old = UUID(), new = UUID() + manager.didStartConnecting(session: old, deviceName: "Old") + manager.didStartConnecting(session: new, deviceName: "New") + try await waitForState(manager, .connecting("New")) + + manager.didEndSession(old) + manager.didStartMirroring(session: new) + try await waitForState(manager, .mirroring("New")) + } + + func testErrorIsNotClobberedBySessionEvents() async throws { + let manager = makeManager() + let session = UUID() + manager.didEncounterError("boom") + try await waitForState(manager, .error("boom")) + + manager.didStartConnecting(session: session, deviceName: "iPhone") + manager.didEndSession(session) + try await settle() + XCTAssertEqual(manager.state, .error("boom")) + } + + // MARK: - TEARDOWN scope + + func testTeardownWithoutBodyEndsSession() { + XCTAssertEqual(AirPlayConnection.teardownScope(body: Data()), .session) + } + + func testTeardownWithoutStreamListEndsSession() throws { + // iOS 27 ends mirroring with a TEARDOWN that names no stream type. + let body = try bplist(["foo": 1]) + XCTAssertEqual(AirPlayConnection.teardownScope(body: body), .session) + } + + func testTeardownWithStreamsIsSelective() throws { + let body = try bplist(["streams": [["type": 110], ["type": 96]]]) + XCTAssertEqual(AirPlayConnection.teardownScope(body: body), .streams([110, 96])) + } + + // MARK: - Volume + + func testAirPlayVolumeMapping() { + XCTAssertEqual(AirPlayConnection.airPlayVolume(fromLinear: 1), 0) + XCTAssertEqual(AirPlayConnection.airPlayVolume(fromLinear: 0), -144) + XCTAssertEqual(AirPlayConnection.airPlayVolume(fromLinear: 0.5), -6.0206, accuracy: 0.001) + XCTAssertEqual(AirPlayConnection.airPlayVolume(fromLinear: 0.001), -30) + } + + // MARK: - Error messages + + func testPortInUseMessageNamesThePort() { + let message = AirPlayManager.listenerErrorMessage(.posix(.EADDRINUSE), port: 47000) + XCTAssertTrue(message.contains("47000"), message) + XCTAssertTrue(message.contains("already in use"), message) + } + + func testPolicyDeniedMentionsLocalNetwork() { + let code = DNSServiceErrorType(kDNSServiceErr_PolicyDenied) + XCTAssertEqual(AirPlayManager.bonjourErrorMessage(code), AirPlayManager.localNetworkDeniedMessage) + XCTAssertEqual(AirPlayManager.listenerErrorMessage(.dns(code), port: 1), AirPlayManager.localNetworkDeniedMessage) + } + + // MARK: - End to end over a real listener + + /// A second sender evicts the first; the evicted session must be reported + /// as ended (it used to leave the UI stuck), and a typeless TEARDOWN from + /// the new sender returns to idle while its connection stays open. + func testEvictionAndTeardownOverTheWire() async throws { + let manager = AirPlayManager(identity: ReceiverIdentity(slot: 7, name: "Test Receiver")) + let server = AirPlayServer(manager: manager) + let port = manager.identity.ports.airplay + server.start(port: port) + defer { server.stop() } + try await settle() + + let phoneA = try await RTSPClient.connect(port: port) + defer { phoneA.close() } + phoneA.send(try RTSPClient.setup(name: "Phone A", cseq: 1)) + try await waitForState(manager, .connecting("Phone A")) + + let phoneB = try await RTSPClient.connect(port: port) + defer { phoneB.close() } + try await waitForState(manager, .idle) + + phoneB.send(try RTSPClient.setup(name: "Phone B", cseq: 1)) + try await waitForState(manager, .connecting("Phone B")) + phoneB.send(RTSPClient.request("RECORD", cseq: 2)) + try await waitForState(manager, .mirroring("Phone B")) + phoneB.send(RTSPClient.request("TEARDOWN", cseq: 3)) + try await waitForState(manager, .idle) + + // The sender re-establishes the mirror stream on the same connection + // without another RECORD: the UI resumes mirroring. + let streams = try bplist(["streams": [["type": 110, "streamConnectionID": 1]]]) + phoneB.send(RTSPClient.request("SETUP", cseq: 4, body: streams, contentType: "application/x-apple-binary-plist")) + try await waitForState(manager, .mirroring("Phone B")) + } + + // MARK: - Helpers + + private func makeManager() -> AirPlayManager { + AirPlayManager(identity: ReceiverIdentity(slot: 6, name: "Unit")) + } + + private func bplist(_ object: Any) throws -> Data { + try PropertyListSerialization.data(fromPropertyList: object, format: .binary, options: 0) + } + + /// Lets queued `Task { @MainActor in … }` hops run. + private func settle() async throws { + try await Task.sleep(for: .milliseconds(100)) + } + + private func waitForState(_ manager: AirPlayManager, _ expected: AirPlayState, + timeout: Duration = .seconds(5), + file: StaticString = #filePath, line: UInt = #line) async throws { + let deadline = ContinuousClock.now + timeout + while manager.state != expected { + if ContinuousClock.now > deadline { + XCTFail("Timed out waiting for \(expected); state is \(manager.state)", file: file, line: line) + return + } + try await Task.sleep(for: .milliseconds(10)) + } + } +} + +/// Minimal RTSP sender for driving AirPlayConnection over loopback. +private final class RTSPClient: Sendable { + let connection: NWConnection + + private init(connection: NWConnection) { + self.connection = connection + } + + static func connect(port: UInt16) async throws -> RTSPClient { + let connection = NWConnection(host: "127.0.0.1", port: try XCTUnwrap(NWEndpoint.Port(rawValue: port)), using: .tcp) + let client = RTSPClient(connection: connection) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Only one of ready/failed/waiting may resume the continuation. + let pending = OSAllocatedUnfairLock(initialState: Optional(continuation)) + connection.stateUpdateHandler = { state in + switch state { + case .ready: + pending.withLock { $0.take() }?.resume() + case .failed(let error), .waiting(let error): + pending.withLock { $0.take() }?.resume(throwing: error) + default: + break + } + } + connection.start(queue: DispatchQueue(label: "test.rtsp-client")) + } + client.drain() + return client + } + + /// Keep reading so responses don't back up; their content isn't asserted. + private func drain() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] _, _, isComplete, error in + guard !isComplete, error == nil else { return } + self?.drain() + } + } + + func send(_ data: Data) { + connection.send(content: data, completion: .contentProcessed { _ in }) + } + + func close() { + connection.cancel() + } + + static func request(_ method: String, cseq: Int, body: Data = Data(), contentType: String? = nil) -> Data { + var head = "\(method) rtsp://127.0.0.1/1 RTSP/1.0\r\nCSeq: \(cseq)\r\n" + if let contentType { head += "Content-Type: \(contentType)\r\n" } + if !body.isEmpty { head += "Content-Length: \(body.count)\r\n" } + head += "\r\n" + return Data(head.utf8) + body + } + + /// A session SETUP carrying the sender's name (and an eiv, which marks it as + /// the session phase) — enough to move the receiver to `.connecting`. + static func setup(name: String, cseq: Int) throws -> Data { + let body = try PropertyListSerialization.data( + fromPropertyList: ["eiv": Data(repeating: 0, count: 16), "name": name], + format: .binary, options: 0) + return request("SETUP", cseq: cseq, body: body, contentType: "application/x-apple-binary-plist") + } +} diff --git a/appcast.xml b/appcast.xml index ff26729..28db691 100644 --- a/appcast.xml +++ b/appcast.xml @@ -23,13 +23,7 @@ 302 0.3.2 14.0 - - - - - - - + 0.3.1 @@ -37,12 +31,7 @@ 301 0.3.1 14.0 - - - - - - + \ No newline at end of file diff --git a/scripts/appcast-tool.py b/scripts/appcast-tool.py new file mode 100755 index 0000000..73d4555 --- /dev/null +++ b/scripts/appcast-tool.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +appcast-tool.py — Normalize and verify the Sparkle appcast. + +Usage: + scripts/appcast-tool.py fix + scripts/appcast-tool.py check [--online] + +Why this exists: generate_appcast rewrites *every* item's download URL with +the single --download-url-prefix passed for the newest release, so older DMGs +end up pointing at a tag that doesn't contain them. It also emits deltas for +older items that were never uploaded. Only the newest item's deltas are ever +used by Sparkle (it updates to the newest item), so we keep those and drop the +rest. + + fix rewrites every enclosure URL to .../releases/download/v/ + and removes from all but the newest item. + check verifies the same invariants without writing; with --online it also + requests every URL and fails on anything but HTTP 200. +""" + +import re +import subprocess +import sys + +ITEM_RE = re.compile(r".*?", re.S) +VERSION_RE = re.compile(r"([^<]+)") +URL_RE = re.compile(r'url="([^"]+)"') +DELTAS_RE = re.compile(r"\n?[ \t]*.*?", re.S) + + +def expected_url(repo, version, url): + filename = url.rsplit("/", 1)[-1] + return f"https://github.com/{repo}/releases/download/v{version}/{filename}" + + +def item_version(item): + match = VERSION_RE.search(item) + if not match: + raise SystemExit("ERROR: appcast item without sparkle:shortVersionString") + return match.group(1) + + +def fix(text, repo): + index = 0 + + def fix_item(match): + nonlocal index + item = match.group(0) + version = item_version(item) + if index > 0: + item = DELTAS_RE.sub("", item) + index += 1 + return URL_RE.sub(lambda m: f'url="{expected_url(repo, version, m.group(1))}"', item) + + return ITEM_RE.sub(fix_item, text) + + +def check(text, repo, online): + problems = [] + urls = [] + for index, item in enumerate(ITEM_RE.findall(text)): + version = item_version(item) + if index > 0 and "" in item: + problems.append(f"{version}: deltas on a non-newest item") + for url in URL_RE.findall(item): + urls.append(url) + if url != expected_url(repo, version, url): + problems.append(f"{version}: {url} is not under tag v{version}") + + if online: + for url in urls: + status = subprocess.run( + ["curl", "-sIL", "-o", "/dev/null", "-w", "%{http_code}", url], + capture_output=True, text=True, + ).stdout.strip() + if status != "200": + problems.append(f"HTTP {status}: {url}") + + for problem in problems: + print(f"appcast: {problem}", file=sys.stderr) + if not problems: + print(f"appcast: OK ({len(urls)} URLs{', all reachable' if online else ''})") + return 1 if problems else 0 + + +def main(argv): + if len(argv) < 4 or argv[1] not in ("fix", "check"): + print(__doc__.strip(), file=sys.stderr) + return 2 + command, path, repo = argv[1], argv[2], argv[3] + with open(path, encoding="utf-8") as f: + text = f.read() + if command == "fix": + with open(path, "w", encoding="utf-8") as f: + f.write(fix(text, repo)) + return check(fix(text, repo), repo, online=False) + return check(text, repo, online="--online" in argv[4:]) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/release.sh b/scripts/release.sh index a27b8ef..ae2e460 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -12,8 +12,9 @@ # 4. Export with Developer ID signing # 5. Notarize and staple the .app # 6. Create branded DMG, sign, notarize, staple -# 7. Generate Sparkle appcast via generate_appcast -# 8. Git commit, push, create GitHub Release with DMG +# 7. Generate Sparkle appcast via generate_appcast (+ normalize URLs) +# 8. Git commit, tag, create GitHub Release with DMG + deltas, then push +# the branch (the appcast only goes live once its downloads exist) # # One-time setup (before first release): # 1. Install "Developer ID Application" certificate @@ -56,6 +57,11 @@ DMG_VOLUME_NAME="VirtualMirror" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" BUILD_DIR="$PROJECT_DIR/build/release" ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive" +# Resolve SPM packages into a fixed directory so the Sparkle CLI tools we run +# always match the Sparkle version linked into the app (not whichever stale +# DerivedData folder happens to be found first). +PACKAGES_DIR="$PROJECT_DIR/build/SourcePackages" +export SPARKLE_BIN_DIR="$PACKAGES_DIR/artifacts/sparkle/Sparkle/bin" EXPORT_DIR="$BUILD_DIR/export" RELEASES_DIR="$PROJECT_DIR/releases" @@ -92,6 +98,9 @@ IFS='.' read -ra VER_PARTS <<< "$VERSION" MAJOR="${VER_PARTS[0]}" MINOR="${VER_PARTS[1]}" PATCH="${VER_PARTS[2]}" +if (( MINOR > 99 || PATCH > 99 )); then + error "Minor and patch must be < 100 (build number is MAJOR*10000 + MINOR*100 + PATCH)" +fi BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) DMG_FILENAME="$APP_NAME-$VERSION.dmg" @@ -120,6 +129,13 @@ if ! xcrun notarytool history --keychain-profile "$NOTARYTOOL_PROFILE" &>/dev/nu error "Notarization credentials not found for profile '$NOTARYTOOL_PROFILE'. Run:\n xcrun notarytool store-credentials \"$NOTARYTOOL_PROFILE\" --apple-id --team-id $TEAM_ID --password " fi +# Resolve packages (also fetches the Sparkle CLI tools) +xcodebuild -resolvePackageDependencies \ + -project "$PROJECT_DIR/$PROJECT" \ + -scheme "$SCHEME" \ + -clonedSourcePackagesDirPath "$PACKAGES_DIR" \ + -quiet + # Check Sparkle tools if ! sparkle_tool generate_keys -p &>/dev/null 2>&1; then error "Sparkle EdDSA key not found. Run: ./scripts/sparkle-tools.sh generate_keys" @@ -205,6 +221,8 @@ xcodebuild archive \ -scheme "$SCHEME" \ -configuration Release \ -archivePath "$ARCHIVE_PATH" \ + -destination 'generic/platform=macOS' \ + -clonedSourcePackagesDirPath "$PACKAGES_DIR" \ -quiet [[ -d "$ARCHIVE_PATH" ]] || error "Archive failed — $ARCHIVE_PATH not found" @@ -367,6 +385,7 @@ xcrun notarytool submit "$DMG_BUILD_PATH" \ --wait xcrun stapler staple "$DMG_BUILD_PATH" +xcrun stapler validate "$DMG_BUILD_PATH" success "DMG signed, notarized, and stapled" @@ -386,13 +405,26 @@ DOWNLOAD_PREFIX="https://github.com/$GITHUB_REPO/releases/download/v$VERSION/" # from the embedded app bundles, signs them with the EdDSA key from the # Keychain, and writes a properly formatted appcast.xml. # -# --download-url-prefix sets the base URL for each DMG's download link. -# Since each version may have a different GH release tag URL, we use -# --versions to only process the new DMG (preserving existing entries). +# It also writes binary deltas (VirtualMirror-.delta) from each +# older DMG to the new one; those get uploaded with the release below. +# +# --download-url-prefix is applied to *every* item, which would point older +# DMGs at this release's tag, so appcast-tool.py rewrites each URL to its own +# item's tag and drops deltas from all but the newest item. sparkle_tool generate_appcast \ --download-url-prefix "$DOWNLOAD_PREFIX" \ -o "$PROJECT_DIR/appcast.xml" \ "$RELEASES_DIR" +"$SCRIPT_DIR/appcast-tool.py" fix "$PROJECT_DIR/appcast.xml" "$GITHUB_REPO" + +DELTA_FILES=() +while IFS= read -r delta; do + DELTA_FILES+=("$RELEASES_DIR/$delta") +done < <(sed -n 's|.*releases/download/v'"$VERSION"'/\([^"]*\.delta\)".*|\1|p' "$PROJECT_DIR/appcast.xml") +for delta in ${DELTA_FILES[@]+"${DELTA_FILES[@]}"}; do + [[ -f "$delta" ]] || error "Appcast references a delta that doesn't exist: $delta" +done +info "Deltas to upload: ${#DELTA_FILES[@]}" success "Appcast updated at appcast.xml" @@ -407,21 +439,31 @@ cd "$PROJECT_DIR" git add "$PBXPROJ" appcast.xml "$CHANGELOG" git commit -m "Release v$VERSION" -# Push to remote -CURRENT_BRANCH=$(git branch --show-current) -git push origin "$CURRENT_BRANCH" +# Publish the tag and the release assets *before* pushing the branch: Sparkle +# reads appcast.xml from master, so pushing it first would advertise an update +# whose downloads don't exist yet. +git tag "v$VERSION" +git push origin "v$VERSION" -# Create GitHub release with DMG attached, using changelog as release notes NOTES_FILE=$(mktemp) echo "$RELEASE_NOTES" > "$NOTES_FILE" gh release create "v$VERSION" \ "$RELEASES_DIR/$DMG_FILENAME" \ + ${DELTA_FILES[@]+"${DELTA_FILES[@]}"} \ --repo "$GITHUB_REPO" \ + --verify-tag \ --title "v$VERSION" \ --notes-file "$NOTES_FILE" \ --latest rm -f "$NOTES_FILE" +CURRENT_BRANCH=$(git branch --show-current) +git push origin "$CURRENT_BRANCH" + +info "Verifying every appcast download URL" +"$SCRIPT_DIR/appcast-tool.py" check "$PROJECT_DIR/appcast.xml" "$GITHUB_REPO" --online \ + || warn "Some appcast URLs are not reachable yet (see above)" + RELEASE_URL="https://github.com/$GITHUB_REPO/releases/tag/v$VERSION" # ========================================================================== diff --git a/scripts/sparkle-tools.sh b/scripts/sparkle-tools.sh index de7b48c..8349eee 100755 --- a/scripts/sparkle-tools.sh +++ b/scripts/sparkle-tools.sh @@ -21,16 +21,25 @@ if [[ -z "$TOOL" ]]; then fi shift -# Search for the tool in DerivedData SPM artifacts -DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData" SPARKLE_BIN="" -for dir in "$DERIVED_DATA"/VirtualMirror-*/SourcePackages/artifacts/sparkle/Sparkle/bin; do - if [[ -x "$dir/$TOOL" ]]; then - SPARKLE_BIN="$dir/$TOOL" - break - fi -done +# Preferred: an explicit tools directory (release.sh resolves packages into a +# fixed location and exports this, so the tools match the linked Sparkle). +if [[ -n "${SPARKLE_BIN_DIR:-}" && -x "$SPARKLE_BIN_DIR/$TOOL" ]]; then + SPARKLE_BIN="$SPARKLE_BIN_DIR/$TOOL" +fi + +# Otherwise search DerivedData SPM artifacts, newest first, so a stale +# DerivedData folder with an older Sparkle doesn't win. +DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData" +if [[ -z "$SPARKLE_BIN" ]]; then + while IFS= read -r dir; do + if [[ -x "$dir/$TOOL" ]]; then + SPARKLE_BIN="$dir/$TOOL" + break + fi + done < <(ls -dt "$DERIVED_DATA"/VirtualMirror-*/SourcePackages/artifacts/sparkle/Sparkle/bin 2>/dev/null) +fi # Fallback: check if tool is on PATH if [[ -z "$SPARKLE_BIN" ]] && command -v "$TOOL" &>/dev/null; then