Add Borealis GUI for patch extraction on Switch.
All checks were successful
Build NRO / build (push) Successful in 1m48s

Replace the console UI with a Borealis-based flow, bundle ROMFS assets and
borealis as a submodule, and apply small upstream patches at build time.
Self-delete runs after romfsExit on quit so the NRO can be removed like the
old console build.
This commit is contained in:
2026-05-28 22:22:44 +02:00
parent 828edf6ad2
commit 2cf3db2097
19 changed files with 956 additions and 214 deletions

134
source/extractor.cpp Normal file
View File

@@ -0,0 +1,134 @@
#include "extractor.hpp"
#include <cstdio>
#include <cstring>
#include <sys/stat.h>
#ifdef __SWITCH__
#include <switch.h>
#endif
static constexpr size_t READ_BUF_SIZE = 8192;
int PatchExtractor::mkdirs(const char* path) {
char tmp[512];
snprintf(tmp, sizeof(tmp), "%s", path);
size_t len = strlen(tmp);
if (len == 0) return 0;
if (tmp[len - 1] == '/') tmp[len - 1] = '\0';
for (char* p = tmp + 1; *p; p++) {
if (*p == '/') {
*p = '\0';
mkdir(tmp, 0755);
*p = '/';
}
}
return mkdir(tmp, 0755);
}
bool PatchExtractor::open() {
zip = unzOpen(PATCHES_ZIP);
if (!zip) return false;
unz_global_info gi{};
if (unzGetGlobalInfo(zip, &gi) != UNZ_OK) {
close();
return false;
}
total = gi.number_entry;
extracted = 0;
skipped = 0;
finished = false;
err = unzGoToFirstFile(zip);
return true;
}
void PatchExtractor::close() {
if (zip) {
unzClose(zip);
zip = nullptr;
}
}
int PatchExtractor::getProgressPercent() const {
if (total == 0) return 100;
return static_cast<int>((extracted * 100) / total);
}
bool PatchExtractor::step() {
if (!zip || finished) return false;
if (err != UNZ_OK) {
finished = true;
return false;
}
char filename[512];
char fullpath[1024];
unz_file_info fi{};
unzGetCurrentFileInfo(zip, &fi, filename, sizeof(filename), nullptr, 0, nullptr, 0);
snprintf(fullpath, sizeof(fullpath), "%s%s", EXTRACT_DIR, filename);
currentFile = filename;
size_t flen = strlen(filename);
if (flen > 0 && filename[flen - 1] == '/') {
mkdirs(fullpath);
} else {
char dirpart[1024];
snprintf(dirpart, sizeof(dirpart), "%s", fullpath);
char* last_slash = strrchr(dirpart, '/');
if (last_slash) {
*last_slash = '\0';
mkdirs(dirpart);
}
if (unzOpenCurrentFile(zip) == UNZ_OK) {
FILE* out = fopen(fullpath, "wb");
if (out) {
unsigned char buf[READ_BUF_SIZE];
int bytes;
while ((bytes = unzReadCurrentFile(zip, buf, READ_BUF_SIZE)) > 0) {
fwrite(buf, 1, static_cast<size_t>(bytes), out);
}
fclose(out);
} else {
skipped++;
}
unzCloseCurrentFile(zip);
} else {
skipped++;
}
}
extracted++;
err = unzGoToNextFile(zip);
if (err != UNZ_OK) finished = true;
return !finished;
}
bool PatchExtractor::cleanup() {
cleanupOk_ = true;
if (remove(PATCHES_ZIP) != 0)
cleanupOk_ = false;
// Cannot delete our own NRO while the app is still running; try anyway for edge cases.
remove(SELF_NRO);
remove("sdmc:/switch/.PatchExtractor.nro.star");
remove("sdmc:/switch/.packages/boot_package.ini");
return cleanupOk_;
}
void PatchExtractor::tryDeleteSelfOnExit() {
#ifdef __SWITCH__
// Embedded ROMFS keeps the .nro open on SD; unmount before unlink (console build had no ROMFS).
romfsExit();
#endif
remove(SELF_NRO);
}

43
source/extractor.hpp Normal file
View File

