Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -43,18 +43,25 @@ jobs:
-destination 'platform=macOS' \
CODE_SIGNING_ALLOWED=NO

appcast:
name: Appcast
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Check appcast URLs point at their own release tags
run: python3 scripts/appcast-tool.py check appcast.xml "$GITHUB_REPOSITORY"

lint:
name: SwiftLint
runs-on: macos-15
# Non-blocking while the team tunes the ruleset on a previously-unlinted
# codebase. Flip to a required check (and add --strict) once it's green.
continue-on-error: true
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Install SwiftLint
run: brew install swiftlint

- name: Run SwiftLint
run: swiftlint lint --reporter github-actions-logging
run: swiftlint lint --strict --reporter github-actions-logging
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Errors now show in the receiver window, with a Retry button. This covers the AirPlay port already being in use (e.g. a second copy of VirtualMirror or another receiver), Local Network access being denied, and Bonjour advertising failing. Previously the window showed "waiting" forever while the iPhone failed to connect.
- Audio recovers by itself when the Mac's output device changes (AirPods, monitor speakers, unplugging headphones). Previously it stayed silent until mirroring restarted.
- 10 new tests covering the session lifecycle: ending, eviction and TEARDOWN handling, including an end-to-end test over a real listener. The suite now has 58 tests.
- A CI check that every appcast download URL points at its own release, plus `scripts/appcast-tool.py` to fix and verify the appcast (`check --online` also requests every URL).

### Changed

- Updated Sparkle from 2.9.0 to 2.10.0. This includes upstream security fixes to the installer and to delta updates, and brings update windows to the front correctly in menu-bar apps.
- The GetParameter volume query now reports the receiver's real volume, in AirPlay's dB scale, instead of a fixed value.
- CI now runs on `macos-26` with Xcode 26.6.
- The project now builds in **Swift 6 language mode**, so the compiler checks for data races at compile time. Types that do all their work on one serial queue are marked `@unchecked Sendable`, with a comment explaining why each is safe. The video decoder's error counter, which the decode callback updated with no lock, is now locked.
- SwiftLint is clean and now a required CI check (`--strict`).
- The in-app Help and the README now cover the error screen and Retry, Local Network access, a second device taking over a receiver, audio following output-device changes, and per-receiver ports. The README no longer says the pairing identity is stored in the Keychain, which stopped being true in 0.4.0.
- The release script:
- uploads the delta updates alongside the DMG
- publishes the GitHub release before pushing the appcast, so an update is never advertised before its download exists
- runs Sparkle's tools from the same version that is linked into the app
- validates the DMG's notarization ticket
- checks every appcast URL after publishing

### Fixed

- The window no longer stays on "Mirroring" with a frozen last frame after the sender stops mirroring. This covers iOS 27, which ends a session with a TEARDOWN that doesn't name a stream.
- When a second device (or any other TCP client) connected to a receiver, the first session was dropped without the UI noticing. The window could then sit on a frozen frame or on "Connecting" indefinitely.
- The 30-second "Connecting" timeout now also closes the stuck connection, so the sender gives up cleanly.
- Fixed potential crashes when closing or restarting a receiver, or when a sender reconnected while streaming. Video, audio and Bonjour resources were torn down on one thread while another was still using them.
- Each iPhone lock/unlock no longer leaks timing-sync network connections.
- Commands from the menu-bar Command Palette now act on the most recently focused receiver instead of the first one, when no receiver window is focused.
- Delta updates now actually work: their files had never been uploaded, and the appcast pointed older versions' downloads at the wrong release.
- A rejected pair-verify now stops processing any further requests already buffered on that connection.

## [0.4.0] - 2026-05-31

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions VirtualMirror.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -102,6 +103,7 @@
T2000004 /* HTTPResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponseTests.swift; sourceTree = "<group>"; };
T2000005 /* HTTPParserHardeningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPParserHardeningTests.swift; sourceTree = "<group>"; };
T2000006 /* PairVerifyHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairVerifyHandlerTests.swift; sourceTree = "<group>"; };
T2000007 /* SessionLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLifecycleTests.swift; sourceTree = "<group>"; };
T3000001 /* VirtualMirrorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VirtualMirrorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -287,6 +290,7 @@
T2000004 /* HTTPResponseTests.swift */,
T2000005 /* HTTPParserHardeningTests.swift */,
T2000006 /* PairVerifyHandlerTests.swift */,
T2000007 /* SessionLifecycleTests.swift */,
);
path = VirtualMirrorTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -550,7 +554,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "VirtualMirror/FairPlay/BridgingHeader.h";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
};
name = Debug;
};
Expand Down Expand Up @@ -579,7 +583,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "VirtualMirror/FairPlay/BridgingHeader.h";
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
};
name = Release;
};
Expand All @@ -595,7 +599,7 @@
PRODUCT_BUNDLE_IDENTIFIER = cloud.souris.virtualmirror.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VirtualMirror.app/Contents/MacOS/VirtualMirror";
};
name = Debug;
Expand All @@ -612,7 +616,7 @@
PRODUCT_BUNDLE_IDENTIFIER = cloud.souris.virtualmirror.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VirtualMirror.app/Contents/MacOS/VirtualMirror";
};
name = Release;
Expand Down Expand Up @@ -655,7 +659,7 @@
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.0;
minimumVersion = 2.10.0;
};
};
/* End XCRemoteSwiftPackageReference section */
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion VirtualMirror/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 5 additions & 5 deletions VirtualMirror/AirPlay/AirPlayConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,33 +92,33 @@ 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": [
[
"type": NSNumber(value: 100 as UInt64),
"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 {
Expand Down
Loading
Loading