Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3696468611 | ||
|
|
777bb4abd4 | ||
|
|
76f8fe21d2 | ||
|
|
5a91067c2e | ||
|
|
ba0704d48c | ||
|
|
4e10dee5fb | ||
|
|
259d0a6434 | ||
|
|
6f46847df2 | ||
|
|
91375e0537 | ||
|
|
f460178ac8 |
No files matched your search
@@ -1,4 +1,7 @@
|
||||
gen/
|
||||
web-file-mgr.elf
|
||||
*.elf
|
||||
web-file-mgr-linux
|
||||
*.o
|
||||
*.d
|
||||
tools/
|
||||
test/
|
||||
@@ -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.8b
|
||||
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
|
||||
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) $@
|
||||
@@ -5,12 +5,24 @@ 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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p>
|
||||
<a href="docs/screenshots/20260617_231827.376.jpg" target="_blank"><img src="docs/screenshots/20260617_231827.376.jpg" width="31%" alt="PS5 Web File Manager screenshot 1"></a>
|
||||
<a href="docs/screenshots/20260619_131432.399.jpg" target="_blank"><img src="docs/screenshots/20260619_131432.399.jpg" width="31%" alt="PS5 Web File Manager screenshot 2"></a>
|
||||
<a href="docs/screenshots/20260617_232348.855.jpg" target="_blank"><img src="docs/screenshots/20260617_232348.855.jpg" width="31%" alt="PS5 Web File Manager screenshot 3"></a>
|
||||
<a href="docs/screenshots/20260619_131811.644.jpg" target="_blank"><img src="docs/screenshots/20260619_131811.644.jpg" width="31%" alt="PS5 Web File Manager screenshot 4"></a>
|
||||
<a href="docs/screenshots/20260619_131535.239.jpg" target="_blank"><img src="docs/screenshots/20260619_131535.239.jpg" width="31%" alt="PS5 Web File Manager screenshot 5"></a>
|
||||
<a href="docs/screenshots/20260620_232728.533.jpg" target="_blank"><img src="docs/screenshots/20260620_232728.533.jpg" width="31%" alt="PS5 Web File Manager screenshot 6"></a>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- List files and folders.
|
||||
- Copy, move, delete, rename, and create folders.
|
||||
- Edit UTF-8 text files up to 1 MiB using the built-in plain text editor.
|
||||
- Multi-select operations.
|
||||
- Copy/move by choosing sources first, then pasting or moving them into the current folder.
|
||||
- Conflict prompts for overwriting files and merging folders.
|
||||
@@ -18,6 +30,8 @@ PS5 web file manager payload. It runs an HTTP UI starting at port `8888`, instal
|
||||
- Copied/moved files and folders are set to `0777` where the filesystem supports Unix permissions. FAT/exFAT-style filesystems may ignore chmod.
|
||||
- Chinese and English UI. The browser language is read from `navigator.languages` / `navigator.language`; Chinese uses `zh`, everything else uses English.
|
||||
- Startup notification showing the app name, version, and listen port.
|
||||
- Create/edit text files ending with `.txt`, `.json`, `.xml`, `.ini`, `.cfg`, `.conf`, `.md`, `.log`, `.lua`, `.js`, `.css`, `.html`, `.htm`, `.c`, `.h`, `.cpp`, `.hpp`, `.sh`, `.csv`, `.yaml`, `.yml`.
|
||||
- Preview image files ending with `.png,`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp`.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -69,7 +83,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
|
||||
|
||||
@@ -77,6 +91,12 @@ On first startup the payload installs a `PS5 Web File Manager` web shortcut on t
|
||||
- Delete is recursive and permanent. There is no recycle bin.
|
||||
- Copy/move tasks can be canceled. A partially copied single file is removed, but partially copied folders are left in place to avoid deleting existing files when merging into an existing target folder.
|
||||
- The UI can recover the active task display if the browser is closed and reopened while the payload process is still running.
|
||||
- Text editing is limited to common text-file extensions. Non-UTF-8 and oversized files are rejected.
|
||||
|
||||
## FAQ
|
||||
- This is a homebrew app which does not affect system process or memory. It should crash itself instead of kernel panic theoretically. If kernel panic happens, make sure you'r using the latest jailbreak methods which includes latest elfldr. Or switch back to the stable method you have been using.
|
||||
- If you're using P2JB, I suggest not to use this if kernel panic happened. It is suffering to wait another 50 min because of kernel panic. Stability first for P2JB.
|
||||
- The preparing stage is calculating the folder size. It would be pretty slow if there are too many files in this folder. It make sure if there is enough available space for this folder you were trying to copy/move.
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 184 B |
@@ -9,8 +9,12 @@
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div id="path" class="path">/</div>
|
||||
<div id="spaceInfo" class="space-info"></div>
|
||||
<div class="top-left">
|
||||
<div id="path" class="path">/</div>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<div id="spaceInfo" class="space-info"></div>
|
||||
</div>
|
||||
<button id="exitBtn" class="icon-button power-button" type="button" aria-label="Exit"></button>
|
||||
</header>
|
||||
|
||||
@@ -29,6 +33,7 @@
|
||||
</div>
|
||||
<div class="tool-right">
|
||||
<button id="refreshBtn" data-i18n="refresh"></button>
|
||||
<button id="newTextBtn" data-i18n="newText"></button>
|
||||
<button id="mkdirBtn" data-i18n="mkdir"></button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -48,6 +53,9 @@
|
||||
<tbody id="files"></tbody>
|
||||
</table>
|
||||
<div id="empty" class="empty" data-i18n="empty" hidden></div>
|
||||
<div id="contentLoading" class="content-loading" hidden>
|
||||
<div class="content-loading-text" data-i18n="readDir"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="status">
|
||||
@@ -56,19 +64,36 @@
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<div id="textEditorOverlay" class="text-editor-overlay" hidden>
|
||||
<section class="text-editor-panel">
|
||||
<div id="textEditorPath" class="text-editor-path"></div>
|
||||
<textarea id="textEditor" class="text-editor" wrap="off" spellcheck="false"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
|
||||
<div id="textEditorStatus" class="text-editor-status"></div>
|
||||
<div class="text-editor-actions">
|
||||
<button id="textEditorCloseBtn" class="secondary" data-i18n="close"></button>
|
||||
<button id="textEditorSaveBtn" class="primary" data-i18n="save"></button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="imagePreviewOverlay" class="text-editor-overlay" hidden>
|
||||
<section class="text-editor-panel image-preview-panel">
|
||||
<div id="imagePreviewName" class="text-editor-path"></div>
|
||||
<div class="image-preview-stage">
|
||||
<img id="imagePreview" class="image-preview" alt="">
|
||||
</div>
|
||||
<button id="imagePreviewCloseBtn" class="secondary" data-i18n="close"></button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="taskOverlay" class="task-overlay" hidden>
|
||||
<div class="task-panel">
|
||||
<div id="tasks" class="tasks"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="initLoading" class="init-loading" aria-hidden="true">
|
||||
<div class="loading-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="initLoading" class="init-loading" aria-hidden="true"></div>
|
||||
|
||||
<script src="/main.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -3,13 +3,19 @@ window.WFM_LANG = {
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
delete: "Delete",
|
||||
copying: "Copying",
|
||||
moving: "Moving",
|
||||
deleting: "Deleting",
|
||||
rename: "Rename",
|
||||
paste: "Paste",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
exit: "Exit",
|
||||
exiting: "Exiting...",
|
||||
refresh: "Refresh",
|
||||
mkdir: "New Folder",
|
||||
newText: "New Text",
|
||||
file: "File",
|
||||
dir: "Folder",
|
||||
parent: "Parent",
|
||||
@@ -33,6 +39,10 @@ window.WFM_LANG = {
|
||||
speedLabel: "Speed",
|
||||
progressLabel: "Progress",
|
||||
etaLabel: "ETA",
|
||||
elapsedLabel: "Elapsed",
|
||||
durationHours: "{hours}h {minutes}m",
|
||||
durationMinutes: "{minutes}m {seconds}s",
|
||||
durationSeconds: "{seconds}s",
|
||||
preparingTask: "Preparing task",
|
||||
canceling: "Canceling...",
|
||||
selectedItems: "{name} and {count} items",
|
||||
@@ -58,9 +68,21 @@ window.WFM_LANG = {
|
||||
cancelTaskConfirm: "Cancel current {label} task?",
|
||||
cancelFailed: "Cancel failed: {error}",
|
||||
tasksPollFailed: "Failed to read task status: {error}",
|
||||
transferComplete: "{label} completed: {name}\n\nTotal time: {duration}\nTransferred: {size}",
|
||||
transferCompleteFiles: "{label} completed: {name}\n\nTotal time: {duration}\nTransferred: {size}\nFiles: {count}",
|
||||
renamePrompt: "New name",
|
||||
mkdirPrompt: "Folder name",
|
||||
deleteConfirm: "Delete {name}?\n\nFolders will be deleted recursively.",
|
||||
newTextPrompt: "Text file name",
|
||||
creatingText: "Creating text file...",
|
||||
createTextFailed: "Failed to create text file: {error}",
|
||||
unsavedSaveConfirm: "The text has unsaved changes. Save before closing?\n\nCancel discards the changes and closes.",
|
||||
loadingText: "Loading text...",
|
||||
savingText: "Saving...",
|
||||
textSaved: "Text saved",
|
||||
openTextFailed: "Failed to open text: {error}",
|
||||
saveTextFailed: "Failed to save text: {error}",
|
||||
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",
|
||||
@@ -69,11 +91,14 @@ window.WFM_LANG = {
|
||||
storageUsb: "USB",
|
||||
storageM2: "M.2",
|
||||
storageExtended: "Extended",
|
||||
storageRoot: "Root",
|
||||
storageCurrent: "Current",
|
||||
err_target_dir_not_writable: "Target folder is not writable: {path}",
|
||||
err_target_file_not_writable: "Target file is not writable: {path}",
|
||||
err_target_parent_not_writable: "Target parent folder is not writable: {path}",
|
||||
err_target_parent_not_writable: "Current folder is not writable: {path}",
|
||||
err_target_check_failed: "Cannot check target path: {path}",
|
||||
err_target_path_too_long: "Target path is too long",
|
||||
err_path_too_long: "Path is too long",
|
||||
err_space_check_failed: "Cannot read target free space: {path}",
|
||||
err_no_space: "Not enough target space. Required {required}, available {available}",
|
||||
err_active_task: "A task is running",
|
||||
@@ -81,6 +106,14 @@ window.WFM_LANG = {
|
||||
err_source_destination_same: "Source and destination are the same",
|
||||
err_destination_inside_source: "Cannot copy or move a folder inside itself",
|
||||
err_invalid_path: "Invalid path",
|
||||
err_file_not_found: "File not found",
|
||||
err_file_already_exists: "A file with the same name already exists",
|
||||
err_invalid_method: "Invalid request method",
|
||||
err_text_type_not_editable: "This file type is not supported by the text editor",
|
||||
err_text_file_too_large: "The text file is too large. The maximum size is 1 MiB",
|
||||
err_text_invalid_utf8: "The file is not valid UTF-8 text",
|
||||
err_text_file_changed: "The file changed after it was opened. Close and reopen it",
|
||||
err_text_file_not_writable: "The text file is not writable",
|
||||
err_unknown_api: "Unknown API",
|
||||
err_out_of_memory: "Out of memory",
|
||||
err_no_source_paths: "No source paths",
|
||||
|
||||
@@ -3,13 +3,19 @@ window.WFM_LANG = {
|
||||
copy: "复制",
|
||||
move: "移动",
|
||||
delete: "删除",
|
||||
copying: "复制",
|
||||
moving: "移动",
|
||||
deleting: "删除",
|
||||
rename: "重命名",
|
||||
paste: "粘贴",
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
save: "保存",
|
||||
exit: "退出",
|
||||
exiting: "正在退出...",
|
||||
refresh: "刷新",
|
||||
mkdir: "新建目录",
|
||||
newText: "新建文本",
|
||||
file: "文件",
|
||||
dir: "目录",
|
||||
parent: "上级目录",
|
||||
@@ -33,12 +39,16 @@ window.WFM_LANG = {
|
||||
speedLabel: "速度",
|
||||
progressLabel: "进度",
|
||||
etaLabel: "剩余",
|
||||
elapsedLabel: "耗时",
|
||||
durationHours: "{hours}小时 {minutes}分",
|
||||
durationMinutes: "{minutes}分 {seconds}秒",
|
||||
durationSeconds: "{seconds}秒",
|
||||
preparingTask: "正在准备任务",
|
||||
canceling: "正在取消...",
|
||||
selectedItems: "{name} 等 {count} 项",
|
||||
countItems: "{count} 项",
|
||||
totalItems: "共 {count} 项",
|
||||
readDir: "读取目录...",
|
||||
readDir: "读取目录中...",
|
||||
actionBusy: "{label}...",
|
||||
taskCreated: "{label}任务已创建",
|
||||
actionDone: "{label}完成",
|
||||
@@ -58,9 +68,21 @@ window.WFM_LANG = {
|
||||
cancelTaskConfirm: "取消当前{label}任务?",
|
||||
cancelFailed: "取消失败: {error}",
|
||||
tasksPollFailed: "任务状态读取失败: {error}",
|
||||
transferComplete: "{label}完成:{name}\n\n总耗时:{duration}\n传输大小:{size}",
|
||||
transferCompleteFiles: "{label}完成:{name}\n\n总耗时:{duration}\n传输大小:{size}\n文件数量:{count}",
|
||||
renamePrompt: "新名称",
|
||||
mkdirPrompt: "目录名",
|
||||
deleteConfirm: "确认删除 {name}?\n\n目录会递归删除。",
|
||||
newTextPrompt: "文本文件名",
|
||||
creatingText: "正在新建文本...",
|
||||
createTextFailed: "新建文本失败: {error}",
|
||||
unsavedSaveConfirm: "文本有未保存的修改,是否保存后关闭?\n\n取消将放弃修改并关闭。",
|
||||
loadingText: "正在读取文本...",
|
||||
savingText: "正在保存...",
|
||||
textSaved: "文本已保存",
|
||||
openTextFailed: "打开文本失败: {error}",
|
||||
saveTextFailed: "保存文本失败: {error}",
|
||||
deleteConfirm: "确认删除 {name}?",
|
||||
deleteConfirmRecursive: "确认删除 {name}?\n\n目录会递归删除。",
|
||||
activeTask: "有任务正在执行",
|
||||
pasteTitle: "{label} {name} 到 {path}",
|
||||
pasteToCurrent: "到当前目录",
|
||||
@@ -69,11 +91,14 @@ window.WFM_LANG = {
|
||||
storageUsb: "USB存储",
|
||||
storageM2: "M2扩充存储",
|
||||
storageExtended: "扩展存储",
|
||||
storageRoot: "根分区",
|
||||
storageCurrent: "当前目录",
|
||||
err_target_dir_not_writable: "目标目录不可写: {path}",
|
||||
err_target_file_not_writable: "目标文件不可写: {path}",
|
||||
err_target_parent_not_writable: "目标父目录不可写: {path}",
|
||||
err_target_parent_not_writable: "当前目录不可写: {path}",
|
||||
err_target_check_failed: "无法检查目标路径: {path}",
|
||||
err_target_path_too_long: "目标路径过长",
|
||||
err_path_too_long: "路径过长",
|
||||
err_space_check_failed: "无法读取目标剩余空间: {path}",
|
||||
err_no_space: "目标空间不足,需要 {required},可用 {available}",
|
||||
err_active_task: "有任务正在执行",
|
||||
@@ -81,6 +106,14 @@ window.WFM_LANG = {
|
||||
err_source_destination_same: "源和目标相同",
|
||||
err_destination_inside_source: "不能复制或移动目录到它自己的内部",
|
||||
err_invalid_path: "路径无效",
|
||||
err_file_not_found: "文件不存在",
|
||||
err_file_already_exists: "同名文件已存在",
|
||||
err_invalid_method: "请求方式无效",
|
||||
err_text_type_not_editable: "该文件类型不支持文本编辑",
|
||||
err_text_file_too_large: "文本文件过大,最大支持 1 MiB",
|
||||
err_text_invalid_utf8: "文件不是有效的 UTF-8 文本",
|
||||
err_text_file_changed: "文件在打开后已发生变化,请关闭后重新打开",
|
||||
err_text_file_not_writable: "文本文件不可写",
|
||||
err_unknown_api: "未知接口",
|
||||
err_out_of_memory: "内存不足",
|
||||
err_no_source_paths: "没有源路径",
|
||||
|
||||
@@ -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,46 +14,17 @@
|
||||
|
||||
.init-loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #111316;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-dots span {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #8fa1b3;
|
||||
animation: loading-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(2) {
|
||||
animation-delay: .12s;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(3) {
|
||||
animation-delay: .24s;
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: .35;
|
||||
transform: scale(.75);
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #111316;
|
||||
color: #aab4be;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -68,24 +38,53 @@ body {
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: auto;
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 12px 30px;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
background: #1b2026;
|
||||
border-bottom: 1px solid #2d343d;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.top-left,
|
||||
.top-right {
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-left {
|
||||
flex: 1 1 420px;
|
||||
min-width: 0;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
flex: 1 1 360px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 40px 12px 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.path {
|
||||
color: #aab4be;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -94,19 +93,25 @@ body {
|
||||
}
|
||||
|
||||
.space-info {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
flex: 0 1 auto;
|
||||
max-width: 58vw;
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
color: #c9d3dc;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
line-height: 30px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.space-item {
|
||||
margin-left: 14px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -127,7 +132,6 @@ body {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
|
||||
.power-button {
|
||||
@@ -157,20 +161,30 @@ body {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
#exitBtn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 18px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.tool-left,
|
||||
.tool-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex: 0 0 auto;
|
||||
height: auto;
|
||||
min-height: 84px;
|
||||
justify-content: space-between;
|
||||
padding: 14px 30px;
|
||||
padding: 15px 18px 0;
|
||||
background: #15191e;
|
||||
border-bottom: 1px solid #2d343d;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tool-left {
|
||||
@@ -178,13 +192,30 @@ body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.toolbar .tool-left,
|
||||
.toolbar .tool-right {
|
||||
min-height: 54px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-left > *,
|
||||
.tool-right > * {
|
||||
margin: 0 12px 15px 0;
|
||||
}
|
||||
|
||||
.tool-right > * {
|
||||
margin-right: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.tool-right {
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
height: 54px;
|
||||
min-height: 54px;
|
||||
min-width: 96px;
|
||||
padding: 0 20px;
|
||||
@@ -225,27 +256,69 @@ button:disabled,
|
||||
background: #402326;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border-color: #4f9f7b;
|
||||
background: #285943;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #347358;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
border-color: #59636e;
|
||||
background: #20262d;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content-loading {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(4, 6, 8, .72);
|
||||
}
|
||||
|
||||
.content-loading-text {
|
||||
color: #aab4be;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1034px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
@@ -258,6 +331,7 @@ td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
th {
|
||||
@@ -275,7 +349,7 @@ th {
|
||||
}
|
||||
|
||||
.name-col {
|
||||
min-width: 360px;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
td.name-col {
|
||||
@@ -312,6 +386,7 @@ tr {
|
||||
}
|
||||
|
||||
thead tr {
|
||||
height: 78px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -324,7 +399,12 @@ tr.selected {
|
||||
background: #1d2d35;
|
||||
}
|
||||
|
||||
tr[data-type="parent"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-hit {
|
||||
height: 78px;
|
||||
min-height: 78px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -336,25 +416,19 @@ tr.selected {
|
||||
input[type="checkbox"] {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
width: 100%;
|
||||
height: 78px;
|
||||
min-height: 78px;
|
||||
padding: 16px 18px;
|
||||
border: 0;
|
||||
@@ -362,33 +436,40 @@ 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,
|
||||
.status {
|
||||
.empty {
|
||||
color: #aab4be;
|
||||
padding: 10px 25px;
|
||||
padding: 20px 90px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #aab4be;
|
||||
flex: 0 0 auto;
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
padding: 0 25px;
|
||||
line-height: 46px;
|
||||
border-top: 1px solid #2d343d;
|
||||
background: #15191e;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.version-text {
|
||||
@@ -396,12 +477,122 @@ input[type="checkbox"] {
|
||||
color: #7f8a94;
|
||||
}
|
||||
|
||||
.text-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
background: rgba(4, 6, 8, .9);
|
||||
}
|
||||
|
||||
.text-editor-panel {
|
||||
width: 1200px;
|
||||
height: calc(100vh - 96px);
|
||||
min-width: 0;
|
||||
max-width: calc(100vw - 96px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
border: 1px solid #46515f;
|
||||
border-radius: 10px;
|
||||
background: #15191e;
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, .45);
|
||||
}
|
||||
|
||||
.text-editor-path {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
color: #c9d3dc;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 20px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-editor {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: 18px;
|
||||
border: 1px solid #46515f;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: #0f1215;
|
||||
color: #edf0f2;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.text-editor:focus {
|
||||
border-color: #6fb1ff;
|
||||
}
|
||||
|
||||
.text-editor-status {
|
||||
min-height: 28px;
|
||||
padding-top: 8px;
|
||||
color: #aab4be;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.text-editor-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.text-editor-actions button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.text-editor-actions button + button {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.image-preview-stage {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-preview-panel > button {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.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 +602,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,20 +612,27 @@ 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;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-head.has-amount {
|
||||
@@ -441,7 +640,7 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
.task-amount {
|
||||
min-width: max-content;
|
||||
min-width: 160px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -450,14 +649,22 @@ input[type="checkbox"] {
|
||||
.task-current {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-current-plain {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.task-name > * {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.task-name-op,
|
||||
@@ -467,27 +674,39 @@ 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-elapsed {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
margin-left: 16px;
|
||||
color: #aab4be;
|
||||
}
|
||||
|
||||
.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 +736,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;
|
||||
@@ -533,18 +756,215 @@ input[type="checkbox"] {
|
||||
background: #8a929b;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
@media (max-width: 815px) {
|
||||
.top-left {
|
||||
padding: 0 40px 0 0;
|
||||
}
|
||||
|
||||
th:nth-child(4),
|
||||
td:nth-child(4),
|
||||
th:nth-child(5),
|
||||
td:nth-child(5),
|
||||
th:nth-child(6),
|
||||
td:nth-child(6) {
|
||||
.top-right {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 42px;
|
||||
padding: 0 10px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.top-left {
|
||||
flex: 1 1 auto;
|
||||
margin-top: 0;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-size: 16px;
|
||||
line-height: 42px;
|
||||
}
|
||||
|
||||
#exitBtn {
|
||||
top: 9px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
min-height: 0;
|
||||
padding: 8px 8px 0;
|
||||
}
|
||||
|
||||
.toolbar .tool-left,
|
||||
.toolbar .tool-right {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tool-left,
|
||||
.tool-right {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.tool-right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.tool-left > *,
|
||||
.tool-right > * {
|
||||
margin: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.tool-right > * {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
min-width: 76px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.paste-action {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.paste-name {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 960px;
|
||||
}
|
||||
|
||||
.name-col {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
tr,
|
||||
thead tr,
|
||||
.select-hit,
|
||||
.row-action {
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-basis: 26px;
|
||||
}
|
||||
|
||||
.status {
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
line-height: 34px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-overlay {
|
||||
padding: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-panel {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.task > * + * {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.task-head {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
display: flex;
|
||||
max-width: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-name > * {
|
||||
margin: 0 8px 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-name-subject {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-elapsed {
|
||||
margin: 8px 0 0;
|
||||
text-align: left;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.task-current {
|
||||
font-size: 18px;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.task-meta div,
|
||||
.task-meta div:nth-child(2),
|
||||
.task-meta div:nth-child(3) {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.task-meta div:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.task .danger {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,25 @@ let pendingAbortController = null;
|
||||
let taskRefreshPath = "/";
|
||||
let hoverPaused = false;
|
||||
let hoverResumeTimer = 0;
|
||||
let hoveredRow = null;
|
||||
let loadingPath = null;
|
||||
let directoryLoadingTimer = 0;
|
||||
let taskOverlayTimer = 0;
|
||||
let lastCompletionId = null;
|
||||
let taskPollFailedAlertShown = false;
|
||||
let textEditorPath = null;
|
||||
let textEditorVersion = null;
|
||||
let textEditorOriginal = "";
|
||||
let textEditorBusy = false;
|
||||
let L = {};
|
||||
|
||||
const APP_VERSION = "v0.1";
|
||||
const APP_VERSION = "v0.8b";
|
||||
const LAST_PATH_KEY = "ps5-web-file-mgr:last-path";
|
||||
const LOADING_DISPLAY_DELAY = 250;
|
||||
|
||||
const filesEl = document.getElementById("files");
|
||||
const contentEl = document.getElementById("content");
|
||||
const contentLoadingEl = document.getElementById("contentLoading");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
const pathEl = document.getElementById("path");
|
||||
const spaceInfoEl = document.getElementById("spaceInfo");
|
||||
@@ -32,6 +44,17 @@ const pasteTargetTextEl = document.getElementById("pasteTargetText");
|
||||
const clearClipboardBtn = document.getElementById("clearClipboardBtn");
|
||||
const initLoadingEl = document.getElementById("initLoading");
|
||||
const exitBtn = document.getElementById("exitBtn");
|
||||
const textEditorOverlayEl = document.getElementById("textEditorOverlay");
|
||||
const textEditorPathEl = document.getElementById("textEditorPath");
|
||||
const textEditorEl = document.getElementById("textEditor");
|
||||
const textEditorStatusEl = document.getElementById("textEditorStatus");
|
||||
const textEditorCloseBtn = document.getElementById("textEditorCloseBtn");
|
||||
const textEditorSaveBtn = document.getElementById("textEditorSaveBtn");
|
||||
const newTextBtn = document.getElementById("newTextBtn");
|
||||
const imagePreviewOverlayEl = document.getElementById("imagePreviewOverlay");
|
||||
const imagePreviewNameEl = document.getElementById("imagePreviewName");
|
||||
const imagePreviewEl = document.getElementById("imagePreview");
|
||||
const imagePreviewCloseBtn = document.getElementById("imagePreviewCloseBtn");
|
||||
|
||||
function chooseLanguage() {
|
||||
const langs = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""];
|
||||
@@ -42,6 +65,7 @@ function chooseLanguage() {
|
||||
function loadLanguage() {
|
||||
return new Promise(resolve => {
|
||||
const lang = chooseLanguage();
|
||||
initLoadingEl.textContent = lang === "zh" ? "加载中..." : "Loading...";
|
||||
const script = document.createElement("script");
|
||||
script.src = "/lang-" + lang + ".js";
|
||||
script.onload = () => {
|
||||
@@ -88,17 +112,104 @@ function applyStaticText() {
|
||||
if (initLoadingEl) initLoadingEl.hidden = true;
|
||||
}
|
||||
|
||||
function api(path, params, options) {
|
||||
async function request(path, params, options) {
|
||||
const qs = new URLSearchParams(params || {});
|
||||
const fetchOptions = Object.assign({ method: "POST" }, options || {});
|
||||
return fetch(path + (qs.toString() ? "?" + qs.toString() : ""), fetchOptions)
|
||||
.then(async response => {
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(backendErrorText(data.error_code, data.error_arg, data.error));
|
||||
}
|
||||
return data;
|
||||
});
|
||||
const response = await fetch(path + (qs.toString() ? "?" + qs.toString() : ""), fetchOptions);
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(backendErrorText(data.error_code, data.error_arg, data.error));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function api(path, params, options) {
|
||||
const data = await (await request(path, params, options)).json();
|
||||
if (!data.ok) throw new Error(backendErrorText(data.error_code, data.error_arg, data.error));
|
||||
return data;
|
||||
}
|
||||
|
||||
function decodeFsText(text) {
|
||||
text = String(text || "");
|
||||
let needsDecode = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code > 0xff) return text;
|
||||
if (code >= 0x80) needsDecode = true;
|
||||
}
|
||||
if (!needsDecode || typeof TextDecoder === "undefined" || typeof Uint8Array === "undefined") return text;
|
||||
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff;
|
||||
if (isUtf8Bytes(bytes)) {
|
||||
try {
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
} catch (err) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new TextDecoder("gbk").decode(bytes);
|
||||
} catch (err) {
|
||||
try {
|
||||
return new TextDecoder("gb18030").decode(bytes);
|
||||
} catch (err2) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUtf8Bytes(bytes) {
|
||||
for (let i = 0; i < bytes.length;) {
|
||||
const c = bytes[i];
|
||||
let n = 0;
|
||||
let cp = 0;
|
||||
|
||||
if (c < 0x80) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if ((c & 0xe0) === 0xc0) {
|
||||
n = 2;
|
||||
cp = c & 0x1f;
|
||||
} else if ((c & 0xf0) === 0xe0) {
|
||||
n = 3;
|
||||
cp = c & 0x0f;
|
||||
} else if ((c & 0xf8) === 0xf0) {
|
||||
n = 4;
|
||||
cp = c & 0x07;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (i + n > bytes.length) return false;
|
||||
for (let j = 1; j < n; j++) {
|
||||
const t = bytes[i + j];
|
||||
if ((t & 0xc0) !== 0x80) return false;
|
||||
cp = (cp << 6) | (t & 0x3f);
|
||||
}
|
||||
if ((n === 2 && cp < 0x80) ||
|
||||
(n === 3 && cp < 0x800) ||
|
||||
(n === 4 && (cp < 0x10000 || cp > 0x10ffff)) ||
|
||||
(cp >= 0xd800 && cp <= 0xdfff)) return false;
|
||||
i += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function displayName(item) {
|
||||
return item.displayName || decodeFsText(item.name);
|
||||
}
|
||||
|
||||
function displayPath(path) {
|
||||
return decodeFsText(path);
|
||||
}
|
||||
|
||||
async function fetchText(path, params) {
|
||||
const response = await request(path, params, { method: "GET" });
|
||||
return {
|
||||
text: await response.text(),
|
||||
version: response.headers.get("X-Text-Version") || ""
|
||||
};
|
||||
}
|
||||
|
||||
function formatSize(size, type) {
|
||||
@@ -119,7 +230,16 @@ function formatBytes(size, decimals) {
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
return seconds ? new Date(seconds * 1000).toLocaleString() : "";
|
||||
if (!seconds) return "";
|
||||
const date = new Date(seconds * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const time = [date.getHours(), date.getMinutes(), date.getSeconds()]
|
||||
.map(value => value < 10 ? "0" + value : value).join(":");
|
||||
return document.documentElement.lang === "en" ?
|
||||
month + "/" + day + "/" + year + ", " + time :
|
||||
year + "/" + month + "/" + day + " " + time;
|
||||
}
|
||||
|
||||
function formatSpeed(bytes) {
|
||||
@@ -132,22 +252,30 @@ function formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
if (hours) return hours + "h " + minutes + "m";
|
||||
if (minutes) return minutes + "m " + secs + "s";
|
||||
return secs + "s";
|
||||
if (hours) return t("durationHours", { hours, minutes });
|
||||
if (minutes) return t("durationMinutes", { minutes, seconds: secs });
|
||||
return t("durationSeconds", { seconds: secs });
|
||||
}
|
||||
|
||||
function averageEta(task, done, total) {
|
||||
if (!total || !done || done >= total) return "--";
|
||||
const elapsed = Math.max(1, Number(task.elapsed || 0));
|
||||
const averageSpeed = done / elapsed;
|
||||
return averageSpeed > 0 ? formatDuration((total - done) / averageSpeed) : "--";
|
||||
const eta = Number(task.eta || 0);
|
||||
return eta > 0 ? formatDuration(eta) : "--";
|
||||
}
|
||||
|
||||
function taskElapsed(task) {
|
||||
if (!task || !Object.prototype.hasOwnProperty.call(task, "total_elapsed")) return "--";
|
||||
return formatDuration(Number(task.total_elapsed || 0));
|
||||
}
|
||||
|
||||
function opLabel(op) {
|
||||
return { copy: t("copy"), move: t("move"), delete: t("delete") }[op] || op;
|
||||
}
|
||||
|
||||
function taskOpLabel(op) {
|
||||
return { copy: t("copying"), move: t("moving"), delete: t("deleting") }[op] || op;
|
||||
}
|
||||
|
||||
function stateLabel(state) {
|
||||
return { queued: t("queued"), running: t("running"), done: t("done"), failed: t("failed"), canceled: t("canceled") }[state] || state;
|
||||
}
|
||||
@@ -214,6 +342,21 @@ function setBusy(value) {
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
function showTaskOverlay() {
|
||||
setBusy(true);
|
||||
if (!overlayEl.hidden || taskOverlayTimer) return;
|
||||
taskOverlayTimer = setTimeout(() => {
|
||||
taskOverlayTimer = 0;
|
||||
overlayEl.hidden = false;
|
||||
}, LOADING_DISPLAY_DELAY);
|
||||
}
|
||||
|
||||
function hideTaskOverlay() {
|
||||
clearTimeout(taskOverlayTimer);
|
||||
taskOverlayTimer = 0;
|
||||
overlayEl.hidden = true;
|
||||
}
|
||||
|
||||
function showActionFailed(label, error) {
|
||||
const message = t("actionFailed", { label, error });
|
||||
setStatus(message);
|
||||
@@ -277,6 +420,140 @@ function typeLabel(type) {
|
||||
return type === "d" ? t("dir") : t("file");
|
||||
}
|
||||
|
||||
function isEditableText(item) {
|
||||
return item.type === "-" &&
|
||||
/\.(txt|json|xml|ini|cfg|conf|md|log|lua|js|css|html?|c|h|cpp|hpp|sh|csv|ya?ml)$/i.test(item.name);
|
||||
}
|
||||
|
||||
function isPreviewableImage(item) {
|
||||
return item.type === "-" && /\.(png|jpe?g|gif|bmp|webp)$/i.test(item.name);
|
||||
}
|
||||
|
||||
function openImagePreview(item) {
|
||||
if (busy) return;
|
||||
imagePreviewNameEl.textContent = displayName(item);
|
||||
imagePreviewNameEl.title = displayName(item);
|
||||
imagePreviewEl.src = "/fs?path=" + encodeURIComponent(item.path);
|
||||
imagePreviewOverlayEl.hidden = false;
|
||||
imagePreviewCloseBtn.focus();
|
||||
}
|
||||
|
||||
function closeImagePreview() {
|
||||
imagePreviewOverlayEl.hidden = true;
|
||||
imagePreviewNameEl.textContent = "";
|
||||
imagePreviewNameEl.title = "";
|
||||
imagePreviewEl.removeAttribute("src");
|
||||
}
|
||||
|
||||
function setTextEditorBusy(value, status) {
|
||||
textEditorBusy = value;
|
||||
textEditorEl.disabled = value;
|
||||
textEditorCloseBtn.disabled = value;
|
||||
textEditorSaveBtn.disabled = value;
|
||||
textEditorStatusEl.textContent = status || "";
|
||||
}
|
||||
|
||||
function closeTextEditor() {
|
||||
if (textEditorBusy) return;
|
||||
textEditorOverlayEl.hidden = true;
|
||||
textEditorPath = null;
|
||||
textEditorVersion = null;
|
||||
textEditorOriginal = "";
|
||||
textEditorEl.value = "";
|
||||
textEditorStatusEl.textContent = "";
|
||||
return load(cwd, false, true);
|
||||
}
|
||||
|
||||
function requestCloseTextEditor() {
|
||||
if (textEditorBusy) return;
|
||||
if (textEditorEl.value !== textEditorOriginal &&
|
||||
confirm(t("unsavedSaveConfirm"))) return saveTextEditor();
|
||||
return closeTextEditor();
|
||||
}
|
||||
|
||||
function showTextEditor(item, text, version) {
|
||||
textEditorPath = item.path;
|
||||
textEditorVersion = version;
|
||||
textEditorPathEl.textContent = displayPath(item.path);
|
||||
textEditorPathEl.title = displayPath(item.path);
|
||||
textEditorEl.value = text;
|
||||
textEditorOriginal = textEditorEl.value;
|
||||
textEditorOverlayEl.hidden = false;
|
||||
setTextEditorBusy(false, "");
|
||||
textEditorEl.focus();
|
||||
}
|
||||
|
||||
async function openTextEditor(item) {
|
||||
if (busy || textEditorBusy) return;
|
||||
textEditorPath = item.path;
|
||||
textEditorVersion = null;
|
||||
textEditorOriginal = "";
|
||||
textEditorPathEl.textContent = displayPath(item.path);
|
||||
textEditorPathEl.title = displayPath(item.path);
|
||||
textEditorEl.value = "";
|
||||
textEditorOverlayEl.hidden = false;
|
||||
setTextEditorBusy(true, t("loadingText"));
|
||||
try {
|
||||
const data = await fetchText("/api/text", { path: item.path });
|
||||
showTextEditor(item, data.text, data.version);
|
||||
} catch (err) {
|
||||
setTextEditorBusy(false, "");
|
||||
textEditorOverlayEl.hidden = true;
|
||||
textEditorPath = null;
|
||||
const message = t("openTextFailed", { error: err.message });
|
||||
setStatus(message);
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTextEditor() {
|
||||
if (textEditorBusy || !textEditorPath || !textEditorVersion) return;
|
||||
if (textEditorEl.value === textEditorOriginal) {
|
||||
await closeTextEditor();
|
||||
return;
|
||||
}
|
||||
const path = textEditorPath;
|
||||
setTextEditorBusy(true, t("savingText"));
|
||||
try {
|
||||
await api("/api/text/save", { path, version: textEditorVersion }, {
|
||||
body: textEditorEl.value,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" }
|
||||
});
|
||||
setTextEditorBusy(false, "");
|
||||
await closeTextEditor();
|
||||
setStatus(t("textSaved"));
|
||||
} catch (err) {
|
||||
setTextEditorBusy(false, "");
|
||||
const message = t("saveTextFailed", { error: err.message });
|
||||
setStatus(message);
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function actionNewText() {
|
||||
if (busy || loadingPath) return;
|
||||
const dir = cwd;
|
||||
const input = prompt(t("newTextPrompt"));
|
||||
if (input === null) return;
|
||||
const name = input.trim();
|
||||
if (!name) return;
|
||||
const item = { name, path: pathJoin(dir, name), type: "-" };
|
||||
|
||||
try {
|
||||
setBusy(true);
|
||||
setStatus(t("creatingText"));
|
||||
const data = await api("/api/text/create", { path: dir, name });
|
||||
await load(dir, false, true);
|
||||
setBusy(false);
|
||||
showTextEditor(item, "", data.version);
|
||||
} catch (err) {
|
||||
setBusy(false);
|
||||
const message = t("createTextFailed", { error: err.message });
|
||||
setStatus(message);
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
function resetScrollTop() {
|
||||
const apply = () => {
|
||||
contentEl.scrollTop = 0;
|
||||
@@ -296,33 +573,35 @@ function clipboardTitle() {
|
||||
|
||||
function itemTitle(items) {
|
||||
if (!items.length) return "";
|
||||
return items.length === 1 ? items[0].name : t("selectedItems", { name: items[0].name, count: items.length });
|
||||
return items.length === 1 ? displayName(items[0]) : t("selectedItems", { name: displayName(items[0]), count: items.length });
|
||||
}
|
||||
|
||||
function pathName(path) {
|
||||
const clean = String(path || "").replace(/\/+$/, "");
|
||||
const pos = clean.lastIndexOf("/");
|
||||
return pos >= 0 ? clean.slice(pos + 1) || "/" : clean;
|
||||
return displayPath(pos >= 0 ? clean.slice(pos + 1) || "/" : clean);
|
||||
}
|
||||
|
||||
function taskSubject(task) {
|
||||
const count = Number(task.src_count || 0);
|
||||
return count > 1 ? t("countItems", { count }) : pathName(task.src);
|
||||
}
|
||||
|
||||
function renderTaskName(el, task) {
|
||||
const count = Number(task.src_count || 0);
|
||||
const target = count > 1 ? t("countItems", { count }) : pathName(task.src);
|
||||
const target = taskSubject(task);
|
||||
const op = document.createElement("span");
|
||||
op.className = "task-name-op";
|
||||
op.textContent = opLabel(task.op);
|
||||
op.textContent = taskOpLabel(task.op);
|
||||
const subject = document.createElement("span");
|
||||
subject.className = "task-name-subject";
|
||||
subject.textContent = target;
|
||||
subject.title = target;
|
||||
const state = document.createElement("span");
|
||||
state.className = "task-name-state";
|
||||
state.textContent = stateLabel(task.state);
|
||||
el.append(op, subject, state);
|
||||
appendChildren(el, op, subject);
|
||||
}
|
||||
|
||||
function renderClipboard() {
|
||||
const hasClipboard = Boolean(clipboard && clipboard.items.length);
|
||||
const locked = busy || contentEl.classList.contains("loading");
|
||||
pasteBtn.hidden = !hasClipboard;
|
||||
clearClipboardBtn.hidden = !hasClipboard;
|
||||
if (!hasClipboard) {
|
||||
@@ -338,8 +617,8 @@ function renderClipboard() {
|
||||
pasteVerbEl.textContent = clipboard.op === "move" ? t("move") : t("paste");
|
||||
pasteNameEl.textContent = title;
|
||||
pasteBtn.title = t("pasteTitle", { label: clipboard.op === "move" ? t("move") : t("paste"), name: title, path: cwd });
|
||||
pasteBtn.disabled = busy;
|
||||
clearClipboardBtn.disabled = busy;
|
||||
pasteBtn.disabled = locked;
|
||||
clearClipboardBtn.disabled = locked;
|
||||
}
|
||||
|
||||
function singleSelected() {
|
||||
@@ -349,11 +628,15 @@ function singleSelected() {
|
||||
|
||||
function updateButtons() {
|
||||
const items = selectedEntries();
|
||||
document.getElementById("copyBtn").disabled = busy || items.length === 0;
|
||||
document.getElementById("moveBtn").disabled = busy || items.length === 0;
|
||||
document.getElementById("renameBtn").disabled = busy || items.length !== 1;
|
||||
document.getElementById("deleteBtn").disabled = busy || items.length === 0;
|
||||
document.getElementById("mkdirBtn").disabled = busy;
|
||||
const locked = busy || contentEl.classList.contains("loading");
|
||||
document.getElementById("copyBtn").disabled = locked || items.length === 0;
|
||||
document.getElementById("moveBtn").disabled = locked || items.length === 0;
|
||||
document.getElementById("renameBtn").disabled = locked || items.length !== 1;
|
||||
document.getElementById("deleteBtn").disabled = locked || items.length === 0;
|
||||
document.getElementById("refreshBtn").disabled = locked;
|
||||
document.getElementById("mkdirBtn").disabled = locked;
|
||||
newTextBtn.disabled = locked;
|
||||
selectAllEl.disabled = locked;
|
||||
selectAllEl.checked = entries.length > 0 && items.length === entries.length;
|
||||
selectAllEl.indeterminate = items.length > 0 && items.length < entries.length;
|
||||
renderClipboard();
|
||||
@@ -368,18 +651,17 @@ function focusPath(path) {
|
||||
if (next) next.classList.add("focused");
|
||||
}
|
||||
|
||||
function hoverPath(path) {
|
||||
function hoverRow(row) {
|
||||
if (hoverPaused) return;
|
||||
const old = filesEl.querySelector("tr.hovered");
|
||||
if (old && old.dataset.path === path) return;
|
||||
const next = path ? filesEl.querySelector("tr[data-path=\"" + cssEscape(path) + "\"]") : null;
|
||||
if (old) old.classList.remove("hovered");
|
||||
if (next) next.classList.add("hovered");
|
||||
if (hoveredRow === row) return;
|
||||
clearHoverPath();
|
||||
hoveredRow = row;
|
||||
if (hoveredRow) hoveredRow.classList.add("hovered");
|
||||
}
|
||||
|
||||
function clearHoverPath() {
|
||||
const old = filesEl.querySelector("tr.hovered");
|
||||
if (old) old.classList.remove("hovered");
|
||||
if (hoveredRow) hoveredRow.classList.remove("hovered");
|
||||
hoveredRow = null;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
@@ -387,6 +669,38 @@ function cssEscape(value) {
|
||||
return String(value).replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function appendChildren(parent) {
|
||||
for (let i = 1; i < arguments.length; i++) {
|
||||
parent.appendChild(arguments[i]);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -394,8 +708,10 @@ function togglePath(path, checked) {
|
||||
}
|
||||
|
||||
function render() {
|
||||
hoveredRow = null;
|
||||
filesEl.innerHTML = "";
|
||||
emptyEl.hidden = entries.length !== 0;
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const rows = cwd === "/" ? entries : [{
|
||||
name: "..",
|
||||
@@ -410,8 +726,10 @@ 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);
|
||||
tr.dataset.name = item.name;
|
||||
tr.dataset.type = item.type;
|
||||
tr.className = (!isParent && selected.has(item.path) ? "selected " : "") +
|
||||
(focusedPath === item.path ? "focused" : "");
|
||||
|
||||
const selectTd = document.createElement("td");
|
||||
selectTd.className = "select-cell";
|
||||
@@ -421,8 +739,6 @@ function render() {
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = selected.has(item.path);
|
||||
checkbox.addEventListener("focus", () => focusPath(item.path));
|
||||
checkbox.addEventListener("change", () => togglePath(item.path, checkbox.checked));
|
||||
label.appendChild(checkbox);
|
||||
selectTd.appendChild(label);
|
||||
}
|
||||
@@ -432,26 +748,22 @@ function render() {
|
||||
const nameBtn = document.createElement("button");
|
||||
nameBtn.className = "row-action";
|
||||
nameBtn.type = "button";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "icon";
|
||||
const iconImg = document.createElement("img");
|
||||
iconImg.src = isParent ? "/icon-up.png" : item.type === "d" ? "/icon-folder.png" : "/icon-file.png";
|
||||
iconImg.alt = "";
|
||||
icon.appendChild(iconImg);
|
||||
const nameText = document.createElement("span");
|
||||
nameText.className = "name-text";
|
||||
nameText.textContent = item.name;
|
||||
nameBtn.title = item.path;
|
||||
nameBtn.addEventListener("focus", () => focusPath(item.path));
|
||||
nameBtn.addEventListener("click", () => {
|
||||
focusPath(item.path);
|
||||
if (isParent || item.type === "d") load(item.path);
|
||||
else togglePath(item.path, !selected.has(item.path));
|
||||
});
|
||||
nameBtn.append(icon, nameText);
|
||||
nameText.textContent = displayName(item);
|
||||
nameBtn.title = displayPath(item.path);
|
||||
const iconImg = document.createElement("img");
|
||||
iconImg.className = "icon";
|
||||
iconImg.src = isParent ? "/icon-up.png" : item.type === "d" ? "/icon-folder.png" :
|
||||
isPreviewableImage(item) ? "/icon-image.png" :
|
||||
isEditableText(item) ? "/icon-file.png" : "/icon-generic.png";
|
||||
iconImg.alt = "";
|
||||
nameBtn.appendChild(iconImg);
|
||||
nameBtn.appendChild(nameText);
|
||||
nameTd.appendChild(nameBtn);
|
||||
|
||||
tr.append(
|
||||
appendChildren(
|
||||
tr,
|
||||
selectTd,
|
||||
nameTd,
|
||||
classCell("type-col", isParent ? t("parent") : item.type === "d" ? t("dir") : t("file")),
|
||||
@@ -459,8 +771,9 @@ function render() {
|
||||
classCell("time-col", formatTime(item.mtime)),
|
||||
classCell("mode-col", isParent ? "" : "0" + Number(item.mode).toString(8))
|
||||
);
|
||||
filesEl.appendChild(tr);
|
||||
fragment.appendChild(tr);
|
||||
}
|
||||
filesEl.appendChild(fragment);
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
@@ -486,18 +799,25 @@ function classCell(className, text) {
|
||||
|
||||
async function load(path, scrollTop, force) {
|
||||
const shouldScrollTop = scrollTop !== false;
|
||||
if (busy && !force) return;
|
||||
if ((busy && !force) || loadingPath) return;
|
||||
loadingPath = path;
|
||||
directoryLoadingTimer = setTimeout(() => {
|
||||
contentLoadingEl.style.top = contentEl.scrollTop + "px";
|
||||
contentLoadingEl.hidden = false;
|
||||
contentEl.classList.add("loading");
|
||||
updateButtons();
|
||||
}, LOADING_DISPLAY_DELAY);
|
||||
try {
|
||||
setStatus(t("readDir"));
|
||||
const data = await api("/api/list", { path });
|
||||
cwd = data.path;
|
||||
pathEl.textContent = cwd;
|
||||
pathEl.textContent = displayPath(cwd);
|
||||
savePath(cwd);
|
||||
refreshSpaces();
|
||||
entries = data.entries.sort((a, b) => {
|
||||
if (a.type === "d" && b.type !== "d") return -1;
|
||||
if (a.type !== "d" && b.type === "d") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
return displayName(a).localeCompare(displayName(b));
|
||||
});
|
||||
selected.clear();
|
||||
focusedPath = null;
|
||||
@@ -506,6 +826,12 @@ async function load(path, scrollTop, force) {
|
||||
setStatus(t("totalItems", { count: entries.length }));
|
||||
} catch (err) {
|
||||
setStatus(err.message);
|
||||
} finally {
|
||||
clearTimeout(directoryLoadingTimer);
|
||||
contentEl.classList.remove("loading");
|
||||
contentLoadingEl.hidden = true;
|
||||
loadingPath = null;
|
||||
updateButtons();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +870,7 @@ async function runImmediateAction(label, fn) {
|
||||
}
|
||||
|
||||
function setClipboard(op) {
|
||||
if (busy || selected.size === 0) return;
|
||||
if (busy || loadingPath || selected.size === 0) return;
|
||||
const items = selectedEntries().map(item => ({ path: item.path, name: item.name, type: item.type }));
|
||||
if (items.length === 0) return;
|
||||
clipboard = { op, items };
|
||||
@@ -554,6 +880,7 @@ function setClipboard(op) {
|
||||
}
|
||||
|
||||
function clearClipboard() {
|
||||
if (busy || loadingPath) return;
|
||||
clipboard = null;
|
||||
renderClipboard();
|
||||
setStatus(t("clipboardCleared"));
|
||||
@@ -601,7 +928,7 @@ function validatePasteTarget() {
|
||||
|
||||
function conflictText(names) {
|
||||
if (!names.length) return "";
|
||||
const shown = names.slice(0, 4).join(", ");
|
||||
const shown = names.slice(0, 4).map(decodeFsText).join(", ");
|
||||
return names.length > 4 ? t("selectedItems", { name: shown, count: names.length }) : shown;
|
||||
}
|
||||
|
||||
@@ -618,7 +945,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,33 +956,56 @@ 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 = "";
|
||||
overlayEl.hidden = true;
|
||||
hideTaskOverlay();
|
||||
setStatus(t("cancelPrepare"));
|
||||
setBusy(false);
|
||||
});
|
||||
|
||||
div.append(head, current, progress, cancel);
|
||||
appendChildren(div, head, current, progress, cancel);
|
||||
tasksEl.appendChild(div);
|
||||
}
|
||||
|
||||
function showPendingOverlay(text) {
|
||||
pendingOverlayText = text;
|
||||
overlayEl.hidden = false;
|
||||
renderPendingOverlay(text);
|
||||
setBusy(true);
|
||||
showTaskOverlay();
|
||||
}
|
||||
|
||||
function renderTaskPath(element, path) {
|
||||
path = displayPath(path);
|
||||
element.title = path;
|
||||
element.textContent = path;
|
||||
if (element.scrollWidth <= element.clientWidth) return;
|
||||
|
||||
let low = 0;
|
||||
let high = path.length;
|
||||
while (low < high) {
|
||||
const count = Math.ceil((low + high) / 2);
|
||||
const left = Math.ceil(count / 2);
|
||||
const right = Math.floor(count / 2);
|
||||
element.textContent = path.slice(0, left) + "..." +
|
||||
(right ? path.slice(-right) : "");
|
||||
if (element.scrollWidth <= element.clientWidth) low = count;
|
||||
else high = count - 1;
|
||||
}
|
||||
|
||||
const left = Math.ceil(low / 2);
|
||||
const right = Math.floor(low / 2);
|
||||
element.textContent = path.slice(0, left) + "..." +
|
||||
(right ? path.slice(-right) : "");
|
||||
}
|
||||
|
||||
async function actionPaste() {
|
||||
if (busy || !clipboard || clipboard.items.length === 0) return;
|
||||
if (busy || loadingPath || !clipboard || clipboard.items.length === 0) return;
|
||||
const op = clipboard.op;
|
||||
const label = clipboard.op === "move" ? t("move") : t("copy");
|
||||
const check = validatePasteTarget();
|
||||
@@ -696,7 +1046,7 @@ async function actionPaste() {
|
||||
const aborted = err && err.name === "AbortError";
|
||||
pendingAbortController = null;
|
||||
pendingOverlayText = "";
|
||||
overlayEl.hidden = true;
|
||||
hideTaskOverlay();
|
||||
if (aborted) setStatus(t("cancelPrepare"));
|
||||
else showActionFailed(label, err.message);
|
||||
renderClipboard();
|
||||
@@ -707,16 +1057,18 @@ async function actionPaste() {
|
||||
function renderTasks(tasks) {
|
||||
const active = tasks.find(task => task.state === "queued" || task.state === "running");
|
||||
if (!active && pendingOverlayText) {
|
||||
overlayEl.hidden = false;
|
||||
renderPendingOverlay(pendingOverlayText);
|
||||
setBusy(true);
|
||||
showTaskOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
tasksEl.innerHTML = "";
|
||||
const hasActive = Boolean(active);
|
||||
overlayEl.hidden = !hasActive;
|
||||
setBusy(hasActive);
|
||||
if (hasActive) showTaskOverlay();
|
||||
else {
|
||||
hideTaskOverlay();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
if (!hasActive) return;
|
||||
|
||||
@@ -728,7 +1080,7 @@ function renderTasks(tasks) {
|
||||
const isPreparing = (task.op === "copy" || task.op === "move") &&
|
||||
task.state === "running" && done === 0;
|
||||
const isFinishing = !isDelete && task.state === "running" && total > 0 && done >= total;
|
||||
const pct = total > 0 ? Math.min(100, Math.round(done * 100 / total)) : 0;
|
||||
const pct = total > 0 ? Math.min(100, Math.floor(done * 100 / total)) : 0;
|
||||
const div = document.createElement("div");
|
||||
div.className = "task " + task.state;
|
||||
|
||||
@@ -737,11 +1089,19 @@ function renderTasks(tasks) {
|
||||
const name = document.createElement("div");
|
||||
name.className = "task-name";
|
||||
renderTaskName(name, task);
|
||||
head.append(name);
|
||||
head.appendChild(name);
|
||||
const elapsed = document.createElement("div");
|
||||
elapsed.className = "task-elapsed";
|
||||
elapsed.textContent = t("elapsedLabel") + ": " + taskElapsed(task);
|
||||
head.appendChild(elapsed);
|
||||
|
||||
const current = document.createElement("div");
|
||||
current.className = "task-current";
|
||||
current.textContent = task.error || task.current || task.src;
|
||||
if (task.error) {
|
||||
current.className += " task-current-plain";
|
||||
current.textContent = task.error;
|
||||
}
|
||||
else current.textContent = displayPath(task.current || task.src);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "task-meta";
|
||||
@@ -751,7 +1111,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 +1121,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,16 +1137,31 @@ 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);
|
||||
if (!task.error) renderTaskPath(current, task.current || task.src);
|
||||
}
|
||||
|
||||
async function pollTasks() {
|
||||
try {
|
||||
const data = await api("/api/tasks");
|
||||
taskPollFailedAlertShown = false;
|
||||
const tasks = data.tasks || [];
|
||||
const completion = data.completion;
|
||||
if (lastCompletionId === null) {
|
||||
lastCompletionId = completion ? completion.id : 0;
|
||||
} else if (completion && completion.id !== lastCompletionId) {
|
||||
lastCompletionId = completion.id;
|
||||
alert(t(completion.file_count ? "transferCompleteFiles" : "transferComplete", {
|
||||
label: opLabel(completion.op),
|
||||
name: taskSubject(completion),
|
||||
duration: formatDuration(Number(completion.elapsed || 0)),
|
||||
size: formatBytes(completion.total, true),
|
||||
count: completion.file_count
|
||||
}));
|
||||
}
|
||||
const wasBusy = busy;
|
||||
renderTasks(tasks);
|
||||
let shouldRefresh = false;
|
||||
@@ -813,7 +1188,8 @@ async function pollTasks() {
|
||||
await refreshSpaces();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus(t("tasksPollFailed", { error: err.message }));
|
||||
const message = t("tasksPollFailed", { error: err.message });
|
||||
setStatus(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,20 +1202,22 @@ function actionMove() {
|
||||
}
|
||||
|
||||
function actionRename() {
|
||||
if (busy) return;
|
||||
if (busy || loadingPath) return;
|
||||
const item = singleSelected();
|
||||
if (!item) return;
|
||||
const name = prompt(t("renamePrompt"), item.name);
|
||||
if (!name || name === item.name) return;
|
||||
const name = (prompt(t("renamePrompt"), displayName(item)) || "").trim();
|
||||
if (!name || name === displayName(item)) return;
|
||||
runImmediateAction(t("rename"), () => api("/api/rename", { path: item.path, name }));
|
||||
}
|
||||
|
||||
function actionDelete() {
|
||||
if (busy || selected.size === 0) return;
|
||||
if (busy || loadingPath || selected.size === 0) return;
|
||||
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") }));
|
||||
}
|
||||
|
||||
@@ -854,9 +1232,9 @@ function actionExit() {
|
||||
|
||||
document.getElementById("refreshBtn").addEventListener("click", () => load(cwd, false));
|
||||
document.getElementById("mkdirBtn").addEventListener("click", () => {
|
||||
if (busy) return;
|
||||
if (busy || loadingPath) return;
|
||||
const path = cwd;
|
||||
const name = prompt(t("mkdirPrompt"));
|
||||
const name = (prompt(t("mkdirPrompt")) || "").trim();
|
||||
if (!name) return;
|
||||
runImmediateAction(t("mkdir"), () => api("/api/mkdir", { path, name }));
|
||||
});
|
||||
@@ -867,16 +1245,56 @@ clearClipboardBtn.addEventListener("click", clearClipboard);
|
||||
document.getElementById("renameBtn").addEventListener("click", actionRename);
|
||||
document.getElementById("deleteBtn").addEventListener("click", actionDelete);
|
||||
exitBtn.addEventListener("click", actionExit);
|
||||
textEditorCloseBtn.addEventListener("click", requestCloseTextEditor);
|
||||
textEditorSaveBtn.addEventListener("click", saveTextEditor);
|
||||
newTextBtn.addEventListener("click", actionNewText);
|
||||
imagePreviewCloseBtn.addEventListener("click", closeImagePreview);
|
||||
textEditorOverlayEl.addEventListener("click", event => {
|
||||
if (event.target === textEditorOverlayEl) requestCloseTextEditor();
|
||||
});
|
||||
imagePreviewOverlayEl.addEventListener("click", event => {
|
||||
if (event.target === imagePreviewOverlayEl) closeImagePreview();
|
||||
});
|
||||
contentEl.addEventListener("click", event => {
|
||||
if (!loadingPath) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, true);
|
||||
filesEl.addEventListener("focusin", event => {
|
||||
const row = findRowFromTarget(event.target);
|
||||
if (row) focusPath(row.dataset.path);
|
||||
});
|
||||
filesEl.addEventListener("change", event => {
|
||||
const row = findRowFromTarget(event.target);
|
||||
if (row && event.target.type === "checkbox") togglePath(row.dataset.path, event.target.checked);
|
||||
});
|
||||
filesEl.addEventListener("click", event => {
|
||||
const row = findRowFromTarget(event.target);
|
||||
if (!row) return;
|
||||
let target = event.target;
|
||||
while (target && target !== row && !target.classList.contains("row-action")) target = target.parentNode;
|
||||
if (target === row && row.dataset.type !== "parent") return;
|
||||
const item = {
|
||||
path: row.dataset.path,
|
||||
name: row.dataset.name,
|
||||
type: row.dataset.type,
|
||||
displayName: decodeFsText(row.dataset.name)
|
||||
};
|
||||
focusPath(item.path);
|
||||
if (item.type === "parent" || item.type === "d") load(item.path);
|
||||
else if (isPreviewableImage(item)) openImagePreview(item);
|
||||
else if (isEditableText(item)) openTextEditor(item);
|
||||
else togglePath(item.path, !selected.has(item.path));
|
||||
});
|
||||
selectAllEl.addEventListener("change", () => {
|
||||
selected.clear();
|
||||
if (selectAllEl.checked) entries.forEach(item => selected.add(item.path));
|
||||
render();
|
||||
});
|
||||
contentEl.addEventListener("mousemove", event => {
|
||||
const row = event.target.closest("tr[data-path]");
|
||||
hoverPath(row ? row.dataset.path : null);
|
||||
hoverRow(findRowFromTarget(event.target));
|
||||
});
|
||||
contentEl.addEventListener("mouseleave", () => hoverPath(null));
|
||||
contentEl.addEventListener("mouseleave", () => hoverRow(null));
|
||||
contentEl.addEventListener("scroll", () => {
|
||||
hoverPaused = true;
|
||||
clearHoverPath();
|
||||
@@ -891,7 +1309,7 @@ async function init() {
|
||||
applyStaticText();
|
||||
const savedPath = readSavedPath();
|
||||
cwd = savedPath;
|
||||
pathEl.textContent = cwd;
|
||||
pathEl.textContent = displayPath(cwd);
|
||||
taskRefreshPath = savedPath;
|
||||
refreshSpaces();
|
||||
await pollTasks();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"applicationCategoryType": 65536,
|
||||
"titleId": "FMGR88888",
|
||||
"deeplinkUri": "http://127.0.0.1:8888/",
|
||||
"localizedParameters": {
|
||||
|
||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 290 KiB |
@@ -4,8 +4,6 @@
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
@@ -33,9 +31,16 @@ int sceAppInstUtilAppInstallAll(void *);
|
||||
|
||||
static int
|
||||
install_file(const char *path, const uint8_t *data, size_t size) {
|
||||
struct stat st;
|
||||
FILE *f;
|
||||
|
||||
if(!(f = fopen(path, "w"))) {
|
||||
if(!stat(path, &st)) {
|
||||
return 0;
|
||||
}
|
||||
if(errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
if(!(f = fopen(path, "wb"))) {
|
||||
return -1;
|
||||
}
|
||||
if(fwrite(data, size, 1, f) != 1) {
|
||||
@@ -65,32 +70,13 @@ install_app(const char *title_id, const char *dir) {
|
||||
}
|
||||
|
||||
static int
|
||||
needs_update(const char *path, const uint8_t *expected, size_t expected_size) {
|
||||
needs_install_file(const char *path) {
|
||||
struct stat st;
|
||||
uint8_t *buf;
|
||||
FILE *f;
|
||||
int mismatch;
|
||||
|
||||
if(stat(path, &st) || st.st_size != (off_t)expected_size) {
|
||||
if(stat(path, &st)) {
|
||||
return 1;
|
||||
}
|
||||
if(!(f = fopen(path, "r"))) {
|
||||
return 1;
|
||||
}
|
||||
if(!(buf = malloc(expected_size))) {
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
if(fread(buf, 1, expected_size, f) != expected_size) {
|
||||
free(buf);
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
mismatch = memcmp(buf, expected, expected_size);
|
||||
free(buf);
|
||||
return mismatch != 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
@@ -111,8 +97,7 @@ app_install_if_needed(void) {
|
||||
|
||||
if(stat(base_dir, &st)) {
|
||||
update_needed = 1;
|
||||
} else if(needs_update(param_path, param_json, param_json_size) ||
|
||||
needs_update(icon_path, icon0_png, icon0_png_size)) {
|
||||
} else if(needs_install_file(param_path) || needs_install_file(icon_path)) {
|
||||
update_needed = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
#include <microhttpd.h>
|
||||
|
||||
enum MHD_Result filemgr_api_request(struct MHD_Connection *conn,
|
||||
const char *url);
|
||||
const char *url, const char *method,
|
||||
const char *body, size_t body_size);
|
||||
enum MHD_Result filemgr_fs_request(struct MHD_Connection *conn);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,8 +8,10 @@ typedef struct mime_entry {
|
||||
} mime_entry_t;
|
||||
|
||||
static const mime_entry_t g_mimes[] = {
|
||||
{"bmp", "image/bmp"},
|
||||
{"css", "text/css"},
|
||||
{"elf", "application/octet-stream"},
|
||||
{"gif", "image/gif"},
|
||||
{"html", "text/html"},
|
||||
{"jpeg", "image/jpeg"},
|
||||
{"jpg", "image/jpeg"},
|
||||
@@ -20,6 +22,7 @@ static const mime_entry_t g_mimes[] = {
|
||||
{"png", "image/png"},
|
||||
{"txt", "text/plain"},
|
||||
{"xml", "text/xml"},
|
||||
{"webp", "image/webp"},
|
||||
{"zip", "application/zip"},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,34 @@
|
||||
#include "filemgr.h"
|
||||
#include "websrv.h"
|
||||
|
||||
#define REQUEST_BODY_MAX (1024 * 1024)
|
||||
|
||||
typedef struct request_context {
|
||||
char *body;
|
||||
size_t size;
|
||||
int too_large;
|
||||
} request_context_t;
|
||||
|
||||
static enum MHD_Result
|
||||
websrv_body_too_large(struct MHD_Connection *conn) {
|
||||
static const char json[] =
|
||||
"{\"ok\":false,\"error\":\"request body is too large\","
|
||||
"\"error_code\":\"text_file_too_large\",\"error_arg\":\"\"}";
|
||||
struct MHD_Response *resp =
|
||||
MHD_create_response_from_buffer(sizeof(json) - 1, (void *)json,
|
||||
MHD_RESPMEM_PERSISTENT);
|
||||
enum MHD_Result ret;
|
||||
|
||||
if(!resp) {
|
||||
return MHD_NO;
|
||||
}
|
||||
MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE,
|
||||
"application/json");
|
||||
ret = websrv_queue_response(conn, MHD_HTTP_CONTENT_TOO_LARGE, resp);
|
||||
MHD_destroy_response(resp);
|
||||
return ret;
|
||||
}
|
||||
|
||||
enum MHD_Result
|
||||
websrv_queue_response(struct MHD_Connection *conn, unsigned int status,
|
||||
struct MHD_Response *resp) {
|
||||
@@ -26,10 +54,9 @@ websrv_on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
const char *method, const char *version,
|
||||
const char *upload_data, size_t *upload_data_size,
|
||||
void **con_cls) {
|
||||
request_context_t *ctx = *con_cls;
|
||||
(void)cls;
|
||||
(void)version;
|
||||
(void)upload_data;
|
||||
(void)upload_data_size;
|
||||
|
||||
if(strcmp(method, MHD_HTTP_METHOD_GET) &&
|
||||
strcmp(method, MHD_HTTP_METHOD_POST) &&
|
||||
@@ -37,13 +64,39 @@ websrv_on_request(void *cls, struct MHD_Connection *conn, const char *url,
|
||||
return MHD_NO;
|
||||
}
|
||||
|
||||
if(!*con_cls) {
|
||||
*con_cls = (void *)1;
|
||||
if(!ctx) {
|
||||
if(!(ctx = calloc(1, sizeof(*ctx)))) {
|
||||
return MHD_NO;
|
||||
}
|
||||
*con_cls = ctx;
|
||||
return MHD_YES;
|
||||
}
|
||||
|
||||
if(*upload_data_size) {
|
||||
size_t chunk_size = *upload_data_size;
|
||||
|
||||
if(chunk_size > REQUEST_BODY_MAX - ctx->size) {
|
||||
ctx->too_large = 1;
|
||||
} else if(!ctx->too_large) {
|
||||
char *body = realloc(ctx->body, ctx->size + chunk_size + 1);
|
||||
if(!body) {
|
||||
return MHD_NO;
|
||||
}
|
||||
ctx->body = body;
|
||||
memcpy(ctx->body + ctx->size, upload_data, chunk_size);
|
||||
ctx->size += chunk_size;
|
||||
ctx->body[ctx->size] = 0;
|
||||
}
|
||||
*upload_data_size = 0;
|
||||
return MHD_YES;
|
||||
}
|
||||
|
||||
if(ctx->too_large) {
|
||||
return websrv_body_too_large(conn);
|
||||
}
|
||||
|
||||
if(!strncmp(url, "/api/", 5)) {
|
||||
return filemgr_api_request(conn, url);
|
||||
return filemgr_api_request(conn, url, method, ctx->body, ctx->size);
|
||||
}
|
||||
if(!strcmp(url, "/fs")) {
|
||||
return filemgr_fs_request(conn);
|
||||
@@ -60,6 +113,12 @@ websrv_on_completed(void *cls, struct MHD_Connection *connection,
|
||||
(void)cls;
|
||||
(void)connection;
|
||||
(void)toe;
|
||||
request_context_t *ctx = *con_cls;
|
||||
|
||||
if(ctx) {
|
||||
free(ctx->body);
|
||||
free(ctx);
|
||||
}
|
||||
*con_cls = NULL;
|
||||
}
|
||||
|
||||
|
||||