refactor: drop milestone names + consolidate clients; loss-recovery & rumble fixes
apple / swift (push) Failing after 40s
audit / cargo-audit (push) Failing after 1m12s
windows-msix / package (push) Successful in 1m37s
windows / build (push) Successful in 1m14s
android / android (push) Successful in 4m48s
ci / web (push) Successful in 27s
ci / rust (push) Successful in 4m21s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish (push) Successful in 6m3s
flatpak / build-publish (push) Successful in 4m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m15s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m16s
docker / deploy-docs (push) Successful in 18s
apple / swift (push) Failing after 40s
audit / cargo-audit (push) Failing after 1m12s
windows-msix / package (push) Successful in 1m37s
windows / build (push) Successful in 1m14s
android / android (push) Successful in 4m48s
ci / web (push) Successful in 27s
ci / rust (push) Successful in 4m21s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish (push) Successful in 6m3s
flatpak / build-publish (push) Successful in 4m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m15s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m16s
docker / deploy-docs (push) Successful in 18s
Two bodies of work in one commit (the rename moved files the fixes also touched). Naming/structure cleanup (pre-launch): - Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host, m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source-> Punktfunk1Options/Punktfunk1Source. - Clients consolidated out of crates/ into clients/: punktfunk-client-rs-> clients/probe (crate punktfunk-probe), client-linux->clients/linux, client-windows->clients/windows, punktfunk-android->clients/android/native (crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI contract is unchanged). crates/ now holds only core + host. - Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site, kept only in docs/implementation-plan.md. docs/m2-plan.md-> docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated. Client loss-recovery (video froze and never recovered after a brief drop): - Export punktfunk_connection_frames_dropped through the C ABI (the core already tracked it for the client keyframe-recovery loop; it was never reachable from the ABI clients). Regenerated punktfunk_core.h. - Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll frames_dropped and request a keyframe when it climbs -- the same loss-driven recovery Linux/Windows already had. Under infinite GOP the decoder silently conceals reference-missing frames, so the decode-error trigger rarely fires. Apple rumble robustness (worked then went spotty -- DualSense + Xbox): - Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio interruption / server reset) and drop the permanent `broken` latch on a transient drive failure; latch only when the controller truly has no haptics. - Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging. Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift. Not runnable on this box (verify in CI): Gitea workflows, gradle/Android, flatpak, Swift/decky. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,876 @@
|
||||
//! The WinUI 3 (windows-reactor) application shell — host list, settings, PIN/TOFU pairing, and
|
||||
//! the stream page (a `SwapChainPanel` bound to the D3D11 composition swapchain in
|
||||
//! [`crate::present`], driven by reactor's per-frame `on_rendering`).
|
||||
//!
|
||||
//! Declarative React-like model: a single root component routes on a `Screen` value held in
|
||||
//! `use_async_state` so background threads (discovery, the session pump) can drive navigation.
|
||||
//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel
|
||||
//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame
|
||||
//! present must not go through state/rerender.
|
||||
//!
|
||||
//! The chrome follows the windows-reactor gallery's look: Mica backdrop, a centred max-width
|
||||
//! column, theme brushes (`ThemeRef`), and rounded `border` cards.
|
||||
|
||||
use crate::discovery::{self, DiscoveredHost};
|
||||
use crate::gamepad::GamepadService;
|
||||
use crate::present::Presenter;
|
||||
use crate::session::{self, SessionEvent, SessionParams, Stats};
|
||||
use crate::trust::{self, KnownHost, KnownHosts, Settings};
|
||||
use crate::video::DecodedFrame;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows_reactor::*;
|
||||
|
||||
const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
(0, 0),
|
||||
(1280, 720),
|
||||
(1920, 1080),
|
||||
(2560, 1440),
|
||||
(3840, 2160),
|
||||
];
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Screen {
|
||||
Hosts,
|
||||
Connecting,
|
||||
Stream,
|
||||
Settings,
|
||||
Pair,
|
||||
}
|
||||
|
||||
/// The host we're about to connect to / pair with (carried into the Pair screen).
|
||||
#[derive(Clone, Default)]
|
||||
struct Target {
|
||||
name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
fp_hex: Option<String>,
|
||||
pair_optional: bool,
|
||||
}
|
||||
|
||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||
/// hooks (`hosts_page`/`pair_page`/`stream_page`) is mounted as its own `component(...)`, so
|
||||
/// its hooks live in an isolated slot list — calling them on the shared parent `cx` would
|
||||
/// change the hook order whenever the screen changes (reactor's Rules-of-Hooks guard aborts).
|
||||
///
|
||||
/// `Svc` compares equal by `ctx` identity (it never meaningfully changes across renders), so a
|
||||
/// page whose props are just `Svc` re-renders only via its own state hooks, never spuriously
|
||||
/// from the parent.
|
||||
#[derive(Clone)]
|
||||
struct Svc {
|
||||
ctx: Arc<AppCtx>,
|
||||
set_screen: AsyncSetState<Screen>,
|
||||
set_status: AsyncSetState<String>,
|
||||
}
|
||||
|
||||
impl PartialEq for Svc {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
Arc::ptr_eq(&self.ctx, &other.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Props for the hosts page: the services plus the changing discovery/status data that must
|
||||
/// drive its re-render (compared by value, so a new host list or error refreshes the page).
|
||||
#[derive(Clone)]
|
||||
struct HostsProps {
|
||||
svc: Svc,
|
||||
hosts: Vec<DiscoveredHost>,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl PartialEq for HostsProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.svc == other.svc && self.hosts == other.hosts && self.status == other.status
|
||||
}
|
||||
}
|
||||
|
||||
/// Props for the stream page: the services plus the live stats that drive the HUD overlay
|
||||
/// (compared by value, so each new sample re-renders the overlay).
|
||||
#[derive(Clone)]
|
||||
struct StreamProps {
|
||||
svc: Svc,
|
||||
stats: Stats,
|
||||
}
|
||||
|
||||
impl PartialEq for StreamProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.svc == other.svc && self.stats == other.stats
|
||||
}
|
||||
}
|
||||
|
||||
/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver.
|
||||
struct PresentCtx {
|
||||
presenter: Presenter,
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static PRESENT: RefCell<Option<PresentCtx>> = const { RefCell::new(None) };
|
||||
static PENDING_FRAMES: RefCell<Option<async_channel::Receiver<DecodedFrame>>> =
|
||||
const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread).
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
handoff: Mutex<Option<(Arc<NativeClient>, async_channel::Receiver<DecodedFrame>)>>,
|
||||
target: Mutex<Target>,
|
||||
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||
/// by the stream page's HUD poll thread to drive the overlay.
|
||||
stats: Mutex<Stats>,
|
||||
}
|
||||
|
||||
pub struct AppCtx {
|
||||
identity: (String, String),
|
||||
settings: Mutex<Settings>,
|
||||
gamepad: GamepadService,
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||
let ctx = Arc::new(AppCtx {
|
||||
identity,
|
||||
settings: Mutex::new(Settings::load()),
|
||||
gamepad,
|
||||
shared: Arc::new(Shared::default()),
|
||||
});
|
||||
App::new()
|
||||
.title("Punktfunk")
|
||||
.inner_size(1000.0, 720.0)
|
||||
.backdrop(Backdrop::Mica)
|
||||
.render(move |cx| root(cx, &ctx))
|
||||
}
|
||||
|
||||
// --- shared styling -----------------------------------------------------------------------
|
||||
|
||||
fn uniform(v: f64) -> Thickness {
|
||||
Thickness::uniform(v)
|
||||
}
|
||||
|
||||
fn edges(left: f64, top: f64, right: f64, bottom: f64) -> Thickness {
|
||||
Thickness {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded, bordered surface in the theme's card colours.
|
||||
fn card(child: impl Into<Element>) -> Border {
|
||||
border(child.into())
|
||||
.background(ThemeRef::CardBackground)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_thickness(uniform(1.0))
|
||||
.corner_radius(8.0)
|
||||
.padding(uniform(16.0))
|
||||
}
|
||||
|
||||
/// A small all-caps section label above a group of cards.
|
||||
fn section(label: &str) -> Element {
|
||||
text_block(label)
|
||||
.font_size(12.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.margin(edges(2.0, 10.0, 0.0, 0.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Wrap a screen's children in a scrollable, centred, max-width column.
|
||||
fn page(children: Vec<Element>) -> Element {
|
||||
let col = vstack(children)
|
||||
.spacing(10.0)
|
||||
.max_width(640.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.margin(edges(24.0, 24.0, 24.0, 40.0));
|
||||
scroll_view(col).into()
|
||||
}
|
||||
|
||||
/// A clickable host row: name + address/badge + chevron.
|
||||
fn host_card(name: &str, sub: &str, badge: &str, on_tap: impl Fn() + 'static) -> Element {
|
||||
card(
|
||||
grid((
|
||||
vstack((
|
||||
text_block(name).font_size(15.0).semibold(),
|
||||
text_block(sub)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(2.0)
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
text_block(badge)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.grid_column(1)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.margin(edges(0.0, 0.0, 12.0, 0.0)),
|
||||
text_block("\u{203A}")
|
||||
.font_size(18.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.grid_column(2)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
))
|
||||
.columns([GridLength::Star(1.0), GridLength::Auto, GridLength::Auto]),
|
||||
)
|
||||
.on_tapped(on_tap)
|
||||
.into()
|
||||
}
|
||||
|
||||
// --- screens ------------------------------------------------------------------------------
|
||||
|
||||
fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let (screen, set_screen) = cx.use_async_state(Screen::Hosts);
|
||||
let (hosts, set_hosts) = cx.use_async_state(Vec::<DiscoveredHost>::new());
|
||||
let (status, set_status) = cx.use_async_state(String::new());
|
||||
let (stats, set_stats) = cx.use_async_state(Stats::default());
|
||||
|
||||
// Continuous LAN discovery (spawned once).
|
||||
cx.use_effect((), {
|
||||
let set_hosts = set_hosts.clone();
|
||||
move || {
|
||||
let rx = discovery::browse();
|
||||
std::thread::spawn(move || {
|
||||
let mut acc: Vec<DiscoveredHost> = Vec::new();
|
||||
while let Ok(h) = rx.recv_blocking() {
|
||||
if let Some(e) = acc.iter_mut().find(|e| e.key == h.key) {
|
||||
*e = h;
|
||||
} else {
|
||||
acc.push(h);
|
||||
}
|
||||
set_hosts.call(acc.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// HUD stats: the session event loop writes `shared.stats`; this poll thread mirrors it into
|
||||
// root state so the stream page gets it as a *prop*. (A child component's own async-state
|
||||
// update is pruned when its props are unchanged — only a prop change re-renders it, exactly
|
||||
// like discovery → hosts above.)
|
||||
cx.use_effect((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let set_stats = set_stats.clone();
|
||||
move || {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-hud".into())
|
||||
.spawn(move || {
|
||||
let mut last = Stats::default();
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
let s = *shared.stats.lock().unwrap();
|
||||
if s != last {
|
||||
last = s;
|
||||
set_stats.call(s);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
|
||||
// Each hook-using screen is mounted as its own component so its hooks are isolated from
|
||||
// root's (root's own hooks above stay a stable prefix regardless of which screen renders).
|
||||
let svc = Svc {
|
||||
ctx: ctx.clone(),
|
||||
set_screen: set_screen.clone(),
|
||||
set_status: set_status.clone(),
|
||||
};
|
||||
match screen {
|
||||
Screen::Hosts => component(hosts_page, HostsProps { svc, hosts, status }),
|
||||
Screen::Connecting => vstack((
|
||||
ProgressRing::indeterminate()
|
||||
.width(48.0)
|
||||
.height(48.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
text_block("Connecting\u{2026}")
|
||||
.font_size(16.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
text_block(status.clone())
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
))
|
||||
.spacing(16.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into(),
|
||||
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
||||
Screen::Settings => settings_page(ctx, &set_screen),
|
||||
Screen::Pair => component(pair_page, svc),
|
||||
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
||||
}
|
||||
}
|
||||
|
||||
fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let ctx = &props.svc.ctx;
|
||||
let hosts = props.hosts.as_slice();
|
||||
let status = props.status.as_str();
|
||||
let set_screen = &props.svc.set_screen;
|
||||
let set_status = &props.svc.set_status;
|
||||
let (manual, set_manual) = cx.use_state(String::new());
|
||||
let known = KnownHosts::load();
|
||||
|
||||
let mut body: Vec<Element> = Vec::new();
|
||||
|
||||
// Header: title block + Settings button.
|
||||
body.push(
|
||||
grid((
|
||||
vstack((
|
||||
text_block("Punktfunk").font_size(30.0).bold(),
|
||||
text_block("Stream from a host on your network.")
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(2.0)
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
button("Settings")
|
||||
.on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
})
|
||||
.grid_column(1)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
))
|
||||
.columns([GridLength::Star(1.0), GridLength::Auto])
|
||||
.margin(edges(0.0, 0.0, 0.0, 6.0))
|
||||
.into(),
|
||||
);
|
||||
|
||||
if !status.is_empty() {
|
||||
body.push(card(text_block(status.to_string()).foreground(ThemeRef::SystemCritical)).into());
|
||||
}
|
||||
|
||||
// Saved (trusted/paired) hosts.
|
||||
if !known.hosts.is_empty() {
|
||||
body.push(section("SAVED HOSTS"));
|
||||
for k in &known.hosts {
|
||||
let target = Target {
|
||||
name: k.name.clone(),
|
||||
addr: k.addr.clone(),
|
||||
port: k.port,
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
body.push(host_card(
|
||||
&k.name,
|
||||
&format!("{}:{}", k.addr, k.port),
|
||||
if k.paired { "Paired" } else { "Trusted" },
|
||||
move || initiate(&ctx2, target.clone(), &ss, &st),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Discovered hosts.
|
||||
body.push(section("ON YOUR NETWORK"));
|
||||
if hosts.is_empty() {
|
||||
body.push(
|
||||
card(
|
||||
hstack((
|
||||
ProgressRing::indeterminate().width(18.0).height(18.0),
|
||||
text_block("Searching the LAN\u{2026}").foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(12.0),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
for h in hosts {
|
||||
let target = Target {
|
||||
name: h.name.clone(),
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
pair_optional: h.pair == "optional",
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let badge = if h.pair == "required" { "PIN" } else { "Open" };
|
||||
body.push(host_card(
|
||||
&h.name,
|
||||
&format!("{}:{}", h.addr, h.port),
|
||||
badge,
|
||||
move || initiate(&ctx2, target.clone(), &ss, &st),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Manual connection.
|
||||
body.push(section("CONNECT MANUALLY"));
|
||||
let connect_manual = {
|
||||
let (ctx2, ss, st, text) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
manual.clone(),
|
||||
);
|
||||
move || {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (addr, port) = match text.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
|
||||
None => (text.to_string(), 9777),
|
||||
};
|
||||
initiate(
|
||||
&ctx2,
|
||||
Target {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
},
|
||||
&ss,
|
||||
&st,
|
||||
);
|
||||
}
|
||||
};
|
||||
body.push(
|
||||
card(
|
||||
grid((
|
||||
text_box(manual)
|
||||
.placeholder("host or host:port")
|
||||
.on_changed(move |s| set_manual.call(s))
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
button("Connect")
|
||||
.accent()
|
||||
.on_click(connect_manual)
|
||||
.grid_column(1)
|
||||
.margin(edges(8.0, 0.0, 0.0, 0.0)),
|
||||
))
|
||||
.columns([GridLength::Star(1.0), GridLength::Auto]),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
|
||||
page(body)
|
||||
}
|
||||
|
||||
/// The trust gate (mirrors the GTK client's `initiate_connect`): pinned fingerprint → silent
|
||||
/// connect; known address → stored pin; advertised `pair=optional` → TOFU; otherwise → PIN
|
||||
/// pairing.
|
||||
fn initiate(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
let known = KnownHosts::load();
|
||||
let pin = target
|
||||
.fp_hex
|
||||
.as_ref()
|
||||
.and_then(|fp| known.find_by_fp(fp).map(|_| fp.clone()))
|
||||
.or_else(|| {
|
||||
known
|
||||
.find_by_addr(&target.addr, target.port)
|
||||
.map(|k| k.fp_hex.clone())
|
||||
})
|
||||
.and_then(|fp| trust::parse_hex32(&fp));
|
||||
|
||||
if let Some(pin) = pin {
|
||||
connect(ctx, &target, Some(pin), set_screen, set_status);
|
||||
} else if target.pair_optional {
|
||||
connect(ctx, &target, None, set_screen, set_status); // TOFU
|
||||
} else {
|
||||
*ctx.shared.target.lock().unwrap() = target;
|
||||
set_screen.call(Screen::Pair);
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: &Target,
|
||||
pin: Option<[u8; 32]>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
||||
Mode {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
refresh_hz: s.refresh_hz,
|
||||
}
|
||||
} else {
|
||||
Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
}
|
||||
};
|
||||
let gamepad_pref = match GamepadPref::from_name(&s.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
};
|
||||
let handle = session::start(SessionParams {
|
||||
host: target.addr.clone(),
|
||||
port: target.port,
|
||||
mode,
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: gamepad_pref,
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
mic_enabled: s.mic_enabled,
|
||||
pin,
|
||||
identity: ctx.identity.clone(),
|
||||
});
|
||||
set_status.call(String::new());
|
||||
set_screen.call(Screen::Connecting);
|
||||
|
||||
let tofu = pin.is_none();
|
||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let target = target.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
match handle.events.recv_blocking() {
|
||||
Ok(SessionEvent::Connected {
|
||||
connector,
|
||||
fingerprint,
|
||||
..
|
||||
}) => {
|
||||
if tofu {
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: trust::hex(&fingerprint),
|
||||
paired: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
gamepad.attach(connector.clone());
|
||||
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
|
||||
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
Ok(SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
st.call(msg);
|
||||
gamepad.detach();
|
||||
if trust_rejected {
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
|
||||
*shared.target.lock().unwrap() = target.clone();
|
||||
ss.call(Screen::Pair);
|
||||
} else {
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(SessionEvent::Ended(err)) => {
|
||||
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s,
|
||||
Err(_) => {
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
let ctx = &props.ctx;
|
||||
let set_screen = &props.set_screen;
|
||||
let set_status = &props.set_status;
|
||||
let (code, set_code) = cx.use_state(String::new());
|
||||
let target = ctx.shared.target.lock().unwrap().clone();
|
||||
|
||||
let pair_btn = {
|
||||
let (ctx2, ss, st, code2, target2) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
code.clone(),
|
||||
target.clone(),
|
||||
);
|
||||
button("Pair & Connect").accent().on_click(move || {
|
||||
let pin = code2.trim().to_string();
|
||||
let (ctx3, ss, st, target3) = (ctx2.clone(), ss.clone(), st.clone(), target2.clone());
|
||||
std::thread::spawn(move || {
|
||||
let name =
|
||||
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
|
||||
match NativeClient::pair(
|
||||
&target3.addr,
|
||||
target3.port,
|
||||
(&ctx3.identity.0, &ctx3.identity.1),
|
||||
&pin,
|
||||
&name,
|
||||
std::time::Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target3.name.clone(),
|
||||
addr: target3.addr.clone(),
|
||||
port: target3.port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
});
|
||||
let _ = k.save();
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
}
|
||||
Err(e) => {
|
||||
st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)"));
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
let cancel_btn = {
|
||||
let ss = set_screen.clone();
|
||||
button("Cancel").on_click(move || ss.call(Screen::Hosts))
|
||||
};
|
||||
|
||||
let content = card(vstack((
|
||||
text_block(format!("Pair with {}", target.name))
|
||||
.font_size(20.0)
|
||||
.semibold(),
|
||||
text_block(
|
||||
"Arm pairing on the host (its console or web console), then enter the 4-digit PIN it \
|
||||
shows.",
|
||||
)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.max_width(440.0),
|
||||
text_box(code)
|
||||
.placeholder("PIN")
|
||||
.on_changed(move |s| set_code.call(s)),
|
||||
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
||||
))
|
||||
.spacing(14.0))
|
||||
.max_width(480.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.margin(edges(0.0, 80.0, 0.0, 0.0));
|
||||
|
||||
page(vec![content.into()])
|
||||
}
|
||||
|
||||
fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
let res_i = RESOLUTIONS
|
||||
.iter()
|
||||
.position(|&(w, h)| w == s.width && h == s.height)
|
||||
.unwrap_or(0) as i32;
|
||||
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0) as i32;
|
||||
|
||||
let res_names: Vec<String> = RESOLUTIONS
|
||||
.iter()
|
||||
.map(|&(w, h)| {
|
||||
if w == 0 {
|
||||
"Native display".into()
|
||||
} else {
|
||||
format!("{w} \u{00D7} {h}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let hz_names: Vec<String> = REFRESH
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
if r == 0 {
|
||||
"Native".into()
|
||||
} else {
|
||||
format!("{r} Hz")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let res_combo = {
|
||||
let ctx = ctx.clone();
|
||||
ComboBox::new(res_names)
|
||||
.header("Resolution")
|
||||
.selected_index(res_i)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let (w, h) = RESOLUTIONS[(i.max(0) as usize).min(RESOLUTIONS.len() - 1)];
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
(s.width, s.height) = (w, h);
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let hz_combo = {
|
||||
let ctx = ctx.clone();
|
||||
ComboBox::new(hz_names)
|
||||
.header("Refresh rate")
|
||||
.selected_index(hz_i)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.refresh_hz = REFRESH[(i.max(0) as usize).min(REFRESH.len() - 1)];
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let mic_toggle = {
|
||||
let ctx = ctx.clone();
|
||||
check_box(s.mic_enabled)
|
||||
.label("Stream microphone to the host")
|
||||
.on_changed(move |on: bool| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.mic_enabled = on;
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
|
||||
let header = grid((
|
||||
text_block("Settings")
|
||||
.font_size(30.0)
|
||||
.bold()
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
button("Back")
|
||||
.accent()
|
||||
.on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Hosts)
|
||||
})
|
||||
.grid_column(1)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
))
|
||||
.columns([GridLength::Star(1.0), GridLength::Auto])
|
||||
.margin(edges(0.0, 0.0, 0.0, 6.0));
|
||||
|
||||
let stream_card = card(
|
||||
vstack((
|
||||
text_block("Stream").font_size(15.0).semibold(),
|
||||
text_block("The host creates a virtual display at exactly this mode.")
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
res_combo,
|
||||
hz_combo,
|
||||
))
|
||||
.spacing(10.0),
|
||||
);
|
||||
|
||||
let audio_card =
|
||||
card(vstack((text_block("Audio").font_size(15.0).semibold(), mic_toggle)).spacing(10.0));
|
||||
|
||||
page(vec![
|
||||
header.into(),
|
||||
section("STREAM"),
|
||||
stream_card.into(),
|
||||
section("AUDIO"),
|
||||
audio_card.into(),
|
||||
])
|
||||
}
|
||||
|
||||
// --- stream page --------------------------------------------------------------------------
|
||||
|
||||
fn present_newest(ctx: &mut PresentCtx) {
|
||||
let mut newest = None;
|
||||
while let Ok(f) = ctx.frames.try_recv() {
|
||||
newest = Some(f);
|
||||
}
|
||||
let cpu = newest.as_ref().map(|DecodedFrame::Cpu(c)| c);
|
||||
ctx.presenter.present(cpu);
|
||||
}
|
||||
|
||||
fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
let ctx = &props.svc.ctx;
|
||||
// Take the connector + frames handoff once on mount; keep the connector alive (and for input)
|
||||
// in a use_ref, stash frames for `on_ready`, install the input hooks (and remove on unmount).
|
||||
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||
cx.use_effect_with_cleanup((), {
|
||||
let shared = ctx.shared.clone();
|
||||
let connector_ref = connector_ref.clone();
|
||||
move || {
|
||||
if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() {
|
||||
let mode = connector.mode();
|
||||
connector_ref.set(Some(connector.clone()));
|
||||
PENDING_FRAMES.with(|c| *c.borrow_mut() = Some(frames));
|
||||
crate::input::install(connector, mode);
|
||||
}
|
||||
Some(crate::input::uninstall)
|
||||
}
|
||||
});
|
||||
|
||||
let rendering = cx.use_ref::<Option<Rendering>>(None);
|
||||
cx.use_effect((), {
|
||||
let rendering = rendering.clone();
|
||||
move || {
|
||||
if let Ok(r) = on_rendering(move || {
|
||||
PRESENT.with(|cell| {
|
||||
if let Some(ctx) = cell.borrow_mut().as_mut() {
|
||||
present_newest(ctx);
|
||||
}
|
||||
});
|
||||
}) {
|
||||
rendering.set(Some(r));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
|
||||
grid((
|
||||
swap_chain_panel()
|
||||
.on_ready(|panel| match Presenter::new(1280, 720) {
|
||||
Ok(p) => {
|
||||
if let Err(e) = panel.set_swap_chain(p.swap_chain()) {
|
||||
tracing::error!(error = %e, "set_swap_chain");
|
||||
}
|
||||
if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) {
|
||||
PRESENT.with(|cell| {
|
||||
*cell.borrow_mut() = Some(PresentCtx {
|
||||
presenter: p,
|
||||
frames,
|
||||
});
|
||||
});
|
||||
tracing::info!("stream presenter bound to SwapChainPanel");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(error = %e, "create presenter"),
|
||||
})
|
||||
.on_resize(|w, h| {
|
||||
PRESENT.with(|cell| {
|
||||
if let Some(ctx) = cell.borrow_mut().as_mut() {
|
||||
ctx.presenter.resize(w as u32, h as u32);
|
||||
}
|
||||
});
|
||||
}),
|
||||
hud_overlay(&props.stats, mode),
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The streaming HUD overlay (top-right), mirroring the Apple client: mode + fps/throughput, the
|
||||
/// capture→client latency + decode time, and the release-cursor hint. Layered over the
|
||||
/// `SwapChainPanel` in the same grid cell.
|
||||
fn hud_overlay(stats: &Stats, mode: Option<Mode>) -> Element {
|
||||
let res = mode
|
||||
.map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz))
|
||||
.unwrap_or_else(|| "\u{2014}".into());
|
||||
let line1 = format!("{res} {:.0} fps {:.1} Mb/s", stats.fps, stats.mbps);
|
||||
let line2 = format!(
|
||||
"capture\u{2192}client {:.1} ms p50 \u{00B7} decode {:.1} ms",
|
||||
stats.latency_ms, stats.decode_ms
|
||||
);
|
||||
border(
|
||||
vstack((
|
||||
text_block(line1)
|
||||
.font_size(12.0)
|
||||
.foreground(Color::rgb(255, 255, 255)),
|
||||
text_block(line2)
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(200, 200, 200)),
|
||||
text_block("Ctrl+Alt+Shift+Q releases the mouse")
|
||||
.font_size(11.0)
|
||||
.foreground(Color::rgb(160, 160, 160)),
|
||||
))
|
||||
.spacing(2.0),
|
||||
)
|
||||
.background(Color::rgb(0, 0, 0))
|
||||
.corner_radius(8.0)
|
||||
.padding(uniform(10.0))
|
||||
.opacity(0.82)
|
||||
.horizontal_alignment(HorizontalAlignment::Right)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(12.0))
|
||||
.into()
|
||||
}
|
||||
Reference in New Issue
Block a user