Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 7c0313a7cb fix(web): flush card content, so a full-bleed table stops doubling its inset
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m18s
ci / bench (push) Successful in 6m42s
apple / screenshots (push) Successful in 6m21s
ci / rust (push) Successful in 24m18s
android-screenshots / screenshots (push) Successful in 3m2s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
android / android (push) Successful in 15m7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-host / package (push) Successful in 10m7s
release / apple (push) Successful in 10m12s
flatpak / build-publish (push) Successful in 6m55s
deb / build-publish (push) Successful in 11m44s
linux-client-screenshots / screenshots (push) Successful in 7m40s
docker / deploy-docs (push) Successful in 28s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m22s
web-screenshots / screenshots (push) Successful in 3m27s
deb / build-publish-host (push) Successful in 12m25s
arch / build-publish (push) Successful in 15m42s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m41s
`<CardContent className="p-0">` never removed the padding it looked like it
removed: tailwind-merge resolves conflicts within a variant, so `p-0` cancels
`p-4` but leaves `sm:p-6` standing. Every call site that used it nests a
CardHeader inside, which brings its own `sm:p-6` — so at >=640px the title sat
24px further in than its neighbours'. Visible on the Plugins page as 'Installed
plugins' not lining up with 'Plugin runner'.

Give CardContent a `flush` prop that omits the padding outright, and use it at
the three call sites (Store, Pairing, Stats) rather than fixing the symptom
three times and leaving the footgun in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:25:31 +02:00
enricobuehlerandClaude Fable 5 04c975e9cb fix(store): uninstall must refuse a plugin's framework, not just odd names
apple / swift (push) Successful in 1m26s
apple / screenshots (push) Successful in 6m47s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m5s
windows-host / package (push) Successful in 9m45s
android / android (push) Successful in 12m4s
arch / build-publish (push) Successful in 12m4s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m57s
docker / deploy-docs (push) Successful in 24s
deb / build-publish (push) Successful in 9m0s
deb / build-publish-host (push) Successful in 9m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m15s
ci / rust (push) Successful in 24m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m55s
The guard checked the package NAME shape, and `@punktfunk/plugin-kit` — the
framework every kit-built plugin depends on — matches `@scope/plugin-*`
exactly. Windows on-glass accepted an uninstall of it; bun no-ops removing a
non-dependency so nothing broke, but the store was offering to pull a library
out from under the plugins using it.

Require membership in the top-level dependency list, the same authority that
already keeps plugin-kit out of the installed listing. Same blind spot, two
call sites — the listing was fixed during implementation, this one wasn't.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:10:49 +02:00
enricobuehler 402ec90edc fix(web/store): restore the plugin cards' top padding, and capitalise platform names
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m24s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m34s
apple / screenshots (push) Successful in 6m33s
windows-host / package (push) Successful in 9m46s
deb / build-publish (push) Successful in 9m19s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 9m48s
android / android (push) Successful in 17m6s
arch / build-publish (push) Successful in 18m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m38s
ci / rust (push) Successful in 27m42s
Two things looked wrong in the store:

The cards had no space above the icon. `CardContent` zeroes its own top padding
(`pt-0`/`sm:pt-0`) because it normally sits under a `CardHeader` that supplies
it — but these cards have no header, so the top padding simply vanished while
the other three sides kept `p-card`. `p-card` does not undo it: `card` is a
custom `--spacing-*` token, which tailwind-merge does not recognise as a
spacing value and so never dedupes against `pt-0`, leaving the longhand to win.
Both header-less cards in this section now restore it explicitly, at both
breakpoints.

Platform chips read "windows" / "linux". Those are the catalog's platform
IDENTIFIERS (the index validator pins `linux | windows | macos`), which is right
for the wire and wrong on screen; the card now maps them to display names —
Linux, Windows, macOS. Proper nouns, so deliberately not routed through i18n.

Adds Store/StoreCard stories covering the padding, the platform labels, and the
installed / update / incompatible / blocked / external states, so the card can
be eyeballed without a host or a catalog.
2026-07-21 00:00:37 +02:00
enricobuehler d783d5445c chore(release): bump workspace version to 0.17.0
apple / swift (push) Successful in 1m20s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
2026-07-20 23:55:51 +02:00
enricobuehlerandClaude Fable 5 67b7981096 feat(encode/nvenc-linux): LN1 phase-3 — multi-slice sub-frame encode DEFAULT-ON for Linux direct-NVENC
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m15s
windows-host / package (push) Successful in 9m39s
ci / web (push) Successful in 1m10s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Gates recorded (nvenc-subframe-slice-output.md Phase 3): loss-harness curve
identical streamed-vs-whole; adversarial security review clean; live .21
3-leg A/B — streamed 0 corrupted frames, p99 8527→5363 µs vs sliced-whole,
first_slice_us ~500 µs of a ~1250 µs encode reaching the send thread; wire
bytes rate-governed under CBR + 1-frame VBV (the ~1-2 % slice-header cost is
absorbed by rate control, not added to the wire). GameStream: plane has zero
code diff (own RTP packetizer; shares only untouched send_pacing); a live
Moonlight re-test joins the standing owed Moonlight item.

- resolve_slices(codec, default): env 1..=32 wins (1 = the NEW explicit
  single-slice escape), else the backend default — 4 on Linux direct-NVENC,
  1 elsewhere. resolve_subframe(default_on): PUNKTFUNK_NVENC_SUBFRAME
  tri-state (0 = never, 1 = force, unset = backend default).
- Linux nvenc_cuda: resolves both ONCE in query_caps — the sub-frame default
  is gated on the GPU's SUBFRAME_READBACK cap so an unsupporting GPU never
  has enableSubFrameWrite forced into its init params — and feeds the same
  resolved values to build_config, build_init_params (open AND in-place
  reconfigure) and the chunked-poll latch.
- Windows: env-only as before (async path untouched, byte-identical config
  for unset env); LowLatencyConfig.slices + the explicit subframe param
  replace the shared env reads.
- Tests: chunked e2e now runs at the DEFAULTS (no knobs); the fallback test
  becomes the escape test (SLICES=1 disarms; SUBFRAME=0 disarms while
  4-slice encode continues on the plain poll path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:44:41 +02:00
enricobuehlerandClaude Fable 5 e6cdd79f71 fix(loss-harness): drop the needless mut on send_wires — workspace clippy is -D warnings
apple / swift (push) Successful in 1m17s
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
deb / build-publish-host (push) Failing after 5s
ci / web (push) Successful in 1m14s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
ci / docs-site (push) Successful in 1m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 10m44s
deb / build-publish (push) Successful in 12m34s
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Slipped through 421e8112: the on-box clippy runs covered punktfunk-core and
punktfunk-host but not the tools crates; CI's workspace-wide pass caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:44:37 +02:00
enricobuehlerandClaude Fable 5 7e5ec7eba9 chore(store): repin the official plugin-index signing key
apple / swift (push) Successful in 1m17s
apple / screenshots (push) Successful in 6m43s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m13s
arch / build-publish (push) Successful in 12m43s
deb / build-publish-host (push) Failing after 25s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / rust (push) Failing after 9m38s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 6m58s
android / android (push) Successful in 16m41s
docker / deploy-docs (push) Successful in 11s
windows-host / package (push) Successful in 10m9s
deb / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The bootstrap key generated during implementation was lost, and its
replacement was pasted into the wrong repository's secret store — Gitea
secrets neither cross repos nor read back, so neither is usable. The private
half of this one lives in punktfunk-plugin-index's own INDEX_SIGNING_KEY,
which is the only thing that signs the catalog.

Nothing has shipped pinning any of these, so this is a straight replacement
of slot 0; the second slot stays reserved for a genuine rotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:25:16 +02:00
enricobuehlerandClaude Opus 4.8 c6caeca5bc feat(encode): RGB-direct (EFC) is now the DEFAULT on capable hosts — gated by a session cursor-blend hint
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
android / android (push) Has been cancelled
ci / web (push) Successful in 55s
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
ci / docs-site (push) Successful in 55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
ci / bench (push) Successful in 7m8s
ci / rust (push) Failing after 9m14s
windows-host / package (push) Successful in 10m24s
arch / build-publish (push) Successful in 12m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m25s
docker / deploy-docs (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m25s
B2 default-on (design/vulkan-rgb-direct-encode.md): wherever the probe
passes, Vulkan Video sessions now take the EFC RGB source by default —
direct import on aligned modes, padded-copy staging on unaligned ones
(1080p). PUNKTFUNK_VULKAN_RGB_DIRECT becomes the override: =0 disables,
=1 forces, unset = the default below.

The one session class that must NOT default on: cursor-as-metadata
captures (every non-gamescope compositor), where the CSC shader's blend
IS the visible pointer — the EFC cannot composite, and defaulting there
would silently drop the cursor from the stream. The hint rides the
existing plumbing:

- SessionPlan gains cursor_blend, resolved once where the compositor is
  known (gamescope embeds the pointer itself → false; kwin/mutter/
  wlroots/hyprland → true), and shows up in the logged plan line.
- open_video/open_video_backend thread it through (native pump: all
  three encoder-open sites read plan.cursor_blend; GameStream monitor
  capture: true — it negotiates metadata cursor; spike: false).
- VulkanVideoEncoder::open resolves: env override, else ON iff the
  session never hands us cursor bitmaps. The warn-once for a cursor on
  an RGB session (forced via =1) stays.

Verified on-hw box (Linux): pf-encode + punktfunk-host compile, clippy
clean, unit suite green. The GPU paths themselves are unchanged from the
smoke-validated 96e19986 — this commit only changes which sessions
select them by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:24:46 +02:00
enricobuehlerandClaude Fable 5 3736bbdc73 fix(core): streamed-AU hardening from the security-review pass
apple / swift (push) Successful in 1m19s
docker / deploy-docs (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
release / apple (push) Successful in 8m41s
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Review verdict: no exploitable memory-safety / amplification / splice /
resurrection issue; nonce order and two-lane seal byte-identical for the
legacy path. Actionable findings applied:
- explicit per-block data_shards cross-check next to the recovery_shards
  one (defense-in-depth — the geometry invariants make it unreachable
  today, but have_data indexing assumes it)
- partial delivery skips an UNPINNED streamed frame (frame_bytes still 0)
  instead of truncating its max-sized buffer to an empty "partial"
- regression tests for the two untested load-bearing guards: the
  final-first out-of-range-sentinel drop (the exact-sized-buffer guard)
  and second-final-with-different-totals rejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:13:59 +02:00
enricobuehlerandClaude Fable 5 421e811225 feat(loss-harness): sweep the streamed-AU wire shape alongside whole-AU
The Phase-2 gate: more, smaller wire units must not regress FEC recovery.
Adds a GF16 streamed column (three chunk pushes + finish per AU — sentinel
blocks then real totals). Measured: identical to whole-AU at every loss
rate (50/50 through 1/6, the same 25%-budget cliff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:59:09 +02:00
enricobuehlerandClaude Fable 5 79913a430e feat(probe): advertise VIDEO_CAP_STREAMED_AU
The probe's reassembler is the shared core one, and the probe is the tool
that measures the streamed-AU overlap win — advertise the cap so a host
with chunked encode streams to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:55:34 +02:00
enricobuehlerandClaude Fable 5 c5402cb1f7 feat(core+host): LN1 phase-2 — VIDEO_CAP_STREAMED_AU streamed access units
The wire half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 2): toward a client that
advertises the new cap bit (0x20), a chunked-poll encoder session ships each
AU's completed FEC blocks while the tail of the frame is still encoding —
the AU's last packet leaves the host as the encode finishes instead of
after it.

Wire semantics (negotiated; zero change for anyone else):
- Non-final blocks ride SENTINEL headers: block_count = 0 (a value no legacy
  sender emits) + frame_bytes = 0 + exactly max_data_per_block data shards,
  so the receiver's shard-offset formula needs no total.
- The final block's headers carry the real frame_bytes/block_count (+
  FLAG_EOF) and RETRO-VALIDATE the whole frame: totals under which a
  received sentinel block is out of range or not full-K kill the frame
  wholesale (no spliced delivery) and the index can't be resurrected.
- Firewall: sentinels are bounded by the negotiated limits (full-K exactly,
  never the last block the limits allow, no total to lie about); the exact
  derived-geometry check runs unchanged on every non-sentinel packet and
  retroactively at pinning. Sentinel opens commit a max_frame_bytes buffer,
  bounded by the existing IN_FLIGHT_BUF_FACTOR budget (amplification test).
- Order-agnostic like legacy: a reversed frame (final block first) opens
  legacy-shaped and still accepts its sentinels against the pinned totals.
- Small/empty streamed AUs degenerate to byte-identical legacy headers.

Host: Packetizer::{begin,push,finish}_streamed seal full-K blocks (data +
parity per block) as chunks arrive; Session::seal_streamed_* share the
pooled-wire + two-lane seal machinery via the new seal_run; the send thread
paces each flush under the frame's existing deadline (pace_sealed split out
of paced_submit) and runs the whole-AU accounting at the last chunk; the
encode pump forwards poll_chunk output as ChunkMsg when the client has the
cap AND the encoder chunks (re-queried per AU — an escalation falls back
seamlessly). Probes never run mid-AU. PUNKTFUNK_STREAMED_AU=0 = host escape
hatch. Client core ORs the cap into Hello (the shared reassembler carries
the support). Sampled first_slice_us vs encode_us PERF log measures the
overlap; the 0xCF stage-field extension stays a follow-up.

Core tests: streamed round-trip (clean/loss/reorder/duplicate, both orders),
sentinel firewall bounds, lying-final wholesale kill + no-resurrect,
open-amplification budget, header-shape pins. Gates still owed before
default-on: security review pass, loss-harness curve, GameStream smoke
(plane untouched structurally), bitrate A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:53:53 +02:00
enricobuehlerandClaude Fable 5 9d3b114fd6 feat(encode/nvenc): LN1 phase-1 — slice-boundary chunked encoder poll (poll_chunk/AuChunk)
apple / swift (push) Successful in 1m20s
apple / screenshots (push) Successful in 6m24s
windows-host / package (push) Successful in 10m45s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The encoder half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 1): with PUNKTFUNK_NVENC_SLICES=N +
PUNKTFUNK_NVENC_SUBFRAME=1 on a sync depth-1 session, Encoder::poll_chunk hands
the in-flight AU out as slice-boundary chunks read through doNotWait sub-frame
locks while the tail is still encoding — the readback loop the on-hw probe
validated (~200 µs slice spacing on the 5070 Ti), productionized.

- codec.rs: AuChunk (AU metadata on the first chunk, `last` closes the AU;
  chunks concatenate to exactly the poll() bytes) + supports_chunked_poll /
  poll_chunk trait surface. Default impl wraps poll() as one self-closing
  chunk, so a chunk-driven consumer works against every backend.
- nvenc_cuda: chunked readback cut at slice boundaries only (bitstream size at
  n reported slices = end of slice n, Annex-B contiguous); completion is NEVER
  numSlices alone — one finishing BLOCKING lock is the authority and the wedge
  watchdog, so the final chunk blocks exactly like sync poll (the depth-1 pump
  contract; 6dc195f9 bug class). Keyframe on early chunks is the submit-time
  IDR prediction (exact under P-only/infinite-GOP), cross-checked at finish.
  Debug builds shadow-check emitted chunks against the finished AU prefix.
  Mutually exclusive with pipelined retrieve (gated off when async_rt exists,
  dropped by the escalation rebuild); composes with stream-ordered submit.
- nvenc_core: slices_env/subframe_requested shared parses so the config author
  and the chunked-poll arming can't disagree.
- TrackedEncoder forwards both new methods (the set_wire_chunking trap class).

Host loop untouched — Phase 2 (VIDEO_CAP_STREAMED_AU seal/send) consumes this.
On-hw: nvenc_cuda_chunked_poll_end_to_end + nvenc_cuda_chunked_poll_fallback_whole_au.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:53:32 +02:00
enricobuehlerandClaude Fable 5 411c6dc06a fix(encode): forward set_pipelined through TrackedEncoder — the LN3 escalation no-oped through the wrapper
Every session encoder is boxed in TrackedEncoder (open_video), and the
wrapper never forwarded Encoder::set_pipelined — so the host loop's
contention escalation (ae673158) hit the trait default, which returns
false ("backend can't pipeline, stop asking"), and the pipelined-retrieve
stage could never engage adaptively. Only the explicit
PUNKTFUNK_NVENC_ASYNC=1 open-time path worked. The exact defaulted-method
trap the wrapper's own comment documents for set_wire_chunking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:53:32 +02:00
enricobuehlerandClaude Fable 5 833f3348a0 feat(store): console plugin store, index repo, and the fixes on-glass found
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s
Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.

Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.

Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
  scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
  own framework: it listed as installed and would have been imported as a
  unit. Both now key off the plugins dir's top-level dependencies, with an
  emptied dependency list meaning 'nothing installed' rather than falling
  back to the naming convention
- the store must not pass new flags to the runner: the scripting package
  ships separately and an older one reads an unknown flag's value as a
  package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
  as Ok with an empty body — handled as an error it made every refresh
  after the first verify a signature over zero bytes and sit stale

Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:48:02 +02:00
enricobuehlerandClaude Fable 5 45c3b96907 feat(store): plugin store host module — signed catalogs, tiered trust, install jobs
The index is the verification gate: a catalog entry pins one exact version
plus that version's tarball integrity hash, so 'verified on every release'
is a property of the data rather than a promise about process. Nothing can
express 'track latest' for a catalogued plugin.

- store/index.rs   signed index parse + ed25519 verify (ring), validate-and-drop
                   per entry, semver minHost/advisory ranges
- store/sources.rs built-in unom source (compiled-in URL + two key slots for
                   rotation) + operator sources in plugin-sources.json
- store/catalog.rs https fetch with size/timeout/redirect caps, signature before
                   parse, last-good disk cache (stale-but-usable when offline)
- store/jobs.rs    single-flight install/uninstall: registry-integrity preflight
                   against the pin, spawn the runner CLI with live log capture,
                   post-install version check with rollback, provenance record,
                   runner restart (discovery is startup-only)
- store/manifest.rs install provenance; absence means CLI-installed
- mgmt/store.rs    12 routes under /api/v1/store, denied to the plugin token
                   (a plugin that can install plugins is an escalation primitive)

Also generalizes runner discovery and listInstalled from @punktfunk/plugin-*
to ANY scope's plugin-*: catalog entries must be scoped so the scope can map
to that entry's registry, so a third-party plugin necessarily arrives under
its own scope and would otherwise install but never run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:48:02 +02:00
enricobuehlerandClaude Opus 4.8 96e19986bc feat(encode): RGB-direct padded-copy — unaligned modes (1080p) get the EFC path safely
apple / swift (push) Successful in 1m20s
apple / screenshots (push) Successful in 6m42s
windows-host / package (push) Successful in 9m24s
android / android (push) Failing after 18s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m43s
arch / build-publish (push) Successful in 11m48s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 7m33s
deb / build-publish (push) Successful in 9m1s
deb / build-publish-host (push) Successful in 13m6s
ci / rust (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
Lifts 3aacec53's alignment gate into a mode select: an aligned mode keeps
the true zero-copy direct import; an unaligned one (1080p!) now blits the
visible frame into a per-slot ALIGNED BGRA staging image and duplicates
the edge rows/columns into the 64x16 padding — transfer-only regions in
one vkCmdCopyImage (1080p: visible + 8 row regions; width padding adds a
second self-copy pass in GENERAL), no compute shader. The encode reads
the staging image, never the capture buffer, so the EFC can never read
past a producer allocation (the field GPU hang). Still one ~8 MB copy vs
the CSC path's ~17 MB + dispatch + plane copies. Verdict line:
active(padded-copy). The staging import drops the video profile entirely
(TRANSFER_SRC only).

CPU-payload paths made honest on the way (they were the smoke baseline
AND the software-capture fallback):
- rgb mode: the staging upload is padded CPU-side (edge duplication) so
  the aligned encode-src is fully defined;
- CSC mode: the sampled image is now SOURCE-sized with a matching copy
  extent — the old aligned-size image + tightly-packed buffer sheared
  rows and left garbage rows at unaligned modes (black-bar artifacts;
  YMIN=16 in every smoke frame), which also masked as a 24 dB PSNR
  'regression' against the (correct) padded output.

On-glass (780M, host Mesa 26.0.4): all four smokes pass at 256x256
(direct) AND 250x250 (padded); padded-EFC frames decode perfectly
uniform (YMIN==YMAX) at the exact 709-narrow values (79/148/60 for the
first three fills); CSC-vs-padded PSNR 49.9 dB avg after the baseline
fix. clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:38:55 +02:00
enricobuehlerandClaude Opus 4.8 3aacec53d8 fix(encode): RGB-direct must not engage on unaligned modes — the EFC reads past the capture buffer (GPU hang)
apple / swift (push) Successful in 1m23s
apple / screenshots (push) Successful in 6m27s
windows-host / package (push) Successful in 9m29s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m15s
ci / bench (push) Successful in 6m8s
android / android (push) Successful in 15m47s
arch / build-publish (push) Successful in 14m49s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 25s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 9m32s
ci / rust (push) Successful in 19m0s
deb / build-publish-host (push) Successful in 13m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m27s
docker / deploy-docs (push) Successful in 13s
Field report (commit 25624443, RDNA2/VCN3 host, 1080p): repeated VCN0 VM
protection faults from punktfunk-host, vcn_enc_0.0 ring timeouts, two ring
resets, then a failed reset escalating to a full MODE1 GPU reset with VRAM
loss. Root cause is B1's RGB-direct source: the session's coded extent is
64x16-aligned (1920x1088) but the captured dmabuf is only the real mode
size (1920x1080) — the VCN EFC reads the 8 padding rows past the end of
the buffer (8 x 7680 B = 61 KB, matching the faulting page span exactly).
The CSC shader absorbs alignment by clamping reads and duplicating edges;
RGB-direct has no such stage. Every prior validation surface happened to
be aligned (256x256 smoke, 1440p live box) — 1080p is the common mode
that is not. The stall watchdog's rebuild-and-refault loop then turned a
per-session fault into the GPU death spiral.

Two layers:
- Open-time gate: RGB-direct engages only when the real mode equals the
  aligned coded extent (720p/1440p/4K yes, 1080p no); otherwise the CSC
  path runs and the verdict line says why (unaligned-mode). The padded-
  copy variant that re-enables 1080p is design-doc B2 work.
- Submit-time check: an RGB-direct frame that does not cover the coded
  extent errors out (encoder-rebuild path) instead of importing an
  undersized source.

On-glass (780M, host Mesa 26.0.4): all four smokes pass aligned; at
PF_SMOKE_W/H=250 the gate refuses RGB-direct and the rgb smokes soft-skip.
clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:23:29 +02:00
67 changed files with 10055 additions and 285 deletions
Generated
+29 -27
View File
@@ -2159,7 +2159,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.16.0"
version = "0.17.0"
[[package]]
name = "lazy_static"
@@ -2264,7 +2264,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"bindgen",
"cmake",
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"punktfunk-core",
]
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ash",
@@ -2832,7 +2832,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2850,7 +2850,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ash",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ash",
@@ -2894,7 +2894,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"ash",
"bindgen",
@@ -2903,7 +2903,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"libc",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2929,11 +2929,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.16.0"
version = "0.17.0"
[[package]]
name = "pf-inject"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2961,14 +2961,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ash",
@@ -2983,7 +2983,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3013,7 +3013,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3025,7 +3025,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ash",
@@ -3221,7 +3221,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"android_logger",
"jni",
@@ -3237,7 +3237,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3268,7 +3268,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3287,7 +3287,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3318,7 +3318,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3363,11 +3363,13 @@ dependencies = [
"rand 0.8.6",
"rcgen",
"reis",
"ring",
"roxmltree",
"rsa",
"rusqlite",
"rustls",
"rusty_enet",
"semver",
"serde",
"serde_json",
"sha2",
@@ -3400,7 +3402,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3414,7 +3416,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"anyhow",
"ksni",
@@ -3437,7 +3439,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.16.0"
version = "0.17.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1133 -1
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -487,8 +487,11 @@ async fn session(args: Args) -> Result<()> {
// host/network split is exactly what it exists to report. Old hosts ignore the bit.
// PROBE_SEQ: the shared-core reassembler windows probe-space frames, so the probe
// qualifies for `--speed-test` bursts; without the bit the host declines them.
// STREAMED_AU: the same shared reassembler accepts sentinel-headed streamed
// blocks, and the probe is exactly the tool that measures the overlap win.
let mut caps = punktfunk_core::quic::VIDEO_CAP_HOST_TIMING
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ;
| punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ
| punktfunk_core::quic::VIDEO_CAP_STREAMED_AU;
if std::env::var_os("PUNKTFUNK_CLIENT_10BIT").is_some() {
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
}
+77
View File
@@ -32,6 +32,46 @@ pub struct EncodedFrame {
pub chunk_aligned: bool,
}
/// One slice-boundary chunk of an encoded AU, emitted by a chunked-poll backend
/// ([`Encoder::poll_chunk`], latency plan §7 LN1): the encoder hands out completed slices while
/// the rest of the frame is still encoding, so packetize/FEC/pacing can overlap the encode tail.
/// The chunks of one AU concatenate to exactly the bytes [`Encoder::poll`] would have returned,
/// and every cut lands on an Annex-B NAL boundary (slice starts). AU-level metadata
/// (`pts_ns`/`keyframe`/`recovery_anchor`/`chunk_aligned`) is authoritative on the FIRST chunk
/// (`first`) — the host opens the wire frame from it; `last` closes the AU. `keyframe` on a
/// non-final chunk is the encoder's own prediction (exact under the P-only/infinite-GOP config —
/// the driver only ever emits an IDR we asked for); the final chunk re-checks it against the
/// driver's reported picture type.
pub struct AuChunk {
pub data: Vec<u8>,
pub pts_ns: u64,
pub keyframe: bool,
/// See [`EncodedFrame::recovery_anchor`].
pub recovery_anchor: bool,
/// See [`EncodedFrame::chunk_aligned`].
pub chunk_aligned: bool,
/// Opens the AU (carries the authoritative AU metadata).
pub first: bool,
/// Closes the AU (the concatenation is complete; the encoder's in-flight slot is released).
pub last: bool,
}
impl AuChunk {
/// A whole AU as a single self-closing chunk — what every non-chunked backend's
/// [`Encoder::poll_chunk`] default emits, so a chunk consumer needs no per-backend fork.
pub fn whole(f: EncodedFrame) -> Self {
AuChunk {
data: f.data,
pts_ns: f.pts_ns,
keyframe: f.keyframe,
recovery_anchor: f.recovery_anchor,
chunk_aligned: f.chunk_aligned,
first: true,
last: true,
}
}
}
/// Codec selection negotiated with the client.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Codec {
@@ -280,6 +320,26 @@ pub trait Encoder: Send {
}
/// Pull the next encoded AU if one is ready.
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
/// Whether [`poll_chunk`](Self::poll_chunk) currently emits sub-AU chunks — i.e. the LIVE
/// session has slice-level readback armed (Linux direct-NVENC with the
/// `PUNKTFUNK_NVENC_SLICES` and `PUNKTFUNK_NVENC_SUBFRAME` knobs on a sync depth-1
/// retrieve). Dynamic, not static: a pipelined-retrieve escalation or a session rebuild can
/// turn it off — re-query per AU, never cache across frames. `false` (the default) means
/// `poll_chunk` degrades to one whole-AU chunk per frame.
fn supports_chunked_poll(&self) -> bool {
false
}
/// Pull the next slice-boundary chunk of the oldest in-flight AU (latency plan §7 LN1).
/// Semantics when chunking is live: BLOCKS until the next chunk is readable, and the final
/// (`last`) chunk blocks exactly like [`poll`](Self::poll) does — the depth-1 pump treats
/// `None` as re-poll-next-tick, so a non-blocking tail would ride the AU one tick late (the
/// `6dc195f9` Vulkan bug class). `Ok(None)` only when no AU is in flight. Each AU must be
/// drained through ONE method: calling `poll` on a partially-chunked AU is a caller bug (the
/// backend errors rather than double-emit bytes). Default: delegates to `poll`, wrapping the
/// whole AU as a single `first && last` chunk.
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
Ok(self.poll()?.map(AuChunk::whole))
}
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
@@ -436,6 +496,23 @@ mod tests {
}
}
/// The whole-AU chunk (every non-chunked backend's `poll_chunk` shape) must carry the AU's
/// metadata verbatim and be self-closing (`first && last`).
#[test]
fn whole_au_chunk_is_self_closing() {
let c = AuChunk::whole(EncodedFrame {
data: vec![0, 0, 0, 1, 0x40],
pts_ns: 42,
keyframe: true,
recovery_anchor: true,
chunk_aligned: false,
});
assert_eq!(c.data, vec![0, 0, 0, 1, 0x40]);
assert_eq!(c.pts_ns, 42);
assert!(c.keyframe && c.recovery_anchor && !c.chunk_aligned);
assert!(c.first && c.last);
}
/// Wire round-trip and the stats label stay in lockstep with the `quic::CODEC_*` bits.
#[test]
fn codec_wire_roundtrip_and_label() {
+447 -10
View File
@@ -44,6 +44,15 @@
//! the retrieve thread the same way it would hang the encode thread today (Linux has no
//! event-timeout escape) — no regression, just no new watchdog either.
//!
//! **Sub-frame chunked poll** (latency plan §7 LN1 — **default-on since Phase 3**: 4 slices +
//! sub-frame readback on every session whose GPU advertises `SUBFRAME_READBACK`; escapes are
//! `PUNKTFUNK_NVENC_SLICES=1` and `PUNKTFUNK_NVENC_SUBFRAME=0`): on a sync depth-1 session,
//! [`Encoder::poll_chunk`] hands the in-flight AU out as slice-boundary chunks read through
//! `doNotWait` sub-frame locks while the tail is still encoding; one final blocking lock closes
//! the AU (the completion authority — `numSlices` alone is not trusted across driver branches).
//! Mutually exclusive with the pipelined retrieve (the escalated rebuild drops it); composes
//! with stream-ordered submit (both are sync depth-1 features).
//!
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
//! and the VAAPI/software backends carry the session).
@@ -52,10 +61,11 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
LowLatencyConfig, NvStatusExt, RFI_DPB,
};
use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result};
use pf_frame::{CapturedFrame, FramePayload};
use pf_zerocopy::cuda::{self, InputSurface};
@@ -425,6 +435,41 @@ struct RingSlot {
reg: nv::NV_ENC_REGISTERED_PTR,
}
/// `doNotWait` sampling cadence inside [`Encoder::poll_chunk`] — the probe measured ~200 µs
/// between slice completions on the 5070 Ti, so 50 µs keeps the added per-chunk delivery delay
/// well under one slice time without hammering the driver.
const CHUNK_SAMPLE_INTERVAL: std::time::Duration = std::time::Duration::from_micros(50);
/// Progress of a sub-frame chunked readback (§7 LN1 Phase 1) for the FRONT in-flight AU: how
/// much of the bitstream has already been handed out as chunks. `Some` from an AU's first
/// emitted chunk until its `last` — [`Encoder::poll`] refuses to run while it exists (a plain
/// poll would re-emit the already-shipped prefix).
struct ChunkState {
/// Bytes emitted so far — also the next chunk's start offset (always a slice boundary).
emitted: usize,
/// Completed slices already covered by emitted chunks.
slices_out: u32,
/// The AU-opening chunk (`AuChunk::first`) has been handed out.
opened: bool,
/// Debug-build shadow of every emitted byte, cross-checked against the finishing blocking
/// lock's full AU — a mis-cut chunk fails loudly in the on-hw tests instead of silently
/// corrupting the wire. Compiled out of release builds.
#[cfg(debug_assertions)]
shadow: Vec<u8>,
}
impl ChunkState {
fn new() -> Self {
ChunkState {
emitted: 0,
slices_out: 0,
opened: false,
#[cfg(debug_assertions)]
shadow: Vec::new(),
}
}
}
pub struct NvencCudaEncoder {
encoder: *mut c_void,
/// The shared process-wide `CUcontext` the session is bound to (from `zerocopy::cuda::context`).
@@ -456,11 +501,14 @@ pub struct NvencCudaEncoder {
/// sampled `PUNKTFUNK_PERF` submit-split log cadence, mirroring the VAAPI backend's counter.
frames: u64,
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
/// in-flight encode. The fourth field tags the first frame encoded after a successful
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the
/// client lifts its post-loss freeze on.
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool)>,
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor,
/// IDR-predicted) per in-flight encode. The fourth field tags the first frame encoded after a
/// successful [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor
/// P-frame the client lifts its post-loss freeze on. The fifth is the submit-time keyframe
/// prediction (forced/opening IDR) that chunked poll stamps on chunks emitted before the
/// driver reports the real picture type — exact under P-only + infinite GOP (the driver only
/// emits IDRs we asked for); the finishing blocking lock cross-checks it.
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool, bool)>,
/// The frame number of the NEXT submission (also its `inputTimeStamp`). Pinned per frame by
/// [`Encoder::submit_indexed`] to the WIRE frame index the AU will carry, so the DPB timestamps
/// `invalidate_ref_frames` compares client frame numbers against stay 1:1 with the wire across
@@ -512,6 +560,24 @@ pub struct NvencCudaEncoder {
/// Stream-ordered submit armed for the live session (sync-retrieve mode only; see
/// [`stream_ordered_requested`]). The per-frame gate additionally requires `pending` empty.
stream_ordered: bool,
/// Slice count the live session was configured with ([`resolve_slices`] — env override,
/// else the Linux direct-NVENC default of 4 since Phase 3; 1 = the preset's single slice).
/// Chunked poll needs ≥ 2 to have boundaries to cut at. Latched at init, consumed by
/// `build_config` (so an in-place reconfigure presents the same slicing).
slices: u32,
/// `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` from the caps probe — gates the DEFAULT-on
/// sub-frame arming (an unsupported GPU must not have `enableSubFrameWrite` forced into its
/// init params, which could fail the session open). `PUNKTFUNK_NVENC_SUBFRAME=1` overrides.
subframe_cap: bool,
/// Sub-frame readback resolved for the live session ([`resolve_subframe`] over
/// [`subframe_cap`](Self::subframe_cap)); consumed by every `build_init_params` call so the
/// open and the in-place reconfigure present identical init params.
subframe_on: bool,
/// Sub-frame chunked poll armed for the live session (§7 LN1 Phase 1): multi-slice +
/// sub-frame readback configured AND sync retrieve at init. See [`Encoder::poll_chunk`].
subframe_chunks: bool,
/// In-progress chunked readback of the front in-flight AU. See [`ChunkState`].
chunk: Option<ChunkState>,
}
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
@@ -594,6 +660,11 @@ impl NvencCudaEncoder {
want_async: false,
io_stream: ptr::null_mut(),
stream_ordered: false,
slices: 1,
subframe_cap: false,
subframe_on: false,
subframe_chunks: false,
chunk: None,
})
}
@@ -635,7 +706,7 @@ impl NvencCudaEncoder {
}
}
// Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams.
for (_, map, _, _) in &self.pending {
for (_, map, _, _, _) in &self.pending {
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, *map);
}
@@ -664,6 +735,10 @@ impl NvencCudaEncoder {
self.io_stream = ptr::null_mut();
}
self.stream_ordered = false;
// Chunked-poll state is per session: a half-chunked AU dies with its in-flight frame
// (the forfeit contract), and the next session re-latches the arming at init.
self.subframe_chunks = false;
self.chunk = None;
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
self.bitstreams.clear();
self.pending.clear();
@@ -748,6 +823,15 @@ impl NvencCudaEncoder {
}
self.rfi_supported = rfi != 0;
self.custom_vbv = custom_vbv != 0;
self.subframe_cap = subframe != 0;
// Phase-3 default-on (nvenc-subframe-slice-output.md): 4 slices + sub-frame readback on
// every Linux direct-NVENC session, resolved HERE (before the session opens) so the
// config author, the init params and the chunked-poll latch all agree; the caps probe
// gates the sub-frame default so a GPU without SUBFRAME_READBACK never has it forced
// into its init params. PUNKTFUNK_NVENC_SLICES=1 / PUNKTFUNK_NVENC_SUBFRAME=0 are the
// escapes.
self.slices = resolve_slices(self.codec, 4);
self.subframe_on = resolve_subframe(self.subframe_cap);
tracing::info!(
rfi = self.rfi_supported,
custom_vbv = self.custom_vbv,
@@ -861,6 +945,7 @@ impl NvencCudaEncoder {
av1_input_depth_minus8: 0,
hdr: self.hdr,
rfi_supported: self.rfi_supported,
slices: self.slices,
},
);
Ok(cfg)
@@ -901,6 +986,7 @@ impl NvencCudaEncoder {
&mut cfg,
split_mode,
false,
self.subframe_on,
);
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
@@ -1124,6 +1210,20 @@ impl NvencCudaEncoder {
}
}
}
// Sub-frame chunked poll (§7 LN1 Phase 1; default-on since Phase 3): armed iff this
// session was CONFIGURED multi-slice + sub-frame readback (`self.slices` /
// `self.subframe_on` were resolved once in `query_caps` and consumed by
// `build_config` / `build_init_params`, so the latch can't disagree with the session
// config) and the retrieve is sync — chunked poll is a depth-1 sync feature; a
// pipelined session's non-blocking poll owns the bitstream from the retrieve thread
// instead (the sub-frame write itself stays armed there; it's harmless).
self.subframe_chunks = self.slices >= 2 && self.subframe_on && self.async_rt.is_none();
if self.subframe_chunks {
tracing::info!(
slices = self.slices,
"NVENC sub-frame chunked poll armed (poll_chunk emits slice-boundary AU chunks)"
);
}
tracing::info!(
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
bit_depth = self.bit_depth,
@@ -1172,7 +1272,7 @@ impl NvencCudaEncoder {
/// in-flight entry, cross-check FIFO pairing, unmap its input HERE (the encode thread — the
/// retrieve thread never touches input resources), and queue the finished AU.
fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
let Some((bs, map, pts_ns, anchor, _)) = self.pending.pop_front() else {
bail!("NVENC retrieve: completion with no in-flight frame (pairing bug)");
};
if bs as usize != done.bs {
@@ -1471,6 +1571,10 @@ impl Encoder for NvencCudaEncoder {
mp.mappedResource,
captured.pts_ns,
anchor,
// The chunked-poll keyframe prediction: exactly the SEI gate's is_idr (forced
// flags or the session-opening frame) — under P-only + infinite GOP the driver
// never emits an IDR on its own, so this matches the eventual pictureType.
is_idr,
));
}
if sample {
@@ -1580,6 +1684,11 @@ impl Encoder for NvencCudaEncoder {
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// A partially-chunked AU must be finished through `poll_chunk`: its emitted prefix is
// already with the caller, so a whole-AU poll here would double-emit those bytes.
if self.chunk.is_some() {
bail!("NVENC poll() called mid-chunked-AU — drain it via poll_chunk (caller bug)");
}
// Two-thread mode: drain whatever the retrieve thread has finished (non-blocking) and
// hand out the oldest ready AU. `None` = nothing completed yet — the session loop keeps
// the frame in flight and re-polls next tick; capture never blocks on the encode wait.
@@ -1600,7 +1709,7 @@ impl Encoder for NvencCudaEncoder {
.ready
.pop_front());
}
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
let Some((bs, map, pts_ns, anchor, _)) = self.pending.pop_front() else {
return Ok(None);
};
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
@@ -1645,6 +1754,172 @@ impl Encoder for NvencCudaEncoder {
}
}
fn supports_chunked_poll(&self) -> bool {
// Dynamic on purpose: a pipelined-retrieve escalation rebuilds the session with
// `async_rt` present (and `teardown` drops the latch), so a caller re-querying per AU
// sees the mode fall away instead of chunk-polling a session that can't serve it.
self.subframe_chunks && self.async_rt.is_none()
}
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
// Not a chunked session (knobs off, AV1, escalated to pipelined retrieve): degrade to a
// single whole-AU chunk so a chunk-driven caller works against every session shape. The
// `chunk.is_none()` arm is defensive — a mid-AU state must always finish below.
if !self.supports_chunked_poll() && self.chunk.is_none() {
return Ok(self.poll()?.map(AuChunk::whole));
}
let Some(&(bs, _, pts_ns, anchor, idr_hint)) = self.pending.front() else {
return Ok(None);
};
// Sampling budget: if this driver branch never publishes intermediate slices, stop
// burning CPU after ~2 frame intervals and finish through the blocking lock — worst
// case poll_chunk behaves like sync `poll` plus a few failed doNotWait attempts.
let budget = std::time::Duration::from_micros(2_000_000 / self.fps.max(1) as u64);
let t0 = std::time::Instant::now();
let mut offsets = [0u32; 32];
loop {
let emitted = self.chunk.as_ref().map_or(0, |c| c.emitted);
let slices_out = self.chunk.as_ref().map_or(0, |c| c.slices_out);
// SAFETY: `bs` is the front `pending` entry's pool bitstream (a prior
// `encode_picture` targeted it, and `teardown` clears `pending` whenever it nulls
// the session), the session is live, and this runs on the encode thread. `lock`
// (version set, doNotWait) and `offsets` are live stack locals across the
// synchronous call; `reportSliceOffsets` was armed at init so the driver may write
// up to `numSlices` ≤ 32 offsets (`sliceModeData` is clamped 2..=32 by
// `resolve_slices`). On a successful sub-frame lock `bitstreamBufferPtr` holds
// `bitstreamSizeInBytes` readable bytes of COMPLETED slices (enableSubFrameWrite
// publishes them mid-encode; proven by the on-hw probe) valid until the matching
// unlock; the emitted range is copied out BEFORE the unlock. Every successful lock
// is unlocked exactly once on all paths through the body.
unsafe {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
outputBitstream: bs,
sliceOffsets: offsets.as_mut_ptr(),
..Default::default()
};
lock.set_doNotWait(1);
if (api().lock_bitstream)(self.encoder, &mut lock)
.nv_ok()
.is_ok()
{
let n = lock.numSlices;
let bytes = lock.bitstreamSizeInBytes as usize;
if n >= self.slices {
// Every slice is readable — fall through to the finishing blocking
// lock (the completion authority; `numSlices` alone is not trusted
// across driver branches).
let _ = (api().unlock_bitstream)(self.encoder, bs);
break;
}
if n > slices_out && bytes > emitted {
// New completed slice(s): cut `[emitted..bytes)`. `bytes` with `n`
// reported slices is the end of slice n (slices are contiguous
// Annex-B), so the cut lands on a NAL boundary.
let data =
std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, bytes)
[emitted..]
.to_vec();
(api().unlock_bitstream)(self.encoder, bs)
.nv_ok()
.map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk)", e))?;
let cs = self.chunk.get_or_insert_with(ChunkState::new);
#[cfg(debug_assertions)]
cs.shadow.extend_from_slice(&data);
let first = !cs.opened;
cs.opened = true;
cs.emitted = bytes;
cs.slices_out = n;
return Ok(Some(AuChunk {
data,
pts_ns,
keyframe: idr_hint,
recovery_anchor: anchor,
chunk_aligned: false,
first,
last: false,
}));
}
let _ = (api().unlock_bitstream)(self.encoder, bs);
}
// Non-SUCCESS (LOCK_BUSY on other branches) = not ready — never an error here;
// the finishing blocking lock below owns real failures.
}
if t0.elapsed() > budget {
break;
}
std::thread::sleep(CHUNK_SAMPLE_INTERVAL);
}
// Finish: ONE blocking lock — the completion authority and the wedge-watchdog hook,
// exactly like sync `poll` (so the final chunk blocks and the AU tail never rides a
// +1 tick — the depth-1 pump contract). Emits whatever the sampler hadn't handed out.
let (bs, map, pts_ns, anchor, idr_hint) =
self.pending.pop_front().expect("front() checked above");
// SAFETY: same contract as `poll`'s blocking lock: `bs` is the popped in-flight pool
// bitstream on the live session (encode thread); the blocking `lock_bitstream` (version
// set) returns when the encode finished, yielding `bitstreamSizeInBytes` CPU-readable
// bytes at `bitstreamBufferPtr` valid until `unlock_bitstream` — every read (tail copy
// + debug prefix check) happens BEFORE the unlock. `map` (paired with `bs` in `pending`)
// is unmapped here, after completion, exactly once.
unsafe {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
outputBitstream: bs,
..Default::default()
};
(api().lock_bitstream)(self.encoder, &mut lock)
.nv_ok()
.map_err(|e| nvenc_status::call_err("lock_bitstream (chunk finish)", e))?;
let total = lock.bitstreamSizeInBytes as usize;
let full = std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, total);
let cs = self.chunk.take().unwrap_or_else(ChunkState::new);
if cs.emitted > total {
let _ = (api().unlock_bitstream)(self.encoder, bs);
bail!(
"NVENC chunked poll: {} bytes already emitted but the finished AU is only \
{} — sub-frame readback reported bytes the final lock disowns",
cs.emitted,
total
);
}
#[cfg(debug_assertions)]
if cs.shadow.as_slice() != &full[..cs.emitted] {
let _ = (api().unlock_bitstream)(self.encoder, bs);
bail!("NVENC chunked poll: emitted chunks diverge from the finished AU prefix");
}
let data = full[cs.emitted..].to_vec();
let keyframe = matches!(
lock.pictureType,
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
);
(api().unlock_bitstream)(self.encoder, bs)
.nv_ok()
.map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk finish)", e))?;
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
if cs.opened && keyframe != idr_hint {
// Can't happen under P-only + infinite GOP; if a driver branch ever proves
// otherwise, the earlier chunks carried the wrong flag — make it visible.
tracing::warn!(
predicted = idr_hint,
actual = keyframe,
"NVENC chunked poll: picture type diverged from the submit-time prediction"
);
}
Ok(Some(AuChunk {
data,
pts_ns,
keyframe,
recovery_anchor: anchor,
chunk_aligned: false,
first: !cs.opened,
last: true,
}))
}
}
fn reset(&mut self) -> bool {
// SAFETY: `teardown` requires the encode thread with no NVENC call in flight and a session
// whose cached resources belong to `self.encoder` — all hold here (reset is called from the
@@ -1683,6 +1958,7 @@ impl Encoder for NvencCudaEncoder {
&mut cfg,
self.split_mode,
false,
self.subframe_on,
),
..Default::default()
};
@@ -2328,4 +2604,165 @@ mod tests {
enc.submit_indexed(&frame, 2).expect("submit follow-up");
enc.poll().expect("poll").expect("follow-up AU");
}
/// Every chunk must be cut at an Annex-B NAL boundary (slice starts carry a start code).
fn starts_with_start_code(d: &[u8]) -> bool {
d.starts_with(&[0, 0, 0, 1]) || d.starts_with(&[0, 0, 1])
}
/// ON-HARDWARE (RTX box `.21`): LN1 Phase 1 — the chunked poll end to end, at the Phase-3
/// DEFAULTS (no env knobs: 4 slices + sub-frame readback arm on their own on a
/// SUBFRAME_READBACK-capable GPU). `poll_chunk` must (a) report the mode armed, (b) hand
/// every AU out as chunks whose first chunk opens the AU with the right metadata and whose
/// `last` closes it, (c) cut every chunk at an Annex-B start code, and (d) reassemble byte-
/// identically to the finishing blocking lock's AU (enforced by the debug-build shadow check
/// inside `poll_chunk` — a mismatch errors the test). At least one frame must actually chunk
/// (>1 chunk) — the 5070 Ti probe shows every frame does. Run single-threaded (env vars are
/// process-global): `-- --ignored --test-threads=1`.
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_chunked_poll_end_to_end() {
const W: u32 = 1920;
const H: u32 = 1080;
// Defaults under test — make sure another test's knobs aren't leaking in.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
W,
H,
60,
20_000_000,
true,
8,
ChromaFormat::Yuv420,
)
.expect("open NVENC CUDA session");
let mut multi_chunk_frames = 0usize;
let mut total_chunks = 0usize;
for i in 0..6u32 {
let frame = nv12_frame(W, H, i);
enc.submit_indexed(&frame, i).expect("submit");
assert!(
enc.supports_chunked_poll(),
"4 slices + subframe on a sync session must arm chunked poll"
);
let mut au = Vec::new();
let mut chunks = 0usize;
loop {
let c = enc
.poll_chunk()
.expect("poll_chunk")
.expect("an AU is in flight — poll_chunk must block, never None");
if chunks == 0 {
assert!(c.first, "the first chunk must open the AU");
assert_eq!(
c.keyframe,
i == 0,
"only the session-opening frame is an IDR"
);
}
assert_eq!(c.pts_ns, i as u64 * 16_666_667, "pts rides every chunk");
assert!(!c.recovery_anchor, "no RFI happened");
if !c.data.is_empty() {
assert!(
starts_with_start_code(&c.data),
"chunk cut must land on an Annex-B start code (frame {i}, chunk {chunks})"
);
}
au.extend_from_slice(&c.data);
chunks += 1;
if c.last {
break;
}
}
assert!(!au.is_empty(), "frame {i} produced an empty AU");
assert!(
enc.chunk.is_none(),
"chunk state must be cleared once the AU closes"
);
if chunks > 1 {
multi_chunk_frames += 1;
}
total_chunks += chunks;
println!("frame {i}: {chunks} chunks, {} bytes", au.len());
}
assert!(
multi_chunk_frames >= 1,
"sub-frame readback yielded no multi-chunk frame — incremental slice readback \
regressed (the probe shows ~200 µs slice spacing on this GPU)"
);
println!(
"nvenc_cuda chunked poll: {total_chunks} chunks over 6 frames, \
{multi_chunk_frames} frames chunked"
);
// Mode-mix across frames is legal: a fully-drained chunked AU leaves poll() usable.
let frame = nv12_frame(W, H, 6);
enc.submit_indexed(&frame, 6)
.expect("submit plain-poll frame");
let au = enc.poll().expect("poll").expect("AU");
assert!(!au.data.is_empty());
}
/// ON-HARDWARE (RTX box `.21`): the Phase-3 default-on ESCAPES — `PUNKTFUNK_NVENC_SLICES=1`
/// must fully disarm chunked poll (and `poll_chunk` degrades
/// to exactly one self-closing whole-AU chunk (the default-path contract every non-chunked
/// session shares). Run with `--test-threads=1` (env vars are process-global).
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_chunked_poll_fallback_whole_au() {
const W: u32 = 1280;
const H: u32 = 720;
struct EnvGuard;
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
}
}
let _guard = EnvGuard;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Escape 1: explicit single slice — no boundaries to cut, chunked poll disarmed.
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "1");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0).expect("submit");
assert!(
!enc.supports_chunked_poll(),
"PUNKTFUNK_NVENC_SLICES=1 → chunked poll must not arm"
);
let c = enc
.poll_chunk()
.expect("poll_chunk")
.expect("whole-AU chunk");
assert!(c.first && c.last, "fallback chunk must be self-closing");
assert!(c.keyframe, "opening AU is the session IDR");
assert!(!c.data.is_empty());
assert!(
enc.poll_chunk().expect("poll_chunk").is_none(),
"nothing in flight → None"
);
drop(enc);
// Escape 2: sub-frame readback vetoed — slices stay (default 4) but chunked poll
// disarms and the plain poll path carries the session.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0).expect("submit");
assert!(
!enc.supports_chunked_poll(),
"PUNKTFUNK_NVENC_SUBFRAME=0 → chunked poll must not arm"
);
let au = enc.poll().expect("poll").expect("AU");
assert!(au.keyframe && !au.data.is_empty());
}
}
+325 -25
View File
@@ -69,12 +69,19 @@ fn quality_request() -> u32 {
.unwrap_or(0)
}
/// `PUNKTFUNK_VULKAN_RGB_DIRECT=1` opts into the RGB-direct encode source (B1,
/// design/vulkan-rgb-direct-encode.md): the captured RGB frame is handed to the encoder as-is
/// and the VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies,
/// one queue submit per frame. Default OFF until the color/latency A/B graduates it (B2).
fn rgb_request() -> bool {
std::env::var("PUNKTFUNK_VULKAN_RGB_DIRECT").is_ok_and(|v| v == "1")
/// `PUNKTFUNK_VULKAN_RGB_DIRECT` override for the RGB-direct encode source
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the
/// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one
/// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2
/// default: ON wherever the probe passes, EXCEPT sessions that may need the CSC's cursor
/// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on
/// cursor-blend sessions (the pointer will be missing from the stream); unset = the default.
fn rgb_request() -> Option<bool> {
match std::env::var("PUNKTFUNK_VULKAN_RGB_DIRECT") {
Ok(v) if v == "0" => Some(false),
Ok(_) => Some(true),
Err(_) => None,
}
}
/// Live RGB-direct session config: the chroma-siting bits the session was created with
@@ -82,6 +89,12 @@ fn rgb_request() -> bool {
struct RgbDirect {
x_offset: u32, // vk_valve_rgb::CHROMA_OFFSET_*
y_offset: u32,
/// The mode is not 64x16-aligned, so the captured buffer cannot be the encode source
/// directly (the EFC would read past it — the 2026-07-20 field GPU hang). Instead each
/// frame is copied into a per-slot ALIGNED BGRA staging image with the edge rows/columns
/// duplicated into the padding (transfer-only, no shader) and encoded from there. Aligned
/// modes keep the true zero-copy import.
padded: bool,
}
/// Stack storage for a complete rgb-chained video profile. Profiled image creation AFTER open
@@ -265,7 +278,12 @@ struct Frame {
ts_pool: vk::QueryPool,
bs_buf: vk::Buffer,
bs_mem: vk::DeviceMemory,
bs_ptr: BsPtr, // persistent mapping of bs_mem (see BsPtr)
bs_ptr: BsPtr, // persistent mapping of bs_mem (see BsPtr)
// Padded-copy RGB staging (RGB-direct on an unaligned mode ONLY, see RgbDirect::padded):
// an ALIGNED BGRA encode-src the visible frame is blitted into with edges duplicated.
pad_img: vk::Image,
pad_mem: vk::DeviceMemory,
pad_view: vk::ImageView,
csc_set: vk::DescriptorSet, // Y/UV bindings fixed; binding 0 (RGB) rewritten each use
y_img: vk::Image,
y_mem: vk::DeviceMemory,
@@ -397,9 +415,21 @@ pub struct VulkanVideoEncoder {
unsafe impl Send for VulkanVideoEncoder {}
impl VulkanVideoEncoder {
/// Signature mirrors the other Linux backends' `open` (see `nvenc_cuda::NvencCudaEncoder::open`).
pub fn open(codec: Codec, width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result<Self> {
Self::open_opts(codec, width, height, fps, bitrate_bps, rgb_request())
/// Signature mirrors the other Linux backends' `open` plus `cursor_blend`: the session may
/// hand this encoder cursor bitmaps to composite (cursor-as-metadata captures — every
/// non-gamescope compositor). The EFC cannot blend, so such sessions default to the CSC
/// path; everywhere else the RGB-direct source is the DEFAULT wherever the probe passes
/// (B2). `PUNKTFUNK_VULKAN_RGB_DIRECT` overrides both ways (see [`rgb_request`]).
pub fn open(
codec: Codec,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
cursor_blend: bool,
) -> Result<Self> {
let want_rgb = rgb_request().unwrap_or(!cursor_blend);
Self::open_opts(codec, width, height, fps, bitrate_bps, want_rgb)
}
/// `open` with the RGB-direct request explicit instead of read from the env — the smoke
@@ -526,18 +556,38 @@ impl VulkanVideoEncoder {
// the session's picture format. The probe runs unconditionally: its verdict is the
// field telemetry that decides where B2 can default this on.
let rgb_probe = probe_rgb_direct(&instance, &vq_inst, pd, codec_op, av1);
// ALIGNMENT GATE (field GPU-hang, 2026-07-20): the coded extent is 64x16-aligned but the
// captured dmabuf is only the REAL mode size — handing it to the encoder as the direct
// source makes the VCN's EFC read the alignment-padding rows PAST the end of the buffer.
// At 1920x1080 (coded 1088) that is 8 rows = 61 KB of out-of-bounds reads per frame:
// deterministic VM protection faults, vcn_enc ring timeouts, and — through the stall
// watchdog's rebuild-and-refault loop — a full MODE1 GPU reset with VRAM loss. The CSC
// shader absorbs the padding by clamping reads and duplicating the edge; RGB-direct has
// no such stage. Mode select: an aligned mode (720p/1440p/4K) encodes the imported
// buffer directly (true zero-copy); an unaligned one (1080p!) goes through the
// padded-copy staging image (see [`RgbDirect::padded`]) — transfer-only, still no
// compute CSC.
let aligned = rw == w && rh == h;
let rgb_cfg: Option<RgbDirect> = match (&rgb_probe, want_rgb) {
(Ok((x, y)), true) => Some(RgbDirect {
x_offset: *x,
y_offset: *y,
padded: !aligned,
}),
_ => None,
};
tracing::info!(
rgb_direct = match (&rgb_probe, &rgb_cfg) {
(_, Some(_)) => "active",
(Ok(_), None) => "available(off; set PUNKTFUNK_VULKAN_RGB_DIRECT=1)",
(Err(e), None) => e,
rgb_direct = match (&rgb_probe, want_rgb, &rgb_cfg) {
(_, _, Some(RgbDirect { padded: false, .. })) => "active",
(_, _, Some(RgbDirect { padded: true, .. })) =>
"active(padded-copy: mode is not 64x16-aligned — staging blit + edge \
duplication instead of the direct import)",
(Ok(_), false, None) =>
"available(off: PUNKTFUNK_VULKAN_RGB_DIRECT=0, or a cursor-blend session \
=1 forces)",
(Err(e), _, None) => e,
// (Ok, wanted) always builds Some above.
(Ok(_), true, None) => unreachable!("rgb gate and cfg disagree"),
},
"vulkan-encode: EFC RGB-direct encode source (design/vulkan-rgb-direct-encode.md)"
);
@@ -948,6 +998,7 @@ impl VulkanVideoEncoder {
sampler,
ts_period_ns > 0.0 && rgb_cfg.is_none(),
rgb_cfg.is_none(),
rgb_cfg.as_ref().is_some_and(|c| c.padded),
guard.frames.last_mut().expect("frame just pushed"),
)?;
}
@@ -1158,7 +1209,20 @@ impl VulkanVideoEncoder {
cw: u32,
ch: u32,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
if self.rgb.is_some() {
if self.rgb.as_ref().is_some_and(|r| r.padded) {
// Padded-copy mode: the import is only ever a transfer SOURCE (the blit into the
// aligned staging image) — plain TRANSFER_SRC, no video profile involved.
super::vk_util::import_rgb_dmabuf_as(
&self.device,
&self.ext_fd,
&self.mem_props,
d,
cw,
ch,
vk::ImageUsageFlags::TRANSFER_SRC,
None,
)
} else if self.rgb.is_some() {
let mut ps = RgbProfileStack::new(codec_op_for(self.codec == Codec::Av1));
let profile = *ps.wire(self.codec == Codec::Av1);
let arr = [profile];
@@ -1241,10 +1305,21 @@ impl VulkanVideoEncoder {
slot: usize,
fmt: vk::Format,
bytes: &[u8],
src_w: u32,
src_h: u32,
) -> Result<vk::ImageView> {
let dev = self.device.clone();
let (w, h) = (self.width, self.height);
let need = (w * h * 4) as u64;
// CSC mode: the image is the SHADER'S sampling source — size it to the real frame so
// the clamp-to-edge duplicates true content (the aligned-size image made the clamp land
// on unwritten rows: black garbage at unaligned modes, the pre-B2 smoke-baseline bug).
// RGB mode: the image IS the encode source — aligned dims, CPU-side padding below.
let (iw, ih) = if self.rgb.is_some() {
(w, h)
} else {
(src_w, src_h)
};
let need = (iw * ih * 4) as u64;
if self.frames[slot].cpu_img.map(|(_, _, _, f)| f) != Some(fmt) {
if let Some((i, m, v, _)) = self.frames[slot].cpu_img.take() {
dev.destroy_image_view(v, None);
@@ -1284,8 +1359,8 @@ impl VulkanVideoEncoder {
&dev,
&self.mem_props,
fmt,
w,
h,
iw,
ih,
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
)?
};
@@ -1322,9 +1397,43 @@ impl VulkanVideoEncoder {
self.frames[slot].cpu_stage = Some((buf, mem, need));
}
let (_, m, _) = self.frames[slot].cpu_stage.unwrap();
// RGB-direct sessions upload the image the ENCODER reads directly, so an undersized
// source (unaligned mode) must be padded here — rows/columns duplicated from the edge,
// matching the CSC shader's clamped reads (and record_pad_blit's GPU-side equivalent).
// The CSC path keeps the raw copy: its shader clamps at sample time.
let padded_owned: Vec<u8>;
let upload: &[u8] = if self.rgb.is_some() && (src_w != w || src_h != h) {
let (sw, sh) = (src_w as usize, src_h as usize);
let (dw, dh) = (w as usize, h as usize);
if bytes.len() < sw * sh * 4 {
bail!(
"vulkan-encode (rgb-direct): CPU frame {}x{} needs {} bytes, got {}",
src_w,
src_h,
sw * sh * 4,
bytes.len()
);
}
let mut out = vec![0u8; dw * dh * 4];
for y in 0..dh {
let sy = y.min(sh - 1);
let srow = &bytes[sy * sw * 4..][..sw * 4];
let drow = &mut out[y * dw * 4..][..dw * 4];
drow[..sw * 4].copy_from_slice(srow);
let mut last = [0u8; 4];
last.copy_from_slice(&srow[(sw - 1) * 4..]);
for x in sw..dw {
drow[x * 4..(x + 1) * 4].copy_from_slice(&last);
}
}
padded_owned = out;
&padded_owned
} else {
bytes
};
let p = dev.map_memory(m, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *mut u8;
let n = bytes.len().min(need as usize);
std::ptr::copy_nonoverlapping(bytes.as_ptr(), p, n);
let n = upload.len().min(need as usize);
std::ptr::copy_nonoverlapping(upload.as_ptr(), p, n);
dev.unmap_memory(m);
Ok(self.frames[slot].cpu_img.unwrap().2)
}
@@ -1472,7 +1581,7 @@ impl VulkanVideoEncoder {
}
FramePayload::Cpu(bytes) => {
let fmt = pixel_to_vk(frame.format).context("unsupported CPU pixel format")?;
let view = self.ensure_cpu_rgb(slot, fmt, bytes)?;
let view = self.ensure_cpu_rgb(slot, fmt, bytes, frame.width, frame.height)?;
let (img, ..) = self.frames[slot].cpu_img.unwrap();
let (stage, ..) = self.frames[slot].cpu_stage.unwrap();
let to_dst = vk::ImageMemoryBarrier2::default()
@@ -1501,9 +1610,13 @@ impl VulkanVideoEncoder {
.aspect_mask(vk::ImageAspectFlags::COLOR)
.layer_count(1),
)
// The staging buffer holds the REAL frame tightly packed and the image
// is source-sized (see ensure_cpu_rgb) — an aligned-size extent here
// sheared rows against the packed buffer and left garbage rows at
// unaligned modes.
.image_extent(vk::Extent3D {
width: self.width,
height: self.height,
width: frame.width,
height: frame.height,
depth: 1,
})],
);
@@ -1718,6 +1831,133 @@ impl VulkanVideoEncoder {
Ok(())
}
/// Padded-copy blit (unaligned-mode RGB-direct): record — into `compute_cmd` — the visible
/// frame copy from the imported capture image into the aligned staging image, plus the edge
/// duplication into the 64x16 padding (the same edge semantics the CSC shader implements
/// with clamped reads). Transfer-only, no shader. The staging image lives in GENERAL — it
/// is both copy dst and, for the right-column pass, copy src — and the encode acquires it
/// via [`SrcAcquire::CscGeneral`] (content produced on the compute queue, csc_sem-ordered).
unsafe fn record_pad_blit(
&self,
dev: &ash::Device,
compute_cmd: vk::CommandBuffer,
src: vk::Image,
src_fresh: bool,
pad: vk::Image,
) -> Result<()> {
let (rw, rh) = (self.render_w, self.render_h);
let (w, h) = (self.width, self.height);
dev.begin_command_buffer(
compute_cmd,
&vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)?;
// Acquire the imported capture buffer for transfer reads (FOREIGN hand-off on first
// import — UNDEFINED preserves the modifier-tiled bytes — visibility-only afterwards),
// and the staging image for transfer writes (prior contents discarded).
let src_acq = if src_fresh {
vk::ImageMemoryBarrier2::default()
.src_stage_mask(vk::PipelineStageFlags2::NONE)
.src_access_mask(vk::AccessFlags2::NONE)
.dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
.dst_access_mask(vk::AccessFlags2::TRANSFER_READ)
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
.src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT)
.dst_queue_family_index(self.compute_family)
} else {
vk::ImageMemoryBarrier2::default()
.src_stage_mask(vk::PipelineStageFlags2::NONE)
.src_access_mask(vk::AccessFlags2::NONE)
.dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
.dst_access_mask(vk::AccessFlags2::TRANSFER_READ)
.old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
.new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
}
.image(src)
.subresource_range(color_range(0));
let pad_dst = vk::ImageMemoryBarrier2::default()
.src_stage_mask(vk::PipelineStageFlags2::NONE)
.src_access_mask(vk::AccessFlags2::NONE)
.dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
.dst_access_mask(vk::AccessFlags2::TRANSFER_WRITE)
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::GENERAL)
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.image(pad)
.subresource_range(color_range(0));
dev.cmd_pipeline_barrier2(
compute_cmd,
&vk::DependencyInfo::default().image_memory_barriers(&[src_acq, pad_dst]),
);
let layers = vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.layer_count(1);
let region = |sx: i32, sy: i32, dx: i32, dy: i32, cw: u32, ch: u32| {
vk::ImageCopy::default()
.src_subresource(layers)
.dst_subresource(layers)
.src_offset(vk::Offset3D { x: sx, y: sy, z: 0 })
.dst_offset(vk::Offset3D { x: dx, y: dy, z: 0 })
.extent(vk::Extent3D {
width: cw,
height: ch,
depth: 1,
})
};
// Pass 1 — from the capture: the visible region, then each bottom padding row as a
// copy of the last visible row (1080p: 8 such rows). One call, disjoint regions.
let mut regions = vec![region(0, 0, 0, 0, rw, rh)];
for y in rh..h {
regions.push(region(0, rh as i32 - 1, 0, y as i32, rw, 1));
}
dev.cmd_copy_image(
compute_cmd,
src,
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
pad,
vk::ImageLayout::GENERAL,
&regions,
);
// Pass 2 — right padding columns (width alignment, e.g. 1366→1408): duplicate the
// staging image's own last visible column over the FULL aligned height (valid after
// pass 1 filled the bottom rows). Self-copy in GENERAL, W→R barrier between, regions
// disjoint from their source column.
if w > rw {
let self_dep = vk::ImageMemoryBarrier2::default()
.src_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
.src_access_mask(vk::AccessFlags2::TRANSFER_WRITE)
.dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
.dst_access_mask(vk::AccessFlags2::TRANSFER_READ)
.old_layout(vk::ImageLayout::GENERAL)
.new_layout(vk::ImageLayout::GENERAL)
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.image(pad)
.subresource_range(color_range(0));
dev.cmd_pipeline_barrier2(
compute_cmd,
&vk::DependencyInfo::default().image_memory_barriers(&[self_dep]),
);
let cols: Vec<vk::ImageCopy> = (rw..w)
.map(|x| region(rw as i32 - 1, 0, x as i32, 0, 1, h))
.collect();
dev.cmd_copy_image(
compute_cmd,
pad,
vk::ImageLayout::GENERAL,
pad,
vk::ImageLayout::GENERAL,
&cols,
);
}
dev.end_command_buffer(compute_cmd)?;
Ok(())
}
/// RGB-direct twin of [`record_submit`]'s steps 24 (step 1 and the bookkeeping tail are
/// shared): resolve the RGB encode source — the imported capture dmabuf itself, or the CPU
/// staging upload — record the encode, and submit. There is no compute CSC: the VCN EFC
@@ -1751,8 +1991,24 @@ impl VulkanVideoEncoder {
metadata-cursor captures)"
);
}
let padded = self.rgb.as_ref().is_some_and(|r| r.padded);
let (src_img, src_view, acquire, compute_active) = match &frame.payload {
FramePayload::Dmabuf(d) => {
FramePayload::Dmabuf(d) if !padded => {
// Defense in depth for the OOB class the alignment gate closes at open: the
// imported buffer must cover the FULL coded extent, or the EFC reads past it
// (VM faults → VCN ring hang → GPU reset, the 2026-07-20 field report). A
// mismatched frame (mid-flight mode change, odd capture) errors out here and
// takes the encoder-rebuild path instead of faulting the GPU.
if frame.width != self.width || frame.height != self.height {
bail!(
"vulkan-encode (rgb-direct): frame {}x{} does not cover the coded \
extent {}x{} refusing an out-of-bounds encode source",
frame.width,
frame.height,
self.width,
self.height
);
}
let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?;
let acq = if fresh {
SrcAcquire::RgbFresh
@@ -1761,9 +2017,33 @@ impl VulkanVideoEncoder {
};
(img, view, acq, false)
}
FramePayload::Dmabuf(d) => {
// Padded-copy mode (unaligned mode, e.g. 1080p): blit the visible frame into
// the per-slot ALIGNED staging image and duplicate the edge into the 64x16
// padding, all on the transfer path of the compute queue (no shader). The
// encode reads the staging image — never the capture buffer — so the EFC can
// never read past a producer allocation again.
if frame.width != self.render_w || frame.height != self.render_h {
bail!(
"vulkan-encode (rgb-direct/padded): frame {}x{} != mode {}x{} — \
refusing a mismatched blit source",
frame.width,
frame.height,
self.render_w,
self.render_h
);
}
let (img, _view, fresh) = self.import_cached(d, frame.width, frame.height)?;
let pad_img = self.frames[slot].pad_img;
let pad_view = self.frames[slot].pad_view;
self.record_pad_blit(&dev, compute_cmd, img, fresh, pad_img)?;
// The staging image ends the blit in GENERAL with the csc_sem ordering the
// hand-off — exactly the CscGeneral acquire contract.
(pad_img, pad_view, SrcAcquire::CscGeneral, true)
}
FramePayload::Cpu(bytes) => {
let fmt = pixel_to_vk(frame.format).context("unsupported CPU pixel format")?;
let view = self.ensure_cpu_rgb(slot, fmt, bytes)?;
let view = self.ensure_cpu_rgb(slot, fmt, bytes, frame.width, frame.height)?;
let (img, ..) = self.frames[slot].cpu_img.expect("ensure_cpu_rgb built it");
let (stage, ..) = self.frames[slot]
.cpu_stage
@@ -2826,6 +3106,9 @@ impl Drop for VkTeardown {
device.destroy_fence(f.fence, None);
device.destroy_query_pool(f.query_pool, None);
device.destroy_query_pool(f.ts_pool, None);
device.destroy_image_view(f.pad_view, None);
device.destroy_image(f.pad_img, None);
device.free_memory(f.pad_mem, None);
device.destroy_buffer(f.bs_buf, None);
// bs_mem is persistently mapped (f.bs_ptr); vkFreeMemory implicitly unmaps.
device.free_memory(f.bs_mem, None);
@@ -3129,10 +3412,27 @@ unsafe fn make_frame(
sampler: vk::Sampler,
with_ts: bool,
csc: bool,
rgb_pad: bool,
f: &mut Frame,
) -> Result<()> {
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
f.cursor_serial = u64::MAX;
// Padded-copy RGB staging (unaligned-mode RGB-direct): aligned BGRA encode-src filled by a
// transfer blit each frame — concurrent compute (copy) + encode (source read).
if rgb_pad {
(f.pad_img, f.pad_mem) = make_video_image(
device,
mem_props,
vk::Format::B8G8R8A8_UNORM,
w,
h,
1,
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR | vk::ImageUsageFlags::TRANSFER_DST,
profile_list,
fams,
)?;
f.pad_view = make_view(device, f.pad_img, vk::Format::B8G8R8A8_UNORM, 0)?;
}
// RGB-direct sessions never touch the CSC pipeline: no NV12 encode-src, no Y/UV scratch, no
// cursor overlay, no descriptor set — the encode source is the imported RGB itself (or the
// CPU staging image, built lazily). Their Frame keeps the null handles (teardown-safe).
+48 -14
View File
@@ -35,6 +35,38 @@ pub(super) fn codec_guid(codec: Codec) -> nv::GUID {
}
}
/// Resolved per-frame slice count for a session (latency plan §7 LN1, Phase 3): the
/// `PUNKTFUNK_NVENC_SLICES` env override wins (1..=32; **1 = the explicit single-slice
/// escape**, needed now that a backend can default higher), else the backend's
/// `default_slices` — 4 on Linux direct-NVENC since the Phase-3 default-on, 1 everywhere else
/// (the Windows async path is deliberately untouched). H.264/HEVC only (AV1 partitions via
/// tiles). ONE parse shared by the config author ([`apply_low_latency_config`] via
/// [`LowLatencyConfig::slices`]) and the Linux backend's chunked-poll arming, so the two can
/// never disagree about whether a session is multi-slice.
pub(super) fn resolve_slices(codec: Codec, default_slices: u32) -> u32 {
if !matches!(codec, Codec::H264 | Codec::H265) {
return 1;
}
std::env::var("PUNKTFUNK_NVENC_SLICES")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| (1..=32).contains(n))
.unwrap_or(default_slices)
}
/// Resolved sub-frame readback (`enableSubFrameWrite` + `reportSliceOffsets`; sync sessions
/// only, see [`build_init_params`]): `PUNKTFUNK_NVENC_SUBFRAME` tri-state — `0` = never (the
/// default-on escape), `1` = force (even where the caps probe says unsupported — an operator
/// explicitly testing), unset = the backend's `default_on` (Linux direct-NVENC passes its
/// SUBFRAME_READBACK caps-probe result since Phase 3; Windows passes `false`).
pub(super) fn resolve_subframe(default_on: bool) -> bool {
match std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() {
Ok("0") => false,
Ok("1") => true,
_ => default_on,
}
}
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
@@ -66,6 +98,9 @@ pub(super) struct LowLatencyConfig {
pub hdr: bool,
/// This GPU supports reference-frame invalidation (a deeper DPB for graceful loss recovery).
pub rfi_supported: bool,
/// Resolved per-frame slice count ([`resolve_slices`] — env override, else the backend
/// default). ≤ 1 leaves the preset's single slice untouched.
pub slices: u32,
}
/// Author the shared `NV_ENC_INITIALIZE_PARAMS` (P1/ULL preset, PTD, the session dimensions/rate)
@@ -82,6 +117,7 @@ pub(super) fn build_init_params(
cfg: &mut nv::NV_ENC_CONFIG,
split_mode: u32,
enable_async: bool,
subframe: bool,
) -> nv::NV_ENC_INITIALIZE_PARAMS {
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
@@ -101,12 +137,13 @@ pub(super) fn build_init_params(
};
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
init.set_splitEncodeMode(split_mode);
// Sub-frame readback (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off): the driver
// writes each slice into the output buffer as it completes and reports per-slice offsets, so a
// sync-mode consumer can read slices out while the frame is still encoding. Pair with
// `PUNKTFUNK_NVENC_SLICES` (a single-slice frame yields nothing to read early).
// `reportSliceOffsets` requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
if !enable_async && std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref() == Ok("1") {
// Sub-frame readback (latency plan §7 LN1; default-on for Linux direct-NVENC since Phase 3 —
// the caller resolves `subframe` via [`resolve_subframe`] + its caps probe): the driver
// writes each slice into the output buffer as it completes and reports per-slice offsets, so
// a sync-mode consumer can read slices out while the frame is still encoding. Pair with
// multi-slice (a single-slice frame yields nothing to read early). `reportSliceOffsets`
// requires `enableEncodeAsync = 0`, so async (Windows) sessions never arm.
if !enable_async && subframe {
init.set_enableSubFrameWrite(1);
init.set_reportSliceOffsets(1);
}
@@ -158,16 +195,13 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
}
// Multi-slice frames (latency plan §7 LN1 groundwork — EXPERIMENTAL, default off = the preset's
// single slice): `PUNKTFUNK_NVENC_SLICES=N` (2..=32) splits every frame into N slices
// Multi-slice frames (latency plan §7 LN1): `c.slices` splits every frame into N slices
// (sliceMode 3 = "N slices per frame"), the unit sub-frame readback ships early and loss
// concealment can discard independently. Costs ~1-2 % bitrate in slice headers. H.264/HEVC
// only — AV1 partitions via tiles, not slices.
if let Some(n) = std::env::var("PUNKTFUNK_NVENC_SLICES")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| (2..=32).contains(n))
{
// only — AV1 partitions via tiles, not slices (the resolver already returns 1 there).
// Default 4 on Linux direct-NVENC (Phase 3), env-only elsewhere; ≤ 1 keeps the preset's
// single slice.
if let Some(n) = Some(c.slices).filter(|n| *n >= 2) {
match c.codec {
Codec::H264 => {
cfg.encodeCodecConfig.h264Config.sliceMode = 3;
+10 -1
View File
@@ -37,7 +37,8 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use super::nvenc_core::{
apply_low_latency_config, build_init_params, codec_guid, LowLatencyConfig, NvStatusExt, RFI_DPB,
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
LowLatencyConfig, NvStatusExt, RFI_DPB,
};
use super::nvenc_status;
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
@@ -744,6 +745,10 @@ impl NvencD3d11Encoder {
av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
hdr: self.hdr,
rfi_supported: self.rfi_supported,
// Env-only on Windows (default single slice): the Phase-3 default-on is a
// Linux-direct-NVENC decision — the Windows async path stays untouched, and a
// Windows operator opting in must choose slices+sync over async retrieve.
slices: resolve_slices(self.codec, 1),
},
);
Ok(cfg)
@@ -791,6 +796,9 @@ impl NvencD3d11Encoder {
&mut cfg,
split_mode,
enable_async,
// Windows: env opt-in only ("1"), never a default — and build_init_params
// additionally refuses it on an async session.
resolve_subframe(false),
);
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
@@ -1568,6 +1576,7 @@ impl Encoder for NvencD3d11Encoder {
&mut cfg,
self.split_mode,
self.session_async,
resolve_subframe(false),
),
..Default::default()
};
+37 -4
View File
@@ -129,6 +129,9 @@ pub fn open_video(
cuda: bool,
bit_depth: u8,
chroma: ChromaFormat,
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
cursor_blend: bool,
) -> Result<Box<dyn Encoder>> {
let (inner, backend) = open_video_backend(
codec,
@@ -140,6 +143,7 @@ pub fn open_video(
cuda,
bit_depth,
chroma,
cursor_blend,
)?;
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
@@ -203,6 +207,12 @@ impl Encoder for TrackedEncoder {
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
self.inner.invalidate_ref_frames(first_frame, last_frame)
}
// Forwarded for the same reason as `set_wire_chunking` below — the unforwarded default
// (`false` = "backend can't pipeline, stop asking") silently killed the §7 LN3 contention
// escalation for every session, since the host loop only ever holds the wrapped box.
fn set_pipelined(&mut self, on: bool) -> bool {
self.inner.set_pipelined(on)
}
// The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded
// silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the
// §4.4 chunking probe run hit the default while the plan said Some(1408)).
@@ -217,6 +227,14 @@ impl Encoder for TrackedEncoder {
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
self.inner.poll()
}
// Both chunked-poll methods forwarded (the same trap class): the defaults would report
// "not chunked" and wrap whole AUs, silently discarding the sub-frame overlap.
fn supports_chunked_poll(&self) -> bool {
self.inner.supports_chunked_poll()
}
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
self.inner.poll_chunk()
}
fn reset(&mut self) -> bool {
self.inner.reset()
}
@@ -250,7 +268,9 @@ fn open_video_backend(
cuda: bool,
bit_depth: u8,
chroma: ChromaFormat,
cursor_blend: bool,
) -> Result<(Box<dyn Encoder>, &'static str)> {
let _ = cursor_blend; // consumed only by the Linux vulkan-encode arm below
validate_dimensions(codec, width, height)?;
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
@@ -308,8 +328,14 @@ fn open_video_backend(
&& vulkan_encode_enabled()
&& !(bit_depth == 10 && format.is_hdr_rgb10())
{
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
{
match vulkan_video::VulkanVideoEncoder::open(
codec,
width,
height,
fps,
bitrate_bps,
cursor_blend,
) {
Ok(e) => {
tracing::info!(
codec = ?codec,
@@ -362,8 +388,15 @@ fn open_video_backend(
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
);
}
vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
vulkan_video::VulkanVideoEncoder::open(
codec,
width,
height,
fps,
bitrate_bps,
cursor_blend,
)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
}
#[cfg(not(feature = "vulkan-encode"))]
{
@@ -83,10 +83,13 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
// bit only asks the host for observability datagrams (never changes the encode).
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
// (every embedder inherits it), so the host may burst speed tests without consuming
// video frame indexes.
// video frame indexes. STREAMED_AU the same way: the shared reassembler accepts
// sentinel-headed streamed blocks (retro-validated at the final block), so the host
// may overlap a multi-slice encode's tail with packetize/FEC/pacing.
video_caps: video_caps
| crate::quic::VIDEO_CAP_HOST_TIMING
| crate::quic::VIDEO_CAP_PROBE_SEQ,
| crate::quic::VIDEO_CAP_PROBE_SEQ
| crate::quic::VIDEO_CAP_STREAMED_AU,
// Requested surround channel count; the host echoes the resolved value in Welcome.
audio_channels,
// The codecs this client can decode + its soft preference (0 = auto). The host
@@ -47,11 +47,50 @@ pub struct Packetizer {
/// where every packet of the block is dropped wholesale, the frame never completes, and the
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
max_total_shards: usize,
/// The peer's per-frame block ceiling, mirroring [`ReassemblerLimits::from_config`]'s
/// `max_blocks` — the streamed path's bound on how many sentinel blocks it may emit (a
/// streamed AU's size isn't known up front, so this is the only pre-emission guard against
/// producing a frame the receiver must reject).
max_blocks: usize,
}
/// One in-progress **streamed** access unit (design/nvenc-subframe-slice-output.md Phase 2):
/// the caller feeds encoder chunks in as they exist ([`Packetizer::push_streamed`]) and every
/// completed `max_data_per_block × shard_payload` block leaves under SENTINEL headers
/// (`frame_bytes = 0`, `block_count = 0` — "not final yet") before the AU's total size is
/// known; [`Packetizer::finish_streamed`] seals the tail block with the real totals and
/// `FLAG_EOF`. Only ever sent to a peer that advertised
/// [`crate::quic::VIDEO_CAP_STREAMED_AU`].
pub struct StreamedAu {
frame_index: u32,
pts_ns: u64,
user_flags: u32,
/// Bytes not yet sealed into a block. Kept ≤ one block by `push_streamed` (it flushes only
/// while STRICTLY more than a block is buffered, so the final block always has ≥ 1 byte and
/// a sentinel block is never retroactively the frame's last).
pending: Vec<u8>,
/// Sentinel blocks already emitted.
blocks_out: u16,
/// Total AU bytes accumulated so far (`pending` included).
total_bytes: u64,
/// The frame's first packet (block 0, shard 0 — carries `FLAG_SOF`) has been emitted.
opened: bool,
}
impl StreamedAu {
/// The wire frame index this AU is sealed with (the caller's RFI bookkeeping domain).
pub fn frame_index(&self) -> u32 {
self.frame_index
}
}
impl Packetizer {
pub fn new(config: &Config) -> Self {
let max_data = config.fec.max_data_per_block as usize;
let total_data_max = config
.max_frame_bytes
.div_ceil(config.shard_payload.max(1))
.max(1);
Packetizer {
next_frame_index: 0,
next_probe_index: 0,
@@ -64,6 +103,7 @@ impl Packetizer {
// Mirrors `ReassemblerLimits::from_config` — keep the two in step.
max_total_shards: (max_data + config.fec.recovery_for(max_data))
.min(config.fec.scheme.max_total_shards()),
max_blocks: total_data_max.div_ceil(max_data).max(1),
}
}
@@ -277,4 +317,202 @@ impl Packetizer {
self.next_seq = next_seq;
Ok(())
}
/// Open a **streamed** access unit (see [`StreamedAu`]). `frame_index` follows the same
/// contract as [`packetize_each`](Self::packetize_each): `Some(i)` = the caller owns the
/// video numbering; `None` draws from the internal counter.
pub fn begin_streamed(
&mut self,
pts_ns: u64,
user_flags: u32,
frame_index: Option<u32>,
) -> StreamedAu {
let frame_index = frame_index.unwrap_or_else(|| {
let i = self.next_frame_index;
self.next_frame_index = i.wrapping_add(1);
i
});
StreamedAu {
frame_index,
pts_ns,
user_flags,
pending: Vec::new(),
blocks_out: 0,
total_bytes: 0,
opened: false,
}
}
/// Feed one encoder chunk into a streamed AU, emitting every block that COMPLETES under
/// sentinel headers (`frame_bytes = 0`, `block_count = 0`, exactly `max_data_per_block`
/// data shards — the receiver's offset formula needs no total). Flushes only while
/// STRICTLY more than one block is buffered, so the frame's last block — whose header must
/// carry the real totals — is never emitted here. Packets reach `emit` in wire order (the
/// caller's nonce order), data then parity per block.
pub fn push_streamed(
&mut self,
au: &mut StreamedAu,
chunk: &[u8],
coder: &dyn ErasureCoder,
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
au.total_bytes += chunk.len() as u64;
au.pending.extend_from_slice(chunk);
let block_bytes = self.fec.max_data_per_block as usize * self.shard_payload;
while au.pending.len() > block_bytes {
// Room for this sentinel block AND the final block after it, within the peer's
// per-frame block ceiling and the u16 wire field.
if au.blocks_out as usize + 2 > self.max_blocks.min(u16::MAX as usize) {
return Err(PunktfunkError::Unsupported(
"streamed AU exceeds the negotiated max_frame_bytes",
));
}
let sof = !au.opened;
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
let fi = au.frame_index;
self.emit_streamed_block(
fi,
pts,
uf,
bi,
&au.pending[..block_bytes],
0,
0,
sof,
false,
coder,
&mut emit,
)?;
au.pending.drain(..block_bytes);
au.blocks_out += 1;
au.opened = true;
}
Ok(())
}
/// Close a streamed AU: seal the final block — its headers carry the REAL
/// `frame_bytes`/`block_count`, which retro-validate the whole frame at the receiver — with
/// `FLAG_EOF` on the last emitted packet. An empty AU degenerates to today's single
/// zero-padded-shard frame (`block_count = 1`, never a sentinel).
pub fn finish_streamed(
&mut self,
au: StreamedAu,
coder: &dyn ErasureCoder,
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
let frame_bytes = u32::try_from(au.total_bytes)
.map_err(|_| PunktfunkError::Unsupported("streamed AU exceeds u32 bytes"))?;
let block_count = au.blocks_out + 1;
self.emit_streamed_block(
au.frame_index,
au.pts_ns,
au.user_flags,
au.blocks_out,
&au.pending,
frame_bytes,
block_count,
!au.opened,
true,
coder,
&mut emit,
)
}
/// Seal ONE streamed block (data shards + its parity, in wire order). Sentinel blocks pass
/// `frame_bytes = 0` / `block_count = 0`; the final block passes the real totals. `sof`
/// marks the frame's very first packet, `eof` its very last.
#[allow(clippy::too_many_arguments)]
fn emit_streamed_block(
&mut self,
frame_index: u32,
pts_ns: u64,
user_flags: u32,
block_index: u16,
bytes: &[u8],
frame_bytes: u32,
block_count: u16,
sof: bool,
eof: bool,
coder: &dyn ErasureCoder,
emit: &mut impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
let payload = self.shard_payload;
if payload > u16::MAX as usize {
return Err(PunktfunkError::InvalidArg("shard_payload exceeds u16"));
}
// At least one (zero-padded) data shard even for an empty final block (empty AU).
let k = bytes.len().div_ceil(payload).max(1);
let m = self
.fec
.recovery_for(k)
.min(self.max_total_shards.saturating_sub(k));
if k + m > u16::MAX as usize {
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
}
// Stage the one possibly-partial (or empty-frame) shard in the zero-padded scratch.
let full_shards = bytes.len() / payload;
self.tail.clear();
self.tail.resize(payload, 0);
let rem = bytes.len() % payload;
if rem > 0 {
self.tail[..rem].copy_from_slice(&bytes[full_shards * payload..]);
}
let tail = &self.tail;
let shard_at = |s: usize| -> &[u8] {
if s < full_shards {
&bytes[s * payload..(s + 1) * payload]
} else {
tail.as_slice()
}
};
let data_shards: Vec<&[u8]> = (0..k).map(shard_at).collect();
if self.recovery.is_empty() {
self.recovery.push(Vec::new());
}
coder.encode_into(&data_shards, m, &mut self.recovery[0])?;
let mut next_seq = self.next_seq;
let mut emit_one = |next_seq: &mut u32, shard_index: usize, body: &[u8], flags: u8| {
let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1);
let hdr = PacketHeader {
pts_ns,
frame_index,
stream_seq: seq,
frame_bytes,
user_flags,
block_index,
block_count,
data_shards: k as u16,
recovery_shards: m as u16,
shard_index: shard_index as u16,
shard_bytes: payload as u16,
magic: PUNKTFUNK_MAGIC,
version: self.version,
fec_scheme: coder.scheme() as u8,
flags,
};
emit(&hdr, body)
};
for (shard_index, body) in data_shards.iter().enumerate() {
let mut flags = FLAG_PIC;
if sof && shard_index == 0 {
flags |= FLAG_SOF;
}
if eof && m == 0 && shard_index + 1 == k {
flags |= FLAG_EOF;
}
emit_one(&mut next_seq, shard_index, body, flags)?;
}
for r in 0..m {
let mut flags = FLAG_PIC;
if eof && r + 1 == m {
flags |= FLAG_EOF;
}
let body: &[u8] = &self.recovery[0][r];
emit_one(&mut next_seq, k + r, body, flags)?;
}
self.next_seq = next_seq;
Ok(())
}
}
+136 -26
View File
@@ -70,7 +70,12 @@ struct BlockState {
}
struct FrameBuf {
/// Exact AU size. 0 = unknown: the frame was opened by a streamed-AU SENTINEL packet
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) and the final block's real totals haven't
/// arrived yet — the frame can't complete before they do (and retro-validate).
frame_bytes: usize,
/// Block count; 0 = unknown (sentinel-opened, totals not yet pinned). A legacy-opened
/// frame always has ≥ 1 here, so 0 doubles as the "unpinned streamed" marker.
block_count: usize,
pts_ns: u64,
user_flags: u32,
@@ -277,33 +282,58 @@ impl Reassembler {
|| total == 0
|| total > lim.max_total_shards
|| shard_index >= total
|| block_count == 0
|| block_count > lim.max_blocks
|| hdr.block_index as usize >= block_count
|| frame_bytes > lim.max_frame_bytes
{
drop(stats);
return Ok(None);
}
// Derived-geometry firewall: every sender (our Packetizer, any version) slices a frame
// into consecutive blocks of exactly `max_data_per_block` data shards with only the LAST
// block smaller, and stamps the exact `frame_bytes` in every header. That makes every
// data shard's final AU offset computable on arrival —
// offset = (block_index × max_data_per_block + shard_index) × shard_bytes
// — which is what lets shards land straight in the frame buffer below. Enforce the
// invariant so a header lying about its geometry is dropped instead of scribbling into
// another shard's range.
let total_data = frame_bytes.div_ceil(shard_bytes).max(1);
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
// Streamed-AU sentinel ([`crate::quic::VIDEO_CAP_STREAMED_AU`]): `block_count == 0` — a
// value no legacy sender ever emits — marks a NON-FINAL block of an AU whose total size
// doesn't exist yet (the host is still encoding its tail). The exact derived-geometry
// check below can't run without a total, so a sentinel is bounded by the negotiated
// limits instead — and by construction: full-K exactly (the offset formula needs it),
// never the last block the limits allow (the real final block must still fit after it),
// and no total to lie about. The frame-wide exact check runs retroactively the moment
// the final block's real totals arrive (the pinning arm below).
let sentinel = block_count == 0;
let block_idx = hdr.block_index as usize;
let expect_data_shards = if block_idx + 1 == expect_blocks {
total_data - (expect_blocks - 1) * lim.max_data_shards
// For a sentinel-opened frame the buffer must hold ANY final geometry the totals may
// later pin — the maximum the negotiated limits allow (the design's "allocate at
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
let total_data = frame_bytes.div_ceil(shard_bytes).max(1);
if sentinel {
if frame_bytes != 0
|| data_shards != lim.max_data_shards
|| block_idx + 1 >= lim.max_blocks
{
drop(stats);
return Ok(None);
}
} else {
lim.max_data_shards
};
if block_count != expect_blocks || data_shards != expect_data_shards {
drop(stats);
return Ok(None);
if block_count > lim.max_blocks || block_idx >= block_count {
drop(stats);
return Ok(None);
}
// Derived-geometry firewall: every sender (our Packetizer, any version) slices a
// frame into consecutive blocks of exactly `max_data_per_block` data shards with
// only the LAST block smaller, and stamps the exact `frame_bytes` in every
// non-sentinel header. That makes every data shard's final AU offset computable on
// arrival —
// offset = (block_index × max_data_per_block + shard_index) × shard_bytes
// — which is what lets shards land straight in the frame buffer below. Enforce the
// invariant so a header lying about its geometry is dropped instead of scribbling
// into another shard's range.
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
let expect_data_shards = if block_idx + 1 == expect_blocks {
total_data - (expect_blocks - 1) * lim.max_data_shards
} else {
lim.max_data_shards
};
if block_count != expect_blocks || data_shards != expect_data_shards {
drop(stats);
return Ok(None);
}
}
let body = &pkt[HEADER_LEN..HEADER_LEN + shard_bytes];
@@ -350,8 +380,13 @@ impl Reassembler {
}
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
// packets must agree with its geometry.
let buf_len = total_data * shard_bytes;
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
// the limits' maximum — its real size doesn't exist yet.
let buf_len = if sentinel {
total_data_max * shard_bytes
} else {
total_data * shard_bytes
};
let frame = match win.frames.entry(hdr.frame_index) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
@@ -374,7 +409,66 @@ impl Reassembler {
})
}
};
if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
if sentinel {
// Sentinel packets carry no totals to cross-check: while the frame is unpinned
// (`block_count == 0`) they match by construction, and once the totals are pinned —
// whichever packet arrived first, sentinel or final (reorder is normal) — a
// sentinel block must still be NON-final under them. Its full-K shape was already
// enforced by the firewall, which is exactly what the pinned geometry demands of
// every non-final block, and its shard offsets are within the pinned range by
// `block_idx + 1 < block_count`.
if frame.block_count != 0 && block_idx + 1 >= frame.block_count {
drop(stats);
return Ok(None);
}
} else if frame.block_count == 0 {
// A streamed frame meets its FINAL block's real totals: retro-validate every block
// the sentinels created against the exact geometry these totals derive (the same
// invariant the firewall enforces per-packet for legacy frames), then PIN them. A
// header that lies — totals under which an already-received sentinel block is
// out of range or not full-K — kills the WHOLE frame: its landed shards can't be
// trusted to be at the offsets this geometry means, so delivering any of it would
// hand the decoder spliced bytes.
let expect_blocks = total_data.div_ceil(lim.max_data_shards).max(1);
let final_k = total_data - (expect_blocks - 1) * lim.max_data_shards;
let lied = frame.blocks.iter().any(|(&bi, b)| {
let bi = bi as usize;
bi >= expect_blocks
|| (bi + 1 < expect_blocks && b.data_shards != lim.max_data_shards)
|| (bi + 1 == expect_blocks && b.data_shards != final_k)
});
if lied {
let mut f = win
.frames
.remove(&hdr.frame_index)
.expect("frame entry exists");
*in_flight_bytes -= f.buf.len();
// Remember the index (with its late-shard memory, exactly like an aged-out
// frame) so stragglers can't resurrect it, reclaim the parity buffers, and
// count the loss — the client's recovery request is the right outcome for a
// frame that was destroyed by a lying header.
win.completed.insert(
hdr.frame_index,
reconstructed_shards(&f.blocks, lim.max_data_shards),
);
for block in f.blocks.values_mut() {
for slot in block.recovery.iter_mut() {
if let Some(rb) = slot.take() {
if recovery_pool.len() < RECOVERY_POOL_MAX {
recovery_pool.push(rb);
}
}
}
}
if !is_probe {
StatsCounters::add(&stats.frames_dropped, 1);
}
drop(stats);
return Ok(None);
}
frame.frame_bytes = frame_bytes;
frame.block_count = block_count;
} else if frame.block_count != block_count || frame.frame_bytes != frame_bytes {
drop(stats);
return Ok(None);
}
@@ -382,6 +476,7 @@ impl Reassembler {
buf,
blocks,
blocks_ok,
block_count: frame_block_count,
..
} = frame;
@@ -402,6 +497,14 @@ impl Reassembler {
drop(stats);
return Ok(None);
}
// Defense-in-depth (2026-07 security review): the geometry invariants above guarantee
// every packet of a block agrees on K, so this can't fire today — but `have_data`
// indexing and the recovery-slot math below assume it, and an explicit check keeps a
// future firewall refactor from turning that assumption into an OOB panic.
if block.data_shards != data_shards {
drop(stats);
return Ok(None);
}
if block.done {
// A data shard the parity reconstruct already restored (`!have_data`) was late, not
// lost — net it out of the `fec_recovered_shards` it was counted into (see the
@@ -485,8 +588,12 @@ impl Reassembler {
}
}
// Whole frame ready?
if *blocks_ok == block_count {
// Whole frame ready? Judged against the FRAME's pinned block count, not this packet's
// header — a streamed frame can complete on a reordered sentinel packet (header says 0)
// after the final block already pinned the real totals, and can never complete before
// they're pinned (`0` never equals a non-zero `blocks_ok`).
let block_count = *frame_block_count;
if block_count != 0 && *blocks_ok == block_count {
let mut done = win.frames.remove(&hdr.frame_index).unwrap();
win.completed.insert(
hdr.frame_index,
@@ -595,7 +702,10 @@ impl ReassemblyWindow {
// where shards are missing (the codec's block walk skips zero windows).
// Newest-wins if several age out in one prune. Still counted dropped below.
if let Some(sink) = partial_sink.as_deref_mut() {
if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 {
// `frame_bytes > 0` also excludes an UNPINNED streamed frame (its total is
// still the 0 sentinel value): truncating its max-sized buffer to 0 would
// deliver an empty "partial" — worse than the plain drop it gets instead.
if f.user_flags & USER_FLAG_CHUNK_ALIGNED != 0 && f.frame_bytes > 0 {
let mut buf = std::mem::take(&mut f.buf);
buf.truncate(f.frame_bytes);
let newer = sink
+399
View File
@@ -640,3 +640,402 @@ fn adaptive_fec_ramp_keeps_maximal_blocks_within_the_peers_ceiling() {
src
);
}
// ---------------------------------------------------------------------------
// Streamed access units (VIDEO_CAP_STREAMED_AU — nvenc-subframe-slice-output.md Phase 2)
// ---------------------------------------------------------------------------
/// Packetize one streamed AU from `chunks` via begin/push/finish, returning the emitted wire
/// packets (header ++ shard) and the concatenated source bytes.
fn streamed_packets(
scheme: FecScheme,
fec_percent: u8,
chunks: &[&[u8]],
) -> (Vec<Vec<u8>>, Vec<u8>) {
let cfg = e2e_config(scheme, fec_percent);
let coder = coder_for(scheme);
let mut pk = Packetizer::new(&cfg);
let mut au = pk.begin_streamed(12345, 0, Some(0));
let mut pkts: Vec<Vec<u8>> = Vec::new();
let mut src = Vec::new();
for c in chunks {
src.extend_from_slice(c);
pk.push_streamed(&mut au, c, coder.as_ref(), |h: &PacketHeader, b: &[u8]| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
Ok(())
})
.unwrap();
}
pk.finish_streamed(au, coder.as_ref(), |h: &PacketHeader, b: &[u8]| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
Ok(())
})
.unwrap();
(pkts, src)
}
/// Deliver a streamed AU's packets (optionally with kills within the FEC budget, reversed
/// order, and a duplicate) and assert byte-identical completion. Reversed order is the
/// critical case: the FINAL block's real-total headers arrive FIRST, the frame opens
/// legacy-shaped, and the sentinels must still be accepted against the pinned totals.
fn streamed_roundtrip(scheme: FecScheme, kill: &[usize], reverse: bool) {
let chunks: Vec<Vec<u8>> = (0..3)
.map(|c| (0..50).map(|i| (c * 57 + i * 131 + 7) as u8).collect())
.collect();
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
let (pkts, src) = streamed_packets(scheme, 50, &chunk_refs);
// 150 B / 16 B shards / 4-shard blocks → sentinel blocks 0,1 (4 data + 2 rec each) +
// final block (2 data + 1 rec) = 15 packets.
assert_eq!(
pkts.len(),
15,
"expected geometry changed — update the kills"
);
let mut delivery: Vec<Vec<u8>> = pkts
.iter()
.enumerate()
.filter(|(i, _)| !kill.contains(i))
.map(|(_, p)| p.clone())
.collect();
if reverse {
delivery.reverse();
}
if let Some(dup) = delivery.first().cloned() {
delivery.push(dup);
}
let cfg = e2e_config(scheme, 50);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let coder = coder_for(scheme);
let stats = StatsCounters::default();
let mut got = None;
for p in &delivery {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
assert!(got.is_none(), "frame must complete exactly once");
got = Some(f);
}
}
let f = got.expect("streamed frame must complete within the FEC budget");
assert_eq!(
f.data, src,
"reassembled streamed AU must be byte-identical"
);
assert_eq!(f.pts_ns, 12345);
assert!(f.complete);
}
#[test]
fn streamed_roundtrip_clean_and_reversed() {
streamed_roundtrip(FecScheme::Gf16, &[], false);
streamed_roundtrip(FecScheme::Gf16, &[], true);
streamed_roundtrip(FecScheme::Gf8, &[], true);
}
/// Loss within each block's FEC budget: one data shard from a sentinel block, one from the
/// final block — in both delivery orders.
#[test]
fn streamed_roundtrip_survives_loss_and_reorder() {
// Wire order: blk0 = 0..4 data + 4..6 rec, blk1 = 6..10 data + 10..12 rec,
// final = 12..14 data + 14 rec.
streamed_roundtrip(FecScheme::Gf16, &[1, 12], false);
streamed_roundtrip(FecScheme::Gf16, &[1, 12], true);
}
/// The wire shape of a streamed AU: sentinel headers (block_count = 0, frame_bytes = 0,
/// full-K) on every non-final block, real totals + EOF on the final block, SOF on the very
/// first packet only.
#[test]
fn streamed_headers_sentinel_then_final() {
let chunks: Vec<Vec<u8>> = (0..3).map(|_| vec![0xA5u8; 50]).collect();
let chunk_refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
let (pkts, src) = streamed_packets(FecScheme::Gf16, 50, &chunk_refs);
let mut saw_final = false;
for (i, p) in pkts.iter().enumerate() {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_eq!(
h.flags & FLAG_SOF != 0,
i == 0,
"SOF exactly on the first packet"
);
assert_eq!(
h.flags & FLAG_EOF != 0,
i + 1 == pkts.len(),
"EOF exactly on the last packet"
);
if h.block_index < 2 {
assert_eq!(
h.block_count, 0,
"non-final block must ride sentinel headers"
);
assert_eq!(h.frame_bytes, 0);
assert_eq!(h.data_shards, 4, "sentinel blocks are exactly full-K");
} else {
saw_final = true;
assert_eq!(h.block_count, 3, "final block carries the real block count");
assert_eq!(h.frame_bytes as usize, src.len(), "and the real AU size");
}
}
assert!(saw_final);
}
/// A streamed AU smaller than one block emits NO sentinels — its single (final) block is
/// byte-identical in shape to a legacy frame, so small frames pay zero streaming overhead
/// and any receiver accepts them.
#[test]
fn streamed_small_frame_degenerates_to_legacy() {
let (pkts, src) = streamed_packets(FecScheme::Gf16, 50, &[&[0x5Au8; 40]]);
for p in &pkts {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_eq!(
h.block_count, 1,
"single-block streamed AU must be legacy-shaped"
);
assert_eq!(h.frame_bytes as usize, src.len());
}
}
/// Sentinel firewall: a sentinel header that is not exactly full-K, or claims a non-zero
/// total, or sits where the final block could no longer follow, is dropped before any
/// allocation happens.
#[test]
fn streamed_sentinel_firewall_bounds() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
let sentinel = |f: fn(&mut PacketHeader)| {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8; // limits().max_data_shards — the only legal sentinel K
h.recovery_shards = 0;
f(&mut h);
h
};
// Not full-K.
let h = sentinel(|h| h.data_shards = 7);
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Claims a total.
let h = sentinel(|h| h.frame_bytes = 64);
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Sits on the last block the limits allow (no room for the final block after it).
let h = sentinel(|h| h.block_index = 3); // limits().max_blocks == 4
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(stats.snapshot().packets_dropped, 3);
// A conformant sentinel IS accepted (proves the rejections above weren't vacuous).
let h = sentinel(|_| {});
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
3,
"conformant sentinel accepted"
);
}
/// Retro-validation: final-block totals under which an already-received sentinel block is
/// out of range (or mis-sized) kill the WHOLE frame — no spliced delivery — and the killed
/// index cannot be resurrected by stragglers.
#[test]
fn streamed_lying_final_totals_kill_the_frame_wholesale() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// Two sentinel blocks (indexes 0 and 1) open the frame and land shards.
for bi in 0..2u16 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = bi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
// A "final" header claiming the whole AU is ONE 16-byte shard: geometry-valid on its own
// (expect_blocks = 1, K = 1), but it disowns both sentinel blocks → the frame dies.
let mut lying = base_header();
lying.block_count = 1;
lying.frame_bytes = 16;
lying.data_shards = 1;
lying.recovery_shards = 0;
assert!(r
.push(&packet(lying), coder.as_ref(), &stats)
.unwrap()
.is_none());
let snap = stats.snapshot();
assert_eq!(
snap.frames_dropped, 1,
"the lying frame must be counted lost"
);
// A straggler sentinel for the killed index must not resurrect it.
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
before + 1,
"straggler for a killed frame must be dropped, not re-open it"
);
}
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
#[test]
fn streamed_open_amplification_is_budget_bounded() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
for fi in 0..5u32 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.frame_index = fi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
assert_eq!(
stats.snapshot().packets_dropped,
1,
"the fifth max-sized open must be refused by the in-flight budget"
);
}
/// The final-first order's single buffer-safety guard (2026-07 security review finding 3): a
/// frame opened by its FINAL block allocates an EXACT-sized buffer; a sentinel aimed at (or
/// past) the pinned final slot must be dropped — without the guard its full-K write would land
/// outside that buffer. And the reject must not corrupt the frame: it still completes.
#[test]
fn streamed_out_of_range_sentinel_after_final_first_is_dropped() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
// Final block opens the frame: block_count = 1, frame_bytes = 32 → K = 2. Send shard 0
// only, so the frame stays in flight with the totals pinned.
let mut fin = base_header();
fin.block_count = 1;
fin.frame_bytes = 32;
fin.data_shards = 2;
fin.recovery_shards = 0;
fin.shard_index = 0;
assert!(r
.push(&packet(fin), coder.as_ref(), &stats)
.unwrap()
.is_none());
// Sentinels at the final slot (0) and past it (1): both non-final-impossible under the
// pinned block_count = 1 → dropped, never written into the 32-byte buffer.
for bi in 0..2u16 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = bi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
assert_eq!(stats.snapshot().packets_dropped, 2);
// The frame is unharmed: its real second shard completes it at the exact pinned length.
let mut fin2 = fin;
fin2.shard_index = 1;
let got = r
.push(&packet(fin2), coder.as_ref(), &stats)
.unwrap()
.expect("frame must still complete after the rejected sentinels");
assert_eq!(got.data.len(), 32);
assert!(got.complete);
}
/// A second "final" header with DIFFERENT totals must be rejected once a streamed frame is
/// pinned (re-pinning would re-interpret already-landed shards), and the frame must still
/// complete under the first totals.
#[test]
fn streamed_second_final_with_different_totals_is_rejected() {
let mut r = Reassembler::new(limits());
let coder = coder_for(FecScheme::Gf8);
let stats = StatsCounters::default();
let sentinel_shard = |shard_index: u16| {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
h.data_shards = 8;
h.recovery_shards = 0;
h.block_index = 0;
h.shard_index = shard_index;
h
};
let final_shard = |frame_bytes: u32, data_shards: u16, shard_index: u16| {
let mut h = base_header();
h.block_count = 2;
h.frame_bytes = frame_bytes;
h.data_shards = data_shards;
h.recovery_shards = 0;
h.block_index = 1;
h.shard_index = shard_index;
h
};
// Sentinel opens block 0, then the real final pins totals: 10 shards = 160 bytes.
assert!(r
.push(&packet(sentinel_shard(0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert!(r
.push(&packet(final_shard(160, 2, 0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
// A second final claiming 144 bytes (K = 1): geometry-valid alone, but it contradicts the
// pinned totals → dropped.
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&packet(final_shard(144, 1, 0)), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(stats.snapshot().packets_dropped, before + 1);
// The frame still completes under the FIRST totals: the rest of block 0 + the final tail.
let mut got = None;
for s in 1..8u16 {
assert!(got.is_none());
got = r
.push(&packet(sentinel_shard(s)), coder.as_ref(), &stats)
.unwrap();
}
assert!(got.is_none(), "block 1 still owes a shard");
let got = r
.push(&packet(final_shard(160, 2, 1)), coder.as_ref(), &stats)
.unwrap()
.expect("frame completes under the first pinned totals");
assert_eq!(got.data.len(), 160);
}
+12
View File
@@ -32,6 +32,18 @@ pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
/// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
/// reassembler would silently drop as stale.
pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10;
/// [`Hello::video_caps`] bit: the client's reassembler accepts **streamed access units**
/// (design/nvenc-subframe-slice-output.md Phase 2): the host may ship an AU's early FEC blocks
/// before the AU's total size exists — while the tail of the frame is still encoding — so the
/// AU's last packet leaves the host sooner (latency plan §7 LN1). Non-final blocks ride
/// SENTINEL headers (`block_count == 0` — a value no legacy sender emits — with
/// `frame_bytes == 0` and exactly `max_data_per_block` data shards, so the shard-offset
/// formula needs no total); the FINAL block's headers carry the real
/// `frame_bytes`/`block_count` (+ `FLAG_EOF`), which retro-validate the whole frame's geometry
/// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
/// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
/// the fallback is zero-risk.
pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20;
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
+74 -7
View File
@@ -13,7 +13,9 @@ use crate::crypto::SessionCrypto;
use crate::error::{PunktfunkError, Result};
use crate::fec::{coder_for, ErasureCoder};
use crate::input::InputEvent;
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES};
use crate::packet::{
PacketHeader, Packetizer, Reassembler, ReassemblerLimits, StreamedAu, MAX_DATAGRAM_BYTES,
};
use crate::stats::{Stats, StatsCounters};
use crate::transport::Transport;
use zerocopy::IntoBytes;
@@ -274,6 +276,68 @@ impl Session {
pts_ns: u64,
user_flags: u32,
frame_index: Option<u32>,
) -> Result<Vec<Vec<u8>>> {
self.seal_run(true, |p, coder, emit| {
p.packetize_each(data, pts_ns, user_flags, frame_index, coder, emit)
})
}
/// Host: open a **streamed** access unit ([`crate::quic::VIDEO_CAP_STREAMED_AU`] — only
/// toward a client that advertised it; anyone else must get the whole-AU
/// [`seal_frame_at`](Self::seal_frame_at) path). The AU's bytes are fed in with
/// [`seal_streamed_chunk`](Self::seal_streamed_chunk) as the encoder produces them and
/// closed with [`seal_streamed_finish`](Self::seal_streamed_finish); the three calls'
/// returned wire batches are ONE frame, and the nonce order is emission order across the
/// calls — send each batch as it is returned, before sealing the next.
pub fn begin_streamed_frame_at(
&mut self,
pts_ns: u64,
user_flags: u32,
frame_index: u32,
) -> Result<StreamedAu> {
if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg(
"seal_frame called on a client session",
));
}
Ok(self
.packetizer
.begin_streamed(pts_ns, user_flags, Some(frame_index)))
}
/// Feed one encoder chunk into a streamed AU, sealing every FEC block it completes under
/// sentinel headers (see [`begin_streamed_frame_at`](Self::begin_streamed_frame_at)). The
/// returned batch is often EMPTY (the chunk is buffered until a block fills) — that's
/// normal, not an error.
pub fn seal_streamed_chunk(
&mut self,
au: &mut StreamedAu,
chunk: &[u8],
) -> Result<Vec<Vec<u8>>> {
self.seal_run(false, |p, coder, emit| {
p.push_streamed(au, chunk, coder, emit)
})
}
/// Close a streamed AU: seal the final block with the real totals (+ `FLAG_EOF`), which
/// retro-validate the frame at the receiver. Counts the frame as submitted.
pub fn seal_streamed_finish(&mut self, au: StreamedAu) -> Result<Vec<Vec<u8>>> {
self.seal_run(true, |p, coder, emit| p.finish_streamed(au, coder, emit))
}
/// The shared packetize → pooled-wire → seal machinery behind [`seal_frame`](Self::seal_frame)
/// and the streamed sealers: `run` drives the packetizer against an emit sink that writes
/// each packet's plaintext at its final wire offset; the seal pass (two-lane for large runs)
/// then encrypts in place. `count_frame` gates the per-frame stats — a streamed AU counts
/// once, at its finish call.
fn seal_run(
&mut self,
count_frame: bool,
run: impl FnOnce(
&mut Packetizer,
&dyn ErasureCoder,
&mut dyn FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()>,
) -> Result<Vec<Vec<u8>>> {
if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg(
@@ -320,10 +384,10 @@ impl Session {
// sealing itself is a separate pass so it can split across lanes.
let seq_base = *next_seq;
let encrypting = crypto.is_some();
let result = packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder_ref, {
let result = {
let wires = &mut wires;
let used = &mut used;
move |hdr, body| {
let mut emit = move |hdr: &PacketHeader, body: &[u8]| -> Result<()> {
if *used == wires.len() {
wires.push(Vec::new());
}
@@ -342,8 +406,9 @@ impl Session {
wire.extend_from_slice(body);
}
Ok(())
}
});
};
run(packetizer, coder_ref, &mut emit)
};
result?;
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
// as the previous `resize_with(packets.len(), ..)` did. (Before the seal phase, so a
@@ -421,10 +486,12 @@ impl Session {
if let Some(p) = self.seal_perf.as_mut() {
p.fec_ns += fec_ns.load(std::sync::atomic::Ordering::Relaxed);
p.seal_ns += seal_ns;
p.frames += 1;
p.frames += count_frame as u64;
p.packets += used as u64;
}
StatsCounters::add(&self.stats.frames_submitted, 1);
if count_frame {
StatsCounters::add(&self.stats.frames_submitted, 1);
}
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
StatsCounters::add(&self.stats.bytes_sent, bytes);
+7
View File
@@ -110,6 +110,13 @@ serde_json = "1"
# utoipa into axum 0.8 extractors; utoipa-axum collects `#[utoipa::path]` routes into the
# spec; utoipa-scalar serves the interactive docs. Codegen-friendly: the spec is emitted
# verbatim by the `openapi` subcommand. Control plane only — never the per-frame path.
# Plugin-store index signatures: ed25519 verification of a catalog document before any field of
# it is read (store/index.rs). Already in the tree via rustls — the workspace is ring-only, so this
# is the one signature primitive available without pulling aws-lc-sys (which fails on Windows CI).
ring = "0.17"
# Semver comparisons for the plugin store: `minHost` gating and the revocation list's version
# ranges (`<0.3.2`). Already in the lockfile transitively.
semver = "1"
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-axum = "0.2"
utoipa-scalar = { version = "0.3", features = ["axum"] }
+7
View File
@@ -170,6 +170,12 @@ pub enum EventKind {
/// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set.
id: String,
},
#[serde(rename = "store.changed")]
/// The set of installed plugins, or what the store knows about them, changed — an install or
/// uninstall finished, or a catalog refresh brought in new rows. A consumer re-reads
/// `GET /api/v1/store/catalog` / `…/installed`. Deliberately payload-free: the store's answer
/// is a join over several sources of truth, so "go look again" is the only honest signal.
StoreChanged,
#[serde(rename = "host.started")]
HostStarted {
version: String,
@@ -197,6 +203,7 @@ impl EventKind {
EventKind::DisplayReleased { .. } => "display.released",
EventKind::LibraryChanged { .. } => "library.changed",
EventKind::PluginsChanged { .. } => "plugins.changed",
EventKind::StoreChanged => "store.changed",
EventKind::HostStarted { .. } => "host.started",
EventKind::HostStopping => "host.stopping",
}
@@ -658,6 +658,9 @@ fn stream_body(
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
encode::ChromaFormat::Yuv420,
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder
// may be handed cursor bitmaps to composite.
true,
)
.context("open video encoder for stream")?;
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
@@ -811,6 +814,7 @@ fn stream_body(
frame.is_cuda(),
gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
true, // metadata-cursor capture — see the first open
)
.context("reopen encoder after rebuild")?;
supports_rfi = enc.caps().supports_rfi;
+3
View File
@@ -74,6 +74,9 @@ mod session_plan;
mod session_status;
mod spike;
mod stats_recorder;
// The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the
// same runner CLI the `plugins` subcommand uses (design/plugin-store.md).
mod store;
mod stream_marker;
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
+13 -1
View File
@@ -42,6 +42,7 @@ mod plugins;
mod session;
mod shared;
mod stats;
mod store;
#[cfg(test)]
mod tests;
@@ -240,7 +241,17 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(hooks::get_hooks, hooks::set_hooks))
.routes(routes!(plugins::list_plugins))
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
.routes(routes!(plugins::get_ui_credential)),
.routes(routes!(plugins::get_ui_credential))
.routes(routes!(store::get_catalog))
.routes(routes!(store::refresh_catalog))
.routes(routes!(store::list_installed))
.routes(routes!(store::install_plugin))
.routes(routes!(store::uninstall_plugin))
.routes(routes!(store::list_jobs))
.routes(routes!(store::get_job))
.routes(routes!(store::list_sources))
.routes(routes!(store::put_source, store::delete_source))
.routes(routes!(store::get_runtime, store::set_runtime)),
)
.split_for_parts()
}
@@ -279,6 +290,7 @@ pub fn openapi_json() -> String {
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
(name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"),
)
)]
struct ApiDoc;
+7
View File
@@ -131,8 +131,15 @@ pub(crate) async fn require_auth(
/// or eject the operator's.
/// - **UI proxy credentials** — a plugin has no business reading another plugin's per-boot UI
/// secret; only the console proxy (admin token) needs it.
/// - **the plugin store** — installing a plugin is running new code with operator privileges, and a
/// plugin that can do that is a persistence/escalation primitive: it could install a helper that
/// isn't constrained the way it is, or switch the runner's own service state. Denied wholesale
/// (reads included — the catalog is not sensitive, but there is no reason a plugin needs it, and
/// a whole-prefix deny can't be defeated by a route added later).
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
let denied = path == "/api/v1/hooks"
|| path == "/api/v1/store"
|| path.starts_with("/api/v1/store/")
|| path == "/api/v1/pair"
|| path.starts_with("/api/v1/pair/")
|| path == "/api/v1/native/pair"
+19
View File
@@ -202,6 +202,15 @@ impl PluginRegistry {
})
}
/// The ids of live registrations, without pruning or announcing anything.
fn live_ids(&self) -> Vec<String> {
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
map.iter()
.filter(|(_, s)| s.is_live())
.map(|(id, _)| id.clone())
.collect()
}
/// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an
/// already-expired or unknown id returns `false` (nothing to announce).
fn remove(&self, id: &str) -> bool {
@@ -222,6 +231,16 @@ pub(crate) fn registry() -> &'static PluginRegistry {
REG.get_or_init(PluginRegistry::new)
}
/// Which plugin ids are registered right now — the plugin store's "running" column
/// ([`super::store::list_installed`]).
///
/// Deliberately **not** [`list_plugins`]: that one prunes expired leases and emits
/// `plugins.changed` for each, which is right for the nav's poll but would turn the store's own
/// polling into an event fire-hose. This is a pure read.
pub(crate) fn live_plugin_ids() -> Vec<String> {
registry().live_ids()
}
// ---------------------------------------------------------------- validation
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
+779
View File
@@ -0,0 +1,779 @@
//! Plugin **store** API: browse catalogs, install/uninstall, manage sources and the runner
//! (design `plugin-store.md` §4.2). The domain logic lives in [`crate::store`]; this is its HTTP
//! surface.
//!
//! Auth: **admin bearer + loopback**, like every mutation — and explicitly *denied to the plugin
//! token* ([`super::auth::plugin_may_access`]). A plugin that can install plugins is a persistence
//! and escalation primitive: it could install a helper that isn't constrained the way it is. That
//! deny is a carve-out in the exclusion-based allowlist, so it is spelled out there and pinned by a
//! test.
//!
//! (A console *session* can already reach arbitrary command execution via `PUT /hooks`, so the
//! store adds convenience for a session holder rather than a new privilege class. It is still worth
//! keeping the plugin lane away from it — the lanes exist precisely so a plugin defect isn't an
//! operator compromise.)
//!
//! Everything here does blocking work — filesystem scans, network fetches, process spawns — so
//! handlers hop to `spawn_blocking` rather than stalling the runtime.
use super::shared::*;
use crate::store::{self, index, jobs, manifest, sources};
/// Run blocking store work off the async runtime, mapping a joined-thread panic to a 500.
async fn blocking<T, F>(f: F) -> Result<T, Response>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(f).await.map_err(|e| {
tracing::error!("plugin-store worker panicked: {e}");
api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"the plugin store worker failed",
)
})
}
// ---------------------------------------------------------------- wire shapes
/// Facts about this host, so the console can grey out rows it can't install.
#[derive(Serialize, ToSchema)]
pub(crate) struct HostFacts {
pub version: String,
/// `linux` / `windows` / `macos`.
pub platform: String,
}
/// A configured catalog source and how its last refresh went.
#[derive(Serialize, ToSchema)]
pub(crate) struct SourceView {
pub name: String,
pub url: String,
/// The built-in `unom` source: not editable, not removable, and the only source whose entries
/// may carry the "verified" tier.
pub builtin: bool,
/// Whether we check a signature on this source's index. An unsigned source still works; the
/// console marks it.
pub signed: bool,
/// The catalog we're serving is older than the last refresh attempt (offline, or the last
/// fetch failed) — entries still install, because the pin travelled with the entry.
pub stale: bool,
/// Unix seconds of the data we hold, when we hold any.
#[serde(skip_serializing_if = "Option::is_none")]
pub fetched_at: Option<u64>,
/// Why the last refresh failed, if it did.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub entry_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
}
/// One row on the shelf.
#[derive(Serialize, ToSchema)]
pub(crate) struct CatalogEntry {
pub id: String,
pub pkg: String,
pub title: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub author: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
/// The one installable version this entry pins.
pub version: String,
/// Which source listed it.
pub source: String,
/// `verified` (built-in source) or `external` (an operator-added source). Never `unverified`:
/// unverified installs come from a raw spec and are never listed (D7).
pub tier: String,
/// When unom reviewed this exact tarball (built-in source only).
#[serde(skip_serializing_if = "Option::is_none")]
pub reviewed_at: Option<String>,
pub platforms: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_host: Option<String>,
/// Can this host install it?
pub compatible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub incompatible_reason: Option<String>,
/// The version installed right now, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub installed_version: Option<String>,
/// Installed, but at a different version than the catalog pins.
pub update_available: bool,
/// A revocation covering the catalogued version — do not offer this without shouting.
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub(crate) struct CatalogResponse {
pub host: HostFacts,
pub sources: Vec<SourceView>,
pub plugins: Vec<CatalogEntry>,
/// True while a package operation is in flight — the console disables install buttons.
pub busy: bool,
}
/// An installed plugin package, joined with its provenance and whether it's actually running.
#[derive(Serialize, ToSchema)]
pub(crate) struct InstalledView {
pub pkg: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// `verified` / `external` / `unverified` / `cli` — remembered from install time, so an
/// unverified plugin stays visibly unverified long after the dialog is forgotten.
pub tier: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
/// The catalog entry this maps to, when it is on a shelf we know.
#[serde(skip_serializing_if = "Option::is_none")]
pub entry_id: Option<String>,
/// The plugin id it registers under — the key into `GET /plugins`.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub installed_at: Option<String>,
/// Is it registered in the live lease registry right now?
pub running: bool,
/// The catalog's version, when it's newer than what's installed.
#[serde(skip_serializing_if = "Option::is_none")]
pub update_available: Option<String>,
/// A revocation covering the *installed* version. Reported, never auto-removed: silently
/// deleting running code is its own hazard, so the operator decides.
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked: Option<String>,
}
/// `POST /store/install` — either a catalogued entry, or a raw spec the operator owns.
#[derive(Deserialize, ToSchema)]
pub(crate) struct InstallRequest {
/// Catalog source name (with [`Self::id`]).
#[serde(default)]
pub source: Option<String>,
/// Catalog entry id (with [`Self::source`]).
#[serde(default)]
pub id: Option<String>,
/// A raw package spec (`@scope/name`, `@scope/name@1.2.3`, an https tarball or git+https URL).
/// Nothing reviewed it and nothing pins it.
#[serde(default)]
pub spec: Option<String>,
/// Required with [`Self::spec`]: the operator's explicit acknowledgement that this installs
/// unreviewed code with operator privileges. The console collects it behind a typed
/// confirmation; the API refuses without it so no other caller can skip the decision.
#[serde(default)]
pub accept_unverified: bool,
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct UninstallRequest {
pub pkg: String,
}
/// 202 body: where to watch the work.
#[derive(Serialize, ToSchema)]
pub(crate) struct JobRef {
pub job: String,
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct SourceInput {
pub url: String,
/// `ed25519:<base64>`. Omitted ⇒ an unsigned source (accepted, flagged everywhere).
#[serde(default)]
pub public_key: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub(crate) struct RuntimeView {
/// Is the runner payload/unit present at all?
pub installed: bool,
pub enabled: bool,
pub running: bool,
/// systemd unit or scheduled-task name.
pub unit: String,
/// Windows: the account the task runs as.
#[serde(skip_serializing_if = "Option::is_none")]
pub principal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct RuntimeRequest {
pub enabled: bool,
}
// ---------------------------------------------------------------- assembly
fn runtime_view() -> RuntimeView {
let st = crate::plugins::runtime_status();
RuntimeView {
installed: st.installed,
enabled: st.enabled,
running: st.running,
unit: st.unit.to_string(),
principal: st.principal,
detail: (!st.detail.is_empty()).then_some(st.detail),
}
}
/// Build the merged catalog view: every source's shelf, annotated with what this host has and can
/// run. `force` refreshes past the TTL.
fn build_catalog(force: bool) -> CatalogResponse {
let states = store::catalogs(force);
let installed = store::installed_packages(&store::plugins_dir());
let mut plugins = Vec::new();
let mut source_views = Vec::new();
for st in &states {
let count = st.index.as_ref().map(|i| i.plugins.len()).unwrap_or(0);
source_views.push(SourceView {
name: st.source.name.clone(),
url: st.source.url.clone(),
builtin: st.source.is_official(),
signed: st.source.is_signed(),
stale: st.stale,
fetched_at: st.fetched_at,
error: st.error.clone(),
entry_count: count as u32,
public_key: st.source.public_key.clone(),
});
let Some(idx) = &st.index else { continue };
let verified = st.source.is_official();
for e in &idx.plugins {
let installed_version = installed
.iter()
.find(|p| p.pkg == e.pkg)
.and_then(|p| p.version.clone());
let reason = e.incompatible_reason();
plugins.push(CatalogEntry {
id: e.id.clone(),
pkg: e.pkg.clone(),
title: e.title.clone(),
description: e.description.clone(),
icon: e.icon.clone(),
author: e.author.clone(),
homepage: e.homepage.clone(),
license: e.license.clone(),
version: e.version.clone(),
source: st.source.name.clone(),
// The badge is the built-in source's alone: a third-party curator can pin and
// publish, but cannot confer unom's review (D6).
tier: if verified { "verified" } else { "external" }.to_string(),
reviewed_at: verified
.then(|| e.verification.as_ref().map(|v| v.reviewed_at.clone()))
.flatten(),
platforms: e.platforms.clone(),
min_host: e.min_host.clone(),
compatible: reason.is_none(),
incompatible_reason: reason,
update_available: installed_version.as_deref().is_some_and(|v| v != e.version),
installed_version,
blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason),
});
}
}
// Stable, useful order: alphabetical by title, then by source so duplicates across shelves
// don't jitter between polls.
plugins.sort_by(|a, b| {
a.title
.to_lowercase()
.cmp(&b.title.to_lowercase())
.then_with(|| a.source.cmp(&b.source))
});
CatalogResponse {
host: HostFacts {
version: index::host_version().to_string(),
platform: index::HOST_PLATFORM.to_string(),
},
sources: source_views,
plugins,
busy: jobs::busy(),
}
}
fn build_installed(live: &[String]) -> Vec<InstalledView> {
let dir = store::plugins_dir();
let records = manifest::load(&dir);
let catalogs = store::cached_catalogs();
let mut out: Vec<InstalledView> = store::installed_packages(&dir)
.into_iter()
.map(|p| {
let rec = records.get(&p.pkg);
// The catalog row for this package, if any shelf carries it — the source of "an update
// is available" and of a friendly title for CLI-installed packages.
let entry = catalogs.iter().find_map(|s| {
s.index
.as_ref()?
.plugins
.iter()
.find(|e| e.pkg == p.pkg)
.map(|e| (e, s.source.name.clone()))
});
let plugin_id = entry
.as_ref()
.map(|(e, _)| e.id.clone())
.or_else(|| store::plugin_id_for_pkg(&p.pkg));
InstalledView {
// Absence of a manifest record is meaningful: the CLI put it there (§4.5).
tier: rec
.map(|r| r.tier)
.unwrap_or(manifest::Tier::Cli)
.as_str()
.to_string(),
source: rec
.and_then(|r| r.source.clone())
.or_else(|| entry.as_ref().map(|(_, s)| s.clone())),
entry_id: rec
.and_then(|r| r.entry_id.clone())
.or_else(|| entry.as_ref().map(|(e, _)| e.id.clone())),
title: entry.as_ref().map(|(e, _)| e.title.clone()),
installed_at: rec.and_then(|r| r.installed_at.clone()),
running: plugin_id
.as_deref()
.is_some_and(|id| live.iter().any(|l| l == id)),
update_available: entry.as_ref().and_then(|(e, _)| {
(p.version.as_deref() != Some(e.version.as_str())).then(|| e.version.clone())
}),
blocked: store::advisory_for(&p.pkg, p.version.as_deref()).map(|a| a.reason),
plugin_id,
version: p.version,
pkg: p.pkg,
}
})
.collect();
out.sort_by(|a, b| a.pkg.cmp(&b.pkg));
out
}
// ---------------------------------------------------------------- handlers
/// Browse the plugin catalog
///
/// The merged shelf across every configured source, annotated with what this host already has and
/// what it can run. Sources past their freshness window are refreshed first; a source that can't be
/// reached keeps serving its last good copy, marked `stale` (a LAN-only host still has a working
/// store — an entry's pin travelled with the entry).
#[utoipa::path(
get,
path = "/store/catalog",
tag = "store",
operation_id = "getPluginCatalog",
responses(
(status = OK, description = "The merged catalog", body = CatalogResponse),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn get_catalog() -> Response {
match blocking(|| build_catalog(false)).await {
Ok(c) => Json(c).into_response(),
Err(e) => e,
}
}
/// Refresh every catalog now
///
/// Bypasses the freshness window and re-fetches all sources, then returns the merged catalog.
#[utoipa::path(
post,
path = "/store/refresh",
tag = "store",
operation_id = "refreshPluginCatalog",
responses(
(status = OK, description = "The freshly-fetched catalog", body = CatalogResponse),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn refresh_catalog() -> Response {
match blocking(|| build_catalog(true)).await {
Ok(c) => {
crate::events::emit(crate::events::EventKind::StoreChanged);
Json(c).into_response()
}
Err(e) => e,
}
}
/// List installed plugins
///
/// What's actually in the plugins directory, joined with how it got there (the provenance manifest)
/// and whether it is registered right now. A package with no provenance record was installed with
/// the CLI and reports `tier: "cli"` — absence is the answer, not a gap.
#[utoipa::path(
get,
path = "/store/installed",
tag = "store",
operation_id = "listInstalledPlugins",
responses(
(status = OK, description = "Installed plugin packages", body = [InstalledView]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn list_installed() -> Response {
// The live set comes from the in-memory lease registry — cheap, and it must be read on this
// thread rather than inside the blocking closure to keep the borrow simple.
let live: Vec<String> = super::plugins::live_plugin_ids();
match blocking(move || build_installed(&live)).await {
Ok(v) => Json(v).into_response(),
Err(e) => e,
}
}
/// Install a plugin
///
/// Either `{source, id}` — a catalogued entry, installed at its pinned version after its integrity
/// is re-checked against the registry — or `{spec, accept_unverified: true}`, which installs an
/// unreviewed package the operator takes responsibility for. Returns `202` with a job id; watch it
/// at `GET /store/jobs/{id}`.
///
/// One package operation runs at a time (`409` otherwise): `bun` operations share a lockfile and a
/// `node_modules` tree.
#[utoipa::path(
post,
path = "/store/install",
tag = "store",
operation_id = "installPlugin",
request_body = InstallRequest,
responses(
(status = ACCEPTED, description = "Install job started", body = JobRef),
(status = BAD_REQUEST, description = "Unknown entry, bad spec, or missing acknowledgement", body = ApiError),
(status = CONFLICT, description = "Another package operation is in flight", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn install_plugin(ApiJson(req): ApiJson<InstallRequest>) -> Response {
let plan =
match (
req.source.as_deref(),
req.id.as_deref(),
req.spec.as_deref(),
) {
(Some(source), Some(id), None) => {
let found = match blocking({
let (source, id) = (source.to_string(), id.to_string());
move || store::find_entry(&source, &id)
})
.await
{
Ok(f) => f,
Err(e) => return e,
};
let Some((entry, verified)) = found else {
return api_error(
StatusCode::BAD_REQUEST,
"no such plugin in that source's catalog",
);
};
if let Some(reason) = entry.incompatible_reason() {
return api_error(
StatusCode::BAD_REQUEST,
&format!("this plugin cannot run on this host: {reason}"),
);
}
match jobs::Plan::from_entry(&entry, source, verified) {
Ok(p) => p,
Err(e) => return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
}
}
(None, None, Some(spec)) => {
if !req.accept_unverified {
return api_error(
StatusCode::BAD_REQUEST,
"installing from a raw package spec runs unreviewed code with operator \
privileges set `accept_unverified` to confirm",
);
}
match jobs::Plan::from_spec(spec) {
Ok(p) => p,
Err(e) => return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
}
}
_ => return api_error(
StatusCode::BAD_REQUEST,
"provide either {source, id} for a catalogued plugin or {spec} for a raw package",
),
};
match jobs::spawn_install(plan) {
Ok(job) => (StatusCode::ACCEPTED, Json(JobRef { job })).into_response(),
Err(e) => api_error(StatusCode::CONFLICT, &format!("{e:#}")),
}
}
/// Uninstall a plugin
///
/// Removes the package and forgets its provenance, then restarts the runner. Only names the runner
/// would actually supervise are accepted, so this can't be used to rip a shared dependency out of
/// the tree.
#[utoipa::path(
post,
path = "/store/uninstall",
tag = "store",
operation_id = "uninstallPlugin",
request_body = UninstallRequest,
responses(
(status = ACCEPTED, description = "Uninstall job started", body = JobRef),
(status = BAD_REQUEST, description = "Not a plugin package name", body = ApiError),
(status = CONFLICT, description = "Another package operation is in flight", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn uninstall_plugin(ApiJson(req): ApiJson<UninstallRequest>) -> Response {
if let Err(e) = store::valid_installed_pkg(&req.pkg) {
return api_error(StatusCode::BAD_REQUEST, &format!("{e:#}"));
}
// The name shape is not enough. `@punktfunk/plugin-kit` — the framework every kit-built plugin
// *depends on* — matches `@scope/plugin-*` exactly, so a syntactic guard waves it through and
// the store offers to uninstall a library out from under the plugins using it (accepted on
// Windows on-glass before this check existed). Only a package the operator actually installed
// may be removed, which is precisely what `installed_packages` reports: the plugins dir's
// top-level dependencies, transitive ones excluded.
let pkg = req.pkg.clone();
let known = match blocking(move || {
store::installed_packages(&store::plugins_dir())
.iter()
.any(|p| p.pkg == pkg)
})
.await
{
Ok(k) => k,
Err(e) => return e,
};
if !known {
return api_error(
StatusCode::BAD_REQUEST,
"that package is not an installed plugin — it may be a dependency of one, or already \
removed",
);
}
match jobs::spawn_uninstall(req.pkg) {
Ok(job) => (StatusCode::ACCEPTED, Json(JobRef { job })).into_response(),
Err(e) => api_error(StatusCode::CONFLICT, &format!("{e:#}")),
}
}
/// List recent package jobs
#[utoipa::path(
get,
path = "/store/jobs",
tag = "store",
operation_id = "listPluginJobs",
responses(
(status = OK, description = "Recent install/uninstall jobs, oldest first", body = [Job]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn list_jobs() -> Json<Vec<jobs::Job>> {
Json(jobs::list())
}
/// Follow one package job
///
/// Poll this while `state` is `running`; `log` carries the tail of the package manager's output.
#[utoipa::path(
get,
path = "/store/jobs/{id}",
tag = "store",
operation_id = "getPluginJob",
params(("id" = String, Path, description = "The job id returned by install/uninstall")),
responses(
(status = OK, description = "The job", body = Job),
(status = NOT_FOUND, description = "No such job (they are kept for a bounded history)", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn get_job(Path(id): Path<String>) -> Response {
match jobs::get(&id) {
Some(j) => Json(j).into_response(),
None => api_error(StatusCode::NOT_FOUND, "no such job"),
}
}
/// List catalog sources
#[utoipa::path(
get,
path = "/store/sources",
tag = "store",
operation_id = "listPluginSources",
responses(
(status = OK, description = "Configured sources, built-in first", body = [SourceView]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn list_sources() -> Response {
match blocking(|| {
store::cached_catalogs()
.into_iter()
.map(|st| SourceView {
name: st.source.name.clone(),
url: st.source.url.clone(),
builtin: st.source.is_official(),
signed: st.source.is_signed(),
stale: st.stale,
fetched_at: st.fetched_at,
error: st.error,
entry_count: st.index.map(|i| i.plugins.len()).unwrap_or(0) as u32,
public_key: st.source.public_key,
})
.collect::<Vec<_>>()
})
.await
{
Ok(v) => Json(v).into_response(),
Err(e) => e,
}
}
/// Add or update a catalog source
///
/// Adding a source is a trust decision: its entries become installable on this host. They are
/// attributed to it in the console and never carry the "verified" badge, which belongs to the
/// built-in source alone.
#[utoipa::path(
put,
path = "/store/sources/{name}",
tag = "store",
operation_id = "putPluginSource",
params(("name" = String, Path, description = "Source slug (`[a-z][a-z0-9-]*`)")),
request_body = SourceInput,
responses(
(status = NO_CONTENT, description = "Source saved"),
(status = BAD_REQUEST, description = "Invalid name, url or key — or the reserved built-in name", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn put_source(
Path(name): Path<String>,
ApiJson(input): ApiJson<SourceInput>,
) -> Response {
let source = sources::Source {
name: name.clone(),
url: input.url,
public_key: input.public_key.filter(|k| !k.trim().is_empty()),
};
let saved = blocking(move || {
let r = sources::put(source);
if r.is_ok() {
// A redefined source must not keep serving what the old definition cached.
store::drop_source_cache(&name);
}
r
})
.await;
match saved {
Err(e) => e,
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
Ok(Ok(())) => {
crate::events::emit(crate::events::EventKind::StoreChanged);
StatusCode::NO_CONTENT.into_response()
}
}
}
/// Remove a catalog source
#[utoipa::path(
delete,
path = "/store/sources/{name}",
tag = "store",
operation_id = "deletePluginSource",
params(("name" = String, Path, description = "Source slug")),
responses(
(status = NO_CONTENT, description = "Removed (or already absent)"),
(status = FORBIDDEN, description = "The built-in source cannot be removed, or the plugin token is not authorized", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn delete_source(Path(name): Path<String>) -> Response {
if name == sources::OFFICIAL_NAME {
return api_error(
StatusCode::FORBIDDEN,
"the built-in source cannot be removed",
);
}
let removed = blocking(move || {
let r = sources::remove(&name);
if matches!(r, Ok(true)) {
store::drop_source_cache(&name);
}
r
})
.await;
match removed {
Err(e) => e,
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
Ok(Ok(_)) => {
crate::events::emit(crate::events::EventKind::StoreChanged);
StatusCode::NO_CONTENT.into_response()
}
}
}
/// Plugin runner state
///
/// Installed plugins only run while the runner is on, and the runner discovers units at startup —
/// so this is both the "is anything running" answer and the explanation for a freshly installed
/// plugin that hasn't appeared yet.
#[utoipa::path(
get,
path = "/store/runtime",
tag = "store",
operation_id = "getPluginRuntime",
responses(
(status = OK, description = "Runner state", body = RuntimeView),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn get_runtime() -> Response {
match blocking(runtime_view).await {
Ok(v) => Json(v).into_response(),
Err(e) => e,
}
}
/// Turn the plugin runner on or off
#[utoipa::path(
post,
path = "/store/runtime",
tag = "store",
operation_id = "setPluginRuntime",
request_body = RuntimeRequest,
responses(
(status = OK, description = "The resulting runner state", body = RuntimeView),
(status = BAD_REQUEST, description = "The runner could not be switched", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = FORBIDDEN, description = "Not authorized for the plugin token", body = ApiError),
)
)]
pub(crate) async fn set_runtime(ApiJson(req): ApiJson<RuntimeRequest>) -> Response {
let switched =
blocking(move || crate::plugins::set_runtime_enabled(req.enabled).map(|()| runtime_view()))
.await;
match switched {
Err(e) => e,
Ok(Err(e)) => api_error(StatusCode::BAD_REQUEST, &format!("{e:#}")),
Ok(Ok(v)) => {
crate::events::emit(crate::events::EventKind::StoreChanged);
Json(v).into_response()
}
}
}
// Re-exported so `routes!` can name the response body types.
pub(crate) use crate::store::jobs::Job;
+46
View File
@@ -434,6 +434,46 @@ fn plugin_allowlist_excludes_escalation_routes() {
&Method::GET,
"/api/v1/plugins/x/ui-credential"
));
// The plugin STORE, wholesale. Installing a plugin runs new code with operator privileges, so a
// plugin able to do it could install a helper that isn't constrained the way it is — and
// `POST /store/runtime` would let it switch its own supervisor. Denied by whole-prefix so a
// route added here later is denied by default rather than by remembering to list it.
for path in [
"/api/v1/store/catalog",
"/api/v1/store/installed",
"/api/v1/store/sources",
"/api/v1/store/jobs",
"/api/v1/store/jobs/job-1",
"/api/v1/store/runtime",
"/api/v1/store/some-route-that-does-not-exist-yet",
] {
assert!(
!auth::plugin_may_access(&Method::GET, path),
"plugin token must not reach {path}"
);
}
for path in [
"/api/v1/store/install",
"/api/v1/store/uninstall",
"/api/v1/store/refresh",
"/api/v1/store/runtime",
] {
assert!(
!auth::plugin_may_access(&Method::POST, path),
"plugin token must not reach {path}"
);
}
assert!(!auth::plugin_may_access(
&Method::PUT,
"/api/v1/store/sources/evil"
));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/store/sources/unom"
));
// …but a route that merely starts with the same letters is unaffected.
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
}
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
@@ -484,6 +524,12 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
(Method::GET, "/api/v1/native/pending"),
(Method::DELETE, "/api/v1/clients/aabbcc"),
(Method::GET, "/api/v1/plugins/x/ui-credential"),
// The plugin store: a plugin must not be able to install plugins or switch its own runner.
(Method::GET, "/api/v1/store/catalog"),
(Method::POST, "/api/v1/store/install"),
(Method::POST, "/api/v1/store/uninstall"),
(Method::POST, "/api/v1/store/runtime"),
(Method::PUT, "/api/v1/store/sources/evil"),
] {
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
+4
View File
@@ -1238,6 +1238,9 @@ async fn serve_session(
// so mid-session bursts don't consume video frame indexes. An older client (bit clear) gets
// mid-session probes declined instead — see `run_probe_burst`.
let probe_seq = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ != 0;
// Streamed-AU capability: the client's reassembler accepts sentinel-headed streamed blocks,
// so a chunked encoder session may ship an AU's early FEC blocks while its tail encodes.
let streamed_au = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_STREAMED_AU != 0;
let stats_dp = stats; // data-plane handle to the shared stats recorder
// Short label for web-console stats captures: the client's cert-fingerprint prefix, else its
// peer IP (no fingerprint = anonymous TOFU/--open client).
@@ -1330,6 +1333,7 @@ async fn serve_session(
conn: conn_stream,
timing_conn,
probe_seq,
streamed_au,
stats: stats_dp,
client_label,
launch: launch_for_dp,
+363 -78
View File
@@ -252,6 +252,19 @@ fn paced_submit(
let wires = session
.seal_frame_at(data, pts_ns, flags, frame_index)
.map_err(|e| anyhow!("seal_frame: {e:?}"))?;
pace_sealed(session, wires, deadline, burst_cap, pace_rate_bps)
}
/// The pace-and-send half of [`paced_submit`], for wires that are ALREADY sealed — shared with
/// the streamed-AU path, whose seal happens per encoder chunk ([`handle_chunk`]) under the same
/// microburst policy and frame deadline.
fn pace_sealed(
session: &mut Session,
wires: Vec<Vec<u8>>,
deadline: std::time::Instant,
burst_cap: Option<usize>,
pace_rate_bps: u64,
) -> Result<PaceStat> {
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
crate::send_pacing::inject_video_drop(&mut refs);
@@ -335,6 +348,116 @@ struct FrameMsg {
was_measured: bool,
}
/// What the encode thread hands the send thread: a whole AU (the legacy path — every session
/// shape except a chunked encoder toward a streamed-capable client), or one slice-boundary
/// chunk of a streamed AU (§7 LN1 Phase 2 — the send thread seals/paces each chunk's completed
/// FEC blocks while the encoder still produces the AU's tail).
enum SendMsg {
Frame(FrameMsg),
Chunk(ChunkMsg),
}
/// One encoder chunk of a streamed AU. AU-level fields (`capture_ns`/`flags`/`frame_index`/
/// `deadline`) are identical on every chunk of one AU (the send thread opens the streamed seal
/// at `first`); the perf split fields are meaningful on `last` (whole-AU figures, exactly like
/// [`FrameMsg`]'s).
struct ChunkMsg {
data: Vec<u8>,
first: bool,
last: bool,
capture_ns: u64,
flags: u32,
frame_index: u32,
deadline: std::time::Instant,
encode_us: u32,
queue_us: u32,
cap_us: u32,
submit_us: u32,
wait_us: u32,
repeat: bool,
was_measured: bool,
}
/// A streamed AU the send thread has open: the core's incremental sealer plus the pace
/// aggregation across its per-chunk flushes (the accounting the whole-AU path reads off one
/// [`paced_submit`] call).
struct StreamedOpen {
au: punktfunk_core::packet::StreamedAu,
spread_us: u32,
paced: bool,
}
/// Feed one [`ChunkMsg`] through the streamed sealer: open at `first`, seal + pace every FEC
/// block the chunk completes, close (+ final block, real totals) at `last`. Returns
/// `Some((accounting, aggregated PaceStat))` when the AU finished — the caller runs the same
/// per-AU accounting as the whole-frame path — and `None` mid-AU.
fn handle_chunk(
session: &mut Session,
open: &mut Option<StreamedOpen>,
c: ChunkMsg,
burst_cap: Option<usize>,
pace_rate_bps: u64,
) -> Result<Option<(FrameMsg, PaceStat)>> {
if c.first {
if open.take().is_some() {
// The encode loop abandoned a mid-flight AU (an encoder stall/rebuild forfeits the
// in-flight frame). Its sentinel packets are already on the wire — the client ages
// that frame out and the rebuild's IDR re-anchors; just don't leak the open state.
tracing::warn!(
"streamed AU abandoned mid-flight (encoder rebuild) — client ages it out"
);
}
*open = Some(StreamedOpen {
au: session
.begin_streamed_frame_at(c.capture_ns, c.flags, c.frame_index)
.map_err(|e| anyhow!("begin_streamed_frame: {e:?}"))?,
spread_us: 0,
paced: false,
});
}
let Some(s) = open.as_mut() else {
return Err(anyhow!(
"streamed chunk without an open AU (encode-loop bug)"
));
};
let wires = session
.seal_streamed_chunk(&mut s.au, &c.data)
.map_err(|e| anyhow!("seal_streamed_chunk: {e:?}"))?;
if !wires.is_empty() {
let stat = pace_sealed(session, wires, c.deadline, burst_cap, pace_rate_bps)?;
s.spread_us = s.spread_us.saturating_add(stat.spread_us);
s.paced |= stat.paced;
}
if !c.last {
return Ok(None);
}
let s = open.take().expect("checked above");
let tail = session
.seal_streamed_finish(s.au)
.map_err(|e| anyhow!("seal_streamed_finish: {e:?}"))?;
let stat = pace_sealed(session, tail, c.deadline, burst_cap, pace_rate_bps)?;
Ok(Some((
FrameMsg {
data: Vec::new(), // already on the wire — accounting only
capture_ns: c.capture_ns,
flags: c.flags,
frame_index: c.frame_index,
deadline: c.deadline,
encode_us: c.encode_us,
queue_us: c.queue_us,
cap_us: c.cap_us,
submit_us: c.submit_us,
wait_us: c.wait_us,
repeat: c.repeat,
was_measured: c.was_measured,
},
PaceStat {
spread_us: s.spread_us.saturating_add(stat.spread_us),
paced: s.paced || stat.paced,
},
)))
}
/// The dedicated send thread: it owns the whole [`Session`] (so no socket clone or shared stats are
/// needed) and does FEC+seal + microburst-paced send OFF the capture/encode thread, plus the
/// speed-test probe bursts (which also need the Session). Decoupling the paced send from encoding
@@ -380,7 +503,7 @@ pub(super) fn reconfig_allowed(
#[allow(clippy::too_many_arguments)]
fn send_loop(
mut session: Session,
frame_rx: std::sync::mpsc::Receiver<FrameMsg>,
frame_rx: std::sync::mpsc::Receiver<SendMsg>,
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
stop: Arc<AtomicBool>,
@@ -425,96 +548,119 @@ fn send_loop(
let mut last_frames_dropped = 0u64;
let mut last_packets_dropped = 0u64;
let mut last_fec_recovered = 0u64;
// The streamed AU currently open (VIDEO_CAP_STREAMED_AU chunked sends) — `Some` strictly
// between a `ChunkMsg::first` and its `last`.
let mut streamed: Option<StreamedOpen> = None;
loop {
if stop.load(Ordering::SeqCst) {
break;
}
// Probes run here (they need the Session); a burst pauses video — the encode thread blocks
// on the full frame channel meanwhile, which is exactly the intended pause.
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq);
// on the full frame channel meanwhile, which is exactly the intended pause. Never mid-AU:
// a streamed frame's chunks are already leaving the socket, so a burst spliced between
// them would push the AU's tail past its deadline (the exact latency the mode removes).
if streamed.is_none() {
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq);
}
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
apply_fec_target(&mut session, &fec_target);
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) {
Ok(msg) => match paced_submit(
&mut session,
&msg.data,
msg.capture_ns,
msg.flags,
msg.frame_index,
msg.deadline,
burst_cap,
Ok(send_msg) => {
// Live ABR-tracked encoder bitrate → pace rate; 0 (not yet known) = uncapped.
(stats.bitrate_kbps.load(Ordering::Relaxed) as f64 * 1000.0 * pace_factor) as u64,
) {
Ok(stat) => {
// First VIDEO packets are on the wire — complete the bring-up trace (P0.1;
// once-only, no-op on every later frame). Speed-test filler isn't video.
if msg.flags & FLAG_PROBE as u32 == 0 {
stats.bringup.finish("first_packet");
let pace_rate = (stats.bitrate_kbps.load(Ordering::Relaxed) as f64
* 1000.0
* pace_factor) as u64;
// `Ok(Some(..))` = an AU fully left the socket (a whole frame, or a streamed
// AU's last chunk) — run the per-AU accounting; `Ok(None)` = mid-AU chunk.
let outcome = match send_msg {
SendMsg::Frame(msg) => paced_submit(
&mut session,
&msg.data,
msg.capture_ns,
msg.flags,
msg.frame_index,
msg.deadline,
burst_cap,
pace_rate,
)
.map(|stat| Some((msg, stat))),
SendMsg::Chunk(c) => {
handle_chunk(&mut session, &mut streamed, c, burst_cap, pace_rate)
}
// Host timing (0xCF): stamped now — the AU's packets have fully left the
// socket — against the same capture anchor the wire pts carries, so the
// client's per-frame math tiles exactly (network = its host+network this).
// Best-effort like every side-plane datagram; skipped for speed-test filler
// (FLAG_PROBE isn't video and its pts is the burst clock).
if let Some(tc) = &timing_conn {
};
match outcome {
// Mid-AU chunk: sealed + on the wire; the per-AU accounting runs at `last`.
Ok(None) => {}
Ok(Some((msg, stat))) => {
// First VIDEO packets are on the wire — complete the bring-up trace (P0.1;
// once-only, no-op on every later frame). Speed-test filler isn't video.
if msg.flags & FLAG_PROBE as u32 == 0 {
let host_us = (now_ns().saturating_sub(msg.capture_ns) / 1000)
.min(u32::MAX as u64)
as u32;
let t = punktfunk_core::quic::HostTiming {
pts_ns: msg.capture_ns,
host_us,
// T0.1 stage split: queue + encode ride the FrameMsg (always
// measured), pace is this send's spread. The client derives
// seal/FEC + channel-wait as the residual against host_us.
stages: Some(punktfunk_core::quic::HostStages {
queue_us: msg.queue_us,
encode_us: msg.encode_us,
pace_us: stat.spread_us,
}),
};
let _ = tc.send_datagram(
punktfunk_core::quic::encode_host_timing_datagram(&t).into(),
);
stats.bringup.finish("first_packet");
}
}
if perf || stats.rec.is_armed() {
// `encode_us`/`pace_us`/fps are valid for every frame (always measured),
// including the Windows relay + tail-drain frames. The cap/submit/wait splits
// are only real when the frame was measured at capture time — a frame captured
// before this capture armed carries zeroed splits, so skip those (an empty
// window → `percentile()` returns 0) rather than pull the percentiles down.
encode_us.push(msg.encode_us);
pace_us.push(stat.spread_us);
if msg.was_measured {
cap_v.push(msg.cap_us);
submit_v.push(msg.submit_us);
wait_v.push(msg.wait_us);
// Queue age is only meaningful for fresh frames (repeats/tail carry 0
// by construction — including those would drag the percentiles down).
if !msg.repeat {
queue_v.push(msg.queue_us);
// Host timing (0xCF): stamped now — the AU's packets have fully left the
// socket — against the same capture anchor the wire pts carries, so the
// client's per-frame math tiles exactly (network = its host+network this).
// Best-effort like every side-plane datagram; skipped for speed-test filler
// (FLAG_PROBE isn't video and its pts is the burst clock).
if let Some(tc) = &timing_conn {
if msg.flags & FLAG_PROBE as u32 == 0 {
let host_us = (now_ns().saturating_sub(msg.capture_ns) / 1000)
.min(u32::MAX as u64)
as u32;
let t = punktfunk_core::quic::HostTiming {
pts_ns: msg.capture_ns,
host_us,
// T0.1 stage split: queue + encode ride the FrameMsg (always
// measured), pace is this send's spread. The client derives
// seal/FEC + channel-wait as the residual against host_us.
stages: Some(punktfunk_core::quic::HostStages {
queue_us: msg.queue_us,
encode_us: msg.encode_us,
pace_us: stat.spread_us,
}),
};
let _ = tc.send_datagram(
punktfunk_core::quic::encode_host_timing_datagram(&t).into(),
);
}
}
if msg.repeat {
repeat_frames += 1;
} else {
new_frames += 1;
}
if stat.paced {
paced_frames += 1;
} else {
immediate_frames += 1;
if perf || stats.rec.is_armed() {
// `encode_us`/`pace_us`/fps are valid for every frame (always measured),
// including the Windows relay + tail-drain frames. The cap/submit/wait splits
// are only real when the frame was measured at capture time — a frame captured
// before this capture armed carries zeroed splits, so skip those (an empty
// window → `percentile()` returns 0) rather than pull the percentiles down.
encode_us.push(msg.encode_us);
pace_us.push(stat.spread_us);
if msg.was_measured {
cap_v.push(msg.cap_us);
submit_v.push(msg.submit_us);
wait_v.push(msg.wait_us);
// Queue age is only meaningful for fresh frames (repeats/tail carry 0
// by construction — including those would drag the percentiles down).
if !msg.repeat {
queue_v.push(msg.queue_us);
}
}
if msg.repeat {
repeat_frames += 1;
} else {
new_frames += 1;
}
if stat.paced {
paced_frames += 1;
} else {
immediate_frames += 1;
}
}
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "send failed — stopping stream");
break;
}
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "send failed — stopping stream");
break;
}
},
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, // encode thread done
}
@@ -798,6 +944,11 @@ pub(super) struct SessionContext {
/// stale — mid-session probes are DECLINED for it (a zeroed [`ProbeResult`]) rather than
/// consuming video frame indexes its gap detectors can't see (the phantom-gap freeze).
pub(super) probe_seq: bool,
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_STREAMED_AU`]: when the session's
/// encoder runs chunked poll (multi-slice sub-frame readback, §7 LN1), the host streams each
/// AU's FEC blocks under sentinel headers as the slices complete instead of waiting for the
/// whole AU. `false` = older client — chunks (if any) are drained whole-AU, zero wire change.
pub(super) streamed_au: bool,
/// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide
/// whether to measure the per-stage split; the send thread builds + pushes the aggregated
/// `StatsSample` at its 2 s boundary.
@@ -832,7 +983,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
// (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
let mut plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma, ctx.codec);
let mut plan = crate::session_plan::SessionPlan::resolve(
ctx.bit_depth,
ctx.chroma,
ctx.codec,
ctx.compositor != pf_vdisplay::Compositor::Gamescope,
);
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
if ctx.codec == crate::encode::Codec::PyroWave {
@@ -866,6 +1022,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
conn,
timing_conn,
probe_seq,
streamed_au,
stats,
client_label,
launch,
@@ -873,6 +1030,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
bringup,
resize_ms,
} = ctx;
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
let streamed_wire = streamed_au && std::env::var("PUNKTFUNK_STREAMED_AU").as_deref() != Ok("0");
if streamed_wire {
tracing::info!(
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
will stream per-slice"
);
}
tracing::info!(
compositor = compositor.id(),
?mode,
@@ -1032,7 +1199,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// encode of frame N+1 overlaps the paced transmit of frame N instead of waiting behind its tail.
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<FrameMsg>(3);
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
@@ -1470,6 +1637,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
frame.is_cuda(),
bit_depth,
plan.chroma,
plan.cursor_blend,
) {
Ok(mut new_enc) => {
tracing::info!(
@@ -1932,6 +2100,116 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// carry it to the shared stall recovery below instead of killing the session outright.
let mut poll_err: Option<anyhow::Error> = None;
while inflight.len() >= depth {
// Streamed chunked drain (§7 LN1 Phase 2): toward a STREAMED_AU client with the
// encoder's chunked poll live, forward each slice chunk to the send thread the
// moment it's readable, so packetize/FEC/pacing overlap the encode tail. Re-queried
// per AU (never cached): a pipelined-retrieve escalation or a session rebuild turns
// the mode off and the next AU falls back to the whole-AU path below.
if streamed_wire && enc.supports_chunked_poll() {
let t_wait = std::time::Instant::now();
let mut first_chunk_us = 0u32;
let mut au_flags = 0u32;
let mut au_done = false;
loop {
let c = match enc.poll_chunk() {
Ok(Some(c)) => c,
Ok(None) => break, // defensive: nothing in flight
Err(e) => {
poll_err = Some(e);
break;
}
};
// Every chunk proves the encoder is alive.
last_au_at = std::time::Instant::now();
encoder_resets = 0;
if c.first {
first_chunk_us = t_wait.elapsed().as_micros() as u32;
au_flags = if c.keyframe {
(FLAG_PIC | FLAG_SOF) as u32
} else {
FLAG_PIC as u32
};
let caps = enc.caps();
if caps.intra_refresh_recovery
&& caps.intra_refresh_period > 0
&& mark_recovery_boundary(
&mut ir_wave_pos,
c.keyframe,
caps.intra_refresh_period,
)
{
au_flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_POINT;
}
if c.recovery_anchor {
au_flags |= punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR;
}
if c.chunk_aligned {
au_flags |= punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED;
}
if let Some(m) = last_hdr_meta {
if c.keyframe || resend_meta {
let _ = conn.send_datagram(
punktfunk_core::quic::encode_hdr_meta_datagram(&m).into(),
);
resend_meta = false;
}
}
bringup.mark("first_au");
}
let last = c.last;
let (cap_ns, sub_ns, deadline) = *inflight.front().expect("inflight non-empty");
let wait_total_us = t_wait.elapsed().as_micros() as u32;
let encode_us = (now_ns().saturating_sub(sub_ns) / 1000) as u32;
let msg = ChunkMsg {
data: c.data,
first: c.first,
last,
capture_ns: cap_ns,
flags: au_flags,
frame_index: au_seq,
deadline,
encode_us,
queue_us,
cap_us,
submit_us,
wait_us: if measure { wait_total_us } else { 0 },
repeat,
was_measured: measure,
};
if frame_tx.send(SendMsg::Chunk(msg)).is_err() {
send_gone = true;
break;
}
if last {
inflight.pop_front();
au_seq = au_seq.wrapping_add(1);
sent += 1;
au_done = true;
if perf {
st_wait.push(wait_total_us);
// The overlap measurement the Phase-3 gate needs (sampled): how
// early the first slice reached the send thread vs. the whole
// encode — the win is roughly their difference per AU.
if sent % 120 == 0 {
tracing::info!(
first_slice_us = first_chunk_us,
encode_us,
"streamed AU (sampled): first slice handed to send at \
first_slice_us; encode finished at encode_us"
);
}
}
break;
}
}
if send_gone || poll_err.is_some() {
break;
}
if au_done {
continue; // drain the next in-flight frame, if depth allows
}
break; // defensive Ok(None): leave the frame in flight, re-poll next tick
}
let t_wait = std::time::Instant::now();
let polled = enc.poll();
let wait_us = if measure {
@@ -2012,7 +2290,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it
// exited (send failure / stop) — end the encode loop too.
bringup.mark("first_au"); // P0.1 (first-crossing only; free afterwards)
if frame_tx.send(msg).is_err() {
if frame_tx.send(SendMsg::Frame(msg)).is_err() {
send_gone = true;
break;
}
@@ -2157,7 +2435,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
repeat: false,
was_measured: false,
};
if frame_tx.send(msg).is_err() {
if frame_tx.send(SendMsg::Frame(msg)).is_err() {
break;
}
au_seq = au_seq.wrapping_add(1);
@@ -2279,6 +2557,7 @@ fn try_inplace_resize(
new_frame.is_cuda(),
bit_depth,
plan.chroma,
plan.cursor_blend,
) {
Ok(e) => e,
Err(e) => {
@@ -2347,7 +2626,12 @@ pub(super) fn prepare_display(
// Same plan resolution as `virtual_stream` (pure in these inputs + host config), including
// PyroWave's datagram-aligned wire mode — `Session::shard_payload()` echoes the negotiated
// Welcome value passed here.
let mut plan = crate::session_plan::SessionPlan::resolve(bit_depth, chroma, codec);
let mut plan = crate::session_plan::SessionPlan::resolve(
bit_depth,
chroma,
codec,
compositor != pf_vdisplay::Compositor::Gamescope,
);
if codec == crate::encode::Codec::PyroWave {
plan.wire_chunk = Some(shard_payload as usize);
}
@@ -2599,6 +2883,7 @@ fn build_pipeline(
frame.is_cuda(),
bit_depth,
plan.chroma,
plan.cursor_blend,
)
.context("open video encoder")?;
if let Some(t) = trace {
+156 -38
View File
@@ -116,7 +116,11 @@ fn forward_to_runner(args: &[String]) -> Result<()> {
/// Resolve how to invoke the runner CLI: the program plus any leading args (the bundled bun needs
/// the runner script path passed to it).
fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
///
/// Also the plugin store's executor seam ([`crate::store::jobs`]): a console-triggered install runs
/// the *same* package ops through the *same* runner as the CLI, so there is exactly one
/// implementation of "install a plugin" on the box (design D4).
pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
#[cfg(target_os = "windows")]
{
// The installer lays the payload out as {app}\punktfunk-host.exe, {app}\bun\bun.exe and
@@ -177,17 +181,163 @@ fn disable() -> Result<()> {
Ok(())
}
#[cfg(target_os = "linux")]
fn status() -> Result<()> {
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_else(|| "unknown".into());
let enabled = systemctl_output(&["is-enabled", UNIT]).unwrap_or_else(|| "unknown".into());
println!("runner: {UNIT}\nenabled: {enabled}\nactive: {active}");
if active != "active" {
let st = runtime_status();
println!(
"runner: {}\nstate: {}\nenabled: {}",
st.unit,
if !st.installed {
"not installed"
} else if st.running {
"running"
} else {
"stopped"
},
st.enabled
);
if let Some(principal) = &st.principal {
println!("runs as: {principal}");
}
if st.installed && !st.running {
println!("\nStart it with: punktfunk-host plugins enable");
} else if !st.installed {
println!("\n{}", st.detail);
}
Ok(())
}
// ---- runtime state, shared by the CLI and the plugin store's mgmt API --------------------------
/// Whether the plugin runner is present, switched on, and up.
///
/// The store's console surface needs this as data (to offer "enable the runner" before the first
/// install, and to explain why a freshly installed plugin isn't running yet), so it lives here
/// rather than being formatted straight to stdout like the CLI once did.
#[derive(Debug, Clone)]
pub(crate) struct RuntimeStatus {
/// Is the runner payload / service unit on this box at all?
pub installed: bool,
/// Is it configured to start (systemd `enabled`, or a non-`Disabled` scheduled task)?
pub enabled: bool,
/// Is it up right now?
pub running: bool,
/// The unit / task name, so operator-facing copy can name the thing to look at.
pub unit: &'static str,
/// Windows: the account the task runs as (the SYSTEM→LocalService migration is visible here).
pub principal: Option<String>,
/// One line of human-readable context, mostly for the "not installed" case.
pub detail: String,
}
#[cfg(target_os = "linux")]
pub(crate) fn runtime_status() -> RuntimeStatus {
let enabled_raw = systemctl_output(&["is-enabled", UNIT]);
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_default();
// `is-enabled` answers `not-found` when the unit file isn't installed at all; the runner
// payload being present is the other half of "can we install plugins".
let unit_known = enabled_raw.as_deref().is_some_and(|s| s != "not-found");
let installed = unit_known || runner_command().is_ok();
RuntimeStatus {
installed,
enabled: enabled_raw.as_deref() == Some("enabled"),
running: active == "active",
unit: UNIT,
principal: None,
detail: if installed {
String::new()
} else {
"the plugin runner package isn't installed (Debian/Ubuntu: `sudo apt install \
punktfunk-scripting`)"
.into()
},
}
}
#[cfg(target_os = "windows")]
pub(crate) fn runtime_status() -> RuntimeStatus {
let out = powershell_output(&format!(
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
if ($null -eq $t) {{ 'missing' }} else {{ \"$($t.State)|$($t.Principal.UserId)\" }}"
));
match out.as_deref().map(str::trim) {
Some("missing") | None => RuntimeStatus {
installed: false,
enabled: false,
running: false,
unit: TASK,
principal: None,
detail: "reinstall punktfunk with the scripting component to get the plugin runner"
.into(),
},
Some(raw) => {
let (state, principal) = raw.split_once('|').unwrap_or((raw, ""));
RuntimeStatus {
installed: true,
enabled: !state.eq_ignore_ascii_case("Disabled"),
running: state.eq_ignore_ascii_case("Running"),
unit: TASK,
principal: (!principal.is_empty()).then(|| principal.to_string()),
detail: String::new(),
}
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub(crate) fn runtime_status() -> RuntimeStatus {
RuntimeStatus {
installed: false,
enabled: false,
running: false,
unit: "punktfunk-scripting",
principal: None,
detail: "the plugin runner is only available on Linux and Windows hosts".into(),
}
}
/// Switch the runner on or off — the [`enable`]/[`disable`] the CLI runs, exposed for the store's
/// `POST /store/runtime`. On Windows this is reached from the SYSTEM service, which already clears
/// the elevation bar the CLI has to check for.
pub(crate) fn set_runtime_enabled(enabled: bool) -> Result<()> {
if enabled {
enable()
} else {
disable()
}
}
/// Restart the runner so it rediscovers installed units. Returns `false` (not an error) when it
/// isn't running — there is nothing to restart, and the store reports that as "installed, but the
/// runner is off" rather than as a failure.
///
/// Unit discovery happens once at runner startup ([`sdk/src/runner.ts`]), so this restart *is* the
/// activation step for a newly installed plugin.
pub(crate) fn restart_runtime() -> Result<bool> {
let st = runtime_status();
if !st.installed || !st.running {
return Ok(false);
}
#[cfg(target_os = "linux")]
{
run_systemctl(&["restart", UNIT])?;
Ok(true)
}
#[cfg(target_os = "windows")]
{
// Stop then start: `Restart-ScheduledTask` does not exist, and a Start on an already-
// running task is a no-op rather than a restart.
powershell(&format!(
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
))?;
Ok(true)
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
Ok(false)
}
}
#[cfg(target_os = "linux")]
fn run_systemctl(args: &[&str]) -> Result<()> {
let status = Command::new("systemctl")
@@ -451,33 +601,6 @@ fn icacls_path() -> String {
.unwrap_or_else(|_| "icacls".to_string())
}
#[cfg(target_os = "windows")]
fn status() -> Result<()> {
let out = powershell_output(&format!(
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
if ($null -eq $t) {{ 'missing' }} else {{ \
\"$($t.State)|$($t.Principal.UserId)\" }}"
));
match out.as_deref().map(str::trim) {
Some("missing") | None => {
println!(
"runner: {TASK}\nstate: not installed\n\nReinstall punktfunk with the \
scripting component to get the plugin runner."
);
}
Some(state) => {
// "State|Principal" — the principal line makes the SYSTEM→LocalService migration
// verifiable at a glance (`plugins enable` converges a legacy SYSTEM task).
let (state, principal) = state.split_once('|').unwrap_or((state, "?"));
println!("runner: {TASK}\nstate: {state}\nruns as: {principal}");
if state.eq_ignore_ascii_case("Disabled") {
println!("\nEnable it with: punktfunk-host plugins enable");
}
}
}
Ok(())
}
/// Resolve powershell by full System32 path rather than PATH — CreateProcess searches the launching
/// EXE's own directory first, so a planted `powershell.exe` beside the host binary would otherwise
/// run with our privileges (security-review 2026-07-17; matches service.rs / pf_vdisplay).
@@ -603,8 +726,3 @@ fn enable() -> Result<()> {
fn disable() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn status() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
@@ -103,6 +103,11 @@ pub struct SessionPlan {
/// PyroWave session — applied to EVERY encoder this plan opens (initial + all rebuilds) so
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
pub wire_chunk: Option<usize>,
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself).
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
/// blending path when this is set, so the pointer never silently vanishes from the stream.
pub cursor_blend: bool,
}
impl SessionPlan {
@@ -112,6 +117,7 @@ impl SessionPlan {
bit_depth: u8,
chroma: crate::encode::ChromaFormat,
codec: crate::encode::Codec,
cursor_blend: bool,
) -> Self {
SessionPlan {
capture: CaptureBackend::resolve(),
@@ -122,6 +128,7 @@ impl SessionPlan {
chroma,
codec,
wire_chunk: None,
cursor_blend,
}
}
+1
View File
@@ -147,6 +147,7 @@ pub fn run(opts: Options) -> Result<()> {
first.is_cuda(),
8, // spike synthetic harness: 8-bit
encode::ChromaFormat::Yuv420, // ...and 4:2:0
false, // synthetic frames carry no cursor
)
.context("open encoder")?;
+619
View File
@@ -0,0 +1,619 @@
//! The **plugin store**: discovering and installing plugins from signed catalogs
//! (design `plugin-store.md`).
//!
//! Installing a plugin means running somebody's code with operator privileges, so the store is
//! built around one idea: *the index is the verification gate*. A catalog entry pins one exact
//! version and that version's tarball hash, and nothing here can express "track the latest
//! release". Upstream can publish whatever they like; this host will keep offering the reviewed
//! version until a newly reviewed entry lands in a signed index.
//!
//! That yields three tiers, which the whole surface is organized around:
//!
//! | tier | where it came from | surfaced? |
//! |------|--------------------|-----------|
//! | **verified** | the built-in `unom` source's index — unom reviewed this exact tarball | yes, with a badge |
//! | **external** | an operator-added source's index — pinned and integrity-checked, curated by somebody else | yes, attributed, no badge |
//! | **unverified** | a raw package spec typed into the console's danger dialog | never listed; install only |
//!
//! This module is the domain half (catalog state, installed-package facts, trust decisions); the
//! HTTP surface lives in [`crate::mgmt::store`]. Everything here is **blocking** — callers on the
//! async side hand it to `spawn_blocking`.
pub(crate) mod catalog;
pub(crate) mod index;
pub(crate) mod jobs;
pub(crate) mod manifest;
pub(crate) mod sources;
use anyhow::{bail, Context, Result};
use index::{Advisory, Entry, Index};
use sources::Source;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
/// How long a fetched catalog is considered fresh. Catalogs change when somebody reviews a
/// release — hours, not seconds — and a streaming host should not be chatting to the internet on a
/// timer for a page nobody has open.
const CATALOG_TTL_SECS: u64 = 6 * 60 * 60;
/// Where plugin packages live: `<config_dir>/plugins` (the same dir the SDK and runner use).
pub(crate) fn plugins_dir() -> PathBuf {
pf_paths::config_dir().join("plugins")
}
// ---------------------------------------------------------------- installed packages
/// A plugin package present in the plugins dir.
#[derive(Debug, Clone)]
pub(crate) struct InstalledPkg {
pub pkg: String,
pub version: Option<String>,
}
/// The packages the operator actually asked for: the `dependencies` of the plugins dir's own
/// `package.json`, which `bun add` maintains. `None` only when there is no readable `package.json`
/// at all.
///
/// This is what separates a plugin from a plugin's *library*. `@punktfunk/plugin-kit` is the
/// framework every kit-built plugin depends on — it matches the `plugin-*` naming convention
/// exactly, lands in `node_modules` as a transitive dependency, and is emphatically not something
/// the operator installed or can meaningfully uninstall. The convention alone cannot tell the two
/// apart; the top-level dependency list can.
///
/// A `package.json` with **no** `dependencies` key returns an empty list, not `None`: `bun remove`
/// drops the key entirely when the last plugin goes, and orphaned transitive packages can outlive
/// it in `node_modules`. Falling back to the naming convention there resurrects a plugin's library
/// as an installed plugin the moment you uninstall the last real one (seen on-glass). If bun is
/// managing this tree at all, its answer is the answer — including when the answer is "nothing".
fn top_level_deps(dir: &Path) -> Option<Vec<String>> {
let bytes = std::fs::read(dir.join("package.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
Some(match v.get("dependencies").and_then(|d| d.as_object()) {
Some(deps) => deps.keys().cloned().collect(),
None => Vec::new(),
})
}
/// Enumerate installed plugin packages under `<dir>/node_modules`.
///
/// Mirrors the runner's own discovery so the store never claims something is installed that the
/// runner wouldn't supervise: the unscoped `punktfunk-plugin-*` convention, and **any** scope's
/// `plugin-*` (`@punktfunk/plugin-rom-manager`, `@retro-hub/plugin-x`). Scoped-any is what makes a
/// third-party catalog entry work at all — a scoped name is required for the registry mapping
/// (D8), so discovery must not be limited to the first-party scope.
///
/// Then narrowed to [`top_level_deps`] when a dependency list exists, so a plugin's *dependencies*
/// (notably `@punktfunk/plugin-kit`) aren't reported as installed plugins. A tree with no readable
/// `package.json` — hand-assembled, or an older layout — falls back to the convention alone rather
/// than reporting nothing.
pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
let modules = dir.join("node_modules");
let top_level = top_level_deps(dir);
let mut out = Vec::new();
let version_of = |pkg_dir: &Path| -> Option<String> {
let bytes = std::fs::read(pkg_dir.join("package.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v.get("version")?.as_str().map(str::to_string)
};
let Ok(entries) = std::fs::read_dir(&modules) else {
return out; // nothing installed yet
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
for name in names {
if name.starts_with("punktfunk-plugin-") {
let dir = modules.join(&name);
out.push(InstalledPkg {
version: version_of(&dir),
pkg: name,
});
} else if name.starts_with('@') {
let scope_dir = modules.join(&name);
let Ok(scoped) = std::fs::read_dir(&scope_dir) else {
continue;
};
let mut inner: Vec<String> = scoped
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
inner.sort();
for s in inner {
if s.starts_with("plugin-") {
let dir = scope_dir.join(&s);
out.push(InstalledPkg {
pkg: format!("{name}/{s}"),
version: version_of(&dir),
});
}
}
}
}
if let Some(top) = top_level {
out.retain(|p| top.iter().any(|d| d == &p.pkg));
}
out
}
/// Point a package scope at its registry in the plugins dir's `bunfig.toml`.
///
/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on
/// that: `runner_command()` resolves whatever scripting package is installed on the box, which can
/// predate the host binary (the packaged runner and the host ship separately). An older runner
/// treats an unknown flag's *value* as a package name and the install dies with
/// "unrecognised dependency format" — found on-glass. Writing the mapping here keeps a
/// catalog-driven install working against every runner that has ever shipped.
///
/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already
/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives.
pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> {
// The scope and URL both come from a signature-verified, field-validated index entry
// (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML.
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") {
bail!("refusing to map scope `{scope}` to `{url}`");
}
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join("bunfig.toml");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let wanted = format!("\"{scope}\" = \"{url}\"");
let is_mapping_for_scope = |line: &str| {
let t = line.trim_start();
t.starts_with(&format!("\"{scope}\"")) || t.starts_with(&format!("{scope} "))
};
if existing.lines().any(|l| l.trim() == wanted) {
return Ok(()); // already correct
}
let updated = if existing.lines().any(is_mapping_for_scope) {
existing
.lines()
.map(|l| {
if is_mapping_for_scope(l) {
wanted.clone()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n"
} else if let Some(pos) = existing
.lines()
.position(|l| l.trim() == "[install.scopes]")
{
let mut lines: Vec<String> = existing.lines().map(str::to_string).collect();
lines.insert(pos + 1, wanted);
lines.join("\n") + "\n"
} else if existing.trim().is_empty() {
format!("[install.scopes]\n{wanted}\n")
} else {
format!(
"{}{}\n[install.scopes]\n{wanted}\n",
existing,
if existing.ends_with('\n') { "" } else { "\n" }
)
};
std::fs::write(&path, updated).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
/// Is `pkg` a name the runner would supervise? Guards the uninstall route so a stray
/// `POST /store/uninstall {"pkg": "effect"}` can't rip a shared dependency out of the tree.
pub(crate) fn valid_installed_pkg(pkg: &str) -> Result<()> {
let plausible = pkg.starts_with("punktfunk-plugin-")
|| (index::valid_scoped_pkg(pkg)
&& pkg
.split_once('/')
.is_some_and(|(_, name)| name.starts_with("plugin-")));
if !plausible {
bail!("`{pkg}` is not a plugin package (`@scope/plugin-*` or `punktfunk-plugin-*`)");
}
Ok(())
}
/// The plugin id a package registers under (`@punktfunk/plugin-rom-manager` → `rom-manager`), used
/// to line an installed package up with the live lease registry. Best-effort: the authoritative id
/// for a catalogued plugin is its index entry.
pub(crate) fn plugin_id_for_pkg(pkg: &str) -> Option<String> {
let last = pkg.rsplit('/').next()?;
let id = last
.strip_prefix("punktfunk-plugin-")
.or_else(|| last.strip_prefix("plugin-"))?;
index::valid_plugin_id(id).then(|| id.to_string())
}
// ---------------------------------------------------------------- catalog state
/// What we hold for one source: the last good index plus why it might be stale.
#[derive(Clone)]
pub(crate) struct SourceState {
pub source: Source,
pub index: Option<Index>,
/// Unix seconds of the fetch that produced [`Self::index`].
pub fetched_at: Option<u64>,
/// True when the last refresh attempt failed and we're serving an older copy.
pub stale: bool,
/// Why the last attempt failed, for the console to show against the source.
pub error: Option<String>,
pub etag: Option<String>,
}
impl SourceState {
fn empty(source: Source) -> Self {
Self {
source,
index: None,
fetched_at: None,
stale: false,
error: None,
etag: None,
}
}
fn is_fresh(&self) -> bool {
self.index.is_some()
&& self
.fetched_at
.is_some_and(|t| catalog::unix_now().saturating_sub(t) < CATALOG_TTL_SECS)
}
}
fn state() -> &'static RwLock<Vec<SourceState>> {
static STATE: std::sync::OnceLock<RwLock<Vec<SourceState>>> = std::sync::OnceLock::new();
STATE.get_or_init(|| RwLock::new(Vec::new()))
}
/// Reconcile the in-memory state list with the configured sources (added/removed/edited), seeding
/// anything new from the on-disk cache so a cold host has a browsable store before its first fetch.
fn sync_sources() {
let configured = sources::load();
let dir = catalog::cache_dir();
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
st.retain(|s| configured.iter().any(|c| c.name == s.source.name));
for c in configured {
match st.iter_mut().find(|s| s.source.name == c.name) {
// The URL or key may have been edited under a name we already hold — drop what we
// cached for the old definition rather than attributing it to the new one.
Some(existing) => {
if existing.source.url != c.url || existing.source.public_key != c.public_key {
*existing = SourceState::empty(c);
} else {
existing.source = c;
}
}
None => {
let mut fresh = SourceState::empty(c.clone());
if let Some((index, meta)) = catalog::read_cache(&dir, &c.name) {
fresh.index = Some(index);
fresh.fetched_at = Some(meta.fetched_at);
fresh.etag = meta.etag;
fresh.stale = true; // from disk — unverified freshness until a fetch says otherwise
}
st.push(fresh);
}
}
}
}
/// Every source's catalog, refreshing those past their TTL (or all of them when `force`).
///
/// **Blocking** — network I/O. The freshness decision is ours (our fetch clock), never the
/// document's self-reported `generated` field.
pub(crate) fn catalogs(force: bool) -> Vec<SourceState> {
sync_sources();
let dir = catalog::cache_dir();
let todo: Vec<Source> = {
let st = state().read().unwrap_or_else(|e| e.into_inner());
st.iter()
.filter(|s| force || !s.is_fresh())
.map(|s| s.source.clone())
.collect()
};
for source in todo {
let etag = {
let st = state().read().unwrap_or_else(|e| e.into_inner());
st.iter()
.find(|s| s.source.name == source.name)
.and_then(|s| s.etag.clone())
};
let outcome = catalog::fetch(&source, etag.as_deref());
let now = catalog::unix_now();
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
let Some(slot) = st.iter_mut().find(|s| s.source.name == source.name) else {
continue; // removed while we were fetching
};
match outcome {
catalog::Fetched::Fresh { index, etag } => {
let count = index.plugins.len();
catalog::write_cache(
&dir,
&source.name,
&index,
&catalog::CacheMeta {
etag: etag.clone(),
fetched_at: now,
},
);
slot.index = Some(*index);
slot.fetched_at = Some(now);
slot.etag = etag;
slot.stale = false;
slot.error = None;
tracing::info!(source = %source.name, entries = count, "plugin catalog refreshed");
}
catalog::Fetched::NotModified => {
slot.fetched_at = Some(now);
slot.stale = false;
slot.error = None;
}
catalog::Fetched::Failed(why) => {
// Fail soft: keep the last good shelf, say plainly that it's stale.
tracing::warn!(source = %source.name, "plugin catalog refresh failed: {why}");
slot.stale = true;
slot.error = Some(why);
}
}
}
state()
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.cloned()
.collect()
}
/// The catalogs we already hold, without touching the network. For paths that must not block on a
/// remote host (an install resolving its own entry, an advisory lookup).
pub(crate) fn cached_catalogs() -> Vec<SourceState> {
sync_sources();
state()
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.cloned()
.collect()
}
/// Find a catalog entry by `(source, id)`, plus whether that source is the built-in one — which is
/// the single place "verified" is decided (D6).
pub(crate) fn find_entry(source_name: &str, id: &str) -> Option<(Entry, bool)> {
cached_catalogs().into_iter().find_map(|s| {
if s.source.name != source_name {
return None;
}
let entry = s.index?.plugins.into_iter().find(|e| e.id == id)?;
Some((entry, s.source.is_official()))
})
}
/// The first advisory covering `pkg@version`, across every source we hold. Revocations are
/// deliberately not scoped to the source a plugin came from: a package known-bad anywhere is
/// known-bad here.
pub(crate) fn advisory_for(pkg: &str, version: Option<&str>) -> Option<Advisory> {
let version = version?;
cached_catalogs().into_iter().find_map(|s| {
s.index?
.security
.into_iter()
.find(|a| a.matches(pkg, version))
})
}
/// Forget a removed source's cached shelf, so re-adding the name later can't serve stale rows.
pub(crate) fn drop_source_cache(name: &str) {
catalog::drop_cache(&catalog::cache_dir(), name);
let mut st = state().write().unwrap_or_else(|e| e.into_inner());
st.retain(|s| s.source.name != name);
}
#[cfg(test)]
mod tests {
use super::*;
fn touch_pkg(root: &Path, pkg: &str, version: &str) {
let dir = root.join("node_modules").join(pkg);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("package.json"),
format!(r#"{{"name":"{pkg}","version":"{version}"}}"#),
)
.unwrap();
}
#[test]
fn scans_both_conventions_and_any_scope() {
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.0");
// A third-party scoped plugin MUST be found: catalog entries are required to be scoped
// (D8), so limiting discovery to @punktfunk would make every external source unusable.
touch_pkg(dir.path(), "@retro-hub/plugin-x", "1.0.0");
touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0");
// Not plugins: a plain dependency and a scoped non-plugin.
touch_pkg(dir.path(), "effect", "4.0.0");
touch_pkg(dir.path(), "@punktfunk/host", "0.1.2");
let found = installed_packages(dir.path());
let names: Vec<&str> = found.iter().map(|p| p.pkg.as_str()).collect();
assert!(
names.contains(&"@punktfunk/plugin-rom-manager"),
"{names:?}"
);
assert!(names.contains(&"@retro-hub/plugin-x"), "{names:?}");
assert!(names.contains(&"punktfunk-plugin-legacy"), "{names:?}");
assert!(!names.contains(&"effect"), "{names:?}");
assert!(!names.contains(&"@punktfunk/host"), "{names:?}");
assert_eq!(
found
.iter()
.find(|p| p.pkg == "@punktfunk/plugin-rom-manager")
.unwrap()
.version
.as_deref(),
Some("0.3.0")
);
}
#[test]
fn scan_of_a_missing_dir_is_empty_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(installed_packages(dir.path()).is_empty());
}
/// A plugin's LIBRARY is not an installed plugin.
///
/// Regression from a live install: `@punktfunk/plugin-kit` is the framework every kit-built
/// plugin depends on. It matches the `plugin-*` convention exactly and lands in `node_modules`
/// transitively, so a convention-only scan reported the framework as an installed plugin the
/// operator could uninstall. The plugins dir's own `dependencies` is the authority.
#[test]
fn transitive_plugin_named_dependencies_are_not_installed_plugins() {
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1");
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // a dependency of the above
touch_pkg(dir.path(), "@punktfunk/host", "0.1.2");
std::fs::write(
dir.path().join("package.json"),
r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"^0.3.1"}}"#,
)
.unwrap();
let found = installed_packages(dir.path());
assert_eq!(
found.iter().map(|p| p.pkg.as_str()).collect::<Vec<_>>(),
vec!["@punktfunk/plugin-rom-manager"],
"only the top-level install counts"
);
}
#[test]
fn a_tree_with_no_package_json_falls_back_to_the_convention() {
// Hand-assembled or older layouts must still be discovered, not silently reported empty.
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0");
assert_eq!(installed_packages(dir.path()).len(), 1);
}
/// Uninstalling the last plugin must not resurrect its library as an installed plugin.
///
/// `bun remove` drops the `dependencies` key entirely once it empties, while orphaned
/// transitive packages can linger in `node_modules`. Treating "package.json exists but has no
/// dependencies" as "no authority, fall back to the naming convention" made
/// `@punktfunk/plugin-kit` pop back into the installed list right after the operator removed
/// the only real plugin — seen on-glass.
#[test]
fn an_emptied_dependency_list_means_nothing_is_installed() {
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // orphan left behind
std::fs::write(dir.path().join("package.json"), r#"{"name":"plugins"}"#).unwrap();
assert!(installed_packages(dir.path()).is_empty());
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"plugins","dependencies":{}}"#,
)
.unwrap();
assert!(installed_packages(dir.path()).is_empty());
}
#[test]
fn uninstall_target_must_be_a_plugin_package() {
assert!(valid_installed_pkg("@punktfunk/plugin-rom-manager").is_ok());
assert!(valid_installed_pkg("@retro-hub/plugin-x").is_ok());
assert!(valid_installed_pkg("punktfunk-plugin-legacy").is_ok());
// A shared dependency is not removable through the store.
assert!(valid_installed_pkg("effect").is_err());
assert!(valid_installed_pkg("@punktfunk/host").is_err());
assert!(valid_installed_pkg("../../etc").is_err());
assert!(valid_installed_pkg("").is_err());
}
#[test]
fn bunfig_scope_mapping_is_idempotent_and_preserves_other_scopes() {
let dir = tempfile::tempdir().unwrap();
let read = || std::fs::read_to_string(dir.path().join("bunfig.toml")).unwrap();
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
assert!(read().contains("[install.scopes]"));
assert!(read().contains("\"@retro-hub\" = \"https://retro.example/npm/\""));
// Idempotent — no duplicate line.
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
assert_eq!(read().matches("@retro-hub").count(), 1);
// A second scope joins the same table.
ensure_bunfig_scope(
dir.path(),
"@punktfunk",
"https://git.unom.io/api/packages/unom/npm/",
)
.unwrap();
assert!(read().contains("@punktfunk"));
assert!(read().contains("@retro-hub"));
// A changed registry rewrites in place rather than duplicating.
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://new.example/npm/").unwrap();
assert_eq!(read().matches("@retro-hub").count(), 1);
assert!(read().contains("https://new.example/npm/"));
assert!(!read().contains("retro.example"));
assert!(read().contains("@punktfunk"), "unrelated scope survives");
}
#[test]
fn bunfig_scope_mapping_refuses_junk() {
let dir = tempfile::tempdir().unwrap();
// Only https, only a well-formed scope — both already guaranteed by index validation, held
// here as the second line of defence for the one place we format TOML by hand.
assert!(ensure_bunfig_scope(dir.path(), "@x", "http://insecure/").is_err());
assert!(ensure_bunfig_scope(dir.path(), "no-at-sign", "https://e/").is_err());
assert!(ensure_bunfig_scope(dir.path(), "@bad\"quote", "https://e/").is_err());
assert!(!dir.path().join("bunfig.toml").exists());
}
/// The name-shape guard is necessary but NOT sufficient — see `mgmt::store::uninstall_plugin`.
///
/// `@punktfunk/plugin-kit` is a plugin's *framework*, and it satisfies every syntactic rule
/// here. Windows on-glass accepted an uninstall of it. The real gate is membership in
/// [`installed_packages`], which excludes transitive dependencies; this test pins the fact that
/// the shape check alone lets it through, so nobody "simplifies" the handler back.
#[test]
fn name_shape_alone_does_not_protect_a_plugins_framework() {
assert!(
valid_installed_pkg("@punktfunk/plugin-kit").is_ok(),
"shape check passes plugin-kit — the handler must additionally require that the \
package is a top-level install"
);
let dir = tempfile::tempdir().unwrap();
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1");
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3");
std::fs::write(
dir.path().join("package.json"),
r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"0.3.1"}}"#,
)
.unwrap();
let installed = installed_packages(dir.path());
assert!(installed
.iter()
.any(|p| p.pkg == "@punktfunk/plugin-rom-manager"));
assert!(
!installed.iter().any(|p| p.pkg == "@punktfunk/plugin-kit"),
"the framework is not an installed plugin, so uninstall must refuse it"
);
}
#[test]
fn plugin_id_derivation() {
assert_eq!(
plugin_id_for_pkg("@punktfunk/plugin-rom-manager").as_deref(),
Some("rom-manager")
);
assert_eq!(
plugin_id_for_pkg("punktfunk-plugin-playnite").as_deref(),
Some("playnite")
);
assert_eq!(plugin_id_for_pkg("@a/plugin-x").as_deref(), Some("x"));
assert_eq!(plugin_id_for_pkg("effect"), None);
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Catalog **fetch + cache**: pulling a source's signed index over HTTPS and keeping the last good
//! copy on disk (design `plugin-store.md` §4.1).
//!
//! Two properties matter more than freshness here:
//!
//! - **Fail closed on signatures, fail soft on the network.** A source with a pinned key whose
//! document doesn't verify is an *error* — we keep serving the last good copy and say so, and we
//! never fall back to "well, unsigned then". But a box that simply can't reach the internet
//! (LAN-only, common for a streaming host) keeps a working store: the cached shelf still browses,
//! and installs off it still pin and integrity-check, because the pin travelled with the entry.
//! - **Bounded everything.** Size cap, timeout, redirect cap, https-only, no credentials ever
//! attached. A source URL is operator config, not request-time input, so there is no lever here
//! for a browser to aim the host at an arbitrary address.
use super::index::{Index, MAX_INDEX_BYTES};
use super::sources::Source;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Wall-clock budget for one index (or signature) fetch.
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
/// What a refresh attempt produced.
pub(crate) enum Fetched {
/// A new, verified, parsed document.
Fresh {
index: Box<Index>,
etag: Option<String>,
},
/// The source says our cached copy is still current (HTTP 304).
NotModified,
/// Nothing usable arrived. The caller keeps whatever it had and marks the source stale.
Failed(String),
}
/// Fetch, verify and parse a source's index.
///
/// Blocking (`ureq`) — callers run this on a blocking thread, never on the async runtime.
pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
if !source.url.starts_with("https://") {
return Fetched::Failed("source url must be https".into());
}
let agent = ureq::AgentBuilder::new()
.timeout(FETCH_TIMEOUT)
// A signed document doesn't need many hops to reach us; a redirect chain is a good way to
// waste a host's time.
.redirects(3)
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
.build();
let mut req = agent.get(&source.url);
if let Some(tag) = etag {
req = req.set("If-None-Match", tag);
}
let resp = match req.call() {
// `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
// arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
// (the intuitive reading) means every refresh after the first one verifies a signature
// over zero bytes and "fails"; the catalog then sits permanently stale, serving cache and
// never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
Ok(r) if r.status() == 304 => return Fetched::NotModified,
Ok(r) => r,
Err(ureq::Error::Status(code, _)) => {
return Fetched::Failed(format!("index fetch returned HTTP {code}"))
}
Err(e) => return Fetched::Failed(format!("index fetch failed: {e}")),
};
let new_etag = resp.header("etag").map(str::to_string);
let body = match read_capped(resp) {
Ok(b) => b,
Err(e) => return Fetched::Failed(e),
};
// Signature FIRST — nothing below may look at a field before this passes (design §6.3).
let keys = source.keys();
if !keys.is_empty() {
let sig = match agent.get(&source.sig_url()).call() {
Ok(r) => match read_capped(r) {
Ok(b) => b,
Err(e) => return Fetched::Failed(format!("signature: {e}")),
},
Err(e) => {
return Fetched::Failed(format!(
"this source is signed but its signature could not be fetched: {e}"
))
}
};
let sig_text = match String::from_utf8(sig) {
Ok(s) => s,
Err(_) => return Fetched::Failed("signature file is not text".into()),
};
if let Err(e) = super::index::verify_signature(&body, &sig_text, &keys) {
return Fetched::Failed(format!("signature check failed: {e}"));
}
}
match Index::parse(&body) {
Ok(index) => Fetched::Fresh {
index: Box::new(index),
etag: new_etag,
},
Err(e) => Fetched::Failed(format!("{e:#}")),
}
}
/// Read a response body, refusing anything past the cap without buffering it.
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
resp.into_reader()
.take((MAX_INDEX_BYTES + 1) as u64)
.read_to_end(&mut buf)
.map_err(|e| format!("reading the response body failed: {e}"))?;
if buf.len() > MAX_INDEX_BYTES {
return Err(format!("response exceeds the {MAX_INDEX_BYTES}-byte cap"));
}
Ok(buf)
}
// ---------------------------------------------------------------- disk cache
/// `<config_dir>/store-cache` — the last good copy of every source's index, so a host that boots
/// without a network still has a browsable store.
pub(crate) fn cache_dir() -> PathBuf {
pf_paths::config_dir().join("store-cache")
}
fn body_path(dir: &Path, source: &str) -> PathBuf {
dir.join(format!("{source}.json"))
}
fn meta_path(dir: &Path, source: &str) -> PathBuf {
dir.join(format!("{source}.meta.json"))
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
pub(crate) struct CacheMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
/// Unix seconds of the fetch that produced the cached body.
#[serde(default)]
pub fetched_at: u64,
}
/// The cached index for a source, if one is on disk and still parses. Re-validated on read: a
/// cache file is just as untrusted as the wire (it lives in a directory an admin can write).
///
/// NOTE the signature is **not** re-checked here — it was checked when the bytes were accepted,
/// and the cache directory is inside the host's own private config tree.
pub(crate) fn read_cache(dir: &Path, source: &str) -> Option<(Index, CacheMeta)> {
let body = std::fs::read(body_path(dir, source)).ok()?;
let index = Index::parse(&body).ok()?;
let meta = std::fs::read(meta_path(dir, source))
.ok()
.and_then(|b| serde_json::from_slice::<CacheMeta>(&b).ok())
.unwrap_or_default();
Some((index, meta))
}
/// Persist a freshly verified index as the new last-good copy.
pub(crate) fn write_cache(dir: &Path, source: &str, index: &Index, meta: &CacheMeta) {
if let Err(e) = pf_paths::create_private_dir(dir) {
tracing::warn!("could not create the store cache dir: {e}");
return;
}
let write = |path: PathBuf, bytes: Vec<u8>| {
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, bytes).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
};
match serde_json::to_vec_pretty(index) {
Ok(b) => write(body_path(dir, source), b),
Err(e) => tracing::warn!("could not serialize the catalog cache: {e}"),
}
if let Ok(b) = serde_json::to_vec_pretty(meta) {
write(meta_path(dir, source), b);
}
}
/// Drop a removed source's cache files so a later re-add can't be served a stale shelf.
pub(crate) fn drop_cache(dir: &Path, source: &str) {
let _ = std::fs::remove_file(body_path(dir, source));
let _ = std::fs::remove_file(meta_path(dir, source));
}
pub(crate) fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_index() -> Index {
Index::parse(
br#"{"schema":1,"name":"t","plugins":[{"id":"a","pkg":"@p/plugin-a",
"registry":"https://r.example/","title":"A","version":"1.0.0",
"integrity":"sha512-AAAA"}]}"#,
)
.unwrap()
}
#[test]
fn cache_round_trips() {
let dir = tempfile::tempdir().unwrap();
let idx = sample_index();
let meta = CacheMeta {
etag: Some("\"abc\"".into()),
fetched_at: 1_700_000_000,
};
write_cache(dir.path(), "unom", &idx, &meta);
let (back, back_meta) = read_cache(dir.path(), "unom").expect("cache should be readable");
assert_eq!(back.plugins.len(), 1);
assert_eq!(back.plugins[0].id, "a");
assert_eq!(back_meta.etag.as_deref(), Some("\"abc\""));
assert_eq!(back_meta.fetched_at, 1_700_000_000);
}
#[test]
fn missing_or_corrupt_cache_reads_as_none() {
let dir = tempfile::tempdir().unwrap();
assert!(read_cache(dir.path(), "nope").is_none());
std::fs::write(dir.path().join("bad.json"), b"{ not json").unwrap();
assert!(read_cache(dir.path(), "bad").is_none());
}
#[test]
fn cache_is_revalidated_on_read() {
// A tampered cache file (schema bumped by hand) must not be served.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("x.json"), br#"{"schema":99,"plugins":[]}"#).unwrap();
assert!(read_cache(dir.path(), "x").is_none());
}
#[test]
fn drop_cache_removes_both_files() {
let dir = tempfile::tempdir().unwrap();
write_cache(dir.path(), "s", &sample_index(), &CacheMeta::default());
assert!(read_cache(dir.path(), "s").is_some());
drop_cache(dir.path(), "s");
assert!(read_cache(dir.path(), "s").is_none());
assert!(!meta_path(dir.path(), "s").exists());
}
/// Pins the HTTP-client assumption that [`fetch`]'s conditional-request handling rests on.
///
/// `ureq` converts only status >= 400 into `Err(Status)`. A 304 — which is exactly what a
/// successful `If-None-Match` produces, i.e. the *steady state* of a healthy catalog — comes
/// back as `Ok` with an empty body. Treating it as an error arm compiles, looks right, and
/// silently breaks every refresh after the first: the empty body fails the signature check and
/// the source sits stale forever. If a `ureq` upgrade ever changes this, fail here rather than
/// on someone's host.
#[test]
fn ureq_returns_304_as_ok() {
use std::io::Write as _;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
let _ = sock.flush();
}
});
let resp = ureq::get(&format!("http://{addr}/index.json"))
.set("If-None-Match", "\"x\"")
.call();
let _ = server.join();
match resp {
Ok(r) => assert_eq!(r.status(), 304, "304 must arrive as Ok, and be checked for"),
Err(e) => panic!("ureq now reports 304 as an error ({e}) — fetch() must be updated"),
}
}
#[test]
fn non_https_source_never_reaches_the_network() {
let s = Source {
name: "x".into(),
url: "http://example.org/i.json".into(),
public_key: None,
};
assert!(matches!(fetch(&s, None), Fetched::Failed(_)));
}
}
+620
View File
@@ -0,0 +1,620 @@
//! The plugin-store **index**: the catalog document a source serves, and the ed25519 signature
//! that makes it trustworthy (design `plugin-store.md` §3.2).
//!
//! The index is the verification gate. Each entry pins **one exact version plus that version's
//! tarball integrity hash** — so "verified on every release" is a property of the data, not a
//! promise about process: upstream can publish a newer version, but nothing in this host will
//! offer it until a re-reviewed entry lands in a signed index. There is deliberately no way to
//! express "track latest" for a catalogued plugin.
//!
//! Two rules keep parsing safe:
//! 1. **Signature before parse.** [`verify_signature`] runs over the exact bytes; only then does
//! anything here look at a field. A source with a pinned key whose signature fails is an
//! error, never a fallback to "unsigned".
//! 2. **Validate every field, drop what fails.** A malformed *entry* is dropped with a warning
//! (one bad row shouldn't blind the operator to the rest of the shelf); a malformed
//! *document* — unknown schema, non-JSON, oversized — is rejected whole.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
/// The only index schema this host understands. A future breaking change bumps this and old
/// hosts report the source as unreadable rather than guessing at unknown semantics.
pub(crate) const SCHEMA: u32 = 1;
/// Hard cap on a fetched index body. Generous for a text catalog (the seed index is ~2 KB) and
/// small enough that a hostile or broken source can't exhaust memory.
pub(crate) const MAX_INDEX_BYTES: usize = 5 * 1024 * 1024;
/// Caps on how much of a (validly signed) index we'll keep. A curated shelf is small; these exist
/// so a compromised signing key can't turn the console into an unbounded list.
const MAX_PLUGINS: usize = 500;
const MAX_ADVISORIES: usize = 200;
// ---------------------------------------------------------------- the document
/// A source's catalog document, as served (and signed).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct Index {
/// Document schema — must equal [`SCHEMA`].
pub schema: u32,
/// Human-readable name of the catalog ("unom official"). Display only.
#[serde(default)]
pub name: String,
/// RFC-3339 build timestamp. Display only — freshness is decided by our own fetch clock,
/// never by a field the source controls.
#[serde(default)]
pub generated: String,
/// The shelf.
#[serde(default)]
pub plugins: Vec<Entry>,
/// Revocations/advisories, checked against **installed** packages as well as catalog rows.
#[serde(default)]
pub security: Vec<Advisory>,
}
/// One catalogued plugin: exactly one installable version, pinned by integrity hash.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Entry {
/// The plugin's `definePlugin` id (`[a-z][a-z0-9-]*`) — also how a running plugin appears in
/// the lease registry, so the console can tell "catalogued" from "running".
pub id: String,
/// npm package name. **Must be scoped** (`@scope/name`): the scope is what maps the package
/// to [`Entry::registry`] in bun's `bunfig.toml`, so an unscoped name would be ambiguous
/// about where it installs from (design D8).
pub pkg: String,
/// The npm registry this package installs from. `https://` only.
pub registry: String,
pub title: String,
#[serde(default)]
pub description: String,
/// Lucide icon name for the console card (`[a-z0-9-]{1,48}`), matched against the console's
/// curated set; anything unknown falls back to a generic puzzle piece.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default)]
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
/// **The** installable version — exact, never a range. One version per entry: the index is a
/// curated shelf, not a version archive (older releases live in the index repo's git history).
pub version: String,
/// npm integrity of that version's tarball (`sha512-<base64>`). Checked against the registry's
/// own advertised integrity before any install runs — a republish under the same version with
/// different content cannot pass.
pub integrity: String,
/// Present when a human reviewed this exact tarball. Only meaningful in the built-in source's
/// index; a third-party source can write it, which is why it never grants the "Verified" badge
/// (design D6).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<Verification>,
/// Minimum host version this plugin supports (semver). Incompatible rows still list, greyed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_host: Option<String>,
/// Host platforms this plugin works on (`linux`/`windows`/`macos`). Empty ⇒ all.
#[serde(default)]
pub platforms: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Verification {
/// ISO date the review landed. Display only.
pub reviewed_at: String,
}
/// A revocation: "this package at these versions is known-bad". Checked against what's *installed*,
/// not just what's listed — the whole point is to reach plugins already on the box.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct Advisory {
pub pkg: String,
/// A semver requirement (`<0.3.2`, `>=1.0.0, <1.2.0`, `*`). Unparseable ⇒ the advisory is
/// dropped rather than applied to everything.
pub versions: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
// ---------------------------------------------------------------- signature
/// An ed25519 public key pinned by a source record, spelled `ed25519:<base64 of the 32 raw bytes>`.
#[derive(Debug, Clone)]
pub(crate) struct PublicKey(Vec<u8>);
impl PublicKey {
pub(crate) fn parse(s: &str) -> Result<Self> {
use base64::Engine as _;
let b64 = s
.strip_prefix("ed25519:")
.context("public key must be spelled `ed25519:<base64>`")?;
let raw = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.context("public key is not valid base64")?;
if raw.len() != 32 {
bail!("ed25519 public key must be 32 bytes, got {}", raw.len());
}
Ok(Self(raw))
}
}
/// Verify a detached ed25519 signature over the **exact** index bytes against any of the pinned
/// keys (two slots, so a key rotation is "sign with the new one, ship a host that trusts both,
/// retire the old" rather than a flag day).
///
/// `sig_text` is the `.sig` file's contents: base64, whitespace-tolerant.
pub(crate) fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Result<()> {
use base64::Engine as _;
if keys.is_empty() {
bail!("no public key pinned for this source");
}
let sig = base64::engine::general_purpose::STANDARD
.decode(sig_text.trim())
.context("signature file is not valid base64")?;
for key in keys {
let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0);
if pk.verify(bytes, &sig).is_ok() {
return Ok(());
}
}
bail!("index signature does not verify against any pinned key")
}
// ---------------------------------------------------------------- parse + validate
impl Index {
/// Parse and validate an index document. Rejects the whole document on a structural problem;
/// drops individual entries/advisories that fail validation (with a warning) so one bad row
/// can't hide the rest of the catalog.
pub(crate) fn parse(bytes: &[u8]) -> Result<Index> {
if bytes.len() > MAX_INDEX_BYTES {
bail!("index is larger than the {MAX_INDEX_BYTES}-byte cap");
}
let mut idx: Index = serde_json::from_slice(bytes).context("index is not valid JSON")?;
if idx.schema != SCHEMA {
bail!(
"unsupported index schema {} (this host understands {SCHEMA})",
idx.schema
);
}
idx.name = sanitize(&idx.name, 64);
idx.generated = sanitize(&idx.generated, 40);
let mut seen: Vec<String> = Vec::new();
idx.plugins.truncate(MAX_PLUGINS);
idx.plugins.retain_mut(|e| match e.validate() {
Err(why) => {
tracing::warn!(pkg = %e.pkg, "dropping catalog entry: {why}");
false
}
Ok(()) => {
// Duplicate ids would make "install this one" ambiguous; first wins.
if seen.iter().any(|s| s == &e.id) {
tracing::warn!(id = %e.id, "dropping duplicate catalog entry");
false
} else {
seen.push(e.id.clone());
true
}
}
});
idx.security.truncate(MAX_ADVISORIES);
idx.security.retain_mut(|a| match a.validate() {
Err(why) => {
tracing::warn!(pkg = %a.pkg, "dropping advisory: {why}");
false
}
Ok(()) => true,
});
Ok(idx)
}
}
impl Entry {
fn validate(&mut self) -> Result<()> {
if !valid_plugin_id(&self.id) {
bail!("id must be kebab-case `[a-z][a-z0-9-]*`, ≤64");
}
if !valid_scoped_pkg(&self.pkg) {
bail!("pkg must be a scoped npm name (`@scope/name`)");
}
if !is_https(&self.registry) {
bail!("registry must be an https:// URL");
}
self.title = sanitize(&self.title, 64);
if self.title.is_empty() {
bail!("title must not be empty");
}
self.description = sanitize(&self.description, 280);
self.author = sanitize(&self.author, 64);
self.version = sanitize(&self.version, 32);
if semver::Version::parse(&self.version).is_err() {
bail!("version must be exact semver (`1.2.3`), not a range");
}
if !valid_integrity(&self.integrity) {
bail!("integrity must look like `sha512-<base64>`");
}
if let Some(icon) = &self.icon {
if !valid_icon(icon) {
self.icon = None; // cosmetic — drop it rather than the whole entry
}
}
if let Some(h) = &self.homepage {
if !is_https(h) || h.len() > 200 {
self.homepage = None;
}
}
if let Some(l) = &self.license {
self.license = Some(sanitize(l, 64)).filter(|s| !s.is_empty());
}
if let Some(v) = &self.verification {
if v.reviewed_at.len() > 32 {
self.verification = None;
}
}
if let Some(m) = &self.min_host {
if semver::Version::parse(m).is_err() {
self.min_host = None; // unusable constraint ⇒ no constraint
}
}
self.platforms
.retain(|p| matches!(p.as_str(), "linux" | "windows" | "macos"));
self.platforms.truncate(4);
Ok(())
}
/// Is this entry installable on the running host? Returns the operator-facing reason when not.
pub(crate) fn incompatible_reason(&self) -> Option<String> {
if !self.platforms.is_empty() && !self.platforms.iter().any(|p| p == HOST_PLATFORM) {
return Some(format!("requires {}", self.platforms.join(" or ")));
}
if let Some(min) = &self.min_host {
let (Ok(min), Ok(host)) = (
semver::Version::parse(min),
semver::Version::parse(host_version()),
) else {
return None;
};
if host < min {
return Some(format!("needs punktfunk {min} or newer"));
}
}
None
}
}
impl Advisory {
fn validate(&mut self) -> Result<()> {
if self.pkg.trim().is_empty() || self.pkg.len() > 214 {
bail!("advisory pkg is empty or too long");
}
semver::VersionReq::parse(&self.versions)
.context("advisory `versions` is not a semver requirement")?;
self.reason = sanitize(&self.reason, 280);
if let Some(u) = &self.url {
if !is_https(u) || u.len() > 200 {
self.url = None;
}
}
Ok(())
}
/// Does this advisory cover `pkg@version`?
pub(crate) fn matches(&self, pkg: &str, version: &str) -> bool {
if self.pkg != pkg {
return false;
}
let (Ok(req), Ok(v)) = (
semver::VersionReq::parse(&self.versions),
semver::Version::parse(version),
) else {
return false;
};
req.matches(&v)
}
}
// ---------------------------------------------------------------- small helpers
/// This host's platform token, as used in [`Entry::platforms`].
pub(crate) const HOST_PLATFORM: &str = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
};
/// The running host's version — the left-hand side of every `minHost` comparison.
pub(crate) fn host_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Strip control characters (a catalog string ends up in a log line and in the console UI) and
/// clamp to `max` characters.
fn sanitize(s: &str, max: usize) -> String {
s.chars()
.filter(|c| !c.is_control())
.take(max)
.collect::<String>()
.trim()
.to_string()
}
pub(crate) fn valid_plugin_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.as_bytes()[0].is_ascii_lowercase()
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// `@scope/name` with npm's safe alphabet. Deliberately stricter than npm: no URL-ish characters
/// can survive into a `bun add` argument or a `bunfig.toml` scope key.
pub(crate) fn valid_scoped_pkg(pkg: &str) -> bool {
let Some(rest) = pkg.strip_prefix('@') else {
return false;
};
if pkg.len() > 214 {
return false;
}
let Some((scope, name)) = rest.split_once('/') else {
return false;
};
let ok = |s: &str| {
!s.is_empty()
&& s.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')
})
};
ok(scope) && ok(name)
}
/// The `@scope` half of a scoped package name — the key `bunfig.toml` maps to a registry.
pub(crate) fn scope_of(pkg: &str) -> Option<String> {
let rest = pkg.strip_prefix('@')?;
let (scope, _) = rest.split_once('/')?;
Some(format!("@{scope}"))
}
fn valid_icon(icon: &str) -> bool {
(1..=48).contains(&icon.len())
&& icon
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// npm's Subresource-Integrity spelling: `<alg>-<base64>`.
fn valid_integrity(s: &str) -> bool {
if s.len() > 200 {
return false;
}
let Some((alg, b64)) = s.split_once('-') else {
return false;
};
matches!(alg, "sha512" | "sha384" | "sha256" | "sha1")
&& !b64.is_empty()
&& b64
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
}
fn is_https(url: &str) -> bool {
url.starts_with("https://") && url.len() > "https://".len()
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(entry_json: &str) -> Vec<u8> {
format!(r#"{{"schema":1,"name":"t","plugins":[{entry_json}]}}"#).into_bytes()
}
const GOOD: &str = r#"{"id":"rom-manager","pkg":"@punktfunk/plugin-rom-manager",
"registry":"https://git.unom.io/api/packages/unom/npm/","title":"ROM Manager",
"description":"d","author":"unom","version":"0.2.1","integrity":"sha512-AAAA",
"verification":{"reviewedAt":"2026-07-19"},"platforms":["linux","windows"]}"#;
#[test]
fn parses_a_good_entry() {
let idx = Index::parse(&doc(GOOD)).unwrap();
assert_eq!(idx.plugins.len(), 1);
let e = &idx.plugins[0];
assert_eq!(e.pkg, "@punktfunk/plugin-rom-manager");
assert_eq!(e.version, "0.2.1");
assert!(e.verification.is_some());
}
#[test]
fn rejects_unknown_schema_and_bad_json() {
assert!(Index::parse(br#"{"schema":2,"plugins":[]}"#).is_err());
assert!(Index::parse(b"not json").is_err());
}
#[test]
fn drops_invalid_entries_but_keeps_the_rest() {
let bad_unscoped = GOOD.replace("@punktfunk/plugin-rom-manager", "punktfunk-plugin-x");
let body = format!(r#"{{"schema":1,"plugins":[{bad_unscoped},{GOOD}]}}"#);
let idx = Index::parse(body.as_bytes()).unwrap();
assert_eq!(idx.plugins.len(), 1, "unscoped pkg must be dropped (D8)");
assert_eq!(idx.plugins[0].id, "rom-manager");
}
#[test]
fn rejects_version_ranges_and_http_registries() {
assert!(Index::parse(&doc(
&GOOD.replace(r#""version":"0.2.1""#, r#""version":"^0.2.1""#)
))
.unwrap()
.plugins
.is_empty());
assert!(Index::parse(&doc(
&GOOD.replace("https://git.unom.io", "http://git.unom.io")
))
.unwrap()
.plugins
.is_empty());
}
#[test]
fn drops_duplicate_ids() {
let body = format!(r#"{{"schema":1,"plugins":[{GOOD},{GOOD}]}}"#);
assert_eq!(Index::parse(body.as_bytes()).unwrap().plugins.len(), 1);
}
#[test]
fn sanitizes_control_characters_in_display_fields() {
let e = GOOD.replace("ROM Manager", "RO\\u0007M");
let idx = Index::parse(&doc(&e)).unwrap();
assert_eq!(idx.plugins[0].title, "ROM");
}
#[test]
fn advisory_matching_is_semver_ranged() {
let mut a = Advisory {
pkg: "@x/y".into(),
versions: "<0.3.2".into(),
reason: "bad".into(),
url: None,
};
a.validate().unwrap();
assert!(a.matches("@x/y", "0.3.1"));
assert!(!a.matches("@x/y", "0.3.2"));
assert!(!a.matches("@other/z", "0.3.1"));
}
#[test]
fn advisory_with_unparseable_range_is_dropped() {
let body = r#"{"schema":1,"plugins":[],"security":[
{"pkg":"@x/y","versions":"not a range","reason":"r"}]}"#;
assert!(Index::parse(body.as_bytes()).unwrap().security.is_empty());
}
#[test]
fn scope_extraction() {
assert_eq!(scope_of("@punktfunk/plugin-x").unwrap(), "@punktfunk");
assert_eq!(scope_of("@a/b").unwrap(), "@a");
assert!(scope_of("punktfunk-plugin-x").is_none());
}
#[test]
fn integrity_shape() {
assert!(valid_integrity("sha512-abcABC123+/="));
assert!(!valid_integrity("md5-abc"));
assert!(!valid_integrity("sha512-"));
assert!(!valid_integrity("sha512"));
}
#[test]
fn signature_verifies_only_over_exact_bytes_and_pinned_keys() {
use ring::signature::KeyPair as _;
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let key = {
use base64::Engine as _;
PublicKey::parse(&format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
))
.unwrap()
};
let body = br#"{"schema":1,"plugins":[]}"#;
let sig = {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(kp.sign(body).as_ref())
};
assert!(verify_signature(body, &sig, std::slice::from_ref(&key)).is_ok());
// whitespace in the .sig file is tolerated
assert!(verify_signature(body, &format!("{sig}\n"), std::slice::from_ref(&key)).is_ok());
// one byte of tampering fails
assert!(verify_signature(
br#"{"schema":1,"plugins":[ ]}"#,
&sig,
std::slice::from_ref(&key)
)
.is_err());
// a different key fails
let other = ring::signature::Ed25519KeyPair::from_pkcs8(
ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
.unwrap()
.as_ref(),
)
.unwrap();
let other_key = {
use base64::Engine as _;
PublicKey::parse(&format!(
"ed25519:{}",
base64::engine::general_purpose::STANDARD.encode(other.public_key().as_ref())
))
.unwrap()
};
assert!(verify_signature(body, &sig, &[other_key]).is_err());
// no pinned key at all fails closed
assert!(verify_signature(body, &sig, &[]).is_err());
}
/// The real published index must parse here, field for field.
///
/// This fixture is a snapshot of `unom/punktfunk-plugin-index`'s `v1/index.json`. The index
/// repo's validator reimplements this module's rules in TypeScript (it has to — it gates PRs
/// before anything is signed), and two hand-written parsers of the same grammar drift. This
/// test is the seam that catches the drift from our side: if a document the publisher accepts
/// stops being one we accept, an entry silently disappears from every operator's store.
#[test]
fn the_published_seed_index_parses() {
let bytes = include_bytes!("testdata/seed-index.json");
let idx = Index::parse(bytes).expect("the published index must parse");
assert_eq!(idx.plugins.len(), 2, "no entry may be silently dropped");
let rom = idx
.plugins
.iter()
.find(|e| e.id == "rom-manager")
.expect("rom-manager entry");
assert_eq!(rom.pkg, "@punktfunk/plugin-rom-manager");
assert!(rom.integrity.starts_with("sha512-"));
assert!(
semver::Version::parse(&rom.version).is_ok(),
"exact version"
);
assert_eq!(
rom.verification.as_ref().map(|v| v.reviewed_at.as_str()),
Some("2026-07-20"),
"camelCase `reviewedAt` must decode"
);
assert_eq!(
rom.min_host.as_deref(),
Some("0.15.0"),
"camelCase `minHost`"
);
assert_eq!(scope_of(&rom.pkg).unwrap(), "@punktfunk");
// A windows-only entry is listed everywhere but only *installable* where it can run.
let playnite = idx
.plugins
.iter()
.find(|e| e.id == "playnite")
.expect("playnite entry");
assert_eq!(playnite.platforms, vec!["windows"]);
if HOST_PLATFORM == "windows" {
assert!(playnite.incompatible_reason().is_none());
} else {
assert!(playnite.incompatible_reason().is_some());
}
}
#[test]
fn public_key_parsing_rejects_junk() {
assert!(PublicKey::parse("nope").is_err());
assert!(PublicKey::parse("ed25519:!!!").is_err());
assert!(PublicKey::parse("ed25519:AAAA").is_err()); // wrong length
}
}
+692
View File
@@ -0,0 +1,692 @@
//! Install / uninstall **jobs** (design `plugin-store.md` §4.3).
//!
//! A package operation takes tens of seconds, so the API hands back a job id immediately (202) and
//! the console polls. One at a time: `bun add` and `bun remove` share a lockfile and a
//! `node_modules` tree, so a second concurrent op is a corruption bug waiting to happen — a
//! request that arrives while a job runs gets 409, not a queue.
//!
//! The pipeline, and why each step is there:
//!
//! ```text
//! resolve → what exactly are we installing (catalog entry ⇒ pkg@version+integrity, or a raw spec)
//! verify → the registry's advertised integrity for that version MUST equal the index pin
//! install → spawn the runner CLI (`bun add`), streaming its output into the job log
//! check → the version on disk is the version we pinned, else roll back
//! record → provenance manifest, so the tier stays visible forever
//! restart → the runner rediscovers units only at startup, so activation is a restart
//! ```
//!
//! **verify** is the step that makes "verified" mean something. A reviewed entry pins a tarball
//! hash; if the registry now advertises a different hash for that same version, someone republished
//! it after review and the install is refused before any code is fetched, let alone run. Tier-3
//! (raw spec) installs skip it — there is no pin to check against, and that asymmetry *is* the
//! tier.
use super::index::{scope_of, Entry};
use super::manifest::{self, Record, Tier};
use anyhow::{bail, Context, Result};
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// How long a package op may run before we kill it. `bun add` over a cold cache on a slow link is
/// the worst case; five minutes is far past it.
const JOB_TIMEOUT: Duration = Duration::from_secs(300);
/// How many finished jobs we keep for the console to read back.
const JOB_HISTORY: usize = 20;
/// Lines of runner output retained per job (the console shows a tail).
const LOG_LINES: usize = 200;
// ---------------------------------------------------------------- job records
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub(crate) enum State {
Running,
Done,
Failed,
}
/// A job as the console sees it. Field names are snake_case like the rest of the management API
/// (the *file* formats — index, sources, manifest — follow npm's camelCase instead).
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
pub(crate) struct Job {
pub id: String,
/// `install` or `uninstall`.
pub kind: String,
/// What the operator asked for — a package name, or the raw spec they typed.
pub target: String,
pub state: State,
/// Coarse step name, for a progress line the operator can read.
pub phase: String,
/// Tail of the runner's combined stdout/stderr.
pub log: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub started_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<u64>,
}
struct Jobs {
jobs: VecDeque<Job>,
counter: u64,
}
fn jobs() -> &'static Mutex<Jobs> {
static JOBS: std::sync::OnceLock<Mutex<Jobs>> = std::sync::OnceLock::new();
JOBS.get_or_init(|| {
Mutex::new(Jobs {
jobs: VecDeque::new(),
counter: 0,
})
})
}
fn lock() -> std::sync::MutexGuard<'static, Jobs> {
jobs().lock().unwrap_or_else(|e| e.into_inner())
}
/// Start tracking a job, refusing if one is already in flight (single-flight, §4.3).
fn begin(kind: &str, target: &str) -> Result<String> {
let mut g = lock();
if let Some(active) = g.jobs.iter().find(|j| j.state == State::Running) {
bail!(
"another plugin operation is already running ({} {})",
active.kind,
active.target
);
}
g.counter += 1;
let id = format!("job-{}-{}", super::catalog::unix_now(), g.counter);
let job = Job {
id: id.clone(),
kind: kind.to_string(),
target: target.to_string(),
state: State::Running,
phase: "queued".into(),
log: Vec::new(),
error: None,
started_at: super::catalog::unix_now(),
finished_at: None,
};
g.jobs.push_back(job);
while g.jobs.len() > JOB_HISTORY {
g.jobs.pop_front();
}
Ok(id)
}
fn update(id: &str, f: impl FnOnce(&mut Job)) {
let mut g = lock();
if let Some(job) = g.jobs.iter_mut().find(|j| j.id == id) {
f(job);
}
}
fn set_phase(id: &str, phase: &str) {
tracing::info!(job = %id, phase, "plugin store job");
update(id, |j| j.phase = phase.to_string());
}
fn log_line(id: &str, line: String) {
update(id, |j| {
if j.log.len() >= LOG_LINES {
j.log.remove(0);
}
j.log.push(line);
});
}
fn finish(id: &str, result: Result<()>) {
update(id, |j| {
j.finished_at = Some(super::catalog::unix_now());
match &result {
Ok(()) => {
j.state = State::Done;
j.phase = "done".into();
}
Err(e) => {
j.state = State::Failed;
j.error = Some(format!("{e:#}"));
}
}
});
match result {
Ok(()) => tracing::info!(job = %id, "plugin store job finished"),
Err(e) => tracing::warn!(job = %id, "plugin store job failed: {e:#}"),
}
}
/// One job by id.
pub(crate) fn get(id: &str) -> Option<Job> {
lock().jobs.iter().find(|j| j.id == id).cloned()
}
/// Recent jobs, newest last.
pub(crate) fn list() -> Vec<Job> {
lock().jobs.iter().cloned().collect()
}
/// Is a job in flight? (The console disables install buttons on this.)
pub(crate) fn busy() -> bool {
lock().jobs.iter().any(|j| j.state == State::Running)
}
// ---------------------------------------------------------------- install plans
/// A fully resolved install: everything the executor needs, with the trust decision already made.
pub(crate) struct Plan {
/// Package name without a version.
pub pkg: Option<String>,
/// The argument handed to `bun add` — `pkg@version` for a catalogued entry, the raw spec
/// otherwise.
pub spec: String,
pub version: Option<String>,
/// `(scope, registry_url)` to map in the plugins dir's `bunfig.toml`.
pub registry: Option<(String, String)>,
pub integrity: Option<String>,
pub tier: Tier,
pub source: Option<String>,
pub entry_id: Option<String>,
}
impl Plan {
/// From a catalog entry: exact version, pinned integrity, scope→registry mapping.
pub(crate) fn from_entry(entry: &Entry, source: &str, verified: bool) -> Result<Plan> {
let scope = scope_of(&entry.pkg).context("catalog entry package must be scoped")?;
Ok(Plan {
pkg: Some(entry.pkg.clone()),
spec: format!("{}@{}", entry.pkg, entry.version),
version: Some(entry.version.clone()),
registry: Some((scope, entry.registry.clone())),
integrity: Some(entry.integrity.clone()),
tier: if verified {
Tier::Verified
} else {
Tier::External
},
source: Some(source.to_string()),
entry_id: Some(entry.id.clone()),
})
}
/// From a raw package spec typed into the danger dialog. No pin, no review, no shelf.
pub(crate) fn from_spec(spec: &str) -> Result<Plan> {
let spec = validate_spec(spec)?;
Ok(Plan {
pkg: parse_spec_pkg(&spec),
spec,
version: None,
registry: None,
integrity: None,
tier: Tier::Unverified,
source: None,
entry_id: None,
})
}
}
/// Accept only specs we're willing to hand to `bun add`.
///
/// This is not shell quoting (we always exec with an argument vector, never a shell) — it is about
/// what the *package manager* will do with the string. Two real hazards: a leading `-` turns the
/// operand into a flag, and `file:` specs would let a console session install code from anywhere on
/// the host's filesystem, which is a different (and larger) capability than "install a package".
/// Local paths remain available through the CLI, which is where local development belongs.
fn validate_spec(spec: &str) -> Result<String> {
let s = spec.trim();
if s.is_empty() {
bail!("empty package spec");
}
if s.len() > 400 {
bail!("package spec is too long");
}
if s.starts_with('-') {
bail!("a package spec cannot start with '-'");
}
if s.chars().any(|c| c.is_whitespace() || c.is_control()) {
bail!("a package spec cannot contain whitespace or control characters");
}
let lower = s.to_ascii_lowercase();
for bad in ["file:", "link:", "portal:"] {
if lower.starts_with(bad) {
bail!("`{bad}` specs are not installable from the console — use the `punktfunk-host plugins add` CLI");
}
}
// URL-ish specs: only https and git+https. (http:// and git:// are unauthenticated transports.)
if lower.contains("://")
&& !(lower.starts_with("https://") || lower.starts_with("git+https://"))
{
bail!("only https:// and git+https:// URLs are accepted");
}
Ok(s.to_string())
}
/// Best-effort package name from an npm-style spec (`@scope/name`, `@scope/name@1.2.3`, `name@1`).
/// `None` for URL/git specs — those are identified after the fact by diffing `node_modules`.
fn parse_spec_pkg(spec: &str) -> Option<String> {
if spec.contains("://") {
return None;
}
if let Some(rest) = spec.strip_prefix('@') {
// Scoped: the version separator is the '@' AFTER the scope's slash.
let (scope_and_name, _) = match rest.split_once('/') {
Some((scope, tail)) => match tail.split_once('@') {
Some((name, ver)) => (format!("@{scope}/{name}"), Some(ver)),
None => (format!("@{scope}/{tail}"), None),
},
None => return None,
};
return Some(scope_and_name);
}
Some(
spec.split_once('@')
.map(|(n, _)| n)
.unwrap_or(spec)
.to_string(),
)
}
// ---------------------------------------------------------------- execution
/// Kick off an install. Returns the job id; the work runs on a detached thread.
pub(crate) fn spawn_install(plan: Plan) -> Result<String> {
let target = plan.pkg.clone().unwrap_or_else(|| plan.spec.clone());
let id = begin("install", &target)?;
let job_id = id.clone();
std::thread::Builder::new()
.name("pf-store-install".into())
.spawn(move || {
let result = run_install(&job_id, plan);
finish(&job_id, result);
crate::events::emit(crate::events::EventKind::StoreChanged);
})
.context("spawn the plugin-install worker")?;
Ok(id)
}
/// Kick off an uninstall.
pub(crate) fn spawn_uninstall(pkg: String) -> Result<String> {
if super::valid_installed_pkg(&pkg).is_err() {
bail!("not an installable plugin package name");
}
let id = begin("uninstall", &pkg)?;
let job_id = id.clone();
std::thread::Builder::new()
.name("pf-store-uninstall".into())
.spawn(move || {
let result = run_uninstall(&job_id, &pkg);
finish(&job_id, result);
crate::events::emit(crate::events::EventKind::StoreChanged);
})
.context("spawn the plugin-uninstall worker")?;
Ok(id)
}
fn run_install(id: &str, plan: Plan) -> Result<()> {
let dir = super::plugins_dir();
// ---- verify: the pin must still describe what the registry serves ------------------------
if let (Some(integrity), Some(pkg), Some(version), Some((_, registry))) = (
plan.integrity.as_deref(),
plan.pkg.as_deref(),
plan.version.as_deref(),
plan.registry.as_ref(),
) {
set_phase(id, "verifying");
let advertised = registry_integrity(registry, pkg, version)
.with_context(|| format!("check {pkg}@{version} against {registry}"))?;
if advertised != integrity {
bail!(
"integrity mismatch for {pkg}@{version}: the catalog pins {integrity} but the \
registry now serves {advertised}. This version was republished after it was \
reviewed refusing to install."
);
}
log_line(id, format!("integrity ok: {pkg}@{version}"));
}
// ---- install ------------------------------------------------------------------------------
set_phase(id, "installing");
let before = super::installed_packages(&dir);
// Map the entry's scope to its registry ourselves rather than through a runner flag: the
// installed scripting package can be older than this binary, and an older runner would read an
// unknown flag's value as a package name (see `ensure_bunfig_scope`).
if let Some((scope, url)) = &plan.registry {
super::ensure_bunfig_scope(&dir, scope, url)
.with_context(|| format!("map {scope} to {url}"))?;
}
let mut args = vec!["add".to_string(), plan.spec.clone()];
if plan.version.is_some() {
// Pin the dependency range too, so a later `bun install` in this tree can't drift off the
// reviewed version. Safely ignored by a runner too old to know it (an unknown `-`-prefixed
// flag is skipped, not misread) — the version we install is exact either way, so the worst
// case is a caret range recorded in package.json.
args.push("--exact".into());
}
if !plan.spec.starts_with("@punktfunk/") {
// The runner CLI's supply-chain gate refuses anything resolving off Punktfunk's own
// registry unless told otherwise. Here the operator already made that decision — either by
// adding the source, or in the danger dialog — so pass the flag rather than making the gate
// unable to express "yes, deliberately".
args.push("--allow-public-registry".into());
}
args.push("--plugins".into());
args.push(dir.to_string_lossy().into_owned());
run_runner(id, &args)?;
// ---- check: is what landed what we asked for? ---------------------------------------------
set_phase(id, "checking");
let after = super::installed_packages(&dir);
let added: Vec<_> = after
.iter()
.filter(|p| !before.iter().any(|b| b.pkg == p.pkg))
.collect();
// For a catalogued install we know the package name; for a URL/git spec we learn it here.
let pkg = plan
.pkg
.clone()
.or_else(|| added.first().map(|p| p.pkg.clone()))
.context(
"the install finished but no new plugin package appeared — is this package a \
punktfunk plugin? (it must be named `@scope/plugin-*` or `punktfunk-plugin-*`)",
)?;
let installed = after
.iter()
.find(|p| p.pkg == pkg)
.with_context(|| format!("{pkg} is not present after install"))?;
if let (Some(want), Some(got)) = (plan.version.as_deref(), installed.version.as_deref()) {
if want != got {
// Roll back rather than leave an unreviewed version installed under a verified badge.
log_line(id, format!("rolling back: expected {want}, found {got}"));
set_phase(id, "rolling back");
let _ = run_runner(
id,
&[
"remove".to_string(),
pkg.clone(),
"--plugins".to_string(),
dir.to_string_lossy().into_owned(),
],
);
bail!("installed {pkg}@{got} but the catalog pinned {want} — rolled back");
}
}
// ---- record + activate ---------------------------------------------------------------------
set_phase(id, "recording");
manifest::record(
&dir,
&pkg,
Record {
tier: plan.tier,
source: plan.source.clone(),
entry_id: plan.entry_id.clone(),
version: installed.version.clone().or(plan.version.clone()),
spec: (plan.tier == Tier::Unverified).then(|| plan.spec.clone()),
installed_at: Some(manifest::now_stamp()),
},
)
.context("record install provenance")?;
restart_runner(id);
Ok(())
}
fn run_uninstall(id: &str, pkg: &str) -> Result<()> {
let dir = super::plugins_dir();
set_phase(id, "removing");
run_runner(
id,
&[
"remove".to_string(),
pkg.to_string(),
"--plugins".to_string(),
dir.to_string_lossy().into_owned(),
],
)?;
set_phase(id, "recording");
manifest::forget(&dir, pkg).context("update install provenance")?;
restart_runner(id);
Ok(())
}
/// Restart the runner so it rediscovers units. Best-effort and non-fatal: the package IS installed
/// at this point, so a restart failure must not present as an install failure — it presents as
/// "installed, runner needs a nudge", which the console says out loud.
fn restart_runner(id: &str) {
set_phase(id, "restarting runner");
match crate::plugins::restart_runtime() {
Ok(true) => log_line(id, "plugin runner restarted".into()),
Ok(false) => log_line(
id,
"the plugin runner is not enabled — enable it to start this plugin".into(),
),
Err(e) => log_line(id, format!("could not restart the plugin runner: {e:#}")),
}
}
/// Run the bun runner CLI with `args`, streaming both streams into the job log.
fn run_runner(id: &str, args: &[String]) -> Result<()> {
let (program, prefix) = crate::plugins::runner_command()?;
tracing::info!(job = %id, program = %program.display(), ?args, "spawning the plugin runner");
let mut child = Command::new(&program)
.args(&prefix)
.args(args)
// No stdin: `bun add` must never sit waiting on a prompt inside a service.
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("run the plugin runner ({})", program.display()))?;
// Pump both streams concurrently: bun writes progress to one and errors to the other, and a
// full pipe on either would block the child.
let mut pumps = Vec::new();
if let Some(out) = child.stdout.take() {
let job = id.to_string();
pumps.push(std::thread::spawn(move || pump(&job, out)));
}
if let Some(err) = child.stderr.take() {
let job = id.to_string();
pumps.push(std::thread::spawn(move || pump(&job, err)));
}
let deadline = Instant::now() + JOB_TIMEOUT;
let status = loop {
match child.try_wait().context("wait for the plugin runner")? {
Some(status) => break status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
bail!(
"the plugin runner timed out after {}s",
JOB_TIMEOUT.as_secs()
);
}
None => std::thread::sleep(Duration::from_millis(150)),
}
};
for p in pumps {
let _ = p.join();
}
if !status.success() {
bail!(
"the plugin runner exited with status {} — see the job log",
status.code().unwrap_or(-1)
);
}
Ok(())
}
/// Copy one child stream into the job log, line by line.
fn pump(job: &str, stream: impl std::io::Read) {
for line in BufReader::new(stream).lines().map_while(Result::ok) {
let line = line.trim_end().to_string();
if !line.is_empty() {
log_line(job, line);
}
}
}
// ---------------------------------------------------------------- registry preflight
/// The integrity hash the registry itself advertises for `pkg@version`.
///
/// This is the check that gives the pin teeth. npm clients already refuse a tarball whose bytes
/// don't match the registry's advertised hash, so the remaining attack is a *republish* — same
/// version, new content, new hash. Comparing the registry's current hash against the reviewed one
/// catches exactly that, before anything is downloaded.
fn registry_integrity(registry: &str, pkg: &str, version: &str) -> Result<String> {
let base = registry.trim_end_matches('/');
// npm registry convention: the scope separator is percent-encoded in the packument path.
let url = format!("{base}/{}", pkg.replace('/', "%2f"));
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(20))
.redirects(3)
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
.build();
let resp = agent
.get(&url)
.set("Accept", "application/json")
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => {
anyhow::anyhow!("the registry does not know this package")
}
other => anyhow::anyhow!("registry request failed: {other}"),
})?;
let mut body = Vec::new();
resp.into_reader()
.take(16 * 1024 * 1024)
.read_to_end(&mut body)
.context("read the registry response")?;
let doc: serde_json::Value =
serde_json::from_slice(&body).context("registry returned invalid JSON")?;
let dist = doc
.get("versions")
.and_then(|v| v.get(version))
.and_then(|v| v.get("dist"))
.with_context(|| format!("the registry has no version {version} of this package"))?;
dist.get("integrity")
.and_then(|i| i.as_str())
.map(str::to_string)
.context("the registry did not advertise an integrity hash for this version")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_validation_refuses_the_dangerous_shapes() {
assert!(validate_spec("@scope/plugin-x").is_ok());
assert!(validate_spec("@scope/plugin-x@1.2.3").is_ok());
assert!(validate_spec("punktfunk-plugin-x").is_ok());
assert!(validate_spec("https://e.org/p.tgz").is_ok());
assert!(validate_spec("git+https://e.org/p.git").is_ok());
assert!(validate_spec("").is_err());
assert!(validate_spec(" ").is_err());
// a leading dash would be parsed by bun as a flag, not an operand
assert!(validate_spec("--production").is_err());
assert!(validate_spec("a b").is_err());
// local-path specs are CLI-only: installing from anywhere on the filesystem is a bigger
// capability than installing a package
assert!(validate_spec("file:/etc/passwd").is_err());
assert!(validate_spec("link:../evil").is_err());
// unauthenticated transports
assert!(validate_spec("http://e.org/p.tgz").is_err());
assert!(validate_spec("git://e.org/p.git").is_err());
}
#[test]
fn spec_package_name_parsing() {
assert_eq!(parse_spec_pkg("@a/b").as_deref(), Some("@a/b"));
assert_eq!(parse_spec_pkg("@a/b@1.2.3").as_deref(), Some("@a/b"));
assert_eq!(parse_spec_pkg("plain").as_deref(), Some("plain"));
assert_eq!(parse_spec_pkg("plain@2").as_deref(), Some("plain"));
// URL specs are identified by diffing node_modules after the install, not by parsing
assert_eq!(parse_spec_pkg("https://e.org/p.tgz"), None);
}
#[test]
fn plan_from_entry_pins_version_registry_and_integrity() {
let idx = super::super::index::Index::parse(
br#"{"schema":1,"plugins":[{"id":"rom-manager","pkg":"@punktfunk/plugin-rom-manager",
"registry":"https://git.unom.io/api/packages/unom/npm/","title":"ROM",
"version":"0.3.0","integrity":"sha512-AAAA"}]}"#,
)
.unwrap();
let plan = Plan::from_entry(&idx.plugins[0], "unom", true).unwrap();
assert_eq!(plan.spec, "@punktfunk/plugin-rom-manager@0.3.0");
assert_eq!(plan.version.as_deref(), Some("0.3.0"));
assert_eq!(plan.tier, Tier::Verified);
assert_eq!(
plan.registry.as_ref().unwrap().0,
"@punktfunk",
"the scope drives the bunfig registry mapping"
);
assert_eq!(plan.integrity.as_deref(), Some("sha512-AAAA"));
// The same entry from an operator-added source is External, never Verified (D6).
let ext = Plan::from_entry(&idx.plugins[0], "retro-hub", false).unwrap();
assert_eq!(ext.tier, Tier::External);
assert_eq!(ext.source.as_deref(), Some("retro-hub"));
}
#[test]
fn plan_from_spec_is_unverified_and_unpinned() {
let plan = Plan::from_spec(" @someone/punktfunk-plugin-x ").unwrap();
assert_eq!(plan.tier, Tier::Unverified);
assert_eq!(plan.spec, "@someone/punktfunk-plugin-x");
assert!(plan.version.is_none());
assert!(
plan.integrity.is_none(),
"nothing to check a raw spec against"
);
assert!(plan.registry.is_none());
}
/// `begin` is process-global and single-flight, so tests that create jobs must not overlap.
fn exclusive() -> std::sync::MutexGuard<'static, ()> {
static M: Mutex<()> = Mutex::new(());
M.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn single_flight_refuses_a_second_job() {
let _g = exclusive();
let first = begin("install", "@a/b").expect("first job starts");
assert!(begin("install", "@c/d").is_err(), "second must be refused");
assert!(busy());
finish(&first, Ok(()));
assert!(!busy());
let second = begin("install", "@c/d").expect("a finished job frees the slot");
finish(&second, Ok(()));
}
#[test]
fn job_log_is_bounded_and_tails() {
let _g = exclusive();
let id = begin("install", "@log/test").unwrap();
for i in 0..(LOG_LINES + 50) {
log_line(&id, format!("line {i}"));
}
let job = get(&id).unwrap();
assert_eq!(job.log.len(), LOG_LINES);
assert_eq!(job.log.last().unwrap(), &format!("line {}", LOG_LINES + 49));
finish(&id, Err(anyhow::anyhow!("boom")));
let job = get(&id).unwrap();
assert_eq!(job.state, State::Failed);
assert!(job.error.unwrap().contains("boom"));
assert!(job.finished_at.is_some());
}
}
+246
View File
@@ -0,0 +1,246 @@
//! The **provenance manifest**: how a plugin got onto this box, remembered for as long as it is
//! installed (design `plugin-store.md` §4.5).
//!
//! `node_modules` knows *what* is installed; it has no idea whether the operator picked a reviewed
//! entry off the official shelf or pasted a tarball URL into the danger dialog. That distinction is
//! exactly what the console has to keep showing — an unverified plugin must stay visibly
//! unverified in the Installed list and in the header above its own UI, long after the install
//! dialog is forgotten.
//!
//! `<plugins_dir>/install-manifest.json`, written by the host (which is the only thing that ever
//! performs a *store* install). **Absence is meaningful**: a package with no manifest entry was
//! installed by the CLI, and is reported as [`Tier::Cli`] rather than being silently promoted.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// How a plugin came to be installed. Ordered by how much review stands behind it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Tier {
/// From the built-in source's index: unom reviewed this exact tarball.
Verified,
/// From an operator-added source's index: pinned and integrity-checked, but curated by
/// somebody else. Attribution, never the badge (D6).
External,
/// Installed from a raw package spec through the danger dialog. Nobody reviewed it.
Unverified,
/// No manifest entry — `punktfunk-host plugins add` put it there.
Cli,
}
impl Tier {
pub(crate) fn as_str(self) -> &'static str {
match self {
Tier::Verified => "verified",
Tier::External => "external",
Tier::Unverified => "unverified",
Tier::Cli => "cli",
}
}
}
/// One remembered install.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Record {
pub tier: Tier,
/// The catalog source's slug, when the install came off a shelf.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
/// The catalog entry id, when there was one — lets the console re-associate an installed
/// package with its shelf row even if the package name is unusual.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<String>,
/// The version we installed (as pinned). The *live* version always comes from disk; this is
/// what we asked for.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// The raw spec, for [`Tier::Unverified`] installs — the only record of what the operator
/// actually typed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec: Option<String>,
/// RFC-3339 install time (wall clock — display only).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_at: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct ManifestFile {
schema: u32,
/// Keyed by npm package name. `BTreeMap` so the file has a stable, diffable order.
#[serde(default)]
plugins: BTreeMap<String, Record>,
}
impl Default for ManifestFile {
fn default() -> Self {
Self {
schema: 1,
plugins: BTreeMap::new(),
}
}
}
fn manifest_path(plugins_dir: &std::path::Path) -> std::path::PathBuf {
plugins_dir.join("install-manifest.json")
}
/// Read the manifest. A missing or corrupt file reads as empty — every package then reports as
/// CLI-installed, which is the honest answer when we have no provenance to show.
pub(crate) fn load(plugins_dir: &std::path::Path) -> BTreeMap<String, Record> {
let Ok(bytes) = std::fs::read(manifest_path(plugins_dir)) else {
return BTreeMap::new();
};
match serde_json::from_slice::<ManifestFile>(&bytes) {
Ok(f) if f.schema == 1 => f.plugins,
Ok(f) => {
tracing::warn!(
schema = f.schema,
"unknown install-manifest schema — ignoring"
);
BTreeMap::new()
}
Err(e) => {
tracing::warn!("install-manifest.json is unreadable ({e}) — treating as empty");
BTreeMap::new()
}
}
}
/// Record (or replace) one package's provenance.
pub(crate) fn record(plugins_dir: &std::path::Path, pkg: &str, rec: Record) -> Result<()> {
let mut plugins = load(plugins_dir);
plugins.insert(pkg.to_string(), rec);
write(plugins_dir, plugins)
}
/// Forget a package (on uninstall). Silent if it wasn't recorded.
pub(crate) fn forget(plugins_dir: &std::path::Path, pkg: &str) -> Result<()> {
let mut plugins = load(plugins_dir);
if plugins.remove(pkg).is_none() {
return Ok(());
}
write(plugins_dir, plugins)
}
fn write(plugins_dir: &std::path::Path, plugins: BTreeMap<String, Record>) -> Result<()> {
std::fs::create_dir_all(plugins_dir)
.with_context(|| format!("create {}", plugins_dir.display()))?;
let json = serde_json::to_string_pretty(&ManifestFile { schema: 1, plugins })
.context("serialize install-manifest.json")?;
let path = manifest_path(plugins_dir);
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, format!("{json}\n"))
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
/// Wall-clock RFC-3339-ish stamp for [`Record::installed_at`]. Display only — nothing compares
/// these, so a clock jump costs a cosmetic oddity, never a decision.
pub(crate) fn now_stamp() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Civil-date arithmetic from a Unix timestamp (days since epoch → y/m/d), so this needs no
// date crate. UTC, second resolution.
let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(tier: Tier) -> Record {
Record {
tier,
source: Some("unom".into()),
entry_id: Some("rom-manager".into()),
version: Some("0.2.1".into()),
spec: None,
installed_at: Some(now_stamp()),
}
}
#[test]
fn round_trips_and_absence_means_cli() {
let dir = tempfile::tempdir().unwrap();
assert!(load(dir.path()).is_empty(), "no file ⇒ nothing recorded");
record(
dir.path(),
"@punktfunk/plugin-rom-manager",
rec(Tier::Verified),
)
.unwrap();
let m = load(dir.path());
let r = m.get("@punktfunk/plugin-rom-manager").unwrap();
assert_eq!(r.tier, Tier::Verified);
assert_eq!(r.version.as_deref(), Some("0.2.1"));
// A package we never recorded has no entry — the caller reports it as CLI-installed.
assert!(!m.contains_key("punktfunk-plugin-other"));
}
#[test]
fn tier_survives_a_reinstall_at_a_new_tier() {
let dir = tempfile::tempdir().unwrap();
record(dir.path(), "@x/y", rec(Tier::Verified)).unwrap();
record(dir.path(), "@x/y", rec(Tier::Unverified)).unwrap();
assert_eq!(load(dir.path()).get("@x/y").unwrap().tier, Tier::Unverified);
}
#[test]
fn forget_removes_only_the_named_package() {
let dir = tempfile::tempdir().unwrap();
record(dir.path(), "@x/y", rec(Tier::Verified)).unwrap();
record(dir.path(), "@x/z", rec(Tier::External)).unwrap();
forget(dir.path(), "@x/y").unwrap();
let m = load(dir.path());
assert!(!m.contains_key("@x/y"));
assert!(m.contains_key("@x/z"));
forget(dir.path(), "@not/here").unwrap(); // no-op, no error
}
#[test]
fn corrupt_manifest_reads_as_empty_not_an_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("install-manifest.json"), b"{ not json").unwrap();
assert!(load(dir.path()).is_empty());
}
#[test]
fn tier_json_spelling_is_stable() {
// The console keys its badges off these strings.
assert_eq!(
serde_json::to_string(&Tier::Unverified).unwrap(),
"\"unverified\""
);
assert_eq!(Tier::Verified.as_str(), "verified");
}
#[test]
fn stamp_is_rfc3339_shaped() {
let s = now_stamp();
assert_eq!(s.len(), 20, "{s}");
assert!(s.ends_with('Z'));
assert_eq!(&s[4..5], "-");
assert_eq!(&s[10..11], "T");
// A known epoch value, to catch the civil-date arithmetic drifting.
assert!(s.as_str() > "2026-01-01T00:00:00Z", "{s}");
}
}
+296
View File
@@ -0,0 +1,296 @@
//! Catalog **sources** — where the store's shelves come from (design `plugin-store.md` §3.1).
//!
//! One source = one URL serving a signed index. The **official** source is compiled in (URL plus
//! two pinned key slots) and cannot be edited or removed; operator-added sources live in
//! `<config_dir>/plugin-sources.json`.
//!
//! Adding a source is itself a trust decision, made once: a source's entries become installable
//! on this host. That is why they carry attribution and warning styling in the console but never
//! the "Verified" badge — verification is unom reviewing a specific tarball, and it is not
//! transferable to a third-party curator (D6).
use super::index::PublicKey;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
/// The built-in source's slug. Reserved: an operator source may not take this name.
pub(crate) const OFFICIAL_NAME: &str = "unom";
/// The built-in source's index URL.
///
/// Served straight out of the index repo over Gitea's anonymous raw endpoint: real HTTPS with a
/// real certificate, no new vhost to stand up, and "merged to main" *is* "published". The document
/// is signed, so the transport is not what we're trusting — swapping this for a dedicated static
/// host later is a one-constant change with no protocol impact.
///
/// One consequence worth knowing: CI signs *after* the merge, so between a merge and the signature
/// commit the index is newer than its `.sig`. That window fails **closed** — the signature check
/// rejects the document and hosts keep serving their last good copy, marked stale.
pub(crate) const OFFICIAL_URL: &str =
"https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json";
/// Pinned signing keys for the built-in source. **Two slots** so a key rotation is "sign with the
/// new key, ship a host that trusts both, retire the old one" instead of a flag day where old
/// hosts lose the catalog. An empty slot is ignored.
pub(crate) const OFFICIAL_KEYS: [&str; 2] = [
"ed25519:V7KKMg8sq2A2TW7D/GFWaM0ruAvigpld9r93JdWcQHw=",
"", // rotation slot
];
/// How many operator sources we'll hold. A guard rail, not a design limit.
const MAX_SOURCES: usize = 32;
/// A catalog source as stored in `plugin-sources.json`. camelCase like the index document it
/// points at (the management API's own view of a source is snake_case — see `mgmt::store`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Source {
/// Slug (`[a-z][a-z0-9-]*`, ≤32) — the cache-file name and the console's attribution chip.
pub name: String,
/// `https://` URL of the index document. The `.sig` is fetched from `<url>.sig`.
pub url: String,
/// Pinned ed25519 key. A source without one is accepted but marked **unsigned**, and its
/// entries inherit that marker — we can still show you the shelf, we just can't promise the
/// shelf wasn't rewritten in transit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
}
impl Source {
/// The compiled-in official source.
pub(crate) fn official() -> Source {
Source {
name: OFFICIAL_NAME.to_string(),
url: OFFICIAL_URL.to_string(),
// Held in [`OFFICIAL_KEYS`], not here: the built-in keys are code, not config, so a
// config file can never downgrade the official source to unsigned.
public_key: None,
}
}
pub(crate) fn is_official(&self) -> bool {
self.name == OFFICIAL_NAME
}
/// The keys this source's index must verify against. Empty ⇒ unsigned source (accepted, but
/// flagged); for the official source this is always the compiled-in pair.
pub(crate) fn keys(&self) -> Vec<PublicKey> {
if self.is_official() {
return OFFICIAL_KEYS
.iter()
.filter(|k| !k.is_empty())
.filter_map(|k| PublicKey::parse(k).ok())
.collect();
}
self.public_key
.as_deref()
.and_then(|k| PublicKey::parse(k).ok())
.into_iter()
.collect()
}
/// Does this source's index carry a signature we check? Drives the console's "unsigned" marker.
pub(crate) fn is_signed(&self) -> bool {
!self.keys().is_empty()
}
/// Where the detached signature lives. Kept a pure function of the index URL so a source
/// record can never point the signature fetch somewhere else than the document it signs.
pub(crate) fn sig_url(&self) -> String {
format!("{}.sig", self.url)
}
fn validate(&self) -> Result<()> {
if !valid_source_name(&self.name) {
bail!("source name must be kebab-case `[a-z][a-z0-9-]*`, ≤32 characters");
}
if !self.url.starts_with("https://") || self.url.len() > 500 {
bail!("source url must be an https:// URL (≤500 characters)");
}
if let Some(k) = &self.public_key {
PublicKey::parse(k).context("source publicKey")?;
}
Ok(())
}
}
pub(crate) fn valid_source_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 32
&& name.as_bytes()[0].is_ascii_lowercase()
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
// ---------------------------------------------------------------- persistence
#[derive(Serialize, Deserialize, Default)]
struct SourcesFile {
#[serde(default = "one")]
schema: u32,
#[serde(default)]
sources: Vec<Source>,
}
fn one() -> u32 {
1
}
fn sources_path() -> std::path::PathBuf {
pf_paths::config_dir().join("plugin-sources.json")
}
/// Every source this host reads, official first. The official source is prepended here rather
/// than stored, so it survives a hand-edited (or deleted) config file.
pub(crate) fn load() -> Vec<Source> {
let mut out = vec![Source::official()];
let path = sources_path();
let Ok(bytes) = std::fs::read(&path) else {
return out; // no operator sources yet — the common case
};
match serde_json::from_slice::<SourcesFile>(&bytes) {
Ok(file) => {
for s in file.sources.into_iter().take(MAX_SOURCES) {
// Defense in depth: a hand-edited file can't smuggle in a source that shadows the
// official one or carries an unusable URL/key.
if s.is_official() || s.validate().is_err() {
tracing::warn!(name = %s.name, "ignoring invalid entry in plugin-sources.json");
continue;
}
if !out.iter().any(|e| e.name == s.name) {
out.push(s);
}
}
}
Err(e) => tracing::warn!(
"plugin-sources.json is unreadable ({e}) — using the official source only"
),
}
out
}
/// Add or replace an operator source. Refuses the reserved official name and invalid records.
pub(crate) fn put(source: Source) -> Result<()> {
source.validate()?;
if source.is_official() {
bail!("`{OFFICIAL_NAME}` is the built-in source and cannot be redefined");
}
let mut list: Vec<Source> = load().into_iter().filter(|s| !s.is_official()).collect();
if list.len() >= MAX_SOURCES && !list.iter().any(|s| s.name == source.name) {
bail!("too many plugin sources (max {MAX_SOURCES})");
}
list.retain(|s| s.name != source.name);
list.push(source);
save(list)
}
/// Remove an operator source. Returns `false` if there was nothing to remove.
pub(crate) fn remove(name: &str) -> Result<bool> {
if name == OFFICIAL_NAME {
bail!("the built-in `{OFFICIAL_NAME}` source cannot be removed");
}
let list: Vec<Source> = load().into_iter().filter(|s| !s.is_official()).collect();
if !list.iter().any(|s| s.name == name) {
return Ok(false); // nothing to remove — don't rewrite the file
}
save(list.into_iter().filter(|s| s.name != name).collect())?;
Ok(true)
}
fn save(list: Vec<Source>) -> Result<()> {
let dir = pf_paths::config_dir();
pf_paths::create_private_dir(&dir).context("create the punktfunk config dir")?;
let file = SourcesFile {
schema: 1,
sources: list,
};
let json = serde_json::to_string_pretty(&file).context("serialize plugin-sources.json")?;
let path = sources_path();
// Write-then-rename: a crash mid-write must not leave a half-file that makes the host forget
// the operator's sources on next boot.
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, format!("{json}\n"))
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn official_source_is_signed_by_compiled_in_keys() {
let s = Source::official();
assert!(s.is_official());
assert!(s.is_signed(), "the built-in source must carry a pinned key");
assert_eq!(s.sig_url(), format!("{OFFICIAL_URL}.sig"));
// A config file cannot downgrade it: `keys()` ignores the record's own field entirely.
let mut forged = Source::official();
forged.public_key = Some("ed25519:AAAA".into());
assert_eq!(forged.keys().len(), 1);
}
#[test]
fn compiled_in_keys_parse() {
for k in OFFICIAL_KEYS.iter().filter(|k| !k.is_empty()) {
PublicKey::parse(k).expect("compiled-in official key must parse");
}
}
#[test]
fn unsigned_third_party_source_is_flagged_not_rejected() {
let s = Source {
name: "retro-hub".into(),
url: "https://example.org/index.json".into(),
public_key: None,
};
s.validate().unwrap();
assert!(!s.is_signed());
}
#[test]
fn validation_rejects_bad_names_urls_and_keys() {
let base = Source {
name: "ok".into(),
url: "https://e.org/i.json".into(),
public_key: None,
};
assert!(base.validate().is_ok());
assert!(Source {
name: "Bad".into(),
..base.clone()
}
.validate()
.is_err());
assert!(Source {
name: "9bad".into(),
..base.clone()
}
.validate()
.is_err());
assert!(Source {
url: "http://e.org/i.json".into(),
..base.clone()
}
.validate()
.is_err());
assert!(Source {
public_key: Some("ed25519:nope".into()),
..base.clone()
}
.validate()
.is_err());
}
#[test]
fn sig_url_is_derived_not_configurable() {
let s = Source {
name: "x".into(),
url: "https://e.org/v1/index.json".into(),
public_key: None,
};
assert_eq!(s.sig_url(), "https://e.org/v1/index.json.sig");
}
}
@@ -0,0 +1,49 @@
{
"schema": 1,
"name": "unom official",
"generated": "2026-07-20T18:00:00Z",
"plugins": [
{
"id": "rom-manager",
"pkg": "@punktfunk/plugin-rom-manager",
"registry": "https://git.unom.io/api/packages/unom/npm/",
"title": "ROM Manager",
"description": "Scans your ROM directories, maps them to emulators, fetches box art, and reconciles everything into the host game library as a provider. Includes a console-hosted web UI for mapping and cleanup.",
"icon": "gamepad-2",
"author": "unom",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
"license": "MIT OR Apache-2.0",
"version": "0.3.1",
"integrity": "sha512-SGqMriqQPOQobXMYiT20w0rcTMNdYfZc8No3fPu57njMN+2eTVBVlQjyn1t3KoRFxfoRYGwuumN4X3aNmS0Tpw==",
"verification": {
"reviewedAt": "2026-07-20"
},
"minHost": "0.15.0",
"platforms": [
"linux",
"windows"
]
},
{
"id": "playnite",
"pkg": "@punktfunk/plugin-playnite",
"registry": "https://git.unom.io/api/packages/unom/npm/",
"title": "Playnite",
"description": "Bridges your Playnite library on Windows into the host game library, keeping installed games, metadata, and launch commands in sync as a library provider.",
"icon": "library",
"author": "unom",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
"license": "MIT OR Apache-2.0",
"version": "0.1.1",
"integrity": "sha512-H40QEcUN7g0wHTy5CFCOTA4+j/FypbVzWSZ0OlW5Ej5+FnnT1fMUDVOx8KEH34mavTy70fd1zx3zDucu9hNQuQ==",
"verification": {
"reviewedAt": "2026-07-20"
},
"minHost": "0.15.0",
"platforms": [
"windows"
]
}
],
"security": []
}
+1
View File
@@ -30,6 +30,7 @@ and nothing you configure here runs anywhere near the streaming path.
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
| `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id |
| `store.changed` | an install or uninstall finished, or a plugin catalog was refreshed | none — re-read `GET /api/v1/store/catalog` / `…/installed` |
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
+50 -2
View File
@@ -14,7 +14,49 @@ Two first-party plugins today:
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
## Installing a plugin
## Installing from the console
Open the [web console](/docs/web-console) → **Plugins**. The **Browse** tab lists the plugin
catalog; pick one, confirm, and the host installs it and restarts the runner for you. **Installed**
shows what you have (and switches the runner on and off), **Sources** is where the catalog comes
from.
That is the whole install. The rest of this page covers the CLI, which does the same thing, and the
trust model behind the badges — worth reading once, because a plugin runs with the same privileges
as the host.
### What "Verified" means
Every catalogued plugin pins **one exact version** and that version's package hash. A verified entry
means somebody at unom reviewed *that exact package* — not the project in general, and not whatever
it publishes next. When a plugin releases a new version, the store keeps offering the reviewed one
until the new release is reviewed too. Before anything is downloaded, the host re-checks the pinned
hash against the registry, so a package that was quietly republished under the same version number
is refused rather than installed.
Three things a plugin can be:
| Badge | Where it came from |
|---|---|
| **Verified** | The built-in catalog. unom reviewed this exact package. |
| **Unverified**, *from &lt;source&gt;* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. |
| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the plugin stays marked this way for as long as it is installed. |
### Adding another catalog
**Sources** → *Add a catalog source*: a name and the URL of its index. Optionally paste the source's
`ed25519:…` public key — with a key set, the host refuses any index from that source that isn't
correctly signed, rather than falling back to an unsigned one.
Adding a source is a trust decision you make once: its plugins become installable on this host.
They are always attributed to it and never carry the Verified badge, which belongs to the built-in
catalog alone.
To publish a plugin to the built-in catalog, open a pull request against
[`punktfunk-plugin-index`](https://git.unom.io/unom/punktfunk-plugin-index) — its README covers the
format and what review looks for.
## Installing from the CLI
Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating
the plugins directory, pointing it at the package registry, and starting the supervisor.
@@ -50,7 +92,13 @@ Open the [web console](/docs/web-console) and the plugin's page appears in the n
that's the whole install.
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
`enable` once — plugins you add later are picked up automatically.
`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
task) — the console does that restart for you as part of installing.
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
and it carries its catalog badge instead.
### The rest of the commands
+15
View File
@@ -450,6 +450,21 @@
#define VIDEO_CAP_PROBE_SEQ 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client's reassembler accepts **streamed access units**
// (design/nvenc-subframe-slice-output.md Phase 2): the host may ship an AU's early FEC blocks
// before the AU's total size exists — while the tail of the frame is still encoding — so the
// AU's last packet leaves the host sooner (latency plan §7 LN1). Non-final blocks ride
// SENTINEL headers (`block_count == 0` — a value no legacy sender emits — with
// `frame_bytes == 0` and exactly `max_data_per_block` data shards, so the shard-offset
// formula needs no total); the FINAL block's headers carry the real
// `frame_bytes`/`block_count` (+ `FLAG_EOF`), which retro-validate the whole frame's geometry
// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
// the fallback is zero-risk.
#define VIDEO_CAP_STREAMED_AU 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
File diff suppressed because one or more lines are too long
+64 -20
View File
@@ -61,42 +61,75 @@ export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
};
/**
* Ensure `<dir>/bunfig.toml` points the `@punktfunk` scope at the registry so `bun add` resolves
* first-party plugins. Idempotent: a file already mapping the scope is left untouched; an existing
* bunfig with an `[install.scopes]` table gets our line inserted under it; anything else appends a
* fresh table.
* Ensure `<dir>/bunfig.toml` maps every scope we need to its registry, so `bun add` resolves
* plugins from the right place. `@punktfunk` Punktfunk's own registry is always mapped;
* `extraScopes` adds others a plugin-store catalog entry carries its own registry, and the scope
* is what binds a package name to it (design D8, which is why catalog entries must be scoped).
*
* Idempotent and non-destructive: a scope already mapped to the same URL is left alone, a scope
* mapped to a *different* URL is rewritten, and any unrelated bunfig content is preserved.
*/
export const ensureBunfig = (dir = pluginsDirDefault()): void => {
export const ensureBunfig = (
dir = pluginsDirDefault(),
extraScopes: Record<string, string> = {},
): void => {
const file = path.join(dir, "bunfig.toml");
const scopeLine = `"@punktfunk" = "${REGISTRY}"`;
const wanted: Record<string, string> = { "@punktfunk": REGISTRY, ...extraScopes };
let existing = "";
try {
existing = fs.readFileSync(file, "utf8");
} catch {
// no bunfig yet — write a fresh one below
}
if (existing.includes("@punktfunk") && existing.includes(REGISTRY)) return; // already wired
const table = `[install.scopes]\n${scopeLine}\n`;
if (!existing.trim()) {
fs.writeFileSync(file, table);
} else if (/^\[install\.scopes\][^\n]*$/m.test(existing)) {
// Insert our scope line right after the existing table header.
let out = existing;
const missing: string[] = [];
for (const [scope, url] of Object.entries(wanted)) {
// Match `"@scope" = "…"` (quoted or bare key) anywhere in the file.
const line = new RegExp(`^\\s*"?${escapeRe(scope)}"?\\s*=\\s*".*"\\s*$`, "m");
const replacement = `"${scope}" = "${url}"`;
if (line.test(out)) {
const current = out.match(line)?.[0] ?? "";
if (current.includes(`"${url}"`)) continue; // already correct
out = out.replace(line, replacement);
} else {
missing.push(replacement);
}
}
if (missing.length === 0) {
if (out !== existing) fs.writeFileSync(file, out);
return;
}
const block = missing.join("\n");
if (!out.trim()) {
fs.writeFileSync(file, `[install.scopes]\n${block}\n`);
} else if (/^\[install\.scopes\][^\n]*$/m.test(out)) {
// Insert under the existing table header.
fs.writeFileSync(
file,
existing.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${scopeLine}`),
out.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${block}`),
);
} else {
const sep = existing.endsWith("\n") ? "" : "\n";
fs.writeFileSync(file, `${existing}${sep}\n${table}`);
const sep = out.endsWith("\n") ? "" : "\n";
fs.writeFileSync(file, `${out}${sep}\n[install.scopes]\n${block}\n`);
}
};
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export interface PkgOpts extends ResolveOptions {
/** Plugins dir. Default `<config_dir>/plugins`. */
dir?: string;
/** Line sink for progress. Default stdout. */
log?: (line: string) => void;
/**
* Record the resolved version exactly (`bun add --exact`) instead of a caret range. The plugin
* store always sets this: a catalog entry pins one reviewed version, and a caret range in
* `package.json` would let a later `bun install` in this tree drift off it.
*/
exact?: boolean;
/** Extra `scope → registry URL` mappings to write into `bunfig.toml` before installing. */
registries?: Record<string, string>;
}
/** Run `bun add`/`bun remove` in the plugins dir on the current (vendored) bun. */
@@ -104,11 +137,21 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
const dir = opts.dir ?? pluginsDirDefault();
const log = opts.log ?? ((l: string) => console.log(l));
ensurePluginsDir(dir);
if (action === "add") ensureBunfig(dir);
if (action === "add") ensureBunfig(dir, opts.registries);
log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`);
// `process.execPath` is the bun running this file (the vendored one under the package), so a
// system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user.
const args = [process.execPath, action, ...pkgs];
if (action === "add") {
// NEVER run install lifecycle scripts. A plugin is code we chose to run under the runner,
// where it is supervised and (on Windows) de-privileged; a postinstall script runs
// immediately, as whoever is installing — which on a console-triggered install is the host
// service. bun already declines untrusted scripts by default; this makes it explicit and
// unconditional. A plugin that needs a native build step is a review rejection, not a case
// to support.
args.push("--ignore-scripts");
if (opts.exact) args.push("--exact");
}
// Windows: install file COPIES, never bun's default hardlinks. A hardlinked file's canonical
// path resolves into the installing admin's per-user bun cache
// (C:\Users\<admin>\.bun\install\cache\…), which the de-privileged LocalService runner cannot
@@ -156,9 +199,10 @@ export interface InstalledPlugin {
}
/**
* Enumerate installed plugin packages under `<dir>/node_modules` both the scoped first-party
* convention (`@punktfunk/plugin-*`) and the unscoped one (`punktfunk-plugin-*`). Mirrors the
* discovery in runner.ts so `list` shows exactly what the runner would supervise.
* Enumerate installed plugin packages under `<dir>/node_modules` the unscoped convention
* (`punktfunk-plugin-*`) and **any** scope's `plugin-*` (`@punktfunk/plugin-rom-manager`,
* `@retro-hub/plugin-x`). Mirrors the discovery in runner.ts so `list` shows exactly what the
* runner would supervise.
*/
export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
const modules = path.join(dir, "node_modules");
@@ -182,7 +226,7 @@ export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
for (const entry of entries) {
if (entry.startsWith("punktfunk-plugin-")) {
out.push({ pkg: entry, version: versionOf(path.join(modules, entry)) });
} else if (entry === "@punktfunk") {
} else if (entry.startsWith("@")) {
let scoped: string[] = [];
try {
scoped = fs.readdirSync(path.join(modules, entry)).sort();
+48 -1
View File
@@ -16,6 +16,11 @@
//
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list] (run the runner)
// bun src/runner-cli.ts add playnite [--plugins DIR] (package ops)
//
// Package-op flags: --exact pins the resolved version instead of a caret range, and
// --registry @scope=https://… maps a scope to its registry in bunfig.toml. Both exist for the
// plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
import { Effect, Fiber } from "effect";
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
import { discoverUnits, runner } from "./runner.js";
@@ -25,18 +30,56 @@ const arg = (flag: string): string | undefined => {
return i >= 0 ? process.argv[i + 1] : undefined;
};
/** Every value of a repeatable flag (`--registry a=b --registry c=d`). */
const argAll = (flag: string): string[] => {
const out: string[] = [];
for (let i = 0; i < process.argv.length; i++) {
if (process.argv[i] === flag && process.argv[i + 1] !== undefined) out.push(process.argv[++i]);
}
return out;
};
/** Flags that take a value — skipped (with their value) when collecting positionals. */
const VALUE_FLAGS = ["--plugins", "--scripts", "--registry"];
const options = {
scriptsDir: arg("--scripts"),
pluginsDir: arg("--plugins"),
};
/**
* `--registry @scope=https://registry/` `{ "@scope": "https://registry/" }`. The plugin store
* passes one per catalog entry so a third-party package's scope resolves to *its* registry rather
* than the public npm default.
*/
const parseRegistries = (): Record<string, string> => {
const out: Record<string, string> = {};
for (const spec of argAll("--registry")) {
const eq = spec.indexOf("=");
if (eq <= 0) {
console.error(`[plugins] ignoring malformed --registry '${spec}' (expected @scope=URL)`);
continue;
}
const scope = spec.slice(0, eq).trim();
const url = spec.slice(eq + 1).trim();
if (!scope.startsWith("@") || !url.startsWith("https://")) {
console.error(
`[plugins] ignoring --registry '${spec}' (scope must start with @, url with https://)`,
);
continue;
}
out[scope] = url;
}
return out;
};
// Positional plugin names after the subcommand (argv: [bun, script, <cmd>, …]). Skip flags and the
// value of `--plugins`/`--scripts` wherever they appear, so ordering doesn't matter.
const positionals = (): string[] => {
const out: string[] = [];
for (let i = 3; i < process.argv.length; i++) {
const a = process.argv[i];
if (a === "--plugins" || a === "--scripts") {
if (VALUE_FLAGS.includes(a)) {
i++; // skip its value too
continue;
}
@@ -51,6 +94,10 @@ const pkgOpts = {
// Opt-in for names that resolve on the public npm registry (supply-chain gate in
// plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own.
allowPublicRegistry: process.argv.includes("--allow-public-registry"),
// Pin the exact version in package.json instead of a caret range — the plugin store installs
// one reviewed version and must not let a later `bun install` drift off it.
exact: process.argv.includes("--exact"),
registries: parseRegistries(),
};
const runPkgOp = (
+29 -5
View File
@@ -302,8 +302,27 @@ export const discoverUnits = (
// no scripts dir — fine
}
const modules = path.join(pluginsDir, "node_modules");
// The packages the operator actually installed — `bun add` records them as the plugins dir's
// own `dependencies`. This is what separates a plugin from a plugin's LIBRARY:
// `@punktfunk/plugin-kit` matches the `plugin-*` naming convention exactly but arrives as a
// transitive dependency of every kit-built plugin, and running it as a unit is nonsense.
// `undefined` only when there is no readable `package.json` at all — a hand-assembled tree then
// falls back to the naming convention rather than discovering nothing. A package.json with no
// `dependencies` key yields an EMPTY set, not `undefined`: `bun remove` drops the key when the
// last plugin goes, and orphaned transitive packages can outlive it, so falling back there
// would start running a plugin's library the moment you uninstall the last real plugin.
let topLevel: Set<string> | undefined;
try {
const root = JSON.parse(
fs.readFileSync(path.join(pluginsDir, "package.json"), "utf8"),
) as { dependencies?: Record<string, string> };
topLevel = new Set(Object.keys(root.dependencies ?? {}));
} catch {
// no package.json — fall back to the convention
}
// Read a plugin package's manifest (`module`/`main` entry) and add it as a unit.
const addPlugin = (dir: string, name: string): void => {
if (topLevel && !topLevel.has(name)) return; // a dependency, not an installed plugin
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
@@ -323,10 +342,15 @@ export const discoverUnits = (
addPlugin(path.join(modules, pkg), pkg);
continue;
}
// Scoped convention: `@punktfunk/plugin-*` (first-party). A scoped name resolves cleanly
// from a single registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect`
// as shared (hoisted) deps rather than bundling its own copy of each.
if (pkg === "@punktfunk") {
// Scoped convention: `<any scope>/plugin-*`. A scoped name resolves cleanly from a
// registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` as shared
// (hoisted) deps rather than bundling its own copy of each.
//
// ANY scope, not just `@punktfunk`: the plugin store requires catalog entries to be
// scoped precisely so the scope can map to that entry's registry, so a third-party
// plugin necessarily arrives as `@their-scope/plugin-*`. Limiting discovery to the
// first-party scope would let such a plugin install and then never run.
if (pkg.startsWith("@")) {
try {
for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) {
if (scoped.startsWith("plugin-")) {
@@ -334,7 +358,7 @@ export const discoverUnits = (
}
}
} catch {
// no @punktfunk scope dir — fine
// not a readable scope dir — fine
}
}
}
+49
View File
@@ -136,6 +136,55 @@ describe("listInstalled", () => {
{ pkg: "punktfunk-plugin-broken", version: undefined },
]);
});
test("finds plugins in ANY scope, not just @punktfunk", () => {
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
// registry, so a third-party plugin necessarily arrives as `@their-scope/plugin-*`.
// Discovery limited to @punktfunk would let it install and then never run.
const dir = tmp("list-foreign-scope");
writePkg(dir, path.join("@retro-hub", "plugin-x"), "1.0.0");
writePkg(dir, path.join("@retro-hub", "helper"), "1.0.0"); // scoped non-plugin — ignored
expect(listInstalled(dir)).toEqual([{ pkg: "@retro-hub/plugin-x", version: "1.0.0" }]);
});
});
describe("ensureBunfig with extra scopes", () => {
const read = (dir: string) => fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
test("maps a third-party scope alongside @punktfunk", () => {
const dir = tmp("bunfig-extra");
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
const out = read(dir);
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
});
test("is idempotent and rewrites a scope whose registry changed", () => {
const dir = tmp("bunfig-rewrite");
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
expect(read(dir).match(/@retro-hub/g)?.length).toBe(1);
ensureBunfig(dir, { "@retro-hub": "https://new.example/npm/" });
const out = read(dir);
expect(out).toContain('"@retro-hub" = "https://new.example/npm/"');
expect(out).not.toContain("old.example");
// the first-party mapping survives an unrelated scope edit
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
test("preserves unrelated scopes already in the file", () => {
const dir = tmp("bunfig-preserve");
fs.writeFileSync(
path.join(dir, "bunfig.toml"),
'[install.scopes]\n"@acme" = "https://acme.example/"\n',
);
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
const out = read(dir);
expect(out).toContain('"@acme" = "https://acme.example/"');
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
});
describe("ensurePluginsDir", () => {
+77
View File
@@ -107,6 +107,83 @@ describe("discovery", () => {
const units = discoverUnits(d);
expect(units.map((u) => u.name)).toEqual(["@punktfunk/plugin-y"]);
});
test("discovers plugins in ANY scope, not just @punktfunk", () => {
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
// registry, so a third-party plugin necessarily arrives under its own scope. Discovery
// limited to @punktfunk would let it install and then never run.
const d = mkdirs("discover-foreign-scope");
write(
path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "package.json"),
JSON.stringify({ name: "@retro-hub/plugin-z", main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "index.js"),
"export default { name: 'z', main: async () => {} };",
);
expect(discoverUnits(d).map((u) => u.name)).toEqual(["@retro-hub/plugin-z"]);
});
test("a plugin's LIBRARY is not a unit — top-level dependencies win", () => {
// Regression from a live install: `@punktfunk/plugin-kit` is the framework kit-built
// plugins depend on. It matches the `plugin-*` convention exactly and lands in
// node_modules transitively, so a convention-only scan would import and run the framework
// as if it were a plugin.
const d = mkdirs("discover-toplevel");
for (const name of ["plugin-real", "plugin-kit"]) {
write(
path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "package.json"),
JSON.stringify({ name: `@punktfunk/${name}`, main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "index.js"),
"export default { name: 'p', main: async () => {} };",
);
}
write(
path.join(d.pluginsDir, "package.json"),
JSON.stringify({ dependencies: { "@punktfunk/plugin-real": "^1.0.0" } }),
);
expect(discoverUnits(d).map((u) => u.name)).toEqual(["@punktfunk/plugin-real"]);
});
test("no package.json at all falls back to the naming convention", () => {
// Hand-assembled or older trees must still run, not silently discover nothing.
const d = mkdirs("discover-nodeps");
write(
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "package.json"),
JSON.stringify({ name: "punktfunk-plugin-legacy", main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "index.js"),
"export default { name: 'l', main: async () => {} };",
);
expect(discoverUnits(d).map((u) => u.name)).toEqual(["punktfunk-plugin-legacy"]);
});
test("an emptied dependency list means nothing to run", () => {
// `bun remove` drops the `dependencies` key when the last plugin goes, while orphaned
// transitive packages linger in node_modules. Falling back to the naming convention there
// would start running a plugin's LIBRARY right after the operator removed the only real
// plugin — seen on-glass.
const d = mkdirs("discover-emptied");
write(
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "package.json"),
JSON.stringify({ name: "@punktfunk/plugin-kit", main: "index.js" }),
);
write(
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "index.js"),
"export default {};",
);
write(path.join(d.pluginsDir, "package.json"), JSON.stringify({ name: "plugins" }));
expect(discoverUnits(d)).toEqual([]);
write(
path.join(d.pluginsDir, "package.json"),
JSON.stringify({ name: "plugins", dependencies: {} }),
);
expect(discoverUnits(d)).toEqual([]);
});
});
describe("windowsSddlUnsafeReason (the sshd rule's Windows half, pure)", () => {
+39 -10
View File
@@ -31,16 +31,41 @@ fn config(role: Role, scheme: FecScheme, drop_period: u32) -> Config {
}
}
/// Returns (frames_completed, frames_attempted) for a loss setting.
fn run(scheme: FecScheme, drop_period: u32, frames: usize, frame_len: usize) -> (usize, usize) {
/// Returns (frames_completed, frames_attempted) for a loss setting. `streamed` feeds each AU
/// through the VIDEO_CAP_STREAMED_AU path (three encoder-chunk pushes + finish — sentinel
/// blocks then real totals) instead of one whole-AU submit, so the two wire shapes' recovery
/// curves can be compared directly (the Phase-2 "more, smaller units must not regress FEC" gate).
fn run(
scheme: FecScheme,
drop_period: u32,
frames: usize,
frame_len: usize,
streamed: bool,
) -> (usize, usize) {
let (h, c) = loopback_pair(drop_period, 0);
let mut host = Session::new(config(Role::Host, scheme, drop_period), Box::new(h)).unwrap();
let mut client = Session::new(config(Role::Client, scheme, drop_period), Box::new(c)).unwrap();
let send_wires = |host: &mut Session, wires: Vec<Vec<u8>>| {
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
host.send_sealed(&refs).unwrap();
drop(refs);
host.reclaim_wires(wires);
};
let mut completed = 0;
for f in 0..frames {
let frame: Vec<u8> = (0..frame_len).map(|b| (b ^ f) as u8).collect();
host.submit_frame(&frame, f as u64, 0).unwrap();
if streamed {
let mut au = host.begin_streamed_frame_at(f as u64, 0, f as u32).unwrap();
for chunk in frame.chunks(frame_len / 3 + 1) {
let wires = host.seal_streamed_chunk(&mut au, chunk).unwrap();
send_wires(&mut host, wires);
}
let wires = host.seal_streamed_finish(au).unwrap();
send_wires(&mut host, wires);
} else {
host.submit_frame(&frame, f as u64, 0).unwrap();
}
match client.poll_frame() {
Ok(got) => {
if got.data == frame {
@@ -60,22 +85,26 @@ fn main() {
let periods = [0u32, 32, 16, 8, 6, 4, 3, 2];
println!("punktfunk loss-harness — 25% FEC, {frames} frames of {frame_len} bytes");
println!("(GF8 = P1/GameStream-compat, GF16 = P2/wall-breaker)\n");
println!("(GF8 = P1/GameStream-compat, GF16 = P2/wall-breaker, strm = streamed-AU wire)\n");
println!(
"{:>10} {:>9} {:>14} {:>14}",
"drop 1/N", "~loss %", "GF8 recovered", "GF16 recovered"
"{:>10} {:>9} {:>14} {:>14} {:>14}",
"drop 1/N", "~loss %", "GF8 recovered", "GF16 recovered", "GF16 strm"
);
println!("{}", "-".repeat(56));
println!("{}", "-".repeat(72));
for &p in &periods {
let loss = if p == 0 { 0.0 } else { 100.0 / p as f64 };
let (g8, n) = run(FecScheme::Gf8, p, frames, frame_len);
let (g16, _) = run(FecScheme::Gf16, p, frames, frame_len);
let (g8, n) = run(FecScheme::Gf8, p, frames, frame_len, false);
let (g16, _) = run(FecScheme::Gf16, p, frames, frame_len, false);
let (g16s, _) = run(FecScheme::Gf16, p, frames, frame_len, true);
let label = if p == 0 {
"none".to_string()
} else {
format!("1/{p}")
};
println!("{label:>10} {loss:>8.1}% {:>11}/{n} {:>11}/{n}", g8, g16);
println!(
"{label:>10} {loss:>8.1}% {:>11}/{n} {:>11}/{n} {:>11}/{n}",
g8, g16, g16s
);
}
println!("\nNote: recovery drops off once per-block loss exceeds the 25% recovery budget.");
}
+108 -1
View File
@@ -267,5 +267,112 @@
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
"stats_detail_title": "Aufnahme-Details",
"stats_close": "Schließen",
"stats_no_samples": "Diese Aufnahme enthält keine Proben."
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
"store_title": "Plugins",
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
"store_tab_browse": "Entdecken",
"store_tab_installed": "Installiert",
"store_tab_sources": "Quellen",
"store_tier_verified": "Geprüft",
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
"store_tier_external": "Externe Quelle",
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
"store_tier_unverified": "Ungeprüft",
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
"store_tier_cli": "Über die CLI installiert",
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
"store_from_source": "von",
"store_search_placeholder": "Plugins durchsuchen…",
"store_filter_all": "Alle Quellen",
"store_empty": "Noch keine Plugins im Katalog.",
"store_no_match": "Kein Plugin passt zur Suche.",
"store_by_author": "von {author}",
"store_homepage": "Website",
"store_install": "Installieren",
"store_installed_label": "Installiert",
"store_update_to": "Auf {version} aktualisieren",
"store_incompatible": "Nicht kompatibel mit diesem Host.",
"store_blocked": "Vom Host blockiert: {reason}",
"store_spec_open": "Aus Paketangabe installieren…",
"store_runner_title": "Plugin-Runner",
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
"store_runner_enable": "Runner aktivieren",
"store_runner_disable": "Runner deaktivieren",
"store_runner_state_running": "Läuft",
"store_runner_state_stopped": "Gestoppt",
"store_runner_state_disabled": "Deaktiviert",
"store_runner_state_missing": "Nicht installiert",
"store_runner_unit": "Dienst",
"store_runner_principal": "Läuft als",
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
"store_installed_title": "Installierte Plugins",
"store_installed_empty": "Noch keine Plugins installiert.",
"store_running": "Läuft",
"store_stopped": "Läuft nicht",
"store_uninstall": "Deinstallieren",
"store_uninstall_confirm": "{title} deinstallieren? 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_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",
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
"store_source_builtin": "Eingebaut",
"store_source_signed": "Signiert",
"store_source_unsigned": "Unsigniert",
"store_source_stale": "Veraltet",
"store_source_entries": "{count} Plugins",
"store_source_fetched": "zuletzt geladen {when}",
"store_source_never": "nie",
"store_source_remove": "Quelle entfernen",
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
"store_add_source_title": "Katalogquelle hinzufügen",
"store_field_source_name": "Name",
"store_field_source_url": "Index-URL",
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
"store_add_source": "Quelle hinzufügen",
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
"store_source_trust_title": "Dieser Quelle vertrauen?",
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
"store_install_title": "{title} installieren?",
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
"store_install_confirm": "Installieren",
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
"store_install_external_confirm": "Trotzdem installieren",
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
"store_spec_title": "Aus einer Paketangabe installieren",
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
"store_spec_field": "Paketangabe",
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
"store_spec_confirm": "Ungeprüft installieren",
"store_job_install": "{target} wird installiert",
"store_job_uninstall": "{target} wird entfernt",
"store_job_done_install": "Installiert.",
"store_job_done_uninstall": "Entfernt.",
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
"store_job_log": "Log anzeigen",
"store_job_dismiss": "Ausblenden",
"store_phase_queued": "In der Warteschlange",
"store_phase_verifying": "Paket wird verifiziert",
"store_phase_installing": "Wird installiert",
"store_phase_removing": "Wird entfernt",
"store_phase_checking": "Installation wird geprüft",
"store_phase_rolling_back": "Wird zurückgerollt",
"store_phase_recording": "Herkunft wird vermerkt",
"store_phase_restarting": "Plugin-Runner startet neu",
"store_phase_done": "Fertig"
}
+108 -1
View File
@@ -267,5 +267,112 @@
"stats_delete_confirm": "Delete this recording? This can't be undone.",
"stats_detail_title": "Recording detail",
"stats_close": "Close",
"stats_no_samples": "This recording has no samples."
"stats_no_samples": "This recording has no samples.",
"store_title": "Plugins",
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
"store_tab_browse": "Browse",
"store_tab_installed": "Installed",
"store_tab_sources": "Sources",
"store_tier_verified": "Verified",
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
"store_tier_external": "External source",
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
"store_tier_unverified": "Unverified",
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
"store_tier_cli": "Installed via CLI",
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
"store_from_source": "from",
"store_search_placeholder": "Search plugins…",
"store_filter_all": "All sources",
"store_empty": "No plugins in the catalog yet.",
"store_no_match": "No plugin matches your search.",
"store_by_author": "by {author}",
"store_homepage": "Homepage",
"store_install": "Install",
"store_installed_label": "Installed",
"store_update_to": "Update to {version}",
"store_incompatible": "Not compatible with this host.",
"store_blocked": "Blocked by the host: {reason}",
"store_spec_open": "Install from package spec…",
"store_runner_title": "Plugin runner",
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
"store_runner_enable": "Enable runner",
"store_runner_disable": "Disable runner",
"store_runner_state_running": "Running",
"store_runner_state_stopped": "Stopped",
"store_runner_state_disabled": "Disabled",
"store_runner_state_missing": "Not installed",
"store_runner_unit": "Service",
"store_runner_principal": "Runs as",
"store_runner_failed": "Could not change the plugin runner.",
"store_installed_title": "Installed plugins",
"store_installed_empty": "No plugins installed yet.",
"store_running": "Running",
"store_stopped": "Not running",
"store_uninstall": "Uninstall",
"store_uninstall_confirm": "Uninstall {title}? 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_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",
"store_refresh_failed": "Could not refresh the catalogs.",
"store_source_builtin": "Built in",
"store_source_signed": "Signed",
"store_source_unsigned": "Unsigned",
"store_source_stale": "Out of date",
"store_source_entries": "{count} plugins",
"store_source_fetched": "last fetched {when}",
"store_source_never": "never",
"store_source_remove": "Remove source",
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
"store_source_remove_failed": "Could not remove the source.",
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
"store_add_source_title": "Add a catalog source",
"store_field_source_name": "Name",
"store_field_source_url": "Index URL",
"store_field_source_key": "Public key (optional)",
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
"store_add_source": "Add source",
"store_add_source_failed": "Could not save the source — check the name and the URL.",
"store_source_trust_title": "Trust this source?",
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
"store_source_trust_confirm": "I understand — add the source",
"store_install_title": "Install {title}?",
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
"store_install_confirm": "Install",
"store_install_external_title": "Install {title} from an external source?",
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
"store_install_external_confirm": "Install anyway",
"store_install_failed": "Could not start the install.",
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
"store_spec_title": "Install from a package spec",
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
"store_spec_field": "Package spec",
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
"store_spec_confirm_field": "Type the package spec again to confirm",
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
"store_spec_confirm": "Install unverified",
"store_job_install": "Installing {target}",
"store_job_uninstall": "Removing {target}",
"store_job_done_install": "Installed.",
"store_job_done_uninstall": "Removed.",
"store_job_failed": "The job failed.",
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
"store_job_log": "Show log",
"store_job_dismiss": "Dismiss",
"store_phase_queued": "Queued",
"store_phase_verifying": "Verifying the package",
"store_phase_installing": "Installing",
"store_phase_removing": "Removing",
"store_phase_checking": "Checking the install",
"store_phase_rolling_back": "Rolling back",
"store_phase_recording": "Recording provenance",
"store_phase_restarting": "Restarting the plugin runner",
"store_phase_done": "Done"
}
+282
View File
@@ -0,0 +1,282 @@
// The plugin store: catalog, install/uninstall jobs, catalog sources, and the scripting runner.
// Like `api/plugins.ts` this is a hand-written client rather than an orval-generated one — the
// OpenAPI document is regenerated on a Linux box (punktfunk-host doesn't build on macOS), so the
// console must not wait on a regen to talk to these endpoints. It rides the same `/api` BFF path as
// every other call, so the bearer token is injected server-side and the browser only ever sends its
// session cookie.
import {
type QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/api/fetcher";
/**
* How much a plugin's provenance is worth, from most to least trustworthy:
*
* - `verified` from the built-in `unom` source; unom reviewed that exact tarball.
* - `external` from an operator-added source; pinned and integrity-checked, but curated by
* somebody else. It is NOT reviewed by unom and never wears the verified badge.
* - `unverified` installed from a raw package spec through the high-friction dialog. No catalog,
* no review, no pinning. Stays badged unverified forever.
* - `cli` installed with the CLI, so the host holds no provenance record at all.
*/
export type StoreTier = "verified" | "external" | "unverified" | "cli";
/** The tier a *catalog* entry can carry — the raw-spec/CLI tiers never appear in a catalog. */
export type CatalogTier = Extract<StoreTier, "verified" | "external">;
export interface StoreHostInfo {
version: string;
platform: string;
}
export interface StoreSource {
name: string;
url: string;
/** The `unom` source ships with the host: it can't be edited or removed. */
builtin: boolean;
signed: boolean;
/** The last fetch failed or is too old — entries may be out of date. */
stale: boolean;
/** Unix seconds of the last successful fetch. */
fetched_at: number;
error?: string;
entry_count: number;
public_key?: string;
}
export interface StoreEntry {
id: string;
pkg: string;
title: string;
description: string;
icon?: string;
author: string;
homepage?: string;
license?: string;
version: string;
/** Name of the source this entry came from — the attribution an external entry shows. */
source: string;
tier: CatalogTier;
reviewed_at?: string;
platforms: string[];
min_host?: string;
compatible: boolean;
incompatible_reason?: string;
installed_version?: string;
update_available: boolean;
blocked?: string;
}
export interface StoreCatalog {
host: StoreHostInfo;
sources: StoreSource[];
plugins: StoreEntry[];
/** An install/uninstall is already running — the host takes one at a time. */
busy: boolean;
}
export interface InstalledPlugin {
pkg: string;
version: string;
tier: StoreTier;
source?: string;
entry_id?: string;
/** The plugin's runtime id — how it's keyed in the plugin directory (`api/plugins.ts`). */
plugin_id?: string;
title?: string;
installed_at?: string;
running: boolean;
/** The version available to update to, if any. */
update_available?: string;
blocked?: string;
}
export type JobKind = "install" | "uninstall";
export type JobState = "running" | "done" | "failed";
export interface StoreJob {
id: string;
kind: JobKind;
target: string;
state: JobState;
phase: string;
log: string[];
error?: string;
started_at: number;
finished_at?: number;
}
export interface RuntimeStatus {
installed: boolean;
enabled: boolean;
running: boolean;
unit: string;
principal?: string;
detail?: string;
}
/** What `POST /store/install` and `POST /store/uninstall` answer with (202). */
export interface JobAccepted {
job: string;
}
/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
export type InstallBody =
| { source: string; id: string }
| { spec: string; accept_unverified: true };
export interface SourceBody {
url: string;
public_key?: string;
}
const BASE = "/api/v1/store";
/** Query keys, in one place so any mutation can invalidate precisely. */
export const storeKeys = {
all: ["store"] as const,
catalog: ["store", "catalog"] as const,
installed: ["store", "installed"] as const,
sources: ["store", "sources"] as const,
runtime: ["store", "runtime"] as const,
job: (id: string) => ["store", "job", id] as const,
};
const json = (method: string, body: unknown): RequestInit => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
/**
* Refresh everything a completed install/uninstall touches the catalog (installed markers), the
* installed list, the runner state (it restarts), and the plugin directory the nav is built from.
*/
export function invalidateStore(qc: QueryClient): Promise<void> {
return Promise.all([
qc.invalidateQueries({ queryKey: storeKeys.catalog }),
qc.invalidateQueries({ queryKey: storeKeys.installed }),
qc.invalidateQueries({ queryKey: storeKeys.sources }),
qc.invalidateQueries({ queryKey: storeKeys.runtime }),
qc.invalidateQueries({ queryKey: ["plugins"] }),
]).then(() => undefined);
}
/** The merged catalog across every source, plus the sources' own health. */
export function useStoreCatalog() {
return useQuery({
queryKey: storeKeys.catalog,
queryFn: () => apiFetch<StoreCatalog>(`${BASE}/catalog`),
});
}
/** What's installed right now, with each plugin's permanent provenance tier. */
export function useInstalledPlugins() {
return useQuery({
queryKey: storeKeys.installed,
queryFn: () => apiFetch<InstalledPlugin[]>(`${BASE}/installed`),
refetchInterval: 30_000,
});
}
export function useStoreSources() {
return useQuery({
queryKey: storeKeys.sources,
queryFn: () => apiFetch<StoreSource[]>(`${BASE}/sources`),
});
}
export function useStoreRuntime() {
return useQuery({
queryKey: storeKeys.runtime,
queryFn: () => apiFetch<RuntimeStatus>(`${BASE}/runtime`),
});
}
/**
* A single install/uninstall job, polled once a second while it runs and left alone once it
* settles. Pass `null` to park the query (no job in flight).
*/
export function useStoreJob(id: string | null) {
return useQuery({
queryKey: storeKeys.job(id ?? ""),
queryFn: () =>
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
enabled: id !== null,
refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false),
});
}
/** Re-fetch every source's index; answers with the freshly merged catalog. */
export function useRefreshCatalog() {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiFetch<StoreCatalog>(`${BASE}/refresh`, { method: "POST" }),
onSuccess: (catalog) => {
qc.setQueryData(storeKeys.catalog, catalog);
qc.setQueryData(storeKeys.sources, catalog.sources);
},
});
}
/** Start an install. Answers 202 with the job to poll; 409 means another op is already running. */
export function useInstallPlugin() {
return useMutation({
mutationFn: (body: InstallBody) =>
apiFetch<JobAccepted>(`${BASE}/install`, json("POST", body)),
});
}
export function useUninstallPlugin() {
return useMutation({
mutationFn: (pkg: string) =>
apiFetch<JobAccepted>(`${BASE}/uninstall`, json("POST", { pkg })),
});
}
/** Add or update an operator source (the built-in one is not editable). */
export function useSetSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, ...body }: SourceBody & { name: string }) =>
apiFetch<void>(
`${BASE}/sources/${encodeURIComponent(name)}`,
json("PUT", body),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: storeKeys.sources });
qc.invalidateQueries({ queryKey: storeKeys.catalog });
},
});
}
export function useDeleteSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
apiFetch<void>(`${BASE}/sources/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: storeKeys.sources });
qc.invalidateQueries({ queryKey: storeKeys.catalog });
},
});
}
/** Enable or disable the plugin/script runner service. */
export function useSetRuntime() {
const qc = useQueryClient();
return useMutation({
mutationFn: (enabled: boolean) =>
apiFetch<RuntimeStatus>(`${BASE}/runtime`, json("POST", { enabled })),
onSuccess: (status) => {
qc.setQueryData(storeKeys.runtime, status);
qc.invalidateQueries({ queryKey: ["plugins"] });
},
});
}
+10 -2
View File
@@ -6,6 +6,7 @@ import {
LibraryBig,
MonitorPlay,
MoreHorizontal,
Puzzle,
ScrollText,
Server,
Settings,
@@ -29,9 +30,15 @@ const NAV = [
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
] as const;
// "/plugins" is the store's index route and "/plugins/<id>" a plugin's own UI, so the store entry
// only counts as active on an exact match — otherwise it would light up alongside the plugin's own
// nav entry below. Same reason "/" is exact.
const EXACT: readonly string[] = ["/", "/plugins"];
// On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a
// "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More".
const MOBILE_PRIMARY = NAV.slice(0, 4);
@@ -74,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
to={to}
activeOptions={{ exact: to === "/" }}
activeOptions={{ exact: EXACT.includes(to) }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
@@ -200,6 +207,7 @@ function MobileNav() {
key={to}
to={to}
onClick={() => setMoreOpen(false)}
activeOptions={{ exact: EXACT.includes(to) }}
className={cn(tab, "rounded-md")}
activeProps={{ className: "text-[var(--brand-light)]" }}
>
@@ -232,7 +240,7 @@ function MobileNav() {
key={to}
to={to}
onClick={() => setMoreOpen(false)}
activeOptions={{ exact: to === "/" }}
activeOptions={{ exact: EXACT.includes(to) }}
className={tab}
activeProps={{ className: "text-[var(--brand-light)]" }}
>
+17 -3
View File
@@ -63,11 +63,25 @@ const CardDescription = React.forwardRef<
));
CardDescription.displayName = "CardDescription";
/**
* Card body. Pass `flush` for content that should meet the card's edges a full-bleed table, most
* commonly instead of trying to cancel the padding from the outside.
*
* `className="p-0"` does NOT work for that: tailwind-merge only resolves conflicts *within the same
* variant*, so `p-0` cancels `p-4` but leaves `sm:p-6` standing, and the padding silently returns at
* 640px. Every call site that tried it ended up with a doubled inset once a `CardHeader` (which
* brings its own `sm:p-6`) was nested inside visible as one card whose title sits 24px further in
* than its neighbours'.
*/
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0 sm:p-6 sm:pt-0", className)} {...props} />
React.HTMLAttributes<HTMLDivElement> & { flush?: boolean }
>(({ className, flush = false, ...props }, ref) => (
<div
ref={ref}
className={cn(!flush && "p-4 pt-0 sm:p-6 sm:pt-0", className)}
{...props}
/>
));
CardContent.displayName = "CardContent";
+49
View File
@@ -0,0 +1,49 @@
// The console's Dialog IS @unom/ui's radix dialog — brand surface (material gloss + card radius)
// with the shared close/overlay behaviour. @unom/ui ships the SURFACE only and leaves placement to
// the app, so `DialogContent` here is the surface already wrapped in its portal + overlay and
// centred in the viewport; everything else is re-exported unchanged.
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogContent as DialogSurface,
DialogTitle,
DialogTrigger,
} from "@unom/ui/dialog";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const DialogContent = ({
className,
...props
}: ComponentProps<typeof DialogSurface>) => (
<DialogPortal>
<DialogOverlay />
<DialogSurface
className={cn(
"fixed left-1/2 top-1/2 z-100 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto p-6",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
</DialogPortal>
);
DialogContent.displayName = "DialogContent";
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+34
View File
@@ -0,0 +1,34 @@
// The console's Tabs ARE @unom/ui's radix tabs, with one correction: the inactive trigger colour.
//
// @unom/ui styles inactive triggers `text-secondary` and the active one `data-[state=active]:
// text-main`. Those tokens come from @unom/ui's own palette; in the console's theme `text-secondary`
// lands on (near) the tab strip's own background, so every inactive tab renders as an invisible
// gap — the tab bar looks like a single lonely label with dead space beside it. Caught in a browser
// pass, not by types: the markup and the a11y roles are entirely correct.
//
// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's
// tokens rather than restyling it at every call site.
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger as TabsTriggerBase,
} from "@unom/ui/tabs";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const TabsTrigger = ({
className,
...props
}: ComponentProps<typeof TabsTriggerBase>) => (
<TabsTriggerBase
className={cn(
"text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
className,
)}
{...props}
/>
);
TabsTrigger.displayName = "TabsTrigger";
export { Tabs, TabsContent, TabsList, TabsTrigger };
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionStore } from "@/sections/Store";
// The plugin store. Sits at /plugins as an index route, so it coexists with the per-plugin UI route
// (`plugins.$pluginId.$.tsx`) without either shadowing the other.
export const Route = createFileRoute("/plugins/")({ component: SectionStore });
+1 -1
View File
@@ -78,7 +78,7 @@ export const PendingDevices: FC<{
return (
<Card>
<CardContent className="p-0">
<CardContent flush>
<CardHeader>
<CardTitle>
<h2 className="flex items-center gap-2 text-lg font-medium">
+9
View File
@@ -8,9 +8,11 @@ import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { ExternalLink, RefreshCw } from "lucide-react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { pluginIcon, usePlugins } from "@/api/plugins";
import { useInstalledPlugins } from "@/api/store";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { TierBadge } from "@/sections/Store/TierBadge";
const route = getRouteApi("/plugins/$pluginId/$");
@@ -26,6 +28,12 @@ export const SectionPlugin: FC = () => {
const Icon = pluginIcon(meta?.ui?.icon);
const title = meta?.title ?? pluginId;
// Provenance follows the plugin into its own page: an unverified plugin must stay visibly
// unverified WHILE you use it, not only in the store listing. The store keys installations by
// package, so the runtime id is matched through `plugin_id`.
const { data: installed } = useInstalledPlugins();
const provenance = installed?.find((p) => p.plugin_id === pluginId);
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
// offline card (the manual Retry re-probes).
const health = useQuery({
@@ -77,6 +85,7 @@ export const SectionPlugin: FC = () => {
v{meta.version}
</span>
)}
{provenance && <TierBadge tier={provenance.tier} />}
<a
href={`/plugin-ui/${pluginId}/`}
target="_blank"
+1 -1
View File
@@ -114,7 +114,7 @@ export const RecordingsCard: FC<{
{m.stats_recordings_empty()}
</CardContent>
) : (
<CardContent className="p-0">
<CardContent flush>
<Table>
<TableHeader>
<TableRow>
+249
View File
@@ -0,0 +1,249 @@
import { AlertTriangle, Ban, Check, Download, Search } from "lucide-react";
import { type FC, useMemo, useState } from "react";
import { pluginIcon } from "@/api/plugins";
import { type StoreEntry, useStoreCatalog } from "@/api/store";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
import { RunnerBanner } from "./Runner";
import { SourceChip, TierBadge } from "./TierBadge";
/** Case-insensitive substring match across the fields an operator would actually search by. */
function matches(entry: StoreEntry, needle: string): boolean {
if (!needle) return true;
const q = needle.toLowerCase();
return [entry.title, entry.description, entry.pkg, entry.author].some((f) =>
f.toLowerCase().includes(q),
);
}
/**
* Container: the catalog. Owns the catalog query plus the local search/source filter; installing is
* escalated to the parent, which owns the tier-appropriate dialog and the resulting job so this
* subsection never installs anything itself.
*/
export const BrowseTab: FC<{
onInstall: (entry: StoreEntry) => void;
onInstallSpec: () => void;
}> = ({ onInstall, onInstallSpec }) => {
const catalog = useStoreCatalog();
const [query, setQuery] = useState("");
const [source, setSource] = useState<string | null>(null);
const entries = catalog.data?.plugins ?? [];
const sources = catalog.data?.sources ?? [];
const shown = useMemo(
() =>
entries.filter(
(e) => (source === null || e.source === source) && matches(e, query),
),
[entries, source, query],
);
return (
<div className="flex flex-col gap-card">
<RunnerBanner />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative sm:max-w-xs sm:flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
className="pl-9"
aria-label={m.store_search_placeholder()}
placeholder={m.store_search_placeholder()}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{/* One chip per source, so an operator can see a third-party catalog's entries alone. */}
{sources.length > 1 && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={source === null ? "default" : "outline"}
aria-pressed={source === null}
onClick={() => setSource(null)}
>
{m.store_filter_all()}
</Button>
{sources.map((s) => (
<Button
key={s.name}
size="sm"
variant={source === s.name ? "default" : "outline"}
aria-pressed={source === s.name}
onClick={() => setSource(s.name)}
>
{s.name}
</Button>
))}
</div>
)}
</div>
<QueryState
isLoading={catalog.isLoading}
error={catalog.error}
refetch={catalog.refetch}
>
{shown.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{entries.length === 0 ? m.store_empty() : m.store_no_match()}
</CardContent>
</Card>
) : (
<div className="@container">
<div className="grid grid-cols-1 gap-card @xl:grid-cols-2 @4xl:grid-cols-3">
{shown.map((entry) => (
<StoreCard
key={`${entry.source}/${entry.id}`}
entry={entry}
onInstall={() => onInstall(entry)}
/>
))}
</div>
</div>
)}
</QueryState>
{/* The ONLY way to the raw-spec install. Deliberately a quiet footer link, not a button on
a card: an unverified install should take a decision, never a stray click. */}
<div className="text-center">
<button
type="button"
onClick={onInstallSpec}
className="text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
{m.store_spec_open()}
</button>
</div>
</div>
);
};
/**
* The catalog stores platform IDENTIFIERS (`linux | windows | macos` see the index
* validator); these are their display names. Proper nouns, so deliberately not routed
* through i18n, and `macos` is spelled the way Apple spells it.
*/
const PLATFORM_LABELS: Record<string, string> = {
linux: "Linux",
windows: "Windows",
macos: "macOS",
};
/**
* `CardContent` zeroes its top padding (`pt-0`/`sm:pt-0`) because it normally sits under a
* `CardHeader` that already supplies it these cards have no header, so the top padding
* has to come back explicitly, at BOTH breakpoints. `p-card` alone does not do it: `card`
* is a custom `--spacing-*` token, which tailwind-merge does not recognise as a spacing
* value and therefore never dedupes against `pt-0`, leaving the longhand to win.
*/
const HEADERLESS_CARD_PADDING = "p-card pt-card sm:pt-card";
/** One catalog entry. Blocked entries shout; incompatible ones grey out; neither can be installed. */
export const StoreCard: FC<{ entry: StoreEntry; onInstall: () => void }> = ({
entry,
onInstall,
}) => {
const Icon = pluginIcon(entry.icon);
const blocked = entry.blocked !== undefined;
const installed = entry.installed_version !== undefined;
const installable = !blocked && entry.compatible;
return (
<Card
className={cn(
"flex flex-col",
blocked && "ring-2 ring-destructive/60",
!entry.compatible && !blocked && "opacity-60",
)}
>
<CardContent
className={cn("flex flex-1 flex-col gap-3", HEADERLESS_CARD_PADDING)}
>
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/15">
<Icon className="size-5 text-foreground" />
</span>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium" title={entry.title}>
{entry.title}
</h3>
<p className="truncate text-xs text-muted-foreground">
{m.store_by_author({ author: entry.author })} · v{entry.version}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<TierBadge tier={entry.tier} />
{/* Attribution, never verification: an external entry names who curated it. */}
{entry.tier === "external" && <SourceChip source={entry.source} />}
</div>
<p className="line-clamp-3 text-sm text-muted-foreground">
{entry.description}
</p>
<div className="flex flex-wrap gap-1.5">
{entry.platforms.map((p) => (
<Badge key={p} variant="secondary" className="font-normal">
{PLATFORM_LABELS[p] ?? p}
</Badge>
))}
</div>
{blocked && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
<Ban className="mt-0.5 size-4 shrink-0" />
<span>{m.store_blocked({ reason: entry.blocked ?? "" })}</span>
</p>
)}
{!entry.compatible && !blocked && (
<p className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="mt-px size-3.5 shrink-0" />
<span>{entry.incompatible_reason ?? m.store_incompatible()}</span>
</p>
)}
{/* Footer pinned to the bottom so cards in a row line their actions up. */}
<div className="mt-auto flex items-center gap-3 pt-1">
{entry.update_available ? (
<Button size="sm" disabled={!installable} onClick={onInstall}>
<Download className="size-4" />
{m.store_update_to({ version: entry.version })}
</Button>
) : installed ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Check className="size-4" />
{m.store_installed_label()}
</span>
) : (
<Button size="sm" disabled={!installable} onClick={onInstall}>
<Download className="size-4" />
{m.store_install()}
</Button>
)}
{entry.homepage && (
<a
href={entry.homepage}
target="_blank"
rel="noreferrer"
className="ml-auto text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
{m.store_homepage()}
</a>
)}
</div>
</CardContent>
</Card>
);
};
+191
View File
@@ -0,0 +1,191 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
import { type FC, useState } from "react";
import type { StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
// Friction proportional to trust. The three install paths deliberately do NOT share a confirm
// button: a verified install is one click on a plain dialog, an external one makes you read that
// unom didn't review the code and names who did curate it, and a raw package spec makes you retype
// the spec and tick a box. The last one is never reachable by accident — no card, no button on a
// listing, only the small footer link on Browse.
/**
* Tiers 1 and 2: install a curated catalog entry. Verified entries get an ordinary confirm; an
* entry from an operator-added source gets the warning treatment and names its source, because
* "pinned and integrity-checked" is not the same claim as "reviewed".
*/
export const InstallDialog: FC<{
/** The entry being confirmed, or null when the dialog is closed. */
entry: StoreEntry | null;
onCancel: () => void;
onConfirm: (entry: StoreEntry) => void;
isPending: boolean;
}> = ({ entry, onCancel, onConfirm, isPending }) => {
const external = entry?.tier === "external";
return (
<Dialog open={entry !== null} onOpenChange={(open) => !open && onCancel()}>
{entry && (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{external ? (
<ShieldQuestion className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
) : (
<BadgeCheck className="size-5 shrink-0 text-[var(--success)]" />
)}
{external
? m.store_install_external_title({ title: entry.title })
: m.store_install_title({ title: entry.title })}
</DialogTitle>
<DialogDescription>
{external
? m.store_install_external_body({
version: entry.version,
source: entry.source,
})
: m.store_install_verified_body({ version: entry.version })}
</DialogDescription>
</DialogHeader>
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
{entry.pkg}@{entry.version}
</p>
{external && (
<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_install_external_note()}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isPending}>
{m.common_cancel()}
</Button>
{/* Red is reserved for the raw-spec install; an external one escalates through
its copy and its amber panel, not by borrowing the danger colour. */}
<Button disabled={isPending} onClick={() => onConfirm(entry)}>
{external
? m.store_install_external_confirm()
: m.store_install_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
* box) before it will even enable its button.
*/
export const SpecInstallDialog: FC<{
open: boolean;
onCancel: () => void;
onConfirm: (spec: string) => void;
isPending: boolean;
}> = ({ open, onCancel, onConfirm, isPending }) => {
const [spec, setSpec] = useState("");
const [echo, setEcho] = useState("");
const [accepted, setAccepted] = useState(false);
// Both confirmations are cleared on every exit, cancel AND confirm alike: reopening this dialog
// must never find it pre-armed with the last spec and a ticked box.
const reset = () => {
setSpec("");
setEcho("");
setAccepted(false);
};
const close = () => {
reset();
onCancel();
};
const wanted = spec.trim();
// Both gates must pass: the retyped spec matches exactly, AND the box is ticked.
const ready = wanted.length > 0 && echo.trim() === wanted && accepted;
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert className="size-5 shrink-0 text-destructive" />
{m.store_spec_title()}
</DialogTitle>
<DialogDescription>{m.store_spec_lead()}</DialogDescription>
</DialogHeader>
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{m.store_spec_permanent()}
</p>
<div className="space-y-2">
<Label htmlFor="store-spec">{m.store_spec_field()}</Label>
<Input
id="store-spec"
autoComplete="off"
spellCheck={false}
placeholder="@scope/plugin-name@1.2.3"
value={spec}
onChange={(e) => setSpec(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{m.store_spec_field_help()}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="store-spec-echo">
{m.store_spec_confirm_field()}
</Label>
<Input
id="store-spec-echo"
autoComplete="off"
spellCheck={false}
value={echo}
onChange={(e) => setEcho(e.target.value)}
/>
</div>
<Label className="flex items-start gap-3 text-sm font-normal">
<Checkbox
checked={accepted}
onCheckedChange={(next) => setAccepted(next === true)}
className="mt-0.5"
/>
<span>{m.store_spec_checkbox()}</span>
</Label>
<DialogFooter>
<Button variant="outline" onClick={close} disabled={isPending}>
{m.common_cancel()}
</Button>
<Button
variant="destructive"
disabled={!ready || isPending}
onClick={() => {
reset();
onConfirm(wanted);
}}
>
{m.store_spec_confirm()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+140
View File
@@ -0,0 +1,140 @@
import { ArrowUpCircle, Ban, Circle, Trash2 } from "lucide-react";
import type { FC } from "react";
import { type InstalledPlugin, useInstalledPlugins } from "@/api/store";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { RunnerCardSection } from "./Runner";
import { SourceChip, TierBadge } from "./TierBadge";
/**
* Container: what's installed. Owns the installed query; the runner switch above it owns its own.
* Updating and uninstalling are escalated to the parent, which owns the confirm dialogs and the
* resulting job card.
*/
export const InstalledTab: FC<{
onUpdate: (plugin: InstalledPlugin) => void;
onUninstall: (plugin: InstalledPlugin) => void;
/** Package whose install/uninstall is in flight, or null — only that row's actions disable. */
busyPkg: string | null;
}> = ({ onUpdate, onUninstall, busyPkg }) => {
const installed = useInstalledPlugins();
return (
<div className="flex flex-col gap-card">
<RunnerCardSection />
<InstalledList
installed={installed}
onUpdate={onUpdate}
onUninstall={onUninstall}
busyPkg={busyPkg}
/>
</div>
);
};
/**
* One row per installed plugin. The tier badge is permanent and non-negotiable: a plugin installed
* from a raw spec stays marked unverified here for as long as it's on the host, no matter what it
* later reports about itself.
*/
export const InstalledList: FC<{
installed: Loadable<InstalledPlugin[]>;
onUpdate: (plugin: InstalledPlugin) => void;
onUninstall: (plugin: InstalledPlugin) => void;
busyPkg: string | null;
}> = ({ installed, onUpdate, onUninstall, busyPkg }) => {
const rows = installed.data ?? [];
return (
<Card>
<CardContent flush>
<CardHeader>
<CardTitle>{m.store_installed_title()}</CardTitle>
</CardHeader>
<QueryState
isLoading={installed.isLoading}
error={installed.error}
refetch={installed.refetch}
>
{rows.length === 0 ? (
<p className="p-card pt-0 text-sm text-muted-foreground">
{m.store_installed_empty()}
</p>
) : (
<Table>
<TableBody>
{rows.map((p) => (
<TableRow key={p.pkg} className="align-top">
<TableCell className="py-4">
<div className="font-medium">{p.title ?? p.pkg}</div>
<div className="font-mono text-xs text-muted-foreground">
{p.pkg}
</div>
{p.blocked !== undefined && (
<p className="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
<Ban className="mt-px size-3.5 shrink-0" />
<span>{m.store_blocked({ reason: p.blocked })}</span>
</p>
)}
</TableCell>
<TableCell className="py-4">
<div className="flex flex-col items-start gap-1">
<TierBadge tier={p.tier} />
{p.tier === "external" && p.source && (
<SourceChip source={p.source} />
)}
</div>
</TableCell>
<TableCell className="py-4 text-sm tabular-nums text-muted-foreground">
v{p.version}
</TableCell>
<TableCell className="py-4">
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Circle
className={
p.running
? "size-2 fill-[var(--success)] text-[var(--success)]"
: "size-2 fill-muted-foreground text-muted-foreground"
}
/>
{p.running ? m.store_running() : m.store_stopped()}
</span>
</TableCell>
<TableCell className="py-4 text-right">
<div className="flex items-center justify-end gap-2">
{p.update_available !== undefined && (
<Button
size="sm"
disabled={busyPkg === p.pkg}
onClick={() => onUpdate(p)}
>
<ArrowUpCircle className="size-4" />
{m.store_update_to({
version: p.update_available,
})}
</Button>
)}
<Button
variant="ghost"
size="icon"
aria-label={m.store_uninstall()}
disabled={busyPkg === p.pkg}
onClick={() => onUninstall(p)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
);
};
+134
View File
@@ -0,0 +1,134 @@
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, RotateCw, X, XCircle } from "lucide-react";
import { type FC, useEffect, useRef } from "react";
import { invalidateStore, type StoreJob, useStoreJob } from "@/api/store";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { m } from "@/paraglide/messages";
// The host reports a job's progress as a free-form phase string; these are the ones it emits, in
// order. Anything unrecognised falls through to the raw phase rather than an empty label, so a new
// host phase degrades to English-but-visible instead of disappearing.
const PHASES: Record<string, () => string> = {
queued: () => m.store_phase_queued(),
verifying: () => m.store_phase_verifying(),
installing: () => m.store_phase_installing(),
removing: () => m.store_phase_removing(),
checking: () => m.store_phase_checking(),
"rolling back": () => m.store_phase_rolling_back(),
recording: () => m.store_phase_recording(),
"restarting runner": () => m.store_phase_restarting(),
done: () => m.store_phase_done(),
};
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;
/**
* 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,
* installed list, runner state, plugin directory the moment it finishes.
*/
export const JobProgressSection: FC<{
jobId: string;
onDismiss: () => void;
}> = ({ jobId, onDismiss }) => {
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 refreshed = useRef<string | null>(null);
useEffect(() => {
if (!settled || refreshed.current === jobId) return;
refreshed.current = jobId;
invalidateStore(qc);
}, [settled, jobId, qc]);
if (!job.data) return null;
return <JobProgressCard job={job.data} onDismiss={onDismiss} />;
};
/** 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 }) => {
const running = job.state === "running";
const failed = job.state === "failed";
const log = job.log.slice(-LOG_TAIL);
return (
<Card
className={failed ? "ring-2 ring-destructive/60" : undefined}
aria-live="polite"
>
<CardContent className="space-y-3 p-card pt-card sm:pt-card">
<div className="flex items-start gap-3">
{running ? (
<Spinner className="mt-0.5 size-5 shrink-0" />
) : failed ? (
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
) : (
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--success)]" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{job.kind === "uninstall"
? m.store_job_uninstall({ target: job.target })
: m.store_job_install({ target: job.target })}
</p>
<p className="text-sm text-muted-foreground">
{running
? phaseLabel(job.phase)
: failed
? m.store_job_failed()
: job.kind === "uninstall"
? m.store_job_done_uninstall()
: m.store_job_done_install()}
</p>
</div>
{!running && (
<Button
variant="ghost"
size="icon"
aria-label={m.store_job_dismiss()}
onClick={onDismiss}
>
<X className="size-4" />
</Button>
)}
</div>
{failed && job.error && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{job.error}
</p>
)}
{/* The runner is bounced at the end of every successful job, so the plugin's nav entry
(and its UI) takes a moment longer to appear than the job's "done". Say so. */}
{job.state === "done" && (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<RotateCw className="size-3.5" />
{m.store_job_restarting()}
</p>
)}
{log.length > 0 && (
<details className="group">
<summary className="cursor-pointer list-none text-xs text-muted-foreground transition-colors hover:text-foreground">
{m.store_job_log()}
</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
<code>{log.join("\n")}</code>
</pre>
</details>
)}
</CardContent>
</Card>
);
};
+125
View File
@@ -0,0 +1,125 @@
import { toast } from "@unom/ui/toast";
import { Play, Power, PowerOff } from "lucide-react";
import type { FC } from "react";
import {
type RuntimeStatus,
useSetRuntime,
useStoreRuntime,
} from "@/api/store";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
// The plugin/script runner is the service every plugin actually executes inside. Installing a plugin
// while it's switched off silently gets you nothing running, so Browse carries a banner and the
// Installed tab carries the full switch.
/** Small helper both surfaces share: fire the toggle, surface a failure as a toast. */
function useRunnerToggle() {
const set = useSetRuntime();
const toggle = (enabled: boolean) => {
set.mutate(enabled, {
onError: () => toast.error(m.store_runner_failed()),
});
};
return { toggle, isPending: set.isPending };
}
/**
* Browse-tab banner: the runner is installed but off, so nothing an operator installs here would
* start. Renders nothing in every other state (including "not installed" the Installed tab's card
* explains that case properly).
*/
export const RunnerBanner: FC = () => {
const runtime = useStoreRuntime();
const { toggle, isPending } = useRunnerToggle();
const s = runtime.data;
if (!s?.installed || s.enabled) return null;
return (
<div className="flex flex-col gap-3 rounded-lg border border-amber-600/40 bg-amber-500/10 p-4 text-sm text-amber-600 sm:flex-row sm:items-center dark:border-amber-500/40 dark:text-amber-500">
<PowerOff className="size-5 shrink-0" />
<p className="flex-1">{m.store_runner_banner()}</p>
<Button size="sm" disabled={isPending} onClick={() => toggle(true)}>
<Play className="size-4" />
{m.store_runner_enable()}
</Button>
</div>
);
};
/**
* Container: the runner switch at the top of the Installed tab. Like the library's source toggles
* this is a secondary control it stays out of the way until the query resolves rather than
* stacking a second error banner on top of the installed list's own.
*/
export const RunnerCardSection: FC = () => {
const runtime = useStoreRuntime();
const { toggle, isPending } = useRunnerToggle();
if (!runtime.data) return null;
return (
<RunnerCard status={runtime.data} busy={isPending} onToggle={toggle} />
);
};
/** The runner card: what the service is, whether it's up, and the one switch that changes it. */
export const RunnerCard: FC<{
status: RuntimeStatus;
busy: boolean;
onToggle: (enabled: boolean) => void;
}> = ({ status, busy, onToggle }) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between gap-3 text-base">
<span>{m.store_runner_title()}</span>
{!status.installed ? (
<Badge variant="outline">{m.store_runner_state_missing()}</Badge>
) : status.running ? (
<Badge variant="success">{m.store_runner_state_running()}</Badge>
) : status.enabled ? (
<Badge variant="outline">{m.store_runner_state_stopped()}</Badge>
) : (
<Badge variant="secondary">{m.store_runner_state_disabled()}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{status.installed
? m.store_runner_help()
: m.store_runner_not_installed()}
</p>
<dl className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
<div className="flex gap-2">
<dt>{m.store_runner_unit()}</dt>
<dd className="font-mono text-foreground">{status.unit}</dd>
</div>
{status.principal && (
<div className="flex gap-2">
<dt>{m.store_runner_principal()}</dt>
<dd className="font-mono text-foreground">{status.principal}</dd>
</div>
)}
</dl>
{status.detail && (
<p className="text-xs text-muted-foreground">{status.detail}</p>
)}
{status.installed && (
<Button
size="sm"
variant={status.enabled ? "outline" : "default"}
disabled={busy}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? (
<PowerOff className="size-4" />
) : (
<Power className="size-4" />
)}
{status.enabled ? m.store_runner_disable() : m.store_runner_enable()}
</Button>
)}
</CardContent>
</Card>
);
+347
View File
@@ -0,0 +1,347 @@
import { toast } from "@unom/ui/toast";
import {
AlertTriangle,
Lock,
RefreshCw,
ShieldCheck,
ShieldOff,
Trash2,
} from "lucide-react";
import { type FC, type FormEvent, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type SourceBody,
type StoreSource,
useDeleteSource,
useRefreshCatalog,
useSetSource,
useStoreSources,
} from "@/api/store";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/** A source the operator has filled in but not yet agreed to trust. */
type SourceDraft = SourceBody & { name: string };
/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. */
const fmtFetched = (secs: number): string =>
secs > 0 ? new Date(secs * 1000).toLocaleString() : m.store_source_never();
/**
* Container: the catalog sources. Owns the source listing, the refresh-all action, and add/remove.
* Adding is a two-step: the form collects the source, and a one-time trust dialog states plainly
* what trusting a third-party catalog means before anything is written to the host.
*/
export const SourcesTab: FC = () => {
const sources = useStoreSources();
const refresh = useRefreshCatalog();
const save = useSetSource();
const remove = useDeleteSource();
// The draft waiting on the trust dialog, and a key that re-mounts (and so clears) the form.
const [draft, setDraft] = useState<SourceDraft | null>(null);
const [formKey, setFormKey] = useState(0);
const onRefresh = () =>
refresh.mutate(undefined, {
onError: () => toast.error(m.store_refresh_failed()),
});
const onConfirmAdd = async () => {
if (!draft) return;
try {
await save.mutateAsync(draft);
setDraft(null);
setFormKey((k) => k + 1);
} catch {
toast.error(m.store_add_source_failed());
}
};
const onRemove = async (source: StoreSource) => {
if (!confirm(m.store_source_remove_confirm({ name: source.name }))) return;
try {
await remove.mutateAsync(source.name);
} catch (e) {
// 403 is the host refusing to drop its built-in catalog — say exactly that.
toast.error(
e instanceof ApiError && e.status === 403
? m.store_source_builtin_locked()
: m.store_source_remove_failed(),
);
}
};
return (
<div className="flex flex-col gap-card">
<SourceList
sources={sources}
busyName={remove.isPending ? (remove.variables ?? null) : null}
isRefreshing={refresh.isPending}
onRefresh={onRefresh}
onRemove={onRemove}
/>
<AddSourceForm
key={formKey}
onSubmit={setDraft}
isSaving={save.isPending}
/>
<TrustSourceDialog
draft={draft}
isSaving={save.isPending}
onCancel={() => setDraft(null)}
onConfirm={onConfirmAdd}
/>
</div>
);
};
/** The source table: health per source, with the built-in one locked. */
export const SourceList: FC<{
sources: {
data?: StoreSource[];
isLoading: boolean;
error: unknown;
refetch?: () => void;
};
/** Name of the source whose delete is in flight, or null. */
busyName: string | null;
isRefreshing: boolean;
onRefresh: () => void;
onRemove: (source: StoreSource) => void;
}> = ({ sources, busyName, isRefreshing, onRefresh, onRemove }) => {
const rows = sources.data ?? [];
return (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>{m.store_sources_title()}</CardTitle>
<Button
variant="outline"
size="sm"
disabled={isRefreshing}
onClick={onRefresh}
>
<RefreshCw
className={isRefreshing ? "size-4 animate-spin" : "size-4"}
/>
{m.store_refresh_all()}
</Button>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.store_sources_help()}
</p>
<QueryState
isLoading={sources.isLoading}
error={sources.error}
refetch={sources.refetch}
>
<div className="flex flex-col gap-3">
{rows.map((s) => (
<div
key={s.name}
className="flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-start"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{s.name}</span>
{s.builtin && (
<Badge variant="secondary" className="gap-1">
<Lock className="size-3" />
{m.store_source_builtin()}
</Badge>
)}
{s.signed ? (
<Badge variant="outline" className="gap-1">
<ShieldCheck className="size-3" />
{m.store_source_signed()}
</Badge>
) : (
<Badge
variant="outline"
className="gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500"
>
<ShieldOff className="size-3" />
{m.store_source_unsigned()}
</Badge>
)}
{s.stale && (
<Badge
variant="outline"
className="gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500"
>
<AlertTriangle className="size-3" />
{m.store_source_stale()}
</Badge>
)}
</div>
<p className="truncate font-mono text-xs text-muted-foreground">
{s.url}
</p>
<p className="text-xs text-muted-foreground">
{m.store_source_entries({ count: s.entry_count })} ·{" "}
{m.store_source_fetched({ when: fmtFetched(s.fetched_at) })}
</p>
{s.error && (
<p className="text-xs text-destructive">{s.error}</p>
)}
</div>
{/* The built-in catalog gets no delete button at all — not a disabled one. */}
{!s.builtin && (
<Button
variant="ghost"
size="icon"
aria-label={m.store_source_remove()}
disabled={busyName === s.name}
onClick={() => onRemove(s)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
</div>
))}
</div>
</QueryState>
</CardContent>
</Card>
);
};
/** The add-source form. Reports a draft; the parent takes it through the trust dialog. */
export const AddSourceForm: FC<{
onSubmit: (draft: SourceDraft) => void;
isSaving: boolean;
}> = ({ onSubmit, isSaving }) => {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [publicKey, setPublicKey] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const key = publicKey.trim();
if (!name.trim() || !url.trim()) return;
onSubmit({
name: name.trim(),
url: url.trim(),
public_key: key ? key : undefined,
});
};
return (
<Card className="max-w-xl">
<CardHeader>
<CardTitle>{m.store_add_source_title()}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="store-source-name">
{m.store_field_source_name()}
</Label>
<Input
id="store-source-name"
required
autoComplete="off"
spellCheck={false}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="store-source-url">
{m.store_field_source_url()}
</Label>
<Input
id="store-source-url"
required
type="url"
inputMode="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="store-source-key">
{m.store_field_source_key()}
</Label>
<Input
id="store-source-key"
autoComplete="off"
spellCheck={false}
placeholder="ed25519:…"
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{m.store_field_source_key_help()}
</p>
</div>
<Button
type="submit"
disabled={isSaving || !name.trim() || !url.trim()}
>
{m.store_add_source()}
</Button>
</form>
</CardContent>
</Card>
);
};
/** The one-time trust warning shown before a third-party catalog is written to the host. */
export const TrustSourceDialog: FC<{
draft: SourceDraft | null;
isSaving: boolean;
onCancel: () => void;
onConfirm: () => void;
}> = ({ draft, isSaving, onCancel, onConfirm }) => (
<Dialog open={draft !== null} onOpenChange={(open) => !open && onCancel()}>
{draft && (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
{m.store_source_trust_title()}
</DialogTitle>
<DialogDescription>
{m.store_source_trust_body({ name: draft.name })}
</DialogDescription>
</DialogHeader>
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs break-all text-muted-foreground">
{draft.url}
</p>
{!draft.public_key && (
<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_source_trust_unsigned()}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
{m.common_cancel()}
</Button>
<Button disabled={isSaving} onClick={onConfirm}>
{m.store_source_trust_confirm()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
+89
View File
@@ -0,0 +1,89 @@
import {
BadgeCheck,
ShieldAlert,
ShieldQuestion,
Terminal,
} from "lucide-react";
import type { FC } from "react";
import type { StoreTier } from "@/api/store";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
// The trust model, rendered. These badges are the only place the console makes a claim about where
// a plugin's code came from, so they are deliberately literal: ONLY the built-in `unom` source earns
// the check mark, everything else says out loud that nobody at unom looked at the code. An entry
// from an operator-added source additionally carries a <SourceChip> naming that source — attribution
// instead of a verification it hasn't got.
/** The short, permanent provenance badge for a tier. */
export const TierBadge: FC<{ tier: StoreTier; className?: string }> = ({
tier,
className,
}) => {
switch (tier) {
case "verified":
return (
<Badge
variant="success"
className={cn("gap-1", className)}
title={m.store_tier_verified_hint()}
>
<BadgeCheck className="size-3.5" />
{m.store_tier_verified()}
</Badge>
);
case "external":
return (
<Badge
variant="outline"
className={cn(
"gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500",
className,
)}
title={m.store_tier_external_hint()}
>
<ShieldQuestion className="size-3.5" />
{m.store_tier_external()}
</Badge>
);
case "unverified":
return (
<Badge
variant="destructive"
className={cn("gap-1", className)}
title={m.store_tier_unverified_hint()}
>
<ShieldAlert className="size-3.5" />
{m.store_tier_unverified()}
</Badge>
);
default:
return (
<Badge
variant="secondary"
className={cn("gap-1", className)}
title={m.store_tier_cli_hint()}
>
<Terminal className="size-3.5" />
{m.store_tier_cli()}
</Badge>
);
}
};
/** "from <source>" — who curated this entry. Shown for anything not from the built-in source. */
export const SourceChip: FC<{ source: string; className?: string }> = ({
source,
className,
}) => (
<span
className={cn(
"inline-flex items-center gap-1 text-xs text-muted-foreground",
className,
)}
>
{m.store_from_source()}
<span className="font-medium text-foreground">{source}</span>
</span>
);
+149
View File
@@ -0,0 +1,149 @@
import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { type FC, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type InstallBody,
type InstalledPlugin,
type StoreEntry,
useInstallPlugin,
useStoreCatalog,
useUninstallPlugin,
} from "@/api/store";
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 { InstalledTab } from "./Installed";
import { JobProgressSection } from "./JobProgress";
import { SourcesTab } from "./Sources";
type StoreTab = "browse" | "installed" | "sources";
/**
* 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
* install/uninstall mutations, the confirm dialogs their trust tier dictates, and the job the host
* hands back (which must stay visible whichever tab you switch to while it runs).
*/
export const SectionStore: FC = () => {
useLocale();
const [tab, setTab] = useState<StoreTab>("browse");
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
const [specOpen, setSpecOpen] = useState(false);
// 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);
const catalog = useStoreCatalog();
const install = useInstallPlugin();
const uninstall = useUninstallPlugin();
/** Turn a failed 202-request into a message: 409 means the host is busy, not that we're broken. */
const failed = (e: unknown, fallback: string) =>
toast.error(
e instanceof ApiError && e.status === 409 ? m.store_busy() : fallback,
);
const start = async (body: InstallBody) => {
try {
const { job } = await install.mutateAsync(body);
setJobId(job);
} catch (e) {
failed(e, m.store_install_failed());
}
};
const onConfirmEntry = async (entry: StoreEntry) => {
setTarget(null);
await start({ source: entry.source, id: entry.id });
};
const onConfirmSpec = async (spec: string) => {
setSpecOpen(false);
await start({ spec, accept_unverified: true });
};
// 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.
const onUpdate = (plugin: InstalledPlugin) => {
const entry = catalog.data?.plugins.find((e) => e.pkg === plugin.pkg);
if (!entry) {
toast.error(m.store_update_no_entry());
return;
}
setTarget(entry);
};
const onUninstall = async (plugin: InstalledPlugin) => {
if (
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
)
return;
try {
const { job } = await uninstall.mutateAsync(plugin.pkg);
setJobId(job);
} catch (e) {
failed(e, m.store_uninstall_failed());
}
};
return (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.store_title()}</h1>
<p className="text-sm text-muted-foreground">{m.store_subtitle()}</p>
</div>
{jobId && (
<JobProgressSection jobId={jobId} onDismiss={() => setJobId(null)} />
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as StoreTab)}>
<TabsList>
<TabsTrigger value="browse">{m.store_tab_browse()}</TabsTrigger>
<TabsTrigger value="installed">
{m.store_tab_installed()}
</TabsTrigger>
<TabsTrigger value="sources">{m.store_tab_sources()}</TabsTrigger>
</TabsList>
<TabsContent value="browse">
<BrowseTab
onInstall={setTarget}
onInstallSpec={() => setSpecOpen(true)}
/>
</TabsContent>
<TabsContent value="installed">
<InstalledTab
onUpdate={onUpdate}
onUninstall={onUninstall}
busyPkg={
uninstall.isPending ? (uninstall.variables ?? null) : null
}
/>
</TabsContent>
<TabsContent value="sources">
<SourcesTab />
</TabsContent>
</Tabs>
<InstallDialog
entry={target}
isPending={install.isPending}
onCancel={() => setTarget(null)}
onConfirm={onConfirmEntry}
/>
<SpecInstallDialog
open={specOpen}
isPending={install.isPending}
onCancel={() => setSpecOpen(false)}
onConfirm={onConfirmSpec}
/>
</div>
</Section>
);
};
+91
View File
@@ -0,0 +1,91 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { StoreEntry } from "@/api/store";
import { StoreCard } from "@/sections/Store/Browse";
// The store catalog card, rendered straight from a fixture entry — it fetches nothing, so
// the visuals (padding, platform labels, tier chip, blocked/incompatible states) can be
// checked without a host or a catalog.
const meta = {
title: "Store/StoreCard",
component: StoreCard,
parameters: { layout: "centered" },
args: { onInstall: () => {} },
} satisfies Meta<typeof StoreCard>;
export default meta;
type Story = StoryObj<typeof meta>;
const BASE: StoreEntry = {
id: "rom-manager",
pkg: "@punktfunk/plugin-rom-manager",
title: "ROM Manager",
description:
"Scans your ROM directories, maps them to emulators, fetches box art, and reconciles everything into the host game library as a provider.",
icon: "gamepad-2",
author: "unom",
homepage: "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
license: "MIT OR Apache-2.0",
version: "0.3.2",
source: "unom official",
tier: "verified",
// The catalog stores lowercase platform IDENTIFIERS; the card renders display names.
platforms: ["linux", "windows"],
compatible: true,
update_available: false,
};
export const Default: Story = { args: { entry: BASE } };
export const AllPlatforms: Story = {
args: { entry: { ...BASE, platforms: ["linux", "windows", "macos"] } },
};
export const SinglePlatform: Story = {
args: {
entry: {
...BASE,
id: "playnite",
pkg: "@punktfunk/plugin-playnite",
title: "Playnite",
description:
"Syncs your Playnite library into the host game library — every store and emulator Playnite manages, launched back through Playnite.",
icon: "library-big",
version: "0.2.0",
platforms: ["windows"],
},
},
};
export const Installed: Story = {
args: { entry: { ...BASE, installed_version: "0.3.2" } },
};
export const UpdateAvailable: Story = {
args: {
entry: { ...BASE, installed_version: "0.3.1", update_available: true },
},
};
export const Incompatible: Story = {
args: {
entry: {
...BASE,
compatible: false,
incompatible_reason:
"needs punktfunk host ≥ 0.16.0 (this host is 0.15.0)",
},
},
};
export const Blocked: Story = {
args: {
entry: { ...BASE, blocked: "withdrawn by the author (CVE-2026-1234)" },
},
};
export const External: Story = {
args: {
entry: { ...BASE, tier: "external", source: "community catalog" },
},
};