Compare commits

...
1 Commits
Author SHA1 Message Date
owendswang f460178ac8 Release v0.2 2026-06-17 23:57:33 +08:00
10 changed files with 243 additions and 85 deletions

No files matched your search

+2 -1
View File
@@ -1,4 +1,5 @@
gen/
web-file-mgr.elf
*.elf
web-file-mgr-linux
*.o
*.d
+47 -11
View File
@@ -1,43 +1,79 @@
ifdef PS5_PAYLOAD_SDK
ifneq ($(filter-out linux linux-deps clean,$(MAKECMDGOALS)),)
ifdef PS5_PAYLOAD_SDK
include $(PS5_PAYLOAD_SDK)/toolchain/prospero.mk
else
else
$(error PS5_PAYLOAD_SDK is undefined)
endif
endif
ifeq ($(MAKECMDGOALS),)
ifdef PS5_PAYLOAD_SDK
include $(PS5_PAYLOAD_SDK)/toolchain/prospero.mk
else
$(error PS5_PAYLOAD_SDK is undefined)
endif
endif
VERSION_TAG := v0.1
VERSION_TAG := v0.2
TITLE_ID := FMGR88888
PYTHON ?= python3
STRIP ?= $(PS5_PAYLOAD_SDK)/bin/prospero-strip
PKG_CONFIG ?= $(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config
HOST_CC ?= cc
HOST_STRIP ?= strip
HOST_PKG_CONFIG ?= pkg-config
BIN := web-file-mgr.elf
SRCS := src/main.c src/websrv.c src/filemgr.c src/asset.c src/mime.c
SRCS += src/app_installer.c src/notify.c
ASSETS := $(wildcard assets/*)
GEN_SRCS := $(patsubst assets/%,gen/%, $(ASSETS:=.c))
BIN := web-file-mgr.elf
LEGACY_BIN := web-file-mgr-legacy.elf
LINUX_BIN := web-file-mgr-linux
COMMON_SRCS := src/main.c src/websrv.c src/filemgr.c src/asset.c src/mime.c src/notify.c
PS5_SRCS := $(COMMON_SRCS) src/app_installer.c
LINUX_SRCS := $(COMMON_SRCS)
ASSETS := $(wildcard assets/*)
GEN_SRCS := $(patsubst assets/%,gen/%, $(ASSETS:=.c))
CFLAGS := -Oz -fno-asynchronous-unwind-tables -fno-unwind-tables -Wall -Werror -ffunction-sections -fdata-sections -Isrc -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
CFLAGS += `$(PKG_CONFIG) libmicrohttpd --cflags`
LDFLAGS := -Wl,--gc-sections
LEGACY_CFLAGS := $(filter-out -ffunction-sections -fdata-sections,$(CFLAGS))
LEGACY_LDFLAGS :=
LDADD := `$(PKG_CONFIG) libmicrohttpd --libs`
LDADD += -lSceIpmi -lSceAppInstUtil
LINUX_CFLAGS := -O2 -Wall -Werror -Isrc -DVERSION_TAG=\"$(VERSION_TAG)\" -DTITLE_ID=\"$(TITLE_ID)\"
LINUX_CFLAGS += `$(HOST_PKG_CONFIG) libmicrohttpd --cflags`
LINUX_LDADD := `$(HOST_PKG_CONFIG) libmicrohttpd --libs` -pthread
.PHONY: all deps clean
.PHONY: all legacy linux deps linux-deps clean
all: deps $(BIN)
legacy: deps $(LEGACY_BIN)
linux: linux-deps $(LINUX_BIN)
deps:
@$(PKG_CONFIG) --exists libmicrohttpd || ./install-libmicrohttpd.sh
linux-deps:
@$(HOST_PKG_CONFIG) --exists libmicrohttpd || \
(echo "libmicrohttpd development package is required for make linux" >&2; exit 1)
gen:
mkdir gen
clean:
rm -rf $(BIN) gen
rm -rf $(BIN) $(LEGACY_BIN) $(LINUX_BIN) gen
gen/%.c: assets/% gen-asset-module.py gen
$(PYTHON) gen-asset-module.py --path $* $< > $@
$(BIN): $(SRCS) $(GEN_SRCS)
$(BIN): $(PS5_SRCS) $(GEN_SRCS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(LDADD)
$(STRIP) $@
$(LEGACY_BIN): $(PS5_SRCS) $(GEN_SRCS)
$(CC) $(LEGACY_CFLAGS) $(LEGACY_LDFLAGS) -o $@ $^ $(LDADD)
$(STRIP) $@
$(LINUX_BIN): $(LINUX_SRCS) $(GEN_SRCS)
$(HOST_CC) $(LINUX_CFLAGS) -o $@ $^ $(LINUX_LDADD)
$(HOST_STRIP) $@
+2 -2
View File
@@ -5,7 +5,7 @@ safely copying game dump folders from USB storage to internal storage.
## Brief
PS5 web file manager payload. It runs an HTTP UI starting at port `8888`, installs a home screen launcher on startup when needed, and provides file operations from the PS5 browser. If `8888` is already in use, the payload tries the next port until one is available; the startup notification shows the actual listen port.
PS5 web file manager payload. It runs an HTTP UI starting at port `8888`, installs a home screen launcher in Media catagory on startup when needed, and provides file operations from the PS5 browser. If `8888` is already in use, the payload tries the next port until one is available; the startup notification shows the actual listen port.
## Features
@@ -69,7 +69,7 @@ After the payload starts, the PS5 notification shows the app name, version, and
http://${PS5_IP_ADDRESS}:8888/
```
On first startup the payload installs a `PS5 Web File Manager` web shortcut on the PS5 home screen when needed. If the payload had to use a fallback port such as `8889`, use the port shown in the startup notification.
On first startup the payload installs a `PS5 Web File Manager` web shortcut in the Media category when needed. If the payload had to use a fallback port such as `8889`, use the port shown in the startup notification.
## Notes
+2 -1
View File
@@ -60,7 +60,8 @@ window.WFM_LANG = {
tasksPollFailed: "Failed to read task status: {error}",
renamePrompt: "New name",
mkdirPrompt: "Folder name",
deleteConfirm: "Delete {name}?\n\nFolders will be deleted recursively.",
deleteConfirm: "Delete {name}?",
deleteConfirmRecursive: "Delete {name}?\n\nFolders will be deleted recursively.",
activeTask: "A task is running",
pasteTitle: "{label} {name} to {path}",
pasteToCurrent: "to current folder",
+2 -1
View File
@@ -60,7 +60,8 @@ window.WFM_LANG = {
tasksPollFailed: "任务状态读取失败: {error}",
renamePrompt: "新名称",
mkdirPrompt: "目录名",
deleteConfirm: "确认删除 {name}?\n\n目录会递归删除。",
deleteConfirm: "确认删除 {name}?",
deleteConfirmRecursive: "确认删除 {name}?\n\n目录会递归删除。",
activeTask: "有任务正在执行",
pasteTitle: "{label} {name} 到 {path}",
pasteToCurrent: "到当前目录",
+79 -35
View File
@@ -1,5 +1,4 @@
:root {
color-scheme: dark;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #111316;
color: #edf0f2;
@@ -15,21 +14,25 @@
.init-loading {
position: fixed;
inset: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 20000;
display: grid;
place-items: center;
display: flex;
align-items: center;
justify-content: center;
background: #111316;
}
.loading-dots {
display: flex;
gap: 12px;
}
.loading-dots span {
width: 14px;
height: 14px;
margin: 0 6px;
border-radius: 50%;
background: #8fa1b3;
animation: loading-pulse 1s ease-in-out infinite;
@@ -68,14 +71,13 @@ body {
.shell {
min-height: 100vh;
height: 100vh;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
gap: 20px;
padding: 12px 30px;
background: #1b2026;
border-bottom: 1px solid #2d343d;
@@ -96,7 +98,6 @@ body {
.space-info {
display: flex;
justify-content: flex-end;
gap: 14px;
flex: 0 1 auto;
max-width: 58vw;
color: #c9d3dc;
@@ -107,6 +108,7 @@ body {
}
.space-item {
margin-left: 14px;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -128,6 +130,7 @@ body {
height: 24px;
padding: 0;
flex: 0 0 24px;
margin-left: 16px;
}
.power-button {
@@ -162,7 +165,6 @@ body {
.tool-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
@@ -178,6 +180,16 @@ body {
flex: 1 1 auto;
}
.tool-left > *,
.tool-right > * {
margin: 0 12px 0 0;
}
.tool-right > * {
margin-right: 0;
margin-left: 12px;
}
.tool-right {
justify-content: flex-end;
flex: 0 0 auto;
@@ -226,19 +238,23 @@ button:disabled,
}
.paste-action {
max-width: min(480px, 100%);
gap: 8px;
max-width: 480px;
}
.paste-action > * {
margin-right: 8px;
}
.paste-name {
display: inline-block;
max-width: min(180px, 18vw);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
@@ -336,6 +352,7 @@ tr.selected {
input[type="checkbox"] {
width: 34px;
height: 34px;
cursor: pointer;
}
.icon {
@@ -362,20 +379,27 @@ input[type="checkbox"] {
background: transparent;
color: #edf0f2;
justify-content: flex-start;
gap: 14px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.row-action .icon {
margin-right: 14px;
}
.name-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.empty,
.empty {
color: #aab4be;
padding: 20px 90px;
}
.status {
color: #aab4be;
padding: 10px 25px;
@@ -388,7 +412,6 @@ input[type="checkbox"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.version-text {
@@ -398,10 +421,14 @@ input[type="checkbox"] {
.task-overlay {
position: fixed;
inset: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 10000;
display: grid;
place-items: center;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
background: rgba(4, 6, 8, .82);
}
@@ -411,7 +438,8 @@ input[type="checkbox"] {
}
.task-panel {
width: min(980px, calc(100vw - 96px));
width: 980px;
max-width: calc(100vw - 96px);
border: 1px solid #46515f;
border-radius: 10px;
background: #15191e;
@@ -420,19 +448,24 @@ input[type="checkbox"] {
}
.tasks {
display: grid;
gap: 18px;
display: block;
}
.task {
display: grid;
gap: 14px;
display: block;
}
.task > * + * {
margin-top: 14px;
}
.task .danger {
width: 100%;
}
.task-head {
display: flex;
align-items: start;
gap: 16px;
align-items: flex-start;
font-size: 24px;
}
@@ -441,7 +474,7 @@ input[type="checkbox"] {
}
.task-amount {
min-width: max-content;
min-width: 160px;
white-space: nowrap;
text-align: right;
}
@@ -456,10 +489,13 @@ input[type="checkbox"] {
.task-name {
display: flex;
gap: 8px;
min-width: 0;
}
.task-name > * {
margin-right: 8px;
}
.task-name-op,
.task-name-state {
flex: 0 0 auto;
@@ -467,27 +503,31 @@ input[type="checkbox"] {
.task-name-subject {
min-width: 0;
max-width: min(56vw, 560px);
max-width: 560px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-meta {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 18px;
display: flex;
color: #c9d3dc;
font-size: 20px;
}
.task-meta div {
flex: 1 1 0;
min-width: 0;
margin-right: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-meta div:last-child {
margin-right: 0;
}
.task-meta div:nth-child(2) {
text-align: center;
}
@@ -517,9 +557,13 @@ input[type="checkbox"] {
.progress-text {
position: absolute;
inset: 0;
display: grid;
place-items: center;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
color: #edf0f2;
font-size: 18px;
font-weight: 700;
+60 -20
View File
@@ -12,7 +12,7 @@ let hoverPaused = false;
let hoverResumeTimer = 0;
let L = {};
const APP_VERSION = "v0.1";
const APP_VERSION = "v0.2";
const LAST_PATH_KEY = "ps5-web-file-mgr:last-path";
const filesEl = document.getElementById("files");
@@ -318,7 +318,7 @@ function renderTaskName(el, task) {
const state = document.createElement("span");
state.className = "task-name-state";
state.textContent = stateLabel(task.state);
el.append(op, subject, state);
appendChildren(el, op, subject, state);
}
function renderClipboard() {
@@ -387,6 +387,43 @@ function cssEscape(value) {
return String(value).replace(/["\\]/g, "\\$&");
}
function appendChildren(parent) {
for (let i = 1; i < arguments.length; i++) {
parent.appendChild(arguments[i]);
}
}
function setClass(el, name, enabled) {
if (enabled) el.classList.add(name);
else el.classList.remove(name);
}
function bindPress(el, fn) {
let handled = false;
const run = event => {
if (handled) {
if (event && event.preventDefault) event.preventDefault();
return;
}
handled = true;
if (event && event.preventDefault) event.preventDefault();
fn();
setTimeout(() => {
handled = false;
}, 350);
};
el.addEventListener("mousedown", run);
el.addEventListener("click", run);
}
function findRowFromTarget(target) {
while (target && target !== contentEl) {
if (target.tagName === "TR" && target.getAttribute("data-path") !== null) return target;
target = target.parentNode;
}
return null;
}
function togglePath(path, checked) {
if (checked) selected.add(path);
else selected.delete(path);
@@ -410,8 +447,8 @@ function render() {
const isParent = item.type === "parent";
const tr = document.createElement("tr");
tr.dataset.path = item.path;
tr.classList.toggle("selected", !isParent && selected.has(item.path));
tr.classList.toggle("focused", focusedPath === item.path);
setClass(tr, "selected", !isParent && selected.has(item.path));
setClass(tr, "focused", focusedPath === item.path);
const selectTd = document.createElement("td");
selectTd.className = "select-cell";
@@ -448,10 +485,11 @@ function render() {
if (isParent || item.type === "d") load(item.path);
else togglePath(item.path, !selected.has(item.path));
});
nameBtn.append(icon, nameText);
appendChildren(nameBtn, icon, nameText);
nameTd.appendChild(nameBtn);
tr.append(
appendChildren(
tr,
selectTd,
nameTd,
classCell("type-col", isParent ? t("parent") : item.type === "d" ? t("dir") : t("file")),
@@ -618,7 +656,7 @@ function renderPendingOverlay(text) {
const amount = document.createElement("div");
amount.className = "task-amount";
amount.textContent = t("pleaseWait");
head.append(name, amount);
appendChildren(head, name, amount);
const current = document.createElement("div");
current.className = "task-current";
@@ -629,12 +667,12 @@ function renderPendingOverlay(text) {
const progressText = document.createElement("div");
progressText.className = "progress-text";
progressText.textContent = t("checking");
progress.append(progressText);
progress.appendChild(progressText);
const cancel = document.createElement("button");
cancel.className = "danger";
cancel.textContent = t("cancel");
cancel.addEventListener("click", () => {
bindPress(cancel, () => {
if (pendingAbortController) pendingAbortController.abort();
pendingAbortController = null;
pendingOverlayText = "";
@@ -643,7 +681,7 @@ function renderPendingOverlay(text) {
setBusy(false);
});
div.append(head, current, progress, cancel);
appendChildren(div, head, current, progress, cancel);
tasksEl.appendChild(div);
}
@@ -737,7 +775,7 @@ function renderTasks(tasks) {
const name = document.createElement("div");
name.className = "task-name";
renderTaskName(name, task);
head.append(name);
head.appendChild(name);
const current = document.createElement("div");
current.className = "task-current";
@@ -751,7 +789,7 @@ function renderTasks(tasks) {
progressItem.textContent = t("progressLabel") + ": " + formatSize(done) + " / " + formatSize(total);
const etaItem = document.createElement("div");
etaItem.textContent = t("etaLabel") + ": " + averageEta(task, done, total);
meta.append(speedItem, progressItem, etaItem);
appendChildren(meta, speedItem, progressItem, etaItem);
const progress = document.createElement("div");
progress.className = "progress";
@@ -761,13 +799,13 @@ function renderTasks(tasks) {
const progressText = document.createElement("div");
progressText.className = "progress-text";
progressText.textContent = isPreparing ? t("preparing") : isFinishing ? t("finishing") : total ? pct + "%" : stateLabel(task.state);
progress.append(bar, progressText);
appendChildren(progress, bar, progressText);
const cancel = document.createElement("button");
cancel.className = "danger";
cancel.textContent = task.cancel_requested ? t("canceling") : t("cancel");
cancel.disabled = task.cancel_requested;
cancel.addEventListener("click", () => {
bindPress(cancel, () => {
if (confirm(t("cancelTaskConfirm", { label: opLabel(task.op) }))) {
requestTaskCancel(task.id);
api("/api/cancel", { id: task.id }).then(pollTasks).catch(err => {
@@ -777,9 +815,9 @@ function renderTasks(tasks) {
}
});
if (isDelete) div.append(head, current, cancel);
else if (total && !isFinishing) div.append(head, current, meta, progress, cancel);
else div.append(head, current, progress, cancel);
if (isDelete) appendChildren(div, head, current, cancel);
else if (total && !isFinishing) appendChildren(div, head, current, meta, progress, cancel);
else appendChildren(div, head, current, progress, cancel);
tasksEl.appendChild(div);
}
@@ -839,7 +877,9 @@ function actionDelete() {
const items = selectedEntries();
const paths = items.map(item => item.path);
const target = itemTitle(items);
if (!confirm(t("deleteConfirm", { name: target }))) return;
const confirmKey = items.some(item => item.type === "d") ?
"deleteConfirmRecursive" : "deleteConfirm";
if (!confirm(t(confirmKey, { name: target }))) return;
runAction(t("delete"), () => api("/api/delete", { paths: paths.join("\n") }));
}
@@ -873,8 +913,8 @@ selectAllEl.addEventListener("change", () => {
render();
});
contentEl.addEventListener("mousemove", event => {
const row = event.target.closest("tr[data-path]");
hoverPath(row ? row.dataset.path : null);
const row = findRowFromTarget(event.target);
hoverPath(row ? row.getAttribute("data-path") : null);
});
contentEl.addEventListener("mouseleave", () => hoverPath(null));
contentEl.addEventListener("scroll", () => {
+1
View File
@@ -1,4 +1,5 @@
{
"applicationCategoryType": 65536,
"titleId": "FMGR88888",
"deeplinkUri": "http://127.0.0.1:8888/",
"localizedParameters": {
+17 -1
View File
@@ -9,10 +9,14 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/types.h>
#ifdef __linux__
#include <sys/vfs.h>
#else
#include <sys/mount.h>
#endif
#include <time.h>
#include <unistd.h>
@@ -580,6 +584,7 @@ ignore_chmod_error(int err) {
return err == ENOTSUP || err == EPERM || err == EINVAL || err == EROFS;
}
#ifndef __linux__
static int
fs_type_has_unix_modes(const char *type) {
return strcmp(type, "exfat") &&
@@ -588,25 +593,36 @@ fs_type_has_unix_modes(const char *type) {
strcmp(type, "fat") &&
strcmp(type, "vfat");
}
#endif
static int
path_has_unix_modes(const char *path) {
#ifdef __linux__
(void)path;
return 1;
#else
struct statfs fs;
if(statfs(path, &fs)) {
return 1;
}
return fs_type_has_unix_modes(fs.f_fstypename);
#endif
}
static int
fd_has_unix_modes(int fd) {
#ifdef __linux__
(void)fd;
return 1;
#else
struct statfs fs;
if(fstatfs(fd, &fs)) {
return 1;
}
return fs_type_has_unix_modes(fs.f_fstypename);
#endif
}
static int
+31 -13
View File
@@ -28,12 +28,12 @@ port_available(unsigned short port) {
if((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
return 0;
return -1;
}
if(setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int)) < 0) {
perror("setsockopt");
close(fd);
return 0;
return -1;
}
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
@@ -50,7 +50,11 @@ find_available_port(unsigned short start) {
unsigned int port;
for(port = start; port <= 65535; port++) {
if(port_available((unsigned short)port)) {
int available = port_available((unsigned short)port);
if(available < 0) {
return 0;
}
if(available) {
return (unsigned short)port;
}
}
@@ -99,7 +103,10 @@ find_pid(const char *name) {
int
main(int argc, char **argv) {
unsigned short port;
#ifdef __SCE__
unsigned short notified_port = 0;
pid_t pid;
#endif
(void)argc;
(void)argv;
@@ -114,26 +121,37 @@ main(int argc, char **argv) {
sleep(1);
}
#else
(void)pid;
#endif
port = find_available_port(DEFAULT_PORT);
if(!port) {
fprintf(stderr, "no available port from %u\n", DEFAULT_PORT);
return 1;
}
puts(PROCESS_NAME);
printf("version: %s\n", VERSION_TAG);
printf("listening on port %u\n", port);
#ifdef __SCE__
app_install_if_needed();
notify_user("Web File Manager\nVersion: %s\nPort: %u", VERSION_TAG, port);
#endif
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
return websrv_listen(port) ? 1 : 0;
while(1) {
port = find_available_port(DEFAULT_PORT);
if(!port) {
fprintf(stderr, "no available port from %u\n", DEFAULT_PORT);
sleep(3);
continue;
}
printf("listening on port %u\n", port);
#ifdef __SCE__
if(notified_port != port) {
notify_user("Web File Manager\nVersion: %s\nPort: %u", VERSION_TAG, port);
notified_port = port;
}
#endif
websrv_listen(port);
sleep(3);
}
return 0;
}