From 455150ad90e646ebf89270098de7a730911b4da2 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 1 Sep 2026 09:06:01 -0600 Subject: [PATCH] Mods: FileService (#2367) --- CMakeLists.txt | 2 +- docs/modding.md | 49 ++- extern/borealis | 2 +- files.cmake | 3 + mods/ao_mod/CMakeLists.txt | 1 - sdk/include/mods/svc/file.h | 118 ++++++ sdk/include/mods/svc/file.hpp | 321 +++++++++++++++ sdk/include/mods/svc/ui.h | 21 +- src/dusk/mods/svc/file.cpp | 697 +++++++++++++++++++++++++++++++++ src/dusk/mods/svc/registry.cpp | 7 + src/dusk/mods/svc/registry.hpp | 1 + src/dusk/mods/svc/ui.cpp | 26 +- src/dusk/ui/file_button.cpp | 71 ++++ src/dusk/ui/file_button.hpp | 43 ++ src/dusk/ui/mod_window.cpp | 12 + src/dusk/ui/mod_window.hpp | 5 + src/dusk/ui/prelaunch.cpp | 53 +-- src/dusk/ui/settings.cpp | 5 +- src/m_Do/m_Do_main.cpp | 56 ++- 19 files changed, 1413 insertions(+), 80 deletions(-) create mode 100644 sdk/include/mods/svc/file.h create mode 100644 sdk/include/mods/svc/file.hpp create mode 100644 src/dusk/mods/svc/file.cpp create mode 100644 src/dusk/ui/file_button.cpp create mode 100644 src/dusk/ui/file_button.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 83118beb1b..dcd53086fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -304,7 +304,7 @@ include(cmake/GameABIConfig.cmake) find_package(Threads REQUIRED) set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd - aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::log borealis::presentation borealis::sentry borealis::update freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt + aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::presentation borealis::sentry borealis::update freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt Threads::Threads zstd::libzstd dusklight_game_headers) if (DUSK_HAS_FUNCHOOK) list(APPEND GAME_LIBS funchook-static) diff --git a/docs/modding.md b/docs/modding.md index 317ae32c54..637a718a53 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -48,7 +48,7 @@ cmake_minimum_required(VERSION 3.26) project(my_mod CXX) if (NOT DUSKLIGHT_VERSION) - set(DUSKLIGHT_VERSION "76b56cd8b81809fce0a5c2a44e2f6d437591132f") + set(DUSKLIGHT_VERSION "76b56cd8b81809fce0a5c2a44e2f6d437591132f") endif () include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/FetchDusklight.cmake") add_subdirectory("${DUSKLIGHT_DIR}/sdk" dusklight-sdk EXCLUDE_FROM_ALL) @@ -212,6 +212,51 @@ if (svc_resource->load(mod_ctx, "config.txt", &buf) == MOD_OK) { Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. The bundle is read-only; use `HostService::data_dir` for persistent storage. +### FileService (`mods/svc/file.h`) + +Provides file and folder pickers, file I/O, exports and folder enumeration. + +A location is an opaque UTF-8 string returned by `pick_*`, `export_file`, `join`, or `create_child`. +Save it and pass it back to the service. Never parse it or manually append path segments. + +```cpp +#include "mods/svc/file.hpp" + +IMPORT_SERVICE(FileService, svc_file); + +mods::file::PickOptions options; +options.filters.push_back({"Audio", "wav;ogg"}); +mods::file::pick_file(options, [](mods::file::PickResult result) { + if (result.status == MOD_OK && !result.locations.empty()) { + save_location(result.locations.front()); + } +}); +``` + +Use `check` before reopening a saved location because removable storage or an access grant may no longer be available. +`open` provides seekable streaming I/O. `read_all` allocates the entire file and should only be used when the file is +small. Folder locations support `list` and child resolution through `join`. Only one picker can be open at a time. + +`create_child` never replaces an existing file and returns `MOD_CONFLICT` if one exists. Use the returned location; +document providers may adjust the requested name. `write_all` is a convenience function over +`open`/`write`/`flush`/`close`. + +```cpp +std::string location; +if (mods::file::create_child(folder, "report.txt", location) == MOD_OK) { + mods::file::write_all(location, report); +} + +mods::file::export_file(location, "report.txt", [](mods::file::PickResult result) { + if (result.status == MOD_OK) { + remember_export_destination(result.locations.front()); + } +}); +``` + +`export_file` copies an existing file to a user-selected destination and returns the destination location in its +callback. Mod-owned persistent files belong in `HostService::data_dir`. + ### HostService (`mods/svc/host.h`) Mod metadata and runtime interaction with the loader: @@ -587,7 +632,7 @@ svc_ui->dialog_push(mod_ctx, &dialog, nullptr); ``` After an action's `on_pressed`, the dialog closes unless the action sets `keep_open`. It can then be closed later -(or immediately) with `dialog_close`. Cancel fires `on_dismiss` and always closes. `dialog_set_body` and +(or immediately) with `dialog_close`. Cancel fires `on_dismiss` and always closes. `dialog_set_body` and `dialog_set_icon` mutate a live dialog. **Toasts:** `push_toast` enqueues a notification. Titles and bodies accept RML. The optional `type` is applied as an diff --git a/extern/borealis b/extern/borealis index 6fd955e7e6..32153a5480 160000 --- a/extern/borealis +++ b/extern/borealis @@ -1 +1 @@ -Subproject commit 6fd955e7e6a2d2d47c5a4eaf363fb49847e490ba +Subproject commit 32153a548019aa0557ce0da354c2e737799f5e3d diff --git a/files.cmake b/files.cmake index c8bc6f1187..b111fb7d92 100644 --- a/files.cmake +++ b/files.cmake @@ -1492,6 +1492,7 @@ set(DUSK_FILES src/dusk/mods/svc/camera.cpp src/dusk/mods/svc/config.cpp src/dusk/mods/svc/config.hpp + src/dusk/mods/svc/file.cpp src/dusk/mods/svc/game.cpp src/dusk/mods/svc/gfx.cpp src/dusk/mods/svc/flow.cpp @@ -1549,6 +1550,8 @@ set(DUSK_FILES src/dusk/ui/graphics_tuner.hpp src/dusk/ui/group_button.cpp src/dusk/ui/group_button.hpp + src/dusk/ui/file_button.cpp + src/dusk/ui/file_button.hpp src/dusk/ui/icon_provider.cpp src/dusk/ui/icon_provider.hpp src/dusk/ui/input.cpp diff --git a/mods/ao_mod/CMakeLists.txt b/mods/ao_mod/CMakeLists.txt index 723ed55006..7304654d0c 100644 --- a/mods/ao_mod/CMakeLists.txt +++ b/mods/ao_mod/CMakeLists.txt @@ -17,5 +17,4 @@ add_mod(ao_mod SOURCES src/mod.cpp MOD_JSON mod.json RES_DIR res - BUNDLE ) diff --git a/sdk/include/mods/svc/file.h b/sdk/include/mods/svc/file.h new file mode 100644 index 0000000000..4d5c82b5a1 --- /dev/null +++ b/sdk/include/mods/svc/file.h @@ -0,0 +1,118 @@ +#pragma once + +#include + +#ifdef __cplusplus +#include +#endif + +#define FILE_SERVICE_ID "dev.twilitrealm.dusklight.file" +#define FILE_SERVICE_MAJOR 1u +#define FILE_SERVICE_MINOR 0u + +typedef uint64_t FileStreamHandle; + +typedef enum FileOpenMode { + FILE_OPEN_READ = 0, + FILE_OPEN_TRUNCATE = 1, + FILE_OPEN_APPEND = 2, +} FileOpenMode; + +typedef struct FileFilter { + const char* name; + /* Semicolon-separated extensions or "*". */ + const char* pattern; +} FileFilter; + +typedef struct FilePickOptions { + uint32_t struct_size; + const FileFilter* filters; + uint32_t filter_count; + /* Optional previously returned location. */ + const char* default_location; +} FilePickOptions; + +#define FILE_PICK_OPTIONS_INIT {sizeof(FilePickOptions), NULL, 0u, NULL} + +/* Locations and error are valid only for the duration of the callback. Canceled picks report + * MOD_UNAVAILABLE. The callback runs on the game thread. */ +typedef void (*FilePickFn)(ModContext* ctx, ModResult status, const char* const* locations, + uint32_t location_count, const char* error, void* user_data); + +typedef struct FileBuffer { + uint32_t struct_size; + void* data; + size_t size; +} FileBuffer; + +#define FILE_BUFFER_INIT {sizeof(FileBuffer), NULL, 0u} + +typedef struct FileEntry { + const char* name; + const char* location; + bool is_directory; +} FileEntry; + +/* Called once per entry, then once with entry == NULL. Entries are valid only for the duration of + * the callback. */ +typedef void (*FileListFn)(ModContext* ctx, const FileEntry* entry, void* user_data); + +/* + * Access to user-selected files and folders. + * + * A location is an opaque UTF-8 string returned by `pick_*`, `export_file`, `join`, `create_child`. + * Save it and pass it back to the service. Never parse it or manually append path segments. + * + * Android: Security grants are restored across launches. Only 512 (or 128 before API 30) grants are + * allowed at a time. If a grant cannot be retained or was revoked, `check`/`open` returns + * MOD_UNAVAILABLE so the user can select the location again. + * + * Calls other than picker completion are synchronous and must run on the game thread. Avoid file + * reads and folder traversal in per-frame callbacks. + */ +typedef struct FileService { + ServiceHeader header; + + /* One native dialog is allowed at a time. Returns MOD_CONFLICT while one is outstanding. */ + ModResult (*pick_file)( + ModContext* ctx, const FilePickOptions* options, FilePickFn fn, void* user_data); + ModResult (*pick_folder)( + ModContext* ctx, const FilePickOptions* options, FilePickFn fn, void* user_data); + /* Exports an existing file to a user-chosen destination. fn receives its final location. */ + ModResult (*export_file)(ModContext* ctx, const char* source_location, + const char* suggested_name, FilePickFn fn, void* user_data); + + ModResult (*display_name)( + ModContext* ctx, const char* location, char* buffer, uint32_t buffer_size); + /* MOD_OK, MOD_UNAVAILABLE when gone or inaccessible, or MOD_UNSUPPORTED. */ + ModResult (*check)(ModContext* ctx, const char* location); + + /* Write modes require an existing writable location and return MOD_UNSUPPORTED otherwise. */ + ModResult (*open)( + ModContext* ctx, const char* location, FileOpenMode mode, FileStreamHandle* out_handle); + ModResult (*size)(ModContext* ctx, FileStreamHandle handle, uint64_t* out_size); + ModResult (*read)(ModContext* ctx, FileStreamHandle handle, void* buffer, uint64_t length, + uint64_t* out_read); + ModResult (*write)( + ModContext* ctx, FileStreamHandle handle, const void* buffer, uint64_t length); + ModResult (*seek)(ModContext* ctx, FileStreamHandle handle, uint64_t offset); + ModResult (*flush)(ModContext* ctx, FileStreamHandle handle); + /* Reports flush and close failures; writers must check the result. */ + ModResult (*close)(ModContext* ctx, FileStreamHandle handle); + + ModResult (*read_all)(ModContext* ctx, const char* location, FileBuffer* out_buffer); + /* Truncates and writes an existing location. This operation is not atomic. */ + ModResult (*write_all)(ModContext* ctx, const char* location, const void* data, size_t size); + void (*free)(ModContext* ctx, FileBuffer* buffer); + + ModResult (*list)(ModContext* ctx, const char* folder_location, FileListFn fn, void* user_data); + /* out_location remains valid until this mod's next `join` or `create_child` call. */ + ModResult (*join)(ModContext* ctx, const char* folder_location, const char* relative_path, + const char** out_location); + + /* Creates one file without replacing an existing child. */ + ModResult (*create_child)( + ModContext* ctx, const char* folder_location, const char* name, const char** out_location); +} FileService; + +MOD_DECLARE_SERVICE(FileService, svc_file, FILE_SERVICE_ID, FILE_SERVICE_MAJOR, FILE_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/file.hpp b/sdk/include/mods/svc/file.hpp new file mode 100644 index 0000000000..e297000c2e --- /dev/null +++ b/sdk/include/mods/svc/file.hpp @@ -0,0 +1,321 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace mods::file { + +class File { +public: + File() = default; + File(FileStreamHandle handle, ModResult result) : mHandle{handle}, mResult{result} {} + ~File() { reset(); } + File(const File&) = delete; + File& operator=(const File&) = delete; + File(File&& other) noexcept { *this = std::move(other); } + File& operator=(File&& other) noexcept { + if (this != &other) { + reset(); + mHandle = std::exchange(other.mHandle, 0); + mResult = other.mResult; + } + return *this; + } + + explicit operator bool() const { return mResult == MOD_OK && mHandle != 0; } + ModResult result() const { return mResult; } + FileStreamHandle handle() const { return mHandle; } + + uint64_t size() const { + uint64_t value = 0; + if (mHandle != 0 && svc_file != nullptr) { + svc_file->size(mod_ctx, mHandle, &value); + } + return value; + } + + uint64_t read(void* buffer, uint64_t length) { + uint64_t value = 0; + if (mHandle == 0 || svc_file == nullptr) { + mResult = MOD_UNAVAILABLE; + } else { + mResult = svc_file->read(mod_ctx, mHandle, buffer, length, &value); + } + return value; + } + + bool seek(uint64_t offset) { + mResult = mHandle != 0 && svc_file != nullptr ? svc_file->seek(mod_ctx, mHandle, offset) : + MOD_UNAVAILABLE; + return mResult == MOD_OK; + } + + bool write(const void* buffer, uint64_t length) { + if (mHandle == 0 || svc_file == nullptr) { + mResult = MOD_UNAVAILABLE; + } else { + mResult = svc_file->write(mod_ctx, mHandle, buffer, length); + } + return mResult == MOD_OK; + } + + bool write(std::span bytes) { return write(bytes.data(), bytes.size()); } + + bool flush() { + if (mHandle == 0 || svc_file == nullptr) { + mResult = MOD_UNAVAILABLE; + } else { + mResult = svc_file->flush(mod_ctx, mHandle); + } + return mResult == MOD_OK; + } + + bool close() { + if (mHandle == 0) { + return mResult == MOD_OK; + } + mResult = svc_file != nullptr ? svc_file->close(mod_ctx, mHandle) : MOD_UNAVAILABLE; + mHandle = 0; + return mResult == MOD_OK; + } + + void reset() { (void)close(); } + +private: + FileStreamHandle mHandle = 0; + ModResult mResult = MOD_UNAVAILABLE; +}; + +inline File open(const std::string& location, FileOpenMode mode = FILE_OPEN_READ) { + if (svc_file == nullptr) { + return {0, MOD_UNAVAILABLE}; + } + FileStreamHandle handle = 0; + const auto result = svc_file->open(mod_ctx, location.c_str(), mode, &handle); + return {handle, result}; +} + +class Buffer { +public: + Buffer() = default; + Buffer(FileBuffer buffer, ModResult result) : mBuffer{buffer}, mResult{result} {} + ~Buffer() { reset(); } + Buffer(const Buffer&) = delete; + Buffer& operator=(const Buffer&) = delete; + Buffer(Buffer&& other) noexcept { *this = std::move(other); } + Buffer& operator=(Buffer&& other) noexcept { + if (this != &other) { + reset(); + mBuffer = other.mBuffer; + mResult = other.mResult; + other.mBuffer = FILE_BUFFER_INIT; + } + return *this; + } + + explicit operator bool() const { return mResult == MOD_OK; } + ModResult result() const { return mResult; } + std::span bytes() const { + return {static_cast(mBuffer.data), mBuffer.size}; + } + void reset() { + if (mBuffer.data != nullptr && svc_file != nullptr) { + svc_file->free(mod_ctx, &mBuffer); + } + mBuffer.data = nullptr; + mBuffer.size = 0; + } + +private: + FileBuffer mBuffer = FILE_BUFFER_INIT; + ModResult mResult = MOD_UNAVAILABLE; +}; + +inline Buffer read_all(const std::string& location) { + FileBuffer buffer = FILE_BUFFER_INIT; + const auto result = svc_file != nullptr ? + svc_file->read_all(mod_ctx, location.c_str(), &buffer) : + MOD_UNAVAILABLE; + return {buffer, result}; +} + +inline ModResult check(const std::string& location) { + return svc_file != nullptr ? svc_file->check(mod_ctx, location.c_str()) : MOD_UNAVAILABLE; +} + +inline std::string display_name(const std::string& location) { + if (svc_file == nullptr) { + return {}; + } + std::array buffer{}; + return svc_file->display_name(mod_ctx, location.c_str(), buffer.data(), + static_cast(buffer.size())) == MOD_OK ? + std::string{buffer.data()} : + std::string{}; +} + +inline ModResult join( + const std::string& folder, const std::string& relativePath, std::string& outLocation) { + outLocation.clear(); + if (svc_file == nullptr) { + return MOD_UNAVAILABLE; + } + const char* location = nullptr; + const auto result = svc_file->join(mod_ctx, folder.c_str(), relativePath.c_str(), &location); + if (result == MOD_OK && location != nullptr) { + outLocation = location; + } + return result; +} + +inline ModResult create_child( + const std::string& folder, const std::string& name, std::string& outLocation) { + outLocation.clear(); + if (svc_file == nullptr) { + return MOD_UNAVAILABLE; + } + const char* location = nullptr; + const auto result = svc_file->create_child(mod_ctx, folder.c_str(), name.c_str(), &location); + if (result == MOD_OK && location != nullptr) { + outLocation = location; + } + return result; +} + +inline ModResult write_all(const std::string& location, std::span bytes) { + if (svc_file == nullptr) { + return MOD_UNAVAILABLE; + } + return svc_file->write_all(mod_ctx, location.c_str(), bytes.data(), bytes.size()); +} + +struct Entry { + std::string name; + std::string location; + bool isDirectory = false; +}; + +inline ModResult list(const std::string& folder, std::vector& outEntries) { + outEntries.clear(); + if (svc_file == nullptr) { + return MOD_UNAVAILABLE; + } + return svc_file->list( + mod_ctx, folder.c_str(), + [](ModContext*, const FileEntry* entry, void* userData) { + if (entry == nullptr) { + return; + } + static_cast*>(userData)->push_back({ + .name = entry->name != nullptr ? entry->name : "", + .location = entry->location != nullptr ? entry->location : "", + .isDirectory = entry->is_directory, + }); + }, + &outEntries); +} + +struct Filter { + std::string name; + std::string pattern; +}; + +struct PickOptions { + std::vector filters; + std::string defaultLocation; +}; + +struct PickResult { + ModResult status = MOD_ERROR; + std::vector locations; + std::string error; +}; + +namespace detail { + +inline std::function pickCallback; + +inline void pick_trampoline(ModContext*, ModResult status, const char* const* locations, + uint32_t locationCount, const char* error, void*) { + PickResult result{.status = status, .error = error != nullptr ? error : ""}; + result.locations.reserve(locationCount); + for (uint32_t i = 0; i < locationCount; ++i) { + if (locations[i] != nullptr) { + result.locations.emplace_back(locations[i]); + } + } + auto callback = std::move(pickCallback); + pickCallback = {}; + if (callback) { + callback(std::move(result)); + } +} + +inline ModResult pick( + const PickOptions& options, std::function callback, bool folder) { + if (svc_file == nullptr) { + return MOD_UNAVAILABLE; + } + if (!callback) { + return MOD_INVALID_ARGUMENT; + } + if (pickCallback) { + return MOD_CONFLICT; + } + std::vector filters; + filters.reserve(options.filters.size()); + for (const auto& filter : options.filters) { + filters.push_back({filter.name.c_str(), filter.pattern.c_str()}); + } + FilePickOptions raw = FILE_PICK_OPTIONS_INIT; + raw.filters = filters.empty() ? nullptr : filters.data(); + raw.filter_count = static_cast(filters.size()); + raw.default_location = + options.defaultLocation.empty() ? nullptr : options.defaultLocation.c_str(); + pickCallback = std::move(callback); + const auto result = folder ? svc_file->pick_folder(mod_ctx, &raw, pick_trampoline, nullptr) : + svc_file->pick_file(mod_ctx, &raw, pick_trampoline, nullptr); + if (result != MOD_OK) { + pickCallback = {}; + } + return result; +} + +} // namespace detail + +inline ModResult pick_file(const PickOptions& options, std::function callback) { + return detail::pick(options, std::move(callback), false); +} + +inline ModResult pick_folder(const PickOptions& options, std::function callback) { + return detail::pick(options, std::move(callback), true); +} + +inline ModResult export_file(const std::string& sourceLocation, const std::string& suggestedName, + std::function callback) { + if (svc_file == nullptr) { + return MOD_UNAVAILABLE; + } + if (!callback) { + return MOD_INVALID_ARGUMENT; + } + if (detail::pickCallback) { + return MOD_CONFLICT; + } + detail::pickCallback = std::move(callback); + const auto result = svc_file->export_file( + mod_ctx, sourceLocation.c_str(), suggestedName.c_str(), detail::pick_trampoline, nullptr); + if (result != MOD_OK) { + detail::pickCallback = {}; + } + return result; +} + +} // namespace mods::file diff --git a/sdk/include/mods/svc/ui.h b/sdk/include/mods/svc/ui.h index ea8abe2d7c..31bff18c58 100644 --- a/sdk/include/mods/svc/ui.h +++ b/sdk/include/mods/svc/ui.h @@ -2,6 +2,7 @@ #include #include +#include #ifdef __cplusplus #include @@ -9,7 +10,7 @@ #define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" #define UI_SERVICE_MAJOR 2u -#define UI_SERVICE_MINOR 1u +#define UI_SERVICE_MINOR 2u /* * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, toasts, @@ -53,6 +54,7 @@ typedef enum UiControlKind { UI_CONTROL_SELECT = 4, /* one of `options`; the value is the option index */ UI_CONTROL_COLOR = 5, /* RGB/RGBA color string with a picker */ UI_CONTROL_GROUP = 6, /* navigation row (on_pressed) */ + UI_CONTROL_FILE_PICKER = 7, /* file/folder picker with an opaque string location */ } UiControlKind; typedef enum UiControlBinding { @@ -62,7 +64,8 @@ typedef enum UiControlBinding { /* The control reads and writes `config_var` (a ConfigService handle owned by the calling mod) * directly: persistence, change notifications and the modified indicator (value != default) are * wired automatically. The var type must match the control kind: TOGGLE = bool, NUMBER and - * SELECT = int, STRING and COLOR = string. Float vars are not bindable; use callbacks. */ + * SELECT = int, STRING, COLOR and FILE_PICKER = string. Float vars are not bindable; use + * callbacks. */ UI_BINDING_CONFIG_VAR = 1, } UiControlBinding; @@ -71,10 +74,10 @@ typedef enum UiStringSetMode { UI_STRING_SET_ON_CHANGE = 1, /* invokes `set` on every text change (e.g. while typing) */ } UiStringSetMode; -/* Tagged by the control's kind: TOGGLE reads bool_value, NUMBER and SELECT read int_value, STRING - * and COLOR read string_value. string_value passed to a setter is only valid during the call; a - * getter should point it at storage owned by the mod (e.g. a static buffer) that stays valid until - * the next call into the mod — the host copies it right after the getter returns. */ +/* Tagged by the control's kind: TOGGLE reads bool_value, NUMBER and SELECT read int_value, STRING, + * COLOR and FILE_PICKER read string_value. string_value passed to a setter is only valid during the + * call; a getter should point it at storage owned by the mod (e.g. a static buffer) that stays valid + * until the next call into the mod. The host copies it right after the getter returns. */ typedef struct UiControlValue { uint32_t struct_size; bool bool_value; @@ -128,12 +131,16 @@ typedef struct UiControlDesc { bool color_alpha; /* COLOR: use RRGGBBAA values instead of RRGGBB */ UiPredicateFn is_selected; /* BUTTON/GROUP: optional selected state */ UiStringSetMode string_set_mode; /* STRING: when to invoke the setter */ + /* FILE_PICKER: optional file filters and folder selection mode. */ + const FileFilter* file_filters; + size_t file_filter_count; + bool directory_mode; } UiControlDesc; #define UI_CONTROL_DESC_INIT \ {sizeof(UiControlDesc), UI_CONTROL_BUTTON, NULL, NULL, UI_BINDING_CALLBACKS, 0u, NULL, NULL, \ NULL, NULL, NULL, NULL, 0, 0, 1, NULL, NULL, NULL, 0u, 0, NULL, 0u, false, NULL, \ - UI_STRING_SET_ON_COMMIT} + UI_STRING_SET_ON_COMMIT, NULL, 0u, false} typedef uint64_t UiListHandle; diff --git a/src/dusk/mods/svc/file.cpp b/src/dusk/mods/svc/file.cpp new file mode 100644 index 0000000000..1650c7afca --- /dev/null +++ b/src/dusk/mods/svc/file.cpp @@ -0,0 +1,697 @@ +#include "registry.hpp" + +#include "slot_map.hpp" + +#include +#include +#include +#include +#include "dusk/config.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/file.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +constexpr borealis::Log Log{"dusk::mods::file"}; + +static_assert(static_cast(FILE_OPEN_READ) == static_cast(borealis::io::File::Mode::Read)); +static_assert( + static_cast(FILE_OPEN_TRUNCATE) == static_cast(borealis::io::File::Mode::Truncate)); +static_assert( + static_cast(FILE_OPEN_APPEND) == static_cast(borealis::io::File::Mode::Append)); + +SlotMap s_streams; +std::unordered_map s_buffers; +std::unordered_map s_joinResults; +ConfigVar s_pickerOverride{"file.pickerOverride", ""}; + +struct PendingPick { + LoadedMod* owner = nullptr; + FilePickFn callback = nullptr; + void* userData = nullptr; + std::optional overrideLocation; + std::optional exportSource; +}; + +std::shared_ptr s_pendingPick; + +ModResult map_status(borealis::io::Status status) { + switch (status) { + case borealis::io::Status::Ok: + return MOD_OK; + case borealis::io::Status::NotFound: + return MOD_UNAVAILABLE; + case borealis::io::Status::Unsupported: + return MOD_UNSUPPORTED; + case borealis::io::Status::AlreadyExists: + return MOD_CONFLICT; + case borealis::io::Status::Failed: + default: + return MOD_ERROR; + } +} + +ModResult map_picker_status(borealis::file_select::Status status) { + switch (status) { + case borealis::file_select::Status::Selected: + return MOD_OK; + case borealis::file_select::Status::Canceled: + return MOD_UNAVAILABLE; + case borealis::file_select::Status::Unsupported: + return MOD_UNSUPPORTED; + case borealis::file_select::Status::Busy: + return MOD_CONFLICT; + case borealis::file_select::Status::Failed: + default: + return MOD_ERROR; + } +} + +void invoke_pick(const std::shared_ptr& pending, ModResult status, + const std::vector& locations, const std::string& error) { + if (s_pendingPick == pending) { + s_pendingPick.reset(); + } + auto* owner = pending->owner; + const auto callback = pending->callback; + if (owner == nullptr || callback == nullptr || !owner->active) { + return; + } + + std::vector rawLocations; + rawLocations.reserve(locations.size()); + for (const auto& location : locations) { + rawLocations.push_back(location.c_str()); + } + try { + callback(owner->context.get(), status, rawLocations.empty() ? nullptr : rawLocations.data(), + static_cast(rawLocations.size()), error.c_str(), pending->userData); + } catch (const std::exception& exception) { + fail_mod(*owner, MOD_ERROR, + std::string{"exception in file picker callback: "} + exception.what()); + } catch (...) { + fail_mod(*owner, MOD_ERROR, "unknown exception in file picker callback"); + } +} + +bool valid_pick_options(const FilePickOptions* options) { + if (options == nullptr || options->struct_size < sizeof(FilePickOptions) || + (options->filter_count != 0 && options->filters == nullptr)) + { + return false; + } + for (uint32_t i = 0; i < options->filter_count; ++i) { + if (options->filters[i].name == nullptr || options->filters[i].pattern == nullptr) { + return false; + } + } + return true; +} + +ModResult begin_pick(ModContext* context, const FilePickOptions* options, FilePickFn callback, + void* userData, bool folder) { + auto* mod = mod_from_context(context); + if (mod == nullptr || callback == nullptr || !valid_pick_options(options)) { + return MOD_INVALID_ARGUMENT; + } + if (s_pendingPick != nullptr || borealis::file_select::busy()) { + return MOD_CONFLICT; + } + + auto pending = std::make_shared(PendingPick{ + .owner = mod, + .callback = callback, + .userData = userData, + }); + const auto& overrideLocation = s_pickerOverride.getValue(); + if (!overrideLocation.empty()) { + pending->overrideLocation = overrideLocation; + s_pendingPick = std::move(pending); + return MOD_OK; + } + + s_pendingPick = pending; + const std::string defaultLocation = + options->default_location != nullptr ? options->default_location : ""; + if (folder) { + borealis::file_select::open_folder( + { + .parentWindow = aurora::window::get_sdl_window(), + .defaultLocation = defaultLocation, + }, + [pending](borealis::file_select::Result result) { + invoke_pick( + pending, map_picker_status(result.status), result.locations, result.message); + }); + return MOD_OK; + } + + std::vector filters; + filters.reserve(options->filter_count); + for (uint32_t i = 0; i < options->filter_count; ++i) { + filters.push_back({options->filters[i].name, options->filters[i].pattern}); + } + borealis::file_select::open_file( + { + .parentWindow = aurora::window::get_sdl_window(), + .filters = std::move(filters), + .defaultLocation = defaultLocation, + }, + [pending](borealis::file_select::Result result) { + invoke_pick( + pending, map_picker_status(result.status), result.locations, result.message); + }); + return MOD_OK; +} + +ModResult begin_export(ModContext* context, const char* sourceLocation, const char* suggestedName, + FilePickFn callback, void* userData) { + auto* mod = mod_from_context(context); + const std::string_view name = suggestedName != nullptr ? suggestedName : ""; + if (mod == nullptr || sourceLocation == nullptr || callback == nullptr || name.empty() || + name == "." || name == ".." || name.find_first_of("/\\") != std::string_view::npos) + { + return MOD_INVALID_ARGUMENT; + } + if (s_pendingPick != nullptr || borealis::file_select::busy()) { + return MOD_CONFLICT; + } + const auto available = borealis::io::check(sourceLocation); + if (available != borealis::io::Status::Ok) { + return map_status(available); + } + + auto pending = std::make_shared(PendingPick{ + .owner = mod, + .callback = callback, + .userData = userData, + }); + const auto& overrideLocation = s_pickerOverride.getValue(); + if (!overrideLocation.empty()) { + pending->overrideLocation = overrideLocation; + pending->exportSource = sourceLocation; + s_pendingPick = std::move(pending); + return MOD_OK; + } + + borealis::file_select::ExportOptions options{ + .parentWindow = aurora::window::get_sdl_window(), + .sourceLocation = sourceLocation, + .suggestedName = suggestedName, + }; + s_pendingPick = pending; + try { + borealis::file_select::export_file( + std::move(options), [pending](borealis::file_select::Result result) { + invoke_pick( + pending, map_picker_status(result.status), result.locations, result.message); + }); + } catch (...) { + if (s_pendingPick == pending) { + s_pendingPick.reset(); + } + throw; + } + return MOD_OK; +} + +ModResult file_pick_file( + ModContext* context, const FilePickOptions* options, FilePickFn callback, void* userData) { + return begin_pick(context, options, callback, userData, false); +} + +ModResult file_pick_folder( + ModContext* context, const FilePickOptions* options, FilePickFn callback, void* userData) { + return begin_pick(context, options, callback, userData, true); +} + +ModResult file_export_file(ModContext* context, const char* sourceLocation, + const char* suggestedName, FilePickFn callback, void* userData) { + try { + return begin_export(context, sourceLocation, suggestedName, callback, userData); + } catch (...) { + return MOD_ERROR; + } +} + +ModResult file_display_name( + ModContext* context, const char* location, char* buffer, uint32_t bufferSize) { + if (mod_from_context(context) == nullptr || location == nullptr || buffer == nullptr || + bufferSize == 0) + { + return MOD_INVALID_ARGUMENT; + } + const std::string name = borealis::io::display_name(location); + if (name.size() + 1 > bufferSize) { + return MOD_INVALID_ARGUMENT; + } + std::memcpy(buffer, name.c_str(), name.size() + 1); + return MOD_OK; +} + +ModResult file_check(ModContext* context, const char* location) { + if (mod_from_context(context) == nullptr || location == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return map_status(borealis::io::check(location)); +} + +ModResult file_open( + ModContext* context, const char* location, FileOpenMode mode, FileStreamHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || location == nullptr || outHandle == nullptr || mode < FILE_OPEN_READ || + mode > FILE_OPEN_APPEND) + { + return MOD_INVALID_ARGUMENT; + } + try { + const auto ioMode = static_cast(mode); + if (mode != FILE_OPEN_READ) { + const auto available = borealis::io::check(location); + if (available != borealis::io::Status::Ok) { + return map_status(available); + } + } + auto result = borealis::io::open(location, ioMode); + if (result.status != borealis::io::Status::Ok) { + Log.warn("[{}] open '{}' failed: {}", mod->metadata.id, + borealis::io::display_name(location), result.message); + return map_status(result.status); + } + *outHandle = s_streams.emplace(*mod, std::move(result.file)); + return MOD_OK; + } catch (...) { + return MOD_ERROR; + } +} + +borealis::io::File* find_stream(ModContext* context, FileStreamHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return nullptr; + } + auto* entry = s_streams.find_owned(handle, *mod); + return entry != nullptr ? &entry->value : nullptr; +} + +ModResult file_size(ModContext* context, FileStreamHandle handle, uint64_t* outSize) { + if (outSize == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outSize = 0; + auto* file = find_stream(context, handle); + if (file == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outSize = file->size(); + return MOD_OK; +} + +ModResult file_read(ModContext* context, FileStreamHandle handle, void* buffer, uint64_t length, + uint64_t* outRead) { + if (outRead == nullptr || (buffer == nullptr && length != 0)) { + return MOD_INVALID_ARGUMENT; + } + *outRead = 0; + auto* file = find_stream(context, handle); + if (file == nullptr || file->writable()) { + return MOD_INVALID_ARGUMENT; + } + *outRead = file->read(buffer, length); + return file->error().empty() ? MOD_OK : MOD_ERROR; +} + +ModResult file_seek(ModContext* context, FileStreamHandle handle, uint64_t offset) { + auto* file = find_stream(context, handle); + if (file == nullptr) { + return MOD_INVALID_ARGUMENT; + } + return file->seek(offset) ? MOD_OK : MOD_ERROR; +} + +ModResult file_close(ModContext* context, FileStreamHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto entry = s_streams.take_owned(handle, *mod); + if (!entry.has_value()) { + return MOD_INVALID_ARGUMENT; + } + return entry->value.close() ? MOD_OK : MOD_ERROR; +} + +ModResult file_write( + ModContext* context, FileStreamHandle handle, const void* buffer, uint64_t length) { + if ((buffer == nullptr && length != 0) || length > std::numeric_limits::max()) { + return MOD_INVALID_ARGUMENT; + } + auto* file = find_stream(context, handle); + if (file == nullptr || !file->writable()) { + return MOD_INVALID_ARGUMENT; + } + const auto bytes = + std::span{static_cast(buffer), static_cast(length)}; + return file->write(bytes) ? MOD_OK : MOD_ERROR; +} + +ModResult file_flush(ModContext* context, FileStreamHandle handle) { + auto* file = find_stream(context, handle); + if (file == nullptr || !file->writable()) { + return MOD_INVALID_ARGUMENT; + } + return file->flush() ? MOD_OK : MOD_ERROR; +} + +ModResult file_write_all(ModContext* context, const char* location, const void* data, size_t size) { + if (mod_from_context(context) == nullptr || location == nullptr || + (data == nullptr && size != 0)) + { + return MOD_INVALID_ARGUMENT; + } + try { + const auto available = borealis::io::check(location); + if (available != borealis::io::Status::Ok) { + return map_status(available); + } + auto opened = borealis::io::open(location, borealis::io::File::Mode::Truncate); + if (opened.status != borealis::io::Status::Ok) { + return map_status(opened.status); + } + if (!opened.file.write(std::span{static_cast(data), size})) { + return MOD_ERROR; + } + return opened.file.close() ? MOD_OK : MOD_ERROR; + } catch (...) { + return MOD_ERROR; + } +} + +ModResult file_read_all(ModContext* context, const char* location, FileBuffer* outBuffer) { + if (outBuffer == nullptr || outBuffer->struct_size < sizeof(FileBuffer)) { + return MOD_INVALID_ARGUMENT; + } + outBuffer->data = nullptr; + outBuffer->size = 0; + auto* mod = mod_from_context(context); + if (mod == nullptr || location == nullptr) { + return MOD_INVALID_ARGUMENT; + } + try { + auto opened = borealis::io::open(location); + if (opened.status != borealis::io::Status::Ok) { + return map_status(opened.status); + } + const uint64_t expectedSize = opened.file.size(); + if (expectedSize > std::numeric_limits::max()) { + return MOD_ERROR; + } + std::vector bytes; + bytes.resize(static_cast(expectedSize)); + uint64_t total = 0; + while (total < expectedSize) { + const uint64_t count = opened.file.read(bytes.data() + total, expectedSize - total); + if (count == 0) { + return MOD_ERROR; + } + total += count; + } + if (expectedSize == 0) { + unsigned char chunk[64 * 1024]; + while (true) { + const uint64_t count = opened.file.read(chunk, sizeof(chunk)); + if (count == 0) { + if (!opened.file.error().empty()) { + return MOD_ERROR; + } + break; + } + bytes.insert(bytes.end(), chunk, chunk + count); + } + } + if (bytes.empty()) { + return MOD_OK; + } + std::unique_ptr data{std::malloc(bytes.size()), &std::free}; + if (!data) { + return MOD_ERROR; + } + std::memcpy(data.get(), bytes.data(), bytes.size()); + s_buffers.emplace(data.get(), mod); + outBuffer->data = data.release(); + outBuffer->size = bytes.size(); + return MOD_OK; + } catch (...) { + return MOD_ERROR; + } +} + +void file_free(ModContext* context, FileBuffer* buffer) { + if (buffer == nullptr || buffer->struct_size < sizeof(FileBuffer) || buffer->data == nullptr) { + return; + } + auto* mod = mod_from_context(context); + const auto found = s_buffers.find(buffer->data); + if (mod == nullptr || found == s_buffers.end() || found->second != mod) { + Log.error("[{}] file free: buffer is not owned by this mod", mod_id_from_context(context)); + return; + } + s_buffers.erase(found); + std::free(buffer->data); + buffer->data = nullptr; + buffer->size = 0; +} + +ModResult file_list( + ModContext* context, const char* folderLocation, FileListFn callback, void* userData) { + auto* mod = mod_from_context(context); + if (mod == nullptr || folderLocation == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + borealis::io::ListResult result; + try { + result = borealis::io::list(folderLocation); + } catch (...) { + return MOD_ERROR; + } + if (result.status != borealis::io::Status::Ok) { + return map_status(result.status); + } + try { + for (const auto& entry : result.entries) { + const FileEntry raw{ + .name = entry.name.c_str(), + .location = entry.location.c_str(), + .is_directory = entry.isDirectory, + }; + callback(mod->context.get(), &raw, userData); + if (!mod->active) { + return MOD_ERROR; + } + } + callback(mod->context.get(), nullptr, userData); + } catch (const std::exception& exception) { + fail_mod( + *mod, MOD_ERROR, std::string{"exception in file list callback: "} + exception.what()); + return MOD_ERROR; + } catch (...) { + fail_mod(*mod, MOD_ERROR, "unknown exception in file list callback"); + return MOD_ERROR; + } + return MOD_OK; +} + +ModResult file_join(ModContext* context, const char* folderLocation, const char* relativePath, + const char** outLocation) { + if (outLocation != nullptr) { + *outLocation = nullptr; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || folderLocation == nullptr || relativePath == nullptr || + outLocation == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + auto result = borealis::io::join(folderLocation, relativePath); + if (result.status != borealis::io::Status::Ok) { + return map_status(result.status); + } + auto& saved = s_joinResults[mod]; + saved = std::move(result.location); + *outLocation = saved.c_str(); + return MOD_OK; +} + +ModResult file_create_child( + ModContext* context, const char* folderLocation, const char* name, const char** outLocation) { + if (outLocation != nullptr) { + *outLocation = nullptr; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || folderLocation == nullptr || name == nullptr || outLocation == nullptr) { + return MOD_INVALID_ARGUMENT; + } + try { + auto result = borealis::io::create_child(folderLocation, name); + if (result.status != borealis::io::Status::Ok) { + return map_status(result.status); + } + auto& saved = s_joinResults[mod]; + saved = std::move(result.location); + *outLocation = saved.c_str(); + return MOD_OK; + } catch (...) { + return MOD_ERROR; + } +} + +void file_initialize() { + config::Register(s_pickerOverride); +} + +ModResult copy_picker_override( + std::string_view source, std::string_view destination, std::string& error) { + try { + if (source == destination) { + return MOD_OK; + } + auto input = borealis::io::open(source); + if (input.status != borealis::io::Status::Ok) { + error = std::move(input.message); + return map_status(input.status); + } + auto output = borealis::io::open(destination, borealis::io::File::Mode::Truncate); + if (output.status != borealis::io::Status::Ok) { + error = std::move(output.message); + return map_status(output.status); + } + std::array buffer{}; + while (true) { + const uint64_t read = input.file.read(buffer.data(), buffer.size()); + if (read == 0) { + if (!input.file.error().empty()) { + error = input.file.error(); + return MOD_ERROR; + } + break; + } + if (!output.file.write(std::span{buffer.data(), static_cast(read)})) { + error = output.file.error(); + return MOD_ERROR; + } + } + if (!output.file.close()) { + error = output.file.error(); + return MOD_ERROR; + } + return MOD_OK; + } catch (const std::exception& exception) { + error = exception.what(); + return MOD_ERROR; + } catch (...) { + error = "Unable to copy export source"; + return MOD_ERROR; + } +} + +void file_frame_begin() { + auto pending = s_pendingPick; + if (pending == nullptr || !pending->overrideLocation.has_value()) { + return; + } + if (!pending->exportSource.has_value()) { + invoke_pick(pending, MOD_OK, {*pending->overrideLocation}, ""); + return; + } + std::string error; + const ModResult result = + copy_picker_override(*pending->exportSource, *pending->overrideLocation, error); + invoke_pick(pending, result, + result == MOD_OK ? std::vector{*pending->overrideLocation} : + std::vector{}, + error); +} + +void file_remove_mod(LoadedMod& mod) { + const size_t streams = s_streams.erase_all(mod); + size_t buffers = 0; + std::erase_if(s_buffers, [&](const auto& entry) { + if (entry.second != &mod) { + return false; + } + std::free(entry.first); + ++buffers; + return true; + }); + s_joinResults.erase(&mod); + if (s_pendingPick != nullptr && s_pendingPick->owner == &mod) { + s_pendingPick->owner = nullptr; + s_pendingPick->callback = nullptr; + s_pendingPick.reset(); + } + if (streams != 0 || buffers != 0) { + Log.warn("[{}] reclaimed {} open stream(s) and {} file buffer(s)", mod.metadata.id, streams, + buffers); + } +} + +void file_shutdown() { + config::unregister(s_pickerOverride); + s_pendingPick.reset(); +} + +constexpr FileService s_fileService{ + .header = SERVICE_HEADER(FileService, FILE_SERVICE_MAJOR, FILE_SERVICE_MINOR), + .pick_file = file_pick_file, + .pick_folder = file_pick_folder, + .export_file = file_export_file, + .display_name = file_display_name, + .check = file_check, + .open = file_open, + .size = file_size, + .read = file_read, + .write = file_write, + .seek = file_seek, + .flush = file_flush, + .close = file_close, + .read_all = file_read_all, + .write_all = file_write_all, + .free = file_free, + .list = file_list, + .join = file_join, + .create_child = file_create_child, +}; + +} // namespace + +constinit const ServiceModule g_fileModule{ + .id = FILE_SERVICE_ID, + .majorVersion = FILE_SERVICE_MAJOR, + .minorVersion = FILE_SERVICE_MINOR, + .service = &s_fileService, + .initialize = file_initialize, + .modDetached = file_remove_mod, + .frameBegin = file_frame_begin, + .shutdown = file_shutdown, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 4f769c2dfe..26c67699f1 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -9,6 +9,10 @@ #include #include +#if defined(__APPLE__) +#include +#endif + namespace dusk::mods::svc { namespace { @@ -210,6 +214,9 @@ void ModLoader::init_services() { &svc::g_hostModule, &svc::g_logModule, &svc::g_resourceModule, +#if !defined(__APPLE__) || !TARGET_OS_TV + &svc::g_fileModule, +#endif &svc::g_hookModule, &svc::g_overlayModule, &svc::g_textureModule, diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 90c3c44d84..7a4b07ad21 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -68,6 +68,7 @@ void modules_shutdown(); extern const ServiceModule g_hostModule; extern const ServiceModule g_logModule; extern const ServiceModule g_resourceModule; +extern const ServiceModule g_fileModule; extern const ServiceModule g_hookModule; extern const ServiceModule g_overlayModule; extern const ServiceModule g_textureModule; diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp index 8e5c61bf52..75e1d514cf 100644 --- a/src/dusk/mods/svc/ui.cpp +++ b/src/dusk/mods/svc/ui.cpp @@ -45,6 +45,8 @@ constexpr size_t kUiControlSelectedSize = offsetof(UiControlDesc, is_selected) + sizeof(UiPredicateFn); constexpr size_t kUiControlStringSetModeSize = offsetof(UiControlDesc, string_set_mode) + sizeof(UiStringSetMode); +constexpr size_t kUiControlFilePickerSize = + offsetof(UiControlDesc, directory_mode) + sizeof(bool); constexpr size_t kUiListItemV21Size = offsetof(UiListItem, label) + sizeof(const char*); constexpr size_t kUiListDescV21Size = offsetof(UiListDesc, user_data) + sizeof(void*); @@ -303,6 +305,7 @@ void wire_callback_binding( break; case UI_CONTROL_STRING: case UI_CONTROL_COLOR: + case UI_CONTROL_FILE_PICKER: spec.getString = [getValue]() -> Rml::String { const UiControlValue value = getValue(); return value.string_value != nullptr ? value.string_value : ""; @@ -382,7 +385,8 @@ bool wire_config_var_binding(LoadedMod& mod, const UiControlDesc& desc, ui::ModC return true; } case UI_CONTROL_STRING: - case UI_CONTROL_COLOR: { + case UI_CONTROL_COLOR: + case UI_CONTROL_FILE_PICKER: { const auto find = [modPtr, varHandle] { return static_cast*>( config_find_var(*modPtr, varHandle, CONFIG_VAR_STRING)); @@ -642,6 +646,14 @@ ModResult ui_pane_add_control( spec.colorPresets.emplace_back(desc.color_presets[i]); } break; + case UI_CONTROL_FILE_PICKER: + spec.kind = ui::ModControlSpec::Kind::FilePicker; + spec.directoryMode = desc.directory_mode; + for (size_t i = 0; i < desc.file_filter_count; ++i) { + spec.fileFilters.push_back( + {desc.file_filters[i].name, desc.file_filters[i].pattern}); + } + break; case UI_CONTROL_SELECT: spec.kind = ui::ModControlSpec::Kind::Select; if (slot->helpPane == nullptr) { @@ -1257,6 +1269,18 @@ bool valid_control_desc(const UiControlDesc& desc) { return false; } break; + case UI_CONTROL_FILE_PICKER: + if (desc.struct_size < kUiControlFilePickerSize || + (desc.file_filter_count != 0 && desc.file_filters == nullptr)) + { + return false; + } + for (size_t i = 0; i < desc.file_filter_count; ++i) { + if (desc.file_filters[i].name == nullptr || desc.file_filters[i].pattern == nullptr) { + return false; + } + } + break; default: return false; } diff --git a/src/dusk/ui/file_button.cpp b/src/dusk/ui/file_button.cpp new file mode 100644 index 0000000000..0b5954512a --- /dev/null +++ b/src/dusk/ui/file_button.cpp @@ -0,0 +1,71 @@ +#include "file_button.hpp" + +#include +#include + +#include + +namespace dusk::ui { + +FileButton::FileButton(Rml::Element* parent, Props props) + : BaseControlledSelectButton{parent, {.key = std::move(props.key)}}, + mGetValue{std::move(props.getValue)}, mSetValue{std::move(props.setValue)}, + mIsDisabled{std::move(props.isDisabled)}, mIsModified{std::move(props.isModified)}, + mFilters{std::move(props.filters)}, mDirectoryMode{props.directoryMode} {} + +bool FileButton::modified() const { + return mIsModified ? mIsModified() : BaseControlledSelectButton::modified(); +} + +bool FileButton::disabled() const { + return borealis::file_select::busy() || (mIsDisabled && mIsDisabled()); +} + +Rml::String FileButton::format_value() { + const Rml::String location = mGetValue ? mGetValue() : ""; + if (location.empty()) { + return "(none)"; + } + const auto name = borealis::io::display_name(location); + return name.empty() ? location : name; +} + +bool FileButton::handle_nav_command(NavCommand command) { + if (command != NavCommand::Confirm) { + return false; + } + open_picker(); + return true; +} + +void FileButton::open_picker() { + if (disabled()) { + return; + } + const std::string defaultLocation = mGetValue ? mGetValue() : ""; + auto complete = [setValue = mSetValue](borealis::file_select::Result result) { + if (result.status == borealis::file_select::Status::Selected && !result.locations.empty() && + setValue) + { + setValue(std::move(result.locations.front())); + } + }; + if (mDirectoryMode) { + borealis::file_select::open_folder( + { + .parentWindow = aurora::window::get_sdl_window(), + .defaultLocation = defaultLocation, + }, + std::move(complete)); + return; + } + borealis::file_select::open_file( + { + .parentWindow = aurora::window::get_sdl_window(), + .filters = mFilters, + .defaultLocation = defaultLocation, + }, + std::move(complete)); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/file_button.hpp b/src/dusk/ui/file_button.hpp new file mode 100644 index 0000000000..487dd70724 --- /dev/null +++ b/src/dusk/ui/file_button.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "select_button.hpp" + +#include + +#include +#include + +namespace dusk::ui { + +class FileButton : public BaseControlledSelectButton { +public: + struct Props { + Rml::String key; + std::function getValue; + std::function setValue; + std::function isDisabled; + std::function isModified; + std::vector filters; + bool directoryMode = false; + }; + + FileButton(Rml::Element* parent, Props props); + bool modified() const override; + bool disabled() const override; + +protected: + Rml::String format_value() override; + bool handle_nav_command(NavCommand command) override; + +private: + void open_picker(); + + std::function mGetValue; + std::function mSetValue; + std::function mIsDisabled; + std::function mIsModified; + std::vector mFilters; + bool mDirectoryMode = false; +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/mod_window.cpp b/src/dusk/ui/mod_window.cpp index fc1a963c01..289576ffe9 100644 --- a/src/dusk/ui/mod_window.cpp +++ b/src/dusk/ui/mod_window.cpp @@ -2,6 +2,7 @@ #include "bool_button.hpp" #include "color_input.hpp" +#include "file_button.hpp" #include "number_button.hpp" #include "string_button.hpp" @@ -83,6 +84,17 @@ Component* build_mod_control(Pane& pane, Pane* helpPane, ModControlSpec spec) { .alpha = s.colorAlpha, }); break; + case ModControlSpec::Kind::FilePicker: + control = &pane.add_child(FileButton::Props{ + .key = s.label, + .getValue = s.getString, + .setValue = s.setString, + .isDisabled = s.isDisabled, + .isModified = s.isModified, + .filters = s.fileFilters, + .directoryMode = s.directoryMode, + }); + break; case ModControlSpec::Kind::Select: if (helpPane == nullptr || s.options.empty()) { return nullptr; diff --git a/src/dusk/ui/mod_window.hpp b/src/dusk/ui/mod_window.hpp index 2d8d810417..552f859324 100644 --- a/src/dusk/ui/mod_window.hpp +++ b/src/dusk/ui/mod_window.hpp @@ -3,6 +3,8 @@ #include "pane.hpp" #include "window.hpp" +#include + #include namespace dusk::ui { @@ -16,6 +18,7 @@ struct ModControlSpec { String, Select, Color, + FilePicker, }; Kind kind = Kind::Button; @@ -42,6 +45,8 @@ struct ModControlSpec { bool stringSetOnChange = false; std::vector colorPresets; bool colorAlpha = false; + std::vector fileFilters; + bool directoryMode = false; }; Component* build_mod_control(Pane& pane, Pane* helpPane, ModControlSpec spec); diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 193eaacf0d..f74f4b871e 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -130,42 +131,7 @@ struct DiscVerificationTask { std::unique_ptr sDiscVerificationTask; bool sDiscVerificationModalPushed = false; -struct UpdateCheckTask { - UpdateCheckTask() { - worker = std::thread([this] { - try { - result = borealis::update::check_latest_github_release(AppInfo); - } catch (const std::exception& e) { - result = { - .status = borealis::update::Status::Failed, - .message = fmt::format("Update check failed with exception: {}", e.what()), - }; - } catch (...) { - result = { - .status = borealis::update::Status::Failed, - .message = "Update check failed with an unknown exception", - }; - } - done.store(true, std::memory_order_release); - }); - } - - ~UpdateCheckTask() { join(); } - - void join() { - if (worker.joinable()) { - worker.join(); - } - } - - [[nodiscard]] bool finished() const { return done.load(std::memory_order_acquire); } - - borealis::update::Result result; - std::atomic_bool done = false; - std::thread worker; -}; - -std::unique_ptr sUpdateCheckTask; +borealis::Task sUpdateCheck; std::optional sUpdateCheckResult; bool verification_state_allows_launch(iso::ValidationError validation) noexcept { @@ -243,20 +209,19 @@ void begin_update_check() { if (!getSettings().backend.checkForUpdates.getValue()) { return; } - if (sUpdateCheckTask != nullptr || sUpdateCheckResult.has_value()) { + if (sUpdateCheck || sUpdateCheckResult.has_value()) { return; } - sUpdateCheckTask = std::make_unique(); + sUpdateCheck = borealis::update::check_latest_github_release(AppInfo); } std::optional take_finished_update_check() { - if (sUpdateCheckTask == nullptr || !sUpdateCheckTask->finished()) { + if (!sUpdateCheck || !sUpdateCheck.ready()) { return std::nullopt; } - sUpdateCheckTask->join(); - auto result = std::move(sUpdateCheckTask->result); - sUpdateCheckTask.reset(); + auto result = sUpdateCheck.try_take(); + sUpdateCheck = {}; return result; } @@ -479,7 +444,7 @@ private: } if (mFileName != nullptr) { - std::string fileName = borealis::file_select::display_name(sDiscVerificationTask->path); + std::string fileName = borealis::io::display_name(sDiscVerificationTask->path); if (fileName.empty()) { fileName = sDiscVerificationTask->path; } @@ -1167,7 +1132,7 @@ void Prelaunch::update() { sUpdateCheckResult = std::move(*result); } - if (sUpdateCheckTask != nullptr) { + if (sUpdateCheck) { mUpdateStatus->SetAttribute("state", "checking"); mUpdateMessage->SetInnerRML("Checking for updates..."); } else if (!sUpdateCheckResult.has_value() || diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index a546f1c76a..bac67f3126 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -231,7 +231,7 @@ Rml::String configured_data_path_display_name() { return "(none)"; } - auto display = borealis::file_select::display_name(path); + auto display = borealis::io::display_name(path); if (display.empty()) { return path; } @@ -493,7 +493,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { if (path.empty()) { display = "(none)"; } else { - display = borealis::file_select::display_name(path); + display = borealis::io::display_name(path); if (display.empty()) { display = path; } @@ -539,6 +539,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { { .parentWindow = aurora::window::get_sdl_window(), .defaultLocation = defaultLocation, + .requireRealPath = true, }, &data_folder_dialog_callback); }); diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 9436d578bc..80ec318919 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -794,12 +794,19 @@ int game_main(int argc, char* argv[]) { bool forcePreLaunchUI = false; bool saveConfigBeforePrelaunch = false; - const std::string p = dusk::getSettings().backend.isoPath; + borealis::io::PathAccess dvdPathAccess; + const auto resolveDvdLocation = [&dvdPathAccess](const std::string& location) { + dvdPathAccess = borealis::io::access_path(location); + return dvdPathAccess ? borealis::io::fs_path_to_string(dvdPathAccess.path()) : location; + }; + + const std::string savedLocation = dusk::getSettings().backend.isoPath; dusk::iso::DiscInfo discInfo{}; - if (!p.empty() && - dusk::iso::inspect(p.c_str(), discInfo) != dusk::iso::ValidationError::Success) + if (!savedLocation.empty() && + dusk::iso::inspect(savedLocation.c_str(), discInfo) != dusk::iso::ValidationError::Success) { - DuskLog.warn("Saved DVD image path failed validation, clearing configured path: {}", p); + DuskLog.warn("Saved DVD image location failed validation, clearing it: {}", + borealis::io::display_name(savedLocation)); dusk::getSettings().backend.isoPath.setValue(""); dusk::getSettings().backend.isoVerification.setValue(dusk::DiscVerificationState::Unknown); forcePreLaunchUI = true; @@ -808,18 +815,23 @@ int game_main(int argc, char* argv[]) { bool skipPreLaunchUI = dusk::getSettings().backend.skipPreLaunchUI.getValue(); - std::string dvd_path = dusk::getSettings().backend.isoPath; + std::string dvdLocation = dusk::getSettings().backend.isoPath; + std::string dvdPath = resolveDvdLocation(dvdLocation); bool dvd_opened = false; if (parsed_arg_options.count("dvd")) { - dvd_path = parsed_arg_options["dvd"].as(); - if (dusk::iso::inspect(dvd_path.c_str(), discInfo) == dusk::iso::ValidationError::Success) { - DuskLog.info("Loading DVD image from command line: {}", dvd_path); - dvd_opened = aurora_dvd_open(dvd_path.c_str()); + dvdLocation = parsed_arg_options["dvd"].as(); + dvdPath = resolveDvdLocation(dvdLocation); + if (dusk::iso::inspect(dvdLocation.c_str(), discInfo) == + dusk::iso::ValidationError::Success) + { + DuskLog.info("Loading DVD image from command line: {}", dvdPath); + dvd_opened = aurora_dvd_open(dvdPath.c_str()); if (!dvd_opened) { - DuskLog.warn("Failed to open DVD image from command line: {}, opening prelaunch UI", dvd_path); + DuskLog.warn("Failed to open DVD image from command line: {}, opening prelaunch UI", + dvdPath); forcePreLaunchUI = true; } else { - dusk::getSettings().backend.isoPath.setValue(dvd_path); + dusk::getSettings().backend.isoPath.setValue(dvdLocation); dusk::getSettings().backend.isoVerification.setValue( dusk::DiscVerificationState::Unknown); dusk::config::save(); @@ -827,13 +839,14 @@ int game_main(int argc, char* argv[]) { skipPreLaunchUI = true; } } else { - DuskLog.warn("DVD image from command line failed validation: {}, opening prelaunch UI", dvd_path); + DuskLog.warn( + "DVD image from command line failed validation: {}, opening prelaunch UI", dvdPath); forcePreLaunchUI = true; } } // If we can't load right into the game, stop requesting to load a stage or save - if (forcePreLaunchUI || dvd_path.empty()) { + if (forcePreLaunchUI || dvdPath.empty()) { if (dusk::StageRequested.set) { DuskLog.warn("Cannot load stage {} because no iso path is set, opening prelaunch UI",dusk::StageRequested.stage); dusk::StageRequested = {}; @@ -893,18 +906,19 @@ int game_main(int argc, char* argv[]) { } } - dvd_path = dusk::getSettings().backend.isoPath; - if (dvd_path.empty()) { + dvdLocation = dusk::getSettings().backend.isoPath; + dvdPath = resolveDvdLocation(dvdLocation); + if (dvdPath.empty()) { DuskLog.fatal("No DVD image specified, unable to boot!"); } - if (!dusk::IsGameLaunched && - dusk::iso::inspect(dvd_path.c_str(), discInfo) != dusk::iso::ValidationError::Success) + if (!dusk::IsGameLaunched && dusk::iso::inspect(dvdLocation.c_str(), discInfo) != + dusk::iso::ValidationError::Success) { - DuskLog.fatal("DVD image failed validation: {}", dvd_path); + DuskLog.fatal("DVD image failed validation: {}", dvdPath); } - DuskLog.info("Loading DVD image: {}", dvd_path); - if (!aurora_dvd_open(dvd_path.c_str())) { - DuskLog.fatal("Failed to open DVD image: {}", dvd_path); + DuskLog.info("Loading DVD image: {}", dvdPath); + if (!aurora_dvd_open(dvdPath.c_str())) { + DuskLog.fatal("Failed to open DVD image: {}", dvdPath); } dusk::IsGameLaunched = true;