forked from unom/punktfunk
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22936bbc89 | ||
|
|
f5de661c9e | ||
|
|
60a85a1344 | ||
|
|
8e7ba00d2d | ||
|
|
ee29b3c3a9 | ||
|
|
d72822ced7 | ||
|
|
ed4bbc6b0b | ||
|
|
5b2be889f9 | ||
|
|
9e8473b46a | ||
|
|
50531c8e9e | ||
|
|
ed021a13ee | ||
|
|
8381fd73ef | ||
|
|
22f2686680 | ||
|
|
735cd99e8f | ||
|
|
3d25482469 | ||
|
|
2a088e13a4 |
@@ -294,7 +294,15 @@ jobs:
|
||||
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
|
||||
}
|
||||
Push-Location web
|
||||
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
# `--ignore-scripts` like every other web install in CI (ci.yml, web-screenshots.yml,
|
||||
# sdk/plugin-kit-publish, and the SDK install further down this same file). This step was
|
||||
# the one site that ran lifecycle scripts, and web's `postinstall` is `bun2nix -o bun.nix`
|
||||
# — a NIX codegen step that shells out to `bun` on PATH. CI runs a fetched PORTABLE bun by
|
||||
# absolute path (`$env:BUN_EXE`), so PATH has none, and bun2nix aborted the install:
|
||||
# error: bun is not installed in %PATH% ... postinstall script exited with 255
|
||||
# Nothing here needs those scripts — `build` re-runs its own `prebuild` codegen — and
|
||||
# bun.nix is a Nix artifact this job neither consumes nor commits.
|
||||
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
|
||||
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||
|
||||
Generated
+1
@@ -3051,6 +3051,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
|
||||
+6
-1
@@ -5831,7 +5831,8 @@
|
||||
"type": "object",
|
||||
"description": "The host's physical monitors + which one capture is pinned to.",
|
||||
"required": [
|
||||
"monitors"
|
||||
"monitors",
|
||||
"pin_supported"
|
||||
],
|
||||
"properties": {
|
||||
"compositor": {
|
||||
@@ -5855,6 +5856,10 @@
|
||||
},
|
||||
"description": "The heads, ordered left-to-right by desktop position."
|
||||
},
|
||||
"pin_supported": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change."
|
||||
},
|
||||
"pinned": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -239,6 +239,7 @@ fn connect_spawn(
|
||||
let target = target.clone();
|
||||
// The closure owns `target`/`fp_hex`; the call itself borrows copies.
|
||||
let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
|
||||
let profile_arg = target.profile.clone();
|
||||
let spawned = crate::spawn::spawn_session(
|
||||
&addr,
|
||||
port,
|
||||
@@ -246,6 +247,7 @@ fn connect_spawn(
|
||||
opts.connect_timeout.as_secs(),
|
||||
fullscreen,
|
||||
opts.launch.as_deref(),
|
||||
profile_arg.as_deref(),
|
||||
child,
|
||||
move |event| {
|
||||
use crate::spawn::SpawnEvent;
|
||||
|
||||
@@ -20,6 +20,13 @@ const MENU_WAKE: &str = "Wake host";
|
||||
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
|
||||
/// that matter.
|
||||
const MENU_EDIT: &str = "Edit\u{2026}";
|
||||
/// Dynamic menu-item prefixes: this flyout has no submenus, so the profile entries are flat
|
||||
/// items matched by prefix. The trailing space keeps them readable AND keeps a profile named
|
||||
/// e.g. "Copy link" from colliding with a fixed entry.
|
||||
const MENU_WITH: &str = "Connect with: ";
|
||||
const MENU_PIN: &str = "Pin as card: ";
|
||||
const MENU_UNPIN: &str = "Unpin card: ";
|
||||
const MENU_COPY_LINK: &str = "Copy link";
|
||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||
|
||||
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
||||
@@ -163,6 +170,19 @@ pub(crate) struct Hover {
|
||||
|
||||
/// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip.
|
||||
fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
||||
status_row_with(online, badge, kind, None)
|
||||
}
|
||||
|
||||
/// [`status_row`] plus the profile chip: what a plain click on THIS tile will use — its own
|
||||
/// profile on a pinned tile, the host's binding on the primary one. A binding whose profile
|
||||
/// was deleted shows nothing and resolves as the defaults, which is what will happen on
|
||||
/// connect (design §6).
|
||||
fn status_row_with(
|
||||
online: Option<bool>,
|
||||
badge: &str,
|
||||
kind: Pill,
|
||||
profile: Option<(&str, Pill)>,
|
||||
) -> Element {
|
||||
let mut items: Vec<Element> = Vec::new();
|
||||
if let Some(online) = online {
|
||||
items.push(
|
||||
@@ -183,6 +203,13 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into(),
|
||||
);
|
||||
if let Some((name, kind)) = profile {
|
||||
items.push(
|
||||
pill(name, kind)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
hstack(items)
|
||||
.spacing(6.0)
|
||||
.margin(edges(0.0, 12.0, 0.0, 0.0))
|
||||
@@ -250,6 +277,43 @@ fn edit_editor(
|
||||
se.call(None);
|
||||
}
|
||||
};
|
||||
// The profile binding: what a plain click on this tile will use. It commits on change
|
||||
// rather than at Save — it is a picker with no draft ref, and the rest of the sheet's
|
||||
// fields are text boxes that genuinely need one.
|
||||
let profile_picker = {
|
||||
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
let stored = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.fp_hex == fp)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
let mut names = vec!["Default settings".to_string()];
|
||||
let mut ids: Vec<String> = vec![String::new()];
|
||||
for p in &catalog.profiles {
|
||||
names.push(p.name.clone());
|
||||
ids.push(p.id.clone());
|
||||
}
|
||||
// A binding whose profile is gone reads as Default settings — the same "dangling
|
||||
// resolves as none" rule the connect path follows — and is cleaned up on the next pick.
|
||||
let current = stored
|
||||
.as_ref()
|
||||
.and_then(|id| ids.iter().position(|i| i == id))
|
||||
.unwrap_or(0);
|
||||
let fp = fp.to_string();
|
||||
ComboBox::new(names)
|
||||
.header("Profile")
|
||||
.selected_index(current as i32)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let Some(id) = ids.get(i.max(0) as usize) else {
|
||||
return;
|
||||
};
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.profile_id = (!id.is_empty()).then(|| id.clone());
|
||||
let _ = known.save();
|
||||
}
|
||||
})
|
||||
};
|
||||
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
|
||||
vstack((
|
||||
text_block(label)
|
||||
@@ -281,6 +345,18 @@ fn edit_editor(
|
||||
"auto-filled when known",
|
||||
mac_draft,
|
||||
),
|
||||
vstack((
|
||||
profile_picker,
|
||||
text_block(
|
||||
"The settings a plain click on this host uses. \u{201c}Connect with\u{201d} \
|
||||
in the tile\u{2019}s menu overrides it for one session without changing it.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.wrap()
|
||||
.horizontal_alignment(HorizontalAlignment::Left),
|
||||
))
|
||||
.spacing(4.0),
|
||||
vstack((
|
||||
ToggleSwitch::new(clip0)
|
||||
.header("Share clipboard with this host")
|
||||
@@ -481,6 +557,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
if !known.hosts.is_empty() {
|
||||
body.push(section("SAVED HOSTS"));
|
||||
let mut tiles: Vec<Element> = Vec::new();
|
||||
// One catalog read per render, shared by every tile's menu and chip.
|
||||
let profiles: Vec<(String, String)> = pf_client_core::profiles::ProfilesFile::load()
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p.name))
|
||||
.collect();
|
||||
for k in &known.hosts {
|
||||
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||
@@ -504,6 +586,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
mac: k.mac.clone(),
|
||||
profile: None,
|
||||
};
|
||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
|
||||
// covers a routed/Tailscale host that never advertises — the display companion to
|
||||
@@ -525,6 +608,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let (svc, target) = (props.svc.clone(), target.clone());
|
||||
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
||||
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
|
||||
let menu_profiles = profiles.clone();
|
||||
let (link_host, link_profile) = (k.clone(), None::<String>);
|
||||
button("")
|
||||
.icon(Symbol::More)
|
||||
.subtle()
|
||||
@@ -532,6 +617,24 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.automation_name("More options")
|
||||
.menu_flyout({
|
||||
let mut items = vec![menu_item(MENU_CONNECT)];
|
||||
// One-off connects, flat: this flyout has no submenus, so each
|
||||
// profile is its own item. "Connect with" NEVER rebinds — the default
|
||||
// is changed in the host editor (design §5.2).
|
||||
for (_, name) in profiles.iter() {
|
||||
items.push(menu_item(format!("{MENU_WITH}{name}")));
|
||||
}
|
||||
if !profiles.is_empty() {
|
||||
items.push(menu_item(format!("{MENU_WITH}Default settings")));
|
||||
for (id, name) in profiles.iter() {
|
||||
let label = if k.pinned_profiles.iter().any(|p| p == id) {
|
||||
format!("{MENU_UNPIN}{name}")
|
||||
} else {
|
||||
format!("{MENU_PIN}{name}")
|
||||
};
|
||||
items.push(menu_item(label));
|
||||
}
|
||||
}
|
||||
items.push(menu_item(MENU_COPY_LINK));
|
||||
// The library surfaces — mouse/KB page and the gamepad console UI —
|
||||
// for paired hosts only (the mgmt API needs the paired identity);
|
||||
// the page additionally sits behind the experimental toggle, the
|
||||
@@ -550,6 +653,52 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
items
|
||||
})
|
||||
.on_item_clicked(move |item: String| match item.as_str() {
|
||||
// The profile items are dynamic, so they are matched by prefix before
|
||||
// the fixed ones.
|
||||
_ if item.starts_with(MENU_WITH) => {
|
||||
let name = item.trim_start_matches(MENU_WITH);
|
||||
let mut target = target.clone();
|
||||
// `Some("")` — not `None` — so "Default settings" really does
|
||||
// override a bound host for this one connect.
|
||||
target.profile = Some(
|
||||
menu_profiles
|
||||
.iter()
|
||||
.find(|(_, n)| n == name)
|
||||
.map(|(id, _)| id.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
initiate(&svc.ctx, target, &svc.set_screen, &svc.set_status)
|
||||
}
|
||||
_ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => {
|
||||
let pin = item.starts_with(MENU_PIN);
|
||||
let name = item
|
||||
.trim_start_matches(MENU_PIN)
|
||||
.trim_start_matches(MENU_UNPIN);
|
||||
let Some((id, _)) =
|
||||
menu_profiles.iter().find(|(_, n)| n == name).cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.pinned_profiles.retain(|p| p != &id);
|
||||
if pin {
|
||||
h.pinned_profiles.push(id);
|
||||
}
|
||||
let _ = known.save();
|
||||
}
|
||||
// The tile list re-reads the store on the next render; nudge it.
|
||||
sr.call(None);
|
||||
}
|
||||
MENU_COPY_LINK => {
|
||||
let url = pf_client_core::deeplink::DeepLink::for_host(
|
||||
&link_host,
|
||||
None,
|
||||
link_profile.as_deref(),
|
||||
)
|
||||
.to_url();
|
||||
pf_client_core::clipboard::set_text(&url);
|
||||
}
|
||||
MENU_CONNECT => {
|
||||
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||
}
|
||||
@@ -575,15 +724,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
})
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let pinned_base = target.clone();
|
||||
tiles.push(host_tile(
|
||||
&k.fp_hex,
|
||||
&hover,
|
||||
&k.name,
|
||||
&format!("{}:{}", k.addr, k.port),
|
||||
status_row(
|
||||
status_row_with(
|
||||
Some(online),
|
||||
if k.paired { "Paired" } else { "Trusted" },
|
||||
if k.paired { Pill::Good } else { Pill::Info },
|
||||
k.profile_id
|
||||
.as_ref()
|
||||
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
|
||||
.map(|(_, name)| (name.as_str(), Pill::Neutral)),
|
||||
),
|
||||
Some(menu),
|
||||
Some(Box::new(move || {
|
||||
@@ -598,6 +752,41 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
}
|
||||
})),
|
||||
));
|
||||
|
||||
// …then this host's pinned host+profile tiles, in the order they were pinned
|
||||
// (design §5.2a). They share the host's live status because they read the same
|
||||
// record, and a pin whose profile is gone simply doesn't render. No menu of their
|
||||
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
|
||||
// on the primary tile's menu — the one place you decide it.
|
||||
for id in &k.pinned_profiles {
|
||||
let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else {
|
||||
continue;
|
||||
};
|
||||
let (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let mut pinned_target = pinned_base.clone();
|
||||
pinned_target.profile = Some(id.clone());
|
||||
tiles.push(host_tile(
|
||||
// Its own hover key: two tiles for one host must not light up together.
|
||||
&format!("{}#{id}", k.fp_hex),
|
||||
&hover,
|
||||
&k.name,
|
||||
&format!("{}:{}", k.addr, k.port),
|
||||
status_row_with(
|
||||
Some(online),
|
||||
if k.paired { "Paired" } else { "Trusted" },
|
||||
if k.paired { Pill::Good } else { Pill::Info },
|
||||
Some((name.as_str(), Pill::Info)),
|
||||
),
|
||||
None,
|
||||
Some(Box::new(move || {
|
||||
if can_wake {
|
||||
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
|
||||
} else {
|
||||
initiate(&ctx3, pinned_target.clone(), &ss3, &st3);
|
||||
}
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
body.push(tile_grid(tiles, cols, TILE_GAP));
|
||||
}
|
||||
@@ -634,6 +823,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
pair_optional: h.pair == "optional",
|
||||
mac: h.mac.clone(),
|
||||
profile: None,
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let (badge, kind) = if h.pair == "required" {
|
||||
@@ -716,6 +906,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
},
|
||||
&ss,
|
||||
&st,
|
||||
|
||||
@@ -80,6 +80,11 @@ pub(crate) struct Target {
|
||||
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
|
||||
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||
pub(crate) mac: Vec<String>,
|
||||
/// A ONE-OFF settings profile for this connect ("Connect with"): `Some(id)` overrides the
|
||||
/// host's binding for this launch, `Some("")` forces the global defaults on a bound host,
|
||||
/// `None` honors the binding. It never rebinds anything — the default changes only through
|
||||
/// the picker in the host editor (design/client-settings-profiles.md §5.2).
|
||||
pub(crate) profile: Option<String>,
|
||||
}
|
||||
|
||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||
@@ -250,6 +255,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
// The profile a Delete… click is asking about; `Some` renders the confirmation. Root state
|
||||
// because this page stays hook-free (its handlers are wired in the reactor backend).
|
||||
let (settings_delete, set_settings_delete) = cx.use_async_state(Option::<String>::None);
|
||||
// Bumped when a settings edit changes what the page should SHOW without changing any state
|
||||
// it already reads — resetting an override, which rewrites the catalog behind the controls.
|
||||
// Root state comparison makes same-value calls free, so a counter is what forces the pass.
|
||||
let (settings_rev, set_settings_rev) = cx.use_async_state(0u64);
|
||||
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
||||
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
||||
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||
@@ -500,6 +509,8 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
&set_settings_scope,
|
||||
&settings_delete,
|
||||
&set_settings_delete,
|
||||
settings_rev,
|
||||
&set_settings_rev,
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
|
||||
@@ -204,6 +204,56 @@ fn commit(ctx: &Arc<AppCtx>, scope: &str, edit: impl FnOnce(&mut Settings)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
|
||||
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
|
||||
#[derive(Default)]
|
||||
struct OverrideFlags {
|
||||
resolution: bool,
|
||||
refresh_hz: bool,
|
||||
render_scale: bool,
|
||||
bitrate_kbps: bool,
|
||||
codec: bool,
|
||||
hdr_enabled: bool,
|
||||
compositor: bool,
|
||||
audio_channels: bool,
|
||||
mic_enabled: bool,
|
||||
touch_mode: bool,
|
||||
mouse_mode: bool,
|
||||
invert_scroll: bool,
|
||||
inhibit_shortcuts: bool,
|
||||
gamepad: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
}
|
||||
|
||||
impl OverrideFlags {
|
||||
fn of(profile: Option<&StreamProfile>) -> OverrideFlags {
|
||||
let Some(o) = profile.map(|p| &p.overrides) else {
|
||||
return OverrideFlags::default();
|
||||
};
|
||||
OverrideFlags {
|
||||
// One control drives the width/height/match-window tri-state, so any of the three
|
||||
// marks the row.
|
||||
resolution: o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
|
||||
refresh_hz: o.refresh_hz.is_some(),
|
||||
render_scale: o.render_scale.is_some(),
|
||||
bitrate_kbps: o.bitrate_kbps.is_some(),
|
||||
codec: o.codec.is_some(),
|
||||
hdr_enabled: o.hdr_enabled.is_some(),
|
||||
compositor: o.compositor.is_some(),
|
||||
audio_channels: o.audio_channels.is_some(),
|
||||
mic_enabled: o.mic_enabled.is_some(),
|
||||
touch_mode: o.touch_mode.is_some(),
|
||||
mouse_mode: o.mouse_mode.is_some(),
|
||||
invert_scroll: o.invert_scroll.is_some(),
|
||||
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
|
||||
gamepad: o.gamepad.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer the settings screen is editing, resolved for display: `None` = the defaults.
|
||||
fn active_profile(scope: &str) -> Option<StreamProfile> {
|
||||
(!scope.is_empty())
|
||||
@@ -261,6 +311,52 @@ fn setting_toggle(
|
||||
/// but a caption under it reads as a caption, which is how every Windows Settings page and
|
||||
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
|
||||
/// full-width caption runs into the control column and the whole cell reads as one block.
|
||||
/// [`described`], plus the override marker and reset a profile-scope row carries: the caption
|
||||
/// says the profile changes this one, and the button is the only way back to inheriting —
|
||||
/// overrides are recorded on touch and never inferred from a value comparison, so "not
|
||||
/// overridden" needs an explicit act.
|
||||
fn described_overridable(
|
||||
rev: (u64, &AsyncSetState<u64>),
|
||||
scope: &str,
|
||||
field: &'static str,
|
||||
overridden: bool,
|
||||
control: impl Into<Element>,
|
||||
caption: &str,
|
||||
) -> Element {
|
||||
if scope.is_empty() || !overridden {
|
||||
return described(control, caption);
|
||||
}
|
||||
let (rev, set_rev) = (rev.0, rev.1.clone());
|
||||
let scope = scope.to_string();
|
||||
vstack((
|
||||
control.into(),
|
||||
hstack((
|
||||
text_block(format!("\u{25cf} Overridden here \u{00b7} {caption}"))
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::AccentText)
|
||||
.wrap()
|
||||
.max_width(360.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Left),
|
||||
button("Reset").on_click(move || {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) {
|
||||
p.overrides.clear(field);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "clearing an override");
|
||||
}
|
||||
}
|
||||
// The catalog changed behind the controls, and nothing the page reads as
|
||||
// state did — bump the revision so the row re-renders showing the inherited
|
||||
// value again.
|
||||
set_rev.call(rev + 1);
|
||||
}),
|
||||
))
|
||||
.spacing(8.0),
|
||||
))
|
||||
.spacing(5.0)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn described(control: impl Into<Element>, caption: &str) -> Element {
|
||||
vstack((
|
||||
control.into(),
|
||||
@@ -329,6 +425,8 @@ pub(crate) fn settings_page(
|
||||
set_scope: &AsyncSetState<String>,
|
||||
delete_pending: &Option<String>,
|
||||
set_delete: &AsyncSetState<Option<String>>,
|
||||
rev: u64,
|
||||
set_rev: &AsyncSetState<u64>,
|
||||
progress: f64,
|
||||
) -> Element {
|
||||
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
|
||||
@@ -339,8 +437,12 @@ pub(crate) fn settings_page(
|
||||
None => "",
|
||||
};
|
||||
let profile_mode = active.is_some();
|
||||
// Every control shows the EFFECTIVE value: the global underneath with this profile's
|
||||
// overrides on top, so a row the profile doesn't override reads as the live global.
|
||||
// Which rows this profile overrides — the marker + reset each of them carries. In the
|
||||
// defaults scope nothing is marked, and `described_overridable` degrades to `described`.
|
||||
let over = OverrideFlags::of(active.as_ref());
|
||||
let _ = rev; // read via the closures below; the value itself only forces the re-render
|
||||
// Every control shows the EFFECTIVE value: the global underneath with this profile's
|
||||
// overrides on top, so a row the profile doesn't override reads as the live global.
|
||||
let s = {
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
match &active {
|
||||
@@ -602,14 +704,22 @@ pub(crate) fn settings_page(
|
||||
let mut out = group(
|
||||
Some("Resolution"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"resolution",
|
||||
over.resolution,
|
||||
res_combo,
|
||||
"The host drives a real virtual output at exactly this size \u{2014} true \
|
||||
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
|
||||
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
|
||||
(1:1) through every resize.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"refresh_hz",
|
||||
over.refresh_hz,
|
||||
hz_combo,
|
||||
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
||||
connect.",
|
||||
@@ -620,24 +730,40 @@ pub(crate) fn settings_page(
|
||||
out.extend(group(
|
||||
Some("Quality"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"render_scale",
|
||||
over.render_scale,
|
||||
scale_combo,
|
||||
"Above native supersamples for sharpness; below renders lighter on the \
|
||||
host and the link. This device resamples the result to the window.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"bitrate_kbps",
|
||||
over.bitrate_kbps,
|
||||
bitrate_box,
|
||||
"0 lets the host decide (its default, clamped to what it supports). A \
|
||||
host card\u{2019}s context menu has a network speed test.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"codec",
|
||||
over.codec,
|
||||
codec_combo,
|
||||
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
|
||||
PyroWave is the low-latency wavelet codec for a WIRED link: it trades \
|
||||
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
|
||||
gigabit Ethernet.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"hdr_enabled",
|
||||
over.hdr_enabled,
|
||||
hdr_toggle,
|
||||
"HDR10, when the host has HDR content and this display supports it. \
|
||||
HEVC only; otherwise the stream stays SDR.",
|
||||
@@ -670,7 +796,11 @@ pub(crate) fn settings_page(
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Host output"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"compositor",
|
||||
over.compositor,
|
||||
comp_combo,
|
||||
"The backend the host uses for its virtual output (Linux hosts only). A \
|
||||
specific choice falls back to auto-detection when that backend \
|
||||
@@ -684,7 +814,11 @@ pub(crate) fn settings_page(
|
||||
"input" => {
|
||||
let mut out = group(
|
||||
Some("Touch & pointer"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"touch_mode",
|
||||
over.touch_mode,
|
||||
touch_combo,
|
||||
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
|
||||
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
|
||||
@@ -695,19 +829,31 @@ pub(crate) fn settings_page(
|
||||
out.extend(group(
|
||||
Some("Keyboard & mouse"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mouse_mode",
|
||||
over.mouse_mode,
|
||||
mouse_combo,
|
||||
"Capture locks the pointer to the stream and sends relative motion — \
|
||||
best for games. Desktop leaves the pointer free to enter and leave \
|
||||
the stream and sends absolute positions — best for remote desktop \
|
||||
work. Ctrl+Alt+Shift+M switches live.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"inhibit_shortcuts",
|
||||
over.inhibit_shortcuts,
|
||||
shortcuts_toggle,
|
||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
||||
has input captured. Off, they act on this machine instead.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"invert_scroll",
|
||||
over.invert_scroll,
|
||||
invert_scroll_toggle,
|
||||
"Reverses the wheel and trackpad scroll direction sent to the host.",
|
||||
),
|
||||
@@ -732,7 +878,11 @@ pub(crate) fn settings_page(
|
||||
one to force single-player \u{2014} only it reaches the host.",
|
||||
)
|
||||
}),
|
||||
Some(described(
|
||||
Some(described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"gamepad",
|
||||
over.gamepad,
|
||||
pad_combo,
|
||||
"The virtual pad created on the host. Automatic matches your controller \
|
||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
||||
@@ -750,12 +900,20 @@ pub(crate) fn settings_page(
|
||||
group(
|
||||
None,
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"audio_channels",
|
||||
over.audio_channels,
|
||||
channels_combo,
|
||||
"The speaker layout requested from the host. It downmixes if its own \
|
||||
output has fewer channels.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mic_enabled",
|
||||
over.mic_enabled,
|
||||
mic_toggle,
|
||||
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
|
||||
),
|
||||
@@ -775,7 +933,11 @@ pub(crate) fn settings_page(
|
||||
_ => {
|
||||
let mut out = group(
|
||||
Some("Session"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"fullscreen_on_stream",
|
||||
over.fullscreen_on_stream,
|
||||
fullscreen_toggle,
|
||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
||||
live.",
|
||||
@@ -796,7 +958,11 @@ pub(crate) fn settings_page(
|
||||
);
|
||||
out.extend(group(
|
||||
Some("Statistics"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"stats_verbosity",
|
||||
over.stats_verbosity,
|
||||
hud_combo,
|
||||
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
|
||||
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
use super::style::*;
|
||||
use super::{Screen, Svc};
|
||||
use crate::probe::run_speed_probe;
|
||||
use crate::trust::KnownHosts;
|
||||
use pf_client_core::profiles::ProfilesFile;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via
|
||||
@@ -122,17 +124,54 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
|
||||
recommended_kbps,
|
||||
} => {
|
||||
let recommended_mbps = f64::from(*recommended_kbps) / 1000.0;
|
||||
// A measured bitrate belongs in the layer the TESTED host actually reads it from
|
||||
// (design/client-settings-profiles.md §5.3) — writing the global here is what made
|
||||
// measuring one host re-tune every other one. Resolved the way a connect resolves
|
||||
// it: the one-off this test was started with, else the host's binding.
|
||||
let target = ctx.shared.target.lock().unwrap().clone();
|
||||
let bound = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == target.addr && h.port == target.port)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
let profile = match target.profile.as_deref() {
|
||||
Some("") => None,
|
||||
Some(id) => Some(id.to_string()),
|
||||
None => bound,
|
||||
}
|
||||
.and_then(|reference| ProfilesFile::load().resolve(&reference).0.cloned());
|
||||
let apply_btn = {
|
||||
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
|
||||
button(format!("Use {recommended_mbps:.0} Mb/s"))
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(move || {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.bitrate_kbps = kbps;
|
||||
s.save();
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
let profile = profile.clone();
|
||||
button(match &profile {
|
||||
Some(p) => format!(
|
||||
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
|
||||
p.name
|
||||
),
|
||||
None => format!("Use {recommended_mbps:.0} Mb/s"),
|
||||
})
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(move || {
|
||||
match &profile {
|
||||
Some(p) => {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == p.id) {
|
||||
slot.overrides.bitrate_kbps = Some(kbps);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"saving the measured bitrate");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.bitrate_kbps = kbps;
|
||||
s.save();
|
||||
}
|
||||
}
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
};
|
||||
let results = card(
|
||||
vstack((
|
||||
|
||||
@@ -101,6 +101,7 @@ pub(crate) fn spawn_session(
|
||||
connect_timeout_secs: u64,
|
||||
fullscreen: bool,
|
||||
launch: Option<&str>,
|
||||
profile: Option<&str>,
|
||||
slot: SessionChild,
|
||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
@@ -117,6 +118,11 @@ pub(crate) fn spawn_session(
|
||||
if let Some(id) = launch {
|
||||
cmd.arg("--launch").arg(id);
|
||||
}
|
||||
// Only a ONE-OFF pick rides the flag: without it the session resolves the host's own
|
||||
// binding through the same helper this shell would have used, so the two can't disagree.
|
||||
if let Some(reference) = profile {
|
||||
cmd.arg("--profile").arg(reference);
|
||||
}
|
||||
add_window_pos(&mut cmd);
|
||||
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||
}
|
||||
|
||||
@@ -387,11 +387,20 @@ pub(crate) struct HdrP010Converter {
|
||||
cbuf: ID3D11Buffer,
|
||||
}
|
||||
|
||||
// The three converters' methods below are SAFE fns. They were `unsafe fn` because their bodies are
|
||||
// D3D11 FFI, which is not the same thing as having a caller contract: every parameter is a borrowed
|
||||
// windows-rs COM wrapper (`&ID3D11Device`, `&ID3D11DeviceContext`, `&ID3D11Texture2D`, the views) or
|
||||
// a plain `u32`/`bool`, each body builds its own descriptors from those, and every created interface
|
||||
// owns its reference. There is nothing a caller can pass that makes them unsound, and the proofs the
|
||||
// markers carried said exactly that — "`?`-checked D3D11 methods on the live `device` borrow" — which
|
||||
// is a description of the body, not an obligation. `unsafe` now marks the FFI inside them, where the
|
||||
// blocks and their proofs already were. `compile_shader` KEEPS its marker: it takes `PCSTR`, a raw
|
||||
// pointer the caller must guarantee is a NUL-terminated literal.
|
||||
impl HdrP010Converter {
|
||||
/// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the
|
||||
/// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's
|
||||
/// `recreate_ring` already drops it).
|
||||
pub(crate) unsafe fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
|
||||
pub(crate) fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
|
||||
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader` receives
|
||||
// `s!()` literals (its contract). Each created COM interface owns its own reference, and no
|
||||
@@ -458,7 +467,7 @@ impl HdrP010Converter {
|
||||
/// Called ONCE PER OUT-RING SLOT by the owner of the P010 textures, not per frame — see
|
||||
/// [`Self::convert`]. Fails when the driver rejects a planar RTV, which is the one hard
|
||||
/// requirement of this whole path (a D3D11.3+ runtime plus driver support).
|
||||
pub(crate) unsafe fn plane_rtv(
|
||||
pub(crate) fn plane_rtv(
|
||||
device: &ID3D11Device,
|
||||
dst: &ID3D11Texture2D,
|
||||
format: DXGI_FORMAT,
|
||||
@@ -498,7 +507,7 @@ impl HdrP010Converter {
|
||||
/// ring slot's keyed-mutex hold, i.e. time the DRIVER spent blocked on that slot. Both are
|
||||
/// lifetime-of-mode facts: the views belong to the out-ring slot (built in `ensure_out_ring`),
|
||||
/// the buffer to this converter, which is already rebuilt on every mode change.
|
||||
pub(crate) unsafe fn convert(
|
||||
pub(crate) fn convert(
|
||||
&self,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
src_srv: &ID3D11ShaderResourceView,
|
||||
@@ -693,7 +702,7 @@ pub(crate) struct BgraToYuvPlanes {
|
||||
}
|
||||
|
||||
impl BgraToYuvPlanes {
|
||||
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
|
||||
pub(crate) fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
|
||||
// SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live
|
||||
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
|
||||
unsafe {
|
||||
@@ -731,7 +740,7 @@ impl BgraToYuvPlanes {
|
||||
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
|
||||
/// passes; `w`/`h` are the full luma dims (even for 4:2:0).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) unsafe fn convert(
|
||||
pub(crate) fn convert(
|
||||
&self,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
src_srv: &ID3D11ShaderResourceView,
|
||||
@@ -828,7 +837,7 @@ impl VideoConverter {
|
||||
/// linear (the HDR ring, used by a PyroWave session that tone-maps the HDR desktop down to the
|
||||
/// 8-bit wavelet stream). The output is always studio-range BT.709 NV12 — the P010/BT.2020 HDR
|
||||
/// path is [`HdrP010Converter`]'s job, never this one.
|
||||
pub(crate) unsafe fn new(
|
||||
pub(crate) fn new(
|
||||
device: &ID3D11Device,
|
||||
context: &ID3D11DeviceContext,
|
||||
width: u32,
|
||||
@@ -897,11 +906,7 @@ impl VideoConverter {
|
||||
/// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output`
|
||||
/// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are
|
||||
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
|
||||
pub(crate) unsafe fn convert(
|
||||
&self,
|
||||
input: &ID3D11Texture2D,
|
||||
output: &ID3D11Texture2D,
|
||||
) -> Result<()> {
|
||||
pub(crate) fn convert(&self, input: &ID3D11Texture2D, output: &ID3D11Texture2D) -> Result<()> {
|
||||
// SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized
|
||||
// stack descriptors and live out-params. `stream.pInputSurface` is a `ManuallyDrop` of the
|
||||
// input view just created: `VideoProcessorBlt` only BORROWS it (a COM in-param never transfers
|
||||
|
||||
@@ -648,11 +648,7 @@ impl IddPushCapturer {
|
||||
// nothing downstream could detect the mismatch, because every field it would compare against
|
||||
// had already been moved. Nothing below this line fails.
|
||||
let fmt = Self::ring_format_for(new_display_hdr);
|
||||
// SAFETY: `create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls); we pass a live
|
||||
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
|
||||
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
|
||||
// returned slot's texture + keyed mutex belongs to that same `self.device`.
|
||||
let new_slots = unsafe { Self::create_ring_slots(&self.device, new_w, new_h, fmt)? };
|
||||
let new_slots = Self::create_ring_slots(&self.device, new_w, new_h, fmt)?;
|
||||
self.display_hdr = new_display_hdr;
|
||||
self.width = new_w;
|
||||
self.height = new_h;
|
||||
@@ -969,11 +965,11 @@ impl IddPushCapturer {
|
||||
/// mid-session mode swap.
|
||||
fn ensure_pyro_conv(&mut self) -> Result<()> {
|
||||
if self.pyro_conv.is_none() {
|
||||
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
|
||||
// failure before it is stored.
|
||||
self.pyro_conv = Some(unsafe {
|
||||
BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)?
|
||||
});
|
||||
self.pyro_conv = Some(BgraToYuvPlanes::new(
|
||||
&self.device,
|
||||
self.display_hdr,
|
||||
self.want_444,
|
||||
)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -984,22 +980,22 @@ impl IddPushCapturer {
|
||||
fn ensure_converter(&mut self) -> Result<()> {
|
||||
if self.display_hdr {
|
||||
if self.hdr_p010_conv.is_none() {
|
||||
// SAFETY: `HdrP010Converter::new` is `unsafe` (it compiles D3D11 shaders + creates
|
||||
// resources); we pass a live borrow of `self.device`, the device the converter's resources
|
||||
// belong to, and `?` propagates any failure before the converter is stored.
|
||||
self.hdr_p010_conv =
|
||||
Some(unsafe { HdrP010Converter::new(&self.device, self.width, self.height)? });
|
||||
self.hdr_p010_conv = Some(HdrP010Converter::new(
|
||||
&self.device,
|
||||
self.width,
|
||||
self.height,
|
||||
)?);
|
||||
}
|
||||
} else if self.want_444 {
|
||||
// Full-chroma passthrough — no conversion resources to build.
|
||||
} else if self.video_conv.is_none() {
|
||||
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
|
||||
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
|
||||
// plain `u32` dimensions, and `?` propagates any failure before it is stored. The converter's
|
||||
// resources belong to that same device/context.
|
||||
self.video_conv = Some(unsafe {
|
||||
VideoConverter::new(&self.device, &self.context, self.width, self.height, false)?
|
||||
});
|
||||
self.video_conv = Some(VideoConverter::new(
|
||||
&self.device,
|
||||
&self.context,
|
||||
self.width,
|
||||
self.height,
|
||||
false,
|
||||
)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ pub(super) struct CursorBlendPass {
|
||||
}
|
||||
|
||||
impl CursorBlendPass {
|
||||
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
pub(super) fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
|
||||
// literals (its contract).
|
||||
@@ -116,11 +116,7 @@ impl CursorBlendPass {
|
||||
}
|
||||
|
||||
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
||||
unsafe fn ensure_shape(
|
||||
&mut self,
|
||||
device: &ID3D11Device,
|
||||
ov: &pf_frame::CursorOverlay,
|
||||
) -> Result<()> {
|
||||
fn ensure_shape(&mut self, device: &ID3D11Device, ov: &pf_frame::CursorOverlay) -> Result<()> {
|
||||
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
|
||||
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
|
||||
// holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
|
||||
@@ -170,7 +166,7 @@ impl CursorBlendPass {
|
||||
/// composition) — linearize and scale to the target's SDR white. The quad is placed purely
|
||||
/// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
|
||||
/// the target automatically.
|
||||
pub(super) unsafe fn blend(
|
||||
pub(super) fn blend(
|
||||
&mut self,
|
||||
device: &ID3D11Device,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
|
||||
@@ -85,7 +85,7 @@ impl IddPushCapturer {
|
||||
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
|
||||
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
|
||||
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
|
||||
pub(super) unsafe fn create_ring_slots(
|
||||
pub(super) fn create_ring_slots(
|
||||
device: &ID3D11Device,
|
||||
w: u32,
|
||||
h: u32,
|
||||
|
||||
@@ -137,9 +137,10 @@ impl Capturer for SyntheticNv12Capturer {
|
||||
/// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference /
|
||||
/// max-VRAM LUID), falling back to adapter 0.
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
||||
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
/// Safe: it takes no arguments, so there is nothing a caller could get wrong — it creates the
|
||||
/// factory itself and returns an owned adapter. The `# Safety` section it used to carry ("calls
|
||||
/// DXGI enumeration; returns owned COM objects") described the body, which is not a contract.
|
||||
fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
|
||||
// created here and the adapters it returns own their own COM references. No raw pointers.
|
||||
unsafe {
|
||||
@@ -155,9 +156,10 @@ unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
|
||||
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
||||
///
|
||||
/// # Safety
|
||||
/// `device` must be a live D3D11 device; the returned texture is owned by the caller.
|
||||
unsafe fn create_nv12(
|
||||
/// Safe: its old `# Safety` asked for "a live D3D11 device", which `&ID3D11Device` — a borrowed,
|
||||
/// reference-counted COM wrapper — already guarantees; the rest ("the returned texture is owned by
|
||||
/// the caller") is an ownership note, not a soundness obligation. Every flag is a plain scalar.
|
||||
fn create_nv12(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -181,8 +183,8 @@ unsafe fn create_nv12(
|
||||
..Default::default()
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
|
||||
// above), with a fully-initialized stack descriptor and a live `Option` out-param.
|
||||
// SAFETY: one `?`-checked `CreateTexture2D` on the `&ID3D11Device` borrow, which the borrow
|
||||
// itself keeps live, with a fully-initialized stack descriptor and a live `Option` out-param.
|
||||
unsafe {
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
|
||||
@@ -260,6 +260,16 @@ fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put plain text on this machine's clipboard — the "Copy link" affordance, which has nothing
|
||||
/// to do with the streaming bridge above but wants the same OS plumbing. Best-effort: a
|
||||
/// clipboard another process is holding open is a transient nuisance, never worth failing a
|
||||
/// user action over. A no-op where this build has no OS clipboard.
|
||||
pub fn set_text(text: &str) {
|
||||
if let Err(e) = os::set(MIME_TEXT, text.as_bytes()) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "copying to the clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod os {
|
||||
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
|
||||
|
||||
@@ -610,7 +610,7 @@ impl D3d11vaDecoder {
|
||||
/// BGRA8) under its keyed mutex and describe the hand-off. The mutex acquire also
|
||||
/// back-pressures against the presenter still reading this slot (only possible if the
|
||||
/// stream runs `RING_SLOTS` ahead of present).
|
||||
unsafe fn lift(&mut self) -> Result<D3d11Frame> {
|
||||
fn lift(&mut self) -> Result<D3d11Frame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
||||
|
||||
@@ -139,7 +139,7 @@ impl VaapiDecoder {
|
||||
/// single-plane texture with the chroma dropped, painting the screen green. The fix:
|
||||
/// derive the COMBINED fourcc from the decoder's software pixel format (NV12 →
|
||||
/// `DRM_FORMAT_NV12`) and flatten every plane across every layer in order (Y then UV).
|
||||
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
|
||||
fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
//! FFmpeg Vulkan Video decode over the presenter's own VkDevice (zero-copy VkImage).
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav + ash calls on the presenter's VkDevice almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
#![allow(clippy::unnecessary_cast)]
|
||||
|
||||
use crate::video::{
|
||||
@@ -67,25 +61,45 @@ struct VkCtxStorage {
|
||||
/// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default,
|
||||
/// which only serializes FFmpeg against itself — the presenter submits to the same
|
||||
/// graphics queue from another thread and holds this same lock around its calls.
|
||||
///
|
||||
/// # Safety
|
||||
/// FFmpeg calls this with the `AVHWDeviceContext` it owns, whose `user_opaque` we set to a
|
||||
/// `*const QueueLock` before handing the context over.
|
||||
unsafe extern "C" fn ffvk_lock_queue(
|
||||
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
||||
_queue_family: u32,
|
||||
_index: u32,
|
||||
) {
|
||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||
let lock = (*dev).user_opaque as *const QueueLock;
|
||||
(*lock).lock();
|
||||
// SAFETY: `ctx` is the live context FFmpeg passes to its own callback, and the two
|
||||
// `AVHWDeviceContext` declarations (pf_ffvk's and ffmpeg-sys's) describe the same C struct, so
|
||||
// the cast reads the same `user_opaque` field. That field holds the pointer we stored, which
|
||||
// borrows `VkCtxStorage::_queue_lock` — an `Arc<QueueLock>` the storage keeps alive for as long
|
||||
// as the hw device context can fire this trampoline (see its field doc), so the lock outlives
|
||||
// every call FFmpeg can make.
|
||||
unsafe {
|
||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||
let lock = (*dev).user_opaque as *const QueueLock;
|
||||
(*lock).lock();
|
||||
}
|
||||
}
|
||||
|
||||
/// The matching `unlock_queue` trampoline — see [`ffvk_lock_queue`].
|
||||
///
|
||||
/// # Safety
|
||||
/// As [`ffvk_lock_queue`]; additionally, FFmpeg only calls this after a matching `lock_queue`, so
|
||||
/// the lock it releases is one this pair took.
|
||||
unsafe extern "C" fn ffvk_unlock_queue(
|
||||
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
||||
_queue_family: u32,
|
||||
_index: u32,
|
||||
) {
|
||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||
let lock = (*dev).user_opaque as *const QueueLock;
|
||||
(*lock).unlock();
|
||||
// SAFETY: as `ffvk_lock_queue` — same live context from FFmpeg, same `user_opaque` pointer into
|
||||
// the `Arc<QueueLock>` that `VkCtxStorage` keeps alive for the context's whole lifetime.
|
||||
unsafe {
|
||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||
let lock = (*dev).user_opaque as *const QueueLock;
|
||||
(*lock).unlock();
|
||||
}
|
||||
}
|
||||
|
||||
impl VulkanDecoder {
|
||||
@@ -306,7 +320,7 @@ impl VulkanDecoder {
|
||||
/// guard — keeps the image + frames context alive through present) and ship the
|
||||
/// POINTERS; the presenter reads the live sync state under the frames-context lock
|
||||
/// at its own submit time.
|
||||
unsafe fn extract(&mut self) -> Result<VkVideoFrame> {
|
||||
fn extract(&mut self) -> Result<VkVideoFrame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 {
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) hwcontext calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -84,30 +78,49 @@ impl CudaHw {
|
||||
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
|
||||
// Each `?`/`bail!` below drops whatever has been built so far — `AvBuffer`'s `Drop` is the
|
||||
// single unref path, so the failure branches carry no cleanup of their own.
|
||||
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
))
|
||||
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
|
||||
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
|
||||
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
|
||||
(*cu).cuda_ctx = cu_ctx; // share the importer's context
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwdevice_ctx_init failed ({r})");
|
||||
}
|
||||
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
||||
(*fc).sw_format = pixel_to_av(sw_format);
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = 0; // we supply the device pointers
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwframe_ctx_init failed ({r})");
|
||||
}
|
||||
// SAFETY: `av_hwdevice_ctx_alloc` returns either null — which `AvBuffer::from_raw` rejects,
|
||||
// so the `?` returns before anything below runs — or a fresh ref whose `data` libav has
|
||||
// already initialized as an `AVHWDeviceContext`. For a CUDA device that context's `hwctx`
|
||||
// is an `AVCUDADeviceContext` (our repr(C) mirror of libav's layout), so writing
|
||||
// `cuda_ctx` is an in-bounds field store on a live allocation, and `cu_ctx` is a valid
|
||||
// `CUcontext` by this fn's contract. `av_hwdevice_ctx_init` then takes the same live ref;
|
||||
// it must see `cuda_ctx` already set, which is why the store precedes it.
|
||||
let device_ref = unsafe {
|
||||
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
))
|
||||
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
|
||||
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
|
||||
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
|
||||
(*cu).cuda_ctx = cu_ctx; // share the importer's context
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwdevice_ctx_init failed ({r})");
|
||||
}
|
||||
device_ref
|
||||
};
|
||||
|
||||
// SAFETY: the same shape one level up — `av_hwframe_ctx_alloc` is handed the live,
|
||||
// now-initialized device ref and returns null (rejected by `from_raw`, so the `?` leaves
|
||||
// before the writes) or a ref whose `data` is a live `AVHWFramesContext`. Every store below
|
||||
// is an in-bounds field write on that allocation, all plain scalars, done before
|
||||
// `av_hwframe_ctx_init` reads them.
|
||||
let frames_ref = unsafe {
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
||||
(*fc).sw_format = pixel_to_av(sw_format);
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = 0; // we supply the device pointers
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwframe_ctx_init failed ({r})");
|
||||
}
|
||||
frames_ref
|
||||
};
|
||||
Ok(CudaHw {
|
||||
frames_ref,
|
||||
device_ref,
|
||||
|
||||
@@ -19,13 +19,6 @@
|
||||
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
||||
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
|
||||
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed AVFrame/AVBuffer
|
||||
// pointers almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per
|
||||
// call that could only restate the signature. Clearing this file means DELETING the markers that
|
||||
// carry no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
|
||||
// everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -164,7 +157,7 @@ struct Vui {
|
||||
}
|
||||
|
||||
/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the
|
||||
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy
|
||||
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020nc on the zero-copy
|
||||
/// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709
|
||||
/// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to
|
||||
/// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input
|
||||
@@ -211,7 +204,7 @@ fn async_depth(raw: Option<&str>) -> u32 {
|
||||
/// matrix untouched.)
|
||||
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
|
||||
if ten_bit {
|
||||
c"format=p010:out_color_matrix=bt2020:out_range=limited"
|
||||
c"format=p010:out_color_matrix=bt2020nc:out_range=limited"
|
||||
} else {
|
||||
c"format=nv12:out_color_matrix=bt709:out_range=limited"
|
||||
}
|
||||
@@ -275,17 +268,23 @@ unsafe fn open_vaapi_encoder(
|
||||
let modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
|
||||
let mut first_err = None;
|
||||
for &lp in modes {
|
||||
match open_vaapi_encoder_mode(
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
device_ref,
|
||||
frames_ref,
|
||||
ten_bit,
|
||||
lp,
|
||||
) {
|
||||
// SAFETY: `device_ref`/`frames_ref` are forwarded unchanged from this fn's own contract —
|
||||
// valid `AVBufferRef`s — and the callee only `av_buffer_ref`s them, so retrying another
|
||||
// entrypoint below reuses the same still-owned buffers rather than consuming them.
|
||||
let attempt = unsafe {
|
||||
open_vaapi_encoder_mode(
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
device_ref,
|
||||
frames_ref,
|
||||
ten_bit,
|
||||
lp,
|
||||
)
|
||||
};
|
||||
match attempt {
|
||||
Ok(enc) => {
|
||||
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
||||
m.insert(key.clone(), latched_mode(lp));
|
||||
@@ -343,16 +342,24 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
video.set_format(if ten_bit { Pixel::P010LE } else { Pixel::NV12 });
|
||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
let vui = vui_for(ten_bit);
|
||||
(*raw).colorspace = vui.colorspace;
|
||||
(*raw).color_range = vui.range;
|
||||
(*raw).color_primaries = vui.primaries;
|
||||
(*raw).color_trc = vui.trc;
|
||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
// SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated
|
||||
// just above, which outlives every write here (it is moved into the return value). The
|
||||
// colour/gop/pix_fmt stores are in-bounds scalar field writes on that live context. The two
|
||||
// `av_buffer_ref` calls take `device_ref`/`frames_ref`, valid `AVBufferRef`s by this fn's
|
||||
// contract, and each returns a NEW reference that the codec context adopts and unrefs when it
|
||||
// is freed — so this shares the caller's buffers rather than taking them over.
|
||||
unsafe {
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
let vui = vui_for(ten_bit);
|
||||
(*raw).colorspace = vui.colorspace;
|
||||
(*raw).color_range = vui.range;
|
||||
(*raw).color_primaries = vui.primaries;
|
||||
(*raw).color_trc = vui.trc;
|
||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
}
|
||||
|
||||
let mut opts = Dictionary::new();
|
||||
if let Some(profile) = explicit_profile(codec, ten_bit) {
|
||||
@@ -479,35 +486,52 @@ struct VaapiHw {
|
||||
}
|
||||
|
||||
impl VaapiHw {
|
||||
unsafe fn new(sw_format: ffi::AVPixelFormat, w: u32, h: u32, pool: c_int) -> Result<Self> {
|
||||
/// Safe: unlike its CUDA twin ([`super::CudaHw::new`], which is handed a `CUcontext` the caller
|
||||
/// must vouch for) this takes only scalars and opens the device itself, so there is no caller
|
||||
/// contract — the `unsafe` below is the libav FFI, not an obligation.
|
||||
fn new(sw_format: ffi::AVPixelFormat, w: u32, h: u32, pool: c_int) -> Result<Self> {
|
||||
let mut device_ref: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||
let node = render_node();
|
||||
let r = ffi::av_hwdevice_ctx_create(
|
||||
&mut device_ref,
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
node.as_ptr(),
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
);
|
||||
// SAFETY: `device_ref` is a live local out-param; `node` is a NUL-terminated `CString` that
|
||||
// outlives the call, and the remaining arguments are the documented "no options" pair. On
|
||||
// success libav writes ONE owned reference into `device_ref`.
|
||||
let r = unsafe {
|
||||
ffi::av_hwdevice_ctx_create(
|
||||
&mut device_ref,
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
node.as_ptr(),
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if r < 0 {
|
||||
bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r));
|
||||
}
|
||||
// `av_hwdevice_ctx_create` wrote an owned ref into `device_ref`; take ownership of it here so
|
||||
// every `bail!` below drops it (and the frames ref, once built) without cleanup of its own.
|
||||
let device_ref = AvBuffer::from_raw(device_ref)
|
||||
// SAFETY: `r >= 0`, so `device_ref` is that owned reference and this is its only owner.
|
||||
let device_ref = unsafe { AvBuffer::from_raw(device_ref) }
|
||||
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc(VAAPI) failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
(*fc).sw_format = sw_format;
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = pool;
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
|
||||
}
|
||||
// SAFETY: the same shape as `CudaHw::new` — `av_hwframe_ctx_alloc` is handed the live device
|
||||
// ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes below)
|
||||
// or a ref whose `data` libav has already initialized as an `AVHWFramesContext`. Every store
|
||||
// is then an in-bounds scalar write on that live allocation, done before
|
||||
// `av_hwframe_ctx_init` reads them.
|
||||
let frames_ref = unsafe {
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc(VAAPI) failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
(*fc).sw_format = sw_format;
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = pool;
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
|
||||
}
|
||||
frames_ref
|
||||
};
|
||||
Ok(VaapiHw {
|
||||
frames_ref,
|
||||
device_ref,
|
||||
@@ -548,12 +572,11 @@ impl CpuInner {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
||||
};
|
||||
const POOL: c_int = 16;
|
||||
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
|
||||
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
|
||||
// `ffmpeg::init()`. The args are valid: an NV12/P010 sw_format, the validated positive
|
||||
// `width`/`height`, pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
|
||||
// on drop.
|
||||
let hw = unsafe { VaapiHw::new(staging_av, width, height, POOL)? };
|
||||
// `VaapiHw::new` is safe now; it returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
|
||||
// on drop. (libav is initialized on every path here — `VaapiEncoder::open` → `ensure_inner`
|
||||
// → `CpuInner::open`, and `open` ran `ffmpeg::init()` — which the call relies on but cannot
|
||||
// itself be broken by a caller, so it is a note rather than a contract.)
|
||||
let hw = VaapiHw::new(staging_av, width, height, POOL)?;
|
||||
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
|
||||
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
|
||||
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
|
||||
@@ -1542,7 +1565,7 @@ mod tests {
|
||||
let args = scale_vaapi_args(true).to_str().unwrap();
|
||||
for needle in [
|
||||
"format=p010",
|
||||
"out_color_matrix=bt2020",
|
||||
"out_color_matrix=bt2020nc",
|
||||
"out_range=limited",
|
||||
] {
|
||||
assert!(
|
||||
|
||||
@@ -37,13 +37,6 @@
|
||||
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
||||
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
||||
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed hwcontext pointers
|
||||
// almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call
|
||||
// that could only restate the signature. Clearing this file means DELETING the markers that carry
|
||||
// no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
|
||||
// everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -337,29 +330,40 @@ unsafe fn open_win_encoder(
|
||||
video.set_format(Pixel::from(sw_pix_fmt));
|
||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
if ten_bit {
|
||||
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
|
||||
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||
} else {
|
||||
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
|
||||
// VUI — else the client decoder washes the picture out.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
}
|
||||
(*raw).pix_fmt = pix_fmt;
|
||||
if !device_ref.is_null() {
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||
}
|
||||
if !frames_ref.is_null() {
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
// SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated just
|
||||
// above, which outlives every write here (it is opened and returned below). The gop/colour/
|
||||
// pix_fmt stores are in-bounds scalar field writes on that live context. `device_ref` and
|
||||
// `frames_ref` are valid `AVBufferRef`s by this fn's contract OR null — the system path passes
|
||||
// null for both — and the `is_null` guards keep `av_buffer_ref` off the null case; each call
|
||||
// returns a NEW reference that the codec context adopts and unrefs when freed, so this shares
|
||||
// the caller's buffers rather than taking them over.
|
||||
let raw = unsafe { video.as_mut_ptr() };
|
||||
// SAFETY: as above — `raw` is that live `AVCodecContext` and every store below is an in-bounds
|
||||
// field write on it, with the two `av_buffer_ref` calls guarded against null.
|
||||
unsafe {
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
if ten_bit {
|
||||
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
|
||||
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||
} else {
|
||||
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
|
||||
// VUI — else the client decoder washes the picture out.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
}
|
||||
(*raw).pix_fmt = pix_fmt;
|
||||
if !device_ref.is_null() {
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
||||
}
|
||||
if !frames_ref.is_null() {
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
||||
}
|
||||
}
|
||||
|
||||
// Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
|
||||
@@ -434,12 +438,19 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
||||
}
|
||||
|
||||
/// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`).
|
||||
unsafe fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
|
||||
///
|
||||
/// Safe: `&ID3D11Device` is a borrowed, reference-counted COM wrapper, so the borrow itself is the
|
||||
/// "live device" guarantee, and the returned context owns its own reference.
|
||||
fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
|
||||
// windows-rs 0.62: the inherent method takes no args and returns the context (the OutRef form is
|
||||
// only on the `_Impl` trait, for implementing the interface). Every D3D11 device has one.
|
||||
device
|
||||
.GetImmediateContext()
|
||||
.expect("ID3D11Device always has an immediate context")
|
||||
// SAFETY: a `?`-free COM call on the live `device` borrow; it takes no pointers and every
|
||||
// D3D11 device has an immediate context, so the `expect` is unreachable in practice.
|
||||
unsafe {
|
||||
device
|
||||
.GetImmediateContext()
|
||||
.expect("ID3D11Device always has an immediate context")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -538,11 +549,10 @@ impl SystemInner {
|
||||
}
|
||||
|
||||
/// Lazily (re)build the staging texture matching `dxgi_fmt` on the captured device.
|
||||
unsafe fn ensure_staging(
|
||||
&mut self,
|
||||
device: &ID3D11Device,
|
||||
dxgi_fmt: DXGI_FORMAT,
|
||||
) -> Result<()> {
|
||||
///
|
||||
/// Safe: `&ID3D11Device` is the live-device guarantee and `dxgi_fmt` is a plain enum; the
|
||||
/// texture it creates is owned by `self`.
|
||||
fn ensure_staging(&mut self, device: &ID3D11Device, dxgi_fmt: DXGI_FORMAT) -> Result<()> {
|
||||
if self.staging.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -562,25 +572,38 @@ impl SystemInner {
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut t: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D(staging readback)")?;
|
||||
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow, over a
|
||||
// fully-initialized stack descriptor and a live `Option` out-param.
|
||||
unsafe {
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D(staging readback)")?;
|
||||
}
|
||||
self.staging = t;
|
||||
self.ctx = Some(immediate_context(device));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send the reusable `sw_frame` to the encoder with the given pts / IDR flag.
|
||||
unsafe fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
|
||||
(*self.sw_frame).pts = pts;
|
||||
(*self.sw_frame).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
|
||||
///
|
||||
/// Safe: both arguments are scalars, and `sw_frame`/`enc` are allocations `self` owns from its
|
||||
/// constructor until `Drop` — no caller supplies or can invalidate them.
|
||||
fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
|
||||
// SAFETY: `self.sw_frame` is the `AVFrame` this struct allocated and owns, so the two field
|
||||
// stores are in-bounds writes on a live allocation; `avcodec_send_frame` then takes that
|
||||
// frame and `self.enc`'s own context, both live for the call and neither retained by libav
|
||||
// (it references the frame's buffers itself).
|
||||
unsafe {
|
||||
(*self.sw_frame).pts = pts;
|
||||
(*self.sw_frame).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -807,7 +830,10 @@ impl SystemInner {
|
||||
/// Lazily build the swscale context (src → NV12/P010, limited range, the given colorspace). A
|
||||
/// SystemInner uses exactly one src→dst conversion for its lifetime (8-bit RGB→NV12 BT.709, or
|
||||
/// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
|
||||
unsafe fn ensure_sws(
|
||||
///
|
||||
/// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
|
||||
/// (freed once in `Drop`).
|
||||
fn ensure_sws(
|
||||
&mut self,
|
||||
src_av: ffi::AVPixelFormat,
|
||||
dst_av: ffi::AVPixelFormat,
|
||||
@@ -816,25 +842,33 @@ impl SystemInner {
|
||||
if !self.sws.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let sws = ffi::sws_getContext(
|
||||
self.width as c_int,
|
||||
self.height as c_int,
|
||||
src_av,
|
||||
self.width as c_int,
|
||||
self.height as c_int,
|
||||
dst_av,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
);
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→YUV) failed");
|
||||
}
|
||||
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI we
|
||||
// signal). For RGB input the src coefficient table is unused; pass the dst table for both.
|
||||
let coeff = ffi::sws_getCoefficients(cs);
|
||||
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
|
||||
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
|
||||
// null trio, and returns an owned context or null — which is checked before use, so
|
||||
// `sws_setColorspaceDetails` and the store below only ever see a live one.
|
||||
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
|
||||
// process, and the call only reads it.
|
||||
let sws = unsafe {
|
||||
let sws = ffi::sws_getContext(
|
||||
self.width as c_int,
|
||||
self.height as c_int,
|
||||
src_av,
|
||||
self.width as c_int,
|
||||
self.height as c_int,
|
||||
dst_av,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
);
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→YUV) failed");
|
||||
}
|
||||
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
|
||||
// we signal). For RGB input the src coefficient table is unused; pass dst for both.
|
||||
let coeff = ffi::sws_getCoefficients(cs);
|
||||
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
|
||||
sws
|
||||
};
|
||||
self.sws = sws;
|
||||
Ok(())
|
||||
}
|
||||
@@ -873,7 +907,10 @@ struct D3d11Hw {
|
||||
|
||||
impl D3d11Hw {
|
||||
/// Wrap the capturer's `ID3D11Device` as a D3D11VA hwdevice and build an NV12/P010 frames pool.
|
||||
unsafe fn new(
|
||||
/// Safe: like [`super::super::linux::VaapiHw::new`] and unlike its CUDA counterpart, this is
|
||||
/// handed no raw pointer — `&ID3D11Device` is a borrowed, reference-counted COM wrapper and the
|
||||
/// rest are scalars — so there is no caller contract; the `unsafe` below is the libav/D3D11 FFI.
|
||||
fn new(
|
||||
device: &ID3D11Device,
|
||||
sw_format: ffi::AVPixelFormat,
|
||||
bind_flags: u32,
|
||||
@@ -883,12 +920,19 @@ impl D3d11Hw {
|
||||
) -> Result<Self> {
|
||||
// Owned from the moment it exists: each `bail!` below drops what was built so far, so none
|
||||
// of the failure branches carry cleanup of their own.
|
||||
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
|
||||
))
|
||||
.context("av_hwdevice_ctx_alloc(D3D11VA) failed")?;
|
||||
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
|
||||
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
|
||||
// SAFETY: `av_hwdevice_ctx_alloc` returns null — rejected by `AvBuffer::from_raw`, so the
|
||||
// `?` leaves before anything below runs — or a ref whose `data` libav has already
|
||||
// initialized as an `AVHWDeviceContext`; for a D3D11VA device that context's `hwctx` is an
|
||||
// `AVD3D11VADeviceContext`, so `d11` addresses a live, correctly-typed struct.
|
||||
let (device_ref, d11) = unsafe {
|
||||
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
|
||||
))
|
||||
.context("av_hwdevice_ctx_alloc(D3D11VA) failed")?;
|
||||
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
|
||||
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
|
||||
(device_ref, d11)
|
||||
};
|
||||
|
||||
// Turn on D3D11 multithread protection before libav sees the device.
|
||||
//
|
||||
@@ -913,7 +957,9 @@ impl D3d11Hw {
|
||||
// device's internal critical section (`was` is the previous state, reported once at debug).
|
||||
match device.cast::<ID3D11Multithread>() {
|
||||
Ok(mt) => {
|
||||
let was = mt.SetMultithreadProtected(true);
|
||||
// SAFETY: a COM call on the live `ID3D11Multithread` just obtained by a checked
|
||||
// `cast` of the borrowed device; it takes a BOOL and returns the previous state.
|
||||
let was = unsafe { mt.SetMultithreadProtected(true) };
|
||||
tracing::debug!(
|
||||
previously_protected = was.as_bool(),
|
||||
"D3D11 multithread protection enabled for the libav hwdevice"
|
||||
@@ -931,26 +977,40 @@ impl D3d11Hw {
|
||||
// reference (clone = AddRef, forget = don't Release ours). init() fills
|
||||
// device_context / video_device / video_context / the default lock from a non-null device.
|
||||
std::mem::forget(device.clone());
|
||||
(*d11).device = device.as_raw();
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
|
||||
// SAFETY: `d11` is the live `AVD3D11VADeviceContext` from above, so storing the device
|
||||
// pointer is an in-bounds field write; the `forget(clone())` on the line above is what
|
||||
// makes that pointer an OWNED reference, matching the Release libav does at teardown.
|
||||
// `av_hwdevice_ctx_init` then reads that field, which is why the store precedes it.
|
||||
let r = unsafe {
|
||||
(*d11).device = device.as_raw();
|
||||
ffi::av_hwdevice_ctx_init(device_ref.as_ptr())
|
||||
};
|
||||
if r < 0 {
|
||||
bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})");
|
||||
}
|
||||
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
(*fc).sw_format = sw_format;
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = pool;
|
||||
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
|
||||
(*f11).bind_flags = bind_flags;
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
|
||||
}
|
||||
// SAFETY: same shape one level up — `av_hwframe_ctx_alloc` takes the live, now-initialized
|
||||
// device ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes)
|
||||
// or a ref whose `data` is a live `AVHWFramesContext` whose `hwctx` is an
|
||||
// `AVD3D11VAFramesContext`. Every store is an in-bounds field write on those, all done
|
||||
// before `av_hwframe_ctx_init` reads them.
|
||||
let frames_ref = unsafe {
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
(*fc).sw_format = sw_format;
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = pool;
|
||||
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
|
||||
(*f11).bind_flags = bind_flags;
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
|
||||
}
|
||||
frames_ref
|
||||
};
|
||||
Ok(D3d11Hw {
|
||||
frames_ref,
|
||||
device_ref,
|
||||
@@ -1494,20 +1554,25 @@ mod tests {
|
||||
/// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no
|
||||
/// video engine at all.
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls the DXGI enumeration FFI and `make_device`; every result is checked before use and no
|
||||
/// alias to the adapter outlives the call.
|
||||
/// Safe: `prefer_vendor` is a plain id and every DXGI object is created here and owned by the
|
||||
/// returned device; the old `# Safety` section described the body, not a caller obligation.
|
||||
#[cfg(test)]
|
||||
unsafe fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
|
||||
fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut preferred = None;
|
||||
let mut fallback = None;
|
||||
// SAFETY: DXGI factory/adapter enumeration over owned locals — the factory is created here,
|
||||
// each adapter it yields owns its own COM reference, and every call is `.ok()`-checked
|
||||
// before use. `GetDesc1` fills a fully-initialized stack descriptor.
|
||||
let (factory, mut preferred, mut fallback): (IDXGIFactory1, _, _) =
|
||||
(unsafe { CreateDXGIFactory1() }.ok()?, None, None);
|
||||
for i in 0.. {
|
||||
let Ok(adapter) = factory.EnumAdapters1(i) else {
|
||||
// SAFETY: a COM call on the live `factory` created above; it takes an index and
|
||||
// yields an owned adapter, and the `Ok` binding is what proves one came back.
|
||||
let Ok(adapter) = (unsafe { factory.EnumAdapters1(i) }) else {
|
||||
break; // DXGI_ERROR_NOT_FOUND — end of the list
|
||||
};
|
||||
let Ok(desc) = adapter.GetDesc1() else {
|
||||
// SAFETY: a COM call on the adapter just enumerated, filling a fully-initialized
|
||||
// stack descriptor it returns by value.
|
||||
let Ok(desc) = (unsafe { adapter.GetDesc1() }) else {
|
||||
continue;
|
||||
};
|
||||
let name = String::from_utf16_lossy(&desc.Description)
|
||||
@@ -1521,7 +1586,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let adapter = preferred.or(fallback)?;
|
||||
pf_frame::dxgi::make_device(&adapter).ok().map(|(d, _c)| d)
|
||||
// SAFETY: `make_device` requires a live `IDXGIAdapter1`; `adapter` is one of the adapters
|
||||
// enumerated above, still owned here and borrowed only for this synchronous call.
|
||||
unsafe { pf_frame::dxgi::make_device(&adapter) }
|
||||
.ok()
|
||||
.map(|(d, _c)| d)
|
||||
}
|
||||
|
||||
/// Construct/drop `D3d11Hw` repeatedly on real silicon — the D3D11VA half of the RAII change,
|
||||
@@ -1537,23 +1606,20 @@ mod tests {
|
||||
#[test]
|
||||
#[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"]
|
||||
fn d3d11hw_alloc_drop_cycles() {
|
||||
// SAFETY: see `test_hw_device`; the returned device outlives every `D3d11Hw` built below.
|
||||
let device = unsafe { test_hw_device(0x8086) }.expect("a hardware D3D11 adapter");
|
||||
let device = test_hw_device(0x8086).expect("a hardware D3D11 adapter");
|
||||
for i in 0..8 {
|
||||
// SAFETY: `D3d11Hw::new` needs libav initialised (it is, statically, by the ffmpeg-next
|
||||
// crate's first use here) and a live `ID3D11Device`, which `device` is for the whole
|
||||
// loop. NV12 at 640x480 with an 8-surface pool are valid pool parameters. The handle
|
||||
// drops at the end of each iteration — that release is what is under test.
|
||||
let hw = unsafe {
|
||||
D3d11Hw::new(
|
||||
&device,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
pool_bind_flags(WinVendor::Amf),
|
||||
640,
|
||||
480,
|
||||
8,
|
||||
)
|
||||
}
|
||||
// `D3d11Hw::new` is safe now; it still needs libav initialised, which the ffmpeg-next
|
||||
// crate does statically on first use here. NV12 at 640x480 with an 8-surface pool are
|
||||
// valid pool parameters. The handle drops at the end of each iteration — that release
|
||||
// is what is under test.
|
||||
let hw = D3d11Hw::new(
|
||||
&device,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
pool_bind_flags(WinVendor::Amf),
|
||||
640,
|
||||
480,
|
||||
8,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("D3d11Hw::new failed on iteration {i}: {e:#}"));
|
||||
assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
|
||||
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
|
||||
@@ -1587,8 +1653,7 @@ mod tests {
|
||||
#[test]
|
||||
#[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
|
||||
fn zerocopy_qsv_alloc_drop_cycles() {
|
||||
// SAFETY: see `test_hw_device`; the device outlives every `ZeroCopyInner` built below.
|
||||
let device = unsafe { test_hw_device(0x8086) }.expect("an Intel D3D11 adapter");
|
||||
let device = test_hw_device(0x8086).expect("an Intel D3D11 adapter");
|
||||
for i in 0..8 {
|
||||
let zc = ZeroCopyInner::open(
|
||||
WinVendor::Qsv,
|
||||
|
||||
@@ -33,6 +33,14 @@ utoipa = { version = "5", features = ["axum_extras"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
# The `#[ignore]`d on-glass cases drive the manager/backend through code paths whose only account of
|
||||
# what they chose is `tracing`. A bare test harness installs no subscriber, so those runs were blind:
|
||||
# `live_a_failed_first_isolate_is_recovered_by_adopting_the_next` could see the panel stay dark but
|
||||
# not whether the adoption arm fired, or whether the dark-desk backstop ran and failed. Dev-only, so
|
||||
# the shipped host's dependency closure through this crate is unchanged.
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
# The Mutter backend drives D-Bus RemoteDesktop + ScreenCast.RecordVirtual via ashpd on a tokio
|
||||
|
||||
@@ -268,33 +268,49 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
|
||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||
pub fn detect() -> Result<Compositor> {
|
||||
if let Some(v) = pf_host_config::config().compositor.as_deref() {
|
||||
return compositor_from_pin(v).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
|
||||
)
|
||||
});
|
||||
// Compositor detection is a Linux question — the variants ARE the Linux backends. Asked
|
||||
// anywhere else this used to fall through to the XDG sniff below and fail with advice about
|
||||
// `XDG_CURRENT_DESKTOP` and `PUNKTFUNK_COMPOSITOR`, which `mgmt/display.rs` puts VERBATIM into
|
||||
// the `/display/monitors` response — so on a Windows host the console's only explanation for an
|
||||
// empty monitor picker was Linux troubleshooting (sweep §13.17). The operator pin is gated with
|
||||
// it: naming a Wayland compositor on Windows cannot be honoured either.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"compositor detection is Linux-only; on {} the host enumerates displays through the OS \
|
||||
display API instead (`vdisplay::monitors::list_windows`)",
|
||||
std::env::consts::OS
|
||||
)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
|
||||
return Ok(c);
|
||||
}
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
if desktop.contains("KDE") {
|
||||
Ok(Compositor::Kwin)
|
||||
} else if desktop.contains("GNOME") {
|
||||
Ok(Compositor::Mutter)
|
||||
} else if desktop.contains("HYPRLAND") {
|
||||
Ok(Compositor::Hyprland)
|
||||
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
||||
Ok(Compositor::Wlroots)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"could not detect compositor: no live graphical session for this uid and \
|
||||
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
|
||||
)
|
||||
{
|
||||
if let Some(v) = pf_host_config::config().compositor.as_deref() {
|
||||
return compositor_from_pin(v).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
|
||||
)
|
||||
});
|
||||
}
|
||||
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
|
||||
return Ok(c);
|
||||
}
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
if desktop.contains("KDE") {
|
||||
Ok(Compositor::Kwin)
|
||||
} else if desktop.contains("GNOME") {
|
||||
Ok(Compositor::Mutter)
|
||||
} else if desktop.contains("HYPRLAND") {
|
||||
Ok(Compositor::Hyprland)
|
||||
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
||||
Ok(Compositor::Wlroots)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"could not detect compositor: no live graphical session for this uid and \
|
||||
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,62 @@ pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every head Windows reports — the non-compositor counterpart to [`list`].
|
||||
///
|
||||
/// Windows has no compositor to ask, so this reads the same CCD database the rest of the Windows
|
||||
/// backend drives and reports what it finds. Until this existed the mgmt API answered
|
||||
/// `/display/monitors` on Windows with an empty list and a LINUX error string (`detect()` fell
|
||||
/// through to an `XDG_CURRENT_DESKTOP` sniff), so the console could neither show the operator's
|
||||
/// screen nor honestly say why.
|
||||
///
|
||||
/// INACTIVE heads are listed too, with zeroed geometry and `enabled: false` — the same contract
|
||||
/// [`list`] documents, so "why can't I pick it?" still has an answer.
|
||||
///
|
||||
/// Two fields cannot mean here what they mean on Linux, and are reported honestly rather than
|
||||
/// invented:
|
||||
/// * `scale` is always `1.0`. Windows scaling is per-monitor DPI applied by each application, not
|
||||
/// a compositor-global logical scale, so there is no factor that would make these coordinates
|
||||
/// "logical" the way the module doc means. The geometry below is therefore PIXELS.
|
||||
/// * `refresh_mhz` comes from the path's own rational rate, which keeps 59.94 distinct from 60.
|
||||
#[cfg(windows)]
|
||||
pub fn list_windows() -> Result<Vec<PhysicalMonitor>> {
|
||||
let inv = pf_win_display::win_display::target_inventory();
|
||||
if inv.is_empty() {
|
||||
// Distinguish "reached it, nothing there" from a failure, exactly as [`list`] promises:
|
||||
// an empty CCD database is a real state (every panel off — measured on .173 with the TV
|
||||
// powered down), not an error.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(inv
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
// The GDI name is what an operator recognises and what capture pins on; an inactive
|
||||
// path has none, so fall back to the stable target id rather than an empty string —
|
||||
// `resolve` matches on this, and a blank id can never be pinned.
|
||||
let connector = if t.gdi_name.is_empty() {
|
||||
format!("target-{}", t.target_id)
|
||||
} else {
|
||||
t.gdi_name
|
||||
};
|
||||
PhysicalMonitor {
|
||||
description: describe("", &t.friendly, &connector),
|
||||
connector,
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
refresh_mhz: t.refresh_mhz,
|
||||
x: t.x,
|
||||
y: t.y,
|
||||
scale: 1.0,
|
||||
primary: t.primary,
|
||||
enabled: t.active,
|
||||
// Unlike the Linux backends, Windows CAN say this reliably: our IddCx monitors
|
||||
// carry our own EDID manufacturer id in their device path.
|
||||
managed: t.ours,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Resolve a configured monitor name against `monitors`, exactly then case-insensitively.
|
||||
///
|
||||
/// **A miss is a hard error carrying the available names**, never a silent fall-back to some other
|
||||
|
||||
@@ -960,6 +960,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` on a worker thread and give up after `budget`, so a HANG fails the case instead of
|
||||
/// wedging the box.
|
||||
///
|
||||
/// Earned the hard way: this file's 3.2 case hung inside `create`, and killing the harness
|
||||
/// skipped every `Drop`, leaking an IddCx monitor. A few of those exhaust the driver's slot
|
||||
/// pool, after which every later run wedges too and only a reboot clears it. A bounded wait
|
||||
/// lets the harness exit NORMALLY, which is what lets the driver reap the session.
|
||||
fn within<T: Send + 'static>(
|
||||
budget: Duration,
|
||||
what: &str,
|
||||
f: impl FnOnce() -> T + Send + 'static,
|
||||
) -> T {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
match rx.recv_timeout(budget) {
|
||||
Ok(v) => v,
|
||||
Err(_) => panic!(
|
||||
"{what} did not finish within {budget:?} — failing rather than hanging, so the \
|
||||
harness can exit and the driver can reap. Check for a leaked punktfunk monitor \
|
||||
before the next run."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// §5 3.2 on glass: when the FIRST member's isolate fails, a later member's isolate must be
|
||||
/// ADOPTED as the group's restore snapshot — otherwise it deactivates the operator's panels
|
||||
/// with nothing able to put them back.
|
||||
@@ -984,6 +1010,10 @@ mod tests {
|
||||
#[test]
|
||||
#[ignore = "needs the pf-vdisplay driver on real hardware; run with --ignored"]
|
||||
fn live_a_failed_first_isolate_is_recovered_by_adopting_the_next() {
|
||||
// Without this the run is BLIND: the adoption arm and the dark-desk backstop announce
|
||||
// themselves only through `tracing`, and a bare test harness has no subscriber. The first
|
||||
// on-glass run could see the panel stay dark but not say WHICH link broke.
|
||||
init_test_tracing();
|
||||
assert!(
|
||||
std::env::var("PUNKTFUNK_NO_ISOLATE").is_err(),
|
||||
"PUNKTFUNK_NO_ISOLATE forces Topology::Extend — this case needs Exclusive"
|
||||
@@ -1010,10 +1040,19 @@ mod tests {
|
||||
})
|
||||
.expect("create member 1");
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
// ⭐ THE MEASUREMENT THAT SEPARATES THE TWO CANDIDATES. Member 1's isolate was injected to
|
||||
// fail, so nothing of OURS deactivated anything here. If the operator's panel is ALREADY
|
||||
// dark at this point, the arriving IddCx monitor took the desktop on its own — and every
|
||||
// snapshot taken from here on records "panel off", so member 2's adopted snapshot is
|
||||
// POISONED AT BIRTH and restoring it faithfully restores darkness. If the panel is still
|
||||
// lit here, poisoning is excluded and the failure is downstream (adoption never fired, or
|
||||
// the restore/backstop did and could not re-light it).
|
||||
let physicals_after_m1 = active_physicals();
|
||||
println!(
|
||||
"after member 1 (isolate INJECTED to fail): {:?}",
|
||||
active_targets()
|
||||
);
|
||||
println!("physicals after member 1 : {physicals_after_m1:?} <- poisoned-at-birth probe");
|
||||
|
||||
let mut vd2 = PfVdisplayDisplay::new().expect("open pf-vdisplay (member 2)");
|
||||
vd2.set_client_identity(Some([0xB2; 32]));
|
||||
@@ -1049,11 +1088,54 @@ mod tests {
|
||||
println!("physicals after teardown : {physicals_after:?}");
|
||||
assert!(
|
||||
!physicals_after.is_empty(),
|
||||
"the operator's physical panel was left DEACTIVATED after teardown. The first \
|
||||
member's isolate failed, so the group held no restore snapshot; the second member's \
|
||||
isolate deactivated the physicals and its snapshot was discarded (sweep §5 3.2). \
|
||||
Active targets now: {:?}",
|
||||
active_targets()
|
||||
"the operator's physical panel was left DEACTIVATED after teardown (sweep §5 3.2). \
|
||||
Active targets now: {:?}.\n\
|
||||
Which candidate this run implicates — read it off the poisoned-at-birth probe above:\n\
|
||||
* physicals after member 1 was EMPTY ({m1_empty}) -> the snapshot member 2 adopted was \
|
||||
already poisoned: the panel went dark at member 1's create (IddCx auto-activation), so \
|
||||
the adopted topology records 'panel off' and restoring it faithfully restores darkness. \
|
||||
Adoption is working; the SNAPSHOT SOURCE is the defect.\n\
|
||||
* physicals after member 1 was NON-empty -> poisoning is excluded; the break is \
|
||||
downstream. Check the trace for 'adopting this member's' (the adoption arm) and for \
|
||||
'no external physical display active after the restore' (the dark-desk backstop). A \
|
||||
missing adoption line means teardown's restore was never gated on; a backstop line \
|
||||
followed by a non-zero force-EXTEND rc means the remedy itself failed.",
|
||||
active_targets(),
|
||||
m1_empty = physicals_after_m1.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// What `/display/monitors` will now answer on Windows — the operator's real screens.
|
||||
///
|
||||
/// Read-only, so it is safe against a live host. Before `monitors::list_windows` existed this
|
||||
/// endpoint returned an empty list plus a LINUX error string on every Windows box (`detect()`
|
||||
/// fell through to an `XDG_CURRENT_DESKTOP` sniff), so the console could show no physical
|
||||
/// screen and could not honestly say why.
|
||||
#[test]
|
||||
#[ignore = "hardware: reads the live display topology"]
|
||||
fn live_windows_monitor_enumeration_reports_the_physical_screens() {
|
||||
let ms = crate::monitors::list_windows().expect("list_windows");
|
||||
for m in &ms {
|
||||
println!(
|
||||
"connector={:<14} enabled={:<5} managed={:<5} primary={:<5} {:>5}x{:<5} @{:>3}Hz \
|
||||
pos=({},{}) {:?}",
|
||||
m.connector,
|
||||
m.enabled,
|
||||
m.managed,
|
||||
m.primary,
|
||||
m.width,
|
||||
m.height,
|
||||
m.refresh_mhz / 1000,
|
||||
m.x,
|
||||
m.y,
|
||||
m.description
|
||||
);
|
||||
}
|
||||
assert!(!ms.is_empty(), "no monitors enumerated at all");
|
||||
// The point of the change: a real, non-managed head is visible to the console.
|
||||
assert!(
|
||||
ms.iter().any(|m| !m.managed),
|
||||
"every enumerated head is one of OURS — the operator's physical screen is still missing"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1070,6 +1152,27 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Surface the manager/backend `tracing` output on stdout for a live case.
|
||||
///
|
||||
/// These on-glass cases drive decision points — the isolate ladder, the snapshot-adoption arm,
|
||||
/// `restore_displays_ccd`'s dark-desk backstop — whose ONLY account of what they chose is a
|
||||
/// `tracing` event. A bare `cargo test` harness installs no subscriber, so those events go
|
||||
/// nowhere and a failing run cannot say which link broke; that is exactly what left §5 3.2's
|
||||
/// two candidates undistinguished after the first on-glass run.
|
||||
///
|
||||
/// `with_test_writer` routes through the harness's capture, so the output appears under
|
||||
/// `--nocapture` (and on failure) rather than racing `println!`. Idempotent and non-fatal: the
|
||||
/// global default can only be set once per process, and several live cases may run in one
|
||||
/// binary, so a second call is a no-op rather than a panic that would fail an unrelated test.
|
||||
/// `RUST_LOG` still wins when set; the default is `debug` for our own crates, which is where
|
||||
/// the ladder's reasoning lives.
|
||||
fn init_test_tracing() {
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("pf_vdisplay=debug,pf_win_display=debug"));
|
||||
let _ = fmt().with_env_filter(filter).with_test_writer().try_init();
|
||||
}
|
||||
|
||||
/// The active targets that are EXTERNAL PHYSICAL panels — the operator's actual desk.
|
||||
fn active_physicals() -> Vec<(u32, String)> {
|
||||
pf_win_display::win_display::target_inventory()
|
||||
@@ -1153,8 +1256,9 @@ mod tests {
|
||||
fn live_inplace_resize() {
|
||||
// Live-run diagnostics: surface the manager/backend tracing (activation ladder, settle
|
||||
// waits, UPDATE_MODES) on stdout — a bare test harness has no subscriber, which made the
|
||||
// first on-glass run blind.
|
||||
// (tracing-subscriber is not a dep of this crate — run the host binary for traced runs.)
|
||||
// first on-glass run blind. `tracing-subscriber` is now a dev-dependency, so this case no
|
||||
// longer has to be re-run through the host binary to be traced.
|
||||
init_test_tracing();
|
||||
// Context probe: can this process see the CCD active-path set at all? (`None` = the query
|
||||
// itself fails in this session/window-station — the whole ladder would be blind, and a
|
||||
// "monitor never activated" verdict would be an artifact of the test context.)
|
||||
|
||||
@@ -998,6 +998,25 @@ pub struct TargetInventory {
|
||||
pub friendly: String,
|
||||
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
|
||||
pub monitor_device_path: String,
|
||||
/// One of OUR virtual displays (see [`is_our_virtual_display`]) — the reliable answer to
|
||||
/// "is this the operator's screen or something we made?", which the connector class cannot give.
|
||||
pub ours: bool,
|
||||
/// GDI device name (`\\.\DISPLAY1`) of the SOURCE driving this target; empty when inactive
|
||||
/// (an inactive path has no source). This is the id a Windows operator recognises and the one
|
||||
/// capture pins on.
|
||||
pub gdi_name: String,
|
||||
/// Desktop position + mode of the driving source, in PIXELS. All zero when inactive: the CCD
|
||||
/// mode indices are only valid for active paths, and inventing geometry for a dark head would
|
||||
/// be worse than reporting none.
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Refresh in mHz (60000 = 60 Hz), from the path's own `refreshRate` rational. 0 when the
|
||||
/// path reports no rate (inactive, or a target that does not drive one).
|
||||
pub refresh_mhz: u32,
|
||||
/// The desktop origin sits on this head — Windows' notion of "primary".
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
/// EDID manufacturer id of punktfunk's own IddCx monitors, as it appears in the PnP hardware id and
|
||||
@@ -1131,18 +1150,66 @@ pub fn target_inventory() -> Vec<TargetInventory> {
|
||||
let (mut external_physical, mut tech) = output_tech_class(req.outputTechnology);
|
||||
// Our own IddCx monitor claims HDMI, so the connector class alone would call it one of the
|
||||
// operator's panels — see `is_our_virtual_display` for what that broke.
|
||||
if is_our_virtual_display(&monitor_device_path) {
|
||||
let ours = is_our_virtual_display(&monitor_device_path);
|
||||
if ours {
|
||||
external_physical = false;
|
||||
tech = "punktfunk-virtual";
|
||||
}
|
||||
let is_active = active.contains(&key);
|
||||
// Geometry + the GDI name come from the SOURCE this path drives, and only an ACTIVE path
|
||||
// has one — `modeInfoIdx` is the INVALID sentinel otherwise, so everything stays zeroed
|
||||
// rather than indexing the mode table with 0xffffffff.
|
||||
let (mut gdi_name, mut x, mut y, mut width, mut height) =
|
||||
(String::new(), 0i32, 0i32, 0u32, 0u32);
|
||||
if is_active {
|
||||
// SAFETY: POD union read (header) — `modeInfoIdx` overlays a same-sized bitfield
|
||||
// struct, both valid for every bit pattern. Used only as a bounds-checked index below.
|
||||
let idx = unsafe { p.sourceInfo.Anonymous.modeInfoIdx } as usize;
|
||||
if let Some(m) = modes.get(idx) {
|
||||
if m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE {
|
||||
// SAFETY: discriminated union read — the `infoType` test directly above is the
|
||||
// discriminant the CCD contract defines for `sourceMode`.
|
||||
let sm = unsafe { m.Anonymous.sourceMode };
|
||||
x = sm.position.x;
|
||||
y = sm.position.y;
|
||||
width = sm.width;
|
||||
height = sm.height;
|
||||
}
|
||||
}
|
||||
let mut src = DISPLAYCONFIG_SOURCE_DEVICE_NAME::default();
|
||||
src.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
|
||||
src.header.size = size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32;
|
||||
src.header.adapterId = p.sourceInfo.adapterId;
|
||||
src.header.id = p.sourceInfo.id;
|
||||
// SAFETY: `src.header` is a live local whose `size` was just set to the enclosing
|
||||
// struct's own `size_of`, which is the contract telling the OS how many bytes it may
|
||||
// write; the struct outlives this synchronous call.
|
||||
if unsafe { DisplayConfigGetDeviceInfo(&mut src.header) } == 0 {
|
||||
gdi_name = utf16z_str(&src.viewGdiDeviceName);
|
||||
}
|
||||
}
|
||||
// A rational, not a scalar: mHz keeps 59.94 distinguishable from 60 without a float.
|
||||
let refresh_mhz = match t.refreshRate.Denominator {
|
||||
0 => 0,
|
||||
d => (u64::from(t.refreshRate.Numerator) * 1000 / u64::from(d)) as u32,
|
||||
};
|
||||
out.push(TargetInventory {
|
||||
target_id: t.id,
|
||||
active: active.contains(&key),
|
||||
active: is_active,
|
||||
external_physical,
|
||||
internal_panel: tech == "internal-panel",
|
||||
tech,
|
||||
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
|
||||
monitor_device_path,
|
||||
ours,
|
||||
gdi_name,
|
||||
// Windows' primary is the head at the desktop origin.
|
||||
primary: is_active && x == 0 && y == 0,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
refresh_mhz,
|
||||
});
|
||||
}
|
||||
out
|
||||
|
||||
@@ -71,27 +71,34 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut enforced: Vec<String> = vec![
|
||||
"keep_alive".into(),
|
||||
"topology".into(),
|
||||
"mode_conflict".into(),
|
||||
"identity".into(),
|
||||
"layout".into(),
|
||||
"game_session".into(),
|
||||
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
|
||||
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||
"ddc_power_off".into(),
|
||||
"pnp_disable_monitors".into(),
|
||||
];
|
||||
// `capture_monitor` routes every session to the MIRROR backend, and that backend exists only on
|
||||
// Linux — `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]`, because `pf-capture`
|
||||
// has no Windows entry point that can capture an arbitrary head. This list is precisely the
|
||||
// "which controls are live vs. coming soon" contract, so claiming it unconditionally is what let
|
||||
// the Windows console offer a picker that saved and then did nothing
|
||||
// (`design/per-monitor-portal-capture.md` §5.3).
|
||||
if cfg!(target_os = "linux") {
|
||||
enforced.push("capture_monitor".into());
|
||||
}
|
||||
DisplaySettingsState {
|
||||
effective: settings.effective(),
|
||||
settings,
|
||||
configured,
|
||||
presets,
|
||||
custom_presets: policy::load_custom_presets(),
|
||||
enforced: vec![
|
||||
"keep_alive".into(),
|
||||
"topology".into(),
|
||||
"mode_conflict".into(),
|
||||
"identity".into(),
|
||||
"layout".into(),
|
||||
"game_session".into(),
|
||||
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
|
||||
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||
"ddc_power_off".into(),
|
||||
"pnp_disable_monitors".into(),
|
||||
// Linux-only in effect: routes every session to the mirror backend
|
||||
// (design/per-monitor-portal-capture.md).
|
||||
"capture_monitor".into(),
|
||||
],
|
||||
enforced,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +142,24 @@ pub(crate) async fn get_display_settings() -> Json<DisplaySettingsState> {
|
||||
pub(crate) async fn set_display_settings(
|
||||
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
||||
) -> Response {
|
||||
#[cfg_attr(target_os = "linux", allow(unused_mut))]
|
||||
let mut policy = policy;
|
||||
// A pin nothing can honor must not be STORED as though it were. Off Linux there is no mirror
|
||||
// backend (`MonitorsResponse::pin_supported`), so drop the field rather than persisting a
|
||||
// setting whose only effect would be to mislead: the console re-reads this state after the PUT
|
||||
// and would otherwise render a screen as "streamed" while every session kept creating a virtual
|
||||
// display. Coerced rather than rejected with a 400 on purpose — this PUT is WHOLE-OBJECT, so a
|
||||
// host that already stored a pin (before this build) would have every later settings save
|
||||
// rejected over a field the operator cannot even see, taking the other axes down with it. This
|
||||
// way such a policy self-heals on the next write, and the response body shows the truth
|
||||
// immediately.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if let Some(dropped) = policy.capture_monitor.take() {
|
||||
tracing::warn!(
|
||||
"management API: ignoring capture_monitor={dropped:?} — streaming a chosen physical \
|
||||
monitor is Linux-only (no Windows mirror backend); the pin was NOT stored"
|
||||
);
|
||||
}
|
||||
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
|
||||
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
|
||||
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
|
||||
@@ -222,6 +247,19 @@ pub(crate) struct MonitorsResponse {
|
||||
/// The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,
|
||||
/// so the console can show "pinned to DP-2, which this host doesn't have".
|
||||
pinned: Option<String>,
|
||||
/// Whether this build can actually STREAM one of these monitors.
|
||||
///
|
||||
/// Enumeration and capture are separate capabilities, and on Windows only the first exists: the
|
||||
/// heads below are real and worth showing (they explain the topology, and `/display/state`
|
||||
/// cross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a
|
||||
/// frame channel pushed by our OWN IddCx virtual display. There is no desktop-duplication
|
||||
/// capturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so
|
||||
/// `vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.
|
||||
///
|
||||
/// The console renders the picker read-only on `false`. Reported as a capability rather than
|
||||
/// sniffed client-side from the OS so the answer comes from the build that would have to honor
|
||||
/// it — when a Windows mirror backend lands, this flips and the UI needs no change.
|
||||
pin_supported: bool,
|
||||
/// Why the list is empty, when enumeration failed (compositor unreachable, unsupported
|
||||
/// platform). `None` with an empty list means "asked, and there are none".
|
||||
error: Option<String>,
|
||||
@@ -248,12 +286,33 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
|
||||
// sessions will actually mirror, not just what the console last wrote.
|
||||
#[cfg(target_os = "linux")]
|
||||
let pinned = crate::vdisplay::capture_monitor();
|
||||
// Off Linux there is no mirror backend, so there is nothing a pin could aim (see
|
||||
// `MonitorsResponse::pin_supported`). Kept `None` DELIBERATELY rather than reporting the stored
|
||||
// value: `pinned` is what the console highlights as "this is the screen sessions stream", and a
|
||||
// highlight on a head nothing will ever capture is precisely the lie this endpoint is here to
|
||||
// stop telling. `pin_supported: false` is how the unsupportedness is reported instead.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let pinned: Option<String> = None;
|
||||
// Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker.
|
||||
let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() {
|
||||
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
|
||||
Err(e) => (None, Err(e)),
|
||||
// Enumeration works on Windows; capture of an enumerated head does not.
|
||||
let pin_supported = cfg!(target_os = "linux");
|
||||
// Enumeration shells out / round-trips D-Bus + Wayland (and on Windows walks the CCD
|
||||
// database, which can serialize on the display-config lock), so keep it off the async worker.
|
||||
let (compositor, listed) = tokio::task::spawn_blocking(|| {
|
||||
// Windows has no compositor to detect — asking used to fail with Linux advice about
|
||||
// XDG_CURRENT_DESKTOP, which landed verbatim in `error` below and was the console's only
|
||||
// explanation for an empty picker. Report the display API we actually used instead.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
(
|
||||
Some("windows".to_string()),
|
||||
crate::vdisplay::monitors::list_windows(),
|
||||
)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
match crate::vdisplay::detect() {
|
||||
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
|
||||
Err(e) => (None, Err(e)),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}"))));
|
||||
@@ -283,6 +342,7 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
|
||||
compositor,
|
||||
monitors,
|
||||
pinned,
|
||||
pin_supported,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -430,6 +430,7 @@
|
||||
"display_monitor_none": "Dieser Host meldet keine Monitore.",
|
||||
"display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.",
|
||||
"display_monitor_env_locked": "Auf diesem Host über PUNKTFUNK_CAPTURE_MONITOR festgelegt — dort entfernen, um hier zu wählen.",
|
||||
"display_monitor_unsupported": "Das Übertragen eines dieser Bildschirme wird auf diesem Host noch nicht unterstützt — das gibt es bisher nur auf Linux-Hosts. Die Bildschirme sind hier aufgeführt, damit du siehst, was dieser Computer hat; Clients bekommen weiterhin ihren eigenen virtuellen Bildschirm.",
|
||||
"display_monitor_primary": "primär",
|
||||
"display_monitor_disabled": "aus",
|
||||
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
|
||||
|
||||
@@ -430,6 +430,7 @@
|
||||
"display_monitor_none": "This host reports no monitors.",
|
||||
"display_monitor_unavailable": "Monitors could not be listed on this host.",
|
||||
"display_monitor_env_locked": "Pinned by PUNKTFUNK_CAPTURE_MONITOR on this host — unset it to choose here.",
|
||||
"display_monitor_unsupported": "Streaming one of these screens isn't supported on this host yet — it's available on Linux hosts only. The screens are listed so you can see what this computer has; clients keep getting their own virtual screen.",
|
||||
"display_monitor_primary": "primary",
|
||||
"display_monitor_disabled": "off",
|
||||
"display_monitor_saved": "Streamed screen saved"
|
||||
|
||||
@@ -42,9 +42,17 @@ export const MonitorCard: FC = () => {
|
||||
// environment is read-only here: offering controls that silently lose to the env would be worse
|
||||
// than saying so.
|
||||
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
|
||||
// The host says whether it can honor a pin at all. Windows enumerates its heads but has no
|
||||
// backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to
|
||||
// offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was
|
||||
// still created on connect. Defaults to TRUE when the field is absent so an older host — which
|
||||
// only ever shipped this picker where it worked — is not retroactively locked out.
|
||||
const pinSupported = monitors.data?.pin_supported ?? true;
|
||||
// Both reasons produce the same read-only card; only the explanation above it differs.
|
||||
const locked = envLocked || !pinSupported;
|
||||
|
||||
const choose = (connector: string | null) => {
|
||||
if (!policy || envLocked) return;
|
||||
if (!policy || locked) return;
|
||||
save.mutate(
|
||||
{ data: { ...policy, capture_monitor: connector } },
|
||||
{
|
||||
@@ -71,13 +79,13 @@ export const MonitorCard: FC = () => {
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={busy || envLocked || !onSelect}
|
||||
disabled={busy || locked || !onSelect}
|
||||
onClick={onSelect}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
|
||||
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
|
||||
(busy || envLocked) && "cursor-not-allowed opacity-60",
|
||||
(busy || locked) && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-col gap-1">
|
||||
@@ -124,7 +132,12 @@ export const MonitorCard: FC = () => {
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.display_monitor_intro()}
|
||||
</p>
|
||||
{envLocked && (
|
||||
{!pinSupported && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||||
{m.display_monitor_unsupported()}
|
||||
</p>
|
||||
)}
|
||||
{pinSupported && envLocked && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||||
{m.display_monitor_env_locked()}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user