Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a0f54b09 | ||
|
|
c2f5e91b3d |
@@ -333,16 +333,9 @@ jobs:
|
||||
echo "published → $CACHE_URL"
|
||||
|
||||
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
|
||||
# Accept BOTH shapes. A checkbox dispatched from the Gitea UI arrives as the STRING
|
||||
# "true", but an API dispatch (scripts, cross-repo automation) can deliver a real JSON
|
||||
# boolean, and `== 'true'` silently misses it — the step is skipped, the run goes green,
|
||||
# and the log looks identical to a run that genuinely had nothing to do. MEASURED
|
||||
# 2026-08-19: dispatched with build-gamescope while verifying a flake.lock bump, and this
|
||||
# step skipped while the job reported success — a green that proved nothing about the
|
||||
# very package being fixed. Still no `inputs.*`: that context is the thing Gitea's parser
|
||||
# is least reliable about, which is why this file used github.event.inputs to begin with.
|
||||
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
|
||||
- name: Build the Rust packages (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-rust == 'true' || github.event.inputs.build-rust == true }}
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
|
||||
@@ -352,6 +345,6 @@ jobs:
|
||||
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
|
||||
# something actually builds it.
|
||||
- name: Build the patched gamescope (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' || github.event.inputs.build-gamescope == true }}
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ three ABIs, which removes the Compose screenshot scenes.
|
||||
| `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/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 |
|
||||
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.2** | unchanged in `package.json` — but `sync-engine.ts` gained `minInterval` (below), so a `plugin-kit-v0.4.3` cut is **owed** for the same reason |
|
||||
|
||||
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
|
||||
`sdk-publish.yml` / `plugin-kit-publish.yml`); this release commit does not bump them. Both have
|
||||
|
||||
@@ -49,9 +49,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
@@ -104,26 +101,6 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
// System bars have ONE owner: this effect. The stream and the console shell both want the
|
||||
// whole panel (bars hidden, a swipe shows them transiently); the touch shell wants them back.
|
||||
// It cannot live inside the screens themselves: `AnimatedContent` below keeps the outgoing
|
||||
// screen composed until its fade ends, so a per-screen `onDispose { show(...) }` fired AFTER
|
||||
// the incoming screen's hide — console → stream left the status and gesture bars parked over
|
||||
// the video. Keyed on the resolved intent, not the screens.
|
||||
val immersive = session != null || gamepadUi
|
||||
DisposableEffect(immersive) {
|
||||
val window = activity?.window ?: return@DisposableEffect onDispose {}
|
||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||
if (immersive) {
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
} else {
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
|
||||
|
||||
@@ -58,6 +58,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -419,8 +420,10 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(true)
|
||||
}
|
||||
// System bars: NOT hidden here — App.kt owns hide/show (one owner; the AnimatedContent
|
||||
// handoff broke per-screen ownership, see the `immersive` effect there).
|
||||
controller?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
|
||||
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
|
||||
// Scoped to the stream; the app's other screens keep the default for their text fields.
|
||||
@@ -814,6 +817,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
|
||||
}
|
||||
}
|
||||
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window?.setPreferMinimalPostProcessing(false)
|
||||
|
||||
@@ -31,6 +31,9 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
|
||||
import io.unom.punktfunk.MainActivity
|
||||
@@ -113,11 +116,19 @@ fun SkiaConsoleShell(
|
||||
}
|
||||
|
||||
// The console owns the whole panel while it fronts the app, exactly like the stream: the
|
||||
// status bar and the gesture bar are hidden (a swipe shows them transiently). This is both the
|
||||
// space win AND the safe-area fix — hidden bars report zero insets, so the scroll clips that
|
||||
// used to end at the visible gesture-bar line now run to the panel edge. Only the display
|
||||
// cutout stays a real inset. The hide/show itself lives in App.kt (one owner; a per-screen
|
||||
// `onDispose { show }` fired after the stream's hide during the AnimatedContent cross-fade).
|
||||
// status bar and the gesture bar are hidden (a swipe shows them transiently), restored on the
|
||||
// way out. This is both the space win AND the safe-area fix — hidden bars report zero insets,
|
||||
// so the scroll clips that used to end at the visible gesture-bar line (scrolled rows sliced
|
||||
// off mid-air with bare backdrop below) now run to the panel edge. Only the display cutout
|
||||
// stays a real inset.
|
||||
DisposableEffect(activity) {
|
||||
val window = activity?.window ?: return@DisposableEffect onDispose {}
|
||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
onDispose { controller.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
|
||||
// The safe area, in surface pixels: system bars ∪ display cutout — the NP3's landscape punch
|
||||
// is a SIDE inset, and the console's chrome must stay clear of it (its backdrop need not).
|
||||
|
||||
@@ -115,7 +115,7 @@ pub(super) struct AscBackend {
|
||||
/// Fixed for the session; the mode table is authoritative for the panel's fastest refresh.
|
||||
panel_seed_ns: i64,
|
||||
last_latch_ns: i64,
|
||||
/// `ADataSpace` for the transaction (BT709 for SDR — never untagged; see `color_dataspace`).
|
||||
/// HDR `ADataSpace` for the transaction (`0` = SDR / leave default).
|
||||
dataspace: i32,
|
||||
/// Layer frame-rate vote (source Hz), applied once.
|
||||
frame_rate: f32,
|
||||
@@ -143,7 +143,7 @@ impl AscBackend {
|
||||
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
|
||||
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
|
||||
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
|
||||
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
|
||||
/// `dataspace` the HDR `ADataSpace` (`0` = SDR); `source_hz` the negotiated stream rate.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn create(
|
||||
window: &NativeWindow,
|
||||
@@ -571,9 +571,9 @@ impl AscBackend {
|
||||
}
|
||||
|
||||
impl AscBackend {
|
||||
/// Update the `ADataSpace` applied to every subsequent transaction (a refinement from the
|
||||
/// codec's output format — the analogue of the SurfaceView path's `apply_hdr_dataspace`; the
|
||||
/// negotiated colour set the initial value at create).
|
||||
/// Update the HDR `ADataSpace` applied to every subsequent transaction (from the codec's
|
||||
/// output format once it is known — the analogue of the SurfaceView path's
|
||||
/// `apply_hdr_dataspace`). `0` leaves the surface SDR.
|
||||
pub(super) fn set_dataspace(&mut self, dataspace: i32) {
|
||||
if self.dataspace != dataspace {
|
||||
self.dataspace = dataspace;
|
||||
|
||||
@@ -15,8 +15,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use super::asc_presenter::{asc_backend_selected, AscBackend};
|
||||
use super::display::{
|
||||
apply_hdr_dataspace, color_dataspace, hdr_dataspace, install_render_callback,
|
||||
release_render_callback, DisplayTracker,
|
||||
apply_hdr_dataspace, hdr_dataspace, install_render_callback, release_render_callback,
|
||||
DisplayTracker,
|
||||
};
|
||||
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
|
||||
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
|
||||
@@ -192,9 +192,11 @@ pub(super) fn run_async(
|
||||
// below is the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview`
|
||||
// sysprop. A non-null `asc` means the codec renders into the reader, not the SurfaceView window.
|
||||
let mut asc = if asc_backend_selected() {
|
||||
// The negotiated colour is authoritative (PQ vs HLG, range) — not a guess the codec's
|
||||
// output format later corrects; many decoders never echo `color-transfer` at all.
|
||||
let initial_ds = color_dataspace(&client.color);
|
||||
let initial_ds = if client.color.is_hdr() {
|
||||
i32::from(ndk::data_space::DataSpace::Bt2020ItuPq)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
AscBackend::create(
|
||||
&window,
|
||||
mode.width as i32,
|
||||
@@ -447,12 +449,7 @@ pub(super) fn run_async(
|
||||
if fmt_dirty {
|
||||
if let Some(a) = asc.as_mut() {
|
||||
// ASC carries the HDR signal on the transaction, not the SurfaceView window.
|
||||
// Refine only when the codec actually reports an HDR transfer — a `None` echo
|
||||
// (decoders commonly omit `color-transfer`) must not clobber the negotiated
|
||||
// dataspace back to SDR before the first present.
|
||||
if let Some(ds) = hdr_dataspace(&codec) {
|
||||
a.set_dataspace(i32::from(ds));
|
||||
}
|
||||
a.set_dataspace(hdr_dataspace(&codec).map_or(0, i32::from));
|
||||
} else {
|
||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||
}
|
||||
|
||||
@@ -274,26 +274,3 @@ pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
|
||||
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the *negotiated* session colour ([`ColorInfo`], carried on Welcome) to the `ADataSpace`
|
||||
/// the presenter should tag buffers with. This is the authoritative source — the wire contract
|
||||
/// says clients configure the presenter from these code points, not from what the decoder happens
|
||||
/// to echo back (many decoders omit `color-transfer` from the output format).
|
||||
///
|
||||
/// SDR maps to `BT709` (limited-range video), never `0`/untagged: an untagged buffer on an
|
||||
/// ASurfaceControl transaction leaves SurfaceFlinger to guess, and a full-range guess shows
|
||||
/// limited-range black (16) as gray — the elevated-blacks bug.
|
||||
// ponytail: full-range SDR would need hand-composed dataspace bits (no named constant); the host
|
||||
// only encodes limited-range SDR today (ColorInfo::SDR_BT709), so BT709 covers every SDR session.
|
||||
pub(super) fn color_dataspace(color: &punktfunk_core::quic::ColorInfo) -> i32 {
|
||||
use punktfunk_core::quic::ColorInfo;
|
||||
let full = color.full_range != 0;
|
||||
let ds = match color.transfer {
|
||||
ColorInfo::TRC_PQ if full => DataSpace::Bt2020Pq,
|
||||
ColorInfo::TRC_PQ => DataSpace::Bt2020ItuPq,
|
||||
ColorInfo::TRC_HLG if full => DataSpace::Bt2020Hlg,
|
||||
ColorInfo::TRC_HLG => DataSpace::Bt2020ItuHlg,
|
||||
_ => DataSpace::Bt709, // SDR — limited-range BT.709 video
|
||||
};
|
||||
i32::from(ds)
|
||||
}
|
||||
|
||||
@@ -333,8 +333,7 @@ impl Layer {
|
||||
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
|
||||
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
|
||||
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
|
||||
/// tagged with `seq`. `dataspace` is the `ADataSpace` value (`0` = leave the layer default —
|
||||
/// only the `setBufferDataSpace`-less API-29 fallback ever presents untagged).
|
||||
/// tagged with `seq`. `dataspace` is the HDR `ADataSpace` value (`0` = leave default/SDR).
|
||||
/// `frame_rate` votes the layer's rate once (`0.0` skips). Returns `false` if the transaction
|
||||
/// could not be created (the caller then frees the buffer itself).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -451,10 +451,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||
// The Kotlin side cancels by dropping the result (`Dial.cancelled`), not by aborting
|
||||
// the dial — its connect runs on a pool thread, so a parked one costs a thread, not a
|
||||
// stuck UI. Wire a flag through here if that ever stops being true.
|
||||
None,
|
||||
) {
|
||||
Ok(client) => {
|
||||
let handle = SessionHandle {
|
||||
|
||||
@@ -883,12 +883,6 @@ fn pump(
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
params.connect_timeout,
|
||||
// THE session's stop flag, so the embedder's cancel reaches a dial that has not landed
|
||||
// yet. Without it this call parks the pump thread for the whole budget — 185 s on a
|
||||
// request-access connect the host holds pending approval — and the embedder's cancel
|
||||
// could not be answered until it returned: the console's takeover sat on "Canceling…"
|
||||
// with no session event to clear it.
|
||||
Some(stop.clone()),
|
||||
) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
|
||||
@@ -139,6 +139,7 @@ struct Toast {
|
||||
|
||||
struct Connecting {
|
||||
title: String,
|
||||
canceling: bool,
|
||||
appear: f64,
|
||||
/// A request-access wait (parked on the host until the operator approves) — the
|
||||
/// takeover reads "Waiting for approval" rather than "Connecting".
|
||||
@@ -435,6 +436,7 @@ impl Shell {
|
||||
self.last_connect_title = Some(title.clone());
|
||||
self.connecting = Some(Connecting {
|
||||
title,
|
||||
canceling: false,
|
||||
appear: 0.0,
|
||||
request_access: false,
|
||||
})
|
||||
@@ -502,6 +504,7 @@ impl Shell {
|
||||
.last_connect_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "the host".to_string()),
|
||||
canceling: false,
|
||||
appear: 1.0,
|
||||
request_access: false,
|
||||
});
|
||||
@@ -680,19 +683,9 @@ impl Shell {
|
||||
pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
|
||||
self.sync();
|
||||
// Modal precedence: the connect card, then the wake card, then the screens.
|
||||
if self.connecting.is_some() {
|
||||
if ev == MenuEvent::Back {
|
||||
// The takeover comes down HERE, not when the host answers. It used to wait for
|
||||
// the next `session_phase` and show "Canceling…" until one arrived — and one is
|
||||
// not guaranteed to: the dial is a blocking call on the host's side of this
|
||||
// interface, so the wait was the whole connect budget (185 s on a request-access
|
||||
// connect the host parks pending approval), and an embedder that simply drops a
|
||||
// canceled dial never sends a phase at all. Either way the console sat on
|
||||
// "Canceling…" with no input that could reach it — only killing the app cleared
|
||||
// it. Cancel is the USER's decision and needs no confirmation from the wire; the
|
||||
// action below still goes out, and every host already handles a dial that lands
|
||||
// after it (quit-close the connector, route the end back silently).
|
||||
self.connecting = None;
|
||||
if let Some(c) = &mut self.connecting {
|
||||
if ev == MenuEvent::Back && !c.canceling {
|
||||
c.canceling = true;
|
||||
self.actions.push_back(OverlayAction::CancelConnect);
|
||||
return Some(MenuPulse::Confirm);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,15 @@ impl Shell {
|
||||
let takeover: Option<(f64, bool, String, String, Vec<Hint>)> =
|
||||
if let Some(c) = &mut self.connecting {
|
||||
c.appear = approach(c.appear, 1.0, dt, 0.07);
|
||||
if c.request_access {
|
||||
if c.canceling {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
"Canceling…".to_string(),
|
||||
String::new(),
|
||||
vec![],
|
||||
))
|
||||
} else if c.request_access {
|
||||
Some((
|
||||
c.appear,
|
||||
true,
|
||||
|
||||
@@ -178,17 +178,15 @@ fn connect_flow_raises_launch_and_cancel() {
|
||||
Some(OverlayAction::Launch { launch: None, .. })
|
||||
));
|
||||
assert!(s.connecting.is_some());
|
||||
// While connecting: B cancels — and the takeover comes down on the spot. It must NOT wait
|
||||
// for a session phase to clear it: the dial is blocking on the host's side of this
|
||||
// interface, so that wait was the whole connect budget, and an embedder that just drops a
|
||||
// canceled dial sends no phase at all — the console stuck on "Canceling…" until the app died.
|
||||
// While connecting: B cancels exactly once.
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
assert!(matches!(
|
||||
s.take_action(),
|
||||
Some(OverlayAction::CancelConnect)
|
||||
));
|
||||
assert!(s.connecting.is_none(), "cancel drops the takeover itself");
|
||||
// A dial that resolves afterwards (or never) changes nothing.
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
assert!(s.take_action().is_none(), "cancel is idempotent");
|
||||
// The canceled dial ends silently.
|
||||
s.session_ended(None);
|
||||
assert!(s.connecting.is_none());
|
||||
}
|
||||
|
||||
@@ -128,6 +128,11 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
|
||||
/// [`restore_takeover_on_startup`] sets it for a stranded takeover it adopts: unmasking a unit we
|
||||
/// never masked is a no-op, while missing one that IS masked leaves the box unable to enter its
|
||||
/// own Game Mode until reboot.
|
||||
///
|
||||
/// ⚠ The takeover itself no longer masks anything — it idles the autologin session instead
|
||||
/// ([`install_idle_dropin`]), because a masked unit FAILS and a failing unit is what the display
|
||||
/// manager relogin-loops against. So this is now only ever true for a takeover adopted from a
|
||||
/// host old enough to have laid one, and the lift paths stay for exactly that box.
|
||||
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
|
||||
@@ -158,6 +163,10 @@ static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::
|
||||
/// giving the DM's desktop session time to come up so re-detection follows it instead.
|
||||
const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Whether [`install_idle_dropin`] has one outstanding. Process memory only — the sweep in
|
||||
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
|
||||
static IDLE_DROPIN_ARMED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
|
||||
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
|
||||
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
|
||||
@@ -373,6 +382,15 @@ pub fn restore_takeover_on_startup() {
|
||||
);
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
}
|
||||
// Same shape, same reason: a host that died mid-stream leaves the box's Game Mode replaced by
|
||||
// a session that does nothing at all, which looks exactly like broken hardware. Runtime-dir
|
||||
// state, so a reboot clears it too — this covers the restart that does not.
|
||||
if remove_idle_dropin() {
|
||||
tracing::warn!(
|
||||
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
|
||||
own Game Mode session would have started and then done nothing"
|
||||
);
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
|
||||
return; // no takeover file — clean start
|
||||
};
|
||||
@@ -1107,8 +1125,9 @@ fn discover_session_display_env() -> Option<(Option<String>, Option<String>, Opt
|
||||
/// ⚠ Only for callers whose timeout answer is the SAFE one. Both current callers time out into
|
||||
/// "assume active"/"keep looping", so a miss costs a poll tick. A caller whose timeout would invert
|
||||
/// the answer into the refusing direction must NOT use this bound — `loginctl show-user -p Linger`
|
||||
/// was given it and became a hard connect failure on a correctly-configured box (see
|
||||
/// [`linger_enabled`], now on [`UNIT_QUERY_BUDGET`]): 300 ms is an in-memory-read budget, and
|
||||
/// was given it and became a hard connect failure on a correctly-configured box (the linger probe
|
||||
/// that produced it is gone with the DM-stop path, but the lesson is not): 300 ms is an
|
||||
/// in-memory-read budget, and
|
||||
/// anything that spawns a process and makes a D-Bus round trip is not that.
|
||||
const UNIT_STATE_BUDGET: Duration = Duration::from_millis(300);
|
||||
|
||||
@@ -1287,6 +1306,74 @@ fn legacy_session_plus_dropin_path() -> std::path::PathBuf {
|
||||
.join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf")
|
||||
}
|
||||
|
||||
/// Where the takeover's IDLE drop-in lives. Same runtime-dir argument as
|
||||
/// [`session_plus_dropin_path`], and here it is the safety property the mechanism rests on rather
|
||||
/// than a tidiness one: this drop-in replaces the box's game-mode `ExecStart`, so a copy that
|
||||
/// outlived the host would leave the box unable to enter Game Mode at all. Under
|
||||
/// `$XDG_RUNTIME_DIR` it dies with the login session, and a reboot restores game mode by itself —
|
||||
/// on top of the unconditional sweep [`restore_takeover_on_startup`] does.
|
||||
fn idle_dropin_path() -> std::path::PathBuf {
|
||||
let base = crate::session::runtime_dir();
|
||||
std::path::Path::new(&base)
|
||||
.join("systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-idle.conf")
|
||||
}
|
||||
|
||||
/// `sleep`'s path on this box. The idle `ExecStart` must not be a command that can fail to
|
||||
/// EXECUTE: a unit that dies on start is precisely the relogin storm this drop-in exists to avoid
|
||||
/// ([`mask_unit`] has that chain), so resolve it instead of hardcoding one distro's layout.
|
||||
fn sleep_binary() -> &'static str {
|
||||
["/usr/bin/sleep", "/bin/sleep"]
|
||||
.into_iter()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
.unwrap_or("/usr/bin/sleep")
|
||||
}
|
||||
|
||||
/// Idle the box's autologin game session for the stream's duration: a drop-in over the
|
||||
/// `gamescope-session-plus@` TEMPLATE (so it reaches whichever instance this box autologs into)
|
||||
/// that replaces `ExecStart` with a process which merely sleeps.
|
||||
///
|
||||
/// This is what the takeover uses INSTEAD of stopping the display manager, and it satisfies all
|
||||
/// three things that path has to get right at once. Steam is freed (the session runs nothing).
|
||||
/// The DM does not storm: its autologin still SUCCEEDS, so there is no failed session to relogin
|
||||
/// against — unlike a masked unit, which fails in milliseconds and is the storm's engine. And the
|
||||
/// box keeps a live display manager, so a session switch the user asks for can still be serviced;
|
||||
/// that is the one a stopped DM could not, and it stranded `.41` on Steam's "Switch to Desktop"
|
||||
/// modal until a reboot.
|
||||
///
|
||||
/// Measured on that box: with this installed, `steam` is down, `sddm` stays active, the unit sits
|
||||
/// `active (running)` with `NRestarts=0`, and a subsequent `switch-to-desktop-mode` brings Plasma
|
||||
/// up in ~10 s.
|
||||
fn install_idle_dropin() -> Result<()> {
|
||||
let path = idle_dropin_path();
|
||||
let dir = path
|
||||
.parent()
|
||||
.context("the idle drop-in path has no parent directory")?;
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[Service]\nExecStart=\nExecStart={} infinity\n",
|
||||
sleep_binary()
|
||||
),
|
||||
)
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
|
||||
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
|
||||
/// the drop-in outliving a host that died is exactly the case that has to be swept.
|
||||
fn remove_idle_dropin() -> bool {
|
||||
let removed = std::fs::remove_file(idle_dropin_path()).is_ok();
|
||||
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = false;
|
||||
if removed {
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and
|
||||
/// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS`
|
||||
/// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60).
|
||||
@@ -2128,6 +2215,12 @@ fn kill_unit(unit: &str) {
|
||||
/// box leaves it (a mid-stream switch to a desktop session), [`lift_autologin_mask`] must lift it, or
|
||||
/// the way back is barred until reboot (`--runtime` lives in tmpfs — which is exactly why "it works
|
||||
/// again after a reboot").
|
||||
/// ⚠ Nothing in the takeover lays a mask any more — it idles the autologin session instead
|
||||
/// ([`install_idle_dropin`]), precisely because a masked unit FAILS and a failing unit is what the
|
||||
/// display manager relogin-loops against. This is kept for the test that builds the state
|
||||
/// [`lift_autologin_mask`] exists to clean up: a takeover adopted from a host old enough to have
|
||||
/// masked. That lift is still live code, so the state has to stay constructible.
|
||||
#[cfg(test)]
|
||||
fn mask_unit(unit: &str) {
|
||||
let _ = crate::proc::status_within(
|
||||
Command::new("systemctl").args(["--user", "mask", "--runtime", unit]),
|
||||
@@ -2230,16 +2323,24 @@ struct DmPlan {
|
||||
/// no Steam, masking them while a DM is up is the relogin storm ([`mask_unit`]), and stopping
|
||||
/// the DM would kill the user's live desktop for it.
|
||||
skip: bool,
|
||||
/// Stop the DM for the stream's duration (only a live instance justifies it). Masking is not
|
||||
/// a plan input: it is laid only once this stop has LANDED, so it can never substitute for it.
|
||||
stop_dm: bool,
|
||||
/// A display manager drives this LIVE gaming session, so its autologin brings the session
|
||||
/// straight back the moment we free Steam. That is what the idle drop-in answers
|
||||
/// ([`install_idle_dropin`]) — not, any longer, stopping the DM.
|
||||
///
|
||||
/// Stopping it satisfied the same requirement and broke a different one: a box with no DM has
|
||||
/// nothing that can start a desktop session, so the user's own "Switch to Desktop" hung on
|
||||
/// Steam's modal until a reboot (field report 2026-08-18). It hung UNDETECTABLY, which is why
|
||||
/// no amount of watching fixes it: on a `steamos-manager` box the switch is a D-Bus call whose
|
||||
/// every trace — the sddm state file, the session units, the login mode — is written by the
|
||||
/// display manager we had just stopped. Leave the DM up and there is nothing to detect.
|
||||
dm_relogins: bool,
|
||||
}
|
||||
|
||||
/// See [`DmPlan`].
|
||||
fn dm_plan(dm: Option<&str>, any_live: bool) -> DmPlan {
|
||||
DmPlan {
|
||||
skip: !any_live,
|
||||
stop_dm: dm.is_some() && any_live,
|
||||
dm_relogins: dm.is_some() && any_live,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2608,122 +2709,11 @@ fn systemctl_system(args: &[&str]) -> bool {
|
||||
out.status.success()
|
||||
}
|
||||
|
||||
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
|
||||
/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login
|
||||
/// session. logind then stops `user@<uid>.service` once `UserStopDelaySec` (10 s by default)
|
||||
/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart
|
||||
/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0**
|
||||
/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0
|
||||
/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was
|
||||
/// enabled there for the sessionless tests.
|
||||
///
|
||||
/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is
|
||||
/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure
|
||||
/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then
|
||||
/// degrades to attach, which mirrors the box's own session and never stops the DM.
|
||||
///
|
||||
/// `Err` carries **why** it could not be ensured, because the helper path is reached here first:
|
||||
/// on a sessionless host the `linger` verb goes through the same [`dm_helper`] gate the `stop`
|
||||
/// verb does, so a user outside the `punktfunk` group fails at THIS step and never reaches the
|
||||
/// DM-stop one. Dropping the reason here would just move the misdiagnosis one message earlier.
|
||||
fn ensure_host_survives_dm_stop() -> std::result::Result<(), String> {
|
||||
if !host_is_under_user_manager() {
|
||||
return Ok(()); // root / a system unit — the DM stop cannot reach us
|
||||
}
|
||||
if linger_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
// `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the
|
||||
// user's session can do this itself; a sessionless one (the packaged unit) goes through the
|
||||
// helper, whose grant is scoped to the calling uid.
|
||||
let uid = uid_string();
|
||||
let _ = crate::proc::status_within(
|
||||
Command::new("loginctl").args(["--no-ask-password", "enable-linger", &uid]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
);
|
||||
let helper = if linger_enabled() {
|
||||
Ok(()) // the plain verb was enough — the helper was never needed
|
||||
} else {
|
||||
dm_helper("linger").map_err(|e| e.to_string())
|
||||
};
|
||||
match helper {
|
||||
Ok(()) if linger_enabled() => {
|
||||
tracing::info!(
|
||||
uid,
|
||||
"enabled lingering for this user — the managed takeover stops the display manager, \
|
||||
which ends this login session, and without lingering logind would stop the host \
|
||||
along with it (`loginctl disable-linger` reverts it)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// The verb reported success and `loginctl` still says no: not a privilege problem, so say
|
||||
// that instead of blaming the grant the operator would then go and re-check.
|
||||
Ok(()) => Err(format!(
|
||||
"`loginctl enable-linger {uid}` reported success but lingering is still off"
|
||||
)),
|
||||
Err(why) => Err(why),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager
|
||||
/// stop take us down)? Read from our own cgroup path.
|
||||
fn host_is_under_user_manager() -> bool {
|
||||
std::fs::read_to_string("/proc/self/cgroup")
|
||||
.as_deref()
|
||||
.map(cgroup_under_user_manager)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a
|
||||
/// `user@<uid>.service` manager? Pure + unit-tested. A system unit
|
||||
/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from
|
||||
/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only
|
||||
/// reaches units the user manager owns.
|
||||
fn cgroup_under_user_manager(cgroup: &str) -> bool {
|
||||
cgroup.contains("user@")
|
||||
}
|
||||
|
||||
/// Our uid as a string — what `loginctl` wants for a user argument.
|
||||
fn uid_string() -> String {
|
||||
crate::proc::current_uid().to_string()
|
||||
}
|
||||
|
||||
/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)? An
|
||||
/// unanswered one reads as "not lingering", which refuses the takeover rather than risking the DM
|
||||
/// stop taking the host down with it.
|
||||
///
|
||||
/// [`UNIT_QUERY_BUDGET`], not [`UNIT_STATE_BUDGET`], and the failure DIRECTION is why. The 300 ms
|
||||
/// bound is documented as "anything near it means the manager is wedged — the case each caller's
|
||||
/// failure path already covers", and that holds for the other two callers, whose timeout answers
|
||||
/// `true`/keep-looping (benign). Here a timeout INVERTS the answer to `false`, and `false` is the
|
||||
/// refusing direction: `ensure_host_survives_dm_stop` then reports "`enable-linger` reported success
|
||||
/// but lingering is still off" and the bare-spawn Steam path fails a connect that would have worked,
|
||||
/// blaming a lingering configuration that is in fact correct. And this is not an in-memory read the
|
||||
/// way `systemctl is-active` is: it is a process spawn plus libsystemd's dynamic link plus a logind
|
||||
/// D-Bus round trip, sampled at the busiest moment on the box (a takeover, with Steam and a
|
||||
/// compositor being torn down). Only a genuinely wedged logind exceeds 5 s.
|
||||
fn linger_enabled() -> bool {
|
||||
crate::proc::output_within(
|
||||
Command::new("loginctl").args(["show-user", &uid_string(), "-p", "Linger", "--value"]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
)
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
|
||||
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
|
||||
/// packaged pkexec helper. The `Err` is the HELPER's reason (the plain verb's failure is expected
|
||||
/// and carries no information: an unprivileged host is meant to fail it), and the caller puts it
|
||||
/// in front of the operator instead of guessing.
|
||||
fn try_stop_display_manager(dm: &str) -> std::result::Result<(), DmHelperError> {
|
||||
if systemctl_system(&["stop", dm]) {
|
||||
return Ok(());
|
||||
}
|
||||
dm_helper("stop")
|
||||
}
|
||||
|
||||
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
|
||||
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
|
||||
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
|
||||
@@ -2974,88 +2964,37 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
if plan.skip {
|
||||
return Ok(());
|
||||
}
|
||||
if plan.stop_dm {
|
||||
let dm = dm.expect("stop_dm ⇒ Some");
|
||||
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
|
||||
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
|
||||
// box's display manager down and nobody left to bring it back.
|
||||
//
|
||||
// BOTH arms below now BAIL, on every DM flavor. They did not always: SDDM used to degrade
|
||||
// to mask-only here, on the reasoning that the mask still protects Steam and the cost is
|
||||
// just relogin churn. It is not just churn — the mask is IN sddm's relogin path, so a
|
||||
// mask without the stop is a 4–5 logins/s fork storm that drops the pad from 250 Hz to
|
||||
// 1.4 Hz ([`mask_unit`]). Degrading to attach costs the client's mode; degrading to
|
||||
// mask-only costs the user their input plane. Attach wins.
|
||||
//
|
||||
// Both bails quote the REASON they were handed rather than describing one.
|
||||
// 0.26.0/0.27.0 described one — "the packaged pf-dm-helper polkit action is missing or was
|
||||
// denied (reinstall the punktfunk package, or install the display-manager polkit rule from
|
||||
// the docs)" — and on the box that produced it the action was installed, permissive,
|
||||
// correctly annotated, and pkexec had already RUN the helper; the helper's refusal ("user
|
||||
// 'x' is not in the 'punktfunk' group") was thrown away with its stderr. Both suggested
|
||||
// remedies were dead ends: neither a reinstall nor a polkit rule adds anyone to a group.
|
||||
if let Err(why) = ensure_host_survives_dm_stop() {
|
||||
// The reason goes LAST in both bails: the helper's own refusal ends in a command
|
||||
// to paste, and burying that mid-sentence is how it stops being read.
|
||||
bail!(
|
||||
"stopping {dm} ends this user's last login session, and without lingering \
|
||||
logind would stop the user manager — and this host with it — about 10s \
|
||||
later, leaving the box with no display manager and nothing to restore it; \
|
||||
lingering could not be enabled, so the managed takeover is unavailable. \
|
||||
Either run `sudo loginctl enable-linger $USER` once, as the setup docs ask, \
|
||||
and reconnect — or fix the privileged path: {why}"
|
||||
);
|
||||
}
|
||||
if let Err(why) = try_stop_display_manager(&dm) {
|
||||
// ERROR, not WARN, and it names the SHAPE: this is the branch whose silence cost an
|
||||
// evening on .41 — the takeover degraded, nothing failed loudly, and the storm that
|
||||
// followed read as a pad bug. The `bail!` below reaches the caller's own warn line;
|
||||
// this one exists so the shape survives into the journal even if the caller's does
|
||||
// not, because the four shapes need four different fixes.
|
||||
tracing::error!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"the managed takeover planned to stop the display manager and could not — \
|
||||
degrading to ATTACH rather than fighting its autologin: a killed session under \
|
||||
a running DM relogin-loops at 4-5/s and starves the box's input plane"
|
||||
);
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, and stopping it for the stream needs \
|
||||
privilege this host does not have; taking over without stopping it would leave \
|
||||
its autologin relogin-looping against us for the whole stream, so the managed \
|
||||
takeover is unavailable — {why}"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
%dm,
|
||||
"freed Steam: stopped the display manager for this stream (its autologin \
|
||||
Relogin loop would otherwise churn against the takeover)"
|
||||
);
|
||||
// Baseline the switch sentinel HERE, not just at a successful launch: setting
|
||||
// STOPPED_DM is what arms the honor gate, so from this instant an unbaselined
|
||||
// sentinel would read as an in-stream "Switch to Desktop" — including the write from
|
||||
// the switch that just brought the box INTO game mode. A successful launch
|
||||
// re-baselines (tighter still).
|
||||
record_session_select_baseline();
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||
// Already idled by an earlier connect in this stream's life? Then the session listed as "live"
|
||||
// above is our own idled one — it holds no Steam and there is nothing left to free. Without
|
||||
// this, every reconnect and every in-place rebuild would kill and restart the box's session
|
||||
// again to accomplish exactly nothing.
|
||||
if *IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) {
|
||||
return Ok(());
|
||||
}
|
||||
// The display manager STAYS UP. Freeing Steam means ending the session its autologin owns, and
|
||||
// the two ways to stop that autologin fighting us are not equivalent: stopping the DM works
|
||||
// until the user asks for a desktop session, at which point nothing on the box can give them
|
||||
// one ([`DmPlan::dm_relogins`]). Idling the session instead keeps the autologin succeeding —
|
||||
// no failed unit to relogin against, no storm — while leaving the DM able to service that
|
||||
// switch.
|
||||
if plan.dm_relogins {
|
||||
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
|
||||
}
|
||||
// Reaching here means no display manager can relogin against us: either there is none
|
||||
// (`!plan.stop_dm` with `dm == None`), or the stop above LANDED — both failure arms bail. That
|
||||
// is the precondition the mask needs, and the only one under which it is a defense rather than
|
||||
// the storm's accelerator ([`mask_unit`]).
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
// Record that a mask is outstanding BEFORE laying it: every hand-back path lifts it off this
|
||||
// flag, and one that ran between the mask and an unrecorded flag would leave it on forever.
|
||||
*AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
for unit in units {
|
||||
mask_unit(&unit); // belt-and-braces: no DM is up to relogin through it
|
||||
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||
if plan.dm_relogins {
|
||||
// Bring it back ourselves rather than waiting for the DM to notice: deterministic, and
|
||||
// it closes the window in which the DM sees a dead session and starts churning. The
|
||||
// drop-in above is already loaded, so what comes back runs nothing.
|
||||
systemctl_user(&["restart", &unit]);
|
||||
}
|
||||
tracing::info!(
|
||||
%unit,
|
||||
dm_stopped = plan.stop_dm,
|
||||
"freed Steam: masked and stopped the autologin gaming session for this stream"
|
||||
idled = plan.dm_relogins,
|
||||
"freed Steam: the box's autologin gaming session is idled for this stream (its \
|
||||
display manager stays up, so the box can still switch sessions)"
|
||||
);
|
||||
stopped.push(unit);
|
||||
}
|
||||
@@ -3591,6 +3530,17 @@ fn do_restore_tv_session() {
|
||||
// rests on. It used to sit after the desktop-active and DM returns, so those two paths leaked
|
||||
// it.
|
||||
disarm_session_plus_dropin();
|
||||
// The idle drop-in belongs to the same rule and leaks the same way — worse, in fact: the bind
|
||||
// one leaves the box's Game Mode running OUR gamescope, this one leaves it running NOTHING.
|
||||
// The desktop-active return below is the live case (the user switched away, so we never
|
||||
// restart the units), and a drop-in left there is a box whose Game Mode silently does nothing
|
||||
// for the rest of the login.
|
||||
if remove_idle_dropin() {
|
||||
tracing::info!(
|
||||
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
|
||||
real again"
|
||||
);
|
||||
}
|
||||
unset_forced_session_screen_env();
|
||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
|
||||
@@ -3648,12 +3598,16 @@ fn do_restore_tv_session() {
|
||||
clear_takeover();
|
||||
return;
|
||||
}
|
||||
// (The idle drop-in is already gone — removed above every early return, so the restarts
|
||||
// below bring the box's real session back rather than another idle one.)
|
||||
for unit in units {
|
||||
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
|
||||
// that logged an unconditional success over a thrown-away exit status. A `--user start`
|
||||
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
|
||||
// and the DM branch thirty lines up already shows the shape — say what happened.
|
||||
match issue_restore_verb(&["start", &unit]) {
|
||||
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
|
||||
// active unit is a no-op that would report success over a session still running nothing.
|
||||
match issue_restore_verb(&["restart", &unit]) {
|
||||
RestoreVerb::Done => tracing::info!(
|
||||
unit,
|
||||
"restored the TV's autologin gaming session (debounce elapsed, no client)"
|
||||
@@ -5278,15 +5232,14 @@ impl Drop for GamescopeProc {
|
||||
mod tests {
|
||||
use super::{
|
||||
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
|
||||
cgroup_under_user_manager, classify_output_size, connected_connector_under,
|
||||
display_manager_unit_under, dm_plan, game_hz, gamescope_output_size, hdr_args,
|
||||
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script,
|
||||
our_wsi_layer_dir, plan_bind, release_autologin_mask, script_hardcodes_gamescope,
|
||||
sentinel_advanced, shape_dedicated_command, switch_ends_mask_window,
|
||||
takeover_state_is_live, unmask_unit, xwayland_refusal_marker, BindOff, BindPlan,
|
||||
BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan, AUTOLOGIN_MASKED,
|
||||
DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV,
|
||||
X11_SOCKET_DIR,
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags,
|
||||
mode_mismatch, nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
|
||||
script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
|
||||
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
|
||||
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
|
||||
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -5461,26 +5414,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manager_lifetime_detection() {
|
||||
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
|
||||
// login session the DM stop ends — this is the case that needs lingering.
|
||||
assert!(cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n"
|
||||
));
|
||||
assert!(cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n"
|
||||
));
|
||||
// A system unit outlives every session — the DM stop cannot reach it.
|
||||
assert!(!cgroup_under_user_manager(
|
||||
"0::/system.slice/punktfunk-host.service\n"
|
||||
));
|
||||
// Started from a login shell: owned by the session scope, not the user manager.
|
||||
assert!(!cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/session-2.scope\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_select_sentinel_needs_a_baseline() {
|
||||
let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
|
||||
@@ -5583,27 +5516,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dm_plan_stops_any_dm_that_drove_a_live_session() {
|
||||
// A live gaming session behind a DM: stop the DM, whatever the flavor. The mask alone does
|
||||
// NOT stop the relogin loop — on .41 it is what makes the loop fast, because the session
|
||||
// script's last act is `systemctl --user --wait start gamescope-session-plus@…` and a
|
||||
// masked unit fails that in milliseconds (4-5 logins/s, pad at 1.4 Hz, 2026-08-18).
|
||||
fn dm_plan_idles_any_dm_that_drove_a_live_session() {
|
||||
// A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two
|
||||
// things that do NOT work is flavor-dependent — a mask fails the unit in milliseconds and
|
||||
// makes the relogin loop fast (4-5 logins/s, pad at 1.4 Hz, 2026-08-18), and stopping the
|
||||
// DM leaves nothing able to start a desktop session when the user asks for one.
|
||||
let p = dm_plan(Some("sddm.service"), true);
|
||||
assert!(!p.skip && p.stop_dm);
|
||||
assert!(!p.skip && p.dm_relogins);
|
||||
// Flavor is no longer an input: plasmalogin gets the same plan as sddm. It used to differ
|
||||
// only to pick a DEGRADED mode (mask-only for sddm), and that degrade is now gone —
|
||||
// `stop_autologin_sessions` bails to ATTACH instead.
|
||||
let q = dm_plan(Some("plasmalogin.service"), true);
|
||||
assert!(q.skip == p.skip && q.stop_dm == p.stop_dm);
|
||||
assert!(q.skip == p.skip && q.dm_relogins == p.dm_relogins);
|
||||
// Nothing live, DM present: hands off entirely, on EVERY flavor. Killing loaded-but-
|
||||
// inactive leftovers frees no Steam; masking them while the DM is up is the storm; and
|
||||
// stopping the DM would kill the user's live desktop for it.
|
||||
assert!(dm_plan(Some("sddm.service"), false).skip);
|
||||
assert!(dm_plan(Some("plasmalogin.service"), false).skip);
|
||||
// No DM at all (getty autologin), live: mask+kill, nothing to stop — masking is sound
|
||||
// here precisely because no relogin loop exists to run into it.
|
||||
// No DM at all (getty autologin), live: kill and leave it stopped. Nothing relogins, so
|
||||
// there is no autologin to idle — and no reason to leave a drop-in on the box.
|
||||
let p = dm_plan(None, true);
|
||||
assert!(!p.skip && !p.stop_dm);
|
||||
assert!(!p.skip && !p.dm_relogins);
|
||||
assert!(dm_plan(None, false).skip);
|
||||
}
|
||||
|
||||
|
||||
@@ -2622,10 +2622,6 @@ unsafe fn connect_ex_impl(
|
||||
pin,
|
||||
identity,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
// No abort switch in the C ABI: `punktfunk_connect*` is a blocking call with
|
||||
// nothing to poll a flag from. An `ex` variant can take one when an ABI embedder
|
||||
// grows a cancelable connect screen.
|
||||
None,
|
||||
) {
|
||||
Ok(c) => {
|
||||
if !observed_sha256_out.is_null() {
|
||||
|
||||
@@ -750,7 +750,6 @@ impl NativeClient {
|
||||
pin,
|
||||
identity,
|
||||
timeout,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -811,16 +810,6 @@ impl NativeClient {
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
timeout: Duration,
|
||||
// The caller's abort switch, polled while this call is still blocked: setting it returns
|
||||
// [`PunktfunkError::Timeout`] straight away instead of parking the caller for the rest of
|
||||
// `timeout` — which is 185 s on a request-access dial the host has PARKED pending an
|
||||
// operator's approval, and a UI that offers Cancel cannot honour it while its dialing
|
||||
// thread is stuck in here. Taking it is the same give-up as running out of budget (quit
|
||||
// close + shutdown), so the worker stops re-dialing and the host tears down rather than
|
||||
// lingering for a reconnect nobody wants. Read ONLY here — deliberately not aliased onto
|
||||
// the client's own `shutdown`, which the pump uses to mean "this connection died" and
|
||||
// whose end reason a caller-set flag would race. `None` = a connect nobody can cancel.
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
) -> Result<NativeClient> {
|
||||
let frame_chan = Arc::new(FrameChannel::new());
|
||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||
@@ -978,34 +967,18 @@ impl NativeClient {
|
||||
})
|
||||
.map_err(PunktfunkError::Io)?;
|
||||
|
||||
// Polled rather than one long `recv_timeout(timeout)`: the wait has to end on the
|
||||
// caller's `cancel` as well as on the budget, and a handshake the host has PARKED
|
||||
// (request-access, pending approval) produces nothing to wake on for minutes.
|
||||
const READY_POLL: Duration = Duration::from_millis(50);
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let negotiated = loop {
|
||||
match ready_rx.recv_timeout(READY_POLL) {
|
||||
Ok(Ok(t)) => break t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
// Timed out with the worker still going: keep waiting unless the budget is
|
||||
// spent or the caller cancelled. Disconnected means the worker died without
|
||||
// reporting — the give-up path below covers it, same as it always did.
|
||||
// Both give-ups land in one arm on purpose: a cancel and an expiry owe the
|
||||
// host the same close, and the caller that cancelled is not listening to the
|
||||
// error it gets back anyway.
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if std::time::Instant::now() < deadline
|
||||
&& !cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) => {}
|
||||
Err(_) => {
|
||||
// A connect we already reported as failed must not leave a lingering host
|
||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||
// instead of holding the session (and its virtual display) for a reconnect
|
||||
// that will never come.
|
||||
quit.store(true, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
}
|
||||
let negotiated = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
// A connect we already reported as failed must not leave a lingering host
|
||||
// session if the handshake lands late: mark it a deliberate QUIT (not a plain
|
||||
// drop / close code 0) so the worker's close tells the host to tear down now
|
||||
// instead of holding the session (and its virtual display) for a reconnect
|
||||
// that will never come.
|
||||
quit.store(true, Ordering::SeqCst);
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
return Err(PunktfunkError::Timeout);
|
||||
}
|
||||
};
|
||||
*mode_slot.lock().unwrap() = negotiated.mode;
|
||||
|
||||
@@ -153,12 +153,10 @@ fn percent_decode(s: &str) -> String {
|
||||
/// Default: the users base (`C:\Users`), where the launchers that install per-user keep their art —
|
||||
/// Playnite stores covers under `%APPDATA%\Playnite`, Heroic under `%APPDATA%\heroic`. Derived from
|
||||
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus the
|
||||
/// two launchers that need NOT live under the users base: the Steam install root
|
||||
/// ([`steam_art_roots`]), and every Playnite root this box can find
|
||||
/// ([`super::launch::playnite_art_roots`]) — a PORTABLE Playnite keeps its whole library, covers and
|
||||
/// all, beside the exe, wherever the operator unzipped it. `PUNKTFUNK_LIBRARY_ART_ROOTS`
|
||||
/// (`;`-separated) replaces the whole default for an operator whose library is somewhere else again.
|
||||
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus
|
||||
/// the Steam install root ([`steam_art_roots`]), which is the one launcher that does NOT live under
|
||||
/// the users base. `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the whole default for an
|
||||
/// operator whose library is somewhere else again.
|
||||
fn art_roots() -> Vec<PathBuf> {
|
||||
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
|
||||
return std::env::split_paths(&configured)
|
||||
@@ -179,11 +177,6 @@ fn art_roots() -> Vec<PathBuf> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
roots.extend(steam_art_roots());
|
||||
// Playnite, for the same reason: a portable install (`D:\Apps\Playnite`) puts `library\files\…`
|
||||
// — every cover it exports — outside every profile. An installed Playnite adds a root that is
|
||||
// already inside the users base, which costs nothing.
|
||||
#[cfg(windows)]
|
||||
roots.extend(super::launch::playnite_art_roots());
|
||||
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
|
||||
// where every launcher this host reads art from actually keeps it. Steam's
|
||||
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
|
||||
@@ -1054,25 +1047,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Whatever Playnite roots this box has, the confinement must be told about them with NO
|
||||
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` set. That `extend` is the whole fix for the portable-install
|
||||
/// report (`D:\Apps\Playnite\library\files\…`, 70 covers dropped), and it is one line a
|
||||
/// refactor can silently drop. Vacuous on a box with no Playnite — the registry half cannot be
|
||||
/// faked from a test, so `launch::exe_from_shell_command`'s own test carries that load instead.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn playnite_roots_reach_the_art_confinement() {
|
||||
let _env = ArtRootsEnv::set(&[("PUNKTFUNK_LIBRARY_ART_ROOTS", None)]);
|
||||
let roots = art_roots();
|
||||
for root in crate::library::launch::playnite_art_roots() {
|
||||
assert!(root.is_dir(), "{root:?} is offered as an art root");
|
||||
assert!(
|
||||
roots.contains(&root),
|
||||
"{root:?} must be an allowed art root with no env var set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
|
||||
assert_eq!(sniff_image_type(PNG), Some("image/png"));
|
||||
|
||||
@@ -633,11 +633,6 @@ fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
|
||||
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
|
||||
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
|
||||
///
|
||||
/// A **portable** Playnite is none of those: it is unzipped wherever the operator wanted it
|
||||
/// (`D:\Apps\Playnite`), registers no uninstall entry, and is not under any profile. Its one
|
||||
/// registry trace is the `playnite://` handler Playnite registers for itself
|
||||
/// ([`playnite_dir_from_uri_handler`]) — the same registration this host's own launch path follows.
|
||||
///
|
||||
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
|
||||
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
|
||||
/// a stale entry costs one `is_file` and nothing else.
|
||||
@@ -650,33 +645,22 @@ fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
|
||||
// so the WOW view is a machine-hive concern only.
|
||||
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
// Playnite's own `playnite://` registration, in both spellings: bare inside a `…_Classes` hive,
|
||||
// and via the `Software\Classes` link everywhere else.
|
||||
const URI_COMMAND: &str = r"playnite\shell\open\command";
|
||||
const CLASSES_URI_COMMAND: &str = r"Software\Classes\playnite\shell\open\command";
|
||||
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
|
||||
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
|
||||
playnite_dir_from_uri_handler(&hklm, CLASSES_URI_COMMAND, &mut dirs);
|
||||
|
||||
let users = RegKey::predef(HKEY_USERS);
|
||||
for sid in users.enum_keys().flatten() {
|
||||
let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) else {
|
||||
continue;
|
||||
};
|
||||
// The `…_Classes` companion hives carry file associations — which is exactly where the
|
||||
// `playnite://` handler lives, `HKCU\Software\Classes` BEING that hive — and never uninstall
|
||||
// entries. Both spellings are probed rather than reasoned about: the in-hive `Software\Classes`
|
||||
// link is a link, and a probe that misses costs one failed `open_subkey`.
|
||||
// The `…_Classes` companion hives carry file associations, never uninstall entries.
|
||||
if sid.ends_with("_Classes") {
|
||||
playnite_dir_from_uri_handler(&hive, URI_COMMAND, &mut dirs);
|
||||
continue;
|
||||
}
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
playnite_dir_from_uri_handler(&hive, CLASSES_URI_COMMAND, &mut dirs);
|
||||
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
|
||||
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
|
||||
}
|
||||
}
|
||||
|
||||
// The conventional per-user location, for every profile on the box — this is where Playnite's
|
||||
@@ -721,80 +705,6 @@ fn playnite_dirs_from_uninstall(
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the directory of Playnite's registered `playnite://` handler from `root\path`, if there is one.
|
||||
///
|
||||
/// This is what finds a **portable** Playnite. It leaves no uninstall entry and lives under no user
|
||||
/// profile, so every other probe here is blind to it — but Playnite registers its own URI scheme,
|
||||
/// and that registration is the very one `explorer.exe "playnite://…"` follows when this host starts
|
||||
/// a Playnite title. If it resolves, this box already opens games with that copy.
|
||||
#[cfg(windows)]
|
||||
fn playnite_dir_from_uri_handler(
|
||||
root: &winreg::RegKey,
|
||||
path: &str,
|
||||
out: &mut Vec<std::path::PathBuf>,
|
||||
) {
|
||||
use winreg::enums::KEY_READ;
|
||||
|
||||
let Ok(command) = root
|
||||
.open_subkey_with_flags(path, KEY_READ)
|
||||
.and_then(|k| k.get_value::<String, _>(""))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = exe_from_shell_command(&command)
|
||||
.map(std::path::Path::new)
|
||||
.and_then(std::path::Path::parent)
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
{
|
||||
push_unique(out, dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// The executable out of a registered shell-open command line:
|
||||
/// `"D:\Apps\Playnite\Playnite.DesktopApp.exe" --uridata "%1"` → `D:\Apps\Playnite\Playnite.DesktopApp.exe`.
|
||||
///
|
||||
/// Quoted form first, because that is what a registrar writes. The cut at the first `.exe` is the
|
||||
/// fallback for the unquoted spelling, whose path may itself contain spaces and so cannot be split on
|
||||
/// whitespace. `None` when neither shape matches; the result is only ever a directory to probe for an
|
||||
/// exe, so a miss costs one `is_file` and nothing else.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn exe_from_shell_command(command: &str) -> Option<&str> {
|
||||
let command = command.trim();
|
||||
if let Some(rest) = command.strip_prefix('"') {
|
||||
return rest.split('"').next().filter(|p| !p.is_empty());
|
||||
}
|
||||
let end = command.to_ascii_lowercase().find(".exe")? + ".exe".len();
|
||||
Some(&command[..end])
|
||||
}
|
||||
|
||||
/// Windows: every Playnite root on this box, as an **art** root.
|
||||
///
|
||||
/// A portable Playnite keeps its library beside the exe — covers land in
|
||||
/// `<PlayniteDir>\library\files\…` — so for that layout the install dir IS where the art lives, and
|
||||
/// the users base can never cover it: the whole point of portable is that it sits wherever the
|
||||
/// operator put it (`D:\Apps\Playnite` in the report that prompted this). Without it a portable
|
||||
/// install synced its games and had EVERY cover dropped by the confinement. An installed Playnite
|
||||
/// keeps the same tree under `%APPDATA%\Playnite`, already inside the users base; naming that
|
||||
/// directory twice costs one `canonicalize` in [`super::art::art_path_is_confined`].
|
||||
///
|
||||
/// Same shape and same reasoning as [`super::art::steam_art_roots`], and it does not widen what the
|
||||
/// host can be *tricked* into reading: every candidate comes from the host's own registry and
|
||||
/// filesystem probes, never from the plugin lane that supplies the art path, and the extension,
|
||||
/// regular-file, magic-byte and config-dir gates all still apply on top.
|
||||
///
|
||||
/// The per-user hives these candidates partly come from are writable by that user — which is a bar
|
||||
/// this host already stands on, and one rung lower here than where it already stood: the same
|
||||
/// lookup picks the `Playnite.FullscreenApp.exe` a launcher tile SPAWNS. Trusting it to name a
|
||||
/// directory whose image files may be read is strictly weaker than trusting it to name a program to
|
||||
/// run.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn playnite_art_roots() -> Vec<std::path::PathBuf> {
|
||||
playnite_install_dirs()
|
||||
.into_iter()
|
||||
.filter(|d| d.is_dir())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
|
||||
///
|
||||
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
|
||||
@@ -1176,36 +1086,6 @@ mod tests {
|
||||
assert!(!valid_aumid("Foo Bar!Game"));
|
||||
}
|
||||
|
||||
/// The portable-Playnite probe, at the only part of it that can be wrong off-Windows: pulling the
|
||||
/// exe out of the registered `playnite://` command line. A miss here is a portable install the
|
||||
/// host cannot find — no launcher tile, and (through [`playnite_art_roots`]) every cover dropped.
|
||||
#[test]
|
||||
fn exe_is_read_out_of_a_registered_shell_command() {
|
||||
// What Playnite actually registers, portable install on a second drive.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r#""D:\Apps\Playnite\Playnite.DesktopApp.exe" --uridata "%1""#),
|
||||
Some(r"D:\Apps\Playnite\Playnite.DesktopApp.exe")
|
||||
);
|
||||
// Unquoted, with a space in the path — which is why this cannot split on whitespace.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r"C:\Program Files\Playnite\Playnite.DesktopApp.exe %1"),
|
||||
Some(r"C:\Program Files\Playnite\Playnite.DesktopApp.exe")
|
||||
);
|
||||
// Case is the registrar's business, not ours.
|
||||
assert_eq!(
|
||||
exe_from_shell_command(r"D:\Apps\Playnite\PLAYNITE.DESKTOPAPP.EXE"),
|
||||
Some(r"D:\Apps\Playnite\PLAYNITE.DESKTOPAPP.EXE")
|
||||
);
|
||||
// Nothing exe-shaped, and the empty quoted form: no candidate beats a bogus one, because a
|
||||
// bogus one would become an allowed art root.
|
||||
assert_eq!(
|
||||
exe_from_shell_command("rundll32 shell32.dll,Control_RunDLL"),
|
||||
None
|
||||
);
|
||||
assert_eq!(exe_from_shell_command(r#""" %1"#), None);
|
||||
assert_eq!(exe_from_shell_command(""), None);
|
||||
}
|
||||
|
||||
/// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the
|
||||
/// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be
|
||||
/// used because it is registered to the desktop app (verified on .173, 2026-08-06).
|
||||
|
||||
@@ -135,13 +135,12 @@ sysext creates it on merge:
|
||||
sudo usermod -aG punktfunk "$USER" # then log out and back in
|
||||
```
|
||||
|
||||
This box **is** a Gaming Mode box, so that group is not optional in practice: it authorizes the
|
||||
helper the host uses to stop the display manager when it takes the Gaming Mode session over at your
|
||||
client's resolution, and it gates the usbip `attach` file the **virtual Steam Deck controller**
|
||||
(paddles, trackpads, gyro) attaches through. It is a separate group on purpose — writing that file
|
||||
This box **is** a Gaming Mode box, so that group is worth having: it gates the usbip `attach` file
|
||||
the **virtual Steam Deck controller** (paddles, trackpads, gyro) attaches through. (The Gaming Mode
|
||||
takeover itself no longer needs it — it idles the box's session with a user-level drop-in rather
|
||||
than stopping the display manager.) It is a separate group on purpose — writing that file
|
||||
can materialise arbitrary emulated USB hardware, so it is not folded into the group everyone is
|
||||
told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller, and the
|
||||
takeover degrades to mirroring the box's own screen — see
|
||||
told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller — see
|
||||
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
|
||||
|
||||
## Configure
|
||||
@@ -172,8 +171,9 @@ and on Bazzite (which ships `gamescope-session-plus`) that is **managed**:
|
||||
relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the
|
||||
virtual screen — restoring the box on idle. This is the model that gives the client a display of
|
||||
its **own**, and the only one under which a game launched from a client's library gets a
|
||||
dedicated session. It needs the [`punktfunk` group](#allow-controller-input): the takeover stops
|
||||
the display manager for the length of the stream, and without that grant it cannot.
|
||||
dedicated session. The takeover idles the box's own session for the length of the
|
||||
stream (a user-level drop-in — no privilege needed, and the display manager stays up, so Steam's
|
||||
"Switch to Desktop" still works mid-stream).
|
||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session on its own
|
||||
display, and the host attaches to whatever's live without ever tearing it down (on a headless
|
||||
box, a box-owned autologin session is restarted at the client's resolution on a mismatch; with a
|
||||
|
||||
@@ -225,7 +225,7 @@ it — leave it or delete it, it makes no difference.
|
||||
| `PUNKTFUNK_MGMT_BIND` | `IP:PORT` *(default: `0.0.0.0:47990`)* | Where the management API listens. The `--mgmt-bind` flag overrides it. Two reasons to set it: pin `127.0.0.1:47990` to keep the API off the LAN entirely (paired clients then can't browse your library), or **move the port to share the machine with Sunshine, Apollo or Vibeshine** — 47990 is their web UI as well as our management API, and it's the only port the two still share once GameStream compat is off. Everything downstream follows the port you pick: native clients learn it from discovery, and the web console, the plugin runner (and so every library plugin) and the status tray read it from `~/.config/punktfunk/mgmt-endpoint` (`%ProgramData%\punktfunk\mgmt-endpoint` on Windows), which the host writes on every start. See [another streaming host is installed](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed). |
|
||||
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
|
||||
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam and Playnite installs, wherever they are — including a portable Playnite on another drive, which keeps its covers next to the program. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
|
||||
|
||||
## Updates
|
||||
|
||||
|
||||
@@ -40,12 +40,23 @@ set** — which is what every shipped template does — a box that has gamescope
|
||||
|
||||
### Nobara and other autologin display managers
|
||||
|
||||
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when that
|
||||
session is a display-manager autologin, it has to stop the **display manager** too, for the length
|
||||
of the stream. That is a privileged operation, and the privilege is granted to one group.
|
||||
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when a display
|
||||
manager autologs into that session, stopping it alone accomplishes nothing: the autologin puts it
|
||||
straight back. So the host **idles** that session for the length of the stream instead, with a
|
||||
systemd drop-in that replaces its `ExecStart` with a process that just sleeps. The autologin still
|
||||
succeeds (nothing relogin-loops), the session it logs into does nothing (Steam is free), and the
|
||||
**display manager keeps running** — which is what lets you still switch the box to Desktop Mode
|
||||
from Steam while a stream is up.
|
||||
|
||||
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover's root helper
|
||||
> runs for members of that group and for nobody else, so this one command is what authorizes it:
|
||||
That needs no privilege at all: the drop-in is a user-level unit override, written under
|
||||
`$XDG_RUNTIME_DIR` so it cannot outlive the login session, and a reboot clears it regardless.
|
||||
Versions before this one stopped the display manager for the stream's duration — which needed a
|
||||
root helper, the `punktfunk` group, and lingering, and left the box with nothing able to start a
|
||||
desktop session, so Steam's own "Switch to Desktop" hung until a reboot.
|
||||
|
||||
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover itself no
|
||||
> longer needs it — the group now gates the usbip nodes the virtual Steam Deck pad attaches
|
||||
> through, so without it the pad arrives as an ordinary Xbox 360 controller:
|
||||
>
|
||||
> ```sh
|
||||
> sudo usermod -aG punktfunk "$USER" # then log out and back in
|
||||
@@ -60,14 +71,12 @@ of the stream. That is a privileged operation, and the privilege is granted to o
|
||||
> symptom side is [Game Mode: black screen on
|
||||
> connect](/docs/troubleshooting#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution).
|
||||
|
||||
How the takeover gets that privilege depends on the display manager driving the autologin:
|
||||
The display-manager flavor is no longer an input — SDDM, plasmalogin and the rest all get the
|
||||
idled session above, and none of them is stopped. The root helper described below is therefore no
|
||||
longer part of a normal takeover; it is kept for the restore path, and for a box where an older
|
||||
host left a display manager stopped:
|
||||
|
||||
- **SDDM** (Bazzite, SteamOS): SDDM survives having the session unit masked, so a box without the
|
||||
grant still streams — at the cost of SDDM relogin-looping against the takeover for the whole
|
||||
stream, which churns logind sessions and can starve the game.
|
||||
- **plasmalogin** (Nobara) and other display managers: masking is fatal there (the autologin
|
||||
start-limit-kills the display manager), so the host stops the display manager itself and
|
||||
restarts it afterwards. The packages ship that privilege: a root helper
|
||||
- The packages ship it: a root helper
|
||||
(`/usr/libexec/punktfunk/pf-dm-helper`, or `/usr/lib/punktfunk/pf-dm-helper` from the Arch
|
||||
package) behind its own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically
|
||||
when the plain `systemctl` verbs are denied. The helper only stops/restores the unit the
|
||||
|
||||
Generated
+3
-3
@@ -62,11 +62,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1787070829,
|
||||
"narHash": "sha256-vXNVDVtvfiQuXthP0NHPFdNvvMTkGpx0UP8oddIWbNk=",
|
||||
"lastModified": 1784120854,
|
||||
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0ae2bc1419c3f345984c2629e72e7a631820fa4d",
|
||||
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -27,11 +27,16 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# instead of a copy of the TV, and it is also what lets a game launched from a client's library get
|
||||
# a dedicated session to itself.
|
||||
#
|
||||
# ⚠ The managed takeover has to stop the display manager for the length of the stream, and that is
|
||||
# privileged: it works for members of the `punktfunk` group and nobody else. Join it once —
|
||||
# `sudo usermod -aG punktfunk "$USER"`, then log out and back in. Skip it and the takeover cannot
|
||||
# stop SDDM, which relogin-loops against it for the whole stream and can starve the game. The host
|
||||
# checks at startup and says so in its log.
|
||||
# The takeover needs no privilege and leaves the display manager RUNNING: it idles the box's own
|
||||
# gaming session for the stream's duration (a user-level systemd drop-in that replaces the
|
||||
# session's ExecStart), so the autologin still succeeds — nothing relogin-loops — while Steam is
|
||||
# free for the stream. Because SDDM stays up, Steam's own "Switch to Desktop" still works while you
|
||||
# are streaming. Earlier versions stopped the display manager instead, which needed the `punktfunk`
|
||||
# group and left that switch hanging until a reboot.
|
||||
#
|
||||
# The `punktfunk` group is still worth joining for the virtual Steam Deck pad (it gates the usbip
|
||||
# nodes; without it the pad arrives as an ordinary Xbox 360 controller):
|
||||
# sudo usermod -aG punktfunk "$USER" # then log out and back in
|
||||
#
|
||||
# Opt IN to the ATTACH model if you would rather the BOX keep ownership: the host captures whatever
|
||||
# gamescope is live and never tears it down, so Desktop<->Game switching is rock-solid and the box
|
||||
|
||||
@@ -46,23 +46,7 @@ let
|
||||
# our own name — the single worst outcome here, because the host reads the name as a promise of
|
||||
# HDR. (`installCheckPhase` below greps for the marker as the second line of defence; this one
|
||||
# fails at eval, before anything is built.)
|
||||
# `enableWsi = true` is NOT optional here, and it is a FUNCTION ARGUMENT — `overrideAttrs`
|
||||
# cannot reach it. nixpkgs defaults `enableWsi ? false` and feeds it to
|
||||
# `mesonBool "enable_gamescope_wsi_layer"`, so the plain derivation installs the compositor and
|
||||
# no layer at all; nixpkgs gets its layer by instantiating a SECOND copy inside the wrapper.
|
||||
# Without the override the build gets all the way through compile, link and install before
|
||||
# postInstall's find turns up nothing and fails with "built no WSI layer" (MEASURED 2026-08-19,
|
||||
# run 19323) — an expensive way to discover a default.
|
||||
#
|
||||
# `.override` before `.overrideAttrs`: the former re-invokes the package function with the new
|
||||
# argument, so the latter must come after or it would be applied to the derivation being
|
||||
# replaced. The `? override` test only skips the call for something that is not overridable at
|
||||
# all (a symlinkJoin) — it does NOT make an unknown argument safe: a nixpkgs whose gamescope
|
||||
# dropped `enableWsi` fails at EVAL with "function has no argument named 'enableWsi'". That is
|
||||
# the right failure. It names the cause outright, and it costs nothing, where the alternative is
|
||||
# discovering the same fact after a full compositor build.
|
||||
raw = gamescope.unwrapped or gamescope;
|
||||
base = if raw ? override then raw.override { enableWsi = true; } else raw;
|
||||
base = gamescope.unwrapped or gamescope;
|
||||
unwrapped =
|
||||
if base ? src then
|
||||
base
|
||||
|
||||
@@ -46,7 +46,7 @@ export default definePluginKit({
|
||||
| `makeConfigService` | Schema-driven config: raw shape on disk, defaults ONLY in the Schema (`withDecodingDefaultKey` + `encodingStrategy: "omit"`), atomic writes, world-writable refusal, `changes` stream |
|
||||
| `makeCacheStore` | disposable derived state (corrupt/absent → empty, write-through) |
|
||||
| `ProviderClient` + wire schemas | typed library-provider reconcile over the untyped wire — including the optional `detect` hint (see below) |
|
||||
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip (loop triggers only — `startup` and `manual` always publish) + status feed |
|
||||
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip + status feed |
|
||||
| `serveUi` / `httpApiEnv` | an `effect/unstable/httpapi` HttpApi behind the SDK's `servePluginUi`, core-only layers |
|
||||
| `sseRoute` | the status SSE endpoint (httpapi has no event-stream media type) |
|
||||
| `runPluginCli` | `<bin> <command>` dispatcher reusing the plugin's layer graph (deliberately not `effect/unstable/cli` — that would drag platform packages into every plugin) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.2",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
// Semantics are a faithful port of the original Engine guard:
|
||||
// - single-flight: a sync while one runs records a pending trigger and returns
|
||||
// AlreadyRunning; the running pass re-fires once ("coalesced") when it finishes
|
||||
// - content fingerprint (sha256 of the entries JSON) skips the apply when unchanged —
|
||||
// except for the two reasons a person is waiting on the answer (`ALWAYS_APPLY`)
|
||||
// - content fingerprint (sha256 of the entries JSON) skips the apply when unchanged
|
||||
// - interval poll + best-effort fs watchers (recursive where the OS supports it, top-dir
|
||||
// fallback on Linux) with debounce; the poll is the real safety net on SMB/NFS
|
||||
// - every transition publishes a SyncStatus (the UI's SSE feed)
|
||||
@@ -70,28 +69,6 @@ export interface SyncSettings {
|
||||
readonly minInterval?: Duration.Duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync reasons that push to the host even when the fingerprint says nothing changed.
|
||||
*
|
||||
* A fingerprint match means WE would compute the same entries again — NOT that the host still
|
||||
* holds them. The host may accept a payload and store less of it than was sent: an art path
|
||||
* outside its allowed roots is stripped and the games kept (deliberately — a cover must not cost
|
||||
* a library), and a launcher tile it cannot open is dropped the same way. Once that happens the
|
||||
* plugin's fingerprint is a permanent "no changes": the operator fixes the host side, nothing
|
||||
* re-publishes, and the only way out is to delete the plugin's cache file. That was real
|
||||
* field advice for a portable-Playnite library whose 70 covers were dropped.
|
||||
*
|
||||
* So the two triggers with a person behind them always apply. `startup` is the restart every
|
||||
* operator reaches for, and `manual` is the console's Sync-now button and the CLI's `sync` —
|
||||
* both mean "publish my library NOW", and answering "no changes" to that is the trap. The loop
|
||||
* reasons (`poll`, `fs-change`, `config-change`, `coalesced`) keep the short-circuit, which is
|
||||
* where it earns its keep: they are what would otherwise PUT the whole library every few minutes.
|
||||
*/
|
||||
const ALWAYS_APPLY: ReadonlySet<SyncReason> = new Set<SyncReason>([
|
||||
"startup",
|
||||
"manual",
|
||||
]);
|
||||
|
||||
/** `SyncSettings.minInterval` when a plugin does not set one. */
|
||||
export const DEFAULT_FS_CHANGE_MIN_INTERVAL: Duration.Duration =
|
||||
Duration.seconds(30);
|
||||
@@ -198,7 +175,7 @@ export const makeSyncEngine = <
|
||||
yield* Ref.set(lastReport, report);
|
||||
const fp = fingerprint(entries);
|
||||
const prev = yield* run(opts.lastSync.get);
|
||||
if (!ALWAYS_APPLY.has(reason) && prev?.fingerprint === fp) {
|
||||
if (prev?.fingerprint === fp) {
|
||||
yield* Effect.log(
|
||||
`sync (${reason}): no changes (${entries.length} entries)`,
|
||||
);
|
||||
|
||||
@@ -52,13 +52,11 @@ const run = <A>(eff: Effect.Effect<A, unknown, Scope.Scope>): Promise<A> =>
|
||||
|
||||
describe("SyncEngine", () => {
|
||||
test("first sync applies; unchanged content skips the apply", async () => {
|
||||
// A LOOP reason, deliberately: the fingerprint skip is what keeps a 5-minute poll from
|
||||
// PUTting the whole library forever. `startup`/`manual` opt out of it (below).
|
||||
const { first, second, count } = await run(
|
||||
Effect.gen(function* () {
|
||||
const h = yield* harness();
|
||||
const first = yield* h.engine.sync("poll");
|
||||
const second = yield* h.engine.sync("poll");
|
||||
const first = yield* h.engine.sync("manual");
|
||||
const second = yield* h.engine.sync("manual");
|
||||
return {
|
||||
first,
|
||||
second,
|
||||
@@ -72,34 +70,6 @@ describe("SyncEngine", () => {
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* The host can store LESS than it was sent (an out-of-root cover is stripped, the games kept),
|
||||
* and the fingerprint cannot see that — it only says we would compute the same thing again. So
|
||||
* the two triggers a person is waiting on re-publish regardless, and the fix for a mangled
|
||||
* host-side copy is a restart or the Sync button rather than deleting the plugin's cache.
|
||||
*/
|
||||
test("startup and manual re-apply even when nothing changed", async () => {
|
||||
const counts = await run(
|
||||
Effect.gen(function* () {
|
||||
const h = yield* harness();
|
||||
yield* h.engine.sync("poll"); // first apply, fingerprint stored
|
||||
const afterPoll = yield* Ref.get(h.applied);
|
||||
const startup = yield* h.engine.sync("startup");
|
||||
const manual = yield* h.engine.sync("manual");
|
||||
// …and the loops still skip, with the same fingerprint in place.
|
||||
const loop = yield* h.engine.sync("fs-change");
|
||||
return {
|
||||
afterPoll,
|
||||
tags: [startup._tag, manual._tag, loop._tag],
|
||||
total: yield* Ref.get(h.applied),
|
||||
};
|
||||
}),
|
||||
);
|
||||
expect(counts.afterPoll).toBe(1);
|
||||
expect(counts.tags).toEqual(["Applied", "Applied", "Unchanged"]);
|
||||
expect(counts.total).toBe(3);
|
||||
});
|
||||
|
||||
test("changed content re-applies", async () => {
|
||||
let call = 0;
|
||||
const { outcomes, count } = await run(
|
||||
|
||||
Reference in New Issue
Block a user