A face was always built as a legacy FaceElem (face.index). QQ-NT only renders
classic small faces that way; newer 'super'/animated faces sent as face.index
are silently remapped by the server (e.g. 424→168) or shown as a placeholder.
Classify each face id off QQ's own system-face catalog (0x9154_1, ported from
SLE/Lagrange) and pick the matching wire form:
- super (animated, aniSticker pack ≠ (1,1)) → CommonElem 37 + QFaceExtra
- other id ≥ 260 → CommonElem 33 + QSmallFaceExtra
- classic id < 260 → legacy FaceElem
The catalog is fetched once (shared, lazy-warmed off the first face send) and
classify falls back to an id-range guess until it lands. Data-driven so all 66
non-(1,1) super faces encode correctly, not just a hardcoded few.
Verified on a live account: 424→424 (was 168), super 392→392, classic 14→14.
Phase 1 of the debug-console expansion. Sweeps the 8 action files,
swapping bare coercion primitives for the role-stamped equivalents from
Phase 0 (or appending `.role()` where a `.default()`/`.optional()` field
must keep its exact coercion). Coercion is byte-for-byte unchanged; the
only intentional bound change is face_id min 1→0 (QQ face 0 is valid).
Classification (verified against each run()):
- group_id → f.groupId() everywhere.
- user_id → f.userId() for any-user / friend / stranger / private-route
targets (send_private, friend poke, friend add/remove, stranger info,
private file transfer, forward-to-friend, share-contact).
- user_id → f.memberId() only when it's a member of a sibling group_id
(batch kick elements; the groupUserAction preset already injects it).
- media file/image/record + file_id → f.file()/image()/record()/fileId()
(or .role() when defaulted/optional); message_id/timestamp/duration via
the constructor or .role() hatch as coercion requires.
Polymorphic ids left as bare uint on purpose (no single role can honestly
describe them): mark_msg_as_read.target_id (group# or uin), bot_appid,
msg_seq, fileset_id, folder_id, chunk indices, stream_id.
Catalog regenerated: 71 group_id / 25 user_id / 9 member_id / 19
message_id / 11 file_id / 7 image / 6 file roles surfaced. Done by 4
parallel agents; adversarially reviewed — zero coercion regressions,
zero member/user misclassifications.
Phase 0 of the debug-console expansion. Introduces a closed `FieldRole`
union (group_id/user_id/member_id/message_id/file_id/file/image/record/
video/duration/timestamp/face_id) orthogonal to `type` (which governs
coercion), so the WebUI debug console can render semantic pickers instead
of forcing hand-written JSON.
- FieldDoc gains `role?`; Field gains a `.role()` modifier (survives
optional/default/describe). toJsonSchema emits it as `x-role`.
- Semantic constructors f.groupId/userId/memberId/fileId/file/image/
record/video/duration/timestamp/faceId wrap the coercion primitives and
stamp a role (messageId backfilled). Media sources reject '' to preserve
the empty-string guard they replace. faceId allows 0 (valid QQ face id).
- Extracted stringCore so f.string() and string-valued roles share it.
- ActionDoc gains `stream?`; defineStreamAction sets stream:true.
- Presets: groupAction injects f.groupId(); groupUserAction injects
f.groupId() + f.memberId() (a group action's user_id is a member of
that group). Backfills role onto 56 group_id / 7 member_id sites.
- Regenerated MCP catalog (79 x-role, 5 stream).
Adversarially reviewed: no coercion regression, member_id stamping has
zero false positives across all 7 groupUserAction sites, x-role is inert
to MCP consumers.
Surface the six Stream API actions in the self-generated docs catalog under
a new 流式接口 category: export each module's specs as an `actions` array
(register now iterates it) and widen the doc collector's spec type to anything
carrying describe() so defineStreamAction results qualify alongside
defineAction. Regenerated the MCP catalog snapshot.
End-to-end verified on a live account over HTTP: test_download_stream (10
frames + terminal), an upload_file_stream round-trip (200 KB chunked in →
sent as a c2c file → download_file_stream back out, byte- and sha256-exact),
and the SSRF guard rejecting a cloud-metadata URL.
NapCat-compatible server-push download: one request answered by a file_info
header, N base64 file_chunk frames, then a terminal file_complete. Bytes come
from a local file under the stream temp root, an http(s) URL, or a cached
image/voice id resolved to its QQ CDN URL. test_download_stream pushes 10
canned frames so a client can validate the transport without touching QQ.
Actions: download_file_stream / download_file_image_stream /
download_file_record_stream / test_download_stream.
Hardened beyond the NapCat original (which streams any local path and follows
redirects to anywhere), per three adversarial review passes:
- arbitrary-file-read: local reads are fenced inside the stream temp root,
realpath-resolved so a symlink can't point out.
- SSRF: every URL (incl. each redirect hop, followed manually) is guarded —
http(s) only, and neither the literal host nor any resolved address may be
private/loopback/link-local; IPv6 is byte-parsed so normalized v4-mapped
literals (::ffff:a9fe:a9fe) can't slip past. (DNS rebinding remains a
documented residual — needs connect-time peer-IP validation.)
- robustness: TTFB-only connect timeout (doesn't cut a large body mid-stream)
plus a per-chunk idle watchdog that aborts a stalled source so a hung
upstream can't pin a socket; total size capped; non-2xx bodies cancelled;
client disconnect aborts via the sink.
501 onebot tests green.
NapCat-compatible chunked upload: a client pushes a file in base64 chunks
across repeated upload_file_stream calls keyed by stream_id; the bot
reassembles them into a local temp file and returns its path for a normal
send. Sidesteps the 2 MB HTTP body cap and the shared-filesystem assumption
of file:// sends.
Reassembly streams chunk-by-chunk to disk (no whole-file Buffer.concat) so
an arbitrarily large upload won't OOM. clean_stream_temp_file wipes only the
stream temp dirs.
Hardened well past the NapCat original (which has all of these holes), per
two adversarial review passes:
- stream_id is allowlist-validated + every derived path fenced inside the
upload dir — no path traversal (the original lets stream_id write/delete
arbitrary paths).
- total_chunks / concurrent streams / per-chunk + per-stream bytes are all
bounded — no single-request OOM or disk/fd exhaustion.
- chunk indices are claimed synchronously and only counted complete once
flushed — no double-count race, no truncated-read-on-concurrent-complete.
- state + temp dirs are namespaced per account.
494 onebot tests green.
Foundation for NapCat-compatible Stream API actions (chunked file
upload/download): a single request answered by many response frames.
- streaming.ts: StreamSink + frame wrappers (wrapStreamFrame /
wrapStreamTerminal) matching NapCat's wire — each frame an OB11 envelope
with stream:'stream-action', data.type ∈ stream/response/error, and
message mirrored into wording.
- ApiHandler: registerStreamAction / isStreamAction; handle() threads a
sink; processStreamRequest drives WS multi-frame dispatch with optional
liveness-based abort; dead processRequest removed.
- action-kit: defineStreamAction (sink-aware run, registers as a stream
action) reusing defineAction's parse/describe.
- adapters: HTTP server streams chunked \r\n\r\n-delimited frames (no
Content-Length, no false application/json), aborting on client
disconnect; WS server/client use a backpressure-aware safeSendAsync and
abort a stream once the socket closes.
No stream actions are registered yet — those land in the next phases.
Verified by two adversarial review passes. 480 onebot tests green.
A video larger than MAX_VIDEO_SIZE (~100 MB, QQ's video-resource cap)
uploads "successfully" — the server accepts the Highway bytes — but the
receiver sees 视频已过期 because no valid video resource is ever produced.
The existing large-video → file fallback only fired from the send catch
block, i.e. when the upload threw. An oversized upload doesn't throw, so
the fallback never triggered. Add a proactive split before the send: any
video whose source size is known to exceed MAX_VIDEO_SIZE is re-routed to
the file pipeline up front (mirrored for private and group sends). Smaller
videos (verified up to ~49 MB) are unaffected and still send as videos.
This pairs with the c2c-file fix (f8caa92): the re-routed video now rides
the now-working offline-file path and downloads correctly.
Private (c2c) files larger than a few MiB uploaded fine but the receiver
always got "download failed" (#157). Two independent bugs in the offline-
file upload (OidbSvcTrpcTcp.0xe37 + highway cmdId 95), both confirmed on a
live account and against a plaintext packet capture of the real QQ client:
1. FileUploadExt.busiBuff carried a stale `busId: 102`. NapCat, Lagrange and
the QQ client all emit busiBuff with senderUin only. The extra busId let
the server accept the highway upload but never finalise it as a
downloadable offline file once the payload passed ~5 MiB.
2. md510MCheckSum used `10 * 1024 * 1024` (10485760) as the "first 10 MB"
limit. QQ's magic number is 0x98A000 = 10002432. With the wrong limit the
server's first-block verification failed for any file larger than
0x98A000, so files >~10 MB uploaded but never finalised either.
With both fixed, c2c files of arbitrary size (verified up to ~20 MB; the
general highway path has no inherent size cap) download correctly.
Real-machine retest showed a plain c2c file send (NotOnlineFile only, no
field6) is already downloadable — the original #157 'fix' made the 0xE37_800
finalize a hard dependency, so any finalize error would now fail a send that
would otherwise succeed (a regression, same speculative-NapCat-parity trap as
the #145 force-full).
Wrap the finalize in try/catch: on success we still attach field6 (extra
download routing that mirrors NapCat and may help some receiver clients); on
failure we log and send without it. field6 is now enrichment, not a
requirement.
Adds a regression test: a failing 0xE37_800 still produces a PbSendMsg with
the file and no field6.
forceFullOnFastPath re-issued the NTV2 upload with tryFastUploadCompleted:
false to try to refresh a possibly-expired video resource. Real-machine
testing on a live account + RE of the QQ NT kernel (wrapper.node) proved it
never works:
- the server's fast-path is driven by md5-metadata existence and ignores
the flag (proton omits a false bool anyway); the retry re-fast-paths
- the kernel itself does no validity check — HandleRspUploadV3 trusts the
response's fileExist and goes straight to SendUploadSuccFTN
So an expired-but-still-indexed video resource is a platform-level
limitation, not something the upload flow can refresh. Removing the retry
drops two wasted OIDB round-trips per cached-video re-send. The underlying
#145 (receiver '资源已过期' for a server-expired resource) remains a
platform issue, tracked separately.
Tests updated to assert the single-request behaviour.
QQ's silent hot-update churns the process table; the watcher's synchronous
native getAllMainProcess() (/proc walk reading cmdline/maps) blocks while a
process is mid-exec(). Running on the main event loop, that froze the whole
bot — OneBot stopped responding, the watcher stopped ticking, the supervisor
saw it 'alive' so never restarted it (silent death until manual restart).
- run the native enumeration in a worker_threads worker, raced against a
deadline on the main thread; a blocked call no longer stalls the loop
- on timeout/failure return an UNKNOWN sentinel (null); PipeWatcher keeps the
prior process+pipe snapshot instead of treating missing data as 'all QQ
gone' (which would dispose live sessions and tear down a healthy hook)
- graceful fallback to in-loop enumeration when the addon can't load in a
worker or there's no addon (macOS); custom listers (tests) bypass the worker
- bounded orphan workers across a sustained hang
Tests: PipeWatcher null-sentinel (no false process-gone, recovers); enumerator
sync-fallback + broken-isolation paths. Native /proc-walk hardening rides in
the nnphook repo separately.
The hover-rail flipped its content to a collapsed layout instantly while the
width animated, so on every expand/collapse icons jumped toward the top-left and
labels flashed in, wrapped super-tall, then settled — and the collapsed rail
leaked the first glyph of every label plus hard-clipped highlight bars.
Lay the rail content out at a fixed width and let the aside clip it: nothing
reflows during the width animation. Icons live in a fixed centered column (never
move); labels stay mounted with nowrap and just fade out when collapsed (no
wrap, no leak); the active pill and update banner render their background as an
absolute layer that becomes a centered squircle on the rail instead of a
hard-clipped bar.
Adds the promised pin toggle to the sidebar footer (desktop) — mirrors the
「钉住侧栏展开」 appearance setting: pinned keeps the rail open, unpinned returns
to hover-to-peek.
The flat tab strip mixed three domains — the dashboard itself, the all-accounts
SnowLuma protocol runtime, and the host/platform. Regroup into 3 labelled
sections (macOS System-Settings idiom): section headers show on the desktop
rail; the mobile strip stays a flat wrap via display:contents. Each group is a
role=group + aria-labelledby; the nav gains an aria-label.
This gives '全局配置' (and future global protocol knobs) a principled home and
ends the mixing. Phase 4 (final) of the global-settings split.
Adds a '全局配置' Settings tab that edits config/snowluma.json — the rkey
fallback-server list and the deployment-wide musicSignUrl — backed by a new
bearer-gated GET/POST /api/global-config (load/saveGlobalSettings, then
oneBotManager.reloadGlobalSettings to hot-apply to live instances).
The panel reuses the established settings-panel idiom (Input/Button/Label,
debounced auto-save, flash); rkey servers are an inline add/remove list with
client-side http(s) validation hints. It deliberately does not reconcile inputs
from the server's normalized response (would erase a half-typed URL); a
load-failure shows an error+retry, and the save toast says '无效项已忽略' when
the server would drop an entry.
Removes the now-dead per-UIN musicSignUrl form (moved to global in Phase 2).
Phase 3 of the global-settings split (nav grouping is Phase 4).
musicSignUrl is a deployment-wide signing-service endpoint (same for every
account), so it belongs in the global config/snowluma.json next to rkey, not in
per-UIN onebot_<uin>.json.
- GlobalSettings gains musicSignUrl (http(s)-validated, '' = built-in default);
consumption switches to globalSettings.musicSignUrl and hot-reloads via
applyGlobalSettings.
- One-shot copy-up at boot (migrateGlobalSettings, called first in main()):
lifts the first VALID legacy value from any config/onebot*.json into the
global store. Idempotent via a key-PRESENCE guard, so an intentional
clear-to-'' is never reverted by a stale legacy value.
- Remove musicSignUrl from per-UIN OneBotConfig (type + parse/serialize).
Phase 2 of the global-settings split. (WebUI form follows in Phase 3.)
The rkey fallback endpoint is deployment infra (one self-hosted server serves
all accounts; fallback rkeys are fungible), so it doesn't belong in per-UIN
onebot_<uin>.json. Introduce config/snowluma.json — a generic home for small
all-accounts SnowLuma settings — and move rkey.fallbackServers there.
- New onebot/global-config.ts: load/save/normalize with section-merge + atomic
write; http(s)+host URL validation; default [] (off). Lives in onebot (not
core) because the onebot manager constructs instances and must not depend on
core.
- OneBotInstance takes globalSettings; manager loads it on session start and
exposes reloadGlobalSettings() to hot-push edits to every live instance via
applyGlobalSettings → RKeyCache.setFallbackServers.
- Remove rkey from per-UIN OneBotConfig + the WebUI per-UIN round-trip (never
released, so no migration). Register snowluma.json in the backup allowlist.
Reworks the per-UIN rkey from 12b0190. Phase 1 of the global-settings split.
Private (c2c) file sends uploaded the bytes (apply 0xE37_1700 + Highway)
and PbSendMsg returned ok, but the receiver saw "文件传输失败": the message
carried only FileExtra.file (metadata) and not the server-issued download
routing. Group files use a different element, hence c2c-only.
Port NapCat's upload→finalize→send sequence:
- add 0xE37_800 (FinalizeOfflineFile) OIDB service, run after the Highway
PUT to fetch the download metadata (field30)
- extend FileExtra with field6 (PrivateFileExtra.field2) and populate it
from that metadata in sendC2cFile; covers both the url-upload and the
file_id-resend send paths (both funnel through sendC2cFile)
Tests: byte-oracle locks the 0xE37_800 request + FileExtra.field6 tags;
core routing test now asserts finalize runs and field6 is populated.
Needs real-machine verification (send a private file, confirm download).
On accounts where the native OIDB 0x9067_202 rkey fetch persistently returns
nothing, every image was served as a bare multimedia.nt.qq.com.cn URL the CDN
rejects with invalid rkey — SnowLuma had no fallback, unlike NapCat.
Add config.rkey.fallbackServers (default []: OFF; no third-party server is
contacted unless the operator configures one). When the native fetch leaves the
cache without a usable image rkey, each configured HTTP endpoint is tried in
turn for { group_rkey, private_rkey, expired_time } (NapCat rkey-server format;
an OneBot { retcode, data } wrapper is also accepted), populating the group/
private image rkey types. Values keep any leading &rkey=/?rkey= since
resolveImageUrl strips before re-appending.
WebUI has no form yet but round-trips rkey.fallbackServers verbatim so a hand-
edited value survives an unrelated config save. Part B of #156 (cooldown is A).
When a bot framework echoes a received image/voice/video, it resends the
original `file=<md5>.ext` (a QQ-internal media id) together with the
download `url`. The parser collapsed sources as `file ?? url ?? ...`, so
`file` won and the send path statSync'd the id as a local path → ENOENT
(retcode 1200). Reported in #155 for images via Yunzai复读.
Add pickMediaSource(): keep `file` when it is directly loadable (inline
base64/data bytes, a remote url, or a path with a separator); otherwise
fall back to a sibling http(s) `url`. Applied to image/record/video.
Tests cover the #155 echo case plus the path/base64-wins guards.
When the OIDB 0x9067_202 rkey fetch returns empty or fails, the cache stays
empty and resolveImageUrl hands out a bare multimedia.nt.qq.com.cn URL the CDN
rejects with {retcode:-5503010, invalid rkey}. The 30s refresh cooldown then
blacked out every image for 30s+ after each empty/failed fetch.
Use a short RKEY_EMPTY_COOLDOWN (10s) whenever no usable image rkey is cached,
keeping the 30s steady-state backoff only once we actually hold one — so a
transient empty fetch self-heals in seconds instead of 30s. (Part A of #156;
remote-server fallback follows.)
The needs-logs workflow tested /\bTRACE\b/i against the raw issue body.
Pasted terminal logs wrap the level in ANSI color codes (\x1b[90mTRACE\x1b[0m);
the trailing 'm' of the color code abuts 'TRACE', so the leading \b never
matches and a log that clearly contains TRACE is wrongly flagged needs-logs
(e.g. #156). Strip ANSI escapes first, and measure the too-short heuristic on
the cleaned text too.
The per-account 上下线通知渠道 picker rendered every global channel as a
flat toggle row, so 40+ channels produced an unbounded, messy wall inside
通用设置. Rework it into a collapsible control:
- collapsed: one-line summary — selected count badge + selected channels
as removable chips (cap 8, then "+N 更多")
- expanded: description + name/url search filter + 全选/清空 bulk actions
+ a height-bounded ScrollArea list (max-h-64) so it never blows up
- globally-disabled channels stay locked off; data flow unchanged
Reuses Badge / ScrollArea / ToggleSwitch / Input.
#153 added a video→file fallback but the group side was dead code: it
lived in element-builder behind `isGroup && element.fileId`, and a video
sent by url/path has no fileId, so oversize group videos just rethrew the
raw Highway error (real bot: `error_code=323` on a 1.4 GB group send).
Move the fallback to the OneBot layer for groups, mirroring the private
path: on a failed Highway send, re-route oversize videos through
groupFile.upload. Verified on a live bot — a 1.4 GB group video now lands
as `[文件:video.mp4]` instead of failing.
Extract the shared heuristic (isHighwaySizeError / videoNeedsFileFallback
/ splitVideoFileFallback) so private and group stay in sync, and drop the
now-unreachable element-builder video catch. Adds send-video-fallback
tests covering both the known-oversize and remote-error-heuristic paths.
Round 3: fix hardcoded 4096 MB in error messages
- Use MAX_VIDEO_SIZE_HARD constant instead of hardcoded 4096 MB
- Both throw sites now derive limit from the same constant
* fix(onebot): support inline file url/path in send_group/private_msg
* test(onebot): update file segment parser test for inline url/path support
* fix(onebot): strip at segments from private messages
Follow-up to the previous #146 fix, which dropped QQ's compat fallback text by
content-matching the "版本不支持 … 升级" family. RE of the QQ kernel
(wrapper.linux.node) showed that is not how QQ does it:
- the receiver binary contains NONE of these fallback strings, so QQ cannot be
matching by content;
- the on-target wire is [lightApp, text(compat), generalFlags, extraInfo] and the
text element has no distinguishing marker (no pbReserve / attr6Buf);
- QQ NT's kernel codec (msg_codec_mgr) collapses the message to a single ark
element — the sibling text is never emitted. NapCat maps kernel elements 1:1
with no ark-aware skip yet still surfaces only the card, confirming the kernel
drops it upstream.
Switch to that structural rule: when a lightApp/richMsg card is present, drop
sibling plain text outright. It is version/locale-proof (won't break when Tencent
rewords the string) and matches QQ NT + NapCat output exactly. @ mentions /
replies / faces are not plain text and survive; QQ clients can't attach a caption
to a card anyway, so no QQ-origin message loses real text.
Verified live on a logged-in build: get_msg returns a single json segment.
A QQ mini-program / ark share (B站 video, QQ 小程序, …) arrives on the wire
as a lightApp/richMsg ark element followed by a plain text element carrying
QQ's graceful-degradation compat string "当前QQ版本不支持此应用,请升级". That
text is meant only for protocol-old clients that can't render the card; QQ NT
and the kernel-backed bridges (NapCat) surface just the card.
On-target capture (test group) confirmed the text element has no distinguishing
wire marker (no pbReserve / attr6Buf) — it is a bare text sibling of the ark.
SnowLuma was decoding both, so every mini-app card came through as the card
plus a spurious "当前QQ版本不支持" text segment.
Drop the compat string when it sits beside a card, matched narrowly (the
"版本不支持 … 升级/更新" family, <=40 chars) so genuine user captions sent
alongside a card are preserved.
Verified live on a logged-in build: get_msg now returns a single json segment.
get_private_file_url required file_hash (and user_id), but NapCat needs
only file_id — it re-derives the hash from its composite file_id. Make
user_id and file_hash optional; the empty hash is accepted on the wire
(SnowLuma's private file_id already carries the md5 prefix). Verified
live: only-file_id returns a working URL (148237B download matches).
get_group_file_url rejected a present non-numeric busid with
`expected an integer` after the f.int().default(102) refactor, while
NapCat/LLBot omit busid entirely and ignore any value. Restore the
legacy `asNumber(busid) || 102` tolerance via f.raw()+busidOr102.
Verified live: null/"102"/omitted/"abc" all return a working URL.
The NTV2 fast-upload video test still asserted a single sendRawPacket
call, but #145 (forceFullOnFastPath) re-issues the OIDB request with
fast-upload disabled when a flagged main file carrying real bytes is
fast-pathed by the server. The stale assertion was never caught because
the dev push workflow doesn't run the suite — only the PR gate (ci.yml)
does, so it first surfaced on #149.
Assert two packets go out and that the second round drops
tryFastUploadCompleted, locking #145's behavior at the onebot layer.
Daily stale sweep over issues still labeled needs-logs: mark stale after
7 days of inactivity, close (not_planned) after another 7. Issues that
get valid logs lose the needs-logs label and are skipped by only-labels.
PRs are excluded.
Form validation can only force a field non-empty, not validate content.
On bug issue opened/edited, parse the TRACE-log section: if empty, missing
a TRACE marker, or too short, label needs-logs + post a one-time guide
comment; auto-remove the label once the reporter edits in valid logs.
- Make the logs textarea required so a bug report can't be submitted empty.
- Document how to enable TRACE: WebUI server log-level switch (+ '开发'
preset so the export actually contains trace lines) or SNOWLUMA_LOG_LEVEL=trace.
- Add a required preflight checkbox attesting TRACE logs are attached.
Pre-upload file/url/base64 segments for forwarded message nodes into the
target scope, re-upload cached file_ids when crossing private/group scope,
and suppress standalone file-message publishing during forward preparation.
Group/c2c video resources expire server-side. When the bot re-sends a
video whose md5 the server already cached, the NTV2 OIDB returns no uKey
for the main file (fast-upload hit) and SnowLuma trusted it — the send
"succeeds" but the receiver sees 资源已过期, because the reused object had
expired. Confirmed by the reporter: same MP4 fast-paths and breaks, but
mutating one byte (new md5) forces a full upload and plays fine.
Add a per-sub `forceFullOnFastPath` flag on MediaSubFileUpload. When a
flagged sub-file (video main) carries real bytes but the server fast-paths
it, runNtv2Upload re-issues the OIDB request with tryFastUploadCompleted
disabled (proton drops a `false` bool, which the server reads as "no
fast-upload"), forcing a fresh resource + full PUT. The thumb stays
fast-path-OK so a cached thumb never re-pushes the main video; forwarding
paths carry no bytes so they never trigger the retry.
Also fixes the observability gap the reporter hit: the main file being
silently skipped on a fast-path now logs explicitly, and so does the
forced-retry, instead of only the thumb PUT showing up.
Note: the retry relies on the server treating an absent
tryFastUploadCompleted as "no fast-upload" — needs real-device
confirmation that the forced round actually returns a uKey.
Replace fixed grid-cols-8 per-core bar display with dynamically
computed square grid that fills a fixed-height (80px) container.
- Adds bestGrid() utility that finds optimal (cols,rows) partition
by minimizing empty cells and aspect balance
- Grid uses grid-template-rows: repeat(N, 1fr) for equal row height
- Empty cells filled with hidden divs for stable layout
- Preserves existing color tokens (bg-muted, bg-primary/70, rounded-sm)
- No scrollbar regardless of core count (tested up to 256)
Closes#140
Co-authored-by: SnowLuma Dev <dev@snowluma.local>