From f81eb60e94aea2bf3ecaf6a54cc08b4e28b2ce8e Mon Sep 17 00:00:00 2001 From: Roman Tarasov Date: Wed, 2 Sep 2026 00:01:42 +0300 Subject: [PATCH] Improve multilingual library controls and game actions (web) --- .github/workflows/ps5.yml | 12 + Makefile | 8 +- docs/api.md | 2 +- docs/openapi.yaml | 9 +- include/sm_icon_thumb.h | 16 + include/sm_paths.h | 1 + src/sm_api_service.c | 173 ++++++++--- src/sm_icon_thumb.c | 195 ++++++++++++ tools/api_test.py | 33 +++ web/index.html | 606 +++++++++++++++++++++++++++++--------- 10 files changed, 858 insertions(+), 197 deletions(-) create mode 100644 include/sm_icon_thumb.h create mode 100644 src/sm_icon_thumb.c diff --git a/.github/workflows/ps5.yml b/.github/workflows/ps5.yml index 502c0b6..0586577 100644 --- a/.github/workflows/ps5.yml +++ b/.github/workflows/ps5.yml @@ -80,6 +80,18 @@ jobs: makepkg -c -f sudo pacman --noconfirm -U ./ps5-payload-*.pkg.tar.gz + - name: Build PS5 zlib package + working-directory: pacbrew-repo/zlib + run: | + makepkg -c -f + sudo pacman --noconfirm -U ./ps5-payload-*.pkg.tar.gz + + - name: Build PS5 libpng package + working-directory: pacbrew-repo/libpng + run: | + makepkg -c -f + sudo pacman --noconfirm -U ./ps5-payload-*.pkg.tar.gz + - name: Build PS5 libmicrohttpd package working-directory: pacbrew-repo/libmicrohttpd run: | diff --git a/Makefile b/Makefile index bfd961a..06a575e 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,10 @@ BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) # Compiler and dependency flags HOMEBREW_ROOT := $(PS5_PAYLOAD_SDK)/target/user/homebrew -JSON_CFLAGS := -I$(HOMEBREW_ROOT)/include +HOMEBREW_CFLAGS := -I$(HOMEBREW_ROOT)/include MHD_LIB := $(HOMEBREW_ROOT)/lib/libmicrohttpd.a +PNG_LIB := $(HOMEBREW_ROOT)/lib/libpng16.a +ZLIB_LIB := $(HOMEBREW_ROOT)/lib/libz.a CFLAGS := -O3 -flto=thin -DNDEBUG -ffunction-sections -fdata-sections -Wall -Wextra -Wstrict-prototypes -Wmissing-prototypes -Werror=strict-prototypes -Werror=missing-prototypes -D_BSD_SOURCE -std=gnu11 -Iinclude -Isrc CFLAGS += -DSHADOWMOUNT_VERSION=\"$(VERSION_TAG)\" @@ -15,7 +17,7 @@ CFLAGS += -DSHADOWMOUNT_VERSION=\"$(VERSION_TAG)\" LDFLAGS := -flto=thin -Wl,--gc-sections # Libraries -LIBS := -lSceNotification -lSceSystemService -lSceUserService -lSceAppInstUtil -lSceNet -lSceSsl -lSceHttp2 -lsqlite3 $(HOMEBREW_ROOT)/lib/libjson-c.a $(MHD_LIB) -lpthread -lm +LIBS := -lSceNotification -lSceSystemService -lSceUserService -lSceAppInstUtil -lSceNet -lSceSsl -lSceHttp2 -lsqlite3 $(HOMEBREW_ROOT)/lib/libjson-c.a $(MHD_LIB) $(PNG_LIB) $(ZLIB_LIB) -lpthread -lm 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 @@ -50,7 +52,7 @@ src/notify_icon_asset.c: smp_icon.png src/config_ini_example_asset.c: config.ini.example xxd -i $< > $@ -src/sm_api_service.o: CFLAGS += $(JSON_CFLAGS) +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 diff --git a/docs/api.md b/docs/api.md index 18e085b..1bcc464 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,7 +48,7 @@ device. | `/api/v1/manual/remove` | `{"path":"/mnt/usb0/games/PPSA12345"}` | Remove matching source lines from `manual.lst` | | `/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` | GET | Stream the PNG referenced by app.db `icon0Info` | +| `/api/v1/games/icon?title_id=PPSA12345[&size=thumb]` | GET | Stream the full PNG or a cached 128x128 thumbnail | | `/api/v1/games/mount` | `{"title_id":"PPSA12345","mode":"ro"}` | Mount a managed game, optionally overriding its image mode with `ro`/`rw` | | `/api/v1/games/unmount` | `{"title_id":"PPSA12345"}` | Unmount a managed game | | `/api/v1/games/uninstall` | `{"title_id":"PPSA12345"}` | Request uninstallation through AppInstUtil | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d228551..b2a091b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -344,7 +344,7 @@ paths: /api/v1/games/icon: get: operationId: getGameIcon - summary: Stream the icon referenced by app.db icon0Info + summary: Stream a game icon or a cached 128x128 thumbnail tags: [Games] parameters: - name: title_id @@ -352,6 +352,13 @@ paths: required: true schema: $ref: "#/components/schemas/TitleId" + - name: size + in: query + required: false + description: Generate and return the cached thumbnail variant + schema: + type: string + enum: [thumb] responses: "200": description: PNG icon diff --git a/include/sm_icon_thumb.h b/include/sm_icon_thumb.h new file mode 100644 index 0000000..ca75bed --- /dev/null +++ b/include/sm_icon_thumb.h @@ -0,0 +1,16 @@ +#ifndef SM_ICON_THUMB_H +#define SM_ICON_THUMB_H + +#include +#include + +#include "sm_limits.h" + +#define SM_ICON_THUMB_SIZE 128u + +// Return a cached thumbnail path, creating it atomically when necessary. +bool sm_icon_thumbnail_path(const char *title_id, const char *source_path, + const struct stat *source_st, + char out[MAX_PATH]); + +#endif diff --git a/include/sm_paths.h b/include/sm_paths.h index 4a2a403..d2b67e8 100644 --- a/include/sm_paths.h +++ b/include/sm_paths.h @@ -11,6 +11,7 @@ "https://github.com/drakmor/ampr_emu/releases/latest/download/" \ "libSceAmpr.sprx" #define FAKELIB_CACHE_PATH "/data/shadowmount/cache" +#define ICON_CACHE_DIR "/data/shadowmount/icon-cache" #define LOG_DIR "/data/shadowmount" #define LOG_FILE "/data/shadowmount/debug.log" #define LOG_FILE_PREV "/data/shadowmount/debug.log.1" diff --git a/src/sm_api_service.c b/src/sm_api_service.c index 6a0e6c2..7e4363f 100644 --- a/src/sm_api_service.c +++ b/src/sm_api_service.c @@ -15,6 +15,7 @@ #include "sm_game_cache.h" #include "sm_game_lifecycle.h" #include "sm_gameinfo.h" +#include "sm_icon_thumb.h" #include "sm_image.h" #include "sm_image_cache.h" #include "sm_image_index.h" @@ -38,9 +39,9 @@ #define SM_API_RETRY_INITIAL_MS 250u #define SM_API_RETRY_MAX_MS 5000u #define SM_API_ROUTE_SIZE 128u -#define SM_API_WORKER_COUNT 4u -#define SM_API_CONNECTION_LIMIT 16u -#define SM_API_CONNECTION_TIMEOUT_SECONDS 5u +#define SM_API_WORKER_COUNT 8u +#define SM_API_CONNECTION_LIMIT 32u +#define SM_API_CONNECTION_TIMEOUT_SECONDS 15u #define SM_API_LISTEN_BACKLOG 32u #define SM_API_CONNECTION_MEMORY_LIMIT 16384u #define SM_API_STORAGE_STOP_LOG_SECONDS 5 @@ -810,9 +811,67 @@ static bool api_listener_stop_requested(void *ctx) { return stopping; } +static bool copy_regular_file_path(const char *path, char out[MAX_PATH]) { + struct stat st; + if (!path || path[0] == '\0' || stat(path, &st) != 0 || + !S_ISREG(st.st_mode) || st.st_size <= 0) { + return false; + } + (void)strlcpy(out, path, MAX_PATH); + return true; +} + +static bool resolve_game_icon_path(const char *title_id, + const char *recorded_path, + char out[MAX_PATH]) { + char appmeta_root[MAX_PATH]; + char app_sce_sys_root[MAX_PATH]; + char candidate[MAX_PATH]; + int appmeta_written = snprintf(appmeta_root, sizeof(appmeta_root), "%s/%s", + APPMETA_BASE, title_id); + int app_written = snprintf(app_sce_sys_root, sizeof(app_sce_sys_root), + "%s/%s/sce_sys", APP_BASE, title_id); + if (appmeta_written < 0 || + (size_t)appmeta_written >= sizeof(appmeta_root) || app_written < 0 || + (size_t)app_written >= sizeof(app_sce_sys_root)) { + return false; + } + + int written = snprintf(candidate, sizeof(candidate), "%s/%s/icon0.png", + APP_BASE, title_id); + if (written > 0 && (size_t)written < sizeof(candidate) && + copy_regular_file_path(candidate, out)) { + return true; + } + written = snprintf(candidate, sizeof(candidate), "%s/icon0.png", + appmeta_root); + if (written > 0 && (size_t)written < sizeof(candidate) && + copy_regular_file_path(candidate, out)) { + return true; + } + + if (recorded_path && recorded_path[0] != '\0') { + (void)strlcpy(candidate, recorded_path, sizeof(candidate)); + char *query = strchr(candidate, '?'); + if (query) + *query = '\0'; + if ((path_matches_root_or_child(candidate, appmeta_root) || + path_matches_root_or_child(candidate, app_sce_sys_root)) && + copy_regular_file_path(candidate, out)) { + return true; + } + } + + written = snprintf(candidate, sizeof(candidate), "%s/icon0.png", + app_sce_sys_root); + return written > 0 && (size_t)written < sizeof(candidate) && + copy_regular_file_path(candidate, out); +} + static struct json_object *game_to_json( const sm_game_cache_snapshot_entry_t *source, - const sm_app_db_game_info_t *metadata, bool include_size) { + const sm_app_db_game_info_t *metadata, bool include_size, + bool thumbnail_icon) { struct json_object *item = json_object_new_object(); if (!item) return NULL; @@ -836,10 +895,19 @@ static struct json_object *game_to_json( const char *last_access_time = metadata ? metadata->last_access_time : ""; const char *install_time = metadata ? metadata->install_time : ""; char icon_url[SM_API_ROUTE_SIZE + MAX_TITLE_ID + 32u]; + char icon_path[MAX_PATH]; icon_url[0] = '\0'; - if (metadata && metadata->icon_path[0] != '\0') { - (void)snprintf(icon_url, sizeof(icon_url), "%s?title_id=%s", - SM_API_ROUTE_GAME_ICON, source->title_id); + if (resolve_game_icon_path(source->title_id, + metadata ? metadata->icon_path : NULL, + icon_path)) { + struct stat icon_st; + if (stat(icon_path, &icon_st) == 0) { + const char *size_arg = thumbnail_icon ? "&size=thumb" : ""; + (void)snprintf( + icon_url, sizeof(icon_url), "%s?title_id=%s%s&v=%lld-%lld", + SM_API_ROUTE_GAME_ICON, source->title_id, size_arg, + (long long)icon_st.st_mtime, (long long)icon_st.st_size); + } } if (!add_json_string(item, "path", source_info.physical_path) || @@ -992,7 +1060,7 @@ static void handle_games(struct MHD_Connection *fd, const sm_app_db_game_info_t *game_metadata = app_db_find_game_info( metadata, metadata_count, snapshot[i].title_id); struct json_object *game = - game_to_json(&snapshot[i], game_metadata, include_size); + game_to_json(&snapshot[i], game_metadata, include_size, true); if (!game || json_object_array_add(games, game) != 0) { if (game) json_object_put(game); @@ -1082,7 +1150,7 @@ static void handle_game_info(struct MHD_Connection *connection, const sm_app_db_game_info_t *game_metadata = app_db_find_game_info(metadata, metadata_count, title_id); struct json_object *response = - game_to_json(&game_entry, game_metadata, true); + game_to_json(&game_entry, game_metadata, true, false); free(metadata); if (!response) { send_out_of_memory_response(connection); @@ -1105,48 +1173,34 @@ static bool handle_game_icon(struct MHD_Connection *connection) { connection, 400, EINVAL, "title_id query parameter must be a valid PS4 or PS5 title ID"); } - - sm_app_db_game_info_t *metadata = NULL; - size_t metadata_count = 0; - if (!app_db_game_info_snapshot(&metadata, &metadata_count)) { - int status = errno != 0 ? errno : EIO; - return send_error_response(connection, operation_http_status(status), - status, strerror(status)); - } - const sm_app_db_game_info_t *game_metadata = - app_db_find_game_info(metadata, metadata_count, title_id); - if (!game_metadata || game_metadata->icon_path[0] == '\0') { - free(metadata); - return send_error_response(connection, 404, ENOENT, - "game icon is not available"); + const char *size = MHD_lookup_connection_value( + connection, MHD_GET_ARGUMENT_KIND, "size"); + bool want_thumbnail = size && size[0] != '\0'; + if (want_thumbnail && strcmp(size, "thumb") != 0) { + return send_error_response(connection, 400, EINVAL, + "size query parameter must be thumb"); } char icon_path[MAX_PATH]; - (void)strlcpy(icon_path, game_metadata->icon_path, sizeof(icon_path)); - free(metadata); - char *query = strchr(icon_path, '?'); - if (query) - *query = '\0'; - - char appmeta_root[MAX_PATH]; - char app_sce_sys_root[MAX_PATH]; - int appmeta_written = snprintf(appmeta_root, sizeof(appmeta_root), "%s/%s", - APPMETA_BASE, title_id); - int app_written = snprintf(app_sce_sys_root, sizeof(app_sce_sys_root), - "%s/%s/sce_sys", APP_BASE, title_id); - if (appmeta_written < 0 || - (size_t)appmeta_written >= sizeof(appmeta_root) || app_written < 0 || - (size_t)app_written >= sizeof(app_sce_sys_root) || - (!path_matches_root_or_child(icon_path, appmeta_root) && - !path_matches_root_or_child(icon_path, app_sce_sys_root))) { - return send_error_response(connection, 404, ENOENT, - "game icon path is not valid"); - } - - struct stat st; - if (stat(icon_path, &st) != 0 || !S_ISREG(st.st_mode)) { - return send_error_response(connection, 404, ENOENT, - "game icon is not available"); + if (!resolve_game_icon_path(title_id, NULL, icon_path)) { + sm_app_db_game_info_t *metadata = NULL; + size_t metadata_count = 0; + if (!app_db_game_info_snapshot(&metadata, &metadata_count)) { + int status = errno != 0 ? errno : EIO; + return send_error_response(connection, operation_http_status(status), + status, strerror(status)); + } + const sm_app_db_game_info_t *game_metadata = + app_db_find_game_info(metadata, metadata_count, title_id); + char recorded_path[MAX_PATH]; + (void)strlcpy(recorded_path, + game_metadata ? game_metadata->icon_path : "", + sizeof(recorded_path)); + free(metadata); + if (!resolve_game_icon_path(title_id, recorded_path, icon_path)) { + return send_error_response(connection, 404, ENOENT, + "game icon is not available"); + } } int fd = open(icon_path, O_RDONLY); if (fd < 0) { @@ -1154,8 +1208,29 @@ static bool handle_game_icon(struct MHD_Connection *connection) { return send_error_response(connection, operation_http_status(status), status, strerror(status)); } + struct stat st; + if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode) || st.st_size <= 0) { + (void)close(fd); + return send_error_response(connection, 404, ENOENT, + "game icon is not available"); + } + if (want_thumbnail) { + char thumbnail_path[MAX_PATH]; + if (sm_icon_thumbnail_path(title_id, icon_path, &st, thumbnail_path)) { + int thumbnail_fd = open(thumbnail_path, O_RDONLY); + struct stat thumbnail_st; + if (thumbnail_fd >= 0 && fstat(thumbnail_fd, &thumbnail_st) == 0 && + S_ISREG(thumbnail_st.st_mode) && thumbnail_st.st_size > 0) { + (void)close(fd); + fd = thumbnail_fd; + st = thumbnail_st; + } else if (thumbnail_fd >= 0) { + (void)close(thumbnail_fd); + } + } + } return send_file_response(connection, fd, (uint64_t)st.st_size, "image/png", - "private, max-age=300"); + "public, max-age=86400, immutable"); } static bool handle_index_page(struct MHD_Connection *connection) { diff --git a/src/sm_icon_thumb.c b/src/sm_icon_thumb.c new file mode 100644 index 0000000..9d08013 --- /dev/null +++ b/src/sm_icon_thumb.c @@ -0,0 +1,195 @@ +#include "sm_platform.h" + +#include + +#include "sm_icon_thumb.h" +#include "sm_paths.h" + +#define SM_ICON_SOURCE_MAX_SIZE (4u * 1024u * 1024u) +#define SM_ICON_SOURCE_MAX_DIMENSION 2048u +#define SM_ICON_TEMP_MAX_AGE_SECONDS 300 + +static unsigned int g_icon_temp_serial; + +static bool ensure_icon_cache_directory(void) { + if (mkdir(LOG_DIR, 0777) != 0 && errno != EEXIST) + return false; + return mkdir(ICON_CACHE_DIR, 0777) == 0 || errno == EEXIST; +} + +static void resize_rgba(const unsigned char *source, uint32_t source_width, + uint32_t source_height, unsigned char *target) { + const uint32_t target_size = SM_ICON_THUMB_SIZE; + for (uint32_t y = 0; y < target_size; ++y) { + uint64_t source_y = target_size > 1u + ? ((uint64_t)y * (source_height - 1u) << 16u) / + (target_size - 1u) + : 0; + uint32_t y0 = (uint32_t)(source_y >> 16u); + uint32_t y1 = y0 + 1u < source_height ? y0 + 1u : y0; + uint32_t wy = (uint32_t)(source_y & 0xffffu); + for (uint32_t x = 0; x < target_size; ++x) { + uint64_t source_x = target_size > 1u + ? ((uint64_t)x * (source_width - 1u) << 16u) / + (target_size - 1u) + : 0; + uint32_t x0 = (uint32_t)(source_x >> 16u); + uint32_t x1 = x0 + 1u < source_width ? x0 + 1u : x0; + uint32_t wx = (uint32_t)(source_x & 0xffffu); + const unsigned char *p00 = source + ((size_t)y0 * source_width + x0) * 4u; + const unsigned char *p10 = source + ((size_t)y0 * source_width + x1) * 4u; + const unsigned char *p01 = source + ((size_t)y1 * source_width + x0) * 4u; + const unsigned char *p11 = source + ((size_t)y1 * source_width + x1) * 4u; + unsigned char *pixel = target + ((size_t)y * target_size + x) * 4u; + for (size_t channel = 0; channel < 4u; ++channel) { + uint32_t top = (p00[channel] * (65536u - wx) + + p10[channel] * wx + 32768u) >> + 16u; + uint32_t bottom = (p01[channel] * (65536u - wx) + + p11[channel] * wx + 32768u) >> + 16u; + pixel[channel] = (unsigned char)((top * (65536u - wy) + + bottom * wy + 32768u) >> + 16u); + } + } + } +} + +static void cleanup_stale_temp_files(const char *title_id) { + DIR *directory = opendir(ICON_CACHE_DIR); + if (!directory) + return; + + char prefix[MAX_TITLE_ID + 32u]; + int written = snprintf(prefix, sizeof(prefix), "%s-%u-", title_id, + SM_ICON_THUMB_SIZE); + if (written < 0 || (size_t)written >= sizeof(prefix)) { + (void)closedir(directory); + return; + } + + time_t now = time(NULL); + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) { + if (strncmp(entry->d_name, prefix, (size_t)written) != 0) + continue; + char path[MAX_PATH]; + int path_written = snprintf(path, sizeof(path), "%s/%s", ICON_CACHE_DIR, + entry->d_name); + if (path_written < 0 || (size_t)path_written >= sizeof(path) || + !strstr(entry->d_name, ".tmp.")) { + continue; + } + struct stat st; + if (stat(path, &st) != 0 || now < st.st_mtime || + now - st.st_mtime < SM_ICON_TEMP_MAX_AGE_SECONDS) { + continue; + } + (void)unlink(path); + } + (void)closedir(directory); +} + +static bool create_thumbnail(const char *source_path, const char *cache_path) { + png_image image; + memset(&image, 0, sizeof(image)); + image.version = PNG_IMAGE_VERSION; + if (!png_image_begin_read_from_file(&image, source_path)) + return false; + if (image.width == 0 || image.height == 0 || + image.width > SM_ICON_SOURCE_MAX_DIMENSION || + image.height > SM_ICON_SOURCE_MAX_DIMENSION) { + png_image_free(&image); + return false; + } + + image.format = PNG_FORMAT_RGBA; + size_t source_size = PNG_IMAGE_SIZE(image); + unsigned char *source = malloc(source_size); + if (!source) { + png_image_free(&image); + return false; + } + if (!png_image_finish_read(&image, NULL, source, 0, NULL)) { + free(source); + png_image_free(&image); + return false; + } + uint32_t source_width = image.width; + uint32_t source_height = image.height; + png_image_free(&image); + + size_t target_size = (size_t)SM_ICON_THUMB_SIZE * SM_ICON_THUMB_SIZE * 4u; + unsigned char *target = malloc(target_size); + if (!target) { + free(source); + return false; + } + resize_rgba(source, source_width, source_height, target); + free(source); + + unsigned int serial = __sync_add_and_fetch(&g_icon_temp_serial, 1u); + char temp_path[MAX_PATH]; + int written = snprintf(temp_path, sizeof(temp_path), "%s.tmp.%ld.%u", + cache_path, (long)getpid(), serial); + if (written < 0 || (size_t)written >= sizeof(temp_path)) { + free(target); + return false; + } + + png_image output; + memset(&output, 0, sizeof(output)); + output.version = PNG_IMAGE_VERSION; + output.width = SM_ICON_THUMB_SIZE; + output.height = SM_ICON_THUMB_SIZE; + output.format = PNG_FORMAT_RGBA; + (void)unlink(temp_path); + bool encoded = png_image_write_to_file(&output, temp_path, 0, target, 0, + NULL) != 0; + free(target); + png_image_free(&output); + if (!encoded) { + (void)unlink(temp_path); + return false; + } + + int fd = open(temp_path, O_RDONLY); + bool synced = fd >= 0 && fsync(fd) == 0; + if (fd >= 0) + (void)close(fd); + if (!synced || rename(temp_path, cache_path) != 0) { + (void)unlink(temp_path); + return false; + } + return true; +} + +bool sm_icon_thumbnail_path(const char *title_id, const char *source_path, + const struct stat *source_st, + char out[MAX_PATH]) { + if (!title_id || title_id[0] == '\0' || strpbrk(title_id, "/\\") || + !source_path || !source_st || !S_ISREG(source_st->st_mode) || + source_st->st_size <= 0 || + (uint64_t)source_st->st_size > SM_ICON_SOURCE_MAX_SIZE || + !ensure_icon_cache_directory()) { + return false; + } + + int written = snprintf(out, MAX_PATH, "%s/%s-%u-%lld-%lld.png", + ICON_CACHE_DIR, title_id, SM_ICON_THUMB_SIZE, + (long long)source_st->st_mtime, + (long long)source_st->st_size); + if (written < 0 || written >= MAX_PATH) + return false; + + struct stat cache_st; + if (stat(out, &cache_st) == 0 && S_ISREG(cache_st.st_mode) && + cache_st.st_size > 0) { + return true; + } + if (!create_thumbnail(source_path, out)) + return false; + cleanup_stale_temp_files(title_id); + return true; +} diff --git a/tools/api_test.py b/tools/api_test.py index a09beeb..2adf3f7 100644 --- a/tools/api_test.py +++ b/tools/api_test.py @@ -7,6 +7,7 @@ import errno import http.client import json import socket +import struct import sys from typing import Any, Dict, Optional, Tuple @@ -386,6 +387,38 @@ def test_game_details(client: ApiClient, games: list) -> None: require(required_fields.issubset(game), "game list item lacks metadata") require("size_bytes" not in game, "game list calculated size by default") + thumbnail_url = next( + ( + game.get("icon_url") + for game in games + if isinstance(game.get("icon_url"), str) and game.get("icon_url") + ), + "", + ) + if thumbnail_url: + require("size=thumb" in thumbnail_url, "game list icon is not a thumbnail") + first_status, headers, first_thumbnail = client.request("GET", thumbnail_url) + second_status, _, second_thumbnail = client.request("GET", thumbnail_url) + require(first_status == 200, f"game thumbnail returned HTTP {first_status}") + require(second_status == 200, "cached game thumbnail request failed") + require( + headers.get("content-type", "").lower().startswith("image/png"), + "game thumbnail response is not PNG", + ) + require( + "max-age=" in headers.get("cache-control", "").lower(), + "game thumbnail response is not cacheable", + ) + require( + len(first_thumbnail) >= 24 and first_thumbnail[:8] == b"\x89PNG\r\n\x1a\n", + "game thumbnail has an invalid PNG header", + ) + require( + struct.unpack(">II", first_thumbnail[16:24]) == (128, 128), + "game thumbnail is not 128x128", + ) + require(first_thumbnail == second_thumbnail, "cached thumbnail changed") + title_id = games[0].get("title_id") require(isinstance(title_id, str) and title_id, "game has invalid title_id") http_status, detail = client.post(ROUTE_GAME_INFO, {"title_id": title_id}) diff --git a/web/index.html b/web/index.html index bbaf733..98f52de 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,5 @@ - + @@ -108,7 +108,7 @@ .toolbar { display: grid; - grid-template-columns: minmax(220px, 1fr) 170px auto auto auto; + grid-template-columns: minmax(220px, 1fr) 150px 110px auto auto auto; gap: 10px; padding: 13px; border-radius: var(--radius); @@ -209,8 +209,11 @@ .cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } .cover-fallback { display: grid; place-items: center; width: 100%; height: 100%; color: var(--blue); font-weight: 800; } .game-main { min-width: 0; } + .game-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; } + .game-identity { min-width: 0; } .game-title { overflow: hidden; font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .game-id { color: var(--muted); font: 12px/1.5 ui-monospace, "SFMono-Regular", Consolas, monospace; } + .game-card-size { color: var(--blue); font-size: 14px; font-weight: 750; white-space: nowrap; } .badges { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; } .badge { border: 1px solid var(--line); border-radius: 999px; color: var(--muted); padding: 2px 7px; font-size: 11px; text-transform: uppercase; } @@ -218,7 +221,7 @@ .badge.image { border-color: rgba(85, 167, 255, .38); color: #8cc4ff; } .game-path { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } - .game-actions { display: flex; gap: 7px; margin-top: 10px; } + .game-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .panel { padding: 16px; border-radius: var(--radius); box-shadow: none; } .sidebar { display: grid; gap: 14px; position: sticky; top: 14px; } @@ -347,99 +350,103 @@
-

ShadowMount Library

-

Подключение к API…

+

ShadowMount Library

+

Connecting to API…

- Проверка + Checking
-
- - + - - - + + + +
-
-
Всего игр—
-
Образы—
-
Папки—
-
Смонтировано—
+
+
Total games—
+
Images—
+
Folders—
+
Mounted—
-

Библиотека

+

Library

-
Загрузка библиотеки…
+
Loading library…
@@ -449,10 +456,10 @@