Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8099e0f5b | ||
|
|
674b16d8eb | ||
|
|
d801cb72f2 | ||
|
|
ace01f06a2 | ||
|
|
e4ec4cec31 | ||
|
|
230d253b06 | ||
|
|
def215ae8e | ||
|
|
dfcc530ee7 | ||
|
|
2b81bd286f |
@@ -417,7 +417,7 @@ jobs:
|
||||
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
|
||||
# actions/checkout needs all three (see the web job).
|
||||
- name: Install git + node + CA certs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
|
||||
- uses: actions/checkout@v4
|
||||
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
|
||||
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
|
||||
|
||||
@@ -61,3 +61,9 @@ jobs:
|
||||
punktfunk-host detect-conflicts
|
||||
- name: Re-running is a no-op install
|
||||
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
|
||||
- name: --uninstall takes the packages and the repo off again
|
||||
run: |
|
||||
sh scripts/install.sh --yes --uninstall
|
||||
! command -v punktfunk-host
|
||||
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
|
||||
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
|
||||
|
||||
@@ -111,3 +111,20 @@ jobs:
|
||||
name: punktfunk-linux-client-screenshots
|
||||
path: clients/linux/screenshots
|
||||
retention-days: 30
|
||||
|
||||
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
|
||||
# blocked reusing these shots for the docs. Publish them to the generic package registry
|
||||
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
|
||||
# public repo:
|
||||
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
|
||||
- name: Publish screenshots to the package registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
|
||||
for f in clients/linux/screenshots/*.png; do
|
||||
name=$(basename "$f")
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
|
||||
echo "published $BASE/$name"
|
||||
done
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
|
||||
| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag |
|
||||
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ `packaging/gamescope/PKGBUILD` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.5** | cut — `sdk/src/config.ts` and `runner-cli.ts` carry the `mgmt-endpoint` fix below, and plugins resolve the SDK from the registry, so it could not reach them until it shipped |
|
||||
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
|
||||
|
||||
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
|
||||
|
||||
+18
-5
@@ -370,15 +370,26 @@ from the config directory for a true factory reset."
|
||||
.unwrap_or(DISCOVER_DEFAULT_SECS)
|
||||
.min(DISCOVER_MAX_SECS);
|
||||
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
|
||||
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
|
||||
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
|
||||
// a write from a read-only verb, and one that races the `hosts list` a caller is very
|
||||
// likely running at the same moment (the Decky panel issues both together).
|
||||
// `read`, not `load`: this verb never hands a record's id back, so it has no business
|
||||
// MINTING one. `load` would mint ids for a pre-mint store and save them, racing the
|
||||
// `hosts list` a caller is very likely running at the same moment (the Decky panel issues
|
||||
// both together) — after which the ids one of them already handed out no longer resolve.
|
||||
let known = KnownHosts::read();
|
||||
let rows: Vec<(
|
||||
&pf_client_core::discovery::DiscoveredHost,
|
||||
Option<&KnownHost>,
|
||||
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
|
||||
// The one write this verb does make, and why it doesn't contradict the above: an advert
|
||||
// is the only place a host's wake MAC is ever published, and this verb is the only one
|
||||
// the Decky panel runs that ever sees one. Without it a Deck in Gaming Mode never learns
|
||||
// a MAC at all and Wake-on-LAN cannot fire, with nothing to show for it (#322).
|
||||
// `learn_from_advert` mints nothing either, and writes only when an advert genuinely
|
||||
// taught the record something new — so a steady-state panel refresh touches no disk.
|
||||
for (d, saved) in &rows {
|
||||
if let Some(k) = saved {
|
||||
trust::learn_from_advert(&k.fp_hex, &k.addr, k.port, &d.mac, &d.os, d.mgmt_port);
|
||||
}
|
||||
}
|
||||
if has(args, "--json") {
|
||||
let hosts: Vec<serde_json::Value> = rows
|
||||
.iter()
|
||||
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
|
||||
};
|
||||
let host = &known.hosts[i];
|
||||
if host.mac.is_empty() {
|
||||
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name);
|
||||
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
|
||||
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
|
||||
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert", host.name);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if !has(args, "--wait") {
|
||||
|
||||
@@ -1087,33 +1087,20 @@ impl HostsPage {
|
||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep.
|
||||
let online = self.adverts.values().any(|a| matches(k, a))
|
||||
|| self.probed.get(&saved_key(k)).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.mac.is_empty())
|
||||
{
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the icon then survives the host going offline.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.os.is_empty())
|
||||
{
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — and this one is not cosmetic: without it a host
|
||||
// that moved off 47990 loses its library the moment mDNS is unavailable, because
|
||||
// the advert was the only place the real port ever lived.
|
||||
if let Some(a) = self
|
||||
.adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && a.mgmt_port.is_some())
|
||||
{
|
||||
if let Some(p) = a.mgmt_port {
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
}
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s),
|
||||
// its OS chain (so the icon survives it going offline), and its management port
|
||||
// — the last one not cosmetic, since a host that moved off 47990 loses its
|
||||
// library the moment mDNS is unavailable and the advert is the only place the
|
||||
// real port ever lived.
|
||||
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
saved.push_back(HostCard {
|
||||
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
|
||||
|
||||
@@ -800,11 +800,21 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
// Write the advertised mgmt port down while the host is visible, so this console
|
||||
// keeps working against a moved port once it is not. No-op (and no disk write)
|
||||
// Write down everything the advert teaches while the host is visible: the mgmt
|
||||
// port (so this console keeps working against a moved one once it is not), the
|
||||
// OS chain, and the wake MAC — which matters most here, because this console and
|
||||
// the Decky panel are the only surfaces a Deck in Gaming Mode ever runs, and a
|
||||
// record that never learned a MAC can never be woken. No-op (and no disk write)
|
||||
// when unchanged, so this is safe on every refresh tick.
|
||||
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
|
||||
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
|
||||
if let Some(a) = advert {
|
||||
pf_client_core::trust::learn_from_advert(
|
||||
&h.fp_hex,
|
||||
&h.addr,
|
||||
h.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
|
||||
@@ -700,31 +700,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.iter()
|
||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
||||
// it once it sleeps (no-op / no disk write when unchanged).
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& !h.mac.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
// Same for its OS chain — the tile's mark then survives the host going offline.
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
|
||||
}
|
||||
// Same for its management port — load-bearing, unlike the two above: a host moved off
|
||||
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
|
||||
if let Some(p) = hosts
|
||||
// Learn what this host's live advert teaches while it's online: its wake MAC(s) (so we
|
||||
// can wake it once it sleeps), its OS chain (so the tile's mark survives it going
|
||||
// offline), and its management port — the last load-bearing rather than cosmetic, as
|
||||
// a host moved off 47990 loses its library entirely once mDNS is gone unless we write
|
||||
// the port down. No-op, and no disk write, when unchanged.
|
||||
if let Some(a) = hosts
|
||||
.iter()
|
||||
.find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& h.mgmt_port.is_some()
|
||||
})
|
||||
.and_then(|h| h.mgmt_port)
|
||||
.find(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
{
|
||||
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
|
||||
crate::trust::learn_from_advert(
|
||||
&k.fp_hex,
|
||||
&k.addr,
|
||||
k.port,
|
||||
&a.mac,
|
||||
&a.os,
|
||||
a.mgmt_port,
|
||||
);
|
||||
}
|
||||
let can_wake = !online && !k.mac.is_empty();
|
||||
let menu = {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct DiscoveredHost {
|
||||
/// persisted like `mac`. Empty if absent (older host).
|
||||
pub os: String,
|
||||
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
|
||||
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
|
||||
/// Persisted like `mac` (`trust::learn_from_advert`), and load-bearing rather than cosmetic:
|
||||
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
|
||||
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
|
||||
pub mgmt_port: Option<u16>,
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
//! still load via a serde alias in core.
|
||||
|
||||
pub use pf_client_core::trust::{
|
||||
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
|
||||
parse_hex32, KnownHost, KnownHosts, Settings,
|
||||
hex, learn_from_advert, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
|
||||
KnownHosts, Settings,
|
||||
};
|
||||
|
||||
@@ -675,10 +675,10 @@ pub fn forget_placeholder(addr: &str, port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
|
||||
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
|
||||
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
|
||||
/// data the live host advertised, purely because it came earlier in the file.
|
||||
/// The record an advert's lesson should land on: the fingerprint match if there is one, else
|
||||
/// whatever the address resolves to. Fingerprint FIRST — a single pass that took "either" would
|
||||
/// hand a stale record at the same address the data the live host advertised, purely because it
|
||||
/// came earlier in the file.
|
||||
fn learn_target<'a>(
|
||||
known: &'a mut KnownHosts,
|
||||
fp_hex: &str,
|
||||
@@ -692,61 +692,62 @@ fn learn_target<'a>(
|
||||
known.hosts.get_mut(i)
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
if mac.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mac == mac {
|
||||
return;
|
||||
}
|
||||
h.mac = mac.to_vec();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` TXT), matched
|
||||
/// like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
|
||||
if os.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.os == os {
|
||||
return;
|
||||
}
|
||||
h.os = os.to_string();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
|
||||
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
|
||||
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
|
||||
/// Copy everything an advert can teach onto a saved record — wake MAC(s), OS-identity chain,
|
||||
/// management port — and report whether anything actually moved, so the caller writes only when
|
||||
/// there is something to write. Pure (no disk, no clock), which is what makes it testable.
|
||||
///
|
||||
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
|
||||
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
|
||||
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
|
||||
if mgmt_port == 0 {
|
||||
return;
|
||||
/// A field the advert does not carry is left alone, never cleared: an older host simply omits the
|
||||
/// TXT, and forgetting a MAC already learned would cost the user their wake.
|
||||
fn apply_advert(h: &mut KnownHost, mac: &[String], os: &str, mgmt_port: Option<u16>) -> bool {
|
||||
let mut changed = false;
|
||||
if !mac.is_empty() && h.mac != mac {
|
||||
h.mac = mac.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
if !os.is_empty() && h.os != os {
|
||||
h.os = os.to_string();
|
||||
changed = true;
|
||||
}
|
||||
// 0 is how "not advertised" reaches us from a caller whose own type has no `Option`.
|
||||
if mgmt_port.is_some_and(|p| p != 0 && h.mgmt_port != Some(p)) {
|
||||
h.mgmt_port = mgmt_port;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Write down everything a live advert teaches the saved record it matched — wake MAC(s), OS
|
||||
/// chain, management port — matched by fingerprint or address. No-op, and no disk write, when
|
||||
/// the record already says all three, so a surface can call this on every discovery tick.
|
||||
///
|
||||
/// ONE call rather than three. Each field used to be learned by its own function, which meant
|
||||
/// every front-end had to remember all three, and only the two desktop hosts pages ever did:
|
||||
/// the console home and the headless CLI learned the management port alone. On a Steam Deck,
|
||||
/// whose Gaming Mode runs nothing but those two, that left every saved host with no MAC forever
|
||||
/// — and every wake gate in the codebase reads `!mac.is_empty()` against this record, so
|
||||
/// Wake-on-LAN there could not fire at all, with no error to show for it (#322).
|
||||
///
|
||||
/// [`KnownHosts::read`], not [`KnownHosts::load`]: `punktfunk discover` calls this, and that verb
|
||||
/// is deliberately not an id-minter (see [`KnownHosts::read`] for the race that avoids). Learning
|
||||
/// a MAC is no reason to become one.
|
||||
///
|
||||
/// Takes the three learned fields rather than a `DiscoveredHost` because there are two of those
|
||||
/// — core's and the WinUI shell's verbatim port — and this has to serve both.
|
||||
pub fn learn_from_advert(
|
||||
fp_hex: &str,
|
||||
addr: &str,
|
||||
port: u16,
|
||||
mac: &[String],
|
||||
os: &str,
|
||||
mgmt_port: Option<u16>,
|
||||
) {
|
||||
let mut known = KnownHosts::read();
|
||||
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
|
||||
return;
|
||||
};
|
||||
if h.mgmt_port == Some(mgmt_port) {
|
||||
return;
|
||||
if apply_advert(h, mac, os, mgmt_port) {
|
||||
let _ = known.save();
|
||||
}
|
||||
h.mgmt_port = Some(mgmt_port);
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
||||
@@ -785,7 +786,7 @@ pub fn touch_last_used(fp_hex: &str) {
|
||||
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
|
||||
/// fingerprint alone — the identity a just-connected client is certain of.
|
||||
///
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
|
||||
/// This is the mDNS-free path, and the one that matters most: [`learn_from_advert`] can only fire
|
||||
/// where an advert is visible, whereas this fires on any successful connect, including a host
|
||||
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
|
||||
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
|
||||
@@ -2293,6 +2294,33 @@ mod tests {
|
||||
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
|
||||
}
|
||||
|
||||
/// What an advert carries lands on the record; what it omits is left alone; and a repeat of
|
||||
/// the same advert reports no change — which is what lets every surface call this on every
|
||||
/// discovery tick without churning the store.
|
||||
#[test]
|
||||
fn apply_advert_learns_what_it_carries_and_keeps_what_it_omits() {
|
||||
let mut h = KnownHost::default();
|
||||
let mac = vec!["aa:bb:cc:dd:ee:ff".to_string()];
|
||||
assert!(apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// The same advert a tick later: nothing moved, so there is nothing to persist.
|
||||
assert!(!apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
|
||||
// An older host advertises none of the three. Clearing a learned MAC here is exactly what
|
||||
// would cost the user their wake, so an absent field must never overwrite a known one.
|
||||
assert!(!apply_advert(&mut h, &[], "", None));
|
||||
assert_eq!(h.mac, mac);
|
||||
assert_eq!(h.os, "linux/arch");
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// 0 is how "not advertised" reaches us from a consumer that has no Option — not a port.
|
||||
assert!(!apply_advert(&mut h, &[], "", Some(0)));
|
||||
assert_eq!(h.mgmt_port, Some(47991));
|
||||
// A host that genuinely moved: the new value wins.
|
||||
assert!(apply_advert(&mut h, &[], "", Some(47992)));
|
||||
assert_eq!(h.mgmt_port, Some(47992));
|
||||
}
|
||||
|
||||
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
|
||||
/// presentation state, so a dangling one is never an error surface.
|
||||
#[test]
|
||||
|
||||
@@ -30,13 +30,13 @@ console, and tells you how to pair:
|
||||
|
||||
<Installer />
|
||||
|
||||
Prefer to read what runs first (it's plain `sh`, ~350 lines):
|
||||
Prefer to read what runs first (it's plain `sh`, ~450 lines):
|
||||
|
||||
<Installer inspect />
|
||||
|
||||
It asks before anything optional (Moonlight compat, the shared clipboard, the `punktfunk` group,
|
||||
starting at boot) and every answer has a default, so `sh install.sh --yes` — or piping it with no
|
||||
terminal — runs unattended; `--channel canary`, `--mgmt-port`, `--no-start` and the environment
|
||||
terminal — runs unattended; `--channel canary`, `--mgmt-port`, `--no-start`, `--uninstall` and the environment
|
||||
twins (`PUNKTFUNK_INSTALL_YES`, `PUNKTFUNK_INSTALL_CHANNEL`, `PUNKTFUNK_INSTALL_GAMESTREAM`,
|
||||
`PUNKTFUNK_INSTALL_CLIPBOARD`, `PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP`, `PUNKTFUNK_INSTALL_LINGER`,
|
||||
`PUNKTFUNK_INSTALL_MGMT_PORT`) are listed by `--help`. It covers Ubuntu/Debian, Fedora, Arch-family
|
||||
|
||||
@@ -36,15 +36,15 @@ stream on a host this device already trusts.
|
||||
|
||||
## Choosing access when you admit a device
|
||||
|
||||
Approving and deciding what the device may do are one dialog. Alongside the name:
|
||||
Approving and deciding what the device may do are one dialog:
|
||||
|
||||
- **Access level** — **Full control**, **Controller only**, or **View only** (an **Advanced**
|
||||
expander has the individual toggles — [Access levels](/docs/access-levels)).
|
||||
- **Expires** — **Forever**, or 1 h / 4 h / 8 h / custom.
|
||||

|
||||
|
||||
Defaults are *Full control · Forever* — right for your own new laptop. For a friend's device there
|
||||
is a one-click **Approve as guest**: Controller only, for 4 hours, then it expires on its own. The
|
||||
same two controls sit on the **Pair a device** card, and apply to whichever device completes the PIN.
|
||||
The levels are **Full control**, **Controller only** and **View only** (**Advanced** opens the
|
||||
individual toggles — [Access levels](/docs/access-levels)); expiry is **Never** or 1 h / 4 h / 8 h /
|
||||
custom. The defaults are right for your own new laptop; **Approve as guest** is for a friend's
|
||||
device — Controller only, for 4 hours, then it expires on its own. The same two controls sit on the
|
||||
**Pair a device** card, and apply to whichever device completes the PIN.
|
||||
|
||||
## Managing paired devices
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ certificate is the host's own, so your browser warns once — continue) and log
|
||||
(SteamOS: the install script printed it; it's in `~/.config/punktfunk/web.env`).
|
||||
- **Windows:** the installer showed it on its last page.
|
||||
|
||||

|
||||
|
||||
Lost it? [Forgot your password](/docs/forgot-password). Everything else about the console:
|
||||
[The Web Console](/docs/web-console).
|
||||
|
||||
@@ -54,11 +56,16 @@ On the device you want to stream *to*, install the app — [Install a Client](/d
|
||||
has the link for every device (Mac, iPhone/iPad/Apple TV, Linux, Windows, Android, Steam Deck), and
|
||||
any Moonlight client works too once you [turn GameStream on](/docs/moonlight).
|
||||
|
||||
Open the app: your host is already in the list. Select it and **connect**. Back in the console, the
|
||||
device appears under **Pairing → Waiting for approval** — click **Approve** and it's in, no PIN to
|
||||
type. (Prefer a PIN? Click **Pair a device** in the console and type the 4-digit code into the
|
||||
client.) Pairing happens once; the device reconnects on its own from then on.
|
||||
Details: [Pairing & Trust](/docs/pairing).
|
||||
Open the app: your host is already in the list.
|
||||
|
||||

|
||||
|
||||
Select it and **connect**, then click **Approve**
|
||||
next to the device in the console's **Pairing** page — no PIN to type. (Prefer a PIN? **Pair a device**
|
||||
shows a 4-digit code to type into the client.) Pairing happens once; the device reconnects on its
|
||||
own from then on. Details: [Pairing & Trust](/docs/pairing).
|
||||
|
||||

|
||||
|
||||
## 5. Stream
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ Jump to what you installed:
|
||||
|
||||
## Linux hosts
|
||||
|
||||
If you installed with the guided script, `sh install.sh --uninstall` runs this section and the
|
||||
package removal for your family in one go (fetch it again with `curl -fsSLO https://punktfunk.unom.io/install.sh`);
|
||||
what it leaves behind is the same list below.
|
||||
|
||||
### Stop the services first
|
||||
|
||||
The Linux packages ship systemd **user** units, and `systemctl --user enable` writes symlinks into
|
||||
|
||||
@@ -112,9 +112,10 @@ appears under **Waiting for approval** instead; approving it pairs it immediatel
|
||||
|
||||
Nine destinations in the sidebar (a **More** tab on a phone holds the last five):
|
||||
|
||||
- **Dashboard** — live status: whether video and audio are streaming, the active sessions with
|
||||
their codec, resolution, frame rate and bitrate, which games are running, and how many clients
|
||||
are paired. Buttons stop a session or ask the encoder for a fresh keyframe.
|
||||

|
||||
|
||||
- **Dashboard** — the live status above: what's streaming, which games run, how many clients are
|
||||
paired. Buttons stop a session or ask the encoder for a fresh keyframe.
|
||||
- **Host** — this host's identity (hostname, OS, local IP, version, unique id), the codecs it
|
||||
advertises, its ports, the **Updates** card (see [Updating the Host](/docs/updating)), the
|
||||
**GPUs** card — Automatic, or prefer one GPU for capture and encode, applied to the next session
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -19,7 +19,8 @@ release is born complete and the announcement always has something to say.
|
||||
catches renamed knobs and dead links, not a stale sentence). If an install command, repo URL or
|
||||
port changed, `data/platforms.json` changed with it — then run `bun run sync-platforms` in
|
||||
punktfunk-website and commit, because its download page vendors that file and only refreshes
|
||||
when someone does.
|
||||
when someone does. Same pass for the website itself: does the landing page still describe what
|
||||
this release ships (features, platforms, the blog post the CMS expects per release)?
|
||||
2. **Tag & push.** `git tag -a vX.Y.Z … && git push origin vX.Y.Z` fans out to the build
|
||||
workflows. Whichever one wins the create race seeds the release body from this file
|
||||
(`scripts/ci/gitea-release.sh` → `ensure_release`, and its PowerShell twin). The release page
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
# states — every `install` line of an apt/pacman/dnf/sysext host platform must appear in
|
||||
# the script verbatim (it edits channel/group into the string at run time, never the
|
||||
# literal), and the script must parse under sh.
|
||||
# 7. The installer under --dry-run against faked os-release files detects every family it claims
|
||||
# to (and --uninstall prints each family's removal) — the committed half of the manual
|
||||
# 16-file matrix PR #345 was verified with. Needs curl on PATH (the script's own prerequisite).
|
||||
#
|
||||
# Textual gates, so textual limits: gate 2/3 match token spelling, not env reads — a var name in
|
||||
# a code comment counts as "exists", and a quoted constant that isn't an env var counts toward
|
||||
@@ -123,4 +126,35 @@ elif command -v node >/dev/null 2>&1; then
|
||||
node -e "$installer_check" || fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- gate 7: installer detection matrix (--dry-run)
|
||||
# Faked os-release files through the real script, nothing executed: each family must be detected
|
||||
# and print its own package-manager line, both for the install and for --uninstall; the unsupported
|
||||
# ones must stop with their pointer. A fix to the installer adds its case here.
|
||||
osr=$(mktemp -d)
|
||||
installer_case() { # name os-release-body expected-substring [extra args...]
|
||||
name=$1; printf '%b' "$2" > "$osr/$name"; want=$3; shift 3
|
||||
out=$(PUNKTFUNK_INSTALL_OS_RELEASE="$osr/$name" sh scripts/install.sh --dry-run --yes --no-start "$@" 2>&1)
|
||||
case "$out" in *"$want"*) ;; *)
|
||||
echo "::error::scripts/install.sh --dry-run $* on a fake $name os-release did not print '$want':"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
fail=1 ;;
|
||||
esac
|
||||
}
|
||||
installer_case debian 'ID=debian\nVERSION_ID=13\n' 'sudo apt install -y punktfunk-host'
|
||||
installer_case ubuntu 'ID=ubuntu\nID_LIKE=debian\nVERSION_ID=26.04\n' 'sudo apt install -y punktfunk-host'
|
||||
installer_case mint22 'ID=linuxmint\nID_LIKE="ubuntu debian"\nVERSION_ID=22.1\n' 'cannot host'
|
||||
installer_case fedora 'ID=fedora\nVERSION_ID=44\n' 'sudo dnf install -y punktfunk'
|
||||
installer_case fedora43 'ID=fedora\nVERSION_ID=43\n' '/rpm/bazzite'
|
||||
installer_case arch 'ID=arch\n' 'sudo pacman -Syu --noconfirm punktfunk-host'
|
||||
installer_case cachyos 'ID=cachyos\nID_LIKE="arch"\n' 'sudo pacman -Syu --noconfirm punktfunk-host'
|
||||
installer_case bazzite 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext.sh install'
|
||||
installer_case nixos 'ID=nixos\n' 'docs/nixos'
|
||||
installer_case steamos 'ID=steamos\nID_LIKE=arch\n' 'docs/steamos-host'
|
||||
installer_case gentoo 'ID=gentoo\n' 'build-from-source'
|
||||
installer_case debian-rm 'ID=debian\nVERSION_ID=13\n' 'sources.list.d/punktfunk.list' --uninstall
|
||||
installer_case fedora-rm 'ID=fedora\nVERSION_ID=44\n' 'yum.repos.d/punktfunk.repo' --uninstall
|
||||
installer_case arch-rm 'ID=arch\n' '/etc/pacman.conf' --uninstall
|
||||
installer_case bazzite-rm 'ID=bazzite\nID_LIKE="fedora"\nVERSION_ID=43\n' 'punktfunk-sysext remove' --uninstall
|
||||
rm -rf "$osr"
|
||||
|
||||
exit "$fail"
|
||||
|
||||
+58
-6
@@ -31,6 +31,7 @@ LINGER=${PUNKTFUNK_INSTALL_LINGER:-} # 1/0, empty = ask (default no)
|
||||
MGMT_PORT=${PUNKTFUNK_INSTALL_MGMT_PORT:-47991} # where the management API moves to on a conflict
|
||||
START=1
|
||||
DRY=${PUNKTFUNK_INSTALL_DRY_RUN:-0}
|
||||
UNINSTALL=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -45,6 +46,7 @@ usage: sh install.sh [options]
|
||||
--linger | --no-linger start the host at boot with nobody logged in (default no)
|
||||
--mgmt-port N port to move the management API to if Sunshine/Apollo holds 47990 (default $MGMT_PORT)
|
||||
--no-start install and configure, but don't enable the services
|
||||
--uninstall stop the services and remove the packages + repo (config stays: $DOCS/uninstall)
|
||||
--dry-run print every command it would run, change nothing
|
||||
-h, --help this text
|
||||
|
||||
@@ -67,6 +69,7 @@ while [ $# -gt 0 ]; do
|
||||
--mgmt-port) shift; MGMT_PORT=${1:-} ;;
|
||||
--mgmt-port=*) MGMT_PORT=${1#*=} ;;
|
||||
--no-start) START=0 ;;
|
||||
--uninstall) UNINSTALL=1 ;;
|
||||
--dry-run) DRY=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
@@ -106,7 +109,10 @@ run() {
|
||||
cmd=$(printf '%s' "$cmd" | sed \
|
||||
-e 's/^sudo apt install /sudo apt install -y /' \
|
||||
-e 's/^sudo dnf install /sudo dnf install -y /' \
|
||||
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /')
|
||||
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /' \
|
||||
-e 's/^sudo apt purge /sudo apt purge -y /' \
|
||||
-e 's/^sudo dnf remove /sudo dnf remove -y /' \
|
||||
-e 's/^sudo pacman -Rns /sudo pacman -Rns --noconfirm /')
|
||||
fi
|
||||
printf ' + %s\n' "$cmd"
|
||||
[ "$DRY" = 1 ] && return 0
|
||||
@@ -172,6 +178,43 @@ else
|
||||
fi
|
||||
say "Detected $PRETTY → $FAMILY (guide: $DOCS_PAGE)"
|
||||
|
||||
# ---------------------------------------------------------------------------- --uninstall
|
||||
# The reverse of step 1 + step 6, as $DOCS/uninstall spells it out per family: user units off first
|
||||
# (package removal can't see the enable symlinks in $HOME), then only the punktfunk packages that
|
||||
# are actually installed, then the repo. Config, groups and firewall rules stay — the page lists them.
|
||||
if [ "$UNINSTALL" = 1 ]; then
|
||||
say "Uninstalling the host ($DOCS/uninstall)"
|
||||
run 'systemctl --user disable --now punktfunk-host punktfunk-web punktfunk-scripting 2>/dev/null || true'
|
||||
case "$FAMILY" in
|
||||
apt)
|
||||
pkgs=$(dpkg-query -W -f='${Package} ${db:Status-Status}\n' 'punktfunk*' 2>/dev/null | awk '$2=="installed"{printf "%s ", $1}')
|
||||
[ -n "$pkgs" ] && run "sudo apt purge $pkgs"
|
||||
run 'sudo rm -f /etc/apt/sources.list.d/punktfunk.list /etc/apt/keyrings/punktfunk.asc'
|
||||
run 'sudo apt update'
|
||||
;;
|
||||
dnf)
|
||||
pkgs=$(rpm -qa --qf '%{NAME} ' 'punktfunk*' 2>/dev/null)
|
||||
[ -n "$pkgs" ] && run "sudo dnf remove $pkgs"
|
||||
run 'sudo rm -f /etc/yum.repos.d/punktfunk.repo'
|
||||
;;
|
||||
pacman)
|
||||
pkgs=$(pacman -Qq 2>/dev/null | grep '^punktfunk' | tr '\n' ' ')
|
||||
[ -n "$pkgs" ] && run "sudo pacman -Rns $pkgs"
|
||||
run "sudo sed -i '/^\\[punktfunk\\(-canary\\)\\{0,1\\}\\]\$/,/^Server = /d' /etc/pacman.conf"
|
||||
;;
|
||||
sysext)
|
||||
run 'sudo punktfunk-sysext remove'
|
||||
;;
|
||||
esac
|
||||
cat <<EOF
|
||||
|
||||
Removed. Left on purpose: ~/.config/punktfunk (identity, pairings, host.env, plugins — a reinstall
|
||||
picks them up), the punktfunk / punktfunk-update groups, and any firewall rules you opened.
|
||||
The one-command cleanups for each are on $DOCS/uninstall#linux-hosts
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Version floors the package can't express: below these the install succeeds and nothing can stream.
|
||||
major=${VERSION_ID%%.*}
|
||||
case "$ID" in
|
||||
@@ -361,11 +404,20 @@ if [ "$START" = 1 ] && [ "$DRY" != 1 ]; then
|
||||
if command -v ss >/dev/null 2>&1 && ss -lun 2>/dev/null | grep -q ':9777 '; then ok "listening on UDP 9777 (punktfunk/1)"
|
||||
else warn "nothing on UDP 9777 yet — give it a second, then: journalctl --user -u punktfunk-host -e"; fi
|
||||
fi
|
||||
# GPU drivers are the docs pages' job (one step, per distro) — but the one silent failure worth
|
||||
# calling out: Fedora + NVIDIA with Fedora's own ffmpeg has no NVENC, and the RPM only Recommends
|
||||
# RPM Fusion's build, so the install succeeded and encoding won't.
|
||||
if [ "$FAMILY" = dnf ] && grep -qs 0x10de /sys/bus/pci/devices/*/vendor 2>/dev/null && ! rpm -q ffmpeg-libs >/dev/null 2>&1; then
|
||||
warn "NVIDIA GPU, but RPM Fusion's ffmpeg-libs isn't installed — NVENC won't work until it is: step 1 of $DOCS_PAGE"
|
||||
# GPU drivers are the docs pages' job (one step, per distro) — but the silent failures worth
|
||||
# calling out, because the install succeeded and streaming won't: an NVIDIA card whose kernel
|
||||
# module didn't load (Secure Boot blocks the unenrolled key — nvidia-smi can't talk to it), or no
|
||||
# driver at all; and Fedora + NVIDIA with Fedora's own ffmpeg, which has no NVENC (the RPM only
|
||||
# Recommends RPM Fusion's build).
|
||||
if grep -qs 0x10de /sys/bus/pci/devices/*/vendor 2>/dev/null; then
|
||||
if ! command -v nvidia-smi >/dev/null 2>&1; then
|
||||
warn "NVIDIA GPU without the NVIDIA driver — nothing can encode until it's installed: step 1 of $DOCS_PAGE"
|
||||
elif ! nvidia-smi >/dev/null 2>&1; then
|
||||
warn "NVIDIA GPU, but nvidia-smi can't talk to the driver — the kernel module didn't load (Secure Boot? run: mokutil --sb-state): $DOCS/troubleshooting#nvidia-smi-says-it-cant-communicate-with-the-driver"
|
||||
fi
|
||||
if [ "$FAMILY" = dnf ] && ! rpm -q ffmpeg-libs >/dev/null 2>&1; then
|
||||
warn "NVIDIA GPU, but RPM Fusion's ffmpeg-libs isn't installed — NVENC won't work until it is: step 1 of $DOCS_PAGE"
|
||||
fi
|
||||
fi
|
||||
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
[ -n "$ip" ] || ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}')
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/host",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
+1
-1
@@ -8,4 +8,4 @@
|
||||
*
|
||||
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
|
||||
*/
|
||||
export const SDK_VERSION = "0.1.4";
|
||||
export const SDK_VERSION = "0.1.5";
|
||||
|
||||
Reference in New Issue
Block a user