Compare commits
7
Commits
2d0143f1aa
...
04bc0a808a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04bc0a808a | ||
|
|
b0fa259187 | ||
|
|
1a746461b4 | ||
|
|
9e839ca452 | ||
|
|
f5d0333366 | ||
|
|
14fc1af716 | ||
|
|
9b9328f6a9 |
+10
-1
@@ -1,5 +1,14 @@
|
||||
/// ALPN identifying the pixelpass wire protocol on the iroh tunnel.
|
||||
/// ALPN identifying the pixelpass video wire protocol on the iroh tunnel.
|
||||
///
|
||||
/// Bump the version suffix whenever the wire format changes. Today the wire is
|
||||
/// "raw MPEG-TS bytes copied bidirectionally," so bumps will be rare.
|
||||
pub const ALPN: &[u8] = b"pixelpass/0";
|
||||
|
||||
/// ALPN for the friends control plane — the always-on presence endpoint that
|
||||
/// carries friend requests and shared codes between peers' GUIs. Separate from
|
||||
/// [`ALPN`] so the same machine can run a control endpoint and a video endpoint
|
||||
/// without their accept loops colliding, and so a control dial never lands on a
|
||||
/// bare video host (which doesn't speak this protocol). GUI-only, like the rest
|
||||
/// of the friends stack.
|
||||
#[cfg(feature = "gui")]
|
||||
pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0";
|
||||
|
||||
+16
-5
@@ -36,6 +36,10 @@ pub struct GuiSettings {
|
||||
/// `~/.config/pixelpass/themes/`). Defaults to the built-in Default Dark.
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String,
|
||||
/// The display name shown to friends (in requests and shared codes).
|
||||
/// Seeded from the login name; editable in Settings.
|
||||
#[serde(default = "default_display_name")]
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl Default for GuiSettings {
|
||||
@@ -44,6 +48,7 @@ impl Default for GuiSettings {
|
||||
close_to_tray: false,
|
||||
show_qr: true,
|
||||
theme: default_theme(),
|
||||
display_name: default_display_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +61,15 @@ fn default_theme() -> String {
|
||||
"Default Dark".to_string()
|
||||
}
|
||||
|
||||
/// Seed the friends display name from the login name, falling back to a
|
||||
/// generic label when `$USER` isn't set.
|
||||
fn default_display_name() -> String {
|
||||
std::env::var("USER")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "PixelPass user".to_string())
|
||||
}
|
||||
|
||||
/// Result of the first-run upstream measurement.
|
||||
///
|
||||
/// `status = "unmeasured"` means we've never asked the user — show the
|
||||
@@ -84,7 +98,6 @@ pub enum BandwidthStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
|
||||
fn default_status() -> BandwidthStatus {
|
||||
BandwidthStatus::Unmeasured
|
||||
}
|
||||
@@ -116,11 +129,9 @@ pub fn save(cfg: &Config) -> Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("config path has no parent directory")?;
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let serialized =
|
||||
toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?;
|
||||
let serialized = toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?;
|
||||
|
||||
let tmp = parent.join(format!(".config.toml.tmp.{}", std::process::id()));
|
||||
{
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Friends control-plane protocol and service.
|
||||
//!
|
||||
//! This is the always-on presence channel that rides the [`CONTROL_ALPN`]
|
||||
//! endpoint (bound with the persistent identity — see
|
||||
//! [`super::endpoint::bind_control`]). It's how two peers' GUIs exchange friend
|
||||
//! requests and pushed share-codes, independent of any video session.
|
||||
//!
|
||||
//! Wire shape: **one message per connection.** The sender opens a bi-stream,
|
||||
//! writes the JSON-encoded [`ControlMsg`], and finishes its send side (EOF
|
||||
//! delimits the message — no length framing needed). The receiver reads to EOF,
|
||||
//! parses, hands the message up, then writes a one-byte [`ACK`] back so the
|
||||
//! sender knows it was delivered *and* parsed. That delivery signal is what
|
||||
//! lets the host-side code-push queue (a later phase) tell "sent" from "friend
|
||||
//! was offline." A friend's *reply* (accept/decline) is a separate later
|
||||
//! connection in the other direction, because acceptance can happen minutes
|
||||
//! after the request — not a response on the same stream.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use iroh::endpoint::{Incoming, VarInt};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::alpn::CONTROL_ALPN;
|
||||
|
||||
/// Upper bound on a single control message. Generous for a display name plus a
|
||||
/// share-code ticket (~150 chars); rejects a peer trying to make us buffer a
|
||||
/// huge blob.
|
||||
const MAX_MSG: usize = 64 * 1024;
|
||||
|
||||
/// One-byte application acknowledgement the receiver returns once it has parsed
|
||||
/// a message. ASCII ACK (0x06).
|
||||
const ACK: &[u8] = b"\x06";
|
||||
|
||||
/// Bound on each phase of the send handshake, so a half-dead peer or relay
|
||||
/// can't park a sender (or an inbound handler) forever.
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// A message on the friends control plane.
|
||||
///
|
||||
/// `#[serde(tag = "type")]` keeps the JSON self-describing and lets us add
|
||||
/// variants without breaking older peers (an unknown tag fails to parse and is
|
||||
/// logged, rather than being silently misread as another variant).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ControlMsg {
|
||||
/// "I'm online; here's my current display name." A presence/name refresh.
|
||||
Hello { name: String },
|
||||
/// Ask the recipient to become friends.
|
||||
FriendRequest { name: String },
|
||||
/// Accept a request the recipient previously sent us.
|
||||
FriendAccept { name: String },
|
||||
/// Decline a pending request, or cancel an outgoing one.
|
||||
FriendDecline,
|
||||
/// A host pushing a freshly generated share-code to an accepted friend.
|
||||
ShareCode { name: String, ticket: String },
|
||||
}
|
||||
|
||||
/// A received control message, paired with the *authenticated* sender id (the
|
||||
/// connection's verified remote public key — not a value the peer can spoof in
|
||||
/// the payload, which is why no variant carries a sender id).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Inbound {
|
||||
pub from: EndpointId,
|
||||
pub msg: ControlMsg,
|
||||
}
|
||||
|
||||
fn encode(msg: &ControlMsg) -> Result<Vec<u8>> {
|
||||
serde_json::to_vec(msg).context("failed to encode control message")
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
||||
serde_json::from_slice(bytes).context("failed to decode control message")
|
||||
}
|
||||
|
||||
/// Deliver one message to `peer` over `endpoint`, returning once the recipient
|
||||
/// has acknowledged it. An error means it was *not* delivered (peer offline,
|
||||
/// unreachable, or rejected the stream) — the caller can queue and retry.
|
||||
///
|
||||
/// `peer` is usually a bare [`EndpointId`] — friends store only the stable id,
|
||||
/// and n0 DNS discovery resolves it to a live address. The full [`EndpointAddr`]
|
||||
/// form exists for callers that already hold one (and for hermetic tests).
|
||||
pub async fn send(
|
||||
endpoint: &Endpoint,
|
||||
peer: impl Into<EndpointAddr>,
|
||||
msg: &ControlMsg,
|
||||
) -> Result<()> {
|
||||
let payload = encode(msg)?;
|
||||
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, CONTROL_ALPN))
|
||||
.await
|
||||
.context("timed out connecting to peer")?
|
||||
.context("failed to connect to peer")?;
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.context("failed to open control stream")?;
|
||||
send.write_all(&payload)
|
||||
.await
|
||||
.context("failed to write control message")?;
|
||||
send.finish().context("failed to finish control stream")?;
|
||||
// Read the peer's ACK. read_to_end returns once the peer finishes its
|
||||
// send side, so this also serves as "the peer is done with us."
|
||||
let ack = recv
|
||||
.read_to_end(ACK.len() + 1)
|
||||
.await
|
||||
.context("peer closed the control stream without acknowledging")?;
|
||||
if ack != ACK {
|
||||
bail!(
|
||||
"peer sent an unexpected acknowledgement ({} bytes)",
|
||||
ack.len()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let result = tokio::time::timeout(IO_TIMEOUT, io)
|
||||
.await
|
||||
.context("timed out sending control message")?;
|
||||
// Clean close so the peer's `closed().await` returns promptly either way.
|
||||
conn.close(VarInt::from_u32(0), b"done");
|
||||
result
|
||||
}
|
||||
|
||||
/// Run the control-plane accept loop, forwarding every received message to
|
||||
/// `tx`. Returns when the endpoint stops accepting (i.e. it was closed).
|
||||
pub async fn serve(endpoint: Endpoint, tx: mpsc::Sender<Inbound>) {
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle(incoming, &tx).await {
|
||||
tracing::warn!("control: inbound connection failed: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
tracing::info!("control: endpoint stopped accepting");
|
||||
}
|
||||
|
||||
async fn handle(incoming: Incoming, tx: &mpsc::Sender<Inbound>) -> Result<()> {
|
||||
let conn = incoming
|
||||
.await
|
||||
.context("inbound control connection failed")?;
|
||||
let from = conn.remote_id();
|
||||
|
||||
let msg = async {
|
||||
let (mut send, mut recv) = conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.context("failed to accept control stream")?;
|
||||
let bytes = recv
|
||||
.read_to_end(MAX_MSG)
|
||||
.await
|
||||
.context("failed to read control message")?;
|
||||
let msg = decode(&bytes)?;
|
||||
// ACK only after a successful parse, so the sender's delivery signal
|
||||
// means "received and understood."
|
||||
send.write_all(ACK).await.context("failed to write ack")?;
|
||||
send.finish().context("failed to finish ack stream")?;
|
||||
Ok::<_, anyhow::Error>(msg)
|
||||
};
|
||||
|
||||
let msg = tokio::time::timeout(IO_TIMEOUT, msg)
|
||||
.await
|
||||
.context("timed out reading control message")??;
|
||||
|
||||
// Wait (briefly) for the sender's close so our ACK flushes before the
|
||||
// connection is dropped at the end of this scope.
|
||||
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
|
||||
|
||||
tx.send(Inbound { from, msg })
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("control: receiver dropped"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_msg_round_trips() {
|
||||
let cases = [
|
||||
ControlMsg::Hello {
|
||||
name: "alice".into(),
|
||||
},
|
||||
ControlMsg::FriendRequest { name: "bob".into() },
|
||||
ControlMsg::FriendAccept {
|
||||
name: "carol".into(),
|
||||
},
|
||||
ControlMsg::FriendDecline,
|
||||
ControlMsg::ShareCode {
|
||||
name: "dave".into(),
|
||||
ticket: "endpointaa…".into(),
|
||||
},
|
||||
];
|
||||
for msg in cases {
|
||||
let bytes = encode(&msg).unwrap();
|
||||
assert_eq!(decode(&bytes).unwrap(), msg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tag_is_rejected() {
|
||||
assert!(decode(br#"{"type":"nonsense"}"#).is_err());
|
||||
}
|
||||
|
||||
/// Bind a control-plane endpoint with a *fresh* random key, so two of them
|
||||
/// in one test get distinct ids (two real machines each have their own
|
||||
/// persistent key; `bind_control` would give both the same one here, and
|
||||
/// iroh refuses "connecting to ourself").
|
||||
async fn bind_test_control() -> Endpoint {
|
||||
iroh::Endpoint::builder(iroh::endpoint::presets::N0)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![CONTROL_ALPN.to_vec()])
|
||||
.bind()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// End-to-end over two real iroh endpoints on this machine. Ignored by
|
||||
/// default — it binds endpoints and waits on the relay, so it's slow and
|
||||
/// network-dependent. Run with `cargo test -- --ignored control`.
|
||||
#[tokio::test]
|
||||
#[ignore = "binds real iroh endpoints; run on demand"]
|
||||
async fn loopback_delivers_and_acks() {
|
||||
let server = bind_test_control().await;
|
||||
let client = bind_test_control().await;
|
||||
// Connect by full addr so the test doesn't depend on DNS discovery.
|
||||
server.online().await;
|
||||
client.online().await;
|
||||
let server_addr = server.addr();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
let server_ep = server.clone();
|
||||
let serve_task = tokio::spawn(async move { serve(server_ep, tx).await });
|
||||
|
||||
let msg = ControlMsg::FriendRequest {
|
||||
name: "tester".into(),
|
||||
};
|
||||
// Full addr (not just the id) so the test doesn't depend on DNS discovery.
|
||||
send(&client, server_addr.clone(), &msg).await.unwrap();
|
||||
|
||||
let got = tokio::time::timeout(Duration::from_secs(15), rx.recv())
|
||||
.await
|
||||
.expect("no inbound within 15s")
|
||||
.expect("channel closed");
|
||||
assert_eq!(got.msg, msg);
|
||||
assert_eq!(got.from, client.addr().id);
|
||||
|
||||
server.close().await;
|
||||
client.close().await;
|
||||
serve_task.abort();
|
||||
}
|
||||
}
|
||||
+39
-13
@@ -85,7 +85,9 @@ fn install_hint_for_bin(bin: &str) -> String {
|
||||
let distro = detect_distro();
|
||||
let pkg = match bin {
|
||||
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gstreamer gst-plugins-base",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gstreamer gst-plugins-base"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-tools",
|
||||
Some("fedora" | "nobara") => "gstreamer1 gstreamer1-plugins-base-tools",
|
||||
_ => "gstreamer + tools",
|
||||
@@ -98,7 +100,9 @@ fn install_hint_for_bin(bin: &str) -> String {
|
||||
_ => "pulseaudio-utils (provides `pactl`)",
|
||||
},
|
||||
"xwininfo" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "xorg-xwininfo",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"xorg-xwininfo"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "x11-utils",
|
||||
Some("fedora" | "nobara") => "xorg-x11-utils",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "xwininfo",
|
||||
@@ -113,56 +117,74 @@ fn install_hint_for_gst_element(name: &str) -> String {
|
||||
let distro = detect_distro();
|
||||
let pkg = match name {
|
||||
"pipewiresrc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gst-plugin-pipewire",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugin-pipewire"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pipewire",
|
||||
Some("fedora" | "nobara") => "pipewire-gstreamer",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pipewire-gstreamer",
|
||||
_ => "the GStreamer PipeWire plugin",
|
||||
},
|
||||
"vah264enc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gst-plugin-va",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugin-va"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
|
||||
_ => "the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)",
|
||||
_ => {
|
||||
"the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)"
|
||||
}
|
||||
},
|
||||
"x264enc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gst-plugins-ugly",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-ugly"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-ugly",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-ugly",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-ugly",
|
||||
_ => "the GStreamer x264 plugin (plugins-ugly)",
|
||||
},
|
||||
"ximagesrc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gst-plugins-good",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-good"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-good",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
||||
_ => "the GStreamer X11 plugin (plugins-good)",
|
||||
},
|
||||
"videoscale" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gst-plugins-base",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-base"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-base",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base",
|
||||
_ => "the GStreamer plugins-base set",
|
||||
},
|
||||
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gst-plugins-bad",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-bad"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
|
||||
_ => "the GStreamer plugins-bad set",
|
||||
},
|
||||
"pulsesrc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gst-plugins-good",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-good"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pulseaudio",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
||||
_ => "the GStreamer PulseAudio plugin",
|
||||
},
|
||||
"avenc_aac" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "gst-libav",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-libav"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-libav",
|
||||
Some("fedora" | "nobara") => "gstreamer1-libav",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-libav",
|
||||
@@ -175,10 +197,14 @@ fn install_hint_for_gst_element(name: &str) -> String {
|
||||
|
||||
fn install_command(distro: &Option<String>, pkg: &str) -> String {
|
||||
let cmd = match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => format!("sudo pacman -S {pkg}"),
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
format!("sudo pacman -S {pkg}")
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => format!("sudo apt install {pkg}"),
|
||||
Some("fedora" | "nobara") => format!("sudo dnf install {pkg}"),
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => format!("sudo zypper install {pkg}"),
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => {
|
||||
format!("sudo zypper install {pkg}")
|
||||
}
|
||||
_ => format!("install the `{pkg}` package via your distro's package manager"),
|
||||
};
|
||||
format!("Install hint: {cmd}")
|
||||
|
||||
+51
-9
@@ -1,6 +1,19 @@
|
||||
//! Shared iroh endpoint construction for the host and viewer.
|
||||
//! Shared iroh endpoint construction.
|
||||
//!
|
||||
//! Both sides bind an endpoint with the same ALPN; the only knob is the relay.
|
||||
//! Two planes, two identities:
|
||||
//!
|
||||
//! * The **video** plane (host/viewer sessions) binds with an *ephemeral*
|
||||
//! keypair — a fresh `EndpointId` per run. Each session is a throwaway tunnel,
|
||||
//! and keeping its id ephemeral means a screen-share leaks no stable
|
||||
//! fingerprint.
|
||||
//! * The **control** plane (the always-on friends presence service) binds with
|
||||
//! the machine's *persistent* identity (see [`identity`]), so peers can find
|
||||
//! and recognise each other across launches.
|
||||
//!
|
||||
//! They must use different identities because both can be live at once on the
|
||||
//! same machine (the GUI's control endpoint while a host session runs), and
|
||||
//! iroh routes by `EndpointId` — two live endpoints sharing one id would make
|
||||
//! relay delivery ambiguous.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -17,11 +30,14 @@ pub const RELAY_ENV: &str = "PIXELPASS_RELAY";
|
||||
/// Resolve the relay override: explicit `--relay` wins, else `PIXELPASS_RELAY`,
|
||||
/// else `None` (use the bundled defaults).
|
||||
pub fn relay_override(flag: Option<&str>) -> Option<String> {
|
||||
flag.map(str::to_owned)
|
||||
.or_else(|| std::env::var(RELAY_ENV).ok().filter(|s| !s.trim().is_empty()))
|
||||
flag.map(str::to_owned).or_else(|| {
|
||||
std::env::var(RELAY_ENV)
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind the iroh endpoint with our ALPN.
|
||||
/// Bind a **video-plane** endpoint (host/viewer) with an ephemeral identity.
|
||||
///
|
||||
/// With no `relay` override we use [`presets::N0`] — n0 DNS discovery, the
|
||||
/// library's default relays, and the chosen crypto provider. With an override
|
||||
@@ -30,13 +46,39 @@ pub fn relay_override(flag: Option<&str>) -> Option<String> {
|
||||
/// (canary-grade) relays or points at a self-hosted one. Discovery is
|
||||
/// unchanged, so peers still resolve each other by endpoint id.
|
||||
pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
|
||||
let mut builder = Endpoint::builder(presets::N0).alpns(vec![ALPN.to_vec()]);
|
||||
// No `secret_key` set → iroh mints a fresh ephemeral keypair for this run.
|
||||
bind_with(relay, None, ALPN).await
|
||||
}
|
||||
|
||||
/// Bind the **control-plane** endpoint with the machine's persistent identity
|
||||
/// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its
|
||||
/// `EndpointId` is the stable id friends know you by.
|
||||
#[cfg(feature = "gui")]
|
||||
pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> {
|
||||
let secret_key = super::identity::load_or_create()?;
|
||||
bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await
|
||||
}
|
||||
|
||||
/// Shared builder: optional persistent key (None → ephemeral) + the plane's ALPN.
|
||||
async fn bind_with(
|
||||
relay: Option<&str>,
|
||||
key: Option<iroh::SecretKey>,
|
||||
alpn: &[u8],
|
||||
) -> Result<Endpoint> {
|
||||
let mut builder = Endpoint::builder(presets::N0).alpns(vec![alpn.to_vec()]);
|
||||
if let Some(key) = key {
|
||||
builder = builder.secret_key(key);
|
||||
}
|
||||
|
||||
if let Some(url) = relay {
|
||||
let url = RelayUrl::from_str(url)
|
||||
.with_context(|| format!("invalid relay URL {url:?} (expected e.g. https://relay.example/)"))?;
|
||||
let url = RelayUrl::from_str(url).with_context(|| {
|
||||
format!("invalid relay URL {url:?} (expected e.g. https://relay.example/)")
|
||||
})?;
|
||||
builder = builder.relay_mode(RelayMode::Custom(RelayMap::from(url)));
|
||||
}
|
||||
|
||||
builder.bind().await.context("failed to bind the iroh endpoint")
|
||||
builder
|
||||
.bind()
|
||||
.await
|
||||
.context("failed to bind the iroh endpoint")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Persistent friends store at `~/.config/pixelpass/friends.toml`.
|
||||
//!
|
||||
//! Kept in its own file rather than a `[friends]` section of `config.toml` so
|
||||
//! the headless CLI — which never manages friends and would round-trip the
|
||||
//! config without this knowledge — can't drop the list on a `--reconfigure`.
|
||||
//! Same reasoning as the separate `identity.key`.
|
||||
//!
|
||||
//! A friend is identified by their stable control-plane [`EndpointId`] (the id
|
||||
//! from [`super::endpoint::bind_control`]). `EndpointId` serialises as its
|
||||
//! string form in TOML, so the file is human-readable and hand-editable.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Where a friendship sits in the mutual-consent handshake.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FriendState {
|
||||
/// We've sent them a request and are waiting for them to accept.
|
||||
PendingOutgoing,
|
||||
/// They've requested us; waiting for the local user to accept or decline.
|
||||
PendingIncoming,
|
||||
/// Both sides have agreed — a real friend.
|
||||
Accepted,
|
||||
}
|
||||
|
||||
/// One entry in the friends list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Friend {
|
||||
pub id: EndpointId,
|
||||
/// Display name — seeded from the name the peer reported, locally editable.
|
||||
pub name: String,
|
||||
pub state: FriendState,
|
||||
/// Whether the host auto-shares its session code with this friend. Toggled
|
||||
/// on the host's share picker; persisted here so the choice survives a
|
||||
/// restart. Defaults to `true` so a newly added friend is included (and an
|
||||
/// older `friends.toml` without the field loads as share-with-all).
|
||||
#[serde(default = "default_share")]
|
||||
pub share: bool,
|
||||
}
|
||||
|
||||
fn default_share() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The persisted friends list. Serialises as a TOML array of tables
|
||||
/// (`[[friends]]`).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FriendStore {
|
||||
#[serde(default)]
|
||||
pub friends: Vec<Friend>,
|
||||
}
|
||||
|
||||
/// Returns `~/.config/pixelpass/friends.toml`. Shares the config directory with
|
||||
/// [`super::config`]; the parent is created on save.
|
||||
pub fn friends_path() -> Result<PathBuf> {
|
||||
Ok(super::config::config_path()?
|
||||
.parent()
|
||||
.context("config path has no parent directory")?
|
||||
.join("friends.toml"))
|
||||
}
|
||||
|
||||
/// Load the store, or a default (empty) one if the file doesn't exist yet.
|
||||
/// Parse errors bubble up so a hand-edit being debugged isn't silently
|
||||
/// overwritten.
|
||||
pub fn load() -> Result<FriendStore> {
|
||||
let path = friends_path()?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => toml::from_str(&s).with_context(|| format!("failed to parse {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
impl FriendStore {
|
||||
/// Atomic write via tempfile-in-same-dir + rename (mirrors
|
||||
/// [`super::config::save`]).
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = friends_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("friends path has no parent directory")?;
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let serialized = toml::to_string_pretty(self).context("failed to serialize friends")?;
|
||||
let tmp = parent.join(format!(".friends.toml.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
f.write_all(serialized.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find(&self, id: &EndpointId) -> Option<&Friend> {
|
||||
self.friends.iter().find(|f| &f.id == id)
|
||||
}
|
||||
|
||||
pub fn find_mut(&mut self, id: &EndpointId) -> Option<&mut Friend> {
|
||||
self.friends.iter_mut().find(|f| &f.id == id)
|
||||
}
|
||||
|
||||
/// True iff this id is a fully-accepted friend — the gate the code-push
|
||||
/// (Phase 4) and "is this a known friend?" checks use.
|
||||
pub fn is_accepted(&self, id: &EndpointId) -> bool {
|
||||
matches!(
|
||||
self.find(id),
|
||||
Some(Friend {
|
||||
state: FriendState::Accepted,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/// Insert a new friend, or update an existing one's `name`/`state` in place.
|
||||
/// Returns a mutable reference to the stored entry.
|
||||
pub fn upsert(&mut self, id: EndpointId, name: String, state: FriendState) -> &mut Friend {
|
||||
if let Some(idx) = self.friends.iter().position(|f| f.id == id) {
|
||||
let f = &mut self.friends[idx];
|
||||
f.name = name;
|
||||
f.state = state;
|
||||
f
|
||||
} else {
|
||||
self.friends.push(Friend {
|
||||
id,
|
||||
name,
|
||||
state,
|
||||
share: true,
|
||||
});
|
||||
self.friends.last_mut().expect("just pushed")
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a friend by id. Returns whether an entry was removed.
|
||||
pub fn remove(&mut self, id: &EndpointId) -> bool {
|
||||
let before = self.friends.len();
|
||||
self.friends.retain(|f| &f.id != id);
|
||||
self.friends.len() != before
|
||||
}
|
||||
|
||||
/// Apply an inbound friend request. Returns `true` if it *completes a mutual
|
||||
/// match* — we'd already sent them one, so they're now [`Accepted`] and the
|
||||
/// caller should reply with a `FriendAccept`. Otherwise it's recorded as
|
||||
/// [`PendingIncoming`] for the user to act on and `false` is returned.
|
||||
///
|
||||
/// [`Accepted`]: FriendState::Accepted
|
||||
/// [`PendingIncoming`]: FriendState::PendingIncoming
|
||||
pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool {
|
||||
if matches!(
|
||||
self.find(&id).map(|f| f.state),
|
||||
Some(FriendState::PendingOutgoing)
|
||||
) {
|
||||
self.upsert(id, name, FriendState::Accepted);
|
||||
true
|
||||
} else {
|
||||
self.upsert(id, name, FriendState::PendingIncoming);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an inbound acceptance of a request we sent. Returns `true` if it
|
||||
/// advanced a friendship to [`Accepted`] (i.e. we actually knew this peer);
|
||||
/// an accept from a stranger is ignored.
|
||||
///
|
||||
/// [`Accepted`]: FriendState::Accepted
|
||||
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
|
||||
if self.find(&id).is_some() {
|
||||
self.upsert(id, name, FriendState::Accepted);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_id() -> EndpointId {
|
||||
iroh::SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_toml() {
|
||||
let mut store = FriendStore::default();
|
||||
store.upsert(sample_id(), "Alice".into(), FriendState::Accepted);
|
||||
store.upsert(sample_id(), "Bob".into(), FriendState::PendingIncoming);
|
||||
|
||||
let toml = toml::to_string_pretty(&store).unwrap();
|
||||
let back: FriendStore = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(back.friends, store.friends);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_friends_default_to_shared_and_survive_round_trip() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Alice".into(), FriendState::Accepted);
|
||||
assert!(store.find(&id).unwrap().share, "new friends start shared");
|
||||
|
||||
// An older friends.toml predating the field loads as share-with-all.
|
||||
let toml = format!("[[friends]]\nid = \"{id}\"\nname = \"Legacy\"\nstate = \"accepted\"\n");
|
||||
let back: FriendStore = toml::from_str(&toml).unwrap();
|
||||
assert!(back.friends[0].share);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_share_across_refresh() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Alice".into(), FriendState::Accepted);
|
||||
store.find_mut(&id).unwrap().share = false;
|
||||
// A later name/presence refresh re-upserts the same peer; the share
|
||||
// choice must not be reset by it.
|
||||
store.upsert(id, "Alice (new name)".into(), FriendState::Accepted);
|
||||
assert!(!store.find(&id).unwrap().share);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_updates_in_place() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Old".into(), FriendState::PendingOutgoing);
|
||||
store.upsert(id, "New".into(), FriendState::Accepted);
|
||||
assert_eq!(store.friends.len(), 1);
|
||||
let f = store.find(&id).unwrap();
|
||||
assert_eq!(f.name, "New");
|
||||
assert_eq!(f.state, FriendState::Accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_accepted_only_for_accepted_state() {
|
||||
let mut store = FriendStore::default();
|
||||
let pending = sample_id();
|
||||
let friend = sample_id();
|
||||
store.upsert(pending, "P".into(), FriendState::PendingOutgoing);
|
||||
store.upsert(friend, "F".into(), FriendState::Accepted);
|
||||
assert!(!store.is_accepted(&pending));
|
||||
assert!(store.is_accepted(&friend));
|
||||
assert!(!store.is_accepted(&sample_id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_reports_whether_present() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "X".into(), FriendState::Accepted);
|
||||
assert!(store.remove(&id));
|
||||
assert!(!store.remove(&id));
|
||||
assert!(store.friends.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_request_from_stranger_is_pending() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
let mutual = store.on_friend_request(id, "Stranger".into());
|
||||
assert!(!mutual);
|
||||
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_request_matching_our_outgoing_is_mutual() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
// We asked them first…
|
||||
store.upsert(id, "Pal".into(), FriendState::PendingOutgoing);
|
||||
// …then their request arrives — that's a mutual match.
|
||||
let mutual = store.on_friend_request(id, "Pal".into());
|
||||
assert!(mutual);
|
||||
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_advances_known_peer_only() {
|
||||
let mut store = FriendStore::default();
|
||||
let known = sample_id();
|
||||
store.upsert(known, "Known".into(), FriendState::PendingOutgoing);
|
||||
assert!(store.on_friend_accept(known, "Known".into()));
|
||||
assert_eq!(store.find(&known).unwrap().state, FriendState::Accepted);
|
||||
// An accept from someone we never asked is ignored.
|
||||
let stranger = sample_id();
|
||||
assert!(!store.on_friend_accept(stranger, "Nope".into()));
|
||||
assert!(store.find(&stranger).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Persistent node identity at `~/.config/pixelpass/identity.key`.
|
||||
//!
|
||||
//! Without this, [`super::endpoint::bind`] would let iroh mint a fresh random
|
||||
//! keypair on every launch, so a peer's `EndpointId` would change each run.
|
||||
//! The friends system identifies people by that id (it's the public key already
|
||||
//! embedded in every share code), so it must stay stable across launches — and
|
||||
//! across roles: the same machine gets the same id whether it's hosting,
|
||||
//! viewing, or just sitting in the GUI.
|
||||
//!
|
||||
//! The key is the ed25519 secret (32 bytes) stored as hex on its own line, in a
|
||||
//! `0600` file separate from `config.toml` — it's a secret, not a preference,
|
||||
//! and keeping it out of the TOML means a hand-edit or a config reset can't
|
||||
//! clobber your identity.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use iroh::SecretKey;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Returns `~/.config/pixelpass/identity.key` (or the XDG equivalent). Shares
|
||||
/// the config directory with [`super::config`]; the parent is created on save.
|
||||
pub fn identity_path() -> Result<PathBuf> {
|
||||
Ok(super::config::config_path()?
|
||||
.parent()
|
||||
.context("config path has no parent directory")?
|
||||
.join("identity.key"))
|
||||
}
|
||||
|
||||
/// Load the persisted secret key, or generate-and-save one on first run.
|
||||
///
|
||||
/// A malformed file is a hard error rather than a silent regenerate: silently
|
||||
/// minting a new identity would orphan every friend who has the old id, so we'd
|
||||
/// rather fail loud and let the user notice (and decide) than lose it quietly.
|
||||
pub fn load_or_create() -> Result<SecretKey> {
|
||||
let path = identity_path()?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => parse_key(s.trim())
|
||||
.with_context(|| format!("failed to parse the identity key at {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let key = SecretKey::generate();
|
||||
save(&key)?;
|
||||
tracing::info!(id = %key.public(), "generated a new persistent identity");
|
||||
Ok(key)
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key(hex: &str) -> Result<SecretKey> {
|
||||
let bytes = decode_hex(hex)?;
|
||||
let arr: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("identity key must be 32 bytes (64 hex chars)"))?;
|
||||
Ok(SecretKey::from_bytes(&arr))
|
||||
}
|
||||
|
||||
/// Atomic, `0600` write: tempfile-in-same-dir, chmod, then rename. Same
|
||||
/// approach as [`super::config::save`], but with restrictive perms applied
|
||||
/// before the rename so the secret is never briefly world-readable.
|
||||
pub fn save(key: &SecretKey) -> Result<()> {
|
||||
let path = identity_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("identity path has no parent directory")?;
|
||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
f.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.with_context(|| format!("failed to chmod {}", tmp.display()))?;
|
||||
}
|
||||
f.write_all(encode_hex(&key.to_bytes()).as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.write_all(b"\n").ok();
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn decode_hex(s: &str) -> Result<Vec<u8>> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
bail!("hex string has an odd length");
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&s[i..i + 2], 16)
|
||||
.with_context(|| format!("invalid hex byte at offset {i}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_round_trips() {
|
||||
let bytes: Vec<u8> = (0u8..=255).collect();
|
||||
let encoded = encode_hex(&bytes);
|
||||
assert_eq!(encoded.len(), bytes.len() * 2);
|
||||
assert_eq!(decode_hex(&encoded).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_round_trips_through_hex() {
|
||||
let key = SecretKey::generate();
|
||||
let hex = encode_hex(&key.to_bytes());
|
||||
let parsed = parse_key(&hex).unwrap();
|
||||
assert_eq!(parsed.to_bytes(), key.to_bytes());
|
||||
assert_eq!(parsed.public(), key.public());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_length() {
|
||||
assert!(parse_key("dead").is_err());
|
||||
assert!(parse_key("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_odd_and_nonhex() {
|
||||
assert!(decode_hex("abc").is_err());
|
||||
assert!(decode_hex("zz").is_err());
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -1,9 +1,18 @@
|
||||
pub mod alpn;
|
||||
pub mod bandwidth;
|
||||
pub mod config;
|
||||
// The friends stack (persistent identity + control plane) is GUI-only — a
|
||||
// headless CLI host runs no presence service — so it's gated with the feature
|
||||
// that pulls the rest of the GUI, keeping the headless build lean.
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod control;
|
||||
pub mod deps;
|
||||
pub mod endpoint;
|
||||
pub mod display;
|
||||
pub mod endpoint;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod friends;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod identity;
|
||||
pub mod output;
|
||||
pub mod process;
|
||||
pub mod signal;
|
||||
|
||||
+12
-4
@@ -261,12 +261,20 @@ mod tests {
|
||||
#[test]
|
||||
fn capture_state_round_trips() {
|
||||
assert!(matches!(
|
||||
parse(Event::Capture { state: EmitState::Started }),
|
||||
ChildEvent::Capture { state: CaptureState::Started }
|
||||
parse(Event::Capture {
|
||||
state: EmitState::Started
|
||||
}),
|
||||
ChildEvent::Capture {
|
||||
state: CaptureState::Started
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(Event::Capture { state: EmitState::Stopped }),
|
||||
ChildEvent::Capture { state: CaptureState::Stopped }
|
||||
parse(Event::Capture {
|
||||
state: EmitState::Stopped
|
||||
}),
|
||||
ChildEvent::Capture {
|
||||
state: CaptureState::Stopped
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Share-code wrapping: carrying the host's stable friend id alongside the
|
||||
//! one-shot video ticket.
|
||||
//!
|
||||
//! A bare video ticket identifies only the host's *ephemeral* video endpoint,
|
||||
//! so two people who meet over one can't learn each other's stable friend id —
|
||||
//! the thing the friends system needs. The GUI host therefore wraps its ticket
|
||||
//! with its control-plane [`EndpointId`]; the viewer unwraps it, dials the
|
||||
//! video ticket as before, and now also knows who to befriend (and announces
|
||||
//! itself back over the control plane so the host learns the viewer in turn).
|
||||
//!
|
||||
//! Format: `pixelpassF1:<host-control-id>.<bare-ticket>`. Both the id and the
|
||||
//! ticket are base32 text with no `.`, so a single `.` separator is
|
||||
//! unambiguous. [`unwrap`] is lenient: anything without the prefix is treated
|
||||
//! as a bare ticket, so a plain CLI ticket pasted into the GUI still works (it
|
||||
//! just offers no friend option). The host name isn't carried here — the
|
||||
//! viewer's announcement triggers a name exchange over the control plane.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use iroh::EndpointId;
|
||||
|
||||
/// Prefix marking a wrapped friend code. The `F1` is the wrap-format version,
|
||||
/// bumped if the layout ever changes.
|
||||
const MAGIC: &str = "pixelpassF1:";
|
||||
|
||||
/// Wrap a bare ticket with the host's control id, for display/copy/QR.
|
||||
pub fn wrap(host_id: EndpointId, ticket: &str) -> String {
|
||||
format!("{MAGIC}{host_id}.{ticket}")
|
||||
}
|
||||
|
||||
/// Split an input into `(host control id if it was a wrapped code, bare
|
||||
/// ticket)`. A bare or unrecognised input yields `(None, trimmed input)` so the
|
||||
/// viewer path stays identical to before for plain tickets.
|
||||
pub fn unwrap(code: &str) -> (Option<EndpointId>, String) {
|
||||
let code = code.trim();
|
||||
if let Some(rest) = code.strip_prefix(MAGIC)
|
||||
&& let Some((id_str, ticket)) = rest.split_once('.')
|
||||
&& let Ok(id) = EndpointId::from_str(id_str)
|
||||
&& !ticket.is_empty()
|
||||
{
|
||||
return (Some(id), ticket.to_string());
|
||||
}
|
||||
(None, code.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_id() -> EndpointId {
|
||||
iroh::SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_unwrap_round_trips() {
|
||||
let id = sample_id();
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
let code = wrap(id, ticket);
|
||||
let (got_id, got_ticket) = unwrap(&code);
|
||||
assert_eq!(got_id, Some(id));
|
||||
assert_eq!(got_ticket, ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ticket_passes_through() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
let (id, got) = unwrap(ticket);
|
||||
assert_eq!(id, None);
|
||||
assert_eq!(got, ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_surrounding_whitespace() {
|
||||
let ticket = "endpointaabwxjex";
|
||||
let (id, got) = unwrap(&format!(" {} ", wrap(sample_id(), ticket)));
|
||||
assert!(id.is_some());
|
||||
assert_eq!(got, ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_wrapped_code_falls_back_to_bare() {
|
||||
// Prefix present but the id isn't a valid EndpointId → treat the whole
|
||||
// thing as a (doomed) bare ticket rather than panicking.
|
||||
let (id, got) = unwrap("pixelpassF1:not-an-id.endpointaa");
|
||||
assert_eq!(id, None);
|
||||
assert_eq!(got, "pixelpassF1:not-an-id.endpointaa");
|
||||
}
|
||||
}
|
||||
+722
-15
@@ -37,6 +37,8 @@
|
||||
//! dropped and no egui frame is running.
|
||||
|
||||
mod child;
|
||||
mod code;
|
||||
mod presence;
|
||||
mod theme;
|
||||
mod tray;
|
||||
|
||||
@@ -64,8 +66,13 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}
|
||||
use winit::raw_window_handle::HasWindowHandle as _;
|
||||
use winit::window::{Window, WindowAttributes, WindowId};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use self::child::{ChildEvent, ChildProc};
|
||||
use self::presence::{PresenceEvent, PresenceHandle};
|
||||
use self::tray::{TrayAction, TrayHandle, TrayStatus};
|
||||
use crate::common::control::ControlMsg;
|
||||
use crate::common::friends::{FriendState, FriendStore};
|
||||
|
||||
/// Initial / minimum window size, in logical points. Initial height fits the
|
||||
/// host screen (ticket + Copy + QR + Stop) without needing to scroll on a 1080p
|
||||
@@ -601,6 +608,9 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
|
||||
};
|
||||
// The tray runs on its own thread and wakes us via the proxy.
|
||||
let tray = tray::start(proxy.clone());
|
||||
// The friends presence service runs on its own thread too, waking us when
|
||||
// a control message arrives.
|
||||
let presence = presence::start(waker.clone(), relay.clone());
|
||||
let gui_settings = crate::common::config::load()
|
||||
.map(|c| c.gui)
|
||||
.unwrap_or_default();
|
||||
@@ -609,6 +619,11 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
|
||||
let names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
||||
let draft = active.clone();
|
||||
|
||||
let friends = crate::common::friends::load().unwrap_or_else(|e| {
|
||||
tracing::warn!("failed to load friends list: {e:#}");
|
||||
Default::default()
|
||||
});
|
||||
|
||||
let state = PixelPassApp {
|
||||
screen: Screen::default(),
|
||||
host: HostState::default(),
|
||||
@@ -626,6 +641,13 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
|
||||
status: None,
|
||||
},
|
||||
waker,
|
||||
presence,
|
||||
friends,
|
||||
display_name: gui_settings.display_name,
|
||||
met: Vec::new(),
|
||||
share_status: BTreeMap::new(),
|
||||
notices: Vec::new(),
|
||||
show_notices: false,
|
||||
};
|
||||
let mut app = App {
|
||||
state,
|
||||
@@ -682,6 +704,27 @@ fn short_id(id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Hello` control message carrying our display name — the self-introduction
|
||||
/// a viewer sends the host on connect, and the host's reply.
|
||||
fn control_hello(name: &str) -> ControlMsg {
|
||||
ControlMsg::Hello {
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The accepted friends a host's share push targets: every accepted friend
|
||||
/// whose per-friend `share` flag is on (toggled on the host form, persisted in
|
||||
/// `friends.toml`). A free function over the store so the rule is unit-testable
|
||||
/// without a live UI.
|
||||
fn selected_share_targets(friends: &FriendStore) -> Vec<iroh::EndpointId> {
|
||||
friends
|
||||
.friends
|
||||
.iter()
|
||||
.filter(|f| f.state == FriendState::Accepted && f.share)
|
||||
.map(|f| f.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fire a desktop notification, on a detached thread so the D-Bus round-trip
|
||||
/// can't stall the egui frame. Best-effort: with no notification daemon it
|
||||
/// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this
|
||||
@@ -718,6 +761,14 @@ fn persist_show_qr(value: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_display_name(value: &str) {
|
||||
let mut cfg = crate::common::config::load().unwrap_or_default();
|
||||
cfg.gui.display_name = value.to_string();
|
||||
if let Err(e) = crate::common::config::save(&cfg) {
|
||||
tracing::warn!("failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_theme(name: &str) {
|
||||
let mut cfg = crate::common::config::load().unwrap_or_default();
|
||||
cfg.gui.theme = name.to_string();
|
||||
@@ -733,6 +784,7 @@ enum Screen {
|
||||
Menu,
|
||||
Host,
|
||||
Viewer,
|
||||
Friends,
|
||||
Settings,
|
||||
Shortcuts,
|
||||
}
|
||||
@@ -807,7 +859,13 @@ struct HostState {
|
||||
window: bool,
|
||||
// running session + accumulated live state
|
||||
proc: Option<ChildProc>,
|
||||
/// The bare video ticket from the child (used for the host-id fingerprint
|
||||
/// line). The copy/QR/display use [`HostState::share_code`] instead.
|
||||
ticket: Option<String>,
|
||||
/// The share code shown/copied/QR'd: the ticket wrapped with our control id
|
||||
/// (see [`code::wrap`]) when the presence service is up, else the bare
|
||||
/// ticket. Wrapping is what lets a viewer offer to befriend the host.
|
||||
share_code: Option<String>,
|
||||
info: Option<HostInfo>,
|
||||
active: u32,
|
||||
max: u32,
|
||||
@@ -849,6 +907,10 @@ struct ViewerState {
|
||||
/// Short endpoint id we're dialing, decoded from the ticket at Connect.
|
||||
/// Shown in the "Connecting to …" line so a dead host is identifiable.
|
||||
connecting_to: Option<String>,
|
||||
/// The host's stable control id, if the pasted code was a wrapped friend
|
||||
/// code. Lets us announce ourselves to the host (so both ends can befriend)
|
||||
/// once connected. `None` for a bare/CLI ticket.
|
||||
host_control_id: Option<iroh::EndpointId>,
|
||||
/// Set when the View screen opens so the code field grabs focus once
|
||||
/// (cleared on use, so it doesn't steal focus every frame).
|
||||
focus_ticket: bool,
|
||||
@@ -880,6 +942,44 @@ struct PixelPassApp {
|
||||
theme: ThemeState,
|
||||
/// Wakes the winit loop when a spawned child emits/exits.
|
||||
waker: Waker,
|
||||
/// The always-on friends presence service (control-plane endpoint). `None`
|
||||
/// if it couldn't start (no identity), in which case friends features are
|
||||
/// simply absent.
|
||||
presence: Option<PresenceHandle>,
|
||||
/// The persisted friends list (mutual-consent contacts).
|
||||
friends: crate::common::friends::FriendStore,
|
||||
/// Our display name, shown to friends. Persisted in `[gui] display_name`.
|
||||
display_name: String,
|
||||
/// Peers met this session (over a connection) who aren't yet in the friends
|
||||
/// list — drives the "add friend" offer. Session-scoped, not persisted.
|
||||
met: Vec<MetPeer>,
|
||||
/// Delivery state for the current host session's share campaign: a friend is
|
||||
/// present once targeted, `true` once their ACK arrives. Drives the live
|
||||
/// "delivered / retrying" list on the running host screen. Cleared on stop.
|
||||
share_status: BTreeMap<iroh::EndpointId, bool>,
|
||||
/// Share codes friends have pushed to us, awaiting the user — the bell badge.
|
||||
/// Deduped by sender (a friend re-hosting replaces their stale code).
|
||||
notices: Vec<ShareNotice>,
|
||||
/// Whether the bell's notification list is currently expanded.
|
||||
show_notices: bool,
|
||||
}
|
||||
|
||||
/// A peer encountered this session but not yet befriended.
|
||||
struct MetPeer {
|
||||
id: iroh::EndpointId,
|
||||
/// Their reported display name (a short id placeholder until a name arrives).
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// A share code a friend pushed to us over the control plane, shown in the bell
|
||||
/// panel until the user watches or dismisses it.
|
||||
struct ShareNotice {
|
||||
/// The friend who shared — the dedupe key (one live notice per friend).
|
||||
from: iroh::EndpointId,
|
||||
/// Their display name, for the panel row.
|
||||
name: String,
|
||||
/// The share code to drop into the viewer when "Watch" is clicked.
|
||||
code: String,
|
||||
}
|
||||
|
||||
/// The active theme plus the Settings picker/editor working state.
|
||||
@@ -956,9 +1056,353 @@ impl PixelPassApp {
|
||||
fn tick(&mut self) {
|
||||
self.pump_host_events();
|
||||
self.pump_viewer_events();
|
||||
self.pump_presence_events();
|
||||
self.sync_tray_status();
|
||||
}
|
||||
|
||||
/// Drain presence-service events — inbound control messages and share-code
|
||||
/// delivery receipts — folding them into the friends list, the session's
|
||||
/// met-peers, the bell notices, and the live share status, queueing any
|
||||
/// replies. Collected up front so the presence borrow is released before we
|
||||
/// mutate `self` / re-borrow it to send.
|
||||
fn pump_presence_events(&mut self) {
|
||||
let Some(events) = self.presence.as_ref().map(|p| p.drain()) else {
|
||||
return;
|
||||
};
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let my_name = self.display_name.clone();
|
||||
let mut outbox: Vec<(iroh::EndpointId, ControlMsg)> = Vec::new();
|
||||
let mut store_changed = false;
|
||||
|
||||
for event in events {
|
||||
let inb = match event {
|
||||
PresenceEvent::ShareDelivered { peer } => {
|
||||
// A code we pushed reached this friend — flip their row.
|
||||
if let Some(s) = self.share_status.get_mut(&peer) {
|
||||
*s = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
PresenceEvent::Message(inb) => inb,
|
||||
};
|
||||
let from = inb.from;
|
||||
match inb.msg {
|
||||
ControlMsg::Hello { name } => {
|
||||
// A peer announcing themselves (the viewer→host intro, or the
|
||||
// host's reply). Keep a known friend's name fresh; otherwise
|
||||
// record them as a met peer and reply once on first contact.
|
||||
if let Some(f) = self.friends.find_mut(&from) {
|
||||
f.name = name;
|
||||
store_changed = true;
|
||||
} else if self.note_met(from, name) {
|
||||
outbox.push((from, control_hello(&my_name)));
|
||||
}
|
||||
}
|
||||
ControlMsg::FriendRequest { name } => {
|
||||
self.note_met(from, name.clone());
|
||||
if self.friends.on_friend_request(from, name.clone()) {
|
||||
// We'd already requested them — mutual, so it's settled.
|
||||
outbox.push((
|
||||
from,
|
||||
ControlMsg::FriendAccept {
|
||||
name: my_name.clone(),
|
||||
},
|
||||
));
|
||||
notify(
|
||||
"PixelPass — now friends",
|
||||
format!("You and {name} are now friends."),
|
||||
);
|
||||
} else {
|
||||
notify(
|
||||
"PixelPass — friend request",
|
||||
format!("{name} wants to be friends."),
|
||||
);
|
||||
}
|
||||
store_changed = true;
|
||||
}
|
||||
ControlMsg::FriendAccept { name } => {
|
||||
if self.friends.on_friend_accept(from, name.clone()) {
|
||||
store_changed = true;
|
||||
notify(
|
||||
"PixelPass — request accepted",
|
||||
format!("{name} accepted your friend request."),
|
||||
);
|
||||
}
|
||||
}
|
||||
ControlMsg::FriendDecline => {
|
||||
if self.friends.remove(&from) {
|
||||
store_changed = true;
|
||||
}
|
||||
}
|
||||
ControlMsg::ShareCode { name, ticket } => {
|
||||
// Only accepted friends may push us a code — a stranger's is
|
||||
// ignored, so the control plane can't be used to spam viewers.
|
||||
if self.friends.is_accepted(&from) {
|
||||
// Keep the stored name fresh from the live push.
|
||||
if let Some(f) = self.friends.find_mut(&from) {
|
||||
f.name = name.clone();
|
||||
store_changed = true;
|
||||
}
|
||||
self.push_notice(from, name.clone(), ticket);
|
||||
notify(
|
||||
"PixelPass — a friend is sharing",
|
||||
format!("{name} is sharing their screen. Open PixelPass to watch."),
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if store_changed {
|
||||
self.save_friends();
|
||||
}
|
||||
if let Some(p) = &self.presence {
|
||||
for (peer, msg) in outbox {
|
||||
p.send(peer, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a peer met this session. Returns true if they were newly added
|
||||
/// (false if we already knew them, in which case the name is refreshed).
|
||||
fn note_met(&mut self, id: iroh::EndpointId, name: String) -> bool {
|
||||
if let Some(m) = self.met.iter_mut().find(|m| m.id == id) {
|
||||
// Don't overwrite a real name with a short-id placeholder.
|
||||
if !name.is_empty() {
|
||||
m.name = name;
|
||||
}
|
||||
false
|
||||
} else {
|
||||
self.met.push(MetPeer { id, name });
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_incoming_count(&self) -> usize {
|
||||
self.friends
|
||||
.friends
|
||||
.iter()
|
||||
.filter(|f| f.state == FriendState::PendingIncoming)
|
||||
.count()
|
||||
}
|
||||
|
||||
fn save_friends(&self) {
|
||||
if let Err(e) = self.friends.save() {
|
||||
tracing::warn!("failed to save friends list: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a share code a friend pushed us, replacing any prior notice from
|
||||
/// the same friend (their previous code is stale once they re-host).
|
||||
fn push_notice(&mut self, from: iroh::EndpointId, name: String, code: String) {
|
||||
if let Some(n) = self.notices.iter_mut().find(|n| n.from == from) {
|
||||
n.name = name;
|
||||
n.code = code;
|
||||
} else {
|
||||
self.notices.push(ShareNotice { from, name, code });
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin pushing the current host share code to the selected accepted
|
||||
/// friends. Called once the child reports its ticket (so `share_code` is
|
||||
/// set). Seeds the per-friend status map to "retrying" and hands the campaign
|
||||
/// to the presence service, which delivers now and retries offline friends
|
||||
/// while we host.
|
||||
fn begin_share(&mut self) {
|
||||
self.share_status.clear();
|
||||
let Some(code) = self.host.share_code.clone() else {
|
||||
return;
|
||||
};
|
||||
let targets = selected_share_targets(&self.friends);
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.share_status = targets.iter().map(|id| (*id, false)).collect();
|
||||
if let Some(p) = &self.presence {
|
||||
p.start_share(
|
||||
ControlMsg::ShareCode {
|
||||
name: self.display_name.clone(),
|
||||
ticket: code,
|
||||
},
|
||||
targets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a friend request to a peer (or accept theirs if they already asked),
|
||||
/// persisting the new state and notifying the peer over the control plane.
|
||||
fn request_friend(&mut self, id: iroh::EndpointId, name: String) {
|
||||
let my_name = self.display_name.clone();
|
||||
let msg = if matches!(
|
||||
self.friends.find(&id).map(|f| f.state),
|
||||
Some(FriendState::PendingIncoming)
|
||||
) {
|
||||
self.friends.upsert(id, name, FriendState::Accepted);
|
||||
ControlMsg::FriendAccept { name: my_name }
|
||||
} else {
|
||||
self.friends.upsert(id, name, FriendState::PendingOutgoing);
|
||||
ControlMsg::FriendRequest { name: my_name }
|
||||
};
|
||||
self.save_friends();
|
||||
if let Some(p) = &self.presence {
|
||||
p.send(id, msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark an incoming request accepted and tell the peer.
|
||||
fn accept_friend(&mut self, id: iroh::EndpointId) {
|
||||
let my_name = self.display_name.clone();
|
||||
if let Some(f) = self.friends.find_mut(&id) {
|
||||
f.state = FriendState::Accepted;
|
||||
self.save_friends();
|
||||
if let Some(p) = &self.presence {
|
||||
p.send(id, ControlMsg::FriendAccept { name: my_name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a friend / decline a request / cancel an outgoing one, telling the
|
||||
/// peer so their side drops us too.
|
||||
fn remove_friend(&mut self, id: iroh::EndpointId) {
|
||||
if self.friends.remove(&id) {
|
||||
self.save_friends();
|
||||
if let Some(p) = &self.presence {
|
||||
p.send(id, ControlMsg::FriendDecline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "people you just connected with" offer shown on the running host /
|
||||
/// viewer screens: met peers not yet in the friends list, each with an Add
|
||||
/// button.
|
||||
fn friend_offers(&mut self, ui: &mut egui::Ui) {
|
||||
let offers: Vec<(iroh::EndpointId, String)> = self
|
||||
.met
|
||||
.iter()
|
||||
.filter(|m| self.friends.find(&m.id).is_none())
|
||||
.map(|m| (m.id, m.name.clone()))
|
||||
.collect();
|
||||
if offers.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.label("People you just connected with:");
|
||||
let mut add: Option<(iroh::EndpointId, String)> = None;
|
||||
for (id, name) in &offers {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(name.as_str());
|
||||
if ui.small_button("➕ Add friend").clicked() {
|
||||
add = Some((*id, name.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some((id, name)) = add {
|
||||
self.request_friend(id, name);
|
||||
}
|
||||
}
|
||||
|
||||
fn friends_screen(&mut self, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("← Menu").clicked() {
|
||||
self.screen = Screen::Menu;
|
||||
}
|
||||
ui.heading("Friends");
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
// Your identity: editable display name + your stable id.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Your name");
|
||||
if ui
|
||||
.text_edit_singleline(&mut self.display_name)
|
||||
.on_hover_text("Shown to friends in requests and shared codes.")
|
||||
.changed()
|
||||
{
|
||||
persist_display_name(&self.display_name);
|
||||
}
|
||||
});
|
||||
if let Some(p) = &self.presence {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Your ID: {}", short_id(&p.id().to_string())))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
} else {
|
||||
ui.colored_label(
|
||||
self.theme.active.warning,
|
||||
"⚠ Friends service unavailable (no identity).",
|
||||
);
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
|
||||
if self.friends.friends.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"No friends yet. Connect with someone, then use \"Add friend\" \
|
||||
on the host/view screen.",
|
||||
)
|
||||
.weak(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect the chosen action first so we don't mutate the store while
|
||||
// iterating it.
|
||||
enum Action {
|
||||
Accept(iroh::EndpointId),
|
||||
Remove(iroh::EndpointId),
|
||||
}
|
||||
let mut action: Option<Action> = None;
|
||||
for f in &self.friends.friends {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(&f.name).strong());
|
||||
ui.label(
|
||||
egui::RichText::new(format!("· {}", short_id(&f.id.to_string())))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
match f.state {
|
||||
FriendState::Accepted => {
|
||||
ui.label(egui::RichText::new("· friend").small().weak());
|
||||
if ui.small_button("Remove").clicked() {
|
||||
action = Some(Action::Remove(f.id));
|
||||
}
|
||||
}
|
||||
FriendState::PendingIncoming => {
|
||||
ui.label(egui::RichText::new("· wants to be friends").small().weak());
|
||||
if ui.small_button("Accept").clicked() {
|
||||
action = Some(Action::Accept(f.id));
|
||||
}
|
||||
if ui.small_button("Decline").clicked() {
|
||||
action = Some(Action::Remove(f.id));
|
||||
}
|
||||
}
|
||||
FriendState::PendingOutgoing => {
|
||||
ui.label(egui::RichText::new("· request sent").small().weak());
|
||||
if ui.small_button("Cancel").clicked() {
|
||||
action = Some(Action::Remove(f.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
match action {
|
||||
Some(Action::Accept(id)) => self.accept_friend(id),
|
||||
Some(Action::Remove(id)) => self.remove_friend(id),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Render the current screen. Called from inside the egui frame.
|
||||
fn draw(&mut self, ui: &mut egui::Ui) {
|
||||
self.handle_keys(ui);
|
||||
@@ -995,9 +1439,102 @@ impl PixelPassApp {
|
||||
Screen::Menu => self.menu(ui),
|
||||
Screen::Host => self.host(ui),
|
||||
Screen::Viewer => self.viewer(ui),
|
||||
Screen::Friends => self.friends_screen(ui),
|
||||
Screen::Settings => self.settings(ui),
|
||||
Screen::Shortcuts => self.shortcuts(ui),
|
||||
}
|
||||
|
||||
// The notification bell floats over every screen (an overlay Area, so it
|
||||
// doesn't disturb the per-screen layouts), drawn last to sit on top.
|
||||
self.notification_bell(ui);
|
||||
}
|
||||
|
||||
/// A bell in the top-right corner with a red badge counting share codes
|
||||
/// friends have pushed us. Clicking it toggles a panel listing them, each
|
||||
/// openable straight into the viewer. An overlay so it rides above whichever
|
||||
/// screen is showing without fighting its scroll areas or back buttons.
|
||||
fn notification_bell(&mut self, ui: &mut egui::Ui) {
|
||||
let count = self.notices.len();
|
||||
egui::Area::new(egui::Id::new("notif_bell"))
|
||||
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-10.0, 8.0))
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if count > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(" {count} "))
|
||||
.color(egui::Color32::WHITE)
|
||||
.background_color(egui::Color32::from_rgb(200, 40, 40))
|
||||
.strong(),
|
||||
);
|
||||
}
|
||||
if ui
|
||||
.button("🔔")
|
||||
.on_hover_text("Screen codes shared by friends")
|
||||
.clicked()
|
||||
{
|
||||
self.show_notices = !self.show_notices;
|
||||
}
|
||||
});
|
||||
});
|
||||
if self.show_notices {
|
||||
self.notification_panel(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dropdown listing pushed share codes. Each row offers Watch (open it in
|
||||
/// the viewer) and Dismiss; collected first so the list isn't mutated mid-draw.
|
||||
fn notification_panel(&mut self, ui: &mut egui::Ui) {
|
||||
let mut watch: Option<String> = None;
|
||||
let mut dismiss: Option<iroh::EndpointId> = None;
|
||||
let mut clear_all = false;
|
||||
egui::Area::new(egui::Id::new("notif_panel"))
|
||||
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-10.0, 40.0))
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_max_width(280.0);
|
||||
ui.label(egui::RichText::new("Shared screen codes").strong());
|
||||
ui.separator();
|
||||
if self.notices.is_empty() {
|
||||
ui.label(egui::RichText::new("No codes from friends right now.").weak());
|
||||
return;
|
||||
}
|
||||
for n in &self.notices {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new(&n.name).strong());
|
||||
ui.label(egui::RichText::new("is sharing their screen").weak());
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("▶ Watch").clicked() {
|
||||
watch = Some(n.code.clone());
|
||||
dismiss = Some(n.from);
|
||||
}
|
||||
if ui.small_button("Dismiss").clicked() {
|
||||
dismiss = Some(n.from);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
if ui.small_button("Clear all").clicked() {
|
||||
clear_all = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if clear_all {
|
||||
self.notices.clear();
|
||||
self.show_notices = false;
|
||||
}
|
||||
if let Some(id) = dismiss {
|
||||
self.notices.retain(|n| n.from != id);
|
||||
}
|
||||
if let Some(code) = watch {
|
||||
self.viewer.ticket_input = code;
|
||||
self.viewer.focus_ticket = true;
|
||||
self.screen = Screen::Viewer;
|
||||
self.show_notices = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Window-focused keyboard shortcuts (mirrored on the Shortcuts screen).
|
||||
@@ -1028,7 +1565,9 @@ impl PixelPassApp {
|
||||
self.screen = Screen::Menu;
|
||||
}
|
||||
Screen::Settings if self.theme.editing => self.cancel_theme_edit(),
|
||||
Screen::Settings | Screen::Shortcuts => self.screen = Screen::Menu,
|
||||
Screen::Settings | Screen::Shortcuts | Screen::Friends => {
|
||||
self.screen = Screen::Menu
|
||||
}
|
||||
Screen::Menu => {}
|
||||
}
|
||||
return;
|
||||
@@ -1053,16 +1592,16 @@ impl PixelPassApp {
|
||||
Screen::Host => {
|
||||
if self.host.proc.is_some() {
|
||||
if key(Key::C)
|
||||
&& let Some(ticket) = self.host.ticket.clone()
|
||||
&& let Some(code) = self.host.share_code.clone()
|
||||
{
|
||||
self.copy_to_clipboard(&ticket);
|
||||
self.copy_to_clipboard(&code);
|
||||
}
|
||||
} else if key(Key::Space) || key(Key::Enter) {
|
||||
self.start_host();
|
||||
}
|
||||
}
|
||||
// View's Enter (Connect) is handled in viewer_form.
|
||||
Screen::Viewer | Screen::Settings | Screen::Shortcuts => {}
|
||||
Screen::Viewer | Screen::Friends | Screen::Settings | Screen::Shortcuts => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1156,6 +1695,16 @@ impl PixelPassApp {
|
||||
self.prefill_viewer_ticket();
|
||||
}
|
||||
ui.add_space(20.0);
|
||||
let pending = self.pending_incoming_count();
|
||||
let friends_label = if pending > 0 {
|
||||
format!("👥 Friends ({pending})")
|
||||
} else {
|
||||
"👥 Friends".to_string()
|
||||
};
|
||||
if ui.button(friends_label).clicked() {
|
||||
self.screen = Screen::Friends;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if ui.button("⚙ Settings").clicked() {
|
||||
// Refresh the picker so themes added to the folder since launch
|
||||
// (or last visit) show up without a restart.
|
||||
@@ -1419,6 +1968,46 @@ impl PixelPassApp {
|
||||
});
|
||||
}
|
||||
|
||||
/// The "share my code with these friends" picker on the host form. Lists the
|
||||
/// accepted friends as checkboxes (ticked = will receive the code when I
|
||||
/// start). Selection is the *exclusion* set, so the default ships the code to
|
||||
/// everyone. Hidden when there's no presence service or no accepted friends.
|
||||
fn share_picker(&mut self, ui: &mut egui::Ui) {
|
||||
if self.presence.is_none() {
|
||||
return;
|
||||
}
|
||||
let accepted: Vec<(iroh::EndpointId, String, bool)> = self
|
||||
.friends
|
||||
.friends
|
||||
.iter()
|
||||
.filter(|f| f.state == FriendState::Accepted)
|
||||
.map(|f| (f.id, f.name.clone(), f.share))
|
||||
.collect();
|
||||
if accepted.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.label("Auto-share my code with:");
|
||||
for (id, name, share) in &accepted {
|
||||
let mut on = *share;
|
||||
if ui.checkbox(&mut on, name.as_str()).changed() {
|
||||
if let Some(f) = self.friends.find_mut(id) {
|
||||
f.share = on;
|
||||
}
|
||||
self.save_friends();
|
||||
}
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Ticked friends get this session's code automatically — even if \
|
||||
they're offline now (we keep retrying while you host).",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
|
||||
fn host_form(&mut self, ui: &mut egui::Ui) {
|
||||
if let Some(err) = &self.host.error {
|
||||
ui.colored_label(self.theme.active.error, err);
|
||||
@@ -1459,6 +2048,8 @@ impl PixelPassApp {
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
self.share_picker(ui);
|
||||
|
||||
ui.add_space(16.0);
|
||||
if ui
|
||||
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
|
||||
@@ -1531,9 +2122,9 @@ impl PixelPassApp {
|
||||
|
||||
ui.add_space(12.0);
|
||||
|
||||
if let Some(ticket) = self.host.ticket.clone() {
|
||||
if let Some(share_code) = self.host.share_code.clone() {
|
||||
ui.label("Share this code with your viewer(s):");
|
||||
if let Some(id) = ticket_endpoint_id(&ticket) {
|
||||
if let Some(id) = self.host.ticket.as_deref().and_then(ticket_endpoint_id) {
|
||||
// The viewer shows "Connecting to <id>…" with this same
|
||||
// truncation, so the two ends can be eyeballed for a match.
|
||||
ui.label(
|
||||
@@ -1545,7 +2136,7 @@ impl PixelPassApp {
|
||||
ui.add_space(4.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(&ticket).monospace().small())
|
||||
egui::Label::new(egui::RichText::new(&share_code).monospace().small())
|
||||
.wrap()
|
||||
.selectable(true),
|
||||
);
|
||||
@@ -1553,7 +2144,7 @@ impl PixelPassApp {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("📋 Copy code").clicked() {
|
||||
self.copy_to_clipboard(&ticket);
|
||||
self.copy_to_clipboard(&share_code);
|
||||
}
|
||||
if self.host.copied {
|
||||
ui.colored_label(self.theme.active.success, "✓ Copied to clipboard");
|
||||
@@ -1570,13 +2161,13 @@ impl PixelPassApp {
|
||||
}
|
||||
|
||||
// Lazy QR build: first draw after a Ticket event has `qr_texture =
|
||||
// None`, so we encode the ticket and load the texture once. The
|
||||
// None`, so we encode the share code and load the texture once. The
|
||||
// 4-module quiet zone (white border) matters — phone scanners reject
|
||||
// QR codes flush against a non-white edge. Skipped when the user
|
||||
// disabled the QR panel in Settings.
|
||||
if self.show_qr
|
||||
&& self.host.qr_texture.is_none()
|
||||
&& let Ok(code) = qrcode::QrCode::new(ticket.as_bytes())
|
||||
&& let Ok(code) = qrcode::QrCode::new(share_code.as_bytes())
|
||||
{
|
||||
let w = code.width();
|
||||
let quiet = 4;
|
||||
@@ -1610,6 +2201,9 @@ impl PixelPassApp {
|
||||
ui.colored_label(self.theme.active.warning, format!("⚠ {reason}"));
|
||||
}
|
||||
|
||||
self.share_status_list(ui);
|
||||
self.friend_offers(ui);
|
||||
|
||||
ui.add_space(16.0);
|
||||
if ui
|
||||
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
|
||||
@@ -1619,10 +2213,39 @@ impl PixelPassApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Live status of the host's share push: each targeted friend with a
|
||||
/// delivered ✓ or a "retrying" marker (offline friends are chased until they
|
||||
/// come online or the session stops). Empty — and so hidden — when no friends
|
||||
/// were selected to share with.
|
||||
fn share_status_list(&mut self, ui: &mut egui::Ui) {
|
||||
if self.share_status.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
ui.label("Shared this code with:");
|
||||
for (id, delivered) in &self.share_status {
|
||||
let name = self
|
||||
.friends
|
||||
.find(id)
|
||||
.map(|f| f.name.clone())
|
||||
.unwrap_or_else(|| short_id(&id.to_string()));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("• {name}"));
|
||||
if *delivered {
|
||||
ui.colored_label(self.theme.active.success, "✓ delivered");
|
||||
} else {
|
||||
ui.colored_label(self.theme.active.waiting, "… offline, retrying");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn start_host(&mut self) {
|
||||
self.host.error = None;
|
||||
self.host.last_refusal = None;
|
||||
self.host.ticket = None;
|
||||
self.host.share_code = None;
|
||||
self.host.info = None;
|
||||
self.host.active = 0;
|
||||
self.host.max = 0;
|
||||
@@ -1630,6 +2253,13 @@ impl PixelPassApp {
|
||||
self.host.copied = false;
|
||||
self.host.viewers.clear();
|
||||
self.host.qr_texture = None;
|
||||
self.met.clear();
|
||||
// Any previous campaign is stale; the new session's Ticket event will
|
||||
// start a fresh one once its code arrives.
|
||||
self.share_status.clear();
|
||||
if let Some(p) = &self.presence {
|
||||
p.stop_share();
|
||||
}
|
||||
|
||||
let mut args = vec![
|
||||
"--host".to_string(),
|
||||
@@ -1664,9 +2294,16 @@ impl PixelPassApp {
|
||||
self.host.proc = None;
|
||||
self.host.capturing = false;
|
||||
self.host.ticket = None;
|
||||
self.host.share_code = None;
|
||||
self.host.copied = false;
|
||||
self.host.viewers.clear();
|
||||
self.host.qr_texture = None;
|
||||
self.met.clear();
|
||||
// The code is no longer valid, so stop chasing offline friends with it.
|
||||
self.share_status.clear();
|
||||
if let Some(p) = &self.presence {
|
||||
p.stop_share();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the host child's event channel into state, and detect an
|
||||
@@ -1700,13 +2337,23 @@ impl PixelPassApp {
|
||||
fn apply_host_event(&mut self, ev: ChildEvent) {
|
||||
match ev {
|
||||
ChildEvent::Ticket { value } => {
|
||||
// Wrap the bare ticket with our stable control id so a viewer
|
||||
// learns who to befriend (see code::wrap). Falls back to the
|
||||
// bare ticket if the presence service isn't up.
|
||||
let share_code = match &self.presence {
|
||||
Some(p) => code::wrap(p.id(), &value),
|
||||
None => value.clone(),
|
||||
};
|
||||
// Auto-copy on arrival, mirroring the CLI/interactive host
|
||||
// (which copies the ticket and prints "copied to your
|
||||
// clipboard"). A failure here is non-fatal: the ticket stays
|
||||
// clipboard"). A failure here is non-fatal: the code stays
|
||||
// visible for manual copy, and `copied` stays false so the UI
|
||||
// doesn't falsely claim success.
|
||||
self.host.copied = set_clipboard(&value);
|
||||
self.host.copied = set_clipboard(&share_code);
|
||||
self.host.ticket = Some(value);
|
||||
self.host.share_code = Some(share_code);
|
||||
// Now that there's a code, push it to the selected friends.
|
||||
self.begin_share();
|
||||
}
|
||||
ChildEvent::HostInfo {
|
||||
display_server,
|
||||
@@ -1832,9 +2479,11 @@ impl PixelPassApp {
|
||||
|
||||
// Decode the pasted code live: confirms it's a real ticket and shows
|
||||
// which host it points at, so a stale clipboard paste is caught here
|
||||
// instead of after the 15s connect timeout.
|
||||
// instead of after the 15s connect timeout. Unwrap first so a wrapped
|
||||
// friend code decodes to its underlying ticket.
|
||||
let trimmed = self.viewer.ticket_input.trim().to_string();
|
||||
let decoded_id = ticket_endpoint_id(&trimmed);
|
||||
let (host_ctrl, bare_ticket) = code::unwrap(&trimmed);
|
||||
let decoded_id = ticket_endpoint_id(&bare_ticket);
|
||||
if !trimmed.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
match &decoded_id {
|
||||
@@ -1847,6 +2496,13 @@ impl PixelPassApp {
|
||||
"⚠ This doesn't look like a share code.",
|
||||
),
|
||||
};
|
||||
if host_ctrl.is_some() {
|
||||
ui.label(
|
||||
egui::RichText::new("This host can be added as a friend after you connect.")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
@@ -1892,6 +2548,8 @@ impl PixelPassApp {
|
||||
ui.colored_label(self.theme.active.waiting, msg);
|
||||
}
|
||||
|
||||
self.friend_offers(ui);
|
||||
|
||||
ui.add_space(16.0);
|
||||
if ui
|
||||
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
|
||||
@@ -1923,7 +2581,11 @@ impl PixelPassApp {
|
||||
self.viewer.url = None;
|
||||
self.viewer.launched = false;
|
||||
|
||||
let ticket = self.viewer.ticket_input.trim().to_string();
|
||||
// Unwrap a wrapped friend code into (host control id, bare ticket). The
|
||||
// child only ever sees the bare ticket; the control id is kept so we can
|
||||
// announce ourselves to the host once connected.
|
||||
let (host_ctrl, ticket) = code::unwrap(&self.viewer.ticket_input);
|
||||
self.viewer.host_control_id = host_ctrl;
|
||||
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
|
||||
let mut args = vec![ticket, "--output".to_string(), "json".to_string()];
|
||||
if let Some(relay) = &self.relay {
|
||||
@@ -1941,6 +2603,8 @@ impl PixelPassApp {
|
||||
self.viewer.url = None;
|
||||
self.viewer.launched = false;
|
||||
self.viewer.connecting_to = None;
|
||||
self.viewer.host_control_id = None;
|
||||
self.met.clear();
|
||||
}
|
||||
|
||||
fn pump_viewer_events(&mut self) {
|
||||
@@ -1957,6 +2621,16 @@ impl PixelPassApp {
|
||||
Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")),
|
||||
}
|
||||
}
|
||||
// If this was a wrapped friend code, announce ourselves to the
|
||||
// host over the control plane so both ends can offer to befriend
|
||||
// each other. We record the host as a met peer up front (name
|
||||
// filled in when the host's Hello reply arrives).
|
||||
if let Some(host_id) = self.viewer.host_control_id {
|
||||
self.note_met(host_id, short_id(&host_id.to_string()));
|
||||
if let Some(p) = &self.presence {
|
||||
p.send(host_id, control_hello(&self.display_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2025,4 +2699,37 @@ mod tests {
|
||||
assert_eq!(short_id("abc"), "abc");
|
||||
assert_eq!(short_id("0123456789ab"), "0123456789ab"); // exactly 12, no ellipsis
|
||||
}
|
||||
|
||||
fn id() -> iroh::EndpointId {
|
||||
iroh::SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_targets_are_accepted_friends_with_share_on() {
|
||||
let mut friends = FriendStore::default();
|
||||
let alice = id();
|
||||
let bob = id();
|
||||
let pending = id();
|
||||
friends.upsert(alice, "Alice".into(), FriendState::Accepted);
|
||||
friends.upsert(bob, "Bob".into(), FriendState::Accepted);
|
||||
friends.upsert(pending, "Pat".into(), FriendState::PendingIncoming);
|
||||
|
||||
// Default (share on for all) → every accepted friend, never a pending one.
|
||||
let all = selected_share_targets(&friends);
|
||||
assert_eq!(all.len(), 2);
|
||||
assert!(all.contains(&alice) && all.contains(&bob));
|
||||
assert!(!all.contains(&pending));
|
||||
|
||||
// Turning Bob's share off drops only Bob.
|
||||
friends.find_mut(&bob).unwrap().share = false;
|
||||
let some = selected_share_targets(&friends);
|
||||
assert_eq!(some, vec![alice]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_targets_empty_without_accepted_friends() {
|
||||
let mut friends = FriendStore::default();
|
||||
friends.upsert(id(), "Out".into(), FriendState::PendingOutgoing);
|
||||
assert!(selected_share_targets(&friends).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! The always-on friends presence service.
|
||||
//!
|
||||
//! A control-plane iroh endpoint ([`endpoint::bind_control`]) that lives for the
|
||||
//! whole GUI session on its own thread with a current-thread tokio runtime — the
|
||||
//! GUI is a synchronous winit/egui loop, so iroh's async work can't run on it
|
||||
//! (the same reason [`super::tray`] has its own thread + runtime).
|
||||
//!
|
||||
//! Inbound control messages are forwarded over a std mpsc channel the UI drains
|
||||
//! each [`super::PixelPassApp::tick`]; the [`Waker`] is pinged on arrival so a
|
||||
//! message wakes the loop even while the window is hidden to the tray — the same
|
||||
//! trick the headless-child reader uses.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::thread;
|
||||
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use tokio::sync::mpsc as tmpsc;
|
||||
|
||||
use super::Waker;
|
||||
use crate::common::{
|
||||
control::{self, ControlMsg, Inbound},
|
||||
endpoint, identity,
|
||||
};
|
||||
|
||||
/// A command the UI hands the presence service over [`PresenceHandle`].
|
||||
enum Command {
|
||||
/// Deliver one message, once, fire-and-forget (friend request/accept/decline
|
||||
/// and the presence `Hello`). A failure is logged, not retried.
|
||||
Send { peer: EndpointId, msg: ControlMsg },
|
||||
/// Begin — or replace — a share campaign: push `msg` (a
|
||||
/// [`ControlMsg::ShareCode`]) to every peer in `peers`, retrying the ones
|
||||
/// that are offline until they're reached or the campaign is stopped. Each
|
||||
/// success emits a [`PresenceEvent::ShareDelivered`]. Replaces any campaign
|
||||
/// already running (a fresh host session supersedes the previous code).
|
||||
StartShare {
|
||||
msg: ControlMsg,
|
||||
peers: Vec<EndpointId>,
|
||||
},
|
||||
/// Stop the active share campaign — the host stopped or left the screen, so
|
||||
/// the perishable code is no longer valid and offline friends shouldn't keep
|
||||
/// being chased.
|
||||
StopShare,
|
||||
}
|
||||
|
||||
/// Something the service surfaces to the UI, drained each tick.
|
||||
pub enum PresenceEvent {
|
||||
/// A control message arrived from a peer.
|
||||
Message(Inbound),
|
||||
/// A share-campaign code reached `peer` (its ACK came back). Lets the host
|
||||
/// screen flip that friend's row from "retrying" to "delivered."
|
||||
ShareDelivered { peer: EndpointId },
|
||||
}
|
||||
|
||||
/// How long to wait before re-attempting delivery to friends who were offline
|
||||
/// on the previous round of a share campaign.
|
||||
const SHARE_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Handle the GUI holds for the presence service. Dropping it doesn't stop the
|
||||
/// service (the thread is detached; the endpoint closes when the process exits)
|
||||
/// — it just stops the UI from draining inbound messages.
|
||||
pub struct PresenceHandle {
|
||||
/// Our stable control-plane id — what friends know us by, and what we embed
|
||||
/// in a wrapped share code so a viewer can find us.
|
||||
id: EndpointId,
|
||||
/// Service events (inbound messages + share receipts), drained by
|
||||
/// [`PresenceHandle::drain`] each tick.
|
||||
rx: Receiver<PresenceEvent>,
|
||||
/// Commands handed to the service thread. Unbounded tokio sender so the sync
|
||||
/// UI can enqueue without blocking or being inside the runtime.
|
||||
out_tx: tmpsc::UnboundedSender<Command>,
|
||||
}
|
||||
|
||||
impl PresenceHandle {
|
||||
/// Our stable control-plane id.
|
||||
pub fn id(&self) -> EndpointId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Pull every service event received since the last call. Collected by the
|
||||
/// caller so it can take `&mut self` while handling them.
|
||||
pub fn drain(&self) -> Vec<PresenceEvent> {
|
||||
std::iter::from_fn(|| self.rx.try_recv().ok()).collect()
|
||||
}
|
||||
|
||||
/// Enqueue a one-shot message for delivery to `peer`. Fire-and-forget from
|
||||
/// the UI's view; the service connects, delivers, and logs a failure. A send
|
||||
/// error here only means the service thread is gone.
|
||||
pub fn send(&self, peer: EndpointId, msg: ControlMsg) {
|
||||
self.command(Command::Send { peer, msg });
|
||||
}
|
||||
|
||||
/// Begin (or replace) a share campaign pushing `msg` to `peers`, retrying
|
||||
/// offline friends until [`PresenceHandle::stop_share`] or the next call.
|
||||
pub fn start_share(&self, msg: ControlMsg, peers: Vec<EndpointId>) {
|
||||
self.command(Command::StartShare { msg, peers });
|
||||
}
|
||||
|
||||
/// Stop the active share campaign (host stopped — the code is now stale).
|
||||
pub fn stop_share(&self) {
|
||||
self.command(Command::StopShare);
|
||||
}
|
||||
|
||||
fn command(&self, cmd: Command) {
|
||||
if self.out_tx.send(cmd).is_err() {
|
||||
tracing::warn!("presence: service thread gone; dropping command");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the presence service. Returns `None` if the persistent identity can't
|
||||
/// be loaded — the GUI then simply runs without friends features rather than
|
||||
/// refusing to start. The endpoint binds asynchronously on the spawned thread;
|
||||
/// our id is known immediately because it derives from the saved key, so we can
|
||||
/// fail-fast and log it without waiting on the relay handshake.
|
||||
pub fn start(waker: Waker, relay: Option<String>) -> Option<PresenceHandle> {
|
||||
let id: EndpointId = match identity::load_or_create() {
|
||||
Ok(key) => key.public(),
|
||||
Err(e) => {
|
||||
tracing::warn!("presence: no identity, friends features disabled: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
tracing::info!(%id, "presence: starting control service");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<PresenceEvent>();
|
||||
let (out_tx, out_rx) = tmpsc::unbounded_channel::<Command>();
|
||||
thread::Builder::new()
|
||||
.name("pixelpass-presence".into())
|
||||
.spawn(move || run(relay, id, tx, out_rx, waker))
|
||||
.map_err(|e| tracing::warn!("presence: could not spawn service thread: {e}"))
|
||||
.ok()?;
|
||||
|
||||
Some(PresenceHandle { id, rx, out_tx })
|
||||
}
|
||||
|
||||
/// Thread body: a current-thread tokio runtime that binds the control endpoint,
|
||||
/// runs the accept loop, bridges inbound messages to the UI channel, and
|
||||
/// delivers outbound messages the UI enqueues.
|
||||
fn run(
|
||||
relay: Option<String>,
|
||||
id: EndpointId,
|
||||
tx: mpsc::Sender<PresenceEvent>,
|
||||
mut out_rx: tmpsc::UnboundedReceiver<Command>,
|
||||
waker: Waker,
|
||||
) {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!("presence: failed to build runtime: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async move {
|
||||
let ep = match endpoint::bind_control(relay.as_deref()).await {
|
||||
Ok(ep) => ep,
|
||||
Err(e) => {
|
||||
tracing::error!("presence: failed to bind control endpoint: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(%id, "presence: control endpoint online");
|
||||
|
||||
// One async→sync bridge for *everything* the UI sees: every producer
|
||||
// (the accept loop and the share campaign) pushes a `PresenceEvent` into
|
||||
// `ui_tx`; this task drains it onto the std channel and wakes the loop so
|
||||
// the event lands even while the window is hidden to the tray.
|
||||
let (ui_tx, mut ui_rx) = tmpsc::channel::<PresenceEvent>(64);
|
||||
let forward = tokio::spawn(async move {
|
||||
while let Some(event) = ui_rx.recv().await {
|
||||
if tx.send(event).is_err() {
|
||||
break; // UI gone
|
||||
}
|
||||
waker.wake();
|
||||
}
|
||||
});
|
||||
|
||||
// Wrap inbound control messages as events and feed the bridge.
|
||||
let (itx, mut irx) = tmpsc::channel::<Inbound>(32);
|
||||
let inbound_ui = ui_tx.clone();
|
||||
let inbound = tokio::spawn(async move {
|
||||
while let Some(msg) = irx.recv().await {
|
||||
if inbound_ui.send(PresenceEvent::Message(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle UI commands: one-shot sends each on their own task, and a single
|
||||
// abortable share campaign (StartShare replaces it, StopShare cancels it).
|
||||
let cmd_ep = ep.clone();
|
||||
let commands = tokio::spawn(async move {
|
||||
let mut share: Option<tokio::task::JoinHandle<()>> = None;
|
||||
while let Some(cmd) = out_rx.recv().await {
|
||||
match cmd {
|
||||
Command::Send { peer, msg } => {
|
||||
let ep = cmd_ep.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = control::send(&ep, peer, &msg).await {
|
||||
tracing::warn!(%peer, "presence: outbound send failed: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Command::StartShare { msg, peers } => {
|
||||
if let Some(t) = share.take() {
|
||||
t.abort();
|
||||
}
|
||||
let ep = cmd_ep.clone();
|
||||
let ui = ui_tx.clone();
|
||||
share = Some(tokio::spawn(run_share(ep, msg, peers, ui)));
|
||||
}
|
||||
Command::StopShare => {
|
||||
if let Some(t) = share.take() {
|
||||
t.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
control::serve(ep, itx).await;
|
||||
forward.abort();
|
||||
inbound.abort();
|
||||
commands.abort();
|
||||
});
|
||||
}
|
||||
|
||||
/// Push `msg` to every peer in `peers`, retrying the ones that are offline every
|
||||
/// [`SHARE_RETRY`] until all are delivered (or the task is aborted by a
|
||||
/// StartShare/StopShare). Emits one [`PresenceEvent::ShareDelivered`] per peer
|
||||
/// the moment its ACK comes back — that ACK *is* the delivery signal.
|
||||
///
|
||||
/// Each round fires all still-pending peers **concurrently**, so a single
|
||||
/// offline friend's ~10s connect timeout doesn't serialise the whole round
|
||||
/// (which it did when peers were tried one at a time).
|
||||
async fn run_share(
|
||||
ep: Endpoint,
|
||||
msg: ControlMsg,
|
||||
mut pending: Vec<EndpointId>,
|
||||
ui: tmpsc::Sender<PresenceEvent>,
|
||||
) {
|
||||
// The code is immutable for the campaign's life; share it across the
|
||||
// per-peer tasks via an `Arc` rather than re-cloning the payload each round.
|
||||
let msg = Arc::new(msg);
|
||||
while !pending.is_empty() {
|
||||
let mut round = tokio::task::JoinSet::new();
|
||||
for peer in pending {
|
||||
let ep = ep.clone();
|
||||
let msg = Arc::clone(&msg);
|
||||
round.spawn(async move {
|
||||
match control::send(&ep, peer, &msg).await {
|
||||
Ok(()) => (peer, true),
|
||||
Err(e) => {
|
||||
tracing::debug!(%peer, "presence: share not yet delivered: {e:#}");
|
||||
(peer, false)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut still = Vec::new();
|
||||
while let Some(joined) = round.join_next().await {
|
||||
let (peer, delivered) = match joined {
|
||||
Ok(outcome) => outcome,
|
||||
// A send task panicking is unexpected; log and drop that peer
|
||||
// from the campaign rather than abort the whole round. (A
|
||||
// campaign-level abort drops this future entirely — we never
|
||||
// observe that as a JoinError here.)
|
||||
Err(e) => {
|
||||
tracing::warn!("presence: share task failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if delivered {
|
||||
tracing::info!(%peer, "presence: shared code delivered");
|
||||
if ui
|
||||
.send(PresenceEvent::ShareDelivered { peer })
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return; // UI gone — nothing left to report to
|
||||
}
|
||||
} else {
|
||||
still.push(peer);
|
||||
}
|
||||
}
|
||||
|
||||
if still.is_empty() {
|
||||
break;
|
||||
}
|
||||
pending = still;
|
||||
tokio::time::sleep(SHARE_RETRY).await;
|
||||
}
|
||||
tracing::info!("presence: share campaign complete");
|
||||
}
|
||||
+23
-19
@@ -52,9 +52,8 @@ impl Routing {
|
||||
let pid = std::process::id();
|
||||
let sink_name = format!("pixelpass_capture_{pid}");
|
||||
|
||||
let sink_module =
|
||||
load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
|
||||
.context("failed to load module-null-sink")?;
|
||||
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
|
||||
.context("failed to load module-null-sink")?;
|
||||
|
||||
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
||||
// default of 200ms is enough to be perceptible.
|
||||
@@ -205,7 +204,9 @@ fn parse_sink_inputs(stdout: &[u8]) -> Result<Vec<App>> {
|
||||
serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?;
|
||||
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let Some(name) = entry.properties.application_name else { continue };
|
||||
let Some(name) = entry.properties.application_name else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
@@ -359,16 +360,14 @@ fn run_router(
|
||||
) -> Result<()> {
|
||||
use pipewire::{self as pw, types::ObjectType};
|
||||
|
||||
let main_loop = pw::main_loop::MainLoopRc::new(None)
|
||||
.context("pw main loop construction failed")?;
|
||||
let context = pw::context::ContextRc::new(&main_loop, None)
|
||||
.context("pw context construction failed")?;
|
||||
let main_loop =
|
||||
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?;
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.context("pw core connect failed (is the daemon running?)")?;
|
||||
let registry = core
|
||||
.get_registry_rc()
|
||||
.context("pw get_registry failed")?;
|
||||
let registry = core.get_registry_rc().context("pw get_registry failed")?;
|
||||
|
||||
let state = Rc::new(RefCell::new(RouterState {
|
||||
sink_serial: None,
|
||||
@@ -409,20 +408,21 @@ fn run_router(
|
||||
let _reg_listener = registry
|
||||
.add_listener_local()
|
||||
.global(move |obj| {
|
||||
let Some(reg) = registry_weak.upgrade() else { return };
|
||||
let Some(reg) = registry_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
match obj.type_ {
|
||||
ObjectType::Node => {
|
||||
let Some(props) = obj.props.as_ref() else { return };
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
||||
if let Some(serial) = props
|
||||
.get("object.serial")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
{
|
||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
||||
tracing::info!(
|
||||
serial,
|
||||
"audio routing: pixelpass sink registered"
|
||||
);
|
||||
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
return;
|
||||
@@ -430,7 +430,9 @@ fn run_router(
|
||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
||||
return;
|
||||
}
|
||||
let Some(app) = props.get("application.name") else { return };
|
||||
let Some(app) = props.get("application.name") else {
|
||||
return;
|
||||
};
|
||||
if !app.eq_ignore_ascii_case(&filter_lower) {
|
||||
return;
|
||||
}
|
||||
@@ -443,7 +445,9 @@ fn run_router(
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
ObjectType::Metadata => {
|
||||
let Some(props) = obj.props.as_ref() else { return };
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if props.get("metadata.name") != Some("default") {
|
||||
return;
|
||||
}
|
||||
|
||||
+34
-7
@@ -256,7 +256,9 @@ fn spawn_kick_listener(sup_tx: mpsc::Sender<SupervisorMsg>) {
|
||||
let Some(id) = line.trim().strip_prefix("kick ") else {
|
||||
continue;
|
||||
};
|
||||
let msg = SupervisorMsg::KickViewer { id: id.trim().to_string() };
|
||||
let msg = SupervisorMsg::KickViewer {
|
||||
id: id.trim().to_string(),
|
||||
};
|
||||
// blocking_send is valid here: this is a plain thread, not inside
|
||||
// the tokio runtime. An Err means the supervisor closed — stop.
|
||||
if sup_tx.blocking_send(msg).is_err() {
|
||||
@@ -315,7 +317,11 @@ async fn supervise(
|
||||
viewers.insert(id.clone(), cancel);
|
||||
let active = viewers.len() as u32;
|
||||
let _ = reply.send(Ok(port));
|
||||
output::emit(output::Event::ViewerJoined { id: &id, active, max: max_viewers });
|
||||
output::emit(output::Event::ViewerJoined {
|
||||
id: &id,
|
||||
active,
|
||||
max: max_viewers,
|
||||
});
|
||||
tracing::info!(active, cap = max_viewers, "viewer joined");
|
||||
}
|
||||
SupervisorMsg::RemoveViewer { id } => {
|
||||
@@ -325,7 +331,11 @@ async fn supervise(
|
||||
continue;
|
||||
}
|
||||
let active = viewers.len() as u32;
|
||||
output::emit(output::Event::ViewerLeft { id: &id, active, max: max_viewers });
|
||||
output::emit(output::Event::ViewerLeft {
|
||||
id: &id,
|
||||
active,
|
||||
max: max_viewers,
|
||||
});
|
||||
tracing::info!(active, cap = max_viewers, "viewer left");
|
||||
if active == 0
|
||||
&& let Some(h) = handle.take()
|
||||
@@ -372,10 +382,25 @@ fn print_host_banner(
|
||||
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
|
||||
eprintln!("│ display server : {display:?}");
|
||||
eprintln!("│ capture : {}", capture_summary(opts));
|
||||
eprintln!("│ quality : {} — {}", quality.label, quality.dimensions_summary());
|
||||
eprintln!(
|
||||
"│ quality : {} — {}",
|
||||
quality.label,
|
||||
quality.dimensions_summary()
|
||||
);
|
||||
eprintln!("│ ({})", quality.note);
|
||||
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off (software x264)" } else { "on (VAAPI H.264)" });
|
||||
eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label());
|
||||
eprintln!(
|
||||
"│ hw encode : {}",
|
||||
if opts.no_hwencode {
|
||||
"off (software x264)"
|
||||
} else {
|
||||
"on (VAAPI H.264)"
|
||||
}
|
||||
);
|
||||
eprintln!(
|
||||
"│ max viewers : {} ({})",
|
||||
resolution.value,
|
||||
resolution.source.label()
|
||||
);
|
||||
eprintln!("│");
|
||||
if clipboard_ok {
|
||||
eprintln!("│ Your share code has been copied to your clipboard.");
|
||||
@@ -439,7 +464,9 @@ fn resolve_max_viewers(opts: &HostOpts, effective_bitrate: u32) -> MaxViewersRes
|
||||
let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate);
|
||||
return MaxViewersResolution {
|
||||
value: n,
|
||||
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
|
||||
source: MaxViewersSource::BandwidthMeasurement {
|
||||
safe_mbps: upstream,
|
||||
},
|
||||
};
|
||||
}
|
||||
MaxViewersResolution {
|
||||
|
||||
@@ -308,7 +308,9 @@ async fn default_audio_monitor() -> Result<String> {
|
||||
.arg("get-default-sink")
|
||||
.output()
|
||||
.await
|
||||
.context("failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)")?;
|
||||
.context(
|
||||
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
|
||||
)?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl get-default-sink failed: {}",
|
||||
|
||||
+31
-7
@@ -27,10 +27,26 @@ impl Quality {
|
||||
/// values and resolves to one of the others at runtime (see [`resolve_auto`]).
|
||||
fn preset(self) -> Option<Preset> {
|
||||
let p = match self {
|
||||
Quality::Source => Preset { max_height: None, bitrate: 6000, framerate: 30 },
|
||||
Quality::High => Preset { max_height: Some(1080), bitrate: 4000, framerate: 30 },
|
||||
Quality::Medium => Preset { max_height: Some(720), bitrate: 2500, framerate: 30 },
|
||||
Quality::Low => Preset { max_height: Some(480), bitrate: 1000, framerate: 30 },
|
||||
Quality::Source => Preset {
|
||||
max_height: None,
|
||||
bitrate: 6000,
|
||||
framerate: 30,
|
||||
},
|
||||
Quality::High => Preset {
|
||||
max_height: Some(1080),
|
||||
bitrate: 4000,
|
||||
framerate: 30,
|
||||
},
|
||||
Quality::Medium => Preset {
|
||||
max_height: Some(720),
|
||||
bitrate: 2500,
|
||||
framerate: 30,
|
||||
},
|
||||
Quality::Low => Preset {
|
||||
max_height: Some(480),
|
||||
bitrate: 1000,
|
||||
framerate: 30,
|
||||
},
|
||||
Quality::Auto => return None,
|
||||
};
|
||||
Some(p)
|
||||
@@ -49,7 +65,12 @@ impl Quality {
|
||||
|
||||
/// Fixed presets in descending quality order — Auto walks this to find the
|
||||
/// best one whose per-viewer bitrate fits the measured upstream budget.
|
||||
const AUTO_LADDER: [Quality; 4] = [Quality::Source, Quality::High, Quality::Medium, Quality::Low];
|
||||
const AUTO_LADDER: [Quality; 4] = [
|
||||
Quality::Source,
|
||||
Quality::High,
|
||||
Quality::Medium,
|
||||
Quality::Low,
|
||||
];
|
||||
|
||||
/// Auto's fallback when there is no usable bandwidth measurement.
|
||||
const AUTO_FALLBACK: Quality = Quality::Medium;
|
||||
@@ -146,7 +167,9 @@ fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String,
|
||||
(
|
||||
preset,
|
||||
format!("Auto → {}", chosen.name()),
|
||||
format!("auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"),
|
||||
format!(
|
||||
"auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"
|
||||
),
|
||||
)
|
||||
}
|
||||
None => {
|
||||
@@ -154,7 +177,8 @@ fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String,
|
||||
(
|
||||
preset,
|
||||
format!("Auto → {}", AUTO_FALLBACK.name()),
|
||||
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)".to_string(),
|
||||
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -26,7 +26,11 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
|
||||
.context("could not reach the xdg-desktop-portal ScreenCast interface")?;
|
||||
let session = proxy.create_session().await?;
|
||||
|
||||
let source = if opts.window { SourceType::Window } else { SourceType::Monitor };
|
||||
let source = if opts.window {
|
||||
SourceType::Window
|
||||
} else {
|
||||
SourceType::Monitor
|
||||
};
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
@@ -70,10 +74,16 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
|
||||
"do-timestamp=true".to_string(),
|
||||
];
|
||||
|
||||
pipeline::spawn(opts, quality, Some((w as u32, h as u32)), source_args, move || {
|
||||
// Parent no longer needs the pipewire fd — gst inherited its own copy.
|
||||
let _ = close(raw_fd);
|
||||
})
|
||||
pipeline::spawn(
|
||||
opts,
|
||||
quality,
|
||||
Some((w as u32, h as u32)),
|
||||
source_args,
|
||||
move || {
|
||||
// Parent no longer needs the pipewire fd — gst inherited its own copy.
|
||||
let _ = close(raw_fd);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -180,10 +180,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
|
||||
eprintln!();
|
||||
let Ok(choice) = Select::with_theme(theme)
|
||||
.with_prompt("Last bandwidth test failed. Try again?")
|
||||
.items([
|
||||
"Yes — retry now",
|
||||
"No — use the conservative default",
|
||||
])
|
||||
.items(["Yes — retry now", "No — use the conservative default"])
|
||||
.default(0)
|
||||
.interact()
|
||||
else {
|
||||
@@ -344,5 +341,9 @@ pub fn prompt_player() -> Result<Player> {
|
||||
.items(["mpv", "VLC"])
|
||||
.default(0)
|
||||
.interact()?;
|
||||
Ok(if choice == 0 { Player::Mpv } else { Player::Vlc })
|
||||
Ok(if choice == 0 {
|
||||
Player::Mpv
|
||||
} else {
|
||||
Player::Vlc
|
||||
})
|
||||
}
|
||||
|
||||
+5
-1
@@ -73,7 +73,11 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
fn init_tracing(verbose: bool) {
|
||||
let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" };
|
||||
let default = if verbose {
|
||||
"pixelpass=trace,iroh=info"
|
||||
} else {
|
||||
"pixelpass=info,iroh=warn"
|
||||
};
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
|
||||
// Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
|
||||
// writer to stdout, but with `--output json` stdout carries the JSON event
|
||||
|
||||
+4
-4
@@ -110,9 +110,7 @@ pub async fn run() -> Result<()> {
|
||||
}
|
||||
|
||||
if live_skipped > 0 {
|
||||
println!(
|
||||
"[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone."
|
||||
);
|
||||
println!("[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone.");
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
@@ -154,7 +152,9 @@ fn list_modules() -> Result<Vec<Module>> {
|
||||
for line in text.lines() {
|
||||
let mut parts = line.splitn(4, '\t');
|
||||
let Some(id_str) = parts.next() else { continue };
|
||||
let Ok(id) = id_str.parse::<u32>() else { continue };
|
||||
let Ok(id) = id_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = parts.next() else { continue };
|
||||
let args = parts.next().unwrap_or("").to_string();
|
||||
modules.push(Module {
|
||||
|
||||
Reference in New Issue
Block a user