feat: 解压目标目录可选 + PKG 安装 ABI/权限修复

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

注:PS5 专有改动(ABI/authid/链接)未经真机验证,需在 kstuff/etaHEN
环境实测安装路径是否真跑通。
This commit is contained in:
Songlx516 committed 2026-09-29 16:27:27 +08:00
1 parent c46f5969c8
commit ce55d08299
7 files changed
+390 -23

No files matched your search

+1 -1
View File
@@ -151,7 +151,7 @@ CFLAGS += `$(PKG_CONFIG) libmicrohttpd --cflags`
# ~850 KiB; see docs/SIZE-OPTIMIZATION.md.
LDFLAGS := -Wl,--gc-sections -Wl,--icf=all
LDADD := `$(PKG_CONFIG) libmicrohttpd --libs`
LDADD += -lSceIpmi -lSceAppInstUtil -lSceUserService
LDADD += -lSceIpmi -lSceAppInstUtil -lSceUserService -lkernel_sys
LINUX_CFLAGS := -O2 -flto -Wall -Werror -Isrc -Ithird_party/minizip-ng/include -Ithird_party/unrar7 -Ithird_party/7z -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
LINUX_CFLAGS += `$(HOST_PKG_CONFIG) libmicrohttpd --cflags`
LINUX_LDADD := `$(HOST_PKG_CONFIG) libmicrohttpd --libs` -pthread
+52
View File
@@ -142,6 +142,58 @@
</section>
</div>
<div id="extractDialog" class="text-editor-overlay" hidden>
<section class="text-editor-panel extract-panel" role="dialog" aria-modal="true" aria-labelledby="extractTitle">
<h2 id="extractTitle" class="extract-title" data-i18n="extractDialogTitle"></h2>
<div class="extract-row">
<span class="extract-label" data-i18n="extractArchive"></span>
<span id="extractArchiveName" class="extract-archive-name"></span>
</div>
<label class="extract-field">
<span data-i18n="extractDestination"></span>
<span class="extract-dest-row">
<input id="extractDestInput" class="extract-dest-input" type="text"
spellcheck="false" autocomplete="off" autocapitalize="off">
<button id="extractBrowseBtn" type="button" class="secondary" data-i18n="browseFolder"></button>
</span>
</label>
<label class="extract-field">
<span data-i18n="extractConflict"></span>
<select id="extractConflictSelect" class="extract-select">
<option value="fail" data-i18n="extractConflictFail"></option>
<option value="overwrite" data-i18n="extractConflictOverwrite"></option>
<option value="merge" data-i18n="extractConflictMerge"></option>
</select>
</label>
<label id="extractPasswordField" class="extract-field" hidden>
<span data-i18n="extractPasswordLabel"></span>
<input id="extractPasswordInput" class="extract-password-input" type="text"
spellcheck="false" autocomplete="off" autocapitalize="off">
</label>
<label class="extract-field extract-large-field">
<input id="extractLargeCheck" type="checkbox">
<span data-i18n="extractLargeMode"></span>
</label>
<div class="text-editor-actions">
<button id="extractCancelBtn" type="button" class="secondary" data-i18n="cancel"></button>
<button id="extractConfirmBtn" type="button" class="primary" data-i18n="extract"></button>
</div>
</section>
</div>
<div id="folderPickerOverlay" class="text-editor-overlay" hidden>
<section class="text-editor-panel folder-picker-panel" role="dialog" aria-modal="true" aria-labelledby="folderPickerTitle">
<h2 id="folderPickerTitle" class="folder-picker-title" data-i18n="selectFolder"></h2>
<div id="folderPickerPath" class="folder-picker-path"></div>
<div id="folderPickerList" class="folder-picker-list"></div>
<div class="text-editor-actions">
<button id="folderPickerCancelBtn" type="button" class="secondary" data-i18n="cancel"></button>
<button id="folderPickerUpBtn" type="button" class="secondary" data-i18n="parent"></button>
<button id="folderPickerOkBtn" type="button" class="primary" data-i18n="selectHere"></button>
</div>
</section>
</div>
<div id="permissionOverlay" class="permission-overlay" hidden>
<section class="permission-panel" role="dialog" aria-modal="true" aria-labelledby="permissionTitle">
<h2 id="permissionTitle" class="permission-title" data-i18n="permissionsTitle"></h2>
+13
View File
@@ -118,6 +118,19 @@ window.WFM_LANG = {
extractSelectArchive: "Select one archive to extract (ZIP / RAR / 7z)",
extractOneAtATime: "Only one archive can be extracted at a time",
extractSelectMainVolume: "Please select the main volume (.rar or .part01.rar)",
extractDialogTitle: "Extract archive",
extractArchive: "Archive",
extractDestination: "Extract into (destination directory)",
browseFolder: "Browse folders",
extractConflict: "File conflict policy",
extractConflictFail: "Fail if name exists",
extractConflictOverwrite: "Overwrite files",
extractConflictMerge: "Merge directories",
extractPasswordLabel: "Password (7z encryption; leave empty if none)",
extractLargeMode: "Large-file mode (max 1 TiB file / 4 TiB total)",
extractDestEmpty: "Please enter a destination directory",
selectFolder: "Select folder",
selectHere: "Select this folder",
extractArchivePending: "Preparing to extract {name}",
sameSourceTarget: "Source and destination are the same. Cannot {label} {name}",
removeConflictFirst: "A {existingType} named {name} already exists. To {label} this {sourceType}, delete that {existingType} first.",
+13
View File
@@ -118,6 +118,19 @@ window.WFM_LANG = {
extractSelectArchive: "选中一个压缩包后才能解压(ZIP / RAR / 7z)",
extractOneAtATime: "一次只能解压一个压缩包",
extractSelectMainVolume: "请改选主卷(如 .rar 或 .part01.rar)",
extractDialogTitle: "解压压缩包",
extractArchive: "压缩包",
extractDestination: "解压到(目标目录)",
browseFolder: "浏览目录",
extractConflict: "文件冲突策略",
extractConflictFail: "同名则失败",
extractConflictOverwrite: "覆盖同名文件",
extractConflictMerge: "合并目录",
extractPasswordLabel: "密码(7z 加密,可留空)",
extractLargeMode: "大文件模式(单文件上限 1 TiB / 总计 4 TiB)",
extractDestEmpty: "请填写目标目录",
selectFolder: "选择目录",
selectHere: "选择此目录",
extractArchivePending: "正在准备解压 {name}",
sameSourceTarget: "源和目标相同,不能{label} {name}",
removeConflictFirst: "目标中已存在同名{existingType} {name}。要{label}{sourceType},请先删除该{existingType}才能继续。",
+139
View File
@@ -959,6 +959,145 @@ input[type="checkbox"] {
word-break: break-all;
}
/* --- Extract dialog + folder picker --- */
.extract-panel {
width: 600px;
max-width: calc(100vw - 48px);
}
.extract-title {
margin: 0 0 16px;
color: #edf0f2;
font-size: 24px;
font-weight: 600;
}
.extract-row {
display: flex;
gap: 10px;
margin-bottom: 14px;
font-size: 18px;
}
.extract-label {
flex: 0 0 auto;
color: #8f9ba6;
}
.extract-archive-name {
min-width: 0;
flex: 1 1 auto;
color: #d9e0e6;
font-weight: 600;
word-break: break-all;
}
.extract-field {
display: block;
margin: 12px 0;
font-size: 18px;
}
.extract-field > span:first-child,
.extract-large-field {
display: flex;
align-items: center;
gap: 8px;
}
.extract-field > span[data-i18n] {
color: #8f9ba6;
margin-bottom: 6px;
display: block;
}
.extract-dest-row {
display: flex;
gap: 8px;
}
.extract-dest-input,
.extract-password-input,
.extract-select {
flex: 1 1 auto;
min-width: 0;
padding: 10px 12px;
font-size: 18px;
font-family: inherit;
color: #d9e0e6;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
}
.extract-dest-input:focus,
.extract-password-input:focus,
.extract-select:focus {
outline: none;
border-color: #6fb1ff;
}
.extract-large-field {
gap: 10px;
cursor: pointer;
}
.folder-picker-panel {
width: 600px;
max-width: calc(100vw - 48px);
display: flex;
flex-direction: column;
}
.folder-picker-title {
margin: 0 0 14px;
color: #edf0f2;
font-size: 24px;
font-weight: 600;
}
.folder-picker-path {
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 16px;
color: #aab4be;
margin-bottom: 10px;
word-break: break-all;
}
.folder-picker-list {
max-height: calc(100vh - 320px);
min-height: 120px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
margin-bottom: 16px;
}
.folder-picker-row {
display: block;
width: 100%;
text-align: left;
padding: 10px 12px;
font-size: 18px;
color: #d9e0e6;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
}
.folder-picker-row:hover,
.folder-picker-row:focus {
background: rgba(255, 255, 255, 0.08);
outline: none;
}
.folder-picker-empty {
padding: 14px 12px;
color: #8f9ba6;
font-size: 17px;
}
.permission-overlay {
position: fixed;
top: 0;
+145 -17
View File
@@ -100,6 +100,22 @@ const pkgInfoTitleEl = document.getElementById("pkgInfoTitle");
const pkgInfoFieldsEl = document.getElementById("pkgInfoFields");
const pkgInfoCloseBtn = document.getElementById("pkgInfoCloseBtn");
const pkgInfoInstallBtn = document.getElementById("pkgInfoInstallBtn");
const extractDialogEl = document.getElementById("extractDialog");
const extractArchiveNameEl = document.getElementById("extractArchiveName");
const extractDestInputEl = document.getElementById("extractDestInput");
const extractBrowseBtnEl = document.getElementById("extractBrowseBtn");
const extractConflictSelectEl = document.getElementById("extractConflictSelect");
const extractPasswordFieldEl = document.getElementById("extractPasswordField");
const extractPasswordInputEl = document.getElementById("extractPasswordInput");
const extractLargeCheckEl = document.getElementById("extractLargeCheck");
const extractCancelBtnEl = document.getElementById("extractCancelBtn");
const extractConfirmBtnEl = document.getElementById("extractConfirmBtn");
const folderPickerOverlayEl = document.getElementById("folderPickerOverlay");
const folderPickerPathEl = document.getElementById("folderPickerPath");
const folderPickerListEl = document.getElementById("folderPickerList");
const folderPickerCancelBtnEl = document.getElementById("folderPickerCancelBtn");
const folderPickerUpBtnEl = document.getElementById("folderPickerUpBtn");
const folderPickerOkBtnEl = document.getElementById("folderPickerOkBtn");
const permissionOverlayEl = document.getElementById("permissionOverlay");
const permissionPathEl = document.getElementById("permissionPath");
const permissionModeEl = document.getElementById("permissionMode");
@@ -1028,25 +1044,137 @@ function actionExtract() {
if (busy || loadingPath) return;
const archives = selectedEntries().filter(isExtractableArchive);
if (archives.length !== 1) return;
const item = archives[0];
if (!confirm(t("extractConfirm", { name: displayName(item), path: displayPath(cwd) }))) return;
const conflict = confirm(t("extractOverwriteAsk")) ? "overwrite" : "fail";
const large = shouldPromptLargeMode(item.size) ? promptLargeMode(item.size) : false;
// 7z archives can be encrypted (7zAES); ask up front so an unprotected
// archive doesn't pay a wasted scan + folder parse. An empty submission is
// fine — the engine returns ZIPX_ERR_PASSWORD and the user retries.
// ZIP and RAR are not asked here: their headers are readable either way, so
// an empty password costs nothing and a failed attempt is retried through
// retryExtractWithPassword() instead of interrupting every extraction.
let password = "";
if (isSevenZipArchive(item) || isSevenZipSplitVolume(item)) {
const asked = prompt(t("extractPasswordAsk"), "");
if (asked === null) return;
password = asked;
}
startExtractTask(item.path, cwd, conflict, false, displayName(item), large, password);
openExtractDialog(archives[0]);
}
// Opens the extract dialog. The destination defaults to the current browsing
// directory, but the user may type any path or pick one with the folder
// browser. Confirming hands the chosen destination to startExtractTask, so the
// backend /api/extract dst_dir is whatever the user selected (or the current
// path when they leave it untouched).
function openExtractDialog(item) {
if (busy || loadingPath) return;
extractDialogArchive = item;
extractArchiveNameEl.textContent = displayName(item);
extractDestInputEl.value = cwd;
extractConflictSelectEl.value = "fail";
extractPasswordInputEl.value = "";
const wantsPassword = isSevenZipArchive(item) || isSevenZipSplitVolume(item);
extractPasswordFieldEl.hidden = !wantsPassword;
// Large archives default to large-file mode (matching the old "ask + likely
// yes" behaviour) but the user can still toggle it off.
extractLargeCheckEl.checked = shouldPromptLargeMode(item.size);
extractDialogEl.hidden = false;
extractDestInputEl.focus();
extractDestInputEl.select();
}
function closeExtractDialog() {
extractDialogEl.hidden = true;
extractDialogArchive = null;
}
let extractDialogArchive = null;
extractCancelBtnEl.addEventListener("click", closeExtractDialog);
extractConfirmBtnEl.addEventListener("click", () => {
const item = extractDialogArchive;
if (!item) return;
const dest = extractDestInputEl.value.trim();
if (!dest) {
alert(t("extractDestEmpty"));
extractDestInputEl.focus();
return;
}
const conflict = extractConflictSelectEl.value;
const large = extractLargeCheckEl.checked;
const password = extractPasswordFieldEl.hidden ? "" : extractPasswordInputEl.value;
closeExtractDialog();
startExtractTask(item.path, dest, conflict, false, displayName(item), large, password);
});
extractDialogEl.addEventListener("click", event => {
if (event.target === extractDialogEl) closeExtractDialog();
});
// --- Folder picker -------------------------------------------------------
// A minimal directory browser reused by the extract dialog's "Browse folders"
// button. It lists subdirectories of the current navigation path and lets the
// user drill down or step up, then confirms the highlighted directory as the
// destination.
let folderPickerOnPick = null;
let folderPickerCurrent = "/";
function openFolderPicker(initialPath, onPick) {
folderPickerOnPick = onPick;
folderPickerCurrent = initialPath || cwd;
folderPickerOverlayEl.hidden = false;
folderPickerRefresh();
}
async function folderPickerRefresh() {
folderPickerPathEl.textContent = folderPickerCurrent;
folderPickerListEl.innerHTML = "";
const loading = document.createElement("div");
loading.className = "folder-picker-empty";
loading.textContent = t("ready");
folderPickerListEl.appendChild(loading);
try {
const data = await api("/api/list", { path: folderPickerCurrent });
const dirs = (data.entries || [])
.filter(e => e.type === "d")
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
folderPickerListEl.innerHTML = "";
if (!dirs.length) {
const empty = document.createElement("div");
empty.className = "folder-picker-empty";
empty.textContent = t("empty");
folderPickerListEl.appendChild(empty);
return;
}
for (const d of dirs) {
const row = document.createElement("button");
row.type = "button";
row.className = "folder-picker-row";
row.textContent = d.name + "/";
row.addEventListener("click", () => {
folderPickerCurrent = d.path;
folderPickerRefresh();
});
folderPickerListEl.appendChild(row);
}
} catch (err) {
folderPickerListEl.innerHTML = "";
const msg = document.createElement("div");
msg.className = "folder-picker-empty";
msg.textContent = err.message;
folderPickerListEl.appendChild(msg);
}
}
function closeFolderPicker(picked) {
folderPickerOverlayEl.hidden = true;
const cb = folderPickerOnPick;
folderPickerOnPick = null;
if (picked && cb) cb(folderPickerCurrent);
}
folderPickerCancelBtnEl.addEventListener("click", () => closeFolderPicker(false));
folderPickerOkBtnEl.addEventListener("click", () => closeFolderPicker(true));
folderPickerUpBtnEl.addEventListener("click", () => {
const trimmed = folderPickerCurrent.replace(/\/+$/, "");
const idx = trimmed.lastIndexOf("/");
folderPickerCurrent = idx <= 0 ? "/" : trimmed.slice(0, idx) || "/";
folderPickerRefresh();
});
folderPickerOverlayEl.addEventListener("click", event => {
if (event.target === folderPickerOverlayEl) closeFolderPicker(false);
});
extractBrowseBtnEl.addEventListener("click", () => {
openFolderPicker(extractDestInputEl.value.trim() || cwd, p => {
if (p) extractDestInputEl.value = p;
});
});
function openImagePreview(item) {
if (busy) return;
setModalBackgroundLocked(true);
+27 -5
View File
@@ -8,6 +8,10 @@
#include <stdio.h>
#include <string.h>
/* Native sceAppInstUtil MetaInfo ABI is 6 pointers (0x30). The old 0x38 layout
carried two extra Mono-managed fields (slot, is_playgo_enabled) that do not
exist in the firmware's native struct; passing the oversized struct shifts
every subsequent argument and makes InstallByPackage fail or misbehave. */
typedef struct pkg_metadata {
const char *uri;
const char *ex_uri;
@@ -15,13 +19,23 @@ typedef struct pkg_metadata {
const char *content_id;
const char *content_name;
const char *icon_url;
uint32_t slot;
uint32_t is_playgo_enabled;
} pkg_metadata_t;
_Static_assert(sizeof(pkg_metadata_t) == 0x38,
_Static_assert(sizeof(pkg_metadata_t) == 0x30,
"sceAppInstUtil metadata ABI mismatch");
/* The stock process lacks the privilege sceAppInstUtil needs. kstuff/etaHEN
expose kernel_set_ucred_authid through libkernel_sys; raising the authid to
the debug value before install is what lets the call succeed on a real
console. Declared here (PS5 build only) and resolved by -lkernel_sys. */
int kernel_set_ucred_authid(uint64_t authid);
#ifndef DEBUG_AUTHID
#define DEBUG_AUTHID 0x4800000000000006ULL
#endif
#define PKG_INSTALL_PRIV_FAILED 0x80000001
typedef struct pkg_info {
char content_id[48];
int type;
@@ -78,8 +92,6 @@ pkg_installer_install(const char *path) {
.content_id = "",
.content_name = "",
.icon_url = "",
.slot = 0,
.is_playgo_enabled = 0
};
pkg_info_t pkg_info = {0};
playgo_info_t playgo_info = {0};
@@ -98,6 +110,16 @@ pkg_installer_install(const char *path) {
pthread_mutex_unlock(&installer_lock);
return result;
}
/* Raise the process authid to the debug value so sceAppInstUtil is allowed
to install. Failure here means the kernel privilege was not granted
(no kstuff/etaHEN present or not patched) -- report it distinctly rather
than handing a privileged call to an unprivileged process. */
if(kernel_set_ucred_authid(DEBUG_AUTHID)) {
printf("pkg_installer: kernel_set_ucred_authid failed (0x%016llx)\n",
(unsigned long long)DEBUG_AUTHID);
pthread_mutex_unlock(&installer_lock);
return PKG_INSTALL_PRIV_FAILED;
}
result = sceAppInstUtilInstallByPackage(&metadata, &pkg_info, &playgo_info);
pthread_mutex_unlock(&installer_lock);
return result;