@@ -0,0 +1,43 @@
#pragma once
#include <minizip/unzip.h>
#include <string>
class PatchExtractor {
public:
static constexpr const char* PATCHES_ZIP = "sdmc:/SaltySD/plugins/FPSLocker/patches.zip";
static constexpr const char* EXTRACT_DIR = "sdmc:/SaltySD/plugins/FPSLocker/";
static constexpr const char* SELF_NRO = "sdmc:/switch/PatchExtractor.nro";
bool open();
void close();
/** Process one zip entry. Returns false when finished or on fatal error. */
bool step();
bool isOpen() const { return zip != nullptr; }
unsigned long getTotal() const { return total; }
unsigned long getExtracted() const { return extracted; }
unsigned long getSkipped() const { return skipped; }
int getProgressPercent() const;
const std::string& getCurrentFile() const { return currentFile; }
bool isFinished() const { return finished; }
bool cleanup();
bool cleanupOk() const { return cleanupOk_; }
/** Call after the UI has shut down; releases romfs so the NRO file can be unlinked. */
static void tryDeleteSelfOnExit();
private:
static int mkdirs(const char* path);
unzFile zip = nullptr;
unsigned long total = 0;
unsigned long extracted = 0;
unsigned long skipped = 0;
int err = UNZ_OK;
bool finished = false;
bool cleanupOk_ = true;
std::string currentFile;
};

View File

