Add macOS AirPlay support via AVKit#194
Conversation
Scaffold for macOS AirPlay support. Adds the request/session shape that matches Chromecast's surface (minus device discovery — AirPlay route selection is system-managed via AVPlayerView's HUD).
📝 WalkthroughWalkthroughAdds macOS AirPlay streaming: generalizes the LAN media proxy to support ReceiverProfile (Chromecast/AirPlay), implements an AVKit-based AirPlay engine and Tauri commands, adds coordinated local-stream cancellation, and wires AirPlay controls and types through the React frontend with lifecycle locks ensuring mutual exclusion. ChangesmacOS AirPlay Integration
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Renames engine/cast_proxy.rs to engine/media_proxy.rs and parameterizes on a ReceiverProfile enum (Chromecast | AirPlay). The proxy now gates: - CORS preflight (OPTIONS handler) on Chromecast only — AVPlayer is a native client and does not preflight. - HEVC remux container: Cast continues to use DASH+fMP4 (Shaka path, required because CAFv3 MPL can't render fMP4 in HLS); AirPlay uses HLS+fMP4, the reference HEVC-fMP4-in-HLS platform per Apple HLS spec. - hvc1 sample-entry tag override on Cast only (Apple TV accepts both hev1 and hvc1). - Audio passthrough set: Cast keeps "AAC only", AirPlay accepts AAC/AC3/EAC3/MP3 natively per Apple HLS spec — only MP2 still transcodes. All cast-domain plumbing reused as-is (token-gated 0.0.0.0 listener, LAN-IP detection, manifest URI rewriting, ffmpeg lifecycle, reconnecting upstream pump for HEVC, ffprobe codec gate, HEAD+Range, post-redirect SSRF pin, path-traversal guard, cancellation propagation).
objc2 0.6 framework crates needed for the AVPlayer / AVPlayerView / NSWindow plumbing in engine/airplay.rs.
cargo-fuzz 0.13.1's pinned Cargo.lock resolves rustix 0.36.5, which uses `#[cfg_attr(rustc_attrs, rustc_layout_scalar_valid_range_*)]` — internal compiler attributes that the current nightly toolchain rejects with "attributes starting with `rustc` are reserved for use by the `rustc` compiler". Letting cargo pick a newer 0.36.x patch unblocks the install.
Builds an AVURLAsset + AVPlayer + AVPlayerView in an NSWindow whose HUD exposes the AirPlay route picker. Local playback streams in the window until the user picks an Apple TV; AVPlayer then transparently swaps to external playback. The proxy URL is fed to the asset (not the upstream). Apple TV in URL-mode AirPlay does not forward AVURLAssetHTTPHeaderFieldsKey, so the proxy remains essential for UA injection and single-credential fan-out. All AVFoundation/AppKit calls are dispatched to the AppKit main thread via run_on_main_thread; Retained handles ride in MainOnly to satisfy Send/Sync without leaking the !Send invariant outside the type.
Adds macOS-only AirPlayState (session + proxy) plus airplay_lifecycle_lock, mirroring the Chromecast pair. The lifecycle lock serializes start/stop so concurrent calls cannot orphan a session or proxy.
start_airplay, stop_airplay, get_airplay_status mirror the cast command shape behind cfg(target_os = "macos"). The lifecycle lock serializes start/stop, and starting AirPlay tears down any live Chromecast first (cast_to_device reciprocates) so the single upstream slot only services one external receiver at a time.
useAirPlay mirrors useChromecast: subscribes to airplay://status, exposes start/stop, gates on detectPlatform() === "macos" via available so the hook is a no-op on Windows/Linux (the command isn't registered there either). buildAirPlayRequest reuses detectStreamKind so the proxy gets the same hls/mpeg_ts hint the cast path uses.
Inline mode shows an AirPlay row above the Cast row when airplay is available (macOS); popover mode adds the same affordance at the top. The AVPlayerView HUD owns the route picker once playback begins, so the in-app control just needs an Open/Stop AirPlay button — no device list.
Threads the shared useAirPlay hook from App.tsx through SelectedChannelSidebar -> ThumbnailPanel -> StreamPlayer/CastMenu, mirroring the existing Chromecast plumbing. handlePlayInApp now redirects to an active AirPlay session the same way it already redirected to an active Cast session, so arrow-key channel-surfing re-targets the AirPlay receiver instead of starting a local player. Renames ChannelTable's isCasting prop to isReceiverActive (the auto- open debounce semantics are identical for both receivers) and updates App.tsx to pass isCasting || isAirPlaying. StreamPlayer renders a top-left "AirPlaying to receiver" / "AirPlay window open" badge mirroring the Cast badge, and forwards the AirPlay hook + request into CastMenu so the popover offers Start/Stop AirPlay alongside Chromecast device picking.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src-tauri/src/engine/media_proxy.rs (1)
115-134: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
RemuxState.childis alwaysNone— dead code in both cleanup paths.
childis moved into thetokio::spawnworker task at line 587 (let mut child = cmd.spawn()?).RemuxStateis then populated at line 641 withchild: None, sochildisNonefor the entire lifetime of theRemuxState. Theif let Some(c) = child.as_mut()branches in bothcleanup_blocking(line 127) andcleanup_remux_async(line 696) are unreachable, and the doc comment "We hold it for diagnostic logging" (line 118) is inaccurate.The worker task already owns child process management exclusively (via the cancel token). Remove the
childfield fromRemuxStateand update both cleanup functions accordingly.♻️ Proposed fix
struct RemuxState { tmpdir: PathBuf, - /// ffmpeg child handle. We hold it for diagnostic logging; the worker task - /// is responsible for waiting on it. - child: Option<Child>, } impl RemuxState { fn cleanup_blocking(self) { - let RemuxState { tmpdir, mut child } = self; - if let Some(c) = child.as_mut() { - let _ = c.start_kill(); - } + let RemuxState { tmpdir } = self; let _ = std::fs::remove_dir_all(&tmpdir); } }async fn cleanup_remux_async(state: RemuxState) { - let RemuxState { tmpdir, mut child } = state; - if let Some(c) = child.as_mut() { - graceful_kill(c, GRACEFUL_KILL_TIMEOUT).await; - } + let RemuxState { tmpdir } = state; let _ = tokio::fs::remove_dir_all(&tmpdir).await; }*guard = Some(RemuxState { tmpdir: tmpdir.clone(), - child: None, });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src-tauri/src/engine/media_proxy.rs` around lines 115 - 134, RemuxState currently keeps a child: Option<Child> that is never set (the worker owns the Child via tokio::spawn), so remove the dead field and update cleanup paths: delete the child field from struct RemuxState and remove any logic that checks or kills it in cleanup_blocking and cleanup_remux_async; also update the struct construction sites (where RemuxState is instantiated with child: None) to stop providing that field and adjust the doc comment that claims we hold the child for diagnostics. Ensure all references to child, as_mut, start_kill, and related handling are removed or refactored to use the worker task's cancellation behavior instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/ci.yml:
- Around line 166-170: The workflow currently runs the unpinned installer
command "cargo install cargo-fuzz", making CI flaky as upstream releases change;
replace that command with a pinned install (e.g., use "cargo install cargo-fuzz
--version <x.y.z>") referencing a known-good release for your CI, so fuzz smoke
tests are reproducible across runs; keep the rationale of not using --locked but
ensure the version is explicitly set for stability.
In `@src-tauri/src/commands/airplay.rs`:
- Around line 28-44: start_airplay currently locks airplay_lifecycle_lock then
cast_lifecycle_lock causing an ABBA deadlock with cast_to_device which acquires
them in the opposite order; reorder the locking in start_airplay to acquire
cast_lifecycle_lock before airplay_lifecycle_lock (or replace both with a single
unified receiver_lifecycle_lock) so both paths use the same canonical order;
update the lock acquisition in the start_airplay function and any related
cleanup paths that reference airplay_lifecycle_lock and cast_lifecycle_lock to
match the new ordering or unified lock, and adjust any comments to reflect the
new lock discipline.
In `@src-tauri/src/commands/chromecast.rs`:
- Around line 29-37: cast_to_device currently locks cast_lifecycle_lock then (on
macOS) airplay_lifecycle_lock, while start_airplay locks airplay_lifecycle_lock
then cast_lifecycle_lock, creating a deadlock risk; change cast_to_device to
acquire the locks in the same order as start_airplay by taking
airplay_lifecycle_lock first (under #[cfg(target_os = "macos")] where
applicable) and only then acquiring cast_lifecycle_lock (i.e., reorder the lock
acquisition around the blocks referencing airplay_lifecycle_lock and
cast_lifecycle_lock in cast_to_device to match start_airplay’s acquisition
order).
In `@src-tauri/src/engine/airplay.rs`:
- Around line 77-101: stop_inner currently moves MainOnly<Retained<...>>
(window, player) into a closure and discards the Result of run_on_main_thread,
risking an off-main-thread drop if dispatch fails; fix by wrapping window and
player in core::mem::ManuallyDrop before calling app.run_on_main_thread, move
those ManuallyDrop wrappers into the closure and explicitly call
ManuallyDrop::drop inside the closure so the ObjC releases happen on the main
thread, and check the Result from app.run_on_main_thread so you can retry or
schedule a guaranteed main-thread drop path on Err; key symbols: stop_inner,
run_on_main_thread, ActiveAirPlaySession, snapshot, window, player, Retained,
MainOnly, and use ManuallyDrop to prevent accidental off-thread drop.
- Around line 197-213: The window lacks an NSWindowDelegate so closing via the
title bar never triggers cleanup; attach an NSWindowDelegate implementation to
the created NSWindow (after setReleasedWhenClosed(false)) that implements
windowWillClose: and calls the existing stop_airplay (or directly invokes
stop_inner()) to perform idempotent cleanup (pause AVPlayer, clear session, drop
refs). Locate the NSWindow creation block around
NSWindow::initWithContentRect_styleMask_backing_defer and set the delegate (or
create a small delegate struct/object) that calls stop_airplay/stop_inner() on
windowWillClose: so user-triggered closes properly stop the session and update
AppState.
In `@src/App.tsx`:
- Around line 2537-2539: The fire-and-forget call to
airplay.start(buildAirPlayRequest(result)) can reject and cause unhandled
promise rejections; wrap it with a promise catch to surface failures as
controlled UI errors. Update the block where
isAirPlaySessionActive(airplay.session) is true to call
airplay.start(buildAirPlayRequest(result)).catch(err => /* surface to UI */) and
invoke your existing UI error handling (e.g., set state, call
showError/dispatch) so redirect failures are logged/shown instead of unhandled.
Ensure the catch references the same buildAirPlayRequest and airplay.start
symbols and does not swallow the error silently.
In `@src/components/CastMenu.tsx`:
- Around line 22-28: CastMenu defines a local UseAirPlayShape duplicate of the
hook's type; remove that local interface and import the canonical
UseAirPlayResult from the hook (useAirPlay.ts) instead, then replace all
references to UseAirPlayShape in the CastMenu component with UseAirPlayResult
(e.g., props, state, or function signatures) so the component uses the exported
type from useAirPlay and stays in sync with future changes.
In `@src/hooks/useAirPlay.ts`:
- Around line 64-70: The async initialization in useAirPlay's IIFE calls
detectPlatform() without handling rejections; wrap the detectPlatform() call (or
the whole IIFE body) in a try/catch and on error return early (and optionally
log) to avoid unhandled promise rejections and partial setup; ensure you still
check mounted, cancelledRef.current and only call setAvailable(true) when
platform === "macos" after a successful detectPlatform() resolution.
In `@src/lib/tauri.ts`:
- Around line 313-325: Guard the macOS-only IPC invocations in startAirplay,
stopAirplay and getAirplayStatus by checking the runtime platform before calling
invoke; if the platform is not macOS (darwin) return an explicit handled
value/error (e.g. reject startAirplay/stopAirplay with a clear Error like
"AirPlay not supported on this platform" and have getAirplayStatus resolve to
null) so invoke("start_airplay" | "stop_airplay" | "get_airplay_status") is
never called on non-macOS runtimes.
---
Outside diff comments:
In `@src-tauri/src/engine/media_proxy.rs`:
- Around line 115-134: RemuxState currently keeps a child: Option<Child> that is
never set (the worker owns the Child via tokio::spawn), so remove the dead field
and update cleanup paths: delete the child field from struct RemuxState and
remove any logic that checks or kills it in cleanup_blocking and
cleanup_remux_async; also update the struct construction sites (where RemuxState
is instantiated with child: None) to stop providing that field and adjust the
doc comment that claims we hold the child for diagnostics. Ensure all references
to child, as_mut, start_kill, and related handling are removed or refactored to
use the worker task's cancellation behavior instead.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b77966e2-2cdc-4a42-b999-8970f37eba4e
⛔ Files ignored due to path filters (1)
src-tauri/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (21)
.github/workflows/ci.ymlsrc-tauri/Cargo.tomlsrc-tauri/src/commands/airplay.rssrc-tauri/src/commands/chromecast.rssrc-tauri/src/commands/mod.rssrc-tauri/src/engine/airplay.rssrc-tauri/src/engine/media_proxy.rssrc-tauri/src/engine/mod.rssrc-tauri/src/lib.rssrc-tauri/src/models/airplay.rssrc-tauri/src/models/mod.rssrc-tauri/src/state.rssrc/App.tsxsrc/components/CastMenu.tsxsrc/components/ChannelTable.tsxsrc/components/StreamPlayer.tsxsrc/components/ThumbnailPanel.tsxsrc/hooks/useAirPlay.tssrc/lib/cast.tssrc/lib/tauri.tssrc/lib/types.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (7)
src-tauri/src/**/*.rs
📄 CodeRabbit inference engine (AGENTS.md)
src-tauri/src/**/*.rs: Rust code should use snake_case naming convention and 4-space indentation
Use thiserror crate for error types in Rust
Use serde for serialization in Rust
Before launching bun tauri dev after Rust code changes, run cargo clean -p iptv-checker && cargo build in src-tauri/ to ensure a fresh binary
Files:
src-tauri/src/models/mod.rssrc-tauri/src/commands/mod.rssrc-tauri/src/engine/mod.rssrc-tauri/src/lib.rssrc-tauri/src/engine/airplay.rssrc-tauri/src/commands/airplay.rssrc-tauri/src/models/airplay.rssrc-tauri/src/state.rssrc-tauri/src/commands/chromecast.rssrc-tauri/src/engine/media_proxy.rs
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}: Define agent interfaces and implementations in TypeScript for agent systems
Use clear naming conventions for agent classes and methods
Document agent behavior and responsibilities in comments or docstrings
Implement error handling in agent methods
Files:
src/lib/tauri.tssrc/lib/cast.tssrc/App.tsxsrc/components/ChannelTable.tsxsrc/hooks/useAirPlay.tssrc/components/StreamPlayer.tsxsrc/lib/types.tssrc/components/ThumbnailPanel.tsxsrc/components/CastMenu.tsx
src/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
TypeScript code should use strict mode and enforce no unused locals/params
Files:
src/lib/tauri.tssrc/lib/cast.tssrc/App.tsxsrc/components/ChannelTable.tsxsrc/hooks/useAirPlay.tssrc/components/StreamPlayer.tsxsrc/lib/types.tssrc/components/ThumbnailPanel.tsxsrc/components/CastMenu.tsx
src/lib/**/*.ts
📄 CodeRabbit inference engine (AGENTS.md)
Use Tauri IPC commands for frontend-backend communication instead of direct HTTP calls
Files:
src/lib/tauri.tssrc/lib/cast.tssrc/lib/types.ts
src/components/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
src/components/**/*.{ts,tsx}: Use functional components with hooks for React components
Use Tailwind CSS for styling components, no CSS-in-JS
Files:
src/components/ChannelTable.tsxsrc/components/StreamPlayer.tsxsrc/components/ThumbnailPanel.tsxsrc/components/CastMenu.tsx
src/components/ChannelTable.tsx
📄 CodeRabbit inference engine (AGENTS.md)
Use
@tanstack/react-virtualfor virtualizing tables with 1000+ items
Files:
src/components/ChannelTable.tsx
src/lib/types.ts
📄 CodeRabbit inference engine (AGENTS.md)
Define and maintain data types in lib/types.ts that mirror Rust models
Files:
src/lib/types.ts
🔇 Additional comments (7)
src-tauri/src/models/airplay.rs (1)
5-33: Model definitions are consistent and IPC-ready.Serde casing strategy and Rust↔TS shape mapping look clean here.
src-tauri/src/models/mod.rs (1)
1-1: AirPlay model module export looks correct.src-tauri/src/commands/mod.rs (1)
1-2: macOS command gating is correctly scoped.src-tauri/Cargo.toml (1)
59-63: macOS-only dependency additions are correctly target-scoped.src-tauri/src/engine/mod.rs (1)
1-5: Engine module wiring is coherent for receiver split + macOS AirPlay.src/components/ThumbnailPanel.tsx (1)
401-422: Receiver-session visibility logic is a solid UX safeguard.Keeping controls visible while either cast session is active is the right behavior.
src/lib/types.ts (1)
462-483: AirPlay TS models look correctly aligned with backend contract.
| if (isAirPlaySessionActive(airplay.session)) { | ||
| void airplay.start(buildAirPlayRequest(result)); | ||
| return; |
There was a problem hiding this comment.
Handle AirPlay redirect failures to prevent unhandled promise rejections.
At Line 2538, airplay.start(...) is invoked fire-and-forget even though the hook method can reject. Add a catch so failed redirects surface as controlled UI errors.
Proposed fix
if (isAirPlaySessionActive(airplay.session)) {
- void airplay.start(buildAirPlayRequest(result));
+ void airplay.start(buildAirPlayRequest(result)).catch((err) => {
+ logger.error("[AirPlay] Redirect failed:", errorToString(err));
+ getStore().setPlaybackError(errorToString(err));
+ });
return;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (isAirPlaySessionActive(airplay.session)) { | |
| void airplay.start(buildAirPlayRequest(result)); | |
| return; | |
| if (isAirPlaySessionActive(airplay.session)) { | |
| void airplay.start(buildAirPlayRequest(result)).catch((err) => { | |
| logger.error("[AirPlay] Redirect failed:", errorToString(err)); | |
| getStore().setPlaybackError(errorToString(err)); | |
| }); | |
| return; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/App.tsx` around lines 2537 - 2539, The fire-and-forget call to
airplay.start(buildAirPlayRequest(result)) can reject and cause unhandled
promise rejections; wrap it with a promise catch to surface failures as
controlled UI errors. Update the block where
isAirPlaySessionActive(airplay.session) is true to call
airplay.start(buildAirPlayRequest(result)).catch(err => /* surface to UI */) and
invoke your existing UI error handling (e.g., set state, call
showError/dispatch) so redirect failures are logged/shown instead of unhandled.
Ensure the catch references the same buildAirPlayRequest and airplay.start
symbols and does not swallow the error silently.
The two commands acquired the cast/airplay lifecycle locks in opposite orders: start_airplay took airplay first then cast, while cast_to_device already took cast first then airplay. Concurrent invocations could circular-wait indefinitely. Establish a canonical order — cast lifecycle lock first, then airplay lifecycle lock — by flipping the order in start_airplay to match cast_to_device. The teardown semantics (mutual exclusion: kill any live cast before opening a new AirPlay upstream slot) are unchanged. Also extract the airplay teardown body into a `teardown_airplay` helper so the upcoming NSWindowDelegate (next commit) can route a user- triggered window close through the same idempotent shutdown path the explicit stop_airplay command uses.
Two related safety issues in the AirPlay engine: 1. Without an NSWindowDelegate, closing the AVPlayer window via the title-bar "×" left the session "playing" in app state — AVPlayer kept consuming the upstream proxy slot, the cast-redirect gate stayed armed, and the user couldn't open a new local stream until they manually clicked stop. Add a delegate that routes windowWillClose through the same teardown_airplay path the explicit stop_airplay command uses. The delegate is retained on the session struct (NSWindow.setDelegate is weak), and a per-instance atomic guards against double-fire when our own stop_inner closes the window programmatically. 2. stop_inner moved the MainOnly<Retained<...>> handles into a run_on_main_thread closure but discarded the dispatch Result. If the closure failed to dispatch (event loop torn down during app shutdown) the closure was dropped on the calling tokio worker, which in turn dropped the Retained<NSObject> handles off the main thread — directly violating the MainOnly invariant documented in the file header. Wrap the handles in ManuallyDrop, take ownership back inside the closure, and on dispatch failure log + leak rather than release off-thread.
The AirPlay Tauri commands are gated on macOS in lib.rs, so calling them on Windows/Linux returns an opaque "command not found" error from the webview layer. Add a synchronous platform check at the wrapper level so: - startAirplay throws a clearly-named error on non-mac. - stopAirplay / getAirplayStatus return early with a sensible default. Also harden useAirPlay's effect: a detectPlatform() rejection (e.g. tauri-plugin-os not yet ready) was escaping the async IIFE as an unhandled promise rejection. Catch it and treat as un-`available` — the hook stays inert rather than crashing the effect.
CastMenu was duplicating useAirPlay's return type as a local UseAirPlayShape interface. Import UseAirPlayResult directly so future hook changes flow through automatically (StreamPlayer / ThumbnailPanel already use the hook's exported type). In App.tsx the redirect branches in handlePlayInApp invoked chromecast.cast / airplay.start as fire-and-forget. Both hooks already mirror failures into their own `error` state (which CastMenu renders), but the rejection still escaped here as an unhandled promise. Add an empty .catch on each branch — the user-facing error surface is the hook's error, not this call site.
Unpinned `cargo install cargo-fuzz` lets upstream releases drift CI behavior between PR runs. Pin to 0.13.1 (the current stable as of this branch) so fuzz smoke tests are reproducible. Keep the no-`--locked` rationale comment intact — that workaround is still needed because cargo-fuzz's pinned Cargo.lock pulls a rustix that won't compile on current nightlies.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/hooks/useAirPlay.ts`:
- Around line 31-37: In the catch block inside useAirPlay (where you currently
call setError(String(err)) and rethrow), replace String(err) with a robust
converter by importing and using the existing errorToString helper from App.tsx
(or hoist errorToString into a shared util) so object-shaped errors (e.g., {
message }) render correctly; update the import/exports or move the helper to a
common module and call setError(errorToString(err)) while keeping the
cancellation guard and rethrow logic intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 80c7ca51-3848-46c3-b66b-28f9f2c28b65
📒 Files selected for processing (7)
.github/workflows/ci.ymlsrc-tauri/src/commands/airplay.rssrc-tauri/src/engine/airplay.rssrc/App.tsxsrc/components/CastMenu.tsxsrc/hooks/useAirPlay.tssrc/lib/tauri.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (5)
src-tauri/src/**/*.rs
📄 CodeRabbit inference engine (AGENTS.md)
src-tauri/src/**/*.rs: Rust code should use snake_case naming convention and 4-space indentation
Use thiserror crate for error types in Rust
Use serde for serialization in Rust
Before launching bun tauri dev after Rust code changes, run cargo clean -p iptv-checker && cargo build in src-tauri/ to ensure a fresh binary
Files:
src-tauri/src/commands/airplay.rssrc-tauri/src/engine/airplay.rs
**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.{ts,tsx}: Define agent interfaces and implementations in TypeScript for agent systems
Use clear naming conventions for agent classes and methods
Document agent behavior and responsibilities in comments or docstrings
Implement error handling in agent methods
Files:
src/components/CastMenu.tsxsrc/lib/tauri.tssrc/App.tsxsrc/hooks/useAirPlay.ts
src/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
TypeScript code should use strict mode and enforce no unused locals/params
Files:
src/components/CastMenu.tsxsrc/lib/tauri.tssrc/App.tsxsrc/hooks/useAirPlay.ts
src/components/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
src/components/**/*.{ts,tsx}: Use functional components with hooks for React components
Use Tailwind CSS for styling components, no CSS-in-JS
Files:
src/components/CastMenu.tsx
src/lib/**/*.ts
📄 CodeRabbit inference engine (AGENTS.md)
Use Tauri IPC commands for frontend-backend communication instead of direct HTTP calls
Files:
src/lib/tauri.ts
🔇 Additional comments (9)
.github/workflows/ci.yml (1)
166-172: Good CI hardening and clear install rationale.Pinning
cargo-fuzzwhile explicitly documenting why--lockedis avoided makes the fuzz job reproducible and easier to maintain.src/App.tsx (1)
2536-2549: LGTM — addresses prior unhandled-rejection concern.The
.catch(() => {})swallow plus inline comment correctly mirror the cast branch's pattern, relying onairplay.errorfor UI surfacing. The earlyreturn(without local-play fallback) is consistent with the PR's documentedreplaceCurrentItem/single-upstream-slot rationale.src/lib/tauri.ts (1)
313-342: LGTM — platform guards correctly added.
ensureAirPlaySupportedprovides a clear error forstartAirplay, whilestopAirplay/getAirplayStatuscorrectly no-op/return-null on non-macOS. This addresses the prior concern about unknown-command failures from non-mac call paths.src/components/CastMenu.tsx (1)
10-10: LGTM — UseAirPlayResult import and AirPlay flow look correct.Importing the canonical
UseAirPlayResultremoves the duplicate type definition raised in prior review. The popover/inline AirPlay sections both correctly gate onairplayAvailableand mirror the cast-flow pattern (fireonAirPlayStartbefore awaiting; surface errors viaairplay.error).Also applies to: 93-97, 151-172
src-tauri/src/commands/airplay.rs (2)
26-77: LGTM — ABBA deadlock fix verified.Lock acquisition order (
cast_lifecycle_lock→airplay_lifecycle_lock) now matchescast_to_device, eliminating the prior deadlock risk. The error path correctly shuts down the freshly created proxy on session-start failure (line 75), and the chromecast preemption is sequenced after locks are held.
100-121: LGTM —teardown_airplayis correctly idempotent.Taking session/proxy out under the lifecycle lock makes repeated calls safe (second call sees
Noneand no-ops), which is exactly what thewindowWillClose:delegate path needs.src-tauri/src/engine/airplay.rs (2)
85-127: LGTM —ManuallyDropcorrectly preserves theMainOnlyinvariant.The
run_on_main_threaderror branch intentionally leaks the Cocoa handles rather than dropping them off-thread, matching the safety contract documented at lines 41–44. Thestoppedswap-and-return at line 86 plus single-consumption (self-by-value) make this fully idempotent.
134-175: LGTM —NSWindowDelegateplus strong ref correctly handle user-driven close.The
firedAtomicBool prevents the delegate from re-spawning teardown whenstop_innerprogrammatically closes the window, and the strong_delegatefield onActiveAirPlaySessioncorrectly outlivesNSWindow's weak delegate reference. Spawning the tokio task fromwindow_will_closeavoids holding the main thread on the lifecycle lock.Also applies to: 307-321
src/hooks/useAirPlay.ts (1)
82-96: ⚡ Quick winNo issue —
AirPlaySessionStatevariants serialize correctly as lowercase strings.The
AirPlaySessionStateenum insrc-tauri/src/models/airplay.rsuses#[serde(rename_all = "snake_case")], which serializes theStoppedandErrorvariants as"stopped"and"error"respectively. The TS comparisons at lines 85 and 91 are correct and will properly clear the session and error state as intended.> Likely an incorrect or invalid review comment.
| } catch (err) { | ||
| if (!cancelledRef.current) { | ||
| setError(String(err)); | ||
| setSession(null); | ||
| } | ||
| throw err; | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | 💤 Low value
Past concerns addressed; minor: String(err) produces [object Object] for plain-object errors.
Tauri rejections often surface as strings, but for object-shaped errors ({ message: ... }) String(err) yields "[object Object]". Consider reusing the existing errorToString helper from App.tsx (or hoisting it) for consistent error display. The detectPlatform() catch and cancellation guards correctly address prior review feedback.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/hooks/useAirPlay.ts` around lines 31 - 37, In the catch block inside
useAirPlay (where you currently call setError(String(err)) and rethrow), replace
String(err) with a robust converter by importing and using the existing
errorToString helper from App.tsx (or hoist errorToString into a shared util) so
object-shaped errors (e.g., { message }) render correctly; update the
import/exports or move the helper to a common module and call
setError(errorToString(err)) while keeping the cancellation guard and rethrow
logic intact.
Single-credential IPTV portals reject the receiver with HTTP 458 when the local stream player still holds the upstream slot, aborting the remux before its manifest is ready. Add a shared CancellationToken in AppState that each localhost streaming-proxy connection races against, and have start_airplay / cast_to_device cancel + briefly wait so the upstream session is freed before ffmpeg connects. Also rename the wait_for_manifest cancellation error from "Cast remux" to "Receiver remux" now that the proxy serves both Cast and AirPlay.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src-tauri/src/commands/chromecast.rs`:
- Around line 76-87: The same-device hot-swap path in same_device_take currently
starts the new media_proxy::start while prior_proxy is still alive, which allows
overlapping MPEG-TS upstream sessions and can trigger HTTP 458; change the
sequence so the old receiver/proxy is torn down before starting a new remux for
CastStreamKind::MpegTs (either call the prior_proxy shutdown/stop method or
drop/prioritize cancellation of the prior_proxy prior to invoking
media_proxy::start, or alternatively make media_proxy::start lazy for MpegTs
until after the receiver switch completes) so no two upstream sessions overlap
during the handoff.
In `@src-tauri/src/engine/stream_proxy.rs`:
- Around line 39-49: The current swap installs a fresh CancellationToken into
state.local_stream_cancel before cancelling the old one, allowing reconnecting
local players to capture the fresh token during the LOCAL_STREAM_CANCEL_GRACE
window; change the logic in cancel_active_local_streams so new connections are
blocked during the grace period — either by first cancelling the old token (call
old.cancel()), then await LOCAL_STREAM_CANCEL_GRACE, and only after the sleep
replace the state.local_stream_cancel with a new CancellationToken, or else
replace the state token with a pre-cancelled “blocker” token (a
CancellationToken that's already cancelled) to prevent
current_local_stream_cancel snapshots in handle_streaming_connection from being
usable until after the grace window, then swap in a fresh CancellationToken when
the grace period ends; update references to local_stream_cancel,
CancellationToken::new(), old.cancel(), LOCAL_STREAM_CANCEL_GRACE,
current_local_stream_cancel, and handle_streaming_connection accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4f89abb9-8e07-4849-9e4e-94cb5a5ff3ea
📒 Files selected for processing (5)
src-tauri/src/commands/airplay.rssrc-tauri/src/commands/chromecast.rssrc-tauri/src/engine/media_proxy.rssrc-tauri/src/engine/stream_proxy.rssrc-tauri/src/state.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
- GitHub Check: Build (windows-latest)
- GitHub Check: Parser Fuzz Smoke
- GitHub Check: Build (ubuntu-22.04)
- GitHub Check: Build (macos-latest)
- GitHub Check: Rust Tests
🧰 Additional context used
📓 Path-based instructions (1)
src-tauri/src/**/*.rs
📄 CodeRabbit inference engine (AGENTS.md)
src-tauri/src/**/*.rs: Rust code should use snake_case naming convention and 4-space indentation
Use thiserror crate for error types in Rust
Use serde for serialization in Rust
Before launching bun tauri dev after Rust code changes, run cargo clean -p iptv-checker && cargo build in src-tauri/ to ensure a fresh binary
Files:
src-tauri/src/commands/chromecast.rssrc-tauri/src/state.rssrc-tauri/src/commands/airplay.rssrc-tauri/src/engine/stream_proxy.rssrc-tauri/src/engine/media_proxy.rs
| if let Some((mut active, prior_proxy)) = same_device_take { | ||
| match cast_proxy::start( | ||
| // Same as the fresh-launch path below: drop any local-player upstream | ||
| // fetch so single-credential IPTV portals don't reject ffmpeg's load | ||
| // with HTTP 458. | ||
| stream_proxy::cancel_active_local_streams(state.inner()).await; | ||
|
|
||
| match media_proxy::start( | ||
| app.clone(), | ||
| request.original_url.clone(), | ||
| request.stream_kind, | ||
| ReceiverProfile::Chromecast, | ||
| ) |
There was a problem hiding this comment.
Avoid overlapping the old and new MPEG-TS upstream sessions during same-device hot-swap.
Line 82 starts the new media_proxy while the old prior_proxy stays alive until Lines 103-119. For CastStreamKind::MpegTs, that means ffmpeg can open the new upstream before the old receiver path has released its slot, so same-device channel changes can still hit the HTTP 458 failure this PR is mitigating. Either tear down the old proxy before starting the new remux, or make remux startup lazy until after the receiver has switched.
Also applies to: 103-119
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/commands/chromecast.rs` around lines 76 - 87, The same-device
hot-swap path in same_device_take currently starts the new media_proxy::start
while prior_proxy is still alive, which allows overlapping MPEG-TS upstream
sessions and can trigger HTTP 458; change the sequence so the old receiver/proxy
is torn down before starting a new remux for CastStreamKind::MpegTs (either call
the prior_proxy shutdown/stop method or drop/prioritize cancellation of the
prior_proxy prior to invoking media_proxy::start, or alternatively make
media_proxy::start lazy for MpegTs until after the receiver switch completes) so
no two upstream sessions overlap during the handoff.
| pub async fn cancel_active_local_streams(state: &AppState) { | ||
| let old = { | ||
| let mut guard = state.local_stream_cancel.lock().await; | ||
| std::mem::replace(&mut *guard, CancellationToken::new()) | ||
| }; | ||
| old.cancel(); | ||
| log::info!( | ||
| "[StreamProxy] Released upstream slot (waiting {}ms for server cleanup)", | ||
| LOCAL_STREAM_CANCEL_GRACE.as_millis() | ||
| ); | ||
| tokio::time::sleep(LOCAL_STREAM_CANCEL_GRACE).await; |
There was a problem hiding this comment.
The token swap still allows local-player reconnects during the grace window.
Lines 40-42 install a fresh CancellationToken before cancelling the old one, and Lines 593-606 make each new connection capture whatever token is current when it is accepted. A local player that reconnects during the 250 ms sleep will grab the fresh token and keep the upstream slot alive, so Chromecast/AirPlay can still lose the race with HTTP 458. This needs a temporary “block new local streams” phase, or the token reset has to happen after the protected window closes.
#!/bin/bash
set -euo pipefail
echo "Inspect replace/cancel ordering in stream_proxy:"
rg -n -C3 'cancel_active_local_streams|std::mem::replace|old\.cancel|LOCAL_STREAM_CANCEL_GRACE' \
src-tauri/src/engine/stream_proxy.rs
echo
echo "Inspect where new connections snapshot the current token:"
rg -n -C3 'current_local_stream_cancel|cancel\.cancelled\(\)|handle_streaming_connection' \
src-tauri/src/engine/stream_proxy.rs
echo
echo "Inspect receiver call sites that rely on this handoff:"
rg -n -C2 'cancel_active_local_streams' \
src-tauri/src/commands/chromecast.rs \
src-tauri/src/commands/airplay.rsAlso applies to: 52-54, 587-606
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/engine/stream_proxy.rs` around lines 39 - 49, The current swap
installs a fresh CancellationToken into state.local_stream_cancel before
cancelling the old one, allowing reconnecting local players to capture the fresh
token during the LOCAL_STREAM_CANCEL_GRACE window; change the logic in
cancel_active_local_streams so new connections are blocked during the grace
period — either by first cancelling the old token (call old.cancel()), then
await LOCAL_STREAM_CANCEL_GRACE, and only after the sleep replace the
state.local_stream_cancel with a new CancellationToken, or else replace the
state token with a pre-cancelled “blocker” token (a CancellationToken that's
already cancelled) to prevent current_local_stream_cancel snapshots in
handle_streaming_connection from being usable until after the grace window, then
swap in a fresh CancellationToken when the grace period ends; update references
to local_stream_cancel, CancellationToken::new(), old.cancel(),
LOCAL_STREAM_CANCEL_GRACE, current_local_stream_cancel, and
handle_streaming_connection accordingly.
|
I've decided that I don't want this, Chromecast support is enough. |
Summary
Adds macOS-only AirPlay support so users can stream IPTV channels to an Apple TV. Cross-platform AirPlay 2 is blocked by needing to port Pair-Setup/Pair-Verify crypto to Rust (~9–13 days, ongoing maintenance burden — no maintained Rust AirPlay 2 sender crate exists). This PR delegates pairing/encryption/discovery to Apple's
AVKitframework, which ships natively on macOS.Important
Stacked on #192 (Chromecast support). Will rebase onto
mainafter #192 lands. Review against the chromecast branch base.Why this is more than a thin AVKit wrapper
The chromecast PR surfaced a long tail of IPTV-specific problems that apply identically to AirPlay:
AVURLAssetHTTPHeaderFieldsKeyheaders. ATV sends its ownAppleCoreMedia/...UA. IPTV portals that filter by UA fail without going through the LAN proxy.So the cast proxy at `engine/cast_proxy.rs` is shared, parameterized on a `ReceiverProfile` enum that gates CORS / DASH+fMP4 / AAC-transcode / hvc1-tag — these are Chromecast-specific behaviors that AirPlay drops.
Scope
New files
Modified
Implementation checklist
Test plan
Requires Apple TV 4K on the same LAN, single-credential IPTV provider:
Risks
Out of scope
Summary by CodeRabbit
New Features
Refactor
Bug Fixes
Chores