diff --git a/Makefile b/Makefile index bae9057..f76505e 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions -std=gnu++11 ASFLAGS := -g $(ARCH) LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) -LIBS := -lcitro2d -lcitro3d -lctru -lm +LIBS := -lcitro2d -lcitro3d -lctru -lminizip -lz -lm #--------------------------------------------------------------------------------- # List of directories containing libraries diff --git a/source/main.c b/source/main.c index 5f7c31d..cd25d60 100644 --- a/source/main.c +++ b/source/main.c @@ -9,6 +9,7 @@ #include #include #include +#include #include <3ds.h> #include #include "config.h" @@ -27,6 +28,7 @@ #include "screens/search.h" #include "screens/about.h" #include "debuglog.h" +#include "zip.h" // App states typedef enum { @@ -93,33 +95,35 @@ static void show_loading(const char *message) { C3D_FrameEnd(0); } -// Download context for progress callback +// Progress state for download/extraction callbacks. +// Set progressLabel before calling api_download_rom() or zip_extract(). static char downloadNameBuf[384]; static const char *downloadName = NULL; static const char *downloadQueueText = NULL; +static const char *progressLabel = "Downloading..."; static void set_download_name(const char *slug, const char *name) { snprintf(downloadNameBuf, sizeof(downloadNameBuf), "[%s] %s", slug, name); downloadName = downloadNameBuf; } -// Download progress callback - renders progress bar each chunk +// Progress callback shared by download and extraction // Returns true to continue, false to cancel -static bool download_progress(u32 downloaded, u32 total) { - float progress = total > 0 ? (float)downloaded / total : -1.0f; +static bool progress_callback(uint32_t current, uint32_t total) { + float progress = total > 0 ? (float)current / total : -1.0f; char sizeText[64]; if (total > 0) { - snprintf(sizeText, sizeof(sizeText), "%.1f / %.1f MB", downloaded / (1024.0f * 1024.0f), + snprintf(sizeText, sizeof(sizeText), "%.1f / %.1f MB", current / (1024.0f * 1024.0f), total / (1024.0f * 1024.0f)); } else { - snprintf(sizeText, sizeof(sizeText), "%.1f MB downloaded", downloaded / (1024.0f * 1024.0f)); + snprintf(sizeText, sizeof(sizeText), "%.1f MB", current / (1024.0f * 1024.0f)); } C3D_FrameBegin(C3D_FRAME_SYNCDRAW); C2D_TargetClear(topScreen, UI_COLOR_BG); C2D_SceneBegin(topScreen); - ui_draw_download_progress(progress, sizeText, downloadName, downloadQueueText); + ui_draw_progress(progress, progressLabel, sizeText, downloadName, downloadQueueText); bottom_draw(); C3D_FrameEnd(0); @@ -148,14 +152,48 @@ static void build_rom_path(char *dest, size_t destSize, const char *folderName, snprintf(dest, destSize, "%s/%s/%s", config.romFolder, folderName, fsName); } -// Check if a ROM file exists on disk by platform slug and filename +// Check if a ROM file exists on disk by platform slug and filename. +// For zip files, checks if any extracted file with the same stem exists. static bool check_file_exists(const char *platformSlug, const char *fileName) { const char *folderName = config_get_platform_folder(platformSlug); if (!folderName || !folderName[0]) return false; char path[CONFIG_MAX_PATH_LEN + CONFIG_MAX_SLUG_LEN + 256 + 3]; build_rom_path(path, sizeof(path), folderName, fileName); + + // Exact match first struct stat st; - return (stat(path, &st) == 0 && S_ISREG(st.st_mode)); + if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) return true; + + // For zip files, check for extracted files matching the stem + if (!zip_is_zip_file(fileName)) return false; + + // Get the stem (filename without .zip extension) + char stem[256]; + snprintf(stem, sizeof(stem), "%s", fileName); + char *dot = strrchr(stem, '.'); + if (dot) *dot = '\0'; + size_t stemLen = strlen(stem); + + // Scan the directory for files starting with the stem + char dirPath[CONFIG_MAX_PATH_LEN + CONFIG_MAX_SLUG_LEN + 2]; + snprintf(dirPath, sizeof(dirPath), "%s/%s", config.romFolder, folderName); + DIR *dir = opendir(dirPath); + if (!dir) return false; + + struct dirent *entry; + bool found = false; + while ((entry = readdir(dir)) != NULL) { + if (strncmp(entry->d_name, stem, stemLen) == 0 && entry->d_name[stemLen] == '.') { + char fullPath[CONFIG_MAX_PATH_LEN + CONFIG_MAX_SLUG_LEN + 256 + 3]; + snprintf(fullPath, sizeof(fullPath), "%s/%s", dirPath, entry->d_name); + if (stat(fullPath, &st) == 0 && S_ISREG(st.st_mode)) { + found = true; + break; + } + } + } + closedir(dir); + return found; } // Check if a platform folder is configured and valid on disk @@ -250,6 +288,27 @@ static void sync_roms_bottom(int index) { lastRomListIndex = index; } +// Extract a zip file after download, showing progress. Returns true on success. +static bool extract_if_zip(const char *destPath) { + if (!zip_is_zip_file(destPath)) return true; + + // Derive directory from the full path + char destDir[CONFIG_MAX_PATH_LEN + CONFIG_MAX_SLUG_LEN + 2]; + snprintf(destDir, sizeof(destDir), "%s", destPath); + char *lastSlash = strrchr(destDir, '/'); + if (lastSlash) *lastSlash = '\0'; + + log_info("Extracting zip: %s", destPath); + progressLabel = "Extracting..."; + if (zip_extract(destPath, destDir, progress_callback)) { + log_info("Extraction complete!"); + return true; + } else { + log_error("Extraction failed!"); + return false; + } +} + // Download the currently focused ROM to the given platform folder static void download_focused_rom(const Rom *rom, const char *slug, const char *folderName) { char destPath[CONFIG_MAX_PATH_LEN + CONFIG_MAX_SLUG_LEN + 256 + 3]; @@ -257,9 +316,13 @@ static void download_focused_rom(const Rom *rom, const char *slug, const char *f bottom_set_mode(BOTTOM_MODE_DOWNLOADING); set_download_name(slug, rom->name); downloadQueueText = NULL; + progressLabel = "Downloading..."; log_info("Downloading to: %s", destPath); - if (api_download_rom(rom->id, rom->fsName, destPath, download_progress)) { + if (api_download_rom(rom->id, rom->fsName, destPath, progress_callback)) { log_info("Download complete!"); + if (!extract_if_zip(destPath)) { + remove(destPath); + } } else { log_error("Download failed!"); } @@ -275,7 +338,13 @@ static bool download_queue_entry(QueueEntry *entry) { char destPath[CONFIG_MAX_PATH_LEN + CONFIG_MAX_SLUG_LEN + 256 + 3]; build_rom_path(destPath, sizeof(destPath), folderName, entry->fsName); log_info("Downloading '%s' to: %s", entry->name, destPath); - return api_download_rom(entry->romId, entry->fsName, destPath, download_progress); + progressLabel = "Downloading..."; + if (!api_download_rom(entry->romId, entry->fsName, destPath, progress_callback)) return false; + if (!extract_if_zip(destPath)) { + remove(destPath); + return false; + } + return true; } // Fetch and display ROM detail, updating all navigation state diff --git a/source/ui.c b/source/ui.c index 8b0d2ca..18aa114 100644 --- a/source/ui.c +++ b/source/ui.c @@ -76,7 +76,8 @@ void ui_draw_loading(const char *message) { ui_draw_text(x, y, message, UI_COLOR_TEXT); } -void ui_draw_download_progress(float progress, const char *sizeText, const char *name, const char *queueText) { +void ui_draw_progress(float progress, const char *label, const char *sizeText, const char *name, + const char *queueText) { ui_draw_rect(0, 0, SCREEN_TOP_WIDTH, SCREEN_TOP_HEIGHT, C2D_Color32(0x1a, 0x1a, 0x2e, 0xE0)); float centerY = SCREEN_TOP_HEIGHT / 2; @@ -88,8 +89,7 @@ void ui_draw_download_progress(float progress, const char *sizeText, const char UI_COLOR_TEXT); } - // "Downloading..." label - const char *label = "Downloading..."; + // Action label float labelWidth = ui_get_text_width(label); ui_draw_text((SCREEN_TOP_WIDTH - labelWidth) / 2, centerY - UI_LINE_HEIGHT - UI_PADDING, label, UI_COLOR_TEXT_DIM); diff --git a/source/ui.h b/source/ui.h index dba7938..c225953 100644 --- a/source/ui.h +++ b/source/ui.h @@ -64,10 +64,11 @@ float ui_get_text_width_scaled(const char *text, float scale); // Draw a centered loading message on top screen void ui_draw_loading(const char *message); -// Draw download progress on top screen (progress 0.0 to 1.0, negative if unknown) +// Draw progress bar on top screen (progress 0.0 to 1.0, negative if unknown) +// label: action label (e.g., "Downloading...", "Extracting...") // name: ROM name to display above progress bar // queueText: optional queue context (e.g., "ROM 1 of 3 in your queue"), NULL if not queued -void ui_draw_download_progress(float progress, const char *sizeText, const char *name, const char *queueText); +void ui_draw_progress(float progress, const char *label, const char *sizeText, const char *name, const char *queueText); // Show software keyboard and get input // Returns true if user confirmed, false if cancelled diff --git a/source/zip.c b/source/zip.c new file mode 100644 index 0000000..d8216f4 --- /dev/null +++ b/source/zip.c @@ -0,0 +1,176 @@ +/* + * Zip extraction module - Extract zip archives with progress reporting + */ + +#include "zip.h" +#include "log.h" +#include +#include +#include +#include + +#define EXTRACT_CHUNK_SIZE (64 * 1024) + +bool zip_is_zip_file(const char *filename) { + if (!filename) return false; + size_t len = strlen(filename); + if (len < 4) return false; + const char *ext = filename + len - 4; + return (ext[0] == '.' && (ext[1] == 'z' || ext[1] == 'Z') && (ext[2] == 'i' || ext[2] == 'I') && + (ext[3] == 'p' || ext[3] == 'P')); +} + +// Ensure all directories in a path exist +static bool ensure_parent_dirs(const char *filePath) { + char tmp[768]; + snprintf(tmp, sizeof(tmp), "%s", filePath); + + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + mkdir(tmp, 0755); + *p = '/'; + } + } + return true; +} + +// Sum uncompressed sizes of all files in the archive +static uint32_t get_total_uncompressed_size(unzFile uf) { + uint32_t total = 0; + unz_file_info fileInfo; + + if (unzGoToFirstFile(uf) != UNZ_OK) return 0; + + do { + if (unzGetCurrentFileInfo(uf, &fileInfo, NULL, 0, NULL, 0, NULL, 0) == UNZ_OK) { + total += (uint32_t)fileInfo.uncompressed_size; + } + } while (unzGoToNextFile(uf) == UNZ_OK); + + return total; +} + +bool zip_extract(const char *zipPath, const char *destDir, ExtractProgressCb progressCb) { + unzFile uf = unzOpen(zipPath); + if (!uf) { + log_error("Failed to open zip: %s", zipPath); + return false; + } + + uint32_t totalSize = get_total_uncompressed_size(uf); + uint32_t totalExtracted = 0; + bool success = true; + + log_info("Extracting %s (%.1f MB uncompressed)", zipPath, totalSize / (1024.0f * 1024.0f)); + + if (unzGoToFirstFile(uf) != UNZ_OK) { + log_error("Failed to read first file in zip"); + unzClose(uf); + return false; + } + + unsigned char *buffer = malloc(EXTRACT_CHUNK_SIZE); + if (!buffer) { + log_error("Failed to allocate extraction buffer"); + unzClose(uf); + return false; + } + + do { + char filename[256]; + unz_file_info fileInfo; + + if (unzGetCurrentFileInfo(uf, &fileInfo, filename, sizeof(filename), NULL, 0, NULL, 0) != UNZ_OK) { + log_error("Failed to get file info from zip"); + success = false; + break; + } + + // Build destination path, skipping if too long + size_t dirLen = strlen(destDir); + size_t nameLen = strlen(filename); + char destPath[768]; + if (dirLen + 1 + nameLen >= sizeof(destPath)) { + log_error("Path too long, skipping: %s/%s", destDir, filename); + continue; + } + memcpy(destPath, destDir, dirLen); + destPath[dirLen] = '/'; + memcpy(destPath + dirLen + 1, filename, nameLen + 1); + + // Reject path traversal (Zip Slip) + if (strstr(filename, "..") != NULL) { + log_error("Zip entry contains path traversal, skipping: %s", filename); + continue; + } + + // Skip directories (entries ending with /) + if (nameLen > 0 && filename[nameLen - 1] == '/') { + mkdir(destPath, 0755); + continue; + } + + ensure_parent_dirs(destPath); + + if (unzOpenCurrentFile(uf) != UNZ_OK) { + log_error("Failed to open file in zip: %s", filename); + success = false; + break; + } + + FILE *outFile = fopen(destPath, "wb"); + if (!outFile) { + log_error("Failed to create file: %s", destPath); + unzCloseCurrentFile(uf); + success = false; + break; + } + + int bytesRead; + while ((bytesRead = unzReadCurrentFile(uf, buffer, EXTRACT_CHUNK_SIZE)) > 0) { + if (fwrite(buffer, 1, bytesRead, outFile) != (size_t)bytesRead) { + log_error("Failed to write extracted file: %s", destPath); + success = false; + break; + } + totalExtracted += bytesRead; + + if (progressCb) { + if (!progressCb(totalExtracted, totalSize)) { + log_info("Extraction cancelled by user"); + success = false; + break; + } + } + } + + fclose(outFile); + unzCloseCurrentFile(uf); + + if (!success) { + remove(destPath); + break; + } + + if (bytesRead < 0) { + log_error("Error reading from zip: %s", filename); + remove(destPath); + success = false; + } + + if (!success) break; + + log_debug("Extracted: %s", filename); + } while (unzGoToNextFile(uf) == UNZ_OK); + + free(buffer); + unzClose(uf); + + if (success) { + remove(zipPath); + log_info("Extraction complete, zip deleted"); + } + + return success; +} diff --git a/source/zip.h b/source/zip.h new file mode 100644 index 0000000..b8a41fc --- /dev/null +++ b/source/zip.h @@ -0,0 +1,23 @@ +/* + * Zip extraction module - Extract zip archives with progress reporting + */ + +#ifndef ZIP_H +#define ZIP_H + +#include +#include + +// Progress callback for extraction. Returns true to continue, false to cancel. +// extracted: bytes extracted so far, total: total uncompressed size +typedef bool (*ExtractProgressCb)(uint32_t extracted, uint32_t total); + +// Extract all files from a zip archive into destDir. +// Deletes the zip file on success. +// Returns true on success, false on failure or cancellation. +bool zip_extract(const char *zipPath, const char *destDir, ExtractProgressCb progressCb); + +// Check if a filename has a .zip extension (case-insensitive) +bool zip_is_zip_file(const char *filename); + +#endif // ZIP_H