Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 142a0100cc ci(audit): scope decky to shipped deps; make docs-site non-blocking deterministically
First real-runner dispatch (run 13627) surfaced two calibration issues:
* pnpm audit flagged 6 high advisories — all in decky's devDependencies
  (the rollup build toolchain), which never ship: the plugin bundle carries
  prod deps only. --prod scopes the gate to what users get, matching the
  locally-validated state (clean).
* docs-site relied on job-level continue-on-error, which act_runner does
  not reliably honor — replaced with a step-level '||' warning so the known
  advisories can never take the run red while staying visible in the log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:02:43 +02:00
enricobuehlerandClaude Fable 5 c14308bf71 ci: the supply chain accounts for itself — per-release SBOM, full-tree audits, a real license gate
CRA Annex I Part II groundwork (see punktfunk-planning design/cra-readiness.md, Phase 1):

* sbom.yml + scripts/ci/gen-sbom.sh: every vX.Y.Z release gets a CycloneDX
  SBOM attached — syft over both Cargo.locks, all Bun/pnpm trees and the
  Swift Package.resolved (2,667 components), merged with
  compliance/sbom/manual-components.cdx.json for what no lockfile records
  (pyrowave/Granite/volk/Vulkan-Headers pins, libvpl, FFmpeg, SDL3,
  VB-CABLE, punktfunk-gamescope).
* audit.yml: bun audit now covers sdk + plugin-kit (not just web), decky's
  pnpm tree is scanned, and docs-site runs non-blocking until its known
  CMS-chain advisories are cleared. All shipping trees verified green today.
* license-gate: about.toml's allowlist claim is finally enforced —
  cargo-about 0.9.1 with --fail over BOTH workspaces. The old [crate.clarify]
  license-only syntax fails to deserialize under 0.9; migrated ring to a
  per-crate accepted extension and dropped the stale aws-lc-sys entry
  (workspace is ring-only). Both gates validated green locally.
* drivers/Cargo.lock: sync the pf-dualsense→pf-gamepad rename — the crate
  rename updated the manifest but the Windows-only lockfile was never
  regenerated; cargo-about's metadata pass caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:21:59 +02:00
