Compare commits

..
Author SHA1 Message Date
enricobuehler 141c04cd6b feat(web-console): "Update all" on the plugins screen
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 2m39s
ci / rust-arm64 (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 7m55s
The Installed tab could only update one plugin at a time, one dialog and one
watched job each. This adds the bulk action beside the list it acts on — the
same place Sources keeps "Refresh all" — plus a count badge on the Installed
tab trigger, because Browse is the tab the page opens on and a control nobody
passes is a control nobody finds.

The host takes ONE package operation at a time (409 otherwise: bun operations
share a lockfile and a node_modules tree), so this is a queue the console works
through job by job, driven by each job settling rather than by a timer. The run
carries its own copy of what is left: every finished install invalidates the
installed list, and a queue that re-derived itself would change shape underneath
a run the operator already confirmed.

Trust rules are unchanged, only taken once instead of N times. If any entry in
the run comes from an operator-added source the whole dialog wears the external
treatment and names those catalogs — a bulk action must not be a way to wave
through, in one click, a warning each package would have shown on its own. The
dialog lists every version change rather than a count, and names what it will
not attempt: an update with no catalog entry, or one this host would refuse
(400 on incompatible, blocked entries) never enters the queue, so the button's
count still adds up on screen.

A failure ends the run. The failed job's card is the only record of what went
wrong, and starting the next install would replace it with a fresh spinner; the
toast says what was applied and what was not, and the rows are still there to
retry from.

Also fixed, because this change leans on it: disabled buttons were invisible.
AnimatedButton is a motion element and its mount animation settles as an inline
`opacity: 1`, which outranks the `disabled:opacity-50` class the library also
ships — measured `opacity: 1` on a disabled button, console-wide. Only
`pointer-events: none` landed, so every disabled control in the app looked live
and silently ignored the click. Corrected in the components/ui wrapper layer
like the other @unom/ui adaptations.

Verified: tsc, biome, `bun test server/`, production build, i18n check (650
messages, en + de). Storybook stories added for the list header and the confirm
dialog; both rendered headless in light and dark, with the disabled states
asserted on the DOM rather than by eye.
2026-08-13 16:43:05 +02:00
enricobuehler 155cced56b Merge pull request 'fix(flatpak): pin the skia-binaries archive to 0.99.0 — #193 bumped the crate and left the tarball at 0.87' (#196) from worktree-flatpak-skia-099-pin into main
android / android (push) Canceled after 9m25s
ci / rust (push) Canceled after 7m50s
ci / rust-arm64 (push) Canceled after 4m48s
ci / web (push) Canceled after 1m31s
ci / docs-site (push) Canceled after 32s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
arch / build-publish (push) Successful in 11m33s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m30s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m27s
flatpak / build-publish (push) Successful in 25m44s
deb / build-publish (push) Successful in 4m8s
deb / build-publish-client-arm64 (push) Successful in 3m10s
deb / build-publish-gamescope (push) Successful in 55s
deb / build-publish-host (push) Successful in 5m53s
deb / smoke-install (push) Successful in 3m53s
2026-08-13 13:53:18 +00:00
enricobuehler 8f66fafb90 Merge pull request 'fix(pf-vdisplay): verify the size KWin actually built the virtual output at (≤60 Hz path)' (#194) from worktree-moonlight-4k60-dims into main
android / android (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Successful in 12m40s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
Reviewed-on: #194
2026-08-13 13:52:29 +00:00
enricobuehler 6fe53fff2b Merge pull request 'Serialize Mutter monitor rebuilds end-to-end — the two-client chain no longer segfaults gnome-shell' (#195) from worktree-mutter-rebuild-serialization into main
android / android (push) Canceled after 35s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #195
2026-08-13 13:51:48 +00:00
enricobuehler a5c9b7b865 fix(pf-vdisplay): serialize Mutter monitor rebuilds end-to-end, not just our D-Bus calls
ci / web (pull_request) Successful in 1m17s
ci / bun-nix (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 1m27s
android / android (pull_request) Successful in 4m36s
ci / rust-arm64 (pull_request) Successful in 6m14s
ci / rust (pull_request) Successful in 33m50s
Chaining two clients through a kept (keep-alive) Mutter display segfaults
gnome-shell in meta_monitor_manager_rebuild (libmutter-18) and takes the whole
desktop down; every later session then fails RemoteDesktop.CreateSession:
ServiceUnknown until GDM restarts. A/B'd on .21: byte-identical on released
0.27.0 and the 0.28.0 RC, so it was never a regression — the trigger has been
there all along.

TOPOLOGY_LOCK already serialized every topology-mutating D-Bus call, but two
gaps still let Mutter's REBUILDS overlap:

- Teardown was fire-and-forget: StopGuard::drop set a flag and returned, and
  the session thread only noticed on its ≤200 ms park tick. The A2 dead-reuse
  path (reused kept display dead on first frame → mark_failed → re-create)
  therefore issued its fresh RecordVirtual with the doomed monitor's removal
  still pending — the fresh session could even win the lock BEFORE the old
  thread had woken to take it, adding a monitor while the dead one still stood.
  The drop now waits (bounded, 20 s) for the session thread to finish.

- The lock was released while the shell was still rebuilding: Stop /
  RecordVirtual / ApplyMonitorsConfig all return mid-rebuild, and a temporary
  (APPLY_TEMPORARY) config auto-reverts asynchronously on top. Every locked
  mutation section now ends with settle_topology() — poll GetCurrentState
  until a removed connector is actually gone and the config serial holds still
  across two consecutive reads — before the guard drops. Bounded at 4 s and
  best-effort (a read error means the shell is gone; a hotplug storm must not
  park sessions), degrading to exactly the old behavior.

Cost when Mutter is already quiet: one confirming read plus one 150 ms recheck
per setup/teardown. The live_mutter_create_drop harness sheds its grace sleep —
the synchronous drop IS the teardown confirmation now.

Not fixed here, documented on TOPOLOGY_LOCK: the mid-stream mode-switch rebuild
is create-before-drop by design (H2), so its RecordVirtual still lands while
the superseded monitor exists; the settle makes Mutter quiescent at that point
but cannot remove the coexistence itself.
2026-08-13 15:41:43 +02:00
enricobuehler 6237e3d0a3 fix(pf-vdisplay): the KWin ≤60 Hz path never checked what KWin actually built, and the log reported the request as if it were a readback
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 2m31s
ci / rust-arm64 (pull_request) Successful in 4m14s
android / android (pull_request) Successful in 4m53s
ci / rust (pull_request) Successful in 25m8s
A 4K60 GameStream session captured 1920x1080. `create()` asked KWin for
3840x2160, KWin built something else, and nothing compared the two: only the
>60 Hz arm read anything back, and it gets that for free because it installs a
custom mode. The ≤60 Hz arm installs nothing, which is exactly why it never
noticed.

The line that should have caught it was the one that hid it. `spawn_vout`
returns a node id, never a size, so

    tracing::info!(node_id, width, height, "KWin virtual output ready")

was echoing the REQUEST — the field log stated 3840x2160 while the output was
1080p, and the first pass at diagnosing this was done against that number. It
now logs `requested_w`/`requested_h`, and the readback sits under it.

Unverified, the mismatch was silent and total. `final_dims` carried the request
forward, so `apply_topology`, `clear_replication_source` and
`resolve_kscreen_addr` — all of which resolve by dims — quietly missed their own
output, leaving the stream neither primary nor de-mirrored; and the encoder
opened at the captured size, handing the client a bitstream that disagreed with
the resolution it had configured its decoder from.

Suspected trigger is KWin restoring per-output mode/scale from
kwinoutputconfig.json, which is keyed by output NAME — and ours is deliberately
stable across sessions so KDE reapplies that client's scaling (Stage 3). The
feature and the failure are the same mechanism.

- `kwin_output_mgmt::actual_dims()` reads the output's real mode + scale.
  Resolution is by name alone, so it declines unless EXACTLY one output carries
  our prefix: two means a supersede is in flight, and the dims filter is the
  only thing that can tell the replacement from the predecessor whose name it
  reuses. Failing closed keeps this a pure addition.
- On a mismatch, re-assert the requested mode through the same
  `set_custom_mode` install+select the sacrificial birth already uses (an output
  at a size we don't want, moved to one we do) and arm `expect_exact_dims` so
  the capturer holds frames until the screencast renegotiates. 60 Hz is
  requested, not `mode.refresh_hz`: only the size is wrong here, and asking for
  the client's rate would install a 30 Hz mode for a 30 fps client.
- If KWin refuses the correction, report the size that is REALLY there rather
  than the request, so the dims-keyed resolves and the encoder key on reality,
  and say in the log how to clear the stored entry.
- Scale is logged, never corrected — a non-unity scale here is the Stage 3
  feature working, not a fault.
- `mode_satisfies()` extracts the acceptance predicate both arms now share, so
  they cannot drift into disagreeing about what "we got what we asked for"
  means. Tested: a restored 1080p does not pass for a 4K request, a CVT-aligned
  width does, and the slack is bounded, one-sided and width-only.

The stream-side warning is reworded but deliberately still NOT fatal: mirroring
a pinned monitor streams a size the client never negotiated BY DESIGN (§7.3 — a
panel runs at the mode its owner set and the client scales), so refusing the
mismatch would break every mirror session. It now names both causes and states
what the client actually does with the stream.

Does not claim to close the Xbox Moonlight disconnect it was found through: that
client's IDR storm begins ~4.6 s after the first frame, which a decoder simply
unable to handle the size would not do. The 1080p-instead-of-4K is a real defect
on its own terms and is what this fixes.
2026-08-13 15:26:35 +02:00
14 changed files with 1042 additions and 53 deletions
+177 -6
View File
@@ -302,10 +302,14 @@ impl VirtualDisplay for KwinDisplay {
let want_high = mode.refresh_hz > 60;
let birth_h = if want_high { height + 16 } else { height };
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
// `requested_*`, NOT `width`/`height`: `spawn_vout` hands back a node id, never a size, so
// every number on this line is what we ASKED for. Logged as `width=… height=…` it read like
// a readback of what KWin built, and a field report where KWin had actually built a 1080p
// output was diagnosed against a log line stating 3840x2160. The readback is below.
tracing::info!(
node_id,
width,
height,
requested_w = width,
requested_h = height,
birth_h,
embedded_pointer = !self.hw_cursor,
"KWin virtual output ready"
@@ -346,9 +350,7 @@ impl VirtualDisplay for KwinDisplay {
// width at or just below the request (a CVT alignment). That also proves the output
// left the sacrificial birth size, so the recording stream will renegotiate to it.
match active {
Some((aw, ah, ahz))
if ah == height && aw <= width && width - aw < CVT_H_GRANULARITY =>
{
Some((aw, ah, ahz)) if mode_satisfies((aw, ah), width, height) => {
expect_exact_dims = true;
final_dims = (aw, ah);
ahz
@@ -381,6 +383,125 @@ impl VirtualDisplay for KwinDisplay {
}
}
} else {
// ≤60 Hz installs no mode, so nothing here ever learned what KWin actually built — and
// KWin does not necessarily build what it was asked for. `OutputConfigurationStore`
// restores per-output mode AND scale from `kwinoutputconfig.json` keyed by output NAME,
// and ours is stable across sessions by design (Stage 3, so KDE reapplies that client's
// scaling) — so a slot that last ran at 1080p gets 1080p put back on top of the 4K we
// just requested. The >60 Hz arm above is immune only incidentally: it installs a mode,
// so it gets a readback for free.
//
// Unverified, that mismatch is silent and total. The capture builds at KWin's size, the
// encoder opens against it, and Moonlight — which configured its decoder for the size it
// negotiated over RTSP — receives a bitstream it cannot decode, asks for a keyframe
// every ~50 ms, and drops the session. Meanwhile every dims-keyed resolve below
// (`apply_topology`, `clear_replication_source`, `resolve_kscreen_addr`) is looking for
// an output at the requested size and quietly finding nothing, so the stream isn't even
// made primary or de-mirrored.
match crate::kwin_output_mgmt::actual_dims(&our_prefix) {
// KWin honoured the request — the overwhelmingly common case. No configuration is
// built and nothing is applied: byte-for-byte the behaviour this arm always had.
//
// The scale is recorded rather than corrected. A non-1.0 scale here is NOT a fault
// to repair: the stable output name exists precisely so KDE reapplies this client's
// scaling on reconnect (Stage 3), so forcing the 1.0 we asked `stream_virtual_output`
// for would undo a feature. It is logged because it is the other half of the stored
// per-output config, and because the pixel-vs-logical question it raises is exactly
// what a future "the size is right but the capture is halved" report will turn on —
// KWin's output screencast streams the source's PIXEL size, so a scale should not
// move the captured dimensions, and a report showing otherwise would be the evidence
// that assumption is wrong on some KWin version.
Some((aw, ah, _, scale)) if (aw, ah) == (width, height) => {
if scale != 1.0 {
tracing::debug!(
width,
height,
scale,
"KWin virtual output verified at the requested size, carrying a stored \
non-unity scale (per-client scaling — capture is unaffected)"
);
}
}
Some((aw, ah, _, scale)) => {
tracing::warn!(
actual_w = aw,
actual_h = ah,
requested_w = width,
requested_h = height,
stored_scale = scale,
our_prefix,
"KWin built our virtual output at a DIFFERENT size than requested (a stored \
kwinoutputconfig.json mode/scale for this output name) — re-asserting the \
requested mode so the stream matches what the client negotiated"
);
// Re-assert the requested size through the SAME install+select the sacrificial
// birth uses above: an output sitting at a size we don't want, moved to the one
// we do, with the screencast stream renegotiating to it on the first buffers
// recorded after the consumer connects. `aw`/`ah` play the birth size — that is
// literally what they are here, just not deliberately.
//
// 60 Hz, NOT `mode.refresh_hz`: this arm is ≤60 Hz by construction and only the
// SIZE is wrong. Asking for the client's rate would install a 30 Hz mode for a
// 30 fps client and throttle the compositor to it — a behaviour change fixing a
// size has no business making. KWin's virtual outputs are 60 Hz natively and
// `achieved_hz` below stays the client's rate exactly as before.
match crate::kwin_output_mgmt::set_custom_mode(
&our_prefix,
aw,
ah,
width,
height,
60,
) {
// Same acceptance test as the high-refresh arm — literally, so the two can
// never drift. That the mode moved at all also proves the screencast will
// renegotiate, which is what `expect_exact_dims` then waits for.
Some((cw, ch, _)) if mode_satisfies((cw, ch), width, height) => {
expect_exact_dims = true;
final_dims = (cw, ch);
tracing::info!(
active_w = cw,
active_h = ch,
"KWin virtual output corrected to the requested size"
);
}
other => {
// Correction refused (pre-6.6 KWin has no `set_custom_modes`, or the
// compositor didn't answer). Report the size that is REALLY there, not
// the one we asked for: the dims-keyed resolves below and the encoder
// all key on `final_dims`, and carrying the request forward is what
// made this a silent failure rather than a degraded one. The session
// still runs, at KWin's size: the stream layer warns that the client is
// decoding something other than what it negotiated but does NOT refuse
// it, because a monitor mirror legitimately streams a size the client
// never asked for (§7.3) and failing here would break every one.
tracing::warn!(
active = ?other,
actual_w = aw,
actual_h = ah,
requested_w = width,
requested_h = height,
"KWin would not re-assert the requested mode — the output is STUCK \
at its stored size. Clear this output's entry from \
kwinoutputconfig.json (or set it to the streamed resolution in \
System Settings → Display) and reconnect"
);
final_dims = (aw, ah);
}
}
}
// Management unavailable, or two outputs share our name (a supersede in flight, the
// one case only a dims-keyed resolve can disambiguate). Nothing verifiable to act
// on, so carry on exactly as this arm always did rather than reconfigure an output
// we cannot identify.
None => {
tracing::debug!(
our_prefix,
"KWin: could not read back the virtual output's actual mode (management \
unavailable or a same-named supersede in flight) — proceeding unverified"
);
}
}
mode.refresh_hz
};
// Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
@@ -733,6 +854,25 @@ fn monitors_from_kscreen_json(doc: &serde_json::Value) -> Vec<crate::monitors::P
/// compiler was checking.
pub(crate) const CVT_H_GRANULARITY: u32 = 8;
/// Does the mode that actually went ACTIVE satisfy a request for `want_w`×`want_h`?
///
/// Exact height, and a width at or just below the request — never an exact width, because KWin
/// generates custom timings through libxcvt and that rounds the width DOWN to the cell grain
/// ([`CVT_H_GRANULARITY`]). Demanding an exact width would reject the very mode we just asked KWin
/// to build, for phone-shaped clients (see the constant's note).
///
/// Both arms of [`VirtualDisplay::create`] that put a mode on the output test their readback
/// through here — the sacrificial high-refresh birth, and the correction for a size KWin restored
/// from its stored per-output config. They are the same question and they were, briefly, two copies
/// of the same expression; one place to change it is the point.
///
/// A width ABOVE the request fails: `aw <= want_w` guards the subtraction on the next line, and a
/// mode wider than we asked for is not a CVT alignment of our request — it is somebody else's mode.
fn mode_satisfies(active: (u32, u32), want_w: u32, want_h: u32) -> bool {
let (aw, ah) = active;
ah == want_h && aw <= want_w && want_w - aw < CVT_H_GRANULARITY
}
/// One row of an output's mode list, as parsed from `kscreen-doctor -j`.
#[derive(Clone, Debug, PartialEq)]
struct KModeRow {
@@ -1851,9 +1991,40 @@ fn await_created(
#[cfg(test)]
mod tests {
use super::{
modes_from_json, monitors_from_kscreen_json, pick_custom_mode, KModeRow, MANAGED_PREFIX,
mode_satisfies, modes_from_json, monitors_from_kscreen_json, pick_custom_mode, KModeRow,
MANAGED_PREFIX,
};
/// The field failure this predicate now guards, in the shape the log reported it: a client
/// negotiated 3840x2160, KWin restored a stored 1920x1080 for the output name, and nothing
/// compared the two — so the session captured 1080p, encoded 1080p, and shipped it to a client
/// that had configured its decoder for 4K. Half the requested size is not an alignment.
#[test]
fn a_restored_stored_mode_does_not_pass_for_the_requested_one() {
assert!(!mode_satisfies((1920, 1080), 3840, 2160));
}
/// The case the predicate must NOT reject, and the reason it can't just test equality: libxcvt
/// rounds a width down to the 8-px cell grain, so the mode KWin builds for a 2868-wide request
/// really is 2864 wide. Rejecting it would strand the output on its birth mode.
#[test]
fn a_cvt_aligned_width_still_satisfies_the_request() {
assert!(mode_satisfies((2864, 1320), 2868, 1320));
assert!(mode_satisfies((3840, 2160), 3840, 2160)); // exact is the common case
}
/// The alignment slack is bounded and one-sided. A width 8+ px short is a different mode, not a
/// rounding of ours; a width ABOVE the request is somebody else's mode entirely (and is what
/// would underflow the subtraction if the `<=` guard were ever dropped); and the height is
/// never rounded, so it must match exactly.
#[test]
fn the_alignment_slack_is_bounded_one_sided_and_width_only() {
assert!(mode_satisfies((3833, 2160), 3840, 2160)); // 7 short — inside the grain
assert!(!mode_satisfies((3832, 2160), 3840, 2160)); // 8 short — a different mode
assert!(!mode_satisfies((3848, 2160), 3840, 2160)); // wider than asked
assert!(!mode_satisfies((3840, 2159), 3840, 2160)); // height is never aligned
}
fn row(id: &str, w: u32, h: u32, hz: f64) -> KModeRow {
KModeRow {
id: id.to_string(),
@@ -1030,6 +1030,44 @@ pub(crate) fn clear_replication_source(our_prefix: &str, our_w: u32, our_h: u32)
}
}
/// The size + scale our just-created virtual output ACTUALLY landed at, read only.
///
/// [`resolve_ours`] keys on the size we asked KWin for, which answers "is our output there?" but
/// can never answer "did KWin give us what we asked for?" — a miss is indistinguishable from an
/// output that simply hasn't appeared. That gap is not theoretical: KWin restores per-output config
/// (mode AND scale) from `kwinoutputconfig.json` keyed by output NAME, and ours is deliberately
/// stable across sessions (see the note on [`is_mirroring`]), so a stored 1080p mode left by an
/// earlier session is re-applied on top of the 4K we just requested. Every dims-keyed caller then
/// silently misses — topology, de-mirror, position — and the capture pipeline builds at a size the
/// client never negotiated.
///
/// Resolution is by NAME ALONE, so it deliberately declines (`None`) unless EXACTLY ONE output
/// carries our prefix. Two matches means a supersede is in flight, and the dims filter is the only
/// thing that can tell the replacement from the predecessor it reuses the name of — picking wrong
/// here would hand the caller the doomed output's size and, worse, invite it to reconfigure the
/// output that is about to disappear. Failing closed leaves today's behaviour untouched; the
/// verification is an addition, never a new way to get it wrong. (A prefix that is also a prefix of
/// a sibling slot's name — `-7` vs `-70` — reads as ambiguous and declines for the same reason.)
///
/// Returns `(width, height, refresh_mHz, scale)`. Scale is reported for the log rather than acted
/// on: KWin's output screencast streams the source's PIXEL size, so a restored scale shifts the
/// desktop's logical layout without changing what we capture — but it is the other half of the
/// stored config, and naming it in the log is what turns "why is this 1080p" into one glance.
pub(crate) fn actual_dims(our_prefix: &str) -> Option<(u32, u32, u32, f64)> {
let sess = Session::open("verify_dims").ok()?;
let mut matches = sess.state.devices.values().filter(|d| {
// `seen_done`: a device mid-announce has no coherent current_mode to read, and reading one
// anyway is how you get a "KWin gave us 0x0" correction that stomps a healthy output.
d.seen_done && d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
});
let ours = matches.next()?;
if matches.next().is_some() {
return None;
}
let (w, h, mhz) = sess.current_dims(ours)?;
Some((w, h, mhz, ours.scale.filter(|s| *s > 0.0).unwrap_or(1.0)))
}
/// Install + select a `want_w`×`want_h`@`want_hz` custom mode on the just-created virtual output
/// (name starts with `our_prefix`, currently at its sacrificial birth size `birth_w`×`birth_h`) —
/// entirely over `kde_output_management_v2`, the in-process replacement for the `kscreen-doctor`
+157 -8
View File
@@ -75,6 +75,20 @@ const CURSOR_EMBEDDED: u32 = 1;
/// appearing at once, "the connector absent from MY pre-snapshot" can name a sibling's monitor.
/// Each session runs on its own dedicated thread (see [`session_thread`]), so blocking on a std
/// mutex — including across the awaits of its single-threaded setup future — is safe.
///
/// The lock alone is NOT enough, because Mutter's rebuilds outlive our D-Bus calls: `Stop` /
/// `RecordVirtual` / `ApplyMonitorsConfig` return while the shell is still rebuilding (and, for a
/// session whose config was applied `APPLY_TEMPORARY`, still auto-reverting it). Releasing the lock
/// at that point hands the next session a NON-QUIESCENT Mutter, and its first mutation rebuilds
/// concurrently with the leftover one — the exact `meta_monitor_manager_rebuild` SIGSEGV again,
/// reproduced on 2026-08-08 (mid-bringup mode switch: two `RecordVirtual`s ~1 s apart) and
/// 2026-08-13 (keep-alive reuse dead on first frame → teardown + immediate re-create; A/B'd
/// identical on 0.27.0 and the 0.28.0 RC, so it was never a regression). So every locked mutation
/// section ends with [`settle_topology`] — poll DisplayConfig until the change is visible and the
/// config serial stops moving — BEFORE the guard drops. And because ordering across sessions runs
/// through the keepalive drop, [`StopGuard`]'s `Drop` must be SYNCHRONOUS (wait for the session
/// thread to finish its Stop + settle): a fire-and-forget flag let the A2 re-create win the lock
/// before the doomed session's thread had even woken to take it.
static TOPOLOGY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
@@ -183,11 +197,18 @@ impl VirtualDisplay for MutterDisplay {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
// Teardown confirmation: the sender lives exactly as long as the session thread, so the
// guard's `Drop` can WAIT on `Disconnected` for the thread to finish its Stop + settle
// (see TOPOLOGY_LOCK — the drop is the only happens-before edge ordering "old monitor
// removed" against "next monitor created").
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let first_in_group = self.first_in_group;
let hw_cursor = self.hw_cursor;
thread::Builder::new()
.name("punktfunk-mutter-vout".into())
.spawn(move || {
// Dropped when the thread returns — every exit path signals `done_rx`.
let _done = done_tx;
session_thread(
setup_tx,
stop_thread,
@@ -204,7 +225,10 @@ impl VirtualDisplay for MutterDisplay {
// that finishes after we gave up then parks for at most one 200 ms tick. `report_node` is
// the primary defence (it stops the session outright); this is the belt-and-braces half,
// and it also covers a thread that is somewhere else entirely when the timeout fires.
let guard = StopGuard(stop);
let guard = StopGuard {
stop,
done: done_rx,
};
// 45 s (was 20 s): setups now queue on TOPOLOGY_LOCK, so a session behind a slow sibling
// (whose guard spans up to a ~10 s stream wait + 6 s connector wait + the apply) must
@@ -230,11 +254,35 @@ impl VirtualDisplay for MutterDisplay {
/// Dropping this ends the keepalive thread, closing the D-Bus connection — Mutter then tears
/// the remote-desktop + screencast sessions (and the virtual monitor) down.
struct StopGuard(Arc<AtomicBool>);
///
/// The drop is SYNCHRONOUS: it waits (bounded) for the session thread to confirm the teardown —
/// Stop issued, the monitor removal settled under [`TOPOLOGY_LOCK`]. The registry drops these
/// outside its pool lock and documents that the drop may block, and the callers that immediately
/// re-create (the A2 dead-reuse teardown, a mode-switch retire) are exactly the ones that NEED the
/// wait: with the old fire-and-forget flag, the fresh session's `RecordVirtual` could win
/// `TOPOLOGY_LOCK` before this session's thread had woken (≤200 ms park tick) to take it, adding a
/// monitor while the doomed one still stood — gnome-shell then died rebuilding the monitor manager
/// (`meta_monitor_manager_rebuild`, 2026-08-13, byte-identical on 0.27.0 and the 0.28.0 RC).
struct StopGuard {
stop: Arc<AtomicBool>,
/// Signals `Disconnected` when the session thread — which owns the paired sender — returns.
done: std::sync::mpsc::Receiver<()>,
}
impl Drop for StopGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
self.stop.store(true, Ordering::Relaxed);
// Generous: teardown is one ~200 ms park tick + Stop + a ≤4 s settle, but the thread may
// first have to outwait a sibling's setup holding TOPOLOGY_LOCK (up to ~16 s of stream +
// connector waits). Timing out is degraded-but-safe: the next mutation still queues on the
// lock; only the wake-up ordering guarantee is lost.
match self.done.recv_timeout(Duration::from_secs(20)) {
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
"mutter: virtual-output teardown did not confirm within 20 s — proceeding; the \
next topology mutation may race the shell's rebuild"
),
}
}
}
@@ -318,6 +366,10 @@ fn session_thread(
Ok(s) => s,
Err(e) => {
let _ = setup_tx.send(Err(format!("{e:#}")));
// A half-built session can still have ADDED the monitor (`RecordVirtual` succeeded,
// the node-id wait didn't) — its connections dropped inside `connect`, so Mutter is
// now removing it. Settle that rebuild before the guard releases the lock.
settle_topology(None, None).await;
return;
}
};
@@ -325,6 +377,10 @@ fn session_thread(
// mutates the operator's desktop topology on behalf of a session that, past this point,
// would have no way to undo it.
if !report_node(&setup_tx, &session).await {
// `report_node` already stopped the session — the virtual monitor is being removed.
// Settle under the still-held lock (same reasoning as the teardown below).
drop(session);
settle_topology(None, None).await;
return;
}
// The send can also LAND in the moment the opener's `recv_timeout` gives up — the value sits
@@ -338,6 +394,8 @@ fn session_thread(
the desktop topology"
);
let _ = session.rd_session.call_method("Stop", &()).await;
drop(session);
settle_topology(None, None).await;
return;
}
@@ -382,6 +440,11 @@ fn session_thread(
}
}
// The lock's promise is "one rebuild at a time", which holds only if the rebuilds THIS
// setup caused — the `RecordVirtual` add, the `ApplyMonitorsConfig`, and Mutter's own
// auto-revert of any sibling's temporary config — are finished before it is released.
// Cheap when Mutter is already quiet (one confirming read + one 150 ms recheck).
settle_topology(tracked.as_ref().map(|(dc, _, _)| dc), None).await;
drop(topology_guard);
// Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s,
@@ -414,13 +477,96 @@ fn session_thread(
// the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we
// just drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
// The Stop (+ the revert it triggers) is a topology mutation too — take TOPOLOGY_LOCK so a
// sibling's teardown or setup can't interleave with the rebuild it causes.
// sibling's teardown or setup can't interleave with the rebuild it causes. And HOLD it
// until the removal has actually settled: `Stop` returns while the shell is still
// rebuilding, and the very next thing after this teardown is often a fresh create (the A2
// dead-reuse re-create, a mode-switch retire) whose `RecordVirtual` must not land in that
// window — that overlap is the reproduced `meta_monitor_manager_rebuild` SIGSEGV.
let _topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _ = session.rd_session.call_method("Stop", &()).await;
let vconn = tracked.as_ref().map(|(_, _, v)| v.clone());
// Close our own handles FIRST — the APPLY_TEMPORARY revert waits on the DisplayConfig
// connection in `tracked` closing (see above) — then observe the settle on a fresh one.
drop(tracked);
drop(session);
settle_topology(None, vconn.as_deref()).await;
});
}
/// Wait, bounded, for Mutter's monitor topology to go QUIET — called at the end of every
/// [`TOPOLOGY_LOCK`]-holding mutation section, before the guard drops (see the lock's docs for the
/// two field crashes this closes). Two phases, both polled over `GetCurrentState`:
///
/// 1. when `gone` names a just-removed virtual connector, wait until it is actually absent (its
/// `Stop` returned before the shell finished the removal rebuild);
/// 2. wait until the config serial holds still across two consecutive reads — the rebuilds we
/// caused (add/remove/apply, plus Mutter's own auto-revert of a temporary config) each bump it.
///
/// `dc` reuses the session's open DisplayConfig proxy when it has one; otherwise a fresh
/// short-lived connection is opened (the teardown path deliberately closes its own first — the
/// APPLY_TEMPORARY revert waits on that close). Best-effort by design: a read error usually means
/// the shell is gone (crashed or logging out), and the deadline keeps an unrelated hotplug storm
/// from parking a session forever — both degrade to "proceed", which is exactly the old behavior.
async fn settle_topology(dc: Option<&zbus::Proxy<'_>>, gone: Option<&str>) {
let fresh;
let dc = match dc {
Some(p) => p,
None => match display_config().await {
Ok(p) => {
fresh = p;
&fresh
}
Err(_) => {
// Nothing to observe (no DisplayConfig — a crashed shell?): a fixed grace still
// beats returning into the next mutation instantly.
tokio::time::sleep(Duration::from_millis(300)).await;
return;
}
},
};
let started = Instant::now();
let deadline = started + Duration::from_secs(4);
if let Some(conn) = gone {
loop {
match get_state(dc).await {
Ok(s) if !connectors(&s).contains(conn) => break,
Ok(_) if Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(100)).await;
}
_ => break, // read error (shell gone) or deadline — proceed either way
}
}
}
let mut last: Option<u32> = None;
loop {
match get_state(dc).await {
Ok(s) => {
if last == Some(s.0) {
break;
}
last = Some(s.0);
}
Err(_) => break,
}
if Instant::now() >= deadline {
tracing::warn!(
"mutter: the monitor topology did not settle within 4 s — proceeding (a concurrent \
hotplug?)"
);
break;
}
tokio::time::sleep(Duration::from_millis(150)).await;
}
let waited = started.elapsed();
if waited > Duration::from_millis(600) {
tracing::info!(
waited_ms = waited.as_millis() as u64,
removed = gone.is_some(),
"mutter: waited out a monitor-topology rebuild before releasing the lock"
);
}
}
/// Record an **existing** monitor by connector — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L2). Returns the PipeWire node id and the keepalive
/// whose drop stops the recording.
@@ -1543,10 +1689,13 @@ mod tests {
);
std::thread::sleep(std::time::Duration::from_secs(3));
// The keepalive's Drop is synchronous: it returns only once the session thread has run the
// Stop and settled the removal rebuild (see `StopGuard`), so no grace sleep is needed.
let dropped_at = std::time::Instant::now();
drop(out);
// The keepalive's Drop only SIGNALS the thread; give it more than one 200 ms tick to run
// the Stop + topology revert before the harness exits and takes the process with it.
std::thread::sleep(std::time::Duration::from_secs(2));
println!("dropped — gnome-shell should have removed the monitor and reverted the topology");
println!(
"dropped in {:?} — gnome-shell should have removed the monitor and reverted the topology",
dropped_at.elapsed()
);
}
}
+22 -1
View File
@@ -1095,10 +1095,31 @@ fn stream_body(
// The first frame establishes the authoritative size/format for the encoder.
let mut frame = capturer.next_frame().context("capture first frame")?;
if frame.width != cfg.width || frame.height != cfg.height {
// Deliberately NOT fatal, and deliberately not "resize the output" (which read as an
// instruction nothing was carrying out). A mismatch has two very different causes and only
// one of them is a fault:
//
// * Mirroring a pinned monitor — EXPECTED. §7.3: a panel runs at the mode its owner set
// and the client scales, so `open_gs_mirror_source` passes the client's mode purely to
// keep the argument honest and the backend ignores it. Failing here would break every
// mirror session.
// * A virtual display — a FAULT. That output was created at the client's negotiated size
// precisely so this can't happen, so a mismatch means the backend did not build what it
// was asked for. The KWin backend now reads its output back and re-asserts the mode, so
// look for its `KWin built our virtual output at a DIFFERENT size` / `would not
// re-assert` lines just above this one — they carry the cause and the remedy.
//
// What the client does with it is the part worth stating plainly: the encoder opens at the
// CAPTURED size below, so Moonlight receives a bitstream that disagrees with the resolution
// it configured its decoder from. Tolerant decoders re-init off the SPS and scale; strict
// ones (Media Foundation on Xbox) may instead produce nothing and ask for a keyframe every
// ~50 ms until the client gives up and drops the session.
tracing::warn!(
captured = ?(frame.width, frame.height),
negotiated = ?(cfg.width, cfg.height),
"captured size != negotiated size — Moonlight expects the negotiated size; resize the output"
"captured size != negotiated size — the client decodes a stream that disagrees with \
what it negotiated (expected when mirroring a monitor; a virtual-display backend fault \
otherwise see the vdisplay lines above)"
);
}
let mut enc = encode::open_video(
+12
View File
@@ -485,6 +485,18 @@
"store_uninstall_body": "Du kannst es jederzeit wieder aus dem Katalog installieren.",
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
"store_update_all_count": "Alle aktualisieren ({count})",
"store_updates_pending": "Für {count} Plugins gibt es Updates",
"store_update_all_title": "Diese Plugins aktualisieren?",
"store_update_all_body": "Jedes wird nacheinander auf seine Katalogversion gebracht — der Host führt immer nur einen Paketvorgang auf einmal aus. Nach jedem startet der Plugin-Runner neu.",
"store_update_all_external_note": "Einige stammen aus Katalogen, die du selbst hinzugefügt hast ({sources}). unom hat diesen Code nicht geprüft; er ist festgepinnt und auf Integrität geprüft, läuft aber mit den Rechten des Plugin-Runners auf diesem Host.",
"store_update_all_confirm": "Alle aktualisieren",
"store_update_all_external_confirm": "Trotzdem alle aktualisieren",
"store_update_all_skipped": "Nicht dabei: {names}. Entweder führt sie kein Katalog, oder dieser Host kann die angebotene Version nicht ausführen.",
"store_update_all_step": "Update {index} von {total}",
"store_update_all_running": "Nächstes Update startet",
"store_update_all_finished": "{count} Plugins aktualisiert.",
"store_update_all_stopped": "Nach einem Fehler gestoppt — {done} aktualisiert, {left} nicht versucht. Du kannst sie unten einzeln erneut anstoßen.",
"store_sources_title": "Katalogquellen",
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
"store_refresh_all": "Alle aktualisieren",
+12
View File
@@ -485,6 +485,18 @@
"store_uninstall_body": "You can install it again from the catalog.",
"store_uninstall_failed": "Could not start the removal.",
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
"store_update_all_count": "Update all ({count})",
"store_updates_pending": "{count} plugins can be updated",
"store_update_all_title": "Update these plugins?",
"store_update_all_body": "Each one is installed at its catalog version, one after another — the host takes a single package operation at a time. The plugin runner restarts after each.",
"store_update_all_external_note": "Some of these come from catalogs you added yourself ({sources}). unom has not reviewed that code; it is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
"store_update_all_confirm": "Update all",
"store_update_all_external_confirm": "Update all anyway",
"store_update_all_skipped": "Not included: {names}. Either no catalog carries them, or this host can't run the version on offer.",
"store_update_all_step": "Update {index} of {total}",
"store_update_all_running": "Starting the next update",
"store_update_all_finished": "Updated {count} plugins.",
"store_update_all_stopped": "Stopped after a failure — {done} updated, {left} not attempted. Retry them from the rows below.",
"store_sources_title": "Catalog sources",
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
"store_refresh_all": "Refresh all",
+68
View File
@@ -108,6 +108,74 @@ export interface InstalledPlugin {
blocked?: string;
}
/** An installed plugin paired with the catalog entry an update would install. */
export interface PendingUpdate {
plugin: InstalledPlugin;
entry: StoreEntry;
}
/** What "Update all" would do: the run, and what it deliberately left out of it. */
export interface UpdatePlan {
/** Ready to install, in the order the run will work through them. */
updates: PendingUpdate[];
/** Display names of plugins offering an update this host cannot take right now. */
skipped: string[];
}
/**
* The catalog entry an installed plugin updates FROM.
*
* Resolve by the entry the plugin was actually installed from (source + entry id) before falling
* back to the package name: two sources may carry the same `pkg`, and matching on the name alone
* could offer a row badged "verified" an entry from somebody else's source at a different version.
*/
export function catalogEntryFor(
plugin: InstalledPlugin,
entries: StoreEntry[] | undefined,
): StoreEntry | undefined {
const list = entries ?? [];
return (
(plugin.source && plugin.entry_id
? list.find((e) => e.source === plugin.source && e.id === plugin.entry_id)
: undefined) ??
(plugin.source
? list.find((e) => e.source === plugin.source && e.pkg === plugin.pkg)
: undefined) ??
list.find((e) => e.pkg === plugin.pkg)
);
}
/**
* Everything an "Update all" run would install, in the installed list's own order so the run
* follows the rows on screen rather than some order of its own.
*
* Two things are dropped rather than attempted, and both are reported instead of hidden: an update
* with no catalog entry to install (the same dead end a single row's button reports on click), and
* an entry this host will refuse incompatible ones are a `400` from `POST /store/install`, and a
* blocked one is what Browse already greys its Install button out for. Either would end the run on
* a failure card that says nothing about the updates still queued behind it, so they never enter
* the queue in the first place.
*
* `plugin.blocked` is NOT a reason to skip: that advisory is against the version installed right
* now, and updating away from it is the fix, not the risk.
*/
export function planUpdates(
installed: InstalledPlugin[] | undefined,
entries: StoreEntry[] | undefined,
): UpdatePlan {
const plan: UpdatePlan = { updates: [], skipped: [] };
for (const plugin of installed ?? []) {
if (plugin.update_available === undefined) continue;
const entry = catalogEntryFor(plugin, entries);
if (entry?.compatible && entry.blocked === undefined) {
plan.updates.push({ plugin, entry });
} else {
plan.skipped.push(plugin.title ?? plugin.pkg);
}
}
return plan;
}
export type JobKind = "install" | "uninstall";
export type JobState = "running" | "done" | "failed";
+22 -1
View File
@@ -1,5 +1,6 @@
import { AnimatedButton, buttonVariants } from "@unom/ui/button";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
// The console's Button IS @unom/ui's animated button — pill shape, specular
// material gloss + UI click/hover sounds (enabled via UnomProviders), driven by
@@ -7,6 +8,26 @@ import type { ComponentProps } from "react";
// (default/destructive/outline/secondary/ghost/link + default/sm/lg/icon).
export type ButtonProps = ComponentProps<typeof AnimatedButton>;
export const Button = AnimatedButton;
/**
* One correction, in the wrapper layer like the other `components/ui/*` ones: make `disabled`
* VISIBLE.
*
* `AnimatedButton` is a motion element, and its mount animation settles as an inline `opacity: 1`.
* An inline style outranks any class, so the `disabled:opacity-50` the library also ships never
* applied: measured `opacity: 1` on a `disabled` button, console-wide. Every disabled control in
* the app therefore looked live and simply ignored the click `pointer-events: none` landed,
* because nothing sets that inline.
*
* `!important` is the one thing that beats an inline declaration, and it is preferable here to
* fighting motion for ownership of the animation: the library keeps animating opacity, this only
* pins the disabled end state.
*/
export const Button = ({ className, ...props }: ButtonProps) => (
<AnimatedButton
className={cn("disabled:opacity-50!", className)}
{...props}
/>
);
Button.displayName = "Button";
export { buttonVariants };
+90 -1
View File
@@ -1,6 +1,6 @@
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import type { StoreEntry } from "@/api/store";
import type { PendingUpdate, StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -87,6 +87,95 @@ export const InstallDialog: FC<{
);
};
/**
* "Update all": one confirmation for a whole run of catalog installs.
*
* It is the same trust decision as `InstallDialog`, taken once for several packages, so it keeps
* the same escalation rule if ANY entry in the run comes from an operator-added source, the whole
* dialog wears the external treatment and names those sources. A bulk action must not be a way to
* wave through, in one click, a warning each package would have shown on its own.
*/
export const UpdateAllDialog: FC<{
/** The updates to run, in order — null when the dialog is closed. */
updates: PendingUpdate[] | null;
/** Plugins with an update the run will not attempt; named so the count adds up on screen. */
skipped: string[];
onCancel: () => void;
onConfirm: (updates: PendingUpdate[]) => void;
isPending: boolean;
}> = ({ updates, skipped, onCancel, onConfirm, isPending }) => {
const external = (updates ?? []).filter((u) => u.entry.tier === "external");
// Each source named once, in the order the run meets it.
const sources = [...new Set(external.map((u) => u.entry.source))];
return (
<Dialog
open={updates !== null}
onOpenChange={(open) => !open && onCancel()}
>
{updates && (
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{sources.length > 0 ? (
<ShieldQuestion className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
) : (
<BadgeCheck className="size-5 shrink-0 text-[var(--success)]" />
)}
{m.store_update_all_title()}
</DialogTitle>
<DialogDescription>{m.store_update_all_body()}</DialogDescription>
</DialogHeader>
{/* Every version change, spelled out: a bulk confirm that only says "3 plugins" is
asking the operator to trust a number. Scrolls rather than growing past the
dialog's own max-height when a host has a lot installed. */}
<ul className="max-h-64 space-y-1 overflow-y-auto rounded-md bg-muted p-3 text-xs">
{updates.map((u) => (
<li
key={u.plugin.pkg}
className="flex items-baseline justify-between gap-3"
>
<span className="truncate font-medium">
{u.plugin.title ?? u.plugin.pkg}
</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{u.plugin.version ? `v${u.plugin.version}` : "—"} v
{u.entry.version}
</span>
</li>
))}
</ul>
{sources.length > 0 && (
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
{m.store_update_all_external_note({
sources: sources.join(", "),
})}
</p>
)}
{skipped.length > 0 && (
<p className="text-xs text-muted-foreground">
{m.store_update_all_skipped({ names: skipped.join(", ") })}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isPending}>
{m.common_cancel()}
</Button>
<Button disabled={isPending} onClick={() => onConfirm(updates)}>
{sources.length > 0
? m.store_update_all_external_confirm()
: m.store_update_all_confirm()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
};
/**
* Tier 3: install a raw package spec. No catalog, no review, no pinning so the dialog spells out
* exactly what that means and asks for two independent confirmations (retype the spec, tick the
+38 -5
View File
@@ -17,10 +17,22 @@ import { SourceChip, TierBadge } from "./TierBadge";
*/
export const InstalledTab: FC<{
onUpdate: (plugin: InstalledPlugin) => void;
onUpdateAll: () => void;
onUninstall: (plugin: InstalledPlugin) => void;
/** How many plugins "Update all" would install; the button hides at zero. */
updateCount: number;
/** Package whose install/uninstall is in flight, or null — only that row's actions disable. */
busyPkg: string | null;
}> = ({ onUpdate, onUninstall, busyPkg }) => {
/** An Update-all run is working through the queue — every action here waits for it. */
batchRunning: boolean;
}> = ({
onUpdate,
onUpdateAll,
onUninstall,
updateCount,
busyPkg,
batchRunning,
}) => {
const installed = useInstalledPlugins();
return (
<div className="flex flex-col gap-card">
@@ -28,8 +40,11 @@ export const InstalledTab: FC<{
<InstalledList
installed={installed}
onUpdate={onUpdate}
onUpdateAll={onUpdateAll}
onUninstall={onUninstall}
updateCount={updateCount}
busyPkg={busyPkg}
batchRunning={batchRunning}
/>
</div>
);
@@ -43,15 +58,33 @@ export const InstalledTab: FC<{
export const InstalledList: FC<{
installed: Loadable<InstalledPlugin[]>;
onUpdate: (plugin: InstalledPlugin) => void;
onUpdateAll: () => void;
onUninstall: (plugin: InstalledPlugin) => void;
updateCount: number;
busyPkg: string | null;
}> = ({ installed, onUpdate, onUninstall, busyPkg }) => {
batchRunning: boolean;
}> = ({
installed,
onUpdate,
onUpdateAll,
onUninstall,
updateCount,
busyPkg,
batchRunning,
}) => {
const rows = installed.data ?? [];
return (
<Card>
<CardContent flush>
<CardHeader>
{/* The bulk action sits with the list it acts on, the way Sources' "Refresh all" does. */}
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
<CardTitle>{m.store_installed_title()}</CardTitle>
{updateCount > 0 && (
<Button size="sm" disabled={batchRunning} onClick={onUpdateAll}>
<ArrowUpCircle className="size-4" />
{m.store_update_all_count({ count: updateCount })}
</Button>
)}
</CardHeader>
<QueryState
@@ -108,7 +141,7 @@ export const InstalledList: FC<{
{p.update_available !== undefined && (
<Button
size="sm"
disabled={busyPkg === p.pkg}
disabled={batchRunning || busyPkg === p.pkg}
onClick={() => onUpdate(p)}
>
<ArrowUpCircle className="size-4" />
@@ -121,7 +154,7 @@ export const InstalledList: FC<{
variant="ghost"
size="icon"
aria-label={m.store_uninstall()}
disabled={busyPkg === p.pkg}
disabled={batchRunning || busyPkg === p.pkg}
onClick={() => onUninstall(p)}
>
<Trash2 className="size-4 text-destructive" />
+62 -8
View File
@@ -27,6 +27,13 @@ const phaseLabel = (phase: string): string => PHASES[phase]?.() ?? phase;
/** Keep the tail — an install log can run long and only the end is ever interesting. */
const LOG_TAIL = 200;
/** Where a job sits in an "Update all" run. Absent for a job the operator started on its own. */
export interface BatchStep {
/** 1-based, so it reads the way it is written: "Update 2 of 5". */
index: number;
total: number;
}
/**
* Container: the in-flight install/uninstall. Polls the job once a second while it runs (the query
* stops polling itself once the job settles), and refreshes everything the job touched catalog,
@@ -35,24 +42,39 @@ const LOG_TAIL = 200;
export const JobProgressSection: FC<{
jobId: string;
onDismiss: () => void;
}> = ({ jobId, onDismiss }) => {
/**
* Called once, with the final job, when it reaches `done` or `failed` how an Update-all run
* learns it may start the next install. The host takes one package operation at a time, so the
* run has to be driven by this rather than by a timer.
*/
onSettled?: (job: StoreJob) => void;
step?: BatchStep;
}> = ({ jobId, onDismiss, onSettled, step }) => {
const qc = useQueryClient();
const job = useStoreJob(jobId);
const settled = job.data?.state === "done" || job.data?.state === "failed";
// Refresh once per job, not on every re-render while the finished card sits there.
const final =
job.data?.state === "done" || job.data?.state === "failed"
? job.data
: undefined;
// Refresh once per job, not on every re-render while the finished card sits there. The settle
// handler starts the next install of a run, so riding the same guard is what keeps a run from
// double-stepping when a later poll re-renders this with the same finished job.
const refreshed = useRef<string | null>(null);
useEffect(() => {
if (!settled || refreshed.current === jobId) return;
if (!final || refreshed.current === jobId) return;
refreshed.current = jobId;
invalidateStore(qc);
}, [settled, jobId, qc]);
onSettled?.(final);
}, [final, jobId, qc, onSettled]);
// A job the host can no longer tell us about — it restarted, and jobs live in memory. This used
// to render `null`, so the card simply vanished while the Install buttons stayed armed and the
// query kept polling a dead id once a second forever. Say what happened and offer the way out.
if (!job.data) {
if (!job.isError) return null;
// Mid-run, "no data yet" is just the first poll of the install we only now started, and
// rendering nothing would blink the run's progress off the page between every package.
if (!job.isError) return step ? <BatchPendingCard step={step} /> : null;
return (
<Card className="ring-2 ring-destructive/60">
<CardContent className="flex items-start gap-3">
@@ -75,14 +97,36 @@ export const JobProgressSection: FC<{
</Card>
);
}
return <JobProgressCard job={job.data} onDismiss={onDismiss} />;
return <JobProgressCard job={job.data} onDismiss={onDismiss} step={step} />;
};
/**
* The gap between two installs of a run: the last one finished, the next has not been accepted yet.
*
* It exists so the run never appears to stop. Without it the card unmounts the moment the finished
* job is let go and comes back a request later, which reads as "it gave up" precisely when the
* operator is watching to see that it hasn't.
*/
export const BatchPendingCard: FC<{ step: BatchStep }> = ({ step }) => (
<Card aria-live="polite">
<CardContent className="flex items-start gap-3">
<Spinner className="mt-0.5 size-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{m.store_update_all_running()}</p>
<p className="text-sm text-muted-foreground">
{m.store_update_all_step({ index: step.index, total: step.total })}
</p>
</div>
</CardContent>
</Card>
);
/** The progress card: phase (or outcome), a collapsible log tail, and the failure reason if any. */
export const JobProgressCard: FC<{
job: StoreJob;
onDismiss: () => void;
}> = ({ job, onDismiss }) => {
step?: BatchStep;
}> = ({ job, onDismiss, step }) => {
const running = job.state === "running";
const failed = job.state === "failed";
const log = job.log.slice(-LOG_TAIL);
@@ -107,6 +151,16 @@ export const JobProgressCard: FC<{
? m.store_job_uninstall({ target: job.target })
: m.store_job_install({ target: job.target })}
</p>
{/* Which package is being installed answers "what is happening"; the step answers
"how much longer", which is the only question a multi-package run adds. */}
{step && (
<p className="text-xs text-muted-foreground">
{m.store_update_all_step({
index: step.index,
total: step.total,
})}
</p>
)}
<p className="text-sm text-muted-foreground">
{running
? phaseLabel(job.phase)
+156 -23
View File
@@ -1,12 +1,18 @@
import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useState } from "react";
import { type FC, useEffect, useMemo, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
catalogEntryFor,
type InstallBody,
type InstalledPlugin,
type PendingUpdate,
planUpdates,
runningJob,
type StoreEntry,
type StoreJob,
type UpdatePlan,
useInstalledPlugins,
useInstallPlugin,
useStoreCatalog,
useStoreJobs,
@@ -17,13 +23,34 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { BrowseTab } from "./Browse";
import { InstallDialog, SpecInstallDialog } from "./InstallDialogs";
import {
InstallDialog,
SpecInstallDialog,
UpdateAllDialog,
} from "./InstallDialogs";
import { InstalledTab } from "./Installed";
import { JobProgressSection } from "./JobProgress";
import { BatchPendingCard, JobProgressSection } from "./JobProgress";
import { SourcesTab } from "./Sources";
type StoreTab = "browse" | "installed" | "sources";
/**
* An "Update all" run in flight.
*
* The host takes one package operation at a time (`409` otherwise `bun` operations share a
* lockfile and a `node_modules` tree), so this is a queue the console works through one job at a
* time, not a fan-out. It carries its own copy of what is left rather than re-deriving it from the
* catalog between packages: every finished install invalidates the installed list, and a queue that
* re-derived itself would change shape underneath a run the operator already confirmed.
*/
interface UpdateRun {
/** Not yet started. The one currently installing has already been taken off the front. */
queue: PendingUpdate[];
/** How many have finished successfully — `done + 1` is the step now running. */
done: number;
total: number;
}
/**
* The plugin store: browse a catalog, manage what's installed, and choose which catalogs this host
* trusts. Each tab owns its own queries; this container owns only what genuinely spans them the
@@ -41,16 +68,35 @@ export const SectionStore: FC = () => {
// The job the host is running for us, if any. Cleared by the operator, not by completion — a
// finished job's log is the only record of what happened.
const [jobId, setJobId] = useState<string | null>(null);
// The plan awaiting its one confirmation, and the run that confirmation started. A SNAPSHOT taken
// when the button was pressed — the installed list refetches on a timer, and the operator must
// confirm the list they were shown, not whatever it became while they read it.
const [updateAllTarget, setUpdateAllTarget] = useState<UpdatePlan | null>(
null,
);
const [run, setRun] = useState<UpdateRun | null>(null);
const catalog = useStoreCatalog();
// Also queried by the Installed tab; react-query serves both from one fetch. Here it is what
// "Update all" counts, so the button is right even while that tab has never been opened.
const installed = useInstalledPlugins();
const plan = useMemo(
() => planUpdates(installed.data, catalog.data?.plugins),
[installed.data, catalog.data],
);
// Re-attach to a job that was already running when this page loaded — an install survives a
// reload on the host side, and losing sight of it left the Install buttons armed against a host
// that answers 409.
const jobs = useStoreJobs();
const orphan = runningJob(jobs.data);
useEffect(() => {
// A run owns the job slot while it lasts, and it clears the id between packages. This list is
// only refetched on a focus or a stale read, so during that gap `orphan` can still be the job
// that just finished — re-attaching to it would remount the progress card, fire its settle
// handler a second time, and step the run forward over a package it never installed.
if (run) return;
if (orphan && !jobId) setJobId(orphan.id);
}, [orphan, jobId]);
}, [orphan, jobId, run]);
const install = useInstallPlugin();
const uninstall = useUninstallPlugin();
@@ -98,24 +144,8 @@ export const SectionStore: FC = () => {
// An update from the Installed tab installs the CATALOG version — so it goes through the very
// same tier-appropriate dialog a fresh install would, warning included.
//
// Resolve by the entry the plugin was actually installed FROM (source + entry id) before falling
// back to the package name: two sources may carry the same `pkg`, and matching on the name alone
// could offer a row badged "verified" an entry from somebody else's source at a different version.
const onUpdate = (plugin: InstalledPlugin) => {
const entries = catalog.data?.plugins ?? [];
const entry =
(plugin.source && plugin.entry_id
? entries.find(
(e) => e.source === plugin.source && e.id === plugin.entry_id,
)
: undefined) ??
(plugin.source
? entries.find(
(e) => e.source === plugin.source && e.pkg === plugin.pkg,
)
: undefined) ??
entries.find((e) => e.pkg === plugin.pkg);
const entry = catalogEntryFor(plugin, catalog.data?.plugins);
if (!entry) {
toast.error(m.store_update_no_entry());
return;
@@ -123,6 +153,72 @@ export const SectionStore: FC = () => {
setTarget(entry);
};
/**
* Start the next install of a run, or finish it when the queue runs dry.
*
* What is left is threaded through the arguments rather than read from `run`: the caller is a
* settle handler that already knows the outcome, and reading state it is itself about to replace
* is how a queue skips or repeats an entry.
*/
const runNext = async (
queue: PendingUpdate[],
done: number,
total: number,
) => {
const [next, ...rest] = queue;
if (!next) {
setRun(null);
toast.success(m.store_update_all_finished({ count: done }));
return;
}
// Let the finished job's card go before asking for the next one: the run's own progress card
// takes over for the moment in between, so the page never shows "Installed." while the next
// package is already on its way.
setJobId(null);
setRun({ queue: rest, done, total });
try {
const { job } = await install.mutateAsync({
source: next.entry.source,
id: next.entry.id,
});
setJobId(job);
} catch (e) {
setRun(null);
failed(e, m.store_install_failed());
}
};
/**
* A job of the run finished.
*
* A failure ENDS the run. The failed job's card its phase, its error, its log is the only
* record of what went wrong, and starting the next install would replace it with a fresh
* spinner; the operator would be left knowing only that something, somewhere, went wrong. So the
* run stops on the evidence and says what it did not get to, which they can retry from the rows.
*/
const onJobSettled = (job: StoreJob) => {
if (!run) return;
if (job.state !== "done") {
setRun(null);
toast.error(
m.store_update_all_stopped({
done: run.done,
left: run.queue.length + 1,
}),
);
return;
}
void runNext(run.queue, run.done + 1, run.total);
};
const onConfirmUpdateAll = (updates: PendingUpdate[]) => {
setUpdateAllTarget(null);
void runNext(updates, 0, updates.length);
};
// 1-based, and only while a run is live — this is what turns the install card into "2 of 5".
const step = run ? { index: run.done + 1, total: run.total } : undefined;
const onUninstall = async (plugin: InstalledPlugin) => {
const ok = await confirm({
title: m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }),
@@ -147,15 +243,42 @@ export const SectionStore: FC = () => {
<p className="text-sm text-muted-foreground">{m.store_subtitle()}</p>
</div>
{jobId && (
<JobProgressSection jobId={jobId} onDismiss={() => setJobId(null)} />
{jobId ? (
<JobProgressSection
jobId={jobId}
onDismiss={() => setJobId(null)}
onSettled={onJobSettled}
step={step}
/>
) : (
// No job id yet, but a run is live — the install we just asked for has not come
// back with one. Only reachable mid-run; a lone install has nothing to show here.
step && <BatchPendingCard step={step} />
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as StoreTab)}>
<TabsList>
<TabsTrigger value="browse">{m.store_tab_browse()}</TabsTrigger>
{/* Browse is the tab this page opens on, so the count has to travel to where the
operator already is otherwise "Update all" is only ever found by someone
who went looking for it. */}
<TabsTrigger value="installed">
{m.store_tab_installed()}
{plan.updates.length > 0 && (
<>
{/* The digit is shorthand for the sentence beside it; a screen reader
gets the sentence, not a tab label that ends in a bare number. */}
<span
aria-hidden="true"
className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-[0.6875rem] font-medium leading-none tabular-nums text-primary-foreground"
>
{plan.updates.length}
</span>
<span className="sr-only">
{m.store_updates_pending({ count: plan.updates.length })}
</span>
</>
)}
</TabsTrigger>
<TabsTrigger value="sources">{m.store_tab_sources()}</TabsTrigger>
</TabsList>
@@ -169,10 +292,13 @@ export const SectionStore: FC = () => {
<TabsContent value="installed">
<InstalledTab
onUpdate={onUpdate}
onUpdateAll={() => setUpdateAllTarget(plan)}
onUninstall={onUninstall}
updateCount={plan.updates.length}
busyPkg={
uninstall.isPending ? (uninstall.variables ?? null) : null
}
batchRunning={run !== null}
/>
</TabsContent>
<TabsContent value="sources">
@@ -186,6 +312,13 @@ export const SectionStore: FC = () => {
onCancel={() => setTarget(null)}
onConfirm={onConfirmEntry}
/>
<UpdateAllDialog
updates={updateAllTarget?.updates ?? null}
skipped={updateAllTarget?.skipped ?? []}
isPending={install.isPending}
onCancel={() => setUpdateAllTarget(null)}
onConfirm={onConfirmUpdateAll}
/>
<SpecInstallDialog
open={specOpen}
isPending={install.isPending}
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { InstalledPlugin } from "@/api/store";
import { InstalledList } from "@/sections/Store/Installed";
// The installed-plugins list, driven straight from fixtures — it fetches nothing, so the header's
// "Update all" affordance can be checked in every state it has (absent, offered, and disabled
// because a run is already working through the queue) without a host or a catalog.
const meta = {
title: "Store/InstalledList",
component: InstalledList,
parameters: { layout: "padded" },
args: {
onUpdate: () => {},
onUpdateAll: () => {},
onUninstall: () => {},
busyPkg: null,
batchRunning: false,
},
} satisfies Meta<typeof InstalledList>;
export default meta;
type Story = StoryObj<typeof meta>;
const ROWS: InstalledPlugin[] = [
{
pkg: "@punktfunk/plugin-rom-manager",
title: "ROM Manager",
version: "0.3.1",
tier: "verified",
source: "unom official",
entry_id: "rom-manager",
running: true,
update_available: "0.3.2",
},
{
pkg: "@punktfunk/plugin-playnite",
title: "Playnite",
version: "0.2.0",
tier: "external",
source: "community catalog",
entry_id: "playnite",
running: true,
update_available: "0.2.1",
},
{
pkg: "@somebody/plugin-scratch",
version: "0.1.0",
tier: "unverified",
running: false,
},
];
const loaded = (data: InstalledPlugin[]) => ({
data,
isLoading: false,
error: null,
});
/** Nothing to update: the header carries its title alone. */
export const UpToDate: Story = {
args: {
installed: loaded(ROWS.map(({ update_available, ...p }) => p)),
updateCount: 0,
},
};
/** Two updates on offer — the bulk action appears beside the title. */
export const UpdatesAvailable: Story = {
args: { installed: loaded(ROWS), updateCount: 2 },
};
/** A run is working through the queue: every action here waits for it, bulk included. */
export const RunInFlight: Story = {
args: { installed: loaded(ROWS), updateCount: 2, batchRunning: true },
};
/** One plugin's own uninstall is in flight — only that row's actions go quiet. */
export const RowBusy: Story = {
args: {
installed: loaded(ROWS),
updateCount: 2,
busyPkg: "@punktfunk/plugin-playnite",
},
};
export const Empty: Story = { args: { installed: loaded([]), updateCount: 0 } };
+101
View File
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { PendingUpdate } from "@/api/store";
import { UpdateAllDialog } from "@/sections/Store/InstallDialogs";
// The one confirmation an "Update all" run takes. Rendered open, from fixtures, so the escalation
// rule can be read off the screen: all-verified is an ordinary confirm, and a single entry from an
// operator-added source turns the whole dialog amber and names the catalogs it came from.
const meta = {
title: "Store/UpdateAllDialog",
component: UpdateAllDialog,
parameters: { layout: "fullscreen" },
args: {
skipped: [],
isPending: false,
onCancel: () => {},
onConfirm: () => {},
},
} satisfies Meta<typeof UpdateAllDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
const update = (
title: string,
pkg: string,
from: string,
to: string,
source = "unom official",
tier: "verified" | "external" = "verified",
): PendingUpdate => ({
plugin: {
pkg,
title,
version: from,
tier,
source,
running: true,
update_available: to,
},
entry: {
id: pkg.split("/").pop() ?? pkg,
pkg,
title,
description: "",
author: "unom",
version: to,
source,
tier,
platforms: ["linux", "windows"],
compatible: true,
update_available: true,
},
});
const VERIFIED: PendingUpdate[] = [
update("ROM Manager", "@punktfunk/plugin-rom-manager", "0.3.1", "0.3.2"),
update("Steam Library", "@punktfunk/plugin-steam", "1.0.0", "1.1.0"),
];
/** Everything from the built-in catalog: a plain confirm, no warning to earn. */
export const AllVerified: Story = { args: { updates: VERIFIED } };
/** One entry from a catalog the operator added — the whole dialog escalates and names it. */
export const WithExternal: Story = {
args: {
updates: [
...VERIFIED,
update(
"Playnite",
"@punktfunk/plugin-playnite",
"0.2.0",
"0.2.1",
"community catalog",
"external",
),
],
},
};
/** Updates the run will not attempt are named, so the button's count adds up on screen. */
export const WithSkipped: Story = {
args: {
updates: VERIFIED,
skipped: ["Emulator Bridge", "@somebody/plugin-scratch"],
},
};
/** A host with a lot installed: the list scrolls rather than pushing the footer off screen. */
export const LongList: Story = {
args: {
updates: Array.from({ length: 12 }, (_, i) =>
update(
`Plugin ${i + 1}`,
`@punktfunk/plugin-number-${i + 1}`,
`0.${i}.0`,
`0.${i}.1`,
),
),
},
};