Expand web API and add embedded library interface

This commit is contained in:
Roman Tarasov committed 2026-09-02 01:54:06 +03:00
1 parent f81eb60e94
commit 5b7ab6ef8c
48 files changed
+1599 -65

No files matched your search

+6 -3
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
+5
View File
@@ -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
+12
View File
@@ -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.
+1
View File
@@ -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;
+5
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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">&times;</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>