From 73cdfefa48ca3b58cf11004cfcf6910c88d28841 Mon Sep 17 00:00:00 2001 From: Migush Date: Tue, 25 Aug 2026 16:24:45 +0200 Subject: [PATCH 1/4] feat: support BMP and Hekate boot images --- .../source/Pages/ThemeEntry/BaseEntry.cpp | 4 +- .../source/Pages/ThemeEntry/ImageEntry.cpp | 136 +++++++++++-- .../source/Pages/ThemeEntry/ImageEntry.hpp | 12 +- .../source/Pages/ThemeEntry/ThemeEntry.hpp | 3 +- SwitchThemesNX/source/Pages/UninstallPage.cpp | 32 ++- .../Bntx/ImageConversion.cpp | 187 +++++++++++++++++- .../Bntx/ImageConversion.hpp | 8 +- .../source/SwitchThemesCommon/Common.hpp | 2 +- SwitchThemesNX/source/fs.cpp | 3 +- 9 files changed, 357 insertions(+), 30 deletions(-) diff --git a/SwitchThemesNX/source/Pages/ThemeEntry/BaseEntry.cpp b/SwitchThemesNX/source/Pages/ThemeEntry/BaseEntry.cpp index 34919bc8..43422bdf 100644 --- a/SwitchThemesNX/source/Pages/ThemeEntry/BaseEntry.cpp +++ b/SwitchThemesNX/source/Pages/ThemeEntry/BaseEntry.cpp @@ -116,7 +116,7 @@ unique_ptr ThemeEntry::FromFile(const std::string& fileName) return make_unique(fileName, move(data)); if (StrEndsWith(fileName, ".nxtheme") || StrEndsWith(fileName, ".zip")) return make_unique(fileName, move(data)); - if (StrEndsWith(fileName, ".jpg") || StrEndsWith(fileName, ".jpeg") || StrEndsWith(fileName, ".png")) + if (StrEndsWith(fileName, ".jpg") || StrEndsWith(fileName, ".jpeg") || StrEndsWith(fileName, ".png") || StrEndsWith(fileName, ".bmp")) return make_unique(fileName, move(data)); } catch (std::exception &ex) @@ -241,4 +241,4 @@ ThemeEntry::UserAction ThemeEntry::Render(bool OverrideColor) return pressed && Utils::ItemNotDragging() ? UserAction::Enter : UserAction::None; } -const std::vector ThemeEntry::_emtptyVec = {}; \ No newline at end of file +const std::vector ThemeEntry::_emtptyVec = {}; diff --git a/SwitchThemesNX/source/Pages/ThemeEntry/ImageEntry.cpp b/SwitchThemesNX/source/Pages/ThemeEntry/ImageEntry.cpp index a6df0118..df4432f5 100644 --- a/SwitchThemesNX/source/Pages/ThemeEntry/ImageEntry.cpp +++ b/SwitchThemesNX/source/Pages/ThemeEntry/ImageEntry.cpp @@ -1,7 +1,11 @@ #include +#include #include #include #include +#include +#include +#include #include #include #include "ThemeEntry.hpp" @@ -16,6 +20,8 @@ namespace { + constexpr std::string_view HekateBootTarget = "__boot"; + std::vector> TargetInstallParts = { { "Home menu", "home"}, { "Lock screen", "lock"}, @@ -24,8 +30,73 @@ namespace { "News applet", "news"}, { "User page", "user"}, { "Player selection", "psl"}, - { "Hekate boot image", "__boot"}, + { "Hekate boot image", std::string(HekateBootTarget)}, }; + + std::string TrimIniValue(std::string_view value) + { + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start]))) + start++; + + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1]))) + end--; + + return std::string(value.substr(start, end - start)); + } + + bool HekateBootSplashEnabled() + { + try + { + const auto path = fs::path::BootloaderDir + "hekate_ipl.ini"; + if (!fs::Exists(path)) + return false; + + const auto data = fs::OpenFile(path); + std::istringstream lines(std::string(data.begin(), data.end())); + std::string line; + bool inConfigSection = false; + while (std::getline(lines, line)) + { + if (const auto comment = line.find_first_of("#;"); comment != std::string::npos) + line.resize(comment); + const auto trimmedLine = TrimIniValue(line); + if (trimmedLine.size() >= 2 && trimmedLine.front() == '[' && trimmedLine.back() == ']') + { + inConfigSection = trimmedLine == "[config]"; + continue; + } + if (!inConfigSection) + continue; + + const auto equals = trimmedLine.find('='); + if (equals == std::string::npos) + continue; + + if (TrimIniValue(std::string_view(trimmedLine).substr(0, equals)) != "bootwait") + continue; + + const auto value = TrimIniValue(std::string_view(trimmedLine).substr(equals + 1)); + return std::atoi(value.c_str()) > 0; + } + + return false; + } + catch (...) + { + return false; + } + } + + std::string HekateBootSplashWarning() + { + if (HekateBootSplashEnabled()) + return {}; + + return "Don't forget to enable boot splash in Hekate: set the global bootwait setting to a value greater than 0."; + } } ImageEntry::ImageEntry(const std::string& fileName, std::vector&& RawData) @@ -68,7 +139,10 @@ void ImageEntry::PerformConversion() if (loaded->Width() == 1280 && loaded->Height() == 720) return; - auto converted = ImageConversion::ToJPG(std::move(loaded), 1280, 720, true); + const bool rotatePortrait = loaded->Width() < loaded->Height(); + originalImageData = std::move(imageData); + auto converted = ImageConversion::ToJPG(std::move(loaded), 1280, 720, true, + rotatePortrait); if (converted.ErrorMessage.size()) { MakeError("Error processing file: "+ converted.ErrorMessage); @@ -119,17 +193,34 @@ bool ImageEntry::DoInstall(bool ShowDialogs) return false; bool result; - PushPageBlocking(new InstallImageDialog(preview, imageData, resizeWarning, ShowDialogs, &result)); + std::string installWarning; + const auto& bootSource = originalImageData.empty() ? imageData : originalImageData; + PushPageBlocking(new InstallImageDialog(preview, imageData, resizeWarning, ShowDialogs, &result, + bootSource, false, &installWarning)); + if (!installWarning.empty()) + AppendInstallMessage(installWarning); return result; } -InstallImageDialog::InstallImageDialog(ImageRef preview, const std::vector& imageBytes, bool resizeWarning, bool showInstallDialogs, bool* outSuccess) : - previewImage(preview), imageBytes(imageBytes), resizeWarning(resizeWarning), showInstallDialogs(showInstallDialogs), - outSuccess(outSuccess) +InstallImageDialog::InstallImageDialog(ImageRef preview, + const std::vector& imageBytes, + bool resizeWarning, + bool showInstallDialogs, + bool* outSuccess, + std::span bootImageBytes, + bool showBootloaderSuccessDialog, + std::string* outInstallWarning) : + previewImage(preview), imageBytes(imageBytes), bootImageBytes(bootImageBytes), + resizeWarning(resizeWarning), showInstallDialogs(showInstallDialogs), + showBootloaderSuccessDialog(showBootloaderSuccessDialog), + outSuccess(outSuccess), outInstallWarning(outInstallWarning) { PageName = "InstallImageDialog"; if (outSuccess) *outSuccess = false; + if (outInstallWarning) outInstallWarning->clear(); + if (this->bootImageBytes.empty()) + this->bootImageBytes = imageBytes; if (!UseLowMemory) { @@ -141,7 +232,7 @@ InstallImageDialog::InstallImageDialog(ImageRef preview, const std::vector& ImageRef InstallImageDialog::LoadOverlayPart(const std::string& part) { - if (previewLoadFailure || part == "__boot") + if (previewLoadFailure || part == HekateBootTarget) return nullptr; std::string cacheKey = "preview_overlay://"; @@ -175,20 +266,37 @@ void InstallImageDialog::ApplyToBootloader() { if (!fs::DirectoryExists(fs::path::BootloaderDir)) { - Dialog("Bootloader directory not found. Make sure hekate is installed and try again."); + DialogBlocking("Bootloader directory not found. Make sure hekate is installed and try again."); return; } DisplayLoading("Installing..."); try { - auto image = ImageConversion::ToBootloaderBMP(imageBytes); + auto image = ImageConversion::ToBootloaderBMP(bootImageBytes); + if (!image.IsSuccess() || image.Data.empty()) + { + DialogBlocking("Failed to convert the image to a bootloader BMP: " + + (image.ErrorMessage.empty() ? "conversion returned no data" : image.ErrorMessage)); + return; + } + fs::WriteFile(fs::path::BootlogoPath, image.Data); - Dialog("Image installed to the bootloader successfully. Reboot to see the changes."); + if (outSuccess) *outSuccess = true; + const auto warning = HekateBootSplashWarning(); + if (outInstallWarning) *outInstallWarning = warning; + if (showBootloaderSuccessDialog) + { + std::string message = "Image installed to the bootloader successfully. Reboot to see the changes."; + if (!warning.empty()) + message += "\n\n" + warning; + Dialog(message); + } + PopPage(this); } catch (const std::exception& ex) { - Dialog("Failed to install the image to the bootloader: " + std::string(ex.what())); + DialogBlocking("Failed to install the image to the bootloader: " + std::string(ex.what())); return; } } @@ -234,7 +342,7 @@ void InstallImageDialog::ApplyToPart(const std::string& part) void InstallImageDialog::RenderTop() { ImGui::PushFont(font40); - Utils::ImGuiCenterString("Set theme wallpaper"); + Utils::ImGuiCenterString("Select Target"); ImGui::PopFont(); Utils::ImGuiCenterString("Select where you want to apply this image"); PaddingLine(); @@ -268,7 +376,7 @@ void InstallImageDialog::RenderRightPanel(float x, float allowedWidth, float end { PushFunction([this, part]() { - if (part == "__boot") + if (part == HekateBootTarget) ApplyToBootloader(); else ApplyToPart(part); @@ -323,4 +431,4 @@ void InstallImageDialog::RenderBottom() ImGui::PopStyleColor(); } -} \ No newline at end of file +} diff --git a/SwitchThemesNX/source/Pages/ThemeEntry/ImageEntry.hpp b/SwitchThemesNX/source/Pages/ThemeEntry/ImageEntry.hpp index 0801d403..c4763e8d 100644 --- a/SwitchThemesNX/source/Pages/ThemeEntry/ImageEntry.hpp +++ b/SwitchThemesNX/source/Pages/ThemeEntry/ImageEntry.hpp @@ -9,7 +9,14 @@ class InstallImageDialog : public BaseImageOptionsDialog { public: - InstallImageDialog(ImageRef preview, const std::vector& imageBytes, bool resizeWarning, bool showInstallDialogs, bool* outSuccess); + InstallImageDialog(ImageRef preview, + const std::vector& imageBytes, + bool resizeWarning, + bool showInstallDialogs, + bool* outSuccess, + std::span bootImageBytes = {}, + bool showBootloaderSuccessDialog = true, + std::string* outInstallWarning = nullptr); void Update() override {}; protected: @@ -24,9 +31,12 @@ class InstallImageDialog : public BaseImageOptionsDialog std::string currentPreviewOverlay; std::span imageBytes; + std::span bootImageBytes; bool resizeWarning; bool showInstallDialogs; + bool showBootloaderSuccessDialog; bool* outSuccess; + std::string* outInstallWarning; bool previewLoadFailure = false; std::string previewError = ""; diff --git a/SwitchThemesNX/source/Pages/ThemeEntry/ThemeEntry.hpp b/SwitchThemesNX/source/Pages/ThemeEntry/ThemeEntry.hpp index 68b77c16..6377b07d 100644 --- a/SwitchThemesNX/source/Pages/ThemeEntry/ThemeEntry.hpp +++ b/SwitchThemesNX/source/Pages/ThemeEntry/ThemeEntry.hpp @@ -137,6 +137,7 @@ class ImageEntry : public ThemeEntry private: ImageRef previewImage = nullptr; + std::vector originalImageData {}; std::vector imageData {}; bool resizeWarning = false; bool conversionDone = false; @@ -144,4 +145,4 @@ class ImageEntry : public ThemeEntry // Lazy conversion, only when needed for preview or installation, and the result is cached void PerformConversion(); ImageRef GetConvertedImage(); -}; \ No newline at end of file +}; diff --git a/SwitchThemesNX/source/Pages/UninstallPage.cpp b/SwitchThemesNX/source/Pages/UninstallPage.cpp index 84475c15..7f716582 100644 --- a/SwitchThemesNX/source/Pages/UninstallPage.cpp +++ b/SwitchThemesNX/source/Pages/UninstallPage.cpp @@ -35,6 +35,28 @@ namespace } return true; } + + void RemoveHekateBootLogo() + { + if (!fs::Exists(fs::path::BootlogoPath)) + { + Dialog("No Hekate boot image is installed."); + return; + } + + if (!YesNoPage::Ask("Remove /bootloader/bootlogo.bmp?")) + return; + + try + { + fs::Delete(fs::path::BootlogoPath); + Dialog("The Hekate boot image has been removed."); + } + catch (const std::exception& ex) + { + Dialog("Error removing the Hekate boot image: " + std::string(ex.what())); + } + } } UninstallPage::UninstallPage() @@ -81,6 +103,12 @@ void UninstallPage::Render(int X, int Y) } }); } + + ImGui::Spacing(); + Utils::ImGuiCenterString("Boot image"); + ImGui::Spacing(); + if (Utils::ImGuiCenterButton("Uninstall Hekate boot image")) + PushFunction([]() { RemoveHekateBootLogo(); }); PAGE_RESET_FOCUS_FOR(firstBtn); ImGui::PopStyleColor(); @@ -96,7 +124,3 @@ void UninstallPage::Update() Parent->PageLeaveFocus(this); } } - - - - diff --git a/SwitchThemesNX/source/SwitchThemesCommon/Bntx/ImageConversion.cpp b/SwitchThemesNX/source/SwitchThemesCommon/Bntx/ImageConversion.cpp index 0eab72ee..d93bd482 100644 --- a/SwitchThemesNX/source/SwitchThemesCommon/Bntx/ImageConversion.cpp +++ b/SwitchThemesNX/source/SwitchThemesCommon/Bntx/ImageConversion.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include "ImageConversion.hpp" #include "../MyTypes.h" #include "../BinaryReadWrite/Buffer.hpp" @@ -15,6 +17,98 @@ namespace { + struct BootloaderBMPInfo + { + int width; + int height; + }; + + u16 ReadLE16(std::span data, size_t offset) + { + return static_cast(data[offset]) | + (static_cast(data[offset + 1]) << 8); + } + + u32 ReadLE32(std::span data, size_t offset) + { + return static_cast(data[offset]) | + (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | + (static_cast(data[offset + 3]) << 24); + } + + u32 ReadBE32(std::span data, size_t offset) + { + return (static_cast(data[offset]) << 24) | + (static_cast(data[offset + 1]) << 16) | + (static_cast(data[offset + 2]) << 8) | + static_cast(data[offset + 3]); + } + + bool HasCompletePngContainer(std::span data) + { + size_t offset = 8; + while (offset <= data.size() && data.size() - offset >= 12) + { + const auto chunkSize = static_cast(ReadBE32(data, offset)); + if (chunkSize > data.size() - offset - 12) + return false; + + const bool isEnd = data[offset + 4] == 'I' && data[offset + 5] == 'E' && + data[offset + 6] == 'N' && data[offset + 7] == 'D'; + offset += chunkSize + 12; + if (isEnd) + return chunkSize == 0 && offset == data.size(); + } + + return false; + } + + std::optional ParseBootloaderBMP(std::span data) + { + // Validate canonical bootlogo files before preserving them verbatim. + constexpr size_t FileHeaderSize = 14; + constexpr size_t InfoHeaderSize = 40; + constexpr size_t MinimumHeaderSize = FileHeaderSize + InfoHeaderSize; + if (data.size() < MinimumHeaderSize || data[0] != 'B' || data[1] != 'M') + return std::nullopt; + + const auto dibSize = static_cast(ReadLE32(data, FileHeaderSize)); + if (dibSize < InfoHeaderSize || dibSize > data.size() - FileHeaderSize) + return std::nullopt; + + const auto pixelOffset = static_cast(ReadLE32(data, 10)); + if (pixelOffset < FileHeaderSize + dibSize || pixelOffset > data.size()) + return std::nullopt; + + const auto width = static_cast(ReadLE32(data, 18)); + const auto height = static_cast(ReadLE32(data, 22)); + if (width <= 0 || height <= 0 || width > 720 || height > 1280) + return std::nullopt; + + const auto compression = ReadLE32(data, 30); + if (ReadLE16(data, 26) != 1 || ReadLE16(data, 28) != 32 || + (compression != 0 && compression != 3)) + return std::nullopt; + + const auto rowSize = static_cast(width) * 4; + const auto pixelSize = rowSize * static_cast(height); + if (pixelSize > data.size() - pixelOffset) + return std::nullopt; + + const auto imageSize = static_cast(ReadLE32(data, 34)); + if (imageSize != 0 && imageSize != pixelSize) + return std::nullopt; + + const auto fileSize = static_cast(ReadLE32(data, 2)); + const auto requiredFileSize = pixelOffset + pixelSize; + // Reject a valid BMP header followed by response data. + if (fileSize != data.size() || fileSize < requiredFileSize) + return std::nullopt; + + return BootloaderBMPInfo{ width, height }; + } + int imin(int x, int y) { return (x < y) ? x : y; } void extractBlock(const unsigned char* src, int x, int y, int w, int h, unsigned char* block) @@ -169,6 +263,27 @@ namespace return result; } + StbImageHolder Rotate90DegreesClockwise() + { + if (channels != 4) + return StbImageHolder("Only ARGB images are supported for rotation"); + + StbImageHolder result(height, width, channels); + + u32* source = (u32*)data; + u32* dest = (u32*)result.data; + + for (int h = 0; h < height; h++) + { + for (int w = 0; w < width; w++) + { + dest[w * height + (height - 1 - h)] = source[h * width + w]; + } + } + + return result; + } + private: bool isStb = false; }; @@ -222,22 +337,84 @@ ImageConversion::BitmapRef ImageConversion::LoadBitmap(std::span imgDa return res; } -ImageConversion::ConversionResult ImageConversion::ToJPG(BitmapRef imgData, int Width, int Height, bool ResizeIfNeeded) +std::string_view ImageConversion::GetSupportedImageExtension(std::span imgData, std::string& error) +{ + error.clear(); + + std::string_view extension; + if (imgData.size() >= 2 && imgData[0] == 'B' && imgData[1] == 'M') + { + if (imgData.size() < 14 || ReadLE32(imgData, 2) != imgData.size()) + { + error = "The BMP file size is invalid"; + return {}; + } + extension = ".bmp"; + } + else if (imgData.size() >= 8 && + imgData[0] == 0x89 && imgData[1] == 'P' && imgData[2] == 'N' && imgData[3] == 'G' && + imgData[4] == 0x0D && imgData[5] == 0x0A && imgData[6] == 0x1A && imgData[7] == 0x0A) + { + if (!HasCompletePngContainer(imgData)) + { + error = "The PNG container is incomplete or contains trailing data"; + return {}; + } + extension = ".png"; + } + else if (imgData.size() >= 3 && imgData[0] == 0xFF && imgData[1] == 0xD8 && imgData[2] == 0xFF) + { + if (imgData.size() < 4 || imgData[imgData.size() - 2] != 0xFF || imgData.back() != 0xD9) + { + error = "The JPEG container is incomplete or contains trailing data"; + return {}; + } + extension = ".jpg"; + } + else + { + error = "Only BMP, PNG, and JPEG images are supported"; + return {}; + } + + auto image = LoadBitmap(imgData, error); + if (!image) + { + if (error.empty()) + error = "The image could not be decoded"; + return {}; + } + + return extension; +} + +ImageConversion::ConversionResult ImageConversion::ToJPG(BitmapRef imgData, int Width, int Height, bool ResizeIfNeeded, bool RotatePortrait) { auto casted = dynamic_cast(imgData.get()); - auto image = Resize(std::move(*casted), Width, Height, ResizeIfNeeded); + auto image = std::move(*casted); + if (RotatePortrait && image.width < image.height) + image = image.Rotate90DegreesClockwise(); + + const bool imageResized = image.width != Width || image.height != Height; + auto resizeAllowed = ResizeIfNeeded; + image = Resize(std::move(image), Width, Height, resizeAllowed); if (image.error.size()) return ImageConversion::ConversionResult::Fail(image.error); std::vector result = {}; stbi_write_jpg_to_func(StbiWrite, &result, image.width, image.height, image.channels, image.data, 95); + if (result.empty()) + return ImageConversion::ConversionResult::Fail("Failed to encode image as JPG"); - return ImageConversion::ConversionResult::Success(std::move(result), ResizeIfNeeded); + return ImageConversion::ConversionResult::Success(std::move(result), imageResized); } ImageConversion::ConversionResult ImageConversion::ToBootloaderBMP(std::span imgData) { + if (auto bmp = ParseBootloaderBMP(imgData); bmp && bmp->width == 720 && bmp->height == 1280) + return ImageConversion::ConversionResult::Success(std::vector(imgData.begin(), imgData.end()), false); + StbImageHolder image{ imgData }; if (image.error.size()) return ImageConversion::ConversionResult::Fail("Failed to load image: " + image.error); @@ -269,6 +446,8 @@ ImageConversion::ConversionResult ImageConversion::ToBootloaderBMP(std::span result = {}; stbi_write_bmp_to_func(StbiWrite, &result, image.width, image.height, image.channels, image.data); + if (result.empty()) + return ImageConversion::ConversionResult::Fail("Failed to encode image as BMP"); return ImageConversion::ConversionResult::Success(std::move(result), false /*don't care*/); } @@ -331,4 +510,4 @@ ImageConversion::ConversionResult ImageConversion::ToDDS(std::span img bin.moveOutBuffer(result); return ImageConversion::ConversionResult::Success(std::move(result), imageResized); } -#endif \ No newline at end of file +#endif diff --git a/SwitchThemesNX/source/SwitchThemesCommon/Bntx/ImageConversion.hpp b/SwitchThemesNX/source/SwitchThemesCommon/Bntx/ImageConversion.hpp index 57e173a5..292268ef 100644 --- a/SwitchThemesNX/source/SwitchThemesCommon/Bntx/ImageConversion.hpp +++ b/SwitchThemesNX/source/SwitchThemesCommon/Bntx/ImageConversion.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include "../MyTypes.h" @@ -44,6 +45,8 @@ namespace ImageConversion BitmapRef LoadBitmap(std::span imgData, std::string& error); + std::string_view GetSupportedImageExtension(std::span imgData, std::string& error); + ConversionResult ToDDS(std::span imgData, bool DXT5 = false, int Width = 1280, @@ -53,8 +56,9 @@ namespace ImageConversion ConversionResult ToJPG(BitmapRef imgData, int Width = 1280, int Height = 720, - bool ResizeIfNeeded = false); + bool ResizeIfNeeded = false, + bool RotatePortrait = false); ConversionResult ToBootloaderBMP(std::span imgData); } -#endif \ No newline at end of file +#endif diff --git a/SwitchThemesNX/source/SwitchThemesCommon/Common.hpp b/SwitchThemesNX/source/SwitchThemesCommon/Common.hpp index 663019ce..e3d00117 100644 --- a/SwitchThemesNX/source/SwitchThemesCommon/Common.hpp +++ b/SwitchThemesNX/source/SwitchThemesCommon/Common.hpp @@ -86,4 +86,4 @@ namespace hos { extern SystemVersion Version; extern std::array VersionHash; -} \ No newline at end of file +} diff --git a/SwitchThemesNX/source/fs.cpp b/SwitchThemesNX/source/fs.cpp index bb744ca8..82e0bbf3 100644 --- a/SwitchThemesNX/source/fs.cpp +++ b/SwitchThemesNX/source/fs.cpp @@ -90,6 +90,7 @@ static vector GetThemeFilesInDirRecursive(const string& path, int level) StrEndsWith(p.path().string(), ".jpg") || StrEndsWith(p.path().string(), ".jpeg") || StrEndsWith(p.path().string(), ".png") || + StrEndsWith(p.path().string(), ".bmp") || StrEndsWith(p.path().string(), ".nxtheme") || StrEndsWith(p.path().string(), ".zip") || StrEndsWith(p.path().string(), ".ttf")) { @@ -451,4 +452,4 @@ std::vector fs::patches::OpenPatchForBuild(const std::string& buildId) bool fs::patches::hasPatchForBuild(const std::string& buildId) { return fs::Exists(fs::path::PatchesDir + buildId + ".ips"); -} \ No newline at end of file +} From 5b871634407dfc916b543fa90edf85a75aa9c080 Mon Sep 17 00:00:00 2001 From: Migush Date: Tue, 25 Aug 2026 16:24:52 +0200 Subject: [PATCH 2/4] feat: support generic remote images --- SwitchThemesNX/SwitchThemesNX.vcxproj | 3 +- SwitchThemesNX/SwitchThemesNX.vcxproj.filters | 5 +- .../source/Pages/RemoteInstall/API.hpp | 6 ++- .../source/Pages/RemoteInstall/Detail.cpp | 48 ++++++++++++++---- .../source/Pages/RemoteInstall/List.cpp | 49 +++++++++++-------- .../source/Pages/RemoteInstall/List.hpp | 3 +- .../Pages/RemoteInstall/RemoteTarget.hpp | 25 ++++++++++ .../source/Pages/RemoteInstall/Worker.cpp | 7 ++- 8 files changed, 109 insertions(+), 37 deletions(-) create mode 100644 SwitchThemesNX/source/Pages/RemoteInstall/RemoteTarget.hpp diff --git a/SwitchThemesNX/SwitchThemesNX.vcxproj b/SwitchThemesNX/SwitchThemesNX.vcxproj index 7849d805..ef46d316 100644 --- a/SwitchThemesNX/SwitchThemesNX.vcxproj +++ b/SwitchThemesNX/SwitchThemesNX.vcxproj @@ -71,6 +71,7 @@ + @@ -551,4 +552,4 @@ - \ No newline at end of file + diff --git a/SwitchThemesNX/SwitchThemesNX.vcxproj.filters b/SwitchThemesNX/SwitchThemesNX.vcxproj.filters index 0e89e46c..09cfee4c 100644 --- a/SwitchThemesNX/SwitchThemesNX.vcxproj.filters +++ b/SwitchThemesNX/SwitchThemesNX.vcxproj.filters @@ -227,6 +227,9 @@ Pages\RemoteInstall + + Pages\RemoteInstall + Platform @@ -933,4 +936,4 @@ Libs\mbedtls - \ No newline at end of file + diff --git a/SwitchThemesNX/source/Pages/RemoteInstall/API.hpp b/SwitchThemesNX/source/Pages/RemoteInstall/API.hpp index 05b0210b..00399cc3 100644 --- a/SwitchThemesNX/source/Pages/RemoteInstall/API.hpp +++ b/SwitchThemesNX/source/Pages/RemoteInstall/API.hpp @@ -72,8 +72,12 @@ namespace RemoteInstall::API The name should be a short name describing the theme, layout info and author name are already part of the NXTheme file and not needed there. When saving on the sd card the installer will normalize and, if needed, shorten the name obtained from the NXTheme manifest. - Valid falues for the `target` field are the internal nxtheme target strings, currently these are the following: + Valid values for the `target` field are the internal nxtheme target strings, currently these are the following: "home", "lock", "user", "apps", "set", "news" and "psl". + The special target "__image" is used for raw image files rather than + nxtheme archives. The installer validates the downloaded bytes, saves them + with their real image extension, and opens the standard image installation + dialog so the user can choose a theme wallpaper or boot-screen destination. The entry must have at least one preview image between `preview` and `thumbnail`, having both is ideal but not needed. `preview` is downloaded for full screen previewing, `thumbnail` for lists. diff --git a/SwitchThemesNX/source/Pages/RemoteInstall/Detail.cpp b/SwitchThemesNX/source/Pages/RemoteInstall/Detail.cpp index 182e713c..9121dbd3 100644 --- a/SwitchThemesNX/source/Pages/RemoteInstall/Detail.cpp +++ b/SwitchThemesNX/source/Pages/RemoteInstall/Detail.cpp @@ -1,16 +1,19 @@ #include "Detail.hpp" #include "Worker.hpp" +#include "RemoteTarget.hpp" #include "../../fs.hpp" #include "../../ViewFunctions.hpp" -#include "../../SwitchThemesCommon/Common.hpp" #include "../ThemeEntry/ThemeEntry.hpp" +#include "../ThemeEntry/ImageEntry.hpp" #include "../ImagePreview.hpp" #include "../ThemePage.hpp" +#include "../../SwitchThemesCommon/Bntx/ImageConversion.hpp" + +#include RemoteInstall::DetailPage::DetailPage(const RemoteInstall::API::Entry& entry, ImageRef i) : entry(entry), img(i) { - auto info = ThemeTargetInfo::Find(entry.Target); - PartName = info ? info->PartName : "Unknown part name"; + PartName = RemoteInstall::TargetLabel(entry.Target); } void RemoteInstall::DetailPage::Update() {} @@ -55,12 +58,29 @@ void RemoteInstall::DetailPage::Render(int X, int Y) void RemoteInstall::DetailPage::UserDownload(Action action) { - PushFunction([this, action]() { + const bool isImage = RemoteInstall::IsImage(entry.Target); + PushFunction([this, action, isImage]() { auto theme = DownloadData(); if (theme.size() == 0) return; - - auto entry = ThemeEntry::FromMemory(theme); - if (!entry->CanInstall()) + + std::string imageError; + const auto imageExtension = isImage ? + ImageConversion::GetSupportedImageExtension(theme, imageError) : std::string_view{}; + if (isImage && imageExtension.empty()) + { + DialogBlocking("The downloaded image is not valid: " + imageError); + return; + } + + std::unique_ptr themeEntry; + if (isImage) + { + themeEntry = std::make_unique(this->entry.Name + std::string(imageExtension), std::move(theme)); + } + else + themeEntry = ThemeEntry::FromMemory(theme); + + if (!themeEntry->CanInstall()) { DialogBlocking("This theme is not valid"); return; @@ -69,7 +89,8 @@ void RemoteInstall::DetailPage::UserDownload(Action action) if ((int)action & (int)Action::Download) { fs::EnsureDownloadsFolderExists(); - std::string name = fs::path::DownloadsFolder + fs::SanitizeName(this->entry.Name) + ".nxtheme"; + const auto extension = isImage ? imageExtension : std::string_view(".nxtheme"); + std::string name = fs::path::DownloadsFolder + fs::SanitizeName(this->entry.Name) + std::string(extension); if (fs::Exists(name) && !YesNoPage::Ask("A file called " + name + " already exists on the sd card, do you want to replace it ?")) { if (action == Action::Download) // If the user asked to download the theme don't close the page, otherwise just install it @@ -77,14 +98,21 @@ void RemoteInstall::DetailPage::UserDownload(Action action) } else { - fs::WriteFile(name, theme); + fs::WriteFile(name, isImage ? DownloadedTheme : theme); fs::theme::RequestThemeListRefresh(); ThemesPage::Instance->SelectElementOnRescan(name); } } if ((int)action & (int)Action::Install) - entry->Install(true); + { + if (!themeEntry->Install(true) && isImage) + { + if (!themeEntry->CanInstall() && !themeEntry->CannotInstallReason.empty()) + DialogBlocking(themeEntry->CannotInstallReason); + return; + } + } PopPage(this); }); diff --git a/SwitchThemesNX/source/Pages/RemoteInstall/List.cpp b/SwitchThemesNX/source/Pages/RemoteInstall/List.cpp index 1400c1f1..a76afb38 100644 --- a/SwitchThemesNX/source/Pages/RemoteInstall/List.cpp +++ b/SwitchThemesNX/source/Pages/RemoteInstall/List.cpp @@ -1,13 +1,15 @@ #include #include +#include #include "List.hpp" #include "Worker.hpp" +#include "RemoteTarget.hpp" #include "../ImagePreview.hpp" #include "../ThemePage.hpp" #include "../../fs.hpp" #include "../../ViewFunctions.hpp" #include "../../UI/UI.hpp" -#include "../../SwitchThemesCommon/Common.hpp" +#include "../../SwitchThemesCommon/Bntx/ImageConversion.hpp" const ImVec2 ImageSize = { 398, 224 }; @@ -114,14 +116,6 @@ bool RemoteInstall::ListPage::IsSelected(size_t i) return Selection[i]; } -std::vector RemoteInstall::ListPage::GetSelectedUrls() -{ - std::vector Urls; - for (size_t i = 0; i < response.Entries.size(); i++) - if (IsSelected(i)) Urls.push_back(response.Entries[i].Url); - return Urls; -} - void RemoteInstall::ListPage::SelectionChanged() { std::stringstream ss; @@ -173,14 +167,29 @@ void RemoteInstall::ListPage::DownloadClicked() folderName += '/'; - auto urls = GetSelectedUrls(); + std::vector urls; + std::vector imageEntries; + for (size_t i = 0; i < response.Entries.size(); i++) + { + if (!IsSelected(i)) + continue; + + urls.push_back(response.Entries[i].Url); + imageEntries.push_back(RemoteInstall::IsImage(response.Entries[i].Target)); + } size_t numFailed; std::string OutFirstFilaName = ""; - auto worker = new Worker::ActionOnItemFinish(urls, numFailed, [&folderName, &OutFirstFilaName](std::vector&& _invec, uintptr_t index) -> bool { - std::vector vec = _invec; - std::string name = folderName + std::to_string(index) + ".nxtheme"; + auto worker = new Worker::ActionOnItemFinish(urls, numFailed, [&folderName, &OutFirstFilaName, imageEntries = std::move(imageEntries)](std::vector&& vec, uintptr_t index) -> bool { + std::string imageError; + const auto imageExtension = imageEntries[index] ? + ImageConversion::GetSupportedImageExtension(vec, imageError) : std::string_view{}; + if (imageEntries[index] && imageExtension.empty()) + return false; + + const auto extension = imageEntries[index] ? imageExtension : std::string_view(".nxtheme"); + std::string name = folderName + std::to_string(index) + std::string(extension); try { fs::WriteFile(name, vec); @@ -230,8 +239,7 @@ RemoteInstall::ListPage::Result RemoteInstall::ListPage::RenderWidget(size_t ind const bool selected = IsSelected(index); const std::string& Name = response.Entries[index].Name; - auto targetInfo = ThemeTargetInfo::Find(response.Entries[index].Target); - const char* Target = targetInfo ? targetInfo->PartName.c_str() : "Unknown part name"; + const auto Target = RemoteInstall::TargetLabel(response.Entries[index].Target); const auto& img = images.List[index]; @@ -244,12 +252,13 @@ RemoteInstall::ListPage::Result RemoteInstall::ListPage::RenderWidget(size_t ind const ImGuiID id = window->GetID(ScrollIDs[index].c_str()); const ImVec2 name_size = ImGui::CalcTextSize(Name.c_str(), NULL, false, ImageSize.x - 6); - const ImVec2 target_size = ImGui::CalcTextSize(Target, NULL, false, ImageSize.x - 6); + const ImVec2 target_size = ImGui::CalcTextSize(Target.data(), NULL, false, ImageSize.x - 6); ImVec2 pos = window->DC.CursorPos; ImVec2 sz = { ImageSize.x, ImageSize.y + 6 + name_size.y }; - if (Target) sz += {0, target_size.y + 6}; + if (!Target.empty()) + sz += {0, target_size.y + 6}; const ImRect imageBox(pos, pos + ImageSize); @@ -284,11 +293,11 @@ RemoteInstall::ListPage::Result RemoteInstall::ListPage::RenderWidget(size_t ind ImGui::PushFont(font25); ImGui::RenderTextWrapped({ pos.x + 3, pos.y + ImageSize.y + 3 }, Name.c_str(), 0, ImageSize.x - 6); - if (Target) - ImGui::RenderTextWrapped({ pos.x + 3, pos.y + ImageSize.y + name_size.y + 6 }, Target, 0, ImageSize.x - 6); + if (!Target.empty()) + ImGui::RenderTextWrapped({ pos.x + 3, pos.y + ImageSize.y + name_size.y + 6 }, Target.data(), 0, ImageSize.x - 6); ImGui::PopFont(); IMGUI_TEST_ENGINE_ITEM_INFO(id, label, window->DC.LastItemStatusFlags); return result; -} \ No newline at end of file +} diff --git a/SwitchThemesNX/source/Pages/RemoteInstall/List.hpp b/SwitchThemesNX/source/Pages/RemoteInstall/List.hpp index d062ffd4..134492f8 100644 --- a/SwitchThemesNX/source/Pages/RemoteInstall/List.hpp +++ b/SwitchThemesNX/source/Pages/RemoteInstall/List.hpp @@ -30,7 +30,6 @@ namespace RemoteInstall void ApplySelection(bool all); void ToggleSelected(size_t i); bool IsSelected(size_t i); - std::vector GetSelectedUrls(); std::string DownloadBtnText = "Download"; void SelectionChanged(); @@ -40,4 +39,4 @@ namespace RemoteInstall void PopulateScrollIDs(); std::vector ScrollIDs; }; -} \ No newline at end of file +} diff --git a/SwitchThemesNX/source/Pages/RemoteInstall/RemoteTarget.hpp b/SwitchThemesNX/source/Pages/RemoteInstall/RemoteTarget.hpp new file mode 100644 index 00000000..792d2fbe --- /dev/null +++ b/SwitchThemesNX/source/Pages/RemoteInstall/RemoteTarget.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include "../../SwitchThemesCommon/Common.hpp" + +namespace RemoteInstall +{ + inline constexpr std::string_view ImageTarget = "__image"; + + inline bool IsImage(std::string_view target) noexcept + { + return target == ImageTarget; + } + + inline std::string TargetLabel(std::string_view target) + { + if (IsImage(target)) + return "Plain Image"; + + auto info = ThemeTargetInfo::Find(std::string(target)); + return info ? info->PartName : "Unknown part name"; + } +} diff --git a/SwitchThemesNX/source/Pages/RemoteInstall/Worker.cpp b/SwitchThemesNX/source/Pages/RemoteInstall/Worker.cpp index 2a291a40..eb320378 100644 --- a/SwitchThemesNX/source/Pages/RemoteInstall/Worker.cpp +++ b/SwitchThemesNX/source/Pages/RemoteInstall/Worker.cpp @@ -52,7 +52,8 @@ void RemoteInstall::Worker::BaseWorker::Update() curl_easy_getinfo(msg->easy_handle, CURLINFO_PRIVATE, &index); curl_easy_getinfo(e, CURLINFO_RESPONSE_CODE, &httpCode); - if (msg->data.result != CURLE_OK || !OnFinished(index, httpCode)) + const bool httpSuccess = httpCode >= 200 && httpCode < 300; + if (msg->data.result != CURLE_OK || !httpSuccess || !OnFinished(index, httpCode)) { if (appendUrlToError) { if (index < urls.size()) @@ -66,6 +67,8 @@ void RemoteInstall::Worker::BaseWorker::Update() if (msg->data.result != CURLE_OK) Errors << " failed: " << curl_easy_strerror(msg->data.result) << "(" << msg->data.result << ")" << std::endl; + else if (!httpSuccess) + Errors << " failed with HTTP status " << httpCode << std::endl; else Errors << " failed due to handler error" << std::endl; @@ -170,4 +173,4 @@ void RemoteInstall::Worker::DownloadSingle::OnComplete() DialogBlocking(str); } else OutBuffer = std::move(Results[0]); -} \ No newline at end of file +} From f5211f7635f553f935f379d9a4c7299c5ad20f39 Mon Sep 17 00:00:00 2001 From: Migush Date: Tue, 25 Aug 2026 16:24:58 +0200 Subject: [PATCH 3/4] chore: bump installer version to 3.0.2 --- SwitchThemesNX/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SwitchThemesNX/Makefile b/SwitchThemesNX/Makefile index 79d4d056..7f5029e0 100644 --- a/SwitchThemesNX/Makefile +++ b/SwitchThemesNX/Makefile @@ -53,7 +53,7 @@ ROMFS := romfs APP_TITLE := NXThemes Installer APP_AUTHOR := exelix -APP_VERSION := 3.0.1 +APP_VERSION := 3.0.2 GITVER ?= Unknown #--------------------------------------------------------------------------------- @@ -246,4 +246,4 @@ $(OFILES_SRC) : $(HFILES_BIN) #--------------------------------------------------------------------------------------- endif -#--------------------------------------------------------------------------------------- \ No newline at end of file +#--------------------------------------------------------------------------------------- From e2b63e40674145431c72b9aef5fdf13169ce2597 Mon Sep 17 00:00:00 2001 From: Migush Date: Tue, 25 Aug 2026 17:20:57 +0200 Subject: [PATCH 4/4] chore: ignore macOS Finder metadata --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index af5ef65b..c4b1315d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# macOS Finder metadata +.DS_Store + # User-specific files *.suo *.user