forked from unom/punktfunk
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b220daf537 | ||
|
|
8299151a09 | ||
|
|
ad99be2fb2 | ||
|
|
22cb975659 | ||
|
|
bd0103b1ac | ||
|
|
b9a7163556 | ||
|
|
08ec0f4239 | ||
|
|
b485f91f52 | ||
|
|
fa430646ff | ||
|
|
7d18468477 | ||
|
|
6de325a6b6 | ||
|
|
f40c90aa23 | ||
|
|
808eba7292 | ||
|
|
37a42078a0 | ||
|
|
21eda37aa3 | ||
|
|
2b2c6f045d | ||
|
|
ea9a25d3b1 | ||
|
|
5a14d2b3f4 |
+23
-4
@@ -60,11 +60,30 @@ 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. `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.)
|
||||
# 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).
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "warn"
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
+113
-4
@@ -35,6 +35,11 @@ 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. */
|
||||
@@ -225,6 +230,7 @@ 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();
|
||||
@@ -565,6 +571,9 @@ 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.
|
||||
@@ -589,6 +598,7 @@ 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.",
|
||||
@@ -620,7 +630,33 @@ 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));
|
||||
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
||||
// 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.set_response_enabled("apply", false);
|
||||
dialog.set_close_response("close");
|
||||
dialog.present(Some(&self.window));
|
||||
@@ -690,13 +726,36 @@ impl AppModel {
|
||||
));
|
||||
dialog.set_response_enabled("apply", true);
|
||||
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
||||
dialog.connect_response(Some("apply"), move |_, _| {
|
||||
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 |_, _| {
|
||||
let mut s = settings.borrow_mut();
|
||||
s.bitrate_kbps = recommended_kbps;
|
||||
s.save();
|
||||
toasts.add_toast(adw::Toast::new(&format!(
|
||||
"Bitrate set to {:.0} Mbit/s",
|
||||
f64::from(recommended_kbps) / 1000.0
|
||||
"{mbit:.0} Mbit/s set in the default bitrate"
|
||||
)));
|
||||
});
|
||||
}
|
||||
@@ -707,6 +766,56 @@ 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,6 +495,7 @@ 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,6 +14,9 @@ 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")]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! "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,10 +47,11 @@ pub fn spawn_session(
|
||||
fullscreen_on_stream: bool,
|
||||
opts: SpawnOpts,
|
||||
) -> Result<(), String> {
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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`
|
||||
@@ -67,7 +68,9 @@ pub fn spawn_session(
|
||||
},
|
||||
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
profile: None,
|
||||
profile_override: 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(),
|
||||
settings: Settings {
|
||||
fullscreen_on_stream,
|
||||
..Default::default()
|
||||
|
||||
+456
-33
@@ -32,6 +32,11 @@ 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 {
|
||||
@@ -62,6 +67,14 @@ 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),
|
||||
}
|
||||
@@ -69,19 +82,55 @@ 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),
|
||||
Rename { fp_hex: String, name: String },
|
||||
Forget { fp_hex: String, name: String },
|
||||
Wake { mac: Vec<String>, addr: String },
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
impl HostCard {
|
||||
fn request(&self) -> ConnectRequest {
|
||||
match &self.kind {
|
||||
CardKind::Saved { host: k, .. } => ConnectRequest {
|
||||
CardKind::Saved {
|
||||
host: k, pinned, ..
|
||||
} => ConnectRequest {
|
||||
name: k.name.clone(),
|
||||
addr: k.addr.clone(),
|
||||
port: k.port,
|
||||
@@ -90,6 +139,9 @@ 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(),
|
||||
@@ -100,6 +152,7 @@ impl HostCard {
|
||||
pair_optional: a.pair == "optional",
|
||||
launch: None,
|
||||
mac: a.mac.clone(),
|
||||
profile: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -171,7 +224,11 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
};
|
||||
match &self.kind {
|
||||
CardKind::Saved {
|
||||
host: k, online, ..
|
||||
host: k,
|
||||
online,
|
||||
profiles,
|
||||
pinned,
|
||||
..
|
||||
} => {
|
||||
// Presence pip + spelled-out state, then the trust pill.
|
||||
let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
@@ -190,6 +247,26 @@ 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 {
|
||||
@@ -214,6 +291,8 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
online,
|
||||
recent,
|
||||
library_enabled,
|
||||
profiles,
|
||||
pinned,
|
||||
} => {
|
||||
if *recent {
|
||||
overlay.add_css_class("pf-recent");
|
||||
@@ -250,7 +329,7 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
|
||||
add(
|
||||
"rename",
|
||||
Box::new(move || CardOutput::Rename {
|
||||
Box::new(move || CardOutput::Edit {
|
||||
fp_hex: fp.clone(),
|
||||
name: name.clone(),
|
||||
}),
|
||||
@@ -276,21 +355,185 @@ 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();
|
||||
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"));
|
||||
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"));
|
||||
}
|
||||
// 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)
|
||||
@@ -392,6 +635,8 @@ 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),
|
||||
@@ -668,9 +913,61 @@ impl SimpleComponent for HostsPage {
|
||||
let mgmt = self.mgmt_port_for(&req);
|
||||
let _ = sender.output(HostsOutput::Library(req, mgmt));
|
||||
}
|
||||
CardOutput::Rename { fp_hex, name } => self.rename_dialog(&sender, &fp_hex, &name),
|
||||
CardOutput::Edit { fp_hex, name } => self.edit_host_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();
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -694,6 +991,14 @@ 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();
|
||||
@@ -715,10 +1020,33 @@ 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())),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,28 +1104,122 @@ impl HostsPage {
|
||||
}
|
||||
|
||||
/// Rename a saved host — an entry in an alert, then upsert + refresh.
|
||||
fn rename_dialog(&self, sender: &ComponentSender<Self>, fp_hex: &str, current: &str) {
|
||||
let entry = gtk::Entry::builder()
|
||||
.text(current)
|
||||
.activates_default(true)
|
||||
/// 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.",
|
||||
)
|
||||
.build();
|
||||
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"));
|
||||
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"));
|
||||
dialog.set_close_response("cancel");
|
||||
{
|
||||
let sender = sender.clone();
|
||||
let fp = fp_hex.to_string();
|
||||
dialog.connect_response(Some("rename"), move |_, _| {
|
||||
let name = entry.text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
dialog.connect_response(Some("save"), move |_, _| {
|
||||
let name = name_row.text().trim().to_string();
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.name = name;
|
||||
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();
|
||||
let _ = known.save();
|
||||
}
|
||||
sender.input(HostsMsg::Refresh);
|
||||
@@ -886,6 +1308,7 @@ impl HostsPage {
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -337,7 +337,12 @@ 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, values: &Settings) {
|
||||
fn commit_profile(
|
||||
active: &StreamProfile,
|
||||
touched: &Touched,
|
||||
cleared: &HashSet<&'static str>,
|
||||
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
|
||||
@@ -394,6 +399,15 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
|
||||
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");
|
||||
}
|
||||
@@ -732,28 +746,6 @@ 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.
|
||||
@@ -782,6 +774,9 @@ 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();
|
||||
|
||||
@@ -1228,6 +1223,96 @@ 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
|
||||
@@ -1426,7 +1511,7 @@ pub fn show_scoped(
|
||||
Some(active) => {
|
||||
let mut values = seed.clone();
|
||||
apply_rows(&mut values);
|
||||
commit_profile(active, &touched, &values);
|
||||
commit_profile(active, &touched, &cleared.borrow(), &values);
|
||||
}
|
||||
None => {
|
||||
let mut s = settings.borrow_mut();
|
||||
|
||||
@@ -243,6 +243,13 @@ 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);
|
||||
// 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.
|
||||
@@ -489,6 +496,10 @@ 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,
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
|
||||
use super::style::*;
|
||||
use super::{AppCtx, Screen};
|
||||
use crate::trust::Settings;
|
||||
use crate::trust::{KnownHosts, Settings};
|
||||
use pf_client_core::profiles::{ProfilesFile, StreamProfile};
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::sync::Arc;
|
||||
@@ -110,22 +111,121 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = ctx.clone();
|
||||
let (ctx, scope) = (ctx.clone(), scope.to_string());
|
||||
let max = names.len().saturating_sub(1);
|
||||
ComboBox::new(names)
|
||||
.header(header)
|
||||
.selected_index(current as i32)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
apply(&mut s, (i.max(0) as usize).min(max));
|
||||
s.save();
|
||||
commit(&ctx, &scope, |s| apply(s, (i.max(0) as usize).min(max)));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,19 +239,18 @@ 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 = ctx.clone();
|
||||
let (ctx, scope) = (ctx.clone(), scope.to_string());
|
||||
ToggleSwitch::new(on)
|
||||
.header(header)
|
||||
.on_content("On")
|
||||
.off_content("Off")
|
||||
.on_toggled(move |v: bool| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
apply(&mut s, v);
|
||||
s.save();
|
||||
commit(&ctx, &scope, |s| apply(s, v));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,14 +319,35 @@ 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>>,
|
||||
progress: f64,
|
||||
) -> Element {
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
// 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();
|
||||
// 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,
|
||||
}
|
||||
};
|
||||
|
||||
// --- Display ---------------------------------------------------------------------------
|
||||
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||
@@ -253,7 +373,7 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
(names, i)
|
||||
};
|
||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
||||
let res_combo = setting_combo(ctx, scope, "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] };
|
||||
});
|
||||
@@ -271,7 +391,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, "Refresh rate", hz_names, hz_i, |s, i| {
|
||||
let hz_combo = setting_combo(ctx, scope, "Refresh rate", hz_names, hz_i, |s, i| {
|
||||
s.refresh_hz = REFRESH[i];
|
||||
});
|
||||
let (scale_names, scale_i) = {
|
||||
@@ -285,18 +405,20 @@ 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, "Render scale", scale_names, scale_i, |s, i| {
|
||||
let scale_combo = setting_combo(ctx, scope, "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, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
let comp_combo = setting_combo(ctx, scope, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
});
|
||||
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
let auto_wake_toggle =
|
||||
setting_toggle(ctx, scope, "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,
|
||||
@@ -304,7 +426,7 @@ pub(crate) fn settings_page(
|
||||
|
||||
// --- Video -----------------------------------------------------------------------------
|
||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
||||
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
|
||||
let decoder_combo = setting_combo(ctx, scope, "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.
|
||||
@@ -318,7 +440,7 @@ pub(crate) fn settings_page(
|
||||
.position(|n| *n == s.adapter)
|
||||
.map_or(0, |i| i + 1);
|
||||
let gpus = gpus.clone();
|
||||
setting_combo(ctx, "GPU", names, current, move |s, i| {
|
||||
setting_combo(ctx, scope, "GPU", names, current, move |s, i| {
|
||||
s.adapter = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -327,7 +449,7 @@ pub(crate) fn settings_page(
|
||||
})
|
||||
});
|
||||
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
||||
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
||||
let codec_combo = setting_combo(ctx, scope, "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
|
||||
@@ -343,9 +465,13 @@ pub(crate) fn settings_page(
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
|
||||
s.hdr_enabled = on
|
||||
});
|
||||
let hdr_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"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;
|
||||
@@ -394,23 +520,27 @@ 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, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||
let pad_combo = setting_combo(ctx, scope, "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, "Touch input", touch_names, touch_i, |s, i| {
|
||||
let touch_combo = setting_combo(ctx, scope, "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, "Mouse input", mouse_names, mouse_i, |s, i| {
|
||||
let mouse_combo = setting_combo(ctx, scope, "Mouse input", mouse_names, mouse_i, |s, i| {
|
||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
||||
});
|
||||
let invert_scroll_toggle =
|
||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
});
|
||||
let invert_scroll_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"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,
|
||||
@@ -418,20 +548,28 @@ 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, "Audio channels", ac_names, ac_i, |s, i| {
|
||||
let channels_combo = setting_combo(ctx, scope, "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, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||
});
|
||||
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 licenses_button = {
|
||||
let ss = set_screen.clone();
|
||||
@@ -439,6 +577,7 @@ 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,
|
||||
@@ -506,9 +645,12 @@ 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 \
|
||||
@@ -578,21 +720,28 @@ 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.
|
||||
described(
|
||||
// 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(
|
||||
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.",
|
||||
),
|
||||
described(
|
||||
)
|
||||
}),
|
||||
Some(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."),
|
||||
),
|
||||
),
|
||||
@@ -626,19 +775,23 @@ pub(crate) fn settings_page(
|
||||
_ => {
|
||||
let mut out = group(
|
||||
Some("Session"),
|
||||
vec![
|
||||
described(
|
||||
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(
|
||||
@@ -651,13 +804,18 @@ 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"),
|
||||
vec![described(
|
||||
if profile_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
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)
|
||||
@@ -698,6 +856,55 @@ 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)
|
||||
@@ -706,6 +913,7 @@ 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
|
||||
@@ -717,12 +925,74 @@ 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(
|
||||
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
|
||||
.margin(edges(24.0, 20.0, 28.0, 40.0)),
|
||||
// ⚠️ 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)),
|
||||
)
|
||||
.opacity(progress)
|
||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||
NavigationView::new(items, content)
|
||||
// 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)
|
||||
.pane_title("Settings")
|
||||
.selected_tag(section)
|
||||
.on_selection_changed({
|
||||
@@ -734,6 +1004,9 @@ pub(crate) fn settings_page(
|
||||
.on_back_requested({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Hosts)
|
||||
})
|
||||
.into()
|
||||
});
|
||||
match confirm {
|
||||
Some(dialog) => vstack(vec![nav.into(), dialog]).into(),
|
||||
None => nav.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,109 @@ 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.
|
||||
@@ -373,6 +476,68 @@ 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]
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
//! rings are retired, not destroyed — the presenter may still hold their views (see
|
||||
//! [`RETIRE_HANDOVERS`]).
|
||||
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is `pyrowave-sys` C-API and ash/Vulkan compute calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use crate::video::{ColorDesc, VulkanDecodeDevice};
|
||||
use anyhow::{bail, Context as _, Result};
|
||||
use ash::vk;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! FFmpeg Vulkan Video decode over the presenter's own VkDevice (zero-copy VkImage).
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav + ash calls on the presenter's VkDevice almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
#![allow(clippy::unnecessary_cast)]
|
||||
|
||||
use crate::video::{
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) hwcontext calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
//! 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,6 +21,13 @@
|
||||
//! 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::{
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
||||
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
|
||||
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed AVFrame/AVBuffer
|
||||
// pointers almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per
|
||||
// call that could only restate the signature. Clearing this file means DELETING the markers that
|
||||
// carry no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
|
||||
// everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
//! 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,6 +1,13 @@
|
||||
//! 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,6 +10,12 @@
|
||||
//! 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,6 +5,13 @@
|
||||
//! `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,6 +43,12 @@
|
||||
//! 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)]
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@
|
||||
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
||||
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
||||
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed hwcontext pointers
|
||||
// almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call
|
||||
// that could only restate the signature. Clearing this file means DELETING the markers that carry
|
||||
// no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
|
||||
// everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
//! 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,6 +22,13 @@
|
||||
//! 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,6 +134,9 @@ 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)");
|
||||
@@ -256,12 +259,17 @@ 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) })
|
||||
}
|
||||
|
||||
@@ -365,8 +373,12 @@ 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;
|
||||
@@ -375,7 +387,11 @@ 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 InjectSyntheticPointerInput(dev, frame) {
|
||||
match unsafe { 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.
|
||||
InjectSyntheticPointerInput(dev, frame)
|
||||
unsafe { InjectSyntheticPointerInput(dev, frame) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
#![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};
|
||||
|
||||
@@ -63,6 +63,9 @@ 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",
|
||||
] }
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
//! 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};
|
||||
@@ -25,11 +28,18 @@ 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) => return Ok(status),
|
||||
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);
|
||||
}
|
||||
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));
|
||||
@@ -49,12 +59,20 @@ 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()? {
|
||||
// Exited: `wait_with_output` now only drains already-buffered pipes.
|
||||
Some(_) => return child.wait_with_output(),
|
||||
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();
|
||||
}
|
||||
None if Instant::now() >= deadline => {
|
||||
tree.terminate();
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(timed_out(cmd, budget));
|
||||
@@ -93,6 +111,136 @@ 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
|
||||
@@ -172,4 +320,78 @@ 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,6 +221,42 @@ 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
|
||||
@@ -975,7 +1011,7 @@ impl VirtualDisplayManager {
|
||||
"re-asserting exclusive topology"
|
||||
),
|
||||
}
|
||||
let _ = isolate_displays_ccd(&keep);
|
||||
let _ = isolate_displays_ccd_seam(&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
|
||||
@@ -1198,7 +1234,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(&keep);
|
||||
inner.group.ccd_saved = isolate_displays_ccd_seam(&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
|
||||
@@ -1223,7 +1259,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(&keep);
|
||||
let snap = isolate_displays_ccd_seam(&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
|
||||
@@ -1643,7 +1679,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(&keep);
|
||||
let _ = isolate_displays_ccd_seam(&keep);
|
||||
}
|
||||
Topology::Primary => {
|
||||
// Make the new target primary again (its predecessor held primary), preserving the
|
||||
@@ -1740,7 +1776,7 @@ impl VirtualDisplayManager {
|
||||
// the group keeps the first member's.
|
||||
ShrinkAction::Reisolate => {
|
||||
let keep = inner.target_ids();
|
||||
let _ = isolate_displays_ccd(&keep);
|
||||
let _ = isolate_displays_ccd_seam(&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,6 +924,223 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// §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() {
|
||||
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));
|
||||
println!(
|
||||
"after member 1 (isolate INJECTED to fail): {:?}",
|
||||
active_targets()
|
||||
);
|
||||
|
||||
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. The first \
|
||||
member's isolate failed, so the group held no restore snapshot; the second member's \
|
||||
isolate deactivated the physicals and its snapshot was discarded (sweep §5 3.2). \
|
||||
Active targets now: {:?}",
|
||||
active_targets()
|
||||
);
|
||||
}
|
||||
|
||||
/// The 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()
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -133,13 +133,15 @@ 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.
|
||||
GetUserObjectInformationW(
|
||||
HANDLE(h.0),
|
||||
UOI_NAME,
|
||||
Some(name.as_mut_ptr().cast()),
|
||||
(name.len() * 2) as u32,
|
||||
Some(&mut needed),
|
||||
)
|
||||
unsafe {
|
||||
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,6 +9,12 @@
|
||||
//! - [`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,6 +924,18 @@ 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`
|
||||
@@ -988,6 +1000,37 @@ pub struct TargetInventory {
|
||||
pub monitor_device_path: String,
|
||||
}
|
||||
|
||||
/// 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:
|
||||
/// new/unknown/indirect technologies read as non-external, so a co-installed third-party virtual
|
||||
/// display can never be mistaken for a physical suspect (same precision rule as `monitor_devnode`).
|
||||
@@ -1084,7 +1127,14 @@ pub fn target_inventory() -> Vec<TargetInventory> {
|
||||
if unsafe { DisplayConfigGetDeviceInfo(&mut req.header) } != 0 {
|
||||
continue; // target with no queryable monitor — nothing to attribute to
|
||||
}
|
||||
let (external_physical, tech) = output_tech_class(req.outputTechnology);
|
||||
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.
|
||||
if is_our_virtual_display(&monitor_device_path) {
|
||||
external_physical = false;
|
||||
tech = "punktfunk-virtual";
|
||||
}
|
||||
out.push(TargetInventory {
|
||||
target_id: t.id,
|
||||
active: active.contains(&key),
|
||||
@@ -1092,7 +1142,7 @@ pub fn target_inventory() -> Vec<TargetInventory> {
|
||||
internal_panel: tech == "internal-panel",
|
||||
tech,
|
||||
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
|
||||
monitor_device_path: utf16z_str(&req.monitorDevicePath),
|
||||
monitor_device_path,
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -1115,6 +1165,20 @@ 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
|
||||
@@ -1501,33 +1565,11 @@ 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> {
|
||||
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);
|
||||
// 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 saved = (paths.clone(), modes.clone());
|
||||
|
||||
// The virtual output's source width, to lay the other displays out to its right.
|
||||
@@ -1651,3 +1693,100 @@ 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,6 +17,12 @@
|
||||
// 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;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
|
||||
// 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;
|
||||
@@ -62,42 +67,53 @@ 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).
|
||||
let session = WTSGetActiveConsoleSessionId();
|
||||
// SAFETY: takes no arguments and returns the console session id by value.
|
||||
let session = unsafe { WTSGetActiveConsoleSessionId() };
|
||||
if session == 0xFFFF_FFFF {
|
||||
bail!("no active console session (no interactive user is logged on)");
|
||||
}
|
||||
let mut user_token = HANDLE::default();
|
||||
WTSQueryUserToken(session, &mut user_token)
|
||||
// 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) }
|
||||
.context("WTSQueryUserToken (host must be SYSTEM; needs a logged-on interactive user)")?;
|
||||
|
||||
// A primary token for CreateProcessAsUserW.
|
||||
let mut primary = HANDLE::default();
|
||||
let dup = DuplicateTokenEx(
|
||||
user_token,
|
||||
TOKEN_ALL_ACCESS,
|
||||
None,
|
||||
SecurityImpersonation,
|
||||
TokenPrimary,
|
||||
&mut primary,
|
||||
);
|
||||
let _ = CloseHandle(user_token);
|
||||
// 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) };
|
||||
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();
|
||||
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
|
||||
let merged_env = merged_env_block(env_block as *const u16);
|
||||
// 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) };
|
||||
if !env_block.is_null() {
|
||||
let _ = DestroyEnvironmentBlock(env_block);
|
||||
// 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) };
|
||||
}
|
||||
|
||||
// The game/launcher must appear on the interactive desktop the host is capturing.
|
||||
@@ -122,26 +138,37 @@ unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
|
||||
};
|
||||
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
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);
|
||||
// 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) };
|
||||
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.
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
// 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);
|
||||
}
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
@@ -163,15 +190,24 @@ pub(crate) unsafe fn merged_env_block(user_block: *const u16) -> Vec<u16> {
|
||||
let mut p = user_block;
|
||||
loop {
|
||||
let mut len = 0isize;
|
||||
while *p.offset(len) != 0 {
|
||||
// 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 {
|
||||
len += 1;
|
||||
}
|
||||
if len == 0 {
|
||||
break; // the trailing empty string = end of block
|
||||
}
|
||||
let slice = std::slice::from_raw_parts(p, len as usize);
|
||||
// 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) };
|
||||
entries.push(String::from_utf16_lossy(slice));
|
||||
p = p.offset(len + 1);
|
||||
// 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) };
|
||||
}
|
||||
}
|
||||
// Overlay "our" settings — PUNKTFUNK_* and RUST_LOG — dropping whatever the target block had.
|
||||
|
||||
@@ -543,44 +543,63 @@ 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();
|
||||
OpenProcessToken(
|
||||
GetCurrentProcess(),
|
||||
TOKEN_DUPLICATE
|
||||
| TOKEN_QUERY
|
||||
| TOKEN_ASSIGN_PRIMARY
|
||||
| TOKEN_ADJUST_DEFAULT
|
||||
| TOKEN_ADJUST_SESSIONID,
|
||||
&mut proc_token,
|
||||
)
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
.context("OpenProcessToken (service must run as SYSTEM)")?;
|
||||
|
||||
let mut primary = HANDLE::default();
|
||||
let dup = DuplicateTokenEx(
|
||||
proc_token,
|
||||
TOKEN_ALL_ACCESS,
|
||||
None,
|
||||
SecurityImpersonation,
|
||||
TokenPrimary,
|
||||
&mut primary,
|
||||
);
|
||||
let _ = CloseHandle(proc_token);
|
||||
// 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) };
|
||||
dup.context("DuplicateTokenEx(TokenPrimary)")?;
|
||||
|
||||
SetTokenInformation(
|
||||
primary,
|
||||
TokenSessionId,
|
||||
&session_id as *const u32 as *const c_void,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
.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();
|
||||
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
|
||||
let merged = crate::interactive::merged_env_block(env_block as *const u16);
|
||||
// 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) };
|
||||
if !env_block.is_null() {
|
||||
let _ = DestroyEnvironmentBlock(env_block);
|
||||
// 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) };
|
||||
}
|
||||
|
||||
// 3) Redirect the host's stdout+stderr to host.log (inheritable handle). The previous child has
|
||||
@@ -604,32 +623,49 @@ unsafe fn spawn_host(
|
||||
let cwd = (!workdir.is_empty()).then_some(PCWSTR(workdir.as_ptr()));
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
|
||||
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: `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 _ = CloseHandle(log); // the child owns its inherited copy
|
||||
let _ = CloseHandle(primary);
|
||||
// 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);
|
||||
}
|
||||
created.context("CreateProcessAsUserW(host)")?;
|
||||
|
||||
// Best-effort: keep the host inside the kill-on-close job.
|
||||
let _ = AssignProcessToJobObject(job, pi.hProcess);
|
||||
// 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) };
|
||||
|
||||
// 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: OwnedHandle::from_raw_handle(pi.hProcess.0),
|
||||
_thread: OwnedHandle::from_raw_handle(pi.hThread.0),
|
||||
process,
|
||||
_thread: thread,
|
||||
pid: pi.dwProcessId,
|
||||
})
|
||||
}
|
||||
|
||||
+13
-3
@@ -126,9 +126,14 @@ 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.
|
||||
depends=('ffmpeg' 'pipewire' 'pipewire-pulse' 'wireplumber' 'opus' 'libei'
|
||||
# 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'
|
||||
'mesa' 'libglvnd' 'libxkbcommon' 'wayland')
|
||||
optdepends=('nvidia-utils: NVENC hardware encode + GPU EGL/CUDA zero-copy (REQUIRED to encode on NVIDIA)'
|
||||
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)'
|
||||
'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)'
|
||||
@@ -227,7 +232,12 @@ 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.
|
||||
depends=('gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wireplumber' 'pipewire-pulse'
|
||||
# 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'
|
||||
'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,7 +107,8 @@ NVENC/EGL come from the NVIDIA driver: `sudo pacman -S --needed nvidia-utils`. A
|
||||
| Need | Arch package |
|
||||
|------|--------------|
|
||||
| FFmpeg + NVENC | `ffmpeg` (NVENC built in) |
|
||||
| PipeWire + Pulse + session mgr | `pipewire` `pipewire-pulse` `wireplumber` |
|
||||
| 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`)* |
|
||||
| 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,7 +106,10 @@ 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).
|
||||
RECOMMENDS="pipewire, wireplumber, pipewire-pulse"
|
||||
# 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"
|
||||
|
||||
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
|
||||
|
||||
|
||||
@@ -104,8 +104,13 @@ 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
|
||||
|
||||
@@ -111,6 +111,14 @@ 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"
|
||||
|
||||
Reference in New Issue
Block a user