mirror of
https://github.com/LisherSong/ps5-web-file-manager.git
synced 2026-10-06 08:00:23 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce55d08299 | ||
|
|
c46f5969c8 | ||
|
|
9c06fca17f | ||
|
|
400784942e | ||
|
|
cfa02562b2 | ||
|
|
9a03fb7c05 | ||
|
|
9740a252a9 | ||
|
|
9e0032c019 | ||
|
|
3af704f854 | ||
|
|
2fb1a1cf56 | ||
|
|
9f3e91241e | ||
|
|
de4b74c354 | ||
|
|
fb7ac30c15 | ||
|
|
ede12cb914 | ||
|
|
edd1a1c177 | ||
|
|
9d8c49a1d4 | ||
|
|
f85e60ed8c | ||
|
|
770dcb8a9f |
No files matched your search
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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)")
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 格式缺口(设计与坑见 §八)。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+354
-331
@@ -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` 载有逐库完整摘要。
|
||||
|
||||
## 免责声明
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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` 才是本次发布版。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 侧唯一已知缺口。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 行零耦合代码
|
||||
|
||||
### 一句话总结
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user