Initial implementation of Wayland AirPlay desktop sharing
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/target/
|
||||
/.cargo-home/
|
||||
/.state/
|
||||
.airplay_sender_identity_*.json
|
||||
Generated
+7487
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
||||
[package]
|
||||
name = "spielab"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-2.0-only"
|
||||
description = "A native Wayland AirPlay sender built with Rust and GPUI"
|
||||
|
||||
[features]
|
||||
default = ["gui"]
|
||||
gui = ["dep:gpui"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
async-channel = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "macros", "io-util", "process"] }
|
||||
gpui = { version = "=0.2.2", default-features = false, features = ["wayland"], optional = true }
|
||||
airplay-core = { git = "https://github.com/lmcgartland/airplay2-rs", rev = "a7f019fe6246ebd9701201a8a5c31e9a15243956" }
|
||||
airplay-discovery = { git = "https://github.com/lmcgartland/airplay2-rs", rev = "a7f019fe6246ebd9701201a8a5c31e9a15243956" }
|
||||
airplay-rtsp = { git = "https://github.com/lmcgartland/airplay2-rs", rev = "a7f019fe6246ebd9701201a8a5c31e9a15243956" }
|
||||
|
||||
ashpd = { version = "0.12.3", default-features = false, features = ["async-std"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
airplay-pairing = { git = "https://github.com/lmcgartland/airplay2-rs", rev = "a7f019fe6246ebd9701201a8a5c31e9a15243956" }
|
||||
airplay-crypto = { git = "https://github.com/lmcgartland/airplay2-rs", rev = "a7f019fe6246ebd9701201a8a5c31e9a15243956" }
|
||||
|
||||
plist = "1"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
chacha20poly1305 = "0.10"
|
||||
rand = "0.8"
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
symphonia-codec-alac = "0.5.5"
|
||||
symphonia-core = "0.5.5"
|
||||
tempfile = "3"
|
||||
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
|
||||
# The development runner is also used for live streaming. Keep per-frame
|
||||
# packet parsing and encryption optimized without rebuilding all GPUI deps.
|
||||
[profile.dev.package.spielab]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package.chacha20poly1305]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.chacha20]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.poly1305]
|
||||
opt-level = 3
|
||||
|
||||
[[bin]]
|
||||
name = "spielab"
|
||||
path = "src/main.rs"
|
||||
required-features = ["gui"]
|
||||
@@ -0,0 +1,338 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Spielab
|
||||
|
||||
A Rust + GPUI AirPlay sender for a Linux Wayland desktop. Discover Apple TV, pair with its on-screen code, choose a monitor or window using the desktop's screen-sharing dialog, and stream it with system audio.
|
||||
|
||||
The media mode is H.264 with selectable **720p/30**, **1080p/30** (default), or **1080p/60**, with stereo **44.1 kHz ALAC** audio. **Interactive** uses a 50 ms playback lead by default; **Responsive** offers 100 ms and **Buffered** restores the previous 500 ms lead for connections that stutter. This is a requested playback buffer, not a measurement of total input-to-display latency. It targets modern Apple TVs with HomeKit pairing and PTP screen-stream timing. Legacy FairPlay-only receivers and NTP-only receivers are not supported yet.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
./scripts/run.sh
|
||||
```
|
||||
|
||||
Select your Apple TV, enter the code if prompted, choose a screen/window, then click **Start sharing**. **Disconnect / cancel** or Escape stops the session. The initial pairing is saved for subsequent connections.
|
||||
|
||||
In the desktop picker, **single-click exactly one source, then press Share**. Avoid double-clicking: KDE portal 6.7.4 can abort when a source card accepts the dialog from its own click handler. Spielab requests checkbox selection to avoid that path; the workaround still needs interactive verification on this host.
|
||||
|
||||
Choose quality before starting. The presets cap video bitrate at 8, 20, and 30 Mbps respectively. The 1080p presets preserve more detail; 60 fps needs more CPU and network capacity. Capture is scaled with the original aspect ratio preserved.
|
||||
|
||||
GPU H.264 encoding is selected automatically through [FFmpeg's VA-API encoder](https://ffmpeg.org/ffmpeg-codecs.html#VAAPI-encoders) when available. Before sharing, Spielab tests actual encoded frames at the selected resolution and frame rate on accessible DRM render devices. If none pass, it uses software H.264. The streaming status shows **GPU H.264 · VA-API** or **Software H.264**. AMD Radeon 890M hardware encoding has been verified on this host; other VA-API drivers are selected only if their probe passes. NVENC and other hardware APIs are not implemented.
|
||||
|
||||
The GPU path uses VBR at a target of 75% of the selected bitrate cap, no B-frames, and an async depth of one. Software uses the existing CRF presets. Encoding runs on the GPU; desktop capture, scaling and transfer to GPU memory still involve the CPU. A device failure after the startup probe stops sharing with an error; it does not switch codecs or encoders in a running session.
|
||||
|
||||
For responsiveness, start with **Interactive** and **1080p/60** if the host can encode it in real time. Try **720p/30** if encoding cannot keep up. Latency mode updates video SETUP, video presentation timestamps, audio SETUP, and audio synchronization together. Capture starts after receiver setup, the raw capture queue retains only the newest waiting frame, and encoded packets flush immediately. A separate packet-length pipe identifies completed encoded frames immediately, avoiding the previous wait for the next frame. Control feedback runs concurrently with media delivery. Network transport, encoding, decoding, and the TV's own display processing still add delay. Actual end-to-end latency requires measurement on the receiver.
|
||||
|
||||
**Create extended display** requests a separate virtual monitor when the desktop portal advertises support. Select it and press Share, then start sharing. Move windows onto it using your desktop's usual display controls. Disconnecting closes the portal session and removes the temporary monitor. This feature depends on the compositor and portal; creating the monitor has not yet been verified after the KDE crash workaround.
|
||||
|
||||
Required on the Linux host:
|
||||
|
||||
- A Wayland session, Vulkan-capable GPU drivers, and a working ScreenCast desktop portal (KDE, GNOME, or an appropriate compositor backend).
|
||||
- Rust and the GPUI native build dependencies: a C linker, pkg-config, FreeType, Fontconfig, libxkbcommon and libxkbcommon-x11.
|
||||
- `gst-launch-1.0` with `pipewiresrc`, `queue`, `videoconvert`, `videoscale`, `videorate`, `y4menc`, and `fdsink`. The PipeWire plugin must support `keepalive-time`.
|
||||
- `ffmpeg` with the `libx264` encoder, and PipeWire's `pw-cat` utility. Hardware encoding additionally needs `h264_vaapi`, a VA-API driver with H.264 encoding support, and access to `/dev/dri/renderD*`.
|
||||
- Apple TV on the same reachable network with AirPlay enabled.
|
||||
|
||||
The runner handles the missing libxkbcommon-x11 development symlink on this host using a local build directory. A normal `cargo build --locked` also works when all development libraries are installed.
|
||||
|
||||
The development profile optimizes Spielab's packet handling and ChaCha20-Poly1305 dependencies because the development runner is used for live sharing. On this host the isolated 64 KiB video-packet encryption diagnostic fell from approximately 7.8 ms to 0.057 ms per packet. This microbenchmark does not measure display latency.
|
||||
|
||||
## How it works
|
||||
|
||||
- GPUI renders directly through its Wayland backend; the X11 backend is disabled.
|
||||
- A separate Tokio worker handles discovery, pairing, network requests, and cancellation.
|
||||
- The XDG ScreenCast portal grants a PipeWire node and remote FD. Both the portal session and FD remain owned until sharing ends; cancelling closes the session.
|
||||
- GStreamer reads the authorized PipeWire source. FFmpeg encodes H.264 with [immediate packet flushing](https://ffmpeg.org/ffmpeg-formats.html); Rust assembles access units, sends the decoder configuration, and encrypts each video packet with ChaCha20-Poly1305 using the complete header as authenticated data.
|
||||
- `pw-cat` captures the default output's monitor, not the microphone. Rust encodes PCM into ALAC, encrypts RTP audio, answers retransmission requests from a bounded history, and sends timing announcements on the negotiated control port.
|
||||
- Video and audio share the receiver's PTP timeline, anchored using Apple TV's response timestamps. Encrypted event-channel commands, feedback, and video heartbeats keep the session alive.
|
||||
|
||||
Pairing identities are saved with mode 0600 in a directory with mode 0700. The default is `$XDG_STATE_HOME/spielab` or `~/.local/state/spielab`; `SPIELAB_STATE_DIR` overrides it. The development runner reuses this checkout's `.state` directory if it exists. Identity files are excluded from version control.
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
cargo test --no-default-features
|
||||
cargo test --no-default-features --test media_runtime -- --ignored
|
||||
cargo test --no-default-features --lib -- --ignored
|
||||
cargo check --all-targets
|
||||
```
|
||||
|
||||
Tests cover authenticated video framing and nonce separation, signed 64-bit clock IDs, audio/video playback lead agreement, PIN validation, private identity replacement, ALAC decoding through an independent decoder, and real H.264 encode/parse/decode round trips for every quality preset, including decoded dimensions and frame rate. An additional timed test checks that tiny, static frames arrive continuously without waiting for the output buffer to fill. The ignored runtime tests require FFmpeg.
|
||||
|
||||
The ignored hardware test requires VA-API H.264 on `/dev/dri/renderD128`. It checks automatic GPU selection and independently decodes more than one GOP at every quality preset. The ignored library tests also verify that an unavailable render device falls back to a working software encoder. On hosts without VA-API, run the software integration tests individually instead of all ignored integration tests.
|
||||
|
||||
Hardware smoke tests:
|
||||
|
||||
```sh
|
||||
cargo run --no-default-features --example discover
|
||||
SPIELAB_STATE_DIR=.state cargo run --no-default-features --example pair -- 'Living Room'
|
||||
SPIELAB_STATE_DIR=.state cargo run --no-default-features --example mirror -- 'Living Room' --test
|
||||
SPIELAB_STATE_DIR=.state cargo run --no-default-features --example mirror -- 'Living Room'
|
||||
SPIELAB_STATE_DIR=.state cargo run --no-default-features --example mirror -- 'Living Room' --extend --quality=1080p60 --latency=50
|
||||
```
|
||||
|
||||
The mirror example stops after 30 seconds; set `SPIELAB_TEST_SECONDS` to change that. Its `--test` mode sends a moving test card with the same audio/session path. Without that flag it opens the screen picker.
|
||||
|
||||
During development, an AppleTV14,1 accepted saved-identity pair verification, encrypted control/video/audio setup, and a 20-second, 600-frame test stream. Live Wayland capture and output-monitor audio have also been connected through the GPUI app. Visible picture, audible sound, and A/V synchronization still need receiver-side confirmation; sending packets alone does not prove playback.
|
||||
|
||||
The responsive-mode smoke test also completed: Apple TV accepted the 100 ms setup and the sender transmitted 600 frames at 1080p/60 over ten seconds. This validates negotiation and transmission, not measured input latency or receiver-side A/V synchronization.
|
||||
|
||||
The same ten-second, 600-frame Apple TV test passed with Radeon 890M VA-API hardware H.264 encoding. All quality presets also passed independent decoding through FFmpeg, and the unavailable-device fallback test passed. End-to-end latency has not been measured.
|
||||
|
||||
The 50 ms mode completed a 15-second, 900-frame 1080p/60 GPU test. Packet-length framing also passes a regression test delivering one complete frame with no subsequent frame. The concurrent-feedback implementation kept measured encoded-frame queue wait below 1 ms after startup during this test, while control replies reached 17 ms. These results validate individual sender improvements, not receiver-side presentation timing.
|
||||
|
||||
After enabling compiler optimization for the packet path, a further 600-frame test passed. Steady-state one-second intervals reported 60 frames with maximum measured queue wait and encrypt/write duration both below 1 ms.
|
||||
|
||||
## Protocol references
|
||||
|
||||
[airplay2-rs](https://github.com/lmcgartland/airplay2-rs/tree/a7f019fe6246ebd9701201a8a5c31e9a15243956) supplies the pinned Rust discovery, cryptographic pairing, and RTSP libraries. The mirroring wire layout and stream negotiation were researched against [Doubletake](https://github.com/omarroth/doubletake) and [UxPlay](https://github.com/FDH2/UxPlay). Spielab's session, capture, packetization, and UI code are in this repository.
|
||||
|
||||
Sender diagnostics log completed frames per interval, maximum encoded-frame queue time, maximum encrypt/write time, and control-response time. These exclude capture, GPU encoding, network delivery after the socket write, and receiver display time; they are not end-to-end latency measurements.
|
||||
@@ -0,0 +1,19 @@
|
||||
use airplay_discovery::{Discovery, ServiceBrowser};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let browser = ServiceBrowser::new()?;
|
||||
let devices = browser.scan(Duration::from_secs(6)).await?;
|
||||
println!("Found {} AirPlay receivers", devices.len());
|
||||
for device in devices {
|
||||
println!(
|
||||
"{} | {} | {:?} | {}",
|
||||
device.name,
|
||||
device.model,
|
||||
device.socket_addr(),
|
||||
device.id.to_mac_string()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Run the real mirroring path. --test uses a moving test card in place of a desktop.
|
||||
use airplay_discovery::{Discovery, ServiceBrowser};
|
||||
use anyhow::Context;
|
||||
use spielab::{
|
||||
capture::ScreenCapture,
|
||||
media::{AudioCapture, VideoCapture, VideoEncoder},
|
||||
mirror::MirrorSession,
|
||||
pairing::{ControlSession, CredentialStore},
|
||||
settings::{CaptureMode, LatencyMode, VideoQuality},
|
||||
};
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let name = args.get(1).context(
|
||||
"Usage: mirror 'Receiver name' [--test | --extend] [--quality=720p30|1080p30|1080p60] [--latency=50|100|500]",
|
||||
)?;
|
||||
let state = std::env::var_os("SPIELAB_STATE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.context("Set SPIELAB_STATE_DIR")?;
|
||||
let store = CredentialStore::new(state)?;
|
||||
let browser = ServiceBrowser::new()?;
|
||||
let device = browser
|
||||
.scan(Duration::from_secs(5))
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|d| &d.name == name)
|
||||
.context("Receiver not found")?;
|
||||
let credentials = store.load(&device)?.context("Pair this receiver first")?;
|
||||
anyhow::ensure!(
|
||||
!(args.iter().any(|a| a == "--test") && args.iter().any(|a| a == "--extend")),
|
||||
"Use --test or --extend, not both"
|
||||
);
|
||||
let quality = match args.iter().find_map(|arg| arg.strip_prefix("--quality=")) {
|
||||
None | Some("1080p30") => VideoQuality::High,
|
||||
Some("720p30") => VideoQuality::Efficient,
|
||||
Some("1080p60") => VideoQuality::Smooth,
|
||||
Some(value) => anyhow::bail!("Unknown quality: {value}"),
|
||||
};
|
||||
let config = quality.config();
|
||||
let encoder = VideoEncoder::detect(config).await;
|
||||
println!("Encoder: {}", encoder.label());
|
||||
let latency = match args.iter().find_map(|arg| arg.strip_prefix("--latency=")) {
|
||||
None | Some("50") => LatencyMode::Interactive,
|
||||
Some("100") => LatencyMode::Responsive,
|
||||
Some("500") => LatencyMode::Buffered,
|
||||
Some(value) => anyhow::bail!("Unknown latency: {value}"),
|
||||
};
|
||||
let mode = if args.iter().any(|a| a == "--extend") {
|
||||
CaptureMode::Extend
|
||||
} else {
|
||||
CaptureMode::Mirror
|
||||
};
|
||||
let screen = if args.iter().any(|a| a == "--test") {
|
||||
None
|
||||
} else {
|
||||
Some(ScreenCapture::select_mode(mode).await?)
|
||||
};
|
||||
println!("Capture: {} · {}", mode.label(), quality.label());
|
||||
let mut control = ControlSession::open(device).await?;
|
||||
control.verify(&credentials).await?;
|
||||
println!("Pair verification succeeded; setting up mirroring");
|
||||
let result = async {
|
||||
let mut mirror =
|
||||
MirrorSession::setup_with_latency(&mut control, config.width, config.height, latency)
|
||||
.await?;
|
||||
let video = VideoCapture::start_with_encoder(screen.as_ref(), config, &encoder)?;
|
||||
let audio = AudioCapture::start()?;
|
||||
println!("Video and screen-audio SETUP accepted");
|
||||
let duration = std::env::var("SPIELAB_TEST_SECONDS")
|
||||
.unwrap_or("30".into())
|
||||
.parse::<u64>()?;
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(duration),
|
||||
mirror.run(&mut control, video, audio, |frames| {
|
||||
println!("Sent {frames} video frames")
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
let _ = control.stop_mirroring().await;
|
||||
let _ = control.transport.close().await;
|
||||
if let Some(screen) = screen {
|
||||
let _ = screen.close().await;
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Interactive pairing smoke test, sharing the GUI's exact protocol implementation.
|
||||
use airplay_discovery::{Discovery, ServiceBrowser};
|
||||
use anyhow::Context;
|
||||
use spielab::pairing::{ControlSession, CredentialStore};
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let name = std::env::args()
|
||||
.nth(1)
|
||||
.context("Usage: cargo run --example pair -- 'Receiver name'")?;
|
||||
let state = std::env::var_os("SPIELAB_STATE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.context("Set SPIELAB_STATE_DIR to the same credential directory as the GUI")?;
|
||||
let store = CredentialStore::new(state)?;
|
||||
let browser = ServiceBrowser::new()?;
|
||||
let device = browser
|
||||
.scan(Duration::from_secs(5))
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|device| device.name == name)
|
||||
.context("Receiver not found")?;
|
||||
let mut session = ControlSession::open(device).await?;
|
||||
if let Some(credentials) = store.load(&session.device)? {
|
||||
session.verify(&credentials).await?;
|
||||
} else {
|
||||
session.request_pin().await?;
|
||||
print!("Enter the code shown on Apple TV: ");
|
||||
io::stdout().flush()?;
|
||||
let mut pin = String::new();
|
||||
io::stdin().read_line(&mut pin)?;
|
||||
let credentials = session.pair(pin.trim()).await?;
|
||||
store.save(&session.device, &credentials)?;
|
||||
}
|
||||
println!("Authenticated: encrypted OPTIONS round trip succeeded.");
|
||||
session.transport.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
project_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$project_root"
|
||||
export CARGO_HOME="${CARGO_HOME:-$project_root/.cargo-home}"
|
||||
|
||||
# Some distributions install the xkbcommon-x11 runtime without its linker name.
|
||||
# GPUI 0.2.2 still links this library when built with only its Wayland feature.
|
||||
# Provide a build-local linker name; no system library files are modified.
|
||||
if ! pkg-config --exists xkbcommon-x11; then
|
||||
for library in /usr/lib64/libxkbcommon-x11.so.0 /usr/lib/x86_64-linux-gnu/libxkbcommon-x11.so.0; do
|
||||
if [[ -f "$library" ]]; then
|
||||
mkdir -p "$project_root/target/native"
|
||||
ln -sf "$library" "$project_root/target/native/libxkbcommon-x11.so"
|
||||
export LIBRARY_PATH="$project_root/target/native${LIBRARY_PATH:+:$LIBRARY_PATH}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Reuse development pairing identities, when present, across local launches.
|
||||
if [[ -d "$project_root/.state" && -z "${SPIELAB_STATE_DIR:-}" ]]; then
|
||||
export SPIELAB_STATE_DIR="$project_root/.state"
|
||||
fi
|
||||
exec cargo run --locked --bin spielab -- "$@"
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
//! AirPlay session worker. Network operations never run on the GPUI thread.
|
||||
use crate::{
|
||||
capture::ScreenCapture,
|
||||
media::{AudioCapture, VideoCapture, VideoEncoder},
|
||||
mirror::MirrorSession,
|
||||
pairing::{ControlSession, CredentialStore},
|
||||
settings::{CaptureMode, LatencyMode, VideoQuality},
|
||||
};
|
||||
use airplay_core::Device;
|
||||
use airplay_discovery::{Discovery, ServiceBrowser};
|
||||
use anyhow::{Context, Result};
|
||||
use async_channel::{Receiver, Sender};
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub enum Command {
|
||||
Discover,
|
||||
Connect(Box<Device>),
|
||||
Pair(String),
|
||||
ChooseScreen(CaptureMode),
|
||||
StartMirroring(VideoQuality, LatencyMode),
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Devices(Vec<Device>),
|
||||
Busy(String),
|
||||
PinRequired(String),
|
||||
Authenticated(String),
|
||||
ScreenSelected {
|
||||
size: Option<(i32, i32)>,
|
||||
mode: CaptureMode,
|
||||
},
|
||||
VirtualSupport(bool),
|
||||
EncoderSelected(String),
|
||||
Streaming(u64),
|
||||
Disconnected,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct Backend {
|
||||
commands: Sender<Command>,
|
||||
cancel: watch::Sender<u64>,
|
||||
pub events: Receiver<Event>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn start(state_directory: PathBuf) -> Result<Self> {
|
||||
let store = CredentialStore::new(state_directory)?;
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let (commands, receiver) = async_channel::bounded(8);
|
||||
let (events, event_receiver) = async_channel::unbounded();
|
||||
let (cancel, cancel_receiver) = watch::channel(0);
|
||||
std::thread::Builder::new()
|
||||
.name("airplay-worker".into())
|
||||
.spawn(move || {
|
||||
runtime.block_on(run(receiver, events, cancel_receiver, store));
|
||||
})?;
|
||||
Ok(Self {
|
||||
commands,
|
||||
cancel,
|
||||
events: event_receiver,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send(&self, command: Command) -> Result<()> {
|
||||
self.commands
|
||||
.try_send(command)
|
||||
.map_err(|_| anyhow::anyhow!("AirPlay worker is busy or has stopped"))
|
||||
}
|
||||
|
||||
pub fn disconnect(&self) {
|
||||
self.cancel
|
||||
.send_modify(|generation| *generation = generation.wrapping_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Backend {
|
||||
fn drop(&mut self) {
|
||||
self.commands.close();
|
||||
self.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
struct Worker {
|
||||
session: Option<ControlSession>,
|
||||
screen: Option<ScreenCapture>,
|
||||
authenticated: bool,
|
||||
store: CredentialStore,
|
||||
events: Sender<Event>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn emit(&self, event: Event) {
|
||||
let _ = self.events.try_send(event);
|
||||
}
|
||||
|
||||
async fn reset(&mut self) {
|
||||
self.authenticated = false;
|
||||
if let Some(screen) = self.screen.take() {
|
||||
let _ = screen.close().await;
|
||||
}
|
||||
if let Some(mut session) = self.session.take() {
|
||||
let _ = session.stop_mirroring().await;
|
||||
let _ = session.transport.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(&mut self, command: Command) -> Result<()> {
|
||||
match command {
|
||||
Command::Discover => {
|
||||
self.emit(Event::Busy("Looking for AirPlay receivers…".into()));
|
||||
self.emit(Event::VirtualSupport(
|
||||
ScreenCapture::supports_virtual().await.unwrap_or(false),
|
||||
));
|
||||
let browser = ServiceBrowser::new()?;
|
||||
let mut devices = browser.scan(Duration::from_secs(5)).await?;
|
||||
devices.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
self.emit(Event::Devices(devices));
|
||||
}
|
||||
Command::Connect(device) => {
|
||||
self.reset().await;
|
||||
self.emit(Event::Busy(format!("Connecting to {}…", device.name)));
|
||||
let credentials = self.store.load(&device)?;
|
||||
let mut session = ControlSession::open(*device).await?;
|
||||
if let Some(credentials) = credentials {
|
||||
match session.verify(&credentials).await {
|
||||
Ok(()) => {
|
||||
self.authenticated = true;
|
||||
self.emit(Event::Authenticated(session.device.name.clone()));
|
||||
}
|
||||
Err(_) => {
|
||||
// Failed encrypted exchanges cannot safely reuse the transport.
|
||||
let device = session.device.clone();
|
||||
session.transport.close().await?;
|
||||
session = ControlSession::open(device).await?;
|
||||
session.request_pin().await?;
|
||||
self.emit(Event::PinRequired(session.device.name.clone()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
session.request_pin().await?;
|
||||
self.emit(Event::PinRequired(session.device.name.clone()));
|
||||
}
|
||||
self.session = Some(session);
|
||||
}
|
||||
Command::Pair(pin) => {
|
||||
self.emit(Event::Busy("Verifying Apple TV code…".into()));
|
||||
let session = self.session.as_mut().context("Select a receiver first")?;
|
||||
let credentials = session.pair(&pin).await?;
|
||||
self.store.save(&session.device, &credentials)?;
|
||||
let name = session.device.name.clone();
|
||||
self.authenticated = true;
|
||||
self.emit(Event::Authenticated(name));
|
||||
}
|
||||
Command::ChooseScreen(mode) => {
|
||||
self.emit(Event::Busy(
|
||||
match mode {
|
||||
CaptureMode::Mirror => "Single-click one screen or window, then press Share…",
|
||||
CaptureMode::Extend => {
|
||||
"Single-click the virtual monitor, then press Share (avoid double-clicking)…"
|
||||
}
|
||||
}
|
||||
.into(),
|
||||
));
|
||||
if let Some(screen) = self.screen.take() {
|
||||
screen.close().await?;
|
||||
}
|
||||
let screen = ScreenCapture::select_mode(mode).await?;
|
||||
self.emit(Event::ScreenSelected {
|
||||
size: screen.size,
|
||||
mode: screen.mode,
|
||||
});
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
Command::StartMirroring(quality, latency) => {
|
||||
anyhow::ensure!(self.authenticated, "Connect to Apple TV first");
|
||||
let screen = self.screen.as_ref().context("Choose a screen first")?;
|
||||
self.emit(Event::Busy(
|
||||
"Starting desktop and system audio sharing…".into(),
|
||||
));
|
||||
let config = quality.config();
|
||||
let encoder = VideoEncoder::detect(config).await;
|
||||
self.emit(Event::EncoderSelected(encoder.label().into()));
|
||||
let session = self.session.as_mut().context("Connect to Apple TV first")?;
|
||||
let mut mirror = MirrorSession::setup_with_latency(
|
||||
session,
|
||||
config.width,
|
||||
config.height,
|
||||
latency,
|
||||
)
|
||||
.await?;
|
||||
// Start capture only once the receiver is ready, so setup does
|
||||
// not accumulate stale video or audio in the process pipes.
|
||||
let video = VideoCapture::start_with_encoder(Some(screen), config, &encoder)?;
|
||||
let audio = AudioCapture::start()?;
|
||||
let events = self.events.clone();
|
||||
mirror
|
||||
.run(session, video, audio, move |frames| {
|
||||
let _ = events.try_send(Event::Streaming(frames));
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
commands: Receiver<Command>,
|
||||
events: Sender<Event>,
|
||||
mut cancel: watch::Receiver<u64>,
|
||||
store: CredentialStore,
|
||||
) {
|
||||
let mut worker = Worker {
|
||||
session: None,
|
||||
screen: None,
|
||||
authenticated: false,
|
||||
store,
|
||||
events,
|
||||
};
|
||||
loop {
|
||||
let command = tokio::select! {
|
||||
biased;
|
||||
changed = cancel.changed() => {
|
||||
worker.reset().await;
|
||||
while commands.try_recv().is_ok() {}
|
||||
worker.emit(Event::Disconnected);
|
||||
if changed.is_err() || commands.is_closed() { break; }
|
||||
continue;
|
||||
}
|
||||
command = commands.recv() => match command { Ok(command) => command, Err(_) => break },
|
||||
};
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.changed() => None,
|
||||
result = worker.execute(command) => Some(result),
|
||||
};
|
||||
match result {
|
||||
Some(Ok(())) => {}
|
||||
Some(Err(error)) => {
|
||||
worker.reset().await;
|
||||
worker.emit(Event::Error(format!("{error:#}")));
|
||||
}
|
||||
None => {
|
||||
worker.reset().await;
|
||||
while commands.try_recv().is_ok() {}
|
||||
worker.emit(Event::Disconnected);
|
||||
}
|
||||
}
|
||||
}
|
||||
worker.reset().await;
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
//! Wayland screen sharing through the desktop portal and its PipeWire remote.
|
||||
//! The portal session and owned FD stay alive together until capture stops.
|
||||
use crate::settings::CaptureMode;
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use ashpd::desktop::{
|
||||
PersistMode, Session,
|
||||
screencast::{CursorMode, Screencast, SourceType},
|
||||
};
|
||||
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
|
||||
|
||||
pub struct ScreenCapture {
|
||||
session: PortalSession,
|
||||
remote: OwnedFd,
|
||||
pub node_id: u32,
|
||||
pub size: Option<(i32, i32)>,
|
||||
pub mode: CaptureMode,
|
||||
}
|
||||
|
||||
// Cancellation can drop select() while the compositor dialog is open.
|
||||
// Retain a guard from the moment CreateSession succeeds so this also closes it.
|
||||
struct PortalSession(Option<Session<'static, Screencast<'static>>>);
|
||||
|
||||
impl PortalSession {
|
||||
async fn close(&mut self) -> Result<()> {
|
||||
if let Some(session) = self.0.take() {
|
||||
session.close().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PortalSession {
|
||||
fn drop(&mut self) {
|
||||
if let Some(session) = self.0.take()
|
||||
&& let Ok(runtime) = tokio::runtime::Handle::try_current()
|
||||
{
|
||||
runtime.spawn(async move {
|
||||
let _ = session.close().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenCapture {
|
||||
pub async fn select() -> Result<Self> {
|
||||
Self::select_mode(CaptureMode::Mirror).await
|
||||
}
|
||||
|
||||
pub async fn supports_virtual() -> Result<bool> {
|
||||
Ok(Screencast::new()
|
||||
.await?
|
||||
.available_source_types()
|
||||
.await?
|
||||
.contains(SourceType::Virtual))
|
||||
}
|
||||
|
||||
pub async fn select_mode(mode: CaptureMode) -> Result<Self> {
|
||||
let portal = Screencast::new()
|
||||
.await
|
||||
.context("ScreenCast portal unavailable")?;
|
||||
let available = portal.available_source_types().await?;
|
||||
let requested = match mode {
|
||||
CaptureMode::Mirror => (SourceType::Monitor | SourceType::Window) & available,
|
||||
CaptureMode::Extend => {
|
||||
ensure!(
|
||||
available.contains(SourceType::Virtual),
|
||||
"This desktop does not support virtual monitors through its screen-sharing portal"
|
||||
);
|
||||
SourceType::Virtual.into()
|
||||
}
|
||||
};
|
||||
ensure!(
|
||||
!requested.is_empty(),
|
||||
"No supported screen sources are available"
|
||||
);
|
||||
let mut guard = PortalSession(Some(portal.create_session().await?));
|
||||
let session = guard.0.as_ref().expect("New portal session");
|
||||
let result = async {
|
||||
portal
|
||||
.select_sources(
|
||||
session,
|
||||
CursorMode::Embedded,
|
||||
requested,
|
||||
// KDE 6.7's single-source card accepts synchronously from
|
||||
// PipeWireLayout.qml::onClicked. Starting the stream can
|
||||
// destroy that delegate inside its own signal handler and
|
||||
// abort the portal. Checkbox selection + the Share button
|
||||
// avoids that reentrant acceptance path. Still require one
|
||||
// stream below; never silently choose among multiple.
|
||||
true,
|
||||
None,
|
||||
PersistMode::DoNot,
|
||||
)
|
||||
.await?
|
||||
.response()?;
|
||||
let selection = portal
|
||||
.start(session, None)
|
||||
.await
|
||||
.context("The desktop sharing dialog closed unexpectedly. Try again: single-click one source, then press Share")?
|
||||
.response()?;
|
||||
ensure!(
|
||||
selection.streams().len() == 1,
|
||||
"Select exactly one screen or window, then press Share"
|
||||
);
|
||||
let stream = selection
|
||||
.streams()
|
||||
.first()
|
||||
.context("No screen was selected")?;
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
ensure!(
|
||||
mode != CaptureMode::Extend || stream.source_type() == Some(SourceType::Virtual),
|
||||
"The desktop did not return a virtual monitor; extended display was not created"
|
||||
);
|
||||
let size = stream.size();
|
||||
let remote = portal.open_pipe_wire_remote(session).await?;
|
||||
Ok::<_, anyhow::Error>((remote, node_id, size))
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok((remote, node_id, size)) => Ok(Self {
|
||||
session: guard,
|
||||
remote,
|
||||
node_id,
|
||||
size,
|
||||
mode,
|
||||
}),
|
||||
Err(error) => {
|
||||
let _ = guard.close().await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remote_fd(&self) -> RawFd {
|
||||
self.remote.as_raw_fd()
|
||||
}
|
||||
|
||||
pub async fn close(mut self) -> Result<()> {
|
||||
self.session.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod backend;
|
||||
pub mod capture;
|
||||
pub mod media;
|
||||
pub mod mirror;
|
||||
pub mod pairing;
|
||||
pub mod settings;
|
||||
+572
@@ -0,0 +1,572 @@
|
||||
use airplay_core::Device;
|
||||
use gpui::{
|
||||
App, Application, Bounds, Context, FocusHandle, KeyDownEvent, SharedString, Window,
|
||||
WindowBounds, WindowOptions, div, prelude::*, px, rgb, size,
|
||||
};
|
||||
use spielab::{
|
||||
backend::{Backend, Command, Event},
|
||||
settings::{CaptureMode, LatencyMode, VideoQuality},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct Spielab {
|
||||
backend: Backend,
|
||||
devices: Vec<Device>,
|
||||
status: String,
|
||||
receiver: Option<String>,
|
||||
pin_required: bool,
|
||||
pin: String,
|
||||
busy: bool,
|
||||
authenticated: bool,
|
||||
streaming: bool,
|
||||
screen: Option<String>,
|
||||
quality: VideoQuality,
|
||||
latency: LatencyMode,
|
||||
encoder: String,
|
||||
capture_mode: CaptureMode,
|
||||
virtual_supported: Option<bool>,
|
||||
error: bool,
|
||||
focus: FocusHandle,
|
||||
}
|
||||
|
||||
impl Spielab {
|
||||
fn new(backend: Backend, cx: &mut Context<Self>) -> Self {
|
||||
let events = backend.events.clone();
|
||||
cx.spawn(async move |view, cx| {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if view
|
||||
.update(cx, |view, cx| {
|
||||
view.event(event);
|
||||
cx.notify();
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
let _ = backend.send(Command::Discover);
|
||||
Self {
|
||||
backend,
|
||||
devices: vec![],
|
||||
status: "Looking for AirPlay receivers…".into(),
|
||||
receiver: None,
|
||||
pin_required: false,
|
||||
pin: String::new(),
|
||||
busy: true,
|
||||
authenticated: false,
|
||||
streaming: false,
|
||||
screen: None,
|
||||
quality: VideoQuality::default(),
|
||||
latency: LatencyMode::default(),
|
||||
encoder: "Automatic encoder".into(),
|
||||
capture_mode: CaptureMode::Mirror,
|
||||
virtual_supported: None,
|
||||
error: false,
|
||||
focus: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event(&mut self, event: Event) {
|
||||
if let Event::EncoderSelected(encoder) = event {
|
||||
self.encoder = encoder;
|
||||
return;
|
||||
}
|
||||
if let Event::VirtualSupport(supported) = event {
|
||||
self.virtual_supported = Some(supported);
|
||||
return;
|
||||
}
|
||||
self.busy = false;
|
||||
self.error = false;
|
||||
match event {
|
||||
Event::Devices(devices) => {
|
||||
self.status = if devices.is_empty() {
|
||||
"No receivers found. Check that Apple TV is awake and on the same network."
|
||||
.into()
|
||||
} else {
|
||||
format!("{} AirPlay receiver(s) found", devices.len())
|
||||
};
|
||||
self.devices = devices;
|
||||
}
|
||||
Event::Busy(status) => {
|
||||
self.status = status;
|
||||
self.busy = true;
|
||||
}
|
||||
Event::PinRequired(name) => {
|
||||
self.status = format!("Enter the four-digit code shown on {name}");
|
||||
self.receiver = Some(name);
|
||||
self.pin_required = true;
|
||||
self.pin.clear();
|
||||
}
|
||||
Event::Authenticated(name) => {
|
||||
self.status = format!("Authenticated with {name}");
|
||||
self.receiver = Some(name);
|
||||
self.authenticated = true;
|
||||
self.pin_required = false;
|
||||
self.pin.clear();
|
||||
}
|
||||
Event::VirtualSupport(_) => unreachable!("Handled above"),
|
||||
Event::EncoderSelected(_) => unreachable!("Handled above"),
|
||||
Event::ScreenSelected { size, mode } => {
|
||||
self.capture_mode = mode;
|
||||
self.screen = Some(match size {
|
||||
Some((w, h)) => format!("{} · {w} × {h}", mode.label()),
|
||||
None => mode.label().into(),
|
||||
});
|
||||
self.status = match mode {
|
||||
CaptureMode::Mirror => "Screen selected. Start sharing to send your desktop and system audio.",
|
||||
CaptureMode::Extend => "Extended display created. Move windows onto it, then start sharing. Arrange it in your desktop’s Display Settings.",
|
||||
}.into();
|
||||
}
|
||||
Event::Streaming(frames) => {
|
||||
self.streaming = true;
|
||||
self.busy = true;
|
||||
self.status = format!(
|
||||
"{} · {} · {} · System audio · {frames} frames",
|
||||
self.capture_mode.label(),
|
||||
self.quality.label(),
|
||||
self.encoder,
|
||||
);
|
||||
}
|
||||
Event::Disconnected => self.reset("Disconnected".into()),
|
||||
Event::Error(error) => {
|
||||
self.reset(error);
|
||||
self.error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, status: String) {
|
||||
self.status = status;
|
||||
self.receiver = None;
|
||||
self.authenticated = false;
|
||||
self.streaming = false;
|
||||
self.pin_required = false;
|
||||
self.pin.clear();
|
||||
self.screen = None;
|
||||
}
|
||||
|
||||
fn send(&mut self, command: Command, cx: &mut Context<Self>) {
|
||||
match self.backend.send(command) {
|
||||
Ok(()) => {
|
||||
self.busy = true;
|
||||
self.error = false;
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
self.error = true;
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn digit(&mut self, value: &str, cx: &mut Context<Self>) {
|
||||
if self.pin_required && !self.busy && self.pin.len() < 4 {
|
||||
self.pin.push_str(value);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let key = event.keystroke.key.as_str();
|
||||
if key == "escape" {
|
||||
self.backend.disconnect();
|
||||
}
|
||||
if !self.pin_required || self.busy {
|
||||
return;
|
||||
}
|
||||
match key {
|
||||
"backspace" => {
|
||||
self.pin.pop();
|
||||
}
|
||||
"enter" if self.pin.len() == 4 => {
|
||||
let pin = std::mem::take(&mut self.pin);
|
||||
self.send(Command::Pair(pin), cx);
|
||||
}
|
||||
value if value.len() == 1 && value.as_bytes()[0].is_ascii_digit() => {
|
||||
self.digit(value, cx)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn button(
|
||||
id: impl Into<gpui::ElementId>,
|
||||
label: impl Into<SharedString>,
|
||||
enabled: bool,
|
||||
) -> gpui::Stateful<gpui::Div> {
|
||||
div()
|
||||
.id(id)
|
||||
.px_4()
|
||||
.py_2()
|
||||
.rounded_lg()
|
||||
.bg(rgb(if enabled { 0x29433c } else { 0x202a2a }))
|
||||
.text_color(rgb(if enabled { 0xc6f7d9 } else { 0x75807b }))
|
||||
.when(enabled, |d| {
|
||||
d.cursor_pointer().hover(|s| s.bg(rgb(0x35584b)))
|
||||
})
|
||||
.child(label.into())
|
||||
}
|
||||
|
||||
impl Render for Spielab {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let idle = !self.busy;
|
||||
let quality_controls =
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.children(VideoQuality::ALL.into_iter().enumerate().map(
|
||||
|(index, quality)| {
|
||||
button(("quality", index), quality.label(), idle)
|
||||
.border_1()
|
||||
.border_color(rgb(if self.quality == quality {
|
||||
0x85d8ae
|
||||
} else {
|
||||
0x35433e
|
||||
}))
|
||||
.on_click(cx.listener(move |view, _, _, cx| {
|
||||
if !view.busy {
|
||||
view.quality = quality;
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(0x9bafa5))
|
||||
.child(self.quality.description()),
|
||||
);
|
||||
let latency_controls =
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.children(LatencyMode::ALL.into_iter().enumerate().map(
|
||||
|(index, latency)| {
|
||||
button(("latency", index), latency.label(), idle)
|
||||
.border_1()
|
||||
.border_color(rgb(if self.latency == latency {
|
||||
0x85d8ae
|
||||
} else {
|
||||
0x35433e
|
||||
}))
|
||||
.on_click(cx.listener(move |view, _, _, cx| {
|
||||
if !view.busy {
|
||||
view.latency = latency;
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().text_sm().text_color(rgb(0x9bafa5)).child(
|
||||
"Playback buffer, not total delay. Increase it if playback stutters.",
|
||||
),
|
||||
);
|
||||
let extend_control = div().flex().flex_col().gap_2()
|
||||
.child(button("extend", "Create extended display", idle && self.virtual_supported == Some(true))
|
||||
.on_click(cx.listener(|view, _, _, cx| {
|
||||
if !view.busy && view.virtual_supported == Some(true) {
|
||||
view.send(Command::ChooseScreen(CaptureMode::Extend), cx);
|
||||
}
|
||||
})))
|
||||
.child(div().text_sm().text_color(rgb(0x9bafa5)).child(match self.virtual_supported {
|
||||
Some(true) => "Single-click the virtual monitor, then press Share. Move windows there; disconnecting removes it.",
|
||||
Some(false) => "Your desktop’s screen-sharing portal does not provide virtual monitors.",
|
||||
None => "Checking virtual-monitor support…",
|
||||
}));
|
||||
let mut receivers = div().flex().flex_col().gap_2();
|
||||
for (index, device) in self.devices.iter().enumerate() {
|
||||
let device = device.clone();
|
||||
let detail = format!(
|
||||
"{} · {}",
|
||||
device.model,
|
||||
device
|
||||
.socket_addr()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let name = device.name.clone();
|
||||
receivers = receivers.child(
|
||||
div()
|
||||
.id(("receiver", index))
|
||||
.p_4()
|
||||
.rounded_lg()
|
||||
.bg(rgb(0x202a2b))
|
||||
.when(idle && !self.authenticated, |d| {
|
||||
d.cursor_pointer()
|
||||
.hover(|s| s.bg(rgb(0x293a37)))
|
||||
.on_click(cx.listener(move |view, _, _, cx| {
|
||||
if !view.busy && !view.authenticated {
|
||||
view.send(Command::Connect(Box::new(device.clone())), cx);
|
||||
}
|
||||
}))
|
||||
})
|
||||
.child(div().text_lg().child(name))
|
||||
.child(div().text_sm().text_color(rgb(0x99ada5)).child(detail)),
|
||||
);
|
||||
}
|
||||
let mut pairing = div().flex().flex_col().gap_3();
|
||||
if self.pin_required {
|
||||
pairing = pairing.child(div().text_lg().child("Apple TV code")).child(
|
||||
div().text_3xl().child(format!(
|
||||
"{}{}",
|
||||
"● ".repeat(self.pin.len()),
|
||||
"○ ".repeat(4 - self.pin.len())
|
||||
)),
|
||||
);
|
||||
for row in [
|
||||
vec!["1", "2", "3"],
|
||||
vec!["4", "5", "6"],
|
||||
vec!["7", "8", "9"],
|
||||
vec!["0"],
|
||||
] {
|
||||
pairing =
|
||||
pairing.child(div().flex().gap_2().children(row.into_iter().map(|digit| {
|
||||
button(digit, digit, idle)
|
||||
.on_click(cx.listener(move |view, _, _, cx| view.digit(digit, cx)))
|
||||
})));
|
||||
}
|
||||
pairing = pairing.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(button("clear", "Clear", idle).on_click(cx.listener(
|
||||
|view, _, _, cx| {
|
||||
view.pin.clear();
|
||||
cx.notify();
|
||||
},
|
||||
)))
|
||||
.child(
|
||||
button("pair", "Pair Apple TV", idle && self.pin.len() == 4).on_click(
|
||||
cx.listener(|view, _, _, cx| {
|
||||
if !view.busy && view.pin.len() == 4 {
|
||||
let pin = std::mem::take(&mut view.pin);
|
||||
view.send(Command::Pair(pin), cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
div()
|
||||
.size_full()
|
||||
.bg(rgb(0x141c1e))
|
||||
.text_color(rgb(0xe6efe9))
|
||||
.font_family("sans-serif")
|
||||
.track_focus(&self.focus)
|
||||
.on_key_down(cx.listener(Self::key))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.p_8()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
div().child(div().text_3xl().child("Spielab")).child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(0x9db3a8))
|
||||
.child("Your desktop, on Apple TV"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(0x85d8ae))
|
||||
.child("WAYLAND · AIRPLAY"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_6()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(
|
||||
div()
|
||||
.w(px(310.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child("RECEIVERS")
|
||||
.child(
|
||||
button("scan", "Refresh", idle && !self.authenticated)
|
||||
.on_click(cx.listener(|view, _, _, cx| {
|
||||
if !view.busy && !view.authenticated {
|
||||
view.send(Command::Discover, cx);
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(div().id("receivers").overflow_y_scroll().child(receivers)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("sharing-settings")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_5()
|
||||
.p_5()
|
||||
.rounded_xl()
|
||||
.bg(rgb(0x1b2527))
|
||||
.child(
|
||||
div().text_xl().child(
|
||||
self.receiver
|
||||
.clone()
|
||||
.unwrap_or("Connect to your Apple TV".into()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(rgb(0xa7b8b0))
|
||||
.child(if self.authenticated {
|
||||
"Pairing verified over an encrypted connection."
|
||||
} else {
|
||||
"Select a receiver on your local network to begin."
|
||||
}),
|
||||
)
|
||||
.child(pairing)
|
||||
.child(
|
||||
div()
|
||||
.border_t_1()
|
||||
.border_color(rgb(0x35433e))
|
||||
.pt_4()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child("DISPLAY & QUALITY")
|
||||
.child(quality_controls)
|
||||
.child(latency_controls)
|
||||
.child(
|
||||
self.screen.clone().unwrap_or("No screen selected".into()),
|
||||
)
|
||||
.child(
|
||||
button("screen", "Choose screen or window", idle).on_click(
|
||||
cx.listener(|view, _, _, cx| {
|
||||
if !view.busy {
|
||||
view.send(Command::ChooseScreen(CaptureMode::Mirror), cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(extend_control)
|
||||
.child(
|
||||
button(
|
||||
"start-mirroring",
|
||||
if self.streaming {
|
||||
"Sharing…"
|
||||
} else {
|
||||
"Start sharing"
|
||||
},
|
||||
idle && self.authenticated && self.screen.is_some(),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener(|view, _, _, cx| {
|
||||
if !view.busy
|
||||
&& view.authenticated
|
||||
&& view.screen.is_some()
|
||||
{
|
||||
view.send(Command::StartMirroring(view.quality, view.latency), cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(0x9bafa5))
|
||||
.child("System audio included · Settings apply when sharing starts"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_4()
|
||||
.rounded_lg()
|
||||
.bg(rgb(if self.error { 0x442b2b } else { 0x202e29 }))
|
||||
.child(self.status.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(0x8d9f96))
|
||||
.child("Esc cancels · Your desktop controls screen-sharing permission"),
|
||||
)
|
||||
.child(
|
||||
button("disconnect", "Disconnect / cancel", true)
|
||||
.on_click(cx.listener(|view, _, _, _| view.backend.disconnect())),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
anyhow::ensure!(
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
||||
"Spielab requires a Wayland session (WAYLAND_DISPLAY is unset)"
|
||||
);
|
||||
let state_directory = std::env::var_os("SPIELAB_STATE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("XDG_STATE_HOME").map(|p| PathBuf::from(p).join("spielab")))
|
||||
.or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".local/state/spielab")))
|
||||
.ok_or_else(|| anyhow::anyhow!("Set SPIELAB_STATE_DIR or XDG_STATE_HOME"))?;
|
||||
let backend = Backend::start(state_directory)?;
|
||||
Application::new().run(move |cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(980.), px(800.)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
app_id: Some("dev.spielab.Spielab".into()),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| {
|
||||
let view = cx.new(|cx| Spielab::new(backend, cx));
|
||||
view.read(cx).focus.focus(window);
|
||||
view
|
||||
},
|
||||
)
|
||||
.expect("Failed to open native Wayland window");
|
||||
cx.on_window_closed(|cx| {
|
||||
if cx.windows().is_empty() {
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
cx.activate(true);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
//! PipeWire capture and low-latency H.264 encoding. Commands use argv, never a shell.
|
||||
use crate::capture::ScreenCapture;
|
||||
use crate::settings::{VideoConfig, VideoQuality};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use std::{
|
||||
os::{fd::AsRawFd, unix::process::CommandExt},
|
||||
process::Stdio,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncReadExt, BufReader, Lines},
|
||||
process::{Child, ChildStdout, Command},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum VideoEncoder {
|
||||
Software,
|
||||
Vaapi(std::path::PathBuf),
|
||||
}
|
||||
|
||||
impl VideoEncoder {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Software => "Software H.264",
|
||||
Self::Vaapi(_) => "GPU H.264 · VA-API",
|
||||
}
|
||||
}
|
||||
|
||||
/// Test real encoded access units, not just FFmpeg's compiled encoder list.
|
||||
pub async fn probe(&self, config: VideoConfig) -> Result<()> {
|
||||
let mut capture = VideoCapture::start_with_encoder(None, config, self)?;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(3), async {
|
||||
for index in 0..3 {
|
||||
let frame = capture.frame().await?;
|
||||
ensure!(
|
||||
frame.iter().any(|n| matches!(n[0] & 31, 1 | 5)),
|
||||
"Encoder returned no picture"
|
||||
);
|
||||
if index == 0 {
|
||||
for kind in [7, 8, 5] {
|
||||
ensure!(
|
||||
frame.iter().any(|n| n[0] & 31 == kind),
|
||||
"Encoder omitted initial SPS, PPS or keyframe"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.context("Hardware encoder probe timed out")?
|
||||
}
|
||||
|
||||
pub async fn detect(config: VideoConfig) -> Self {
|
||||
let mut devices: Vec<_> = std::fs::read_dir("/dev/dri")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.file_name().to_string_lossy().starts_with("renderD"))
|
||||
.map(|entry| entry.path())
|
||||
.collect();
|
||||
devices.sort();
|
||||
Self::select_from(devices, config).await
|
||||
}
|
||||
|
||||
async fn select_from(devices: Vec<std::path::PathBuf>, config: VideoConfig) -> Self {
|
||||
for device in devices {
|
||||
let encoder = Self::Vaapi(device);
|
||||
match encoder.probe(config).await {
|
||||
Ok(()) => return encoder,
|
||||
Err(error) => eprintln!("Hardware encoder unavailable ({encoder:?}): {error:#}"),
|
||||
}
|
||||
}
|
||||
Self::Software
|
||||
}
|
||||
}
|
||||
|
||||
// An abrupt app exit must not leave a PipeWire screen/audio capture running.
|
||||
// kill_on_drop covers Rust cleanup; Linux's parent-death signal covers crashes
|
||||
// and SIGTERM too. Check the parent again after prctl to close the fork race.
|
||||
fn terminate_with_parent(command: &mut std::process::Command) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let parent = std::process::id() as libc::pid_t;
|
||||
unsafe {
|
||||
command.pre_exec(move || {
|
||||
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if libc::getppid() != parent {
|
||||
return Err(std::io::Error::other("Capture owner exited during startup"));
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VideoCapture {
|
||||
encoder: Child,
|
||||
source: Option<std::process::Child>,
|
||||
output: ChildStdout,
|
||||
packet_sizes: Lines<BufReader<tokio::net::UnixStream>>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl VideoCapture {
|
||||
pub fn start(screen: Option<&ScreenCapture>, width: u32, height: u32) -> Result<Self> {
|
||||
Self::start_config(
|
||||
screen,
|
||||
VideoConfig {
|
||||
width,
|
||||
height,
|
||||
..VideoQuality::Efficient.config()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn start_config(screen: Option<&ScreenCapture>, config: VideoConfig) -> Result<Self> {
|
||||
Self::start_with_encoder(screen, config, &VideoEncoder::Software)
|
||||
}
|
||||
|
||||
pub fn start_with_encoder(
|
||||
screen: Option<&ScreenCapture>,
|
||||
config: VideoConfig,
|
||||
encoder: &VideoEncoder,
|
||||
) -> Result<Self> {
|
||||
Self::start_pipeline(screen, config, None, encoder)
|
||||
}
|
||||
|
||||
fn start_pipeline(
|
||||
screen: Option<&ScreenCapture>,
|
||||
config: VideoConfig,
|
||||
synthetic_source: Option<&str>,
|
||||
encoder: &VideoEncoder,
|
||||
) -> Result<Self> {
|
||||
config.validate()?;
|
||||
let VideoConfig {
|
||||
width, height, fps, ..
|
||||
} = config;
|
||||
let mut source = None;
|
||||
let mut command = Command::new("ffmpeg");
|
||||
command.args(["-hide_banner", "-loglevel", "error", "-nostdin"]);
|
||||
let (metadata_reader, metadata_writer) = std::os::unix::net::UnixStream::pair()?;
|
||||
metadata_reader.set_nonblocking(true)?;
|
||||
let packet_sizes =
|
||||
BufReader::new(tokio::net::UnixStream::from_std(metadata_reader)?).lines();
|
||||
let metadata_fd = metadata_writer.as_raw_fd();
|
||||
unsafe {
|
||||
command.pre_exec(move || {
|
||||
if libc::fcntl(metadata_fd, libc::F_SETFD, 0) == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
if let VideoEncoder::Vaapi(device) = encoder {
|
||||
command.arg("-vaapi_device").arg(device);
|
||||
}
|
||||
if let Some(screen) = screen {
|
||||
let fd = screen.remote_fd();
|
||||
let mut gst = std::process::Command::new("gst-launch-1.0");
|
||||
terminate_with_parent(&mut gst);
|
||||
gst.args([
|
||||
"-q",
|
||||
"pipewiresrc",
|
||||
&format!("fd={fd}"),
|
||||
&format!("path={}", screen.node_id),
|
||||
"do-timestamp=true",
|
||||
&format!("keepalive-time={}", 1000 / fps),
|
||||
"!",
|
||||
"queue",
|
||||
"max-size-buffers=1",
|
||||
"max-size-bytes=0",
|
||||
"max-size-time=0",
|
||||
"leaky=downstream",
|
||||
"!",
|
||||
"videoconvert",
|
||||
"!",
|
||||
"videoscale",
|
||||
"add-borders=true",
|
||||
"!",
|
||||
"videorate",
|
||||
"!",
|
||||
&format!("video/x-raw,format=I420,width={width},height={height},framerate={fps}/1"),
|
||||
"!",
|
||||
"y4menc",
|
||||
"!",
|
||||
"fdsink",
|
||||
"fd=1",
|
||||
"sync=false",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
// The FD is owned by ScreenCapture throughout this process lifetime.
|
||||
// Only the child's descriptor flags change; fcntl is async-signal-safe.
|
||||
unsafe {
|
||||
gst.pre_exec(move || {
|
||||
if libc::fcntl(fd, libc::F_SETFD, 0) == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut child = gst
|
||||
.spawn()
|
||||
.context("Install GStreamer with pipewiresrc and y4menc")?;
|
||||
command
|
||||
.args([
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"1",
|
||||
"-f",
|
||||
"yuv4mpegpipe",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
])
|
||||
.stdin(child.stdout.take().context("Capture pipe unavailable")?);
|
||||
source = Some(child);
|
||||
} else {
|
||||
command
|
||||
.args([
|
||||
"-re",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
synthetic_source
|
||||
.unwrap_or(&format!("testsrc2=size={width}x{height}:rate={fps}")),
|
||||
])
|
||||
.stdin(Stdio::null());
|
||||
}
|
||||
match encoder {
|
||||
VideoEncoder::Software => {
|
||||
command.args([
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
config.preset,
|
||||
"-tune",
|
||||
"zerolatency",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-crf",
|
||||
&config.crf.to_string(),
|
||||
"-x264-params",
|
||||
"aud=1:repeat-headers=1:scenecut=0",
|
||||
]);
|
||||
}
|
||||
VideoEncoder::Vaapi(_) => {
|
||||
command.args([
|
||||
"-vf",
|
||||
"format=nv12,hwupload",
|
||||
"-c:v",
|
||||
"h264_vaapi",
|
||||
"-rc_mode",
|
||||
"VBR",
|
||||
"-b:v",
|
||||
&format!("{}k", config.max_kbps * 3 / 4),
|
||||
"-async_depth",
|
||||
"1",
|
||||
"-aud",
|
||||
"1",
|
||||
]);
|
||||
}
|
||||
}
|
||||
command
|
||||
.args([
|
||||
"-an",
|
||||
"-profile:v",
|
||||
"main",
|
||||
"-maxrate",
|
||||
&format!("{}k", config.max_kbps),
|
||||
"-bufsize",
|
||||
&format!("{}k", config.buffer_kbps),
|
||||
"-g",
|
||||
&fps.to_string(),
|
||||
"-bf",
|
||||
"0",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-f",
|
||||
"tee",
|
||||
&format!(
|
||||
"[f=framecrc:flush_packets=1]pipe:{metadata_fd}|[f=h264:flush_packets=1]pipe:1"
|
||||
),
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
terminate_with_parent(command.as_std_mut());
|
||||
let mut encoder = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
if let Some(mut child) = source {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
return Err(error).context("Could not start FFmpeg video encoder");
|
||||
}
|
||||
};
|
||||
drop(metadata_writer);
|
||||
let output = encoder.stdout.take().context("Encoder pipe unavailable")?;
|
||||
Ok(Self {
|
||||
encoder,
|
||||
source,
|
||||
output,
|
||||
packet_sizes,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
/// FFmpeg provides packet lengths on a separate pipe before writing each
|
||||
/// encoded packet. This preserves frame boundaries without waiting for the
|
||||
/// next frame's AUD, including when the desktop stops changing.
|
||||
pub async fn frame(&mut self) -> Result<Vec<Vec<u8>>> {
|
||||
loop {
|
||||
let line = self
|
||||
.packet_sizes
|
||||
.next_line()
|
||||
.await?
|
||||
.context("Video encoder stopped before providing the next packet")?;
|
||||
let Some(size) = packet_size(&line)? else {
|
||||
continue;
|
||||
};
|
||||
let mut packet = vec![0; size];
|
||||
self.output
|
||||
.read_exact(&mut packet)
|
||||
.await
|
||||
.context("Truncated encoded video packet")?;
|
||||
return split_packet(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_size(line: &str) -> Result<Option<usize>> {
|
||||
if line.starts_with('#') || line.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let fields: Vec<_> = line.split(',').map(str::trim).collect();
|
||||
ensure!(
|
||||
fields.len() >= 6 && fields[0] == "0",
|
||||
"Invalid encoder packet metadata"
|
||||
);
|
||||
let size: usize = fields[4].parse().context("Invalid encoded packet size")?;
|
||||
ensure!(
|
||||
(1..=16 * 1024 * 1024).contains(&size),
|
||||
"Encoded packet exceeds size limit"
|
||||
);
|
||||
Ok(Some(size))
|
||||
}
|
||||
|
||||
fn split_packet(packet: &[u8]) -> Result<Vec<Vec<u8>>> {
|
||||
let mut cursor = start_code(packet, 0).context("Encoder returned non-Annex-B video")?;
|
||||
ensure!(cursor.0 == 0, "Unexpected data before H.264 start code");
|
||||
let mut nals = Vec::new();
|
||||
loop {
|
||||
let begin = cursor.0 + cursor.1;
|
||||
let next = start_code(packet, begin);
|
||||
let end = next.map_or(packet.len(), |(offset, _)| offset);
|
||||
ensure!(end > begin, "Empty H.264 NAL unit");
|
||||
if packet[begin] & 31 != 9 {
|
||||
nals.push(packet[begin..end].to_vec());
|
||||
}
|
||||
match next {
|
||||
Some(next) => cursor = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
ensure!(!nals.is_empty(), "Encoded packet has no video NAL units");
|
||||
Ok(nals)
|
||||
}
|
||||
|
||||
fn start_code(data: &[u8], from: usize) -> Option<(usize, usize)> {
|
||||
(from..data.len().saturating_sub(2)).find_map(|index| {
|
||||
if data.get(index..index + 4) == Some(&[0, 0, 0, 1]) {
|
||||
Some((index, 4))
|
||||
} else if data.get(index..index + 3) == Some(&[0, 0, 1]) {
|
||||
Some((index, 3))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Drop for VideoCapture {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.encoder.start_kill();
|
||||
if let Some(mut source) = self.source.take() {
|
||||
let _ = source.kill();
|
||||
let _ = source.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioCapture {
|
||||
_child: Child,
|
||||
output: ChildStdout,
|
||||
}
|
||||
|
||||
impl AudioCapture {
|
||||
pub fn start() -> Result<Self> {
|
||||
let mut command = Command::new("pw-cat");
|
||||
terminate_with_parent(command.as_std_mut());
|
||||
let mut child = command
|
||||
.args([
|
||||
"--record",
|
||||
"--raw",
|
||||
"--rate",
|
||||
"44100",
|
||||
"--channels",
|
||||
"2",
|
||||
"--format",
|
||||
"s16",
|
||||
"--latency",
|
||||
"20ms",
|
||||
"--properties",
|
||||
"{ stream.capture.sink = true node.name = spielab-audio }",
|
||||
"-",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.context("Install PipeWire's pw-cat to capture system audio")?;
|
||||
let output = child.stdout.take().context("Audio pipe unavailable")?;
|
||||
Ok(Self {
|
||||
_child: child,
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn frame(&mut self) -> Result<Vec<u8>> {
|
||||
let mut pcm = vec![0; 352 * 2 * 2];
|
||||
self.output
|
||||
.read_exact(&mut pcm)
|
||||
.await
|
||||
.context("System audio capture stopped")?;
|
||||
Ok(pcm)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn packet_metadata_rejects_invalid_lengths_and_accepts_side_data() {
|
||||
assert_eq!(packet_size("#tb 0: 1/60").unwrap(), None);
|
||||
assert_eq!(packet_size("0, 0, 0, 1, 42, 0x0, S=1").unwrap(), Some(42));
|
||||
for line in [
|
||||
"0,0,0,1,0,0x0",
|
||||
"0,0,0,1,16777217,0x0",
|
||||
"0,0,0,1,-1,0x0",
|
||||
"1,0,0,1,4,0x0",
|
||||
"broken",
|
||||
] {
|
||||
assert!(packet_size(line).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FFmpeg with libx264 installed"]
|
||||
async fn single_frame_is_delivered_without_any_following_frame() {
|
||||
let config = VideoConfig {
|
||||
width: 320,
|
||||
height: 240,
|
||||
..VideoQuality::Efficient.config()
|
||||
};
|
||||
let mut capture = VideoCapture::start_pipeline(
|
||||
None,
|
||||
config,
|
||||
Some("color=c=black:size=320x240:rate=30,trim=end_frame=1"),
|
||||
&VideoEncoder::Software,
|
||||
)
|
||||
.unwrap();
|
||||
let nals = tokio::time::timeout(std::time::Duration::from_secs(3), capture.frame())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for kind in [7, 8, 5] {
|
||||
assert!(nals.iter().any(|n| n[0] & 31 == kind));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FFmpeg"]
|
||||
async fn broken_hardware_falls_back_to_working_software() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let encoder = VideoEncoder::select_from(
|
||||
vec![directory.path().join("missing-render-node")],
|
||||
VideoQuality::Efficient.config(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(encoder, VideoEncoder::Software);
|
||||
encoder
|
||||
.probe(VideoQuality::Efficient.config())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FFmpeg with libx264 installed"]
|
||||
async fn static_frames_arrive_without_filling_the_output_buffer() {
|
||||
let config = VideoConfig {
|
||||
width: 320,
|
||||
height: 240,
|
||||
..VideoQuality::Efficient.config()
|
||||
};
|
||||
let mut capture = VideoCapture::start_pipeline(
|
||||
None,
|
||||
config,
|
||||
Some("color=c=black:size=320x240:rate=30"),
|
||||
&VideoEncoder::Software,
|
||||
)
|
||||
.unwrap();
|
||||
// Tiny unchanged frames expose output buffering that moving test cards
|
||||
// hide. Require continuous delivery, including interframes, in real time.
|
||||
tokio::time::timeout(std::time::Duration::from_secs(3), async {
|
||||
for _ in 0..30 {
|
||||
let nals = capture.frame().await.unwrap();
|
||||
assert!(nals.iter().any(|n| matches!(n[0] & 31, 1 | 5)));
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("Static frames were held in the encoder output buffer");
|
||||
}
|
||||
#[test]
|
||||
fn annex_b_supports_both_prefix_lengths() {
|
||||
let data = [0, 0, 0, 1, 0x67, 5, 0, 0, 1, 0x68];
|
||||
assert_eq!(start_code(&data, 0), Some((0, 4)));
|
||||
assert_eq!(start_code(&data, 4), Some((6, 3)));
|
||||
assert_eq!(start_code(&data[..3], 0), None);
|
||||
}
|
||||
}
|
||||
+857
@@ -0,0 +1,857 @@
|
||||
//! AirPlay screen session (type 110 video and type 96 screen audio).
|
||||
//! Wire layout references: Doubletake's protocol implementation and UxPlay.
|
||||
use crate::{
|
||||
media::{AudioCapture, VideoCapture},
|
||||
pairing::ControlSession,
|
||||
settings::LatencyMode,
|
||||
};
|
||||
use airplay_crypto::chacha::ControlCipher;
|
||||
use airplay_rtsp::{RtspMethod, RtspRequest, RtspResponse};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use chacha20poly1305::{
|
||||
ChaCha20Poly1305, KeyInit,
|
||||
aead::{Aead, Payload},
|
||||
};
|
||||
use hkdf::Hkdf;
|
||||
use plist::{Dictionary, Value};
|
||||
use rand::RngCore;
|
||||
use sha2::Sha512;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::Cursor,
|
||||
net::SocketAddr,
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpStream, UdpSocket},
|
||||
sync::mpsc,
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
const AUDIO_RATE: u32 = 44100;
|
||||
|
||||
fn dict(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Value {
|
||||
Value::Dictionary(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
fn number(value: u64) -> Value {
|
||||
Value::Integer(value.into())
|
||||
}
|
||||
fn get<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
value.as_dictionary()?.get(key)
|
||||
}
|
||||
fn integer(value: &Value, key: &str) -> Option<u64> {
|
||||
get(value, key)?
|
||||
.as_unsigned_integer()
|
||||
.or_else(|| get(value, key)?.as_signed_integer().map(|n| n as u64))
|
||||
}
|
||||
fn binary(value: &Value) -> Result<Vec<u8>> {
|
||||
let mut data = vec![];
|
||||
value.to_writer_binary(&mut data)?;
|
||||
Ok(data)
|
||||
}
|
||||
fn parse(data: &[u8]) -> Result<Value> {
|
||||
Ok(Value::from_reader(Cursor::new(data))?)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Clock {
|
||||
anchor: Instant,
|
||||
nanos: u64,
|
||||
timeline: u64,
|
||||
}
|
||||
impl Clock {
|
||||
fn at(&self, time: Instant) -> u64 {
|
||||
let delta = time
|
||||
.checked_duration_since(self.anchor)
|
||||
.map(|d| d.as_nanos() as i128)
|
||||
.unwrap_or_else(|| -(self.anchor.duration_since(time).as_nanos() as i128));
|
||||
(self.nanos as i128 + delta).max(0) as u64
|
||||
}
|
||||
fn update(&mut self, response: &RtspResponse) -> Result<()> {
|
||||
let received: u64 = response
|
||||
.header("X-Apple-RequestReceivedTimestamp")
|
||||
.context("Apple TV did not provide a media clock timestamp")?
|
||||
.parse()?;
|
||||
let processing: u64 = response
|
||||
.header("X-Apple-ProcessingTime")
|
||||
.unwrap_or("0")
|
||||
.parse()?;
|
||||
self.nanos = received
|
||||
.checked_add(processing)
|
||||
.and_then(|v| v.checked_mul(1_000_000))
|
||||
.context("Media clock overflow")?;
|
||||
self.anchor = Instant::now();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn fixed_time(nanos: u64) -> u64 {
|
||||
((nanos / 1_000_000_000) << 32)
|
||||
| (((nanos % 1_000_000_000) as u128 * (1u128 << 32) / 1_000_000_000) as u64)
|
||||
}
|
||||
|
||||
pub struct MirrorSession {
|
||||
video: TcpStream,
|
||||
video_cipher: ChaCha20Poly1305,
|
||||
video_nonce: u64,
|
||||
audio: UdpSocket,
|
||||
audio_control: UdpSocket,
|
||||
audio_cipher: ChaCha20Poly1305,
|
||||
audio_nonce: u64,
|
||||
audio_seq: u16,
|
||||
audio_rtp: u32,
|
||||
audio_origin: Option<(Instant, u32)>,
|
||||
ssrc: u32,
|
||||
history: VecDeque<(u16, Vec<u8>)>,
|
||||
clock: Arc<Mutex<Clock>>,
|
||||
tasks: Vec<JoinHandle<()>>,
|
||||
codec: Option<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
latency: LatencyMode,
|
||||
}
|
||||
|
||||
impl Drop for MirrorSession {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MirrorSession {
|
||||
pub async fn setup(control: &mut ControlSession, width: u32, height: u32) -> Result<Self> {
|
||||
Self::setup_with_latency(control, width, height, LatencyMode::default()).await
|
||||
}
|
||||
|
||||
pub async fn setup_with_latency(
|
||||
control: &mut ControlSession,
|
||||
width: u32,
|
||||
height: u32,
|
||||
latency: LatencyMode,
|
||||
) -> Result<Self> {
|
||||
let secret = control
|
||||
.shared_secret
|
||||
.context("Pair verification is required before mirroring")?;
|
||||
let local = control
|
||||
.transport
|
||||
.local_addr()
|
||||
.context("Control socket has no local address")?
|
||||
.ip();
|
||||
let remote = control.transport.addr().ip();
|
||||
let video_id = rand::random::<u64>() & i64::MAX as u64;
|
||||
let audio_id = rand::random::<u64>() & i64::MAX as u64;
|
||||
let uri = format!("rtsp://{remote}/{audio_id}");
|
||||
let video_uri = format!("rtsp://{remote}/{video_id}");
|
||||
let audio = UdpSocket::bind(SocketAddr::new(local, 0)).await?;
|
||||
let audio_control = UdpSocket::bind(SocketAddr::new(local, 0)).await?;
|
||||
let peer = dict([
|
||||
("ID", uuid::Uuid::new_v4().to_string().into()),
|
||||
("SupportsClockPortMatchingOverride", true.into()),
|
||||
("DeviceType", number(0)),
|
||||
("Addresses", Value::Array(vec![local.to_string().into()])),
|
||||
]);
|
||||
let device_id = uuid::Uuid::new_v4().simple().to_string()[..12].to_owned();
|
||||
let request = dict([
|
||||
("deviceID", device_id.clone().into()),
|
||||
("macAddress", device_id.into()),
|
||||
("sessionUUID", uuid::Uuid::new_v4().to_string().into()),
|
||||
("sourceVersion", "980.71.1".into()),
|
||||
("isScreenMirroringSession", true.into()),
|
||||
("timingProtocol", "PTP".into()),
|
||||
("osBuildVersion", "13F69".into()),
|
||||
("model", "Linux".into()),
|
||||
("name", "Spielab".into()),
|
||||
("timingPeerInfo", peer.clone()),
|
||||
("timingPeerList", Value::Array(vec![peer])),
|
||||
("updateSessionRequest", false.into()),
|
||||
("combinedGetInfoWithControlSetup", true.into()),
|
||||
]);
|
||||
let response = control
|
||||
.request_response(RtspRequest::setup(&uri, binary(&request)?))
|
||||
.await
|
||||
.context("Mirroring control SETUP")?;
|
||||
control.mirror_uri = Some(uri.clone());
|
||||
let body = parse(response.body.as_deref().unwrap_or_default())?;
|
||||
let timeline = get(&body, "timingPeerInfo")
|
||||
.and_then(|v| integer(v, "ClockID"))
|
||||
.context("Receiver did not negotiate PTP timeline")?;
|
||||
let mut clock = Clock {
|
||||
anchor: Instant::now(),
|
||||
nanos: 0,
|
||||
timeline,
|
||||
};
|
||||
clock.update(&response)?;
|
||||
let clock = Arc::new(Mutex::new(clock));
|
||||
let mut tasks = vec![];
|
||||
if let Some(port) = integer(&body, "eventPort").filter(|p| *p > 0 && *p <= 65535) {
|
||||
let socket = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
TcpStream::connect(SocketAddr::new(remote, port as u16)),
|
||||
)
|
||||
.await??;
|
||||
let event_clock = clock.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
if let Err(error) = serve_events(socket, secret, event_clock).await {
|
||||
eprintln!("AirPlay event channel: {error:#}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
// Guard task lifetimes even if one of the later SETUP phases fails.
|
||||
let mut guard = TaskGuard(tasks);
|
||||
if get(&body, "skipRecord").and_then(Value::as_boolean) != Some(true) {
|
||||
control.request(RtspRequest::record(&uri)).await?;
|
||||
}
|
||||
let mut audio_key = [0; 32];
|
||||
rand::thread_rng().fill_bytes(&mut audio_key);
|
||||
let mut audio_desc = Dictionary::new();
|
||||
for (name, value) in [
|
||||
("type", 96),
|
||||
("streamConnectionID", audio_id),
|
||||
("ct", 2),
|
||||
("spf", 352),
|
||||
("sr", 44100),
|
||||
("audioFormat", 0x40000),
|
||||
("latencyMin", 0),
|
||||
("latencyMax", latency.audio_samples(AUDIO_RATE) as u64),
|
||||
] {
|
||||
audio_desc.insert(name.into(), number(value));
|
||||
}
|
||||
audio_desc.insert("audioMode".into(), "default".into());
|
||||
audio_desc.insert("usingScreen".into(), true.into());
|
||||
audio_desc.insert("shk".into(), Value::Data(audio_key.to_vec()));
|
||||
if control.device.features.raw() & (1 << 59) != 0 {
|
||||
audio_desc.insert("isMedia".into(), false.into());
|
||||
audio_desc.insert("supportsDynamicStreamID".into(), true.into());
|
||||
audio_desc.insert(
|
||||
"streamConnections".into(),
|
||||
dict([
|
||||
(
|
||||
"streamConnectionTypeRTP",
|
||||
dict([("streamConnectionKeyUseStreamEncryptionKey", true.into())]),
|
||||
),
|
||||
(
|
||||
"streamConnectionTypeRTCP",
|
||||
dict([(
|
||||
"streamConnectionKeyPort",
|
||||
number(audio_control.local_addr()?.port() as u64),
|
||||
)]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
audio_desc.insert(
|
||||
"controlPort".into(),
|
||||
number(audio_control.local_addr()?.port() as u64),
|
||||
);
|
||||
}
|
||||
let audio_response = control
|
||||
.request(RtspRequest::setup(
|
||||
&uri,
|
||||
binary(&dict([(
|
||||
"streams",
|
||||
Value::Array(vec![Value::Dictionary(audio_desc)]),
|
||||
)]))?,
|
||||
))
|
||||
.await
|
||||
.context("Screen audio SETUP")?;
|
||||
let audio_response = parse(&audio_response)?;
|
||||
let audio_stream = stream(&audio_response, 96)?;
|
||||
let data_port = stream_port(audio_stream, "dataPort", "streamConnectionTypeRTP")?;
|
||||
let control_port = stream_port(audio_stream, "controlPort", "streamConnectionTypeRTCP")?;
|
||||
audio.connect(SocketAddr::new(remote, data_port)).await?;
|
||||
audio_control
|
||||
.connect(SocketAddr::new(remote, control_port))
|
||||
.await?;
|
||||
let video_desc = dict([
|
||||
("type", number(110)),
|
||||
("streamConnectionID", number(video_id)),
|
||||
("latencyMs", number(latency.millis())),
|
||||
("shk", Value::Data(control.stream_key.to_vec())),
|
||||
("shiv", Value::Data(control.stream_iv.to_vec())),
|
||||
(
|
||||
"timestampInfo",
|
||||
Value::Array(
|
||||
["SubSu", "BePxT", "AfPxT", "BefEn", "EmEnc"]
|
||||
.map(|name| dict([("name", name.into())]))
|
||||
.to_vec(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
let video_response = control
|
||||
.request(RtspRequest::setup(
|
||||
&video_uri,
|
||||
binary(&dict([("streams", Value::Array(vec![video_desc]))]))?,
|
||||
))
|
||||
.await
|
||||
.context("Screen video SETUP")?;
|
||||
let video_response = parse(&video_response)?;
|
||||
let port = integer(stream(&video_response, 110)?, "dataPort")
|
||||
.context("Receiver omitted video port")?;
|
||||
ensure!(port > 0 && port <= 65535, "Invalid video port");
|
||||
let video = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
TcpStream::connect(SocketAddr::new(remote, port as u16)),
|
||||
)
|
||||
.await??;
|
||||
video.set_nodelay(true)?;
|
||||
let video_key = derive_key(
|
||||
&secret,
|
||||
format!("DataStream-Salt{video_id}").as_bytes(),
|
||||
b"DataStream-Output-Encryption-Key",
|
||||
);
|
||||
control
|
||||
.request(RtspRequest::set_parameter_text(
|
||||
&uri,
|
||||
b"volume: 0.000000\r\n".to_vec(),
|
||||
))
|
||||
.await?;
|
||||
Ok(Self {
|
||||
video,
|
||||
video_cipher: ChaCha20Poly1305::new(&video_key.into()),
|
||||
video_nonce: 0,
|
||||
audio,
|
||||
audio_control,
|
||||
audio_cipher: ChaCha20Poly1305::new(&audio_key.into()),
|
||||
audio_nonce: 0,
|
||||
audio_seq: 0,
|
||||
audio_rtp: rand::random(),
|
||||
audio_origin: None,
|
||||
ssrc: rand::random(),
|
||||
history: VecDeque::with_capacity(512),
|
||||
clock,
|
||||
tasks: std::mem::take(&mut guard.0),
|
||||
codec: None,
|
||||
width,
|
||||
height,
|
||||
latency,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
&mut self,
|
||||
control: &mut ControlSession,
|
||||
mut video: VideoCapture,
|
||||
mut audio: AudioCapture,
|
||||
progress: impl Fn(u64) + Send,
|
||||
) -> Result<()> {
|
||||
enum Media {
|
||||
Video(Instant, Vec<Vec<u8>>),
|
||||
Audio(Instant, Vec<u8>),
|
||||
Error(anyhow::Error),
|
||||
}
|
||||
let (send, mut frames) = mpsc::channel(2);
|
||||
let video_send = send.clone();
|
||||
self.tasks.push(tokio::spawn(async move {
|
||||
loop {
|
||||
let frame = match video.frame().await {
|
||||
Ok(frame) => Media::Video(Instant::now(), frame),
|
||||
Err(e) => Media::Error(e),
|
||||
};
|
||||
let stop = matches!(frame, Media::Error(_));
|
||||
if video_send.send(frame).await.is_err() || stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
self.tasks.push(tokio::spawn(async move {
|
||||
loop {
|
||||
let frame = match audio.frame().await {
|
||||
Ok(frame) => Media::Audio(Instant::now(), frame),
|
||||
Err(e) => Media::Error(e),
|
||||
};
|
||||
let stop = matches!(frame, Media::Error(_));
|
||||
if send.send(frame).await.is_err() || stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
// Poll control traffic alongside media. A delayed RTSP reply must not
|
||||
// stop capture consumption or video/audio delivery.
|
||||
let feedback_clock = self.clock.clone();
|
||||
let feedback = async {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let started = Instant::now();
|
||||
let response = control
|
||||
.request_response(RtspRequest::new(RtspMethod::Post, "/feedback").body(vec![]))
|
||||
.await?;
|
||||
feedback_clock.lock().unwrap().update(&response)?;
|
||||
eprintln!("Stream control: reply={}ms", started.elapsed().as_millis());
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
Ok::<(), anyhow::Error>(())
|
||||
};
|
||||
tokio::pin!(feedback);
|
||||
let mut max_queue = Duration::ZERO;
|
||||
let mut max_send = Duration::ZERO;
|
||||
let mut previous_frames = 0;
|
||||
let mut heartbeat = tokio::time::interval(Duration::from_secs(1));
|
||||
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut video_frames = 0;
|
||||
let started = Instant::now();
|
||||
let mut control_packet = [0; 2048];
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut feedback => { result?; anyhow::bail!("Control feedback stopped"); },
|
||||
frame = frames.recv() => match frame.context("Capture tasks stopped")? {
|
||||
Media::Video(time, nals) => { max_queue = max_queue.max(time.elapsed()); let send_started = Instant::now(); self.send_video(time, nals).await?; max_send = max_send.max(send_started.elapsed()); video_frames += 1; if video_frames == 1 || video_frames % 30 == 0 { progress(video_frames); } },
|
||||
Media::Audio(time, pcm) => self.send_audio(time, &pcm).await?,
|
||||
Media::Error(error) => return Err(error),
|
||||
},
|
||||
_ = heartbeat.tick() => {
|
||||
ensure!(video_frames > 0 || started.elapsed() < Duration::from_secs(15), "No video frames arrived from the selected screen; check the sharing permission and capture pipeline");
|
||||
ensure!(self.audio_origin.is_some() || started.elapsed() < Duration::from_secs(15), "No system audio frames arrived from PipeWire");
|
||||
if video_frames > 0 {
|
||||
let mut packet = [0;128]; packet[4] = 2; packet[6] = 0x1e;
|
||||
self.write_video(&packet).await?;
|
||||
}
|
||||
eprintln!("Stream sender: frames={} max_queue={}ms max_send={}ms lead={}ms",
|
||||
video_frames - previous_frames, max_queue.as_millis(), max_send.as_millis(), self.latency.millis());
|
||||
previous_frames = video_frames;
|
||||
max_queue = Duration::ZERO;
|
||||
max_send = Duration::ZERO;
|
||||
if let Some((origin, rtp)) = self.audio_origin {
|
||||
let time = Instant::now();
|
||||
let current = rtp.wrapping_add((time.duration_since(origin).as_secs_f64() * AUDIO_RATE as f64) as u32);
|
||||
self.send_sync(time, current, false).await?;
|
||||
}
|
||||
},
|
||||
size = self.audio_control.recv(&mut control_packet) => {
|
||||
self.retransmit(&control_packet[..size?]).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_video(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
tokio::time::timeout(Duration::from_secs(2), self.video.write_all(bytes))
|
||||
.await
|
||||
.context("Apple TV stopped reading video")??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_video(&mut self, time: Instant, nals: Vec<Vec<u8>>) -> Result<()> {
|
||||
let sps = nals.iter().find(|n| n[0] & 31 == 7);
|
||||
let pps = nals.iter().find(|n| n[0] & 31 == 8);
|
||||
let clock = self.clock.lock().unwrap().clone();
|
||||
let timestamp = fixed_time(clock.at(time + self.latency.lead()));
|
||||
if let (Some(sps), Some(pps)) = (sps, pps) {
|
||||
let config = avcc_config(sps, pps)?;
|
||||
if self.codec.as_ref() != Some(&config) {
|
||||
let mut header = video_header(config.len(), 1, timestamp, 0)?;
|
||||
header[6] = 0x16;
|
||||
header[7] = 1;
|
||||
for offset in [16, 40, 56] {
|
||||
header[offset..offset + 4].copy_from_slice(&(self.width as f32).to_le_bytes());
|
||||
header[offset + 4..offset + 8]
|
||||
.copy_from_slice(&(self.height as f32).to_le_bytes());
|
||||
}
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(&config);
|
||||
self.write_video(&packet).await?;
|
||||
self.codec = Some(config);
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
self.codec.is_some(),
|
||||
"Encoder did not provide H.264 SPS/PPS"
|
||||
);
|
||||
let mut data = vec![];
|
||||
let mut keyframe = false;
|
||||
for nal in nals {
|
||||
if [7, 8, 9].contains(&(nal[0] & 31)) {
|
||||
continue;
|
||||
}
|
||||
keyframe |= nal[0] & 31 == 5;
|
||||
data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
data.extend_from_slice(&nal);
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut header = video_header(data.len() + 16, 0, timestamp, clock.timeline)?;
|
||||
if keyframe {
|
||||
header[5] = 0x10;
|
||||
}
|
||||
let packet = seal_video(&self.video_cipher, &header, self.video_nonce, &data)?;
|
||||
self.video_nonce = self
|
||||
.video_nonce
|
||||
.checked_add(1)
|
||||
.context("Video nonce exhausted")?;
|
||||
self.write_video(&packet).await
|
||||
}
|
||||
|
||||
async fn send_audio(&mut self, time: Instant, pcm: &[u8]) -> Result<()> {
|
||||
if self.audio_origin.is_none() {
|
||||
self.audio_origin = Some((time, self.audio_rtp));
|
||||
self.send_sync(time, self.audio_rtp, true).await?;
|
||||
}
|
||||
let payload = alac_verbatim(pcm)?;
|
||||
let mut header = [0u8; 12];
|
||||
header[0] = 0x80;
|
||||
header[1] = 96;
|
||||
header[2..4].copy_from_slice(&self.audio_seq.to_be_bytes());
|
||||
header[4..8].copy_from_slice(&self.audio_rtp.to_be_bytes());
|
||||
header[8..12].copy_from_slice(&self.ssrc.to_be_bytes());
|
||||
let nonce = nonce(self.audio_nonce);
|
||||
let sealed = self
|
||||
.audio_cipher
|
||||
.encrypt(
|
||||
(&nonce).into(),
|
||||
Payload {
|
||||
msg: &payload,
|
||||
aad: &header[4..12],
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Audio encryption failed"))?;
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(&sealed);
|
||||
packet.extend_from_slice(&self.audio_nonce.to_le_bytes());
|
||||
self.audio.send(&packet).await?;
|
||||
if self.history.len() == 512 {
|
||||
self.history.pop_front();
|
||||
}
|
||||
self.history.push_back((self.audio_seq, packet));
|
||||
self.audio_nonce = self
|
||||
.audio_nonce
|
||||
.checked_add(1)
|
||||
.context("Audio nonce exhausted")?;
|
||||
self.audio_seq = self.audio_seq.wrapping_add(1);
|
||||
self.audio_rtp = self.audio_rtp.wrapping_add(352);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_sync(&self, time: Instant, rtp: u32, first: bool) -> Result<()> {
|
||||
let clock = self.clock.lock().unwrap().clone();
|
||||
let mut packet = [0u8; 28];
|
||||
packet[0] = if first { 0x90 } else { 0x80 };
|
||||
packet[1] = 0xd7;
|
||||
packet[3] = 4;
|
||||
packet[4..8].copy_from_slice(
|
||||
&rtp.wrapping_sub(self.latency.audio_samples(AUDIO_RATE))
|
||||
.to_be_bytes(),
|
||||
);
|
||||
packet[8..16].copy_from_slice(&clock.at(time).to_be_bytes());
|
||||
packet[16..20].copy_from_slice(&rtp.to_be_bytes());
|
||||
packet[20..28].copy_from_slice(&clock.timeline.to_be_bytes());
|
||||
self.audio_control.send(&packet).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retransmit(&self, request: &[u8]) -> Result<()> {
|
||||
if request.len() != 8 || request[..2] != [0x80, 0xd5] {
|
||||
return Ok(());
|
||||
}
|
||||
let first = u16::from_be_bytes([request[4], request[5]]);
|
||||
let count = u16::from_be_bytes([request[6], request[7]]).min(512);
|
||||
for offset in 0..count {
|
||||
let seq = first.wrapping_add(offset);
|
||||
if let Some((_, original)) = self.history.iter().find(|(number, _)| *number == seq) {
|
||||
let mut response = vec![0x80, 0xd6, request[2], request[3]];
|
||||
response.extend_from_slice(original);
|
||||
self.audio_control.send(&response).await?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskGuard(Vec<JoinHandle<()>>);
|
||||
impl Drop for TaskGuard {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.0 {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stream(response: &Value, kind: u64) -> Result<&Value> {
|
||||
get(response, "streams")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|streams| streams.iter().find(|s| integer(s, "type") == Some(kind)))
|
||||
.context("SETUP omitted expected stream")
|
||||
}
|
||||
fn stream_port(stream: &Value, old: &str, modern: &str) -> Result<u16> {
|
||||
let port = integer(stream, old)
|
||||
.or_else(|| {
|
||||
get(stream, "streamConnections")
|
||||
.and_then(|v| get(v, modern))
|
||||
.and_then(|v| integer(v, "streamConnectionKeyPort"))
|
||||
})
|
||||
.context("SETUP omitted audio port")?;
|
||||
ensure!(port > 0 && port <= 65535, "Invalid audio port");
|
||||
Ok(port as u16)
|
||||
}
|
||||
fn derive_key(secret: &[u8], salt: &[u8], info: &[u8]) -> [u8; 32] {
|
||||
let mut key = [0; 32];
|
||||
Hkdf::<Sha512>::new(Some(salt), secret)
|
||||
.expand(info, &mut key)
|
||||
.expect("32 byte HKDF output");
|
||||
key
|
||||
}
|
||||
fn nonce(counter: u64) -> [u8; 12] {
|
||||
let mut n = [0; 12];
|
||||
n[4..].copy_from_slice(&counter.to_le_bytes());
|
||||
n
|
||||
}
|
||||
fn video_header(length: usize, kind: u8, time: u64, timeline: u64) -> Result<[u8; 128]> {
|
||||
ensure!(length <= 16 * 1024 * 1024, "Video frame too large");
|
||||
let mut h = [0; 128];
|
||||
h[..4].copy_from_slice(&(length as u32).to_le_bytes());
|
||||
h[4] = kind;
|
||||
h[8..16].copy_from_slice(&time.to_le_bytes());
|
||||
h[40..48].copy_from_slice(&timeline.to_le_bytes());
|
||||
Ok(h)
|
||||
}
|
||||
fn seal_video(
|
||||
cipher: &ChaCha20Poly1305,
|
||||
header: &[u8; 128],
|
||||
counter: u64,
|
||||
data: &[u8],
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(
|
||||
&cipher
|
||||
.encrypt(
|
||||
(&nonce(counter)).into(),
|
||||
Payload {
|
||||
msg: data,
|
||||
aad: header,
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Video encryption failed"))?,
|
||||
);
|
||||
Ok(packet)
|
||||
}
|
||||
fn avcc_config(sps: &[u8], pps: &[u8]) -> Result<Vec<u8>> {
|
||||
ensure!(
|
||||
sps.len() >= 4 && sps.len() <= 65535 && !pps.is_empty() && pps.len() <= 65535,
|
||||
"Invalid H.264 parameter sets"
|
||||
);
|
||||
let mut config = vec![1, sps[1], sps[2], sps[3], 0xff, 0xe1];
|
||||
config.extend_from_slice(&(sps.len() as u16).to_be_bytes());
|
||||
config.extend_from_slice(sps);
|
||||
config.push(1);
|
||||
config.extend_from_slice(&(pps.len() as u16).to_be_bytes());
|
||||
config.extend_from_slice(pps);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn alac_verbatim(pcm: &[u8]) -> Result<Vec<u8>> {
|
||||
ensure!(pcm.len() == 352 * 4, "Expected 352 stereo PCM frames");
|
||||
let mut output = vec![0; (55 + pcm.len() * 8 + 3).div_ceil(8)];
|
||||
let mut bit = 0;
|
||||
let mut write = |value: u32, count: usize| {
|
||||
for shift in (0..count).rev() {
|
||||
output[bit / 8] |= (((value >> shift) & 1) as u8) << (7 - bit % 8);
|
||||
bit += 1;
|
||||
}
|
||||
};
|
||||
write(1, 3);
|
||||
write(0, 4);
|
||||
write(0, 12);
|
||||
write(1, 1);
|
||||
write(0, 2);
|
||||
write(1, 1);
|
||||
write(352, 32);
|
||||
for sample in pcm.as_chunks::<2>().0 {
|
||||
write(u16::from_le_bytes([sample[0], sample[1]]) as u32, 16);
|
||||
}
|
||||
write(7, 3);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn serve_events(
|
||||
mut socket: TcpStream,
|
||||
secret: [u8; 32],
|
||||
clock: Arc<Mutex<Clock>>,
|
||||
) -> Result<()> {
|
||||
let mut cipher = ControlCipher::new(
|
||||
derive_key(&secret, b"Events-Salt", b"Events-Read-Encryption-Key"),
|
||||
derive_key(&secret, b"Events-Salt", b"Events-Write-Encryption-Key"),
|
||||
);
|
||||
let mut pending = vec![];
|
||||
loop {
|
||||
let length = socket.read_u16_le().await?;
|
||||
ensure!(length > 0 && length <= 1024, "Invalid event frame length");
|
||||
let mut frame = vec![0; length as usize + 16];
|
||||
socket.read_exact(&mut frame).await?;
|
||||
pending.extend_from_slice(&cipher.decrypt_block(&frame, length)?);
|
||||
ensure!(pending.len() <= 1024 * 1024, "Event request exceeds limit");
|
||||
while let Some(boundary) = pending.windows(4).position(|s| s == b"\r\n\r\n") {
|
||||
ensure!(boundary <= 16384, "Event headers exceed limit");
|
||||
let headers = std::str::from_utf8(&pending[..boundary])?;
|
||||
let mut size = 0;
|
||||
let mut cseq = None;
|
||||
for line in headers.lines().skip(1) {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
if key.eq_ignore_ascii_case("Content-Length") {
|
||||
size = value.trim().parse::<usize>()?;
|
||||
}
|
||||
if key.eq_ignore_ascii_case("CSeq") {
|
||||
cseq = Some(value.trim().parse::<u32>()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure!(size <= 1024 * 1024 - 16388, "Event body exceeds limit");
|
||||
if pending.len() < boundary + 4 + size {
|
||||
break;
|
||||
}
|
||||
let cseq = cseq.context("Event omitted CSeq")?;
|
||||
if size > 0
|
||||
&& let Ok(command) = parse(&pending[boundary + 4..boundary + 4 + size])
|
||||
&& get(&command, "type").and_then(Value::as_string) == Some("updateTimingPeerInfo")
|
||||
&& let Some(id) = get(&command, "value")
|
||||
.and_then(|v| integer(v, "ClockID"))
|
||||
.filter(|v| *v != 0)
|
||||
{
|
||||
clock.lock().unwrap().timeline = id;
|
||||
}
|
||||
pending.drain(..boundary + 4 + size);
|
||||
let response = format!("RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\nContent-Length: 0\r\n\r\n");
|
||||
socket
|
||||
.write_all(&cipher.encrypt(response.as_bytes())?)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual packet-encryption timing diagnostic"]
|
||||
fn measure_video_packet_encryption() {
|
||||
let cipher = ChaCha20Poly1305::new(&[7; 32].into());
|
||||
let payload = vec![42; 65536];
|
||||
let header = video_header(payload.len() + 16, 0, 123, 456).unwrap();
|
||||
let started = Instant::now();
|
||||
for nonce in 0..100 {
|
||||
std::hint::black_box(seal_video(&cipher, &header, nonce, &payload).unwrap());
|
||||
}
|
||||
eprintln!(
|
||||
"64 KiB video encryption: {:.3} ms/packet",
|
||||
started.elapsed().as_secs_f64() * 10.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_lead_matches_audio_sample_offset_for_both_modes() {
|
||||
let clock = Clock {
|
||||
anchor: Instant::now(),
|
||||
nanos: 2_000_000_000,
|
||||
timeline: 1,
|
||||
};
|
||||
for (mode, samples, nanos) in [
|
||||
(LatencyMode::Interactive, 2205, 50_000_000),
|
||||
(LatencyMode::Responsive, 4410, 100_000_000),
|
||||
(LatencyMode::Buffered, 22050, 500_000_000),
|
||||
] {
|
||||
assert_eq!(mode.audio_samples(AUDIO_RATE), samples);
|
||||
assert_eq!(clock.at(clock.anchor + mode.lead()) - clock.nanos, nanos);
|
||||
// RTP offset arithmetic must also work across its 32-bit wrap.
|
||||
let rtp = 5u32;
|
||||
assert_eq!(rtp.wrapping_sub(samples).wrapping_add(samples), rtp);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn encrypted_video_authenticates_header_and_uses_distinct_nonces() {
|
||||
let key = derive_key(
|
||||
&[7; 32],
|
||||
b"DataStream-Salt42",
|
||||
b"DataStream-Output-Encryption-Key",
|
||||
);
|
||||
let cipher = ChaCha20Poly1305::new(&key.into());
|
||||
let header = video_header(19, 0, 123, 456).unwrap();
|
||||
let packet = seal_video(&cipher, &header, 0, b"abc").unwrap();
|
||||
assert_eq!(packet.len(), 147);
|
||||
assert_eq!(
|
||||
cipher
|
||||
.decrypt(
|
||||
(&nonce(0)).into(),
|
||||
Payload {
|
||||
msg: &packet[128..],
|
||||
aad: &header
|
||||
}
|
||||
)
|
||||
.unwrap(),
|
||||
b"abc"
|
||||
);
|
||||
let mut changed = header;
|
||||
changed[8] ^= 1;
|
||||
assert!(
|
||||
cipher
|
||||
.decrypt(
|
||||
(&nonce(0)).into(),
|
||||
Payload {
|
||||
msg: &packet[128..],
|
||||
aad: &changed
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_ne!(packet, seal_video(&cipher, &header, 1, b"abc").unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn uncompressed_alac_has_expected_bit_length_and_stereo_tag() {
|
||||
let packet = alac_verbatim(&vec![0; 1408]).unwrap();
|
||||
assert_eq!(packet.len(), 1416);
|
||||
assert_eq!(packet[0], 0x20);
|
||||
assert!(alac_verbatim(&[0; 10]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn fixed_point_timestamps_preserve_half_seconds() {
|
||||
assert_eq!(fixed_time(1_500_000_000), 0x1_8000_0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_binary_plist_clock_ids_retain_all_64_bits() {
|
||||
let id = -4253718895002124280i64;
|
||||
let wire = binary(&dict([("ClockID", Value::Integer(id.into()))])).unwrap();
|
||||
assert_eq!(integer(&parse(&wire).unwrap(), "ClockID"), Some(id as u64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_alac_decoder_recovers_pcm_exactly() {
|
||||
use symphonia_codec_alac::AlacDecoder;
|
||||
use symphonia_core::{
|
||||
audio::SampleBuffer,
|
||||
codecs::{CODEC_TYPE_ALAC, CodecParameters, Decoder, DecoderOptions},
|
||||
formats::Packet,
|
||||
};
|
||||
let samples: Vec<i16> = (0..704)
|
||||
.map(|i| (i as i16).wrapping_mul(173).wrapping_sub(32768u16 as i16))
|
||||
.collect();
|
||||
let pcm: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
let encoded = alac_verbatim(&pcm).unwrap();
|
||||
let mut cookie = vec![];
|
||||
cookie.extend_from_slice(&352u32.to_be_bytes());
|
||||
cookie.extend_from_slice(&[0, 16, 40, 10, 14, 2]);
|
||||
cookie.extend_from_slice(&255u16.to_be_bytes());
|
||||
cookie.extend_from_slice(&0u32.to_be_bytes());
|
||||
cookie.extend_from_slice(&0u32.to_be_bytes());
|
||||
cookie.extend_from_slice(&44100u32.to_be_bytes());
|
||||
let mut parameters = CodecParameters::new();
|
||||
parameters.codec = CODEC_TYPE_ALAC;
|
||||
parameters.extra_data = Some(cookie.into_boxed_slice());
|
||||
let mut decoder = AlacDecoder::try_new(¶meters, &DecoderOptions::default()).unwrap();
|
||||
let decoded = decoder
|
||||
.decode(&Packet::new_from_slice(0, 0, 352, &encoded))
|
||||
.unwrap();
|
||||
let mut output = SampleBuffer::<i16>::new(decoded.capacity() as u64, *decoded.spec());
|
||||
output.copy_interleaved_ref(decoded);
|
||||
assert_eq!(output.samples(), samples);
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
//! HomeKit normal pairing (HKP=3) and authenticated control connections.
|
||||
use airplay_core::Device;
|
||||
use airplay_crypto::{chacha::ControlCipher, ed25519::IdentityKeyPair};
|
||||
use airplay_pairing::{ControllerIdentity, PairSetup, PairVerify};
|
||||
use airplay_rtsp::{RtspConnection, RtspMethod, RtspRequest};
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
// Deliberately does not implement Debug: this contains a long-term secret.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
controller_id: String,
|
||||
seed: [u8; 32],
|
||||
receiver_key: [u8; 32],
|
||||
}
|
||||
|
||||
pub struct CredentialStore(PathBuf);
|
||||
|
||||
impl CredentialStore {
|
||||
pub fn new(directory: PathBuf) -> Result<Self> {
|
||||
fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(&directory)?;
|
||||
ensure!(
|
||||
!fs::symlink_metadata(&directory)?.file_type().is_symlink(),
|
||||
"Credential directory must not be a symlink"
|
||||
);
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?;
|
||||
Ok(Self(directory))
|
||||
}
|
||||
|
||||
fn path(&self, device: &Device) -> PathBuf {
|
||||
self.0.join(format!(
|
||||
"{}.json",
|
||||
device.id.to_mac_string().replace(':', "")
|
||||
))
|
||||
}
|
||||
|
||||
pub fn load(&self, device: &Device) -> Result<Option<Credentials>> {
|
||||
match fs::read(self.path(device)) {
|
||||
Ok(data) => Ok(Some(
|
||||
serde_json::from_slice(&data).context("Invalid saved pairing identity")?,
|
||||
)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, device: &Device, credentials: &Credentials) -> Result<()> {
|
||||
write_private(&self.path(device), &serde_json::to_vec(credentials)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_private(path: &Path, data: &[u8]) -> Result<()> {
|
||||
let temp = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4()));
|
||||
let result = (|| {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp)?;
|
||||
file.write_all(data)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temp, path)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(temp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub struct ControlSession {
|
||||
pub transport: RtspConnection,
|
||||
pub device: Device,
|
||||
pub shared_secret: Option<[u8; 32]>,
|
||||
pub mirror_uri: Option<String>,
|
||||
pub stream_key: [u8; 16],
|
||||
pub stream_iv: [u8; 16],
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
impl ControlSession {
|
||||
pub async fn open(device: Device) -> Result<Self> {
|
||||
let addr = device
|
||||
.socket_addr()
|
||||
.context("Receiver has no usable address")?;
|
||||
let mut transport = RtspConnection::new(addr);
|
||||
tokio::time::timeout(Duration::from_secs(8), transport.connect())
|
||||
.await
|
||||
.context("Timed out connecting to receiver")??;
|
||||
let client_id = uuid::Uuid::new_v4().simple().to_string()[..12].to_owned();
|
||||
transport.add_session_header("User-Agent", "AirPlay/745.83");
|
||||
transport.add_session_header("X-Apple-Client-Name", "Spielab");
|
||||
transport.add_session_header("X-Apple-Device-ID", &client_id);
|
||||
let mut session = Self {
|
||||
transport,
|
||||
device,
|
||||
client_id,
|
||||
shared_secret: None,
|
||||
mirror_uri: None,
|
||||
stream_key: [0; 16],
|
||||
stream_iv: [0; 16],
|
||||
};
|
||||
session.request(RtspRequest::get_info()).await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn request(&mut self, request: RtspRequest) -> Result<Vec<u8>> {
|
||||
Ok(self
|
||||
.request_response(request)
|
||||
.await?
|
||||
.body
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn request_response(
|
||||
&mut self,
|
||||
request: RtspRequest,
|
||||
) -> Result<airplay_rtsp::RtspResponse> {
|
||||
let endpoint = request.uri.clone();
|
||||
let response = tokio::time::timeout(Duration::from_secs(12), self.transport.send(request))
|
||||
.await
|
||||
.with_context(|| format!("Receiver timed out at {endpoint}"))??;
|
||||
ensure!(
|
||||
(200..300).contains(&response.status_code),
|
||||
"Receiver rejected {endpoint}: status {}",
|
||||
response.status_code
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn stop_mirroring(&mut self) -> Result<()> {
|
||||
if let Some(uri) = self.mirror_uri.take() {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
self.request(RtspRequest::teardown(uri)),
|
||||
)
|
||||
.await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn request_pin(&mut self) -> Result<()> {
|
||||
self.request(RtspRequest::new(RtspMethod::Post, "/pair-pin-start").body(Vec::new()))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pair(&mut self, pin: &str) -> Result<Credentials> {
|
||||
validate_pin(pin)?;
|
||||
let controller = ControllerIdentity::generate();
|
||||
let mut setup = PairSetup::new(pin);
|
||||
let m1 = setup.generate_m1()?;
|
||||
let m2 = self
|
||||
.request(RtspRequest::pair_setup(m1, &self.client_id, 3))
|
||||
.await?;
|
||||
setup.process_m2(&m2)?;
|
||||
let m3 = setup.generate_m3()?;
|
||||
let m4 = self
|
||||
.request(RtspRequest::pair_setup(m3, &self.client_id, 3))
|
||||
.await?;
|
||||
setup.process_m4(&m4)?;
|
||||
let m5 = setup.generate_m5_with_controller(&controller)?;
|
||||
let m6 = self
|
||||
.request(RtspRequest::pair_setup(m5, &self.client_id, 3))
|
||||
.await?;
|
||||
setup.process_m6(&m6)?;
|
||||
let credentials = Credentials {
|
||||
controller_id: controller.id().into(),
|
||||
seed: controller.keypair().seed(),
|
||||
receiver_key: setup
|
||||
.server_ltpk()
|
||||
.context("Receiver omitted its identity key")?,
|
||||
};
|
||||
self.verify(&credentials).await?;
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
pub async fn verify(&mut self, credentials: &Credentials) -> Result<()> {
|
||||
let controller = ControllerIdentity::with_id(
|
||||
IdentityKeyPair::from_seed(&credentials.seed),
|
||||
credentials.controller_id.clone(),
|
||||
);
|
||||
let mut verify = PairVerify::new_with_controller(&controller);
|
||||
verify.set_server_ltpk(credentials.receiver_key);
|
||||
let m1 = verify.generate_m1()?;
|
||||
let m2 = self.request(RtspRequest::pair_verify(m1, 3)).await?;
|
||||
verify.process_m2(&m2)?;
|
||||
let m3 = verify.generate_m3()?;
|
||||
let m4 = self.request(RtspRequest::pair_verify(m3, 3)).await?;
|
||||
let keys = verify.process_m4(&m4)?;
|
||||
self.shared_secret = verify.shared_secret();
|
||||
self.stream_key
|
||||
.copy_from_slice(&keys.write_key.as_bytes()[..16]);
|
||||
self.stream_iv
|
||||
.copy_from_slice(&keys.read_key.as_bytes()[..16]);
|
||||
self.transport.set_cipher(ControlCipher::new(
|
||||
*keys.write_key.as_bytes(),
|
||||
*keys.read_key.as_bytes(),
|
||||
));
|
||||
// Only report an authenticated session after a successful encrypted round trip.
|
||||
self.request(RtspRequest::options()).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_pin(pin: &str) -> Result<()> {
|
||||
if pin.len() != 4 || !pin.bytes().all(|b| b.is_ascii_digit()) {
|
||||
bail!("Enter the four digits displayed on Apple TV");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pin_accepts_leading_zero_and_rejects_unicode_or_whitespace() {
|
||||
assert!(validate_pin("0123").is_ok());
|
||||
for value in ["", "123", "12345", "1234", "12 3", "1234\n"] {
|
||||
assert!(validate_pin(value).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_replacement_is_private_and_atomic() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("identity.json");
|
||||
write_private(&path, b"first").unwrap();
|
||||
write_private(&path, b"replacement").unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"replacement");
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
//! Validated quality presets shared by the UI, capture and stream setup.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LatencyMode {
|
||||
#[default]
|
||||
Interactive,
|
||||
Responsive,
|
||||
Buffered,
|
||||
}
|
||||
|
||||
impl LatencyMode {
|
||||
pub const ALL: [Self; 3] = [Self::Interactive, Self::Responsive, Self::Buffered];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interactive => "Interactive · 50 ms",
|
||||
Self::Responsive => "Responsive · 100 ms",
|
||||
Self::Buffered => "Buffered · 500 ms",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn millis(self) -> u64 {
|
||||
match self {
|
||||
Self::Interactive => 50,
|
||||
Self::Responsive => 100,
|
||||
Self::Buffered => 500,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lead(self) -> std::time::Duration {
|
||||
std::time::Duration::from_millis(self.millis())
|
||||
}
|
||||
|
||||
pub fn audio_samples(self, rate: u32) -> u32 {
|
||||
(self.millis() * rate as u64 / 1000) as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum VideoQuality {
|
||||
Efficient,
|
||||
#[default]
|
||||
High,
|
||||
Smooth,
|
||||
}
|
||||
|
||||
impl VideoQuality {
|
||||
pub const ALL: [Self; 3] = [Self::Efficient, Self::High, Self::Smooth];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Efficient => "720p / 30",
|
||||
Self::High => "1080p / 30",
|
||||
Self::Smooth => "1080p / 60",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Efficient => "Lower bandwidth and CPU use · up to 8 Mbps",
|
||||
Self::High => "Sharper text and detail · up to 20 Mbps",
|
||||
Self::Smooth => "Sharper text and smoother motion · up to 30 Mbps · higher CPU use",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(self) -> VideoConfig {
|
||||
match self {
|
||||
Self::Efficient => VideoConfig {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
fps: 30,
|
||||
crf: 23,
|
||||
max_kbps: 8000,
|
||||
buffer_kbps: 2000,
|
||||
preset: "ultrafast",
|
||||
},
|
||||
Self::High => VideoConfig {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
crf: 18,
|
||||
max_kbps: 20000,
|
||||
buffer_kbps: 4000,
|
||||
preset: "veryfast",
|
||||
},
|
||||
Self::Smooth => VideoConfig {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
crf: 18,
|
||||
max_kbps: 30000,
|
||||
buffer_kbps: 6000,
|
||||
preset: "veryfast",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct VideoConfig {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub crf: u8,
|
||||
pub max_kbps: u32,
|
||||
pub buffer_kbps: u32,
|
||||
pub preset: &'static str,
|
||||
}
|
||||
|
||||
impl VideoConfig {
|
||||
pub fn validate(self) -> anyhow::Result<()> {
|
||||
anyhow::ensure!(
|
||||
self.width > 0
|
||||
&& self.height > 0
|
||||
&& self.width <= 3840
|
||||
&& self.height <= 2160
|
||||
&& self.width.is_multiple_of(2)
|
||||
&& self.height.is_multiple_of(2),
|
||||
"Invalid video dimensions"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
(1..=60).contains(&self.fps)
|
||||
&& self.crf <= 51
|
||||
&& self.max_kbps > 0
|
||||
&& self.buffer_kbps > 0,
|
||||
"Invalid video encoding settings"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CaptureMode {
|
||||
#[default]
|
||||
Mirror,
|
||||
Extend,
|
||||
}
|
||||
|
||||
impl CaptureMode {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mirror => "Screen or window",
|
||||
Self::Extend => "Extended display",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use spielab::{
|
||||
media::{VideoCapture, VideoEncoder},
|
||||
settings::{VideoConfig, VideoQuality},
|
||||
};
|
||||
use std::{process::Stdio, time::Duration};
|
||||
use tokio::{io::AsyncWriteExt, process::Command};
|
||||
|
||||
/// Exercises the actual encoder, Annex-B reader and an independent H.264 decoder.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FFmpeg with libx264 installed"]
|
||||
async fn encoded_access_units_decode_to_three_video_frames() {
|
||||
decode_frames(VideoConfig {
|
||||
width: 320,
|
||||
height: 240,
|
||||
..VideoQuality::Efficient.config()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires FFmpeg with libx264 installed"]
|
||||
async fn every_quality_preset_encodes_the_requested_resolution_and_frame_rate() {
|
||||
for quality in VideoQuality::ALL {
|
||||
decode_frames(quality.config()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn decode_frames(config: VideoConfig) {
|
||||
decode_with_encoder(config, &VideoEncoder::Software, 3).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a working VA-API H.264 encoder on /dev/dri/renderD128"]
|
||||
async fn hardware_frames_decode_at_every_quality_and_auto_detection_selects_gpu() {
|
||||
let encoder = VideoEncoder::Vaapi("/dev/dri/renderD128".into());
|
||||
for quality in VideoQuality::ALL {
|
||||
encoder.probe(quality.config()).await.unwrap();
|
||||
decode_with_encoder(
|
||||
quality.config(),
|
||||
&encoder,
|
||||
quality.config().fps as usize + 2,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert!(matches!(
|
||||
VideoEncoder::detect(VideoQuality::High.config()).await,
|
||||
VideoEncoder::Vaapi(_)
|
||||
));
|
||||
}
|
||||
|
||||
async fn decode_with_encoder(config: VideoConfig, encoder: &VideoEncoder, count: usize) {
|
||||
let mut capture = VideoCapture::start_with_encoder(None, config, encoder).unwrap();
|
||||
let mut stream = vec![];
|
||||
for index in 0..count {
|
||||
let nals = tokio::time::timeout(Duration::from_secs(5), capture.frame())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if index == 0 {
|
||||
assert!(nals.iter().any(|n| n[0] & 31 == 7));
|
||||
assert!(nals.iter().any(|n| n[0] & 31 == 8));
|
||||
}
|
||||
for nal in nals {
|
||||
stream.extend_from_slice(&[0, 0, 0, 1]);
|
||||
stream.extend_from_slice(&nal);
|
||||
}
|
||||
}
|
||||
drop(capture);
|
||||
let mut decoder = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"h264",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-f",
|
||||
"framehash",
|
||||
"pipe:1",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
decoder
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(&stream)
|
||||
.await
|
||||
.unwrap();
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), decoder.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let decoded = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(
|
||||
decoded.contains(&format!(
|
||||
"#dimensions 0: {}x{}",
|
||||
config.width, config.height
|
||||
)),
|
||||
"{decoded}"
|
||||
);
|
||||
assert!(
|
||||
decoded.contains(&format!("#tb 0: 1/{}", config.fps)),
|
||||
"{decoded}"
|
||||
);
|
||||
assert_eq!(
|
||||
decoded
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with('#') && !line.is_empty())
|
||||
.count(),
|
||||
count
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user