From e7cee572d6273f5ad9dd17145b82f181a7aff371 Mon Sep 17 00:00:00 2001 From: "Souris.CLOUD" Date: Wed, 23 Sep 2026 03:40:49 +0200 Subject: [PATCH 1/6] Release tooling: publish deltas, fix appcast URLs, Sparkle 2.10, CI on macos-26 - appcast-tool.py rewrites each appcast item's URLs to its own release tag (generate_appcast pointed older DMGs at the newest tag) and keeps deltas only on the newest item; `check --online` verifies every URL - release.sh uploads the new release's .delta files, tags and creates the GitHub release before pushing the branch (appcast goes live only once its downloads exist), resolves packages into a fixed dir so the Sparkle tools match the linked framework, archives for generic macOS, validates the DMG staple, and guards the build-number scheme - Sparkle 2.9.0 -> 2.10.0 (security fixes from 2.9.2-2.9.6) - CI: macos-26 / Xcode 26.6, checkout v7, offline appcast check job Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PF2xMVip2GR5v9A7n5xAdH --- .github/workflows/ci.yml | 20 +++- VirtualMirror.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- appcast.xml | 15 +-- scripts/appcast-tool.py | 102 ++++++++++++++++++ scripts/release.sh | 60 +++++++++-- scripts/sparkle-tools.sh | 25 +++-- 7 files changed, 190 insertions(+), 38 deletions(-) create mode 100755 scripts/appcast-tool.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9362e52..d2b8b32 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,15 +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 + runs-on: macos-26 # 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 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install SwiftLint run: brew install swiftlint diff --git a/VirtualMirror.xcodeproj/project.pbxproj b/VirtualMirror.xcodeproj/project.pbxproj index 0544511..22984d8 100644 --- a/VirtualMirror.xcodeproj/project.pbxproj +++ b/VirtualMirror.xcodeproj/project.pbxproj @@ -655,7 +655,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/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 From ae6f39ad11eb0fedd4d909a7a66c5a168b01e5ca Mon Sep 17 00:00:00 2001 From: "Souris.CLOUD" Date: Wed, 23 Sep 2026 03:47:58 +0200 Subject: [PATCH 2/6] Fix session lifecycle, surface receiver errors, serialize stream teardown Session lifecycle - AirPlayConnection runs on its own serial queue and tears down exactly once via finish(), which always reports the end of session. close() holds a strong reference, so a connection evicted by a newer sender still reports it (previously the weak state handler never fired and the UI stuck on a frozen frame / "Connecting"). - A full (typeless) TEARDOWN ends the session in the UI; iOS 27 sends only this when mirroring stops. - The manager tracks the owning session ID so late events from a replaced connection are ignored; start() is idempotent; the connecting watchdog also closes the stuck connection. Errors - Listener failures (port in use, Local Network denied) and Bonjour registration failures (sync or via the registration callback) now stop the services and show the error screen with Retry. Thread safety - MirrorStreamReceiver and AudioStreamReceiver run all state on one serial queue; VideoDecoder guards its session with a lock. - Audio sockets and DNS-SD refs are closed/deallocated in their dispatch source cancel handlers; NTP server tracks and cancels inbound flows. Also: audio engine restarts on output-device change, palette falls back to the last-focused receiver, GET_PARAMETER reports real volume in dB. Adds SessionLifecycleTests (10 tests incl. end-to-end eviction/teardown). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PF2xMVip2GR5v9A7n5xAdH --- CHANGELOG.md | 30 +++ VirtualMirror.xcodeproj/project.pbxproj | 4 + VirtualMirror/AirPlay/AirPlayConnection.swift | 118 +++++++--- VirtualMirror/AirPlay/AirPlayServer.swift | 44 ++-- VirtualMirror/AirPlay/AirPlayService.swift | 55 +++-- VirtualMirror/AirPlay/NTPTimingServer.swift | 25 ++- VirtualMirror/AirPlay/ReceiverRegistry.swift | 29 ++- VirtualMirror/AirPlayManager.swift | 83 ++++++- VirtualMirror/Audio/AudioPlayer.swift | 40 ++++ VirtualMirror/Audio/AudioStreamReceiver.swift | 79 +++++-- .../Video/MirrorStreamReceiver.swift | 32 ++- VirtualMirror/Video/VideoDecoder.swift | 10 + .../SessionLifecycleTests.swift | 210 ++++++++++++++++++ 13 files changed, 655 insertions(+), 104 deletions(-) create mode 100644 VirtualMirrorTests/SessionLifecycleTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efd853..10f953d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ 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 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/VirtualMirror.xcodeproj/project.pbxproj b/VirtualMirror.xcodeproj/project.pbxproj index 22984d8..c78fee7 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 = ""; diff --git a/VirtualMirror/AirPlay/AirPlayConnection.swift b/VirtualMirror/AirPlay/AirPlayConnection.swift index d038a05..e9bb659 100644 --- a/VirtualMirror/AirPlay/AirPlayConnection.swift +++ b/VirtualMirror/AirPlay/AirPlayConnection.swift @@ -14,6 +14,17 @@ 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 + private var buffer = Data() /// This connection's receiver identity (ports, device ID, signing key). @@ -64,27 +75,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 +145,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 +174,7 @@ class AirPlayConnection { case .error(let message): logger.error("HTTP parse error: \(message)") buffer.removeAll() - close() + finish() return } } @@ -259,7 +289,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 +383,7 @@ class AirPlayConnection { // Extract device name for UI state if let name = plist["name"] as? String { logger.info("Device: \(name)") - manager?.didStartConnecting(deviceName: name) + manager?.didStartConnecting(session: sessionID, deviceName: name) } // Read the iPhone's timing port and start active NTP sync @@ -416,7 +446,7 @@ class AirPlayConnection { private func handleRecord(_ request: HTTPRequest) { logger.info("Handling RECORD - mirroring started!") - manager?.didStartMirroring() + manager?.didStartMirroring(session: sessionID) let headers = [ "Audio-Latency": "11025", "Audio-Jack-Status": "connected; type=analog" @@ -439,7 +469,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 +483,52 @@ 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() + 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 +599,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..05d9308 100644 --- a/VirtualMirror/AirPlay/AirPlayServer.swift +++ b/VirtualMirror/AirPlay/AirPlayServer.swift @@ -1,5 +1,6 @@ import Foundation import Network +import dnssd import os class AirPlayServer { @@ -9,6 +10,10 @@ class AirPlayServer { 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 +24,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 +40,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 +63,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 +92,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 +107,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..d8d7c75 100644 --- a/VirtualMirror/AirPlay/AirPlayService.swift +++ b/VirtualMirror/AirPlay/AirPlayService.swift @@ -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..2b769a8 100644 --- a/VirtualMirror/AirPlay/NTPTimingServer.swift +++ b/VirtualMirror/AirPlay/NTPTimingServer.swift @@ -10,6 +10,10 @@ class NTPTimingServer { 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 +25,10 @@ class NTPTimingServer { } func start() { + stateQueue.sync { _start() } + } + + private func _start() { do { let params = NWParameters.udp params.allowLocalEndpointReuse = true @@ -50,6 +58,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 +100,10 @@ class NTPTimingServer { activeConnection = nil listener?.cancel() listener = nil + for conn in passiveConnections.values { + conn.cancel() + } + passiveConnections.removeAll() } } @@ -151,9 +167,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/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..5c9c982 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,32 +147,47 @@ 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) { Task { @MainActor in + if case .error = self.state { return } + self.activeSession = session self.connectingTimeoutTask?.cancel() let name = self.state.deviceName ?? "Unknown" self.state = .mirroring(name) @@ -163,8 +195,13 @@ class AirPlayManager: ObservableObject { } } - 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 @@ -172,11 +209,37 @@ class AirPlayManager: ObservableObject { } } + /// 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..02e3944 100644 --- a/VirtualMirror/Audio/AudioPlayer.swift +++ b/VirtualMirror/Audio/AudioPlayer.swift @@ -3,12 +3,22 @@ import AVFoundation import os /// Plays decoded PCM audio through the default audio output using AVAudioEngine. +/// +/// 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. class AudioPlayer { 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 +57,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 +93,36 @@ 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) { + configurationObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: engine, + queue: nil + ) { [weak self] _ in + guard let self else { return } + self.queue.async { self.restartAfterConfigurationChange(of: engine) } + } + } + + private func restartAfterConfigurationChange(of changedEngine: AVAudioEngine) { + guard isRunning, let engine, 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..0b78693 100644 --- a/VirtualMirror/Audio/AudioStreamReceiver.swift +++ b/VirtualMirror/Audio/AudioStreamReceiver.swift @@ -20,9 +20,11 @@ import os class AudioStreamReceiver { 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 +37,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 } } } @@ -82,6 +91,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 +101,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 +157,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 +182,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 +281,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 +294,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 +321,7 @@ class AudioStreamReceiver { } break } + guard streamActive else { continue } let data = Data(buffer.prefix(bytesRead)) processRTPPacket(data) } @@ -315,6 +340,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 +356,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/Video/MirrorStreamReceiver.swift b/VirtualMirror/Video/MirrorStreamReceiver.swift index fb8c0b6..35ec429 100644 --- a/VirtualMirror/Video/MirrorStreamReceiver.swift +++ b/VirtualMirror/Video/MirrorStreamReceiver.swift @@ -11,6 +11,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 +41,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 +74,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 +92,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 +129,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 +176,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..0e88e06 100644 --- a/VirtualMirror/Video/VideoDecoder.swift +++ b/VirtualMirror/Video/VideoDecoder.swift @@ -16,6 +16,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 +39,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 @@ -50,6 +58,7 @@ 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 @@ -201,6 +210,7 @@ class VideoDecoder: ObservableObject { private var decodeErrorCount = 0 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 diff --git a/VirtualMirrorTests/SessionLifecycleTests.swift b/VirtualMirrorTests/SessionLifecycleTests.swift new file mode 100644 index 0000000..dc8dfaa --- /dev/null +++ b/VirtualMirrorTests/SessionLifecycleTests.swift @@ -0,0 +1,210 @@ +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) + } + + // 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 { + 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: 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") + } +} From 0c5d053a7ee11aef5872137b8e694ba566c7fbde Mon Sep 17 00:00:00 2001 From: "Souris.CLOUD" Date: Wed, 23 Sep 2026 03:53:09 +0200 Subject: [PATCH 3/6] Order decoder reset on the connection queue; resume mirroring on re-SETUP Review follow-ups: - A full TEARDOWN resets the video decoder synchronously on the connection queue instead of from didEndSession on main, where a late reset could wipe a stream the same connection had already set up again. - A screen-mirror SETUP on a connection that has already RECORDed resumes the mirroring UI (with the sender's name) instead of waiting for a RECORD the sender may not repeat. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PF2xMVip2GR5v9A7n5xAdH --- VirtualMirror/AirPlay/AirPlayConnection.swift | 16 +++++++++++++++- VirtualMirror/AirPlayManager.swift | 8 +++++--- VirtualMirrorTests/SessionLifecycleTests.swift | 6 ++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/VirtualMirror/AirPlay/AirPlayConnection.swift b/VirtualMirror/AirPlay/AirPlayConnection.swift index e9bb659..9dc8c14 100644 --- a/VirtualMirror/AirPlay/AirPlayConnection.swift +++ b/VirtualMirror/AirPlay/AirPlayConnection.swift @@ -24,6 +24,12 @@ class AirPlayConnection { 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() @@ -383,6 +389,7 @@ class AirPlayConnection { // Extract device name for UI state if let name = plist["name"] as? String { logger.info("Device: \(name)") + senderName = name manager?.didStartConnecting(session: sessionID, deviceName: name) } @@ -423,6 +430,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), @@ -446,7 +456,8 @@ class AirPlayConnection { private func handleRecord(_ request: HTTPRequest) { logger.info("Handling RECORD - mirroring started!") - manager?.didStartMirroring(session: sessionID) + hasRecorded = true + manager?.didStartMirroring(session: sessionID, deviceName: senderName) let headers = [ "Audio-Latency": "11025", "Audio-Jack-Status": "connected; type=analog" @@ -502,6 +513,9 @@ class AirPlayConnection { // 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) } diff --git a/VirtualMirror/AirPlayManager.swift b/VirtualMirror/AirPlayManager.swift index 5c9c982..cdf3ca9 100644 --- a/VirtualMirror/AirPlayManager.swift +++ b/VirtualMirror/AirPlayManager.swift @@ -184,12 +184,12 @@ class AirPlayManager: ObservableObject { } } - nonisolated func didStartMirroring(session: UUID) { + 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() } @@ -205,7 +205,9 @@ class AirPlayManager: ObservableObject { 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. } } diff --git a/VirtualMirrorTests/SessionLifecycleTests.swift b/VirtualMirrorTests/SessionLifecycleTests.swift index dc8dfaa..f5139ac 100644 --- a/VirtualMirrorTests/SessionLifecycleTests.swift +++ b/VirtualMirrorTests/SessionLifecycleTests.swift @@ -114,6 +114,12 @@ final class SessionLifecycleTests: XCTestCase { 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 From fdbe564bd17a57875fbdd03ff0cce543c6e4c132 Mon Sep 17 00:00:00 2001 From: "Souris.CLOUD" Date: Wed, 23 Sep 2026 03:58:29 +0200 Subject: [PATCH 4/6] Fix all SwiftLint violations and make the lint job blocking (--strict) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PF2xMVip2GR5v9A7n5xAdH --- .github/workflows/ci.yml | 5 +--- .swiftlint.yml | 2 +- VirtualMirror/AboutView.swift | 2 +- VirtualMirror/AirPlay/AirPlayConfig.swift | 10 ++++---- VirtualMirror/Audio/AudioStreamReceiver.swift | 2 +- VirtualMirror/ContentView.swift | 2 +- VirtualMirror/Support/CommandPalette.swift | 7 ++++-- VirtualMirror/Support/GitHubFeedback.swift | 4 ++-- VirtualMirror/Support/HelpView.swift | 12 +++++----- VirtualMirrorTests/HTTPResponseTests.swift | 24 +++++++++---------- .../PairVerifyHandlerTests.swift | 6 ++--- .../SessionLifecycleTests.swift | 2 +- 12 files changed, 39 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b8b32..4033515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,6 @@ jobs: lint: name: SwiftLint runs-on: macos-26 - # 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 steps: - name: Checkout uses: actions/checkout@v7 @@ -67,4 +64,4 @@ jobs: 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/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/Audio/AudioStreamReceiver.swift b/VirtualMirror/Audio/AudioStreamReceiver.swift index 0b78693..8b45d00 100644 --- a/VirtualMirror/Audio/AudioStreamReceiver.swift +++ b/VirtualMirror/Audio/AudioStreamReceiver.swift @@ -65,7 +65,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 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..298e711 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.") @@ -295,7 +295,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 +312,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/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 index f5139ac..e4a0f25 100644 --- a/VirtualMirrorTests/SessionLifecycleTests.swift +++ b/VirtualMirrorTests/SessionLifecycleTests.swift @@ -160,7 +160,7 @@ private final class RTSPClient { } static func connect(port: UInt16) async throws -> RTSPClient { - let connection = NWConnection(host: "127.0.0.1", port: NWEndpoint.Port(rawValue: port)!, using: .tcp) + 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. From def846f5dfc8a25b4df3d998dbfb64d33c6234c0 Mon Sep 17 00:00:00 2001 From: "Souris.CLOUD" Date: Wed, 23 Sep 2026 04:00:30 +0200 Subject: [PATCH 5/6] Adopt Swift 6 language mode - SWIFT_VERSION 6.0 for app and tests; builds with no warnings. - Queue-confined types (AirPlayConnection, AirPlayServer, NTPTimingServer, MirrorStreamReceiver, AudioStreamReceiver, AudioPlayer) and the lock-guarded ReceiverIdentity/VideoDecoder are @unchecked Sendable, each documenting the invariant the compiler can't verify. - VideoDecoder's decode-error counter (written from the VT output callback thread) now lives under statsLock instead of being racy. - AboutWindowController is @MainActor; AudioPlayer no longer captures the non-Sendable AVAudioEngine in its notification handler. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PF2xMVip2GR5v9A7n5xAdH --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- VirtualMirror.xcodeproj/project.pbxproj | 8 ++-- VirtualMirror/AirPlay/AirPlayConnection.swift | 4 +- VirtualMirror/AirPlay/AirPlayServer.swift | 5 ++- VirtualMirror/AirPlay/AirPlayService.swift | 2 +- VirtualMirror/AirPlay/NTPTimingServer.swift | 4 +- VirtualMirror/AirPlay/ReceiverIdentity.swift | 5 ++- VirtualMirror/Audio/AudioPlayer.swift | 13 +++--- VirtualMirror/Audio/AudioStreamReceiver.swift | 4 +- .../Video/MirrorStreamReceiver.swift | 3 +- VirtualMirror/Video/VideoDecoder.swift | 40 ++++++++++++------- VirtualMirror/VirtualMirrorApp.swift | 1 + .../SessionLifecycleTests.swift | 2 +- 14 files changed, 62 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f953d..92f8d25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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 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/VirtualMirror.xcodeproj/project.pbxproj b/VirtualMirror.xcodeproj/project.pbxproj index c78fee7..bcfe339 100644 --- a/VirtualMirror.xcodeproj/project.pbxproj +++ b/VirtualMirror.xcodeproj/project.pbxproj @@ -554,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; }; @@ -583,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; }; @@ -599,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; @@ -616,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; diff --git a/VirtualMirror/AirPlay/AirPlayConnection.swift b/VirtualMirror/AirPlay/AirPlayConnection.swift index 9dc8c14..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 diff --git a/VirtualMirror/AirPlay/AirPlayServer.swift b/VirtualMirror/AirPlay/AirPlayServer.swift index 05d9308..31c97e9 100644 --- a/VirtualMirror/AirPlay/AirPlayServer.swift +++ b/VirtualMirror/AirPlay/AirPlayServer.swift @@ -3,7 +3,10 @@ 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] = [:] diff --git a/VirtualMirror/AirPlay/AirPlayService.swift b/VirtualMirror/AirPlay/AirPlayService.swift index d8d7c75..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? diff --git a/VirtualMirror/AirPlay/NTPTimingServer.swift b/VirtualMirror/AirPlay/NTPTimingServer.swift index 2b769a8..2cc4a6a 100644 --- a/VirtualMirror/AirPlay/NTPTimingServer.swift +++ b/VirtualMirror/AirPlay/NTPTimingServer.swift @@ -6,7 +6,9 @@ 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? 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/Audio/AudioPlayer.swift b/VirtualMirror/Audio/AudioPlayer.swift index 02e3944..4aa4f62 100644 --- a/VirtualMirror/Audio/AudioPlayer.swift +++ b/VirtualMirror/Audio/AudioPlayer.swift @@ -1,5 +1,5 @@ import Foundation -import AVFoundation +@preconcurrency import AVFoundation import os /// Plays decoded PCM audio through the default audio output using AVAudioEngine. @@ -7,7 +7,9 @@ import os /// 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. -class AudioPlayer { +/// +/// @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? @@ -97,18 +99,19 @@ class AudioPlayer { /// 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: engine) } + self.queue.async { self.restartAfterConfigurationChange(of: engineID) } } } - private func restartAfterConfigurationChange(of changedEngine: AVAudioEngine) { - guard isRunning, let engine, engine === changedEngine, let playerNode else { return } + 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() diff --git a/VirtualMirror/Audio/AudioStreamReceiver.swift b/VirtualMirror/Audio/AudioStreamReceiver.swift index 8b45d00..8b69085 100644 --- a/VirtualMirror/Audio/AudioStreamReceiver.swift +++ b/VirtualMirror/Audio/AudioStreamReceiver.swift @@ -17,7 +17,9 @@ 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, diff --git a/VirtualMirror/Video/MirrorStreamReceiver.swift b/VirtualMirror/Video/MirrorStreamReceiver.swift index 35ec429..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? diff --git a/VirtualMirror/Video/VideoDecoder.swift b/VirtualMirror/Video/VideoDecoder.swift index 0e88e06..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? @@ -49,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 } @@ -60,7 +62,7 @@ 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) @@ -207,8 +209,16 @@ 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 { @@ -291,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))") } } } @@ -344,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/SessionLifecycleTests.swift b/VirtualMirrorTests/SessionLifecycleTests.swift index e4a0f25..0bd0c32 100644 --- a/VirtualMirrorTests/SessionLifecycleTests.swift +++ b/VirtualMirrorTests/SessionLifecycleTests.swift @@ -152,7 +152,7 @@ final class SessionLifecycleTests: XCTestCase { } /// Minimal RTSP sender for driving AirPlayConnection over loopback. -private final class RTSPClient { +private final class RTSPClient: Sendable { let connection: NWConnection private init(connection: NWConnection) { From c009ceade5413675bf7fc6696ec965cb11dfc6ca Mon Sep 17 00:00:00 2001 From: "Souris.CLOUD" Date: Wed, 23 Sep 2026 04:09:29 +0200 Subject: [PATCH 6/6] Update Help and README for the new receiver behavior Cover the error screen and Retry, Local Network access (and how to re-enable it), a second device taking over a receiver, audio following output-device changes, and per-receiver ports. Drop the stale README claim that the pairing identity lives in the Keychain (ephemeral since 0.4.0) and bump the build requirement to Xcode 26+ for Swift 6. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PF2xMVip2GR5v9A7n5xAdH --- CHANGELOG.md | 1 + README.md | 14 ++++++++------ VirtualMirror/Support/HelpView.swift | 12 ++++++++---- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f8d25..dea6368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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/Support/HelpView.swift b/VirtualMirror/Support/HelpView.swift index 298e711..dd8b170 100644 --- a/VirtualMirror/Support/HelpView.swift +++ b/VirtualMirror/Support/HelpView.swift @@ -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.") } } }