Compare commits

...
18 Commits
Author SHA1 Message Date
Songlx516 ce55d08299 feat: 解压目标目录可选 + PKG 安装 ABI/权限修复
- 前端:解压前弹出目标目录对话框,支持手改路径与「浏览目录」导航
  选择器逐级选目录;冲突策略、7z 密码、大文件模式一并纳入。后端
  /api/extract 原本就收 dst_dir,无需改动。上传即解压仍落当前路径。
- pkg_installer.c:元数据 ABI 由 0x38 改为原生 0x30(移除 Mono 托管层
  多算的 slot/is_playgo_enabled 两个字段,旧布局会让 InstallByPackage
  参数错位);安装前补 kernel_set_ucred_authid(0x4800000000000006),
  权限失败以 PKG_INSTALL_PRIV_FAILED 区分;Makefile 链接 -lkernel_sys。
- i18n / HTML / CSS 同步新增解压对话框与目录选择器文案与样式。

注:PS5 专有改动(ABI/authid/链接)未经真机验证,需在 kstuff/etaHEN
环境实测安装路径是否真跑通。
2026-09-29 16:27:27 +08:00
Songlx516 c46f5969c8 build(check): make the demo5 sweep assertion able to fail
The old pin ("running progress bars sweep, capacity bars do not") only read
animationName — but getComputedStyle is readable on a display:none subtree, and
the element it found was the .track.pulse inside #view-tasks, hidden by default.
So it was green while nothing on screen was animating. That is exactly the
defect the user reported ("demo5 has no flashing progress bar").

ui_demos_check.mjs
- strength: require a non-zero layout box AND animationName !== "none"; switch to
  #view-tasks inside the assertion and switch back (synchronous, so it stays
  atomic under Promise.all). The negative half only checks the track's
  visibility, not the fill's — a queued row's fill is legitimately 0 wide.
- scope: also cover the task table's progress column (.row .bar[data-p]), which
  is the same task as the big card; classifying .bar by class had filed it under
  "capacity meter, never animate".
- print the total at the end: the "N assertions" figure in the docs had been
  obtained by grepping the output and was one too high (claimed 99, actually 98).
- comment: 8888 -> 2026 (the default port changed, the comment did not).

proposal_check.mjs
- keyword 99 项 -> 98 项, matching the measured count.
2026-09-27 13:03:57 +08:00
Songlx516 9c06fca17f test(ui): 候选收敛到 1/5 + 定名/端口/状态口径/动效成族的断言
第十一轮用户五条:去掉 etaHEN 状态显示、新项目起新名字与新端口、HTTP 与 SMB
服务也用「小绿灯」、demo 只留 1 和 5、把 demo1 进度条的闪光效果移植到 demo5
并在其他能加的地方也加。

拍板:项目定名 PS5 Nexus(仓库/ELF ps5-nexus),默认端口 2026。

- ui_demos_check.mjs:DEMOS 5 档 → 2 档(B/C/D 归档到工作区根的
  _retired-demos/),删掉对应断言 209 → 96;再加 3 条回归钉(demo1 三盏绿灯
  与地址都在吸顶导航条里且状态区不列打包发行版、demo5 状态区口径、demo5
  运行中进度条有扫光而静态容量条没有)→ 99 项全过。
- proposal_check.mjs:209 → 99,并新增 8 条命名/状态/动效关键词断言,另加
  一条「8888 只能作为『已改掉』的引述出现」的引述约束。

注:断言一律用渲染后的纯文本片段。textContent 会剥离标签,写
"<code>2026</code>" 这类关键词永远匹配不上,且不会抛错(假失败)。
2026-09-27 12:46:19 +08:00
Songlx516 400784942e test(ui): 本机地址断言 + 1100 断点档 + 地址硬规则
- ui_demos_check.mjs:
  · 新增本机地址断言(形如 IP:PORT / 非回环 / 命中区 ≥40px /
    点一下有可见反馈且 1.5s 后复原 / PS5 档在首屏内)
  · 命中区必须量 ::after —— 视觉高度按风格只有 24~37px,
    只量 getBoundingClientRect() 会把「看着小但点得中」误判成不合格
  · 回归钉改为逐个 await(点了按钮要等一个 tick 才看得到结果)
  · 对比度采样 25 → 30(每个 demo 加一处地址文字),
    备用主题下额外复测一次地址(--fg2/--fg3 是主题令牌,换色就可能掉出阈值)
  · 断点矩阵补 1100 档:1280 与 390 都给绿时,1100 上风格 C 顶栏溢出 24px
- proposal_check.mjs: 新增 8 条本机地址硬规则关键词断言
2026-09-27 11:07:58 +08:00
Songlx516 cfa02562b2 test(ui): 加 PS5 视口档与逐 demo 回归钉
背景:用户在 PS5 上直接打开这个插件用,屏幕形状与桌面不同
(浏览器可视区约 1920×970,横向充裕、纵向紧缺),且本轮起
游戏页要显示 PKG 封面、存档页对标 Garlic SaveMgr 的三栏骨架。

- ui_demos_check.mjs
  · 新增 PS5 视口档(1920×970):断言一级导航单行不折行、落在
    首屏顶部、无横向溢出,并逐个切视图再各量一遍
  · 单页长滚动的 demo(B/C/D)没有一级导航 ⇒ 导航项 SKIP 而不是
    FAIL —— 假失败和假通过一样会让人忽略整组断言
  · 新增 EXTRA 逐 demo 回归钉:demo1(导航置顶后触控目标仍 ≥44px、
    不再是左侧竖栏)、demo5(封面网格 ≥6、含抽不到 icon0.png 的
    回退态与加密锁定态、筛选真会筛、存档页有快照列与操作日志终端、
    空状态可来回切换)
  · EXTRA 的判定必须以字符串形式传入:playwright 的 evaluate 在
    序列化阶段就拒绝函数数组
- proposal_check.mjs
  · 新增 8 条界面硬规则关键词断言(kstuff 启动 / 1920×970 /
    顶部单行吸顶 / sce_sys/icon0.png / .covers/ / 抽不到必须回退 /
    底部操作日志终端 / 以及刻意不抄的那四个动作)
2026-09-27 10:02:49 +08:00
Songlx516 9a03fb7c05 test(ui): measure every view, not just the one that happens to be visible
Style A and E are view-switched pages (mutually exclusive `.view` containers).
The runner only ever measured the default view, so the other four were
unguarded — and the switch itself was never asserted. Style E's first version
had navigation that only moved the `aria-current` highlight while the content
stayed identical, which is exactly the failure this now catches.

Both gaps paid for themselves immediately:

- style A: `view-tasks` overflowed 65px at 390px. `.task` is a grid item whose
  `min-width:auto` resolved to min-content (371px) and burst the single column
  out of a 288px content area. Fixed with an explicit `minmax(0,1fr)` track,
  plus `overflow-wrap:anywhere` on the mono path line (a long
  `Media/Streaming/…/pak` segment has no break opportunity).
- style E: the fake navigation had to be replaced by real view switching.

Also tightened two things that were measuring the wrong element:

- style E's contrast targets moved into the default view — reading
  `getComputedStyle` on a `display:none` node returns values, but "measuring an
  element nobody can see" is not a check.
- the focus-ring group now switches to a view that actually contains a
  focusable row; otherwise the "inline inset ring" convention was silently
  skipped and the nav button was measured instead.

140 assertions, all passing.
2026-09-27 09:46:19 +08:00
Songlx516 9740a252a9 docs(check): pin the save-writeback "forced snapshot" vacuum as a regression
Enumerated every independent save-writeback implementation by fingerprinting the one
system call a write-back cannot avoid — sceFsCreatePfsSaveDataImage. 25 code hits,
minus SDK stubs and verbatim derived copies leaves 5 distinct projects; read each
write path. Result: 0 of 5 force a snapshot before write-back.

  garlic-savemgr      copy_file(local, ORIGINAL) with O_TRUNC -> the original is
                      truncated in place; no copy at all when it already lives on
                      /data/.  (src/main.c:419 + :692)
  garlic-worker       no write-back step at all, and save_periodic_cleanup()
                      unlinks every leftover copy -> using it as a backup loses
                      the save.  (src/ps5/savedata.c:287-298)
  elf-arsenal family  verbatim copy of the above
  ps5-sd-tool         tmp image -> copy_recursive -> unmount; 0 backup keywords
  apollo-ps4          real backup UI, but _addBackupCommands() lists "Apply Changes
                      & Resign" BEFORE the "File Backup" section -> edit first
  savescum (MIT)      best backup UX (timestamped), but restore never forces a
                      backup first, and FTP sees only plaintext
  vsh-utils/trophies  0 hits

So this is the domain's only vacuum, and it is a live data-loss path in the shipped
competitor rather than merely a missing feature. Recorded as the domain's admission
criterion, not an optional extra.

proposal_check.mjs:
- tables 14 -> 15 (new section 2-2-bis evidence table)
- NEW named assertion: no table may exceed the 960px content column. The generic
  page-overflow check did catch this, but pointed at the page instead of the cause.
  TRAP: the sheet's global `td:first-child{white-space:nowrap}` also matches a
  `td[colspan]` full of prose (it IS the first child of its row), so one note row
  rendered as a single 2265px line and blew the table out to 2085px. Fix was to
  remove the conflict — move the prose out of the table — not to raise specificity.
- keyword assertions for the four hard rules and the evidence
- quotedOnly guard: the mis-stated install-layer AuthID 0x3800000000000010 (zero hits
  in etaHEN; section 6 carried a stale copy of it until now) may only survive inside
  a correction, never as a live claim
2026-09-27 09:28:12 +08:00
Songlx516 9e0032c019 test(ui-demos): 将风格 E 纳入无头 UI 验收
- DEMOS 注册 ps5-ui-demo-5-harness.html,并补 5 个对比度采样点
- 备用主题分支由「仅 demo4 暗色」改为按 demo 配置:
  D 亮→暗、E 暗→亮,两个方向都验(原来只测了一半)
- 截图前先 scrollTo(0,0):焦点组调用 el.focus() 会把目标行滚进视口,
  导致长页 demo 的整页截图拍到中段而非首屏
2026-09-26 19:57:54 +08:00
Songlx516 3af704f854 test(ui-demos): pin archive-suffix stripping for the new-subdir name
The four UI demos and the proposal spelled the auto-created folder as
`PPSA28495-app.rar/` -- the archive's full filename including its
extension. The folder must be `PPSA28495-app/`.

- demos 1-4: the subdir target is now `.../PPSA28495-app/`
- proposal: add the derivation spec -- strip the WHOLE archive suffix,
  not just the last extension (`a.part01.rar` -> `a`, not `a.part01`;
  `a.7z.001` -> `a`, not `a.7z`), reusing the regexes already in
  assets/main.js:816-869 -- plus a worked strip table covering .rar /
  .zip / .7z / RAR5 volumes / 7z byte-split sets
- proposal_check: table count 13 -> 14, assert the spec text survives,
  and quotedOnly("-app.rar/") so the wrong target cannot return as a
  live claim
- ui_demos_check: assert no `.(zip|rar|7z)/` path appears in any demo
2026-09-26 17:58:53 +08:00
Songlx516 2fb1a1cf56 test(ui): add headless UI-demo checker; whitelist it
Four UI style demos for the rewrite project now share one information
architecture, so the review criteria that keep them honest are worth
pinning as assertions rather than eyeballing:

- horizontal overflow at 1920 / 1280 / 390, including *after* opening
  the notes drawer and the extract dialog — a transform-translated
  drawer still extends the scrollable area, and `visibility:hidden`
  does not stop it
- WCAG-AA contrast on 20 sampled text/background pairs, measured from
  computed styles with an ancestor walk for the effective background.
  This is how a 4.43:1 secondary colour was found (threshold 4.5:1)
- focus ring actually renders, and the focused row stays inside its
  parent box (the inline-inset-ring convention for 46-52px list rows)
- no `button[disabled][title]` — `disabled` implies
  `pointer-events:none`, so the tooltip explaining *why* a button is
  disabled can never appear; the demos use `aria-disabled` instead
- content baseline: the four nav domains, the kstuff platform chip, the
  average-speed progress wording, the save-mount singleton notice, the
  pick-a-directory extract target, and "make subdirectory" defaulting
  to unchecked

The script also screenshots each demo and inlines them into the
workspace-root comparison page as data URIs (kept single-file, no
external requests), marking placeholders with a `data-shot` attribute
so re-runs are idempotent.

104 assertions pass.
2026-09-26 17:39:52 +08:00
Songlx516 9f3e91241e test(proposal-check): pin the singleDPI / no-etaHEN install-layer facts
Round 7 of the rewrite proposal added section 2-1-bis, which pins the install
path to a self-built installer that needs kstuff only (no etaHEN). Guard the
load-bearing facts so a later edit cannot silently drop them:

- GPL-3.0-or-later reuse right + the NOTICE attribution obligation
- the AuthID that actually works: DEBUG_AUTHID 0x4800000000000006
  (the documented 0x3800000000000010 has zero hits in the etaHEN source)
- self-elevation (kernel_set_ucred_authid / -lkernel_sys) and status polling
  (sceAppInstUtilGetInstallStatus), both of which this repo currently lacks
- the MetaInfo 0x38 -> 0x30 ABI correction: slot / is_playgo_enabled came from
  the Mono managed signature, not from the native struct
- DPI v2 being browser-reachable (Access-Control-Allow-Origin)

Also generalise the falsified-claim guard into quotedOnly() and add a second
case for "the SDK grants the permission" (rebutted by singleDPI's explicit
kernel_set_ucred_authid call, which is what actually runs on the console).

Tables 10 -> 13; 37 assertions, all passing.
2026-09-26 16:53:21 +08:00
Songlx516 de4b74c354 test(proposal-check): fix blind 390px table assertion, add install-layer guards
- 旧的窄屏表格断言用 scrollWidth/clientWidth 比较,而 `table{overflow:hidden}`
  让二者恒等 ⇒ 该断言永远不可能触发(假绿)。改为量 bounding rect 与视口
  比较,并放行 .dectable 横向滚动容器内的宽表 —— 正是这个盲点让 §8 表格
  的 12px 溢出漏检。
- 新增 6 条内容断言:kstuff / VoidShell / elf-arsenal / /proc/kstuff /
  autoload.txt / "没安装过 etaHEN"。
- 新增 1 条反向断言:被证伪的「HEN 事实标准就是 etaHEN」不得以断言形式
  回归;作为历史引文(上下文含 作废/修正/已删除)则允许存在。
- 结构断言:表格 9 → 10(新增 §2③ 竞品格局)。
2026-09-26 16:31:49 +08:00
Songlx516 fb7ac30c15 test: track the rewrite-proposal render check
`.build/proposal_check.mjs` verifies `ps5-nas-rewrite-proposal.html` -- a
document that deliberately lives outside the repo -- by loading it in real
Chromium and asserting: zero external requests, no horizontal overflow at
1100px and 390px, and the structural counts its sections depend on.

It was the last harness with no copy anywhere but this machine, so clearing
`.build` would have lost it. Adds it to the .gitignore allowlist next to the
other render checks and extends its assertions for the fourth capability
domain (save management):

- tables 8 -> 9, phase cards 5 -> 6
- six content assertions (garlic-savemgr, /dev/pfsmgr, sceFsMountSaveData,
  save-management heading, Phase 5, re-sign) so a later edit cannot silently
  drop that whole section
2026-09-26 16:22:39 +08:00
Songlx516 ede12cb914 docs: the READMEs said the harnesses aren't tracked -- they are now
Follow-up to the .gitignore whitelist. Both READMEs described the frontend
harnesses as living "outside the gitignore whitelist", which stopped being true
one commit ago. Reword both, and add .build/ to the project-layout tree now
that it holds tracked files.
2026-09-26 12:14:18 +08:00
Songlx516 edd1a1c177 chore: track the .build validation harnesses in git
The eight validation scripts live under .build/, which is an otherwise
whitelisted scratch directory. They were not on the whitelist, so they were
tracked nowhere -- a scratch-tree cleanup deletes them permanently, and there
is no copy on the release page either.

- ui_retry_test.mjs, ui_upload_menu_test.mjs -- headless-DOM tests against
  assets/main.js (extract password retry flow; upload menu, i18n key coverage,
  CSS cascade scoping, extract-button state)
- preview_build.py, preview_stub.html, preview_check.mjs -- serve the real
  index.html against a stub API and drive it in headless Chromium (hidden and
  focus state, layout overflow, computed highlight values, wrap thresholds)
- readme_header_render.mjs, compare_html_check.mjs, compare_simple_check.mjs --
  render checks for the README header badges and the comparison pages

Scripts themselves are unchanged; this only stops .gitignore from hiding them.
2026-09-26 12:12:41 +08:00
Songlx516 9d8c49a1d4 docs: correct upstream comparison claims, add a short release-notes template
Three "only this fork has it" claims contradicted upstream's own source:

* Encrypted archives are not fork-only. Upstream's frontend carries a full
  password flow (archive_password_required, prompt(), and it persists the
  password to localStorage), and its helper IS 7-Zip. Moved to "shared".
* Split volumes are not fork-only either: upstream's extension regex already
  matches .001 and its README lists .part01.rar.
* "The helper is ~100 MB" was wrong -- the released asset is 1,017,616 B, and
  the correction reverses the verdict: our single file is 33.7% smaller than
  upstream's two files combined.

Neither claim could have been caught from the READMEs: upstream's never says
the word "password". Same for the mixed-encoding note, which now says what the
fork actually adds (decoding the error text) instead of claiming the mechanism.

Also restored the inherited "rename" feature to both READMEs -- upstream lists
it, we have it, the fork's feature list had dropped it.

