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"
|
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
|
||||||
}
|
}
|
||||||
Push-Location web
|
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)" }
|
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||||
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
|
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"
|
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||||
|
|||||||
Generated
+1
@@ -3051,6 +3051,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
|||||||
+6
-1
@@ -5831,7 +5831,8 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The host's physical monitors + which one capture is pinned to.",
|
"description": "The host's physical monitors + which one capture is pinned to.",
|
||||||
"required": [
|
"required": [
|
||||||
"monitors"
|
"monitors",
|
||||||
|
"pin_supported"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"compositor": {
|
"compositor": {
|
||||||
@@ -5855,6 +5856,10 @@
|
|||||||
},
|
},
|
||||||
"description": "The heads, ordered left-to-right by desktop position."
|
"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": {
|
"pinned": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ fn connect_spawn(
|
|||||||
let target = target.clone();
|
let target = target.clone();
|
||||||
// The closure owns `target`/`fp_hex`; the call itself borrows copies.
|
// 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 (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
|
||||||
|
let profile_arg = target.profile.clone();
|
||||||
let spawned = crate::spawn::spawn_session(
|
let spawned = crate::spawn::spawn_session(
|
||||||
&addr,
|
&addr,
|
||||||
port,
|
port,
|
||||||
@@ -246,6 +247,7 @@ fn connect_spawn(
|
|||||||
opts.connect_timeout.as_secs(),
|
opts.connect_timeout.as_secs(),
|
||||||
fullscreen,
|
fullscreen,
|
||||||
opts.launch.as_deref(),
|
opts.launch.as_deref(),
|
||||||
|
profile_arg.as_deref(),
|
||||||
child,
|
child,
|
||||||
move |event| {
|
move |event| {
|
||||||
use crate::spawn::SpawnEvent;
|
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
|
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
|
||||||
/// that matter.
|
/// that matter.
|
||||||
const MENU_EDIT: &str = "Edit\u{2026}";
|
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}";
|
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||||
|
|
||||||
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
/// 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.
|
/// 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 {
|
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();
|
let mut items: Vec<Element> = Vec::new();
|
||||||
if let Some(online) = online {
|
if let Some(online) = online {
|
||||||
items.push(
|
items.push(
|
||||||
@@ -183,6 +203,13 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
|||||||
.vertical_alignment(VerticalAlignment::Center)
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
|
if let Some((name, kind)) = profile {
|
||||||
|
items.push(
|
||||||
|
pill(name, kind)
|
||||||
|
.vertical_alignment(VerticalAlignment::Center)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
hstack(items)
|
hstack(items)
|
||||||
.spacing(6.0)
|
.spacing(6.0)
|
||||||
.margin(edges(0.0, 12.0, 0.0, 0.0))
|
.margin(edges(0.0, 12.0, 0.0, 0.0))
|
||||||
@@ -250,6 +277,43 @@ fn edit_editor(
|
|||||||
se.call(None);
|
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>| {
|
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
|
||||||
vstack((
|
vstack((
|
||||||
text_block(label)
|
text_block(label)
|
||||||
@@ -281,6 +345,18 @@ fn edit_editor(
|
|||||||
"auto-filled when known",
|
"auto-filled when known",
|
||||||
mac_draft,
|
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((
|
vstack((
|
||||||
ToggleSwitch::new(clip0)
|
ToggleSwitch::new(clip0)
|
||||||
.header("Share clipboard with this host")
|
.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() {
|
if !known.hosts.is_empty() {
|
||||||
body.push(section("SAVED HOSTS"));
|
body.push(section("SAVED HOSTS"));
|
||||||
let mut tiles: Vec<Element> = Vec::new();
|
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 {
|
for k in &known.hosts {
|
||||||
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
||||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
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()),
|
fp_hex: Some(k.fp_hex.clone()),
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
mac: k.mac.clone(),
|
mac: k.mac.clone(),
|
||||||
|
profile: None,
|
||||||
};
|
};
|
||||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
|
// 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
|
// 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 (svc, target) = (props.svc.clone(), target.clone());
|
||||||
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
||||||
let (fp, name) = (k.fp_hex.clone(), k.name.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("")
|
button("")
|
||||||
.icon(Symbol::More)
|
.icon(Symbol::More)
|
||||||
.subtle()
|
.subtle()
|
||||||
@@ -532,6 +617,24 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
.automation_name("More options")
|
.automation_name("More options")
|
||||||
.menu_flyout({
|
.menu_flyout({
|
||||||
let mut items = vec![menu_item(MENU_CONNECT)];
|
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 —
|
// The library surfaces — mouse/KB page and the gamepad console UI —
|
||||||
// for paired hosts only (the mgmt API needs the paired identity);
|
// for paired hosts only (the mgmt API needs the paired identity);
|
||||||
// the page additionally sits behind the experimental toggle, the
|
// 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
|
items
|
||||||
})
|
})
|
||||||
.on_item_clicked(move |item: String| match item.as_str() {
|
.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 => {
|
MENU_CONNECT => {
|
||||||
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
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 (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||||
|
let pinned_base = target.clone();
|
||||||
tiles.push(host_tile(
|
tiles.push(host_tile(
|
||||||
&k.fp_hex,
|
&k.fp_hex,
|
||||||
&hover,
|
&hover,
|
||||||
&k.name,
|
&k.name,
|
||||||
&format!("{}:{}", k.addr, k.port),
|
&format!("{}:{}", k.addr, k.port),
|
||||||
status_row(
|
status_row_with(
|
||||||
Some(online),
|
Some(online),
|
||||||
if k.paired { "Paired" } else { "Trusted" },
|
if k.paired { "Paired" } else { "Trusted" },
|
||||||
if k.paired { Pill::Good } else { Pill::Info },
|
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(menu),
|
||||||
Some(Box::new(move || {
|
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));
|
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()),
|
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||||
pair_optional: h.pair == "optional",
|
pair_optional: h.pair == "optional",
|
||||||
mac: h.mac.clone(),
|
mac: h.mac.clone(),
|
||||||
|
profile: None,
|
||||||
};
|
};
|
||||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||||
let (badge, kind) = if h.pair == "required" {
|
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,
|
fp_hex: None,
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
mac: Vec::new(),
|
mac: Vec::new(),
|
||||||
|
profile: None,
|
||||||
},
|
},
|
||||||
&ss,
|
&ss,
|
||||||
&st,
|
&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
|
/// 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.
|
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||||
pub(crate) mac: Vec<String>,
|
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
|
/// 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
|
// 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).
|
// 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);
|
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
|
// 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
|
// (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.
|
// 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,
|
&set_settings_scope,
|
||||||
&settings_delete,
|
&settings_delete,
|
||||||
&set_settings_delete,
|
&set_settings_delete,
|
||||||
|
settings_rev,
|
||||||
|
&set_settings_rev,
|
||||||
nav_progress,
|
nav_progress,
|
||||||
),
|
),
|
||||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
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.
|
/// The layer the settings screen is editing, resolved for display: `None` = the defaults.
|
||||||
fn active_profile(scope: &str) -> Option<StreamProfile> {
|
fn active_profile(scope: &str) -> Option<StreamProfile> {
|
||||||
(!scope.is_empty())
|
(!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
|
/// 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
|
/// 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.
|
/// 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 {
|
fn described(control: impl Into<Element>, caption: &str) -> Element {
|
||||||
vstack((
|
vstack((
|
||||||
control.into(),
|
control.into(),
|
||||||
@@ -329,6 +425,8 @@ pub(crate) fn settings_page(
|
|||||||
set_scope: &AsyncSetState<String>,
|
set_scope: &AsyncSetState<String>,
|
||||||
delete_pending: &Option<String>,
|
delete_pending: &Option<String>,
|
||||||
set_delete: &AsyncSetState<Option<String>>,
|
set_delete: &AsyncSetState<Option<String>>,
|
||||||
|
rev: u64,
|
||||||
|
set_rev: &AsyncSetState<u64>,
|
||||||
progress: f64,
|
progress: f64,
|
||||||
) -> Element {
|
) -> Element {
|
||||||
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
|
// 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 => "",
|
None => "",
|
||||||
};
|
};
|
||||||
let profile_mode = active.is_some();
|
let profile_mode = active.is_some();
|
||||||
// Every control shows the EFFECTIVE value: the global underneath with this profile's
|
// Which rows this profile overrides — the marker + reset each of them carries. In the
|
||||||
// overrides on top, so a row the profile doesn't override reads as the live global.
|
// 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 s = {
|
||||||
let base = ctx.settings.lock().unwrap().clone();
|
let base = ctx.settings.lock().unwrap().clone();
|
||||||
match &active {
|
match &active {
|
||||||
@@ -602,14 +704,22 @@ pub(crate) fn settings_page(
|
|||||||
let mut out = group(
|
let mut out = group(
|
||||||
Some("Resolution"),
|
Some("Resolution"),
|
||||||
vec![
|
vec![
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"resolution",
|
||||||
|
over.resolution,
|
||||||
res_combo,
|
res_combo,
|
||||||
"The host drives a real virtual output at exactly this size \u{2014} true \
|
"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 \
|
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 \
|
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
|
||||||
(1:1) through every resize.",
|
(1:1) through every resize.",
|
||||||
),
|
),
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"refresh_hz",
|
||||||
|
over.refresh_hz,
|
||||||
hz_combo,
|
hz_combo,
|
||||||
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
||||||
connect.",
|
connect.",
|
||||||
@@ -620,24 +730,40 @@ pub(crate) fn settings_page(
|
|||||||
out.extend(group(
|
out.extend(group(
|
||||||
Some("Quality"),
|
Some("Quality"),
|
||||||
vec![
|
vec![
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"render_scale",
|
||||||
|
over.render_scale,
|
||||||
scale_combo,
|
scale_combo,
|
||||||
"Above native supersamples for sharpness; below renders lighter on the \
|
"Above native supersamples for sharpness; below renders lighter on the \
|
||||||
host and the link. This device resamples the result to the window.",
|
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,
|
bitrate_box,
|
||||||
"0 lets the host decide (its default, clamped to what it supports). A \
|
"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.",
|
host card\u{2019}s context menu has a network speed test.",
|
||||||
),
|
),
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"codec",
|
||||||
|
over.codec,
|
||||||
codec_combo,
|
codec_combo,
|
||||||
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
|
"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 \
|
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 \
|
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
|
||||||
gigabit Ethernet.",
|
gigabit Ethernet.",
|
||||||
),
|
),
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"hdr_enabled",
|
||||||
|
over.hdr_enabled,
|
||||||
hdr_toggle,
|
hdr_toggle,
|
||||||
"HDR10, when the host has HDR content and this display supports it. \
|
"HDR10, when the host has HDR content and this display supports it. \
|
||||||
HEVC only; otherwise the stream stays SDR.",
|
HEVC only; otherwise the stream stays SDR.",
|
||||||
@@ -670,7 +796,11 @@ pub(crate) fn settings_page(
|
|||||||
));
|
));
|
||||||
out.extend(group(
|
out.extend(group(
|
||||||
Some("Host output"),
|
Some("Host output"),
|
||||||
vec![described(
|
vec![described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"compositor",
|
||||||
|
over.compositor,
|
||||||
comp_combo,
|
comp_combo,
|
||||||
"The backend the host uses for its virtual output (Linux hosts only). A \
|
"The backend the host uses for its virtual output (Linux hosts only). A \
|
||||||
specific choice falls back to auto-detection when that backend \
|
specific choice falls back to auto-detection when that backend \
|
||||||
@@ -684,7 +814,11 @@ pub(crate) fn settings_page(
|
|||||||
"input" => {
|
"input" => {
|
||||||
let mut out = group(
|
let mut out = group(
|
||||||
Some("Touch & pointer"),
|
Some("Touch & pointer"),
|
||||||
vec![described(
|
vec![described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"touch_mode",
|
||||||
|
over.touch_mode,
|
||||||
touch_combo,
|
touch_combo,
|
||||||
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
|
"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 \
|
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
|
||||||
@@ -695,19 +829,31 @@ pub(crate) fn settings_page(
|
|||||||
out.extend(group(
|
out.extend(group(
|
||||||
Some("Keyboard & mouse"),
|
Some("Keyboard & mouse"),
|
||||||
vec![
|
vec![
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"mouse_mode",
|
||||||
|
over.mouse_mode,
|
||||||
mouse_combo,
|
mouse_combo,
|
||||||
"Capture locks the pointer to the stream and sends relative motion — \
|
"Capture locks the pointer to the stream and sends relative motion — \
|
||||||
best for games. Desktop leaves the pointer free to enter and leave \
|
best for games. Desktop leaves the pointer free to enter and leave \
|
||||||
the stream and sends absolute positions — best for remote desktop \
|
the stream and sends absolute positions — best for remote desktop \
|
||||||
work. Ctrl+Alt+Shift+M switches live.",
|
work. Ctrl+Alt+Shift+M switches live.",
|
||||||
),
|
),
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"inhibit_shortcuts",
|
||||||
|
over.inhibit_shortcuts,
|
||||||
shortcuts_toggle,
|
shortcuts_toggle,
|
||||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
||||||
has input captured. Off, they act on this machine instead.",
|
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,
|
invert_scroll_toggle,
|
||||||
"Reverses the wheel and trackpad scroll direction sent to the host.",
|
"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.",
|
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,
|
pad_combo,
|
||||||
"The virtual pad created on the host. Automatic matches your controller \
|
"The virtual pad created on the host. Automatic matches your controller \
|
||||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
||||||
@@ -750,12 +900,20 @@ pub(crate) fn settings_page(
|
|||||||
group(
|
group(
|
||||||
None,
|
None,
|
||||||
vec![
|
vec![
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"audio_channels",
|
||||||
|
over.audio_channels,
|
||||||
channels_combo,
|
channels_combo,
|
||||||
"The speaker layout requested from the host. It downmixes if its own \
|
"The speaker layout requested from the host. It downmixes if its own \
|
||||||
output has fewer channels.",
|
output has fewer channels.",
|
||||||
),
|
),
|
||||||
described(
|
described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"mic_enabled",
|
||||||
|
over.mic_enabled,
|
||||||
mic_toggle,
|
mic_toggle,
|
||||||
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
|
"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(
|
let mut out = group(
|
||||||
Some("Session"),
|
Some("Session"),
|
||||||
vec![described(
|
vec![described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"fullscreen_on_stream",
|
||||||
|
over.fullscreen_on_stream,
|
||||||
fullscreen_toggle,
|
fullscreen_toggle,
|
||||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
||||||
live.",
|
live.",
|
||||||
@@ -796,7 +958,11 @@ pub(crate) fn settings_page(
|
|||||||
);
|
);
|
||||||
out.extend(group(
|
out.extend(group(
|
||||||
Some("Statistics"),
|
Some("Statistics"),
|
||||||
vec![described(
|
vec![described_overridable(
|
||||||
|
(rev, set_rev),
|
||||||
|
scope,
|
||||||
|
"stats_verbosity",
|
||||||
|
over.stats_verbosity,
|
||||||
hud_combo,
|
hud_combo,
|
||||||
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
|
"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 \
|
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{Screen, Svc};
|
use super::{Screen, Svc};
|
||||||
use crate::probe::run_speed_probe;
|
use crate::probe::run_speed_probe;
|
||||||
|
use crate::trust::KnownHosts;
|
||||||
|
use pf_client_core::profiles::ProfilesFile;
|
||||||
use windows_reactor::*;
|
use windows_reactor::*;
|
||||||
|
|
||||||
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via
|
/// 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,
|
recommended_kbps,
|
||||||
} => {
|
} => {
|
||||||
let recommended_mbps = f64::from(*recommended_kbps) / 1000.0;
|
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 apply_btn = {
|
||||||
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
|
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
|
||||||
button(format!("Use {recommended_mbps:.0} Mb/s"))
|
let profile = profile.clone();
|
||||||
.accent()
|
button(match &profile {
|
||||||
.icon(Symbol::Accept)
|
Some(p) => format!(
|
||||||
.on_click(move || {
|
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
|
||||||
let mut s = ctx.settings.lock().unwrap();
|
p.name
|
||||||
s.bitrate_kbps = kbps;
|
),
|
||||||
s.save();
|
None => format!("Use {recommended_mbps:.0} Mb/s"),
|
||||||
ss.call(Screen::Hosts);
|
})
|
||||||
})
|
.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(
|
let results = card(
|
||||||
vstack((
|
vstack((
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ pub(crate) fn spawn_session(
|
|||||||
connect_timeout_secs: u64,
|
connect_timeout_secs: u64,
|
||||||
fullscreen: bool,
|
fullscreen: bool,
|
||||||
launch: Option<&str>,
|
launch: Option<&str>,
|
||||||
|
profile: Option<&str>,
|
||||||
slot: SessionChild,
|
slot: SessionChild,
|
||||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -117,6 +118,11 @@ pub(crate) fn spawn_session(
|
|||||||
if let Some(id) = launch {
|
if let Some(id) = launch {
|
||||||
cmd.arg("--launch").arg(id);
|
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);
|
add_window_pos(&mut cmd);
|
||||||
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,11 +387,20 @@ pub(crate) struct HdrP010Converter {
|
|||||||
cbuf: ID3D11Buffer,
|
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 {
|
impl HdrP010Converter {
|
||||||
/// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the
|
/// `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
|
/// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's
|
||||||
/// `recreate_ring` already drops it).
|
/// `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
|
// 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
|
// 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
|
// `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
|
/// 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
|
/// [`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).
|
/// 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,
|
device: &ID3D11Device,
|
||||||
dst: &ID3D11Texture2D,
|
dst: &ID3D11Texture2D,
|
||||||
format: DXGI_FORMAT,
|
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
|
/// 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`),
|
/// 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.
|
/// the buffer to this converter, which is already rebuilt on every mode change.
|
||||||
pub(crate) unsafe fn convert(
|
pub(crate) fn convert(
|
||||||
&self,
|
&self,
|
||||||
ctx: &ID3D11DeviceContext,
|
ctx: &ID3D11DeviceContext,
|
||||||
src_srv: &ID3D11ShaderResourceView,
|
src_srv: &ID3D11ShaderResourceView,
|
||||||
@@ -693,7 +702,7 @@ pub(crate) struct BgraToYuvPlanes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
// SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live
|
||||||
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
|
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -731,7 +740,7 @@ impl BgraToYuvPlanes {
|
|||||||
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
|
/// 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).
|
/// passes; `w`/`h` are the full luma dims (even for 4:2:0).
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) unsafe fn convert(
|
pub(crate) fn convert(
|
||||||
&self,
|
&self,
|
||||||
ctx: &ID3D11DeviceContext,
|
ctx: &ID3D11DeviceContext,
|
||||||
src_srv: &ID3D11ShaderResourceView,
|
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
|
/// 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
|
/// 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.
|
/// path is [`HdrP010Converter`]'s job, never this one.
|
||||||
pub(crate) unsafe fn new(
|
pub(crate) fn new(
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
context: &ID3D11DeviceContext,
|
context: &ID3D11DeviceContext,
|
||||||
width: u32,
|
width: u32,
|
||||||
@@ -897,11 +906,7 @@ impl VideoConverter {
|
|||||||
/// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output`
|
/// 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
|
/// (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.
|
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
|
||||||
pub(crate) unsafe fn convert(
|
pub(crate) fn convert(&self, input: &ID3D11Texture2D, output: &ID3D11Texture2D) -> Result<()> {
|
||||||
&self,
|
|
||||||
input: &ID3D11Texture2D,
|
|
||||||
output: &ID3D11Texture2D,
|
|
||||||
) -> Result<()> {
|
|
||||||
// SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized
|
// 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
|
// 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
|
// 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
|
// nothing downstream could detect the mismatch, because every field it would compare against
|
||||||
// had already been moved. Nothing below this line fails.
|
// had already been moved. Nothing below this line fails.
|
||||||
let fmt = Self::ring_format_for(new_display_hdr);
|
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
|
let new_slots = Self::create_ring_slots(&self.device, new_w, new_h, fmt)?;
|
||||||
// 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)? };
|
|
||||||
self.display_hdr = new_display_hdr;
|
self.display_hdr = new_display_hdr;
|
||||||
self.width = new_w;
|
self.width = new_w;
|
||||||
self.height = new_h;
|
self.height = new_h;
|
||||||
@@ -969,11 +965,11 @@ impl IddPushCapturer {
|
|||||||
/// mid-session mode swap.
|
/// mid-session mode swap.
|
||||||
fn ensure_pyro_conv(&mut self) -> Result<()> {
|
fn ensure_pyro_conv(&mut self) -> Result<()> {
|
||||||
if self.pyro_conv.is_none() {
|
if self.pyro_conv.is_none() {
|
||||||
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
|
self.pyro_conv = Some(BgraToYuvPlanes::new(
|
||||||
// failure before it is stored.
|
&self.device,
|
||||||
self.pyro_conv = Some(unsafe {
|
self.display_hdr,
|
||||||
BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)?
|
self.want_444,
|
||||||
});
|
)?);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -984,22 +980,22 @@ impl IddPushCapturer {
|
|||||||
fn ensure_converter(&mut self) -> Result<()> {
|
fn ensure_converter(&mut self) -> Result<()> {
|
||||||
if self.display_hdr {
|
if self.display_hdr {
|
||||||
if self.hdr_p010_conv.is_none() {
|
if self.hdr_p010_conv.is_none() {
|
||||||
// SAFETY: `HdrP010Converter::new` is `unsafe` (it compiles D3D11 shaders + creates
|
self.hdr_p010_conv = Some(HdrP010Converter::new(
|
||||||
// resources); we pass a live borrow of `self.device`, the device the converter's resources
|
&self.device,
|
||||||
// belong to, and `?` propagates any failure before the converter is stored.
|
self.width,
|
||||||
self.hdr_p010_conv =
|
self.height,
|
||||||
Some(unsafe { HdrP010Converter::new(&self.device, self.width, self.height)? });
|
)?);
|
||||||
}
|
}
|
||||||
} else if self.want_444 {
|
} else if self.want_444 {
|
||||||
// Full-chroma passthrough — no conversion resources to build.
|
// Full-chroma passthrough — no conversion resources to build.
|
||||||
} else if self.video_conv.is_none() {
|
} else if self.video_conv.is_none() {
|
||||||
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
|
self.video_conv = Some(VideoConverter::new(
|
||||||
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
|
&self.device,
|
||||||
// plain `u32` dimensions, and `?` propagates any failure before it is stored. The converter's
|
&self.context,
|
||||||
// resources belong to that same device/context.
|
self.width,
|
||||||
self.video_conv = Some(unsafe {
|
self.height,
|
||||||
VideoConverter::new(&self.device, &self.context, self.width, self.height, false)?
|
false,
|
||||||
});
|
)?);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ pub(super) struct CursorBlendPass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
|
||||||
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
|
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
|
||||||
// literals (its contract).
|
// literals (its contract).
|
||||||
@@ -116,11 +116,7 @@ impl CursorBlendPass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
||||||
unsafe fn ensure_shape(
|
fn ensure_shape(&mut self, device: &ID3D11Device, ov: &pf_frame::CursorOverlay) -> Result<()> {
|
||||||
&mut self,
|
|
||||||
device: &ID3D11Device,
|
|
||||||
ov: &pf_frame::CursorOverlay,
|
|
||||||
) -> Result<()> {
|
|
||||||
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
|
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
|
||||||
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
|
// `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
|
// 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
|
/// 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
|
/// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
|
||||||
/// the target automatically.
|
/// the target automatically.
|
||||||
pub(super) unsafe fn blend(
|
pub(super) fn blend(
|
||||||
&mut self,
|
&mut self,
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
ctx: &ID3D11DeviceContext,
|
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
|
/// 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
|
/// 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.
|
/// 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,
|
device: &ID3D11Device,
|
||||||
w: u32,
|
w: u32,
|
||||||
h: u32,
|
h: u32,
|
||||||
|
|||||||
@@ -137,9 +137,10 @@ impl Capturer for SyntheticNv12Capturer {
|
|||||||
/// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference /
|
/// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference /
|
||||||
/// max-VRAM LUID), falling back to adapter 0.
|
/// max-VRAM LUID), falling back to adapter 0.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// Safe: it takes no arguments, so there is nothing a caller could get wrong — it creates the
|
||||||
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
/// factory itself and returns an owned adapter. The `# Safety` section it used to carry ("calls
|
||||||
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
/// 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
|
// 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.
|
// created here and the adapters it returns own their own COM references. No raw pointers.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -155,9 +156,10 @@ unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
|||||||
|
|
||||||
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// Safe: its old `# Safety` asked for "a live D3D11 device", which `&ID3D11Device` — a borrowed,
|
||||||
/// `device` must be a live D3D11 device; the returned texture is owned by the caller.
|
/// reference-counted COM wrapper — already guarantees; the rest ("the returned texture is owned by
|
||||||
unsafe fn create_nv12(
|
/// the caller") is an ownership note, not a soundness obligation. Every flag is a plain scalar.
|
||||||
|
fn create_nv12(
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
@@ -181,8 +183,8 @@ unsafe fn create_nv12(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut tex: Option<ID3D11Texture2D> = None;
|
let mut tex: Option<ID3D11Texture2D> = None;
|
||||||
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
|
// SAFETY: one `?`-checked `CreateTexture2D` on the `&ID3D11Device` borrow, which the borrow
|
||||||
// above), with a fully-initialized stack descriptor and a live `Option` out-param.
|
// itself keeps live, with a fully-initialized stack descriptor and a live `Option` out-param.
|
||||||
unsafe {
|
unsafe {
|
||||||
device
|
device
|
||||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
.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)]
|
#[cfg(windows)]
|
||||||
mod os {
|
mod os {
|
||||||
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
|
//! 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
|
/// 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
|
/// back-pressures against the presenter still reading this slot (only possible if the
|
||||||
/// stream runs `RING_SLOTS` ahead of present).
|
/// stream runs `RING_SLOTS` ahead of present).
|
||||||
unsafe fn lift(&mut self) -> Result<D3d11Frame> {
|
fn lift(&mut self) -> Result<D3d11Frame> {
|
||||||
use ffmpeg::ffi;
|
use ffmpeg::ffi;
|
||||||
unsafe {
|
unsafe {
|
||||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
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:
|
/// 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 →
|
/// 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).
|
/// `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;
|
use ffmpeg::ffi;
|
||||||
unsafe {
|
unsafe {
|
||||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
|
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).
|
//! 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)]
|
#![allow(clippy::unnecessary_cast)]
|
||||||
|
|
||||||
use crate::video::{
|
use crate::video::{
|
||||||
@@ -67,25 +61,45 @@ struct VkCtxStorage {
|
|||||||
/// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default,
|
/// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default,
|
||||||
/// which only serializes FFmpeg against itself — the presenter submits to the same
|
/// 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.
|
/// 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(
|
unsafe extern "C" fn ffvk_lock_queue(
|
||||||
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
||||||
_queue_family: u32,
|
_queue_family: u32,
|
||||||
_index: u32,
|
_index: u32,
|
||||||
) {
|
) {
|
||||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
// SAFETY: `ctx` is the live context FFmpeg passes to its own callback, and the two
|
||||||
let lock = (*dev).user_opaque as *const QueueLock;
|
// `AVHWDeviceContext` declarations (pf_ffvk's and ffmpeg-sys's) describe the same C struct, so
|
||||||
(*lock).lock();
|
// 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`].
|
/// 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(
|
unsafe extern "C" fn ffvk_unlock_queue(
|
||||||
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
||||||
_queue_family: u32,
|
_queue_family: u32,
|
||||||
_index: u32,
|
_index: u32,
|
||||||
) {
|
) {
|
||||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
// SAFETY: as `ffvk_lock_queue` — same live context from FFmpeg, same `user_opaque` pointer into
|
||||||
let lock = (*dev).user_opaque as *const QueueLock;
|
// the `Arc<QueueLock>` that `VkCtxStorage` keeps alive for the context's whole lifetime.
|
||||||
(*lock).unlock();
|
unsafe {
|
||||||
|
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||||
|
let lock = (*dev).user_opaque as *const QueueLock;
|
||||||
|
(*lock).unlock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VulkanDecoder {
|
impl VulkanDecoder {
|
||||||
@@ -306,7 +320,7 @@ impl VulkanDecoder {
|
|||||||
/// guard — keeps the image + frames context alive through present) and ship the
|
/// 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
|
/// POINTERS; the presenter reads the live sync state under the frames-context lock
|
||||||
/// at its own submit time.
|
/// at its own submit time.
|
||||||
unsafe fn extract(&mut self) -> Result<VkVideoFrame> {
|
fn extract(&mut self) -> Result<VkVideoFrame> {
|
||||||
use ffmpeg::ffi;
|
use ffmpeg::ffi;
|
||||||
unsafe {
|
unsafe {
|
||||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 {
|
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).
|
//! 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
|
//! 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.
|
//! 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).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![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> {
|
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
|
// 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.
|
// 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()))
|
// SAFETY: `av_hwdevice_ctx_alloc` returns either null — which `AvBuffer::from_raw` rejects,
|
||||||
.context("av_hwframe_ctx_alloc failed")?;
|
// so the `?` returns before anything below runs — or a fresh ref whose `data` libav has
|
||||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
// already initialized as an `AVHWDeviceContext`. For a CUDA device that context's `hwctx`
|
||||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
// is an `AVCUDADeviceContext` (our repr(C) mirror of libav's layout), so writing
|
||||||
(*fc).sw_format = pixel_to_av(sw_format);
|
// `cuda_ctx` is an in-bounds field store on a live allocation, and `cu_ctx` is a valid
|
||||||
(*fc).width = w as c_int;
|
// `CUcontext` by this fn's contract. `av_hwdevice_ctx_init` then takes the same live ref;
|
||||||
(*fc).height = h as c_int;
|
// it must see `cuda_ctx` already set, which is why the store precedes it.
|
||||||
(*fc).initial_pool_size = 0; // we supply the device pointers
|
let device_ref = unsafe {
|
||||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
||||||
if r < 0 {
|
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||||
bail!("av_hwframe_ctx_init failed ({r})");
|
))
|
||||||
}
|
.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 {
|
Ok(CudaHw {
|
||||||
frames_ref,
|
frames_ref,
|
||||||
device_ref,
|
device_ref,
|
||||||
|
|||||||
@@ -19,13 +19,6 @@
|
|||||||
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
//! 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
|
//! 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.
|
//! *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).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![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
|
/// 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
|
/// 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
|
/// *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
|
/// `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.)
|
/// matrix untouched.)
|
||||||
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
|
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
|
||||||
if ten_bit {
|
if ten_bit {
|
||||||
c"format=p010:out_color_matrix=bt2020:out_range=limited"
|
c"format=p010:out_color_matrix=bt2020nc:out_range=limited"
|
||||||
} else {
|
} else {
|
||||||
c"format=nv12:out_color_matrix=bt709:out_range=limited"
|
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 modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
|
||||||
let mut first_err = None;
|
let mut first_err = None;
|
||||||
for &lp in modes {
|
for &lp in modes {
|
||||||
match open_vaapi_encoder_mode(
|
// SAFETY: `device_ref`/`frames_ref` are forwarded unchanged from this fn's own contract —
|
||||||
codec,
|
// valid `AVBufferRef`s — and the callee only `av_buffer_ref`s them, so retrying another
|
||||||
width,
|
// entrypoint below reuses the same still-owned buffers rather than consuming them.
|
||||||
height,
|
let attempt = unsafe {
|
||||||
fps,
|
open_vaapi_encoder_mode(
|
||||||
bitrate_bps,
|
codec,
|
||||||
device_ref,
|
width,
|
||||||
frames_ref,
|
height,
|
||||||
ten_bit,
|
fps,
|
||||||
lp,
|
bitrate_bps,
|
||||||
) {
|
device_ref,
|
||||||
|
frames_ref,
|
||||||
|
ten_bit,
|
||||||
|
lp,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
match attempt {
|
||||||
Ok(enc) => {
|
Ok(enc) => {
|
||||||
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
|
||||||
m.insert(key.clone(), latched_mode(lp));
|
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 });
|
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.
|
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||||
let raw = video.as_mut_ptr();
|
// SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated
|
||||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
// just above, which outlives every write here (it is moved into the return value). The
|
||||||
let vui = vui_for(ten_bit);
|
// colour/gop/pix_fmt stores are in-bounds scalar field writes on that live context. The two
|
||||||
(*raw).colorspace = vui.colorspace;
|
// `av_buffer_ref` calls take `device_ref`/`frames_ref`, valid `AVBufferRef`s by this fn's
|
||||||
(*raw).color_range = vui.range;
|
// contract, and each returns a NEW reference that the codec context adopts and unrefs when it
|
||||||
(*raw).color_primaries = vui.primaries;
|
// is freed — so this shares the caller's buffers rather than taking them over.
|
||||||
(*raw).color_trc = vui.trc;
|
unsafe {
|
||||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
let raw = video.as_mut_ptr();
|
||||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
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();
|
let mut opts = Dictionary::new();
|
||||||
if let Some(profile) = explicit_profile(codec, ten_bit) {
|
if let Some(profile) = explicit_profile(codec, ten_bit) {
|
||||||
@@ -479,35 +486,52 @@ struct VaapiHw {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 mut device_ref: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||||
let node = render_node();
|
let node = render_node();
|
||||||
let r = ffi::av_hwdevice_ctx_create(
|
// SAFETY: `device_ref` is a live local out-param; `node` is a NUL-terminated `CString` that
|
||||||
&mut device_ref,
|
// outlives the call, and the remaining arguments are the documented "no options" pair. On
|
||||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
// success libav writes ONE owned reference into `device_ref`.
|
||||||
node.as_ptr(),
|
let r = unsafe {
|
||||||
ptr::null_mut(),
|
ffi::av_hwdevice_ctx_create(
|
||||||
0,
|
&mut device_ref,
|
||||||
);
|
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||||
|
node.as_ptr(),
|
||||||
|
ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
if r < 0 {
|
if r < 0 {
|
||||||
bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r));
|
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
|
// `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.
|
// 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")?;
|
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
|
||||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
// SAFETY: the same shape as `CudaHw::new` — `av_hwframe_ctx_alloc` is handed the live device
|
||||||
.context("av_hwframe_ctx_alloc(VAAPI) failed")?;
|
// ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes below)
|
||||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
// or a ref whose `data` libav has already initialized as an `AVHWFramesContext`. Every store
|
||||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
// is then an in-bounds scalar write on that live allocation, done before
|
||||||
(*fc).sw_format = sw_format;
|
// `av_hwframe_ctx_init` reads them.
|
||||||
(*fc).width = w as c_int;
|
let frames_ref = unsafe {
|
||||||
(*fc).height = h as c_int;
|
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||||
(*fc).initial_pool_size = pool;
|
.context("av_hwframe_ctx_alloc(VAAPI) failed")?;
|
||||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||||
if r < 0 {
|
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||||
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
|
(*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 {
|
Ok(VaapiHw {
|
||||||
frames_ref,
|
frames_ref,
|
||||||
device_ref,
|
device_ref,
|
||||||
@@ -548,12 +572,11 @@ impl CpuInner {
|
|||||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
||||||
};
|
};
|
||||||
const POOL: c_int = 16;
|
const POOL: c_int = 16;
|
||||||
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
|
// `VaapiHw::new` is safe now; it returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
|
||||||
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
|
// on drop. (libav is initialized on every path here — `VaapiEncoder::open` → `ensure_inner`
|
||||||
// `ffmpeg::init()`. The args are valid: an NV12/P010 sw_format, the validated positive
|
// → `CpuInner::open`, and `open` ran `ffmpeg::init()` — which the call relies on but cannot
|
||||||
// `width`/`height`, pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
|
// itself be broken by a caller, so it is a note rather than a contract.)
|
||||||
// on drop.
|
let hw = VaapiHw::new(staging_av, width, height, POOL)?;
|
||||||
let hw = unsafe { VaapiHw::new(staging_av, width, height, POOL)? };
|
|
||||||
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
|
// 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
|
// 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
|
// 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();
|
let args = scale_vaapi_args(true).to_str().unwrap();
|
||||||
for needle in [
|
for needle in [
|
||||||
"format=p010",
|
"format=p010",
|
||||||
"out_color_matrix=bt2020",
|
"out_color_matrix=bt2020nc",
|
||||||
"out_range=limited",
|
"out_range=limited",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -37,13 +37,6 @@
|
|||||||
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
||||||
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
||||||
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
|
//! 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).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
@@ -337,29 +330,40 @@ unsafe fn open_win_encoder(
|
|||||||
video.set_format(Pixel::from(sw_pix_fmt));
|
video.set_format(Pixel::from(sw_pix_fmt));
|
||||||
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
|
||||||
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
apply_low_latency_rc(&mut video, fps, bitrate_bps);
|
||||||
let raw = video.as_mut_ptr();
|
// SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated just
|
||||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
// above, which outlives every write here (it is opened and returned below). The gop/colour/
|
||||||
if ten_bit {
|
// pix_fmt stores are in-bounds scalar field writes on that live context. `device_ref` and
|
||||||
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
|
// `frames_ref` are valid `AVBufferRef`s by this fn's contract OR null — the system path passes
|
||||||
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
|
// null for both — and the `is_null` guards keep `av_buffer_ref` off the null case; each call
|
||||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
// returns a NEW reference that the codec context adopts and unrefs when freed, so this shares
|
||||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
// the caller's buffers rather than taking them over.
|
||||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
let raw = unsafe { video.as_mut_ptr() };
|
||||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
// SAFETY: as above — `raw` is that live `AVCodecContext` and every store below is an in-bounds
|
||||||
} else {
|
// field write on it, with the two `av_buffer_ref` calls guarded against null.
|
||||||
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
|
unsafe {
|
||||||
// VUI — else the client decoder washes the picture out.
|
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
if ten_bit {
|
||||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
|
||||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
|
||||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
|
||||||
}
|
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||||
(*raw).pix_fmt = pix_fmt;
|
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||||
if !device_ref.is_null() {
|
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
|
} else {
|
||||||
}
|
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
|
||||||
if !frames_ref.is_null() {
|
// VUI — else the client decoder washes the picture out.
|
||||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
|
(*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).
|
// 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`).
|
/// 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
|
// 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.
|
// only on the `_Impl` trait, for implementing the interface). Every D3D11 device has one.
|
||||||
device
|
// SAFETY: a `?`-free COM call on the live `device` borrow; it takes no pointers and every
|
||||||
.GetImmediateContext()
|
// D3D11 device has an immediate context, so the `expect` is unreachable in practice.
|
||||||
.expect("ID3D11Device always has an immediate context")
|
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.
|
/// Lazily (re)build the staging texture matching `dxgi_fmt` on the captured device.
|
||||||
unsafe fn ensure_staging(
|
///
|
||||||
&mut self,
|
/// Safe: `&ID3D11Device` is the live-device guarantee and `dxgi_fmt` is a plain enum; the
|
||||||
device: &ID3D11Device,
|
/// texture it creates is owned by `self`.
|
||||||
dxgi_fmt: DXGI_FORMAT,
|
fn ensure_staging(&mut self, device: &ID3D11Device, dxgi_fmt: DXGI_FORMAT) -> Result<()> {
|
||||||
) -> Result<()> {
|
|
||||||
if self.staging.is_some() {
|
if self.staging.is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -562,25 +572,38 @@ impl SystemInner {
|
|||||||
MiscFlags: 0,
|
MiscFlags: 0,
|
||||||
};
|
};
|
||||||
let mut t: Option<ID3D11Texture2D> = None;
|
let mut t: Option<ID3D11Texture2D> = None;
|
||||||
device
|
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow, over a
|
||||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
// fully-initialized stack descriptor and a live `Option` out-param.
|
||||||
.context("CreateTexture2D(staging readback)")?;
|
unsafe {
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||||
|
.context("CreateTexture2D(staging readback)")?;
|
||||||
|
}
|
||||||
self.staging = t;
|
self.staging = t;
|
||||||
self.ctx = Some(immediate_context(device));
|
self.ctx = Some(immediate_context(device));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send the reusable `sw_frame` to the encoder with the given pts / IDR flag.
|
/// 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;
|
/// Safe: both arguments are scalars, and `sw_frame`/`enc` are allocations `self` owns from its
|
||||||
(*self.sw_frame).pict_type = if idr {
|
/// constructor until `Drop` — no caller supplies or can invalidate them.
|
||||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
|
||||||
} else {
|
// SAFETY: `self.sw_frame` is the `AVFrame` this struct allocated and owns, so the two field
|
||||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
// 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
|
||||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
|
// (it references the frame's buffers itself).
|
||||||
if r < 0 {
|
unsafe {
|
||||||
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
|
(*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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -807,7 +830,10 @@ impl SystemInner {
|
|||||||
/// Lazily build the swscale context (src → NV12/P010, limited range, the given colorspace). A
|
/// 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
|
/// 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.
|
/// 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,
|
&mut self,
|
||||||
src_av: ffi::AVPixelFormat,
|
src_av: ffi::AVPixelFormat,
|
||||||
dst_av: ffi::AVPixelFormat,
|
dst_av: ffi::AVPixelFormat,
|
||||||
@@ -816,25 +842,33 @@ impl SystemInner {
|
|||||||
if !self.sws.is_null() {
|
if !self.sws.is_null() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let sws = ffi::sws_getContext(
|
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
|
||||||
self.width as c_int,
|
// null trio, and returns an owned context or null — which is checked before use, so
|
||||||
self.height as c_int,
|
// `sws_setColorspaceDetails` and the store below only ever see a live one.
|
||||||
src_av,
|
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
|
||||||
self.width as c_int,
|
// process, and the call only reads it.
|
||||||
self.height as c_int,
|
let sws = unsafe {
|
||||||
dst_av,
|
let sws = ffi::sws_getContext(
|
||||||
SWS_POINT,
|
self.width as c_int,
|
||||||
ptr::null_mut(),
|
self.height as c_int,
|
||||||
ptr::null_mut(),
|
src_av,
|
||||||
ptr::null(),
|
self.width as c_int,
|
||||||
);
|
self.height as c_int,
|
||||||
if sws.is_null() {
|
dst_av,
|
||||||
bail!("sws_getContext(RGB→YUV) failed");
|
SWS_POINT,
|
||||||
}
|
ptr::null_mut(),
|
||||||
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI we
|
ptr::null_mut(),
|
||||||
// signal). For RGB input the src coefficient table is unused; pass the dst table for both.
|
ptr::null(),
|
||||||
let coeff = ffi::sws_getCoefficients(cs);
|
);
|
||||||
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
|
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;
|
self.sws = sws;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -873,7 +907,10 @@ struct D3d11Hw {
|
|||||||
|
|
||||||
impl D3d11Hw {
|
impl D3d11Hw {
|
||||||
/// Wrap the capturer's `ID3D11Device` as a D3D11VA hwdevice and build an NV12/P010 frames pool.
|
/// 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,
|
device: &ID3D11Device,
|
||||||
sw_format: ffi::AVPixelFormat,
|
sw_format: ffi::AVPixelFormat,
|
||||||
bind_flags: u32,
|
bind_flags: u32,
|
||||||
@@ -883,12 +920,19 @@ impl D3d11Hw {
|
|||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// Owned from the moment it exists: each `bail!` below drops what was built so far, so none
|
// 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.
|
// of the failure branches carry cleanup of their own.
|
||||||
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
// SAFETY: `av_hwdevice_ctx_alloc` returns null — rejected by `AvBuffer::from_raw`, so the
|
||||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
|
// `?` 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
|
||||||
.context("av_hwdevice_ctx_alloc(D3D11VA) failed")?;
|
// `AVD3D11VADeviceContext`, so `d11` addresses a live, correctly-typed struct.
|
||||||
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
|
let (device_ref, d11) = unsafe {
|
||||||
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
|
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.
|
// 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).
|
// device's internal critical section (`was` is the previous state, reported once at debug).
|
||||||
match device.cast::<ID3D11Multithread>() {
|
match device.cast::<ID3D11Multithread>() {
|
||||||
Ok(mt) => {
|
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!(
|
tracing::debug!(
|
||||||
previously_protected = was.as_bool(),
|
previously_protected = was.as_bool(),
|
||||||
"D3D11 multithread protection enabled for the libav hwdevice"
|
"D3D11 multithread protection enabled for the libav hwdevice"
|
||||||
@@ -931,26 +977,40 @@ impl D3d11Hw {
|
|||||||
// reference (clone = AddRef, forget = don't Release ours). init() fills
|
// reference (clone = AddRef, forget = don't Release ours). init() fills
|
||||||
// device_context / video_device / video_context / the default lock from a non-null device.
|
// device_context / video_device / video_context / the default lock from a non-null device.
|
||||||
std::mem::forget(device.clone());
|
std::mem::forget(device.clone());
|
||||||
(*d11).device = device.as_raw();
|
// SAFETY: `d11` is the live `AVD3D11VADeviceContext` from above, so storing the device
|
||||||
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
|
// 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 {
|
if r < 0 {
|
||||||
bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})");
|
bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})");
|
||||||
}
|
}
|
||||||
|
|
||||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
// SAFETY: same shape one level up — `av_hwframe_ctx_alloc` takes the live, now-initialized
|
||||||
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
|
// device ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes)
|
||||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
// or a ref whose `data` is a live `AVHWFramesContext` whose `hwctx` is an
|
||||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
|
// `AVD3D11VAFramesContext`. Every store is an in-bounds field write on those, all done
|
||||||
(*fc).sw_format = sw_format;
|
// before `av_hwframe_ctx_init` reads them.
|
||||||
(*fc).width = w as c_int;
|
let frames_ref = unsafe {
|
||||||
(*fc).height = h as c_int;
|
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||||
(*fc).initial_pool_size = pool;
|
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
|
||||||
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
|
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||||
(*f11).bind_flags = bind_flags;
|
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
(*fc).sw_format = sw_format;
|
||||||
if r < 0 {
|
(*fc).width = w as c_int;
|
||||||
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
|
(*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 {
|
Ok(D3d11Hw {
|
||||||
frames_ref,
|
frames_ref,
|
||||||
device_ref,
|
device_ref,
|
||||||
@@ -1494,20 +1554,25 @@ mod tests {
|
|||||||
/// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no
|
/// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no
|
||||||
/// video engine at all.
|
/// video engine at all.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// Safe: `prefer_vendor` is a plain id and every DXGI object is created here and owned by the
|
||||||
/// Calls the DXGI enumeration FFI and `make_device`; every result is checked before use and no
|
/// returned device; the old `# Safety` section described the body, not a caller obligation.
|
||||||
/// alias to the adapter outlives the call.
|
|
||||||
#[cfg(test)]
|
#[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};
|
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
|
||||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
// SAFETY: DXGI factory/adapter enumeration over owned locals — the factory is created here,
|
||||||
let mut preferred = None;
|
// each adapter it yields owns its own COM reference, and every call is `.ok()`-checked
|
||||||
let mut fallback = None;
|
// 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.. {
|
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
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
let name = String::from_utf16_lossy(&desc.Description)
|
let name = String::from_utf16_lossy(&desc.Description)
|
||||||
@@ -1521,7 +1586,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let adapter = preferred.or(fallback)?;
|
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,
|
/// Construct/drop `D3d11Hw` repeatedly on real silicon — the D3D11VA half of the RAII change,
|
||||||
@@ -1537,23 +1606,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"]
|
#[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"]
|
||||||
fn d3d11hw_alloc_drop_cycles() {
|
fn d3d11hw_alloc_drop_cycles() {
|
||||||
// SAFETY: see `test_hw_device`; the returned device outlives every `D3d11Hw` built below.
|
let device = test_hw_device(0x8086).expect("a hardware D3D11 adapter");
|
||||||
let device = unsafe { test_hw_device(0x8086) }.expect("a hardware D3D11 adapter");
|
|
||||||
for i in 0..8 {
|
for i in 0..8 {
|
||||||
// SAFETY: `D3d11Hw::new` needs libav initialised (it is, statically, by the ffmpeg-next
|
// `D3d11Hw::new` is safe now; it still needs libav initialised, which the ffmpeg-next
|
||||||
// crate's first use here) and a live `ID3D11Device`, which `device` is for the whole
|
// crate does statically on first use here. NV12 at 640x480 with an 8-surface pool are
|
||||||
// loop. NV12 at 640x480 with an 8-surface pool are valid pool parameters. The handle
|
// valid pool parameters. The handle drops at the end of each iteration — that release
|
||||||
// drops at the end of each iteration — that release is what is under test.
|
// is what is under test.
|
||||||
let hw = unsafe {
|
let hw = D3d11Hw::new(
|
||||||
D3d11Hw::new(
|
&device,
|
||||||
&device,
|
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
pool_bind_flags(WinVendor::Amf),
|
||||||
pool_bind_flags(WinVendor::Amf),
|
640,
|
||||||
640,
|
480,
|
||||||
480,
|
8,
|
||||||
8,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
.unwrap_or_else(|e| panic!("D3d11Hw::new failed on iteration {i}: {e:#}"));
|
.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.device_ref.as_ptr().is_null(), "device ref went null");
|
||||||
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
|
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
|
||||||
@@ -1587,8 +1653,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
|
#[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
|
||||||
fn zerocopy_qsv_alloc_drop_cycles() {
|
fn zerocopy_qsv_alloc_drop_cycles() {
|
||||||
// SAFETY: see `test_hw_device`; the device outlives every `ZeroCopyInner` built below.
|
let device = test_hw_device(0x8086).expect("an Intel D3D11 adapter");
|
||||||
let device = unsafe { test_hw_device(0x8086) }.expect("an Intel D3D11 adapter");
|
|
||||||
for i in 0..8 {
|
for i in 0..8 {
|
||||||
let zc = ZeroCopyInner::open(
|
let zc = ZeroCopyInner::open(
|
||||||
WinVendor::Qsv,
|
WinVendor::Qsv,
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ utoipa = { version = "5", features = ["axum_extras"] }
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
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]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
# The Mutter backend drives D-Bus RemoteDesktop + ScreenCast.RecordVirtual via ashpd on a tokio
|
# 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
|
/// 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.
|
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||||
pub fn detect() -> Result<Compositor> {
|
pub fn detect() -> Result<Compositor> {
|
||||||
if let Some(v) = pf_host_config::config().compositor.as_deref() {
|
// Compositor detection is a Linux question — the variants ARE the Linux backends. Asked
|
||||||
return compositor_from_pin(v).ok_or_else(|| {
|
// anywhere else this used to fall through to the XDG sniff below and fail with advice about
|
||||||
anyhow::anyhow!(
|
// `XDG_CURRENT_DESKTOP` and `PUNKTFUNK_COMPOSITOR`, which `mgmt/display.rs` puts VERBATIM into
|
||||||
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
|
// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
|
{
|
||||||
return Ok(c);
|
if let Some(v) = pf_host_config::config().compositor.as_deref() {
|
||||||
}
|
return compositor_from_pin(v).ok_or_else(|| {
|
||||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
anyhow::anyhow!(
|
||||||
.unwrap_or_default()
|
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
|
||||||
.to_ascii_uppercase();
|
)
|
||||||
if desktop.contains("KDE") {
|
});
|
||||||
Ok(Compositor::Kwin)
|
}
|
||||||
} else if desktop.contains("GNOME") {
|
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
|
||||||
Ok(Compositor::Mutter)
|
return Ok(c);
|
||||||
} else if desktop.contains("HYPRLAND") {
|
}
|
||||||
Ok(Compositor::Hyprland)
|
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||||
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
.unwrap_or_default()
|
||||||
Ok(Compositor::Wlroots)
|
.to_ascii_uppercase();
|
||||||
} else {
|
if desktop.contains("KDE") {
|
||||||
anyhow::bail!(
|
Ok(Compositor::Kwin)
|
||||||
"could not detect compositor: no live graphical session for this uid and \
|
} else if desktop.contains("GNOME") {
|
||||||
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
|
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.
|
/// 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
|
/// **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
|
/// §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
|
/// ADOPTED as the group's restore snapshot — otherwise it deactivates the operator's panels
|
||||||
/// with nothing able to put them back.
|
/// with nothing able to put them back.
|
||||||
@@ -984,6 +1010,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[ignore = "needs the pf-vdisplay driver on real hardware; run with --ignored"]
|
#[ignore = "needs the pf-vdisplay driver on real hardware; run with --ignored"]
|
||||||
fn live_a_failed_first_isolate_is_recovered_by_adopting_the_next() {
|
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!(
|
assert!(
|
||||||
std::env::var("PUNKTFUNK_NO_ISOLATE").is_err(),
|
std::env::var("PUNKTFUNK_NO_ISOLATE").is_err(),
|
||||||
"PUNKTFUNK_NO_ISOLATE forces Topology::Extend — this case needs Exclusive"
|
"PUNKTFUNK_NO_ISOLATE forces Topology::Extend — this case needs Exclusive"
|
||||||
@@ -1010,10 +1040,19 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("create member 1");
|
.expect("create member 1");
|
||||||
thread::sleep(Duration::from_secs(2));
|
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!(
|
println!(
|
||||||
"after member 1 (isolate INJECTED to fail): {:?}",
|
"after member 1 (isolate INJECTED to fail): {:?}",
|
||||||
active_targets()
|
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)");
|
let mut vd2 = PfVdisplayDisplay::new().expect("open pf-vdisplay (member 2)");
|
||||||
vd2.set_client_identity(Some([0xB2; 32]));
|
vd2.set_client_identity(Some([0xB2; 32]));
|
||||||
@@ -1049,11 +1088,54 @@ mod tests {
|
|||||||
println!("physicals after teardown : {physicals_after:?}");
|
println!("physicals after teardown : {physicals_after:?}");
|
||||||
assert!(
|
assert!(
|
||||||
!physicals_after.is_empty(),
|
!physicals_after.is_empty(),
|
||||||
"the operator's physical panel was left DEACTIVATED after teardown. The first \
|
"the operator's physical panel was left DEACTIVATED after teardown (sweep §5 3.2). \
|
||||||
member's isolate failed, so the group held no restore snapshot; the second member's \
|
Active targets now: {:?}.\n\
|
||||||
isolate deactivated the physicals and its snapshot was discarded (sweep §5 3.2). \
|
Which candidate this run implicates — read it off the poisoned-at-birth probe above:\n\
|
||||||
Active targets now: {:?}",
|
* physicals after member 1 was EMPTY ({m1_empty}) -> the snapshot member 2 adopted was \
|
||||||
active_targets()
|
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()
|
.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.
|
/// The active targets that are EXTERNAL PHYSICAL panels — the operator's actual desk.
|
||||||
fn active_physicals() -> Vec<(u32, String)> {
|
fn active_physicals() -> Vec<(u32, String)> {
|
||||||
pf_win_display::win_display::target_inventory()
|
pf_win_display::win_display::target_inventory()
|
||||||
@@ -1153,8 +1256,9 @@ mod tests {
|
|||||||
fn live_inplace_resize() {
|
fn live_inplace_resize() {
|
||||||
// Live-run diagnostics: surface the manager/backend tracing (activation ladder, settle
|
// 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
|
// waits, UPDATE_MODES) on stdout — a bare test harness has no subscriber, which made the
|
||||||
// first on-glass run blind.
|
// first on-glass run blind. `tracing-subscriber` is now a dev-dependency, so this case no
|
||||||
// (tracing-subscriber is not a dep of this crate — run the host binary for traced runs.)
|
// 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
|
// 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
|
// 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.)
|
// "monitor never activated" verdict would be an artifact of the test context.)
|
||||||
|
|||||||
@@ -998,6 +998,25 @@ pub struct TargetInventory {
|
|||||||
pub friendly: String,
|
pub friendly: String,
|
||||||
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
|
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
|
||||||
pub monitor_device_path: String,
|
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
|
/// 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);
|
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
|
// 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.
|
// 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;
|
external_physical = false;
|
||||||
tech = "punktfunk-virtual";
|
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 {
|
out.push(TargetInventory {
|
||||||
target_id: t.id,
|
target_id: t.id,
|
||||||
active: active.contains(&key),
|
active: is_active,
|
||||||
external_physical,
|
external_physical,
|
||||||
internal_panel: tech == "internal-panel",
|
internal_panel: tech == "internal-panel",
|
||||||
tech,
|
tech,
|
||||||
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
|
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
|
||||||
monitor_device_path,
|
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
|
out
|
||||||
|
|||||||
@@ -71,27 +71,34 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
DisplaySettingsState {
|
||||||
effective: settings.effective(),
|
effective: settings.effective(),
|
||||||
settings,
|
settings,
|
||||||
configured,
|
configured,
|
||||||
presets,
|
presets,
|
||||||
custom_presets: policy::load_custom_presets(),
|
custom_presets: policy::load_custom_presets(),
|
||||||
enforced: vec![
|
enforced,
|
||||||
"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(),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +142,24 @@ pub(crate) async fn get_display_settings() -> Json<DisplaySettingsState> {
|
|||||||
pub(crate) async fn set_display_settings(
|
pub(crate) async fn set_display_settings(
|
||||||
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
||||||
) -> Response {
|
) -> 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
|
// `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).
|
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
|
||||||
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
|
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,
|
/// 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".
|
/// so the console can show "pinned to DP-2, which this host doesn't have".
|
||||||
pinned: Option<String>,
|
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
|
/// Why the list is empty, when enumeration failed (compositor unreachable, unsupported
|
||||||
/// platform). `None` with an empty list means "asked, and there are none".
|
/// platform). `None` with an empty list means "asked, and there are none".
|
||||||
error: Option<String>,
|
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.
|
// sessions will actually mirror, not just what the console last wrote.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let pinned = crate::vdisplay::capture_monitor();
|
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"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let pinned: Option<String> = None;
|
let pinned: Option<String> = None;
|
||||||
// Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker.
|
// Enumeration works on Windows; capture of an enumerated head does not.
|
||||||
let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() {
|
let pin_supported = cfg!(target_os = "linux");
|
||||||
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
|
// Enumeration shells out / round-trips D-Bus + Wayland (and on Windows walks the CCD
|
||||||
Err(e) => (None, Err(e)),
|
// 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
|
.await
|
||||||
.unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}"))));
|
.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,
|
compositor,
|
||||||
monitors,
|
monitors,
|
||||||
pinned,
|
pinned,
|
||||||
|
pin_supported,
|
||||||
error,
|
error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,6 +430,7 @@
|
|||||||
"display_monitor_none": "Dieser Host meldet keine Monitore.",
|
"display_monitor_none": "Dieser Host meldet keine Monitore.",
|
||||||
"display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.",
|
"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_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_primary": "primär",
|
||||||
"display_monitor_disabled": "aus",
|
"display_monitor_disabled": "aus",
|
||||||
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
|
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
|
||||||
|
|||||||
@@ -430,6 +430,7 @@
|
|||||||
"display_monitor_none": "This host reports no monitors.",
|
"display_monitor_none": "This host reports no monitors.",
|
||||||
"display_monitor_unavailable": "Monitors could not be listed on this host.",
|
"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_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_primary": "primary",
|
||||||
"display_monitor_disabled": "off",
|
"display_monitor_disabled": "off",
|
||||||
"display_monitor_saved": "Streamed screen saved"
|
"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
|
// environment is read-only here: offering controls that silently lose to the env would be worse
|
||||||
// than saying so.
|
// than saying so.
|
||||||
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
|
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) => {
|
const choose = (connector: string | null) => {
|
||||||
if (!policy || envLocked) return;
|
if (!policy || locked) return;
|
||||||
save.mutate(
|
save.mutate(
|
||||||
{ data: { ...policy, capture_monitor: connector } },
|
{ data: { ...policy, capture_monitor: connector } },
|
||||||
{
|
{
|
||||||
@@ -71,13 +79,13 @@ export const MonitorCard: FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || envLocked || !onSelect}
|
disabled={busy || locked || !onSelect}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
|
"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",
|
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">
|
<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">
|
<p className="max-w-prose text-sm text-muted-foreground">
|
||||||
{m.display_monitor_intro()}
|
{m.display_monitor_intro()}
|
||||||
</p>
|
</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">
|
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||||||
{m.display_monitor_env_locked()}
|
{m.display_monitor_env_locked()}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user