mirror of
https://github.com/drakmor/ShadowMountPlus.git
synced 2026-10-21 22:22:28 -05:00
Expand web API and add embedded library interface
This commit is contained in:
1 parent
f81eb60e94
commit
5b7ab6ef8c
48 files changed
+1599
-65
No files matched your search
@@ -22,7 +22,7 @@ PS5_SCE_STUBS_DIR ?= $(PS5_PAYLOAD_SDK)/src/sce_stubs
|
||||
KERNEL_SYS_STUB_SO := src/libkernel_sys_ext.so
|
||||
KERNEL_SYS_STUB_SRCS := $(PS5_SCE_STUBS_DIR)/libkernel_sys.c src/libkernel_sys_ext.c
|
||||
|
||||
ASSET_SRCS := src/notify_icon_asset.c src/config_ini_example_asset.c
|
||||
ASSET_SRCS := src/notify_icon_asset.c src/config_ini_example_asset.c src/web_index_asset.c
|
||||
SRCS := src/main.c $(wildcard src/sm_*.c) $(ASSET_SRCS)
|
||||
ASM_SRCS := src/sm_shellcore_bridge.S
|
||||
OBJS := $(SRCS:.c=.o) $(ASM_SRCS:.S=.o)
|
||||
@@ -40,7 +40,7 @@ usb-info.elf: tools/usb_info.c
|
||||
shadowmountplus.elf: $(OBJS) $(KERNEL_SYS_STUB_SO)
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS) $(KERNEL_SYS_STUB_SO) $(LIBS)
|
||||
$(PS5_PAYLOAD_SDK)/bin/prospero-strip --strip-all $@
|
||||
rm -f src/notify_icon_asset.c src/config_ini_example_asset.c
|
||||
rm -f src/notify_icon_asset.c src/config_ini_example_asset.c src/web_index_asset.c
|
||||
|
||||
$(KERNEL_SYS_STUB_SO): $(KERNEL_SYS_STUB_SRCS)
|
||||
test -f "$(PS5_SCE_STUBS_DIR)/libkernel_sys.c"
|
||||
@@ -52,6 +52,9 @@ src/notify_icon_asset.c: smp_icon.png
|
||||
src/config_ini_example_asset.c: config.ini.example
|
||||
xxd -i $< > $@
|
||||
|
||||
src/web_index_asset.c: web/index.html
|
||||
xxd -i $< > $@
|
||||
|
||||
src/sm_api_service.o src/sm_icon_thumb.o: CFLAGS += $(HOMEBREW_CFLAGS)
|
||||
src/main.o src/sm_image_index.o: CFLAGS += -DSHADOWMOUNT_BUILD_TIME=\"$(BUILD_TIME)\"
|
||||
src/main.o src/sm_image_index.o: FORCE
|
||||
@@ -69,4 +72,4 @@ src/%.o: src/%.S
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
|
||||
clean:
|
||||
rm -f shadowmountplus.elf usb-info.elf api-test.elf kill.elf src/*.o $(KERNEL_SYS_STUB_SO) src/notify_icon_asset.c src/config_ini_example_asset.c
|
||||
rm -f shadowmountplus.elf usb-info.elf api-test.elf kill.elf src/*.o $(KERNEL_SYS_STUB_SO) src/notify_icon_asset.c src/config_ini_example_asset.c src/web_index_asset.c
|
||||
@@ -31,6 +31,7 @@
|
||||
# The loopback default accepts connections only from software running on the PS5.
|
||||
# Use 0.0.0.0 explicitly if remote devices on the network must connect.
|
||||
# Defaults: 127.0.0.1:10101
|
||||
# api_enabled=1
|
||||
# api_bind_address=127.0.0.1
|
||||
# api_port=10101
|
||||
|
||||
|
||||
+45
-9
@@ -7,7 +7,15 @@ ShadowMount exposes its public API over HTTP/TCP. The default endpoint is
|
||||
`127.0.0.1:10101`, so only software running on the PS5 can connect. Set
|
||||
`api_bind_address=0.0.0.0` to allow clients on the network, or select a
|
||||
specific PS5 IPv4 address. `api_port` accepts values from 1 through 65535.
|
||||
Both settings are applied on runtime config reload.
|
||||
These settings are applied on runtime config reload. `api_enabled` defaults to
|
||||
`1`; setting it to `0` stops the listener and joins the libmicrohttpd service
|
||||
and worker threads. Re-enabling it restarts the HTTP service without restarting
|
||||
the payload. An already-running storage job is allowed to finish, but no new
|
||||
jobs can be submitted while the API is disabled.
|
||||
|
||||
`/data/shadowmount/index.html` overrides the web interface bundled into the
|
||||
payload. If the file is absent, the same `web/index.html` built into the ELF is
|
||||
served automatically, so enabling the API always provides a landing page.
|
||||
|
||||
The API has no authentication. Bind it to a network-facing address only on a
|
||||
trusted network.
|
||||
@@ -15,9 +23,9 @@ trusted network.
|
||||
The internal `/system_tmp/shadowmount.sock` Unix socket remains private to the
|
||||
SceShellCore bridge and must not be used by third-party applications.
|
||||
|
||||
The HTTP service uses libmicrohttpd with four fixed polling workers. It accepts
|
||||
up to 16 concurrent connections, uses a listen backlog of 32 and closes idle
|
||||
connections after 5 seconds. Slow clients therefore do not serialize unrelated
|
||||
The HTTP service uses libmicrohttpd with eight fixed polling workers. It accepts
|
||||
up to 32 concurrent connections, uses a listen backlog of 32 and closes idle
|
||||
connections after 15 seconds. Slow clients therefore do not serialize unrelated
|
||||
requests or create an unbounded number of threads. If daemon startup fails, the
|
||||
service retries with an interruptible 250 ms to 5 s backoff. Suspend, shutdown
|
||||
and config reload stop the daemon cleanly; resume recreates it.
|
||||
@@ -38,14 +46,18 @@ device.
|
||||
|
||||
| Route | Request | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/` | GET | Serve `/data/shadowmount/index.html` when it exists |
|
||||
| `/` | GET | Serve `/data/shadowmount/index.html`, or the embedded page when it is absent |
|
||||
| `/api/v1/version` | `{}` | API and ShadowMount versions plus capabilities |
|
||||
| `/api/v1/storage` | `{}` | Mounted storage filesystems with total, free and available space |
|
||||
| `/api/v1/images` | `{}` | Complete image snapshot |
|
||||
| `/api/v1/scan` | `{"reset_attempts":false}` | Queue an immediate full rescan; optionally reset title and image retry counters |
|
||||
| `/api/v1/manual/list` | `{}` | List normalized source paths from `manual.lst` |
|
||||
| `/api/v1/manual/add` | `{"path":"/mnt/usb0/games/PPSA12345"}` | Idempotently add a source to `manual.lst` |
|
||||
| `/api/v1/manual/list` | `{}` | List direct paths to individual game folders or images from `manual.lst` |
|
||||
| `/api/v1/manual/add` | `{"path":"/mnt/usb0/games/PPSA12345"}` | Idempotently add one game source to `manual.lst` |
|
||||
| `/api/v1/manual/remove` | `{"path":"/mnt/usb0/games/PPSA12345"}` | Remove matching source lines from `manual.lst` |
|
||||
| `/api/v1/settings` | `{}` | Read web-managed runtime settings and scan paths |
|
||||
| `/api/v1/settings/update` | `{"debug":true,"quiet_mode":false,"update_emulators":true,"allow_lan_access":true,"fan_target_temperature":0,"scan_paths":["/mnt/usb0/games"]}` | Atomically update the web-managed part of `config.ini` |
|
||||
| `/api/v1/debug-log` | `{"max_bytes":131072}` | Read a bounded tail of `debug.log` for the web log dialog |
|
||||
| `/api/v1/kernel-log` | `{"max_bytes":131072}` | Read a bounded tail of the SDK kernel-log stream used by crash detection |
|
||||
| `/api/v1/games` | `{"include_size":false}` | Detailed game snapshot; optional physical source-size calculation |
|
||||
| `/api/v1/games/info` | `{"title_id":"PPSA12345"}` | Detailed app.db and source information for one game; size is always calculated |
|
||||
| `/api/v1/games/icon?title_id=PPSA12345[&size=thumb]` | GET | Stream the full PNG or a cached 128x128 thumbnail |
|
||||
@@ -54,6 +66,7 @@ device.
|
||||
| `/api/v1/games/uninstall` | `{"title_id":"PPSA12345"}` | Request uninstallation through AppInstUtil |
|
||||
| `/api/v1/games/move` | `{"title_id":"PPSA12345","destination_dir":"/mnt/usb1/games"}` | Start an asynchronous move job |
|
||||
| `/api/v1/games/copy` | `{"title_id":"PPSA12345","destination_dir":"/mnt/usb1/games"}` | Start an asynchronous copy job |
|
||||
| `/api/v1/games/unpack` | `{"title_id":"PPSA12345","destination_dir":"/mnt/usb1/games","delete_source":false}` | Mount an image read-only and asynchronously copy the game into a folder |
|
||||
| `/api/v1/games/storage/status` | `{"job_id":1}` | Get current or last storage job status; `job_id` is optional |
|
||||
| `/api/v1/games/storage/cancel` | `{"job_id":1}` | Request cancellation while the active job is still cancellable |
|
||||
| `/api/v1/games/delete` | `{"title_id":"PPSA12345","confirm":true}` | Start asynchronous permanent deletion of the physical source |
|
||||
@@ -97,7 +110,7 @@ A successful version response has this shape:
|
||||
"status": 0,
|
||||
"api_version": 1,
|
||||
"shadowmount_version": "1.7",
|
||||
"capabilities": ["web_ui", "storage_space", "list_images", "list_games", "game_info", "game_icon", "mount_game", "unmount_game", "uninstall_game", "move_game_source", "copy_game_source", "delete_game_source", "storage_job_status", "storage_job_cancel", "list_manual_sources", "add_manual_source", "remove_manual_source", "rescan"]
|
||||
"capabilities": ["web_ui", "storage_space", "list_images", "list_games", "game_info", "game_icon", "mount_game", "unmount_game", "uninstall_game", "move_game_source", "copy_game_source", "delete_game_source", "unpack_game_image", "storage_job_status", "storage_job_cancel", "list_manual_sources", "add_manual_source", "remove_manual_source", "manage_settings", "read_debug_log", "read_kernel_log", "rescan"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -144,6 +157,12 @@ ShellCore mutation gates remain held for the whole job and a rescan is queued
|
||||
after success, cancellation or failure. `move` uses `rename()` on one
|
||||
filesystem and exact copy-plus-delete across filesystems. A backing image
|
||||
shared by several titles is handled as one physical source.
|
||||
`unpack` uses the same worker and progress endpoint. It mounts the requested
|
||||
image read-only, copies the mounted game tree to `<destination_dir>/<TITLE_ID>`,
|
||||
and always releases the runtime mount afterward. `delete_source=true` removes
|
||||
the image only after a complete copy and successful unmount; this irreversible
|
||||
final phase is non-cancellable. Source deletion is rejected when one image is
|
||||
shared by several titles.
|
||||
Changing the API bind address or port restarts only the HTTP listener; an
|
||||
active storage job continues and remains available through the new listener.
|
||||
|
||||
@@ -170,7 +189,8 @@ install/remount counters plus all path-owned image-mount counters. If several
|
||||
scan requests merge while one is pending, one `true` request keeps the reset
|
||||
enabled for that scan.
|
||||
|
||||
Manual source updates atomically replace `manual.lst` and preserve comments and
|
||||
Manual entries are direct paths to individual game directories or image files;
|
||||
they are not recursive scan roots. Updates atomically replace `manual.lst` and preserve comments and
|
||||
unrelated lines. Adding an existing normalized path and removing a missing path
|
||||
both succeed with `changed: false`. A real change queues a scan. Removing a
|
||||
manual source does not uninstall its game; use the uninstall route separately
|
||||
@@ -179,6 +199,22 @@ The list route returns `{status, count, paths}`. Paths use the same trimming and
|
||||
trailing-slash normalization as the scanner; comments and empty lines are not
|
||||
included. A missing `manual.lst` is reported as an empty list.
|
||||
|
||||
The settings update owns only `debug`, `quiet_mode`, `update_emulators`,
|
||||
`api_bind_address`, `fan_target_temperature`, and repeated `scanpath` keys. The
|
||||
`allow_lan_access` field maps to `0.0.0.0` when enabled and `127.0.0.1` when
|
||||
disabled. It atomically rewrites those keys while preserving every unrelated
|
||||
`config.ini` line and comment, then wakes the scanner so its existing
|
||||
runtime-reload path applies the values and rebuilds scan watches. `api_enabled`
|
||||
is deliberately config-only, so the web interface cannot disable the service it
|
||||
needs to restore access. A fan target of `0` means system control; explicit
|
||||
values must be 50 through 91 degrees Celsius. Unlike `manual.lst`, `scan_paths`
|
||||
are recursive library roots. The settings response contains only explicit
|
||||
custom roots, never compile-time defaults or internal image-mount roots. Saving
|
||||
an empty array removes the custom override and restores compile-time defaults.
|
||||
The debug-log route returns at most 256 KiB and never modifies or rotates the log.
|
||||
The kernel-log route reads the same `sceKernelDebugGetSdkLogText` snapshot used
|
||||
by `kstuff_crash_detection`.
|
||||
|
||||
Uninstall returns success after `sceAppInstUtilAppUnInstall` accepts the
|
||||
request; the on-disk removal itself is asynchronous. It returns `EBUSY` while a
|
||||
game is active, another title owns a prepared runtime mount, or batch install
|
||||
|
||||
+281
-8
@@ -196,8 +196,8 @@ paths:
|
||||
$ref: "#/components/responses/Preflight"
|
||||
post:
|
||||
operationId: listManualSources
|
||||
summary: List normalized source paths from manual.lst
|
||||
description: Comments and empty lines are omitted. A missing file is returned as an empty list.
|
||||
summary: List direct game source paths from manual.lst
|
||||
description: Entries identify individual game folders or images, not recursive scan roots. Comments and empty lines are omitted.
|
||||
tags: [Manual sources]
|
||||
requestBody:
|
||||
$ref: "#/components/requestBodies/EmptyRequest"
|
||||
@@ -377,6 +377,136 @@ paths:
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/v1/settings:
|
||||
options:
|
||||
operationId: preflightGetSettings
|
||||
summary: CORS preflight
|
||||
tags: [Settings]
|
||||
responses:
|
||||
"204":
|
||||
$ref: "#/components/responses/Preflight"
|
||||
post:
|
||||
operationId: getSettings
|
||||
summary: Read web-managed settings and recursive scan roots
|
||||
tags: [Settings]
|
||||
requestBody:
|
||||
$ref: "#/components/requestBodies/EmptyRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Current runtime settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SettingsResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/v1/settings/update:
|
||||
options:
|
||||
operationId: preflightUpdateSettings
|
||||
summary: CORS preflight
|
||||
tags: [Settings]
|
||||
responses:
|
||||
"204":
|
||||
$ref: "#/components/responses/Preflight"
|
||||
post:
|
||||
operationId: updateSettings
|
||||
summary: Atomically update web-managed config.ini keys
|
||||
description: Preserves unrelated keys and comments, then queues scanner-owned runtime reload.
|
||||
tags: [Settings]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SettingsUpdateRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Settings saved and reload queued
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/v1/debug-log:
|
||||
options:
|
||||
operationId: preflightGetDebugLog
|
||||
summary: CORS preflight
|
||||
tags: [Settings]
|
||||
responses:
|
||||
"204":
|
||||
$ref: "#/components/responses/Preflight"
|
||||
post:
|
||||
operationId: getDebugLog
|
||||
summary: Read a bounded tail of debug.log
|
||||
tags: [Settings]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
max_bytes:
|
||||
type: integer
|
||||
minimum: 4096
|
||||
maximum: 262144
|
||||
default: 131072
|
||||
responses:
|
||||
"200":
|
||||
description: Debug log tail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DebugLogResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/v1/kernel-log:
|
||||
options:
|
||||
operationId: preflightGetKernelLog
|
||||
summary: CORS preflight
|
||||
tags: [Settings]
|
||||
responses:
|
||||
"204":
|
||||
$ref: "#/components/responses/Preflight"
|
||||
post:
|
||||
operationId: getKernelLog
|
||||
summary: Read recent events from the SDK log stream used by crash detection
|
||||
description: Uses the sceKernelDebugGetSdkLogText snapshot shared with crash detection.
|
||||
tags: [Settings]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
max_bytes:
|
||||
type: integer
|
||||
minimum: 4096
|
||||
maximum: 262144
|
||||
default: 131072
|
||||
responses:
|
||||
"200":
|
||||
description: SDK kernel-log tail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/KernelLogResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/v1/games/move:
|
||||
options:
|
||||
operationId: preflightMoveGameSource
|
||||
@@ -437,6 +567,42 @@ paths:
|
||||
"507":
|
||||
$ref: "#/components/responses/InsufficientStorage"
|
||||
|
||||
/api/v1/games/unpack:
|
||||
options:
|
||||
operationId: preflightUnpackGameImage
|
||||
summary: CORS preflight
|
||||
tags: [Storage]
|
||||
responses:
|
||||
"204":
|
||||
$ref: "#/components/responses/Preflight"
|
||||
post:
|
||||
operationId: unpackGameImage
|
||||
summary: Mount an image read-only and copy its game tree into a folder
|
||||
tags: [Storage]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StorageUnpackRequest"
|
||||
responses:
|
||||
"202":
|
||||
$ref: "#/components/responses/StorageJobAccepted"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/Busy"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplemented"
|
||||
"507":
|
||||
$ref: "#/components/responses/InsufficientStorage"
|
||||
|
||||
/api/v1/games/storage/status:
|
||||
options:
|
||||
operationId: preflightStorageJobStatus
|
||||
@@ -780,7 +946,7 @@ components:
|
||||
$ref: "#/components/schemas/GameInfoResponse"
|
||||
|
||||
StorageJobAccepted:
|
||||
description: Copy, move or delete job accepted for asynchronous execution
|
||||
description: Copy, move, delete or unpack job accepted for asynchronous execution
|
||||
headers:
|
||||
Access-Control-Allow-Origin:
|
||||
$ref: "#/components/headers/AllowOrigin"
|
||||
@@ -790,7 +956,7 @@ components:
|
||||
$ref: "#/components/schemas/StorageJobResponse"
|
||||
|
||||
StorageJobStatusSuccess:
|
||||
description: Current or last copy, move or delete job status
|
||||
description: Current or last copy, move, delete or unpack job status
|
||||
headers:
|
||||
Access-Control-Allow-Origin:
|
||||
$ref: "#/components/headers/AllowOrigin"
|
||||
@@ -1034,6 +1200,22 @@ components:
|
||||
type: boolean
|
||||
const: true
|
||||
|
||||
StorageUnpackRequest:
|
||||
type: object
|
||||
required: [title_id, destination_dir]
|
||||
properties:
|
||||
title_id:
|
||||
$ref: "#/components/schemas/TitleId"
|
||||
destination_dir:
|
||||
type: string
|
||||
pattern: "^/.+"
|
||||
maxLength: 1023
|
||||
description: Existing directory under a configured scan root. A TITLE_ID child directory is created.
|
||||
delete_source:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Delete the backing image only after a complete copy and successful unmount.
|
||||
|
||||
StorageJobStatusRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1052,6 +1234,92 @@ components:
|
||||
format: int64
|
||||
minimum: 1
|
||||
|
||||
SettingsUpdateRequest:
|
||||
type: object
|
||||
required: [debug, quiet_mode, update_emulators, allow_lan_access, fan_target_temperature, scan_paths]
|
||||
properties:
|
||||
debug:
|
||||
type: boolean
|
||||
quiet_mode:
|
||||
type: boolean
|
||||
update_emulators:
|
||||
type: boolean
|
||||
allow_lan_access:
|
||||
type: boolean
|
||||
description: Bind to 0.0.0.0 when true or 127.0.0.1 when false.
|
||||
fan_target_temperature:
|
||||
type: integer
|
||||
description: Zero selects system control; otherwise 50 through 91 degrees Celsius.
|
||||
oneOf:
|
||||
- const: 0
|
||||
- minimum: 50
|
||||
maximum: 91
|
||||
scan_paths:
|
||||
type: array
|
||||
description: Explicit custom roots only. An empty array restores compile-time defaults.
|
||||
maxItems: 256
|
||||
items:
|
||||
type: string
|
||||
pattern: "^/.+"
|
||||
maxLength: 1023
|
||||
|
||||
SettingsResponse:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/SettingsUpdateRequest"
|
||||
- type: object
|
||||
required: [status, api_enabled, scan_path_count]
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
const: 0
|
||||
api_enabled:
|
||||
type: boolean
|
||||
description: Whether the HTTP API is enabled in config.ini.
|
||||
scan_path_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
DebugLogResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [status, file_size, returned_bytes, truncated, content]
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
const: 0
|
||||
file_size:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
returned_bytes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 262144
|
||||
truncated:
|
||||
type: boolean
|
||||
content:
|
||||
type: string
|
||||
|
||||
KernelLogResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [status, total_bytes, returned_bytes, truncated, content]
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
const: 0
|
||||
total_bytes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
returned_bytes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 262144
|
||||
truncated:
|
||||
type: boolean
|
||||
content:
|
||||
type: string
|
||||
|
||||
VersionResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -1069,8 +1337,8 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [web_ui, storage_space, list_images, list_games, game_info, game_icon, mount_game, unmount_game, uninstall_game, move_game_source, copy_game_source, delete_game_source, storage_job_status, storage_job_cancel, list_manual_sources, add_manual_source, remove_manual_source, rescan]
|
||||
example: [web_ui, storage_space, list_images, list_games, game_info, game_icon, mount_game, unmount_game, uninstall_game, move_game_source, copy_game_source, delete_game_source, storage_job_status, storage_job_cancel, list_manual_sources, add_manual_source, remove_manual_source, rescan]
|
||||
enum: [web_ui, storage_space, list_images, list_games, game_info, game_icon, mount_game, unmount_game, uninstall_game, move_game_source, copy_game_source, delete_game_source, unpack_game_image, storage_job_status, storage_job_cancel, list_manual_sources, add_manual_source, remove_manual_source, manage_settings, read_debug_log, read_kernel_log, rescan]
|
||||
example: [web_ui, storage_space, list_images, list_games, game_info, game_icon, mount_game, unmount_game, uninstall_game, move_game_source, copy_game_source, delete_game_source, unpack_game_image, storage_job_status, storage_job_cancel, list_manual_sources, add_manual_source, remove_manual_source, manage_settings, read_debug_log, read_kernel_log, rescan]
|
||||
|
||||
StorageMount:
|
||||
type: object
|
||||
@@ -1287,7 +1555,7 @@ components:
|
||||
StorageJobResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [status, job_id, operation, state, active, cancellable, cancel_requested, title_id, source_type, source, destination, total_bytes, processed_bytes, total_files, processed_files, progress_percent, speed_bytes_per_second, elapsed_ms, affected_titles, result_status, result_error, scan_queued]
|
||||
required: [status, job_id, operation, state, active, cancellable, cancel_requested, title_id, source_type, source, runtime_source, destination, delete_source, total_bytes, processed_bytes, total_files, processed_files, progress_percent, speed_bytes_per_second, elapsed_ms, affected_titles, result_status, result_error, scan_queued]
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
@@ -1298,7 +1566,7 @@ components:
|
||||
minimum: 0
|
||||
operation:
|
||||
type: string
|
||||
enum: ["", move, copy, delete]
|
||||
enum: ["", move, copy, delete, unpack]
|
||||
state:
|
||||
type: string
|
||||
enum: [idle, preparing, measuring, transferring, deleting, finalizing, completed, failed, cancelled]
|
||||
@@ -1315,9 +1583,14 @@ components:
|
||||
enum: ["", folder, image]
|
||||
source:
|
||||
type: string
|
||||
runtime_source:
|
||||
type: string
|
||||
destination:
|
||||
type: string
|
||||
description: Empty for delete jobs.
|
||||
delete_source:
|
||||
type: boolean
|
||||
description: True only for unpack jobs that will remove the image after successful extraction.
|
||||
total_bytes:
|
||||
type: integer
|
||||
format: int64
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_ar_sa[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "الانتقال إلى إعدادات التصحيح",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor والمجتمع",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor والمجتمع\nواجهة الويب: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "شادوماونت+: لم يعد الفحص التكراري مدعوماً، وسيتم استخدام عمق الفحص ٢.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_cs_cz[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Přejít do nastavení ladění",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor a komunita",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor a komunita\nWebové rozhraní: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 je zastaralé, používá se scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_da_dk[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Gå til fejlfindingsindstillinger",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor og fællesskabet",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor og fællesskabet\nWebgrænseflade: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 er forældet, bruger scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_de_de[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Zu den Debug-Einstellungen",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor und die Community",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor und die Community\nWeboberfläche: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 ist veraltet, scan_depth=2 wird verwendet.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_el_gr[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Μετάβαση στις ρυθμίσεις αποσφαλμάτωσης",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor και η κοινότητα",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor και η κοινότητα\nΔιεπαφή ιστού: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: το recursive_scan=1 είναι παρωχημένο, χρησιμοποιείται scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_en_gb[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Go to Debug Settings",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor and Community",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor and Community\nWeb interface: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
static const char *const g_en_us[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Go to Debug Settings",
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor and Community",
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor and Community\nWeb interface: %s:%u",
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] =
|
||||
"ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2.",
|
||||
[SM_L10N_SCAN_DEPTH_ENABLED] = "ShadowMount+: scan depth %u enabled.",
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_es_es[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Ir a ajustes de depuración",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor y la comunidad",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor y la comunidad\nInterfaz web: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 está obsoleto, se usará scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_es_mx[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Ir a configuración de depuración",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor y la comunidad",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor y la comunidad\nInterfaz web: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 está obsoleto, se usará scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_fi_fi[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Siirry virheenkorjausasetuksiin",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor ja yhteisö",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor ja yhteisö\nVerkkokäyttöliittymä: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 on vanhentunut, käytetään scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_fr_ca[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Accéder aux paramètres de débogage",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor et la communauté",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor et la communauté\nInterface web : %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+ : recursive_scan=1 est obsolète, scan_depth=2 sera utilisé.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_fr_fr[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Accéder aux paramètres de débogage",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor et la communauté",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor et la communauté\nInterface web : %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+ : recursive_scan=1 est obsolète, scan_depth=2 sera utilisé.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_hu_hu[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Ugrás a hibakeresési beállításokhoz",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor és a közösség",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor és a közösség\nWebes felület: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: a recursive_scan=1 elavult, scan_depth=2 lesz használva.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_id_id[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Buka pengaturan debug",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor dan Komunitas",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor dan Komunitas\nAntarmuka web: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 sudah usang, memakai scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_it_it[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Vai alle impostazioni di debug",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor e la community",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor e la community\nInterfaccia web: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 è deprecato; verrà usato scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_ja_jp[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "デバッグ設定へ移動",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor とコミュニティ",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor とコミュニティ\nWebインターフェース: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 は非推奨です。scan_depth=2 を使用します。",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_ko_kr[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "디버그 설정으로 이동",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor 및 커뮤니티",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor 및 커뮤니티\n웹 인터페이스: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1은 더 이상 권장되지 않으며 scan_depth=2를 사용합니다.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_nl_nl[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Ga naar debug-instellingen",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor en de community",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor en de community\nWebinterface: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 is verouderd, scan_depth=2 wordt gebruikt.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_no_no[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Gå til feilsøkingsinnstillinger",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor og fellesskapet",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor og fellesskapet\nWebgrensesnitt: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 er utdatert, bruker scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_pl_pl[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Przejdź do ustawień debugowania",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor i społeczność",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor i społeczność\nInterfejs WWW: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 jest przestarzałe, zastosowano scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_pt_br[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Ir para as Configurações de Depuração",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor e a comunidade",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor e a comunidade\nInterface web: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 está obsoleto, usando scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_pt_pt[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Ir para Definições de depuração",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor e a comunidade",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor e a comunidade\nInterface web: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 está obsoleto, será usado scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_ro_ro[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Mergi la setările de depanare",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor și comunitatea",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor și comunitatea\nInterfață web: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 este depășit, se folosește scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_ru_ru[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Перейти к настройкам отладки",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor и Сообщество",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor и Сообщество\nВеб-интерфейс: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: параметр recursive_scan=1 устарел; используется scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_sv_se[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Gå till felsökningsinställningar",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor och gemenskapen",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor och gemenskapen\nWebbgränssnitt: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 är föråldrat, använder scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_th_th[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "ไปที่การตั้งค่าดีบัก",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor และชุมชน",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor และชุมชน\nเว็บอินเทอร์เฟซ: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 เลิกใช้แล้ว จะใช้ scan_depth=2",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_tr_tr[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Hata ayıklama ayarlarına git",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor ve Topluluk",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor ve Topluluk\nWeb arayüzü: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 kullanımdan kaldırıldı; scan_depth=2 kullanılacak.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_uk_ua[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Перейти до налаштувань налагодження",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor і спільнота",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor і спільнота\nВебінтерфейс: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: параметр recursive_scan=1 застарів; використовується scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_vi_vn[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "Đi tới cài đặt gỡ lỗi",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor và Cộng đồng",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor và Cộng đồng\nGiao diện web: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 đã lỗi thời; sẽ sử dụng scan_depth=2.",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_zh_cn[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "前往调试设置",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor 与社区",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor 与社区\n网页界面: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 已弃用,将使用 scan_depth=2。",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -3,6 +3,9 @@ static const char *const g_zh_tw[SM_L10N_COUNT] = {
|
||||
[SM_L10N_ACTION_DEBUG_SETTINGS] = "前往除錯設定",
|
||||
/* English: ShadowMount+ v%s\n© Drakmor and Community */
|
||||
[SM_L10N_STARTUP] = "ShadowMount+ v%s\n© Drakmor 與社群",
|
||||
/* English: ShadowMount+ startup with web interface */
|
||||
[SM_L10N_STARTUP_WEB] =
|
||||
"ShadowMount+ v%s\n© Drakmor 與社群\n網頁介面: %s:%u",
|
||||
/* English: ShadowMount+: recursive_scan=1 is deprecated, using scan_depth=2. */
|
||||
[SM_L10N_RECURSIVE_SCAN_DEPRECATED] = "ShadowMount+: recursive_scan=1 已淘汰,將使用 scan_depth=2。",
|
||||
/* English: ShadowMount+: scan depth %u enabled. */
|
||||
|
||||
@@ -20,11 +20,16 @@
|
||||
#define SM_API_ROUTE_GAME_MOVE "/api/v1/games/move"
|
||||
#define SM_API_ROUTE_GAME_COPY "/api/v1/games/copy"
|
||||
#define SM_API_ROUTE_GAME_DELETE "/api/v1/games/delete"
|
||||
#define SM_API_ROUTE_GAME_UNPACK "/api/v1/games/unpack"
|
||||
#define SM_API_ROUTE_GAME_STORAGE_STATUS "/api/v1/games/storage/status"
|
||||
#define SM_API_ROUTE_GAME_STORAGE_CANCEL "/api/v1/games/storage/cancel"
|
||||
#define SM_API_ROUTE_MANUAL_LIST "/api/v1/manual/list"
|
||||
#define SM_API_ROUTE_MANUAL_ADD "/api/v1/manual/add"
|
||||
#define SM_API_ROUTE_MANUAL_REMOVE "/api/v1/manual/remove"
|
||||
#define SM_API_ROUTE_SETTINGS "/api/v1/settings"
|
||||
#define SM_API_ROUTE_SETTINGS_UPDATE "/api/v1/settings/update"
|
||||
#define SM_API_ROUTE_DEBUG_LOG "/api/v1/debug-log"
|
||||
#define SM_API_ROUTE_KERNEL_LOG "/api/v1/kernel-log"
|
||||
#define SM_API_ROUTE_SCAN "/api/v1/scan"
|
||||
|
||||
#endif
|
||||
@@ -2,6 +2,7 @@
|
||||
#define SM_CONFIG_MOUNT_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct runtime_config runtime_config_t;
|
||||
@@ -12,12 +13,23 @@ void ensure_runtime_config_ready(void);
|
||||
bool load_runtime_config(void);
|
||||
// Reload runtime configuration from disk when config.ini changed.
|
||||
bool reload_runtime_config_if_changed(bool *reloaded_out);
|
||||
// Atomically persist the settings exposed by the HTTP API while preserving
|
||||
// unrelated config.ini keys and comments. Runtime reload remains scanner-owned.
|
||||
bool sm_config_write_web_settings(bool debug_enabled, bool quiet_mode,
|
||||
bool update_emulators_enabled,
|
||||
bool allow_lan_access,
|
||||
uint32_t fan_target_temperature_c,
|
||||
const char *const *scan_paths,
|
||||
size_t scan_path_count);
|
||||
// Return the current runtime configuration.
|
||||
const runtime_config_t *runtime_config(void);
|
||||
// Return the number of configured scan roots.
|
||||
int get_scan_path_count(void);
|
||||
// Return a scan root by index, or NULL if out of range.
|
||||
const char *get_scan_path(int index);
|
||||
// Return only scan roots explicitly configured through scanpath entries.
|
||||
int get_custom_scan_path_count(void);
|
||||
const char *get_custom_scan_path(int index);
|
||||
// Return scan depth for a root, including managed container-root expansion.
|
||||
uint32_t get_scan_depth_for_root(const char *scan_path);
|
||||
// Resolve a per-image read-only override from the file name.
|
||||
|
||||
@@ -54,6 +54,7 @@ typedef enum {
|
||||
SM_L10N_USB_CONNECTED_SCANNING,
|
||||
SM_L10N_USB_CLUSTER_RECOMMENDATION,
|
||||
SM_L10N_USB_DISCONNECTED,
|
||||
SM_L10N_STARTUP_WEB,
|
||||
SM_L10N_COUNT
|
||||
} sm_l10n_key_t;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define SM_MDBG_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
@@ -22,5 +23,9 @@ void sm_mdbg_game_shutdown(void);
|
||||
uint64_t sm_mdbg_next_wake_us(uint64_t now_us);
|
||||
// Poll the tracked process for mdbg crash-candidate state.
|
||||
void sm_mdbg_poll(void);
|
||||
// Return a caller-owned tail of the same SDK log stream used by crash
|
||||
// detection. The total byte count describes the current complete snapshot.
|
||||
int sm_mdbg_get_log_tail(size_t max_bytes, char **text_out,
|
||||
size_t *text_length_out, size_t *total_length_out);
|
||||
|
||||
#endif
|
||||
@@ -122,6 +122,7 @@ typedef struct {
|
||||
} attached_unit_detach_state_t;
|
||||
|
||||
typedef struct runtime_config {
|
||||
bool api_enabled;
|
||||
bool debug_enabled;
|
||||
bool quiet_mode;
|
||||
bool mount_read_only;
|
||||
|
||||
+7
-1
@@ -575,7 +575,13 @@ int main(void) {
|
||||
log_debug(" [MOUNT] remount_system_ex failed: %s", strerror(errno));
|
||||
}
|
||||
|
||||
notify_system_l10n(SM_L10N_STARTUP, SHADOWMOUNT_VERSION);
|
||||
const runtime_config_t *startup_cfg = runtime_config();
|
||||
if (startup_cfg->api_enabled) {
|
||||
notify_system_l10n(SM_L10N_STARTUP_WEB, SHADOWMOUNT_VERSION,
|
||||
startup_cfg->api_bind_address, startup_cfg->api_port);
|
||||
} else {
|
||||
notify_system_l10n(SM_L10N_STARTUP, SHADOWMOUNT_VERSION);
|
||||
}
|
||||
log_non_empty_scan_paths();
|
||||
|
||||
if (runtime_config()->legacy_recursive_scan_forced) {
|
||||
|
||||
+462
-15
@@ -22,6 +22,7 @@
|
||||
#include "sm_install_queue.h"
|
||||
#include "sm_log.h"
|
||||
#include "sm_manual.h"
|
||||
#include "sm_mdbg.h"
|
||||
#include "sm_mount_device.h"
|
||||
#include "sm_path_utils.h"
|
||||
#include "sm_paths.h"
|
||||
@@ -45,6 +46,11 @@
|
||||
#define SM_API_LISTEN_BACKLOG 32u
|
||||
#define SM_API_CONNECTION_MEMORY_LIMIT 16384u
|
||||
#define SM_API_STORAGE_STOP_LOG_SECONDS 5
|
||||
#define SM_API_LOG_DEFAULT_BYTES (128u * 1024u)
|
||||
#define SM_API_LOG_MAX_BYTES (256u * 1024u)
|
||||
|
||||
extern unsigned char web_index_html[];
|
||||
extern unsigned int web_index_html_len;
|
||||
|
||||
typedef struct {
|
||||
pthread_t thread;
|
||||
@@ -74,6 +80,7 @@ typedef enum {
|
||||
GAME_STORAGE_MOVE,
|
||||
GAME_STORAGE_COPY,
|
||||
GAME_STORAGE_DELETE,
|
||||
GAME_STORAGE_UNPACK,
|
||||
} game_storage_operation_t;
|
||||
|
||||
typedef enum {
|
||||
@@ -100,7 +107,9 @@ typedef struct {
|
||||
char title_id[MAX_TITLE_ID];
|
||||
char source_type[16];
|
||||
char source[MAX_PATH];
|
||||
char runtime_source[MAX_PATH];
|
||||
char destination[MAX_PATH];
|
||||
bool delete_source;
|
||||
uint64_t total_bytes;
|
||||
uint64_t processed_bytes;
|
||||
uint64_t total_files;
|
||||
@@ -123,7 +132,9 @@ typedef struct {
|
||||
char title_id[MAX_TITLE_ID];
|
||||
char source_type[16];
|
||||
char source[MAX_PATH];
|
||||
char runtime_source[MAX_PATH];
|
||||
char destination[MAX_PATH];
|
||||
bool delete_source;
|
||||
uint64_t total_bytes;
|
||||
uint64_t processed_bytes;
|
||||
uint64_t total_files;
|
||||
@@ -327,6 +338,22 @@ static bool send_file_response(struct MHD_Connection *connection, int fd,
|
||||
return result == MHD_YES;
|
||||
}
|
||||
|
||||
static bool send_embedded_index_response(struct MHD_Connection *connection) {
|
||||
struct MHD_Response *response = MHD_create_response_from_buffer(
|
||||
(size_t)web_index_html_len, web_index_html, MHD_RESPMEM_PERSISTENT);
|
||||
if (!response)
|
||||
return false;
|
||||
bool ready = add_common_response_headers(response) &&
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
|
||||
"text/html; charset=utf-8") == MHD_YES &&
|
||||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
|
||||
"no-cache") == MHD_YES;
|
||||
enum MHD_Result result =
|
||||
ready ? MHD_queue_response(connection, MHD_HTTP_OK, response) : MHD_NO;
|
||||
MHD_destroy_response(response);
|
||||
return result == MHD_YES;
|
||||
}
|
||||
|
||||
static bool add_json_value(struct json_object *object, const char *key,
|
||||
struct json_object *value) {
|
||||
if (!value)
|
||||
@@ -589,11 +616,15 @@ static void handle_version(struct MHD_Connection *fd) {
|
||||
!append_json_string(capabilities, "move_game_source") ||
|
||||
!append_json_string(capabilities, "copy_game_source") ||
|
||||
!append_json_string(capabilities, "delete_game_source") ||
|
||||
!append_json_string(capabilities, "unpack_game_image") ||
|
||||
!append_json_string(capabilities, "storage_job_status") ||
|
||||
!append_json_string(capabilities, "storage_job_cancel") ||
|
||||
!append_json_string(capabilities, "list_manual_sources") ||
|
||||
!append_json_string(capabilities, "add_manual_source") ||
|
||||
!append_json_string(capabilities, "remove_manual_source") ||
|
||||
!append_json_string(capabilities, "manage_settings") ||
|
||||
!append_json_string(capabilities, "read_debug_log") ||
|
||||
!append_json_string(capabilities, "read_kernel_log") ||
|
||||
!append_json_string(capabilities, "rescan")) {
|
||||
json_object_put(capabilities);
|
||||
json_object_put(response);
|
||||
@@ -1237,10 +1268,8 @@ static bool handle_index_page(struct MHD_Connection *connection) {
|
||||
int fd = open(WEB_INDEX_FILE, O_RDONLY);
|
||||
if (fd < 0) {
|
||||
int status = errno != 0 ? errno : EIO;
|
||||
if (status == ENOENT || status == ENOTDIR) {
|
||||
return send_error_response(connection, 404, ENOENT,
|
||||
"web UI is not installed");
|
||||
}
|
||||
if (status == ENOENT || status == ENOTDIR)
|
||||
return send_embedded_index_response(connection);
|
||||
return send_error_response(connection, operation_http_status(status),
|
||||
status, strerror(status));
|
||||
}
|
||||
@@ -1476,6 +1505,285 @@ static void handle_manual_update(struct MHD_Connection *fd,
|
||||
json_object_put(response);
|
||||
}
|
||||
|
||||
static bool get_required_bool(struct json_object *request, const char *key,
|
||||
bool *value_out) {
|
||||
struct json_object *value = NULL;
|
||||
if (!json_object_object_get_ex(request, key, &value) ||
|
||||
!json_object_is_type(value, json_type_boolean)) {
|
||||
return false;
|
||||
}
|
||||
*value_out = json_object_get_boolean(value) != 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool get_log_max_bytes(struct MHD_Connection *connection,
|
||||
struct json_object *request,
|
||||
size_t *max_bytes_out) {
|
||||
*max_bytes_out = SM_API_LOG_DEFAULT_BYTES;
|
||||
struct json_object *value = NULL;
|
||||
if (!json_object_object_get_ex(request, "max_bytes", &value))
|
||||
return true;
|
||||
if (!json_object_is_type(value, json_type_int)) {
|
||||
send_error_response(connection, 400, EINVAL,
|
||||
"max_bytes must be an integer");
|
||||
return false;
|
||||
}
|
||||
int64_t requested = json_object_get_int64(value);
|
||||
if (requested < 4096 || requested > SM_API_LOG_MAX_BYTES) {
|
||||
send_error_response(connection, 400, EINVAL,
|
||||
"max_bytes must be in range 4096..262144");
|
||||
return false;
|
||||
}
|
||||
*max_bytes_out = (size_t)requested;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void send_log_tail_response(struct MHD_Connection *connection,
|
||||
char *content, size_t returned_bytes,
|
||||
size_t total_bytes,
|
||||
const char *total_bytes_key) {
|
||||
struct json_object *response = new_status_response(0);
|
||||
struct json_object *content_value =
|
||||
json_object_new_string_len(content, (int)returned_bytes);
|
||||
free(content);
|
||||
if (!response || !content_value) {
|
||||
if (content_value)
|
||||
json_object_put(content_value);
|
||||
if (response)
|
||||
json_object_put(response);
|
||||
send_out_of_memory_response(connection);
|
||||
return;
|
||||
}
|
||||
if (!add_json_value(response, "content", content_value) ||
|
||||
!add_json_int(response, total_bytes_key, (int64_t)total_bytes) ||
|
||||
!add_json_int(response, "returned_bytes", (int64_t)returned_bytes) ||
|
||||
!add_json_bool(response, "truncated", total_bytes > returned_bytes)) {
|
||||
json_object_put(response);
|
||||
send_out_of_memory_response(connection);
|
||||
return;
|
||||
}
|
||||
(void)send_json_object(connection, 200, response);
|
||||
json_object_put(response);
|
||||
}
|
||||
|
||||
static void handle_settings(struct MHD_Connection *connection) {
|
||||
runtime_config_t cfg = *runtime_config();
|
||||
struct json_object *response = new_status_response(0);
|
||||
struct json_object *scan_paths = json_object_new_array();
|
||||
if (!response || !scan_paths) {
|
||||
if (response)
|
||||
json_object_put(response);
|
||||
if (scan_paths)
|
||||
json_object_put(scan_paths);
|
||||
send_out_of_memory_response(connection);
|
||||
return;
|
||||
}
|
||||
|
||||
size_t count = 0;
|
||||
int custom_path_count = get_custom_scan_path_count();
|
||||
for (int i = 0; i < custom_path_count; ++i) {
|
||||
const char *path = get_custom_scan_path(i);
|
||||
if (!path)
|
||||
continue;
|
||||
if (!append_json_string(scan_paths, path)) {
|
||||
json_object_put(scan_paths);
|
||||
json_object_put(response);
|
||||
send_out_of_memory_response(connection);
|
||||
return;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
if (!add_json_value(response, "scan_paths", scan_paths) ||
|
||||
!add_json_bool(response, "api_enabled", cfg.api_enabled) ||
|
||||
!add_json_bool(response, "allow_lan_access",
|
||||
strcmp(cfg.api_bind_address,
|
||||
SM_API_DEFAULT_BIND_ADDRESS) != 0) ||
|
||||
!add_json_bool(response, "debug", cfg.debug_enabled) ||
|
||||
!add_json_bool(response, "quiet_mode", cfg.quiet_mode) ||
|
||||
!add_json_bool(response, "update_emulators",
|
||||
cfg.update_emulators_enabled) ||
|
||||
!add_json_int(response, "fan_target_temperature",
|
||||
cfg.fan_target_temperature_c) ||
|
||||
!add_json_int(response, "scan_path_count", (int64_t)count)) {
|
||||
json_object_put(response);
|
||||
send_out_of_memory_response(connection);
|
||||
return;
|
||||
}
|
||||
(void)send_json_object(connection, 200, response);
|
||||
json_object_put(response);
|
||||
}
|
||||
|
||||
static void handle_settings_update(struct MHD_Connection *connection,
|
||||
struct json_object *request) {
|
||||
bool debug_enabled = false;
|
||||
bool quiet_mode = false;
|
||||
bool update_emulators = false;
|
||||
bool allow_lan_access = false;
|
||||
struct json_object *fan_value = NULL;
|
||||
struct json_object *paths_value = NULL;
|
||||
if (!get_required_bool(request, "debug", &debug_enabled) ||
|
||||
!get_required_bool(request, "quiet_mode", &quiet_mode) ||
|
||||
!get_required_bool(request, "update_emulators", &update_emulators) ||
|
||||
!get_required_bool(request, "allow_lan_access", &allow_lan_access) ||
|
||||
!json_object_object_get_ex(request, "fan_target_temperature",
|
||||
&fan_value) ||
|
||||
!json_object_is_type(fan_value, json_type_int) ||
|
||||
!json_object_object_get_ex(request, "scan_paths", &paths_value) ||
|
||||
!json_object_is_type(paths_value, json_type_array)) {
|
||||
send_error_response(connection, 400, EINVAL,
|
||||
"settings fields have invalid types");
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t fan_value_int = json_object_get_int64(fan_value);
|
||||
if (fan_value_int < 0 || fan_value_int > UINT32_MAX ||
|
||||
(fan_value_int != FAN_TARGET_TEMPERATURE_SYSTEM &&
|
||||
(fan_value_int < MIN_FAN_TARGET_TEMPERATURE_C ||
|
||||
fan_value_int > MAX_FAN_TARGET_TEMPERATURE_C))) {
|
||||
send_error_response(connection, 400, EINVAL,
|
||||
"fan_target_temperature must be 0 or 50..91");
|
||||
return;
|
||||
}
|
||||
|
||||
size_t path_count = json_object_array_length(paths_value);
|
||||
if (path_count > MAX_SCAN_PATHS) {
|
||||
send_error_response(connection, 400, EINVAL, "too many scan paths");
|
||||
return;
|
||||
}
|
||||
const char **paths = path_count > 0 ? calloc(path_count, sizeof(*paths))
|
||||
: NULL;
|
||||
if (path_count > 0 && !paths) {
|
||||
send_out_of_memory_response(connection);
|
||||
return;
|
||||
}
|
||||
bool valid = true;
|
||||
for (size_t i = 0; i < path_count; ++i) {
|
||||
struct json_object *item = json_object_array_get_idx(paths_value, i);
|
||||
if (!item || !json_object_is_type(item, json_type_string)) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
paths[i] = json_object_get_string(item);
|
||||
}
|
||||
if (!valid || !sm_config_write_web_settings(
|
||||
debug_enabled, quiet_mode, update_emulators,
|
||||
allow_lan_access,
|
||||
(uint32_t)fan_value_int, paths, path_count)) {
|
||||
int status = valid && errno != 0 ? errno : EINVAL;
|
||||
free(paths);
|
||||
send_error_response(connection, operation_http_status(status), status,
|
||||
strerror(status));
|
||||
return;
|
||||
}
|
||||
free(paths);
|
||||
|
||||
// The config vnode event reloads the new topology first and then schedules
|
||||
// its full scan. Queuing an immediate scan here would race that reload and
|
||||
// scan the previous (often default) roots once more.
|
||||
|
||||
struct json_object *response = new_status_response(0);
|
||||
if (!response || !add_json_bool(response, "saved", true) ||
|
||||
!add_json_bool(response, "reload_queued", true)) {
|
||||
if (response)
|
||||
json_object_put(response);
|
||||
send_out_of_memory_response(connection);
|
||||
return;
|
||||
}
|
||||
(void)send_json_object(connection, 200, response);
|
||||
json_object_put(response);
|
||||
}
|
||||
|
||||
static void handle_debug_log(struct MHD_Connection *connection,
|
||||
struct json_object *request) {
|
||||
size_t max_bytes;
|
||||
if (!get_log_max_bytes(connection, request, &max_bytes))
|
||||
return;
|
||||
|
||||
int fd = open(LOG_FILE, O_RDONLY);
|
||||
struct stat st;
|
||||
if (fd < 0 && errno != ENOENT) {
|
||||
int status = errno != 0 ? errno : EIO;
|
||||
send_error_response(connection, operation_http_status(status), status,
|
||||
strerror(status));
|
||||
return;
|
||||
}
|
||||
memset(&st, 0, sizeof(st));
|
||||
if (fd >= 0 && (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode))) {
|
||||
int status = errno != 0 ? errno : EIO;
|
||||
(void)close(fd);
|
||||
send_error_response(connection, operation_http_status(status), status,
|
||||
strerror(status));
|
||||
return;
|
||||
}
|
||||
uint64_t file_size = st.st_size > 0 ? (uint64_t)st.st_size : 0;
|
||||
size_t read_size = file_size > max_bytes ? max_bytes : (size_t)file_size;
|
||||
char *content = malloc(read_size + 1u);
|
||||
if (!content) {
|
||||
if (fd >= 0)
|
||||
(void)close(fd);
|
||||
send_out_of_memory_response(connection);
|
||||
return;
|
||||
}
|
||||
size_t offset = 0;
|
||||
int read_status = 0;
|
||||
off_t file_offset = (off_t)(file_size - read_size);
|
||||
while (fd >= 0 && offset < read_size) {
|
||||
ssize_t count = pread(fd, content + offset, read_size - offset,
|
||||
file_offset + (off_t)offset);
|
||||
if (count < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (count < 0) {
|
||||
read_status = errno != 0 ? errno : EIO;
|
||||
break;
|
||||
}
|
||||
if (count == 0)
|
||||
break;
|
||||
offset += (size_t)count;
|
||||
}
|
||||
if (fd >= 0)
|
||||
(void)close(fd);
|
||||
if (read_status != 0) {
|
||||
free(content);
|
||||
send_error_response(connection, operation_http_status(read_status),
|
||||
read_status, strerror(read_status));
|
||||
return;
|
||||
}
|
||||
if (file_size > read_size && offset > 0) {
|
||||
char *newline = memchr(content, '\n', offset);
|
||||
if (newline) {
|
||||
size_t discard = (size_t)(newline - content) + 1u;
|
||||
memmove(content, content + discard, offset - discard);
|
||||
offset -= discard;
|
||||
}
|
||||
}
|
||||
content[offset] = '\0';
|
||||
|
||||
send_log_tail_response(connection, content, offset, (size_t)file_size,
|
||||
"file_size");
|
||||
}
|
||||
|
||||
static void handle_kernel_log(struct MHD_Connection *connection,
|
||||
struct json_object *request) {
|
||||
size_t max_bytes;
|
||||
if (!get_log_max_bytes(connection, request, &max_bytes))
|
||||
return;
|
||||
|
||||
char *content = NULL;
|
||||
size_t returned_bytes = 0;
|
||||
size_t total_bytes = 0;
|
||||
int status = sm_mdbg_get_log_tail(max_bytes, &content, &returned_bytes,
|
||||
&total_bytes);
|
||||
if (status != 0) {
|
||||
send_error_response(connection, operation_http_status(status), status,
|
||||
strerror(status));
|
||||
return;
|
||||
}
|
||||
|
||||
send_log_tail_response(connection, content, returned_bytes, total_bytes,
|
||||
"total_bytes");
|
||||
}
|
||||
|
||||
static int request_game_uninstall(const char *title_id) {
|
||||
if (runtime_sleep_mode_active() ||
|
||||
sm_game_lifecycle_has_active_game() ||
|
||||
@@ -1573,6 +1881,8 @@ static void dispatch_request(struct MHD_Connection *connection,
|
||||
handle_game_storage_operation(connection, json, GAME_STORAGE_COPY);
|
||||
} else if (strcmp(route, SM_API_ROUTE_GAME_DELETE) == 0) {
|
||||
handle_game_storage_operation(connection, json, GAME_STORAGE_DELETE);
|
||||
} else if (strcmp(route, SM_API_ROUTE_GAME_UNPACK) == 0) {
|
||||
handle_game_storage_operation(connection, json, GAME_STORAGE_UNPACK);
|
||||
} else if (strcmp(route, SM_API_ROUTE_GAME_STORAGE_STATUS) == 0) {
|
||||
handle_storage_job_status(connection, json);
|
||||
} else if (strcmp(route, SM_API_ROUTE_GAME_STORAGE_CANCEL) == 0) {
|
||||
@@ -1583,6 +1893,14 @@ static void dispatch_request(struct MHD_Connection *connection,
|
||||
handle_manual_update(connection, json, true);
|
||||
} else if (strcmp(route, SM_API_ROUTE_MANUAL_REMOVE) == 0) {
|
||||
handle_manual_update(connection, json, false);
|
||||
} else if (strcmp(route, SM_API_ROUTE_SETTINGS) == 0) {
|
||||
handle_settings(connection);
|
||||
} else if (strcmp(route, SM_API_ROUTE_SETTINGS_UPDATE) == 0) {
|
||||
handle_settings_update(connection, json);
|
||||
} else if (strcmp(route, SM_API_ROUTE_DEBUG_LOG) == 0) {
|
||||
handle_debug_log(connection, json);
|
||||
} else if (strcmp(route, SM_API_ROUTE_KERNEL_LOG) == 0) {
|
||||
handle_kernel_log(connection, json);
|
||||
} else if (strcmp(route, SM_API_ROUTE_SCAN) == 0) {
|
||||
handle_scan(connection, json);
|
||||
} else {
|
||||
@@ -1599,6 +1917,8 @@ static const char *game_storage_operation_name(
|
||||
return "copy";
|
||||
case GAME_STORAGE_DELETE:
|
||||
return "delete";
|
||||
case GAME_STORAGE_UNPACK:
|
||||
return "unpack";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -1654,6 +1974,44 @@ static int build_storage_destination(const char *requested_dir,
|
||||
return errno == ENOENT ? 0 : (errno != 0 ? errno : EIO);
|
||||
}
|
||||
|
||||
static int build_unpack_destination(const char *requested_dir,
|
||||
const char *title_id,
|
||||
char destination[MAX_PATH]) {
|
||||
char resolved_dir[MAX_PATH];
|
||||
if (!realpath(requested_dir, resolved_dir))
|
||||
return errno != 0 ? errno : EINVAL;
|
||||
struct stat st;
|
||||
if (stat(resolved_dir, &st) != 0)
|
||||
return errno != 0 ? errno : EIO;
|
||||
if (!S_ISDIR(st.st_mode) || !destination_is_managed(resolved_dir))
|
||||
return EINVAL;
|
||||
int written = snprintf(destination, MAX_PATH, "%s/%s", resolved_dir,
|
||||
title_id);
|
||||
if (written < 0 || (size_t)written >= MAX_PATH)
|
||||
return ENAMETOOLONG;
|
||||
if (lstat(destination, &st) == 0)
|
||||
return EEXIST;
|
||||
return errno == ENOENT ? 0 : (errno != 0 ? errno : EIO);
|
||||
}
|
||||
|
||||
static int count_titles_for_source(const char *physical_path,
|
||||
size_t *count_out) {
|
||||
*count_out = 0;
|
||||
sm_game_cache_snapshot_entry_t *snapshot = NULL;
|
||||
size_t count = 0;
|
||||
if (!sm_game_cache_snapshot(&snapshot, &count))
|
||||
return ENOMEM;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
game_source_info_t info;
|
||||
if (resolve_game_source(&snapshot[i], &info) &&
|
||||
strcmp(info.physical_path, physical_path) == 0) {
|
||||
(*count_out)++;
|
||||
}
|
||||
}
|
||||
free(snapshot);
|
||||
return *count_out > 0 ? 0 : ENOENT;
|
||||
}
|
||||
|
||||
static int release_storage_source_runtime(const char *physical_path,
|
||||
const char *title_id,
|
||||
size_t *affected_out) {
|
||||
@@ -1796,8 +2154,11 @@ static void copy_storage_job_snapshot_locked(
|
||||
sizeof(snapshot->source_type));
|
||||
(void)strlcpy(snapshot->source, g_storage_job.source,
|
||||
sizeof(snapshot->source));
|
||||
(void)strlcpy(snapshot->runtime_source, g_storage_job.runtime_source,
|
||||
sizeof(snapshot->runtime_source));
|
||||
(void)strlcpy(snapshot->destination, g_storage_job.destination,
|
||||
sizeof(snapshot->destination));
|
||||
snapshot->delete_source = g_storage_job.delete_source;
|
||||
snapshot->total_bytes = g_storage_job.total_bytes;
|
||||
snapshot->processed_bytes = g_storage_job.processed_bytes;
|
||||
snapshot->total_files = g_storage_job.total_files;
|
||||
@@ -1885,7 +2246,10 @@ static bool add_storage_job_fields(struct json_object *response,
|
||||
add_json_string(response, "title_id", snapshot->title_id) &&
|
||||
add_json_string(response, "source_type", snapshot->source_type) &&
|
||||
add_json_string(response, "source", snapshot->source) &&
|
||||
add_json_string(response, "runtime_source",
|
||||
snapshot->runtime_source) &&
|
||||
add_json_string(response, "destination", snapshot->destination) &&
|
||||
add_json_bool(response, "delete_source", snapshot->delete_source) &&
|
||||
add_json_int(response, "total_bytes",
|
||||
(int64_t)snapshot->total_bytes) &&
|
||||
add_json_int(response, "processed_bytes",
|
||||
@@ -1991,14 +2355,19 @@ static void *storage_job_thread_main(void *arg) {
|
||||
uint64_t job_id = (uint64_t)(uintptr_t)arg;
|
||||
char title_id[MAX_TITLE_ID];
|
||||
char source[MAX_PATH];
|
||||
char runtime_source[MAX_PATH];
|
||||
char destination[MAX_PATH];
|
||||
bool delete_source;
|
||||
game_storage_operation_t operation;
|
||||
|
||||
pthread_mutex_lock(&g_storage_job.mutex);
|
||||
(void)strlcpy(title_id, g_storage_job.title_id, sizeof(title_id));
|
||||
(void)strlcpy(source, g_storage_job.source, sizeof(source));
|
||||
(void)strlcpy(runtime_source, g_storage_job.runtime_source,
|
||||
sizeof(runtime_source));
|
||||
(void)strlcpy(destination, g_storage_job.destination,
|
||||
sizeof(destination));
|
||||
delete_source = g_storage_job.delete_source;
|
||||
operation = g_storage_job.operation;
|
||||
pthread_mutex_unlock(&g_storage_job.mutex);
|
||||
|
||||
@@ -2007,6 +2376,7 @@ static void *storage_job_thread_main(void *arg) {
|
||||
int status = 0;
|
||||
bool scanner_mutation = false;
|
||||
bool shellcore_mutation = false;
|
||||
bool unpack_mounted = false;
|
||||
bool recovery_scan_needed = false;
|
||||
void *job_ctx = (void *)(uintptr_t)job_id;
|
||||
|
||||
@@ -2019,6 +2389,14 @@ static void *storage_job_thread_main(void *arg) {
|
||||
} else {
|
||||
scanner_mutation = true;
|
||||
}
|
||||
if (status == 0 && operation == GAME_STORAGE_UNPACK) {
|
||||
const bool read_only = true;
|
||||
status = sm_shellcore_mount_title_runtime(title_id, &read_only);
|
||||
if (status == 0) {
|
||||
unpack_mounted = true;
|
||||
recovery_scan_needed = true;
|
||||
}
|
||||
}
|
||||
if (status == 0) {
|
||||
if (!sm_shellcore_try_begin_external_mutation())
|
||||
status = EBUSY;
|
||||
@@ -2037,7 +2415,7 @@ static void *storage_job_thread_main(void *arg) {
|
||||
pthread_mutex_unlock(&g_storage_job.mutex);
|
||||
}
|
||||
|
||||
if (status == 0) {
|
||||
if (status == 0 && operation != GAME_STORAGE_UNPACK) {
|
||||
size_t affected = 0;
|
||||
recovery_scan_needed = true;
|
||||
status = release_storage_source_runtime(source, title_id, &affected);
|
||||
@@ -2047,13 +2425,29 @@ static void *storage_job_thread_main(void *arg) {
|
||||
pthread_mutex_unlock(&g_storage_job.mutex);
|
||||
}
|
||||
|
||||
if (status == 0 && operation == GAME_STORAGE_UNPACK) {
|
||||
size_t affected = 0;
|
||||
status = count_titles_for_source(source, &affected);
|
||||
if (status == 0 && delete_source && affected > 1u)
|
||||
status = EBUSY;
|
||||
pthread_mutex_lock(&g_storage_job.mutex);
|
||||
if (g_storage_job.id == job_id)
|
||||
g_storage_job.affected_titles = affected;
|
||||
pthread_mutex_unlock(&g_storage_job.mutex);
|
||||
if (status == 0 && !path_exists(runtime_source))
|
||||
status = ENOENT;
|
||||
}
|
||||
|
||||
if (status == 0) {
|
||||
const char *measure_source = operation == GAME_STORAGE_UNPACK
|
||||
? runtime_source
|
||||
: source;
|
||||
int measure_result = operation == GAME_STORAGE_DELETE
|
||||
? sm_storage_measure_delete_path_progress(
|
||||
source, &total_bytes, &total_files,
|
||||
storage_job_cancelled, job_ctx)
|
||||
: sm_storage_measure_path_progress(
|
||||
source, &total_bytes, &total_files,
|
||||
measure_source, &total_bytes, &total_files,
|
||||
storage_job_cancelled, job_ctx);
|
||||
if (measure_result != 0)
|
||||
status = errno != 0 ? errno : EIO;
|
||||
@@ -2079,6 +2473,9 @@ static void *storage_job_thread_main(void *arg) {
|
||||
|
||||
if (status == 0) {
|
||||
bool renamed = false;
|
||||
const char *copy_source = operation == GAME_STORAGE_UNPACK
|
||||
? runtime_source
|
||||
: source;
|
||||
int result = operation == GAME_STORAGE_DELETE
|
||||
? sm_storage_delete_path_progress(
|
||||
source, update_storage_job_progress, job_ctx)
|
||||
@@ -2088,7 +2485,8 @@ static void *storage_job_thread_main(void *arg) {
|
||||
storage_job_cancelled,
|
||||
begin_storage_job_finalizing, job_ctx, &renamed)
|
||||
: sm_storage_copy_path_progress(
|
||||
source, destination, update_storage_job_progress,
|
||||
copy_source, destination,
|
||||
update_storage_job_progress,
|
||||
storage_job_cancelled, job_ctx);
|
||||
pthread_mutex_lock(&g_storage_job.mutex);
|
||||
if (g_storage_job.transfer_finished_us == 0)
|
||||
@@ -2108,6 +2506,23 @@ static void *storage_job_thread_main(void *arg) {
|
||||
}
|
||||
}
|
||||
|
||||
if (status == 0 && operation == GAME_STORAGE_UNPACK &&
|
||||
!begin_storage_job_finalizing(job_ctx)) {
|
||||
status = ECANCELED;
|
||||
if (sm_storage_delete_path(destination) != 0 && errno != ENOENT)
|
||||
status = errno != 0 ? errno : EIO;
|
||||
}
|
||||
|
||||
if (unpack_mounted) {
|
||||
if (!sm_shellcore_release_title_runtime(title_id) && status == 0)
|
||||
status = errno == EBUSY ? EBUSY : EIO;
|
||||
unpack_mounted = false;
|
||||
}
|
||||
if (status == 0 && operation == GAME_STORAGE_UNPACK && delete_source &&
|
||||
sm_storage_delete_path(source) != 0) {
|
||||
status = errno != 0 ? errno : EIO;
|
||||
}
|
||||
|
||||
if (shellcore_mutation)
|
||||
sm_shellcore_end_external_mutation();
|
||||
if (scanner_mutation)
|
||||
@@ -2122,7 +2537,9 @@ static void *storage_job_thread_main(void *arg) {
|
||||
? "HTTP API async game source moved"
|
||||
: operation == GAME_STORAGE_COPY
|
||||
? "HTTP API async game source copied"
|
||||
: "HTTP API async game source deleted");
|
||||
: operation == GAME_STORAGE_UNPACK
|
||||
? "HTTP API game image unpacked"
|
||||
: "HTTP API async game source deleted");
|
||||
}
|
||||
log_debug(" [API] async game source %s %s: job=%llu title=%s status=%d",
|
||||
game_storage_operation_name(operation),
|
||||
@@ -2135,6 +2552,7 @@ static void *storage_job_thread_main(void *arg) {
|
||||
static int start_storage_job(const char *title_id,
|
||||
const char *destination_dir,
|
||||
game_storage_operation_t operation,
|
||||
bool delete_source,
|
||||
storage_job_snapshot_t *accepted_out) {
|
||||
sm_game_cache_snapshot_entry_t game_entry;
|
||||
if (!find_game_snapshot_by_title(title_id, &game_entry))
|
||||
@@ -2145,11 +2563,20 @@ static int start_storage_job(const char *title_id,
|
||||
!path_exists(source_info.physical_path)) {
|
||||
return ENOENT;
|
||||
}
|
||||
if (operation == GAME_STORAGE_UNPACK &&
|
||||
(!source_info.image_backed || is_data_mounted(title_id) ||
|
||||
sm_shellcore_service_title_is_prepared(title_id))) {
|
||||
return source_info.image_backed ? EBUSY : ENOTSUP;
|
||||
}
|
||||
char destination[MAX_PATH];
|
||||
destination[0] = '\0';
|
||||
if (operation != GAME_STORAGE_DELETE) {
|
||||
int destination_status = build_storage_destination(
|
||||
destination_dir, source_info.physical_path, destination);
|
||||
int destination_status = operation == GAME_STORAGE_UNPACK
|
||||
? build_unpack_destination(
|
||||
destination_dir, title_id, destination)
|
||||
: build_storage_destination(
|
||||
destination_dir,
|
||||
source_info.physical_path, destination);
|
||||
if (destination_status != 0)
|
||||
return destination_status;
|
||||
}
|
||||
@@ -2172,8 +2599,12 @@ static int start_storage_job(const char *title_id,
|
||||
sizeof(g_storage_job.source_type));
|
||||
(void)strlcpy(g_storage_job.source, source_info.physical_path,
|
||||
sizeof(g_storage_job.source));
|
||||
(void)strlcpy(g_storage_job.runtime_source, source_info.runtime_path,
|
||||
sizeof(g_storage_job.runtime_source));
|
||||
(void)strlcpy(g_storage_job.destination, destination,
|
||||
sizeof(g_storage_job.destination));
|
||||
g_storage_job.delete_source =
|
||||
operation == GAME_STORAGE_UNPACK && delete_source;
|
||||
g_storage_job.total_bytes = 0;
|
||||
g_storage_job.processed_bytes = 0;
|
||||
g_storage_job.total_files = 0;
|
||||
@@ -2317,10 +2748,18 @@ static void handle_game_storage_operation(
|
||||
}
|
||||
}
|
||||
|
||||
bool delete_source = false;
|
||||
if (operation == GAME_STORAGE_UNPACK &&
|
||||
!get_optional_bool(request, "delete_source", false, &delete_source)) {
|
||||
send_error_response(connection, 400, EINVAL,
|
||||
"delete_source must be a boolean");
|
||||
return;
|
||||
}
|
||||
|
||||
storage_job_snapshot_t accepted;
|
||||
memset(&accepted, 0, sizeof(accepted));
|
||||
int status =
|
||||
start_storage_job(title_id, destination_dir, operation, &accepted);
|
||||
int status = start_storage_job(title_id, destination_dir, operation,
|
||||
delete_source, &accepted);
|
||||
if (status != 0) {
|
||||
log_debug(" [API] async game source %s start failed: title=%s "
|
||||
"status=%d",
|
||||
@@ -2572,10 +3011,18 @@ static void *service_thread_main(void *arg) {
|
||||
}
|
||||
|
||||
bool sm_api_service_start(void) {
|
||||
const runtime_config_t *cfg = runtime_config();
|
||||
runtime_config_t cfg = *runtime_config();
|
||||
if (!cfg.api_enabled) {
|
||||
pthread_mutex_lock(&g_storage_job.mutex);
|
||||
g_storage_job.accepting = false;
|
||||
pthread_mutex_unlock(&g_storage_job.mutex);
|
||||
log_debug(" [API] HTTP API disabled by configuration");
|
||||
return true;
|
||||
}
|
||||
|
||||
char bind_address[MAX_API_BIND_ADDRESS];
|
||||
(void)strlcpy(bind_address, cfg->api_bind_address, sizeof(bind_address));
|
||||
uint16_t port = (uint16_t)cfg->api_port;
|
||||
(void)strlcpy(bind_address, cfg.api_bind_address, sizeof(bind_address));
|
||||
uint16_t port = (uint16_t)cfg.api_port;
|
||||
|
||||
pthread_mutex_lock(&g_api.mutex);
|
||||
if (g_api.started) {
|
||||
|
||||
+218
-2
@@ -38,6 +38,7 @@ typedef struct {
|
||||
runtime_config_t cfg;
|
||||
char scan_path_storage[MAX_SCAN_PATHS][MAX_PATH];
|
||||
int scan_path_count;
|
||||
int custom_scan_path_count;
|
||||
image_mode_rule_t image_mode_rules[MAX_IMAGE_MODE_RULES];
|
||||
char kstuff_no_pause_title_ids[MAX_KSTUFF_TITLE_RULES][MAX_TITLE_ID];
|
||||
int kstuff_no_pause_title_count;
|
||||
@@ -69,6 +70,7 @@ static _Atomic int g_runtime_state_active_index = 0;
|
||||
static atomic_bool g_runtime_cfg_ready = false;
|
||||
static config_file_stamp_t g_config_file_stamp;
|
||||
static pthread_mutex_t g_autotune_file_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_mutex_t g_config_file_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static char *trim_ascii(char *s);
|
||||
static bool parse_ini_line(char *line, char **key_out, char **value_out);
|
||||
@@ -110,6 +112,20 @@ static bool upsert_kstuff_delay_override_in_file(const char *path,
|
||||
const char *title_id,
|
||||
uint32_t delay_seconds);
|
||||
|
||||
static bool web_managed_config_key(const char *key) {
|
||||
return strcasecmp(key, "debug") == 0 ||
|
||||
strcasecmp(key, "quiet_mode") == 0 ||
|
||||
strcasecmp(key, "update_emulators") == 0 ||
|
||||
strcasecmp(key, "fan_target_temperature") == 0 ||
|
||||
strcasecmp(key, "api_bind_address") == 0 ||
|
||||
strcasecmp(key, "api_bind_adress") == 0 ||
|
||||
strcasecmp(key, "scanpath") == 0;
|
||||
}
|
||||
|
||||
static int config_io_error(void) {
|
||||
return errno != 0 ? errno : EIO;
|
||||
}
|
||||
|
||||
static char *trim_ascii(char *s) {
|
||||
while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n')
|
||||
s++;
|
||||
@@ -185,6 +201,7 @@ static attach_backend_t default_ufs_backend(void) {
|
||||
|
||||
static void clear_runtime_scan_paths(runtime_config_state_t *state) {
|
||||
state->scan_path_count = 0;
|
||||
state->custom_scan_path_count = 0;
|
||||
memset(state->scan_path_storage, 0, sizeof(state->scan_path_storage));
|
||||
}
|
||||
|
||||
@@ -242,6 +259,7 @@ static void clear_kstuff_title_rules(runtime_config_state_t *state) {
|
||||
|
||||
static void init_runtime_config_defaults(runtime_config_state_t *state) {
|
||||
memset(state, 0, sizeof(*state));
|
||||
state->cfg.api_enabled = true;
|
||||
state->cfg.debug_enabled = true;
|
||||
state->cfg.quiet_mode = false;
|
||||
state->cfg.mount_read_only = (IMAGE_MOUNT_READ_ONLY != 0);
|
||||
@@ -321,6 +339,7 @@ static void apply_reloadable_runtime_fields(runtime_config_state_t *dst,
|
||||
const runtime_config_state_t *src) {
|
||||
dst->cfg = src->cfg;
|
||||
dst->scan_path_count = src->scan_path_count;
|
||||
dst->custom_scan_path_count = src->custom_scan_path_count;
|
||||
memcpy(dst->scan_path_storage, src->scan_path_storage,
|
||||
sizeof(dst->scan_path_storage));
|
||||
memcpy(dst->image_mode_rules, src->image_mode_rules,
|
||||
@@ -370,6 +389,19 @@ const char *get_scan_path(int index) {
|
||||
return state->scan_path_storage[index];
|
||||
}
|
||||
|
||||
int get_custom_scan_path_count(void) {
|
||||
ensure_runtime_config_ready();
|
||||
return active_runtime_state()->custom_scan_path_count;
|
||||
}
|
||||
|
||||
const char *get_custom_scan_path(int index) {
|
||||
ensure_runtime_config_ready();
|
||||
const runtime_config_state_t *state = active_runtime_state();
|
||||
if (index < 0 || index >= state->custom_scan_path_count)
|
||||
return NULL;
|
||||
return state->scan_path_storage[index];
|
||||
}
|
||||
|
||||
uint32_t get_scan_depth_for_root(const char *scan_path) {
|
||||
uint32_t scan_depth = runtime_config()->scan_depth;
|
||||
if (scan_depth < MIN_SCAN_DEPTH)
|
||||
@@ -607,6 +639,10 @@ static bool normalize_absolute_path_value(const char *value,
|
||||
size_t len = strlen(trimmed);
|
||||
if (len == 0 || len >= MAX_PATH || trimmed[0] != '/')
|
||||
return false;
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
if (iscntrl((unsigned char)trimmed[i]) || trimmed[i] == '#')
|
||||
return false;
|
||||
}
|
||||
|
||||
while (len > 1 && trimmed[len - 1] == '/') {
|
||||
trimmed[len - 1] = '\0';
|
||||
@@ -1264,7 +1300,18 @@ static config_load_status_t load_runtime_config_state(runtime_config_state_t *st
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcasecmp(key, "api_bind_address") == 0) {
|
||||
if (strcasecmp(key, "api_enabled") == 0) {
|
||||
if (!parse_bool_ini(value, &bval)) {
|
||||
log_debug(" [CFG] invalid bool at line %d: %s=%s", line_no, key,
|
||||
value);
|
||||
continue;
|
||||
}
|
||||
state->cfg.api_enabled = bval;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcasecmp(key, "api_bind_address") == 0 ||
|
||||
strcasecmp(key, "api_bind_adress") == 0) {
|
||||
struct in_addr address;
|
||||
if (strlen(value) >= sizeof(state->cfg.api_bind_address) ||
|
||||
inet_pton(AF_INET, value, &address) != 1) {
|
||||
@@ -1272,6 +1319,9 @@ static config_load_status_t load_runtime_config_state(runtime_config_state_t *st
|
||||
line_no, key, value);
|
||||
continue;
|
||||
}
|
||||
if (strcasecmp(key, "api_bind_adress") == 0)
|
||||
log_debug(" [CFG] legacy api_bind_adress at line %d; use "
|
||||
"api_bind_address", line_no);
|
||||
(void)strlcpy(state->cfg.api_bind_address, value,
|
||||
sizeof(state->cfg.api_bind_address));
|
||||
continue;
|
||||
@@ -1679,6 +1729,8 @@ static config_load_status_t load_runtime_config_state(runtime_config_state_t *st
|
||||
if (has_custom_scanpaths && state->scan_path_count == 0) {
|
||||
log_debug(" [CFG] no valid scanpath entries, using defaults");
|
||||
init_runtime_scan_paths_defaults(state);
|
||||
} else if (has_custom_scanpaths) {
|
||||
state->custom_scan_path_count = state->scan_path_count;
|
||||
}
|
||||
add_runtime_managed_scan_paths(state);
|
||||
|
||||
@@ -1701,7 +1753,8 @@ static config_load_status_t load_runtime_config_state(runtime_config_state_t *st
|
||||
kstuff_delay_rule_count++;
|
||||
}
|
||||
|
||||
log_debug(" [CFG] loaded: debug=%d quiet=%d language=%s ro=%d force=%d "
|
||||
log_debug(" [CFG] loaded: api_enabled=%d debug=%d quiet=%d language=%s "
|
||||
"ro=%d force=%d "
|
||||
"persistent_image_mounts=%d app_install_all=%d "
|
||||
"auto_remove_missing_games=%d "
|
||||
"auto_remove_games_with_dlc=%d auto_remove_missing_delay_s=%u "
|
||||
@@ -1719,6 +1772,7 @@ static config_load_status_t load_runtime_config_state(runtime_config_state_t *st
|
||||
"lvd_sec(exfat=%u ufs=%u pfs=%u) md_sec(exfat=%u ufs=%u) "
|
||||
"scan_interval_s=%u stability_wait_s=%u scan_paths=%d image_rules=%d "
|
||||
"kstuff_no_pause=%d kstuff_delay_rules=%d",
|
||||
state->cfg.api_enabled ? 1 : 0,
|
||||
state->cfg.debug_enabled ? 1 : 0, state->cfg.quiet_mode ? 1 : 0,
|
||||
sm_l10n_language_name(state->cfg.language_id),
|
||||
state->cfg.mount_read_only ? 1 : 0,
|
||||
@@ -1797,3 +1851,165 @@ bool reload_runtime_config_if_changed(bool *reloaded_out) {
|
||||
*reloaded_out = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool sm_config_write_web_settings(bool debug_enabled, bool quiet_mode,
|
||||
bool update_emulators_enabled,
|
||||
bool allow_lan_access,
|
||||
uint32_t fan_target_temperature_c,
|
||||
const char *const *scan_paths,
|
||||
size_t scan_path_count) {
|
||||
if ((fan_target_temperature_c != FAN_TARGET_TEMPERATURE_SYSTEM &&
|
||||
(fan_target_temperature_c < MIN_FAN_TARGET_TEMPERATURE_C ||
|
||||
fan_target_temperature_c > MAX_FAN_TARGET_TEMPERATURE_C)) ||
|
||||
scan_path_count > MAX_SCAN_PATHS ||
|
||||
(scan_path_count > 0 && !scan_paths)) {
|
||||
errno = EINVAL;
|
||||
return false;
|
||||
}
|
||||
|
||||
char(*normalized_paths)[MAX_PATH] =
|
||||
scan_path_count > 0 ? calloc(scan_path_count, MAX_PATH) : NULL;
|
||||
if (scan_path_count > 0 && !normalized_paths) {
|
||||
errno = ENOMEM;
|
||||
return false;
|
||||
}
|
||||
size_t normalized_count = 0;
|
||||
for (size_t i = 0; i < scan_path_count; ++i) {
|
||||
char path[MAX_PATH];
|
||||
if (!normalize_absolute_path_value(scan_paths[i], path) ||
|
||||
strcmp(path, "/") == 0 ||
|
||||
is_under_image_mount_base(path)) {
|
||||
free(normalized_paths);
|
||||
errno = EINVAL;
|
||||
return false;
|
||||
}
|
||||
bool duplicate = false;
|
||||
for (size_t j = 0; j < normalized_count; ++j) {
|
||||
if (strcmp(normalized_paths[j], path) == 0) {
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!duplicate) {
|
||||
(void)strlcpy(normalized_paths[normalized_count], path, MAX_PATH);
|
||||
normalized_count++;
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_config_file_mutex);
|
||||
(void)mkdir(LOG_DIR, 0777);
|
||||
FILE *in = fopen(CONFIG_FILE, "r");
|
||||
if (!in && errno != ENOENT) {
|
||||
int saved_errno = config_io_error();
|
||||
pthread_mutex_unlock(&g_config_file_mutex);
|
||||
free(normalized_paths);
|
||||
errno = saved_errno;
|
||||
return false;
|
||||
}
|
||||
|
||||
char temp_path[MAX_PATH];
|
||||
int temp_written = snprintf(temp_path, sizeof(temp_path), "%s.web.tmp",
|
||||
CONFIG_FILE);
|
||||
if (temp_written < 0 || (size_t)temp_written >= sizeof(temp_path)) {
|
||||
if (in)
|
||||
(void)fclose(in);
|
||||
pthread_mutex_unlock(&g_config_file_mutex);
|
||||
free(normalized_paths);
|
||||
errno = ENAMETOOLONG;
|
||||
return false;
|
||||
}
|
||||
(void)unlink(temp_path);
|
||||
FILE *out = fopen(temp_path, "w");
|
||||
if (!out) {
|
||||
int saved_errno = config_io_error();
|
||||
if (in)
|
||||
(void)fclose(in);
|
||||
pthread_mutex_unlock(&g_config_file_mutex);
|
||||
free(normalized_paths);
|
||||
errno = saved_errno;
|
||||
return false;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
int saved_errno = 0;
|
||||
int last_written = '\n';
|
||||
char line[MAX_PATH + 256u];
|
||||
while (in && fgets(line, sizeof(line), in)) {
|
||||
bool truncated = strchr(line, '\n') == NULL && !feof(in);
|
||||
char parsed[sizeof(line)];
|
||||
(void)strlcpy(parsed, line, sizeof(parsed));
|
||||
char *key = NULL;
|
||||
char *value = NULL;
|
||||
char *trimmed = trim_ascii(parsed);
|
||||
bool skip = strcmp(trimmed,
|
||||
"# Managed by the ShadowMount web interface.") == 0;
|
||||
if (!skip)
|
||||
skip = parse_ini_line(parsed, &key, &value) &&
|
||||
web_managed_config_key(key);
|
||||
if (!skip && fputs(line, out) == EOF) {
|
||||
saved_errno = config_io_error();
|
||||
break;
|
||||
}
|
||||
if (!skip && line[0] != '\0')
|
||||
last_written = (unsigned char)line[strlen(line) - 1u];
|
||||
if (!truncated)
|
||||
continue;
|
||||
int ch;
|
||||
while ((ch = fgetc(in)) != EOF) {
|
||||
if (!skip && fputc(ch, out) == EOF && saved_errno == 0)
|
||||
saved_errno = config_io_error();
|
||||
if (!skip)
|
||||
last_written = ch;
|
||||
if (ch == '\n')
|
||||
break;
|
||||
}
|
||||
if (saved_errno != 0)
|
||||
break;
|
||||
}
|
||||
if (in && ferror(in) && saved_errno == 0)
|
||||
saved_errno = config_io_error();
|
||||
if (saved_errno == 0 && last_written != '\n' && fputc('\n', out) == EOF)
|
||||
saved_errno = config_io_error();
|
||||
if (saved_errno == 0 &&
|
||||
fprintf(out,
|
||||
"\n# Managed by the ShadowMount web interface.\n"
|
||||
"debug=%u\nquiet_mode=%u\nupdate_emulators=%u\n"
|
||||
"api_bind_address=%s\n",
|
||||
debug_enabled ? 1u : 0u, quiet_mode ? 1u : 0u,
|
||||
update_emulators_enabled ? 1u : 0u,
|
||||
allow_lan_access ? "0.0.0.0" : "127.0.0.1") < 0) {
|
||||
saved_errno = config_io_error();
|
||||
}
|
||||
if (saved_errno == 0) {
|
||||
if (fan_target_temperature_c == FAN_TARGET_TEMPERATURE_SYSTEM) {
|
||||
if (fputs("fan_target_temperature=system\n", out) == EOF)
|
||||
saved_errno = config_io_error();
|
||||
} else if (fprintf(out, "fan_target_temperature=%u\n",
|
||||
fan_target_temperature_c) < 0) {
|
||||
saved_errno = config_io_error();
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; saved_errno == 0 && i < normalized_count; ++i) {
|
||||
if (fprintf(out, "scanpath=%s\n", normalized_paths[i]) < 0)
|
||||
saved_errno = config_io_error();
|
||||
}
|
||||
if (fflush(out) != 0 && saved_errno == 0)
|
||||
saved_errno = config_io_error();
|
||||
if (saved_errno == 0 && fsync(fileno(out)) != 0)
|
||||
saved_errno = config_io_error();
|
||||
if (fclose(out) != 0 && saved_errno == 0)
|
||||
saved_errno = config_io_error();
|
||||
if (in && fclose(in) != 0 && saved_errno == 0)
|
||||
saved_errno = config_io_error();
|
||||
if (saved_errno == 0 && rename(temp_path, CONFIG_FILE) != 0)
|
||||
saved_errno = config_io_error();
|
||||
if (saved_errno != 0)
|
||||
(void)unlink(temp_path);
|
||||
pthread_mutex_unlock(&g_config_file_mutex);
|
||||
free(normalized_paths);
|
||||
if (saved_errno != 0) {
|
||||
errno = saved_errno;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+87
-6
@@ -1,6 +1,7 @@
|
||||
#include "sm_platform.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "sm_config_mount.h"
|
||||
@@ -96,6 +97,7 @@ typedef struct {
|
||||
} sm_mdbg_state_t;
|
||||
|
||||
static sm_mdbg_state_t g_mdbg;
|
||||
static pthread_mutex_t g_mdbg_kernel_log_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static bool sm_mdbg_enabled(void);
|
||||
#if !MDBG_SKIP_PRIVILEGE_ELEVATION
|
||||
@@ -157,8 +159,12 @@ static int elevate_to_coredump(void) {
|
||||
#endif
|
||||
|
||||
static bool ensure_mdbg_privileges(void) {
|
||||
if (g_mdbg.privilege_probe_done)
|
||||
return g_mdbg.privilege_ready;
|
||||
pthread_mutex_lock(&g_mdbg_kernel_log_mutex);
|
||||
if (g_mdbg.privilege_probe_done) {
|
||||
bool ready = g_mdbg.privilege_ready;
|
||||
pthread_mutex_unlock(&g_mdbg_kernel_log_mutex);
|
||||
return ready;
|
||||
}
|
||||
|
||||
g_mdbg.privilege_probe_done = true;
|
||||
#if MDBG_SKIP_PRIVILEGE_ELEVATION
|
||||
@@ -174,7 +180,9 @@ static bool ensure_mdbg_privileges(void) {
|
||||
}
|
||||
#endif
|
||||
|
||||
return g_mdbg.privilege_ready;
|
||||
bool ready = g_mdbg.privilege_ready;
|
||||
pthread_mutex_unlock(&g_mdbg_kernel_log_mutex);
|
||||
return ready;
|
||||
}
|
||||
|
||||
static int mdbg_call_raw(int64_t pid, uint64_t subcmd, uint64_t arg,
|
||||
@@ -285,9 +293,10 @@ static int fetch_log_text(const char **text_out, size_t *text_len_out) {
|
||||
*text_out = NULL;
|
||||
*text_len_out = 0;
|
||||
|
||||
int ret =
|
||||
MDBG_FETCH_LOG_TEXT(g_mdbg.log_storage, g_mdbg.log_buffer_size, &raw_text,
|
||||
&raw_len);
|
||||
pthread_mutex_lock(&g_mdbg_kernel_log_mutex);
|
||||
int ret = MDBG_FETCH_LOG_TEXT(g_mdbg.log_storage, g_mdbg.log_buffer_size,
|
||||
&raw_text, &raw_len);
|
||||
pthread_mutex_unlock(&g_mdbg_kernel_log_mutex);
|
||||
if (ret < 0)
|
||||
return ret;
|
||||
|
||||
@@ -302,6 +311,78 @@ static int fetch_log_text(const char **text_out, size_t *text_len_out) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sm_mdbg_get_log_tail(size_t max_bytes, char **text_out,
|
||||
size_t *text_length_out, size_t *total_length_out) {
|
||||
if (!text_out || !text_length_out || !total_length_out || max_bytes == 0)
|
||||
return EINVAL;
|
||||
*text_out = NULL;
|
||||
*text_length_out = 0;
|
||||
*total_length_out = 0;
|
||||
|
||||
if (!ensure_mdbg_privileges())
|
||||
return EACCES;
|
||||
|
||||
uint64_t raw_buffer_size = sceKernelDebugGetLogBufferSize();
|
||||
if (raw_buffer_size == 0 || raw_buffer_size > SIZE_MAX)
|
||||
return EOVERFLOW;
|
||||
size_t buffer_size = (size_t)raw_buffer_size;
|
||||
char *storage = malloc(buffer_size);
|
||||
if (!storage)
|
||||
return ENOMEM;
|
||||
|
||||
char *raw_text = NULL;
|
||||
uint64_t raw_length = 0;
|
||||
pthread_mutex_lock(&g_mdbg_kernel_log_mutex);
|
||||
int ret = MDBG_FETCH_LOG_TEXT(storage, buffer_size, &raw_text, &raw_length);
|
||||
pthread_mutex_unlock(&g_mdbg_kernel_log_mutex);
|
||||
if (ret != 0) {
|
||||
free(storage);
|
||||
return EIO;
|
||||
}
|
||||
if (!raw_text || raw_length == 0) {
|
||||
free(storage);
|
||||
char *empty = calloc(1u, 1u);
|
||||
if (!empty)
|
||||
return ENOMEM;
|
||||
*text_out = empty;
|
||||
return 0;
|
||||
}
|
||||
|
||||
uintptr_t storage_start = (uintptr_t)storage;
|
||||
uintptr_t text_start = (uintptr_t)raw_text;
|
||||
if (text_start < storage_start || text_start > storage_start + buffer_size) {
|
||||
free(storage);
|
||||
return EIO;
|
||||
}
|
||||
size_t text_offset = (size_t)(text_start - storage_start);
|
||||
if (raw_length > buffer_size - text_offset) {
|
||||
free(storage);
|
||||
return EOVERFLOW;
|
||||
}
|
||||
|
||||
size_t total_length = (size_t)raw_length;
|
||||
size_t start = total_length > max_bytes ? total_length - max_bytes : 0;
|
||||
if (start > 0) {
|
||||
char *newline = memchr(raw_text + start, '\n', total_length - start);
|
||||
if (newline)
|
||||
start = (size_t)(newline - raw_text) + 1u;
|
||||
}
|
||||
size_t text_length = total_length - start;
|
||||
char *result = malloc(text_length + 1u);
|
||||
if (!result) {
|
||||
free(storage);
|
||||
return ENOMEM;
|
||||
}
|
||||
memcpy(result, raw_text + start, text_length);
|
||||
result[text_length] = '\0';
|
||||
free(storage);
|
||||
|
||||
*text_out = result;
|
||||
*text_length_out = text_length;
|
||||
*total_length_out = total_length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static size_t find_log_overlap(const char *current, size_t current_len) {
|
||||
if (!g_mdbg.log_snapshot || g_mdbg.log_snapshot_length == 0 || !current ||
|
||||
current_len == 0) {
|
||||
|
||||
+2
-1
@@ -1229,7 +1229,8 @@ static bool apply_runtime_config_reload_effects(int kq,
|
||||
const runtime_config_t *old_cfg,
|
||||
const runtime_config_t *new_cfg,
|
||||
bool scan_topology_changed) {
|
||||
if (strcmp(old_cfg->api_bind_address, new_cfg->api_bind_address) != 0 ||
|
||||
if (old_cfg->api_enabled != new_cfg->api_enabled ||
|
||||
strcmp(old_cfg->api_bind_address, new_cfg->api_bind_address) != 0 ||
|
||||
old_cfg->api_port != new_cfg->api_port) {
|
||||
if (!sm_api_service_reconfigure())
|
||||
log_debug(" [CFG] HTTP API reconfigure failed: %s", strerror(errno));
|
||||
|
||||
@@ -201,6 +201,7 @@ static int begin_runtime_release(const char *title_id,
|
||||
bool different_title = has_owner && !owner;
|
||||
bool blocked = different_title || g_service.releasing_title_id[0] != '\0' ||
|
||||
(prepared && g_service.prepare_in_progress) ||
|
||||
(public_request && g_service.external_mutation_in_progress) ||
|
||||
(public_request && prepared &&
|
||||
(g_service.launch_pending || g_service.prepared.app_id != 0));
|
||||
if (!blocked) {
|
||||
|
||||
+89
-1
@@ -31,8 +31,13 @@ ROUTE_UNINSTALL = "/api/v1/games/uninstall"
|
||||
ROUTE_MOVE = "/api/v1/games/move"
|
||||
ROUTE_COPY = "/api/v1/games/copy"
|
||||
ROUTE_DELETE = "/api/v1/games/delete"
|
||||
ROUTE_UNPACK = "/api/v1/games/unpack"
|
||||
ROUTE_STORAGE_JOB_STATUS = "/api/v1/games/storage/status"
|
||||
ROUTE_STORAGE_JOB_CANCEL = "/api/v1/games/storage/cancel"
|
||||
ROUTE_SETTINGS = "/api/v1/settings"
|
||||
ROUTE_SETTINGS_UPDATE = "/api/v1/settings/update"
|
||||
ROUTE_DEBUG_LOG = "/api/v1/debug-log"
|
||||
ROUTE_KERNEL_LOG = "/api/v1/kernel-log"
|
||||
|
||||
|
||||
class ApiTestError(RuntimeError):
|
||||
@@ -204,11 +209,15 @@ def test_version(client: ApiClient) -> None:
|
||||
"move_game_source",
|
||||
"copy_game_source",
|
||||
"delete_game_source",
|
||||
"unpack_game_image",
|
||||
"storage_job_status",
|
||||
"storage_job_cancel",
|
||||
"list_manual_sources",
|
||||
"add_manual_source",
|
||||
"remove_manual_source",
|
||||
"manage_settings",
|
||||
"read_debug_log",
|
||||
"read_kernel_log",
|
||||
}
|
||||
require(
|
||||
required_capabilities.issubset(capabilities),
|
||||
@@ -271,7 +280,9 @@ def validate_storage_job(response: Dict[str, Any]) -> None:
|
||||
"title_id",
|
||||
"source_type",
|
||||
"source",
|
||||
"runtime_source",
|
||||
"destination",
|
||||
"delete_source",
|
||||
"total_bytes",
|
||||
"processed_bytes",
|
||||
"total_files",
|
||||
@@ -292,6 +303,7 @@ def validate_storage_job(response: Dict[str, Any]) -> None:
|
||||
"copy",
|
||||
"move",
|
||||
"delete",
|
||||
"unpack",
|
||||
}
|
||||
require(
|
||||
valid_operation,
|
||||
@@ -314,7 +326,13 @@ def validate_storage_job(response: Dict[str, Any]) -> None:
|
||||
},
|
||||
"storage job state is invalid",
|
||||
)
|
||||
for key in ("active", "cancellable", "cancel_requested", "scan_queued"):
|
||||
for key in (
|
||||
"active",
|
||||
"cancellable",
|
||||
"cancel_requested",
|
||||
"delete_source",
|
||||
"scan_queued",
|
||||
):
|
||||
require(type(response[key]) is bool, f"storage job {key} must be boolean")
|
||||
for key in (
|
||||
"total_bytes",
|
||||
@@ -345,6 +363,49 @@ def test_storage_job_status(client: ApiClient) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_settings_and_log(client: ApiClient) -> None:
|
||||
http_status, settings = client.post(ROUTE_SETTINGS, {})
|
||||
require(http_status == 200, f"settings returned HTTP {http_status}")
|
||||
require(integer_field(settings, "status") == 0, "settings request failed")
|
||||
for key in (
|
||||
"api_enabled",
|
||||
"allow_lan_access",
|
||||
"debug",
|
||||
"quiet_mode",
|
||||
"update_emulators",
|
||||
):
|
||||
require(type(settings.get(key)) is bool, f"settings {key} must be boolean")
|
||||
require(settings["api_enabled"], "API reports itself disabled")
|
||||
fan_target = integer_field(settings, "fan_target_temperature")
|
||||
require(fan_target == 0 or 50 <= fan_target <= 91, "fan target is invalid")
|
||||
scan_paths = settings.get("scan_paths")
|
||||
require(isinstance(scan_paths, list), "scan_paths must be an array")
|
||||
require(
|
||||
len(scan_paths) == integer_field(settings, "scan_path_count"),
|
||||
"scan path count does not match",
|
||||
)
|
||||
require(
|
||||
all(isinstance(path, str) and path.startswith("/") for path in scan_paths),
|
||||
"scan_paths contains an invalid path",
|
||||
)
|
||||
|
||||
for route, name, total_key in (
|
||||
(ROUTE_DEBUG_LOG, "debug log", "file_size"),
|
||||
(ROUTE_KERNEL_LOG, "kernel log", "total_bytes"),
|
||||
):
|
||||
http_status, log = client.post(route, {"max_bytes": 4096})
|
||||
require(http_status == 200, f"{name} returned HTTP {http_status}")
|
||||
require(integer_field(log, "status") == 0, f"{name} request failed")
|
||||
require(isinstance(log.get("content"), str), f"{name} content must be text")
|
||||
require(type(log.get("truncated")) is bool, f"{name} truncated must be boolean")
|
||||
require(integer_field(log, total_key) >= 0, f"{name} total size is invalid")
|
||||
require(
|
||||
0 <= integer_field(log, "returned_bytes") <= 4096,
|
||||
f"{name} response is too large",
|
||||
)
|
||||
print(f"[API-TEST] settings={len(scan_paths)} scan paths, logs=PASS")
|
||||
|
||||
|
||||
def test_list(client: ApiClient, route: str, key: str) -> list:
|
||||
http_status, response = client.post(route, {})
|
||||
require(http_status == 200, f"{key} request returned HTTP {http_status}")
|
||||
@@ -566,10 +627,36 @@ def test_mutation_validation(client: ApiClient) -> None:
|
||||
(ROUTE_UNINSTALL, {"title_id": "INVALID"}),
|
||||
(ROUTE_MOVE, {"title_id": "INVALID", "destination_dir": "/mnt"}),
|
||||
(ROUTE_COPY, {"title_id": "INVALID", "destination_dir": "/mnt"}),
|
||||
(ROUTE_UNPACK, {"title_id": "INVALID", "destination_dir": "/mnt"}),
|
||||
(ROUTE_DELETE, {"title_id": "INVALID", "confirm": True}),
|
||||
(ROUTE_DELETE, {"title_id": "PPSA00000"}),
|
||||
(ROUTE_STORAGE_JOB_STATUS, {"job_id": "invalid"}),
|
||||
(ROUTE_STORAGE_JOB_CANCEL, {"job_id": 0}),
|
||||
(ROUTE_SETTINGS_UPDATE, {}),
|
||||
(
|
||||
ROUTE_SETTINGS_UPDATE,
|
||||
{
|
||||
"debug": True,
|
||||
"quiet_mode": False,
|
||||
"update_emulators": False,
|
||||
"allow_lan_access": False,
|
||||
"fan_target_temperature": 0,
|
||||
"scan_paths": ["/"],
|
||||
},
|
||||
),
|
||||
(
|
||||
ROUTE_SETTINGS_UPDATE,
|
||||
{
|
||||
"debug": True,
|
||||
"quiet_mode": False,
|
||||
"update_emulators": False,
|
||||
"allow_lan_access": False,
|
||||
"fan_target_temperature": 0,
|
||||
"scan_paths": ["/mnt/usb0\ndebug=0"],
|
||||
},
|
||||
),
|
||||
(ROUTE_DEBUG_LOG, {"max_bytes": 1}),
|
||||
(ROUTE_KERNEL_LOG, {"max_bytes": 1}),
|
||||
)
|
||||
for route, payload in invalid_requests:
|
||||
http_status, response = client.post(route, payload)
|
||||
@@ -626,6 +713,7 @@ def main() -> int:
|
||||
test_version(client)
|
||||
storage_count = test_storage(client)
|
||||
test_storage_job_status(client)
|
||||
test_settings_and_log(client)
|
||||
images = test_list(client, ROUTE_IMAGES, "images")
|
||||
games = test_list(client, ROUTE_GAMES, "games")
|
||||
test_game_details(client, games)
|
||||
|
||||
+284
-19
@@ -82,6 +82,18 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.language-select {
|
||||
min-width: 110px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
@@ -108,7 +120,7 @@
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) 150px 110px auto auto auto;
|
||||
grid-template-columns: minmax(220px, 1fr) 150px auto auto auto;
|
||||
gap: 10px;
|
||||
padding: 13px;
|
||||
border-radius: var(--radius);
|
||||
@@ -273,6 +285,12 @@
|
||||
}
|
||||
|
||||
.manual-path { overflow: hidden; color: var(--muted); font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.settings-form { display: grid; gap: 10px; margin-top: 12px; }
|
||||
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12px; }
|
||||
.setting-row select { width: 112px; }
|
||||
.settings-actions { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.subheading { margin-top: 17px; font-size: 14px; }
|
||||
.log-view { min-height: 460px; max-height: 76vh; margin: 0; overflow: auto; padding: 20px; background: #080c12; color: #c8d7ea; font: 14px/1.55 ui-monospace, "SFMono-Regular", Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.activity-item { border-left: 2px solid var(--line); padding-left: 9px; color: var(--muted); font-size: 12px; }
|
||||
.activity-item.ok { border-color: var(--green); }
|
||||
.activity-item.error { border-color: var(--red); color: #ff9aa3; }
|
||||
@@ -298,6 +316,7 @@
|
||||
|
||||
.modal[hidden] { display: none; }
|
||||
.modal-card { width: min(720px, 100%); max-height: 90vh; overflow: auto; border-radius: 18px; }
|
||||
.modal-card.log-card { width: min(1440px, 96vw); }
|
||||
.modal-head { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 18px; border-bottom: 1px solid var(--line); }
|
||||
.close { width: 36px; min-height: 36px; padding: 0; font-size: 22px; }
|
||||
.details { display: grid; grid-template-columns: 168px minmax(0, 1fr); align-items: start; gap: 18px; padding: 18px; }
|
||||
@@ -325,14 +344,17 @@
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.toolbar { grid-template-columns: minmax(180px, 1fr) 150px auto; }
|
||||
.toolbar .check { order: 2; }
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 660px) {
|
||||
.modal { padding: 10px; }
|
||||
.modal-card.log-card { width: 100%; }
|
||||
.log-view { min-height: 60vh; max-height: 75vh; padding: 14px; font-size: 13px; }
|
||||
.shell { padding: 14px; }
|
||||
.topbar { align-items: flex-start; }
|
||||
.topbar { flex-direction: column; align-items: stretch; }
|
||||
.topbar-actions { justify-content: space-between; }
|
||||
.toolbar { grid-template-columns: 1fr 1fr; }
|
||||
.toolbar #search { grid-column: 1 / -1; }
|
||||
.stats { grid-template-columns: 1fr 1fr; }
|
||||
@@ -354,9 +376,15 @@
|
||||
<p class="subtle" id="version" data-i18n="connecting">Connecting to API…</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connection" id="connection" role="status">
|
||||
<span class="connection-dot"></span>
|
||||
<span id="connectionText" data-i18n="checking">Checking</span>
|
||||
<div class="topbar-actions">
|
||||
<div class="connection" id="connection" role="status">
|
||||
<span class="connection-dot"></span>
|
||||
<span id="connectionText" data-i18n="checking">Checking</span>
|
||||
</div>
|
||||
<select class="language-select" id="language" aria-label="Language" data-i18n-aria-label="language">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -369,10 +397,6 @@
|
||||
<option value="ps4">PS4</option>
|
||||
<option value="ps5">PS5</option>
|
||||
</select>
|
||||
<select id="language" aria-label="Language" data-i18n-aria-label="language">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
<label class="check"><input id="includeSize" type="checkbox"> <span data-i18n="calculateSize">Calculate size</span></label>
|
||||
<button class="btn" id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
|
||||
<button class="btn primary" id="scanBtn" type="button" data-i18n="scan">Scan</button>
|
||||
@@ -428,11 +452,31 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="manualHeading">
|
||||
<h2 id="manualHeading">manual.lst</h2>
|
||||
<p class="subtle" data-i18n="manualDescription">Additional scan sources</p>
|
||||
<section class="panel" aria-labelledby="settingsHeading">
|
||||
<h2 id="settingsHeading" data-i18n="settings">Settings</h2>
|
||||
<form class="settings-form" id="settingsForm">
|
||||
<label class="check"><input id="debugEnabled" type="checkbox"> <span data-i18n="debugLog">Debug log</span></label>
|
||||
<label class="check"><input id="quietMode" type="checkbox"> <span data-i18n="quietMode">Quiet mode</span></label>
|
||||
<label class="check"><input id="updateEmulators" type="checkbox"> <span data-i18n="autoUpdateEmulator">Auto-update emulator</span></label>
|
||||
<label class="check"><input id="allowLanAccess" type="checkbox"> <span data-i18n="allowLanAccess">Allow local network access</span></label>
|
||||
<label class="setting-row"><span data-i18n="fanTemperature">Fan target</span><select id="fanTemperature"><option value="0" data-i18n="systemControlled">System controlled</option></select></label>
|
||||
<div class="settings-actions">
|
||||
<button class="btn small primary" type="submit" data-i18n="saveSettings">Save settings</button>
|
||||
<button class="btn small" id="viewLog" type="button" data-i18n="viewLog">View debug log</button>
|
||||
<button class="btn small" id="viewKernelLog" type="button" data-i18n="viewKernelLog">View kernel log</button>
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="subheading" data-i18n="scanPaths">Scan paths</h3>
|
||||
<p class="subtle" data-i18n="scanPathsDescription">Custom recursive roots; defaults stay hidden</p>
|
||||
<form class="manual-form" id="scanPathForm">
|
||||
<input id="scanPath" required placeholder="/mnt/usb0/games">
|
||||
<button class="btn small" type="submit" data-i18n="add">Add</button>
|
||||
</form>
|
||||
<div class="manual-list" id="scanPathList"><span class="subtle" data-i18n="loading">Loading…</span></div>
|
||||
<h3 class="subheading" id="manualHeading">manual.lst</h3>
|
||||
<p class="subtle" data-i18n="manualDescription">Direct paths to individual game folders or images</p>
|
||||
<form class="manual-form" id="manualForm">
|
||||
<input id="manualPath" required placeholder="/mnt/usb0/games">
|
||||
<input id="manualPath" required placeholder="/mnt/usb0/games/PPSA12345">
|
||||
<button class="btn small" type="submit" data-i18n="add">Add</button>
|
||||
</form>
|
||||
<div class="manual-list" id="manualList"><span class="subtle" data-i18n="loading">Loading…</span></div>
|
||||
@@ -466,6 +510,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="logModal" hidden role="dialog" aria-modal="true" aria-labelledby="logTitle">
|
||||
<div class="modal-card log-card">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="logTitle" data-i18n="debugLogTitle">Debug log</h2>
|
||||
<p class="subtle" id="logSubtitle"></p>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="btn small" id="refreshLog" type="button" data-i18n="refresh">Refresh</button>
|
||||
<button class="btn close" id="closeLog" type="button" aria-label="Close" data-i18n-aria-label="close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="log-view" id="logContent"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" hidden role="status"></div>
|
||||
|
||||
<script>
|
||||
@@ -481,9 +541,13 @@
|
||||
storageJob: null,
|
||||
storagePollTimer: 0,
|
||||
reportedStorageJob: "",
|
||||
scanPaths: [],
|
||||
apiVersion: null,
|
||||
connectionStatus: "",
|
||||
connectionKey: "checking"
|
||||
connectionKey: "checking",
|
||||
logRoute: "/debug-log",
|
||||
logTitleKey: "debugLogTitle",
|
||||
logTriggerId: "viewLog"
|
||||
};
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
@@ -519,8 +583,27 @@
|
||||
perSecond: "/s",
|
||||
time: "Time",
|
||||
cancel: "Cancel",
|
||||
manualDescription: "Additional scan sources",
|
||||
manualDescription: "Direct paths to individual game folders or images",
|
||||
add: "Add",
|
||||
settings: "Settings",
|
||||
debugLog: "Debug log",
|
||||
quietMode: "Quiet mode",
|
||||
autoUpdateEmulator: "Auto-update emulator",
|
||||
allowLanAccess: "Allow local network access",
|
||||
fanTemperature: "Fan target",
|
||||
systemControlled: "System controlled",
|
||||
saveSettings: "Save settings",
|
||||
settingsSaved: "Settings saved; runtime reload queued",
|
||||
viewLog: "View debug log",
|
||||
viewKernelLog: "View kernel log",
|
||||
debugLogTitle: "Debug log",
|
||||
kernelLogTitle: "Kernel log",
|
||||
logBytes: "Showing {shown} of {total}",
|
||||
scanPaths: "Scan paths",
|
||||
scanPathsDescription: "Custom recursive roots; an empty list uses built-in defaults",
|
||||
scanPathAdded: "Scan path added locally; save settings to apply",
|
||||
scanPathRemoved: "Scan path removed locally; save settings to apply",
|
||||
invalidAbsolutePath: "Enter a non-root absolute path",
|
||||
scanOptions: "Scan options",
|
||||
resetAttempts: "Reset attempt counters",
|
||||
resetAttemptsDescription: "This option applies only to the next forced scan.",
|
||||
@@ -550,6 +633,7 @@
|
||||
operationCopy: "Copying",
|
||||
operationMove: "Moving",
|
||||
operationDelete: "Deleting",
|
||||
operationUnpack: "Unpacking",
|
||||
noActiveOperations: "No active operations",
|
||||
calculating: "Calculating…",
|
||||
cancelling: "Cancelling…",
|
||||
@@ -588,6 +672,7 @@
|
||||
mountReadWrite: "Mount R/W",
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
unpack: "Unpack",
|
||||
deleteSource: "Delete source",
|
||||
storageBusyHint: "Wait for the current storage operation to finish",
|
||||
uninstall: "Uninstall",
|
||||
@@ -595,6 +680,8 @@
|
||||
storageBusy: "A storage operation is already running",
|
||||
copyDestination: "Destination folder for copying:",
|
||||
moveDestination: "Destination folder for moving:",
|
||||
unpackDestination: "Destination folder for unpacking. A {title} subfolder will be created:",
|
||||
unpackDeleteConfirm: "Delete the source image only after unpacking completes successfully?\n\nCancel keeps the image.",
|
||||
jobStarted: "{operation} {title} started as job {id}",
|
||||
operationStarted: "Operation started",
|
||||
uninstallConfirm: "Request uninstall of {game}?\n\nThe source image or folder will not be deleted.",
|
||||
@@ -632,8 +719,27 @@
|
||||
perSecond: "/с",
|
||||
time: "Время",
|
||||
cancel: "Отменить",
|
||||
manualDescription: "Дополнительные источники сканирования",
|
||||
manualDescription: "Прямые пути к отдельным папкам игр или образам",
|
||||
add: "Добавить",
|
||||
settings: "Настройки",
|
||||
debugLog: "Журнал отладки",
|
||||
quietMode: "Тихий режим",
|
||||
autoUpdateEmulator: "Автообновление эмулятора",
|
||||
allowLanAccess: "Разрешить доступ из локальной сети",
|
||||
fanTemperature: "Целевая температура",
|
||||
systemControlled: "Управление системой",
|
||||
saveSettings: "Сохранить настройки",
|
||||
settingsSaved: "Настройки сохранены; обновление конфигурации поставлено в очередь",
|
||||
viewLog: "Просмотреть журнал",
|
||||
viewKernelLog: "Просмотреть журнал ядра",
|
||||
debugLogTitle: "Журнал отладки",
|
||||
kernelLogTitle: "Журнал ядра",
|
||||
logBytes: "Показано {shown} из {total}",
|
||||
scanPaths: "Точки сканирования",
|
||||
scanPathsDescription: "Пользовательские корни; при пустом списке используются встроенные пути",
|
||||
scanPathAdded: "Точка добавлена локально; сохраните настройки для применения",
|
||||
scanPathRemoved: "Точка удалена локально; сохраните настройки для применения",
|
||||
invalidAbsolutePath: "Введите абсолютный путь, отличный от корня",
|
||||
scanOptions: "Параметры сканирования",
|
||||
resetAttempts: "Сбросить счётчики попыток",
|
||||
resetAttemptsDescription: "Параметр применяется только к следующему принудительному сканированию.",
|
||||
@@ -663,6 +769,7 @@
|
||||
operationCopy: "Копирование",
|
||||
operationMove: "Перемещение",
|
||||
operationDelete: "Удаление",
|
||||
operationUnpack: "Распаковка",
|
||||
noActiveOperations: "Активных операций нет",
|
||||
calculating: "Подсчёт…",
|
||||
cancelling: "Отмена…",
|
||||
@@ -701,6 +808,7 @@
|
||||
mountReadWrite: "Подключить R/W",
|
||||
copy: "Копировать",
|
||||
move: "Переместить",
|
||||
unpack: "Распаковать",
|
||||
deleteSource: "Удалить источник",
|
||||
storageBusyHint: "Дождитесь завершения текущей операции с хранилищем",
|
||||
uninstall: "Деинсталлировать",
|
||||
@@ -708,6 +816,8 @@
|
||||
storageBusy: "Уже выполняется операция с хранилищем",
|
||||
copyDestination: "Папка назначения для копирования:",
|
||||
moveDestination: "Папка назначения для перемещения:",
|
||||
unpackDestination: "Папка назначения для распаковки. В ней будет создана подпапка {title}:",
|
||||
unpackDeleteConfirm: "Удалить исходный образ только после успешного завершения распаковки?\n\nОтмена сохранит образ.",
|
||||
jobStarted: "{operation} {title} запущено, задача {id}",
|
||||
operationStarted: "Операция запущена",
|
||||
uninstallConfirm: "Запросить деинсталляцию {game}?\n\nИсходный образ или папка не удаляются.",
|
||||
@@ -751,6 +861,7 @@
|
||||
$("version").textContent = `ShadowMount ${state.apiVersion.shadowmount_version} · API v${state.apiVersion.api_version}`;
|
||||
}
|
||||
if (state.connectionStatus) setConnection(state.connectionStatus, state.connectionKey);
|
||||
if (!$("logModal").hidden) $("logTitle").textContent = t(state.logTitleKey);
|
||||
if (persist) {
|
||||
try { localStorage.setItem("shadowmount.language", currentLanguage); } catch (_) { /* Keep the in-memory selection. */ }
|
||||
}
|
||||
@@ -947,6 +1058,7 @@
|
||||
function storageOperationLabel(operation) {
|
||||
if (operation === "move") return t("operationMove");
|
||||
if (operation === "delete") return t("operationDelete");
|
||||
if (operation === "unpack") return t("operationUnpack");
|
||||
return t("operationCopy");
|
||||
}
|
||||
|
||||
@@ -1144,6 +1256,121 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderScanPaths() {
|
||||
const list = $("scanPathList");
|
||||
list.replaceChildren();
|
||||
if (!state.scanPaths.length) {
|
||||
list.append(node("span", "subtle", t("listEmpty")));
|
||||
return;
|
||||
}
|
||||
state.scanPaths.forEach((path, index) => {
|
||||
const row = node("div", "manual-row");
|
||||
const value = node("span", "manual-path", path);
|
||||
value.title = path;
|
||||
const remove = node("button", "btn small danger", t("remove"));
|
||||
remove.type = "button";
|
||||
remove.addEventListener("click", () => {
|
||||
state.scanPaths.splice(index, 1);
|
||||
renderScanPaths();
|
||||
toast(t("scanPathRemoved"));
|
||||
});
|
||||
row.append(value, remove);
|
||||
list.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSettings() {
|
||||
try {
|
||||
const settings = await api("/settings");
|
||||
$("debugEnabled").checked = Boolean(settings.debug);
|
||||
$("quietMode").checked = Boolean(settings.quiet_mode);
|
||||
$("updateEmulators").checked = Boolean(settings.update_emulators);
|
||||
$("allowLanAccess").checked = Boolean(settings.allow_lan_access);
|
||||
$("fanTemperature").value = String(settings.fan_target_temperature || 0);
|
||||
state.scanPaths = Array.isArray(settings.scan_paths) ? settings.scan_paths.slice() : [];
|
||||
renderScanPaths();
|
||||
} catch (error) {
|
||||
$("scanPathList").replaceChildren(node("span", "subtle", errorMessage(error)));
|
||||
}
|
||||
}
|
||||
|
||||
function addScanPath(event) {
|
||||
event.preventDefault();
|
||||
const input = $("scanPath");
|
||||
let path = input.value.trim();
|
||||
while (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
||||
if (!path.startsWith("/") || path === "/") {
|
||||
toast(t("invalidAbsolutePath"), true);
|
||||
return;
|
||||
}
|
||||
if (!state.scanPaths.includes(path)) state.scanPaths.push(path);
|
||||
input.value = "";
|
||||
renderScanPaths();
|
||||
toast(t("scanPathAdded"));
|
||||
}
|
||||
|
||||
async function saveSettings(event) {
|
||||
event.preventDefault();
|
||||
const submit = $("settingsForm").querySelector('button[type="submit"]');
|
||||
submit.disabled = true;
|
||||
try {
|
||||
await api("/settings/update", {
|
||||
debug: $("debugEnabled").checked,
|
||||
quiet_mode: $("quietMode").checked,
|
||||
update_emulators: $("updateEmulators").checked,
|
||||
allow_lan_access: $("allowLanAccess").checked,
|
||||
fan_target_temperature: Number($("fanTemperature").value),
|
||||
scan_paths: state.scanPaths
|
||||
});
|
||||
logActivity(t("settingsSaved"));
|
||||
toast(t("settingsSaved"));
|
||||
setTimeout(refreshSettings, 1200);
|
||||
} catch (error) {
|
||||
logActivity(errorMessage(error), "error");
|
||||
toast(errorMessage(error), true);
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLog() {
|
||||
$("refreshLog").disabled = true;
|
||||
try {
|
||||
const result = await api(state.logRoute, { max_bytes: 262144 });
|
||||
$("logContent").textContent = result.content || "";
|
||||
$("logSubtitle").textContent = t("logBytes", {
|
||||
shown: formatBytes(Number(result.returned_bytes)),
|
||||
total: formatBytes(Number(result.total_bytes !== undefined
|
||||
? result.total_bytes : result.file_size))
|
||||
});
|
||||
$("logContent").scrollTop = $("logContent").scrollHeight;
|
||||
} catch (error) {
|
||||
$("logContent").textContent = errorMessage(error);
|
||||
$("logSubtitle").textContent = "";
|
||||
} finally {
|
||||
$("refreshLog").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showLog(route, titleKey, triggerId) {
|
||||
state.logRoute = route;
|
||||
state.logTitleKey = titleKey;
|
||||
state.logTriggerId = triggerId;
|
||||
$("logModal").hidden = false;
|
||||
$("logTitle").textContent = t(titleKey);
|
||||
$("logContent").textContent = t("loading");
|
||||
$("refreshLog").focus();
|
||||
refreshLog();
|
||||
}
|
||||
|
||||
function showDebugLog() { showLog("/debug-log", "debugLogTitle", "viewLog"); }
|
||||
function showKernelLog() { showLog("/kernel-log", "kernelLogTitle", "viewKernelLog"); }
|
||||
|
||||
function closeDebugLog() {
|
||||
$("logModal").hidden = true;
|
||||
$(state.logTriggerId).focus();
|
||||
}
|
||||
|
||||
async function addManual(event) {
|
||||
event.preventDefault();
|
||||
const input = $("manualPath");
|
||||
@@ -1270,16 +1497,22 @@
|
||||
const storageBusy = Boolean(state.storageJob && state.storageJob.active);
|
||||
const copy = actionButton(t("copy"), () => storageOperation(game, "copy"));
|
||||
const move = actionButton(t("move"), () => storageOperation(game, "move"));
|
||||
const unpack = game.image_backed
|
||||
? actionButton(t("unpack"), () => unpackImage(game))
|
||||
: null;
|
||||
const remove = actionButton(t("deleteSource"), () => deleteSource(game), "danger");
|
||||
copy.disabled = storageBusy;
|
||||
move.disabled = storageBusy;
|
||||
if (unpack) unpack.disabled = storageBusy || game.mounted;
|
||||
remove.disabled = storageBusy;
|
||||
if (storageBusy) {
|
||||
copy.title = t("storageBusyHint");
|
||||
move.title = copy.title;
|
||||
if (unpack) unpack.title = copy.title;
|
||||
remove.title = copy.title;
|
||||
}
|
||||
actions.append(copy, move);
|
||||
if (unpack) actions.append(unpack);
|
||||
actions.append(actionButton(t("uninstall"), () => uninstallGame(game), "danger"));
|
||||
actions.append(remove);
|
||||
if (focusRuntimeAction) runtimeAction.focus();
|
||||
@@ -1313,6 +1546,21 @@
|
||||
await startStorageJob(game, operation, { title_id: game.title_id, destination_dir: destination });
|
||||
}
|
||||
|
||||
async function unpackImage(game) {
|
||||
if (state.storageJob && state.storageJob.active) {
|
||||
toast(t("storageBusy"), true);
|
||||
return;
|
||||
}
|
||||
const destination = prompt(t("unpackDestination", { title: game.title_id }), "/mnt/usb0");
|
||||
if (!destination) return;
|
||||
const deleteSourceAfter = confirm(t("unpackDeleteConfirm"));
|
||||
await startStorageJob(game, "unpack", {
|
||||
title_id: game.title_id,
|
||||
destination_dir: destination,
|
||||
delete_source: deleteSourceAfter
|
||||
});
|
||||
}
|
||||
|
||||
async function startStorageJob(game, operation, payload) {
|
||||
try {
|
||||
const job = await api(`/games/${operation}`, payload);
|
||||
@@ -1351,6 +1599,7 @@
|
||||
renderGames();
|
||||
if (state.storageJob) renderStorageJob(state.storageJob);
|
||||
if (state.selected) renderDetails(state.selected);
|
||||
renderScanPaths();
|
||||
refreshStorage();
|
||||
refreshManual();
|
||||
});
|
||||
@@ -1359,12 +1608,28 @@
|
||||
$("scanBtn").addEventListener("click", requestScan);
|
||||
$("cancelTransfer").addEventListener("click", cancelStorageJob);
|
||||
$("manualForm").addEventListener("submit", addManual);
|
||||
$("settingsForm").addEventListener("submit", saveSettings);
|
||||
$("scanPathForm").addEventListener("submit", addScanPath);
|
||||
$("viewLog").addEventListener("click", showDebugLog);
|
||||
$("viewKernelLog").addEventListener("click", showKernelLog);
|
||||
$("refreshLog").addEventListener("click", refreshLog);
|
||||
$("closeLog").addEventListener("click", closeDebugLog);
|
||||
$("logModal").addEventListener("click", (event) => { if (event.target === $("logModal")) closeDebugLog(); });
|
||||
$("closeModal").addEventListener("click", closeModal);
|
||||
$("modal").addEventListener("click", (event) => { if (event.target === $("modal")) closeModal(); });
|
||||
document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !$("modal").hidden) closeModal(); });
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (!$("logModal").hidden) closeDebugLog();
|
||||
else if (!$("modal").hidden) closeModal();
|
||||
});
|
||||
|
||||
for (let temperature = 50; temperature <= 91; temperature += 1) {
|
||||
const option = node("option", "", `${temperature} °C`);
|
||||
option.value = String(temperature);
|
||||
$("fanTemperature").append(option);
|
||||
}
|
||||
applyLanguage(currentLanguage);
|
||||
Promise.all([refreshAll(), refreshManual(), refreshStorageJob()]);
|
||||
Promise.all([refreshAll(), refreshManual(), refreshSettings(), refreshStorageJob()]);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user