@@ -1,202 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <errno.h>
#include <sys/stat.h>
#include <switch.h>
#include <minizip/unzip.h>
#define PATCHES_ZIP "sdmc:/SaltySD/plugins/FPSLocker/patches.zip"
#define EXTRACT_DIR "sdmc:/SaltySD/plugins/FPSLocker/"
#define SELF_NRO "sdmc:/switch/PatchExtractor.nro"
#define READ_BUF_SIZE 8192
#define STATUS_ROW 11
static PrintConsole *con;
static void status(const char *fmt, ...) {
printf("\x1b[%d;0H\x1b[2K", STATUS_ROW);
va_list ap;
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
}
static int mkdirs(const char *path) {
char tmp[512];
snprintf(tmp, sizeof(tmp), "%s", path);
size_t len = strlen(tmp);
if (len == 0) return 0;
if (tmp[len - 1] == '/') tmp[len - 1] = '\0';
for (char *p = tmp + 1; *p; p++) {
if (*p == '/') {
*p = '\0';
mkdir(tmp, 0755);
*p = '/';
}
}
return mkdir(tmp, 0755);
}
int main(int argc, char *argv[]) {
con = consoleInit(NULL);
padConfigureInput(1, HidNpadStyleSet_NpadStandard);
PadState pad;
padInitializeDefault(&pad);
printf("\n");
printf(" ========================================\n");
printf(" PatchExtractor - OmniNX OC\n");
printf(" SaltyNX / FPSLocker Patch Installer\n");
printf(" ========================================\n\n");
unzFile zip = unzOpen(PATCHES_ZIP);
if (!zip) {
printf(" \x1b[31mFEHLER:\x1b[0m patches.zip nicht gefunden!\n\n");
printf(" Pfad: %s\n\n", PATCHES_ZIP);
printf(" Bist du sicher, dass du OmniNX OC installiert hast?\n\n");
printf(" Die Datei patches.zip muss unter\n");
printf(" sd:/SaltySD/plugins/FPSLocker/ liegen.\n\n");
printf(" ----------------------------------------\n");
printf(" Druecke + zum Beenden.\n");
while (appletMainLoop()) {
padUpdate(&pad);
if (padGetButtonsDown(&pad) & HidNpadButton_Plus) break;
consoleUpdate(NULL);
}
consoleExit(NULL);
return 1;
}
unz_global_info gi;
unzGetGlobalInfo(zip, &gi);
unsigned long total = gi.number_entry;
printf(" patches.zip gefunden! (%lu Eintraege)\n", total);
printf(" Druecke A zum Entpacken, + zum Abbrechen.\n\n");
consoleUpdate(NULL);
while (appletMainLoop()) {
padUpdate(&pad);
u64 kDown = padGetButtonsDown(&pad);
if (kDown & HidNpadButton_A) break;
if (kDown & HidNpadButton_Plus) {
unzClose(zip);
consoleExit(NULL);
return 0;
}
consoleUpdate(NULL);
}
printf("\x1b[10;0H \x1b[93mBitte nicht beenden, bis der Vorgang abgeschlossen ist!\x1b[0m\n");
consoleUpdate(NULL);
char filename[512];
char fullpath[1024];
unsigned char buf[READ_BUF_SIZE];
unsigned long extracted = 0;
unsigned long skipped = 0;
int err = unzGoToFirstFile(zip);
while (err == UNZ_OK) {
unz_file_info fi;
unzGetCurrentFileInfo(zip, &fi, filename, sizeof(filename), NULL, 0, NULL, 0);
snprintf(fullpath, sizeof(fullpath), "%s%s", EXTRACT_DIR, filename);
unsigned long progress = (unsigned long)((extracted * 100) / total);
size_t flen = strlen(filename);
if (flen > 0 && filename[flen - 1] == '/') {
mkdirs(fullpath);
status(" [%3lu%%] DIR %s", progress, filename);
} else {
char dirpart[1024];
snprintf(dirpart, sizeof(dirpart), "%s", fullpath);
char *last_slash = strrchr(dirpart, '/');
if (last_slash) {
*last_slash = '\0';
mkdirs(dirpart);
}
if (unzOpenCurrentFile(zip) != UNZ_OK) {
status(" [%3lu%%] \x1b[31mERR\x1b[0m %s", progress, filename);
skipped++;
extracted++;
err = unzGoToNextFile(zip);
continue;
}
FILE *out = fopen(fullpath, "wb");
if (!out) {
status(" [%3lu%%] \x1b[31mERR\x1b[0m %s", progress, filename);
unzCloseCurrentFile(zip);
skipped++;
extracted++;
err = unzGoToNextFile(zip);
continue;
}
int bytes;
while ((bytes = unzReadCurrentFile(zip, buf, READ_BUF_SIZE)) > 0) {
fwrite(buf, 1, bytes, out);
}
fclose(out);
unzCloseCurrentFile(zip);
status(" [%3lu%%] FILE %s", progress, filename);
}
extracted++;
consoleUpdate(NULL);
err = unzGoToNextFile(zip);
}
unzClose(zip);
status(" [100%%] Fertig! %lu / %lu entpackt.", extracted - skipped, total);
consoleUpdate(NULL);
int cleanup_ok = 1;
printf("\x1b[%d;0H", STATUS_ROW + 2);
if (remove(PATCHES_ZIP) == 0) {
printf(" patches.zip geloescht.\n");
} else {
printf(" \x1b[31mpatches.zip konnte nicht geloescht werden.\x1b[0m\n");
cleanup_ok = 0;
}
if (remove(SELF_NRO) == 0) {
printf(" PatchExtractor.nro geloescht (Selbstreinigung).\n");
} else {
printf(" \x1b[31mPatchExtractor.nro konnte nicht geloescht werden.\x1b[0m\n");
cleanup_ok = 0;
}
remove("sdmc:/switch/.PatchExtractor.nro.star");
remove("sdmc:/switch/.packages/boot_package.ini");
printf("\n ========================================\n");
if (skipped == 0 && cleanup_ok) {
printf(" \x1b[32mAlles erledigt!\x1b[0m\n");
} else {
printf(" \x1b[32mEntpacken abgeschlossen.\x1b[0m\n");
if (skipped > 0)
printf(" \x1b[31m%lu Eintraege fehlgeschlagen.\x1b[0m\n", skipped);
}
printf(" ========================================\n\n");
printf(" Druecke + zum Beenden.\n");
while (appletMainLoop()) {
padUpdate(&pad);
if (padGetButtonsDown(&pad) & HidNpadButton_Plus) break;
consoleUpdate(NULL);
}
consoleExit(NULL);
return 0;
}

25
source/main.cpp Normal file
View File

@@ -0,0 +1,25 @@
#include <borealis.hpp>
#include "extractor.hpp"
#include "patch_activity.hpp"
int main(int argc, char* argv[]) {
(void)argc;
(void)argv;
brls::Logger::setLogLevel(brls::LogLevel::WARNING);
if (!brls::Application::init()) {
brls::Logger::error("Borealis init failed");
return 1;
}
brls::Application::createWindow("PatchExtractor");
brls::Application::setGlobalQuit(true);
brls::Application::pushActivity(new PatchActivity());
while (brls::Application::mainLoop()) {}
PatchExtractor::tryDeleteSelfOnExit();
return 0;
}

