Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
141c04cd6b | ||
|
|
155cced56b | ||
|
|
8f66fafb90 | ||
|
|
6fe53fff2b | ||
|
|
a5c9b7b865 | ||
|
|
6237e3d0a3 |
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } };
|
||||
@@ -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`,
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user