forked from unom/punktfunk
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
142a0100cc | ||
|
|
c14308bf71 |
+101
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -3051,7 +3051,6 @@ dependencies = [
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
|
||||
+4
-23
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(>k::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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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)* |
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -402,7 +402,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-dualsense"
|
||||
name = "pf-gamepad"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-driver-proto",
|
||||
|
||||
Executable
+31
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user