201
source/patch_activity.cpp Normal file
View File

@@ -0,0 +1,201 @@
#include "patch_activity.hpp"
#include <cstdio>
PatchActivity::PatchActivity() {
extractor = std::make_unique<PatchExtractor>();
extractTimer.setCallback([this]() { onExtractTick(); });
extractTimer.setPeriod(0);
}
PatchActivity::~PatchActivity() {
extractTimer.stop();
if (extractor) extractor->close();
}
brls::View* PatchActivity::createContentView() {
frame = new brls::AppletFrame();
frame->setTitle("PatchExtractor");
frame->setIconFromRes("img/omninx.png");
contentBox = new brls::Box(brls::Axis::COLUMN);
contentBox->setAlignItems(brls::AlignItems::CENTER);
contentBox->setJustifyContent(brls::JustifyContent::CENTER);
contentBox->setMargins(40, 40, 40, 40);
contentBox->setGrow(1.0f);
titleLabel = new brls::Label();
titleLabel->setText("PatchExtractor");
titleLabel->setFontSize(28);
titleLabel->setHorizontalAlign(brls::HorizontalAlign::CENTER);
titleLabel->setMarginBottom(16);
messageLabel = new brls::Label();
messageLabel->setHorizontalAlign(brls::HorizontalAlign::CENTER);
messageLabel->setMarginBottom(12);
detailLabel = new brls::Label();
detailLabel->setHorizontalAlign(brls::HorizontalAlign::CENTER);
detailLabel->setFontSize(14);
detailLabel->setMarginBottom(24);
detailLabel->setVisibility(brls::Visibility::GONE);
progressLabel = new brls::Label();
progressLabel->setHorizontalAlign(brls::HorizontalAlign::CENTER);
progressLabel->setFontSize(16);
progressLabel->setMarginBottom(8);
progressLabel->setVisibility(brls::Visibility::GONE);
progressTrack = new brls::Box(brls::Axis::ROW);
progressTrack->setWidth(600);
progressTrack->setHeight(12);
progressTrack->setMarginBottom(24);
progressTrack->setVisibility(brls::Visibility::GONE);
progressFill = new brls::Rectangle(nvgRGB(0, 190, 80));
progressFill->setHeight(12);
progressFill->setWidth(0);
progressTrack->addView(progressFill);
actionButton = new brls::Button();
actionButton->setStyle(&brls::BUTTONSTYLE_PRIMARY);
actionButton->setWidth(320);
actionButton->registerClickAction([this](brls::View* view) { return onExtractClicked(view); });
contentBox->addView(titleLabel);
contentBox->addView(messageLabel);
contentBox->addView(detailLabel);
contentBox->addView(progressLabel);
contentBox->addView(progressTrack);
contentBox->addView(actionButton);
frame->setContentView(contentBox);
return frame;
}
void PatchActivity::onContentAvailable() {
registerExitAction(brls::BUTTON_START);
if (extractor->open()) {
char detail[128];
snprintf(detail, sizeof(detail), "%lu Eintraege in patches.zip", extractor->getTotal());
detailLabel->setText(detail);
showScreen(Screen::Ready);
} else {
detailLabel->setText(PatchExtractor::PATCHES_ZIP);
detailLabel->setVisibility(brls::Visibility::VISIBLE);
showScreen(Screen::Error);
}
}
void PatchActivity::showScreen(Screen screen) {
currentScreen = screen;
progressLabel->setVisibility(brls::Visibility::GONE);
progressTrack->setVisibility(brls::Visibility::GONE);
actionButton->setVisibility(brls::Visibility::VISIBLE);
switch (screen) {
case Screen::Error:
titleLabel->setText("Fehler");
messageLabel->setText("patches.zip nicht gefunden!");
detailLabel->setVisibility(brls::Visibility::VISIBLE);
actionButton->setText("Beenden");
actionButton->registerClickAction([](brls::View*) {
brls::Application::quit();
return true;
});
brls::Application::giveFocus(actionButton);
break;
case Screen::Ready:
titleLabel->setText("OmniNX OC");
messageLabel->setText("SaltyNX / FPSLocker Patch Installer");
detailLabel->setVisibility(brls::Visibility::VISIBLE);
actionButton->setText("Entpacken");
actionButton->registerClickAction([this](brls::View* view) { return onExtractClicked(view); });
brls::Application::giveFocus(actionButton);
break;
case Screen::Extracting:
titleLabel->setText("Entpacken...");
messageLabel->setText("Bitte nicht beenden, bis der Vorgang abgeschlossen ist!");
detailLabel->setVisibility(brls::Visibility::VISIBLE);
progressLabel->setVisibility(brls::Visibility::VISIBLE);
progressTrack->setVisibility(brls::Visibility::VISIBLE);
actionButton->setVisibility(brls::Visibility::GONE);
break;
case Screen::Done: {
titleLabel->setText("Fertig");
const unsigned long ok = extractor->getExtracted() - extractor->getSkipped();
char msg[128];
if (extractor->getSkipped() == 0 && extractor->cleanupOk())
snprintf(msg, sizeof(msg), "Alles erledigt! %lu / %lu entpackt.", ok, extractor->getTotal());
else
snprintf(msg, sizeof(msg), "Entpacken abgeschlossen. %lu / %lu entpackt.", ok, extractor->getTotal());
messageLabel->setText(msg);
if (extractor->getSkipped() > 0) {
char err[64];
snprintf(err, sizeof(err), "%lu Eintraege fehlgeschlagen.", extractor->getSkipped());
detailLabel->setText(err);
detailLabel->setVisibility(brls::Visibility::VISIBLE);
} else if (!extractor->cleanupOk()) {
detailLabel->setText("Aufräumen teilweise fehlgeschlagen.");
detailLabel->setVisibility(brls::Visibility::VISIBLE);
} else {
detailLabel->setVisibility(brls::Visibility::GONE);
}
actionButton->setText("Beenden");
actionButton->setVisibility(brls::Visibility::VISIBLE);
actionButton->registerClickAction([](brls::View*) {
brls::Application::quit();
return true;
});
brls::Application::unblockInputs();
brls::Application::giveFocus(actionButton);
break;
}
}
}
bool PatchActivity::onExtractClicked(brls::View* view) {
(void)view;
if (currentScreen != Screen::Ready) return false;
startExtraction();
return true;
}
void PatchActivity::startExtraction() {
showScreen(Screen::Extracting);
brls::Application::blockInputs();
updateProgressUi();
extractTimer.start();
}
void PatchActivity::onExtractTick() {
if (!extractor->step()) {
extractTimer.stop();
extractor->close();
extractor->cleanup();
updateProgressUi();
showScreen(Screen::Done);
return;
}
updateProgressUi();
}
void PatchActivity::updateProgressUi() {
const int pct = extractor->getProgressPercent();
progressLabel->setText(std::to_string(pct) + "%");
constexpr float trackWidth = 600.0f;
progressFill->setWidth(trackWidth * static_cast<float>(pct) / 100.0f);
const std::string& file = extractor->getCurrentFile();
if (!file.empty())
detailLabel->setText(file);
}

39
source/patch_activity.hpp Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include <borealis.hpp>
#include <memory>
#include "extractor.hpp"
class PatchActivity : public brls::Activity {
public:
PatchActivity();
~PatchActivity() override;
brls::View* createContentView() override;
void onContentAvailable() override;
private:
enum class Screen { Error, Ready, Extracting, Done };
void showScreen(Screen screen);
void startExtraction();
void onExtractTick();
void updateProgressUi();
bool onExtractClicked(brls::View* view);
brls::AppletFrame* frame = nullptr;
brls::Box* contentBox = nullptr;
brls::Label* titleLabel = nullptr;
brls::Label* messageLabel = nullptr;
brls::Label* detailLabel = nullptr;
brls::Label* progressLabel = nullptr;
brls::Box* progressTrack = nullptr;
brls::Rectangle* progressFill = nullptr;
brls::Button* actionButton = nullptr;
std::unique_ptr<PatchExtractor> extractor;
brls::RepeatingTimer extractTimer;
Screen currentScreen = Screen::Ready;
};