Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 80 additions & 11 deletions source/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <dirent.h>
#include <3ds.h>
#include <citro2d.h>
#include "config.h"
Expand All @@ -27,6 +28,7 @@
#include "screens/search.h"
#include "screens/about.h"
#include "debuglog.h"
#include "zip.h"

// App states
typedef enum {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -250,16 +288,41 @@ 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];
build_rom_path(destPath, sizeof(destPath), folderName, rom->fsName);
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!");
}
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions source/ui.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand Down
5 changes: 3 additions & 2 deletions source/ui.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
176 changes: 176 additions & 0 deletions source/zip.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Zip extraction module - Extract zip archives with progress reporting
*/

#include "zip.h"
#include "log.h"
#include <minizip/unzip.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>

#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;
}
Loading