70 changed files with 871 additions and 3524 deletions
+101 -11
View File
@@ -1,9 +1,22 @@
# Supply-chain advisory scan for BOTH dependency trees the project ships to users:
# Supply-chain advisory scan for EVERY dependency tree the project ships or publishes, plus the
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
# it lands).
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# * bun audit → the web management console (Nitro/Bun BFF) — the component that holds the login
# gate, session sealing, and the mgmt bearer token, so its deps matter too.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile change (catch a bad
# dep the moment it lands), and on demand.
# * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
# plugin-kit (@punktfunk/plugin-kit).
# * pnpm audit → clients/decky (the Steam Deck plugin).
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
# verified against the LIVE site (the docs don't build standalone) — tracked in
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.)
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
name: audit
@@ -12,7 +25,16 @@ on:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
push:
branches: [main]
paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml']
paths:
- 'Cargo.lock'
- 'packaging/windows/drivers/Cargo.lock'
- 'web/bun.lock'
- 'docs-site/bun.lock'
- 'sdk/bun.lock'
- 'plugin-kit/bun.lock'
- 'clients/decky/pnpm-lock.yaml'
- 'about.toml'
- '.gitea/workflows/audit.yml'
workflow_dispatch:
jobs:
@@ -36,13 +58,17 @@ jobs:
cargo audit
bun-audit:
strategy:
fail-fast: false
matrix:
tree: [web, sdk, plugin-kit]
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: web
working-directory: ${{ matrix.tree }}
steps:
# oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them
# (same preamble as web-screenshots.yml / ci.yml's web job).
@@ -50,9 +76,73 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# `bun audit` queries the registry advisory DB for the versions pinned in web/bun.lock. No
# install/build needed — it reads the manifest + lockfile. Fails the job on any advisory, the
# same fail-on-vulnerability stance as cargo-audit above; triage a finding by bumping the dep
# (or, if genuinely unfixable + inapplicable, pinning a resolution and noting why here).
# `bun audit` queries the registry advisory DB for the versions pinned in the tree's
# bun.lock. No install/build needed — it reads the manifest + lockfile. Fails the job on any
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
# noting why here).
- name: bun audit
run: bun audit
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
# act_runner does not reliably honor — a red job here would take the whole run red). The full
# advisory list still lands in the log; the warning marks it wasn't clean.
docs-site-audit:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: docs-site
steps:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- name: bun audit (non-blocking)
run: bun audit || echo "::warning::docs-site has known advisories (CMS/UI + nitropack chains) — tracked in punktfunk-planning design/cra-readiness.md"
pnpm-audit:
runs-on: ubuntu-24.04
container:
image: node:22-bookworm
timeout-minutes: 15
defaults:
run:
working-directory: clients/decky
steps:
- uses: actions/checkout@v4
# decky is pnpm-managed (pnpm-lock.yaml lockfileVersion 9.0 → pnpm 10 reads it). Like
# bun audit, `pnpm audit` needs no install/build — lockfile + registry advisory DB only.
# --prod: rollup bundles only the prod deps into the shipped plugin; devDependencies are
# build tooling that never leaves CI (auditing them fails on toolchain advisories that
# can't reach a user — the docs-site problem in miniature).
- name: pnpm audit
run: |
npm install -g pnpm@10
pnpm audit --prod
# The regression guard about.toml documents: fail if any crate in either Rust workspace carries
# a license outside the `accepted` allowlist (e.g. a copyleft dep silently entering the linked
# set). cargo-about is version-pinned: the config uses the per-crate `accepted` syntax
# validated against exactly this version.
license-gate:
runs-on: ubuntu-24.04
container:
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: /usr/local/cargo
key: cargo-about-0.9.1
restore-keys: cargo-about-
- name: cargo about license gate (host + driver workspaces)
run: |
git config --global --add safe.directory "$PWD"
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
cargo about generate about.hbs --fail -o /dev/null
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
+60
View File
@@ -0,0 +1,60 @@
# Per-release SBOM (CRA Annex I Part II §1: identify and document the components in the product,
# in a commonly used machine-readable format — we emit CycloneDX JSON).
#
# Tag push → the SBOM is attached to the Gitea release, next to the artifacts it describes.
# Release assets are never pruned (security updates must stay available ≥10 years, CRA Art. 13),
# so the SBOM's retention rides on the release's.
# workflow_dispatch on a non-tag ref → generated and uploaded as a workflow artifact only
# (pipeline validation / an on-demand snapshot); no release is touched.
#
# What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in
# both Rust workspaces + the JS trees + Swift Package.resolved) merged with
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, VB-CABLE, gamescope).
name: sbom
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
sbom:
runs-on: ubuntu-24.04
container:
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 20
steps:
# fetch-depth 0: the dispatch path derives the canary base from the tag history
# (scripts/ci/pf-version.sh), which a shallow clone cannot see.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
- name: Install syft
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
case "$GITHUB_REF" in
refs/tags/v*) VERSION="${GITHUB_REF_NAME#v}" ;;
*) eval "$(bash scripts/ci/pf-version.sh)"; VERSION="${PF_BASE}-snapshot" ;;
esac
sh scripts/ci/gen-sbom.sh "$VERSION" "punktfunk-${VERSION}.cdx.json"
echo "SBOM_FILE=punktfunk-${VERSION}.cdx.json" >> "$GITHUB_ENV"
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$SBOM_FILE"
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see release.yml).
- name: Upload artifact (non-tag runs)
if: "!startsWith(github.ref, 'refs/tags/')"
uses: actions/upload-artifact@v3
with:
name: sbom
path: punktfunk-*.cdx.json
+1 -9
View File
@@ -294,15 +294,7 @@ jobs:
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
}
Push-Location web
# `--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 install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
Generated
-1
View File
@@ -3051,7 +3051,6 @@ dependencies = [
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
"utoipa",
"wayland-backend",
"wayland-client",
+4 -23
View File
@@ -60,30 +60,11 @@ repository = "https://git.unom.io/unom/punktfunk"
# workspace. `unsafe fn` marks a CONTRACT the caller must uphold; it is not a licence for the whole
# body to skip checking. Without this lint an `unsafe fn` body is unchecked end to end, so a 600-line
# function hides which handful of lines are actually the unsafe ones — exactly the reviewer-hostile
# shape we are working down. (This is the Rust 2024 default; adopting it early also pays off the
# edition migration.)
#
# `deny`, not `warn`. `warn` was never actually a softer setting: CI runs `cargo clippy … -D
# warnings`, which promotes it to a hard error anyway — that is how adopting this lint turned main
# red on every platform for a day without the level in this file ever saying `deny`. A level that
# lies about its own severity is worse than a strict one, so this now states what CI already does,
# and the exemptions are written down per file instead of hiding in a 689-warning wall nobody reads.
#
# THE EXEMPTIONS. Fourteen GPU/FFI backend files carry `#![allow(unsafe_op_in_unsafe_fn)]` with a
# one-line reason each. They are not "not done yet" — they are where this lint stops paying:
# their bodies are ash/CUDA/AMF/libav calls almost line for line (measured: 64% of the sites are a
# single third-party FFI call, and of the 44 `unsafe fn`s in them only 4 have a body containing no
# unsafe operation at all). Narrowing them means one `unsafe {}` per line plus, since pf-encode also
# denies `clippy::undocumented_unsafe_blocks`, one hand-written SAFETY comment per line that could
# only ever restate "an ash call on a live device" — the precise noise that made `unsafe` stop
# meaning anything here before (see the header of `pf-win-display/src/win_display.rs`).
#
# Everything else in the workspace is at zero and enforced. Removing one of those allows, file by
# file, is real work with a real payoff; blanket-narrowing all fourteen is not. Prefer DELETING an
# `unsafe fn` marker over wrapping its body: keep the marker only where a caller can actually break
# something (a raw pointer, a borrowed HANDLE, a GPU object that must not be in flight).
# shape we are working down. `warn` while the remaining sites are cleared crate by crate; flip to
# `deny` once they are, so it can never regress. (This is the Rust 2024 default; adopting it early
# also pays off the edition migration.)
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
unsafe_op_in_unsafe_fn = "warn"
[profile.release]
opt-level = 3
+12 -10
View File
@@ -37,13 +37,15 @@ accepted = [
ignore-build-dependencies = true
ignore-dev-dependencies = true
# r-efi offers an LGPL-2.1-or-later arm but is tri-licensed; take a permissive arm. (It is also
# UEFI-target-gated out of every shipped build.)
[r-efi.clarify]
license = "MIT OR Apache-2.0"
[ring.clarify]
license = "MIT AND ISC AND OpenSSL"
[aws-lc-sys.clarify]
license = "ISC AND Apache-2.0 AND MIT AND BSD-3-Clause AND OpenSSL"
# Per-crate license-acceptance additions (cargo-about ≥0.6 syntax; the old `[crate.clarify]`
# license-only form fails to deserialize under cargo-about 0.9, which now wants checksummed file
# clarifications — per-crate `accepted` extensions express the same intent without checksums).
#
# r-efi is tri-licensed with an LGPL-2.1-or-later arm; cargo-about resolves OR-expressions to an
# accepted arm on its own (MIT/Apache-2.0 are globally accepted), so it needs no entry. (It is
# also UEFI-target-gated out of every shipped build.)
#
# ring's license is an AND of permissive terms including the OpenSSL license; accept the
# OpenSSL/ISC parts for this crate only, not globally.
[ring]
accepted = ["OpenSSL", "ISC"]
+1 -6
View File
@@ -5831,8 +5831,7 @@
"type": "object",
"description": "The host's physical monitors + which one capture is pinned to.",
"required": [
"monitors",
"pin_supported"
"monitors"
],
"properties": {
"compositor": {
@@ -5856,10 +5855,6 @@
},
"description": "The heads, ordered left-to-right by desktop position."
},
"pin_supported": {
"type": "boolean",
"description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change."
},
"pinned": {
"type": [
"string",
+4 -113
View File
@@ -35,11 +35,6 @@ const CSS: &str = "
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
background: alpha(currentColor, 0.35); }
.pf-pip.pf-online { background: @success_color; }
/* An overridden row in profile scope: an accent dot in the prefix, so which settings this
profile changes is legible at a glance without reading every value. (Plain string literal
-- a quote in here would end it.) */
.pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px;
background: @accent_color; }
/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
the card's own elevation shadow intact. */
@@ -230,7 +225,6 @@ impl SimpleComponent for AppModel {
HostsOutput::Pair(req) => AppMsg::Pair(req),
HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req),
HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt),
HostsOutput::Toast(msg) => AppMsg::Toast(msg),
});
let nav = adw::NavigationView::new();
@@ -571,9 +565,6 @@ impl AppModel {
pair_optional: false,
launch: plan.launch.clone().map(|id| (id.clone(), id)),
mac: plan.host.mac.clone(),
// `profile=` in a URL is a one-off, exactly like "Connect with ▸": it
// shapes this session and leaves the host's binding alone.
profile: plan.profile_override.clone(),
};
// A link is a launch like any other: with a MAC it takes the dial-first wake
// path, so a sleeping host wakes instead of erroring.
@@ -598,7 +589,6 @@ impl AppModel {
pair_optional: false,
launch: unknown.launch.clone().map(|id| (id.clone(), id)),
mac: Vec::new(),
profile: None,
};
self.toast(&format!(
"{} isn't paired with this device yet — pair it to continue.",
@@ -630,33 +620,7 @@ impl AppModel {
let status = gtk::Label::new(Some("Connecting…"));
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
dialog.set_extra_child(Some(&status));
// Where a measured bitrate belongs is "the layer this host actually resolves bitrate
// from" (design/client-settings-profiles.md §5.3) — the long-standing wrong answer was
// always the global, so measuring the slow retro box downstairs re-tuned the desktop
// too. The target depends only on the host, so it is known before the result lands and
// the button can say where it will write.
let target = SpeedTestTarget::resolve(&req);
match &target {
SpeedTestTarget::Global => {
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
}
SpeedTestTarget::Profile(p) => {
dialog.add_responses(&[
("close", "Close"),
("apply", &format!("Apply to “{}", p.name)),
]);
}
// A bound host whose profile doesn't override bitrate could legitimately mean
// either: the user gets both, rather than us guessing which layer they meant.
SpeedTestTarget::Ask(p) => {
dialog.add_responses(&[
("close", "Close"),
("apply-global", "Set as default"),
("apply", &format!("Set in “{}", p.name)),
]);
dialog.set_response_enabled("apply-global", false);
}
}
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
dialog.set_response_enabled("apply", false);
dialog.set_close_response("close");
dialog.present(Some(&self.window));
@@ -726,36 +690,13 @@ impl AppModel {
));
dialog.set_response_enabled("apply", true);
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
if matches!(target, SpeedTestTarget::Ask(_)) {
dialog.set_response_enabled("apply-global", true);
}
let mbit = f64::from(recommended_kbps) / 1000.0;
{
let (settings, toasts) = (settings.clone(), toasts.clone());
dialog.connect_response(Some("apply"), move |_, _| {
let where_to = match &target {
SpeedTestTarget::Global => {
let mut s = settings.borrow_mut();
s.bitrate_kbps = recommended_kbps;
s.save();
"the default bitrate".to_string()
}
SpeedTestTarget::Profile(p) | SpeedTestTarget::Ask(p) => {
write_profile_bitrate(&p.id, recommended_kbps);
format!("{}", p.name)
}
};
toasts.add_toast(adw::Toast::new(&format!(
"{mbit:.0} Mbit/s set in {where_to}"
)));
});
}
dialog.connect_response(Some("apply-global"), move |_, _| {
dialog.connect_response(Some("apply"), move |_, _| {
let mut s = settings.borrow_mut();
s.bitrate_kbps = recommended_kbps;
s.save();
toasts.add_toast(adw::Toast::new(&format!(
"{mbit:.0} Mbit/s set in the default bitrate"
"Bitrate set to {:.0} Mbit/s",
f64::from(recommended_kbps) / 1000.0
)));
});
}
@@ -766,56 +707,6 @@ impl AppModel {
}
}
/// Which layer a measured bitrate should land in for the host that was tested
/// (design/client-settings-profiles.md §5.3).
enum SpeedTestTarget {
/// No profile bound — the global default, i.e. what has always happened.
Global,
/// The bound profile already overrides bitrate, so that override is what this host reads.
Profile(pf_client_core::profiles::StreamProfile),
/// Bound, but the profile inherits bitrate: writing either layer is defensible, so ask.
Ask(pf_client_core::profiles::StreamProfile),
}
impl SpeedTestTarget {
fn resolve(req: &crate::ui_hosts::ConnectRequest) -> SpeedTestTarget {
// Resolved exactly the way a connect resolves it: the one-off pick this test was
// started with (a pinned card carries one), else the host's binding.
let bound = trust::KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == req.addr && h.port == req.port)
.and_then(|h| h.profile_id.clone());
let reference = match req.profile.as_deref() {
Some("") => return SpeedTestTarget::Global,
Some(id) => Some(id.to_string()),
None => bound,
};
let Some(reference) = reference else {
return SpeedTestTarget::Global;
};
let catalog = pf_client_core::profiles::ProfilesFile::load();
match catalog.resolve(&reference).0 {
Some(p) if p.overrides.bitrate_kbps.is_some() => SpeedTestTarget::Profile(p.clone()),
Some(p) => SpeedTestTarget::Ask(p.clone()),
// A dangling binding resolves as no profile everywhere else; here too.
None => SpeedTestTarget::Global,
}
}
}
/// Write a measured bitrate into one profile's overlay, leaving everything else alone.
fn write_profile_bitrate(id: &str, kbps: u32) {
let mut catalog = pf_client_core::profiles::ProfilesFile::load();
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) else {
return; // deleted while the test ran — the toast still tells the truth about the test
};
p.overrides.bitrate_kbps = Some(kbps);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the measured bitrate");
}
}
thread_local! {
/// Where a delivered URL goes once the window exists. Both ends of this live on the GTK
/// main thread: `connect_open` fires there, and so does the model's `init`.
-1
View File
@@ -495,7 +495,6 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
pair_optional: true,
launch: None,
mac: Vec::new(),
profile: None,
};
let mock_advert =
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
-3
View File
@@ -14,9 +14,6 @@ pub use pf_client_core::{discovery, gamepad, library, trust, video, wol};
mod app;
#[cfg(target_os = "linux")]
mod cli;
// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5).
#[cfg(target_os = "linux")]
mod shortcuts;
#[cfg(target_os = "linux")]
mod spawn;
#[cfg(target_os = "linux")]
-102
View File
@@ -1,102 +0,0 @@
//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a
//! profile or a game), design/client-deep-links.md §5.
//!
//! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the
//! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser
//! prompt use. That is deliberate — it keeps working if scheme registration is lost or the
//! host store changes, because the URL itself carries the stable id, the address and the pin.
//!
//! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the
//! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for
//! exactly this, with its own confirmation) is the intended upgrade there.
use std::path::PathBuf;
/// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and
/// nowhere else — the standard check, and the one the portal docs use.
pub fn sandboxed() -> bool {
std::path::Path::new("/.flatpak-info").exists()
}
/// Write `~/.local/share/applications/punktfunk-<slug>.desktop` for this URL and return the
/// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still
/// works from a file manager, it just won't show up in search straight away.
pub fn write_desktop_entry(label: &str, url: &str) -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?;
let dir = PathBuf::from(home).join(".local/share/applications");
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label)));
// Desktop-entry values are line-oriented: a newline in a host name would end the Name
// key and turn the rest into an unparsable line (or, worse, another key).
let name = one_line(label);
let entry = format!(
"[Desktop Entry]\n\
Type=Application\n\
Name={name}\n\
Comment=Stream from this Punktfunk host\n\
Exec=punktfunk-client \"{url}\"\n\
Icon=io.unom.Punktfunk\n\
Terminal=false\n\
Categories=Game;Network;\n\
StartupNotify=true\n"
);
std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?;
// Some desktops only offer a `.desktop` as a launchable icon when it is executable.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
}
let _ = std::process::Command::new("update-desktop-database")
.arg(&dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
Ok(path)
}
/// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`,
/// capped so a long host+profile pair can't produce a name the filesystem rejects.
fn file_slug(label: &str) -> String {
let mut out = String::new();
for c in label.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
let capped: String = trimmed.chars().take(48).collect();
if capped.is_empty() {
"host".to_string()
} else {
capped
}
}
/// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic.
fn one_line(s: &str) -> String {
s.replace(['\n', '\r'], " ").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
/// Names become safe filenames, and a name that survives to `Name=` can't break the file.
#[test]
fn labels_are_sanitised_both_ways() {
assert_eq!(file_slug("Living Room PC"), "living-room-pc");
assert_eq!(file_slug("Büro · Mac"), "b-ro-mac");
assert_eq!(file_slug("Desk · Work"), "desk-work");
assert_eq!(file_slug("////"), "host");
assert_eq!(file_slug(""), "host");
assert!(file_slug(&"x".repeat(200)).len() <= 48);
// The classic injection: a newline would end the Name key and start a new one.
assert_eq!(
one_line("Desk\nExec=rm -rf ~"),
"Desk Exec=rm -rf ~".to_string()
);
}
}
+5 -8
View File
@@ -47,11 +47,10 @@ pub fn spawn_session(
fullscreen_on_stream: bool,
opts: SpawnOpts,
) -> Result<(), String> {
// The plan this connect resolves to. A plain card click carries no `--profile`: it honors
// the host's own binding, which the session resolves through the same helper this shell
// would have used (design/client-settings-profiles.md §4.6) — passing it here would be a
// second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's
// `profile=`) sets one, and it applies to this session alone.
// The plan a card click resolves to. No `--profile`: a plain click honors the host's own
// binding, which the session resolves through the same helper this shell would have used
// (design/client-settings-profiles.md §4.6) — passing it here would be a second source of
// truth for the same decision.
//
// Two fields are deliberately not the shell's state yet, because nothing in the spawn path
// reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake`
@@ -68,9 +67,7 @@ pub fn spawn_session(
},
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
profile: None,
// A one-off pick rides the flag; without one the session resolves the host's own
// binding through the same helper this shell would have used.
profile_override: req.profile.clone(),
profile_override: None,
settings: Settings {
fullscreen_on_stream,
..Default::default()
+33 -456
View File
@@ -32,11 +32,6 @@ pub struct ConnectRequest {
pub launch: Option<(String, String)>,
/// Wake-on-LAN MAC(s) for this host. Empty when none is known.
pub mac: Vec<String>,
/// A ONE-OFF settings profile for this connect ("Connect with ▸ X"): `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 host's default changes
/// only through an explicit "Default profile" pick (design/client-settings-profiles.md §5.2).
pub profile: Option<String>,
}
impl ConnectRequest {
@@ -67,14 +62,6 @@ enum CardKind {
online: bool,
recent: bool,
library_enabled: bool,
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
/// refresh rather than re-read per card.
profiles: Rc<Vec<(String, String)>>,
/// `Some((id, name))` when this card is a PINNED host+profile pair rather than the
/// host's primary card (design §5.2a): a one-click shortcut for a profile the user
/// reaches for often. It is presentation state on the host record — never a second
/// host entry, which would fork pairing, WoL and renames.
pinned: Option<(String, String)>,
},
Discovered(DiscoveredHost),
}
@@ -82,55 +69,19 @@ enum CardKind {
#[derive(Debug)]
pub enum CardOutput {
Connect(ConnectRequest),
/// Set (or clear, with `None`) this host's DEFAULT settings profile — the explicit
/// rebinding act; a one-off connect never does this.
BindProfile {
fp_hex: String,
addr: String,
port: u16,
profile_id: Option<String>,
},
WakeConnect(ConnectRequest),
Pair(ConnectRequest),
SpeedTest(ConnectRequest),
Library(ConnectRequest),
/// Open the host edit sheet (name, profile binding, clipboard).
Edit {
fp_hex: String,
name: String,
},
Forget {
fp_hex: String,
name: String,
},
Wake {
mac: Vec<String>,
addr: String,
},
/// Put this card's `punktfunk://` URL on the clipboard.
CopyLink(String),
/// Write a desktop entry that launches this card's URL.
CreateShortcut {
label: String,
url: String,
},
/// Add or remove a pinned host+profile card (design §5.2a). Presentation only — it never
/// changes the host's default profile, and unpinning never touches the profile itself.
TogglePin {
fp_hex: String,
addr: String,
port: u16,
profile_id: String,
pin: bool,
},
Rename { fp_hex: String, name: String },
Forget { fp_hex: String, name: String },
Wake { mac: Vec<String>, addr: String },
}
impl HostCard {
fn request(&self) -> ConnectRequest {
match &self.kind {
CardKind::Saved {
host: k, pinned, ..
} => ConnectRequest {
CardKind::Saved { host: k, .. } => ConnectRequest {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
@@ -139,9 +90,6 @@ impl HostCard {
pair_optional: false,
launch: None,
mac: k.mac.clone(),
// A pinned card IS its profile: clicking it connects with that one, without
// touching the host's default.
profile: pinned.as_ref().map(|(id, _)| id.clone()),
},
CardKind::Discovered(a) => ConnectRequest {
name: a.name.clone(),
@@ -152,7 +100,6 @@ impl HostCard {
pair_optional: a.pair == "optional",
launch: None,
mac: a.mac.clone(),
profile: None,
},
}
}
@@ -224,11 +171,7 @@ impl relm4::factory::FactoryComponent for HostCard {
};
match &self.kind {
CardKind::Saved {
host: k,
online,
profiles,
pinned,
..
host: k, online, ..
} => {
// Presence pip + spelled-out state, then the trust pill.
let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0);
@@ -247,26 +190,6 @@ impl relm4::factory::FactoryComponent for HostCard {
} else {
pill("Trusted", "pf-accent")
});
// The chip says what a plain click on THIS card will do: its own profile on a
// pinned card, the host's binding on the primary one. A binding whose profile
// was deleted shows nothing and resolves as the defaults, which is exactly
// what will happen on connect (design §6).
let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| {
k.profile_id
.as_ref()
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
.map(|(_, name)| name.as_str())
});
if let Some(name) = chip {
status.append(&pill(
name,
if pinned.is_some() {
"pf-accent"
} else {
"pf-neutral"
},
));
}
}
CardKind::Discovered(_) => {
status.append(&if req.pair_optional {
@@ -291,8 +214,6 @@ impl relm4::factory::FactoryComponent for HostCard {
online,
recent,
library_enabled,
profiles,
pinned,
} => {
if *recent {
overlay.add_css_class("pf-recent");
@@ -329,7 +250,7 @@ impl relm4::factory::FactoryComponent for HostCard {
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
add(
"rename",
Box::new(move || CardOutput::Edit {
Box::new(move || CardOutput::Rename {
fp_hex: fp.clone(),
name: name.clone(),
}),
@@ -355,185 +276,21 @@ impl relm4::factory::FactoryComponent for HostCard {
}),
);
}
// Profiles: a ONE-OFF connect ("Connect with"), and the explicit rebinding
// act ("Default profile"). They are separate menus on purpose — the whole
// predictability rule is that connecting with a profile never changes what
// the card will do next time (design §5.2).
{
let profile_action =
|name: &str, out: Box<dyn Fn(Option<String>) -> CardOutput>| {
let a = gio::SimpleAction::new(name, Some(glib::VariantTy::STRING));
let sender = sender.clone();
a.connect_activate(move |_, param| {
// The empty string is "Default settings" — a real choice, not
// an absent one, so it has to survive as a value.
let id = param.and_then(|p| p.str()).unwrap_or("").to_string();
let _ = sender.output(out(Some(id).filter(|s| !s.is_empty())));
});
actions.add_action(&a);
};
let req_for_connect = req.clone();
profile_action(
"connect-with",
Box::new(move |id| {
let mut req = req_for_connect.clone();
// `Some("")` — not `None` — so a bound host really does connect
// with the defaults when the user asks for them.
req.profile = Some(id.unwrap_or_default());
CardOutput::Connect(req)
}),
);
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
profile_action(
"bind-profile",
Box::new(move |id| CardOutput::BindProfile {
fp_hex: fp.clone(),
addr: addr.clone(),
port,
profile_id: id,
}),
);
// "Copy link": the self-emitted URL for this card, which is the pairing
// an external tool (a Playnite entry, a Stream Deck macro) is configured
// with. It carries the stable id AND host+fp, so it still resolves after a
// re-address or a reinstall (design/client-deep-links.md §2/§5).
{
let (host, profile) = (k.clone(), pinned.clone());
let a = gio::SimpleAction::new("copy-link", None);
let sender = sender.clone();
a.connect_activate(move |_, _| {
let url = pf_client_core::deeplink::DeepLink::for_host(
&host,
None,
profile.as_ref().map(|(id, _)| id.as_str()),
)
.to_url();
let _ = sender.output(CardOutput::CopyLink(url));
});
actions.add_action(&a);
}
// "Create shortcut…": the same URL as Copy link, wrapped in a desktop
// entry so it is double-clickable from the app grid.
{
let (host, profile) = (k.clone(), pinned.clone());
let a = gio::SimpleAction::new("shortcut", None);
let sender = sender.clone();
a.connect_activate(move |_, _| {
let url = pf_client_core::deeplink::DeepLink::for_host(
&host,
None,
profile.as_ref().map(|(id, _)| id.as_str()),
)
.to_url();
let label = match &profile {
Some((_, name)) => format!("{} \u{00b7} {name}", host.name),
None => host.name.clone(),
};
let _ = sender.output(CardOutput::CreateShortcut { label, url });
});
actions.add_action(&a);
}
// The same action pins from a primary card and unpins from a pinned one —
// which of the two this card is decides the direction.
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
let pinning = pinned.is_none();
profile_action(
"toggle-pin",
Box::new(move |id| CardOutput::TogglePin {
fp_hex: fp.clone(),
addr: addr.clone(),
port,
profile_id: id.unwrap_or_default(),
pin: pinning,
}),
);
}
overlay.insert_action_group("card", Some(&actions));
let menu = gio::Menu::new();
if let Some((pin_id, pin_name)) = pinned {
// A pinned card is a shortcut, not a second host: it offers the one-offs
// and its own removal, and deliberately NOT pair/rename/forget — those
// belong to the host, and offering them here would blur what the card is.
let with = gio::Menu::new();
for (label, target) in std::iter::once(("Default settings", "")).chain(
profiles
.iter()
.map(|(id, name)| (name.as_str(), id.as_str())),
) {
let item = gio::MenuItem::new(Some(label), None);
item.set_action_and_target_value(
Some("card.connect-with"),
Some(&target.to_variant()),
);
with.append_item(&item);
}
menu.append_submenu(Some("Connect with"), &with);
let unpin = gio::MenuItem::new(
Some(&format!("Unpin \u{201c}{pin_name}\u{201d}")),
None,
);
unpin.set_action_and_target_value(
Some("card.toggle-pin"),
Some(&pin_id.as_str().to_variant()),
);
menu.append_item(&unpin);
menu.append(Some("Copy link"), Some("card.copy-link"));
menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
} else {
if !profiles.is_empty() {
let with = gio::Menu::new();
let bind = gio::Menu::new();
let pin = gio::Menu::new();
for (label, id) in std::iter::once(("Default settings", "")).chain(
profiles
.iter()
.map(|(id, name)| (name.as_str(), id.as_str())),
) {
// A checkmark would be the natural cue for the current binding, but
// a GMenu radio needs shared state per card; the bound profile is
// named on the card's chip instead, visible without opening a menu.
for (menu, action) in
[(&with, "card.connect-with"), (&bind, "card.bind-profile")]
{
let item = gio::MenuItem::new(Some(label), None);
item.set_action_and_target_value(
Some(action),
Some(&id.to_variant()),
);
menu.append_item(&item);
}
// "Default settings" is not pinnable — the primary card is that.
if !id.is_empty() && !k.pinned_profiles.iter().any(|p| p == id) {
let item = gio::MenuItem::new(Some(label), None);
item.set_action_and_target_value(
Some("card.toggle-pin"),
Some(&id.to_variant()),
);
pin.append_item(&item);
}
}
menu.append_submenu(Some("Connect with"), &with);
menu.append_submenu(Some("Default profile"), &bind);
if pin.n_items() > 0 {
menu.append_submenu(Some("Pin as card"), &pin);
}
}
menu.append(Some("Pair with PIN\u{2026}"), Some("card.pair"));
menu.append(Some("Test network speed\u{2026}"), Some("card.speed"));
// An explicit wake only when offline and a MAC is known.
if !online && !k.mac.is_empty() {
menu.append(Some("Wake host"), Some("card.wake"));
}
// Experimental (Preferences gate): browse the host's game library.
if *library_enabled {
menu.append(Some("Browse library\u{2026}"), Some("card.library"));
}
menu.append(Some("Copy link"), Some("card.copy-link"));
menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
menu.append(Some("Edit\u{2026}"), Some("card.rename"));
menu.append(Some("Forget"), Some("card.forget"));
menu.append(Some("Pair with PIN…"), Some("card.pair"));
menu.append(Some("Test network speed…"), Some("card.speed"));
// An explicit wake only when offline and a MAC is known.
if !online && !k.mac.is_empty() {
menu.append(Some("Wake host"), Some("card.wake"));
}
// Experimental (Preferences gate): browse the host's game library.
if *library_enabled {
menu.append(Some("Browse library…"), Some("card.library"));
}
menu.append(Some("Rename…"), Some("card.rename"));
menu.append(Some("Forget"), Some("card.forget"));
let menu_btn = gtk::MenuButton::builder()
.icon_name("view-more-symbolic")
.menu_model(&menu)
@@ -635,8 +392,6 @@ pub enum HostsMsg {
#[derive(Debug)]
pub enum HostsOutput {
Connect(ConnectRequest),
/// A one-line confirmation for the window's toast overlay.
Toast(String),
WakeConnect(ConnectRequest),
Pair(ConnectRequest),
SpeedTest(ConnectRequest),
@@ -913,61 +668,9 @@ impl SimpleComponent for HostsPage {
let mgmt = self.mgmt_port_for(&req);
let _ = sender.output(HostsOutput::Library(req, mgmt));
}
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
CardOutput::Rename { fp_hex, name } => self.rename_dialog(&sender, &fp_hex, &name),
CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name),
CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()),
CardOutput::BindProfile {
fp_hex,
addr,
port,
profile_id,
} => {
// Written straight onto the host record — the binding IS a field there, so
// there is no map to keep in step (design §4.1). Matched by fingerprint
// when there is one, else by address, like every other per-host lookup.
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| {
(!fp_hex.is_empty() && h.fp_hex == fp_hex)
|| (h.addr == addr && h.port == port)
}) {
h.profile_id = profile_id;
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the profile binding");
}
}
self.rebuild(); // the chip follows immediately
}
CardOutput::CopyLink(url) => {
if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(&url);
}
let _ = sender.output(HostsOutput::Toast("Link copied".into()));
}
CardOutput::CreateShortcut { label, url } => {
self.shortcut_result(&sender, &label, &url);
}
CardOutput::TogglePin {
fp_hex,
addr,
port,
profile_id,
pin,
} => {
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| {
(!fp_hex.is_empty() && h.fp_hex == fp_hex)
|| (h.addr == addr && h.port == port)
}) {
h.pinned_profiles.retain(|p| p != &profile_id);
if pin {
h.pinned_profiles.push(profile_id);
}
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the pinned cards");
}
}
self.rebuild();
}
},
}
}
@@ -991,14 +694,6 @@ impl HostsPage {
.max_by_key(|&(_, t)| t)
.map(|(fp, _)| fp);
let library_enabled = self.settings.borrow().library_enabled;
// One catalog read per refresh, shared by every card's menus and chip.
let profiles: Rc<Vec<(String, String)>> = Rc::new(
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.map(|p| (p.id, p.name))
.collect(),
);
{
let mut saved = self.saved.guard();
@@ -1020,33 +715,10 @@ impl HostsPage {
kind: CardKind::Saved {
host: k.clone(),
online,
profiles: profiles.clone(),
recent: most_recent.as_deref() == Some(k.fp_hex.as_str()),
library_enabled,
pinned: None,
},
});
// …then its pinned host+profile cards, in the order the user pinned them.
// They share the host's live status because they read the same record, and a
// pin whose profile is gone simply doesn't render (design §5.2a).
for id in &k.pinned_profiles {
let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else {
continue;
};
saved.push_back(HostCard {
// The spinner belongs to whichever card was clicked; a pin has its own
// key so two cards for one host don't both spin.
connecting: false,
kind: CardKind::Saved {
host: k.clone(),
online,
profiles: profiles.clone(),
recent: false,
library_enabled,
pinned: Some((id.clone(), name.clone())),
},
});
}
}
}
@@ -1104,122 +776,28 @@ impl HostsPage {
}
/// Rename a saved host — an entry in an alert, then upsert + refresh.
/// Write the shortcut, or — inside the flatpak sandbox, which cannot reach
/// `~/.local/share/applications` — hand the user the URL to place themselves. The
/// DynamicLauncher portal is the intended upgrade for that case (design §5); until then
/// the fallback is the one the design already sanctions, not a dead end.
fn shortcut_result(&self, sender: &ComponentSender<Self>, label: &str, url: &str) {
if crate::shortcuts::sandboxed() {
let dialog = adw::AlertDialog::new(
Some("Create Shortcut"),
Some(
"Punktfunk is sandboxed here, so it can't add the shortcut itself. Copy \
this link and make a launcher for it \u{2014} it opens the same stream.",
),
);
let entry = gtk::Entry::builder().text(url).editable(false).build();
dialog.set_extra_child(Some(&entry));
dialog.add_responses(&[("close", "Close"), ("copy", "Copy link")]);
dialog.set_response_appearance("copy", adw::ResponseAppearance::Suggested);
dialog.set_close_response("close");
{
let url = url.to_string();
dialog.connect_response(Some("copy"), move |_, _| {
if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(&url);
}
});
}
dialog.present(Some(&self.widgets.stack));
return;
}
let msg = match crate::shortcuts::write_desktop_entry(label, url) {
Ok(_) => format!("Shortcut for \u{201c}{label}\u{201d} added to your applications"),
Err(e) => {
tracing::warn!(error = %e, "writing the shortcut");
format!("Couldn't create the shortcut \u{2014} {e}")
}
};
let _ = sender.output(HostsOutput::Toast(msg));
}
/// The host edit sheet — the per-host settings that are properties of the HOST, not of
/// the stream: its name, whether this machine shares its clipboard with it, and which
/// settings profile it defaults to.
///
/// Linux had only "Rename" until now; the clipboard toggle in particular existed in the
/// store and on the Apple and Windows clients but had no Linux surface at all, so a Linux
/// user could not turn on a feature they were already paying the storage for.
fn edit_host_dialog(&self, sender: &ComponentSender<Self>, fp_hex: &str, current: &str) {
let stored = KnownHosts::load()
.hosts
.iter()
.find(|h| h.fp_hex == fp_hex)
.cloned();
let name_row = adw::EntryRow::builder().title("Name").build();
name_row.set_text(current);
let clipboard_row = adw::SwitchRow::builder()
.title("Share clipboard")
.subtitle(
"Copy and paste between this machine and that host. Per host \u{2014} handing a \
host your clipboard is a decision about that host.",
)
fn rename_dialog(&self, sender: &ComponentSender<Self>, fp_hex: &str, current: &str) {
let entry = gtk::Entry::builder()
.text(current)
.activates_default(true)
.build();
clipboard_row.set_active(stored.as_ref().is_some_and(|h| h.clipboard_sync));
// Profile picker: "Default settings" plus the catalog, seeded to the current binding.
let catalog = pf_client_core::profiles::ProfilesFile::load();
let mut labels = vec!["Default settings".to_string()];
let mut ids: Vec<String> = vec![String::new()];
for p in &catalog.profiles {
labels.push(p.name.clone());
ids.push(p.id.clone());
}
let bound = stored.as_ref().and_then(|h| h.profile_id.clone());
// A binding whose profile is gone reads as Default settings and is cleaned up on save
// — the same "dangling resolves as none" rule the connect path follows.
let selected = bound
.as_ref()
.and_then(|id| ids.iter().position(|i| i == id))
.unwrap_or(0);
let profile_row = adw::ComboRow::builder()
.title("Profile")
.subtitle("The settings a plain click uses for this host")
.model(&gtk::StringList::new(
&labels.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
profile_row.set_selected(selected as u32);
let list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
list.append(&name_row);
list.append(&profile_row);
list.append(&clipboard_row);
let dialog = adw::AlertDialog::new(Some("Edit Host"), None);
dialog.set_extra_child(Some(&list));
dialog.add_responses(&[("cancel", "Cancel"), ("save", "Save")]);
dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("save"));
let dialog = adw::AlertDialog::new(Some("Rename Host"), None);
dialog.set_extra_child(Some(&entry));
dialog.add_responses(&[("cancel", "Cancel"), ("rename", "Rename")]);
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("rename"));
dialog.set_close_response("cancel");
{
let sender = sender.clone();
let fp = fp_hex.to_string();
dialog.connect_response(Some("save"), move |_, _| {
let name = name_row.text().trim().to_string();
dialog.connect_response(Some("rename"), move |_, _| {
let name = entry.text().trim().to_string();
if name.is_empty() {
return;
}
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
if !name.is_empty() {
h.name = name;
}
h.clipboard_sync = clipboard_row.is_active();
h.profile_id = ids
.get(profile_row.selected() as usize)
.filter(|id| !id.is_empty())
.cloned();
h.name = name;
let _ = known.save();
}
sender.input(HostsMsg::Refresh);
@@ -1308,7 +886,6 @@ impl HostsPage {
pair_optional: false,
launch: None,
mac: Vec::new(),
profile: None,
}));
});
}
+24 -109
View File
@@ -337,12 +337,7 @@ fn prompt_name(
/// what keeps an older client from erasing a newer one's values just by opening the dialog.
/// The catalog is re-read here rather than reused, so a profile renamed in another window
/// between opening and closing this one survives.
fn commit_profile(
active: &StreamProfile,
touched: &Touched,
cleared: &HashSet<&'static str>,
values: &Settings,
) {
fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings) {
let mut catalog = ProfilesFile::load();
let Some(slot) = catalog.profiles.iter_mut().find(|p| p.id == active.id) else {
return; // deleted from under us — nothing to write to, and nothing to complain about
@@ -399,15 +394,6 @@ fn commit_profile(
if touched.has("fullscreen_on_stream") {
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
}
// Resets last: a row the user reset may also have fired its change handler on the way
// (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent. The field
// names are the overlay's own, so the list of what can be cleared lives in ONE place —
// this used to be a second `match` here, which is exactly how the two drift.
for key in cleared {
if !o.clear(key) {
tracing::warn!(field = key, "reset of an unknown overlay field");
}
}
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
}
@@ -746,6 +732,28 @@ fn group(title: &str, description: &str) -> adw::PreferencesGroup {
g
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
/// presented dialog so the screenshot harness can select a page; callers ignore it.
pub fn show(
parent: &impl IsA<gtk::Widget>,
settings: Rc<RefCell<Settings>>,
gamepads: &crate::gamepad::GamepadService,
probes: &DeviceProbes,
on_closed: impl Fn() + 'static,
) -> adw::PreferencesDialog {
show_scoped(
parent,
settings,
gamepads,
probes,
Scope::Defaults,
|_| {},
on_closed,
)
}
/// The dialog in a given [`Scope`]. `on_scope` asks the app to re-open it in another one:
/// switching scope closes this dialog first, so the layer being edited is committed before
/// the next one is loaded, and there is exactly one place that builds the rows.
@@ -774,9 +782,6 @@ pub fn show_scoped(
None => settings.borrow().clone(),
};
let touched = Touched::default();
// Fields whose override a per-row reset dropped — applied at commit, after the touched
// ones, so "reset" wins over a value the same row happens to be showing.
let cleared: Rc<RefCell<HashSet<&'static str>>> = Rc::default();
// Where a scope switch wants to go once this dialog has committed and closed.
let next_scope: Rc<RefCell<Option<Scope>>> = Rc::default();
@@ -1223,96 +1228,6 @@ pub fn show_scoped(
bitrate_row.connect_value_notify(move |_| t.mark("bitrate_kbps"));
}
// ---- Override markers + per-row reset (profile scope) ----
// Every overridden row says so — an accent dot — and carries the only way back to
// inheriting: an explicit reset. Without this a profile is a one-way door, since the
// override model deliberately never infers "not overridden" from a value comparison.
if let Some(active) = &active {
let o = &active.overrides;
let mark = |row: &adw::PreferencesRow, key: &'static str, overridden: bool| {
if !overridden {
return;
}
let Some(row) = row.downcast_ref::<adw::ActionRow>() else {
return;
};
let dot = gtk::Box::builder()
.css_classes(["pf-override-dot"])
.valign(gtk::Align::Center)
.build();
row.add_prefix(&dot);
let reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Reset to Default settings")
.valign(gtk::Align::Center)
.css_classes(["flat"])
.build();
let (dialog, next, cleared, scope) = (
dialog.clone(),
next_scope.clone(),
cleared.clone(),
scope.clone(),
);
reset.connect_clicked(move |_| {
// Queue the clear and re-open in the same scope: the close handler commits
// whatever else was edited first, then drops this field, and the rebuilt rows
// show the inherited value. One code path builds rows — including this one.
cleared.borrow_mut().insert(key);
*next.borrow_mut() = Some(scope.clone());
dialog.close();
});
row.add_suffix(&reset);
};
mark(
res_row.widget(),
"resolution",
o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
);
mark(hz_row.widget(), "refresh_hz", o.refresh_hz.is_some());
mark(scale_row.widget(), "render_scale", o.render_scale.is_some());
mark(
bitrate_row.upcast_ref(),
"bitrate_kbps",
o.bitrate_kbps.is_some(),
);
mark(codec_row.widget(), "codec", o.codec.is_some());
mark(hdr_row.upcast_ref(), "hdr_enabled", o.hdr_enabled.is_some());
mark(
compositor_row.widget(),
"compositor",
o.compositor.is_some(),
);
mark(
surround_row.widget(),
"audio_channels",
o.audio_channels.is_some(),
);
mark(mic_row.upcast_ref(), "mic_enabled", o.mic_enabled.is_some());
mark(touch_row.widget(), "touch_mode", o.touch_mode.is_some());
mark(mouse_row.widget(), "mouse_mode", o.mouse_mode.is_some());
mark(
invert_row.upcast_ref(),
"invert_scroll",
o.invert_scroll.is_some(),
);
mark(
inhibit_row.upcast_ref(),
"inhibit_shortcuts",
o.inhibit_shortcuts.is_some(),
);
mark(pad_row.widget(), "gamepad", o.gamepad.is_some());
mark(
stats_row.widget(),
"stats_verbosity",
o.stats_verbosity.is_some(),
);
mark(
fullscreen_row.upcast_ref(),
"fullscreen_on_stream",
o.fullscreen_on_stream.is_some(),
);
}
// ---- Assemble the category pages (the Apple revamp's map) ----
let general = page("General", "preferences-system-symbolic");
// The scope switcher heads the first page — the one row that is always about which layer
@@ -1511,7 +1426,7 @@ pub fn show_scoped(
Some(active) => {
let mut values = seed.clone();
apply_rows(&mut values);
commit_profile(active, &touched, &cleared.borrow(), &values);
commit_profile(active, &touched, &values);
}
None => {
let mut s = settings.borrow_mut();
-2
View File
@@ -239,7 +239,6 @@ fn connect_spawn(
let target = target.clone();
// The closure owns `target`/`fp_hex`; the call itself borrows copies.
let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
let profile_arg = target.profile.clone();
let spawned = crate::spawn::spawn_session(
&addr,
port,
@@ -247,7 +246,6 @@ fn connect_spawn(
opts.connect_timeout.as_secs(),
fullscreen,
opts.launch.as_deref(),
profile_arg.as_deref(),
child,
move |event| {
use crate::spawn::SpawnEvent;
+1 -192
View File
@@ -20,13 +20,6 @@ const MENU_WAKE: &str = "Wake host";
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter.
const MENU_EDIT: &str = "Edit\u{2026}";
/// Dynamic menu-item prefixes: this flyout has no submenus, so the profile entries are flat
/// items matched by prefix. The trailing space keeps them readable AND keeps a profile named
/// e.g. "Copy link" from colliding with a fixed entry.
const MENU_WITH: &str = "Connect with: ";
const MENU_PIN: &str = "Pin as card: ";
const MENU_UNPIN: &str = "Unpin card: ";
const MENU_COPY_LINK: &str = "Copy link";
const MENU_FORGET: &str = "Forget\u{2026}";
/// Whether the console (gamepad) UI is available in this build: the session binary ships
@@ -170,19 +163,6 @@ pub(crate) struct Hover {
/// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip.
fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
status_row_with(online, badge, kind, None)
}
/// [`status_row`] plus the profile chip: what a plain click on THIS tile will use — its own
/// profile on a pinned tile, the host's binding on the primary one. A binding whose profile
/// was deleted shows nothing and resolves as the defaults, which is what will happen on
/// connect (design §6).
fn status_row_with(
online: Option<bool>,
badge: &str,
kind: Pill,
profile: Option<(&str, Pill)>,
) -> Element {
let mut items: Vec<Element> = Vec::new();
if let Some(online) = online {
items.push(
@@ -203,13 +183,6 @@ fn status_row_with(
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
if let Some((name, kind)) = profile {
items.push(
pill(name, kind)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
hstack(items)
.spacing(6.0)
.margin(edges(0.0, 12.0, 0.0, 0.0))
@@ -277,43 +250,6 @@ fn edit_editor(
se.call(None);
}
};
// The profile binding: what a plain click on this tile will use. It commits on change
// rather than at Save — it is a picker with no draft ref, and the rest of the sheet's
// fields are text boxes that genuinely need one.
let profile_picker = {
let catalog = pf_client_core::profiles::ProfilesFile::load();
let stored = KnownHosts::load()
.hosts
.iter()
.find(|h| h.fp_hex == fp)
.and_then(|h| h.profile_id.clone());
let mut names = vec!["Default settings".to_string()];
let mut ids: Vec<String> = vec![String::new()];
for p in &catalog.profiles {
names.push(p.name.clone());
ids.push(p.id.clone());
}
// A binding whose profile is gone reads as Default settings — the same "dangling
// resolves as none" rule the connect path follows — and is cleaned up on the next pick.
let current = stored
.as_ref()
.and_then(|id| ids.iter().position(|i| i == id))
.unwrap_or(0);
let fp = fp.to_string();
ComboBox::new(names)
.header("Profile")
.selected_index(current as i32)
.on_selection_changed(move |i: i32| {
let Some(id) = ids.get(i.max(0) as usize) else {
return;
};
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.profile_id = (!id.is_empty()).then(|| id.clone());
let _ = known.save();
}
})
};
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
vstack((
text_block(label)
@@ -345,18 +281,6 @@ fn edit_editor(
"auto-filled when known",
mac_draft,
),
vstack((
profile_picker,
text_block(
"The settings a plain click on this host uses. \u{201c}Connect with\u{201d} \
in the tile\u{2019}s menu overrides it for one session without changing it.",
)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(4.0),
vstack((
ToggleSwitch::new(clip0)
.header("Share clipboard with this host")
@@ -557,12 +481,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS"));
let mut tiles: Vec<Element> = Vec::new();
// One catalog read per render, shared by every tile's menu and chip.
let profiles: Vec<(String, String)> = pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.map(|p| (p.id, p.name))
.collect();
for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
@@ -586,7 +504,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
mac: k.mac.clone(),
profile: None,
};
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
// covers a routed/Tailscale host that never advertises — the display companion to
@@ -608,8 +525,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let (svc, target) = (props.svc.clone(), target.clone());
let (sf, sr) = (set_forget.clone(), set_rename.clone());
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
let menu_profiles = profiles.clone();
let (link_host, link_profile) = (k.clone(), None::<String>);
button("")
.icon(Symbol::More)
.subtle()
@@ -617,24 +532,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.automation_name("More options")
.menu_flyout({
let mut items = vec![menu_item(MENU_CONNECT)];
// One-off connects, flat: this flyout has no submenus, so each
// profile is its own item. "Connect with" NEVER rebinds — the default
// is changed in the host editor (design §5.2).
for (_, name) in profiles.iter() {
items.push(menu_item(format!("{MENU_WITH}{name}")));
}
if !profiles.is_empty() {
items.push(menu_item(format!("{MENU_WITH}Default settings")));
for (id, name) in profiles.iter() {
let label = if k.pinned_profiles.iter().any(|p| p == id) {
format!("{MENU_UNPIN}{name}")
} else {
format!("{MENU_PIN}{name}")
};
items.push(menu_item(label));
}
}
items.push(menu_item(MENU_COPY_LINK));
// The library surfaces — mouse/KB page and the gamepad console UI —
// for paired hosts only (the mgmt API needs the paired identity);
// the page additionally sits behind the experimental toggle, the
@@ -653,52 +550,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
items
})
.on_item_clicked(move |item: String| match item.as_str() {
// The profile items are dynamic, so they are matched by prefix before
// the fixed ones.
_ if item.starts_with(MENU_WITH) => {
let name = item.trim_start_matches(MENU_WITH);
let mut target = target.clone();
// `Some("")` — not `None` — so "Default settings" really does
// override a bound host for this one connect.
target.profile = Some(
menu_profiles
.iter()
.find(|(_, n)| n == name)
.map(|(id, _)| id.clone())
.unwrap_or_default(),
);
initiate(&svc.ctx, target, &svc.set_screen, &svc.set_status)
}
_ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => {
let pin = item.starts_with(MENU_PIN);
let name = item
.trim_start_matches(MENU_PIN)
.trim_start_matches(MENU_UNPIN);
let Some((id, _)) =
menu_profiles.iter().find(|(_, n)| n == name).cloned()
else {
return;
};
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.pinned_profiles.retain(|p| p != &id);
if pin {
h.pinned_profiles.push(id);
}
let _ = known.save();
}
// The tile list re-reads the store on the next render; nudge it.
sr.call(None);
}
MENU_COPY_LINK => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&link_host,
None,
link_profile.as_deref(),
)
.to_url();
pf_client_core::clipboard::set_text(&url);
}
MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
@@ -724,20 +575,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
})
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let pinned_base = target.clone();
tiles.push(host_tile(
&k.fp_hex,
&hover,
&k.name,
&format!("{}:{}", k.addr, k.port),
status_row_with(
status_row(
Some(online),
if k.paired { "Paired" } else { "Trusted" },
if k.paired { Pill::Good } else { Pill::Info },
k.profile_id
.as_ref()
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
.map(|(_, name)| (name.as_str(), Pill::Neutral)),
),
Some(menu),
Some(Box::new(move || {
@@ -752,41 +598,6 @@ 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));
}
@@ -823,7 +634,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
pair_optional: h.pair == "optional",
mac: h.mac.clone(),
profile: None,
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let (badge, kind) = if h.pair == "required" {
@@ -906,7 +716,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: None,
pair_optional: false,
mac: Vec::new(),
profile: None,
},
&ss,
&st,
-22
View File
@@ -80,11 +80,6 @@ pub(crate) struct Target {
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
/// magic packet before connecting to an offline host. Empty when none is known.
pub(crate) mac: Vec<String>,
/// A ONE-OFF settings profile for this connect ("Connect with"): `Some(id)` overrides the
/// host's binding for this launch, `Some("")` forces the global defaults on a bound host,
/// `None` honors the binding. It never rebinds anything — the default changes only through
/// the picker in the host editor (design/client-settings-profiles.md §5.2).
pub(crate) profile: Option<String>,
}
/// Stable app services handed to the page components as props. Each routed screen that uses
@@ -248,17 +243,6 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// Which Settings section the NavigationView shows (persists across visits this run).
// Opens on General — the first sidebar item, matching the Apple client's landing category.
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
// Which LAYER the settings screen edits: "" = the global defaults, else a profile id
// (design/client-settings-profiles.md §5.1). Root state for the same reason as the section
// above — the ComboBox's change handler is wired in the reactor backend.
let (settings_scope, set_settings_scope) = cx.use_async_state(String::new());
// The profile a Delete… click is asking about; `Some` renders the confirmation. Root state
// because this page stays hook-free (its handlers are wired in the reactor backend).
let (settings_delete, set_settings_delete) = cx.use_async_state(Option::<String>::None);
// Bumped when a settings edit changes what the page should SHOW without changing any state
// it already reads — resetting an override, which rewrites the catalog behind the controls.
// Root state comparison makes same-value calls free, so a counter is what forces the pass.
let (settings_rev, set_settings_rev) = cx.use_async_state(0u64);
// Connected-controller count, mirrored from the gamepad service by a poll thread
// (thread-driven state must be root state — see the module docs). Drives the hosts
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
@@ -505,12 +489,6 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
&set_screen,
&settings_nav,
&set_settings_nav,
&settings_scope,
&set_settings_scope,
&settings_delete,
&set_settings_delete,
settings_rev,
&set_settings_rev,
nav_progress,
),
Screen::Licenses => licenses::licenses_page(&set_screen),
+69 -508
View File
@@ -13,8 +13,7 @@
use super::style::*;
use super::{AppCtx, Screen};
use crate::trust::{KnownHosts, Settings};
use pf_client_core::profiles::{ProfilesFile, StreamProfile};
use crate::trust::Settings;
use pf_client_core::trust::StatsVerbosity;
use punktfunk_core::config::GamepadPref;
use std::sync::Arc;
@@ -111,171 +110,22 @@ const COMPOSITORS: &[(&str, &str)] = &[
/// A `ComboBox` bound to one settings field: shows `names`, starts at `current`, and runs
/// `apply(settings, picked_index)` under the settings lock, then saves. The index handed to
/// `apply` is already clamped to `names`.
/// The sentinel the scope ComboBox's last entry carries — "New profile…" is an action, not a
/// layer, and a real id can never collide with it (ids are 12 hex chars).
const NEW_PROFILE: &str = "\u{1}new";
/// Rename / duplicate / delete for the profile in scope. Rename is a text box rather than a
/// modal because this toolkit's ContentDialog is text-only (the same constraint that put the
/// host editor in a tile); it commits on change, which is how every other control here works.
fn profile_actions(
profile: &StreamProfile,
set_scope: &AsyncSetState<String>,
set_delete: &AsyncSetState<Option<String>>,
) -> Element {
let id = profile.id.clone();
let name_box = {
let id = id.clone();
text_box(&profile.name)
.placeholder_text("Profile name")
.on_text_changed(move |t: String| {
let name = t.trim().to_string();
if name.is_empty() {
return;
}
let mut catalog = ProfilesFile::load();
// Names are unique case-insensitively — menus keyed by name are ambiguous
// otherwise. A collision simply doesn't commit; the box keeps what was typed.
if catalog.name_taken(&name, Some(&id)) {
return;
}
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
p.name = name;
let _ = catalog.save();
}
})
};
let duplicate = {
let (id, set_scope) = (id.clone(), set_scope.clone());
button("Duplicate").on_click(move || {
let mut catalog = ProfilesFile::load();
let Some(source) = catalog.find_by_id(&id).cloned() else {
return;
};
let name = (2..)
.map(|n| format!("{} {n}", source.name))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| source.name.clone());
let mut copy = StreamProfile::new(name);
copy.overrides = source.overrides.clone();
copy.accent = source.accent.clone();
let new_id = copy.id.clone();
catalog.profiles.push(copy);
if catalog.save().is_ok() {
set_scope.call(new_id);
}
})
};
let delete = {
let (id, set_delete) = (id.clone(), set_delete.clone());
button("Delete\u{2026}").on_click(move || set_delete.call(Some(id.clone())))
};
described(
vstack((name_box, hstack((duplicate, delete)).spacing(8.0))).spacing(8.0),
"Renaming applies as you type. Deleting leaves hosts that used it on Default settings.",
)
}
/// Persist one control's edit into the layer being edited.
///
/// This shell commits PER CONTROL (unlike the GTK one, which writes when its dialog closes),
/// so it can't hand the profile a list of touched fields. It hands over the effective settings
/// before and after instead, and [`SettingsOverlay::absorb`] records the field that moved —
/// the comparison is against what the control was SHOWING, so picking a value that happens to
/// equal the global still records an override (the pin the design asks for).
fn commit(ctx: &Arc<AppCtx>, scope: &str, edit: impl FnOnce(&mut Settings)) {
if scope.is_empty() {
let mut s = ctx.settings.lock().unwrap();
edit(&mut s);
s.save();
return;
}
let mut catalog = ProfilesFile::load();
let base = ctx.settings.lock().unwrap().clone();
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
return; // deleted from under us; the next render falls back to the defaults scope
};
let before = p.overrides.apply(&base);
let mut after = before.clone();
edit(&mut after);
p.overrides.absorb(&before, &after);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
}
}
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
#[derive(Default)]
struct OverrideFlags {
resolution: bool,
refresh_hz: bool,
render_scale: bool,
bitrate_kbps: bool,
codec: bool,
hdr_enabled: bool,
compositor: bool,
audio_channels: bool,
mic_enabled: bool,
touch_mode: bool,
mouse_mode: bool,
invert_scroll: bool,
inhibit_shortcuts: bool,
gamepad: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
}
impl OverrideFlags {
fn of(profile: Option<&StreamProfile>) -> OverrideFlags {
let Some(o) = profile.map(|p| &p.overrides) else {
return OverrideFlags::default();
};
OverrideFlags {
// One control drives the width/height/match-window tri-state, so any of the three
// marks the row.
resolution: o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
refresh_hz: o.refresh_hz.is_some(),
render_scale: o.render_scale.is_some(),
bitrate_kbps: o.bitrate_kbps.is_some(),
codec: o.codec.is_some(),
hdr_enabled: o.hdr_enabled.is_some(),
compositor: o.compositor.is_some(),
audio_channels: o.audio_channels.is_some(),
mic_enabled: o.mic_enabled.is_some(),
touch_mode: o.touch_mode.is_some(),
mouse_mode: o.mouse_mode.is_some(),
invert_scroll: o.invert_scroll.is_some(),
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
}
}
}
/// The layer the settings screen is editing, resolved for display: `None` = the defaults.
fn active_profile(scope: &str) -> Option<StreamProfile> {
(!scope.is_empty())
.then(|| ProfilesFile::load().find_by_id(scope).cloned())
.flatten()
}
fn setting_combo(
ctx: &Arc<AppCtx>,
scope: &str,
header: &str,
names: Vec<String>,
current: usize,
apply: impl Fn(&mut Settings, usize) + 'static,
) -> ComboBox {
let (ctx, scope) = (ctx.clone(), scope.to_string());
let ctx = ctx.clone();
let max = names.len().saturating_sub(1);
ComboBox::new(names)
.header(header)
.selected_index(current as i32)
.on_selection_changed(move |i: i32| {
commit(&ctx, &scope, |s| apply(s, (i.max(0) as usize).min(max)));
let mut s = ctx.settings.lock().unwrap();
apply(&mut s, (i.max(0) as usize).min(max));
s.save();
})
}
@@ -289,18 +139,19 @@ fn presets<V>(table: &[(V, &str)], is_current: impl Fn(&V) -> bool) -> (Vec<Stri
/// A `ToggleSwitch` bound to one boolean settings field.
fn setting_toggle(
ctx: &Arc<AppCtx>,
scope: &str,
header: &str,
on: bool,
apply: impl Fn(&mut Settings, bool) + 'static,
) -> ToggleSwitch {
let (ctx, scope) = (ctx.clone(), scope.to_string());
let ctx = ctx.clone();
ToggleSwitch::new(on)
.header(header)
.on_content("On")
.off_content("Off")
.on_toggled(move |v: bool| {
commit(&ctx, &scope, |s| apply(s, v));
let mut s = ctx.settings.lock().unwrap();
apply(&mut s, v);
s.save();
})
}
@@ -311,52 +162,6 @@ fn setting_toggle(
/// but a caption under it reads as a caption, which is how every Windows Settings page and
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
/// full-width caption runs into the control column and the whole cell reads as one block.
/// [`described`], plus the override marker and reset a profile-scope row carries: the caption
/// says the profile changes this one, and the button is the only way back to inheriting —
/// overrides are recorded on touch and never inferred from a value comparison, so "not
/// overridden" needs an explicit act.
fn described_overridable(
rev: (u64, &AsyncSetState<u64>),
scope: &str,
field: &'static str,
overridden: bool,
control: impl Into<Element>,
caption: &str,
) -> Element {
if scope.is_empty() || !overridden {
return described(control, caption);
}
let (rev, set_rev) = (rev.0, rev.1.clone());
let scope = scope.to_string();
vstack((
control.into(),
hstack((
text_block(format!("\u{25cf} Overridden here \u{00b7} {caption}"))
.font_size(12.0)
.foreground(ThemeRef::AccentText)
.wrap()
.max_width(360.0)
.horizontal_alignment(HorizontalAlignment::Left),
button("Reset").on_click(move || {
let mut catalog = ProfilesFile::load();
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) {
p.overrides.clear(field);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"), "clearing an override");
}
}
// The catalog changed behind the controls, and nothing the page reads as
// state did — bump the revision so the row re-renders showing the inherited
// value again.
set_rev.call(rev + 1);
}),
))
.spacing(8.0),
))
.spacing(5.0)
.into()
}
fn described(control: impl Into<Element>, caption: &str) -> Element {
vstack((
control.into(),
@@ -415,41 +220,14 @@ fn group(header: Option<&str>, fields: Vec<Element>, footer: Option<&str>) -> Ve
/// state (this page stays hook-free): `on_selection_changed` is wired in the reactor backend, so
/// only a root `AsyncSetState` reliably re-renders the new section in. `progress` is the
/// section-switch entrance tween (0 → 1), mapped onto the content column's opacity + offset.
#[allow(clippy::too_many_arguments)]
pub(crate) fn settings_page(
ctx: &Arc<AppCtx>,
set_screen: &AsyncSetState<Screen>,
section: &str,
set_section: &AsyncSetState<String>,
scope_id: &str,
set_scope: &AsyncSetState<String>,
delete_pending: &Option<String>,
set_delete: &AsyncSetState<Option<String>>,
rev: u64,
set_rev: &AsyncSetState<u64>,
progress: f64,
) -> Element {
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
// the same rule a dangling host binding follows.
let active = active_profile(scope_id);
let scope: &str = match &active {
Some(p) => &p.id,
None => "",
};
let profile_mode = active.is_some();
// Which rows this profile overrides — the marker + reset each of them carries. In the
// defaults scope nothing is marked, and `described_overridable` degrades to `described`.
let over = OverrideFlags::of(active.as_ref());
let _ = rev; // read via the closures below; the value itself only forces the re-render
// Every control shows the EFFECTIVE value: the global underneath with this profile's
// overrides on top, so a row the profile doesn't override reads as the live global.
let s = {
let base = ctx.settings.lock().unwrap().clone();
match &active {
Some(p) => p.overrides.apply(&base),
None => base,
}
};
let s = ctx.settings.lock().unwrap().clone();
// --- Display ---------------------------------------------------------------------------
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
@@ -475,7 +253,7 @@ pub(crate) fn settings_page(
};
(names, i)
};
let res_combo = setting_combo(ctx, scope, "Resolution", res_names, res_i, |s, i| {
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
s.match_window = i == 1;
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
});
@@ -493,7 +271,7 @@ pub(crate) fn settings_page(
let i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
(names, i)
};
let hz_combo = setting_combo(ctx, scope, "Refresh rate", hz_names, hz_i, |s, i| {
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
s.refresh_hz = REFRESH[i];
});
let (scale_names, scale_i) = {
@@ -507,20 +285,18 @@ pub(crate) fn settings_page(
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
(names, i)
};
let scale_combo = setting_combo(ctx, scope, "Render scale", scale_names, scale_i, |s, i| {
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
s.render_scale = RENDER_SCALES[i];
});
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
let comp_combo = setting_combo(ctx, scope, "Host compositor", comp_names, comp_i, |s, i| {
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
s.compositor = COMPOSITORS[i].0.to_string();
});
let auto_wake_toggle =
setting_toggle(ctx, scope, "Auto-wake on connect", s.auto_wake, |s, on| {
s.auto_wake = on
});
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
s.auto_wake = on
});
let fullscreen_toggle = setting_toggle(
ctx,
scope,
"Start streams fullscreen",
s.fullscreen_on_stream,
|s, on| s.fullscreen_on_stream = on,
@@ -528,7 +304,7 @@ pub(crate) fn settings_page(
// --- Video -----------------------------------------------------------------------------
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
let decoder_combo = setting_combo(ctx, scope, "Video decoder", dec_names, dec_i, |s, i| {
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
s.decoder = DECODERS[i].0.to_string();
});
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
@@ -542,7 +318,7 @@ pub(crate) fn settings_page(
.position(|n| *n == s.adapter)
.map_or(0, |i| i + 1);
let gpus = gpus.clone();
setting_combo(ctx, scope, "GPU", names, current, move |s, i| {
setting_combo(ctx, "GPU", names, current, move |s, i| {
s.adapter = if i == 0 {
String::new()
} else {
@@ -551,7 +327,7 @@ pub(crate) fn settings_page(
})
});
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
let codec_combo = setting_combo(ctx, scope, "Video codec", codec_names, codec_i, |s, i| {
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
s.codec = CODECS[i].0.to_string();
});
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
@@ -567,13 +343,9 @@ pub(crate) fn settings_page(
s.save();
})
};
let hdr_toggle = setting_toggle(
ctx,
scope,
"HDR (10-bit, BT.2020 PQ)",
s.hdr_enabled,
|s, on| s.hdr_enabled = on,
);
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
s.hdr_enabled = on
});
// --- Input -----------------------------------------------------------------------------
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
@@ -622,27 +394,23 @@ pub(crate) fn settings_page(
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
});
let pad_combo = setting_combo(ctx, scope, "Gamepad type", pad_names, pad_i, |s, i| {
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
s.gamepad = GAMEPADS[i].0.to_string();
});
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
let touch_combo = setting_combo(ctx, scope, "Touch input", touch_names, touch_i, |s, i| {
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string();
});
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
let mouse_combo = setting_combo(ctx, scope, "Mouse input", mouse_names, mouse_i, |s, i| {
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
s.mouse_mode = MOUSE_MODES[i].0.to_string();
});
let invert_scroll_toggle = setting_toggle(
ctx,
scope,
"Invert scroll direction",
s.invert_scroll,
|s, on| s.invert_scroll = on,
);
let invert_scroll_toggle =
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
s.invert_scroll = on
});
let shortcuts_toggle = setting_toggle(
ctx,
scope,
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
s.inhibit_shortcuts,
|s, on| s.inhibit_shortcuts = on,
@@ -650,28 +418,20 @@ pub(crate) fn settings_page(
// --- Audio -----------------------------------------------------------------------------
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
let channels_combo = setting_combo(ctx, scope, "Audio channels", ac_names, ac_i, |s, i| {
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
s.audio_channels = AUDIO_CHANNELS[i].0;
});
let mic_toggle = setting_toggle(
ctx,
scope,
"Stream microphone to the host",
s.mic_enabled,
|s, on| s.mic_enabled = on,
);
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
let hud_combo = setting_combo(
ctx,
scope,
"Stats overlay (HUD)",
hud_names,
hud_i,
|s, i| {
s.set_stats_verbosity(STATS_TIERS[i].0);
},
);
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
s.set_stats_verbosity(STATS_TIERS[i].0);
});
let licenses_button = {
let ss = set_screen.clone();
@@ -679,7 +439,6 @@ pub(crate) fn settings_page(
};
let library_toggle = setting_toggle(
ctx,
scope,
"Show game library (experimental)",
s.library_enabled,
|s, on| s.library_enabled = on,
@@ -704,22 +463,14 @@ pub(crate) fn settings_page(
let mut out = group(
Some("Resolution"),
vec![
described_overridable(
(rev, set_rev),
scope,
"resolution",
over.resolution,
described(
res_combo,
"The host drives a real virtual output at exactly this size \u{2014} true \
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
(1:1) through every resize.",
),
described_overridable(
(rev, set_rev),
scope,
"refresh_hz",
over.refresh_hz,
described(
hz_combo,
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
connect.",
@@ -730,40 +481,24 @@ pub(crate) fn settings_page(
out.extend(group(
Some("Quality"),
vec![
described_overridable(
(rev, set_rev),
scope,
"render_scale",
over.render_scale,
described(
scale_combo,
"Above native supersamples for sharpness; below renders lighter on the \
host and the link. This device resamples the result to the window.",
),
described_overridable(
(rev, set_rev),
scope,
"bitrate_kbps",
over.bitrate_kbps,
described(
bitrate_box,
"0 lets the host decide (its default, clamped to what it supports). A \
host card\u{2019}s context menu has a network speed test.",
),
described_overridable(
(rev, set_rev),
scope,
"codec",
over.codec,
described(
codec_combo,
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
PyroWave is the low-latency wavelet codec for a WIRED link: it trades \
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
gigabit Ethernet.",
),
described_overridable(
(rev, set_rev),
scope,
"hdr_enabled",
over.hdr_enabled,
described(
hdr_toggle,
"HDR10, when the host has HDR content and this display supports it. \
HEVC only; otherwise the stream stays SDR.",
@@ -771,12 +506,9 @@ pub(crate) fn settings_page(
],
None,
));
// Decoder and GPU are facts about THIS device's hardware — never per profile.
out.extend(group(
Some("Decoding"),
if profile_mode {
Vec::new()
} else {
{
let mut fields = vec![described(
decoder_combo,
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
@@ -796,11 +528,7 @@ pub(crate) fn settings_page(
));
out.extend(group(
Some("Host output"),
vec![described_overridable(
(rev, set_rev),
scope,
"compositor",
over.compositor,
vec![described(
comp_combo,
"The backend the host uses for its virtual output (Linux hosts only). A \
specific choice falls back to auto-detection when that backend \
@@ -814,11 +542,7 @@ pub(crate) fn settings_page(
"input" => {
let mut out = group(
Some("Touch & pointer"),
vec![described_overridable(
(rev, set_rev),
scope,
"touch_mode",
over.touch_mode,
vec![described(
touch_combo,
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
@@ -829,31 +553,19 @@ pub(crate) fn settings_page(
out.extend(group(
Some("Keyboard & mouse"),
vec![
described_overridable(
(rev, set_rev),
scope,
"mouse_mode",
over.mouse_mode,
described(
mouse_combo,
"Capture locks the pointer to the stream and sends relative motion — \
best for games. Desktop leaves the pointer free to enter and leave \
the stream and sends absolute positions best for remote desktop \
work. Ctrl+Alt+Shift+M switches live.",
),
described_overridable(
(rev, set_rev),
scope,
"inhibit_shortcuts",
over.inhibit_shortcuts,
described(
shortcuts_toggle,
"Alt+Tab, the Windows key and friends reach the host while the stream \
has input captured. Off, they act on this machine instead.",
),
described_overridable(
(rev, set_rev),
scope,
"invert_scroll",
over.invert_scroll,
described(
invert_scroll_toggle,
"Reverses the wheel and trackpad scroll direction sent to the host.",
),
@@ -866,32 +578,21 @@ pub(crate) fn settings_page(
"Controllers",
group(
None,
[
vec![
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
// Which physical pad this device forwards is a device fact (tier G), so it
// renders only in the defaults scope; the EMULATED type below is profileable.
(!profile_mode).then(|| {
described(
described(
forward_combo,
"Every connected controller is forwarded, each as its own player. Pick \
one to force single-player \u{2014} only it reaches the host.",
)
}),
Some(described_overridable(
(rev, set_rev),
scope,
"gamepad",
over.gamepad,
),
described(
pad_combo,
"The virtual pad created on the host. Automatic matches your controller \
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
motion.",
)),
]
.into_iter()
.flatten()
.collect(),
),
],
Some("Applies from the next session."),
),
),
@@ -900,20 +601,12 @@ pub(crate) fn settings_page(
group(
None,
vec![
described_overridable(
(rev, set_rev),
scope,
"audio_channels",
over.audio_channels,
described(
channels_combo,
"The speaker layout requested from the host. It downmixes if its own \
output has fewer channels.",
),
described_overridable(
(rev, set_rev),
scope,
"mic_enabled",
over.mic_enabled,
described(
mic_toggle,
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
),
@@ -933,36 +626,24 @@ pub(crate) fn settings_page(
_ => {
let mut out = group(
Some("Session"),
vec![described_overridable(
(rev, set_rev),
scope,
"fullscreen_on_stream",
over.fullscreen_on_stream,
fullscreen_toggle,
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
vec![
described(
fullscreen_toggle,
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
live.",
)]
.into_iter()
// Auto-wake is about this host and this network, not about "Game vs Work" —
// it stays global in v1 (design §3, tier H/G).
.chain((!profile_mode).then(|| {
),
described(
auto_wake_toggle,
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
waits for it to boot. Turn off if hosts behind a VPN look offline when \
they aren\u{2019}t.",
)
}))
.collect(),
),
],
None,
);
out.extend(group(
Some("Statistics"),
vec![described_overridable(
(rev, set_rev),
scope,
"stats_verbosity",
over.stats_verbosity,
vec![described(
hud_combo,
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
@@ -970,18 +651,13 @@ pub(crate) fn settings_page(
)],
None,
));
// The library browser is an app-level toggle for this device, not a per-profile one.
out.extend(group(
Some("Library"),
if profile_mode {
Vec::new()
} else {
vec![described(
vec![described(
library_toggle,
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
their Steam and custom games and launch one directly. No extra host setup.",
)]
},
)],
None,
));
("General", out)
@@ -1022,55 +698,6 @@ pub(crate) fn settings_page(
// left inset belongs to WinUI's own template (a string prop is all we can set), so it
// sat noticeably right of the cards under it. In the content column it shares the cards'
// left edge by construction.
// The scope switcher sits above the section title, so it reads as "which layer all of
// this belongs to" rather than as one section's setting.
let catalog = ProfilesFile::load();
let mut scope_names = vec!["Default settings".to_string()];
let mut scope_ids: Vec<String> = vec![String::new()];
for p in &catalog.profiles {
scope_names.push(p.name.clone());
scope_ids.push(p.id.clone());
}
scope_names.push("New profile\u{2026}".to_string());
scope_ids.push(NEW_PROFILE.to_string());
let scope_i = scope_ids.iter().position(|i| i == scope).unwrap_or(0);
let switcher = described(
ComboBox::new(scope_names)
.header("Editing")
.selected_index(scope_i as i32)
.on_selection_changed({
let (set_scope, ids) = (set_scope.clone(), scope_ids.clone());
move |i: i32| {
let Some(id) = ids.get(i.max(0) as usize) else {
return;
};
if id == NEW_PROFILE {
// Creation needs no prompt here: WinUI's ContentDialog is text-only in
// this toolkit, so a new profile takes an auto-numbered name and is
// renamed from the row below — one fewer modal, same end state.
let mut catalog = ProfilesFile::load();
let name = (1..)
.map(|n| format!("Profile {n}"))
.find(|n| !catalog.name_taken(n, None))
.unwrap_or_else(|| "Profile".to_string());
let profile = StreamProfile::new(name);
let new_id = profile.id.clone();
catalog.profiles.push(profile);
if catalog.save().is_ok() {
set_scope.call(new_id);
}
return;
}
set_scope.call(id.clone());
}
}),
"A profile overrides only what you change while it is selected; everything else \
follows Default settings.",
);
let mut header_rows = vec![switcher];
if let Some(p) = &active {
header_rows.push(profile_actions(p, set_scope, set_delete));
}
let titled: Vec<Element> = std::iter::once(
text_block(title)
.font_size(28.0)
@@ -1079,7 +706,6 @@ pub(crate) fn settings_page(
.margin(edges(0.0, 0.0, 0.0, 6.0))
.into(),
)
.chain(group(None, header_rows, None))
.chain(groups)
.collect();
// The keyed column MUST sit inside a panel's child list, not directly under the
@@ -1091,74 +717,12 @@ pub(crate) fn settings_page(
// combos render blank until touched. A panel (vstack) takes the keyed path, so the key
// remounts the whole column and every prop is applied fresh.
let content = scroll_view(
// ⚠️ Keyed on (scope, section), not section alone: switching SCOPE re-renders the same
// section's controls with different values, and an in-place diff re-sets each reused
// ComboBox's items (clearing WinUI's selection) while skipping `selected_index`
// wherever the two scopes' values compare equal — the combo then renders blank. A
// fresh mount applies every prop. Same reason the section key exists.
vstack(vec![vstack(titled)
.spacing(10.0)
.with_key(format!("{scope}/{section}"))
.into()])
.margin(edges(24.0, 20.0, 28.0, 40.0)),
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
.margin(edges(24.0, 20.0, 28.0, 40.0)),
)
.opacity(progress)
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
// The delete confirmation, when armed. Declarative, like every other dialog in this shell:
// it is an element in the tree with `is_open`, not a call.
let confirm: Option<Element> = delete_pending.as_ref().and_then(|id| {
let p = ProfilesFile::load().find_by_id(id).cloned()?;
// The warning counts what actually breaks: hosts that fall back to the defaults, and
// pinned cards that disappear (design §6).
let known = KnownHosts::load();
let bound = known
.hosts
.iter()
.filter(|h| h.profile_id.as_deref() == Some(p.id.as_str()))
.count();
let pinned = known
.hosts
.iter()
.filter(|h| h.pinned_profiles.iter().any(|x| x == &p.id))
.count();
let mut body = format!("\u{201c}{}\u{201d} will be removed.", p.name);
if bound > 0 {
body.push_str(&format!(
" {bound} host{} will fall back to Default settings.",
if bound == 1 { "" } else { "s" }
));
}
if pinned > 0 {
body.push_str(&format!(
" {pinned} pinned card{} will disappear.",
if pinned == 1 { "" } else { "s" }
));
}
let (id, set_scope, set_delete) = (p.id.clone(), set_scope.clone(), set_delete.clone());
Some(
ContentDialog::new("Delete profile?")
.content(body)
.primary_button_text("Delete")
.close_button_text("Cancel")
.is_open(true)
.on_closed(move |r: ContentDialogResult| {
set_delete.call(None);
if r != ContentDialogResult::Primary {
return;
}
let mut catalog = ProfilesFile::load();
catalog.profiles.retain(|p| p.id != id);
// Bindings and pins are left dangling on purpose: they resolve as "no
// profile" everywhere, and rewriting every host record here would be a
// second, racier source of truth.
if catalog.save().is_ok() {
set_scope.call(String::new());
}
})
.into(),
)
});
let nav = NavigationView::new(items, content)
NavigationView::new(items, content)
.pane_title("Settings")
.selected_tag(section)
.on_selection_changed({
@@ -1170,9 +734,6 @@ pub(crate) fn settings_page(
.on_back_requested({
let ss = set_screen.clone();
move || ss.call(Screen::Hosts)
});
match confirm {
Some(dialog) => vstack(vec![nav.into(), dialog]).into(),
None => nav.into(),
}
})
.into()
}
+9 -48
View File
@@ -5,8 +5,6 @@
use super::style::*;
use super::{Screen, Svc};
use crate::probe::run_speed_probe;
use crate::trust::KnownHosts;
use pf_client_core::profiles::ProfilesFile;
use windows_reactor::*;
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via
@@ -124,54 +122,17 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
recommended_kbps,
} => {
let recommended_mbps = f64::from(*recommended_kbps) / 1000.0;
// A measured bitrate belongs in the layer the TESTED host actually reads it from
// (design/client-settings-profiles.md §5.3) — writing the global here is what made
// measuring one host re-tune every other one. Resolved the way a connect resolves
// it: the one-off this test was started with, else the host's binding.
let target = ctx.shared.target.lock().unwrap().clone();
let bound = KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == target.addr && h.port == target.port)
.and_then(|h| h.profile_id.clone());
let profile = match target.profile.as_deref() {
Some("") => None,
Some(id) => Some(id.to_string()),
None => bound,
}
.and_then(|reference| ProfilesFile::load().resolve(&reference).0.cloned());
let apply_btn = {
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
let profile = profile.clone();
button(match &profile {
Some(p) => format!(
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
p.name
),
None => format!("Use {recommended_mbps:.0} Mb/s"),
})
.accent()
.icon(Symbol::Accept)
.on_click(move || {
match &profile {
Some(p) => {
let mut catalog = ProfilesFile::load();
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == p.id) {
slot.overrides.bitrate_kbps = Some(kbps);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"),
"saving the measured bitrate");
}
}
}
None => {
let mut s = ctx.settings.lock().unwrap();
s.bitrate_kbps = kbps;
s.save();
}
}
ss.call(Screen::Hosts);
})
button(format!("Use {recommended_mbps:.0} Mb/s"))
.accent()
.icon(Symbol::Accept)
.on_click(move || {
let mut s = ctx.settings.lock().unwrap();
s.bitrate_kbps = kbps;
s.save();
ss.call(Screen::Hosts);
})
};
let results = card(
vstack((
-6
View File
@@ -101,7 +101,6 @@ pub(crate) fn spawn_session(
connect_timeout_secs: u64,
fullscreen: bool,
launch: Option<&str>,
profile: Option<&str>,
slot: SessionChild,
on_event: impl FnMut(SpawnEvent) + Send + 'static,
) -> Result<(), String> {
@@ -118,11 +117,6 @@ pub(crate) fn spawn_session(
if let Some(id) = launch {
cmd.arg("--launch").arg(id);
}
// Only a ONE-OFF pick rides the flag: without it the session resolves the host's own
// binding through the same helper this shell would have used, so the two can't disagree.
if let Some(reference) = profile {
cmd.arg("--profile").arg(reference);
}
add_window_pos(&mut cmd);
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
}
@@ -0,0 +1,79 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"components": [
{
"type": "library",
"name": "pyrowave",
"version": "509e4f887b585a3f97471fcc804e9de649f2c16f",
"description": "GPU wavelet codec, vendored at crates/pyrowave-sys/vendor/pyrowave (pruned tree) with local patches from crates/pyrowave-sys/patches/; commits recorded in vendor/pyrowave/PUNKTFUNK-VENDOR.txt",
"licenses": [{ "license": { "id": "MIT" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/Themaister/pyrowave" }]
},
{
"type": "library",
"name": "Granite",
"version": "44362775d36e0c4139352f83efd96bab4e239f66",
"description": "Vulkan backend subset vendored inside the pyrowave tree",
"licenses": [{ "license": { "id": "MIT" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/Themaister/Granite" }]
},
{
"type": "library",
"name": "volk",
"version": "47cddf7ed97b94118a08aacb548a411188e016cc",
"description": "Vulkan meta-loader vendored inside the pyrowave/Granite tree",
"licenses": [{ "license": { "id": "MIT" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/zeux/volk" }]
},
{
"type": "library",
"name": "Vulkan-Headers",
"version": "015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9",
"description": "Vulkan API headers vendored inside the pyrowave tree",
"licenses": [{ "license": { "id": "Apache-2.0" } }, { "license": { "id": "MIT" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/KhronosGroup/Vulkan-Headers" }]
},
{
"type": "library",
"name": "libvpl",
"version": "2.17.0",
"description": "Intel oneVPL dispatcher, vendored at crates/libvpl-sys/vendor/libvpl",
"licenses": [{ "license": { "id": "MIT" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/intel/libvpl" }]
},
{
"type": "library",
"name": "FFmpeg",
"version": "7.x/8.x (system-provided on Linux; replaceable DLLs bundled with the Windows packages)",
"description": "Dynamically linked libav* decode/encode; LGPL notice at packaging/windows/licenses/FFmpeg-LGPL-NOTICE.txt",
"licenses": [{ "license": { "id": "LGPL-2.1-or-later" } }],
"externalReferences": [{ "type": "website", "url": "https://ffmpeg.org" }]
},
{
"type": "library",
"name": "SDL3",
"version": "3.x (system-provided or bundled per platform package)",
"description": "Windowing/input layer for the desktop clients, dynamically linked",
"licenses": [{ "license": { "id": "Zlib" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/libsdl-org/SDL" }]
},
{
"type": "application",
"name": "VB-CABLE",
"version": "redistributed installer, see packaging/windows/install-vbcable.ps1",
"description": "Third-party kernel-mode virtual audio driver redistributed with the Windows host; notice at packaging/windows/licenses/VB-CABLE-NOTICE.txt. Planned to be replaced by an attestation-signed first-party driver.",
"licenses": [{ "license": { "name": "Proprietary freeware (VB-Audio Software, redistribution permitted per notice)" } }],
"externalReferences": [{ "type": "website", "url": "https://vb-audio.com/Cable/" }]
},
{
"type": "application",
"name": "punktfunk-gamescope",
"version": "upstream gamescope pinned by packaging/nix/gamescope.nix (nixpkgs) or built by packaging/gamescope/build-punktfunk-gamescope.sh, plus 3 local patches from packaging/gamescope/patches/",
"description": "Patched gamescope compositor distributed via sysext/Arch/nix channels alongside the host",
"licenses": [{ "license": { "id": "BSD-2-Clause" } }],
"externalReferences": [{ "type": "vcs", "url": "https://github.com/ValveSoftware/gamescope" }]
}
]
}
+11 -16
View File
@@ -387,20 +387,11 @@ pub(crate) struct HdrP010Converter {
cbuf: ID3D11Buffer,
}
// The three converters' methods below are SAFE fns. They were `unsafe fn` because their bodies are
// D3D11 FFI, which is not the same thing as having a caller contract: every parameter is a borrowed
// windows-rs COM wrapper (`&ID3D11Device`, `&ID3D11DeviceContext`, `&ID3D11Texture2D`, the views) or
// a plain `u32`/`bool`, each body builds its own descriptors from those, and every created interface
// owns its reference. There is nothing a caller can pass that makes them unsound, and the proofs the
// markers carried said exactly that — "`?`-checked D3D11 methods on the live `device` borrow" — which
// is a description of the body, not an obligation. `unsafe` now marks the FFI inside them, where the
// blocks and their proofs already were. `compile_shader` KEEPS its marker: it takes `PCSTR`, a raw
// pointer the caller must guarantee is a NUL-terminated literal.
impl HdrP010Converter {
/// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the
/// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's
/// `recreate_ring` already drops it).
pub(crate) fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
pub(crate) unsafe fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader` receives
// `s!()` literals (its contract). Each created COM interface owns its own reference, and no
@@ -467,7 +458,7 @@ impl HdrP010Converter {
/// Called ONCE PER OUT-RING SLOT by the owner of the P010 textures, not per frame — see
/// [`Self::convert`]. Fails when the driver rejects a planar RTV, which is the one hard
/// requirement of this whole path (a D3D11.3+ runtime plus driver support).
pub(crate) fn plane_rtv(
pub(crate) unsafe fn plane_rtv(
device: &ID3D11Device,
dst: &ID3D11Texture2D,
format: DXGI_FORMAT,
@@ -507,7 +498,7 @@ impl HdrP010Converter {
/// ring slot's keyed-mutex hold, i.e. time the DRIVER spent blocked on that slot. Both are
/// lifetime-of-mode facts: the views belong to the out-ring slot (built in `ensure_out_ring`),
/// the buffer to this converter, which is already rebuilt on every mode change.
pub(crate) fn convert(
pub(crate) unsafe fn convert(
&self,
ctx: &ID3D11DeviceContext,
src_srv: &ID3D11ShaderResourceView,
@@ -702,7 +693,7 @@ pub(crate) struct BgraToYuvPlanes {
}
impl BgraToYuvPlanes {
pub(crate) fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
// SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
unsafe {
@@ -740,7 +731,7 @@ impl BgraToYuvPlanes {
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
/// passes; `w`/`h` are the full luma dims (even for 4:2:0).
#[allow(clippy::too_many_arguments)]
pub(crate) fn convert(
pub(crate) unsafe fn convert(
&self,
ctx: &ID3D11DeviceContext,
src_srv: &ID3D11ShaderResourceView,
@@ -837,7 +828,7 @@ impl VideoConverter {
/// linear (the HDR ring, used by a PyroWave session that tone-maps the HDR desktop down to the
/// 8-bit wavelet stream). The output is always studio-range BT.709 NV12 — the P010/BT.2020 HDR
/// path is [`HdrP010Converter`]'s job, never this one.
pub(crate) fn new(
pub(crate) unsafe fn new(
device: &ID3D11Device,
context: &ID3D11DeviceContext,
width: u32,
@@ -906,7 +897,11 @@ impl VideoConverter {
/// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output`
/// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
pub(crate) fn convert(&self, input: &ID3D11Texture2D, output: &ID3D11Texture2D) -> Result<()> {
pub(crate) unsafe fn convert(
&self,
input: &ID3D11Texture2D,
output: &ID3D11Texture2D,
) -> Result<()> {
// SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized
// stack descriptors and live out-params. `stream.pInputSurface` is a `ManuallyDrop` of the
// input view just created: `VideoProcessorBlt` only BORROWS it (a COM in-param never transfers
+22 -18
View File
@@ -648,7 +648,11 @@ impl IddPushCapturer {
// nothing downstream could detect the mismatch, because every field it would compare against
// had already been moved. Nothing below this line fails.
let fmt = Self::ring_format_for(new_display_hdr);
let new_slots = Self::create_ring_slots(&self.device, new_w, new_h, fmt)?;
// SAFETY: `create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls); we pass a live
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
// returned slot's texture + keyed mutex belongs to that same `self.device`.
let new_slots = unsafe { Self::create_ring_slots(&self.device, new_w, new_h, fmt)? };
self.display_hdr = new_display_hdr;
self.width = new_w;
self.height = new_h;
@@ -965,11 +969,11 @@ impl IddPushCapturer {
/// mid-session mode swap.
fn ensure_pyro_conv(&mut self) -> Result<()> {
if self.pyro_conv.is_none() {
self.pyro_conv = Some(BgraToYuvPlanes::new(
&self.device,
self.display_hdr,
self.want_444,
)?);
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
// failure before it is stored.
self.pyro_conv = Some(unsafe {
BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)?
});
}
Ok(())
}
@@ -980,22 +984,22 @@ impl IddPushCapturer {
fn ensure_converter(&mut self) -> Result<()> {
if self.display_hdr {
if self.hdr_p010_conv.is_none() {
self.hdr_p010_conv = Some(HdrP010Converter::new(
&self.device,
self.width,
self.height,
)?);
// SAFETY: `HdrP010Converter::new` is `unsafe` (it compiles D3D11 shaders + creates
// resources); we pass a live borrow of `self.device`, the device the converter's resources
// belong to, and `?` propagates any failure before the converter is stored.
self.hdr_p010_conv =
Some(unsafe { HdrP010Converter::new(&self.device, self.width, self.height)? });
}
} else if self.want_444 {
// Full-chroma passthrough — no conversion resources to build.
} else if self.video_conv.is_none() {
self.video_conv = Some(VideoConverter::new(
&self.device,
&self.context,
self.width,
self.height,
false,
)?);
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
// plain `u32` dimensions, and `?` propagates any failure before it is stored. The converter's
// resources belong to that same device/context.
self.video_conv = Some(unsafe {
VideoConverter::new(&self.device, &self.context, self.width, self.height, false)?
});
}
Ok(())
}
@@ -56,7 +56,7 @@ pub(super) struct CursorBlendPass {
}
impl CursorBlendPass {
pub(super) fn new(device: &ID3D11Device) -> Result<Self> {
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
// literals (its contract).
@@ -116,7 +116,11 @@ impl CursorBlendPass {
}
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
fn ensure_shape(&mut self, device: &ID3D11Device, ov: &pf_frame::CursorOverlay) -> Result<()> {
unsafe fn ensure_shape(
&mut self,
device: &ID3D11Device,
ov: &pf_frame::CursorOverlay,
) -> Result<()> {
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
// holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
@@ -166,7 +170,7 @@ impl CursorBlendPass {
/// composition) — linearize and scale to the target's SDR white. The quad is placed purely
/// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
/// the target automatically.
pub(super) fn blend(
pub(super) unsafe fn blend(
&mut self,
device: &ID3D11Device,
ctx: &ID3D11DeviceContext,
@@ -85,7 +85,7 @@ impl IddPushCapturer {
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
pub(super) fn create_ring_slots(
pub(super) unsafe fn create_ring_slots(
device: &ID3D11Device,
w: u32,
h: u32,
@@ -137,10 +137,9 @@ impl Capturer for SyntheticNv12Capturer {
/// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference /
/// max-VRAM LUID), falling back to adapter 0.
///
/// Safe: it takes no arguments, so there is nothing a caller could get wrong — it creates the
/// factory itself and returns an owned adapter. The `# Safety` section it used to carry ("calls
/// DXGI enumeration; returns owned COM objects") described the body, which is not a contract.
fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
/// # Safety
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
// created here and the adapters it returns own their own COM references. No raw pointers.
unsafe {
@@ -156,10 +155,9 @@ fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
///
/// Safe: its old `# Safety` asked for "a live D3D11 device", which `&ID3D11Device` — a borrowed,
/// reference-counted COM wrapper — already guarantees; the rest ("the returned texture is owned by
/// the caller") is an ownership note, not a soundness obligation. Every flag is a plain scalar.
fn create_nv12(
/// # Safety
/// `device` must be a live D3D11 device; the returned texture is owned by the caller.
unsafe fn create_nv12(
device: &ID3D11Device,
width: u32,
height: u32,
@@ -183,8 +181,8 @@ fn create_nv12(
..Default::default()
};
let mut tex: Option<ID3D11Texture2D> = None;
// SAFETY: one `?`-checked `CreateTexture2D` on the `&ID3D11Device` borrow, which the borrow
// itself keeps live, with a fully-initialized stack descriptor and a live `Option` out-param.
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
// above), with a fully-initialized stack descriptor and a live `Option` out-param.
unsafe {
device
.CreateTexture2D(&desc, None, Some(&mut tex))
-10
View File
@@ -260,16 +260,6 @@ fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
}
}
/// Put plain text on this machine's clipboard — the "Copy link" affordance, which has nothing
/// to do with the streaming bridge above but wants the same OS plumbing. Best-effort: a
/// clipboard another process is holding open is a transient nuisance, never worth failing a
/// user action over. A no-op where this build has no OS clipboard.
pub fn set_text(text: &str) {
if let Err(e) = os::set(MIME_TEXT, text.as_bytes()) {
tracing::warn!(error = %format!("{e:#}"), "copying to the clipboard");
}
}
#[cfg(windows)]
mod os {
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
-165
View File
@@ -143,109 +143,6 @@ impl SettingsOverlay {
s
}
/// Record, as overrides, every tier-P field that differs between two settings snapshots.
///
/// This is for front-ends that commit PER CONTROL rather than per dialog (the WinUI shell
/// writes on every change; the GTK one writes once on close). They can't hand over a list
/// of touched fields, so they hand over "the effective settings before this control fired"
/// and "after": the only field that can differ is the one the user just touched.
///
/// That is not the diff-on-save this design rejects. The comparison is against the
/// EFFECTIVE settings — what the control was showing — not against the globals, so setting
/// a value back to what the global happens to be still records an override, which is the
/// pin the design asks for. It only ever adds overrides; removing one is an explicit
/// reset, which is a different operation.
pub fn absorb(&mut self, before: &Settings, after: &Settings) {
if after.width != before.width {
self.width = Some(after.width);
}
if after.height != before.height {
self.height = Some(after.height);
}
if after.refresh_hz != before.refresh_hz {
self.refresh_hz = Some(after.refresh_hz);
}
if after.match_window != before.match_window {
self.match_window = Some(after.match_window);
}
if after.bitrate_kbps != before.bitrate_kbps {
self.bitrate_kbps = Some(after.bitrate_kbps);
}
if after.render_scale != before.render_scale {
self.render_scale = Some(after.render_scale);
}
if after.codec != before.codec {
self.codec = Some(after.codec.clone());
}
if after.hdr_enabled != before.hdr_enabled {
self.hdr_enabled = Some(after.hdr_enabled);
}
if after.compositor != before.compositor {
self.compositor = Some(after.compositor.clone());
}
if after.audio_channels != before.audio_channels {
self.audio_channels = Some(after.audio_channels);
}
if after.mic_enabled != before.mic_enabled {
self.mic_enabled = Some(after.mic_enabled);
}
if after.touch_mode != before.touch_mode {
self.touch_mode = Some(after.touch_mode.clone());
}
if after.mouse_mode != before.mouse_mode {
self.mouse_mode = Some(after.mouse_mode.clone());
}
if after.invert_scroll != before.invert_scroll {
self.invert_scroll = Some(after.invert_scroll);
}
if after.inhibit_shortcuts != before.inhibit_shortcuts {
self.inhibit_shortcuts = Some(after.inhibit_shortcuts);
}
if after.gamepad != before.gamepad {
self.gamepad = Some(after.gamepad.clone());
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
if after.fullscreen_on_stream != before.fullscreen_on_stream {
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
}
}
/// Drop one override by its overlay field name, putting the row back to inheriting. The
/// names are the serialised ones, so a UI can carry them as plain strings; `resolution`
/// is the one alias, covering the width/height/match-window tri-state a single control
/// drives on every client. Returns false for a name this build doesn't know.
pub fn clear(&mut self, field: &str) -> bool {
match field {
"resolution" => {
self.width = None;
self.height = None;
self.match_window = None;
}
"width" => self.width = None,
"height" => self.height = None,
"refresh_hz" => self.refresh_hz = None,
"match_window" => self.match_window = None,
"bitrate_kbps" => self.bitrate_kbps = None,
"render_scale" => self.render_scale = None,
"codec" => self.codec = None,
"hdr_enabled" => self.hdr_enabled = None,
"compositor" => self.compositor = None,
"audio_channels" => self.audio_channels = None,
"mic_enabled" => self.mic_enabled = None,
"touch_mode" => self.touch_mode = None,
"mouse_mode" => self.mouse_mode = None,
"invert_scroll" => self.invert_scroll = None,
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
_ => return false,
}
true
}
/// True when the profile overrides nothing — "inherits everything", the state a freshly
/// created profile starts in. Unknown-key carry-through counts: a profile that only holds
/// a newer client's field is not empty.
@@ -476,68 +373,6 @@ mod tests {
assert_eq!(pin.apply(&moved).bitrate_kbps, 20000);
}
/// `absorb` records exactly the field a control changed, compares against the EFFECTIVE
/// settings (so a value equal to the global is still a pin), and never removes anything.
#[test]
fn absorb_records_the_touched_field_only() {
let base = Settings {
bitrate_kbps: 20000,
codec: "hevc".into(),
..Default::default()
};
let mut o = SettingsOverlay::default();
// One control fires: before = what it was showing, after = what the user picked.
let before = o.apply(&base);
let mut after = before.clone();
after.codec = "av1".into();
o.absorb(&before, &after);
assert_eq!(o.codec.as_deref(), Some("av1"));
assert_eq!(o.bitrate_kbps, None, "nothing else may be recorded");
// Setting it BACK to the global's value is still an override — the pin case. This is
// what makes absorb different from diffing against the globals at save time.
let before = o.apply(&base);
let mut after = before.clone();
after.codec = "hevc".into();
o.absorb(&before, &after);
assert_eq!(o.codec.as_deref(), Some("hevc"));
let mut moved = base.clone();
moved.codec = "h264".into();
assert_eq!(o.apply(&moved).codec, "hevc");
// The stats tier goes through the resolver, not the legacy bool.
let before = o.apply(&base);
let mut after = before.clone();
after.set_stats_verbosity(StatsVerbosity::Detailed);
o.absorb(&before, &after);
assert_eq!(o.stats_verbosity, Some(StatsVerbosity::Detailed));
// Identical snapshots record nothing.
let before = o.apply(&base);
let mut o2 = o.clone();
o2.absorb(&before, &before);
assert_eq!(o2, o);
}
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
#[test]
fn clear_drops_one_override() {
let mut o = SettingsOverlay {
width: Some(3840),
height: Some(2160),
match_window: Some(false),
codec: Some("av1".into()),
..Default::default()
};
assert!(o.clear("codec"));
assert_eq!(o.codec, None);
assert!(o.clear("resolution"));
assert_eq!((o.width, o.height, o.match_window), (None, None, None));
assert!(o.is_empty());
assert!(!o.clear("no_such_field"));
}
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
#[test]
+1 -1
View File
@@ -610,7 +610,7 @@ impl D3d11vaDecoder {
/// BGRA8) under its keyed mutex and describe the hand-off. The mutex acquire also
/// back-pressures against the presenter still reading this slot (only possible if the
/// stream runs `RING_SLOTS` ahead of present).
fn lift(&mut self) -> Result<D3d11Frame> {
unsafe fn lift(&mut self) -> Result<D3d11Frame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
@@ -29,13 +29,6 @@
//! rings are retired, not destroyed — the presenter may still hold their views (see
//! [`RETIRE_HANDOVERS`]).
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is `pyrowave-sys` C-API and ash/Vulkan compute 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)]
use crate::video::{ColorDesc, VulkanDecodeDevice};
use anyhow::{bail, Context as _, Result};
use ash::vk;
+1 -1
View File
@@ -139,7 +139,7 @@ impl VaapiDecoder {
/// single-plane texture with the chroma dropped, painting the screen green. The fix:
/// derive the COMBINED fourcc from the decoder's software pixel format (NV12 →
/// `DRM_FORMAT_NV12`) and flatten every plane across every layer in order (Y then UV).
fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
+7 -27
View File
@@ -61,45 +61,25 @@ struct VkCtxStorage {
/// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default,
/// which only serializes FFmpeg against itself — the presenter submits to the same
/// graphics queue from another thread and holds this same lock around its calls.
///
/// # Safety
/// FFmpeg calls this with the `AVHWDeviceContext` it owns, whose `user_opaque` we set to a
/// `*const QueueLock` before handing the context over.
unsafe extern "C" fn ffvk_lock_queue(
ctx: *mut pf_ffvk::AVHWDeviceContext,
_queue_family: u32,
_index: u32,
) {
// SAFETY: `ctx` is the live context FFmpeg passes to its own callback, and the two
// `AVHWDeviceContext` declarations (pf_ffvk's and ffmpeg-sys's) describe the same C struct, so
// the cast reads the same `user_opaque` field. That field holds the pointer we stored, which
// borrows `VkCtxStorage::_queue_lock` — an `Arc<QueueLock>` the storage keeps alive for as long
// as the hw device context can fire this trampoline (see its field doc), so the lock outlives
// every call FFmpeg can make.
unsafe {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).lock();
}
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).lock();
}
/// The matching `unlock_queue` trampoline — see [`ffvk_lock_queue`].
///
/// # Safety
/// As [`ffvk_lock_queue`]; additionally, FFmpeg only calls this after a matching `lock_queue`, so
/// the lock it releases is one this pair took.
unsafe extern "C" fn ffvk_unlock_queue(
ctx: *mut pf_ffvk::AVHWDeviceContext,
_queue_family: u32,
_index: u32,
) {
// SAFETY: as `ffvk_lock_queue` — same live context from FFmpeg, same `user_opaque` pointer into
// the `Arc<QueueLock>` that `VkCtxStorage` keeps alive for the context's whole lifetime.
unsafe {
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).unlock();
}
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
let lock = (*dev).user_opaque as *const QueueLock;
(*lock).unlock();
}
impl VulkanDecoder {
@@ -320,7 +300,7 @@ impl VulkanDecoder {
/// guard — keeps the image + frames context alive through present) and ship the
/// POINTERS; the presenter reads the live sync state under the frames-context lock
/// at its own submit time.
fn extract(&mut self) -> Result<VkVideoFrame> {
unsafe fn extract(&mut self) -> Result<VkVideoFrame> {
use ffmpeg::ffi;
unsafe {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 {
+23 -42
View File
@@ -78,49 +78,30 @@ impl CudaHw {
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
// Each `?`/`bail!` below drops whatever has been built so far — `AvBuffer`'s `Drop` is the
// single unref path, so the failure branches carry no cleanup of their own.
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
))
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
(*cu).cuda_ctx = cu_ctx; // share the importer's context
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
if r < 0 {
bail!("av_hwdevice_ctx_init failed ({r})");
}
// SAFETY: `av_hwdevice_ctx_alloc` returns either null — which `AvBuffer::from_raw` rejects,
// so the `?` returns before anything below runs — or a fresh ref whose `data` libav has
// already initialized as an `AVHWDeviceContext`. For a CUDA device that context's `hwctx`
// is an `AVCUDADeviceContext` (our repr(C) mirror of libav's layout), so writing
// `cuda_ctx` is an in-bounds field store on a live allocation, and `cu_ctx` is a valid
// `CUcontext` by this fn's contract. `av_hwdevice_ctx_init` then takes the same live ref;
// it must see `cuda_ctx` already set, which is why the store precedes it.
let device_ref = unsafe {
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
))
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
(*cu).cuda_ctx = cu_ctx; // share the importer's context
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
if r < 0 {
bail!("av_hwdevice_ctx_init failed ({r})");
}
device_ref
};
// SAFETY: the same shape one level up — `av_hwframe_ctx_alloc` is handed the live,
// now-initialized device ref and returns null (rejected by `from_raw`, so the `?` leaves
// before the writes) or a ref whose `data` is a live `AVHWFramesContext`. Every store below
// is an in-bounds field write on that allocation, all plain scalars, done before
// `av_hwframe_ctx_init` reads them.
let frames_ref = unsafe {
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
.context("av_hwframe_ctx_alloc failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
(*fc).sw_format = pixel_to_av(sw_format);
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = 0; // we supply the device pointers
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init failed ({r})");
}
frames_ref
};
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})");
}
Ok(CudaHw {
frames_ref,
device_ref,
@@ -57,12 +57,6 @@
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
//! and the VAAPI/software backends carry the session).
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw CUDA driver + `nvEncodeAPI` entry-table 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 / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -21,13 +21,6 @@
//! on every AU. NOTE: until Phase 2 lands `CODEC_PYROWAVE` negotiation + a client decoder,
//! no shipping client can decode this — the backend is reachable only via an explicit
//! `PUNKTFUNK_ENCODER=pyrowave` and logs that loudly.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is `pyrowave-sys` C-API and ash/Vulkan compute 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 module carries a `// SAFETY:` proof (parent module enforces it).
use super::vk_util::{
+51 -81
View File
@@ -157,7 +157,7 @@ struct Vui {
}
/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020nc on the zero-copy
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy
/// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709
/// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to
/// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input
@@ -204,7 +204,7 @@ fn async_depth(raw: Option<&str>) -> u32 {
/// matrix untouched.)
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
if ten_bit {
c"format=p010:out_color_matrix=bt2020nc:out_range=limited"
c"format=p010:out_color_matrix=bt2020:out_range=limited"
} else {
c"format=nv12:out_color_matrix=bt709:out_range=limited"
}
@@ -268,23 +268,17 @@ unsafe fn open_vaapi_encoder(
let modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
let mut first_err = None;
for &lp in modes {
// SAFETY: `device_ref`/`frames_ref` are forwarded unchanged from this fn's own contract —
// valid `AVBufferRef`s — and the callee only `av_buffer_ref`s them, so retrying another
// entrypoint below reuses the same still-owned buffers rather than consuming them.
let attempt = unsafe {
open_vaapi_encoder_mode(
codec,
width,
height,
fps,
bitrate_bps,
device_ref,
frames_ref,
ten_bit,
lp,
)
};
match attempt {
match open_vaapi_encoder_mode(
codec,
width,
height,
fps,
bitrate_bps,
device_ref,
frames_ref,
ten_bit,
lp,
) {
Ok(enc) => {
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
m.insert(key.clone(), latched_mode(lp));
@@ -342,24 +336,16 @@ unsafe fn open_vaapi_encoder_mode(
video.set_format(if ten_bit { Pixel::P010LE } else { Pixel::NV12 });
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps);
// SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated
// just above, which outlives every write here (it is moved into the return value). The
// colour/gop/pix_fmt stores are in-bounds scalar field writes on that live context. The two
// `av_buffer_ref` calls take `device_ref`/`frames_ref`, valid `AVBufferRef`s by this fn's
// contract, and each returns a NEW reference that the codec context adopts and unrefs when it
// is freed — so this shares the caller's buffers rather than taking them over.
unsafe {
let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
let vui = vui_for(ten_bit);
(*raw).colorspace = vui.colorspace;
(*raw).color_range = vui.range;
(*raw).color_primaries = vui.primaries;
(*raw).color_trc = vui.trc;
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
}
let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
let vui = vui_for(ten_bit);
(*raw).colorspace = vui.colorspace;
(*raw).color_range = vui.range;
(*raw).color_primaries = vui.primaries;
(*raw).color_trc = vui.trc;
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
let mut opts = Dictionary::new();
if let Some(profile) = explicit_profile(codec, ten_bit) {
@@ -486,52 +472,35 @@ struct VaapiHw {
}
impl VaapiHw {
/// 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> {
unsafe fn new(sw_format: ffi::AVPixelFormat, w: u32, h: u32, pool: c_int) -> Result<Self> {
let mut device_ref: *mut ffi::AVBufferRef = ptr::null_mut();
let node = render_node();
// SAFETY: `device_ref` is a live local out-param; `node` is a NUL-terminated `CString` that
// outlives the call, and the remaining arguments are the documented "no options" pair. On
// success libav writes ONE owned reference into `device_ref`.
let r = unsafe {
ffi::av_hwdevice_ctx_create(
&mut device_ref,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
node.as_ptr(),
ptr::null_mut(),
0,
)
};
let r = ffi::av_hwdevice_ctx_create(
&mut device_ref,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
node.as_ptr(),
ptr::null_mut(),
0,
);
if r < 0 {
bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r));
}
// `av_hwdevice_ctx_create` wrote an owned ref into `device_ref`; take ownership of it here so
// every `bail!` below drops it (and the frames ref, once built) without cleanup of its own.
// 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) }
let device_ref = AvBuffer::from_raw(device_ref)
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
// SAFETY: the same shape as `CudaHw::new` — `av_hwframe_ctx_alloc` is handed the live device
// ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes below)
// or a ref whose `data` libav has already initialized as an `AVHWFramesContext`. Every store
// is then an in-bounds scalar write on that live allocation, done before
// `av_hwframe_ctx_init` reads them.
let frames_ref = unsafe {
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
.context("av_hwframe_ctx_alloc(VAAPI) failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*fc).sw_format = sw_format;
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = pool;
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
}
frames_ref
};
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
.context("av_hwframe_ctx_alloc(VAAPI) failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*fc).sw_format = sw_format;
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = pool;
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
}
Ok(VaapiHw {
frames_ref,
device_ref,
@@ -572,11 +541,12 @@ impl CpuInner {
ffi::AVPixelFormat::AV_PIX_FMT_NV12
};
const POOL: c_int = 16;
// `VaapiHw::new` is safe now; it returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
// on drop. (libav is initialized on every path here `VaapiEncoder::open` → `ensure_inner`
// → `CpuInner::open`, and `open` ran `ffmpeg::init()` — which the call relies on but cannot
// itself be broken by a caller, so it is a note rather than a contract.)
let hw = VaapiHw::new(staging_av, width, height, POOL)?;
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
// `ffmpeg::init()`. The args are valid: an NV12/P010 sw_format, the validated positive
// `width`/`height`, pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
// on drop.
let hw = unsafe { VaapiHw::new(staging_av, width, height, POOL)? };
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
@@ -1565,7 +1535,7 @@ mod tests {
let args = scale_vaapi_args(true).to_str().unwrap();
for needle in [
"format=p010",
"out_color_matrix=bt2020nc",
"out_color_matrix=bt2020",
"out_range=limited",
] {
assert!(
@@ -6,13 +6,6 @@
//! visibility churn, and ~800 lines of construction `unsafe` get their own review surface.
//! Steady-state encode logic stays in the parent.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw ash/Vulkan object construction and bitstream writing 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)]
// The parent's whole item namespace (Frame, the consts, sibling helpers) — the point of the
// child-module shape. External imports are this file's own; `vk_util` is a crate-root sibling,
// so the path is `crate::`, not the parent-relative `super::` the parent uses.
@@ -1,13 +1,6 @@
//! Small ash/Vulkan leaf helpers shared by the Linux Vulkan encode backends
//! (`vulkan_video.rs`, `pyrowave.rs`) — extracted verbatim from `vulkan_video.rs`
//! when the PyroWave backend arrived so the two don't fork copies.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw ash/Vulkan object construction 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 carries a `// SAFETY:` proof (parent module enforces it).
use anyhow::Result;
@@ -10,12 +10,6 @@
//! VAAPI instead). Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`.
//! Opt-in via `PUNKTFUNK_VULKAN_ENCODE`; gated to HEVC/AV1 + a device that advertises the encode op.
//! The AV1 encode structs our pinned `ash 0.38` predates are vendored in `vk_av1_encode.rs`.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw ash/Vulkan Video calls against an app-owned DPB 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::too_many_arguments)]
use super::vk_util::{
-7
View File
@@ -5,13 +5,6 @@
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the
//! Windows-only async retrieve — stay in their backends. Sibling of [`super::nvenc_status`].
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table 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)]
use super::Codec;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
-6
View File
@@ -43,12 +43,6 @@
//! fallback + `PUNKTFUNK_AMF_FFMPEG` hatch are gone). 4:4:4 is **permanently** out: VCN hardware
//! does not encode 4:4:4.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw AMF COM-style vtable calls through `*mut AmfComponent` 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 / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
+117 -189
View File
@@ -330,40 +330,29 @@ unsafe fn open_win_encoder(
video.set_format(Pixel::from(sw_pix_fmt));
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps);
// SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated just
// above, which outlives every write here (it is opened and returned below). The gop/colour/
// pix_fmt stores are in-bounds scalar field writes on that live context. `device_ref` and
// `frames_ref` are valid `AVBufferRef`s by this fn's contract OR null — the system path passes
// null for both — and the `is_null` guards keep `av_buffer_ref` off the null case; each call
// returns a NEW reference that the codec context adopts and unrefs when freed, so this shares
// the caller's buffers rather than taking them over.
let raw = unsafe { video.as_mut_ptr() };
// SAFETY: as above — `raw` is that live `AVCodecContext` and every store below is an in-bounds
// field write on it, with the two `av_buffer_ref` calls guarded against null.
unsafe {
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit {
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
} else {
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
// VUI — else the client decoder washes the picture out.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
}
(*raw).pix_fmt = pix_fmt;
if !device_ref.is_null() {
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
}
if !frames_ref.is_null() {
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
}
let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit {
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from
// the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
} else {
// We hand the encoder BT.709 *limited* NV12 (video-processor or swscale CSC), so signal that
// VUI — else the client decoder washes the picture out.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
}
(*raw).pix_fmt = pix_fmt;
if !device_ref.is_null() {
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
}
if !frames_ref.is_null() {
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
}
// Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
@@ -438,19 +427,12 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
}
/// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`).
///
/// 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 {
unsafe fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
// windows-rs 0.62: the inherent method takes no args and returns the context (the OutRef form is
// only on the `_Impl` trait, for implementing the interface). Every D3D11 device has one.
// SAFETY: a `?`-free COM call on the live `device` borrow; it takes no pointers and every
// D3D11 device has an immediate context, so the `expect` is unreachable in practice.
unsafe {
device
.GetImmediateContext()
.expect("ID3D11Device always has an immediate context")
}
device
.GetImmediateContext()
.expect("ID3D11Device always has an immediate context")
}
// ---------------------------------------------------------------------------------------------
@@ -549,10 +531,11 @@ impl SystemInner {
}
/// Lazily (re)build the staging texture matching `dxgi_fmt` on the captured device.
///
/// Safe: `&ID3D11Device` is the live-device guarantee and `dxgi_fmt` is a plain enum; the
/// texture it creates is owned by `self`.
fn ensure_staging(&mut self, device: &ID3D11Device, dxgi_fmt: DXGI_FORMAT) -> Result<()> {
unsafe fn ensure_staging(
&mut self,
device: &ID3D11Device,
dxgi_fmt: DXGI_FORMAT,
) -> Result<()> {
if self.staging.is_some() {
return Ok(());
}
@@ -572,38 +555,25 @@ impl SystemInner {
MiscFlags: 0,
};
let mut t: Option<ID3D11Texture2D> = None;
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow, over a
// fully-initialized stack descriptor and a live `Option` out-param.
unsafe {
device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(staging readback)")?;
}
device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(staging readback)")?;
self.staging = t;
self.ctx = Some(immediate_context(device));
Ok(())
}
/// Send the reusable `sw_frame` to the encoder with the given pts / IDR flag.
///
/// Safe: both arguments are scalars, and `sw_frame`/`enc` are allocations `self` owns from its
/// constructor until `Drop` — no caller supplies or can invalidate them.
fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
// SAFETY: `self.sw_frame` is the `AVFrame` this struct allocated and owns, so the two field
// stores are in-bounds writes on a live allocation; `avcodec_send_frame` then takes that
// frame and `self.enc`'s own context, both live for the call and neither retained by libav
// (it references the frame's buffers itself).
unsafe {
(*self.sw_frame).pts = pts;
(*self.sw_frame).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
if r < 0 {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
}
unsafe fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
(*self.sw_frame).pts = pts;
(*self.sw_frame).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
if r < 0 {
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
}
Ok(())
}
@@ -830,10 +800,7 @@ impl SystemInner {
/// Lazily build the swscale context (src → NV12/P010, limited range, the given colorspace). A
/// SystemInner uses exactly one src→dst conversion for its lifetime (8-bit RGB→NV12 BT.709, or
/// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
///
/// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
/// (freed once in `Drop`).
fn ensure_sws(
unsafe fn ensure_sws(
&mut self,
src_av: ffi::AVPixelFormat,
dst_av: ffi::AVPixelFormat,
@@ -842,33 +809,25 @@ impl SystemInner {
if !self.sws.is_null() {
return Ok(());
}
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
// null trio, and returns an owned context or null — which is checked before use, so
// `sws_setColorspaceDetails` and the store below only ever see a live one.
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
// process, and the call only reads it.
let sws = unsafe {
let sws = ffi::sws_getContext(
self.width as c_int,
self.height as c_int,
src_av,
self.width as c_int,
self.height as c_int,
dst_av,
SWS_POINT,
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
);
if sws.is_null() {
bail!("sws_getContext(RGB→YUV) failed");
}
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
// we signal). For RGB input the src coefficient table is unused; pass dst for both.
let coeff = ffi::sws_getCoefficients(cs);
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
sws
};
let sws = ffi::sws_getContext(
self.width as c_int,
self.height as c_int,
src_av,
self.width as c_int,
self.height as c_int,
dst_av,
SWS_POINT,
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
);
if sws.is_null() {
bail!("sws_getContext(RGB→YUV) failed");
}
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI we
// signal). For RGB input the src coefficient table is unused; pass the dst table for both.
let coeff = ffi::sws_getCoefficients(cs);
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
self.sws = sws;
Ok(())
}
@@ -907,10 +866,7 @@ struct D3d11Hw {
impl D3d11Hw {
/// Wrap the capturer's `ID3D11Device` as a D3D11VA hwdevice and build an NV12/P010 frames pool.
/// 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(
unsafe fn new(
device: &ID3D11Device,
sw_format: ffi::AVPixelFormat,
bind_flags: u32,
@@ -920,19 +876,12 @@ impl D3d11Hw {
) -> Result<Self> {
// Owned from the moment it exists: each `bail!` below drops what was built so far, so none
// of the failure branches carry cleanup of their own.
// SAFETY: `av_hwdevice_ctx_alloc` returns null — rejected by `AvBuffer::from_raw`, so the
// `?` leaves before anything below runs — or a ref whose `data` libav has already
// initialized as an `AVHWDeviceContext`; for a D3D11VA device that context's `hwctx` is an
// `AVD3D11VADeviceContext`, so `d11` addresses a live, correctly-typed struct.
let (device_ref, d11) = unsafe {
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
))
.context("av_hwdevice_ctx_alloc(D3D11VA) failed")?;
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
(device_ref, d11)
};
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;
// Turn on D3D11 multithread protection before libav sees the device.
//
@@ -957,9 +906,7 @@ impl D3d11Hw {
// device's internal critical section (`was` is the previous state, reported once at debug).
match device.cast::<ID3D11Multithread>() {
Ok(mt) => {
// 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) };
let was = mt.SetMultithreadProtected(true);
tracing::debug!(
previously_protected = was.as_bool(),
"D3D11 multithread protection enabled for the libav hwdevice"
@@ -977,40 +924,26 @@ impl D3d11Hw {
// reference (clone = AddRef, forget = don't Release ours). init() fills
// device_context / video_device / video_context / the default lock from a non-null device.
std::mem::forget(device.clone());
// SAFETY: `d11` is the live `AVD3D11VADeviceContext` from above, so storing the device
// pointer is an in-bounds field write; the `forget(clone())` on the line above is what
// makes that pointer an OWNED reference, matching the Release libav does at teardown.
// `av_hwdevice_ctx_init` then reads that field, which is why the store precedes it.
let r = unsafe {
(*d11).device = device.as_raw();
ffi::av_hwdevice_ctx_init(device_ref.as_ptr())
};
(*d11).device = device.as_raw();
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
if r < 0 {
bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})");
}
// SAFETY: same shape one level up — `av_hwframe_ctx_alloc` takes the live, now-initialized
// device ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes)
// or a ref whose `data` is a live `AVHWFramesContext` whose `hwctx` is an
// `AVD3D11VAFramesContext`. Every store is an in-bounds field write on those, all done
// before `av_hwframe_ctx_init` reads them.
let frames_ref = unsafe {
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
(*fc).sw_format = sw_format;
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = pool;
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
(*f11).bind_flags = bind_flags;
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
}
frames_ref
};
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
(*fc).sw_format = sw_format;
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = pool;
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
(*f11).bind_flags = bind_flags;
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 {
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
}
Ok(D3d11Hw {
frames_ref,
device_ref,
@@ -1554,25 +1487,20 @@ mod tests {
/// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no
/// video engine at all.
///
/// Safe: `prefer_vendor` is a plain id and every DXGI object is created here and owned by the
/// returned device; the old `# Safety` section described the body, not a caller obligation.
/// # Safety
/// Calls the DXGI enumeration FFI and `make_device`; every result is checked before use and no
/// alias to the adapter outlives the call.
#[cfg(test)]
fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
unsafe fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
// SAFETY: DXGI factory/adapter enumeration over owned locals — the factory is created here,
// each adapter it yields owns its own COM reference, and every call is `.ok()`-checked
// before use. `GetDesc1` fills a fully-initialized stack descriptor.
let (factory, mut preferred, mut fallback): (IDXGIFactory1, _, _) =
(unsafe { CreateDXGIFactory1() }.ok()?, None, None);
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
let mut preferred = None;
let mut fallback = None;
for i in 0.. {
// 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 {
let Ok(adapter) = factory.EnumAdapters1(i) else {
break; // DXGI_ERROR_NOT_FOUND — end of the list
};
// 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 {
let Ok(desc) = adapter.GetDesc1() else {
continue;
};
let name = String::from_utf16_lossy(&desc.Description)
@@ -1586,11 +1514,7 @@ mod tests {
}
}
let adapter = preferred.or(fallback)?;
// 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)
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,
@@ -1606,20 +1530,23 @@ mod tests {
#[test]
#[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"]
fn d3d11hw_alloc_drop_cycles() {
let device = test_hw_device(0x8086).expect("a hardware D3D11 adapter");
// SAFETY: see `test_hw_device`; the returned device outlives every `D3d11Hw` built below.
let device = unsafe { test_hw_device(0x8086) }.expect("a hardware D3D11 adapter");
for i in 0..8 {
// `D3d11Hw::new` is safe now; it still needs libav initialised, which the ffmpeg-next
// crate does statically on first use here. NV12 at 640x480 with an 8-surface pool are
// valid pool parameters. The handle drops at the end of each iteration — that release
// is what is under test.
let hw = D3d11Hw::new(
&device,
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
pool_bind_flags(WinVendor::Amf),
640,
480,
8,
)
// SAFETY: `D3d11Hw::new` needs libav initialised (it is, statically, by the ffmpeg-next
// crate's first use here) and a live `ID3D11Device`, which `device` is for the whole
// loop. NV12 at 640x480 with an 8-surface pool are valid pool parameters. The handle
// drops at the end of each iteration — that release is what is under test.
let hw = unsafe {
D3d11Hw::new(
&device,
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
pool_bind_flags(WinVendor::Amf),
640,
480,
8,
)
}
.unwrap_or_else(|e| panic!("D3d11Hw::new failed on iteration {i}: {e:#}"));
assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
@@ -1653,7 +1580,8 @@ mod tests {
#[test]
#[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
fn zerocopy_qsv_alloc_drop_cycles() {
let device = test_hw_device(0x8086).expect("an Intel D3D11 adapter");
// SAFETY: see `test_hw_device`; the device outlives every `ZeroCopyInner` built below.
let device = unsafe { test_hw_device(0x8086) }.expect("an Intel D3D11 adapter");
for i in 0..8 {
let zc = ZeroCopyInner::open(
WinVendor::Qsv,
@@ -33,12 +33,6 @@
//! AU completes within the same tick and `poll` picks it up); under contention completed frames
//! queue instead of stalling capture — throughput recovers up to the scheduler-granted share.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table + D3D11 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 / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -22,13 +22,6 @@
//! The capture side (a BGRA→YUV CSC into two shareable plane textures + a shared fence, gated on the
//! pyrowave session flag) lives in `pf-capture` (`windows/idd_push.rs`); the CbCr plane + fence ride
//! the frame on [`pf_frame::dxgi::D3d11Frame::pyro`], the Y plane on `D3d11Frame::texture`.
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is `pyrowave-sys` C-API plus D3D11/Vulkan interop 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 module carries a `// SAFETY:` proof (the crate root enforces it).
use crate::pyrowave_wire;
-16
View File
@@ -134,9 +134,6 @@ pub unsafe fn make_device(adapter: &IDXGIAdapter1) -> Result<(ID3D11Device, ID3D
// SAFETY: `dxgi_dev` is a live interface just obtained by a checked `cast`; both calls take
// a scalar and only report failure through their return value.
if unsafe { dxgi_dev.SetGPUThreadPriority(0x4000_001E) }.is_err()
// SAFETY: same live interface, same scalar-in/HRESULT-out contract as the call above.
// Deliberately its own block rather than one around the whole chain, which would
// destroy the short-circuit and always issue the relative-priority call too.
&& unsafe { dxgi_dev.SetGPUThreadPriority(7) }.is_err()
{
tracing::warn!("SetGPUThreadPriority failed (run as admin/SYSTEM for GPU priority)");
@@ -259,17 +256,12 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
// process keeps for its lifetime (gdi32 is never unloaded here), and `GetProcAddress` is passed
// that live handle. Both results are checked by `?` before use.
let gdi32 = unsafe { LoadLibraryA(s!("gdi32.dll")) }.ok()?;
// SAFETY: `gdi32` is the live module handle the checked `LoadLibraryA` just returned, and the
// export name is a static NUL-terminated literal; the result is checked by `?` before use.
let p = unsafe { GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass")) }?;
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
// SAFETY: `p` is the non-null export just resolved, and `SetPrio` is its documented signature
// (`NTSTATUS D3DKMTSetProcessSchedulingPriorityClass(HANDLE, D3DKMT_SCHEDULINGPRIORITYCLASS)`,
// both arguments 4/8-byte scalars). `process` is a valid handle by this fn's own contract.
let f: SetPrio = unsafe { std::mem::transmute(p) };
// SAFETY: `f` is that export transmuted to its documented signature directly above; `process`
// is a valid handle by this fn's own contract and `prio` is a plain scalar. The call returns an
// NTSTATUS and retains nothing.
Some(unsafe { f(process, prio) })
}
@@ -373,12 +365,8 @@ fn hags_enabled(luid: LUID) -> Option<bool> {
// SAFETY: static NUL-terminated literals; gdi32 stays loaded for the process lifetime, and each
// result is checked by `?`/`.ok()?` before the next call uses it.
let gdi32 = unsafe { LoadLibraryA(s!("gdi32.dll")) }.ok()?;
// SAFETY: `gdi32` is the live handle from the `.ok()?`-checked load above; static literal name;
// `?` checks the result.
let open = unsafe { GetProcAddress(gdi32, s!("D3DKMTOpenAdapterFromLuid")) }?;
// SAFETY: same live `gdi32` handle and static-literal name; `?` checks the result.
let query = unsafe { GetProcAddress(gdi32, s!("D3DKMTQueryAdapterInfo")) }?;
// SAFETY: same live `gdi32` handle and static-literal name; `?` checks the result.
let close = unsafe { GetProcAddress(gdi32, s!("D3DKMTCloseAdapter")) }?;
type OpenFn = unsafe extern "system" fn(*mut OpenFromLuid) -> i32;
type QueryFn = unsafe extern "system" fn(*mut QueryInfo) -> i32;
@@ -387,11 +375,7 @@ fn hags_enabled(luid: LUID) -> Option<bool> {
// export's documented signature — one `*mut` to the matching `repr(C)` struct declared here,
// returning NTSTATUS.
let open: OpenFn = unsafe { std::mem::transmute(open) };
// SAFETY: `query` is the non-null export resolved above and `QueryFn` mirrors its documented
// signature — one `*mut` to the `repr(C)` struct declared here, returning NTSTATUS.
let query: QueryFn = unsafe { std::mem::transmute(query) };
// SAFETY: `close` is the non-null export resolved above and `CloseFn` mirrors its documented
// signature — one `*mut` to the `repr(C)` struct declared here, returning NTSTATUS.
let close: CloseFn = unsafe { std::mem::transmute(close) };
let mut oa = OpenFromLuid { luid, h_adapter: 0 };
@@ -132,7 +132,7 @@ unsafe fn inject_following_desktop(
) -> windows::core::Result<()> {
// SAFETY: per this fn's contract — `dev` is live and `frame` outlives the call, which only
// reads it. Best-effort, exactly as the direct call was.
match unsafe { InjectSyntheticPointerInput(dev, frame) } {
match InjectSyntheticPointerInput(dev, frame) {
Ok(()) => Ok(()),
Err(first) => {
// Only a desktop switch is worth a rebind; anything else would just fail identically.
@@ -140,7 +140,7 @@ unsafe fn inject_following_desktop(
return Err(first);
};
// SAFETY: same live `dev`/`frame`, re-issued with this thread on the input desktop.
unsafe { InjectSyntheticPointerInput(dev, frame) }
InjectSyntheticPointerInput(dev, frame)
}
}
}
-4
View File
@@ -16,10 +16,6 @@
#![allow(dead_code)]
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// …and its companion: without this, an `unsafe fn` body needs no blocks, so an unproven FFI call
// could hide inside one and still satisfy the deny above. The workspace keeps
// `unsafe_op_in_unsafe_fn` at `warn` while the encoder backends are cleared; this crate is at zero.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::Result;
use punktfunk_core::input::{InputEvent, InputKind};
-11
View File
@@ -33,14 +33,6 @@ utoipa = { version = "5", features = ["axum_extras"] }
sha2 = "0.10"
hex = "0.4"
[dev-dependencies]
# The `#[ignore]`d on-glass cases drive the manager/backend through code paths whose only account of
# what they chose is `tracing`. A bare test harness installs no subscriber, so those runs were blind:
# `live_a_failed_first_isolate_is_recovered_by_adopting_the_next` could see the panel stay dark but
# not whether the adoption arm fired, or whether the dark-desk backstop ran and failed. Dev-only, so
# the shipped host's dependency closure through this crate is unchanged.
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
# The Mutter backend drives D-Bus RemoteDesktop + ScreenCast.RecordVirtual via ashpd on a tokio
@@ -71,9 +63,6 @@ windows = { version = "0.62", features = [
"Win32_Graphics_Gdi",
"Win32_Storage_FileSystem",
"Win32_System_IO",
# `proc`'s budget ends the helper's whole process TREE: every Windows helper is reached through
# a shell, so the process that hangs is a grandchild `Child::kill` cannot reach.
"Win32_System_JobObjects",
"Win32_System_Threading",
] }
+25 -41
View File
@@ -268,49 +268,33 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
pub fn detect() -> Result<Compositor> {
// Compositor detection is a Linux question — the variants ARE the Linux backends. Asked
// anywhere else this used to fall through to the XDG sniff below and fail with advice about
// `XDG_CURRENT_DESKTOP` and `PUNKTFUNK_COMPOSITOR`, which `mgmt/display.rs` puts VERBATIM into
// the `/display/monitors` response — so on a Windows host the console's only explanation for an
// empty monitor picker was Linux troubleshooting (sweep §13.17). The operator pin is gated with
// it: naming a Wayland compositor on Windows cannot be honoured either.
#[cfg(not(target_os = "linux"))]
{
anyhow::bail!(
"compositor detection is Linux-only; on {} the host enumerates displays through the OS \
display API instead (`vdisplay::monitors::list_windows`)",
std::env::consts::OS
)
if let Some(v) = pf_host_config::config().compositor.as_deref() {
return compositor_from_pin(v).ok_or_else(|| {
anyhow::anyhow!(
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
)
});
}
#[cfg(target_os = "linux")]
{
if let Some(v) = pf_host_config::config().compositor.as_deref() {
return compositor_from_pin(v).ok_or_else(|| {
anyhow::anyhow!(
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
)
});
}
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
return Ok(c);
}
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_uppercase();
if desktop.contains("KDE") {
Ok(Compositor::Kwin)
} else if desktop.contains("GNOME") {
Ok(Compositor::Mutter)
} else if desktop.contains("HYPRLAND") {
Ok(Compositor::Hyprland)
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
return Ok(c);
}
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_uppercase();
if desktop.contains("KDE") {
Ok(Compositor::Kwin)
} else if desktop.contains("GNOME") {
Ok(Compositor::Mutter)
} else if desktop.contains("HYPRLAND") {
Ok(Compositor::Hyprland)
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
}
@@ -111,62 +111,6 @@ pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
}
}
/// Every head Windows reports — the non-compositor counterpart to [`list`].
///
/// Windows has no compositor to ask, so this reads the same CCD database the rest of the Windows
/// backend drives and reports what it finds. Until this existed the mgmt API answered
/// `/display/monitors` on Windows with an empty list and a LINUX error string (`detect()` fell
/// through to an `XDG_CURRENT_DESKTOP` sniff), so the console could neither show the operator's
/// screen nor honestly say why.
///
/// INACTIVE heads are listed too, with zeroed geometry and `enabled: false` — the same contract
/// [`list`] documents, so "why can't I pick it?" still has an answer.
///
/// Two fields cannot mean here what they mean on Linux, and are reported honestly rather than
/// invented:
/// * `scale` is always `1.0`. Windows scaling is per-monitor DPI applied by each application, not
/// a compositor-global logical scale, so there is no factor that would make these coordinates
/// "logical" the way the module doc means. The geometry below is therefore PIXELS.
/// * `refresh_mhz` comes from the path's own rational rate, which keeps 59.94 distinct from 60.
#[cfg(windows)]
pub fn list_windows() -> Result<Vec<PhysicalMonitor>> {
let inv = pf_win_display::win_display::target_inventory();
if inv.is_empty() {
// Distinguish "reached it, nothing there" from a failure, exactly as [`list`] promises:
// an empty CCD database is a real state (every panel off — measured on .173 with the TV
// powered down), not an error.
return Ok(Vec::new());
}
Ok(inv
.into_iter()
.map(|t| {
// The GDI name is what an operator recognises and what capture pins on; an inactive
// path has none, so fall back to the stable target id rather than an empty string —
// `resolve` matches on this, and a blank id can never be pinned.
let connector = if t.gdi_name.is_empty() {
format!("target-{}", t.target_id)
} else {
t.gdi_name
};
PhysicalMonitor {
description: describe("", &t.friendly, &connector),
connector,
width: t.width,
height: t.height,
refresh_mhz: t.refresh_mhz,
x: t.x,
y: t.y,
scale: 1.0,
primary: t.primary,
enabled: t.active,
// Unlike the Linux backends, Windows CAN say this reliably: our IddCx monitors
// carry our own EDID manufacturer id in their device path.
managed: t.ours,
}
})
.collect())
}
/// Resolve a configured monitor name against `monitors`, exactly then case-insensitively.
///
/// **A miss is a hard error carrying the available names**, never a silent fall-back to some other
+3 -225
View File
@@ -10,9 +10,6 @@
//! These wrappers bound the wait: poll for exit until the budget runs out, then kill the child and
//! report [`std::io::ErrorKind::TimedOut`], so callers see a plain "the helper failed" error and
//! take their existing failure path instead of hanging.
//!
//! What the budget bounds is the whole **process tree**, not just the process we spawned — see
//! [`tree`] for why that distinction is the entire difference on Windows.
use std::io::{Error, ErrorKind, Result};
use std::process::{Command, ExitStatus, Output};
@@ -28,18 +25,11 @@ const POLL: Duration = Duration::from_millis(20);
/// commands run for their exit status alone — see [`output_within`] when the output is read.
pub(crate) fn status_within(cmd: &mut Command, budget: Duration) -> Result<ExitStatus> {
let mut child = cmd.spawn()?;
let tree = tree::Guard::attach(&child);
let deadline = Instant::now() + budget;
loop {
match child.try_wait()? {
Some(status) => {
// The helper is gone; anything it left running is not something the caller asked
// for and still holds the stdio it inherited from us.
tree.terminate();
return Ok(status);
}
Some(status) => return Ok(status),
None if Instant::now() >= deadline => {
tree.terminate();
let _ = child.kill();
let _ = child.wait(); // reap it — never leave a zombie behind
return Err(timed_out(cmd, budget));
@@ -59,20 +49,12 @@ pub(crate) fn output_within(cmd: &mut Command, budget: Duration) -> Result<Outpu
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let tree = tree::Guard::attach(&child);
let deadline = Instant::now() + budget;
loop {
match child.try_wait()? {
Some(_) => {
// Exited: `wait_with_output` now only drains already-buffered pipes — but only if
// nothing else still holds their WRITE end. A grandchild that outlived the helper
// does, and `wait_with_output` reads to an EOF that would then never arrive, which
// is the one way this "bounded" helper could still hang forever. End the tree first.
tree.terminate();
return child.wait_with_output();
}
// Exited: `wait_with_output` now only drains already-buffered pipes.
Some(_) => return child.wait_with_output(),
None if Instant::now() >= deadline => {
tree.terminate();
let _ = child.kill();
let _ = child.wait();
return Err(timed_out(cmd, budget));
@@ -111,136 +93,6 @@ pub(crate) fn current_uid() -> u32 {
unsafe { libc::getuid() }
}
/// Ending the *tree* the helper started, not just the process we spawned.
///
/// [`std::process::Child::kill`] is one `TerminateProcess` / one `SIGKILL`: it ends exactly the
/// process we launched. On Unix that is the whole story here — `kscreen-doctor`, `systemctl`,
/// `pw-dump` and friends are single processes we exec directly, and none of them forks a worker
/// that outlives it.
///
/// On Windows it is not, because there is no direct exec: every helper is reached through a shell
/// (`cmd /c …`, `powershell -Command "… | pnputil …"`), so the process that actually hangs is a
/// **grandchild**. Killing the shell leaves it running — holding the stdio handles and the working
/// directory it inherited from us — and a budget that leaves that behind has not bounded anything.
/// This is not theoretical: it is how a fully green `cargo test -p pf-vdisplay` still failed its CI
/// job. The suite's own hung-helper case orphaned a 60-second `ping.exe`, which kept the build
/// step's stdout pipe open past the runner's 10 s `WaitDelay` and pinned `crates\pf-vdisplay` so
/// the workspace could not be cleaned up.
///
/// A Job object is the mechanism Windows provides for exactly this: job membership is inherited
/// across `CreateProcess`, so assigning the child enrolls every descendant it goes on to spawn, and
/// one `TerminateJobObject` ends all of them. `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` makes that hold
/// even on paths that never reach [`Guard::terminate`] — an early `?`, a panic — because closing
/// the last handle to the job is then itself the kill.
///
/// Best-effort by construction: if the job cannot be created or the child cannot be assigned, the
/// helpers degrade to the single-process kill they did before rather than failing the query, which
/// is the same stance as the already-ignored result of `Child::kill`.
#[cfg(windows)]
mod tree {
use std::os::windows::io::AsRawHandle;
use std::process::Child;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
/// Owns a Job object holding the spawned helper and everything it spawns. `None` when the job
/// could not be set up (see the module doc: degrade, don't fail).
pub(super) struct Guard(Option<HANDLE>);
impl Guard {
/// Enroll `child` — and, transitively, its descendants — in a fresh kill-on-close job.
pub(super) fn attach(child: &Child) -> Self {
// SAFETY: an unnamed job object with default security attributes; no pointer is passed
// and none is retained. The handle it yields is owned by the `Guard` constructed from
// it on the next line and closed exactly once, in that `Guard`'s `Drop`.
let job = match unsafe { CreateJobObjectW(None, None) } {
Ok(job) => job,
Err(e) => {
tracing::debug!(error = %e, "no job object for this helper — a hung one's \
grandchildren will outlive its budget");
return Self(None);
}
};
// Owned from here on, so both fallible steps below can bail without leaking it.
let guard = Self(Some(job));
if let Err(e) = guard.enroll(child) {
tracing::debug!(error = %e, "could not enroll this helper in its job object — a \
hung one's grandchildren will outlive its budget");
}
guard
}
fn enroll(&self, child: &Child) -> windows::core::Result<()> {
let Some(job) = self.0 else { return Ok(()) };
let info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation:
windows::Win32::System::JobObjects::JOBOBJECT_BASIC_LIMIT_INFORMATION {
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
..Default::default()
},
..Default::default()
};
// SAFETY: `job` is the live handle this `Guard` owns. The pointer is to a fully
// initialised local of exactly the type `JobObjectExtendedLimitInformation` selects,
// passed with that type's own size, and the kernel copies the limits out before
// returning — `info` is not retained past the call.
unsafe {
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
std::ptr::from_ref(&info).cast(),
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)?;
}
// SAFETY: `job` is the live handle this `Guard` owns. `child` is borrowed for the call,
// so the process handle it lends is open and stays open for the duration — `Child`
// closes it only in its own `Drop`. The kernel duplicates what it needs; we keep
// ownership of both handles.
unsafe { AssignProcessToJobObject(job, HANDLE(child.as_raw_handle())) }
}
/// End every process still in the job. A no-op once they have all exited, so this is safe
/// to call on the success path as well as the timeout one.
pub(super) fn terminate(&self) {
let Some(job) = self.0 else { return };
// SAFETY: `job` is this `Guard`'s live, owned handle — it is closed only in `Drop`,
// which cannot have run while `&self` is borrowed. The exit code is arbitrary; nothing
// reads it, because a killed tree is reported to callers as the budget's `TimedOut`.
let _ = unsafe { TerminateJobObject(job, 1) };
}
}
impl Drop for Guard {
fn drop(&mut self) {
let Some(job) = self.0.take() else { return };
// SAFETY: `job` is the handle created in `attach` and owned solely by this `Guard`;
// `take` makes this the one and only close. Per the module doc this close is also the
// backstop kill — with KILL_ON_JOB_CLOSE set, dropping the last handle terminates
// whatever is still in the job, so no early return can leak the tree.
let _ = unsafe { CloseHandle(job) };
}
}
}
/// The Unix half: `Child::kill` already ends the only process there is (see the Windows module doc
/// for why that is not true there). Kept as a real type rather than `cfg`ing the call sites, so the
/// two platforms read as one flow.
#[cfg(not(windows))]
mod tree {
pub(super) struct Guard;
impl Guard {
pub(super) fn attach(_child: &std::process::Child) -> Self {
Self
}
pub(super) fn terminate(&self) {}
}
}
// `unix` gate, not `test` alone: this module is compiled on every platform (lib.rs declares it
// unconditionally), but the cases below spawn `sleep`/`true`/`echo` as EXECUTABLES. On Windows
// `echo` is a shell builtin and there is no `sleep.exe`, so an ungated module turns a green suite
@@ -320,78 +172,4 @@ mod tests_windows {
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "punktfunk");
}
/// A scratch directory for the tree test's marker files, removed on drop. `remove_dir_all` is
/// deliberately un-asserted: if the tree *did* survive it still has this directory as its
/// working directory and the removal fails — which is the failure the test itself reports.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("pf-vd-proc-tree-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
Self(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The budget must end the whole tree, not just the process we spawned.
///
/// Every Windows helper is reached through a shell, so the process that actually hangs is a
/// grandchild that `Child::kill` cannot see. Left alive it keeps our stdio handles and our
/// working directory — which is how a green test run still failed its CI job before the job
/// object went in (the orphan held the build step's pipe open past the runner's `WaitDelay`
/// and pinned the crate directory against cleanup).
#[test]
fn the_budget_ends_the_whole_tree_not_just_the_child() {
let scratch = Scratch::new();
let ran = scratch.0.join("grandchild-ran");
let survived = scratch.0.join("grandchild-survived");
let script = scratch.0.join("grandchild.cmd");
// `%~dp0` — the script's own directory, resolved by cmd at run time — rather than the paths
// interpolated in. A .cmd file is read in the OEM code page, so an absolute path baked into
// it is mangled the moment the temp dir contains a non-ASCII character (`C:\Users\Enrico
// Bühler\…` arrives as `B?hler`) and every redirect in it fails with "path not found".
std::fs::write(
&script,
format!(
"@echo off\r\n\
echo up>\"%~dp0{}\"\r\n\
ping -n 4 127.0.0.1 >NUL\r\n\
echo up>\"%~dp0{}\"\r\n",
ran.file_name().expect("marker name").to_string_lossy(),
survived.file_name().expect("marker name").to_string_lossy(),
),
)
.expect("write the grandchild script");
// `cmd /c cmd /c <script>`: the OUTER cmd is our child, the inner one is the grandchild
// that `Child::kill` alone would leave running.
let err = status_within(
Command::new("cmd").args(["/c", "cmd", "/c", &script.display().to_string()]),
Duration::from_millis(2500),
)
.expect_err("must time out");
assert_eq!(err.kind(), ErrorKind::TimedOut);
// Without this the test could pass vacuously — a grandchild killed before it ever started
// proves nothing about killing trees.
assert!(
ran.exists(),
"the grandchild never got as far as its first marker, so this run proves nothing"
);
// Outlast the grandchild's own sleep: a surviving tree writes the second marker.
std::thread::sleep(Duration::from_secs(5));
assert!(
!survived.exists(),
"a grandchild outlived the budget — the shell was killed but not the tree under it"
);
}
}
@@ -221,42 +221,6 @@ fn poll_gdi_name(target_id: u32) -> Option<String> {
None
}
/// Test-only fault injection for the CCD isolate.
///
/// Every Phase-3 recovery leg in this file fires only when [`isolate_displays_ccd`] returns `None`,
/// and on healthy hardware it never does — which is exactly why those legs shipped unexercised on
/// glass. This counter lets a live test fail the next N isolates against the REAL driver and a REAL
/// panel, so the recovery is observed rather than reasoned about.
///
/// `#[cfg(test)]`-only on purpose: this crate's live hardware tests compile under `cfg(test)`, so
/// the seam is reachable where it is needed WITHOUT shipping a production knob that could leave
/// display isolation silently disabled on an operator's box.
#[cfg(test)]
pub(crate) static FAIL_NEXT_ISOLATES: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
/// [`isolate_displays_ccd`] with the test seam in front of it. Every call site in this file goes
/// through here so an injected failure exercises the same gates a real one would.
fn isolate_displays_ccd_seam(keep_target_ids: &[u32]) -> Option<SavedConfig> {
#[cfg(test)]
{
use std::sync::atomic::Ordering;
if FAIL_NEXT_ISOLATES
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
(n > 0).then(|| n - 1)
})
.is_ok()
{
tracing::warn!(
keep = ?keep_target_ids,
"TEST fault injection: forcing isolate_displays_ccd -> None"
);
return None;
}
}
isolate_displays_ccd(keep_target_ids)
}
fn shrink_action(ccd_exclusive: bool, has_saved: bool) -> ShrinkAction {
if ccd_exclusive {
ShrinkAction::Reisolate
@@ -1011,7 +975,7 @@ impl VirtualDisplayManager {
"re-asserting exclusive topology"
),
}
let _ = isolate_displays_ccd_seam(&keep);
let _ = isolate_displays_ccd(&keep);
// That same forced re-commit hands the live IDD path a fresh swap-chain,
// orphaning the session's capture ring — announce it so the session rebuilds
// its capture attachment (same-mode ring recreate + driver re-attach + fresh
@@ -1234,7 +1198,7 @@ impl VirtualDisplayManager {
if crate::policy::prefs().ddc_power_off() {
inner.group.ddc_panels_off = crate::ddc::panel_off_except(n);
}
inner.group.ccd_saved = isolate_displays_ccd_seam(&keep);
inner.group.ccd_saved = isolate_displays_ccd(&keep);
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
// additionally disable the deactivated monitors' PnP devnodes (persistent
// across hot-plug re-arrival) so a standby monitor/TV's periodic wake
@@ -1259,7 +1223,7 @@ impl VirtualDisplayManager {
// Grown set: re-isolate so the fresh member joins the composited set
// (its auto-activate may have lit nothing extra to deactivate, but the
// re-commit also drives COMMIT_MODES for the new path).
let snap = isolate_displays_ccd_seam(&keep);
let snap = isolate_displays_ccd(&keep);
// Normally DISCARDED — the group restores the FIRST member's snapshot.
// But if the first member's isolate FAILED, there is no first snapshot,
// and this one just deactivated the physicals with nothing able to put
@@ -1679,7 +1643,7 @@ impl VirtualDisplayManager {
// snapshot is DISCARDED — the group keeps the first member's (design §6.1).
let mut keep = inner.target_ids();
keep.push(new_target);
let _ = isolate_displays_ccd_seam(&keep);
let _ = isolate_displays_ccd(&keep);
}
Topology::Primary => {
// Make the new target primary again (its predecessor held primary), preserving the
@@ -1776,7 +1740,7 @@ impl VirtualDisplayManager {
// the group keeps the first member's.
ShrinkAction::Reisolate => {
let keep = inner.target_ids();
let _ = isolate_displays_ccd_seam(&keep);
let _ = isolate_displays_ccd(&keep);
}
// Re-promote a survivor rather than leaving the desktop's primary on a target that
// is about to be REMOVEd. Same save/restore-the-snapshot dance as
@@ -924,326 +924,6 @@ mod tests {
drop(vout); // triggers REMOVE + stops the pinger
}
/// Forces `Topology::Exclusive` for the duration of a case and puts the operator's real policy
/// back on drop — including when the case panics.
///
/// The isolate branch this file's Phase-3 cases exercise runs ONLY under `Exclusive`, and a real
/// install is usually configured otherwise (.173 is `"topology": "extend"`, which is why the
/// first attempt at these cases silently never ran an isolate at all — `topology_action()`
/// returns `effective_topology()` as soon as ANY policy is configured). Note this writes the
/// host's `display-settings.json`; the guard is what makes that safe to do on a real box.
struct ExclusiveTopology(crate::policy::DisplayPolicy);
impl ExclusiveTopology {
fn force() -> Self {
let original = crate::policy::prefs().get();
let mut forced = original.clone();
forced.preset = crate::policy::Preset::Custom; // explicit fields are ignored otherwise
forced.topology = crate::policy::Topology::Exclusive;
crate::policy::prefs()
.set(forced)
.expect("force Topology::Exclusive for this case");
assert_eq!(
crate::effective_topology(),
crate::policy::Topology::Exclusive,
"the forced policy did not resolve to Exclusive"
);
Self(original)
}
}
impl Drop for ExclusiveTopology {
fn drop(&mut self) {
if let Err(e) = crate::policy::prefs().set(self.0.clone()) {
eprintln!("WARNING: could not restore the display policy: {e}");
}
}
}
/// Run `f` on a worker thread and give up after `budget`, so a HANG fails the case instead of
/// wedging the box.
///
/// Earned the hard way: this file's 3.2 case hung inside `create`, and killing the harness
/// skipped every `Drop`, leaking an IddCx monitor. A few of those exhaust the driver's slot
/// pool, after which every later run wedges too and only a reboot clears it. A bounded wait
/// lets the harness exit NORMALLY, which is what lets the driver reap the session.
fn within<T: Send + 'static>(
budget: Duration,
what: &str,
f: impl FnOnce() -> T + Send + 'static,
) -> T {
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(budget) {
Ok(v) => v,
Err(_) => panic!(
"{what} did not finish within {budget:?} — failing rather than hanging, so the \
harness can exit and the driver can reap. Check for a leaked punktfunk monitor \
before the next run."
),
}
}
/// §5 3.2 on glass: when the FIRST member's isolate fails, a later member's isolate must be
/// ADOPTED as the group's restore snapshot — otherwise it deactivates the operator's panels
/// with nothing able to put them back.
///
/// This leg only fires on a FAILED `isolate_displays_ccd`, which real hardware does not
/// produce, so it shipped unexercised. `manager::FAIL_NEXT_ISOLATES` (a `#[cfg(test)]` seam)
/// fails exactly the first isolate, against the real driver and a real panel; the second member
/// then isolates for real and the physical genuinely goes dark mid-test.
///
/// The assertion is the user-visible one: after both members are torn down, the operator's
/// external panel is ACTIVE again. Without the adoption the group holds no snapshot,
/// `teardown_removed`'s restore is gated on it and never runs, and the panel stays deactivated.
///
/// ⚠️ Two members means two SLOTS, which is what `slot_id_for(client_fp, …)` keys on — hence the
/// two distinct client fingerprints. Needs `Topology::Exclusive`, which is the default when no
/// policy is configured and `PUNKTFUNK_NO_ISOLATE` is unset; the test asserts an isolate really
/// happened rather than trusting that.
///
/// ⚠️ If this test leaves the desk dark, recover from the CONSOLE session with
/// `SetDisplayConfig(0,null,0,null, SDC_USE_DATABASE_CURRENT|SDC_APPLY)` — measured rc=0 on
/// .173. `SDC_TOPOLOGY_EXTEND` will NOT do it with a single connected display (rc=31).
#[test]
#[ignore = "needs the pf-vdisplay driver on real hardware; run with --ignored"]
fn live_a_failed_first_isolate_is_recovered_by_adopting_the_next() {
// Without this the run is BLIND: the adoption arm and the dark-desk backstop announce
// themselves only through `tracing`, and a bare test harness has no subscriber. The first
// on-glass run could see the panel stay dark but not say WHICH link broke.
init_test_tracing();
assert!(
std::env::var("PUNKTFUNK_NO_ISOLATE").is_err(),
"PUNKTFUNK_NO_ISOLATE forces Topology::Extend — this case needs Exclusive"
);
let _topology = ExclusiveTopology::force();
let physicals_before = active_physicals();
assert!(
!physicals_before.is_empty(),
"no external physical panel is active, so 'the panel came back' cannot be observed — \
power the display on first (a TV in standby reads as Code 45 / zero CCD paths)"
);
println!("physicals before : {physicals_before:?}");
// Fail EXACTLY the first member's isolate.
super::super::manager::FAIL_NEXT_ISOLATES.store(1, std::sync::atomic::Ordering::Relaxed);
let mut vd1 = PfVdisplayDisplay::new().expect("open pf-vdisplay (member 1)");
vd1.set_client_identity(Some([0xA1; 32]));
let out1 = vd1
.create(Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
})
.expect("create member 1");
thread::sleep(Duration::from_secs(2));
// ⭐ THE MEASUREMENT THAT SEPARATES THE TWO CANDIDATES. Member 1's isolate was injected to
// fail, so nothing of OURS deactivated anything here. If the operator's panel is ALREADY
// dark at this point, the arriving IddCx monitor took the desktop on its own — and every
// snapshot taken from here on records "panel off", so member 2's adopted snapshot is
// POISONED AT BIRTH and restoring it faithfully restores darkness. If the panel is still
// lit here, poisoning is excluded and the failure is downstream (adoption never fired, or
// the restore/backstop did and could not re-light it).
let physicals_after_m1 = active_physicals();
println!(
"after member 1 (isolate INJECTED to fail): {:?}",
active_targets()
);
println!("physicals after member 1 : {physicals_after_m1:?} <- poisoned-at-birth probe");
let mut vd2 = PfVdisplayDisplay::new().expect("open pf-vdisplay (member 2)");
vd2.set_client_identity(Some([0xB2; 32]));
let out2 = vd2
.create(Mode {
width: 1280,
height: 720,
refresh_hz: 60,
})
.expect("create member 2");
thread::sleep(Duration::from_secs(2));
let during = active_physicals();
println!(
"after member 2 (isolate REAL) : {:?}",
active_targets()
);
println!("physicals during : {during:?}");
// The seam must have been consumed — otherwise the injection never took and a pass here
// would prove nothing about the recovery.
assert_eq!(
super::super::manager::FAIL_NEXT_ISOLATES.load(std::sync::atomic::Ordering::Relaxed),
0,
"the injected isolate failure was never consumed — no isolate ran, so this run proves \
nothing (is the topology really Exclusive?)"
);
drop(out2);
drop(out1);
thread::sleep(Duration::from_secs(6)); // async PnP removal + the restore settling
let physicals_after = active_physicals();
println!("physicals after teardown : {physicals_after:?}");
assert!(
!physicals_after.is_empty(),
"the operator's physical panel was left DEACTIVATED after teardown (sweep §5 3.2). \
Active targets now: {:?}.\n\
Which candidate this run implicates read it off the poisoned-at-birth probe above:\n\
* physicals after member 1 was EMPTY ({m1_empty}) -> the snapshot member 2 adopted was \
already poisoned: the panel went dark at member 1's create (IddCx auto-activation), so \
the adopted topology records 'panel off' and restoring it faithfully restores darkness. \
Adoption is working; the SNAPSHOT SOURCE is the defect.\n\
* physicals after member 1 was NON-empty -> poisoning is excluded; the break is \
downstream. Check the trace for 'adopting this member's' (the adoption arm) and for \
'no external physical display active after the restore' (the dark-desk backstop). A \
missing adoption line means teardown's restore was never gated on; a backstop line \
followed by a non-zero force-EXTEND rc means the remedy itself failed.",
active_targets(),
m1_empty = physicals_after_m1.is_empty()
);
}
/// What `/display/monitors` will now answer on Windows — the operator's real screens.
///
/// Read-only, so it is safe against a live host. Before `monitors::list_windows` existed this
/// endpoint returned an empty list plus a LINUX error string on every Windows box (`detect()`
/// fell through to an `XDG_CURRENT_DESKTOP` sniff), so the console could show no physical
/// screen and could not honestly say why.
#[test]
#[ignore = "hardware: reads the live display topology"]
fn live_windows_monitor_enumeration_reports_the_physical_screens() {
let ms = crate::monitors::list_windows().expect("list_windows");
for m in &ms {
println!(
"connector={:<14} enabled={:<5} managed={:<5} primary={:<5} {:>5}x{:<5} @{:>3}Hz \
pos=({},{}) {:?}",
m.connector,
m.enabled,
m.managed,
m.primary,
m.width,
m.height,
m.refresh_mhz / 1000,
m.x,
m.y,
m.description
);
}
assert!(!ms.is_empty(), "no monitors enumerated at all");
// The point of the change: a real, non-managed head is visible to the console.
assert!(
ms.iter().any(|m| !m.managed),
"every enumerated head is one of OURS — the operator's physical screen is still missing"
);
}
/// The ACTIVE display targets, as `(target_id, friendly)` — not just a count.
///
/// Counting alone cannot tell "the physical is still lit" from "the physical was deactivated
/// and the virtual took its place", which on a single-panel box are both `1`. Every on-glass
/// claim in this module about panels going dark rests on the identities, so read them.
fn active_targets() -> Vec<(u32, String)> {
pf_win_display::win_display::target_inventory()
.into_iter()
.filter(|t| t.active)
.map(|t| (t.target_id, format!("{} [{}]", t.friendly, t.tech)))
.collect()
}
/// Surface the manager/backend `tracing` output on stdout for a live case.
///
/// These on-glass cases drive decision points — the isolate ladder, the snapshot-adoption arm,
/// `restore_displays_ccd`'s dark-desk backstop — whose ONLY account of what they chose is a
/// `tracing` event. A bare `cargo test` harness installs no subscriber, so those events go
/// nowhere and a failing run cannot say which link broke; that is exactly what left §5 3.2's
/// two candidates undistinguished after the first on-glass run.
///
/// `with_test_writer` routes through the harness's capture, so the output appears under
/// `--nocapture` (and on failure) rather than racing `println!`. Idempotent and non-fatal: the
/// global default can only be set once per process, and several live cases may run in one
/// binary, so a second call is a no-op rather than a panic that would fail an unrelated test.
/// `RUST_LOG` still wins when set; the default is `debug` for our own crates, which is where
/// the ladder's reasoning lives.
fn init_test_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("pf_vdisplay=debug,pf_win_display=debug"));
let _ = fmt().with_env_filter(filter).with_test_writer().try_init();
}
/// The active targets that are EXTERNAL PHYSICAL panels — the operator's actual desk.
fn active_physicals() -> Vec<(u32, String)> {
pf_win_display::win_display::target_inventory()
.into_iter()
.filter(|t| t.active && t.external_physical)
.map(|t| (t.target_id, format!("{} [{}]", t.friendly, t.tech)))
.collect()
}
/// `SDC_TOPOLOGY_EXTEND` needs something to extend ACROSS — and that is the state its callers
/// are in, which is why this looked like a defect and is not.
///
/// `force_extend_topology` carries two jobs: stop a fresh IddCx monitor being CLONED onto the
/// existing panel, and serve as `restore_displays_ccd`'s last-resort "the desk is not left
/// dark" backstop. Probed directly on .173 with only the LG TV connected, the preset returns
/// **rc=31 ERROR_GEN_FAILURE** (`SDC_USE_DATABASE_CURRENT` returns 0), which reads like an
/// inert backstop.
///
/// On glass it is not, and this case is the measurement that settled it — active paths
/// `1 -> (virtual up) 1 -> (after force-EXTEND) 2`:
///
/// * With one connected display there is nothing to extend across, hence rc=31.
/// * With the virtual present there are two, and the preset applies. Both real call sites run
/// in exactly that state — the restore fires BEFORE the REMOVE, so the virtual is still
/// there — so the backstop does work where it fires.
/// * ⭐ It also caught the clone hazard live: the arriving virtual monitor did **not** get its
/// own active path (1 -> 1), only the forced EXTEND gave it one (-> 2). That is precisely the
/// "no distinct source -> no frames" case `force_extend_topology`'s own doc describes.
///
/// ⚠️ Residual worth remembering rather than asserting: a restore that fails once the virtual
/// is already gone is back to one connected display, where EXTEND returns 31 and cannot
/// re-light anything.
///
/// Reports the counts rather than pinning a topology — which answer is "correct" depends on the
/// box. It does assert the desk is not left with zero active paths.
#[test]
#[ignore = "needs the pf-vdisplay driver on real hardware; run with --ignored"]
fn live_force_extend_with_a_virtual_display_present() {
let before = active_targets();
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
let vout = vd
.create(Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
})
.expect("create virtual display");
thread::sleep(Duration::from_secs(2));
let with_virtual = active_targets();
let physicals_with_virtual = active_physicals();
pf_win_display::win_display::force_extend_topology();
thread::sleep(Duration::from_secs(2));
let after_extend = active_targets();
drop(vout);
thread::sleep(Duration::from_secs(6)); // PnP removal is async — a short wait reads a ghost
let after_drop = active_targets();
println!("force-EXTEND on glass, ACTIVE TARGETS at each step:");
println!(" before : {before:?}");
println!(" virtual up : {with_virtual:?} (physicals: {physicals_with_virtual:?})");
println!(" after force-EXT : {after_extend:?}");
println!(" virtual dropped : {after_drop:?}");
assert!(
!after_drop.is_empty(),
"the desk was left with NO active display path after the teardown"
);
assert!(
!active_physicals().is_empty(),
"the operator's physical panel was left DEACTIVATED after teardown: {after_drop:?}"
);
}
/// Live in-place resize spike — `#[ignore]`d (needs a v4 pf-vdisplay driver installed + the host
/// service STOPPED, single-instance guard); run with `-- --ignored live_inplace_resize`. Answers the
/// P2 open questions on real glass with no streaming client: create at one mode, then acquire
@@ -1256,9 +936,8 @@ mod tests {
fn live_inplace_resize() {
// Live-run diagnostics: surface the manager/backend tracing (activation ladder, settle
// waits, UPDATE_MODES) on stdout — a bare test harness has no subscriber, which made the
// first on-glass run blind. `tracing-subscriber` is now a dev-dependency, so this case no
// longer has to be re-run through the host binary to be traced.
init_test_tracing();
// first on-glass run blind.
// (tracing-subscriber is not a dep of this crate — run the host binary for traced runs.)
// Context probe: can this process see the CCD active-path set at all? (`None` = the query
// itself fails in this session/window-station — the whole ladder would be blind, and a
// "monitor never activated" verdict would be an artifact of the test context.)
+7 -9
View File
@@ -133,15 +133,13 @@ unsafe fn desktop_name(h: HDESK) -> Option<String> {
let mut needed = 0u32;
// SAFETY: `h` is live per this fn's contract; `name`/`needed` are live out-params and the call
// writes at most `nlength` bytes, exactly the size passed.
unsafe {
GetUserObjectInformationW(
HANDLE(h.0),
UOI_NAME,
Some(name.as_mut_ptr().cast()),
(name.len() * 2) as u32,
Some(&mut needed),
)
}
GetUserObjectInformationW(
HANDLE(h.0),
UOI_NAME,
Some(name.as_mut_ptr().cast()),
(name.len() * 2) as u32,
Some(&mut needed),
)
.ok()?;
let len = name.iter().position(|&c| c == 0).unwrap_or(name.len());
Some(String::from_utf16_lossy(&name[..len]))
-6
View File
@@ -9,12 +9,6 @@
//! - [`display_events`]: the `WM_DISPLAYCHANGE` / device-arrival watch that lets a capture stall say
//! whether an OS display event coincided with it.
// `win_display` has denied both unsafe-proof lints since its CCD helpers stopped being `unsafe fn`;
// hoist that to the crate root so the smaller modules (`input_desktop`, `monitor_devnode`,
// `display_events`) and any future one are covered by default rather than by remembering to opt in.
#![deny(clippy::undocumented_unsafe_blocks)]
#![deny(unsafe_op_in_unsafe_fn)]
#[cfg(target_os = "windows")]
pub mod display_events;
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
+30 -236
View File
@@ -924,18 +924,6 @@ fn query_active_config() -> Option<SavedConfig> {
if unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm) }.is_err() {
return None;
}
// Zero active paths is an ANSWER, not a failure — and an ordinary state: every panel off or in
// standby, a KVM switched away, a headless box between the adapter arriving and its first
// monitor. `QueryDisplayConfig` REJECTS a zero-count call rather than returning an empty set,
// so asking anyway turns "nothing is active" into "the query failed". Measured on .173
// (RTX 4090, Win11 26200, TV powered off — every monitor devnode Code 45): the sizing call
// succeeds with numPaths=0 and the query then returns 0x57 ERROR_INVALID_PARAMETER in a live
// logged-on console session, 0x5 ERROR_ACCESS_DENIED from session 0. That `None` is what makes
// `isolate_displays_ccd` report failure, which is the condition whose recovery legs exist to
// stop the operator's panels being left dark.
if np == 0 {
return Some((Vec::new(), Vec::new()));
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
// SAFETY: the CCD contract — `paths`/`modes` were just allocated with exactly `np`/`nm`
@@ -998,56 +986,6 @@ pub struct TargetInventory {
pub friendly: String,
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
pub monitor_device_path: String,
/// One of OUR virtual displays (see [`is_our_virtual_display`]) — the reliable answer to
/// "is this the operator's screen or something we made?", which the connector class cannot give.
pub ours: bool,
/// GDI device name (`\\.\DISPLAY1`) of the SOURCE driving this target; empty when inactive
/// (an inactive path has no source). This is the id a Windows operator recognises and the one
/// capture pins on.
pub gdi_name: String,
/// Desktop position + mode of the driving source, in PIXELS. All zero when inactive: the CCD
/// mode indices are only valid for active paths, and inventing geometry for a dark head would
/// be worse than reporting none.
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
/// Refresh in mHz (60000 = 60 Hz), from the path's own `refreshRate` rational. 0 when the
/// path reports no rate (inactive, or a target that does not drive one).
pub refresh_mhz: u32,
/// The desktop origin sits on this head — Windows' notion of "primary".
pub primary: bool,
}
/// EDID manufacturer id of punktfunk's own IddCx monitors, as it appears in the PnP hardware id and
/// therefore in the CCD monitor device path (`\\?\DISPLAY#PNK….`). The driver stamps `"PNK"` into
/// EDID bytes 8-9 (`packaging/windows/drivers/pf-vdisplay/src/edid.rs`).
const PF_EDID_MANUFACTURER: &str = "PNK";
/// Is this monitor device path one of OUR virtual displays?
///
/// It has to be asked, because the connector class cannot answer it: our IddCx monitor declares
/// `DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HDMI` (driver `monitor.rs`, `IDDCX_MONITOR_INFO::MonitorType`),
/// which [`output_tech_class`]'s allowlist reads as a real external panel — so without this check
/// punktfunk's own display counts as one of the operator's physical monitors. Measured on .173:
/// `target_inventory()` returned `[(4352, "LG TV SSCR2", HDMI), (257, "punktfunk", HDMI)]` with
/// BOTH flagged `external_physical`.
///
/// That is not cosmetic. [`restore_displays_ccd`]'s last-resort "the desk is not left dark"
/// backstop fires on `connected > 0 && lit == 0` over exactly this set, and the restore runs BEFORE
/// the virtual is REMOVEd — so our own still-active display kept `lit >= 1` and the backstop could
/// never fire, in precisely the situation it was written for. It also made our display a candidate
/// "physical suspect" in the disturbance-attribution inventory, which [`TargetInventory`]'s own doc
/// says can never happen ("only indirect/virtual targets (our own IDD included)").
///
/// Matching on the device path rather than the friendly name: the name comes from the EDID's 0xFC
/// descriptor and is what a user sees, while the path carries the manufacturer id the OS itself
/// derived. Allowlist-shaped like [`output_tech_class`] — anything unrecognised stays "not ours",
/// so a third-party virtual display is never silently adopted.
fn is_our_virtual_display(monitor_device_path: &str) -> bool {
monitor_device_path
.to_ascii_uppercase()
.contains(PF_EDID_MANUFACTURER)
}
/// Classify a CCD output technology: `(external physical?, log label)`. Allowlist, not blocklist:
@@ -1146,70 +1084,15 @@ pub fn target_inventory() -> Vec<TargetInventory> {
if unsafe { DisplayConfigGetDeviceInfo(&mut req.header) } != 0 {
continue; // target with no queryable monitor — nothing to attribute to
}
let monitor_device_path = utf16z_str(&req.monitorDevicePath);
let (mut external_physical, mut tech) = output_tech_class(req.outputTechnology);
// Our own IddCx monitor claims HDMI, so the connector class alone would call it one of the
// operator's panels — see `is_our_virtual_display` for what that broke.
let ours = is_our_virtual_display(&monitor_device_path);
if ours {
external_physical = false;
tech = "punktfunk-virtual";
}
let is_active = active.contains(&key);
// Geometry + the GDI name come from the SOURCE this path drives, and only an ACTIVE path
// has one — `modeInfoIdx` is the INVALID sentinel otherwise, so everything stays zeroed
// rather than indexing the mode table with 0xffffffff.
let (mut gdi_name, mut x, mut y, mut width, mut height) =
(String::new(), 0i32, 0i32, 0u32, 0u32);
if is_active {
// SAFETY: POD union read (header) — `modeInfoIdx` overlays a same-sized bitfield
// struct, both valid for every bit pattern. Used only as a bounds-checked index below.
let idx = unsafe { p.sourceInfo.Anonymous.modeInfoIdx } as usize;
if let Some(m) = modes.get(idx) {
if m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE {
// SAFETY: discriminated union read — the `infoType` test directly above is the
// discriminant the CCD contract defines for `sourceMode`.
let sm = unsafe { m.Anonymous.sourceMode };
x = sm.position.x;
y = sm.position.y;
width = sm.width;
height = sm.height;
}
}
let mut src = DISPLAYCONFIG_SOURCE_DEVICE_NAME::default();
src.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
src.header.size = size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32;
src.header.adapterId = p.sourceInfo.adapterId;
src.header.id = p.sourceInfo.id;
// SAFETY: `src.header` is a live local whose `size` was just set to the enclosing
// struct's own `size_of`, which is the contract telling the OS how many bytes it may
// write; the struct outlives this synchronous call.
if unsafe { DisplayConfigGetDeviceInfo(&mut src.header) } == 0 {
gdi_name = utf16z_str(&src.viewGdiDeviceName);
}
}
// A rational, not a scalar: mHz keeps 59.94 distinguishable from 60 without a float.
let refresh_mhz = match t.refreshRate.Denominator {
0 => 0,
d => (u64::from(t.refreshRate.Numerator) * 1000 / u64::from(d)) as u32,
};
let (external_physical, tech) = output_tech_class(req.outputTechnology);
out.push(TargetInventory {
target_id: t.id,
active: is_active,
active: active.contains(&key),
external_physical,
internal_panel: tech == "internal-panel",
tech,
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
monitor_device_path,
ours,
gdi_name,
// Windows' primary is the head at the desktop origin.
primary: is_active && x == 0 && y == 0,
x,
y,
width,
height,
refresh_mhz,
monitor_device_path: utf16z_str(&req.monitorDevicePath),
});
}
out
@@ -1232,20 +1115,6 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
// Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes.
let saved = query_active_config()?;
// Nothing is active, so there is nothing to deactivate and nothing to restore later. Say so by
// returning the empty snapshot rather than falling into the retry loop: the re-commit below is
// there to drive the IddCx adapter's COMMIT_MODES, and with no path at all there is no config
// to commit — a zero-path `SetDisplayConfig` is simply rejected, and the four attempts would
// log an apply failure and a 250 ms sleep each for a topology that is already what we want.
// `restore_displays_ccd` already treats an empty config as the no-op it is.
if saved.0.is_empty() {
tracing::info!(
"display isolate (CCD): no display path is active — nothing to isolate for target set \
{keep_target_ids:?} (every panel off/standby, or a headless host)"
);
return Some(saved);
}
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
@@ -1632,11 +1501,33 @@ pub fn apply_source_positions(positions: &[(u32, i32, i32)]) {
/// `DXGI_ERROR_MODE_CHANGE_IN_PROGRESS` when another display is live — see [`set_active_mode`]).
/// Returns the original config to restore on teardown.
pub fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
// Through the shared query, not a private copy of it. This was a verbatim duplicate of
// `query_active_config` — same flags, same shape — and so the one CCD entry point that did not
// inherit its zero-path fix (the seam asymmetry this crate keeps producing: N-1 of N sibling
// paths share a helper and the Nth open-codes it).
let (paths, mut modes) = query_active_config()?;
let mut np = 0u32;
let mut nm = 0u32;
// SAFETY: the CCD contract at the top of this file — `&mut np`/`&mut nm` are live
// locals the OS fills with the counts it wants for these flags.
if unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm) }.is_err() {
return None;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
// SAFETY: the CCD contract — `paths`/`modes` were just allocated with exactly `np`/`nm`
// elements from the sizing call above, and are handed over with those same counts.
if unsafe {
QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
}
.is_err()
{
return None;
}
paths.truncate(np as usize);
modes.truncate(nm as usize);
let saved = (paths.clone(), modes.clone());
// The virtual output's source width, to lay the other displays out to its right.
@@ -1760,100 +1651,3 @@ pub fn restore_displays_ccd(saved: &SavedConfig) {
force_extend_topology();
}
}
/// This file's first tests. Everything here reads the LIVE display topology, so each case is
/// `#[ignore]`d and reports `ignored` rather than a vacuous `ok` when nobody runs it on hardware —
/// the shape Phase 0.3 of the pf-vdisplay sweep settled on after finding env-guarded early-returns
/// reporting success without executing.
///
/// Run with `cargo test -p pf-win-display -- --ignored` on a real box.
///
/// ⚠️ Read-only by construction, and it must stay that way. The obvious companion assertion —
/// `isolate_displays_ccd(&[])` — is NOT written here on purpose: an empty keep set means "keep
/// nothing", so on a box with displays it would deactivate every one of them and blank the
/// operator's desk. The query below is the safe half of the same evidence.
#[cfg(test)]
mod live_tests {
use super::*;
/// A CCD query must never report FAILURE for a host that simply has nothing lit.
///
/// `GetDisplayConfigBufferSizes` answers `numPaths = 0` for an ordinary state — every panel off
/// or in standby, a KVM switched away, a headless box. `QueryDisplayConfig` then rejects the
/// zero-count call rather than handing back an empty set, so asking anyway converted "nothing
/// is active" into "the query failed". That `None` propagates: `isolate_displays_ccd` returns
/// `None`, which is the teardown-gate condition whose recovery legs exist precisely to stop the
/// operator's panels being left dark.
///
/// Verified on .173 (RTX 4090, Win11 26200) with the TV powered off — every monitor devnode
/// Code 45, `numPaths = 0` for `QDC_ALL_PATHS` as well — in a live logged-on console session,
/// where the query returned 0x57 ERROR_INVALID_PARAMETER (and 0x5 ERROR_ACCESS_DENIED from
/// session 0). This case FAILS there without the zero-path short-circuit and passes with it,
/// while a box that does have a display lit passes either way.
/// Pure, so it runs everywhere — the identification rule itself needs no hardware.
#[test]
fn our_own_virtual_display_is_never_an_external_physical() {
assert!(super::is_our_virtual_display(
r"\\?\DISPLAY#PNK0000#5&1234abcd&0&UID257#{e6f07b5f-ee97-4a90-b076-33f57bf4eaa7}"
));
// Case-insensitive: the OS is not consistent about the path's case.
assert!(super::is_our_virtual_display(
r"\\?\display#pnk0000#5&1&0&uid257#{guid}"
));
// A real panel, and a third-party virtual display, both stay physical suspects.
assert!(!super::is_our_virtual_display(
r"\\?\DISPLAY#GSM83CD#5&367fb4cb&0&UID4352#{e6f07b5f-ee97-4a90-b076-33f57bf4eaa7}"
));
assert!(!super::is_our_virtual_display(
r"\\?\DISPLAY#SMVD0001#5&1&0&UID999#{guid}"
));
}
/// Read-only: prove on real hardware that punktfunk's own display is not counted among the
/// operator's physical panels. Creates and destroys nothing, so it is safe to run against a
/// live host — which matters, because repeated IddCx create/destroy cycles are exactly what
/// wedges the driver's slot pool.
///
/// Measured on .173 BEFORE the fix: `[(4352, "LG TV SSCR2", HDMI), (257, "punktfunk", HDMI)]`
/// with both flagged `external_physical`, because the driver declares
/// `DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HDMI`.
#[test]
#[ignore = "hardware: reads the live display topology"]
fn our_own_display_is_excluded_from_the_operators_physicals_on_real_hardware() {
let inv = target_inventory();
for t in &inv {
println!(
"target {:>5} active={:<5} external_physical={:<5} tech={:<18} {:?} {}",
t.target_id,
t.active,
t.external_physical,
t.tech,
t.friendly,
t.monitor_device_path
);
}
for t in inv
.iter()
.filter(|t| is_our_virtual_display(&t.monitor_device_path))
{
assert!(
!t.external_physical,
"our own display {} ({:?}) is still counted as one of the operator's physical \
panels `restore_displays_ccd`'s dark-desk backstop keys on exactly this set and \
would never fire",
t.target_id, t.friendly
);
}
}
#[test]
#[ignore = "hardware: reads the live display topology"]
fn a_host_with_nothing_lit_reports_zero_actives_rather_than_a_failed_query() {
let n = count_other_active(&[]).expect(
"count_other_active returned None, i.e. the CCD query was reported as FAILED. On a \
host with no active display path that is the zero-path conflation, and it is what \
makes isolate_displays_ccd yield None on a perfectly healthy machine",
);
tracing::info!("live CCD query: {n} active display path(s)");
}
}
-6
View File
@@ -17,12 +17,6 @@
// proof of why it is sound. This crate-root deny is the permanent, catch-all gate (it also covers
// any future module); individual files keep their own `#![deny(...)]` as belt-and-suspenders.
#![deny(clippy::undocumented_unsafe_blocks)]
// The companion gate: a proof only covers what it is attached to, and an `unsafe fn` body without
// this lint needs no blocks at all — so an unproven FFI call could hide inside one and satisfy the
// deny above. The workspace sets `unsafe_op_in_unsafe_fn` to `warn` (a ratchet across ~590 sites);
// this crate is at zero, so it denies. Keep the marker only where a caller can actually violate
// something — a raw pointer or a borrowed `HANDLE` parameter, as in `service::spawn_host`.
#![deny(unsafe_op_in_unsafe_fn)]
mod audio;
mod bringup;
+19 -79
View File
@@ -71,34 +71,27 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
})
})
.collect();
let mut enforced: Vec<String> = vec![
"keep_alive".into(),
"topology".into(),
"mode_conflict".into(),
"identity".into(),
"layout".into(),
"game_session".into(),
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
];
// `capture_monitor` routes every session to the MIRROR backend, and that backend exists only on
// Linux — `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]`, because `pf-capture`
// has no Windows entry point that can capture an arbitrary head. This list is precisely the
// "which controls are live vs. coming soon" contract, so claiming it unconditionally is what let
// the Windows console offer a picker that saved and then did nothing
// (`design/per-monitor-portal-capture.md` §5.3).
if cfg!(target_os = "linux") {
enforced.push("capture_monitor".into());
}
DisplaySettingsState {
effective: settings.effective(),
settings,
configured,
presets,
custom_presets: policy::load_custom_presets(),
enforced,
enforced: vec![
"keep_alive".into(),
"topology".into(),
"mode_conflict".into(),
"identity".into(),
"layout".into(),
"game_session".into(),
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
// Linux-only in effect: routes every session to the mirror backend
// (design/per-monitor-portal-capture.md).
"capture_monitor".into(),
],
}
}
@@ -142,24 +135,6 @@ pub(crate) async fn get_display_settings() -> Json<DisplaySettingsState> {
pub(crate) async fn set_display_settings(
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
) -> Response {
#[cfg_attr(target_os = "linux", allow(unused_mut))]
let mut policy = policy;
// A pin nothing can honor must not be STORED as though it were. Off Linux there is no mirror
// backend (`MonitorsResponse::pin_supported`), so drop the field rather than persisting a
// setting whose only effect would be to mislead: the console re-reads this state after the PUT
// and would otherwise render a screen as "streamed" while every session kept creating a virtual
// display. Coerced rather than rejected with a 400 on purpose — this PUT is WHOLE-OBJECT, so a
// host that already stored a pin (before this build) would have every later settings save
// rejected over a field the operator cannot even see, taking the other axes down with it. This
// way such a policy self-heals on the next write, and the response body shows the truth
// immediately.
#[cfg(not(target_os = "linux"))]
if let Some(dropped) = policy.capture_monitor.take() {
tracing::warn!(
"management API: ignoring capture_monitor={dropped:?} — streaming a chosen physical \
monitor is Linux-only (no Windows mirror backend); the pin was NOT stored"
);
}
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
@@ -247,19 +222,6 @@ pub(crate) struct MonitorsResponse {
/// The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,
/// so the console can show "pinned to DP-2, which this host doesn't have".
pinned: Option<String>,
/// Whether this build can actually STREAM one of these monitors.
///
/// Enumeration and capture are separate capabilities, and on Windows only the first exists: the
/// heads below are real and worth showing (they explain the topology, and `/display/state`
/// cross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a
/// frame channel pushed by our OWN IddCx virtual display. There is no desktop-duplication
/// capturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so
/// `vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.
///
/// The console renders the picker read-only on `false`. Reported as a capability rather than
/// sniffed client-side from the OS so the answer comes from the build that would have to honor
/// it — when a Windows mirror backend lands, this flips and the UI needs no change.
pin_supported: bool,
/// Why the list is empty, when enumeration failed (compositor unreachable, unsupported
/// platform). `None` with an empty list means "asked, and there are none".
error: Option<String>,
@@ -286,33 +248,12 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
// sessions will actually mirror, not just what the console last wrote.
#[cfg(target_os = "linux")]
let pinned = crate::vdisplay::capture_monitor();
// Off Linux there is no mirror backend, so there is nothing a pin could aim (see
// `MonitorsResponse::pin_supported`). Kept `None` DELIBERATELY rather than reporting the stored
// value: `pinned` is what the console highlights as "this is the screen sessions stream", and a
// highlight on a head nothing will ever capture is precisely the lie this endpoint is here to
// stop telling. `pin_supported: false` is how the unsupportedness is reported instead.
#[cfg(not(target_os = "linux"))]
let pinned: Option<String> = None;
// Enumeration works on Windows; capture of an enumerated head does not.
let pin_supported = cfg!(target_os = "linux");
// Enumeration shells out / round-trips D-Bus + Wayland (and on Windows walks the CCD
// database, which can serialize on the display-config lock), so keep it off the async worker.
let (compositor, listed) = tokio::task::spawn_blocking(|| {
// Windows has no compositor to detect — asking used to fail with Linux advice about
// XDG_CURRENT_DESKTOP, which landed verbatim in `error` below and was the console's only
// explanation for an empty picker. Report the display API we actually used instead.
#[cfg(windows)]
{
(
Some("windows".to_string()),
crate::vdisplay::monitors::list_windows(),
)
}
#[cfg(not(windows))]
match crate::vdisplay::detect() {
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
Err(e) => (None, Err(e)),
}
// Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker.
let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() {
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
Err(e) => (None, Err(e)),
})
.await
.unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}"))));
@@ -342,7 +283,6 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
compositor,
monitors,
pinned,
pin_supported,
error,
})
}
@@ -16,11 +16,6 @@
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// …and the proofs only cover the whole file once an `unsafe fn` body needs its own blocks: the
// workspace sets `unsafe_op_in_unsafe_fn` to `warn`, which is a ratchet, not a floor. This module is
// at zero, so hold it there — `merged_env_block`'s pointer walk is the one real contract here, and
// it must not silently re-absorb the FFI calls around it.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::{bail, Context, Result};
use std::path::Path;
@@ -67,53 +62,42 @@ pub fn console_session_mismatch() -> Option<(u32, u32)> {
/// Requires the host to run as SYSTEM (`WTSQueryUserToken` needs `SE_TCB`). Fails when no interactive
/// user is logged on (a pre-login / freshly-booted box can stream the login desktop but cannot
/// auto-launch a store title until someone signs in).
/// Safe: `cmdline`/`workdir` are borrowed Rust data, every FFI argument is built locally from them,
/// and the function owns each handle it opens (closing all of them before it returns) — there is no
/// precondition a caller could violate, so the `unsafe` marks only the Win32 calls below.
pub fn spawn_in_active_session(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
// SAFETY: `spawn_inner` is unsafe only for its Win32 FFI; it has no caller-side preconditions — it
// validates the session/token itself and owns every handle it opens — so calling it is always sound.
unsafe { spawn_inner(cmdline, workdir) }
}
unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
// The user token of the active console session (requires the host to be SYSTEM).
// SAFETY: takes no arguments and returns the console session id by value.
let session = unsafe { WTSGetActiveConsoleSessionId() };
let session = WTSGetActiveConsoleSessionId();
if session == 0xFFFF_FFFF {
bail!("no active console session (no interactive user is logged on)");
}
let mut user_token = HANDLE::default();
// SAFETY: `session` is a plain id and `user_token` a live local out-param; on `Ok` the call
// yields an owned token handle, closed exactly once below.
unsafe { WTSQueryUserToken(session, &mut user_token) }
WTSQueryUserToken(session, &mut user_token)
.context("WTSQueryUserToken (host must be SYSTEM; needs a logged-on interactive user)")?;
// A primary token for CreateProcessAsUserW.
let mut primary = HANDLE::default();
// SAFETY: `user_token` is the live token just opened; `primary` is a live local out-param that
// receives a second owned handle on `Ok`. Both are closed exactly once, below.
let dup = unsafe {
DuplicateTokenEx(
user_token,
TOKEN_ALL_ACCESS,
None,
SecurityImpersonation,
TokenPrimary,
&mut primary,
)
};
// SAFETY: `user_token` is live and owned here, and is not used again after this close.
let _ = unsafe { CloseHandle(user_token) };
let dup = DuplicateTokenEx(
user_token,
TOKEN_ALL_ACCESS,
None,
SecurityImpersonation,
TokenPrimary,
&mut primary,
);
let _ = CloseHandle(user_token);
dup.context("DuplicateTokenEx(TokenPrimary)")?;
// The user's environment block (PATH/USERPROFILE/SystemRoot for handler + DLL resolution), MERGED
// with the host's PUNKTFUNK_*/RUST_LOG vars — same shared helper the WGC helper + service spawns use.
let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut();
// SAFETY: `env_block` is a live local out-param and `primary` the live token above; on success
// the call stores an owned block pointer, destroyed exactly once below.
let _ = unsafe { CreateEnvironmentBlock(&mut env_block, Some(primary), false) };
// SAFETY: `env_block` is either still null (the call above failed) or the double-null-terminated
// UTF-16 block `CreateEnvironmentBlock` just wrote — exactly the two states the helper accepts.
let merged_env = unsafe { merged_env_block(env_block as *const u16) };
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
let merged_env = merged_env_block(env_block as *const u16);
if !env_block.is_null() {
// SAFETY: `env_block` is the live block from the call above, destroyed exactly once and not
// read after — `merged_env` owns its own copy of the parsed entries.
let _ = unsafe { DestroyEnvironmentBlock(env_block) };
let _ = DestroyEnvironmentBlock(env_block);
}
// The game/launcher must appear on the interactive desktop the host is capturing.
@@ -138,37 +122,26 @@ pub fn spawn_in_active_session(cmdline: &str, workdir: Option<&Path>) -> Result<
};
let mut pi = PROCESS_INFORMATION::default();
// SAFETY: `primary` is the live primary token; `cmd`, `desktop` (via `si.lpDesktop`), `workdir_w`
// (via `cwd`) and `merged_env` are locals that outlive the call, each NUL-terminated as the API
// requires — `merged_env` doubly so, per `merged_env_block`. `pi` is a live local out-param, and
// the API retains none of these pointers.
let created = unsafe {
CreateProcessAsUserW(
Some(primary),
None,
Some(PWSTR(cmd.as_mut_ptr())),
None,
None,
false, // no handle inheritance — fire-and-forget GUI launch, no stdio relay
CREATE_UNICODE_ENVIRONMENT,
Some(merged_env.as_ptr() as *const core::ffi::c_void),
cwd,
&si,
&mut pi,
)
};
// SAFETY: `primary` is live and owned here, closed exactly once and not used after.
let _ = unsafe { CloseHandle(primary) };
let created = CreateProcessAsUserW(
Some(primary),
None,
Some(PWSTR(cmd.as_mut_ptr())),
None,
None,
false, // no handle inheritance — fire-and-forget GUI launch, no stdio relay
CREATE_UNICODE_ENVIRONMENT,
Some(merged_env.as_ptr() as *const core::ffi::c_void),
cwd,
&si,
&mut pi,
);
let _ = CloseHandle(primary);
created.context("CreateProcessAsUserW (interactive-session launch)")?;
let pid = pi.dwProcessId;
// We don't supervise the child (it owns its own window/lifetime) — close the handles the API gave us.
// SAFETY: `created` was `Ok`, so `pi` holds two owned handles; each is closed exactly once here
// and never used after. Closing them does not terminate the child, which owns its own lifetime.
unsafe {
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
}
let _ = CloseHandle(pi.hProcess);
let _ = CloseHandle(pi.hThread);
Ok(pid)
}
@@ -190,24 +163,15 @@ pub(crate) unsafe fn merged_env_block(user_block: *const u16) -> Vec<u16> {
let mut p = user_block;
loop {
let mut len = 0isize;
// SAFETY: per this fn's contract `p` points into a double-null-terminated block that is
// readable for its whole length. `len` only advances over units this loop has already
// read as non-NUL, so `p.offset(len)` stays inside the current entry — the scan stops at
// that entry's terminator, and the empty entry stops the outer loop before `p` can pass
// the block's end.
while unsafe { *p.offset(len) } != 0 {
while *p.offset(len) != 0 {
len += 1;
}
if len == 0 {
break; // the trailing empty string = end of block
}
// SAFETY: `p` is readable for `len` non-NUL UTF-16 units, just scanned above, and the
// slice is consumed before `p` moves.
let slice = unsafe { std::slice::from_raw_parts(p, len as usize) };
let slice = std::slice::from_raw_parts(p, len as usize);
entries.push(String::from_utf16_lossy(slice));
// SAFETY: `len` is the entry length and unit `len` is its NUL, so this lands on the next
// entry — at worst the trailing empty one, which is still inside the block.
p = unsafe { p.offset(len + 1) };
p = p.offset(len + 1);
}
}
// Overlay "our" settings — PUNKTFUNK_* and RUST_LOG — dropping whatever the target block had.
+45 -81
View File
@@ -543,63 +543,44 @@ unsafe fn spawn_host(
// (LocalSystem) token, then set its session id. SYSTEM holds SE_TCB so SetTokenInformation
// (TokenSessionId) is permitted.
let mut proc_token = HANDLE::default();
// SAFETY: `GetCurrentProcess` returns the pseudo-handle for this process, which needs no close;
// `proc_token` is a live local out-param that receives an owned handle on `Ok`, closed once below.
unsafe {
OpenProcessToken(
GetCurrentProcess(),
TOKEN_DUPLICATE
| TOKEN_QUERY
| TOKEN_ASSIGN_PRIMARY
| TOKEN_ADJUST_DEFAULT
| TOKEN_ADJUST_SESSIONID,
&mut proc_token,
)
}
OpenProcessToken(
GetCurrentProcess(),
TOKEN_DUPLICATE
| TOKEN_QUERY
| TOKEN_ASSIGN_PRIMARY
| TOKEN_ADJUST_DEFAULT
| TOKEN_ADJUST_SESSIONID,
&mut proc_token,
)
.context("OpenProcessToken (service must run as SYSTEM)")?;
let mut primary = HANDLE::default();
// SAFETY: `proc_token` is the live token just opened; `primary` is a live local out-param that
// receives a second owned handle on `Ok`. Both are closed exactly once in this function.
let dup = unsafe {
DuplicateTokenEx(
proc_token,
TOKEN_ALL_ACCESS,
None,
SecurityImpersonation,
TokenPrimary,
&mut primary,
)
};
// SAFETY: `proc_token` is live and owned here, closed exactly once and not used after.
let _ = unsafe { CloseHandle(proc_token) };
let dup = DuplicateTokenEx(
proc_token,
TOKEN_ALL_ACCESS,
None,
SecurityImpersonation,
TokenPrimary,
&mut primary,
);
let _ = CloseHandle(proc_token);
dup.context("DuplicateTokenEx(TokenPrimary)")?;
// SAFETY: `primary` is the live duplicated token; the value pointer is a local `u32` matching
// what `TokenSessionId` expects, and the length argument is exactly its `size_of`.
unsafe {
SetTokenInformation(
primary,
TokenSessionId,
&session_id as *const u32 as *const c_void,
std::mem::size_of::<u32>() as u32,
)
}
SetTokenInformation(
primary,
TokenSessionId,
&session_id as *const u32 as *const c_void,
std::mem::size_of::<u32>() as u32,
)
.context("SetTokenInformation(TokenSessionId)")?;
// 2) The session's environment block, merged with this process's PUNKTFUNK_*/RUST_LOG (so the
// host runs with host.env's settings, not a bare block). Same merge the interactive launch uses.
let mut env_block: *mut c_void = std::ptr::null_mut();
// SAFETY: `env_block` is a live local out-param and `primary` the live token above; on success
// the call stores an owned block pointer, destroyed exactly once below.
let _ = unsafe { CreateEnvironmentBlock(&mut env_block, Some(primary), false) };
// SAFETY: `env_block` is either still null (the call above failed) or the double-null-terminated
// UTF-16 block `CreateEnvironmentBlock` just wrote — exactly the two states the helper accepts.
let merged = unsafe { crate::interactive::merged_env_block(env_block as *const u16) };
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
let merged = crate::interactive::merged_env_block(env_block as *const u16);
if !env_block.is_null() {
// SAFETY: `env_block` is the live block from the call above, destroyed exactly once and not
// read after — `merged` owns its own copy of the parsed entries.
let _ = unsafe { DestroyEnvironmentBlock(env_block) };
let _ = DestroyEnvironmentBlock(env_block);
}
// 3) Redirect the host's stdout+stderr to host.log (inheritable handle). The previous child has
@@ -623,49 +604,32 @@ unsafe fn spawn_host(
let cwd = (!workdir.is_empty()).then_some(PCWSTR(workdir.as_ptr()));
let mut pi = PROCESS_INFORMATION::default();
// SAFETY: `primary` is the live retargeted token; `cmd`, `desktop` (via `si.lpDesktop`),
// `workdir` (via `cwd`) and `merged` are live for the call and NUL-terminated as the API
// requires — `merged` doubly so, per `merged_env_block`. `si.hStdOutput`/`hStdError` are the
// live inheritable `log` handle. `pi` is a live local out-param; no pointer is retained.
let created = unsafe {
CreateProcessAsUserW(
Some(primary),
None,
Some(PWSTR(cmd.as_mut_ptr())),
None,
None,
true, // inherit the log handle
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW,
Some(merged.as_ptr() as *const c_void),
cwd.unwrap_or(PCWSTR::null()),
&si,
&mut pi,
)
};
let created = CreateProcessAsUserW(
Some(primary),
None,
Some(PWSTR(cmd.as_mut_ptr())),
None,
None,
true, // inherit the log handle
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW,
Some(merged.as_ptr() as *const c_void),
cwd.unwrap_or(PCWSTR::null()),
&si,
&mut pi,
);
// SAFETY: both are live and owned here, each closed exactly once and not used after — the child
// holds its own inherited copy of `log`.
unsafe {
let _ = CloseHandle(log); // the child owns its inherited copy
let _ = CloseHandle(primary);
}
let _ = CloseHandle(log); // the child owns its inherited copy
let _ = CloseHandle(primary);
created.context("CreateProcessAsUserW(host)")?;
// Best-effort: keep the host inside the kill-on-close job.
// SAFETY: `job` is a live job object per this fn's contract, and `pi.hProcess` is the live child
// handle just created (`created` was `Ok`), still owned here.
let _ = unsafe { AssignProcessToJobObject(job, pi.hProcess) };
let _ = AssignProcessToJobObject(job, pi.hProcess);
// Take ownership of the process + thread handles the API filled into `pi`; the returned `Child`
// closes BOTH on drop, so the supervise loop no longer hand-closes them in its match arms.
// SAFETY: `created` was `Ok`, so `pi.hProcess` is an owned handle nothing else closes; wrapping
// it transfers that ownership to the `OwnedHandle`, which closes it exactly once.
let process = unsafe { OwnedHandle::from_raw_handle(pi.hProcess.0) };
// SAFETY: the same, for the distinct thread handle `CreateProcessAsUserW` filled in.
let thread = unsafe { OwnedHandle::from_raw_handle(pi.hThread.0) };
Ok(Child {
process,
_thread: thread,
process: OwnedHandle::from_raw_handle(pi.hProcess.0),
_thread: OwnedHandle::from_raw_handle(pi.hThread.0),
pid: pi.dwProcessId,
})
}
+3 -13
View File
@@ -126,14 +126,9 @@ package_punktfunk-host() {
pkgdesc="Low-latency desktop/game streaming HOST (Moonlight-compatible + punktfunk/1)"
# NVENC + GPU EGL/CUDA come from the NVIDIA driver (nvidia-utils) — kept an optdepend, never a
# hard dep, exactly as the RPM (__requires_exclude libcuda) and deb (shlibdeps filter) do.
# The host captures the sink monitor through NATIVE PipeWire (audio/linux.rs) — it never
# opens a Pulse socket itself, so pipewire-pulse is an OPTdepend, not a depend: it exists
# for the GAMES, which commonly emit through the PulseAudio API. Hard-depending on it made
# the package uninstallable next to real `pulseaudio`, which serves those games just as well.
depends=('ffmpeg' 'pipewire' 'wireplumber' 'opus' 'libei'
depends=('ffmpeg' 'pipewire' 'pipewire-pulse' 'wireplumber' 'opus' 'libei'
'mesa' 'libglvnd' 'libxkbcommon' 'wayland')
optdepends=('pipewire-pulse: PulseAudio-API audio from games/apps (real `pulseaudio` also works)'
'nvidia-utils: NVENC hardware encode + GPU EGL/CUDA zero-copy (REQUIRED to encode on NVIDIA)'
optdepends=('nvidia-utils: NVENC hardware encode + GPU EGL/CUDA zero-copy (REQUIRED to encode on NVIDIA)'
'gamescope: per-session nested compositor backend (no desktop login needed) — needs >=3.16.22'
'kwin: stream a KDE Plasma desktop (kwin VirtualDisplay backend)'
'mutter: stream a GNOME desktop (Mutter RecordVirtual backend)'
@@ -232,12 +227,7 @@ package_punktfunk-client() {
# The GTK4/libadwaita shell + its Vulkan session streamer: SDL3 gamepads, FFmpeg (VAAPI +
# Vulkan Video) decode, PipeWire audio/mic. vulkan-icd-loader: the session binary loads
# libvulkan at runtime (ash) for its ash/Skia presenter.
# NOT pipewire-pulse: the client speaks NATIVE PipeWire (audio.rs drives libpipewire-0.3
# directly for both playback and the mic uplink) and never opens a Pulse socket, so the
# compat shim buys it nothing — while `pipewire-pulse` CONFLICTS with `pulseaudio`, which
# made the package uninstallable for anyone keeping real PulseAudio. Matches the .deb
# (Recommends) and the RPM (Recommends, "degrade gracefully without it").
depends=('gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wireplumber'
depends=('gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wireplumber' 'pipewire-pulse'
'opus' 'libglvnd' 'vulkan-icd-loader')
optdepends=('libva-mesa-driver: VAAPI hardware decode on AMD (incl. Steam Deck); software fallback otherwise'
'intel-media-driver: VAAPI hardware decode on Intel'
+1 -2
View File
@@ -107,8 +107,7 @@ NVENC/EGL come from the NVIDIA driver: `sudo pacman -S --needed nvidia-utils`. A
| Need | Arch package |
|------|--------------|
| FFmpeg + NVENC | `ffmpeg` (NVENC built in) |
| PipeWire + session mgr | `pipewire` `wireplumber` |
| PulseAudio-API audio for games | `pipewire-pulse` *(host optdepend — real `pulseaudio` also works; never a hard dep, it CONFLICTS with `pulseaudio`)* |
| PipeWire + Pulse + session mgr | `pipewire` `pipewire-pulse` `wireplumber` |
| Opus / input injection | `opus` `libei` |
| GL/EGL + gbm + xkb + wayland | `libglvnd` `mesa` `libxkbcommon` `wayland` |
| NVIDIA driver (NVENC/EGL/CUDA) | `nvidia-utils` *(optdepend — never a hard dep)* |
+1 -4
View File
@@ -106,10 +106,7 @@ SHDEPS="$SHDEPS, libvulkan1"
# Manual additions shlibdeps can't see: the PipeWire daemon + session manager are runtime
# services (audio playback / mic capture degrade gracefully without them — Recommends).
# NOT pipewire-pulse: the client speaks native PipeWire (audio.rs → libpipewire-0.3) and never
# opens a Pulse socket, and the shim Conflicts with pulseaudio — so recommending it only nags
# users who run real PulseAudio, in exchange for nothing the client can use.
RECOMMENDS="pipewire, wireplumber"
RECOMMENDS="pipewire, wireplumber, pipewire-pulse"
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
+1 -6
View File
@@ -104,13 +104,8 @@ BuildRequires: vulkan-headers
# --- Runtime -----------------------------------------------------------------
Requires: pipewire
Requires: pipewire-pulseaudio
Requires: wireplumber
# The host captures the sink monitor through NATIVE PipeWire (audio/linux.rs) and never opens a
# Pulse socket itself — the shim is for the GAMES, which commonly emit through the PulseAudio
# API. Weak-dep, because `pipewire-pulseaudio` CONFLICTS with `pulseaudio`: as a hard Requires it
# made the host uninstallable for anyone running real PulseAudio, which serves those games just
# as well. Fedora installs pipewire-pulseaudio by default, so the default box is unaffected.
Recommends: pipewire-pulseaudio
Requires: opus
Requires: libei
# FFmpeg runtime with NVENC (RPM Fusion). Weak-dep so the package installs even if
+1 -1
View File
@@ -402,7 +402,7 @@ dependencies = [
]
[[package]]
name = "pf-dualsense"
name = "pf-gamepad"
version = "0.0.1"
dependencies = [
"pf-driver-proto",
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Generate the per-release CycloneDX SBOM (CRA Annex I Part II §1 — machine-readable component
# inventory, attached to every stable release by .gitea/workflows/sbom.yml).
#
# syft walks the checkout and catalogs every lockfile-pinned dependency (both Rust workspaces via
# their Cargo.locks, the Bun/pnpm/npm trees, the Swift Package.resolved);
# compliance/sbom/manual-components.cdx.json contributes the components no lockfile records —
# vendored C/C++ trees (pyrowave/Granite/volk/Vulkan-Headers, libvpl), dynamically-linked/bundled
# libraries (FFmpeg, SDL3), the redistributed VB-CABLE driver, and the patched gamescope. Keep
# that file current when vendoring changes (scripts/vendor-pyrowave.sh etc.).
#
# Usage: scripts/ci/gen-sbom.sh VERSION [OUTPUT]
# Requires: syft (pinned install in the workflow), python3 (a proven runner dependency).
set -eu
VERSION="${1:?usage: gen-sbom.sh VERSION [OUTPUT]}"
OUT="${2:-punktfunk-${VERSION}.cdx.json}"
TMP="${OUT}.syft.tmp"
syft scan "dir:." --source-name punktfunk --source-version "$VERSION" \
-o "cyclonedx-json=$TMP" -q
OUT="$OUT" TMP="$TMP" python3 - <<'PY'
import json, os
gen = json.load(open(os.environ["TMP"]))
manual = json.load(open("compliance/sbom/manual-components.cdx.json"))
gen.setdefault("components", []).extend(manual["components"])
with open(os.environ["OUT"], "w") as f:
json.dump(gen, f, indent=2)
print("SBOM: %d components -> %s" % (len(gen["components"]), os.environ["OUT"]))
PY
rm -f "$TMP"
-8
View File
@@ -111,14 +111,6 @@ mkdir -p "$OUT"
echo
echo '[workspace.package]'
sed -n '/^\[workspace\.package\]/,/^$/p' "$REPO/Cargo.toml" | sed '1d;/^$/d'
# …and the real [workspace.lints.*] tables. Every crate manifest now carries
# `[lints] workspace = true` (the workspace-wide unsafe discipline), and a member inheriting a
# lint table the generated ROOT does not define does not merely lose the lint — cargo refuses to
# parse the manifest at all ("error inheriting `lints` from workspace root manifest's
# `workspace.lints`"), which broke this script outright the day that landed. Mirrored generically
# so a new table (clippy, rustdoc, …) is picked up without touching this script again.
echo
awk '/^\[/ { in_lints = ($0 ~ /^\[workspace\.lints/) } in_lints' "$REPO/Cargo.toml"
} > "$OUT/Cargo.toml"
mkdir -p "$OUT/punktfunk-core/src"
-1
View File
@@ -430,7 +430,6 @@
"display_monitor_none": "Dieser Host meldet keine Monitore.",
"display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.",
"display_monitor_env_locked": "Auf diesem Host über PUNKTFUNK_CAPTURE_MONITOR festgelegt — dort entfernen, um hier zu wählen.",
"display_monitor_unsupported": "Das Übertragen eines dieser Bildschirme wird auf diesem Host noch nicht unterstützt — das gibt es bisher nur auf Linux-Hosts. Die Bildschirme sind hier aufgeführt, damit du siehst, was dieser Computer hat; Clients bekommen weiterhin ihren eigenen virtuellen Bildschirm.",
"display_monitor_primary": "primär",
"display_monitor_disabled": "aus",
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
-1
View File
@@ -430,7 +430,6 @@
"display_monitor_none": "This host reports no monitors.",
"display_monitor_unavailable": "Monitors could not be listed on this host.",
"display_monitor_env_locked": "Pinned by PUNKTFUNK_CAPTURE_MONITOR on this host — unset it to choose here.",
"display_monitor_unsupported": "Streaming one of these screens isn't supported on this host yet — it's available on Linux hosts only. The screens are listed so you can see what this computer has; clients keep getting their own virtual screen.",
"display_monitor_primary": "primary",
"display_monitor_disabled": "off",
"display_monitor_saved": "Streamed screen saved"
+4 -17
View File
@@ -42,17 +42,9 @@ export const MonitorCard: FC = () => {
// environment is read-only here: offering controls that silently lose to the env would be worse
// than saying so.
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
// The host says whether it can honor a pin at all. Windows enumerates its heads but has no
// backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to
// offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was
// still created on connect. Defaults to TRUE when the field is absent so an older host — which
// only ever shipped this picker where it worked — is not retroactively locked out.
const pinSupported = monitors.data?.pin_supported ?? true;
// Both reasons produce the same read-only card; only the explanation above it differs.
const locked = envLocked || !pinSupported;
const choose = (connector: string | null) => {
if (!policy || locked) return;
if (!policy || envLocked) return;
save.mutate(
{ data: { ...policy, capture_monitor: connector } },
{
@@ -79,13 +71,13 @@ export const MonitorCard: FC = () => {
<button
key={key}
type="button"
disabled={busy || locked || !onSelect}
disabled={busy || envLocked || !onSelect}
onClick={onSelect}
aria-pressed={selected}
className={cn(
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
(busy || locked) && "cursor-not-allowed opacity-60",
(busy || envLocked) && "cursor-not-allowed opacity-60",
)}
>
<span className="flex flex-col gap-1">
@@ -132,12 +124,7 @@ export const MonitorCard: FC = () => {
<p className="max-w-prose text-sm text-muted-foreground">
{m.display_monitor_intro()}
</p>
{!pinSupported && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.display_monitor_unsupported()}
</p>
)}
{pinSupported && envLocked && (
{envLocked && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.display_monitor_env_locked()}
</p>