Release notes are now a flat "What's Changed" bullet list instead of an essay;
.github/release-notes-template.md records the shape so they stay short.
2026-09-25 14:43:53 +08:00
Songlx516 f85e60ed8c docs: rewrite README around features, move per-version history to CHANGELOG
Both READMEs had grown into an upstream-style append log ("What's new in
v1.7 / v1.8 / v1.8.1 / v1.8.2 / v1.9 / v1.9.2 / v1.9.3M") that also dropped
several features the upstream README still documents. Rewrite both as
feature-oriented descriptions with one shared outline.

- README.md, README.zh-CN.md: feature-oriented rewrite. New sections for
  the archive support matrix, limits and safety, conflict policy,
  password handling, what is deliberately not built, and an
  upstream-vs-us comparison table that includes the rows where upstream
  wins.
- Add header badges (release / license / target / downloads) and two
  for-the-badge buttons (Download, beginner's guide). The release badge
  reads github/v/release so it tracks the latest release instead of
  hard-coding a version; colours are pinned so they do not clash.
- CHANGELOG.md: write the missing [v1.9.1] and [v1.9.2] body sections
  before that prose was removed from the README, so nothing was lost.
- Fix 4 dead links to third_party/unrar/ (renamed unrar7/ in v1.9) and one
  reference to a README section that no longer exists.
- HANDOVER.md: update the handover commit and README structure note.

Documentation only. No binary rebuild, and tag v1.9.3M with its release
asset is untouched.

Verified: link + anchor audit across the tree (now including raw HTML
href/src, not just Markdown syntax) reports 0 problems; table column
counts consistent; bold pairing clean; the new header renders with 6/6
badges loaded at both 1280px and 420px viewports and no horizontal
overflow.
2026-09-25 13:45:40 +08:00
Songlx516 770dcb8a9f docs: mark v1.9.3M as published
The release is out, so every "unreleased / not yet committed" statement in the
docs became false. Updated:

  - CHANGELOG: the artifact header says published and links the release; the
    [v1.9.3M] section carries the release date; the sentence claiming the
    GitHub release was "still v1.9.2" is gone
  - README (both languages): the version line reads v1.9.3M, the "what's new"
    heading loses its "(unreleased)" qualifier, and the note around it links
    the release instead of saying the published binary lacks all of it
  - HANDOVER: current commit / tag / release, the artifact table row (no longer
    "worktree, uncommitted"), the test counts (27 -> 40 retry checks, plus the
    40 menu checks and the 12 headless assertions), and the two section headers
    that said "not committed"
  - DEVICE-TEST: the checklist has now been run and passed, so it is an
    acceptance record rather than a to-do; the rollback pointer also offers the
    v1.9.2 release page
  - FORUM-POST-v1.9.2: the note about two gaps closed in the worktree but not
    yet released now says they shipped in v1.9.3M

Left alone deliberately: the single remaining "the then-unreleased worktree"
phrase in DEVICE-TEST, which states a historical fact about the copy that was
named v1.9.2.
2026-09-24 21:11:40 +08:00
28 changed files with 3163 additions and 894 deletions

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
/* Render the fork-vs-upstream comparison page, verify the filter logic actually
filters, and shoot it for review.
Run: node .build/compare_html_check.mjs
*/
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
const { chromium } = playwright;
const URL =
"file:///C:/Users/songl/Desktop/Web%20File%20Manager/ps5-wfm-fork-vs-upstream.html";
const OUT = "C:/Users/songl/AppData/Local/Temp/readme-header";
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 1320, height: 1100 },
deviceScaleFactor: 2,
});
page.on("pageerror", e => console.log("pageerror: " + e.message));
page.on("console", m => {
if (m.type() === "error") console.log("console.error: " + m.text());
});
await page.goto(URL, { waitUntil: "load" });
await page.waitForTimeout(300);
const counts = () =>
page.evaluate(() => {
const vis = sel => document.querySelectorAll(sel).length;
const byKind = {};
document.querySelectorAll("tbody tr").forEach(r => {
const k = r.getAttribute("data-kind") || "common";
byKind[k] = (byKind[k] || 0) + 1;
});
return {
total: byKind.common + byKind.ours + byKind.theirs,
byKind,
visible: vis("tbody tr:not(.hide)"),
visibleSections: vis("section[data-section]:not(.hide)"),
chips: [...document.querySelectorAll(".filters button")].map(
b => b.dataset.filter + "=" + b.querySelector(".count").textContent
),
};
});
console.log("initial:", JSON.stringify(await counts()));
for (const f of ["ours", "theirs", "diff", "all"]) {
await page.click(`.filters button[data-filter="${f}"]`);
await page.waitForTimeout(120);
const c = await counts();
console.log(
`filter=${f.padEnd(6)} visible rows=${c.visible} visible sections=${c.visibleSections}`
);
}
await page.click('.filters button[data-filter="all"]');
await page.waitForTimeout(150);
await page.screenshot({ path: `${OUT}/compare-page.png`, fullPage: true });
await page.click('.filters button[data-filter="diff"]');
await page.waitForTimeout(150);
await page.screenshot({ path: `${OUT}/compare-diff.png`, fullPage: true });
console.log("screenshots -> " + OUT);
await browser.close();
+103
View File
@@ -0,0 +1,103 @@
// Verify the simplified comparison page: no external requests, no horizontal
// overflow at narrow widths, expected row count, and that the two "winner"
// columns carry readable contrast. Fails loudly (exit 1) on any problem.
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
const FILE = "file:///C:/Users/songl/Desktop/Web File Manager/ps5-wfm-simple-compare.html";
const browser = await playwright.chromium.launch();
let fails = 0;
const check = (ok, msg) => { console.log(`${ok ? " ok " : " FAIL "} ${msg}`); if (!ok) fails++; };
// ---- 1. desktop: offline + structure ----
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } });
const external = [];
page.on("request", r => { if (!r.url().startsWith("file://")) external.push(r.url()); });
page.on("requestfailed", r => external.push("FAILED " + r.url()));
await page.goto(FILE, { waitUntil: "networkidle" });
const info = await page.evaluate(() => {
const rows = [...document.querySelectorAll("tbody tr")];
const cells = rows.map(r => [...r.children].map(td => td.innerText.replace(/\s+/g, " ").trim()));
return {
h1: document.querySelector("h1").innerText.trim(),
rows: rows.length,
cols: new Set(rows.map(r => r.children.length)).size,
empty: cells.filter(c => c.some(t => t === "")).length,
pick: document.querySelectorAll(".pick div").length,
same: document.querySelectorAll("ul.same li").length,
bodyH: document.body.scrollHeight,
// per row: the verdict chip text in each of the two answer columns.
// Read the DOM (.tag) rather than regex-matching a hardcoded word list,
// so rewording a verdict never causes a false failure.
tags: rows.map(r => [...r.children].slice(1).map(td => {
const el = td.querySelector(".tag");
return el ? el.innerText.trim() : "";
})),
};
});
check(external.length === 0, `no external requests (got ${external.length}${external.length ? ": " + external[0] : ""})`);
check(info.rows === 10, `10 difference rows (got ${info.rows})`);
check(info.cols === 1, "every row has the same column count");
check(info.empty === 0, "no empty cells");
check(info.pick === 2, "two 'which to pick' cards");
check(info.same === 6, `6 'identical' bullet lines (got ${info.same})`);
check(/两个版本/.test(info.h1), `heading present: ${info.h1}`);
// every row must have a verdict tag in both answer columns -> no row is silent
const missing = info.tags.map((t, i) => (t[0] && t[1] ? null : `row${i + 1}:[${t}]`)).filter(Boolean);
check(missing.length === 0, `both columns give a verdict on every row${missing.length ? " -> " + missing.join(" ") : ""}`);
// ---- 2. responsive: table on desktop, stacked cards on phones ----
const BREAKPOINT = 700;
for (const w of [1280, 768, 701, 700, 390, 320]) {
const p = await browser.newPage({ viewport: { width: w, height: 900 } });
await p.goto(FILE, { waitUntil: "load" });
const r = await p.evaluate(() => {
const de = document.documentElement;
const tw = document.querySelector(".tw");
const thead = document.querySelector("thead");
const row = document.querySelector("tbody tr");
const cell = document.querySelector("tbody td:nth-child(2)");
const before = getComputedStyle(cell, "::before");
return {
pageOverflow: de.scrollWidth > de.clientWidth + 1,
tableScrolls: tw.scrollWidth > tw.clientWidth,
cardMode: getComputedStyle(thead).display === "none",
rowDisplay: getComputedStyle(row).display,
labelVisible: before.display !== "none" && before.content !== "none" && before.content !== "normal",
usable: tw.clientWidth,
tableMin: getComputedStyle(document.querySelector("table")).minWidth,
};
});
const expectCards = w <= BREAKPOINT;
check(!r.pageOverflow, `${w}px: no page-level horizontal overflow`);
check(r.usable > 240, `${w}px: content area usable (${r.usable}px)`);
check(r.cardMode === expectCards, `${w}px: ${expectCards ? "cards" : "table"} layout`);
// Nothing should ever need sideways scrolling: cards reflow, and the table
// only renders once the viewport can actually fit it.
check(r.tableScrolls === false, `${w}px: no sideways scrolling (table min-width ${r.tableMin})`);
check(
r.labelVisible === expectCards,
`${w}px: column labels ${expectCards ? "shown inside cards" : "hidden in table mode"}`
);
if (expectCards) {
check(r.rowDisplay === "block", `${w}px: rows stack as blocks`);
}
await p.close();
}
// ---- 3. screenshots ----
const shots = [
["wide", 1100, false],
["narrow", 390, true],
];
for (const [label, width, full] of shots) {
const p = await browser.newPage({ viewport: { width, height: 1000 }, deviceScaleFactor: 2 });
await p.goto(FILE, { waitUntil: "load" });
await p.screenshot({ path: `C:/Users/songl/AppData/Local/Temp/wfm-compare/simple-${label}.png`, fullPage: full });
await p.close();
}
console.log(`\nscreenshots -> C:/Users/songl/AppData/Local/Temp/wfm-compare/simple-{wide,narrow}.png`);
await browser.close();
console.log(fails ? `\n${fails} FAILURES` : "\nall checks passed");
process.exit(fails ? 1 : 0);
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Rebuild the offline preview under .build/preview/ from the real assets.
python .build/preview_build.py
Copies assets/* and re-injects .build/preview_stub.html into a COPY of
index.html, so the page can be served from a plain static server
(.build/preview_check.mjs drives it with a browser). assets/ is never touched.
"""
from pathlib import Path
import shutil
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "assets"
DST = ROOT / ".build" / "preview"
STUB = (ROOT / ".build" / "preview_stub.html").read_text(encoding="utf-8")
MARKER = ' <script src="/main.js"></script>'
DST.mkdir(parents=True, exist_ok=True)
for item in SRC.iterdir():
if item.is_file():
shutil.copy2(item, DST / item.name)
html = (DST / "index.html").read_text(encoding="utf-8")
if MARKER not in html:
raise SystemExit("index.html has no <script src=\"/main.js\"> tag to hook")
(DST / "index.html").write_text(html.replace(MARKER, STUB + MARKER), encoding="utf-8")
print("preview ready:", DST)
print("serve it with: python -m http.server 8899 --bind 127.0.0.1 (run inside .build/preview)")
+267
View File
@@ -0,0 +1,267 @@
/* Screenshot the toolbar out of the offline preview harness and report what the
upload menu did, so the change can be looked at instead of trusted.
Run: node .build/preview_check.mjs
*/
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
const { chromium } = playwright;
const URL = "http://127.0.0.1:8899/index.html";
const out = [];
const fails = [];
function assert(cond, label) {
out.push((cond ? " ok " : " FAIL ") + label);
if (!cond) fails.push(label);
}
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, locale: "zh-CN" });
page.on("pageerror", err => out.push("pageerror: " + err.message));
await page.goto(URL, { waitUntil: "load" });
await page.waitForTimeout(1200);
const boxOf = selector => page.$eval(selector, el => {
const r = el.getBoundingClientRect();
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
});
const hiddenOf = selector => page.$eval(selector, el => el.hidden);
out.push("menu hidden at rest: " + await hiddenOf("#uploadMenu"));
out.push("hint hidden? " + await hiddenOf("#dropHint") + " text=" +
await page.$eval("#dropHint", el => JSON.stringify(el.textContent)));
out.push("button label: " + JSON.stringify(await page.$eval("#uploadBtn", el => el.textContent)));
out.push("button box: " + JSON.stringify(await boxOf("#uploadBtn")));
out.push("toolbar box (one row is 69px tall inside it): " + JSON.stringify(await boxOf(".toolbar")));
out.push("footer box: " + JSON.stringify(await boxOf(".status")));
out.push("hint box: " + JSON.stringify(await boxOf("#dropHint")));
out.push("hint is between the status and the version: " +
await page.evaluate(() => {
const status = document.getElementById("statusText").getBoundingClientRect();
const hint = document.getElementById("dropHint").getBoundingClientRect();
const version = document.getElementById("versionText").getBoundingClientRect();
return status.right <= hint.left && hint.right <= version.left;
}));
out.push("status text box: " + JSON.stringify(await boxOf("#statusText")));
await page.click("#uploadBtn");
await page.waitForTimeout(250);
out.push("menu hidden after click: " + await hiddenOf("#uploadMenu"));
out.push("aria-expanded: " + await page.$eval("#uploadBtn", el => el.getAttribute("aria-expanded")));
out.push("menu box: " + JSON.stringify(await boxOf("#uploadMenu")));
out.push("items: " + JSON.stringify(await page.$$eval("#uploadMenu button",
els => els.map(el => el.textContent))));
out.push("focused: " + await page.evaluate(() => document.activeElement && document.activeElement.id));
await page.screenshot({ path: ".build/preview/menu-open.png" });
/* The row highlight is a cascade question -- specificity, source order, and a
generic rule that was never meant to reach a 46px list row -- so it can only
be checked by a real engine reading back the computed values. */
const rowStyle = sel => page.$eval(sel, el => {
const cs = getComputedStyle(el);
const panel = document.getElementById("uploadMenu").getBoundingClientRect();
const r = el.getBoundingClientRect();
return {
background: cs.backgroundColor,
outline: cs.outlineWidth + " " + cs.outlineStyle,
outlineStyle: cs.outlineStyle,
boxShadow: cs.boxShadow,
bleeds: (r.left < panel.left) || (r.right > panel.right) ||
(r.top < panel.top) || (r.bottom > panel.bottom),
};
});
const PANEL_FILL = "rgb(43, 52, 62)"; /* #2b343e -- the menu's own row fill */
const TOOLBAR_HOVER = "rgb(48, 57, 69)"; /* #303945 -- the toolbar's hover fill */
const focusedRow = await rowStyle("#uploadFilesItem");
out.push("a focused menu row carries no outer ring: " + (focusedRow.outlineStyle === "none"));
out.push(" outline was: " + focusedRow.outline);
out.push("a menu row never paints outside its panel: " + !focusedRow.bleeds);
await page.hover("#uploadFolderItem");
await page.waitForTimeout(200);
const hoveredRow = await rowStyle("#uploadFolderItem");
out.push("a hovered row uses the menu fill, not the toolbar's: " + (hoveredRow.background === PANEL_FILL));
out.push(" hover fill was: " + hoveredRow.background + " (the toolbar's would be " + TOOLBAR_HOVER + ")");
out.push(" and it does not bleed outside the panel: " + !hoveredRow.bleeds);
/* Keyboard navigation is where the focus cue has to be visible, and it must be
drawn inside the row so it cannot cross the panel edge. */
await page.mouse.move(20, 700);
await page.keyboard.press("ArrowDown");
await page.waitForTimeout(200);
const keyedRow = await rowStyle("#uploadFolderItem");
out.push("ArrowDown moves the highlight to the second row: " +
await page.evaluate(() => document.activeElement && document.activeElement.id));
out.push(" the keyboard focus cue is an inset ring: " + /inset/.test(keyedRow.boxShadow));
out.push(" its outline is still suppressed: " + (keyedRow.outlineStyle === "none"));
out.push(" and the row stays inside the panel: " + !keyedRow.bleeds);
out.push(" it is a single inset cue, not a stack of rings: " +
((keyedRow.boxShadow.match(/inset/g) || []).length === 1));
const menuClip = await page.$eval("#uploadMenu", el => {
const r = el.getBoundingClientRect();
return { x: Math.max(0, r.x - 36), y: Math.max(0, r.y - 36), width: r.width + 72, height: r.height + 72 };
});
await page.screenshot({ path: ".build/preview/menu-highlight.png", clip: menuClip });
await page.keyboard.press("ArrowUp");
await page.waitForTimeout(150);
out.push("ArrowUp wraps back to the first row: " +
await page.evaluate(() => document.activeElement && document.activeElement.id));
/* the file entry must reach the hidden <input type=file> */
await page.evaluate(() => {
const input = document.getElementById("uploadFiles");
window.__picked = false;
input.addEventListener("click", event => { window.__picked = true; event.preventDefault(); });
});
await page.click("#uploadFilesItem");
await page.waitForTimeout(200);
out.push("file input was clicked: " + await page.evaluate(() => window.__picked));
out.push("menu hidden after choosing: " + await hiddenOf("#uploadMenu"));
await page.click("#uploadBtn");
await page.waitForTimeout(150);
await page.mouse.click(640, 660);
await page.waitForTimeout(150);
out.push("menu hidden after an outside click: " + await hiddenOf("#uploadMenu"));
await page.click("#uploadBtn");
await page.waitForTimeout(150);
await page.keyboard.press("Escape");
await page.waitForTimeout(150);
out.push("menu hidden after Escape: " + await hiddenOf("#uploadMenu"));
/* the wide layout: the button must not fall off the right edge */
await page.setViewportSize({ width: 1920, height: 1080 });
await page.waitForTimeout(300);
await page.screenshot({ path: ".build/preview/wide.png" });
/* the narrow layout, where the toolbar wraps */
await page.setViewportSize({ width: 1024, height: 720 });
await page.waitForTimeout(300);
out.push("narrow button box: " + JSON.stringify(await boxOf("#uploadBtn")));
out.push("narrow toolbar box: " + JSON.stringify(await boxOf(".toolbar")));
out.push("narrow hint box: " + JSON.stringify(await boxOf("#dropHint")));
out.push("narrow hint still fits the footer: " +
await page.evaluate(() => {
const footer = document.querySelector(".status").getBoundingClientRect();
const hint = document.getElementById("dropHint").getBoundingClientRect();
return hint.bottom <= footer.bottom + 1 && hint.right <= footer.right + 1;
}));
/* a long status must not squeeze the hint out of the footer */
await page.evaluate(() => {
document.getElementById("statusText").textContent =
"正在上传 3/12: PPSA16608-2026-09-24-full-backup-part03.zip";
});
await page.waitForTimeout(150);
out.push("with a long status, footer box: " + JSON.stringify(await boxOf(".status")));
out.push(" hint box: " + JSON.stringify(await boxOf("#dropHint")));
await page.screenshot({ path: ".build/preview/long-status.png" });
await page.click("#uploadBtn");
await page.waitForTimeout(250);
out.push("narrow menu box: " + JSON.stringify(await boxOf("#uploadMenu")));
out.push("narrow menu is inside the window: " +
await page.$eval("#uploadMenu", el => el.getBoundingClientRect().right <= window.innerWidth + 1));
await page.screenshot({ path: ".build/preview/narrow.png" });
/* ---- the extract button: always on screen, greyed until it can be used --- */
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(250);
const toolbarHeight = () => page.$eval(".toolbar", el => Math.round(el.getBoundingClientRect().height));
const toolbarFits = async widths => {
const result = [];
for (const width of widths) {
await page.setViewportSize({ width, height: 800 });
await page.waitForTimeout(200);
result.push(width + ":" + (await toolbarHeight()));
}
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(200);
return result;
};
const extractState = () => page.$eval("#extractBtn", el => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const bar = document.querySelector(".toolbar").getBoundingClientRect();
return {
hidden: el.hidden,
disabled: el.disabled,
text: el.textContent,
title: el.title,
width: Math.round(r.width),
opacity: cs.opacity,
insideToolbar: r.top >= bar.top - 1 && r.bottom <= bar.bottom + 1,
onScreen: r.right <= window.innerWidth + 1 && r.left >= -1,
};
});
const selectPaths = async paths => {
await page.evaluate(() => {
for (const box of document.querySelectorAll("#content .select-cell input")) {
box.checked = false;
box.dispatchEvent(new Event("change", { bubbles: true }));
}
});
for (const p of paths) {
await page.evaluate(path => {
const row = document.querySelector('#content tr[data-path="' + CSS.escape(path) + '"]');
const box = row && row.querySelector(".select-cell input");
if (!box) throw new Error("no checkbox for " + path);
box.checked = true;
box.dispatchEvent(new Event("change", { bubbles: true }));
}, p);
}
await page.waitForTimeout(180);
};
const rest = await extractState();
out.push("extract button at rest: " + JSON.stringify(rest));
assert(!rest.hidden, "the extract button is on screen with nothing selected");
assert(rest.disabled, "and it is disabled");
assert(rest.opacity !== "1", "and it is drawn dimmed (opacity " + rest.opacity + ")");
assert(rest.insideToolbar && rest.onScreen, "and it sits inside the toolbar, on screen");
assert(rest.title.length > 0, "and its tooltip explains why: " + JSON.stringify(rest.title));
await page.screenshot({ path: ".build/preview/extract-disabled.png", clip: { x: 0, y: 54, width: 760, height: 90 } });
await page.screenshot({ path: ".build/preview/extract-disabled-tight.png", clip: { x: 524, y: 60, width: 176, height: 78 } });
await selectPaths(["/saves"]);
const onFolder = await extractState();
assert(onFolder.disabled, "selecting a folder keeps it disabled");
await selectPaths(["/PPSA16608.zip"]);
const onArchive = await extractState();
assert(!onArchive.disabled, "selecting one archive enables it");
assert(onArchive.opacity === "1", "and it becomes fully opaque");
assert(onArchive.title.indexOf("PPSA16608.zip") >= 0, "and the tooltip names the archive: " + JSON.stringify(onArchive.title));
await page.screenshot({ path: ".build/preview/extract-enabled.png", clip: { x: 0, y: 54, width: 760, height: 90 } });
await page.screenshot({ path: ".build/preview/extract-enabled-tight.png", clip: { x: 524, y: 60, width: 176, height: 78 } });
await selectPaths(["/PPSA16608.zip", "/\u6e38\u620f\u5907\u4efd.7z"]);
const onTwo = await extractState();
assert(onTwo.disabled, "selecting two archives disables it again");
assert(onTwo.title.indexOf("\u4e00\u6b21\u53ea\u80fd") >= 0, "with its own message: " + JSON.stringify(onTwo.title));
await selectPaths([]);
/* Keeping the button on screen widens the resting toolbar by its own width, so
the wrap point has to be pinned. Measured in the zh locale, which is the one
the console runs: it moved from 1080px to 1190px when the button stopped
being hidden. The English labels are wider and that build wraps 1280px. */
const fits = await toolbarFits([1920, 1600, 1280]);
out.push("toolbar height at 1920/1600/1280 (one row is 85): " + fits.join(" "));
assert(fits.every(entry => Number(entry.split(":")[1]) < 100),
"the toolbar still fits on one row at every realistic console width");
await browser.close();
console.log(out.join("\n"));
console.log(fails.length
? "\n" + fails.length + " FAILED:\n " + fails.join("\n ")
: "\nall " + out.filter(line => line.indexOf(" ok ") === 0).length + " assertions passed");
process.exit(fails.length ? 1 : 0);
+48
View File
@@ -0,0 +1,48 @@
<script>
/* Offline fixture: every /api call the page makes is answered here. Injected
into a copy of index.html by .build/preview_build.py, just before the real
script tag, so the toolbar can be rendered without a console. */
(function () {
var now = Math.floor(Date.now() / 1000);
var dirs = {
"/": [
{ name: "PPSA16608.zip", type: "-", mode: 420, size: 3980000000, mtime: now - 3600 },
{ name: "\u6e38\u620f\u5907\u4efd.7z", type: "-", mode: 420, size: 12345678, mtime: now - 7200 },
{ name: "saves", type: "d", mode: 493, size: 0, mtime: now - 86400 }
]
};
function json(payload) {
return Promise.resolve(new Response(JSON.stringify(payload), {
status: 200, headers: { "Content-Type": "application/json" }
}));
}
window.fetch = function (url) {
var target = String(url);
var query = target.split("?")[1] || "";
var path = "/";
query.split("&").forEach(function (kv) {
var pair = kv.split("=");
if (pair[0] === "path") path = decodeURIComponent(pair[1] || "/");
});
if (target.indexOf("/api/list") === 0) {
var entries = (dirs[path] || []).map(function (entry) {
return Object.assign({}, entry, { path: (path === "/" ? "" : path) + "/" + entry.name });
});
return json({ ok: true, path: path, parent: "/", entries: entries });
}
if (target.indexOf("/api/tasks") === 0) {
return json({ ok: true, tasks: [], completion: null, now: now });
}
if (target.indexOf("/api/space") === 0) {
return json({ ok: true, mounts: [
{ label_key: "storageRoot", path: "/", free: 812000000000, total: 2000000000000, current: true }
] });
}
if (target.indexOf("/api/version") === 0) {
return json({ ok: true, version: "v1.9.3M", title_id: "FMGR88888" });
}
if (target.indexOf("/api/") === 0) return json({ ok: true });
return new Promise(function () {});
};
})();
</script>
+194
View File
@@ -0,0 +1,194 @@
// Headless check for ps5-nas-rewrite-proposal.html
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
import fs from "node:fs";
const FILE = "file:///C:/Users/songl/Desktop/Web File Manager/ps5-nas-rewrite-proposal.html";
const OUT = "C:/Users/songl/AppData/Local/Temp/wfm-proposal";
fs.mkdirSync(OUT, { recursive: true });
let fails = 0;
const check = (ok, name) => { console.log((ok ? "PASS" : "FAIL") + " " + name); if (!ok) fails++; };
const browser = await playwright.chromium.launch();
// external request guard
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } });
const ext = [];
page.on("request", r => { if (!r.url().startsWith("file://")) ext.push(r.url()); });
await page.goto(FILE, { waitUntil: "load" });
await page.waitForTimeout(300);
check(ext.length === 0, `zero external requests (${ext.length})`);
const r = await page.evaluate(() => ({
pageOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
tables: document.querySelectorAll("table").length,
phases: document.querySelectorAll(".phase").length,
h2: [...document.querySelectorAll("h2")].map(h => h.textContent.trim()),
bars: [...document.querySelectorAll(".speedbar .bar")].map(b => b.style.width),
widestTable: Math.max(0, ...[...document.querySelectorAll("table")].map(t => Math.round(t.getBoundingClientRect().width))),
body: document.body.textContent,
}));
check(!r.pageOverflow, "wide: no horizontal overflow");
// Named, so the failure message points at the root cause instead of "page overflows".
// TRAP (hit 2026-09-27): the stylesheet has a global `td:first-child{white-space:nowrap}`.
// A wide table's first cell is therefore pinned to one line, and a `td[colspan]` full of
// prose counts as `:first-child` too — one such row pushed a table to 2085px inside a
// 912px column, which is a 2265px single line. Fix by removing the conflict (move prose
// out of the table / use <br>), NOT by raising specificity or adding nowrap overrides.
const WRAP = 960; // .wrap{max-width:960px}
check(r.widestTable <= WRAP, `no table exceeds the 960px content column (widest ${r.widestTable}px)`);
check(r.tables === 15, `15 tables present (${r.tables})`);
// the "package name" derivation spec must survive edits (2026-09-26 round 9): the new
// subdir name strips the WHOLE archive suffix, so a target like …-app.rar/ must never return
for (const key of ["剥掉的整段后缀", "整段后缀匹配", "part01.rar", "isRarSubVolume", "回退用完整文件名"]) {
check(r.body.includes(key), `subdir-name spec present: ${key}`);
}
check(r.phases === 6, `6 phase cards present (${r.phases})`);
check(r.bars.length === 12, `12 speed bars (${r.bars.length})`);
// save-manager capability domain must survive edits (section 2-2 / 6 / 7-Phase5)
for (const key of ["garlic-savemgr", "/dev/pfsmgr", "sceFsMountSaveData", "存档管理", "Phase 5", "重签"]) {
check(r.body.includes(key), `save-mgr content present: ${key}`);
}
// install-layer dependency must stay corrected: kstuff, NOT etaHEN (2026-09-26 round 6)
for (const key of ["kstuff", "VoidShell", "elf-arsenal", "/proc/kstuff", "autoload.txt", "没安装过 etaHEN"]) {
check(r.body.includes(key), `install-layer/competitor content present: ${key}`);
}
// singleDPI / "extract DPI, don't depend on etaHEN" (2026-09-26 round 7). These pin the
// load-bearing facts: the GPL-3.0 reuse right, the real AuthID, the three-part readiness
// probe, and the MetaInfo 0x30 ABI correction. Losing any of them silently guts the plan.
for (const key of [
"singleDPI", "ps5-direct-package-installer",
"GPL-3.0-or-later", "NOTICE", // code may be reused, with attribution
"DEBUG_AUTHID", "0x4800000000000006", // the AuthID that actually works (code, not docs)
"kernel_set_ucred_authid", "kernel_sys", // self-elevation we currently lack
"sceAppInstUtilGetInstallStatus", // status polling we currently lack
"0x2700", "0x30", "is_playgo_enabled", // MetaInfo 8-field -> 6-field correction
"Access-Control-Allow-Origin", // DPI v2 is browser-reachable (no app needed)
]) {
check(r.body.includes(key), `singleDPI content present: ${key}`);
}
// falsified claims may only survive as QUOTED corrections, never as live claims
const quotedOnly = (claim, allowRe, label) => {
let i = r.body.indexOf(claim), live = false;
while (i !== -1) {
const ctx = r.body.slice(Math.max(0, i - 160), i + 160);
if (!allowRe.test(ctx)) live = true;
i = r.body.indexOf(claim, i + 1);
}
check(!live, label);
};
quotedOnly("事实标准就是 etaHEN", /作废|修正|已删除/, "no unqualified 'etaHEN is the de-facto HEN' claim survives");
quotedOnly("SDK 自动给", /收回|推翻|修正/, "no unqualified 'the SDK grants the permission' claim survives");
check(r.body.includes("SDK 给不了"), "the corrected ShellCore-permission statement is present");
quotedOnly("-app.rar/", /指出|修正|原型里写成了/, "no live '…-app.rar/' subdir target survives");
// save-writeback safety (2026-09-27 round 8): "forced snapshot before write-back" was
// verified to be a Vacuum — enumerate the implementation set by fingerprinting the one
// system call every write-back must use, then read each write path. These keys keep the
// differentiator and its evidence alive; losing them silently downgrades us to
// "another save manager", which is exactly what the plan decided not to be.
for (const key of [
"写回前强制留快照", // the question this round answered
"sceFsCreatePfsSaveDataImage", // the fingerprint used to enumerate implementations
"savescum", "apollo-ps4", // the two closest competitors: both non-forcing
"O_TRUNC", // garlic's in-place truncating write-back
"save_periodic_cleanup", // the cleanup that deletes garlic's own copy
"/data/savesnap/", // where OUR snapshots must live (independent dir)
"强制且不可跳过", "失败自动回滚", // the two load-bearing hard rules
]) {
check(r.body.includes(key), `save-writeback content present: ${key}`);
}
// UI hard rules (2026-09-27 round 9). PS5's viewport SHAPE (wide, short) is what forces
// the top nav — not taste. And the library cover must come from INSIDE the pkg, not from
// a scraper. Lose these and the decisions the user already made get re-litigated.
for (const key of [
"kstuff 启动", // the status wording the user picked (was "在位")
"1920×970", // the PS5 viewport shape that forces the top nav
"顶部单行吸顶", // nav placement rule
"sce_sys/icon0.png", // where covers actually come from
".covers/", // the runtime cover cache
"抽不到必须回退", // no blank holes in the cover grid
"底部操作日志终端", // the Garlic-derived save-page skeleton
"Decrypt / Encrypt / Resign / Import", // and the part we deliberately do NOT copy
]) {
check(r.body.includes(key), `UI rule content present: ${key}`);
}
// local-address rule (2026-09-27 round 10). The address is a FUNCTIONAL entry point
// (how you open this same UI from a PC/phone), not decoration — and PS5 has no
// `ipconfig`, so the UI is the only place it can come from. These keys pin the two
// implementation constraints that are easy to "simplify away" later and would then be
// quietly wrong: the port is probed at runtime, and clipboard is unavailable over plain
// http on a LAN IP.
for (const key of [
"本机地址", // the rule itself
"find_available_port", // the port is NOT a constant (8888 gets bumped)
"navigator.clipboard", // the API that does not exist in this deployment
"execCommand", // ...so the fallback must stay
"局域网地址而非回环", // 127.0.0.1 is useless on the OTHER device
"::after", // hit area is expanded separately from visual height
"IP:PORT", // the accepted shape
"98 项", // acceptance count (was 209; B/C/D retired in round 11)
]) {
check(r.body.includes(key), `local-address rule present: ${key}`);
}
// the install-layer AuthID was mis-stated in section 6 until this round; it may only
// survive as a quoted correction, never as a live claim
quotedOnly("0x3800000000000010", /0 命中|作废|误记|误写|上一轮/, "no live '0x3800000000000010' AuthID survives");
// naming / port / status wording / motion family (2026-09-27 round 11). The project is
// `PS5 Nexus` on port 2026; the status strip lists ONLY running services (never a
// third-party bundle's install state -- we depend on the kstuff leaf, not on a packager);
// and motion is a deliberate FAMILY (running progress bar sweep / breathing LEDs / CTA
// sheen) rather than scattered decoration. Losing any of these silently re-opens
// decisions the user already made.
for (const key of [
"PS5 Nexus", // the project name replacing "文件与安装中心"
"ps5-nexus", // repo / ELF name
"默认 2026", // the new default port (was 8888).
// NB: assert on rendered text, not markup — `textContent` strips tags, so a key like
// "<code>2026</code>" can never match and would fail forever without anyone noticing.
"状态区只列「正在跑的服务」", // the status-strip rule
"打包发行版", // ...and what must NOT be listed there
"动效要克制", // the motion-family rule
"var(--pri-fg)", // inverted-button sheen must use the fg colour
"首尾同色", // seamless loop for the sweep gradient
]) {
check(r.body.includes(key), `naming/status/motion content present: ${key}`);
}
// 8888 may only survive as a quoted "we changed it" note, never as the live default
quotedOnly("默认 <code>8888</code>", /第六轮|改过来|避开|旧仓/, "no live '8888 is the default port' claim survives");
console.log(" sections: " + r.h2.join(" | "));
await page.screenshot({ path: OUT + "/proposal-wide.png", fullPage: true });
await page.close();
// narrow
const np = await browser.newPage({ viewport: { width: 390, height: 844 } });
await np.goto(FILE, { waitUntil: "load" });
const nr = await np.evaluate(() => {
const de = document.documentElement;
const cw = de.clientWidth;
// NOTE: `table{overflow:hidden}` makes scrollWidth == clientWidth, so the old
// scrollWidth test could never fire. Measure the box against the viewport, and
// ignore tables that sit inside a horizontal scroll container (the .dectable
// convention) — those are allowed to be wide.
const clipped = el => {
for (let a = el.parentElement; a && a !== de; a = a.parentElement) {
const ox = getComputedStyle(a).overflowX;
if (ox === "auto" || ox === "scroll" || ox === "hidden") return true;
}
return false;
};
const wide = [...document.querySelectorAll("table")]
.filter(t => t.getBoundingClientRect().right > cw + 1 && !clipped(t));
return {
pageOverflow: de.scrollWidth > cw + 1,
wideTables: wide.length,
which: wide.map(t => `${t.className || "no-class"}:${Math.round(t.getBoundingClientRect().width)}px`),
};
});
check(!nr.pageOverflow, "390px: no page-level overflow");
check(nr.wideTables === 0, `390px: no unclipped wide table (${nr.wideTables}) ${nr.which.join(" ")}`);
await np.screenshot({ path: OUT + "/proposal-narrow.png", fullPage: true });
await np.close();
await browser.close();
console.log(fails ? `\n${fails} FAILURE(S)` : "\nALL CHECKS PASSED");
process.exit(fails ? 1 : 0);
+114
View File
@@ -0,0 +1,114 @@
/* Render the README header the way GitHub would, with the real shields.io
images, and report whether every badge actually loaded.
Run: node .build/readme_header_render.mjs
*/
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
import fs from "node:fs";
import path from "node:path";
const { chromium } = playwright;
const REPO = "C:/Users/songl/Desktop/Web File Manager/ps5-web-file-manager";
const OUT = "C:/Users/songl/AppData/Local/Temp/readme-header";
fs.mkdirSync(OUT, { recursive: true });
function extract(file) {
const text = fs.readFileSync(path.join(REPO, file), "utf8");
const div = (text.match(/<div align="right">[\s\S]*?<\/div>/) || [""])[0];
const ps = text.match(/<p align="center">[\s\S]*?<\/p>/g) || [];
const h1 = (text.match(/^# (.+)$/m) || [, ""])[1];
const quote = (text.match(/^(?:> .*\n)+/m) || [""])[0]
.split(/\r?\n/).filter(Boolean).map(l => l.replace(/^> ?/, "")).join(" ");
return { div, ps, h1, quote, file };
}
function page_html(d) {
return `<!doctype html><meta charset="utf-8">
<style>
body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,"Microsoft YaHei",sans-serif;
color:#1f2328;background:#fff;margin:0;padding:32px;max-width:1012px}
h1{font-size:2em;font-weight:600;border-bottom:1px solid #d1d9e0;padding-bottom:.3em;margin:.67em 0}
blockquote{margin:0 0 16px;padding:0 1em;color:#59636e;border-left:.25em solid #d1d9e0}
p img{vertical-align:middle}
</style>
${d.div}
<h1>${d.h1}</h1>
${d.ps.join("\n")}
<blockquote>${d.quote}</blockquote>`;
}
const browser = await chromium.launch();
const report = [];
for (const file of ["README.md", "README.zh-CN.md"]) {
const d = extract(file);
for (const [label, width] of [["wide", 1280], ["narrow", 420]]) {
const page = await browser.newPage({
viewport: { width, height: 700 },
deviceScaleFactor: 2,
});
await page.setContent(page_html(d), { waitUntil: "load" });
await page
.waitForFunction(
() => [...document.images].every(i => i.complete),
null,
{ timeout: 20000 }
)
.catch(() => {});
await page.waitForTimeout(400);
const imgs = await page.$$eval("img", els =>
els.map(e => ({
src: e.currentSrc || e.src,
w: e.naturalWidth,
h: e.naturalHeight,
alt: e.alt,
box: Math.round(e.getBoundingClientRect().width),
}))
);
const ok = imgs.filter(i => i.w > 0).length;
report.push(`--- ${file} @${label}(${width}px): ${ok}/${imgs.length} badges loaded`);
for (const i of imgs) {
const tail = i.src.split("/").slice(-2).join("/");
report.push(` ${i.w}x${i.h} ${String(i.box).padStart(4)}px ${tail}`);
}
const overflow = await page.evaluate(() => document.body.scrollWidth > window.innerWidth);
report.push(` horizontal overflow: ${overflow}`);
await page.screenshot({
path: path.join(OUT, `${file.replace(/[^\w.]/g, "_")}.${label}.png`),
fullPage: label === "narrow",
});
await page.close();
if (label === "wide") {
// 5x zoom of the badge row — at 1x the colours are guesswork
const zoom = await browser.newPage({
viewport: { width: 640, height: 48 },
deviceScaleFactor: 5,
});
await zoom.setContent(
`<body style="margin:0;background:#fff">${d.ps[0]}</body>`,
{ waitUntil: "load" }
);
await zoom
.waitForFunction(
() => [...document.images].every(i => i.complete && i.naturalWidth > 0),
null,
{ timeout: 20000 }
)
.catch(() => {});
await zoom.waitForTimeout(300);
await zoom.screenshot({
path: path.join(OUT, `${file.replace(/[^\w.]/g, "_")}.badges-zoom.png`),
});
await zoom.close();
}
}
}
await browser.close();
console.log(report.join("\n"));
console.log("screenshots -> " + OUT);
+495
View File
@@ -0,0 +1,495 @@
// ============================================================
// UI 风格 demo 校验 + 截图 + 对比页生成
// 跑法: node .build/ui_demos_check.mjs
// 检查对象:工作区根的 ps5-ui-demo-{1,5}-*.html(这些 HTML 本身不入库;
// 2026-09-27 起 B/C/D 三个候选已归档到工作区根的 _retired-demos/)
//
// 为什么这个脚本值得存在:
// ① 溢出:transform 移出视口的抽屉会撑开 documentElement 滚动区,
// 静态检查抓不住,只有真引擎能量出来(本次真实踩过 404px)。
// ② 对比度:fg3 #737b8c on #0e1014 = 4.43:1,低于 4.5 阈值 —— 肉眼看不出来。
// ③ 焦点环:密集列表里全局 outline 会压住邻行,必须验「行内 inset 环」。
// ④ 禁用按钮:button:disabled 带 pointer-events:none ⇒ title 永远弹不出来。
// ⑤ 宽度档不能只挑整数:1100 这种「不整不齐」的窗口宽度才是真实现场 ——
// 风格 C 的顶栏正是在 1100 溢出 24px,而 1280 与 390 两档都给绿。
// ⑥ 命中区与视觉高度是两件事:控件可以只画 24px 高(风格 B/C 的底栏就这么高),
// 但 PS5 是触摸板光标 ⇒ 命中区必须 ≥40px,得用 ::after 单独撑,断言也要单独量。
// ============================================================
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
import fs from "node:fs";
import path from "node:path";
const ROOT = "C:/Users/songl/Desktop/Web File Manager";
const OUT = "C:/Users/songl/AppData/Local/Temp/wfm-ui";
fs.mkdirSync(OUT, { recursive: true });
// ⚠️ 2026-09-27 第五轮:候选收敛到 A 与 E 两条,B/C/D 已归档到工作区根的 _retired-demos/。
// 删掉它们的同时也删掉对应断言 —— 为已经出局的风格维持检查,成本远大于收益。
const DEMOS = [
{ key: "demo1", file: "ps5-ui-demo-1-console.html", name: "风格 A · 主机大厅" },
{ key: "demo5", file: "ps5-ui-demo-5-harness.html", name: "风格 E · Harness 开发者页" },
];
// 备用主题方向:只剩 demo5 是双主题(默认暗,忠于站点)→ 切亮再验一遍。
const ALT_THEME = { demo5: "light" };
// 每个 demo 各自的对比度采样点:正文 / 次要文字 / 强调文字 / 有底色的提示条
const CONTRAST_TARGETS = {
demo1: [["h1", "大标题"], [".crumb", "次要说明"], [".row .meta", "行内数字"], [".chip", "状态胶囊"], [".banner", "提示条"], [".ipchip .ipv", "本机地址"]],
// demo5 的采样点刻意跨了「页面底 / 卡片底 / 强调底」三种底色,
// 因为这套语言全靠近黑底 + 极低对比叠层,底色一变就容易掉出阈值。
// ⚠️ 六处全部落在**默认视图**内:视图化之后其余视图是 display:none,
// getComputedStyle 仍读得出颜色,但「量一个看不见的元素」没有意义。
demo5: [[".kicker", "大写分区标签"], ["#heroSub", "Hero 副标题"], [".card p", "卡片正文"], [".facts .pill.n", "状态胶囊"], [".note.info", "品牌色提示条"], [".ipchip .ipv", "本机地址"]],
};
let fails = 0, total = 0;
/* ⚠️ total 不能省:文档里「N 项断言」这个数字上一轮是靠人 grep 输出数出来的,结果多报了一项
(文档写 99、实际 98 —— HEAD 版脚本复跑同样是 98)。现在每次运行末尾直接打印总数,
改文档时照抄,不再靠数。 */
const check = (ok, name) => { total++; console.log((ok ? " PASS " : " FAIL ") + name); if (!ok) fails++; };
/* 逐 demo 的回归钉:只验「这个 demo 该有的东西」,不硬套到别的 demo 上。
⚠️ 必须是**字符串形式**的箭头函数:playwright 的 evaluate 只会序列化普通值,
把函数数组直接当参数传,会在序列化阶段就抛
"Attempting to serialize unexpected value"(本次真踩过)。 */
const EXTRA = {
demo1: [
["三盏状态绿灯与地址都在吸顶导航条里,且状态区不列第三方打包发行版",
`() => { const rail = document.querySelector(".toprail"); if (!rail) return false;
const r = rail.getBoundingClientRect();
const items = [...rail.querySelectorAll(".led, .chip")].filter(e => /kstuff|HTTP|SMB|\\d+\\.\\d+/.test(e.textContent));
if (items.length < 4) return false; // 三盏灯 + 地址胶囊
const t = rail.textContent;
if (!/kstuff/.test(t) || !/HTTP/.test(t) || !/SMB/.test(t)) return false;
if (/etaHEN/.test(t)) return false; // 状态区只列正在跑的服务
return items.every(e => { const b = e.getBoundingClientRect();
return b.top >= r.top - 1 && b.bottom <= r.bottom + 1; }); }`],
["导航改到顶部后触控目标仍 ≥44px(PS5 用触摸板光标)",
`() => [...document.querySelectorAll(".nav button")].every(b => b.getBoundingClientRect().height >= 44)`],
["导航不再产生左侧竖栏(横向空间全让给内容)",
`() => { const r = document.querySelector("nav").getBoundingClientRect();
return r.width > 900 && r.height <= 80; }`],
],
demo5: [
["状态区只列正在跑的服务,不列第三方打包发行版",
`() => { const el = document.querySelector(".topbar"); if (!el) return false;
const t = el.textContent;
return /kstuff/.test(t) && /HTTP/.test(t) && /SMB/.test(t) && !/etaHEN|未安装/.test(t); }`],
["扫光只给正在运行的条:大卡与「进度」列都在闪,排队行与容量条不闪",
`() => {
/* ⚠️ 断言必须同时要求「可见」。computed style 在 display:none 的子树上**照样读得到**
(animationName 仍是 "sh"),所以老版本只验「动画名不是 none」时,元素根本看不见也会
全绿 —— 假通过。.track.pulse 住在 #view-tasks 里,默认(概览)视图下它就是
display:none;2026-09-27 用户报「demo5 的进度条闪光效果没有」,根因有两层:
⚠️ 这些检查体是**模板字面量**,里面连注释都不能出现反引号 —— 会把字符串提前截断,
报成「Cannot read properties of undefined」。全角引号、角括号都可以,反引号不行。
另一层根因是「同一屏、同一个任务,大卡在闪而表格那一列『进度』不闪」。
一层是这条断言测不到可见性,另一层是「同一屏同一个任务,大卡在闪、表格『进度』列不闪」。
⚠️ 下面切视图的代码是**纯同步**的(无 await),所以在 Promise.all 里是原子的:
其它检查不可能观察到切走/切回的中间态。将来若给它加 await,必须改成串行或加锁。 */
const prev = document.querySelector(".view.on");
const prevId = prev ? prev.id : null;
document.querySelectorAll(".view").forEach(v => v.classList.toggle("on", v.id === "view-tasks"));
const visEl = el => !!el && el.offsetWidth > 0 && el.offsetHeight > 0;
const fillOf = el => (el ? el.querySelector("i") : null);
/* 「在闪」= 填充条真的有宽度 + 动画名不是 none。两条都要:
只有动画名会让「元素根本看不见」的条也判成在闪(就是这条断言上次假通过的原因)。 */
const sweeps = el => { const f = fillOf(el);
return visEl(f) && getComputedStyle(f).animationName !== "none"; };
/* 「不该闪」量的是轨道可见性 + 填充的动画名 —— 不要要求填充条自己可见:
排队/已完成的填充宽度是 0(甚至没有填色),那属于「没在跑」,不是「没渲染」。 */
const still = el => visEl(el) && getComputedStyle(fillOf(el)).animationName === "none";
const bars = [...document.querySelectorAll(".row .bar")];
const live = bars.find(b => b.hasAttribute("data-p")); // 「进度」列:JS 在推的那条
const queued = bars.find(b => !b.hasAttribute("data-p")); // 排队/已完成:不该闪
const ok = sweeps(document.querySelector(".track.pulse")) && sweeps(live) &&
still(queued) && still(document.querySelector(".meter .bar"));
document.querySelectorAll(".view").forEach(v => v.classList.toggle("on", v.id === prevId));
return ok; }`],
["游戏页有封面网格(≥6 张封面)",
`() => document.querySelectorAll("#view-library .gcard .cover").length >= 6`],
["封面有「抽不到 icon0.png」的回退态(一排卡片里不留空洞)",
`() => !!document.querySelector("#view-library .cover.fb")`],
["封面有加密锁定态(需要口令的包也得有封面)",
`() => !!document.querySelector("#view-library .cover.locked")`],
["筛选控件真会筛,不是只换按下态",
`() => { const s = document.getElementById("view-library");
const segs = s.querySelectorAll(".seg"); if (segs.length < 2) return false;
const vis = () => [...s.querySelectorAll(".gcard")].filter(c => !c.hidden).length;
const before = vis();
segs[1].querySelectorAll("button")[2].click(); // 需口令
const after = vis();
segs[1].querySelectorAll("button")[0].click(); // 切回「在盘上」
return before === 6 && after === 1 && vis() === 6; }`],
["存档页有快照列(这是要卖的差异化,必须看得见)",
`() => /快照/.test((document.getElementById("view-saves") || {}).textContent || "")`],
["存档页有操作日志终端(借自 Garlic 的 TERMINAL 面板)",
`() => !!document.querySelector("#view-saves .console .lines li")`],
["存档页空状态可来回切换(空态不是留白,是真会出现的状态)",
`() => { const d = document.getElementById("svDetail"), e = document.getElementById("svEmpty"),
c = document.getElementById("svClose"), it = document.querySelector("#view-saves .svi");
if (!d || !e || !c || !it) return false;
c.click(); const a = d.hidden === true && e.hidden === false;
it.click(); const b = d.hidden === false && e.hidden === true;
return a && b; }`],
],
};
const browser = await playwright.chromium.launch();
/* ---------- 在页面里注入的工具:有效背景色 + 对比度 ---------- */
const CONTRAST_HELPER = `
window.__bg = el => {
for (let n = el; n; n = n.parentElement) {
const c = getComputedStyle(n).backgroundColor;
const m = c.match(/rgba?\\(([^)]+)\\)/);
if (m) { const p = m[1].split(",").map(s => parseFloat(s)); if (p.length < 4 || p[3] > 0.95) return p.slice(0, 3); }
}
return [255, 255, 255];
};
window.__lum = rgb => { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]); };
window.__ratio = (a, b) => { const l1 = window.__lum(a), l2 = window.__lum(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); };
window.__parse = s => { const m = s.match(/rgba?\\(([^)]+)\\)/); return m ? m[1].split(",").map(x => parseFloat(x)).slice(0, 3) : [0, 0, 0]; };
`;
const shots = {};
/* ---------- 视图化页面的逐视图溢出测量 ----------
风格 A 与 E 把内容分成若干互斥的 .view 容器(隐藏的那个是 display:none)。
隐藏视图不贡献宽度 ⇒ 只量默认视图等于对其它视图「不设防」:溢出要等用户
亲手点进去才暴露。这里逐个切过去量,并且把「有没有切成功」也验一遍 ——
否则导航一旦失效(风格 E 第一版就是:按钮只换高亮、不换内容),
「5 个视图都无溢出」这句话会在同一个视图上量五遍,变成静默假绿。 */
async function widestOverflow(page) {
const views = await page.evaluate(() => [...document.querySelectorAll("button[data-view]")].map(b => b.dataset.view));
if (views.length < 2) return null;
let worst = -1, at = "", switched = true;
for (const k of views) {
const r = await page.evaluate(key => {
const b = document.querySelector(`button[data-view="${key}"]`);
if (b) b.click();
const on = document.querySelector(".view.on");
const de = document.documentElement;
return { ov: de.scrollWidth - de.clientWidth, on: on ? on.id : "?", ok: on ? on.id === "view-" + key : false };
}, k);
if (!r.ok) switched = false;
if (r.ov > worst) { worst = r.ov; at = r.on; }
}
await page.evaluate(() => document.querySelector("button[data-view]")?.click()); // 切回默认视图
return { n: views.length, worst, at, switched };
}
for (const d of DEMOS) {
const url = "file:///" + ROOT + "/" + d.file;
console.log("\n== " + d.name + " (" + d.file + ")");
/* ---------- 1920 电视档 ---------- */
const p = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
const ext = [];
p.on("request", r => { if (!r.url().startsWith("file://")) ext.push(r.url()); });
const errs = [];
p.on("pageerror", e => errs.push(e.message));
await p.goto(url, { waitUntil: "load" });
await p.waitForTimeout(450);
await p.evaluate(CONTRAST_HELPER);
check(ext.length === 0, `零外部请求 (${ext.length})${ext.slice(0, 2).join(" ")}`);
check(errs.length === 0, `零 JS 运行时错误 (${errs.length})${errs[0] ? " :: " + errs[0] : ""}`);
const base = await p.evaluate(() => ({
ov: document.documentElement.scrollWidth - document.documentElement.clientWidth,
hasNav: ["文件", "任务", "游戏", "存档"].every(t => document.body.textContent.includes(t)),
hasKstuff: document.body.textContent.includes("kstuff"),
hasAvg: document.body.textContent.includes("均速"),
hasLock: /单例|挂载中/.test(document.body.textContent),
hasPick: /点选|选择落点|解压到/.test(document.body.textContent),
hasSubdirDefault: /默认不勾/.test(document.body.textContent),
archiveExtAsDir: (document.body.textContent.match(/\.(zip|rar|7z)\//gi) || []).length,
ariaDisabled: document.querySelectorAll('[aria-disabled="true"]').length,
nativelyDisabledWithTitle: [...document.querySelectorAll('button[disabled][title]')].length,
reducedMotion: /prefers-reduced-motion/.test(document.documentElement.outerHTML),
svgIcons: document.querySelectorAll("svg.i").length,
emoji: (document.body.textContent.match(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu) || []).length,
/* 本机地址:命中区要单独量 —— 视觉高度是按风格定的(24~37px 不等),
命中区一律靠 ::after 撑到 40px。只量 getBoundingClientRect 会把
「看着小但点得中」误判成不合格。 */
ip: (() => {
const c = document.getElementById("ipChip");
if (!c) return { text: "", hit: 0 };
const v = c.querySelector(".ipv");
const after = getComputedStyle(c, "::after");
return { text: v ? v.textContent.trim() : "",
hit: Math.round(Math.max(c.getBoundingClientRect().height,
parseFloat(after.height) || 0)) };
})(),
}));
check(base.ov <= 1, `1920 无页面横向溢出 (${base.ov}px)`);
check(base.hasNav, "四个一级导航项齐全(文件/任务/游戏/存档)");
check(base.hasKstuff, "平台状态(kstuff)可见");
check(base.hasAvg && base.hasLock, "进度口径(均速)+ 存档单例提示在场");
check(base.hasPick && base.hasSubdirDefault, "解压落点点选 + 子目录默认不勾(已拍板)");
check(base.archiveExtAsDir === 0, `新建子目录名已去归档扩展名(.rar/ .zip/ .7z/ 命中 ${base.archiveExtAsDir} 次)`);
check(base.ariaDisabled > 0, `存在 aria-disabled 禁用项 (${base.ariaDisabled})`);
check(base.nativelyDisabledWithTitle === 0, "没有 button[disabled][title](否则 title 永不弹出)");
check(base.reducedMotion, "已处理 prefers-reduced-motion");
check(base.svgIcons >= 6, `图标为内联 SVG 而非 emoji (${base.svgIcons} 个)`);
check(base.emoji === 0, `正文无 emoji (${base.emoji})`);
/* ---------- 本机地址(2026-09-27 加) ----------
它不是装饰件:插件跑在 PS5 上就是个 HTTP 服务,而这个地址是「用电脑 / 手机
打开同一个界面」的唯一入口,偏偏 PS5 自己没有 ipconfig —— 界面不给就无处可查。
端口来自服务端上报(默认 2026,被占用会顺延),所以这里只要求「像 IP:PORT」,
不锁死具体端口;锁死了反而会把一个真实的运行时行为挡住。 */
check(/^\d{1,3}(\.\d{1,3}){3}:\d{2,5}$/.test(base.ip.text),
`本机地址形如 IP:PORT(${base.ip.text || "缺失"})`);
check(!!base.ip.text && !/^(127\.|0\.0\.0\.0)/.test(base.ip.text),
"地址是局域网地址而不是回环(回环对「用另一台设备打开」没有意义)");
check(base.ip.hit >= 40, `本机地址可点区域 ≥40px(${base.ip.hit}px,PS5 是触摸板光标)`);
/* ---------- 本次改动的回归钉 ---------- */
const extras = EXTRA[d.key] || [];
if (extras.length) {
/* ⚠️ 逐个 await:回归钉里有的检查必须等一个 tick(例:点了复制按钮之后
DOM 才会显示「已复制」)。同步版只能拿到点击瞬间的状态,会把真功能判成假按钮。 */
const got = await p.evaluate(async srcs => Promise.all(srcs.map(async s => {
try { return !!(await (new Function("return (" + s + ")")())()); } catch (e) { return false; }
})), extras.map(e => e[1]));
extras.forEach((e, i) => check(got[i], e[0]));
}
/* ---------- 本机地址:点一下必须真的有反应 ----------
「点击复制」是本轮最容易做成假交互的地方:按钮看着是按钮,按下去什么都没发生,
断言却只验了「元素存在」。这里验三件事:有可见反馈、反馈会复原、按钮不是死的。
⚠️ 两条路径都失败时文案是「复制失败…」,仍然算有反馈 —— 因为「静默无反应」才是
真正要防的那种 bug;headless 下能不能写进系统剪贴板本来就不该由页面决定。 */
const ipClick = await p.evaluate(async () => {
const c = document.getElementById("ipChip");
if (!c) return { ok: false, restored: false, why: "找不到 #ipChip" };
const v = c.querySelector(".ipv"), before = v.textContent;
c.click();
await new Promise(r => setTimeout(r, 700));
const after = v.textContent, marked = c.hasAttribute("data-copied");
await new Promise(r => setTimeout(r, 1000)); // 等复原:长留会把地址本身盖住,截图也拍错
return { ok: after !== before, restored: v.textContent === before, why: after };
});
check(ipClick.ok, `本机地址点一下有可见反馈(显示「${ipClick.why}」)`);
check(ipClick.restored, "复制提示 1.5s 后自动复原(否则地址会被提示文案长期盖掉)");
/* ---------- 视图化页面:逐视图量溢出 ---------- */
const vw = await widestOverflow(p);
if (vw) {
check(vw.switched, `导航真能切换视图(${vw.n} 个视图逐个点过)`);
check(vw.worst <= 1, `1920 · ${vw.n} 个视图切换后均无横向溢出 (最大 ${vw.worst}px @ ${vw.at})`);
}
/* ---------- 对比度 ---------- */
const cr = await p.evaluate(targets => targets.map(([sel, label]) => {
const el = document.querySelector(sel);
if (!el) return { label, sel, missing: true };
const cs = getComputedStyle(el);
const size = parseFloat(cs.fontSize), weight = parseInt(cs.fontWeight) || 400;
const fg = window.__parse(cs.color), bg = window.__bg(el);
const large = size >= 24 || (size >= 18.66 && weight >= 700);
return { label, sel, size, ratio: +window.__ratio(fg, bg).toFixed(2), min: large ? 3 : 4.5 };
}), CONTRAST_TARGETS[d.key]);
for (const c of cr) {
if (c.missing) { check(false, `对比度采样点存在: ${c.sel}`); continue; }
check(c.ratio >= c.min, `对比度 ${c.label} ${c.ratio}:1 (需 ≥${c.min}, ${c.size}px)`);
}
/* ---------- 焦点环:必须是「不越出容器」的行内环 ----------
视图化页面(风格 E)的默认视图里可能根本没有可聚焦的行 —— 那样这组检查会
落到导航按钮上,等于把「行内 inset 环」这条约定静默跳过。先切到第一个含行
的视图;测完再切回来(1920 截图必须拍默认视图)。 */
const focusedView = await p.evaluate(() => {
const btns = [...document.querySelectorAll("button[data-view]")];
if (btns.length < 2) return false;
const t = btns.find(b => {
const v = document.querySelector("#view-" + b.dataset.view);
return v && v.querySelector(".row[tabindex], .li[tabindex], .lr[tabindex]");
});
if (!t) return false;
t.click();
return true;
});
const foc = await p.evaluate(() => {
// 只挑「当前可见」的候选 —— 隐藏视图里的列表聚焦不上,
// 会给出 outline=0 shadow=0 的假失败(曾经有个候选默认停在总览视图,踩过)。
const vis = el => el && el.offsetParent !== null && el.getClientRects().length > 0;
const sel = [".row[tabindex]", ".li[tabindex]", ".lr[tabindex]", ".tabs button", ".seg button", ".nav button"]
.map(s => [...document.querySelectorAll(s)].find(vis)).find(Boolean);
if (!sel) return { none: true };
sel.focus();
const cs = getComputedStyle(sel);
const r = sel.getBoundingClientRect();
const par = sel.parentElement.getBoundingClientRect();
return {
tag: sel.className || sel.tagName,
outline: cs.outlineStyle === "none" ? 0 : parseFloat(cs.outlineWidth),
shadow: cs.boxShadow === "none" ? 0 : 1,
insideParent: r.left >= par.left - 1 && r.right <= par.right + 1,
};
});
check(!foc.none, "存在可聚焦的行元素");
if (!foc.none) {
check(foc.outline > 0 || foc.shadow > 0, `焦点态有可见指示 (outline=${foc.outline} shadow=${foc.shadow})`);
check(foc.insideParent, "聚焦元素未越出容器(行内环约定)");
}
/* ---------- 抽屉打开后不得产生溢出(本次真踩的坑) ---------- */
const drawer = await p.evaluate(() => {
const b = document.querySelector("#btnNotes, [id*='otes'], [id*='rawer']");
if (!b) return { skip: true };
b.click();
return { ov: document.documentElement.scrollWidth - document.documentElement.clientWidth,
opened: !!document.querySelector(".notes.on, .docs.on") };
});
if (!drawer.skip) {
check(drawer.opened, "设计说明抽屉可打开");
check(drawer.ov <= 1, `抽屉打开后仍无横向溢出 (${drawer.ov}px)`);
}
/* ---------- 对话框可打开 ---------- */
const dlg = await p.evaluate(() => {
const b = document.querySelector("#btnExtract");
if (!b) return { skip: true };
b.click();
return { on: !!document.querySelector(".scrim.on"), ov: document.documentElement.scrollWidth - document.documentElement.clientWidth };
});
if (!dlg.skip) { check(dlg.on, "解压对话框可打开"); check(dlg.ov <= 1, `对话框打开后无横向溢出 (${dlg.ov}px)`); }
/* ---------- 截图 ----------
必须先滚回顶部:焦点那一组检查调用了 el.focus(),浏览器会自动把该行滚进视口,
于是长页 demo(风格 E)的「全页截图」拍到的是中段而不是首屏。 */
await p.keyboard.press("Escape");
if (focusedView) await p.evaluate(() => document.querySelector("button[data-view]")?.click()); // 切回默认视图再拍
await p.evaluate(() => window.scrollTo(0, 0));
await p.waitForTimeout(250);
await p.screenshot({ path: `${OUT}/${d.key}-1920.jpg`, type: "jpeg", quality: 84 });
shots[d.key] = `${OUT}/${d.key}-1920.jpg`;
/* 视图化页面再拍一张「第二个视图」:默认视图(概览 / 落地页)看起来仍像宣传页,
真正证明「这是工具界面」的是工作视图那一张。 */
if (vw) {
const second = await p.evaluate(() => {
const bs = [...document.querySelectorAll("button[data-view]")];
if (!bs[1]) return null;
bs[1].click(); window.scrollTo(0, 0);
return document.querySelector(".view.on")?.id || null;
});
if (second) {
await p.waitForTimeout(320);
await p.screenshot({ path: `${OUT}/${d.key}-2nd-1920.jpg`, type: "jpeg", quality: 84 });
shots[`${d.key}-2nd`] = `${OUT}/${d.key}-2nd-1920.jpg`;
await p.evaluate(() => document.querySelector("button[data-view]")?.click());
}
}
if (ALT_THEME[d.key]) { // 备用主题:同布局只换 token,必须同样无溢出
const mode = ALT_THEME[d.key];
const alt = await p.evaluate(m => {
document.getElementById("btnTheme").click();
/* 备用主题下单独再量一次地址文字:它吃的是 --fg2 / --fg3 这类**主题令牌**,
换一套色值就可能掉出阈值,而上面那一组采样只发生在默认主题。 */
const el = document.querySelector(".ipchip .ipv");
const cs = el ? getComputedStyle(el) : null;
return { on: document.body.classList.contains(m),
ov: document.documentElement.scrollWidth - document.documentElement.clientWidth,
ip: el ? +window.__ratio(window.__parse(cs.color), window.__bg(el)).toFixed(2) : 0 };
}, mode);
check(alt.on && alt.ov <= 1, `备用主题(切到 ${mode})正常且无溢出 (on=${alt.on} ov=${alt.ov}px)`);
check(alt.ip >= 4.5, `备用主题(${mode})下本机地址仍可读 (${alt.ip}:1)`);
await p.waitForTimeout(350);
await p.screenshot({ path: `${OUT}/${d.key}-${mode}-1920.jpg`, type: "jpeg", quality: 84 });
shots[`${d.key}-${mode}`] = `${OUT}/${d.key}-${mode}-1920.jpg`;
}
/* ---------- 1280 电脑档 + 1100 窄窗档 + 390 手机档:只验溢出 ---------- */
for (const [w, h, tag] of [[1280, 820, "1280"], [1100, 800, "1100"], [390, 844, "390"]]) {
const q = await browser.newPage({ viewport: { width: w, height: h } });
await q.goto(url, { waitUntil: "load" });
await q.waitForTimeout(300);
const o = await q.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
const vq = await widestOverflow(q);
if (vq) check(vq.worst <= 1, `${tag} · ${vq.n} 个视图逐个切换后均无横向溢出 (最大 ${vq.worst}px @ ${vq.at})`);
else check(o <= 1, `${tag} 无横向溢出 (${o}px)`);
await q.evaluate(() => window.scrollTo(0, 0));
if (w === 390) {
await q.screenshot({ path: `${OUT}/${d.key}-390.jpg`, type: "jpeg", quality: 80 });
shots[d.key + "-390"] = `${OUT}/${d.key}-390.jpg`;
}
await q.close();
}
/* ---------- PS5 档:1920×970 ----------
PS5 的浏览器自己不把 1080 全留给页面,可视区形状是「横向充裕、纵向紧缺」。
1080×1920 那一轮抓不到这个形状特有的问题,所以单独补一档:
① 一级导航必须停在顶部并且**单行** —— 折行等于白吃纵向空间;
② 导航必须在首屏内(吸顶或至少在顶部),滚一次就找不到了等于没有导航。 */
const ps5p = await browser.newPage({ viewport: { width: 1920, height: 970 } });
await ps5p.goto(url, { waitUntil: "load" });
await ps5p.waitForTimeout(320);
const ps5 = await ps5p.evaluate(() => {
const de = document.documentElement;
const bs = [...document.querySelectorAll("button[data-view]")];
const rects = bs.map(b => b.getBoundingClientRect());
const ipc = document.getElementById("ipChip");
const ir = ipc ? ipc.getBoundingClientRect() : null;
return {
ov: de.scrollWidth - de.clientWidth,
n: bs.length,
rows: new Set(rects.map(r => Math.round(r.top))).size,
top: rects.length ? Math.min(...rects.map(r => r.top)) : -1,
bottom: rects.length ? Math.max(...rects.map(r => r.bottom)) : -1,
ipTop: ir ? Math.round(ir.top) : null,
ipBottom: ir ? Math.round(ir.bottom) : null,
ipW: ir ? Math.round(ir.width) : 0,
};
});
check(ps5.ov <= 1, `PS5 1920×970 无横向溢出 (${ps5.ov}px)`);
/* 地址在 PS5 档必须可见且落在首屏里。
1920 宽下它**不该**被任何响应式规则藏起来(风格 A 只在 ≤900px 才让位给导航),
所以这里不给 SKIP 分支:查不到就是真失败。 */
check(ps5.ipW > 0, "PS5 档本机地址可见(1920 宽下不该被响应式规则藏起来)");
check(ps5.ipW > 0 && ps5.ipTop >= 0 && ps5.ipBottom <= 970,
`PS5 档本机地址在首屏内(top=${ps5.ipTop} bottom=${ps5.ipBottom})`);
/* 只有「视图化」页面(带 data-view 导航)才验「导航置顶且单行」。
单页长滚动的候选压根没有一级导航 —— 硬套只会造出假失败,
而假失败和假通过一样有毒:它会让人开始忽略这一组断言。 */
if (ps5.n >= 2) {
check(ps5.n >= 4 && ps5.rows === 1, `PS5 档一级导航单行不折行(${ps5.n} 项 / ${ps5.rows} 行)`);
check(ps5.top >= 0 && ps5.bottom > 0 && ps5.bottom <= 970,
`PS5 档导航在首屏顶部(top=${Math.round(ps5.top)}px bottom=${Math.round(ps5.bottom)}px)`);
} else {
console.log(" SKIP PS5 档导航检查(此 demo 无一级导航,是单页长滚动)");
}
const pvq = await widestOverflow(ps5p);
if (pvq) check(pvq.worst <= 1, `PS5 档 ${pvq.n} 个视图逐个切换后均无横向溢出 (最大 ${pvq.worst}px @ ${pvq.at})`);
await ps5p.close();
await p.close();
}
await browser.close();
console.log("\n" + (fails ? fails + " FAILURE(S)" : "ALL CHECKS PASSED") +
`\n 共 ${total} 项(${total - fails} 通过 / ${fails} 失败)`);
/* ---------- 把截图注入对比页(保持单文件、零外部依赖) ----------
对比页里用 <img data-shot="demo1"> 作占位;这里填 src。
用属性而非注释做标记 ⇒ 脚本可重复运行且幂等。 */
if (!fails) {
fs.writeFileSync(`${OUT}/shots.json`, JSON.stringify(shots, null, 2));
const PAGE = path.join(ROOT, "ps5-ui-demos-compare.html");
if (fs.existsSync(PAGE)) {
let html = fs.readFileSync(PAGE, "utf8");
let n = 0;
for (const [key, file] of Object.entries(shots)) {
if (!fs.existsSync(file)) continue;
const uri = "data:image/jpeg;base64," + fs.readFileSync(file).toString("base64");
const re = new RegExp(`(<img )((?:src="[^"]*" )?)data-shot="${key}"`, "g");
html = html.replace(re, (_, pre) => { n++; return pre + `src="${uri}" data-shot="${key}"`; });
}
fs.writeFileSync(PAGE, html);
console.log(` 注入 ${n} 张截图 → ps5-ui-demos-compare.html (${(html.length / 1048576).toFixed(2)} MB)`);
}
}
process.exit(fails ? 1 : 0);
+381
View File
@@ -0,0 +1,381 @@
/* Headless check for the extract "retry with a password" flow in
assets/main.js, plus the byte-mapped name translation its error messages go
through.
This project has no browser test runner, so the real script is loaded into a
stubbed DOM. Only the pieces the flow touches are faked: fetch (which also
records the /api/extract bodies), prompt, alert and a permissive element
object. init() is allowed to stall on its first real data fetch -- the retry
path is driven directly.
Run: node .build/ui_retry_test.mjs
*/
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
const root = path.resolve(import.meta.dirname, "..");
const mainSrc = fs.readFileSync(path.join(root, "assets/main.js"), "utf8");
const langSrc = fs.readFileSync(path.join(root, "assets/lang-en.js"), "utf8");
let pass = 0;
let fail = 0;
function check(cond, label) {
if (cond) {
pass++;
console.log(" ok " + label);
} else {
fail++;
console.log(" FAIL " + label);
}
}
/* ---- stubs ------------------------------------------------------------- */
const extractCalls = [];
const alerts = [];
let promptReply = null;
let promptCount = 0;
let lastPromptText = null;
function fakeElement(tag) {
const node = {
tagName: String(tag || "div").toUpperCase(),
children: [],
style: {},
dataset: {},
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
innerHTML: "",
textContent: "",
value: "",
hidden: false,
checked: false,
disabled: false,
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
files: [],
parentNode: { removeChild() {} },
title: "",
type: "",
addEventListener() {},
removeEventListener() {},
dispatchEvent() {},
appendChild() {},
removeChild() {},
insertBefore() {},
replaceChildren() {},
setAttribute() {},
getAttribute() { return null; },
removeAttribute() {},
focus() {},
blur() {},
click() {},
remove() {},
after() {},
before() {},
closest() { return null; },
contains() { return false; },
cloneNode() { return fakeElement(tag); },
querySelector() { return fakeElement("div"); },
querySelectorAll() { return []; },
getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0, bottom: 0, right: 0 }; },
scrollIntoView() {},
};
return node;
}
/* The language <script> is fetched in the browser; here the tag is handed back
with an onload hook that fires as soon as main.js assigns the handler, which
is the order loadLanguage() expects. */
function fakeScriptElement() {
const node = fakeElement("script");
let src = "";
Object.defineProperty(node, "src", {
get() { return src; },
set(value) { src = String(value); },
});
Object.defineProperty(node, "onload", {
set(handler) { if (typeof handler === "function") queueMicrotask(handler); },
get() { return null; },
});
return node;
}
const documentStub = {
getElementById() { return fakeElement("div"); },
querySelector() { return fakeElement("div"); },
querySelectorAll() { return []; },
createElement(tag) {
return String(tag).toLowerCase() === "script" ? fakeScriptElement() : fakeElement(tag);
},
createDocumentFragment() { return fakeElement("div"); },
addEventListener() {},
removeEventListener() {},
body: fakeElement("body"),
head: fakeElement("head"),
documentElement: fakeElement("html"),
title: "",
cookie: "",
hidden: false,
};
function jsonResponse(payload) {
return { ok: true, status: 200, json: async () => payload, text: async () => "" };
}
function stubFetch(url, options) {
const target = String(url);
if (target.startsWith("/api/extract")) {
extractCalls.push(String((options && options.body) || ""));
return Promise.resolve(jsonResponse({ ok: true, task_id: extractCalls.length }));
}
if (target.startsWith("/api/tasks")) {
return Promise.resolve(jsonResponse({ ok: true, tasks: [], completion: null }));
}
if (target.startsWith("/api/")) {
return Promise.resolve(jsonResponse({ ok: true }));
}
/* Anything else (file listings, spaces) never settles: init() parks on it
instead of walking into code paths this harness does not stub. */
return new Promise(() => {});
}
const sandbox = {
console,
setTimeout,
clearTimeout,
setInterval() { return 0; },
clearInterval() {},
requestAnimationFrame(handler) { return setTimeout(handler, 0); },
fetch: stubFetch,
URLSearchParams,
AbortController,
Uint8Array,
TextDecoder,
TextEncoder,
addEventListener() {},
removeEventListener() {},
dispatchEvent() {},
alert(message) { alerts.push(String(message)); },
confirm() { return true; },
prompt(text) {
promptCount++;
lastPromptText = String(text);
return promptReply;
},
XMLHttpRequest: class {
abort() {}
open() {}
send() {}
setRequestHeader() {}
addEventListener() {}
},
localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
navigator: { languages: ["en-US"], language: "en-US", userAgent: "retry-test" },
location: { href: "http://localhost:8888/", pathname: "/", search: "", hash: "", origin: "http://localhost:8888" },
history: { pushState() {}, replaceState() {} },
performance: { now: () => Date.now() },
document: documentStub,
};
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
process.on("unhandledRejection", reason => {
console.log(" (note) init() rejected as expected in the stub: " + (reason && reason.message));
});
const context = vm.createContext(sandbox);
vm.runInContext(langSrc, context, { filename: "lang-en.js" });
const EXPORTS = ";globalThis.__WFM_TEST = { startExtractTask, handleTerminalTask, " +
"retryExtractWithPassword, extractRequestRetries, extractRetryKey, " +
"backendErrorText, decodeFsText, encodeFsText };\n";
vm.runInContext(mainSrc + EXPORTS, context, { filename: "main.js" });
const T = sandbox.__WFM_TEST;
if (!T) {
console.log("FAIL: could not reach the functions under test");
process.exit(1);
}
const flush = async (ticks = 8) => {
for (let i = 0; i < ticks; i++) await new Promise(resolve => setTimeout(resolve, 0));
};
const failedExtract = (id, src, dst, code, arg) => ({
id,
op: "extract",
state: "failed",
error_code: code,
error_arg: arg || "",
error: "password required or wrong",
src,
dst,
current: src,
});
function bodyField(body, name) {
const params = new URLSearchParams(body);
return params.get(name);
}
/* The stub hands out task ids 1, 2, 3 ... in call order, so the id of the task a
startExtractTask() call created is simply the number of /api/extract calls
made so far. Every terminal payload below must carry the id of the task the
page actually remembered -- that is the whole point of keying by id. */
const startedId = () => extractCalls.length;
const lastField = name => bodyField(extractCalls[extractCalls.length - 1], name);
async function startExtract(...args) {
await T.startExtractTask(...args);
await flush();
return startedId();
}
/* ---- 1. the request is recorded when the task starts ------------------- */
console.log("recording the extract request");
const idA = await startExtract("/enc-aes256.zip", "/", "overwrite", false, "enc-aes256.zip", true, "", 0);
check(extractCalls.length === 1, "one /api/extract POST was sent");
check(bodyField(extractCalls[0], "path") === "/enc-aes256.zip", "path is the archive");
check(bodyField(extractCalls[0], "conflict") === "overwrite", "conflict policy is sent");
check(bodyField(extractCalls[0], "large") === "1", "large-file opt-in is sent");
check(bodyField(extractCalls[0], "password") === null, "no password field on a plain attempt");
check(T.extractRequestRetries.get(T.extractRetryKey(idA)) !== undefined,
"the request is remembered under the task id");
check([...T.extractRequestRetries.keys()].every(key => key.startsWith("task:")),
"nothing is keyed by a path -- see the regression below");
/* ---- 2. REGRESSION: a non-ASCII folder must still retry ---------------- */
/* The page holds the byte-mapped form of a directory (see decodeFsText in
main.js), the task reports the byte-repaired one, and the two differ whenever
a name is not pure ASCII. Keyed by path the lookup missed for exactly those
archives, so the password prompt never appeared and the user only ever saw the
raw "extract failed" alert. */
console.log("\nnon-ASCII folder, path as reported by the server differs");
promptCount = 0;
alerts.length = 0;
promptReply = "secret123";
const wireDir = "/mnt/\u00e6\u0088\u0091\u00e7\u009a\u0084"; /* "/mnt/我的", byte-mapped */
const idB = await startExtract(wireDir + "/games.zip", wireDir, "fail", false, "games.zip", false, "", 0);
const beforeRetry = extractCalls.length;
T.handleTerminalTask(failedExtract(idB, "/mnt/我的/games.zip", "/mnt/我的",
"extract_password", "a.psd (entry is encrypted and no password was given)"));
await flush();
check(promptCount === 1, "the password is still asked for");
check(extractCalls.length === beforeRetry + 1, "the retry was sent");
check(lastField("path") === "/mnt/我的/games.zip",
"the retry re-sends the path the server reported, got " + lastField("path"));
check(lastField("password") === "secret123", "with the typed password");
/* ---- 3. a password failure retries with what the user types ------------ */
console.log("\npassword failure -> prompt -> retry");
promptCount = 0;
alerts.length = 0;
promptReply = "secret123";
const idC = await startExtract("/enc-aes256.zip", "/", "overwrite", false, "enc-aes256.zip", true, "", 0);
const beforeC = extractCalls.length;
T.handleTerminalTask(failedExtract(idC, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
await flush();
const idC2 = startedId();
check(promptCount === 1, "the user was asked for a password once");
check(String(lastPromptText).startsWith("This archive is encrypted"),
"the prompt is the encrypted-archive one, not the up-front 7z one: " + lastPromptText);
check(String(lastPromptText).indexOf("did not work") < 0,
"a first failure does not blame a password that was never given: " + lastPromptText);
check(extractCalls.length === beforeC + 1, "a second /api/extract POST was sent");
check(lastField("password") === "secret123", "the typed password is sent");
check(lastField("conflict") === "overwrite",
"the original conflict policy survives the retry");
check(lastField("large") === "1", "the large-file opt-in survives the retry");
check(lastField("path") === "/enc-aes256.zip", "the same archive is retried");
check(alerts.length === 0, "no failure alert while the retry is running");
const afterFirstRetry = T.extractRequestRetries.get(T.extractRetryKey(idC2));
check(afterFirstRetry && afterFirstRetry.attempts === 1, "the retry count is tracked");
check(T.extractRequestRetries.get(T.extractRetryKey(idC)) === undefined,
"the consumed entry is dropped, so the old id cannot re-prompt");
/* ---- 4. cancelling gives up and reports the failure -------------------- */
console.log("\ncancelling the prompt");
promptCount = 0;
alerts.length = 0;
promptReply = null; // Cancel
const beforeCancel = extractCalls.length;
T.handleTerminalTask(failedExtract(idC2, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
await flush();
check(promptCount === 1, "the prompt was shown");
check(String(lastPromptText).indexOf("did not work") >= 0,
"the second failure does say the password was wrong: " + lastPromptText);
check(extractCalls.length === beforeCancel, "no new request after cancelling");
check(alerts.length === 1, "the failure is reported");
check(T.extractRequestRetries.get(T.extractRetryKey(idC2)) === undefined,
"the remembered request is dropped");
/* ---- 5. an empty password is the same as cancelling -------------------- */
console.log("\nempty password");
promptReply = "";
alerts.length = 0;
const idD = await startExtract("/enc-aes256.zip", "/", "fail", false, "enc-aes256.zip", false, "", 0);
const beforeEmpty = extractCalls.length;
T.handleTerminalTask(failedExtract(idD, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
await flush();
check(extractCalls.length === beforeEmpty, "an empty box does not fire a retry");
check(alerts.length === 1, "the failure is reported instead");
/* ---- 6. the retry count is capped ------------------------------------- */
console.log("\nretry cap");
promptCount = 0;
alerts.length = 0;
promptReply = "pw";
let cappedId = await startExtract("/capped.zip", "/", "fail", false, "capped.zip", false, "", 0);
for (let i = 0; i < 4; i++) {
T.handleTerminalTask(failedExtract(cappedId, "/capped.zip", "/", "extract_password", "capped.zip"));
await flush();
cappedId = startedId();
}
check(promptCount === 3, "exactly three prompts for four failures, got " + promptCount);
check(alerts.length === 1, "the fourth failure is reported instead of prompting again");
/* ---- 7. other failures are untouched ---------------------------------- */
console.log("\nunrelated failures");
promptCount = 0;
alerts.length = 0;
promptReply = "pw";
T.handleTerminalTask(failedExtract(startedId(), "/enc-aes256.zip", "/", "extract_ratio", "enc-aes256.zip"));
await flush();
check(promptCount === 0, "a ratio failure does not ask for a password");
check(alerts.length === 1, "a ratio failure is reported as before");
promptCount = 0;
alerts.length = 0;
T.handleTerminalTask(failedExtract(987654, "/elsewhere.zip", "/tmp", "extract_password", "elsewhere.zip"));
await flush();
check(promptCount === 0, "a task this page did not start is not retried");
check(alerts.length === 1, "it is reported as before");
/* ---- 8. names in the error text are translated back -------------------- */
console.log("\nbyte-mapped names reach the user as text");
const gbkName = "\u00c4\u00a3\u00b0\u00e5.psd"; /* 模板.psd as GBK bytes, byte-mapped */
check(T.decodeFsText(gbkName) === "模板.psd",
"a GBK entry name decodes to the real name, got " + T.decodeFsText(gbkName));
check(T.decodeFsText(T.encodeFsText("模板.psd")) === "模板.psd",
"encode/decode is a round trip");
check(T.encodeFsText("plain.zip") === "plain.zip", "ASCII names are left alone");
const errText = T.backendErrorText("extract_password",
gbkName + " (entry is encrypted and no password was given)", "hint");
check(errText.indexOf("模板.psd") >= 0, "the message carries the real name: " + errText);
check(errText.indexOf("\u00c4") < 0 && errText.indexOf("\u00a3") < 0,
"no mojibake is left in it: " + errText);
console.log("\n" + pass + " checks, " + fail + " failures");
process.exit(fail === 0 ? 0 : 1);
+153
View File
@@ -0,0 +1,153 @@
/* Static checks for the upload entry point in assets/index.html.
The upload button is markup plus i18n plus CSS plus three event bindings in
main.js, and nothing else in the project validates any of those: a missing
language key renders an empty button, a renamed id silently unbinds a click,
and a class that only exists in the HTML looks fine in the diff but not in the
browser. All three have happened here, so they are checked mechanically.
Run: node .build/ui_upload_menu_test.mjs
*/
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
const root = path.resolve(import.meta.dirname, "..");
const read = name => fs.readFileSync(path.join(root, "assets", name), "utf8");
const html = read("index.html");
const css = read("main.css");
const js = read("main.js");
let pass = 0;
let fail = 0;
function check(cond, label) {
if (cond) {
pass++;
console.log(" ok " + label);
} else {
fail++;
console.log(" FAIL " + label);
}
}
function loadLang(name) {
const sandbox = { window: {} };
vm.runInNewContext(read(name), sandbox, { filename: name });
return sandbox.window.WFM_LANG;
}
const zh = loadLang("lang-zh.js");
const en = loadLang("lang-en.js");
/* ---- 1. every key the markup asks for exists, in both languages -------- */
console.log("i18n coverage");
const used = [...html.matchAll(/data-i18n="([^"]+)"/g)].map(match => match[1]);
const uniqueUsed = [...new Set(used)];
check(uniqueUsed.length > 0, "the markup uses data-i18n at all");
const missingZh = uniqueUsed.filter(key => !(key in zh));
const missingEn = uniqueUsed.filter(key => !(key in en));
check(missingZh.length === 0, "every key exists in lang-zh: " + (missingZh.join(", ") || "none missing"));
check(missingEn.length === 0, "every key exists in lang-en: " + (missingEn.join(", ") || "none missing"));
const zhKeys = Object.keys(zh).sort();
const enKeys = Object.keys(en).sort();
const onlyZh = zhKeys.filter(key => !(key in en));
const onlyEn = enKeys.filter(key => !(key in zh));
check(onlyZh.length === 0 && onlyEn.length === 0,
"the two language files carry the same keys" +
(onlyZh.length || onlyEn.length ? " (zh-only: " + onlyZh.join(",") + "; en-only: " + onlyEn.join(",") + ")" : ""));
const emptyZh = zhKeys.filter(key => !String(zh[key]).trim());
check(emptyZh.length === 0, "no empty values in lang-zh: " + (emptyZh.join(", ") || "none"));
/* ---- 2. the menu is there and wired ----------------------------------- */
console.log("\nupload menu markup");
check(/id="uploadBtn"[^>]*data-i18n="upload"/.test(html), "the upload button is present");
check(/id="uploadBtn"[^>]*aria-haspopup="menu"/.test(html) ||
/aria-haspopup="menu"[^>]*id="uploadBtn"/.test(html), "the button announces it opens a menu");
check(html.includes('id="uploadMenu"'), "the menu container is present");
check(/id="uploadMenu"[^>]*role="menu"/.test(html), "the menu has menu role");
check(/id="uploadMenu"[^>]*hidden/.test(html), "the menu starts hidden");
check(html.includes('id="uploadFilesItem"') && html.includes('data-i18n="uploadFiles"'),
"the file entry exists");
check(html.includes('id="uploadFolderItem"') && html.includes('data-i18n="uploadFolder"'),
"the folder entry exists");
check(!html.includes("split-arrow") && !html.includes("split-button"),
"the old main-button-plus-caret split is gone");
check(!css.includes("split-button") && !css.includes("split-arrow"),
"and so are its styles");
console.log("\ndrag hint");
check(html.includes('id="dropHint"') && html.includes('data-i18n="dropUploadHint"'),
"the drag hint element exists");
check(/id="dropHint"[^>]*remote-only/.test(html),
"the hint is remote-only, like the upload it advertises");
check(/id="dropHint"[^>]*hidden/.test(html), "the hint starts hidden");
check("dropUploadHint" in zh && zh.dropUploadHint.indexOf("拖") >= 0,
"the hint tells the user they can drag: " + zh.dropUploadHint);
console.log("\nextract button");
/* The extract entry used to be hidden until an archive was selected, so the
resting toolbar had no extract button at all. It now stays on screen and
only greys out, which means the markup must not hide it and main.js must
only ever disable it -- a stray `hidden = true` left behind would silently
restore the old behaviour on one code path. */
check(/id="extractBtn"[^>]*data-i18n="extract"/.test(html),
"the extract button carries the short toolbar label");
check(!/id="extractBtn"[^>]*\shidden/.test(html), "the extract button is not hidden in the markup");
check(!js.includes("extractBtn.hidden"), "main.js never hides it");
check(/extractBtn\.disabled\s*=\s*true/.test(js), "it is disabled when the selection cannot be extracted");
check(js.includes('t("extractSelectArchive")') && js.includes('t("extractOneAtATime")') &&
js.includes('t("extractSelectMainVolume")'), "each disabled reason is wired to a message");
check(/\.extract-action:disabled\s*\{[^}]*pointer-events:\s*auto/.test(css),
"a disabled extract button keeps its tooltip readable");
/* Keys reached only through t("...") in main.js are invisible to the markup
sweep above, which is exactly how a message can go missing unnoticed. */
const tKeys = [...new Set([...js.matchAll(/\bt\("([A-Za-z0-9_]+)"/g)].map(match => match[1]))];
const missingT = tKeys.filter(key => !(key in zh) || !(key in en));
check(missingT.length === 0,
"every t(\"...\") key in main.js exists in both languages (" + tKeys.length + " keys): " +
(missingT.join(", ") || "none missing"));
console.log("\nwiring in main.js");
check(js.includes('getElementById("uploadMenu")'), "main.js looks up the menu");
check(js.includes('const uploadFolderItemEl = document.getElementById("uploadFolderItem")'),
"main.js looks up the folder entry");
check(!js.includes("uploadFolderBtn"), "no reference to the removed folder button is left");
check(js.includes('uploadBtn.addEventListener("click", toggleUploadMenu)'),
"the button toggles the menu instead of opening a dialog");
check(js.includes('uploadFilesItemEl.addEventListener("click", actionUploadFiles)'),
"the file entry opens the file dialog");
check(js.includes('uploadFolderItemEl.addEventListener("click", actionUploadFolder)'),
"the folder entry opens the folder dialog");
check(js.includes("setupUploadMenu();"), "the menu behaviour is installed");
/* ---- 3. classes used by the markup exist in the stylesheet ------------- */
console.log("\nstylesheet");
for (const cls of ["upload-menu", "upload-menu-list", "status-hint"]) {
check(new RegExp("\\." + cls + "\\s*[,{]").test(css), "." + cls + " is styled");
}
check(/\.upload-menu-list\[hidden\]|\[hidden\]\s*\{\s*display:\s*none !important/.test(css),
"a hidden menu really is invisible");
/* The row highlight has to out-specify the generic button rules, so it must be
scoped to the panel id: the unscoped version tied on specificity (0,3,1)
with `button:not(.row-action):hover:not(:disabled)` and lost on source
order, which is why the menu's own hover fill never appeared. */
check(/#uploadMenu button:hover:not\(:disabled\)/.test(css),
"the menu row hover is scoped to the panel, not left to the generic rule");
check(/#uploadMenu button:focus\s*\{[^}]*outline:\s*none/.test(css),
"the toolbar's outer focus ring cannot land on a menu row");
check(/#uploadMenu button:focus-visible\s*\{[^}]*box-shadow:\s*inset/.test(css),
"the keyboard cue is drawn inside the row instead");
check(!/\.upload-menu-list button:focus/.test(css),
"no unscoped menu focus rule is left behind");
console.log("\n" + pass + " checks, " + fail + " failures");
process.exit(fail === 0 ? 0 : 1);
+28
View File
@@ -0,0 +1,28 @@
<!--
Release notes template — keep them SHORT.
One bullet per user-visible change, one line each. A release page is a
"What do I get?" list, not a design document or a test report.
* `## What's Changed`, then a flat bullet list. No tables, no sub-sections,
no design rationale, no test counts, no per-file detail.
* Start each bullet with an area label: **extract** / **ui** / **fix** /
**build** / **docs**.
* Describe what the user can now DO, in their words. "encrypted archives
work" beats "implemented a virtual ISeekInStream that splices a
pseudo-header, the archive and the decrypted header".
* Close with one line for the artifact (name · size · sha256 · machine) and
one line for the fork marker / rollback build.
* Implementation notes, measurements, verification runs and known gaps go to
CHANGELOG.md and docs/ — link them if needed, never inline them.
-->
## What's Changed
- **area**: one line, the user-visible outcome
- **area**: another one
- **fix**: what used to be broken, and is not any more
`your-artifact.elf` · NNN,NNN bytes · sha256 `…` · x86-64 (`e_machine 0x003e`)
One line for the fork marker and/or the rollback build, with a link.
+20 -3
View File
@@ -6,9 +6,11 @@ gen/
web-file-mgr-linux
# Sandbox scratch under .build/: ignore the whole directory, then re-allow the
# handful of files that are actually part of the repo (build scripts + the
# ELF checker). A whitelist is the only thing that survives -- every debugging
# session drops a new probe directory in here.
# handful of files that are actually part of the repo -- the build scripts, the
# ELF checker, and the validation harnesses. A whitelist is the only thing that
# survives: every debugging session drops a new probe directory in here, and a
# scratch-tree cleanup would otherwise delete the harnesses for good (they exist
# nowhere else -- not in git, not on the release page).
.build/*
!.build/build-elf.sh
!.build/build-elf-wsl.sh
@@ -17,6 +19,21 @@ web-file-mgr-linux
!.build/extract-demo.html
!.build/build-elf.log
# Validation harnesses: headless-DOM unit tests against assets/main.js, the
# real-page preview server and its Chromium pass, and the README / comparison /
# proposal / UI-demo render checks. Run them before shipping a UI or docs change.
# ui_demos_check.mjs also turns the UI review criteria (overflow at three
# breakpoints, WCAG-AA contrast, focus-ring containment, aria-disabled hygiene)
# into assertions — they are invisible to the eye, so they need a test.
!.build/ui_*_test.mjs
!.build/ui_demos_check.mjs
!.build/preview_build.py
!.build/preview_stub.html
!.build/preview_check.mjs
!.build/readme_header_render.mjs
!.build/compare_*_check.mjs
!.build/proposal_check.mjs
# Generated 7z fixtures (tests/make_sevenz_fixtures.py rebuilds them).
tests/fixtures-7z/
+69 -6
View File
@@ -4,7 +4,8 @@ All notable changes to **PS5 Web File Manager** are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> Release artifact for v1.9.3M — **built and verified locally, not published yet**:
> Release artifact for v1.9.3M — **published** (<https://github.com/LisherSong/ps5-web-file-manager/releases/tag/v1.9.3M>),
> after end-to-end validation on a real console:
> `web-file-mgr-v1.9.3M.elf` — size 903 448 bytes (~882 KiB)
> sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7`
> ELF class 64, little-endian, e_machine `0x003e` (x86_64-sie-ps5)
@@ -21,9 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
> (`versionTooltip`, en + zh). This is the first release using the convention;
> the older entries below keep their original plain numbers.
>
> This is the first binary to carry the encrypted-archive work and the
> dictionary-reporting fix (`[v1.9.3M]` below). It exists for on-device testing:
> the GitHub release is still v1.9.2 and its binary contains none of it.
> This is the first **published** binary to carry the encrypted-archive work and
> the dictionary-reporting fix (`[v1.9.3M]` below). It was validated end to end
> on a real console before release; the acceptance checklist that was run is
> `docs/DEVICE-TEST-v1.9.3M.md`. The previous release, v1.9.2, contains none of
> this work.
>
> Its size is **unchanged yet again** (903 448 B) although the content grew, for
> the sixth build in a row. Every round of this release has only moved
@@ -108,7 +111,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
> (`Makefile` CFLAGS `-Ithird_party/unrar`, `src/extract.c` forward
> declaration of `extract_progress`); no vendored or engine changes.
## [v1.9.3M] — 2026-09-23
## [v1.9.3M] — 2026-09-24
**Encrypted archives now extract end to end — ZIP (both schemes), RAR, and 7z
with an encrypted header.**
@@ -393,6 +396,64 @@ code the task overlay's existing password prompt already reacts to.
- End-to-end validation of the built ELF on a real console.
## [v1.9.2] — 2026-09-05
**Version-string-only re-release: the tag now points at the tree that produced
the published binary.**
The `v1.9.1` tag sat four commits behind the tree its ELF was built from, so
cloning that tag could not rebuild the published artifact. v1.9.2 is cut from
the right commit. It is functionally identical to the v1.9.1 binary — the only
source delta is the version literal itself (`VERSION_TAG` in the Makefile, plus
the UI footer fallback in `assets/main.js`) — and the build is reproducible:
reverting those two literals reproduces the v1.9.1 ELF byte for byte.
Release artifact: `web-file-mgr-v1.9.2.elf` — 870 488 bytes (~850 KiB), sha256
`177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84`.
## [v1.9.1] — 2026-09-05
**7z extraction — a third engine — plus a size and throughput pass.**
Added:
- `src/sevenz_extract.{c,h}` — the 7z engine, built on the LZMA SDK 26.03
decode subset plus the project's own pull-based codec chain
(`src/sevenz_chain.c`). The SDK's own `SzArEx` path only understands folders
of up to four coders, which cannot express BCJ2's five — hence the
self-parsed folder table and the pull-based chain. Dispatch is by extension
in `src/extract.c`; the three-phase model, the limit profiles and the
conflict policy are shared with ZIP and RAR, so `.7z` files get the same
**Extract** button as `.zip` and `.rar`.
- `src/sevenz_volstream.{c,h}` — `.7z.001` / `.z01` byte-split volume sets,
stitched by name; open the first volume.
- 7zAES content decryption (AES-256-CBC). The frontend asks for the password
*up front* here, so an unencrypted archive does not pay for a wasted scan.
Performance (decode-only, no functional change):
- LZMA SDK assembly decoder (`Asm/x86/LzmaDecOpt.asm` assembled with jwasm,
with an automatic pure-C fallback) ≈ 1.26×.
- Single-coder pure-LZMA2 folders decode multi-threaded
(`Lzma2DecMt` via `src/sevenz_mt.c`, 8 threads) ≈ 1.37×.
- The extract path drops its per-entry `fsync` — publish is rename-only and
there is no resume feature to protect (≥ 14× measured on an 8000-file
archive; see `docs/EXTRACTION-PERF.md`).
Build and size:
- `VERSION_TAG` v1.9.1. `src/demangle_stub.c` keeps libc++abi's Itanium name
demangler (105 KiB, reachable only from the uncaught-exception path) out of
the link, and `-Wl,--icf=all` folds identical functions: −15.8% overall with
no functional or throughput change, 1 017 864 B → 870 488 B. Measured in
`docs/SIZE-OPTIMIZATION.md`.
Known gap at the time: 7z `-mhe=on` encrypted headers — closed in v1.9.3M with
`src/sevenz_header.c`.
Tests: **163 checks** (ZIP 108 + RAR 27 + 7z 28), 0 failures, plus a successful
PS5 cross-compile.
## [v1.9] — 2026-09-05
**RAR engine replaced: rarlab UnRAR 7.20.1 (v6 / multi-volume / decryption-capable).**
@@ -758,7 +819,9 @@ python3 .build/check-elf-gzip.py ./web-file-mgr.elf # 7/7 v1.7 keys + 1 v
A long-form technical write-up of this upgrade lives in
[`docs/UPGRADE-v1.8-rar-support.md`](./docs/UPGRADE-v1.8-rar-support.md).
The vendoring decision tree (and the v1.9 plan) is in
[`third_party/unrar/VENDORED.md`](./third_party/unrar/VENDORED.md).
[`third_party/unrar7/VENDORED.md`](./third_party/unrar7/VENDORED.md) — v1.8
shipped it at `third_party/unrar/VENDORED.md`; the directory was renamed in
v1.9 when the engine was replaced.
### Credits
+13 -12
View File
@@ -1,10 +1,10 @@
# 交接文档 — ps5-web-file-manager 工作进度
> 交接时间:2026-09-23 · 分支 `main` · 最新提交仍是 **`3f80eb4`**(tag `v1.9.2`)——**本轮加密改动全部尚未提交**
> 交接时间:2026-09-25 · 分支 `main` · 最新提交 **`770dcb8`**(「docs: mark v1.9.3M as published」)——tag + Release **`v1.9.3M`** 指向发布提交 `ba668ad`,三者均已推送
>
> **主线(用户 2026-09-12 指令)**:「先从 zip 分卷开始吧,然后把六种组合打齐,并把密码通道补齐,注意一些报错信息提示的时候尽量详细准确」
> **状态:主线全部闭合,且格式面已无已知缺口。** 六种组合(ZIP/RAR/7z × 单卷/分卷)+ 密码通道(ZIP ZipCrypto/AES + RAR `-p`/`-hp` + 7zAES **含 `-mhe=on` 加密头**)+ 报错详细信息,全部落地、测试全绿、PS5 ELF 构建成功。
> **剩余**:① PS5 真机端到端验证(**唯一还没过的关卡**);② 版本号仍是 `v1.9.2`,本轮改动处于「未发布」状态——发版需先升 `VERSION_TAG` 与 `APP_VERSION_FALLBACK`。
> **状态:主线全部闭合,格式面已无已知缺口,且已发版。** 六种组合(ZIP/RAR/7z × 单卷/分卷)+ 密码通道(ZIP ZipCrypto/AES + RAR `-p`/`-hp` + 7zAES **含 `-mhe=on` 加密头**)+ 报错详细信息,全部落地、测试全绿、PS5 ELF 构建成功。
> **剩余**:① ~~真机端到端验证~~ **已于 2026-09-24 通过**(用户实机复测三项反馈全过);② ~~发版~~ **已发 `v1.9.3M`**。当前唯一遗留是「含目录条目 + 覆盖模式」第二次解压失败是否属设计意图(见 §六)。
---
@@ -13,12 +13,13 @@
| 维度 | 状态 |
|---|---|
| 解压引擎 | ZIP / RAR / 7z × 单卷/分卷(6 组合)+ 三种加密(ZIP ZipCrypto/WinZipAES、RAR `-p`/`-hp`、7zAES 与 `-mhe=on` 加密头)全部打通 |
| 主机测试 | **ZIP 140 + RAR 37 = 177 checks,0 失败**(MinGW gcc;2026-09-23 复跑)+ 7z 套件 **27 用例 0 失败**(`KNOWN_GAPS` 已清空)+ 前端重试流程 27 checks(`.build/ui_retry_test.mjs`) |
| PS5 构建 | ✅ WSL prospero-clang 18.1.8,一键脚本可复现;构建已实测**确定性**(同源两次构建 sha256 相同) |
| ELF 产物(工作树,未提交) | `web-file-mgr-v1.9.3M.elf` · 903,448 B · sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7` · e_machine=0x003e(2026-09-24 最后一轮:上传菜单 + 拖拽提示 + 口令重试改按键 id + 错误文案编码修复 + 菜单行高亮的层叠修复 + 解压按钮常显置灰;**未发布**) |
| 已发布产物 | `web-file-mgr-v1.9.2.elf` · 870,488 B · sha256 `177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84`(不含加密改动) |
| GitHub | `main`(`3f80eb4`)与 tag `v1.9.2` 均已推送;Release `v1.9.2` 资产对应上一行;本轮改动尚未 commit |
| 主机测试 | **ZIP 140 + RAR 37 = 177 checks,0 失败**(MinGW gcc)+ 7z 套件 **27 用例 0 失败**(`KNOWN_GAPS` 已清空)+ 前端三份:`.build/ui_retry_test.mjs` **40 checks**、`.build/ui_upload_menu_test.mjs` **40 checks**、`.build/preview_check.mjs` **12 断言**(无头 Chromium) |
| PS5 构建 | ✅ WSL prospero-clang 18.1.8,一键脚本可复现;构建已实测**确定性**(同源两次构建 sha256 相同,并用 `cmp` 逐字节验证过) |
| ELF 产物(**已发布**) | `web-file-mgr-v1.9.3M.elf` · 903,448 B · sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7` · e_machine=0x003e(上传菜单 + 拖拽提示 + 口令重试改按键 id + 错误文案编码修复 + 菜单行高亮层叠修复 + 解压按钮常显置灰;2026-09-24 真机验证通过后发布) |
| 上一个发布产物(回滚点) | `web-file-mgr-v1.9.2.elf` · 870,488 B · sha256 `177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84`(不含加密改动) |
| GitHub | `main` = `ba668ad`、tag `v1.9.3M`、Release `v1.9.3M` 三者均已推送/发布,资产 sha256 与本地逐字节一致 |
| 已知功能缺口 | **无**(`-mhe=on` 已于 2026-09-23 补齐) |
| README | 2026-09-25 **重写为中英双语「功能导向」结构**(原为上游式的逐版本累加稿):删去六段 `What's new in vX.Y.Z`,改为 *功能 / 压缩包支持矩阵 / 限额与安全 / 快速上手 / 构建 / 使用 / 校验 / 测试 / 项目结构 / **与上游的差异** / 备注 / FAQ / 版本历史*;逐版本正文已迁入 `CHANGELOG.md`(并补上了原先缺正文的 `[v1.9.1]` `[v1.9.2]` 两节)。**注意**:上游 README 里的 `.elf` 一键启动(`localhost:9021`)**本仓源码中已不存在**,重写时未回填 |
### 发布命令(v1.9.2)
@@ -345,7 +346,7 @@ ELF 已构建,但需装 PS5 实测:
## 十、工作区状态
⚠️ **2026-09-23:工作树不再干净** —— 本轮加密改动(15 个已修改 + 8 个未跟踪文件)**尚未提交**,见文末「十一、本轮变更」。发版前需要先决定版本号并 commit。
✅ **2026-09-24:工作树已重新干净并发布** —— 该批次改动(59 个文件)已提交为 `ba668ad`、打 tag `v1.9.3M` 并发布 Release,资产与本地逐字节一致。以下为当时(2026-09-23 起)的状态记录,保留作历史。
以下为 2026-09-15 的历史清理记录(当时工作树干净,"仅剩有意保留的未跟踪文档")。
@@ -362,7 +363,7 @@ ELF 已构建,但需装 PS5 实测:
---
## 十一、本轮(2026-09-23)变更:加密通道补齐(未提交)
## 十一、本轮(2026-09-23)变更:加密通道补齐(已随 v1.9.3M 发布)
**目标**:让 ZIP 与 RAR 的加密归档真正可解(7zAES 早已可用)。两者此前都报
`extract_unsupported`,但**缺口在引擎侧,不在 UI** —— 密码框、`password=` 字段、
@@ -497,11 +498,11 @@ $(PS5_FLAGS_STAMP): FORCE
**未做(发版前必做)**:未 commit / tag / 发 Release;真机端到端未验。
版本号**已升**为 `v1.9.3M`(2026-09-24 加改版标记 `M`,见 §2.2)。
README(中英)、CHANGELOG、本文档已同步为「未发布」状态。
README(中英)、CHANGELOG、本文档已同步为「已随 v1.9.3M 发布」状态。
---
## 十二、本轮(2026-09-23)变更:7z `-mhe=on` 加密头(未提交)
## 十二、本轮(2026-09-23)变更:7z `-mhe=on` 加密头(已随 v1.9.3M 发布)
**目标**:补上最后一个 7z 格式缺口(设计与坑见 §八)。
+1 -1
View File
@@ -151,7 +151,7 @@ CFLAGS += `$(PKG_CONFIG) libmicrohttpd --cflags`
# ~850 KiB; see docs/SIZE-OPTIMIZATION.md.
LDFLAGS := -Wl,--gc-sections -Wl,--icf=all
LDADD := `$(PKG_CONFIG) libmicrohttpd --libs`
LDADD += -lSceIpmi -lSceAppInstUtil -lSceUserService
LDADD += -lSceIpmi -lSceAppInstUtil -lSceUserService -lkernel_sys
LINUX_CFLAGS := -O2 -flto -Wall -Werror -Isrc -Ithird_party/minizip-ng/include -Ithird_party/unrar7 -Ithird_party/7z -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
LINUX_CFLAGS += `$(HOST_PKG_CONFIG) libmicrohttpd --cflags`
LINUX_LDADD := `$(HOST_PKG_CONFIG) libmicrohttpd --libs` -pthread
+413 -501
View File
File diff suppressed because it is too large. Load diff
+354 -331
View File
@@ -4,120 +4,43 @@
# PS5 网页文件管理器(PS5 Web File Manager)
> 面向已越狱 PS5 主机的自制 HTTP 文件管理器。通过同一局域网内的任意浏览器(包括 PS5 自带浏览器)即可浏览、编辑、上传、下载并解压 ZIP / RAR / 7z 压缩包——单个自包含 ELF 载荷,无外部服务、无遥测上报。
<p align="center">
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases/latest"><img src="https://img.shields.io/github/v/release/LisherSong/ps5-web-file-manager" alt="最新发布版"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/LisherSong/ps5-web-file-manager?color=blue" alt="许可证"></a>
<img src="https://img.shields.io/badge/target-x86__64--sie--ps5-blue" alt="目标平台:x86_64-sie-ps5">
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases"><img src="https://img.shields.io/github/downloads/LisherSong/ps5-web-file-manager/total?color=green" alt="总下载量"></a>
</p>
**版本:** v1.9.2 · **标题 ID:** `FMGR88888` · **许可证:** GPLv3+ · **目标平台:** `x86_64-sie-ps5`
<p align="center">
<a href="https://github.com/LisherSong/ps5-web-file-manager/releases/latest"><img src="https://img.shields.io/badge/%E4%B8%8B%E8%BD%BD-ELF%20%E8%BD%BD%E8%8D%B7-2ea44f?style=for-the-badge" alt="下载 ELF 载荷"></a>
<a href="docs/USER-GUIDE-zh-CN.md"><img src="https://img.shields.io/badge/%E6%96%B0%E6%89%8B%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E-%E4%B8%AD%E6%96%87-2563eb?style=for-the-badge" alt="新手使用说明"></a>
</p>
> 面向已越狱 PS5 主机的自制 HTTP 文件管理器。通过同一局域网内的任意浏览器即可浏览、编辑、上传、
> 下载并解压压缩包——单个自包含 ELF 载荷,不需要任何外部 helper 文件,不上报任何遥测。
**版本:** v1.9.3M · **标题 ID:** `FMGR88888` · **许可证:** GPLv3+ · **目标平台:** `x86_64-sie-ps5`
**下载:** [最新发布版](https://github.com/LisherSong/ps5-web-file-manager/releases/latest) · **第一次用?** [《新手使用说明》](docs/USER-GUIDE-zh-CN.md)
---
## 概述
## 这是什么
一个在已越狱 PS5 上运行的 HTTP 文件管理器载荷。从局域网内任意浏览器(含 PS5 浏览器本身)打开 `http://<PS5_IP>:8888/`,即可管理外接 USB 存储与用户分区的文件。设计初衷是安全地把游戏 dump 文件夹从 USB 拷贝到内置存储,但它同时也支持常规文件管理、原地文本编辑、PKG 预览/安装、图片预览,以及内置防 zip 炸弹保护的解压功能。
一个单文件载荷 ELF,在已越狱的 PS5 上跑起一个 HTTP 文件管理器。把它发给主机的 ELF 加载器,主机会在
`8888` 端口启动 HTTP 服务(该端口被占用时自动往上找下一个空闲端口)。在局域网内任意浏览器(包括
PS5 自带浏览器)打开 `http://<PS5_IP>:8888/`,即可管理外接 USB 存储与用户分区上的文件。
同一套源码树可构建出供开发用的 Linux 二进制,以及供部署的 PS5 载荷 ELF——见下方 `make linux`。
它只为把一件事做安全、做快而写:**把游戏 dump 文件夹从 USB 拷进内置存储。** 其余能力——浏览、排序、
改权限、原地编辑文本、预览图片、安装 PKG、多选复制/移动/删除、上传与下载——都是为了让这件事在
实际操作中行得通。在这之上,本仓又加了 **ZIP / RAR / 7z 的原生解压**,并配上了上游那套 helper 路线
所没有的安全护栏(防压缩炸弹、防路径穿越、防写满磁盘)。
> **第一次用、不想看技术细节?** 直接看
> [《新手使用说明》](docs/USER-GUIDE-zh-CN.md):怎么装、怎么传文件、怎么解压(含带密码与分卷的包)、
> 界面上每句话是什么意思,以及与上游原版的差别——全部用大白话写。
同一套源码树也能编出 Linux 二进制,因此整个前端界面不依赖主机、也不需要 PS5 SDK 就能开发:
## 未发布内容(下次发版将包含)
**加密归档现在可以端到端解压——ZIP(两种方案)、RAR,以及带头加密的 7z 都已打通。**
此前所有加密归档都会被提前拒绝,尽管密码输入框、失败提示与 `extract_password` 文案从 v1.9 起就已就位。真正的缺口在引擎侧,而不在 UI:
- **ZIP**:vendored 的 minizip-ng 在裁剪时把 crypto 后端一起裁掉了,于是(未被改动的)`mz_zip.c` 里那些 `-DHAVE_WZAES` / `-DHAVE_PKCRYPT` 分支没有实现可调。
- **RAR**:rarlab UnRAR 本身能解密,但 `RARSetPassword` 从未被调用。
- **7z**:`-mhe=on` 把文件名与 folder 表放进了加密头,归档连列出都做不到。
三者现在都已接线。密码缺失或错误会统一报为 `extract_password`(引擎层即 `ZIPX_ERR_PASSWORD`),也就是任务浮层已有的密码提示所响应的那个错误码。
### 变更
- **版本号加改版标记:`v1.9.3` → `v1.9.3M`。** 上游 owendswang 的发布版是纯 `vX.Y.Z`,因此这个 `M`(Modified,改版)就是「上游原版还是本仓改版」的判据。它是 `VERSION_TAG` 的一部分,所以 `/api/version`、PS5 启动通知、stdout 横幅、网页右下角**与 ELF 文件名**会一次性全部带上;网页右下角另加悬浮提示(`versionTooltip`,中英各一)解释这个字母的含义,免得没读过发行说明的人无从判断。产物名随之改变,也顺带让本仓产物再不可能与上游同版本号的资产同名相撞。
### 新增
- **加密 ZIP** —— 传统 PKWARE(「ZipCrypto」,即 `zip -e` 写出的格式)与 WinZip AES-128/192/256(压缩方法 `99` + `0x9901` 扩展字段,即 `7z -mem=AES256` 写出的格式),stored 与 deflated 条目均支持。
- **加密 RAR** —— `-p` 内容加密与 `-hp` 头加密。`RARSetPassword` 现在在 `RAROpenArchiveEx` 之后、首次 `RARReadHeaderEx` 之前调用,这正是 unrar 解密 RAR5 头所需的顺序。
- `/api/extract` 的 `password=` 现在对两个引擎都能真正走到解密路径。空值或缺失视为「无密码」,因此表单原值可以直接透传。
- `third_party/minizip-ng/src/mz_crypt_wfm.c` —— 为裁剪后的 minizip-ng 提供的本地 crypto 后端:SHA-1、HMAC-SHA1、AES-128/192/256;S-box 与 GF(2^8) 表在首次使用时推导,因此二进制不新增任何 `.rodata` 查表。PBKDF2 复用 vendored 的 `mz_crypt.c`;随机数直接读 `/dev/urandom`(不再是 `mz_os_rand()`),从而把 `rand`/`srand` 排除在导入表之外。以下文件按上游 4.2.2 原样恢复:`mz_strm_wzaes.{c,h}`、`mz_strm_pkcrypt.{c,h}`。
- **前端在密码失败后可直接重试**:`extract_password` 失败不再只是弹一个错误框,而是弹出密码输入框并按原参数(冲突策略、大文件选配)重新发起同一次解压,最多重试 3 次;取消或留空则回落到原有的失败提示。
- **加密 7z 头(`-mhe=on`)现在可以打开。** 这是最后一个已知的格式缺口:`-mhe=on` 时文件名、folder 表**与每个条目的尺寸**全都在加密头里,因此 vendored SDK 在能列出任何条目之前就以 `SZ_ERROR_UNSUPPORTED` 退出。新模块 `src/sevenz_header.c` 读出该头部记录,用它自己的那一个 folder 走项目自研的 7zAES 路径(`src/sevenz_chain.c`)解码,然后交给 SDK 一个虚拟流——把加密头所在区域替换成明文。SDK 于是照常解析它一向解析的那个归档,磁盘上的文件完全不被改动;头部只是被*压缩*(`-mhc=on`,默认)的归档完全不受影响;密码错误则与其它加密归档一样返回 `extract_password`。
- `tests/make-zip-enc-fixtures.bat`,以及 `tests/fixtures-real/` 下的三个真实 fixture(`enc-zipcrypto.zip`、`enc-aes256.zip`、`enc-aes256-store.zip`,密码 `secret123`)。
### 修复
- **编译选项变化现在会使目标文件失效。** `make` 察觉不到编译选项变化,因此加上 `-DHAVE_WZAES -DHAVE_PKCRYPT` 后旧的 `mz_zip.o` / `mz_crypt.o` 原样保留——又因为此时已没有任何代码引用新流,`--gc-sections` 会在链接「成功」的同时把加密代码再次丢掉(本次改动的第一次构建产物与已发布的 release 逐字节相同)。Makefile 现在把第三方编译选项集记录进 `ps5-obj/.third_party_cflags` / `linux-obj/.third_party_cflags`,只在真正变化时重编——这正是早年 `LzmaDec.o` 规则所规避的同一个陷阱,现已通用化。
- `ZIPX_ERR_UNSUPPORTED` 不再涵盖加密,现在仅表示「多卷或不受支持的压缩方法」。
- 顺带把 `tests/test_sevenz_extract.c` 里一处会导致截断告警的 `snprintf` 缓冲区调足。
### 测试
- `tests/test_zip_extract.c` 对每个加密 fixture 跑四种情况(无密码 → `PASSWORD`、空密码 → `PASSWORD`、错密码 → `PASSWORD`、正确密码 → `ZIPX_OK` 并逐字节校验内容),另加一项证明「提供密码后限额依旧生效」。
- `tests/test_rar_extract.c` 对 `enc-v6.rar` 做同样的四种情况验证,包括失败路径绝不发布任何文件。
- `tests/test_sevenz_extract.c` 对 `aeshe.7z` 跑三种情况:无密码 → `ZIPX_ERR_PASSWORD`、错密码 → `ZIPX_ERR_PASSWORD`、正确密码 → 成功且逐字节一致,并证明失败后不留下 staging 目录。
- 前端重试流程有一份无头检查(`.build/ui_retry_test.mjs`,把 `assets/main.js` 载入桩 DOM):**40 项检查**,覆盖参数记忆、重试上限、取消与空密码的回落,以及「非 ASCII 目录下必须仍然弹出密码框」的回归用例。另有一份 `.build/ui_upload_menu_test.mjs`(**40 项检查**)盯标记侧:i18n 键在两份语言文件里都存在、上传菜单接对了回调、用到的 class 确实有样式、菜单行高亮规则必须带面板作用域(否则会输给通用按钮规则而静默失效),以及**解压按钮不许被隐藏、只许被置灰**(顺带扫 `main.js` 里 117 个 `t("...")` 键是否双语齐全)。
- 主机端合计:**140 ZIP + 37 RAR = 177 项检查**,0 失败。
- 请求的字典超过本构建支持上限的 RAR 归档不再被误报成「单条目过大」:它有独立的 `extract_dict_too_large` 编码,报错文案同时给出归档需要的字典与构建支持的上限。构建行为**未变** —— 这类归档仍然被拒,因为放行意味着一次性分配整个字典窗口,而这正是 rarlab 自家 CLI 默认拒绝、16 GB 共享内存的主机也承受不了的。
- 7z 套件:**27 项用例,0 失败**(`tests/run-sevenz-tests.sh`),且原先登记 `aeshe` 的 `KNOWN_GAPS` 列表现已**清空**——加密头 fixture 同时通过 folder 解码器与解压 façade 两条路径。
- 当前源码树构建产物 **903 448 B**,sha256 `8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7`,`e_machine` 为 `0x003e`;同一源码树构建两次逐字节一致。产物内已确认包含新的前端代码(前端资源是 gzip 内嵌的,需先解压才能在二进制里检索到)。段数仍为 20,**动态符号零新增**。加密归档那批改动净增 5 712 字节正文(`.text` +4 880、`.rodata` +640、`.eh_frame*` +192);加 `M` 标记再让 `.rodata` 涨 0x100(256 B),修正 `err_extract_unsupported` 文案再涨 0x40(64 B),上传菜单再涨 0x980(2 432 B),第一轮修复的文案与 CSS 再涨 0x100(256 B),菜单行高亮的收敛规则再涨 0x180(384 B),解压按钮常显(去掉 `hidden`、换短标签、三条禁用理由、`.extract-action:disabled`)再涨 0x240(576 B),**其余段尺寸一个都没变**,因此文件总尺寸仍是 903 448 B。**尺寸没变不等于内容没变** —— 判断只看 `readelf -SW` 的段尺寸。
### 仍未完成
- 在真机上做端到端验证。
> 注意:本节描述的是**未发布**状态。已发布版本号仍为 `v1.9.2`,其二进制**不含**上述加密支持;当前未发布的源码树自报版本为 `v1.9.3M`。
## v1.9.2 与 v1.9.1 新增内容
> **v1.9.2 与 v1.9.1 的功能完全相同,只换了内嵌版本号。** 原因是原先的 `v1.9.1` tag 指在产出发布二进制的提交**之前 4 个提交**,tag 与产物对不上(clone 该 tag 无法重建出发布的那份 ELF);v1.9.2 重新从产出该二进制的提交上打,使 tag = 源码 = 二进制。
- **7z 解压引擎**(`src/sevenz_extract.{c,h}`):自研解码子集 + 拉式 codec 链(`src/sevenz_chain.c`,覆盖 LZMA2 / BCJ2 等),由 `src/extract.c` 按扩展名分派,与 ZIP / RAR 共用同一套三阶段模型与限额档位。`.7z` 文件在文件列表中同样带「解压」按钮。
- **7zAES 内容解密**(AES-256-CBC):引擎可解密带密码的 7z 内容,密码经 `/api/extract` 的 `password=` 传入;解压 7z 时前端会提前询问密码。ZIP / RAR 的加密当时尚未打通(引擎侧缺口,见顶部「未发布内容」),v1.9.1 时对它们仍会报 `extract_unsupported`。
- **7z 分卷**:`.7z.001` / `.z01` 等链式分卷由 `src/sevenz_volstream.c` 按名拼接,打开首个分卷即可。
- **性能三项**(纯解码提速,不影响功能面):
- SDK 汇编 LZMA 解码器(`Asm/x86/LzmaDecOpt.asm` + jwasm,无 jwasm 自动回退纯 C)≈ 1.26×。
- 纯 LZMA2 文件夹多线程解码(`src/sevenz_mt.c` + `Lzma2DecMt`,8 线程)≈ 1.37×。
- 移除 ZIP 逐条目 fsync,减少 staging 重命名前的写盘开销。
- **当时唯一缺口**:7z `-mhe=on` 加密头(独立单元,读取需自研头解析器),其余 7z 特性均已支持。已在顶部「未发布内容」中补上。
## v1.9 新增内容
- **RAR 引擎替换为官方 rarlab UnRAR 7.20.1**(`third_party/unrar7/`,取代 dmc_unrar)。这正是让 RAR 解压在真实文件上可用的一步:dmc_unrar 无法解码 **WinRAR 6.x/7.x** 写出的归档(RAR5「v6」压缩),也不支持多卷;两者现在都能工作。
- **RAR5「v6」归档可解压**(v1.8 时代在 WinRAR 6/7 文件上报「归档损坏」的问题已消失)。
- **多卷 RAR**(`.part01.rar` 链):当完整卷集与被打开的卷放在同一目录时,unrar 按文件名拼接各部分。
- 引擎可解密加密 RAR(`RARSetPassword`)——发 v1.9 时密码 UI / API 接线尚未完成,加密归档会被拒绝;该接线已在顶部「未发布内容」中补齐。
- 主机测试现用真实归档(v6 / 加密 / 3 卷 fixture,提交于 `tests/fixtures-real/`):**70 ZIP + 24 RAR = 94 项检查**。
## v1.8 新增内容
- **单卷 RAR 解压**,基于内置的 FLOSS 库 [`dmc_unrar`](https://github.com/DrMcCoy/dmc_unrar)(GPL-2.0-or-later)。支持 RAR 1.5、2.x、3.x、4.x、5.x 归档。`.rar` 文件出现在文件列表中且「解压」按钮可用;`.part02+.rar` 子卷上的按钮置灰,提示「请选择主卷」——v1.8 无法拼接多卷 RAR(见下方 [RAR 解压](#rar-解压) 章节)。
- 新引擎 `src/rar_extract.c` 与既有 `src/zip_extract.c` 之间**共享解压协议**:相同的 `zipx_status_t` 状态码、相同的 `zipx_limits_t` 档位(默认 / `large=1`)、相同的三阶段模型(`scan → extract → publish → cleanup`)、相同的 staging 目录布局、相同的冲突策略、相同的错误映射到任务 UI。`src/extract.c` 中的分派器只是一个微小的 `ends_with_ci(…)` 判断。
- **14 个新增主机端 C 测试**(`tests/test_rar_extract.c`)接入现有 `tests/run-tests.sh`。覆盖:格式分派、每个影响 RAR 用户的 `DMC_UNRAR_*` 错误码翻译、限额档位交接。主机检查总数:**69 ZIP + 14 RAR = 83**。
- **文档**:[`CHANGELOG.md`](./CHANGELOG.md)、[`docs/UPGRADE-v1.8-rar-support.md`](./docs/UPGRADE-v1.8-rar-support.md),以及 `third_party/unrar7/VENDORED.md` 中的 vendoring 决策树(v1.8 时为 `third_party/unrar/VENDORED.md`)。
## v1.8.1 新增内容
- **放宽默认 ZIP 限额**(配合 v1.7 的大档案档位)。v1.7 默认单条目上限为 64 GiB,对典型 PS5 系统备份 ZIP(200–300 GiB)过于激进。v1.8.1 将默认档位提高到 **总量 1 TiB / 单条目 256 GiB / 500:1 比率**,保留 `large=1` 选项为 2 TiB / 1 TiB / 1000:1。前端阈值从 60 GiB 提升到 240 GiB,使常见系统备份归档不再触发确认提示。
- RAR 解压继承这些新默认值(`rar_extract.c` 直接从引擎透传 `c->limits`,无需改引擎)。
- 理由:真正的防 zip 炸弹防线是 `check_space()`(staging 前基于 statvfs 的真实磁盘空间检查)+ `max_ratio`(声明的压缩比上限)。尺寸上限只是 UX 护栏,而非安全边界。
## v1.8.2 新增内容
- **再次放宽默认 ZIP 限额**,针对 3A 游戏单文件场景。v1.8.1 仍会静默拒绝归档内单个约 300 GiB 的未压缩文件(默认扫描在请求到达前端确认提示之前就返回 `ZIPX_ERR_LIMIT_FILE_SIZE`)。v1.8.2 将默认档位提高到 **总量 2 TiB / 单条目 512 GiB / 500:1 比率**,`large=1` 选件提到 4 TiB / 1 TiB / 1000:1。前端阈值从 240 GiB 提升到 480 GiB。
- **两个 PS5 专属构建修复**,在交叉编译 PS5 目标时发现。主机端测试套件(`tests/run-tests.sh`)曾静默接受二者,因为它链接相同源码但使用 gcc 而非 clang 18,且包含路径不同:
- `Makefile` CFLAGS:加入 `-Ithird_party/unrar`,使 `src/rar_extract.c` 能找到项目自有的 `dmc_unrar_api.h` 门面头文件。
- `src/extract.c`:把 `extract_progress()` 定义移到 `extract_dispatch()` 之前,避免被 `-Werror=implicit-function-declaration` 标记(PS5 SDK 的 clang 18 比测试用的主机 gcc 更严格)。
- v1.8.2 发布产物:`web-file-mgr.elf` —— 509 704 字节,sha256 `1b2c3d68b35e32737105f17d14a80a3c159ceca0cabd274ee168cbcd81906f65`,ELF 64 位小端,e_machine `0x003e`(x86_64-sie-ps5)。
- 测试:**84 项主机端检查**(70 ZIP + 14 RAR),0 失败。PS5 交叉编译端到端成功。
## v1.7 新增内容
- **ZIP 大文件档位**(通过在 `/api/extract` 传入新的 `large=1` 参数选配启用):放宽的限额为 **总量 2 TiB** / **单条目 1 TiB** / **1000:1 压缩比**。当磁盘上归档大于 **60 GiB** 时前端会提示确认;仅当用户明确同意时服务器才启用该档位。
- **更严格的默认 ZIP 档位**保持安全:**总量 1 TiB** / **单条目 256 GiB** / **500:1 比率**。一个 4 MiB 压缩包解压到 800 GiB 仍会在打开任何输出文件之前被拒绝。
- **69 项主机端 C 测试**(`tests/run-tests.sh`)现已覆盖路径穿越、ZIP64、加密拒绝、压缩比、冲突策略与新增大文件档位(`tests/test_zip_extract.c`)。
- 更早的细化——见 v1.6 以来的 `git log`。
```sh
make linux && ./web-file-mgr-linux-v1.9.3M
```
## 截图
@@ -132,37 +55,176 @@
## 功能
- **浏览** —— 列出文件与文件夹;按名称、类型、大小、修改时间或权限排序。上次排序方式持久化在 `localStorage`。
- **权限** —— 用复选框切换读/写/执行,或粘贴经过校验的四位八进制模式。
- **操作** —— 复制、移动、删除(递归、无回收站)、重命名、创建文件与文件夹。
- **编辑器** —— 针对 ≤ 1 MiB 的文件,跨精选扩展名列表的原地 UTF-8 文本编辑器:`.txt .json .xml .ini .cfg .conf .md .log .lua .js .css .html .htm .c .h .cpp .hpp .sh .csv .yaml .yml .shn`。
**文件与目录**
- **浏览与排序** —— 列出文件与文件夹;按名称、类型、大小、修改时间或权限排序。上次选的排序方式
持久化在 `localStorage` 里。
- **权限** —— 在权限列用复选框切换读 / 写 / 执行,或粘贴一个经过校验的四位八进制模式。
- **复制与移动** —— 两步式「剪贴板」流程:先选中要处理的项,再浏览到目标目录粘贴(复制)或移入
(移动)。覆盖文件与合并文件夹时都会弹出冲突确认。
- **重命名** —— 就地重命名选中的单个条目。
- **删除** —— 递归且永久,没有回收站。
- **新建** —— 新建文件夹与新建空文本文件。
- **多选** —— 一次性复制、移动、删除或打包下载多个项目。
- **上传** —— 从局域网内任意设备上传单文件或文件夹树(在 PS5 浏览器中隐藏)。原子化的临时文件 + 重命名。
- **下载** —— 单文件以原始字节下载,或文件夹/多选以流式 `.tar` 下载。在 PS5 浏览器中隐藏。
- **任务** —— 全屏覆盖层,带延迟显示、实时进度、吞吐率、ETA、取消,以及浏览器中途关闭重开后的恢复能力。
- **归档解压** —— ZIP、RAR、7z 三种引擎,均带防 zip 炸弹 / 路径穿越 / 压缩比保护。ZIP 覆盖 stored / deflated / ZIP64 以及**加密**条目(ZipCrypto 与 WinZip AES-128/192/256);RAR 覆盖 RAR4 + RAR5(含 WinRAR 6/7「v6」)、多卷,以及 `-p` / `-hp` 加密;7z 覆盖 LZMA / LZMA2 / PPMd、Delta 与 BCJ2、`.7z.001` 分卷、7zAES 与 `-mhe=on` 加密头。详见下方 [ZIP 解压](#zip-解压)、[RAR 解压](#rar-解压)、[7z 解压](#7z-解压)。
- **加密归档重试** —— 解压遇到加密归档时,会弹出密码输入框并按原参数自动重试(最多 3 次);也可以在解压 7z 时提前输入密码以免白跑一次扫描。
- **PKG** —— 安装并预览 `.pkg` 文件。
- **图片** —— 预览 `.png .jpg .jpeg .gif .bmp .webp`。
- **本地化** —— 英文 + 简体中文,根据 `navigator.languages` 自动选择。
- **复制/移动后的文件会被 chmod 成 `0777`**(前提是文件系统支持 Unix 权限)。FAT/exFAT 类文件系统
可能忽略 chmod——那是文件系统自己的答复,不是出错。
**内容**
- **文本编辑器** —— 对 ≤ 1 MiB 的文件做原地 UTF-8 编辑,覆盖一份精选扩展名列表:`.txt .json .xml
.ini .cfg .conf .md .log .lua .js .css .html .htm .c .h .cpp .hpp .sh .csv .yaml .yml .shn`。
非 UTF-8 与超大文件会被直接拒绝,而不是改坏。
- **图片预览** —— `.png .jpg .jpeg .gif .bmp .webp`,直接由主机串出。
- **PKG** —— 安装 `.pkg` 文件,并预览其元信息。
**数据的进出**
- **上传** —— 工具条上的「上传 ▾」菜单里选**单文件**或**文件夹树**;整页拖拽上传同样可用,页脚也
写明了这一点。文件先写临时名,传输完成后重命名就位。在 PS5 浏览器中隐藏——它的用途是让你从
另一台设备去驱动主机。
- **下载** —— 单文件按原始字节下载;文件夹或多选则打成流式 `.tar`,不会先写进主机存储。在 PS5
浏览器中隐藏。
- **上传并解压** —— 选中一个压缩包并勾选「上传后解压」,上传一落盘就开始解压;若发现是加密包,
密码框会立刻弹出。
**压缩包解压** —— 完整支持矩阵见 [压缩包支持](#压缩包支持)。一句话:ZIP、RAR、7z,明文或加密、
单卷或分卷,全都走同一套尺寸 / 压缩比 / 路径穿越 / 磁盘空间保护,而且全部实现在本载荷内部——
不需要再装第二个文件。
**其他**
- **任务浮层** —— 全屏浮层,延迟显示、实时进度、吞吐率、ETA、取消,并能在浏览器中途关闭重开、
而载荷进程仍在运行时恢复活动任务的显示。
- **本地化** —— 英文与简体中文,依 `navigator.languages` / `navigator.language` 自动选择
(`zh*` → 中文,其余 → 英文)。
- **移动端友好** —— 响应式布局,工具栏自动换行,文件列表可横向滚动。
- **启动通知与主屏启动器** —— 通知会显示应用名、版本与实际监听端口;首次启动时载荷会在 Media
分类安装一个「PS5 Web File Manager」快捷方式,且不覆盖已存在的启动器文件。启动器图标与浏览器
favicon 用的是同一份内嵌 `icon0.png`,所以图标在 ELF 里只存一份。
- **文件名不因编码混杂而丢失** —— 名字经 Web API 以 UTF-8 传输,但载荷也会保留挂载文件系统返回的
字节序名称,因此一块装着 GBK 文件名的 U 盘仍能正确显示与操作。上游有同一套机制;本仓多出来的
是错误提示也会解码,不至于在最需要看清条目的那一刻给出乱码名(见[备注](#备注))。
## 压缩包支持
三个引擎,由 `src/extract.c` 按扩展名分派,共用同一条三阶段流水线
(`scan → 解压到 staging → 按 rename 发布`)与同一套限额档位、冲突策略。
vendoring 决策与逐库许可证立场见
[`third_party/unrar7/VENDORED.md`](third_party/unrar7/VENDORED.md) 与
[`THIRD_PARTY_NOTICES`](THIRD_PARTY_NOTICES)。
| | ZIP | RAR | 7z |
|---|---|---|---|
| 引擎 | `src/zip_extract.{c,h}` | `src/rar_extract.{c,h}` | `src/sevenz_extract.{c,h}` |
| 后端 | vendored minizip-ng 4.2.2 + zlib | vendored **rarlab UnRAR 7.20.1**(官方源码) | LZMA SDK 26.03 解码子集 + 自研 codec 链 |
| stored / deflated | ✅ | 不适用 | ✅(Copy / LZMA / LZMA2 / PPMd) |
| 64 位尺寸 | ✅ ZIP64 | ✅ | ✅ |
| 过滤器 / 转换器 | — | — | ✅ Delta、BCJ2、PPC / IA64 / ARM / ARMT / SPARC |
| 分卷 | ✅ 引擎自行找齐各卷 | ✅ unrar 按名拼接 | ✅ |
| 传统密码 | ✅ PKWARE「ZipCrypto」(`zip -e`) | ✅ `-p` | — |
| AES 加密 | ✅ WinZip AES-128/192/256 | ✅ | ✅ 7zAES(AES-256-CBC) |
| 加密文件名 | — | ✅ `-hp` 头加密 | ✅ `-mhe=on` 加密头 |
| 密码询问时机 | 失败后询问并重试 | 失败后询问并重试 | 解压前提前询问 |
**可识别的分卷命名**
| 格式 | 接受 | 说明 |
|---|---|---|
| ZIP | `name.zip.001…`(7-Zip)、`name.part1.zip…`(WinRAR)、`name.z01…` + `name.zip`(Info-ZIP) | 任意一卷都可选,引擎会自己在同目录找齐其余分卷 |
| RAR | `name.part1.rar` / `name.part01.rar`(首卷) | 请选**首卷**;其余卷在界面中置灰并带提示 |
| 7z | `name.7z.001…` | 任意一卷均可,引擎会遍历目录取齐其余分卷 |
### 尺寸与安全限额
两档档位。默认档位出厂即安全;大档案档位**仅**在请求携带 `large=1` 时才启用,而界面会通过一次
确认提示让用户做出这个选择。
| 限额 | 默认 | 大档案(`large=1`) |
|---|---|---|
| `max_entries` | 200 000 | 500 000 |
| `max_total_bytes`(未压缩) | 2 TiB | 4 TiB |
| `max_file_bytes`(单条目) | 512 GiB | 1 TiB |
| `max_ratio`(未压缩 ÷ 压缩) | 500 : 1 | 1000 : 1 |
| `max_depth`(文件夹嵌套) | 32 | 32 |
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
默认上限是按主机上的真实工作量定的:一个 3A 作品打成「单个约 300 GiB 文件」的归档,无需任何提示
即可解出。
### 安全检查
在创建任何一个输出文件**之前**,解压就会拒绝以下情况:
- **路径穿越** —— `..` 段、绝对 POSIX 路径、Windows 盘符、RAR 内把 `\` 当分隔符。
- **特殊文件** —— 符号链接、设备、FIFO、套接字(`ZIPX_ERR_SPECIAL`)。
- **重复条目**,以及同一归档内目录与文件同名冲突。
- **突破限额** —— 解压后总尺寸、条目数、嵌套深度、名称长度或压缩比超出当前档位。
- **磁盘空间** —— `check_space()` 在开始写 staging 之前就按**解压后总量**查 `statvfs`,因此一个
不可能完成的解压根本不会启动。
提交密码**不会**跳过 scan 阶段:加密归档与明文归档受同一套限额约束。
### 冲突策略
通过 `/api/extract` 上的 `conflict=` 传入:
- `fail`(默认)—— 只要目标已存在就失败。
- `overwrite` —— 覆盖已存在文件,合并进已存在文件夹。
- `merge` —— 保留已存在文件,只新增其余文件。
### 密码处理
密码缺失或错误会返回 `ZIPX_ERR_PASSWORD`(界面上的 `err_extract_password`)。前端会弹出密码框,
并**按原请求**重新发起——冲突策略、大文件选配全部沿用——最多三次;取消或留空则回落到最初的失败
提示。首次失败时的文案说的是「此压缩包已加密」,而不会去责怪一个你压根还没被问过的密码。
7z 是例外:因为 `-mhe=on` 把文件名藏在加密头里,密码框会**提前**出现、早于 scan——否则一个加密
的 7z 会先白跑一遍扫描,才轮到有人问你要密码。
### 调整大文件提示阈值
前端阈值位于 `assets/main.js`:
```js
const LARGE_FILE_THRESHOLD_BYTES = 480 * 1024 * 1024 * 1024; // 480 GiB
```
磁盘上大于该值的归档会触发确认提示。设为 `Infinity` 可静音提示,调低则更保守,或干脆删掉该调用
——无论前端如何,服务器始终遵循 `large=1`。
### 本构建刻意不做的部分
- **ZIP / RAR / 7z 之外的格式。** `.tar`、`.tar.gz` / `.tgz`、`.gz`、`.xz`、`.bz2`、`.zst`、`.cab`、
`.arj`、`.lzh`、`.cpio`、`.xar` 以及长尾里的其他格式都不识别。上游是靠把一整个 7-Zip 当作外部
helper 进程分发,从而覆盖约 30 种后缀;本仓刻意不走这条路——原因见
[与上游的差异](#与上游的差异)。
- **ZIP 中 stored / deflated 之外的压缩方法**、7z 中使用了不受支持 coder 的 folder、早于 RAR 1.4 的归档。
- **命名成 `x.rar.001` 的 RAR 分卷集。** unrar 只认它自己的 `x.partN.rar` 命名;把分卷改名
(`.rar.001` → `.part1.rar`、`.002` → `.part2.rar`……)即可正常解压。ZIP 与 7z 的分卷集可以直接吃
`.001` 风格。
- **字典超过 4 GiB 的 RAR。** 这类归档会被独立地报成 `err_extract_dict_too_large`,文案同时给出
归档需要的尺寸与构建支持的尺寸。放行意味着**一次性分配整个字典窗口**——正是 rarlab 自家 CLI
默认拒绝、16 GB 共享内存的主机也承受不起的那件事。(RAR5 头字段本身卡在 4 GiB,所以这种情况只
可能来自更新版的 RAR7 头格式。)密码错误**不属于**这一类,多卷归档也不属于。
## 快速上手
1. **构建** ELF:
1. **构建**载荷:
```sh
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk # 见「构建」章节的 SDK 配置
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk # SDK 配置见下方「构建」
make
```
2. **发送** 载荷到 PS5(默认 ELF 加载器端口 `9021`):
2. **发送**到主机(ELF 加载器常用端口 `9021`):
```sh
nc -q0 "$PS5_HOST" 9021 < web-file-mgr.elf
nc -q0 "$PS5_HOST" 9021 < web-file-mgr-v1.9.3M.elf
```
3. **读取** PS5 屏幕上的通知——它会打印实际监听端口(默认 `8888`)。
4. 在**同一局域网**内的任意浏览器中打开 `http://<PS5_IP>:<port>/`——PS5 浏览器也可以。
5. 首次运行时,载荷还会写入一个 **Media** 分类的主屏启动器;已有的启动器文件不会被覆盖。
3. **读取**主机屏幕上的通知——它会打印实际监听端口(通常 `8888`)。
4. 在同一局域网内任意浏览器中**打开** `http://<PS5_IP>:<port>/`。
5. 首次启动时载荷还会写入一个 **Media** 分类的主屏启动器;已有的启动器文件不会被改动。
## 构建
@@ -172,13 +234,13 @@
export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk
```
本项目链接 `libmicrohttpd`。`make` 在构建前会检查它,缺失时自动运行安装器:
本项目链接 `libmicrohttpd`。`make` 会先检查它,缺失时自动运行安装器:
```sh
make
```
若构建主机无网络访问,可提前放入 libmicrohttpd 源码包并手动运行安装器:
若构建主机没有外网,可提前放入源码包并手动装一次:
```sh
LIBMICROHTTPD_TARBALL=/path/to/libmicrohttpd-1.0.1.tar.gz \
@@ -189,276 +251,232 @@ make
输出:
```text
web-file-mgr.elf (约数百 KiB,v1.9.1 含 unrar7 + 7z 后更大;x86_64-sie-ps5)
web-file-mgr-v1.9.3M.elf # x86_64-sie-ps5,约 882 KiB
```
若只想做纯 UI/JS 开发而不需要 PS5 工具链:
版本号是 `VERSION_TAG` 的一部分,因此也是输出**文件名**的一部分——一次构建不可能悄悄顶替掉另一个
版本的产物。需要时可以直接覆盖:
```sh
make VERSION_TAG=v1.9.4M
```
只想做纯 UI / JS 开发、不需要 PS5 工具链时:
```sh
make linux
./web-file-mgr-linux
./web-file-mgr-linux-v1.9.3M
```
Linux 构建**不包含** PS5 主屏启动器安装器。
## 使用
在 PS5 上启动一个 ELF 加载器(端口 `9021` 常见)。发送载荷:
在主机上启动一个 ELF 加载器(常用端口 `9021`),发送载荷:
```sh
export PS5_HOST=ps5_ip_address
nc -q0 "$PS5_HOST" 9021 < web-file-mgr.elf
nc -q0 "$PS5_HOST" 9021 < web-file-mgr-v1.9.3M.elf
```
载荷启动后,PS5 通知会显示应用名、版本与实际监听端口。打开它打印的 URL,例如:
启动后,通知会显示应用名、版本与实际监听端口。打开它打印的 URL:
```text
http://${PS5_IP_ADDRESS}:8888/
```
若载荷不得不回退到其它端口(如 `8889`),请以通知显示的端口为准——URL 并未硬编码。
如果 `8888` 已被占用,载荷会往上走到下一个空闲端口——请以通知显示的端口为准,URL 并未硬编码。
首次启动时它会在需要时于 Media 分类安装一个 `PS5 Web File Manager` 快捷方式;缺失的启动器文件会
被写入,已存在的会被保留。
首次启动时,载荷会在需要时于 Media 分类安装一个 `PS5 Web File Manager` 快捷方式。已有的启动器文件会被保留;只补写缺失的文件。
## ZIP 解压
支持普通 ZIP 与加密 ZIP——stored / deflated / ZIP64,传统 PKWARE(ZipCrypto)与 WinZip AES-128/192/256 两种加密方案。引擎是一个独立的三阶段模块(`scan → extract → publish → cleanup`),位于 `src/zip_extract.{c,h}`,配有独立的主机端 C 测试套件。每个条目先写入 staging 目录(`*.wfm-part-*`),再原子重命名到目标位置。解压路径里**刻意不做逐条目 `fsync`**——整条流水线是「不 sync、只 rename」,因为 publish 只是 rename、也没有续解功能需要保护(8000 文件档实测 ≥14×,见 `docs/EXTRACTION-PERF.md`)。归档中途任何失败都会回滚部分改动;取消与致命错误总会清理 staging。
### 限额
| 限额 | 默认档位 | 大档案档位(`ZIPX_LIMITS_LARGE`) |
|---|---|---|
| `max_entries` | 200 000 | 500 000 |
| `max_total_bytes`(未压缩) | 2 TiB | 4 TiB |
| `max_file_bytes`(单条目) | 512 GiB | 1 TiB |
| `max_ratio`(未压缩 / 压缩) | 500 : 1 | 1000 : 1 |
| `max_depth`(文件夹嵌套) | 32 | 32 |
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
**默认档位**出厂即安全:一个解压到 800 GiB 的 4 MiB 压缩块会在打开任何输出文件之前被拒绝。**大档案档位**仅在请求携带 `large=1` 时才启用——当磁盘上归档大于 `LARGE_FILE_THRESHOLD_BYTES`(默认 480 GiB;可在 `assets/main.js` 配置)时,解压对话框会自动提示用户。确认提示即为用户的明确选配;服务器自身不会额外记录任何内容。
### 安全检查
引擎拒绝解压以下归档:
- 路径穿越(`..` 段、绝对 POSIX 路径、Windows 盘符)。
- 符号链接、设备、FIFO、套接字(`ZIPX_ERR_SPECIAL`)。
- 同一归档内的重复条目或目录/文件名冲突。
- 解压后尺寸、条目数、嵌套深度、名称长度或压缩比突破当前档位。
加密条目**不再**属于拒绝项:密码通过 `/api/extract` 的 `password=` 传入,缺失或错误时返回 `zipx` 层的 `ZIPX_ERR_PASSWORD`(前端对应 `err_extract_password`),由界面提示后重试。scan 阶段对加密条目同样生效——限额不会因为提供了密码而被跳过。
### 冲突策略
通过 `/api/extract` 上的 `conflict=` 传入:
- `fail`(默认)—— 拒绝覆盖任何已存在的目标。
- `overwrite` —— 替换已存在文件;合并进已存在文件夹。
- `merge` —— 保留已存在文件,新增其余文件。
### 调整阈值
480 GiB 的前端阈值位于 `assets/main.js`:
```js
const LARGE_FILE_THRESHOLD_BYTES = 480 * 1024 * 1024 * 1024;
```
设为 `Infinity` 可静音提示,调低则更保守,或干脆删掉该调用——无论阈值如何,服务器始终遵循 `large=1`。
## RAR 解压
RAR 解压引擎(`src/rar_extract.{c,h}`)由 **官方 rarlab UnRAR 源码** 支撑(`third_party/unrar7/`,版本 7.20.1,编译为静态库并通过其 C 兼容的 DLL API 驱动)。扩展名为 `.rar` 的文件与 `.zip` 文件一样拥有**解压**按钮;引擎由 `src/extract.c` 按扩展名分派。
> v1.9 替换了 v1.8 的引擎(dmc_unrar 1.7.0)。dmc_unrar 无法解码 WinRAR 6.x/7.x 写出的归档(RAR5「v6」压缩)且不支持多卷;unrar 原生支持两者。
### 支持范围
| 格式 | 支持 | 备注 |
|---|---|---|
| RAR 1.5 → 4.x(含 2.9 / 3.6 / 4.0) | ✅ | |
| RAR 5.0 及 **5.0「v6」**(WinRAR 6.x / 7.x) | ✅ | v1.9 的触发点 |
| Solid 块、最大 1 GiB 字典 | ✅ | |
| PPMd 解压(RAR 3.0+) | ✅ | |
| **多卷**(`.part01.rar` + `.part02.rar` + …) | ✅ | 当完整卷集与被打开的卷同处一目录时,unrar 按名拼接。选择首个卷(`name.part1.rar` / `name.part01.rar`);非首卷在 UI 中仍置灰并给出提示。 |
| **加密 RAR** | ✅ | `-p` 内容加密与 `-hp` 头加密均可。密码经 `/api/extract` 的 `password=` 传入引擎(`RARSetPassword` 在 `RAROpenArchiveEx` 之后、首次 `RARReadHeaderEx` 之前调用);缺失或错误返回 `ZIPX_ERR_PASSWORD`,界面提示后重试。 |
| 符号链接 / FIFO / 套接字 / 设备 | ❌ | 以 `ZIPX_ERR_SPECIAL` 拒绝(与 ZIP 行为一致) |
| RAR 1.3(1.4 之前) | ❌ | 被 unrar 上游拒绝 |
当某归档被拒绝时,用户会收到 `extract_unsupported` 失败,文件名作为详情参数。前端已用典型的双语重试指引显示该错误。
### 限额
RAR 引擎原样复用 ZIP 的限额表——其上并无额外的 RAR 档位表。默认值与 `large=1` 选配完全相同:
| 限额 | 默认档位 | 大档案档位(`large=1`) |
|---|---|---|
| `max_entries` | 200 000 | 500 000 |
| `max_total_bytes`(未压缩) | 2 TiB | 4 TiB |
| `max_file_bytes`(单条目) | 512 GiB | 1 TiB |
| `max_ratio`(未压缩 / 压缩) | 500 : 1 | 1000 : 1 |
| `max_depth`(文件夹嵌套) | 32 | 32 |
| `max_name_len` / `max_path_len` | 255 / 1024 | 255 / 1024 |
大档案档位的 RAR 解压使用与 ZIP 相同的 `LARGE_FILE_THRESHOLD_BYTES`(480 GiB)提示——前端对 `.rar` 与 `.zip` 的提示处理相同,且服务器仅在请求携带 `large=1`(选配)时才启用大限额。
### 安全检查
RAR 引擎应用与 ZIP 引擎相同的检查——复用 `zipx_status_t` 状态码,因此任务 UI 的 `err_extract_unsafe_name`、`err_extract_too_deep`、`err_extract_ratio` 等会一致触发:
- 路径穿越(`..` 段、绝对 POSIX 路径、Windows 盘符、`\` 在 `Rar!\x1a\x07…` 头之后被视为路径分隔符等)。
- 符号链接、FIFO、套接字、设备。
- 归档内重复条目或目录/文件名冲突。
- 归档尺寸、条目数、深度、名称长度或压缩比突破当前档位。
### Vendoring 与许可
`third_party/unrar7/` 是官方 **rarlab UnRAR 源码**(7.20.1)的逐字副本,由 [`opello/unrar`](https://github.com/opello/unrar) 在提交 `97e1780` 处镜像。它依 **UnRAR 免费软件许可** 分发(见 `third_party/unrar7/license.txt`):可于任何软件中用于处理 RAR 归档,但不得用于开发 RAR 兼容的*归档器*或重新实现 RAR 压缩算法。项目自有的门面 `third_party/unrar7/unrar_c_api.h` 携带项目自身许可。
> v1.8 引擎 `third_party/unrar/dmc_unrar.c`(DrMcCoy/dmc_unrar 1.7.0,GPL-2.0-or-later)已在 v1.9 移除;其声明留存于 git 历史。
### 加密 RAR
密码通道现已完整接通:`/api/extract` 的 `password=` 会被透传给引擎,并在 `RAROpenArchiveEx` 之后、首次 `RARReadHeaderEx` 之前通过 `RARSetPassword` 交给 unrar(这个顺序是解密 `-hp` 加密头的前提)。密码缺失或错误一律返回 `ZIPX_ERR_PASSWORD`(前端 `err_extract_password`),界面据此弹出密码框并按原参数重试,最多 3 次。
## 7z 解压
7z 解压引擎(`src/sevenz_extract.{c,h}`)基于 SDK 解码子集(LZMA2 / LZMA / BCJ2 等)加上项目自研的拉式 codec 链(`src/sevenz_chain.c`,位于 `src/sevenz_chain.h`)。扩展名为 `.7z` 的文件与 ZIP / RAR 一样拥有**解压**按钮;引擎由 `src/extract.c` 按扩展名分派,并复用同一套三阶段模型、限额档位与冲突策略。
> v1.9.1 新增。SDK 自带的 `SzArEx` 路径仅覆盖 4 个 coder 的文件夹,不足以装下 BCJ2 的 5 coder;本项目改为自研 folder 解析 + 拉式 codec 链,从而原生支持 BCJ2 与多 coder 组合。
关于头部:7-Zip 把归档头放在文件末尾,并在头部变大时把它压缩(`-mhc=on`,默认行为),所以头部区域通常以一条 `k7zIdEncodedHeader` 记录开头、描述一个 folder。`-mhe=on` 时那个 folder **也被加密**,而它装着文件名、folder 表与每个条目的尺寸,于是 vendored SDK 在能列出任何条目之前就对整个归档放弃。`src/sevenz_header.c` 负责这种情况:读出该记录,用与内容完全相同的那条 7zAES 路径解出它的 folder,再把一个「头部区域是明文」的虚拟流交给 SDK——磁盘上的归档从不被写入,仅仅被*压缩*过头的归档也完全不受影响。
### 支持范围
| 格式 | 支持 | 备注 |
|---|---|---|
| LZMA2 / LZMA(含 ZIP64 式大尺寸) | ✅ | 单 coder 纯 LZMA2 走多线程解码(`src/sevenz_mt.c`,8 线程) |
| BCJ2(x86 反汇编后处理) | ✅ | 经自研拉式链;SDK `SzArEx` 装不下 5 coder 时由本项目承载 |
| 多 coder 组合文件夹 | ✅ | 自研 `sevenz_chain.c` 解析 |
| **分卷**(`.7z.001` / `.z01` 链) | ✅ | `src/sevenz_volstream.c` 按名拼接;打开首个分卷 |
| **内容加密**(7zAES,AES-256-CBC) | ✅ | 引擎可解密;解压 7z 时前端会**提前**询问密码(避免为无密码归档白跑一次扫描 + folder 解析),密码经 `password=` 传给引擎,缺失或错误返回 `ZIPX_ERR_PASSWORD` 并可重试 |
| **`-mhe=on` 加密头** | ✅ | `src/sevenz_header.c` 自行解码该头部记录(复用同一条 7zAES 路径),再把一个携带明文的虚拟流交给 SDK;密码错误返回 `ZIPX_ERR_PASSWORD`,与其它加密归档一样由提示重试 |
| `-mhc=off`(未压缩头) | ✅ | 明文头一向可读;现在按字节探测后完全不做干预 |
当某归档被拒绝时,用户同样收到 `extract_unsupported` 失败,UI 显示双语重试指引。
### 限额
7z 引擎复用与 ZIP / RAR 完全相同的限额表;默认档位与 `large=1` 选配一致(见 [ZIP 解压 → 限额](#限额))。
### 安全检查
7z 引擎复用相同的 `zipx_status_t` 错误码与检查集合:路径穿越、特殊文件、重复条目/名冲突、以及突破当前档位的尺寸/条目数/深度/名称长度/压缩比。coder 的 `out_size` 取自 `coder_unpack_sizes[index]`(而非文件夹尺寸),`SzArEx` 失败时重置 `blockIndex` 以避免伪 CRC。
## 校验
`make` 之后,对生成的 ELF 做健全性检查:
## 校验产物
```sh
ls -la web-file-mgr.elf # v1.9.1 因含 unrar7 + 7z 体积更大;v1.8.3 约 509 KiB
sha256sum web-file-mgr.elf # 把摘要记录进你的发布说明
file web-file-mgr.elf # 期望 "ELF 64-bit LSB pie executable, x86-64"
od -An -tx1 -N20 web-file-mgr.elf | head -2 # 魔数 7f45 4c46 0201 + e_machine 003e
ls -la web-file-mgr-v1.9.3M.elf # 约 882 KiB
sha256sum web-file-mgr-v1.9.3M.elf # v1.9.3M 应为 8ca47d5a…c9bb
file web-file-mgr-v1.9.3M.elf # 期望 "ELF 64-bit LSB pie executable, x86-64"
od -An -tx1 -N20 web-file-mgr-v1.9.3M.elf | head -2 # 魔数 7f45 4c46 0201,e_machine 003e
```
`e_machine = 0x003e` 确认了 PS5 目标三元组 `x86_64-sie-ps5`。`e_type = 3`(`ET_DYN`)确认了 ELF 加载器期望的位置无关载荷。
`e_machine = 0x003e` 确认了 PS5 目标三元组 `x86_64-sie-ps5`;`e_type = 3`(`ET_DYN`)确认了 ELF
加载器期望的位置无关载荷。
前端资源(JS / CSS / HTML)是 **gzip 压缩后内嵌** 进 ELF 的,所以拿 `strings` 去搜 `assets/` 里的
任何东西都不会有命中——这是压缩所致,不是内容缺失。请改用附带脚本:
```sh
python3 .build/check-elf-gzip.py ./web-file-mgr-v1.9.3M.elf uploadMenu extractRetryKey
```
## 测试
一套 POSIX / 主机端 C 测试套件覆盖 ZIP、RAR 与 7z 三个引擎,可在任意 Linux / macOS / MSYS shell 下、无需 PS5 SDK 运行:
一套 POSIX / 主机端 C 测试套件覆盖 ZIP、RAR 与 7z 三个引擎,可在任意 Linux / macOS / MSYS shell
下、无需 PS5 SDK 运行:
```sh
cd tests && bash run-tests.sh # ZIP + RAR 套件
bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二进制)
cd tests && bash run-tests.sh # ZIP + RAR 套件
bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二进制)
```
输出为逐用例的 `check` 风格报告。当前 `main` 上为 **177 项检查**(140 ZIP + 37 RAR),0 失败;7z 套件另有 **27 项用例**,同样 0 失败。覆盖:
当前 `main`:**177 项检查**(140 ZIP + 37 RAR),0 失败;7z 套件另有 **27 项用例**,0 失败。覆盖:
- ZIP 条目解析(stored + deflated + ZIP64)
- ZIP 条目解析(stored、deflated、ZIP64),并与真实归档做逐字节内容比对
- 路径穿越、绝对路径、反斜杠、Windows 盘符
- 符号链接、FIFO、坏 CRC、截断归档、非 ZIP 文件
- 限额:`entries`、`total_bytes`、`file_bytes`、`ratio`、`depth`、`name_len`
- 冲突策略:`fail` / `overwrite` / `merge`
- 每个阶段的取消
- **加密档案** —— `tests/fixtures-real/` 下的真实归档各跑四种情况(无密码 / 空密码 / 错密码 → `ZIPX_ERR_PASSWORD`;正确密码 → 成功并逐字节校验内容):ZIP 侧覆盖 `enc-zipcrypto.zip`、`enc-aes256.zip`、`enc-aes256-store.zip`,RAR 侧覆盖 `enc-v6.rar`;另验证「提供密码后限额依旧生效」与「失败路径绝不发布文件」
- **大文件档位** —— `medium_bomb.zip`(比率 ≈ 238)在默认限额下被拒、在大档位下通过;降低后的大档位仍生效
- **RAR 引擎**(`tests/test_rar_extract.c`,37 项检查)—— 格式分派(改名的 ZIP / 垃圾数据均被拒)、每个可达引擎错误码的翻译、限额交接(`large=1` 原样传入 `rar_extract()`)、超过上限的字典路径,以及真实归档覆盖
- **7z 引擎**(`tests/test_sevenz_extract.c` + `tests/run-sevenz-tests.sh`)—— 真实 `.7z` fixture 逐字节比对、加密头(三种情况:无密码 / 错密码 / 正确密码)、各策略下的冲突、取消、限额、缺失目标父目录,以及失败时绝不发布且 staging 树被清理的保证
- 符号链接、FIFO、坏 CRC、截断归档、非 ZIP 输入
- 全部限额(条目数、总字节、单文件字节、压缩比、深度、名称长度)
- 冲突策略 `fail` / `overwrite` / `merge`
- 每个阶段的取消,以及「失败绝不发布任何文件、并清理自己的 staging 树」这一保证
- **加密归档** —— 每个真实 fixture 各跑四种情况:无密码、空密码、错密码都得
`ZIPX_ERR_PASSWORD`,正确密码则成功并做逐字节内容校验。另有两项证明「提供密码后限额仍然生效」。
fixture:`enc-zipcrypto.zip`、`enc-aes256.zip`、`enc-aes256-store.zip`(ZIP)、
`enc-v6.rar`(RAR)、`aeshe.7z`(7z,加密头)
- **大档案档位** —— `medium_bomb.zip`(压缩比 ≈ 238)在默认档位下被拒、在大档案档位下通过
- **格式分派** —— 改名的 ZIP 与垃圾数据块都会被拒
前端还有一份无头检查 `.build/ui_retry_test.mjs`(`node .build/ui_retry_test.mjs`):把 `assets/main.js` 载入桩 DOM,验证加密失败后的密码重试流程——参数记忆、重试上限、取消与空密码的回落,共 27 项检查。它位于 `.build/`(gitignore 白名单之外),属于开发期验证脚本。
三份前端 / 真页面验证脚本位于 `.build/` —— 该目录整体被 gitignore 忽略、只放行白名单,
而这三份连同文档渲染检查脚本都在白名单内,因此它们受版本控制、清理临时文件时不会被误删:
| 脚本 | 覆盖内容 | 检查数 |
|---|---|---|
| `ui_retry_test.mjs` | 真实 `assets/main.js` 载入桩 DOM 后的密码重试流程:参数记忆、重试上限、取消 / 空密码的回落,以及「非 ASCII 目录必须仍弹口令框」的回归用例 | 40 |
| `ui_upload_menu_test.mjs` | 标记侧:`index.html` 里每个 `data-i18n` 键在两份语言文件中都存在、`main.js` 里 117 个 `t("…")` 键全部有译文、上传菜单接对了回调、用到的 class 确实有样式、菜单行高亮规则保住了面板作用域,以及解压按钮**绝不隐藏、只置灰** | 40 |
| `preview_check.mjs` | 真页面 + 桩 API + 无头 Chromium:菜单静止时隐藏 / 点击打开 / 焦点落位 / 真能点到 file input / 关闭,页脚布局,以及被钉死的工具栏换行阈值 | 12 项断言 |
## 项目结构
```
.
├── .build/ # 构建脚本 + 验证脚本(目录其余部分被 gitignore)
├── Makefile # PS5 + Linux 构建(VERSION_TAG v1.9.3M)
├── install-libmicrohttpd.sh # 一次性依赖安装器
├── gen-asset-module.py # 将 assets/* 内联为 gzip 压缩的 C 数组
├── assets/ # HTML / CSS / JS / 图标 / param.json
├── src/ # C 载荷源码
│ ├── main.c websrv.c filemgr.c # 入口、HTTP 前端、任务模型
│ ├── upload.c download.c # 流处理
│ ├── upload.c download.c text.c # 流处理与原地编辑
│ ├── extract.c # /api/extract 分派器(ZIP + RAR + 7z)
│ ├── zip_extract.{c,h} zipx_common.c # ZIP 引擎
│ ├── zip_extract.{c,h} zipx_common.c # ZIP 引擎(minizip-ng 后端)
│ ├── zipx_volume.c zipx_volstream.c # ZIP 分卷探测 + 拼接流
│ ├── rar_extract.{c,h} # RAR 引擎(unrar7 后端)
│ ├── sevenz_extract.{c,h} # 7z 引擎
│ ├── sevenz_chain.{c,h} # 7z 拉式 codec 链(BCJ2 等)
│ ├── rar_extract.{c,h} # RAR 引擎(rarlab UnRAR 7.20.1 后端)
│ ├── sevenz_extract.{c,h} sevenz_chain.{c,h} # 7z 引擎,自解析 codec 链
│ ├── sevenz_header.{c,h} # 7z 头部读取 / `-mhe=on` 解密
│ ├── sevenz_mt.{c,h} # 7z 多线程 LZMA2 解码
│ ├── sevenz_volstream.{c,h} # 7z 分卷流拼接
│ └── app_installer.c # PS5 Media 启动器安装器
├── third_party/ # vendored:zlib、minizip-ng、unrar7、7z(SDK 子集)
│ ├── unrar7/ # rarlab UnRAR 7.20.1,静态库 + C API 门面
│ ├── minizip-ng/ # ZIP 读取器
│ ├── zlib/ # minizip-ng 的压缩后端
│ └── 7z/ # LZMA SDK 解码子集
├── tests/ # POSIX / 主机测试套件
│ ├── test_zip_extract.c
│ ├── test_rar_extract.c
│ ├── test_sevenz_extract.c # 7z 用例驱动
│ ├── make_fixtures.py # 重新生成测试 fixture
│ ├── run-tests.sh # 一次性运行器(ZIP + RAR)
│ ├── run-sevenz-tests.sh # 7z 运行器
│ ├── compat/ # 小型 Win32 / MSYS 垫片
│ └── fixtures/ fixtures-7z/ fixtures-real/ # 生成的测试归档
│ ├── sevenz_mt.c sevenz_volstream.c # 多线程 LZMA2 + `.7z.001` 分卷
│ ├── app_installer.c pkg_installer.c pkg_info.c # PS5 PKG 预览 / 安装
│ └── demangle_stub.c cpu_support_stub.c # 体积 / 可移植性桩
├── third_party/ # vendored 库
│ ├── unrar7/ # rarlab UnRAR 7.20.1 —— RAR 引擎
│ ├── minizip-ng/ # 4.2.2,裁剪到只留读取路径
│ ├── 7z/ # LZMA SDK 26.03 解码子集
│ └── zlib/ # minizip 的压缩后端
├── tests/ # POSIX / 主机端测试套件
│ ├── test_zip_extract.c test_rar_extract.c test_sevenz_extract.c
│ ├── sevenz_chain_e2e.c sevenz_e2e.c bigfile_e2e.c
│ ├── make_fixtures.py make_sevenz_fixtures.py make_split_fixtures.py
│ ├── run-tests.sh # 一次性运行器(ZIP + RAR)
│ ├── run-sevenz-tests.sh # 7z 套件
│ ├── bench_driver.py bench_formats.py # 吞吐基准
│ ├── compat/ # 小型 Win32 / MSYS 垫片
│ └── fixtures/ fixtures-7z/ fixtures-real/
├── docs/
│ ├── HANDOVER.md # v1.8 时代的开发手册(历史存档,现行见根目录 HANDOVER.md)
│ ├── USER-GUIDE-zh-CN.md # 新手使用说明(中文)
│ ├── DEVICE-TEST-v1.9.3M.md # 发布前跑过的真机验收清单
│ ├── SIZE-OPTIMIZATION.md # ELF 体积分析 + 逐符号台账
│ ├── EXTRACTION-PERF.md # 解压基准
│ ├── REAL-CONSOLE-PROFILE.md # 真机实测吞吐
│ ├── UPSTREAM-V1.8-COMPARISON.md # 本仓 vs 上游 helper 路线
│ ├── REWRITE-FEASIBILITY.md # 引擎抽取可行性研究
│ ├── UPGRADE-v1.7-zip-large-file-profile.md
│ ├── UPGRADE-v1.8-rar-support.md
│ └── screenshots/ # README 截图
│ └── screenshots/ # README 截图
├── CHANGELOG.md # 逐版本变更记录
├── THIRD_PARTY_NOTICES # 捆绑库署名
├── HANDOVER.md # 现行开发交接文档
├── LICENSE # GPLv3+
└── README.md
```
## 与上游的差异
本项目 **fork 自 [owendswang/ps5-web-file-manager](https://github.com/owendswang/ps5-web-file-manager)**。
Web UI、任务模型与 PS5 打包方式均源自该项目;上游作者以 GPL-3.0 发布,是本衍生作品得以存在的前提。
从 v1.8 起,上游把解压**外包给一个独立 helper 进程**——一整个 7-Zip,做成
`wfm-7zip-helper.elf`,由用户自行安装到 `/data/wfm/`。本仓走的是相反的路:解码器 vendor **进**
载荷内部。
| | 上游 | 本仓 |
|---|---|---|
| 解压架构 | 外部 `wfm-7zip-helper.elf`(1,017,616 B,单独分发,路径写死 `/data/wfm/`),用 Unix socket IPC 协议驱动 | 引擎就在**载荷内部**;没有第二个文件,没有 IPC |
| 部署 | 两个文件合计 1,363,048 B;helper 缺失或放错位置,解压功能全废(`archive_helper_not_running`) | 单 ELF 903,448 B,零外部依赖 —— **小 33.7%**,且上游那个 helper 单独一个就比本仓整个载荷还大 |
| 格式 | 约 30 种后缀(`.tar`、`.gz`、`.xz`、`.bz2`、`.zst`、`.cab`、`.arj`、`.lzh`、`.cpio`……) | `.zip` / `.rar` / `.7z` 及其分卷形态——三种,但每一种都完整 |
| 防压缩炸弹 / 压缩比 | 无 | 条目数、总尺寸、单文件尺寸、压缩比筛查,并对 1 GiB 以下小文件豁免以免误判 |
| 磁盘空间预检 | 无 | 写 staging 前按解压后总量查 `statvfs` |
| 路径穿越防护 | 交给 7-Zip | 本仓实现,并有专项测试组 |
| 失败残留 | 可能留下解压了一半的目录 | staging 目录 + rename;失败或取消都会清理,且不发布任何文件 |
| 密码提示 | helper 的 IPC 协议里带 `PASSWORD_REQUIRED` 消息 | 失败后弹框重试(上限三次),统一报为 `extract_password`;7z 提前询问 |
| 载荷重启后的任务存活 | ✅ helper 是独立进程,解压任务不会丢 | ❌ 重启会丢掉正在跑的任务 |
| 内存隔离 | ✅ 解压在独立进程里 | ❌ 共享地址空间(改为对 LZMA2 字典封顶) |
| 版本标识 | 纯 `vX.Y.Z` | `vX.Y.ZM`——尾部的 `M` 标记本仓改版 |
这套取舍的实测数据与推理过程在
[`docs/UPSTREAM-V1.8-COMPARISON.md`](docs/UPSTREAM-V1.8-COMPARISON.md)。一句话:
**上游赢在格式广度与进程架构,本仓赢在安全、部署与错误质量。** 格式覆盖的差距是现有架构里可以
增量补的活,不构成推倒重来的理由。
## 备注
- 复制、移动、删除、上传、下载作为单个后台任务运行。一个任务运行时,其它文件操作会被拒绝。
- 删除是递归且永久的。没有回收站。
- 复制/移动任务可取消。单个文件的部分拷贝会被移除,但部分拷贝的文件夹会保留在原地,以避免在合并进已存在目标文件夹时误删既有文件。
- 上传任务可取消。尽可能移除部分上传的临时文件。
- 下载文件夹或多个选中项会生成 tar 流。tar 归档由载荷生成,不会先写入 PS5 存储。
- 若浏览器在载荷进程仍在运行时被关闭重开,UI 可恢复活动任务显示。
- 文本编辑仅限于上述精选扩展名列表。非 UTF-8 与超大文件会被拒绝。
- 文件名通过 Web API 以 UTF-8 传输。载荷也会保留挂载文件系统返回的遗留字节序名称,以便混合 USB 文件名编码仍能正确显示与操作。
- 复制、移动、删除、上传、下载都作为单个后台任务运行。一个任务运行时,其它文件操作会被拒绝。
- 删除是递归且永久的,没有回收站。
- 复制 / 移动任务可取消。单个文件的部分拷贝会被移除;部分拷贝的**文件夹**会保留在原地,以免在
合并进已存在的目标文件夹时误删既有文件。
- 上传任务可取消;尽可能移除部分上传的临时文件。
- 下载文件夹或多选会生成 tar 流,就地生成——不会先写入主机存储。
- 若浏览器在载荷进程仍在运行时被关闭重开,界面可恢复活动任务的显示。
- 文本编辑仅限上述扩展名列表;非 UTF-8 与超大文件会被拒绝。
- **文件名编码:** 名字经 Web API 以 UTF-8 传输,而挂载的文件系统可能返回遗留字节序列(比如一块
GBK 的 U 盘)。为了不丢这些字节,API 会把每个 ≥ `0x80` 的字节映射成 `\u00XX`、回程再还原,
前端显示时按 GBK / gb18030 解码。实际后果是:同一个目录在「页面手里」与「服务端手里」是两串
不同的字符串——这就是为什么前端里任何东西都不能拿路径当跨请求的键。
## 常见问题
- **这是自制应用,不应故意修改系统进程或内核内存。** 若遇到内核崩溃(kernel panic),请确保使用较新的越狱方法与 ELF 加载器,或回退到你惯用的稳定方法。
- **P2JB 用户** —— 若此载荷触发内核崩溃,请避免在该环境下使用。当每次重试代价高昂时,稳定性比便利更重要。
- **准备阶段可能耗时较久** —— 当文件夹含大量文件时,它会累加文件夹大小并检查剩余空间,这有助于避免启动一个无法安全完成的复制 / 移动 / 上传 / 下载。
- **`err_extract_entry_too_large`** —— 默认归档上限为单条目 512 GiB / 500:1 比率(覆盖典型 3A 游戏归档中单个约 300 GiB 未压缩文件)。若超过默认,请确认大文件提示(磁盘上 > 480 GiB 的归档会出现),拆分归档,或直接向 API 传入 `large=1`。
- **`err_extract_unsupported`** —— 这个包本机读不了:既非 `.zip` / `.rar` / `.7z` 的文件;ZIP 条目用了 stored / deflated 之外的压缩方法;7z 用了不支持的 coder;分卷命名不被识别(RAR 分卷若叫 `x.rar.001`,需改名为 `x.part1.rar`、`x.part2.rar` ……);或早于 RAR 1.4 的归档。**加密归档与多卷归档不属于这一类**——两者都支持。界面会在括号里附上后端原文,指明具体原因。
- **`err_extract_dict_too_large`** —— RAR 归档声明的压缩字典超过本构建支持的上限(4096 MiB),且 unrar 请求允许超额。报错文案会同时给出归档需要的尺寸与构建允许的尺寸。这是**刻意拒绝**:另一条路是一次性分配整个字典窗口,rarlab 自家 CLI 默认也会拒绝,16 GB 共享内存的主机更是承受不起。请在 PC 上用不超过 4 GiB 的字典重新压缩(`-md`),或在 PC 上解压。注意 RAR5 格式本身把这个字段卡在 4 GiB,所以这种情况只可能来自更新版 RAR7 头格式写出的归档。
- **`err_extract_password`** —— 归档已加密,而本次提交的密码缺失或错误。这也包括带加密头(`-mhe=on`)的 7z:文件名与条目尺寸都在头部里,头解密之前连条目列表都读不出来。ZIP / RAR(以及现在的 7z 加密头)在失败后会弹出密码框(取消或留空即放弃),可用正确密码按原参数重试,最多 3 次;解压 7z 时仍会提前询问一次密码。
- **这是自制软件,不会有意修改系统进程或内核内存。** 若遇到内核崩溃(kernel panic),请确认使用
较新的越狱方法与 ELF 加载器,或回到你惯用的稳定方案。
- **P2JB 用户** —— 若此载荷在该环境下触发内核崩溃,请勿在此环境使用。当每次重试代价都很高时,
稳定性比便利更重要。
- **「准备阶段」在文件很多的目录下可能耗时较久** —— 它会累加目录大小并检查剩余空间,这正是让一个
无法安全完成的复制 / 移动 / 上传 / 下载从一开始就不会启动的原因。
- **`err_extract_unsupported`** —— 这个包本机读不了:既非 `.zip` / `.rar` / `.7z` 的文件;ZIP 条目
用了 stored / deflated 之外的压缩方法;7z 用了不支持的 coder;分卷命名不被识别(RAR 分卷若叫
`x.rar.001`,需改名为 `x.part1.rar`、`x.part2.rar`……);或早于 RAR 1.4 的归档。**加密归档与
多卷归档不属于这一类**——两者都支持。界面会在括号里附上后端原文,指明具体原因。
- **`err_extract_entry_too_large`** —— 归档超出默认上限(单条目 512 GiB / 500:1 比率)。确认那个
大文件提示(磁盘上 > 480 GiB 的归档会出现)、拆分归档,或直接向 API 传入 `large=1`。
- **`err_extract_dict_too_large`** —— RAR 归档声明的压缩字典超过本构建支持的上限(4096 MiB)。
请在 PC 上用不超过 4 GiB 的字典(`-md`)重新压缩,或直接在 PC 上解压。
- **`err_extract_password`** —— 归档已加密,而密码缺失或错误。这也包括带加密头(`-mhe=on`)的 7z:
文件名与条目尺寸都在头部里,头解密之前连条目列表都读不出来。
## 版本历史
逐版本的产物、摘要、段尺寸增量与测试计数见 [`CHANGELOG.md`](./CHANGELOG.md)。
| 版本 | 日期 | 一句话 |
|---|---|---|
| `v1.9.3M` | 2026-09-24 | 加密归档端到端打通(ZIP ZipCrypto + WinZip AES、RAR `-p`/`-hp`、7z 7zAES 含 `-mhe=on`)、字典超限独立报错,以及一轮 UI(上传菜单、拖拽提示、解压按钮常显置灰) |
| `v1.9.2` | 2026-09-05 | 只改版本号的重发版;重新打 tag 使 tag = 源码 = 二进制 |
| `v1.9.1` | 2026-09-05 | 7z 引擎、分卷、7zAES,以及 −15.8% 体积 / 吞吐优化 |
| `v1.9` | 2026-09-05 | RAR 引擎换成 rarlab UnRAR 7.20.1(RAR5「v6」、多卷) |
| `v1.8.3` | 2026-09-05 | 「上传并解压」开始接受 `.rar` |
| `v1.8.2` | 2026-09-05 | 为 3A 单文件归档放宽单条目上限;两个 PS5 专属构建修复 |
| `v1.8.1` | 2026-09-05 | 为系统备份归档放宽默认 ZIP 上限 |
| `v1.8` | 2026-09-05 | 首个 RAR 支持(dmc_unrar),共享解压协议 |
| `v1.7` | 2026-09-04 | ZIP 大文件档位(`large=1`) |
## 署名
本项目 **fork 自 [owendswang/ps5-web-file-manager](https://github.com/owendswang/ps5-web-file-manager)**(GPL-3.0)。Web UI、任务模型与 PS5 打包方式均源自该项目;上游作者以 GPL-3.0 发布,是本衍生作品得以存在的前提。
**怎么区分上游原版与本仓改版:** 自 v1.9.3 起版本号带 `M` 后缀(`vX.Y.ZM`),*M* 即 *Modified*(改版);上游 owendswang 的发布版是纯 `vX.Y.Z`。因此 `v1.9.3M` 只可能出自本仓,而这个字母同时出现在 ELF 文件名、PS5 启动通知、`/api/version` 与网页右下角。v1.9.3M 之前的发布早于该约定,保留原本的无后缀编号。
**怎么区分上游原版与本仓改版:** 自 v1.9.3 起版本号带 `M` 后缀(`vX.Y.ZM`),*M* 即 *Modified*(改版);上游 owendswang 的发布版是纯 `vX.Y.Z`。因此 `v1.9.2` 是上游 / 本仓共用的编号,而 `v1.9.3M` 只可能出自本仓;这个字母同时出现在 ELF 文件名、PS5 启动通知、`/api/version` 与网页右下角。v1.9.3M 之前的发布早于该约定,保留原本的无后缀编号。
本项目另参考了以下项目构建:
@@ -472,7 +490,10 @@ bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二
- **[ezremote](https://github.com/cy33hc/ps5-ezremote-client):** PKG 预览功能的参考出处。许可证:**GPL-2.0-only** —— 其源文件未声明 "or later",因此**无法**与本项目的 GPL-3.0 代码组合。**未取其任何代码**:`src/pkg_info.c` 是独立的 C99 实现(它还负责 `.pkg` 条目表与 `param.json` 字段,而 ezremote 根本没有 `.pkg` 解析器;JSON 走的是 `src/json_util.c` 里自写的分词器,不是 json-c)。详见 `docs/REWRITE-FEASIBILITY.md` §2.2。
- **[zlib-ng/minizip-ng](https://github.com/zlib-ng/minizip-ng):** `/api/extract` 端点使用的 ZIP 读取器。vendored 于 `third_party/minizip-ng/`。许可证:zlib。
- **[zlib](https://www.zlib.net/):** minizip-ng 的压缩后端。vendored 于 `third_party/zlib/`。许可证:zlib。
- **[rarlab UnRAR (opello/unrar)](https://github.com/opello/unrar):** v1.9 起 `/api/extract` 使用的 RAR 读取器(7.20.1)。vendored 于 `third_party/unrar7/`。许可证:UnRAR 免费软件许可。
- **[rarlab UnRAR](https://www.rarlab.com/rar_add.htm)** —— v1.9 起 `/api/extract` 使用的 RAR 读取器(7.20.1,RARDLL 源文件集)。vendored 于 `third_party/unrar7/`。许可证:**UnRAR 免费软件许可**(见 `third_party/unrar7/license.txt`)。注意这是受限许可而非 FLOSS 许可:它允许用源码处理 RAR 归档,但禁止用它开发 RAR 兼容的压缩器。
- **[opello/unrar](https://github.com/opello/unrar)** —— vendored 的 rarlab 源码取自该镜像(提交 `97e1780`)。
- **[LZMA SDK](https://www.7-zip.org/sdk.html)**(7-Zip / Igor Pavlov)—— v1.9.1 起 `/api/extract` 使用的 7z 解码器,以解码子集形式 vendored 于 `third_party/7z/`。许可证:公有领域。
- **[DrMcCoy/dmc_unrar](https://github.com/DrMcCoy/dmc_unrar)** —— 仅 v1.8 使用的 RAR 引擎,v1.9 被 rarlab UnRAR 取代(它无法解码 RAR5「v6」归档,也不支持多卷)。已从树中移除;其许可证为 GPL-2.0-or-later。
## 许可证
@@ -480,7 +501,9 @@ bash run-sevenz-tests.sh # 7z 套件(需 MinGW gcc 与 7-Zip 二
第三方项目保留各自许可证。请勿在未保留相应许可证声明的情况下,将署名项目的资源或源码复制到其它发行版中。
若分发二进制,除本项目 GPL 许可外,还需遵守 `libmicrohttpd` 的 LGPL 条款。vendored 的 `zlib` 与 `minizip-ng` 源码以 zlib 许可分发;再分发用此特性构建的二进制时,保留 `third_party/zlib/LICENSE` 与 `third_party/minizip-ng/LICENSE` 中的版权声明。vendored 的 `unrar7`(RAR 引擎)依 UnRAR 免费软件许可分发;再分发用 v1.9 或更高版本构建的二进制时,保留 `third_party/unrar7/license.txt` 中的声明,且不得用其开发 RAR 兼容归档器或重新实现 RAR 压缩算法。
若分发二进制,除本项目 GPL 许可外,还需遵守 `libmicrohttpd` 的 LGPL 条款。vendored 的 `zlib` 与 `minizip-ng` 源码以 zlib 许可分发;再分发用此特性构建的二进制时,保留 `third_party/zlib/LICENSE` 与 `third_party/minizip-ng/LICENSE` 中的版权声明。
vendored 的 `third_party/unrar7/`(rarlab UnRAR —— `src/rar_extract.c` 背后的 RAR 引擎)**不是** GPL:它依 UnRAR 免费软件许可分发(见 `third_party/unrar7/license.txt`),该许可禁止用它开发 RAR 兼容的压缩器。再分发时请保留该声明与限制。`THIRD_PARTY_NOTICES` 载有逐库完整摘要。
## 免责声明
+52
View File
@@ -142,6 +142,58 @@
</section>
</div>
<div id="extractDialog" class="text-editor-overlay" hidden>
<section class="text-editor-panel extract-panel" role="dialog" aria-modal="true" aria-labelledby="extractTitle">
<h2 id="extractTitle" class="extract-title" data-i18n="extractDialogTitle"></h2>
<div class="extract-row">
<span class="extract-label" data-i18n="extractArchive"></span>
<span id="extractArchiveName" class="extract-archive-name"></span>
</div>
<label class="extract-field">
<span data-i18n="extractDestination"></span>
<span class="extract-dest-row">
<input id="extractDestInput" class="extract-dest-input" type="text"
spellcheck="false" autocomplete="off" autocapitalize="off">
<button id="extractBrowseBtn" type="button" class="secondary" data-i18n="browseFolder"></button>
</span>
</label>
<label class="extract-field">
<span data-i18n="extractConflict"></span>
<select id="extractConflictSelect" class="extract-select">
<option value="fail" data-i18n="extractConflictFail"></option>
<option value="overwrite" data-i18n="extractConflictOverwrite"></option>
<option value="merge" data-i18n="extractConflictMerge"></option>
</select>
</label>
<label id="extractPasswordField" class="extract-field" hidden>
<span data-i18n="extractPasswordLabel"></span>
<input id="extractPasswordInput" class="extract-password-input" type="text"
spellcheck="false" autocomplete="off" autocapitalize="off">
</label>
<label class="extract-field extract-large-field">
<input id="extractLargeCheck" type="checkbox">
<span data-i18n="extractLargeMode"></span>
</label>
<div class="text-editor-actions">
<button id="extractCancelBtn" type="button" class="secondary" data-i18n="cancel"></button>
<button id="extractConfirmBtn" type="button" class="primary" data-i18n="extract"></button>
</div>
</section>
</div>
<div id="folderPickerOverlay" class="text-editor-overlay" hidden>
<section class="text-editor-panel folder-picker-panel" role="dialog" aria-modal="true" aria-labelledby="folderPickerTitle">
<h2 id="folderPickerTitle" class="folder-picker-title" data-i18n="selectFolder"></h2>
<div id="folderPickerPath" class="folder-picker-path"></div>
<div id="folderPickerList" class="folder-picker-list"></div>
<div class="text-editor-actions">
<button id="folderPickerCancelBtn" type="button" class="secondary" data-i18n="cancel"></button>
<button id="folderPickerUpBtn" type="button" class="secondary" data-i18n="parent"></button>
<button id="folderPickerOkBtn" type="button" class="primary" data-i18n="selectHere"></button>
</div>
</section>
</div>
<div id="permissionOverlay" class="permission-overlay" hidden>
<section class="permission-panel" role="dialog" aria-modal="true" aria-labelledby="permissionTitle">
<h2 id="permissionTitle" class="permission-title" data-i18n="permissionsTitle"></h2>
+13
View File
@@ -118,6 +118,19 @@ window.WFM_LANG = {
extractSelectArchive: "Select one archive to extract (ZIP / RAR / 7z)",
extractOneAtATime: "Only one archive can be extracted at a time",
extractSelectMainVolume: "Please select the main volume (.rar or .part01.rar)",
extractDialogTitle: "Extract archive",
extractArchive: "Archive",
extractDestination: "Extract into (destination directory)",
browseFolder: "Browse folders",
extractConflict: "File conflict policy",
extractConflictFail: "Fail if name exists",
extractConflictOverwrite: "Overwrite files",
extractConflictMerge: "Merge directories",
extractPasswordLabel: "Password (7z encryption; leave empty if none)",
extractLargeMode: "Large-file mode (max 1 TiB file / 4 TiB total)",
extractDestEmpty: "Please enter a destination directory",
selectFolder: "Select folder",
selectHere: "Select this folder",
extractArchivePending: "Preparing to extract {name}",
sameSourceTarget: "Source and destination are the same. Cannot {label} {name}",
removeConflictFirst: "A {existingType} named {name} already exists. To {label} this {sourceType}, delete that {existingType} first.",
+13
View File
@@ -118,6 +118,19 @@ window.WFM_LANG = {
extractSelectArchive: "选中一个压缩包后才能解压(ZIP / RAR / 7z)",
extractOneAtATime: "一次只能解压一个压缩包",
extractSelectMainVolume: "请改选主卷(如 .rar 或 .part01.rar)",
extractDialogTitle: "解压压缩包",
extractArchive: "压缩包",
extractDestination: "解压到(目标目录)",
browseFolder: "浏览目录",
extractConflict: "文件冲突策略",
extractConflictFail: "同名则失败",
extractConflictOverwrite: "覆盖同名文件",
extractConflictMerge: "合并目录",
extractPasswordLabel: "密码(7z 加密,可留空)",
extractLargeMode: "大文件模式(单文件上限 1 TiB / 总计 4 TiB)",
extractDestEmpty: "请填写目标目录",
selectFolder: "选择目录",
selectHere: "选择此目录",
extractArchivePending: "正在准备解压 {name}",
sameSourceTarget: "源和目标相同,不能{label} {name}",
removeConflictFirst: "目标中已存在同名{existingType} {name}。要{label}{sourceType},请先删除该{existingType}才能继续。",
+139
View File
@@ -959,6 +959,145 @@ input[type="checkbox"] {
word-break: break-all;
}
/* --- Extract dialog + folder picker --- */
.extract-panel {
width: 600px;
max-width: calc(100vw - 48px);
}
.extract-title {
margin: 0 0 16px;
color: #edf0f2;
font-size: 24px;
font-weight: 600;
}
.extract-row {
display: flex;
gap: 10px;
margin-bottom: 14px;
font-size: 18px;
}
.extract-label {
flex: 0 0 auto;
color: #8f9ba6;
}
.extract-archive-name {
min-width: 0;
flex: 1 1 auto;
color: #d9e0e6;
font-weight: 600;
word-break: break-all;
}
.extract-field {
display: block;
margin: 12px 0;
font-size: 18px;
}
.extract-field > span:first-child,
.extract-large-field {
display: flex;
align-items: center;
gap: 8px;
}
.extract-field > span[data-i18n] {
color: #8f9ba6;
margin-bottom: 6px;
display: block;
}
.extract-dest-row {
display: flex;
gap: 8px;
}
.extract-dest-input,
.extract-password-input,
.extract-select {
flex: 1 1 auto;
min-width: 0;
padding: 10px 12px;
font-size: 18px;
font-family: inherit;
color: #d9e0e6;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
}
.extract-dest-input:focus,
.extract-password-input:focus,
.extract-select:focus {
outline: none;
border-color: #6fb1ff;
}
.extract-large-field {
gap: 10px;
cursor: pointer;
}
.folder-picker-panel {
width: 600px;
max-width: calc(100vw - 48px);
display: flex;
flex-direction: column;
}
.folder-picker-title {
margin: 0 0 14px;
color: #edf0f2;
font-size: 24px;
font-weight: 600;
}
.folder-picker-path {
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 16px;
color: #aab4be;
margin-bottom: 10px;
word-break: break-all;
}
.folder-picker-list {
max-height: calc(100vh - 320px);
min-height: 120px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
margin-bottom: 16px;
}
.folder-picker-row {
display: block;
width: 100%;
text-align: left;
padding: 10px 12px;
font-size: 18px;
color: #d9e0e6;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
}
.folder-picker-row:hover,
.folder-picker-row:focus {
background: rgba(255, 255, 255, 0.08);
outline: none;
}
.folder-picker-empty {
padding: 14px 12px;
color: #8f9ba6;
font-size: 17px;
}
.permission-overlay {
position: fixed;
top: 0;
+145 -17
View File
@@ -100,6 +100,22 @@ const pkgInfoTitleEl = document.getElementById("pkgInfoTitle");
const pkgInfoFieldsEl = document.getElementById("pkgInfoFields");
const pkgInfoCloseBtn = document.getElementById("pkgInfoCloseBtn");
const pkgInfoInstallBtn = document.getElementById("pkgInfoInstallBtn");
const extractDialogEl = document.getElementById("extractDialog");
const extractArchiveNameEl = document.getElementById("extractArchiveName");
const extractDestInputEl = document.getElementById("extractDestInput");
const extractBrowseBtnEl = document.getElementById("extractBrowseBtn");
const extractConflictSelectEl = document.getElementById("extractConflictSelect");
const extractPasswordFieldEl = document.getElementById("extractPasswordField");
const extractPasswordInputEl = document.getElementById("extractPasswordInput");
const extractLargeCheckEl = document.getElementById("extractLargeCheck");
const extractCancelBtnEl = document.getElementById("extractCancelBtn");
const extractConfirmBtnEl = document.getElementById("extractConfirmBtn");
const folderPickerOverlayEl = document.getElementById("folderPickerOverlay");
const folderPickerPathEl = document.getElementById("folderPickerPath");
const folderPickerListEl = document.getElementById("folderPickerList");
const folderPickerCancelBtnEl = document.getElementById("folderPickerCancelBtn");
const folderPickerUpBtnEl = document.getElementById("folderPickerUpBtn");
const folderPickerOkBtnEl = document.getElementById("folderPickerOkBtn");
const permissionOverlayEl = document.getElementById("permissionOverlay");
const permissionPathEl = document.getElementById("permissionPath");
const permissionModeEl = document.getElementById("permissionMode");
@@ -1028,25 +1044,137 @@ function actionExtract() {
if (busy || loadingPath) return;
const archives = selectedEntries().filter(isExtractableArchive);
if (archives.length !== 1) return;
const item = archives[0];
if (!confirm(t("extractConfirm", { name: displayName(item), path: displayPath(cwd) }))) return;
const conflict = confirm(t("extractOverwriteAsk")) ? "overwrite" : "fail";
const large = shouldPromptLargeMode(item.size) ? promptLargeMode(item.size) : false;
// 7z archives can be encrypted (7zAES); ask up front so an unprotected
// archive doesn't pay a wasted scan + folder parse. An empty submission is
// fine — the engine returns ZIPX_ERR_PASSWORD and the user retries.
// ZIP and RAR are not asked here: their headers are readable either way, so
// an empty password costs nothing and a failed attempt is retried through
// retryExtractWithPassword() instead of interrupting every extraction.
let password = "";
if (isSevenZipArchive(item) || isSevenZipSplitVolume(item)) {
const asked = prompt(t("extractPasswordAsk"), "");
if (asked === null) return;
password = asked;
}
startExtractTask(item.path, cwd, conflict, false, displayName(item), large, password);
openExtractDialog(archives[0]);
}
// Opens the extract dialog. The destination defaults to the current browsing
// directory, but the user may type any path or pick one with the folder
// browser. Confirming hands the chosen destination to startExtractTask, so the
// backend /api/extract dst_dir is whatever the user selected (or the current
// path when they leave it untouched).
function openExtractDialog(item) {
if (busy || loadingPath) return;
extractDialogArchive = item;
extractArchiveNameEl.textContent = displayName(item);
extractDestInputEl.value = cwd;
extractConflictSelectEl.value = "fail";
extractPasswordInputEl.value = "";
const wantsPassword = isSevenZipArchive(item) || isSevenZipSplitVolume(item);
extractPasswordFieldEl.hidden = !wantsPassword;
// Large archives default to large-file mode (matching the old "ask + likely
// yes" behaviour) but the user can still toggle it off.
extractLargeCheckEl.checked = shouldPromptLargeMode(item.size);
extractDialogEl.hidden = false;
extractDestInputEl.focus();
extractDestInputEl.select();
}
function closeExtractDialog() {
extractDialogEl.hidden = true;
extractDialogArchive = null;
}
let extractDialogArchive = null;
extractCancelBtnEl.addEventListener("click", closeExtractDialog);
extractConfirmBtnEl.addEventListener("click", () => {
const item = extractDialogArchive;
if (!item) return;
const dest = extractDestInputEl.value.trim();
if (!dest) {
alert(t("extractDestEmpty"));
extractDestInputEl.focus();
return;
}
const conflict = extractConflictSelectEl.value;
const large = extractLargeCheckEl.checked;
const password = extractPasswordFieldEl.hidden ? "" : extractPasswordInputEl.value;
closeExtractDialog();
startExtractTask(item.path, dest, conflict, false, displayName(item), large, password);
});
extractDialogEl.addEventListener("click", event => {
if (event.target === extractDialogEl) closeExtractDialog();
});
// --- Folder picker -------------------------------------------------------
// A minimal directory browser reused by the extract dialog's "Browse folders"
// button. It lists subdirectories of the current navigation path and lets the
// user drill down or step up, then confirms the highlighted directory as the
// destination.
let folderPickerOnPick = null;
let folderPickerCurrent = "/";
function openFolderPicker(initialPath, onPick) {
folderPickerOnPick = onPick;
folderPickerCurrent = initialPath || cwd;
folderPickerOverlayEl.hidden = false;
folderPickerRefresh();
}
async function folderPickerRefresh() {
folderPickerPathEl.textContent = folderPickerCurrent;
folderPickerListEl.innerHTML = "";
const loading = document.createElement("div");
loading.className = "folder-picker-empty";
loading.textContent = t("ready");
folderPickerListEl.appendChild(loading);
try {
const data = await api("/api/list", { path: folderPickerCurrent });
const dirs = (data.entries || [])
.filter(e => e.type === "d")
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
folderPickerListEl.innerHTML = "";
if (!dirs.length) {
const empty = document.createElement("div");
empty.className = "folder-picker-empty";
empty.textContent = t("empty");
folderPickerListEl.appendChild(empty);
return;
}
for (const d of dirs) {
const row = document.createElement("button");
row.type = "button";
row.className = "folder-picker-row";
row.textContent = d.name + "/";
row.addEventListener("click", () => {
folderPickerCurrent = d.path;
folderPickerRefresh();
});
folderPickerListEl.appendChild(row);
}
} catch (err) {
folderPickerListEl.innerHTML = "";
const msg = document.createElement("div");
msg.className = "folder-picker-empty";
msg.textContent = err.message;
folderPickerListEl.appendChild(msg);
}
}
function closeFolderPicker(picked) {
folderPickerOverlayEl.hidden = true;
const cb = folderPickerOnPick;
folderPickerOnPick = null;
if (picked && cb) cb(folderPickerCurrent);
}
folderPickerCancelBtnEl.addEventListener("click", () => closeFolderPicker(false));
folderPickerOkBtnEl.addEventListener("click", () => closeFolderPicker(true));
folderPickerUpBtnEl.addEventListener("click", () => {
const trimmed = folderPickerCurrent.replace(/\/+$/, "");
const idx = trimmed.lastIndexOf("/");
folderPickerCurrent = idx <= 0 ? "/" : trimmed.slice(0, idx) || "/";
folderPickerRefresh();
});
folderPickerOverlayEl.addEventListener("click", event => {
if (event.target === folderPickerOverlayEl) closeFolderPicker(false);
});
extractBrowseBtnEl.addEventListener("click", () => {
openFolderPicker(extractDestInputEl.value.trim() || cwd, p => {
if (p) extractDestInputEl.value = p;
});
});
function openImagePreview(item) {
if (busy) return;
setModalBackgroundLocked(true);
+11 -6
View File
@@ -8,22 +8,27 @@
| 项 | 值 |
|---|---|
| 待测 ELF | `web-file-mgr-v1.9.3M.elf`(903,448 B,sha256 `8ca47d5a…86b7`) |
| 已发布 ELF | `web-file-mgr-v1.9.3M.elf`(903,448 B,sha256 `8ca47d5a…86b7`) |
| **回滚 ELF** | `.build/rel-v1.9.2/web-file-mgr-v1.9.2.elf`(870,488 B,sha256 `177e90fe…8e84`,**从 GitHub Release 下载并已核验**) |
| 测试归档 | `.build/device-test/`(22 个文件,2.5 MB,含 `MANIFEST.txt` 指纹) |
| 监听端口 | 默认 `8888`,通知栏显示实际端口 |
> **这一轮(2026-09-24 晚)又重编了三次**,都只动前端资源(上传菜单、拖拽提示、页脚
> **这一轮(2026-09-24 晚)连续重编了多次**,都只动前端资源(上传菜单、拖拽提示、页脚
> 状态行钳制、口令提示键、菜单行高亮、解压按钮常显),C 代码一字节没改。六个构建的
> **文件尺寸都是 903,448 B**,sha256 各不相同,`.rodata` 逐轮
> +0x140 / +0x980 / +0x100 / +0x180 / +0x240:
> `7b5ab00c…`(首轮)→ `212107a6…`(+ 上传菜单)→ `da36834d…`(+ 文案与状态行钳制)→
> `cf2c0fcf…`(+ 菜单行高亮修复)→ **`8ca47d5a…`(+ 解压按钮常显置灰,本轮待测)**。
> `cf2c0fcf…`(+ 菜单行高亮修复)→ **`8ca47d5a…`(+ 解压按钮常显置灰,已发布)**。
> **以 sha256 为准,别用文件尺寸判断"包换没换"。**
>
> ✅ **本清单已于 2026-09-24 在真机跑通,并据此发布 `v1.9.3M`。** 下面的记录表保留原始
> 条目,供后续回归对照;已发布资产同样是 sha256 `8ca47d5a…`(903,448 B),与本地逐字节
> 一致。
⚠️ **回滚只能用 `.build/rel-v1.9.2/` 那个**。项目根目录里曾经并存的、同名的
`web-file-mgr-v1.9.2.elf`(903,448 B 的**未发布工作树**)已挪到
`.build/elf-v1.9.2-worktree-f3164efa.elf`,根目录现在只剩本次待测的 `v1.9.3M`。
`web-file-mgr-v1.9.2.elf`(903,448 B 的**当时未发布工作树**)已挪到
`.build/elf-v1.9.2-worktree-f3164efa.elf`,根目录现在只剩 `v1.9.3M`。回滚包也可直接从
Release 页下载:<https://github.com/LisherSong/ps5-web-file-manager/releases/tag/v1.9.2>。
> **带 `M` = LisherSong 改版,不带 `M` = 上游原版。** 从这一版起版本号统一带 `M`
> 后缀(如 `v1.9.3M`),所以「名字里有没有 M」本身就是上游 / 改版的判据。
@@ -60,7 +65,7 @@ nc -q0 <PS5_IP> 9021 < web-file-mgr-v1.9.3M.elf
> 若你此前已经刷过不带 M 的 `v1.9.3`:那个包只差字符串,**功能行为与本版完全一致**,
> 所以先前测出的结果仍然有效,不必因为加了 M 就重测一遍功能项。
> 反过来,只要界面显示的是 `v1.9.3`(无 M)就是旧包,`v1.9.3M` 才是本次待测。
> 反过来,只要界面显示的是 `v1.9.3`(无 M)就是旧包,`v1.9.3M` 才是本次发布版。
---
+3 -3
View File
@@ -73,9 +73,9 @@
### 已知缺口(诚实列出)
> 本节描述的是 **v1.9.2 发布时**的状态。此后有两条已在工作树中补齐、尚未发版:
> 加密 ZIP/RAR 与 7z `-mhe=on` 加密头。详见 README「未发布内容」与
> `HANDOVER.md` §十一 / §十二。
> 本节描述的是 **v1.9.2 发布时**的状态。此后补齐的两条缺口 —— 加密 ZIP/RAR 与
> 7z `-mhe=on` 加密头 —— 已随 **v1.9.3M** 发布。详见 README 的「压缩包支持」一节、
> [`CHANGELOG.md`](../CHANGELOG.md) 的 `[v1.9.3M]` 段,以及 `HANDOVER.md` §十一 / §十二。
- 带密码的 ZIP / RAR / 7z:**拒绝解压**(引擎有解密能力,但密码输入 UI/API 还没接,临时先挡掉)。
- 7z `-mhe=on` **加密头**:暂不支持(需要自研头解析器)。这是 7z 侧唯一已知缺口。
+7 -5
View File
@@ -2,11 +2,12 @@
> This is the long-form maintainer's manual for the v1.8 archive-engine
> expansion. It is written for the next developer, not the user. The
> user-facing description lives in [`README.md → RAR extraction`](../README.md#rar-extraction);
> user-facing description lives in [`README.md → Archive support`](../README.md#archive-support);
> the release notes are in [`CHANGELOG.md`](../CHANGELOG.md). The vendoring
> decision tree (and the v1.9 upgrade path) is at
> [`third_party/unrar/VENDORED.md`](../third_party/unrar/VENDORED.md) — most
> of the "why" questions are answered there, not here.
> [`third_party/unrar7/VENDORED.md`](../third_party/unrar7/VENDORED.md) — most
> of the "why" questions are answered there, not here. (v1.8 shipped that file
> as `third_party/unrar/VENDORED.md`; the directory was renamed in v1.9.)
---
@@ -54,7 +55,8 @@ in `third_party/unrar/`, add a CXX link step to `Makefile`, switch
`src/rar_extract.c` to the `RAROpenArchiveEx` / `RARSetPassword` DLL
API. **The `rar_extract()` signature, the dispatch layer and the host
tests do not need to change.** Full step-by-step recipe is in
[`third_party/unrar/VENDORED.md`](../third_party/unrar/VENDORED.md).
[`third_party/unrar7/VENDORED.md`](../third_party/unrar7/VENDORED.md) (the v1.8
original was `third_party/unrar/VENDORED.md`).
---
@@ -509,7 +511,7 @@ git -c core.autocrlf=false commit -m "v1.8: RAR4/RAR5 single-volume unencrypted
### 10.1 v1.9 — full RAR (multi-volume + encrypted)
See [`third_party/unrar/VENDORED.md`](../third_party/unrar/VENDORED.md)
See [`third_party/unrar7/VENDORED.md`](../third_party/unrar7/VENDORED.md)
§"Upgrading to a fuller library (v1.9 plan)" for the migration recipe.
The public `rar_extract()` signature and the dispatch layer do **not**
need to change; only:
+2 -2
View File
@@ -22,7 +22,7 @@
|---|---|---|
| `src/archive_extract.c`(124 行) | 本仓库 | 只做**后缀识别** + 输出目录名推导 |
| `src/archive_helper.c`(732 行) | 本仓库 | **IPC 客户端**:启动 helper + Unix socket 协议 |
| `wfm-7zip-helper.elf`(~百 MB 级) | `/data/wfm/`,**不在仓库里,单独分发** | 真正的解压 = **7-Zip 本体** |
| `wfm-7zip-helper.elf`(**实测 1,017,616 B ≈ 1 MB**) | `/data/wfm/`,**不在仓库里,单独分发** | 真正的解压 = **7-Zip 本体** |
README 原文:
@@ -191,7 +191,7 @@ ZIP 108 + RAR 27 + 7z 28 = **163 checks**,0 失败(MinGW host)+ PS5 真机
1. **部署体验倒退** —— 用户要装两个文件,还得记住放 `/data/wfm/`;丢一个功能全废。现在单 ELF 是无状态交付,这是真实优势
2. **安全护栏会一起丢** —— 走 7-Zip 就意味着放弃我们对 entries/ratio/空间/穿越的控制
3. **helper 上游自己都不敢放进仓库**("separately distributed"),大概率是体积或许可原因,跟着走会继承同样的问题
3. **helper 上游自己都不敢放进仓库**("separately distributed")。已实测它只有 **1,017,616 B ≈ 1 MB**(v1.9 release 资产),所以原因**不是体积**——更可能是许可(7-Zip 为 LGPL,其 unRAR 部分另带 "unRAR restriction")与交叉编译成本,跟着走会继承同样的问题
4. **我们已经付过的成本会沉没** —— 7z 引擎(自解析 folder + pull 链 + 7zAES)+ 三类分卷抽象共约 3,600 行零耦合代码
### 一句话总结
+2 -2
View File
@@ -174,9 +174,9 @@ http://192.168.1.50:8888/
这个程序是从开源项目 **owendswang/ps5-web-file-manager** 改来的。下面是和你有关的差别,说人话:
| 对你意味着什么 | 原版(上游 v1.8) | 本版(v1.9.3M) |
| 对你意味着什么 | 原版(上游 v1.9) | 本版(v1.9.3M) |
|---|---|---|
| **要装几个东西** | **两个**:主程序 + 一个上百 MB 的 7-Zip 辅助程序,还得放到固定目录 `/data/wfm/`。**辅助文件丢了,解压功能直接全废** | **就一个文件**,拷上去就能用 |
| **要装几个东西** | **两个**:主程序 + 一个约 1 MB 的 7-Zip 辅助程序(`wfm-7zip-helper.elf`),还得放到固定目录 `/data/wfm/`。**辅助文件丢了,解压功能直接全废** | **就一个文件**,拷上去就能用 |
| **能解多少种格式** | **约 30 种**(`.tar.gz` `.xz` `.cab` `.iso` 类……) | **3 种**:`.zip` `.rar` `.7z` |
| **带密码的压缩包** | 靠 7-Zip 支持 | **三种格式都支持**,密码错了会弹框让你重填(最多 3 次) |
| **RAR 分卷** | 支持 | 支持(要点第一个分卷) |
+27 -5
View File
@@ -8,6 +8,10 @@
#include <stdio.h>
#include <string.h>
/* Native sceAppInstUtil MetaInfo ABI is 6 pointers (0x30). The old 0x38 layout
carried two extra Mono-managed fields (slot, is_playgo_enabled) that do not
exist in the firmware's native struct; passing the oversized struct shifts
every subsequent argument and makes InstallByPackage fail or misbehave. */
typedef struct pkg_metadata {
const char *uri;
const char *ex_uri;
@@ -15,13 +19,23 @@ typedef struct pkg_metadata {
const char *content_id;
const char *content_name;
const char *icon_url;
uint32_t slot;
uint32_t is_playgo_enabled;
} pkg_metadata_t;
_Static_assert(sizeof(pkg_metadata_t) == 0x38,
_Static_assert(sizeof(pkg_metadata_t) == 0x30,
"sceAppInstUtil metadata ABI mismatch");
/* The stock process lacks the privilege sceAppInstUtil needs. kstuff/etaHEN
expose kernel_set_ucred_authid through libkernel_sys; raising the authid to
the debug value before install is what lets the call succeed on a real
console. Declared here (PS5 build only) and resolved by -lkernel_sys. */
int kernel_set_ucred_authid(uint64_t authid);
#ifndef DEBUG_AUTHID
#define DEBUG_AUTHID 0x4800000000000006ULL
#endif
#define PKG_INSTALL_PRIV_FAILED 0x80000001
typedef struct pkg_info {
char content_id[48];
int type;
@@ -78,8 +92,6 @@ pkg_installer_install(const char *path) {
.content_id = "",
.content_name = "",
.icon_url = "",
.slot = 0,
.is_playgo_enabled = 0
};
pkg_info_t pkg_info = {0};
playgo_info_t playgo_info = {0};
@@ -98,6 +110,16 @@ pkg_installer_install(const char *path) {
pthread_mutex_unlock(&installer_lock);
return result;
}
/* Raise the process authid to the debug value so sceAppInstUtil is allowed
to install. Failure here means the kernel privilege was not granted
(no kstuff/etaHEN present or not patched) -- report it distinctly rather
than handing a privileged call to an unprivileged process. */
if(kernel_set_ucred_authid(DEBUG_AUTHID)) {
printf("pkg_installer: kernel_set_ucred_authid failed (0x%016llx)\n",
(unsigned long long)DEBUG_AUTHID);
pthread_mutex_unlock(&installer_lock);
return PKG_INSTALL_PRIV_FAILED;
}
result = sceAppInstUtilInstallByPackage(&metadata, &pkg_info, &playgo_info);
pthread_mutex_unlock(&installer_lock);
return result;