Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
259d0a6434 | ||
|
|
6f46847df2 | ||
|
|
91375e0537 | ||
|
|
f460178ac8 |
No files matched your search
@@ -1,4 +1,5 @@
|
||||
gen/
|
||||
web-file-mgr.elf
|
||||
*.elf
|
||||
web-file-mgr-linux
|
||||
*.o
|
||||
*.d
|
||||
@@ -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.4
|
||||
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,23 @@ 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>
|
||||
</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 +29,7 @@ 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`.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -69,7 +81,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 +89,7 @@ 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.
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 184 B |
@@ -29,6 +29,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 +49,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 +60,35 @@
|
||||
</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 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>
|
||||
|
||||
@@ -6,10 +6,13 @@ window.WFM_LANG = {
|
||||
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",
|
||||
@@ -58,9 +61,21 @@ window.WFM_LANG = {
|
||||
cancelTaskConfirm: "Cancel current {label} task?",
|
||||
cancelFailed: "Cancel failed: {error}",
|
||||
tasksPollFailed: "Failed to read task status: {error}",
|
||||
transferComplete: "{label} completed\n\nTotal time: {duration}\nTransferred: {size}",
|
||||
transferCompleteFiles: "{label} completed\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",
|
||||
@@ -71,7 +86,7 @@ window.WFM_LANG = {
|
||||
storageExtended: "Extended",
|
||||
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_space_check_failed: "Cannot read target free space: {path}",
|
||||
@@ -81,6 +96,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",
|
||||
|
||||
@@ -6,10 +6,13 @@ window.WFM_LANG = {
|
||||
rename: "重命名",
|
||||
paste: "粘贴",
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
save: "保存",
|
||||
exit: "退出",
|
||||
exiting: "正在退出...",
|
||||
refresh: "刷新",
|
||||
mkdir: "新建目录",
|
||||
newText: "新建文本",
|
||||
file: "文件",
|
||||
dir: "目录",
|
||||
parent: "上级目录",
|
||||
@@ -38,7 +41,7 @@ window.WFM_LANG = {
|
||||
selectedItems: "{name} 等 {count} 项",
|
||||
countItems: "{count} 项",
|
||||
totalItems: "共 {count} 项",
|
||||
readDir: "读取目录...",
|
||||
readDir: "读取目录中...",
|
||||
actionBusy: "{label}...",
|
||||
taskCreated: "{label}任务已创建",
|
||||
actionDone: "{label}完成",
|
||||
@@ -58,9 +61,21 @@ window.WFM_LANG = {
|
||||
cancelTaskConfirm: "取消当前{label}任务?",
|
||||
cancelFailed: "取消失败: {error}",
|
||||
tasksPollFailed: "任务状态读取失败: {error}",
|
||||
transferComplete: "{label}完成\n\n总耗时:{duration}\n传输大小:{size}",
|
||||
transferCompleteFiles: "{label}完成\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: "到当前目录",
|
||||
@@ -71,7 +86,7 @@ window.WFM_LANG = {
|
||||
storageExtended: "扩展存储",
|
||||
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_space_check_failed: "无法读取目标剩余空间: {path}",
|
||||
@@ -81,6 +96,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,14 +38,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 +65,6 @@ body {
|
||||
.space-info {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
flex: 0 1 auto;
|
||||
max-width: 58vw;
|
||||
color: #c9d3dc;
|
||||
@@ -107,6 +75,7 @@ body {
|
||||
}
|
||||
|
||||
.space-item {
|
||||
margin-left: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -128,6 +97,7 @@ body {
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
flex: 0 0 24px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.power-button {
|
||||
@@ -162,7 +132,6 @@ body {
|
||||
.tool-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -178,6 +147,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;
|
||||
@@ -225,27 +204,68 @@ 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;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
@@ -324,6 +344,10 @@ tr.selected {
|
||||
background: #1d2d35;
|
||||
}
|
||||
|
||||
tr[data-type="parent"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-hit {
|
||||
min-height: 78px;
|
||||
width: 100%;
|
||||
@@ -336,20 +360,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -396,12 +419,116 @@ 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);
|
||||
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 {
|
||||
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 {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
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 +538,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 +548,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 +574,7 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
.task-amount {
|
||||
min-width: max-content;
|
||||
min-width: 160px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -456,10 +589,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 +603,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 +657,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;
|
||||
|
||||
@@ -10,13 +10,23 @@ let pendingAbortController = null;
|
||||
let taskRefreshPath = "/";
|
||||
let hoverPaused = false;
|
||||
let hoverResumeTimer = 0;
|
||||
let hoveredRow = null;
|
||||
let loadingPath = null;
|
||||
let directoryLoadingTimer = 0;
|
||||
let lastCompletionId = null;
|
||||
let textEditorPath = null;
|
||||
let textEditorVersion = null;
|
||||
let textEditorOriginal = "";
|
||||
let textEditorBusy = false;
|
||||
let L = {};
|
||||
|
||||
const APP_VERSION = "v0.1";
|
||||
const APP_VERSION = "v0.4";
|
||||
const LAST_PATH_KEY = "ps5-web-file-mgr:last-path";
|
||||
const DIRECTORY_LOADING_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 +42,16 @@ 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 imagePreviewEl = document.getElementById("imagePreview");
|
||||
const imagePreviewCloseBtn = document.getElementById("imagePreviewCloseBtn");
|
||||
|
||||
function chooseLanguage() {
|
||||
const langs = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""];
|
||||
@@ -42,6 +62,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 +109,29 @@ 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;
|
||||
}
|
||||
|
||||
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 +152,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) {
|
||||
@@ -277,6 +319,136 @@ 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;
|
||||
imagePreviewEl.src = "/fs?path=" + encodeURIComponent(item.path);
|
||||
imagePreviewOverlayEl.hidden = false;
|
||||
imagePreviewCloseBtn.focus();
|
||||
}
|
||||
|
||||
function closeImagePreview() {
|
||||
imagePreviewOverlayEl.hidden = true;
|
||||
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 = item.path;
|
||||
textEditorPathEl.title = 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 = item.path;
|
||||
textEditorPathEl.title = 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;
|
||||
@@ -318,11 +490,12 @@ 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() {
|
||||
const hasClipboard = Boolean(clipboard && clipboard.items.length);
|
||||
const locked = busy || contentEl.classList.contains("loading");
|
||||
pasteBtn.hidden = !hasClipboard;
|
||||
clearClipboardBtn.hidden = !hasClipboard;
|
||||
if (!hasClipboard) {
|
||||
@@ -338,8 +511,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 +522,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 +545,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 +563,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 +602,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 +620,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 +633,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 +642,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);
|
||||
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 +665,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,7 +693,14 @@ 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();
|
||||
}, DIRECTORY_LOADING_DELAY);
|
||||
try {
|
||||
setStatus(t("readDir"));
|
||||
const data = await api("/api/list", { path });
|
||||
@@ -506,6 +720,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 +764,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 +774,7 @@ function setClipboard(op) {
|
||||
}
|
||||
|
||||
function clearClipboard() {
|
||||
if (busy || loadingPath) return;
|
||||
clipboard = null;
|
||||
renderClipboard();
|
||||
setStatus(t("clipboardCleared"));
|
||||
@@ -618,7 +839,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 +850,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 +864,7 @@ function renderPendingOverlay(text) {
|
||||
setBusy(false);
|
||||
});
|
||||
|
||||
div.append(head, current, progress, cancel);
|
||||
appendChildren(div, head, current, progress, cancel);
|
||||
tasksEl.appendChild(div);
|
||||
}
|
||||
|
||||
@@ -655,7 +876,7 @@ function showPendingOverlay(text) {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -737,7 +958,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 +972,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 +982,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 +998,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);
|
||||
}
|
||||
|
||||
@@ -787,6 +1008,18 @@ async function pollTasks() {
|
||||
try {
|
||||
const data = await api("/api/tasks");
|
||||
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),
|
||||
duration: formatDuration(Number(completion.elapsed || 0)),
|
||||
size: formatBytes(completion.total, true),
|
||||
count: completion.file_count
|
||||
}));
|
||||
}
|
||||
const wasBusy = busy;
|
||||
renderTasks(tasks);
|
||||
let shouldRefresh = false;
|
||||
@@ -826,20 +1059,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);
|
||||
const name = (prompt(t("renamePrompt"), item.name) || "").trim();
|
||||
if (!name || name === item.name) 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 +1089,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 +1102,51 @@ 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 };
|
||||
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();
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
|
||||