From 5b7ab6ef8c703d475afe99d87feb8ec231b54e13 Mon Sep 17 00:00:00 2001 From: Roman Tarasov Date: Wed, 2 Sep 2026 01:54:06 +0300 Subject: [PATCH] Expand web API and add embedded library interface --- Makefile | 9 +- config.ini.example | 1 + docs/api.md | 54 ++++- docs/openapi.yaml | 289 +++++++++++++++++++++- include/lang/ar_sa.inc | 3 + include/lang/cs_cz.inc | 3 + include/lang/da_dk.inc | 3 + include/lang/de_de.inc | 3 + include/lang/el_gr.inc | 3 + include/lang/en_gb.inc | 3 + include/lang/en_us.inc | 2 + include/lang/es_es.inc | 3 + include/lang/es_mx.inc | 3 + include/lang/fi_fi.inc | 3 + include/lang/fr_ca.inc | 3 + include/lang/fr_fr.inc | 3 + include/lang/hu_hu.inc | 3 + include/lang/id_id.inc | 3 + include/lang/it_it.inc | 3 + include/lang/ja_jp.inc | 3 + include/lang/ko_kr.inc | 3 + include/lang/nl_nl.inc | 3 + include/lang/no_no.inc | 3 + include/lang/pl_pl.inc | 3 + include/lang/pt_br.inc | 3 + include/lang/pt_pt.inc | 3 + include/lang/ro_ro.inc | 3 + include/lang/ru_ru.inc | 3 + include/lang/sv_se.inc | 3 + include/lang/th_th.inc | 3 + include/lang/tr_tr.inc | 3 + include/lang/uk_ua.inc | 3 + include/lang/vi_vn.inc | 3 + include/lang/zh_cn.inc | 3 + include/lang/zh_tw.inc | 3 + include/sm_api_protocol.h | 5 + include/sm_config_mount.h | 12 + include/sm_l10n.h | 1 + include/sm_mdbg.h | 5 + include/sm_types.h | 1 + src/main.c | 8 +- src/sm_api_service.c | 477 +++++++++++++++++++++++++++++++++++-- src/sm_config_mount.c | 220 ++++++++++++++++- src/sm_mdbg.c | 93 +++++++- src/sm_scanner.c | 3 +- src/sm_shellcore_service.c | 1 + tools/api_test.py | 90 ++++++- web/index.html | 303 +++++++++++++++++++++-- 48 files changed, 1599 insertions(+), 65 deletions(-) diff --git a/Makefile b/Makefile index 06a575e..7dab025 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/config.ini.example b/config.ini.example index a9ced92..7ab8af2 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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 diff --git a/docs/api.md b/docs/api.md index 1bcc464..595ebf4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 `/`, +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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b2a091b..abe1341 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 diff --git a/include/lang/ar_sa.inc b/include/lang/ar_sa.inc index 227f0b2..9f4cbfb 100644 --- a/include/lang/ar_sa.inc +++ b/include/lang/ar_sa.inc @@ -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. */ diff --git a/include/lang/cs_cz.inc b/include/lang/cs_cz.inc index 3fd8401..4803ee1 100644 --- a/include/lang/cs_cz.inc +++ b/include/lang/cs_cz.inc @@ -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. */ diff --git a/include/lang/da_dk.inc b/include/lang/da_dk.inc index f03ae1f..d96d620 100644 --- a/include/lang/da_dk.inc +++ b/include/lang/da_dk.inc @@ -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. */ diff --git a/include/lang/de_de.inc b/include/lang/de_de.inc index 165dddd..beeac9f 100644 --- a/include/lang/de_de.inc +++ b/include/lang/de_de.inc @@ -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. */ diff --git a/include/lang/el_gr.inc b/include/lang/el_gr.inc index aece4c8..c3f6681 100644 --- a/include/lang/el_gr.inc +++ b/include/lang/el_gr.inc @@ -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. */ diff --git a/include/lang/en_gb.inc b/include/lang/en_gb.inc index 63d5ede..220cc59 100644 --- a/include/lang/en_gb.inc +++ b/include/lang/en_gb.inc @@ -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. */ diff --git a/include/lang/en_us.inc b/include/lang/en_us.inc index c1c5f08..98ec1dc 100644 --- a/include/lang/en_us.inc +++ b/include/lang/en_us.inc @@ -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.", diff --git a/include/lang/es_es.inc b/include/lang/es_es.inc index adf20f7..faba6c1 100644 --- a/include/lang/es_es.inc +++ b/include/lang/es_es.inc @@ -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. */ diff --git a/include/lang/es_mx.inc b/include/lang/es_mx.inc index 6c01f5a..f4b83fe 100644 --- a/include/lang/es_mx.inc +++ b/include/lang/es_mx.inc @@ -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. */ diff --git a/include/lang/fi_fi.inc b/include/lang/fi_fi.inc index f82386c..07b17e6 100644 --- a/include/lang/fi_fi.inc +++ b/include/lang/fi_fi.inc @@ -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. */ diff --git a/include/lang/fr_ca.inc b/include/lang/fr_ca.inc index 8022a12..fddfabe 100644 --- a/include/lang/fr_ca.inc +++ b/include/lang/fr_ca.inc @@ -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. */ diff --git a/include/lang/fr_fr.inc b/include/lang/fr_fr.inc index 44bcd91..5aaeb74 100644 --- a/include/lang/fr_fr.inc +++ b/include/lang/fr_fr.inc @@ -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. */ diff --git a/include/lang/hu_hu.inc b/include/lang/hu_hu.inc index cba16b2..80ee8cb 100644 --- a/include/lang/hu_hu.inc +++ b/include/lang/hu_hu.inc @@ -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. */ diff --git a/include/lang/id_id.inc b/include/lang/id_id.inc index 57361a3..9cedef2 100644 --- a/include/lang/id_id.inc +++ b/include/lang/id_id.inc @@ -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. */ diff --git a/include/lang/it_it.inc b/include/lang/it_it.inc index 1b6953a..2d8d506 100644 --- a/include/lang/it_it.inc +++ b/include/lang/it_it.inc @@ -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. */ diff --git a/include/lang/ja_jp.inc b/include/lang/ja_jp.inc index bd69e43..a253621 100644 --- a/include/lang/ja_jp.inc +++ b/include/lang/ja_jp.inc @@ -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. */ diff --git a/include/lang/ko_kr.inc b/include/lang/ko_kr.inc index 891aabc..ea90192 100644 --- a/include/lang/ko_kr.inc +++ b/include/lang/ko_kr.inc @@ -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. */ diff --git a/include/lang/nl_nl.inc b/include/lang/nl_nl.inc index 5009e77..b41dbb1 100644 --- a/include/lang/nl_nl.inc +++ b/include/lang/nl_nl.inc @@ -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. */ diff --git a/include/lang/no_no.inc b/include/lang/no_no.inc index 588de64..47fa06f 100644 --- a/include/lang/no_no.inc +++ b/include/lang/no_no.inc @@ -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. */ diff --git a/include/lang/pl_pl.inc b/include/lang/pl_pl.inc index 506d387..223ad06 100644 --- a/include/lang/pl_pl.inc +++ b/include/lang/pl_pl.inc @@ -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. */ diff --git a/include/lang/pt_br.inc b/include/lang/pt_br.inc index 4959e5e..868844d 100644 --- a/include/lang/pt_br.inc +++ b/include/lang/pt_br.inc @@ -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. */ diff --git a/include/lang/pt_pt.inc b/include/lang/pt_pt.inc index 089ab47..e4f00d0 100644 --- a/include/lang/pt_pt.inc +++ b/include/lang/pt_pt.inc @@ -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. */ diff --git a/include/lang/ro_ro.inc b/include/lang/ro_ro.inc index 90c8cbb..7a40ebb 100644 --- a/include/lang/ro_ro.inc +++ b/include/lang/ro_ro.inc @@ -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. */ diff --git a/include/lang/ru_ru.inc b/include/lang/ru_ru.inc index 49ffa2d..83c514f 100644 --- a/include/lang/ru_ru.inc +++ b/include/lang/ru_ru.inc @@ -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. */ diff --git a/include/lang/sv_se.inc b/include/lang/sv_se.inc index 1c5efdb..c53d565 100644 --- a/include/lang/sv_se.inc +++ b/include/lang/sv_se.inc @@ -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. */ diff --git a/include/lang/th_th.inc b/include/lang/th_th.inc index 71e4130..9460337 100644 --- a/include/lang/th_th.inc +++ b/include/lang/th_th.inc @@ -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. */ diff --git a/include/lang/tr_tr.inc b/include/lang/tr_tr.inc index f67a786..bf9cb04 100644 --- a/include/lang/tr_tr.inc +++ b/include/lang/tr_tr.inc @@ -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. */ diff --git a/include/lang/uk_ua.inc b/include/lang/uk_ua.inc index ef136b0..e5c37c0 100644 --- a/include/lang/uk_ua.inc +++ b/include/lang/uk_ua.inc @@ -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. */ diff --git a/include/lang/vi_vn.inc b/include/lang/vi_vn.inc index 14aa425..bcb601d 100644 --- a/include/lang/vi_vn.inc +++ b/include/lang/vi_vn.inc @@ -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. */ diff --git a/include/lang/zh_cn.inc b/include/lang/zh_cn.inc index ddbb2a3..3543445 100644 --- a/include/lang/zh_cn.inc +++ b/include/lang/zh_cn.inc @@ -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. */ diff --git a/include/lang/zh_tw.inc b/include/lang/zh_tw.inc index c8817d0..bb45403 100644 --- a/include/lang/zh_tw.inc +++ b/include/lang/zh_tw.inc @@ -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. */ diff --git a/include/sm_api_protocol.h b/include/sm_api_protocol.h index 5a90051..859826e 100644 --- a/include/sm_api_protocol.h +++ b/include/sm_api_protocol.h @@ -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 diff --git a/include/sm_config_mount.h b/include/sm_config_mount.h index 72651b7..e9ae6f1 100644 --- a/include/sm_config_mount.h +++ b/include/sm_config_mount.h @@ -2,6 +2,7 @@ #define SM_CONFIG_MOUNT_H #include +#include #include 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. diff --git a/include/sm_l10n.h b/include/sm_l10n.h index 3771f41..e36a636 100644 --- a/include/sm_l10n.h +++ b/include/sm_l10n.h @@ -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; diff --git a/include/sm_mdbg.h b/include/sm_mdbg.h index 8866932..756bd9a 100644 --- a/include/sm_mdbg.h +++ b/include/sm_mdbg.h @@ -2,6 +2,7 @@ #define SM_MDBG_H #include +#include #include #include @@ -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 diff --git a/include/sm_types.h b/include/sm_types.h index 2968118..2846188 100644 --- a/include/sm_types.h +++ b/include/sm_types.h @@ -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; diff --git a/src/main.c b/src/main.c index ef2b694..eb190a5 100644 --- a/src/main.c +++ b/src/main.c @@ -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) { diff --git a/src/sm_api_service.c b/src/sm_api_service.c index 7e4363f..2fd8e8a 100644 --- a/src/sm_api_service.c +++ b/src/sm_api_service.c @@ -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) { diff --git a/src/sm_config_mount.c b/src/sm_config_mount.c index 6c2b77e..e00c848 100644 --- a/src/sm_config_mount.c +++ b/src/sm_config_mount.c @@ -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; +} diff --git a/src/sm_mdbg.c b/src/sm_mdbg.c index a42e81f..bd4cead 100644 --- a/src/sm_mdbg.c +++ b/src/sm_mdbg.c @@ -1,6 +1,7 @@ #include "sm_platform.h" #include +#include #include #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) { diff --git a/src/sm_scanner.c b/src/sm_scanner.c index 6b87edf..767cdbb 100644 --- a/src/sm_scanner.c +++ b/src/sm_scanner.c @@ -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)); diff --git a/src/sm_shellcore_service.c b/src/sm_shellcore_service.c index 29a330a..6dc3727 100644 --- a/src/sm_shellcore_service.c +++ b/src/sm_shellcore_service.c @@ -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) { diff --git a/tools/api_test.py b/tools/api_test.py index 2adf3f7..f91e732 100644 --- a/tools/api_test.py +++ b/tools/api_test.py @@ -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) diff --git a/web/index.html b/web/index.html index 98f52de..4a8390d 100644 --- a/web/index.html +++ b/web/index.html @@ -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 @@

Connecting to API…

-
- - Checking +
+
+ + Checking +
+
@@ -369,10 +397,6 @@ - @@ -428,11 +452,31 @@
-
-

manual.lst

-

Additional scan sources

+
+

Settings

+
+ + + + + +
+ + + +
+
+

Scan paths

+

Custom recursive roots; defaults stay hidden

+
+ + +
+
Loading…
+

manual.lst

+

Direct paths to individual game folders or images

- +
Loading…
@@ -466,6 +510,22 @@ + +