diff --git a/CMakeLists.txt b/CMakeLists.txt index 834713ed00..ec18d422bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -180,8 +180,15 @@ FetchContent_Declare(miniz DOWNLOAD_EXTRACT_TIMESTAMP TRUE EXCLUDE_FROM_ALL ) +message(STATUS "dusklight: Fetching PicoSHA2") +FetchContent_Declare(picosha2 + URL https://github.com/okdshin/PicoSHA2/archive/refs/tags/v1.0.1.tar.gz + URL_HASH SHA256=9983136544234e573fe07cc1a22fdf978ad7979043e7be2e9082f6d4991ff8a8 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + EXCLUDE_FROM_ALL +) -set(_fetch_content_deps miniz) +set(_fetch_content_deps miniz picosha2) if (DUSK_HAS_FUNCHOOK) message(STATUS "dusklight: Fetching funchook") # cmake/PatchFunchook.cmake patches funchook's cmake/capstone.cmake.in to inject a @@ -216,6 +223,14 @@ if (DUSK_HAS_FUNCHOOK) endif () FetchContent_MakeAvailable(${_fetch_content_deps}) +if (DUSK_HAS_FUNCHOOK AND APPLE) + target_sources(funchook-static PRIVATE src/dusk/mods/loader/code_patch_macos.cpp) + target_include_directories(funchook-static PRIVATE src/dusk/mods/loader) + set_source_files_properties(src/dusk/mods/loader/code_patch_macos.cpp + TARGET_DIRECTORY funchook-static PROPERTIES + COMPILE_OPTIONS "-O2;-fno-sanitize=all;-fno-stack-protector") +endif () + # Use signed char on ARM to match the original game (and x86) string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _arch) if(_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") @@ -241,8 +256,8 @@ 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::thp - aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::net borealis::presentation borealis::sentry borealis::update borealis::ws freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt - Threads::Threads zstd::libzstd dusklight_game_headers) + aurora::card borealis::http borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::net borealis::presentation borealis::sentry borealis::update borealis::ws freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt + Threads::Threads zstd::libzstd dusklight_game_headers picosha2 PNG::PNG) if (DUSK_HAS_FUNCHOOK) list(APPEND GAME_LIBS funchook-static) endif () diff --git a/cmake/PatchFunchook.cmake b/cmake/PatchFunchook.cmake index 6d5a6e36b4..cbd91fc38e 100644 --- a/cmake/PatchFunchook.cmake +++ b/cmake/PatchFunchook.cmake @@ -2,59 +2,55 @@ file(READ "${SOURCE_DIR}/cmake/capstone.cmake.in" _content) # Insert PATCH_COMMAND before CONFIGURE_COMMAND in the ExternalProject_Add. # Bracket args prevent cmake from substituting ${...} while writing this file. -string(REPLACE - " CONFIGURE_COMMAND \"\"" - [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR=${CMAKE_CURRENT_BINARY_DIR}/capstone-src -P "${CAPSTONE_FIX_SCRIPT}" +if (NOT _content MATCHES "CAPSTONE_FIX_SCRIPT") + string(REPLACE + " CONFIGURE_COMMAND \"\"" + [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR=${CMAKE_CURRENT_BINARY_DIR}/capstone-src -P "${CAPSTONE_FIX_SCRIPT}" CONFIGURE_COMMAND ""]=] - _content "${_content}") - -file(WRITE "${SOURCE_DIR}/cmake/capstone.cmake.in" "${_content}") - -file(READ "${SOURCE_DIR}/src/funchook_unix.c" _unix_content) - -# macOS rejects the POSIX mprotect RWX/RW transition for executable image pages on arm64. -# Use Mach VM_PROT_COPY for the short patch window, then restore RX permissions. -if (NOT _unix_content MATCHES "VM_PROT_READ \\| VM_PROT_WRITE \\| VM_PROT_COPY") - string(REPLACE - [=[ rv = mprotect(mstate->addr, mstate->size, prot);]=] - [=[#ifdef __APPLE__ - kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, - (vm_size_t)mstate->size, FALSE, - VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY); - if (kr == KERN_SUCCESS) { - funchook_log(funchook, " unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR")\n", - mstate->addr, mstate->size, start, len); - return 0; - } - funchook_set_error_message(funchook, "Failed to unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR", error=%s)", - mstate->addr, mstate->size, start, len, - mach_error_string(kr)); - return FUNCHOOK_ERROR_MEMORY_FUNCTION; -#endif - rv = mprotect(mstate->addr, mstate->size, prot);]=] - _unix_content "${_unix_content}") - - string(REPLACE - [=[ char errbuf[128]; - int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] - [=[ char errbuf[128]; -#ifdef __APPLE__ - kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, - (vm_size_t)mstate->size, FALSE, - VM_PROT_READ | VM_PROT_EXECUTE); - - if (kr == KERN_SUCCESS) { - funchook_log(funchook, " protect memory %p (size=%"PRIuPTR", prot=read,exec)\n", - mstate->addr, mstate->size); - return 0; - } - funchook_set_error_message(funchook, "Failed to protect memory %p (size=%"PRIuPTR", prot=read,exec, error=%s)", - mstate->addr, mstate->size, - mach_error_string(kr)); - return FUNCHOOK_ERROR_MEMORY_FUNCTION; -#endif - int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] - _unix_content "${_unix_content}") + _content "${_content}") + file(WRITE "${SOURCE_DIR}/cmake/capstone.cmake.in" "${_content}") endif () -file(WRITE "${SOURCE_DIR}/src/funchook_unix.c" "${_unix_content}") +file(READ "${SOURCE_DIR}/src/funchook.c" _content) +if (NOT _content MATCHES "commit_code_patch") + string(REPLACE "#include \"funchook_internal.h\"" + "#include \"funchook_internal.h\"\n#ifdef __APPLE__\n#include \"code_patch_macos.hpp\"\n#endif" + _content "${_content}") + + foreach(_operation install uninstall) + if (_operation STREQUAL "install") + set(_expected old_code) + set(_replacement new_code) + else () + set(_expected new_code) + set(_replacement old_code) + endif () + set(_original " mem_state_t mstate; + int rv = funchook_unprotect_begin(funchook, &mstate, entry->target_func, JUMP32_BYTE_SIZE); + + if (rv != 0) { + return rv; + } + memcpy(entry->target_func, entry->${_replacement}, JUMP32_BYTE_SIZE); + rv = funchook_unprotect_end(funchook, &mstate); + if (rv != 0) { + return rv; + } + flush_instruction_cache(entry->target_func, JUMP32_BYTE_SIZE);") + string(FIND "${_content}" "${_original}" _position) + if (_position EQUAL -1) + message(FATAL_ERROR "Funchook ${_operation} patch site changed") + endif () + string(REPLACE "${_original}" "#ifdef __APPLE__ + int rv = commit_code_patch(entry->target_func, entry->${_expected}, + entry->${_replacement}, JUMP32_BYTE_SIZE); + if (rv != 0) { + funchook_set_error_message(funchook, \"Code patch commit failed (Mach error %d)\", rv); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; + } +#else +${_original} +#endif" _content "${_content}") + endforeach () + file(WRITE "${SOURCE_DIR}/src/funchook.c" "${_content}") +endif () diff --git a/extern/borealis b/extern/borealis index f55910bd79..0bdba6c50a 160000 --- a/extern/borealis +++ b/extern/borealis @@ -1 +1 @@ -Subproject commit f55910bd79248db250ebd9debb6624d78538c21c +Subproject commit 0bdba6c50a46409c4862474c72b4a3a631fbe0ec diff --git a/files.cmake b/files.cmake index 5c7a9223a2..42a12c353c 100644 --- a/files.cmake +++ b/files.cmake @@ -1422,6 +1422,8 @@ set(DUSK_FILES src/dusk/achievements.cpp src/dusk/action_bindings.cpp src/dusk/action_bindings.h + src/dusk/archive.cpp + src/dusk/archive.hpp src/dusk/asserts.cpp src/dusk/autosave.cpp src/dusk/config.cpp @@ -1435,6 +1437,7 @@ set(DUSK_FILES src/dusk/commands.cpp src/dusk/commands.hpp src/dusk/game_clock.cpp + src/dusk/hash.hpp src/dusk/game_mode.cpp src/dusk/gamepad_color.cpp src/dusk/globals.cpp @@ -1481,10 +1484,20 @@ set(DUSK_FILES src/dusk/mods/loader/depgraph.hpp src/dusk/mods/loader/loader.cpp src/dusk/mods/loader/loader.hpp + src/dusk/mods/loader/manifest.cpp + src/dusk/mods/loader/manifest.hpp + src/dusk/mods/loader/natives.cpp + src/dusk/mods/loader/natives.hpp + src/dusk/mods/loader/packages.cpp + src/dusk/mods/loader/packages.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp src/dusk/mods/loader/prepatch.cpp src/dusk/mods/loader/prepatch.hpp + src/dusk/mods/catalog.cpp + src/dusk/mods/catalog.hpp + src/dusk/mods/queue.cpp + src/dusk/mods/queue.hpp src/dusk/mods/item.hpp src/dusk/mods/item_actor.cpp src/dusk/mods/item_checks.cpp @@ -1551,6 +1564,8 @@ set(DUSK_FILES src/dusk/ui/controls.hpp src/dusk/ui/document.cpp src/dusk/ui/document.hpp + src/dusk/ui/drop_install_modal.cpp + src/dusk/ui/drop_install_modal.hpp src/dusk/ui/editor.cpp src/dusk/ui/editor.hpp src/dusk/ui/event.cpp @@ -1571,8 +1586,18 @@ set(DUSK_FILES src/dusk/ui/list.hpp src/dusk/ui/menu_bar.cpp src/dusk/ui/menu_bar.hpp + src/dusk/ui/mod_browser.cpp + src/dusk/ui/mod_browser.hpp + src/dusk/ui/queue_window.cpp + src/dusk/ui/queue_window.hpp + src/dusk/ui/package_row.cpp + src/dusk/ui/package_row.hpp src/dusk/ui/mod_texture_provider.cpp src/dusk/ui/mod_texture_provider.hpp + src/dusk/ui/remote_texture_provider.cpp + src/dusk/ui/remote_texture_provider.hpp + src/dusk/ui/runtime_image.cpp + src/dusk/ui/runtime_image.hpp src/dusk/ui/mod_window.cpp src/dusk/ui/mod_window.hpp src/dusk/ui/modal.cpp @@ -1582,6 +1607,12 @@ set(DUSK_FILES src/dusk/ui/nav_types.hpp src/dusk/ui/nav_group.cpp src/dusk/ui/nav_group.hpp + src/dusk/ui/context_menu.cpp + src/dusk/ui/context_menu.hpp + src/dusk/ui/icon_button.cpp + src/dusk/ui/icon_button.hpp + src/dusk/ui/tooltip.cpp + src/dusk/ui/tooltip.hpp src/dusk/ui/number_button.cpp src/dusk/ui/number_button.hpp src/dusk/ui/overlay.cpp diff --git a/res/rml/command_console.rcss b/res/rml/command_console.rcss index b5378b5d7a..d19246692a 100644 --- a/res/rml/command_console.rcss +++ b/res/rml/command_console.rcss @@ -1,8 +1,6 @@ -*, *:before, *:after { - box-sizing: border-box; -} - body { + --console-command-color: #FFD966; + display: block; width: 100%; height: 100%; @@ -19,11 +17,11 @@ console { width: 50%; display: flex; flex-direction: column; - background-color: rgba(0, 0, 0, 60%); + background-color: rgba(var(--color-black-rgb), 60%); pointer-events: auto; - font-family: "Noto Mono"; - font-size: 14dp; - color: #FFFFFF; + font-family: var(--font-family-monospace); + font-size: var(--font-size-sm); + color: var(--color-white); transition: background-color 0.8s linear-in-out; } @@ -31,7 +29,7 @@ output { display: block; overflow: hidden; max-height: 480dp; - padding: 4dp 8dp; + padding: var(--space-xs) var(--space-sm); line-height: 1.4em; } @@ -46,11 +44,11 @@ console:not([open]) { } console:not([open])[fading] { - background-color: rgba(0, 0, 0, 0%); + background-color: rgba(var(--color-black-rgb), 0%); } console[open] { - background-color: rgba(0, 0, 0, 60%); + background-color: rgba(var(--color-black-rgb), 60%); transition: none; } @@ -75,19 +73,19 @@ output[open] line { } line.cmd { - color: #FFD966; + color: var(--console-command-color); } console input { display: none; width: 100%; - background-color: rgba(0, 0, 0, 40%); + background-color: rgba(var(--color-black-rgb), 40%); border: 0dp; - border-top: 1dp rgba(255, 255, 255, 20%); - color: #FFFFFF; - font-family: "Noto Mono"; - font-size: 14dp; - padding: 4dp 8dp; + border-top: 1dp rgba(var(--color-white-rgb), 20%); + color: var(--color-white); + font-family: var(--font-family-monospace); + font-size: var(--font-size-sm); + padding: var(--space-xs) var(--space-sm); } console[open] input { diff --git a/res/rml/logs.rcss b/res/rml/logs.rcss index 126684f9d2..fd1933ed3a 100644 --- a/res/rml/logs.rcss +++ b/res/rml/logs.rcss @@ -2,19 +2,20 @@ window.logs content { flex-flow: column; } -window.logs .log-toolbar { +window.logs log-toolbar { display: flex; flex-flow: row; - flex: 0 0 64dp; - height: 64dp; + flex: 0 0 var(--toolbar-height); + height: var(--toolbar-height); align-items: center; - gap: 8dp; + gap: var(--space-sm); padding-right: 72dp; - background-color: rgba(217, 217, 217, 10%); - border-bottom: 2dp #92875B; - font-family: "Fira Sans Condensed"; + background-color: rgba(var(--color-neutral-rgb), 10%); + border-bottom-width: 2dp; + border-bottom-color: var(--color-border); + font-family: var(--font-family-heading); font-weight: bold; - font-size: 18dp; + font-size: var(--font-size-xl); } window.logs > close { @@ -22,76 +23,77 @@ window.logs > close { right: 8dp; } -window.logs .log-title { +window.logs log-title { align-self: stretch; flex: 0 0 auto; - padding: 0 24dp; - line-height: 64dp; + padding: 0 var(--space-xl); + line-height: var(--toolbar-height); text-transform: uppercase; - border-bottom: 4dp #C2A42D; + border-bottom-width: 4dp; + border-bottom-color: var(--color-accent); font-effect: glow(0dp 4dp 0dp 4dp black); } -window.logs .log-title-mod { +window.logs log-title-mod { flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; - font-family: "Fira Sans"; + font-family: var(--font-family-body); font-weight: normal; - font-size: 15dp; - color: rgba(224, 219, 200, 55%); + font-size: var(--font-size-base); + color: rgba(var(--color-text-rgb), 55%); } -.log-toolbar-spacer { +log-toolbar-spacer { flex: 1 1 0; } -.log-toolbar button { +log-toolbar button { flex: 0 0 auto; - font-family: "Fira Sans"; + font-family: var(--font-family-body); font-weight: normal; - font-size: 15dp; - padding: 5dp 12dp; + font-size: var(--font-size-base); + padding: 5dp var(--space-md); } window.logs content pane.log-view { flex: 1 1 0; - padding: 12dp 16dp; + padding: var(--space-md) var(--space-lg); padding-bottom: 0dp; gap: 0dp; } -.log-lines { +log-lines { display: block; } -.log-line { +log-line { display: block; - font-family: "Noto Mono"; - font-size: 13dp; + font-family: var(--font-family-monospace); + font-size: var(--font-size-xs); line-height: 1.5; word-break: break-word; white-space: pre-wrap; } -.log-line .log-time { - color: rgba(224, 219, 200, 45%); +log-line log-time { + color: rgba(var(--color-text-rgb), 45%); } -.log-line .log-mod { - color: rgba(194, 164, 45, 80%); +log-line log-mod { + color: rgba(var(--color-accent-rgb), 80%); } -.log-line.lvl-trace, -.log-line.lvl-debug { +log-line.lvl-trace, +log-line.lvl-debug { opacity: 0.55; } -.log-line.lvl-warn .log-msg { - color: #ffa826; +log-line.lvl-warn log-msg { + color: var(--color-warning); } -.log-line.lvl-error .log-msg { - color: #cc4444; +log-line.lvl-error log-msg { + color: var(--color-error); } diff --git a/res/rml/mod_browser.rcss b/res/rml/mod_browser.rcss new file mode 100644 index 0000000000..e88285b019 --- /dev/null +++ b/res/rml/mod_browser.rcss @@ -0,0 +1,922 @@ +window.mod-browser, +window.mod-browser-detail, +window.screenshot-viewer { + background-color: rgba(var(--color-surface-rgb), 96%); +} + +window.mod-browser > content { + flex-flow: row; +} + +catalog-filters { + display: flex; + flex-flow: column; + flex: 0 0 264dp; + min-width: 0; + padding: var(--space-xl) 18dp; + gap: var(--space-sm); + border-right-width: 1dp; + border-right-color: var(--color-border); + background-color: rgba(var(--color-control-rgb), 45%); +} + +catalog-filters h1, +catalog-results header h1 { + margin: 0; + font-family: var(--font-family-heading); + font-size: var(--font-size-5xl); + font-weight: bold; +} + +catalog-filters h1 { + padding: 0 var(--space-sm) var(--space-sm) var(--space-sm); +} + +catalog-filters h2 { + margin: var(--space-md) var(--space-sm) 0 var(--space-sm); + font-family: var(--font-family-heading); + font-size: var(--font-size-xs); + font-weight: bold; + text-transform: uppercase; + opacity: 0.42; +} + +catalog-filters select-button { + padding: var(--space-sm) 10dp; + border-radius: var(--radius-panel); +} + +catalog-filters select-button key { + font-size: var(--font-size-xs); +} + +catalog-filters select-button value, +catalog-filters select-button input { + font-size: var(--font-size-sm); +} + +.catalog-library-link { + padding: var(--space-sm) 10dp; + border-radius: var(--radius-panel); + text-align: left; + font-size: var(--font-size-sm); +} + +catalog-results { + display: flex; + flex-flow: column; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + padding: var(--space-xl); + gap: var(--space-lg); +} + +catalog-results header { + display: block; + flex: 0 0 auto; + padding-right: var(--space-2xl); +} + +catalog-results > header small { + display: block; + margin-top: var(--space-2xs); + font-size: var(--font-size-xs); + opacity: 0.5; +} + +catalog-viewport { + display: block; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden auto; +} + +catalog-grid { + display: flex; + flex-flow: row wrap; + min-width: 0; + gap: 14dp; + padding: var(--space-2xs); +} + +.catalog-card { + display: flex; + flex-flow: column; + flex: 0 0 48%; + min-width: 260dp; + height: 310dp; + padding: 0; + overflow: hidden; + text-align: left; + border-radius: 12dp; +} + +catalog-card-art { + display: block; + position: relative; + flex: 0 0 118dp; + min-height: 118dp; +} + +catalog-card-art-image { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + mask-image: linear-gradient(180deg, #fff 45%, transparent); +} + +catalog-card-body { + display: flex; + position: relative; + flex-flow: column; + flex: 1 1 auto; + min-height: 0; + padding: var(--space-xl) var(--space-lg) var(--space-lg); + gap: 7dp; +} + +catalog-official-badge { + padding: var(--space-2xs) 5dp; + border-radius: var(--radius-small); + background-color: rgba(var(--color-accent-rgb), 28%); + color: var(--color-info); + font-style: normal; + font-size: var(--font-size-3xs); +} + +catalog-card-body > section > b { + display: block; + font-family: var(--font-family-heading); + font-size: var(--font-size-sm); + font-weight: bold; + text-transform: uppercase; + color: var(--color-accent); +} + +catalog-card-body > small { + position: absolute; + top: var(--space-md); + right: var(--space-lg); + margin: 0; + padding: 0; + font-size: var(--font-size-sm); + font-weight: normal; + text-transform: none; + opacity: 0.45; +} + +.catalog-card mod-icon { + display: block; + position: absolute; + left: 14dp; + bottom: -18dp; + z-index: 1; + width: 56dp; + height: 56dp; + border-radius: var(--radius-panel); + overflow: hidden; + box-shadow: rgba(var(--color-black-rgb), 60%) 0 6dp 16dp; +} + +mod-icon-image { + display: block; + width: 100%; + height: 100%; + border-radius: var(--radius-panel); +} + +catalog-card-body > section { + display: block; + margin: 0; + padding: 0; +} + +catalog-card-body > section h2 { + display: block; + margin: 0; + font-family: var(--font-family-body); + font-size: var(--font-size-3xl); + font-weight: bold; + color: var(--color-neutral); + line-height: 1.5; +} + +catalog-card-body > section small { + display: block; + font-size: var(--font-size-3xs); + opacity: 0.55; +} + +catalog-card-body > p { + flex: 1 1 auto; + min-height: 0; + margin: 0; + overflow: hidden; + font-size: var(--font-size-base); + line-height: 1.35; + color: rgba(var(--color-text-rgb), 68%); +} + +catalog-card-body > footer { + display: flex; + align-items: center; + gap: 10dp; + font-size: var(--font-size-3xs); + opacity: 0.52; +} + +catalog-card-body > footer stat { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 3dp; + white-space: nowrap; +} + +catalog-card-body > footer icon { + flex: 0 0 13dp; + font-size: var(--font-size-sm); + line-height: 1; +} + +catalog-card-body > footer .size { + margin-left: auto; + font-size: var(--font-size-sm); +} + +catalog-card-body > footer .size.installed { + color: var(--color-success); +} + +catalog-pagination { + display: flex; + flex-flow: row; + align-items: center; + flex: 0 0 auto; + gap: var(--space-sm); +} + +catalog-pagination-label { + flex: 1 1 auto; + text-align: center; + font-size: var(--font-size-xs); + opacity: 0.5; +} + +catalog-pagination button { + font-size: var(--font-size-sm); + padding: 6dp var(--space-md); +} + +catalog-results-status, +catalog-detail-status { + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + height: 100%; + gap: var(--space-sm); + text-align: center; +} + +catalog-results-status h2, +catalog-detail-status h2 { + display: block; + margin: 0; + font-family: var(--font-family-heading); + font-size: var(--font-size-4xl); + font-weight: bold; +} + +catalog-results-status p, +catalog-detail-status p { + display: block; + margin: 0; + opacity: 0.58; +} + +catalog-results-status button, +catalog-detail-status button { + margin-top: var(--space-sm); + font-size: var(--font-size-base); +} + +window.mod-browser-detail > content { + display: block; +} + +detail-scroll { + display: flex; + flex-flow: column; + width: 100%; + height: 100%; + min-width: 0; + overflow: hidden auto; + padding-bottom: var(--space-2xl); +} + +catalog-detail-hero { + display: flex; + position: relative; + flex-flow: column; + justify-content: space-between; + flex: 0 0 220dp; + min-height: 220dp; + padding: 18dp var(--space-xl) 20dp var(--space-xl); +} + +catalog-detail-hero-image { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + mask-image: linear-gradient(180deg, #fff 40%, transparent); +} + +catalog-detail-actions { + display: flex; + position: relative; + z-index: 1; + flex-flow: row; + gap: var(--space-sm); +} + +catalog-detail-actions button, +catalog-source-actions button { + font-size: var(--font-size-sm); + padding: 7dp var(--space-md); + --button-background: rgba(var(--color-control-rgb), 75%); + --button-background-hover: rgba(var(--color-control-rgb), 90%); + --button-background-selected: rgba(var(--color-control-rgb), 90%); + --button-background-active: rgba(var(--color-surface-rgb), 90%); +} + +.catalog-icon-action { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.catalog-icon-action icon { + flex: 0 0 18dp; + font-size: var(--font-size-xl); + line-height: 1; +} + +.catalog-install-action { + display: flex; + position: relative; + align-items: center; + justify-content: center; + gap: 10dp; + min-width: 132dp; + padding: 8dp 22dp; + border-radius: var(--radius-window); + overflow: hidden; + font-size: var(--font-size-2xl); + opacity: 1; + --button-background: rgba(var(--color-control-rgb), 20%); + --button-background-hover: rgba(var(--color-interactive-rgb), 20%); + --button-background-selected: rgba(var(--color-interactive-rgb), 20%); + --button-background-active: rgba(var(--color-interactive-rgb), 20%); + box-shadow: var(--color-accent) 0 0 0 2dp; +} + +.catalog-install-action icon { + flex: 0 0 22dp; + font-size: var(--font-size-3xl); + line-height: 1; +} + +.catalog-install-action > span { + white-space: nowrap; +} + +.catalog-install-action.idle { + --button-background: rgba(var(--color-interactive-rgb), 18%); + --button-background-hover: rgba(var(--color-interactive-rgb), 42%); + --button-background-selected: rgba(var(--color-interactive-rgb), 42%); + --button-background-active: rgba(var(--color-interactive-rgb), 65%); + box-shadow: rgba(var(--color-accent-rgb), 65%) 0 0 0 1dp; +} + +.catalog-install-action.paused { + box-shadow: rgba(var(--color-border-rgb), 25%) 0 0 0 1dp; +} + +.catalog-install-action.paused:not(:disabled):hover, +.catalog-install-action.paused:not(:disabled):focus-visible { + box-shadow: var(--color-accent) 0 0 0 2dp; +} + +.catalog-install-action progress { + position: absolute; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 4dp; + margin: 0; + border-radius: 0; + overflow: hidden; + background-color: rgba(var(--color-white-rgb), 10%); +} + +.catalog-install-action progress fill { + border-radius: 0; + background-color: rgba(var(--color-accent-rgb), 80%); +} + +.catalog-install-action.paused progress fill { + background-color: rgba(var(--color-text-rgb), 35%); +} + +.catalog-install-action.retrying { + color: var(--color-warning); + box-shadow: rgba(var(--color-warning-rgb), 60%) 0 0 0 2dp; +} + +.catalog-install-action.retrying progress fill { + background-color: rgba(var(--color-warning-rgb), 60%); +} + +.catalog-install-action.failed { + color: var(--color-white); + --button-background: rgba(var(--color-error-rgb), 20%); + --button-background-hover: rgba(var(--color-error-rgb), 35%); + --button-background-selected: rgba(var(--color-error-rgb), 35%); + --button-background-active: rgba(var(--color-error-rgb), 35%); + box-shadow: var(--color-error) 0 0 0 2dp; +} + +.catalog-install-action.failed progress fill { + background-color: rgba(var(--color-error-rgb), 70%); +} + +.catalog-install-action.installed { + color: var(--color-success); + box-shadow: rgba(var(--color-success-rgb), 50%) 0 0 0 2dp; +} + +.catalog-install-action.installing progress fill { + background-color: rgba(var(--color-info-rgb), 80%); +} + +catalog-install-control { + display: flex; + flex-flow: column; + align-items: flex-end; + flex: 0 0 auto; + gap: var(--space-xs); +} + +catalog-install-caption { + display: block; + max-width: 240dp; + overflow: hidden; + font-size: var(--font-size-xs); + color: rgba(var(--color-text-rgb), 50%); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +catalog-install-caption.failed { + color: var(--color-error); +} + +catalog-detail-identity { + display: flex; + position: relative; + z-index: 1; + align-items: flex-end; + gap: var(--space-lg); +} + +catalog-detail-identity mod-icon { + display: block; + flex: 0 0 70dp; + width: 70dp; + height: 70dp; + border-radius: var(--radius-panel); + overflow: hidden; + box-shadow: rgba(var(--color-black-rgb), 65%) 0 8dp 20dp; +} + +catalog-detail-identity > header { + display: block; + flex: 1 1 auto; + min-width: 0; +} + +catalog-detail-identity > header > b { + display: block; + font-family: var(--font-family-heading); + font-size: var(--font-size-sm); + font-weight: bold; + text-transform: uppercase; + color: var(--color-accent); +} + +catalog-detail-identity h1 { + display: block; + margin: 1dp 0; + font-family: var(--font-family-body); + font-size: var(--font-size-5xl); + font-weight: bold; + color: var(--color-neutral); +} + +catalog-detail-identity h1 small { + margin-left: 9dp; + font-family: var(--font-family-body); + font-size: var(--font-size-xs); + font-weight: normal; + opacity: 0.55; +} + +catalog-detail-identity p { + display: block; + margin: 0; + font-size: var(--font-size-xs); + opacity: 0.72; +} + +catalog-detail-stats { + display: flex; + flex-flow: row; + align-items: center; + gap: 28dp; + margin: 0 var(--space-xl); + font-size: var(--font-size-2xs); + color: rgba(var(--color-text-rgb), 58%); +} + +catalog-detail-stats > stat { + display: flex; + align-items: center; + gap: 5dp; +} + +catalog-detail-stats icon { + flex: 0 0 17dp; + font-size: var(--font-size-lg); + line-height: 1; +} + +catalog-detail-body { + display: flex; + flex-flow: row; + align-items: flex-start; + gap: 28dp; + padding: var(--space-xl); +} + +catalog-detail-body main { + display: flex; + flex-flow: column; + flex: 1 1 auto; + min-width: 0; + gap: 28dp; +} + +catalog-detail-body section { + display: block; +} + +catalog-detail-body section.catalog-scroll-anchor { + focus: auto; +} + +catalog-detail-body section.catalog-scroll-anchor:focus-visible { + border-radius: var(--radius-control); + box-shadow: rgba(var(--color-accent-rgb), 45%) 0 0 0 1dp; +} + +catalog-detail-body h2, +catalog-detail-body h3 { + display: block; + margin: 0 0 10dp 0; + font-family: var(--font-family-heading); + font-size: var(--font-size-2xl); + font-weight: bold; +} + +catalog-detail-body h2 small { + margin-left: 7dp; + font-family: var(--font-family-body); + font-size: var(--font-size-3xs); + font-weight: normal; + opacity: 0.5; +} + +catalog-fragment { + display: block; + font-size: var(--font-size-md); + line-height: 1.55; + color: var(--color-neutral); +} + +catalog-fragment p, +catalog-fragment ul, +catalog-fragment ol { + display: block; + margin: 0 0 10dp 0; +} + +catalog-fragment ul, +catalog-fragment ol { + padding-left: var(--space-xl); +} + +catalog-fragment li { + display: block; + position: relative; + margin: 3dp 0; +} + +catalog-list-marker { + display: block; + position: absolute; + right: 100%; + width: var(--space-xl); + padding-right: var(--space-sm); + text-align: right; +} + +catalog-fragment h1, +catalog-fragment h2, +catalog-fragment h3, +catalog-fragment h4, +catalog-fragment h5, +catalog-fragment h6 { + display: block; + margin: 15dp 0 5dp 0; + font-family: var(--font-family-heading); + font-weight: bold; + font-size: var(--font-size-xl); + color: var(--color-neutral); +} + +catalog-fragment h1 { + font-size: var(--font-size-5xl); +} + +catalog-fragment h2 { + font-size: var(--font-size-4xl); +} + +catalog-fragment h3 { + font-size: var(--font-size-2xl); +} + +window.screenshot-viewer > close { + display: none; +} + +catalog-gallery { + display: flex; + flex-flow: row; + height: 210dp; + gap: var(--space-sm); +} + +.catalog-screenshot { + position: relative; + overflow: hidden; + flex: 1 1 0; + height: 100%; + min-width: 0; + padding: 0; + border-radius: var(--radius-panel); + --button-background: rgba(var(--color-border-rgb), 14%); + font-size: var(--font-size-5xl); +} + +.catalog-screenshot.primary { + flex: 2 1 0; +} + +catalog-screenshot-image, +catalog-screenshot-more { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; +} + +catalog-screenshot-more { + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(var(--color-black-rgb), 60%); + color: var(--color-white); +} + +catalog-dependencies { + display: flex; + flex-flow: column; + gap: 7dp; + font-size: var(--font-size-xs); +} + +catalog-dependency { + display: flex; + flex-flow: column; + padding: 9dp 11dp; + border-radius: var(--radius-control); + background-color: rgba(var(--color-border-rgb), 9%); +} + +catalog-dependency-status { + display: block; + font-size: var(--font-size-3xs); + opacity: 0.56; +} + +catalog-dependency.missing { + color: var(--color-warning); +} + +catalog-detail-body aside { + display: flex; + flex-flow: column; + flex: 0 0 264dp; + min-width: 0; + padding: var(--space-lg); + gap: var(--space-md); + border-radius: 9dp; + background-color: rgba(var(--color-control-rgb), 42%); + box-shadow: rgba(var(--color-border-rgb), 24%) 0 0 0 1dp; +} + +catalog-detail-body dl { + display: flex; + flex-flow: row wrap; + margin: 0; + font-size: var(--font-size-sm); +} + +catalog-detail-body dt { + flex: 0 0 42%; + padding: 5dp 0; + font-weight: bold; + opacity: 0.8; +} + +catalog-detail-body dd { + flex: 1 1 52%; + margin: 0; + padding: 5dp 0; + text-align: right; +} + +window.screenshot-viewer > content { + flex-flow: column; + padding: 18dp; + gap: var(--space-md); +} + +catalog-screenshot-full { + display: block; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + background-color: rgba(var(--color-black-rgb), 45%); +} + +catalog-screenshot-actions { + display: flex; + flex-flow: row; + justify-content: center; + flex: 0 0 auto; + gap: var(--space-sm); +} + +catalog-screenshot-actions button { + font-size: var(--font-size-sm); + padding: 7dp 13dp; +} + +@media (max-height: 640dp) { + catalog-filters { + flex-basis: 232dp; + padding: 15dp var(--space-md); + gap: 5dp; + } + + catalog-filters h1, + catalog-results header h1 { + font-size: var(--font-size-4xl); + } + + catalog-filters h2 { + margin-top: 6dp; + } + + catalog-results { + padding: 15dp; + gap: 10dp; + } + + .catalog-card { + min-width: 220dp; + height: 254dp; + } + + catalog-card-art { + flex-basis: 78dp; + min-height: 78dp; + } + + catalog-card-body { + padding: 18dp 10dp var(--space-sm) 10dp; + } + + .catalog-card mod-icon { + left: 10dp; + bottom: -14dp; + width: 40dp; + height: 40dp; + } + + catalog-card-body > section > b, + catalog-card-body > small { + font-size: var(--font-size-3xs); + } + + catalog-card-body > section h2 { + font-size: var(--font-size-md); + } + + catalog-card-body > p { + font-size: var(--font-size-3xs); + } + + catalog-detail-hero { + flex-basis: 160dp; + min-height: 160dp; + padding: var(--space-md) 18dp; + } + + catalog-detail-identity mod-icon { + flex-basis: 56dp; + width: 56dp; + height: 56dp; + } + + catalog-detail-identity { + gap: var(--space-md); + } + + catalog-detail-identity > header > b { + font-size: var(--font-size-2xs); + } + + .catalog-install-action { + gap: var(--space-sm); + min-width: 112dp; + padding: 7dp 18dp; + border-radius: 12dp; + font-size: var(--font-size-md); + } + + .catalog-install-action icon { + flex-basis: 18dp; + font-size: var(--font-size-xl); + } + + catalog-install-control { + gap: 3dp; + } + + catalog-install-caption { + max-width: 190dp; + font-size: var(--font-size-3xs); + } + + catalog-detail-identity h1 { + font-size: var(--font-size-3xl); + } + + catalog-detail-body { + padding: 18dp; + gap: 18dp; + } + + catalog-gallery { + height: 160dp; + } +} diff --git a/res/rml/mods.rcss b/res/rml/mods.rcss index 34e86f485b..acd6d923a3 100644 --- a/res/rml/mods.rcss +++ b/res/rml/mods.rcss @@ -1,8 +1,8 @@ window.mods content pane.mod-list { flex: 0 0 360dp; - padding: 16dp; + padding: var(--space-lg); padding-bottom: 0dp; - gap: 4dp; + gap: var(--space-xs); } @media (max-height: 640dp) { @@ -12,29 +12,66 @@ window.mods content pane.mod-list { } window.mods content pane.mod-detail { - gap: 12dp; + gap: var(--space-md); } -.mod-info-row { +mod-entry.browser, +mod-entry.installs { + min-height: 76dp; +} + +mod-entry.browser mod-icon { + color: var(--color-accent); + decorator: text("" center center); +} + +mod-entry.installs mod-icon { + color: var(--color-accent); + decorator: text("" center center); +} + +mod-entry.installs { + background-color: rgba(var(--color-control-rgb), 20%); + box-shadow: rgba(var(--color-border-rgb), 25%) 0 0 0 1dp; +} + +mod-entry.installs:hover, +mod-entry.installs:focus-visible { + background-color: rgba(var(--color-interactive-rgb), 12%); + box-shadow: var(--color-accent) 0 0 0 2dp; +} + +mod-entry.installs progress { + height: 6dp; + margin: var(--space-xs) 0 0 0; +} + +mod-list-separator { + display: block; + height: 1dp; + margin: var(--space-sm) 10dp; + background-color: rgba(var(--color-border-rgb), 30%); +} + +mod-info-row { display: flex; align-items: center; - gap: 12dp; - padding: 4dp 0; + gap: var(--space-md); + padding: var(--space-xs) 0; } -.mod-info-label { - font-family: "Fira Sans Condensed"; - font-weight: bold; +mod-info-row > b { + font-family: var(--font-family-heading); opacity: 0.55; flex: 0 0 auto; } -.mod-info-value { +mod-info-row > span { flex: 1 1 0; } .mod-path { - font-size: 14dp; + font-size: var(--font-size-sm); word-break: break-all; opacity: 0.7; } @@ -42,106 +79,113 @@ window.mods content pane.mod-detail { mod-entry { display: flex; flex-flow: row; - gap: 12dp; + gap: var(--space-md); padding: 10dp; border-radius: 10dp; - decorator: vertical-gradient(#c2a42d00 #c2a42d00); + decorator: vertical-gradient(rgba(var(--color-accent-rgb), 0%) rgba(var(--color-accent-rgb), 0%)); transition: decorator 0.1s linear-in-out; cursor: pointer; focus: auto; } mod-entry.current { - box-shadow: rgba(146, 135, 91, 40%) 0 0 0 1dp; + box-shadow: rgba(var(--color-border-rgb), 40%) 0 0 0 1dp; } mod-entry:hover, mod-entry:focus-visible { - decorator: vertical-gradient(#c2a42d00 #c2a42d26); + decorator: vertical-gradient(rgba(var(--color-accent-rgb), 0) rgba(var(--color-accent-rgb), 38)); } mod-entry:selected { - decorator: vertical-gradient(#c2a42d10 #c2a42d40); + decorator: vertical-gradient(rgba(var(--color-accent-rgb), 16) rgba(var(--color-accent-rgb), 64)); } -mod-entry .mod-icon { +mod-icon { + display: block; flex: 0 0 auto; width: 56dp; height: 56dp; - border-radius: 8dp; -} - -mod-entry icon.mod-icon { + border-radius: var(--radius-panel); + font-family: var(--font-family-icons); font-size: 36dp; - background-color: rgba(17, 16, 10, 20%); - color: rgba(224, 219, 200, 45%); + background-color: rgba(var(--color-control-rgb), 20%); + color: rgba(var(--color-text-rgb), 45%); decorator: text("" center center); + overflow: hidden; } -mod-entry .mod-entry-info { +mod-icon img { + display: block; + width: 100%; + height: 100%; + border-radius: var(--radius-panel); +} + +mod-info { display: flex; flex-flow: column; flex: 1 1 0; min-width: 0; - gap: 2dp; + gap: var(--space-2xs); } -mod-entry .mod-entry-name { +mod-info header { display: flex; flex-flow: row; align-items: baseline; gap: 6dp; } -mod-entry .mod-entry-name-text { +mod-info header b { flex: 0 1 auto; min-width: 0; - font-weight: bold; white-space: nowrap; overflow: hidden; } -mod-entry .mod-entry-version { +mod-info header small { flex: 0 0 auto; - font-size: 13dp; - color: rgba(224, 219, 200, 50%); + font-size: var(--font-size-xs); + color: rgba(var(--color-text-rgb), 50%); } -mod-entry .mod-entry-status.active { - color: #44cc55; +mod-status.active { + color: var(--color-success); } -mod-entry .mod-entry-status.failed { - color: #cc4444; +mod-status.failed { + color: var(--color-error); } -mod-entry .mod-entry-network { +mod-network { margin-left: 6dp; padding: 1dp 5dp; border-radius: 5dp; - background-color: rgba(67, 151, 219, 20%); - color: #6fb7ef; + background-color: rgba(var(--color-info-rgb), 20%); + color: var(--color-info); } -mod-entry .mod-entry-desc { - font-size: 14dp; +mod-info > p { + margin: 0; + font-size: var(--font-size-sm); line-height: 1.3; - color: rgba(224, 219, 200, 65%); + color: rgba(var(--color-text-rgb), 65%); max-height: 2.6em; overflow: hidden; white-space: pre-wrap; } -mod-entry .mod-entry-sub { - font-size: 13dp; - color: rgba(224, 219, 200, 50%); +mod-info > small { + font-size: var(--font-size-xs); + color: rgba(var(--color-text-rgb), 50%); } -mod-entry.inactive .mod-icon { +mod-entry.inactive mod-icon { filter: grayscale(1); } -mod-entry.inactive .mod-entry-info { +mod-entry.inactive mod-info { opacity: 0.5; } @@ -155,20 +199,37 @@ mod-header.has-banner { margin: -24dp -24dp 0dp -24dp; } -mod-header .mod-actions { +mod-header-image { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + mask-image: linear-gradient(180deg, #fff 40%, transparent); + filter: grayscale(0); +} + +mod-header.inactive mod-header-image { + filter: grayscale(1); +} + +mod-actions { position: absolute; top: 24dp; left: 24dp; display: flex; flex-flow: row; - gap: 8dp; + gap: var(--space-sm); } -mod-header .mod-actions button { - font-size: 16dp; - padding: 6dp 14dp; - background-color: rgba(21, 22, 16, 80%); - box-shadow: rgba(146, 135, 91, 60%) 0 0 0 1dp; +mod-actions button { + --button-background: rgba(var(--color-surface-rgb), 80%); + --button-background-hover: rgba(var(--color-control-rgb), 90%); + --button-background-selected: rgba(var(--color-control-rgb), 90%); + --button-background-active: rgba(var(--color-surface-rgb), 90%); + box-shadow: rgba(var(--color-border-rgb), 60%) 0 0 0 1dp; } mod-header.no-banner { @@ -177,56 +238,55 @@ mod-header.no-banner { align-items: center; } -mod-header.no-banner .mod-actions { +mod-header.no-banner mod-actions { position: static; } -window.mods .mod-title { +mod-title { display: block; - font-size: 28dp; + font-size: var(--font-size-5xl); font-weight: bold; } -window.mods .mod-title .mod-title-version { +mod-title small { font-weight: normal; - font-size: 16dp; - color: rgba(224, 219, 200, 55%); + font-size: var(--font-size-md); + color: rgba(var(--color-text-rgb), 55%); } -window.mods .mod-author { +mod-author { display: block; - font-size: 15dp; - color: rgba(224, 219, 200, 55%); + font-size: var(--font-size-base); + color: rgba(var(--color-text-rgb), 55%); } -window.mods .mod-restart-note { - font-size: 15dp; - color: #ffa826; +mod-restart-note { + font-size: var(--font-size-base); + color: var(--color-warning); opacity: 0.85; } -window.mods .mod-description { +mod-description { line-height: 1.5; } -.status-badge { - font-size: 14dp; +status-badge { + font-size: var(--font-size-sm); opacity: 0.7; } -.status-badge.active, -.mod-info-label.active { - color: #44cc55; +status-badge.active, +mod-info-row > b.active { + color: var(--color-success); opacity: 1; } -.status-badge.failed, -.mod-info-label.failed { - color: #cc4444; +status-badge.failed, +mod-info-row > b.failed { + color: var(--color-error); opacity: 1; } - -.status-badge.network { - color: #6fb7ef; +status-badge.network { + color: var(--color-info); opacity: 1; } diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index 98f8bfa27a..a200005ca1 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -8,10 +8,10 @@ body { height: 100%; margin: 0; padding: 0; - font-family: "Fira Sans"; + font-family: var(--font-family-body); font-weight: normal; - font-size: 20dp; - color: #E0DBC8; + font-size: var(--font-size-2xl); + color: var(--color-text); display: flex; flex-direction: column; justify-content: flex-end; @@ -24,16 +24,22 @@ fps, pipeline-progress, toast { position: absolute; - border: 1dp #92875B; - background-color: rgba(21, 22, 16, 80%); + border-width: 1dp; + background-color: rgba(var(--color-surface-rgb), 80%); +} + +fps, +pipeline-progress { + border-color: var(--color-border); } toast { + border-color: var(--toast-border-color); top: 40dp; right: 40dp; display: flex; flex-flow: column; - border-radius: 14dp; + border-radius: var(--radius-window); overflow: hidden; backdrop-filter: blur(5dp); box-shadow: 0 0 15dp 3dp; @@ -41,8 +47,8 @@ toast { transform: scale(0.9); transform-origin: center; transition: filter transform 0.2s cubic-in-out; - padding: 18dp 24dp; - gap: 8dp; + padding: 18dp var(--space-xl); + gap: var(--space-sm); } toast[open] { @@ -50,15 +56,6 @@ toast[open] { transform: scale(1); } -/*toast:hover { - cursor: pointer; - background-color: rgba(61, 59, 36, 80%); -} - -toast:active { - background-color: rgba(45, 43, 26, 80%); -}*/ - b { font-weight: bold; } @@ -67,14 +64,14 @@ toast heading { display: flex; gap: 18dp; align-items: center; - font-family: "Fira Sans Condensed"; - font-size: 18dp; + font-family: var(--font-family-heading); + font-size: var(--font-size-xl); font-weight: bold; text-transform: uppercase; - color: #92875B; + color: var(--toast-heading-color); } -toast heading > span { +toast heading > toast-title { flex: 1 0 auto; } @@ -82,13 +79,13 @@ toast heading > row { flex: 1 0 auto; display: flex; align-items: center; - gap: 4dp; + gap: var(--space-xs); } toast message { display: flex; flex-flow: column; - gap: 8dp; + gap: var(--space-sm); } toast message row { @@ -99,6 +96,50 @@ toast message row.muted { opacity: 0.5; } +toast.mod-installed row { + align-items: center; + gap: var(--space-md); +} + +mod-icon { + flex: 0 0 42dp; + width: 42dp; + height: 42dp; + overflow: hidden; + border-radius: var(--radius-panel); + background-color: rgba(var(--color-control-rgb), 45%); + color: rgba(var(--color-text-rgb), 45%); + font-family: var(--font-family-icons); + font-size: var(--font-size-4xl); + decorator: text("" center center); +} + +mod-icon img { + width: 100%; + height: 100%; + border-radius: var(--radius-panel); +} + +mod-info { + display: flex; + flex-flow: column; + min-width: 0; + gap: var(--space-xs); +} + +mod-name { + color: var(--color-white); +} + +toast.mod-installed small { + font-size: var(--font-size-sm); + color: rgba(var(--color-text-rgb), 55%); +} + +toast.mod-installed small.version { + margin-left: var(--space-sm); +} + progress { height: 4dp; position: absolute; @@ -108,7 +149,7 @@ progress { } progress fill { - background-color: rgba(194, 164, 45, 80%); + background-color: rgba(var(--color-accent-rgb), 80%); } pipeline-progress { @@ -119,8 +160,8 @@ pipeline-progress { z-index: 100; min-width: 260dp; max-width: 90%; - padding: 10dp 16dp 12dp; - border-radius: 7dp; + padding: 10dp var(--space-lg) var(--space-md); + border-radius: var(--radius-control); overflow: hidden; filter: opacity(0); transition: filter 0.2s linear-in-out; @@ -134,8 +175,8 @@ pipeline-progress[open] { pipeline-status { display: flex; align-items: center; - gap: 8dp; - font-size: 18dp; + gap: var(--space-sm); + font-size: var(--font-size-xl); font-weight: normal; white-space: nowrap; } @@ -145,26 +186,16 @@ icon.pipeline-spinner { height: 1.2em; line-height: 1.2em; font-size: 1.2em; - color: #C2A42D; + color: var(--color-accent); text-align: center; transform-origin: center; animation: 1s linear infinite pipeline-spinner-spin; } -toast.achievement { - border: 1dp #C2A42D; -} - -toast.achievement heading { - color: #C2A42D; -} - +toast.achievement, toast.warning { - border: 1dp #C2A42D; -} - -toast.warning heading { - color: #C2A42D; + --toast-border-color: var(--color-accent); + --toast-heading-color: var(--color-accent); } toast.controller-warning { @@ -181,8 +212,8 @@ toast.controller-warning[open] { transform: translateX(-50%) scale(1); } -toast.controller-warning heading { - color: #C2A42D; +toast.controller-warning { + --toast-heading-color: var(--color-accent); } toast.menu-notification { @@ -209,7 +240,7 @@ toast.menu-notification message row { } icon { - font-family: "Material Symbols Rounded"; + font-family: var(--font-family-icons); font-weight: normal; display: inline-block; vertical-align: middle; @@ -243,13 +274,20 @@ icon.warning { decorator: text("" center center); } +icon.download-done { + width: 1.2em; + height: 1.2em; + font-size: 1.2em; + decorator: text("" center center); +} + fps { display: none; z-index: 99; - font-size: 18dp; + font-size: var(--font-size-xl); font-weight: bold; - padding: 9dp 12dp; - border-radius: 7dp; + padding: 9dp var(--space-md); + border-radius: var(--radius-control); pointer-events: none; white-space: nowrap; } @@ -260,12 +298,12 @@ speedrun-timer { bottom: 0; right: 0; z-index: 99; - background-color: rgba(0, 0, 0, 65%); - padding: 2dp 4dp; + background-color: rgba(var(--color-black-rgb), 65%); + padding: var(--space-2xs) var(--space-xs); pointer-events: none; - font-family: "Noto Mono"; - font-size: 16dp; - color: #ffffff; + font-family: var(--font-family-monospace); + font-size: var(--font-size-md); + color: var(--color-white); white-space: nowrap; } @@ -329,7 +367,7 @@ logo img { left: 0; width: 100%; height: 100%; - filter: drop-shadow(#0008 0 0 14dp); + filter: drop-shadow(rgba(var(--color-black-rgb), 53.333333%) 0 0 14dp); transform-origin: center; } diff --git a/res/rml/popover.rcss b/res/rml/popover.rcss index 1db4f64a05..2084b132f0 100644 --- a/res/rml/popover.rcss +++ b/res/rml/popover.rcss @@ -3,6 +3,8 @@ } body { + --button-background: rgba(var(--color-control-rgb), 35%); + width: 100%; height: 100%; z-index: 10; @@ -12,14 +14,15 @@ popover { position: absolute; display: flex; flex-flow: column; - font-family: "Fira Sans"; - font-size: 14dp; - color: #E0DBC8; - border-radius: 14dp; - border: 2dp #92875B; - background-color: rgba(21, 22, 16, 96%); + font-family: var(--font-family-body); + font-size: var(--font-size-sm); + color: var(--color-text); + border-radius: var(--radius-window); + border-width: 2dp; + border-color: var(--color-border); + background-color: rgba(var(--color-surface-rgb), 96%); backdrop-filter: blur(5dp); - box-shadow: 0 6dp 24dp 2dp rgba(0, 0, 0, 55%); + box-shadow: 0 6dp 24dp 2dp rgba(var(--color-black-rgb), 55%); filter: opacity(0); transform: scale(0.95); transform-origin: center; @@ -43,8 +46,8 @@ color-sv { position: relative; width: 240dp; height: 150dp; - border-radius: 8dp; - box-shadow: rgba(146, 135, 91, 50%) 0 0 0 1dp; + border-radius: var(--radius-panel); + box-shadow: rgba(var(--color-border-rgb), 50%) 0 0 0 1dp; drag: drag; focus: auto; } @@ -55,8 +58,8 @@ color-alpha { position: relative; width: 240dp; height: 14dp; - border-radius: 7dp; - box-shadow: rgba(146, 135, 91, 50%) 0 0 0 1dp; + border-radius: var(--radius-control); + box-shadow: rgba(var(--color-border-rgb), 50%) 0 0 0 1dp; drag: drag; focus: auto; } @@ -64,13 +67,13 @@ color-alpha { color-sv:focus-visible, color-hue:focus-visible, color-alpha:focus-visible { - box-shadow: #C2A42D 0 0 0 2dp; + box-shadow: var(--color-accent) 0 0 0 2dp; } color-sv.adjusting, color-hue.adjusting, color-alpha.adjusting { - box-shadow: #FFFFFF 0 0 0 3dp; + box-shadow: var(--color-white) 0 0 0 3dp; } color-hue { @@ -82,20 +85,21 @@ color-cursor { position: absolute; width: 14dp; height: 14dp; - border-radius: 7dp; - border: 2dp #ffffff; - box-shadow: 0 0 4dp 1dp rgba(0, 0, 0, 70%); + border-radius: var(--radius-control); + border-width: 2dp; + border-color: var(--color-white); + box-shadow: 0 0 4dp 1dp rgba(var(--color-black-rgb), 70%); pointer-events: none; } color-heading { display: block; - margin-top: 2dp; - font-family: "Fira Sans Condensed"; + margin-top: var(--space-2xs); + font-family: var(--font-family-heading); font-weight: bold; - font-size: 13dp; + font-size: var(--font-size-xs); text-transform: uppercase; - color: rgba(224, 219, 200, 55%); + color: rgba(var(--color-text-rgb), 55%); } color-presets { @@ -114,7 +118,7 @@ button.color-swatch-button { width: 25dp; height: 25dp; padding: 0; - border-radius: 7dp; + border-radius: var(--radius-control); } color-chip { @@ -123,62 +127,124 @@ color-chip { width: 20dp; height: 20dp; border-radius: 5dp; - box-shadow: rgba(255, 255, 255, 45%) 0 0 0 1dp; + box-shadow: rgba(var(--color-white-rgb), 45%) 0 0 0 1dp; } button.color-swatch-button color-chip { width: 25dp; height: 25dp; - border-radius: 7dp; + border-radius: var(--radius-control); } color-chip.empty, color-swatch.empty { - background-color: rgba(224, 219, 200, 12%); - decorator: linear-gradient(135deg, rgba(224, 219, 200, 0) 45%, - rgba(194, 164, 45, 70%) 48%, rgba(194, 164, 45, 70%) 52%, - rgba(224, 219, 200, 0) 55%); + background-color: rgba(var(--color-text-rgb), 12%); + decorator: linear-gradient(135deg, rgba(var(--color-text-rgb), 0) 45%, + rgba(var(--color-accent-rgb), 70%) 48%, rgba(var(--color-accent-rgb), 70%) 52%, + rgba(var(--color-text-rgb), 0) 55%); } color-footer { display: flex; align-items: center; gap: 6dp; - padding-top: 2dp; + padding-top: var(--space-2xs); } button { - background-color: rgba(17, 16, 10, 35%); - padding: 4dp 8dp; - border-radius: 8dp; - box-shadow: rgba(146, 135, 91, 30%) 0 0 0 1dp; - color: #E0DBC8; + background-color: var(--button-background); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-panel); + box-shadow: rgba(var(--color-border-rgb), 30%) 0 0 0 1dp; + color: var(--button-color); cursor: pointer; focus: auto; } button:hover, button:focus-visible { - background-color: rgba(204, 184, 119, 20%); - box-shadow: #C2A42D 0 0 0 2dp; + background-color: var(--button-background-hover); + box-shadow: var(--color-accent) 0 0 0 2dp; } button:active { - background-color: rgba(204, 184, 119, 40%); + background-color: var(--button-background-active); } color-value { display: block; flex: 1 1 auto; text-align: right; - font-family: "Noto Mono"; - font-size: 12dp; - color: #FFFFFF; + font-family: var(--font-family-monospace); + font-size: var(--font-size-2xs); + color: var(--color-white); cursor: pointer; focus: auto; } color-value:hover, color-value:focus-visible { - color: #C2A42D; + color: var(--color-accent); +} + +popover.context-menu { + min-width: 200dp; + max-width: 90%; + max-height: 90%; + overflow-y: auto; + padding: 6dp; + gap: 2dp; + transform-origin: left top; +} + +.context-menu button { + display: flex; + align-items: center; + gap: 10dp; + padding: 8dp 12dp; + flex: 0 0 auto; + white-space: nowrap; + --button-background: transparent; + background-color: var(--button-background); + box-shadow: none; + transition: background-color 0.1s linear-in-out; +} + +.context-menu button:not(:disabled):hover, +.context-menu button:not(:disabled):focus-visible { + background-color: var(--button-background-hover); + box-shadow: none; +} + +.context-menu button:not(:disabled):active { + background-color: var(--button-background-active); +} + +.context-menu button:disabled { + background-color: transparent; + box-shadow: none; + opacity: 0.4; + cursor: unavailable; +} + +.context-menu button.destructive { + color: var(--color-error); +} + +.context-menu icon { + display: block; + flex: 0 0 1em; + width: 1em; + height: 1em; + font-family: var(--font-family-icons); + font-weight: normal; + line-height: 1; +} + +menu-separator { + display: block; + flex: 0 0 1dp; + height: 1dp; + margin: 4dp 6dp; + background-color: var(--color-border); } diff --git a/res/rml/popup.rcss b/res/rml/popup.rcss index effc80344d..1ed1652ecd 100644 --- a/res/rml/popup.rcss +++ b/res/rml/popup.rcss @@ -8,10 +8,10 @@ body { height: 100%; margin: 0; padding: 0; - font-family: "Fira Sans Condensed"; + font-family: var(--font-family-heading); font-weight: bold; - font-size: 18dp; - color: #E0DBC8; + font-size: var(--font-size-xl); + color: var(--color-text); } button { @@ -23,11 +23,12 @@ popup { width: 100%; display: flex; align-items: stretch; - height: 64dp; - background-color: rgba(21, 22, 16, 80%); - border-bottom: 2dp #92875B; + height: var(--toolbar-height); + background-color: rgba(var(--color-surface-rgb), 80%); + border-bottom-width: 2dp; + border-bottom-color: var(--color-border); backdrop-filter: blur(5dp); - transform: translateY(-64dp); + transform: translateY(var(--toolbar-hidden-offset)); transition: transform 0.2s cubic-in-out; } @@ -41,5 +42,5 @@ popup tab-bar { popup tab-bar tab { opacity: 0.35; - color: #E0DBC8; + color: var(--color-text); } diff --git a/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 6cc5da65ae..8d98577ad5 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -1,32 +1,40 @@ -*, *:before, *:after { - box-sizing: border-box; -} - body { + --color-prelaunch-accent: #FEE685; + --color-prelaunch-muted: #A6A09B; + --color-disc-error: #FFC9C9; + --color-disc-mismatch: #FFD6A7; + --color-ready: #D8F999; + --menu-button-decorator: horizontal-gradient(rgba(var(--color-black-rgb), 0%) rgba(var(--color-black-rgb), 0%)); + --menu-button-decorator-hover: horizontal-gradient(#FEE685FF #FEE68500); + width: 100%; height: 100%; - font-family: "Fira Sans"; + font-family: var(--font-family-body); font-weight: normal; - font-size: 20dp; - color: #FFFFFF; + font-size: var(--font-size-2xl); + color: var(--color-white); filter: opacity(0); transition: filter 1s 0.2s linear-in-out; z-index: -1; } -.gradient { +body.mirrored { + --menu-button-decorator-hover: horizontal-gradient(#FEE68500 #FEE685FF); +} + +prelaunch-gradient { position: absolute; width: 100%; height: 100%; /* The color gradient from the Figma bands really badly. A fully black gradient does as well, but not as badly. */ - decorator: horizontal-gradient(#000000FF #00000000); + decorator: horizontal-gradient(rgba(var(--color-black-rgb), 100%) rgba(var(--color-black-rgb), 0%)); } -body.mirrored .gradient { - decorator: horizontal-gradient(#00000000 #000000FF); +body.mirrored prelaunch-gradient { + decorator: horizontal-gradient(rgba(var(--color-black-rgb), 0%) rgba(var(--color-black-rgb), 100%)); } -.background { +prelaunch-background { position: absolute; width: 100%; height: 100%; @@ -39,11 +47,11 @@ body[open] { filter: opacity(1); } -body[open] .background { +body[open] prelaunch-background { opacity: 1; } -body.disc-ready .background { +body.disc-ready prelaunch-background { opacity: 0; } @@ -84,7 +92,7 @@ hero { display: flex; flex-direction: column; align-items: flex-start; - gap: 4dp; + gap: var(--space-xs); } body.mirrored hero { @@ -96,8 +104,8 @@ hero img { } eyebrow { - font-family: "Alegreya SC"; - font-size: 32dp; + font-family: var(--font-family-display); + font-size: var(--font-size-6xl); } @media (min-width: 1216dp) { @@ -107,36 +115,36 @@ eyebrow { } } -eyebrow span { +eyebrow studio-name { font-weight: bold; } -#menu-list { +menu-list { display: flex; flex-direction: column; - gap: 12dp; + gap: var(--space-md); align-items: flex-start; } #menu-list button { width: 428dp; height: 54dp; - padding: 8dp 16dp; - border-radius: 8dp; + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-panel); text-align: left; text-transform: uppercase; - font-family: "Fira Sans Condensed"; - font-size: 32dp; + font-family: var(--font-family-heading); + font-size: var(--font-size-6xl); font-weight: normal; cursor: pointer; /* Define a fully transparent gradient as the default state, otherwise a white flash occurs */ - decorator: horizontal-gradient(#00000000 #00000000); + decorator: var(--menu-button-decorator); } #menu-list button:disabled { opacity: 0.75; cursor: default; - decorator: horizontal-gradient(#00000000 #00000000); + decorator: var(--menu-button-decorator); } #menu-list button.anim-done { @@ -157,7 +165,7 @@ eyebrow span { width: 100%; height: 100%; overflow: hidden; - border-radius: 8dp; + border-radius: var(--radius-panel); pointer-events: none; z-index: 0; } @@ -169,7 +177,7 @@ eyebrow span { left: 0; width: 100%; height: 100%; - padding: 8dp 16dp; + padding: var(--space-sm) var(--space-lg); opacity: 0; text-overflow: ellipsis; white-space: nowrap; @@ -189,8 +197,8 @@ eyebrow span { height: 54dp; align-items: center; justify-content: center; - color: #FFFFFF; - font-family: "Material Symbols Rounded"; + color: var(--color-white); + font-family: var(--font-family-icons); font-weight: normal; font-size: 30dp; z-index: 1; @@ -213,8 +221,8 @@ eyebrow span { #menu-list button:hover, #menu-list button:focus-visible { - color: black; - decorator: horizontal-gradient(#FEE685FF #FEE68500); + color: var(--color-black); + decorator: var(--menu-button-decorator-hover); } body.mirrored #menu-list { @@ -225,11 +233,6 @@ body.mirrored #menu-list button { text-align: right; } -body.mirrored #menu-list button:hover, -body.mirrored #menu-list button:focus-visible { - decorator: horizontal-gradient(#FEE68500 #FEE685FF); -} - disc-info { position: absolute; left: 96dp; @@ -237,8 +240,8 @@ disc-info { bottom: 72dp; display: flex; flex-direction: column; - gap: 12dp; - font-size: 24dp; + gap: var(--space-md); + font-size: var(--font-size-4xl); font-effect: glow(0dp 4dp 0dp 4dp black); text-align: left; } @@ -256,9 +259,9 @@ version-info { bottom: 72dp; display: flex; flex-direction: column; - gap: 12dp; + gap: var(--space-md); text-align: right; - font-size: 24dp; + font-size: var(--font-size-4xl); font-effect: glow(0dp 4dp 0dp 4dp black); text-align: right; } @@ -272,40 +275,40 @@ body.mirrored version-info { #disc-status { display: flex; align-items: center; - gap: 8dp; + gap: var(--space-sm); } #disc-status[status=good] { - color: #D8F999; + color: var(--color-ready); } #disc-status[status=bad] { - color: #FFC9C9; + color: var(--color-disc-error); } #disc-status[status=verifying] { - color: #FFFFFF; + color: var(--color-white); } #disc-status[status=mismatch] { - color: #FFD6A7; + color: var(--color-disc-mismatch); } #disc-status[status=unknown] { - color: rgba(224, 219, 200, 65%); + color: rgba(var(--color-text-rgb), 65%); } #disc-status[status=pending] { - color: #FEE685; + color: var(--color-prelaunch-accent); } #disc-status icon { display: none; width: 24dp; height: 24dp; - font-family: "Material Symbols Rounded"; + font-family: var(--font-family-icons); font-weight: normal; - font-size: 24dp; + font-size: var(--font-size-4xl); } #disc-status[status] icon { @@ -337,24 +340,24 @@ body.mirrored version-info { } #disc-version { - font-size: 20dp; + font-size: var(--font-size-2xl); } -.update { +update-status { display: none; - color: #A6A09B; + color: var(--color-prelaunch-muted); align-items: center; justify-content: flex-end; - gap: 8dp; - font-size: 20dp; + gap: var(--space-sm); + font-size: var(--font-size-2xl); } -.update[state=checking], -.update[state=failed] { +update-status[state=checking], +update-status[state=failed] { display: block; } -.update[state=available] { +update-status[state=available] { display: flex; } @@ -364,33 +367,33 @@ body.mirrored version-info { padding: 0dp; border-width: 0dp; background-color: transparent; - color: #D8F999; + color: var(--color-ready); cursor: pointer; text-transform: uppercase; font-weight: bold; - decorator: horizontal-gradient(#00000000 #00000000); + decorator: var(--menu-button-decorator); } -.update[state=available] #update-download { +update-status[state=available] #update-download { display: flex; align-items: center; - gap: 2dp; + gap: var(--space-2xs); } #update-download icon { display: block; width: 18dp; height: 18dp; - font-family: "Material Symbols Rounded"; + font-family: var(--font-family-icons); font-weight: normal; decorator: text("" center center); } -.detail { - color: #A6A09B; +disc-version { + color: var(--color-prelaunch-muted); } -body.mirrored .update { +body.mirrored update-status { justify-content: flex-start; } @@ -436,12 +439,20 @@ body.animate-in .intro-item { /* Mobile layout */ @media (max-height: 640dp) { - .gradient { - decorator: horizontal-gradient(#00000000 #000000FF); + body { + --menu-button-decorator-hover: horizontal-gradient(#FEE68500 #FEE685FF); } - body.mirrored .gradient { - decorator: horizontal-gradient(#000000FF #00000000); + body.mirrored { + --menu-button-decorator-hover: horizontal-gradient(#FEE685FF #FEE68500); + } + + prelaunch-gradient { + decorator: horizontal-gradient(rgba(var(--color-black-rgb), 0%) rgba(var(--color-black-rgb), 100%)); + } + + body.mirrored prelaunch-gradient { + decorator: horizontal-gradient(rgba(var(--color-black-rgb), 100%) rgba(var(--color-black-rgb), 0%)); } menu { @@ -453,7 +464,7 @@ body.animate-in .intro-item { flex-direction: row; align-items: center; justify-content: space-between; - gap: 16dp; + gap: var(--space-lg); } body.mirrored menu { @@ -466,7 +477,7 @@ body.animate-in .intro-item { flex: 1 1 0; min-width: 0; max-width: 48%; - margin-left: 32dp; + margin-left: var(--space-2xl); } body.mirrored hero { @@ -490,11 +501,6 @@ body.animate-in .intro-item { text-align: right; } - #menu-list button:hover, - #menu-list button:focus-visible { - decorator: horizontal-gradient(#FEE68500 #FEE685FF); - } - body.mirrored #menu-list { align-items: flex-start; } @@ -503,11 +509,6 @@ body.animate-in .intro-item { text-align: left; } - body.mirrored #menu-list button:hover, - body.mirrored #menu-list button:focus-visible { - decorator: horizontal-gradient(#FEE685FF #FEE68500); - } - eyebrow { display: none; } @@ -518,8 +519,8 @@ body.animate-in .intro-item { bottom: 32dp; top: auto; text-align: right; - font-size: 16dp; - gap: 8dp; + font-size: var(--font-size-md); + gap: var(--space-sm); } #disc-status { @@ -527,11 +528,11 @@ body.animate-in .intro-item { } #disc-status icon { - font-size: 20dp; + font-size: var(--font-size-2xl); } #disc-version { - font-size: 16dp; + font-size: var(--font-size-md); } version-info { @@ -540,12 +541,12 @@ body.animate-in .intro-item { bottom: auto; top: 32dp; text-align: right; - font-size: 16dp; - gap: 8dp; + font-size: var(--font-size-md); + gap: var(--space-sm); } - .update { - font-size: 16dp; + update-status { + font-size: var(--font-size-md); } body.mirrored disc-info { diff --git a/res/rml/tabbing.rcss b/res/rml/tabbing.rcss index 8f42dd84d5..862d79a5df 100644 --- a/res/rml/tabbing.rcss +++ b/res/rml/tabbing.rcss @@ -18,17 +18,18 @@ tab-bar scrollbarhorizontal sliderbar { tab-bar tab { flex: 0 0 auto; - padding: 0 24dp; - line-height: 64dp; + padding: 0 var(--space-xl); + line-height: var(--toolbar-height); white-space: nowrap; - decorator: vertical-gradient(#c2a42d00 #c2a42d00); + decorator: vertical-gradient(rgba(var(--color-accent-rgb), 0%) rgba(var(--color-accent-rgb), 0%)); transition: decorator 0.1s linear-in-out, opacity 0.1s linear-in-out; cursor: pointer; } tab-bar tab:selected { opacity: 1; - border-bottom: 4dp #C2A42D; + border-bottom-width: 4dp; + border-bottom-color: var(--color-accent); font-effect: glow(0dp 4dp 0dp 4dp black); } @@ -36,17 +37,17 @@ tab-bar tab:focus-visible, tab-bar tab:hover { opacity: 1; font-effect: glow(0dp 4dp 0dp 4dp black); - decorator: vertical-gradient(#c2a42d00 #c2a42d26); + decorator: vertical-gradient(rgba(var(--color-accent-rgb), 0) rgba(var(--color-accent-rgb), 38)); } tab-bar tab:active { - decorator: vertical-gradient(#c2a42d10 #c2a42d40); + decorator: vertical-gradient(rgba(var(--color-accent-rgb), 16) rgba(var(--color-accent-rgb), 64)); } tab-bar[closable] tab-end-spacer { display: block; - flex: 0 0 64dp; - width: 64dp; + flex: 0 0 var(--toolbar-height); + width: var(--toolbar-height); pointer-events: none; } @@ -56,13 +57,13 @@ window > close { position: fixed; top: 8dp; right: 8dp; - z-index: 1; + z-index: 2; width: 48dp; height: 48dp; - font-family: "Material Symbols Rounded"; + font-family: var(--font-family-icons); font-weight: normal; - font-size: 24dp; - color: rgba(224, 219, 200, 70%); + font-size: var(--font-size-4xl); + color: rgba(var(--color-text-rgb), 70%); backdrop-filter: blur(2dp); border-radius: 6dp; decorator: text("" center center); @@ -74,8 +75,8 @@ tab-bar[closable] close:hover, tab-bar[closable] close:focus-visible, window > close:hover, window > close:focus-visible { - color: #fff; - background-color: rgba(194, 164, 45, 24%); + color: var(--color-white); + background-color: rgba(var(--color-accent-rgb), 24%); } window > close { @@ -85,6 +86,6 @@ window > close { tab-bar[closable] close:active, window > close:active { - color: #fff; - background-color: rgba(194, 164, 45, 40%); + color: var(--color-white); + background-color: rgba(var(--color-accent-rgb), 40%); } diff --git a/res/rml/theme.rcss b/res/rml/theme.rcss new file mode 100644 index 0000000000..8d49f32a9c --- /dev/null +++ b/res/rml/theme.rcss @@ -0,0 +1,80 @@ +*, *:before, *:after { + box-sizing: border-box; +} + +body { + --font-family-body: Fira Sans; + --font-family-heading: Fira Sans Condensed; + --font-family-monospace: Noto Mono; + --font-family-icons: Material Symbols Rounded; + --font-family-display: Alegreya SC; + + --font-size-3xs: 11dp; + --font-size-2xs: 12dp; + --font-size-xs: 13dp; + --font-size-sm: 14dp; + --font-size-base: 15dp; + --font-size-md: 16dp; + --font-size-lg: 17dp; + --font-size-xl: 18dp; + --font-size-2xl: 20dp; + --font-size-3xl: 22dp; + --font-size-4xl: 24dp; + --font-size-5xl: 28dp; + --font-size-6xl: 32dp; + + --space-2xs: 2dp; + --space-xs: 4dp; + --space-sm: 8dp; + --space-md: 12dp; + --space-lg: 16dp; + --space-xl: 24dp; + --space-2xl: 32dp; + + --radius-small: 4dp; + --radius-control: 7dp; + --radius-panel: 8dp; + --radius-window: 14dp; + + --toolbar-height: 64dp; + --toolbar-hidden-offset: -64dp; + + --color-text-rgb: 224, 219, 200; + --color-text: rgb(var(--color-text-rgb)); + --color-accent-rgb: 194, 164, 45; + --color-accent: rgb(var(--color-accent-rgb)); + --color-border-rgb: 146, 135, 91; + --color-border: rgb(var(--color-border-rgb)); + --color-surface-rgb: 21, 22, 16; + --color-interactive-rgb: 204, 184, 119; + --color-control-rgb: 17, 16, 10; + --color-neutral-rgb: 217, 217, 217; + --color-neutral: rgb(var(--color-neutral-rgb)); + --color-white-rgb: 255, 255, 255; + --color-white: rgb(var(--color-white-rgb)); + --color-black-rgb: 0, 0, 0; + --color-black: rgb(var(--color-black-rgb)); + + --color-success-rgb: 68, 204, 85; + --color-success: rgb(var(--color-success-rgb)); + --color-info-rgb: 111, 183, 239; + --color-info: rgb(var(--color-info-rgb)); + --color-warning-rgb: 255, 168, 38; + --color-warning: rgb(var(--color-warning-rgb)); + --color-error-rgb: 204, 68, 68; + --color-error: rgb(var(--color-error-rgb)); + + --color-progress-done: #44AA22; + --color-progress-ongoing: #2255BB; + --color-danger-border: #852221; + --color-danger-heading: #B3261E; + + --button-color: var(--color-text); + --button-background: rgba(var(--color-control-rgb), 20%); + --button-background-hover: rgba(var(--color-interactive-rgb), 20%); + --button-background-selected: rgba(var(--color-interactive-rgb), 40%); + --button-background-active: rgba(var(--color-interactive-rgb), 40%); + + --toast-border-color: var(--color-border); + --toast-heading-color: var(--color-border); +} diff --git a/res/rml/touch_controls.rcss b/res/rml/touch_controls.rcss index 4c8057d6c0..85320315bf 100644 --- a/res/rml/touch_controls.rcss +++ b/res/rml/touch_controls.rcss @@ -1,16 +1,28 @@ -*, *:before, *:after { - box-sizing: border-box; -} - body { + --color-oil-border: rgba(42, 32, 18, 82%); + --color-oil-background: rgba(18, 14, 10, 70%); + --color-oil-fill: rgb(255, 232, 74); + --color-button-a: rgba(34, 112, 123, 62%); + --color-button-b: rgba(161, 61, 66, 58%); + --color-button-x: rgba(83, 115, 151, 56%); + --color-button-y: rgba(113, 91, 150, 54%); + --color-stick-background: rgba(18, 20, 24, 35%); + --color-stick-knob: rgba(238, 236, 226, 55%); + + --button-color: rgba(248, 244, 232, 90%); + --button-background: rgba(22, 24, 28, 48%); + --button-background-active: rgba(63, 78, 90, 68%); + --button-border-color: rgba(var(--color-white-rgb), 22%); + --button-border-color-active: rgba(var(--color-white-rgb), 48%); + width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; - font-family: "Fira Sans Condensed"; + font-family: var(--font-family-heading); font-weight: bold; - color: rgba(248, 244, 232, 90%); + color: var(--button-color); z-index: 1; filter: opacity(0); transition: filter 0.2s linear-in-out; @@ -30,12 +42,11 @@ button { justify-content: center; decorator: none; padding: 0; - border: 1dp rgba(255, 255, 255, 22%); - background-color: rgba(22, 24, 28, 48%); - color: rgba(248, 244, 232, 90%); + border-width: 1dp; + border-color: var(--button-border-color); + background-color: var(--button-background); + color: var(--button-color); text-align: center; - /* backdrop-filter: blur(7dp); */ - /* box-shadow: 0 6dp 18dp rgba(0, 0, 0, 28%); */ transform-origin: center; transition: background-color border-color filter transform 0.08s linear-in-out, opacity 0.2s linear-in-out; @@ -43,8 +54,8 @@ button { button.pressed, button.active { - background-color: rgba(63, 78, 90, 68%); - border-color: rgba(255, 255, 255, 48%); + background-color: var(--button-background-active); + border-color: var(--button-border-color-active); filter: brightness(1.18); } @@ -68,9 +79,9 @@ button icon { button icon glyph { display: block; - font-family: "Material Symbols Rounded"; + font-family: var(--font-family-icons); font-weight: normal; - font-size: 24dp; + font-size: var(--font-size-4xl); line-height: 1; } @@ -92,9 +103,9 @@ button icon glyph { position: absolute; } -.trigger-l.active { - background-color: rgba(57, 116, 133, 74%); - border-color: rgba(128, 222, 234, 72%); +.trigger-l { + --button-background-active: rgba(57, 116, 133, 74%); + --button-border-color-active: rgba(128, 222, 234, 72%); } .trigger, @@ -103,12 +114,14 @@ button icon glyph { } .trigger { - font-size: 22dp; + font-size: var(--font-size-3xl); } .button-z { - background-color: rgba(118, 79, 158, 58%); - border-color: rgba(203, 170, 255, 36%); + --button-background: rgba(118, 79, 158, 58%); + --button-background-active: rgba(139, 91, 187, 82%); + --button-border-color: rgba(203, 170, 255, 36%); + --button-border-color-active: rgba(220, 194, 255, 70%); } .midna-icon { @@ -121,7 +134,7 @@ button icon glyph { .button-z.has-icon span, .face.has-item span { position: absolute; - font-size: 13dp; + font-size: var(--font-size-xs); line-height: 1; } @@ -130,20 +143,14 @@ button icon glyph { bottom: 7dp; } -.button-z.pressed { - background-color: rgba(139, 91, 187, 82%); - border-color: rgba(220, 194, 255, 70%); -} - action-bar { position: absolute; display: flex; align-items: center; - border: 1dp rgba(255, 255, 255, 22%); + border-width: 1dp; + border-color: var(--button-border-color); border-radius: 23dp; - background-color: rgba(22, 24, 28, 48%); - /* backdrop-filter: blur(7dp); */ - /* box-shadow: 0 -6dp 18dp rgba(0, 0, 0, 28%); */ + background-color: var(--button-background); overflow: hidden; opacity: 1; transform-origin: center; @@ -175,7 +182,7 @@ action-bar:hidden separator { } .utility.pressed { - background-color: rgba(63, 78, 90, 68%); + background-color: var(--button-background-active); } .utility.pressed, @@ -185,7 +192,7 @@ action-bar:hidden separator { .skip { z-index: 1; - border-color: rgba(255, 255, 255, 36%); + border-color: rgba(var(--color-white-rgb), 36%); } separator { @@ -193,7 +200,7 @@ separator { flex: 0 0 1dp; width: 1dp; height: 24dp; - background-color: rgba(255, 255, 255, 18%); + background-color: rgba(var(--color-white-rgb), 18%); opacity: 1; transition: opacity 0.2s linear-in-out; } @@ -201,7 +208,7 @@ separator { .face { position: absolute; border-radius: 29dp; - font-size: 24dp; + font-size: var(--font-size-4xl); overflow: visible; } @@ -219,10 +226,10 @@ separator { min-width: 17dp; height: 15dp; padding: 1dp 3dp; - border-radius: 7dp; - background-color: rgba(0, 0, 0, 52%); - color: rgba(255, 255, 255, 92%); - font-size: 12dp; + border-radius: var(--radius-control); + background-color: rgba(var(--color-black-rgb), 52%); + color: rgba(var(--color-white-rgb), 92%); + font-size: var(--font-size-2xs); line-height: 13dp; text-align: center; } @@ -233,11 +240,10 @@ separator { bottom: -5dp; width: 34dp; height: 8dp; - padding: 2dp; - border: 1dp rgba(42, 32, 18, 82%); - border-radius: 4dp; - background-color: rgba(18, 14, 10, 70%); - /* box-shadow: 0 2dp 6dp rgba(0, 0, 0, 35%); */ + padding: var(--space-2xs); + border: 1dp var(--color-oil-border); + border-radius: var(--radius-small); + background-color: var(--color-oil-background); } oil-fill { @@ -245,31 +251,31 @@ oil-fill { width: 0%; height: 100%; border-radius: 2dp; - background-color: rgb(255, 232, 74); + background-color: var(--color-oil-fill); } .face.has-item span { right: 6dp; bottom: 6dp; - color: rgba(255, 255, 255, 88%); + color: rgba(var(--color-white-rgb), 88%); } .face.a { border-radius: 37dp; font-size: 31dp; - background-color: rgba(34, 112, 123, 62%); + background-color: var(--color-button-a); } .face.b { - background-color: rgba(161, 61, 66, 58%); + background-color: var(--color-button-b); } .face.x { - background-color: rgba(83, 115, 151, 56%); + background-color: var(--color-button-x); } .face.y { - background-color: rgba(113, 91, 150, 54%); + background-color: var(--color-button-y); } button.control.docked-top, @@ -306,10 +312,9 @@ touch-stick { width: 124dp; height: 124dp; border-radius: 62dp; - background-color: rgba(18, 20, 24, 35%); - border: 1dp rgba(255, 255, 255, 20%); - /* backdrop-filter: blur(7dp); */ - /* box-shadow: 0 8dp 24dp rgba(0, 0, 0, 24%); */ + background-color: var(--color-stick-background); + border-width: 1dp; + border-color: rgba(var(--color-white-rgb), 20%); opacity: 0; pointer-events: none; transition: opacity 0.18s linear-in-out; @@ -326,7 +331,8 @@ stick-ring { width: 88dp; height: 88dp; border-radius: 44dp; - border: 1dp rgba(255, 255, 255, 18%); + border-width: 1dp; + border-color: rgba(var(--color-white-rgb), 18%); } stick-knob { @@ -334,6 +340,7 @@ stick-knob { width: 48dp; height: 48dp; border-radius: 24dp; - background-color: rgba(238, 236, 226, 55%); - border: 1dp rgba(255, 255, 255, 45%); + background-color: var(--color-stick-knob); + border-width: 1dp; + border-color: rgba(var(--color-white-rgb), 45%); } diff --git a/res/rml/touch_controls_editor.rcss b/res/rml/touch_controls_editor.rcss index 2ca99d935a..dbf0ae74e4 100644 --- a/res/rml/touch_controls_editor.rcss +++ b/res/rml/touch_controls_editor.rcss @@ -1,5 +1,10 @@ body.touch-editor { - background-color: rgba(4, 6, 8, 34%); + --color-editor-backdrop: rgba(4, 6, 8, 34%); + --color-editor-handle: rgba(34, 37, 42, 86%); + --color-editor-accent-rgb: 255, 232, 128; + --color-editor-highlight-rgb: 255, 244, 190; + + background-color: var(--color-editor-backdrop); z-index: 8; } @@ -14,7 +19,7 @@ body.touch-editor .control:hover, body.touch-editor action-bar:hover, body.touch-editor .control.editor-selected, body.touch-editor action-bar.editor-selected { - border-color: rgba(255, 232, 128, 80%); + border-color: rgba(var(--color-editor-accent-rgb), 80%); filter: brightness(1.15); } @@ -27,8 +32,9 @@ selection-frame { display: none; position: absolute; z-index: 20; - border: 2dp rgba(255, 232, 128, 88%); - background-color: rgba(255, 232, 128, 7%); + border-width: 2dp; + border-color: rgba(var(--color-editor-accent-rgb), 88%); + background-color: rgba(var(--color-editor-accent-rgb), 7%); pointer-events: none; } @@ -41,9 +47,10 @@ resize-handle { position: absolute; width: 22dp; height: 22dp; - border: 2dp rgba(255, 244, 190, 96%); + border-width: 2dp; + border-color: rgba(var(--color-editor-highlight-rgb), 96%); border-radius: 11dp; - background-color: rgba(34, 37, 42, 86%); + background-color: var(--color-editor-handle); pointer-events: auto; } @@ -90,6 +97,12 @@ resize-handle.corner.bottom { } editor-toolbar { + --button-background: rgba(17, 19, 24, 88%); + --button-background-hover: rgba(78, 85, 96, 92%); + --button-border-color: rgba(var(--color-white-rgb), 26%); + --button-border-color-hover: rgba(var(--color-editor-highlight-rgb), 92%); + --button-color: rgba(255, 250, 232, 94%); + display: flex; position: absolute; left: 24dp; @@ -98,7 +111,7 @@ editor-toolbar { z-index: 30; height: 48dp; margin-top: -24dp; - gap: 8dp; + gap: var(--space-sm); justify-content: center; pointer-events: auto; } @@ -108,12 +121,13 @@ editor-toolbar button.editor-command { min-width: 96dp; height: 48dp; padding: 0 14dp; - border-radius: 8dp; - border: 1dp rgba(255, 255, 255, 26%); - background-color: rgba(17, 19, 24, 88%); - color: rgba(255, 250, 232, 94%); - font-family: "Fira Sans"; - font-size: 18dp; + border-radius: var(--radius-panel); + border-width: 1dp; + border-color: var(--button-border-color); + background-color: var(--button-background); + color: var(--button-color); + font-family: var(--font-family-body); + font-size: var(--font-size-xl); line-height: 48dp; opacity: 1; cursor: pointer; @@ -127,12 +141,12 @@ editor-toolbar button.editor-command span { } editor-toolbar button.editor-command.primary { - border-color: rgba(255, 232, 128, 70%); - background-color: rgba(96, 82, 38, 90%); + --button-border-color: rgba(var(--color-editor-accent-rgb), 70%); + --button-background: rgba(96, 82, 38, 90%); } editor-toolbar button.editor-command:hover, editor-toolbar button.editor-command:focus-visible { - border-color: rgba(255, 244, 190, 92%); - background-color: rgba(78, 85, 96, 92%); + border-color: var(--button-border-color-hover); + background-color: var(--button-background-hover); } diff --git a/res/rml/tuner.rcss b/res/rml/tuner.rcss index 86dd2043f6..5e0d96e855 100644 --- a/res/rml/tuner.rcss +++ b/res/rml/tuner.rcss @@ -1,90 +1,87 @@ -*, *:before, *:after { - box-sizing: border-box; -} - body { overflow: visible; width: 100%; height: 100%; margin: 0; padding: 0; - font-family: "Fira Sans Condensed"; - font-size: 24dp; - color: #FFFFFF; + font-family: var(--font-family-heading); + font-size: var(--font-size-4xl); + color: var(--color-white); display: flex; flex-direction: column; justify-content: flex-end; align-items: stretch; } -.tuner-root { +tuner-root { width: 100%; min-height: 45%; display: flex; flex-direction: column; justify-content: flex-end; align-items: stretch; - decorator: vertical-gradient(#00000000 #151610F2); + decorator: vertical-gradient(rgba(var(--color-black-rgb), 0%) rgba(var(--color-surface-rgb), 242)); filter: opacity(0); transition: filter 0.2s linear-in-out; } -.tuner-root[open] { +tuner-root[open] { filter: opacity(1); } -.tuner { +graphics-tuner { width: 100%; max-width: 1216dp; margin-left: auto; margin-right: auto; display: flex; flex-direction: column; - gap: 24dp; + gap: var(--space-xl); padding: 48dp 64dp; } @media (max-height: 800dp) { - .tuner-root { + tuner-root { min-height: 38%; } - .tuner { - gap: 16dp; - padding: 32dp 48dp; + graphics-tuner { + gap: var(--space-lg); + padding: var(--space-2xl) 48dp; } } -.header { +tuner-header { display: flex; justify-content: space-between; align-items: center; - gap: 24dp; + gap: var(--space-xl); } -.carousel-container { +carousel-container { flex: 1 1 auto; display: flex; justify-content: flex-end; min-width: 0; } -.description { - font-size: 18dp; +tuner-description { + font-size: var(--font-size-xl); line-height: 22dp; - color: rgba(255, 255, 255, 50%); + color: rgba(var(--color-white-rgb), 50%); } -.divider { +tuner-divider { margin: 1dp 0; - border-top: 1dp rgba(217, 217, 217, 50%); + border-top-width: 1dp; + border-top-color: rgba(var(--color-neutral-rgb), 50%); } -.footer { +tuner-footer { display: flex; justify-content: space-between; align-items: center; - gap: 24dp; + gap: var(--space-xl); } footer-button { @@ -94,12 +91,12 @@ footer-button { border: 0; padding: 0; background-color: transparent; - font-family: "Fira Sans Condensed"; + font-family: var(--font-family-heading); font-weight: bold; - font-size: 20dp; + font-size: var(--font-size-2xl); line-height: 24dp; text-transform: uppercase; - color: #FFFFFF; + color: var(--color-white); opacity: 1; cursor: pointer; } @@ -112,20 +109,20 @@ footer-button.reset { text-align: right; } -.stepped-carousel { +stepped-carousel { display: flex; align-items: center; justify-content: center; - gap: 16dp; + gap: var(--space-lg); width: auto; min-width: 246dp; padding: 0; background-color: transparent; - font-family: "Fira Sans Condensed"; + font-family: var(--font-family-heading); font-weight: bold; } -.stepped-carousel-value { +stepped-carousel-value { line-height: 29dp; min-width: 166dp; text-align: center; @@ -142,6 +139,6 @@ footer-button.reset { background-color: transparent; opacity: 1; cursor: pointer; - font-family: "Material Symbols Rounded"; + font-family: var(--font-family-icons); font-weight: normal; } diff --git a/res/rml/window.rcss b/res/rml/window.rcss index 98b63dfcd9..f2f2574750 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -1,17 +1,13 @@ -*, *:before, *:after { - box-sizing: border-box; -} - body { display: flex; width: 100%; height: 100%; padding: 64dp; - font-family: "Fira Sans"; + font-family: var(--font-family-body); font-weight: normal; font-style: normal; - font-size: 15dp; - color: #E0DBC8; + font-size: var(--font-size-base); + color: var(--color-text); } b { @@ -27,12 +23,13 @@ window { max-width: 1088dp; max-height: 768dp; margin: auto; - border-radius: 14dp; + border-radius: var(--radius-window); overflow: hidden; - border: 2dp #92875B; + border-width: 2dp; + border-color: var(--color-border); backdrop-filter: blur(5dp); box-shadow: 0 0 25dp 5dp; - background-color: rgba(21, 22, 16, 90%); + background-color: rgba(var(--color-surface-rgb), 90%); filter: opacity(0); transform: scale(0.9); transform-origin: center; @@ -61,7 +58,7 @@ window[open] { @media (max-height: 640dp) { body { - padding: 16dp; + padding: var(--space-lg); } window { box-shadow: none; @@ -70,7 +67,7 @@ window[open] { @media (max-width: 768dp) { body { - padding: 16dp; + padding: var(--space-lg); } window.modal { width: 100%; @@ -79,13 +76,14 @@ window[open] { } window tab-bar { - flex: 0 0 64dp; - height: 64dp; - background-color: rgba(217, 217, 217, 10%); - font-family: "Fira Sans Condensed"; + flex: 0 0 var(--toolbar-height); + height: var(--toolbar-height); + background-color: rgba(var(--color-neutral-rgb), 10%); + font-family: var(--font-family-heading); font-weight: bold; - font-size: 18dp; - border-bottom: 2dp #92875B; + font-size: var(--font-size-xl); + border-bottom-width: 2dp; + border-bottom-color: var(--color-border); } window tab-bar tab { @@ -106,14 +104,15 @@ window content pane { flex: 1 1 0; min-width: 0; min-height: 0; - padding: 24dp; - gap: 8dp; + padding: var(--space-xl); + gap: var(--space-sm); overflow: hidden auto; - font-size: 20dp; + font-size: var(--font-size-2xl); } window content pane:not(:last-of-type) { - border-right: 1dp #92875B; + border-right-width: 1dp; + border-right-color: var(--color-border); } window content pane > * { @@ -129,7 +128,7 @@ ui-list { } window content pane > ui-list, -.modal-content pane > ui-list { +modal-content pane > ui-list { flex: 1 1 0; min-width: 0; min-height: 0; @@ -146,7 +145,7 @@ ui-list-viewport { ui-list-content { display: flex; flex-flow: column; - gap: 8dp; + gap: var(--space-sm); min-width: 0; } @@ -156,7 +155,7 @@ ui-list-content > button.ui-list-row { ui-list-empty { display: block; - padding: 16dp; + padding: var(--space-lg); text-align: center; opacity: 0.45; } @@ -176,33 +175,33 @@ window content pane > ui-list:last-child { window content pane > ui-list ui-list-content, window content pane > ui-list ui-list-empty { - padding-left: 24dp; - padding-right: 24dp; + padding-left: var(--space-xl); + padding-right: var(--space-xl); } window content pane > ui-list:first-child ui-list-content, window content pane > ui-list:first-child ui-list-empty { - padding-top: 24dp; + padding-top: var(--space-xl); } window content pane > ui-list:last-child ui-list-content, window content pane > ui-list:last-child ui-list-empty { - padding-bottom: 24dp; + padding-bottom: var(--space-xl); } window content pane:last-of-type > div { line-height: 1.625; } -.data-folder-current { +data-folder-current { display: block; - font-size: 16dp; - color: rgba(224, 219, 200, 65%); + font-size: var(--font-size-md); + color: rgba(var(--color-text-rgb), 65%); } scrollbarvertical { width: 8dp; - margin: 4dp 4dp 4dp 0; + margin: var(--space-xs) var(--space-xs) var(--space-xs) 0; } scrollbarvertical sliderarrowdec, @@ -218,14 +217,14 @@ scrollbarvertical slidertrack { scrollbarvertical sliderbar { width: 8dp; min-height: 24dp; - background-color: rgba(224, 219, 200, 45%); + background-color: rgba(var(--color-text-rgb), 45%); border-radius: 2dp; transition: background-color 0.2s cubic-in-out; } scrollbarvertical sliderbar:hover, scrollbarvertical sliderbar:active { - background-color: rgba(194, 164, 45, 80%); + background-color: rgba(var(--color-accent-rgb), 80%); } scrollbarhorizontal { @@ -244,26 +243,27 @@ scrollbarhorizontal sliderbar { height: 0; } -.section-heading { - font-family: "Fira Sans Condensed"; +section-heading { + font-family: var(--font-family-heading); font-weight: bold; text-transform: uppercase; - font-size: 22dp; + font-size: var(--font-size-3xl); opacity: 0.25; } -.section-heading:not(:first-of-type) { - padding-top: 12dp; +section-heading:not(:first-of-type) { + padding-top: var(--space-md); } button { text-align: center; - background-color: rgba(17, 16, 10, 20%); + color: var(--button-color); + background-color: var(--button-background); opacity: 0.9; - padding: 8dp 16dp; - border-radius: 14dp; - box-shadow: rgba(146, 135, 91, 25%) 0 0 0 1dp; - font-size: 20dp; + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-window); + box-shadow: rgba(var(--color-border-rgb), 25%) 0 0 0 1dp; + font-size: var(--font-size-2xl); transition: background-color 0.1s linear-in-out, opacity 0.1s linear-in-out; cursor: pointer; focus: auto; @@ -271,19 +271,19 @@ button { button:not(:disabled):hover, button:not(:disabled):focus-visible { - background-color: rgba(204, 184, 119, 20%); - box-shadow: #C2A42D 0 0 0 2dp; + background-color: var(--button-background-hover); + box-shadow: var(--color-accent) 0 0 0 2dp; } button:not(:disabled):selected { opacity: 1; - background-color: rgba(204, 184, 119, 40%); + background-color: var(--button-background-selected); } button:not(:disabled):active { opacity: 1; - background-color: rgba(204, 184, 119, 40%); - box-shadow: #C2A42D 0 0 0 2dp; + background-color: var(--button-background-active); + box-shadow: var(--color-accent) 0 0 0 2dp; } button:disabled { @@ -299,12 +299,13 @@ button.modal-btn { select-button { display: flex; align-items: center; - gap: 8dp; - background-color: rgba(17, 16, 10, 20%); + gap: var(--space-sm); + color: var(--button-color); + background-color: var(--button-background); opacity: 0.9; - padding: 8dp 16dp; - border-radius: 14dp; - box-shadow: rgba(146, 135, 91, 25%) 0 0 0 1dp; + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-window); + box-shadow: rgba(var(--color-border-rgb), 25%) 0 0 0 1dp; transition: background-color 0.1s linear-in-out, opacity 0.1s linear-in-out; cursor: pointer; focus: auto; @@ -312,19 +313,19 @@ select-button { select-button:not(:disabled):hover, select-button:not(:disabled):focus-visible { - background-color: rgba(204, 184, 119, 20%); - box-shadow: #C2A42D 0 0 0 2dp; + background-color: var(--button-background-hover); + box-shadow: var(--color-accent) 0 0 0 2dp; } select-button:not(:disabled):selected { opacity: 1; - background-color: rgba(204, 184, 119, 40%); + background-color: var(--button-background-selected); } select-button:not(:disabled):active { opacity: 1; - background-color: rgba(204, 184, 119, 40%); - box-shadow: #C2A42D 0 0 0 2dp; + background-color: var(--button-background-active); + box-shadow: var(--color-accent) 0 0 0 2dp; } select-button:disabled { @@ -333,9 +334,9 @@ select-button:disabled { } select-button key { - font-family: "Fira Sans Condensed"; + font-family: var(--font-family-heading); font-weight: bold; - font-size: 18dp; + font-size: var(--font-size-xl); text-transform: uppercase; flex: 0 1 auto; } @@ -343,7 +344,7 @@ select-button key { select-button value { flex: 1 1 auto; text-align: right; - font-size: 20dp; + font-size: var(--font-size-2xl); } select-button value.modified { @@ -352,7 +353,7 @@ select-button value.modified { select-button input { text-align: right; - font-size: 20dp; + font-size: var(--font-size-2xl); } select-button.group-button icon { @@ -360,8 +361,8 @@ select-button.group-button icon { margin-left: auto; width: 24dp; height: 24dp; - font-size: 24dp; - color: inherit; + font-size: var(--font-size-4xl); + color: var(--button-color); decorator: text("" center center); } @@ -371,8 +372,8 @@ select-button.group-button value { select-button.color-input value { min-width: 0; - font-family: "Noto Mono"; - font-size: 15dp; + font-family: var(--font-family-monospace); + font-size: var(--font-size-base); } select-button.color-input color-swatch { @@ -380,19 +381,19 @@ select-button.color-input color-swatch { flex: 0 0 48dp; width: 48dp; height: 24dp; - border-radius: 7dp; - box-shadow: rgba(255, 255, 255, 45%) 0 0 0 1dp; + border-radius: var(--radius-control); + box-shadow: rgba(var(--color-white-rgb), 45%) 0 0 0 1dp; } select-button.color-input color-swatch.empty { - background-color: rgba(224, 219, 200, 12%); - decorator: linear-gradient(135deg, rgba(224, 219, 200, 0) 45%, rgba(194, 164, 45, 70%) 48%, rgba(194, 164, 45, 70%) 52%, rgba(224, 219, 200, 0) 55%); + background-color: rgba(var(--color-text-rgb), 12%); + decorator: linear-gradient(135deg, rgba(var(--color-text-rgb), 0) 45%, rgba(var(--color-accent-rgb), 70%) 48%, rgba(var(--color-accent-rgb), 70%) 52%, rgba(var(--color-text-rgb), 0) 55%); } icon { width: 1em; height: 1em; - font-family: "Material Symbols Rounded"; + font-family: var(--font-family-icons); font-weight: normal; display: inline-block; vertical-align: middle; @@ -410,6 +411,10 @@ icon.verifying { decorator: text("" center center); } +icon.download { + decorator: text("" center center); +} + icon.celebration { decorator: text("" center center); } @@ -418,73 +423,74 @@ icon.question-mark { decorator: text("" center center); } -.achievement-total { +achievement-total { position: absolute; top: 0; - right: 64dp; - height: 64dp; - line-height: 64dp; - font-family: "Fira Sans Condensed"; + right: var(--toolbar-height); + height: var(--toolbar-height); + line-height: var(--toolbar-height); + font-family: var(--font-family-heading); font-weight: bold; - font-size: 16dp; - color: rgba(224, 219, 200, 55%); + font-size: var(--font-size-md); + color: rgba(var(--color-text-rgb), 55%); pointer-events: none; } -.achievement-row { +achievement-row { display: flex; align-items: flex-start; gap: 10dp; - padding: 12dp 0; - border-bottom: 1dp rgba(146, 135, 91, 30%); + padding: var(--space-md) 0; + border-bottom-width: 1dp; + border-bottom-color: rgba(var(--color-border-rgb), 30%); } -.achievement-info { +achievement-info { display: block; flex: 1 1 0; min-width: 0; } -.achievement-header { +achievement-header { display: flex; align-items: center; } -.achievement-name { +achievement-name { flex: 1; font-weight: bold; } -.achievement-name.unlocked { - color: #ffa826; +achievement-name.unlocked { + color: var(--color-warning); } -.achievement-badge { - font-size: 14dp; +achievement-badge { + font-size: var(--font-size-sm); opacity: 0.7; } -.achievement-badge.unlocked { - color: #44cc55; +achievement-badge.unlocked { + color: var(--color-success); opacity: 1; } -.achievement-badge.locked { - color: #cc4444; +achievement-badge.locked { + color: var(--color-error); opacity: 1; } .achievement-desc { display: block; - color: rgba(224, 219, 200, 55%); - font-size: 16dp; - margin: 4dp 0 0 0; + color: rgba(var(--color-text-rgb), 55%); + font-size: var(--font-size-md); + margin: var(--space-xs) 0 0 0; } -.achievement-progress { +achievement-progress { display: block; - font-size: 13dp; - color: rgba(224, 219, 200, 45%); + font-size: var(--font-size-xs); + color: rgba(var(--color-text-rgb), 45%); } progress { @@ -492,32 +498,32 @@ progress { width: 100%; height: 6dp; border-radius: 3dp; - background-color: rgba(255, 255, 255, 10%); - margin: 6dp 0 2dp 0; + background-color: rgba(var(--color-white-rgb), 10%); + margin: 6dp 0 var(--space-2xs) 0; } progress fill { - background-color: rgba(194, 164, 45, 80%); + background-color: rgba(var(--color-accent-rgb), 80%); border-radius: 3dp; } progress.progress-done fill { - background-color: #44aa22; + background-color: var(--color-progress-done); } progress.progress-ongoing fill { - background-color: #2255bb; + background-color: var(--color-progress-ongoing); } button.achievement-clear { flex: 0 0 auto; align-self: center; - font-size: 14dp; - padding: 2dp 8dp; + font-size: var(--font-size-sm); + padding: var(--space-2xs) var(--space-sm); opacity: 0.45; } -.preset-grid { +preset-grid { display: flex; flex-direction: row; gap: 20dp; @@ -526,24 +532,24 @@ button.achievement-clear { width: 100%; } -.preset-col { +preset-option { display: flex; flex-flow: column; - gap: 12dp; + gap: var(--space-md); flex: 1 1 0; } -.preset-desc { +preset-description { display: block; - font-size: 16dp; + font-size: var(--font-size-md); text-align: center; } -.modal-dialog { +modal-dialog { display: flex; flex-direction: column; align-items: flex-start; - padding: 24dp; + padding: var(--space-xl); gap: 20dp; flex: 0 1 auto; min-height: 0; @@ -553,55 +559,55 @@ button.achievement-clear { } window.modal.danger { - border: 2dp #852221; + border: 2dp var(--color-danger-border); } -.modal-header { +modal-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; width: 100%; flex: 0 0 auto; - gap: 16dp; + gap: var(--space-lg); } -.modal-header icon { - font-size: 24dp; - color: #92875B; +modal-header icon { + font-size: var(--font-size-4xl); + color: var(--color-border); } -.modal-title { +modal-title { display: block; - font-family: "Fira Sans Condensed"; + font-family: var(--font-family-heading); font-weight: bold; text-transform: uppercase; - font-size: 18dp; - color: #92875B; + font-size: var(--font-size-xl); + color: var(--color-border); flex: 1 1 auto; } -window.modal.danger .modal-title, -window.modal.danger .modal-header icon { - color: #B3261E; +window.modal.danger modal-title, +window.modal.danger modal-header icon { + color: var(--color-danger-heading); } -.modal-body { +modal-body { display: block; width: 100%; flex: 0 0 auto; min-width: 0; - font-size: 20dp; - color: #FFFFFF; + font-size: var(--font-size-2xl); + color: var(--color-white); font-weight: normal; } -.modal-body span.tip { - font-size: 14dp; - color: #92875B; +modal-body modal-tip { + font-size: var(--font-size-sm); + color: var(--color-border); } -.modal-content { +modal-content { display: none; width: 100%; flex: 1 1 auto; @@ -609,88 +615,367 @@ window.modal.danger .modal-header icon { overflow: hidden; } -.modal-content.active { +modal-content.active { display: flex; flex-direction: column; } -.modal-content pane { +modal-content pane { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; width: 100%; - gap: 8dp; + gap: var(--space-sm); overflow: hidden auto; } -.modal-content pane > * { +modal-content pane > * { flex: 0 0 auto; } -.verification-progress { +window.modal.install-queue { + max-height: 768dp; +} + +window.modal.drop-install { + max-height: 720dp; +} + +window.modal.drop-install package-row { + padding-right: 0; +} + +window.modal.install-queue modal-body { + display: none; +} + +window.modal.install-queue modal-content pane { + gap: 0; + padding-right: 14dp; + padding-bottom: 6dp; +} + +package-row { + display: flex; + flex-direction: row; + align-items: flex-start; + position: relative; + width: 100%; + gap: 10dp; + padding: var(--space-md) 0; + border-bottom-width: 1dp; + border-bottom-color: rgba(var(--color-border-rgb), 30%); +} + +package-row:last-child { + border-bottom-width: 0; +} + +package-row > mod-icon { + display: none; + flex: 0 0 36dp; + width: 36dp; + height: 36dp; + margin-top: var(--space-2xs); + overflow: hidden; + border-radius: var(--radius-panel); + background-color: rgba(var(--color-control-rgb), 45%); + color: rgba(var(--color-text-rgb), 45%); + font-family: var(--font-family-icons); + font-size: var(--font-size-4xl); + decorator: text("" center center); +} + +package-row > mod-icon.visible { + display: block; +} + +package-row > mod-icon.has-image { + background-color: transparent; +} + +package-row.paused > mod-icon, +package-row.retrying > mod-icon, +package-row.failed > mod-icon { + filter: grayscale(1); + opacity: 0.55; +} + +package-row > section { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-width: 0; + margin: 0; + padding: 0; +} + +package-row > section > header { + display: flex; + width: 100%; + gap: var(--space-sm); + align-items: center; + margin: 0; + padding: 0 0 6dp 0; +} + +package-row h3 { + display: flex; + align-items: baseline; + flex: 1 1 auto; + min-width: 0; + gap: var(--space-xs); + margin: 0; + padding: 0; + overflow: hidden; +} + +package-row h3 > span { + display: block; + flex: 0 1 auto; + min-width: 0; + font-weight: bold; + color: var(--color-white); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +package-row h3 > small { + display: block; + flex: 0 0 auto; + font-size: var(--font-size-xs); + font-weight: normal; + color: rgba(var(--color-text-rgb), 50%); +} + +package-row header > small { + display: block; + flex: 0 0 auto; + font-size: var(--font-size-sm); + color: rgba(var(--color-text-rgb), 60%); +} + +package-row.downloading header > small { + color: var(--color-accent); +} + +package-row.retrying header > small { + color: var(--color-warning); +} + +package-row.failed header > small, +package-row.failed footer > small { + color: var(--color-error); +} + +package-row.installed header > small, +package-row.installed footer > small { + color: var(--color-success); +} + +package-row progress { + width: 100%; + height: 6dp; + margin: 0 0 var(--space-sm) 0; + border-radius: 3dp; +} + +package-row.failed progress fill { + background-color: var(--color-error); +} + +package-row.paused progress fill { + background-color: rgba(var(--color-text-rgb), 35%); +} + +package-row.retrying progress fill { + background-color: rgba(var(--color-warning-rgb), 55%); +} + +package-row.installed progress fill { + background-color: var(--color-success); +} + +package-row footer { + display: flex; + align-items: flex-start; + width: 100%; + min-width: 0; + gap: var(--space-sm); + margin: 0; + padding: 0; +} + +package-row footer > small { + display: block; + flex: 1 1 0; + min-width: 0; + font-size: var(--font-size-xs); + line-height: 1; + color: rgba(var(--color-text-rgb), 45%); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +package-row nav { + display: flex; + flex: 0 0 auto; + gap: 6dp; + margin: 0; + padding: 0; +} + +package-row nav > button.icon-action { + align-items: center; + justify-content: center; + flex: 0 0 30dp; + width: 30dp; + min-width: 30dp; + height: 26dp; + padding: 0; + border-radius: 14dp; + font-size: var(--font-size-md); +} + +package-row nav > button.icon-action icon { + flex: 0 0 var(--font-size-md); + width: var(--font-size-md); + height: var(--font-size-md); + font-size: var(--font-size-md); + line-height: 1; +} + +package-row.installed { + align-items: center; +} + +package-row.installed > mod-icon { + margin-top: 0; +} + +package-row.installed > section { + padding-right: 38dp; +} + +package-row.installed header > small { + display: none; +} + +package-row.installed nav { + position: absolute; + top: 17dp; + right: 0; +} + +package-row.installed nav > button.icon-action { + opacity: 0.45; +} + +verification-progress { display: flex; flex-direction: column; gap: 10dp; width: 100%; } -.verification-file { +verification-file { display: block; - font-size: 17dp; - color: #FFFFFF; + font-size: var(--font-size-lg); + color: var(--color-white); } progress.verification-progress-bar { height: 8dp; - margin: 2dp 0 0 0; + margin: var(--space-2xs) 0 0 0; } -.verification-detail { +verification-detail { display: block; - font-size: 14dp; - color: rgba(224, 219, 200, 65%); + font-size: var(--font-size-sm); + color: rgba(var(--color-text-rgb), 65%); } -.modal-actions { +modal-actions { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: stretch; - gap: 12dp; + gap: var(--space-md); width: 100%; flex: 0 0 auto; - padding-top: 4dp; + padding-top: var(--space-xs); } -.modal-actions-vertical { +modal-actions.vertical { flex-direction: column; align-items: stretch; } -.modal-actions-vertical button.modal-btn { +modal-actions.vertical button.modal-btn { flex: 0 0 auto; width: 100%; } @media (max-height: 640dp) { - .modal-dialog { - padding: 16dp; - gap: 12dp; + modal-dialog { + padding: var(--space-lg); + gap: var(--space-md); } - .modal-body { - font-size: 17dp; + modal-body { + font-size: var(--font-size-lg); } } @media (max-width: 640dp) { - .modal-actions { + modal-actions { flex-direction: column; } - .modal-actions button.modal-btn { + modal-actions button.modal-btn { flex: 0 0 auto; width: 100%; } } + +button.icon-button { + display: flex; + align-items: center; + justify-content: center; + width: 44dp; + height: 44dp; + padding: var(--space-sm); + box-sizing: border-box; + flex-shrink: 0; + font-size: var(--font-size-5xl); +} + +button.icon-button icon { + display: block; + flex-shrink: 0; + line-height: 1; + pointer-events: none; +} + +ui-tooltip { + display: none; + position: absolute; + z-index: 1000; + max-width: 240dp; + padding: 6dp 10dp; + border: 1dp var(--color-border); + border-radius: var(--radius-panel); + background-color: rgba(var(--color-surface-rgb), 96%); + color: var(--color-text); + font-size: var(--font-size-md); + word-break: break-word; + pointer-events: none; + focus: none; +} + +ui-tooltip.visible { + display: block; +} diff --git a/sdk/include/mods/api.h b/sdk/include/mods/api.h index 3fde6c9f6a..3015130ae3 100644 --- a/sdk/include/mods/api.h +++ b/sdk/include/mods/api.h @@ -42,6 +42,8 @@ extern "C" { #define MOD_ABI_VERSION 1u #define MOD_ERROR_MESSAGE_SIZE 512u +#define DUSKLIGHT_SERVICE_ID_PREFIX "dev.twilitrealm.dusklight." + typedef struct ModContext ModContext; typedef enum ModResult { diff --git a/sdk/include/mods/svc/actor.h b/sdk/include/mods/svc/actor.h index 5876ddcaf2..0048b70332 100644 --- a/sdk/include/mods/svc/actor.h +++ b/sdk/include/mods/svc/actor.h @@ -3,7 +3,7 @@ #include #include -#define ACTOR_SERVICE_ID "dev.twilitrealm.dusklight.actor" +#define ACTOR_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "actor" #define ACTOR_SERVICE_MAJOR 1u #define ACTOR_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/camera.h b/sdk/include/mods/svc/camera.h index 1dccecfba6..a901c2b739 100644 --- a/sdk/include/mods/svc/camera.h +++ b/sdk/include/mods/svc/camera.h @@ -6,7 +6,7 @@ #include #endif -#define CAMERA_SERVICE_ID "dev.twilitrealm.dusklight.camera" +#define CAMERA_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "camera" #define CAMERA_SERVICE_MAJOR 1u #define CAMERA_SERVICE_MINOR 1u diff --git a/sdk/include/mods/svc/config.h b/sdk/include/mods/svc/config.h index fb04f6e9ea..55a0bca131 100644 --- a/sdk/include/mods/svc/config.h +++ b/sdk/include/mods/svc/config.h @@ -6,7 +6,7 @@ #include #endif -#define CONFIG_SERVICE_ID "dev.twilitrealm.dusklight.config" +#define CONFIG_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "config" #define CONFIG_SERVICE_MAJOR 1u #define CONFIG_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/file.h b/sdk/include/mods/svc/file.h index 4d5c82b5a1..0383c54066 100644 --- a/sdk/include/mods/svc/file.h +++ b/sdk/include/mods/svc/file.h @@ -6,7 +6,7 @@ #include #endif -#define FILE_SERVICE_ID "dev.twilitrealm.dusklight.file" +#define FILE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "file" #define FILE_SERVICE_MAJOR 1u #define FILE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/flow.h b/sdk/include/mods/svc/flow.h index a9028df0a4..1099d86fd9 100644 --- a/sdk/include/mods/svc/flow.h +++ b/sdk/include/mods/svc/flow.h @@ -6,7 +6,7 @@ #include #endif -#define FLOW_SERVICE_ID "dev.twilitrealm.dusklight.flow" +#define FLOW_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "flow" #define FLOW_SERVICE_MAJOR 1u #define FLOW_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/game.h b/sdk/include/mods/svc/game.h index 32de49903f..61a10968e6 100644 --- a/sdk/include/mods/svc/game.h +++ b/sdk/include/mods/svc/game.h @@ -15,7 +15,7 @@ * ordinary version check then fails mods built against the old epoch with a clear message instead * of letting them corrupt memory. */ -#define GAME_SERVICE_ID "dev.twilitrealm.dusklight.game" +#define GAME_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "game" #define GAME_SERVICE_MAJOR 2u #define GAME_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/game_mode.h b/sdk/include/mods/svc/game_mode.h index 8ccc5d64f1..d17a8f003c 100644 --- a/sdk/include/mods/svc/game_mode.h +++ b/sdk/include/mods/svc/game_mode.h @@ -3,7 +3,7 @@ #include #include -#define GAME_MODE_SERVICE_ID "dev.twilitrealm.dusklight.gamemode" +#define GAME_MODE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "gamemode" #define GAME_MODE_SERVICE_MAJOR 1u #define GAME_MODE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/gfx.h b/sdk/include/mods/svc/gfx.h index 0eee026984..787958716b 100644 --- a/sdk/include/mods/svc/gfx.h +++ b/sdk/include/mods/svc/gfx.h @@ -31,7 +31,7 @@ * should be released in mod_shutdown. The device outlives all mods. */ -#define GFX_SERVICE_ID "dev.twilitrealm.dusklight.gfx" +#define GFX_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "gfx" #define GFX_SERVICE_MAJOR 1u #define GFX_SERVICE_MINOR 2u diff --git a/sdk/include/mods/svc/hook.h b/sdk/include/mods/svc/hook.h index 288db3cc14..30b199b386 100644 --- a/sdk/include/mods/svc/hook.h +++ b/sdk/include/mods/svc/hook.h @@ -20,7 +20,7 @@ * (file-local statics included). */ -#define HOOK_SERVICE_ID "dev.twilitrealm.dusklight.hook" +#define HOOK_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "hook" #define HOOK_SERVICE_MAJOR 1u #define HOOK_SERVICE_MINOR 1u diff --git a/sdk/include/mods/svc/host.h b/sdk/include/mods/svc/host.h index 45d89eea3e..2b751f34c3 100644 --- a/sdk/include/mods/svc/host.h +++ b/sdk/include/mods/svc/host.h @@ -11,7 +11,7 @@ * Always available; every other service can be reached from it. */ -#define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" +#define HOST_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "host" #define HOST_SERVICE_MAJOR 2u #define HOST_SERVICE_MINOR 2u diff --git a/sdk/include/mods/svc/http.h b/sdk/include/mods/svc/http.h index fd4d9a98db..2c880b3b80 100644 --- a/sdk/include/mods/svc/http.h +++ b/sdk/include/mods/svc/http.h @@ -6,7 +6,7 @@ #include #endif -#define HTTP_SERVICE_ID "dev.twilitrealm.dusklight.http" +#define HTTP_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "http" #define HTTP_SERVICE_MAJOR 1u #define HTTP_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/item.h b/sdk/include/mods/svc/item.h index ff4f31b547..58cf7743e4 100644 --- a/sdk/include/mods/svc/item.h +++ b/sdk/include/mods/svc/item.h @@ -6,7 +6,7 @@ #include #endif -#define ITEM_SERVICE_ID "dev.twilitrealm.dusklight.item" +#define ITEM_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "item" #define ITEM_SERVICE_MAJOR 2u #define ITEM_SERVICE_MINOR 3u diff --git a/sdk/include/mods/svc/log.h b/sdk/include/mods/svc/log.h index 1040710492..2401b4dccf 100644 --- a/sdk/include/mods/svc/log.h +++ b/sdk/include/mods/svc/log.h @@ -11,7 +11,7 @@ * (prefixed with its ID). */ -#define LOG_SERVICE_ID "dev.twilitrealm.dusklight.log" +#define LOG_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "log" #define LOG_SERVICE_MAJOR 1u #define LOG_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/message.h b/sdk/include/mods/svc/message.h index b170e6f09b..d3833d904e 100644 --- a/sdk/include/mods/svc/message.h +++ b/sdk/include/mods/svc/message.h @@ -6,7 +6,7 @@ #include #endif -#define MESSAGE_SERVICE_ID "dev.twilitrealm.dusklight.message" +#define MESSAGE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "message" #define MESSAGE_SERVICE_MAJOR 1u #define MESSAGE_SERVICE_MINOR 1u diff --git a/sdk/include/mods/svc/net.h b/sdk/include/mods/svc/net.h index 2c5e9a531a..9202ff50ad 100644 --- a/sdk/include/mods/svc/net.h +++ b/sdk/include/mods/svc/net.h @@ -6,7 +6,7 @@ #include #endif -#define NET_SERVICE_ID "dev.twilitrealm.dusklight.net" +#define NET_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "net" #define NET_SERVICE_MAJOR 1u #define NET_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/overlay.h b/sdk/include/mods/svc/overlay.h index ea46bc8798..ee4318ea4f 100644 --- a/sdk/include/mods/svc/overlay.h +++ b/sdk/include/mods/svc/overlay.h @@ -6,7 +6,7 @@ #include #endif -#define OVERLAY_SERVICE_ID "dev.twilitrealm.dusklight.overlay" +#define OVERLAY_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "overlay" #define OVERLAY_SERVICE_MAJOR 1u #define OVERLAY_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/resource.h b/sdk/include/mods/svc/resource.h index 60b609d2a2..516dcab20a 100644 --- a/sdk/include/mods/svc/resource.h +++ b/sdk/include/mods/svc/resource.h @@ -12,7 +12,7 @@ * for temporary storage. */ -#define RESOURCE_SERVICE_ID "dev.twilitrealm.dusklight.resource" +#define RESOURCE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "resource" #define RESOURCE_SERVICE_MAJOR 1u #define RESOURCE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/save.h b/sdk/include/mods/svc/save.h index 0d6943f96a..d9eb7432be 100644 --- a/sdk/include/mods/svc/save.h +++ b/sdk/include/mods/svc/save.h @@ -6,7 +6,7 @@ #include #endif -#define SAVE_SERVICE_ID "dev.twilitrealm.dusklight.save" +#define SAVE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "save" #define SAVE_SERVICE_MAJOR 1u #define SAVE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/stage.h b/sdk/include/mods/svc/stage.h index a9f8198919..9dbd94cff1 100644 --- a/sdk/include/mods/svc/stage.h +++ b/sdk/include/mods/svc/stage.h @@ -6,7 +6,7 @@ #include #endif -#define STAGE_SERVICE_ID "dev.twilitrealm.dusklight.stage" +#define STAGE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "stage" #define STAGE_SERVICE_MAJOR 1u #define STAGE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/texture.h b/sdk/include/mods/svc/texture.h index dac73fbade..0de813cd04 100644 --- a/sdk/include/mods/svc/texture.h +++ b/sdk/include/mods/svc/texture.h @@ -6,7 +6,7 @@ #include #endif -#define TEXTURE_SERVICE_ID "dev.twilitrealm.dusklight.texture" +#define TEXTURE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "texture" #define TEXTURE_SERVICE_MAJOR 1u #define TEXTURE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/ui.h b/sdk/include/mods/svc/ui.h index d0badfda05..bcac0c53fa 100644 --- a/sdk/include/mods/svc/ui.h +++ b/sdk/include/mods/svc/ui.h @@ -8,7 +8,7 @@ #include #endif -#define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" +#define UI_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "ui" #define UI_SERVICE_MAJOR 2u #define UI_SERVICE_MINOR 2u diff --git a/sdk/include/mods/svc/websocket.h b/sdk/include/mods/svc/websocket.h index bfc12d8fca..abd36410ef 100644 --- a/sdk/include/mods/svc/websocket.h +++ b/sdk/include/mods/svc/websocket.h @@ -7,7 +7,7 @@ #include #endif -#define WEBSOCKET_SERVICE_ID "dev.twilitrealm.dusklight.websocket" +#define WEBSOCKET_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "websocket" #define WEBSOCKET_SERVICE_MAJOR 1u #define WEBSOCKET_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/window.h b/sdk/include/mods/svc/window.h index 7cee8a5abe..843e388ed8 100644 --- a/sdk/include/mods/svc/window.h +++ b/sdk/include/mods/svc/window.h @@ -8,7 +8,7 @@ #include -#define WINDOW_SERVICE_ID "dev.twilitrealm.dusklight.window" +#define WINDOW_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "window" #define WINDOW_SERVICE_MAJOR 1u #define WINDOW_SERVICE_MINOR 0u diff --git a/src/dusk/archive.cpp b/src/dusk/archive.cpp new file mode 100644 index 0000000000..a749d5432b --- /dev/null +++ b/src/dusk/archive.cpp @@ -0,0 +1,145 @@ +#include "archive.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace dusk::archive { +namespace { + +constexpr std::array ZipMagic{'P', 'K', '\x03', '\x04'}; + +PackageFormat detect_package_format(mz_zip_archive& zip) { + size_t modManifests = 0; + for (mz_uint index = 0, count = mz_zip_reader_get_num_files(&zip); index < count; ++index) { + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&zip, index, &stat) || + mz_zip_reader_is_file_a_directory(&zip, index)) + { + continue; + } + const std::string_view name{stat.m_filename}; + modManifests += name == "mod.json"; + } + if (modManifests == 1) { + return PackageFormat::Mod; + } + return PackageFormat::Unknown; +} + +} // namespace + +struct ZipArchive::Impl { + ~Impl() { + if (open) { + mz_zip_reader_end(&zip); + } + } + + static size_t read_zip( + void* opaque, mz_uint64 offset, void* buffer, const size_t size) { + auto& archive = *static_cast(opaque); + std::error_code error; + return archive.file.read_at(offset, {static_cast(buffer), size}, error); + } + + borealis::io::RandomAccessFile file; + mz_zip_archive zip{}; + PackageFormat format = PackageFormat::Unknown; + bool open = false; + std::mutex mutex; +}; + +ZipArchive::ZipArchive(const std::filesystem::path& path) : m_impl{std::make_unique()} { + auto opened = borealis::io::RandomAccessFile::open(path); + if (opened.status != borealis::io::Status::Ok) { + throw std::runtime_error(opened.message); + } + m_impl->file = std::move(opened.file); + + std::array header{}; + std::error_code error; + const auto read = m_impl->file.read_at( + 0, {reinterpret_cast(header.data()), header.size()}, error); + if (error) { + throw std::runtime_error(fmt::format("Reading ZIP magic failed: {}", error.message())); + } + if (read != header.size() || header != ZipMagic) { + throw std::runtime_error("File does not have ZIP magic"); + } + + m_impl->zip.m_pRead = Impl::read_zip; + m_impl->zip.m_pIO_opaque = m_impl.get(); + if (!mz_zip_reader_init(&m_impl->zip, m_impl->file.size(), 0)) { + const auto zipError = mz_zip_get_last_error(&m_impl->zip); + throw std::runtime_error( + fmt::format("Opening ZIP failed: {}", mz_zip_get_error_string(zipError))); + } + m_impl->open = true; + m_impl->format = detect_package_format(m_impl->zip); +} + +ZipArchive::~ZipArchive() = default; + +ZipArchive::ZipArchive(ZipArchive&&) noexcept = default; +ZipArchive& ZipArchive::operator=(ZipArchive&&) noexcept = default; + +PackageFormat ZipArchive::package_format() const noexcept { + return m_impl->format; +} + +std::vector ZipArchive::read_file(const std::string_view name) { + std::lock_guard lock{m_impl->mutex}; + const std::string fileName{name}; + size_t size = 0; + void* extracted = mz_zip_reader_extract_file_to_heap(&m_impl->zip, fileName.c_str(), &size, 0); + if (extracted == nullptr) { + throw std::runtime_error(fmt::format("File does not exist: {}", name)); + } + + const std::unique_ptr owner{extracted, &mz_free}; + const std::span data{static_cast(owner.get()), size}; + std::vector result; + result.assign(data.begin(), data.end()); + return result; +} + +std::vector ZipArchive::file_names() { + std::lock_guard lock{m_impl->mutex}; + std::vector results; + for (mz_uint index = 0, count = mz_zip_reader_get_num_files(&m_impl->zip); index < count; + ++index) + { + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&m_impl->zip, index, &stat) || + mz_zip_reader_is_file_a_directory(&m_impl->zip, index)) + { + continue; + } + results.emplace_back(stat.m_filename); + } + return results; +} + +size_t ZipArchive::file_size(const std::string_view name) { + std::lock_guard lock{m_impl->mutex}; + const std::string fileName{name}; + const auto index = mz_zip_reader_locate_file(&m_impl->zip, fileName.c_str(), nullptr, 0); + if (index < 0) { + throw std::runtime_error(fmt::format("Unable to locate file in ZIP: {}", name)); + } + + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&m_impl->zip, static_cast(index), &stat)) { + throw std::runtime_error(fmt::format("Unable to inspect file in ZIP: {}", name)); + } + return static_cast(stat.m_uncomp_size); +} + +} // namespace dusk::archive diff --git a/src/dusk/archive.hpp b/src/dusk/archive.hpp new file mode 100644 index 0000000000..2e1003d49c --- /dev/null +++ b/src/dusk/archive.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::archive { + +enum class PackageFormat { + Unknown, + Mod, + // Save, +}; + +class ZipArchive { +public: + explicit ZipArchive(const std::filesystem::path& path); + ~ZipArchive(); + + ZipArchive(ZipArchive&&) noexcept; + ZipArchive& operator=(ZipArchive&&) noexcept; + ZipArchive(const ZipArchive&) = delete; + ZipArchive& operator=(const ZipArchive&) = delete; + + PackageFormat package_format() const noexcept; + std::vector read_file(std::string_view name); + std::vector file_names(); + size_t file_size(std::string_view name); + +private: + struct Impl; + std::unique_ptr m_impl; +}; + +} // namespace dusk::archive diff --git a/src/dusk/hash.hpp b/src/dusk/hash.hpp new file mode 100644 index 0000000000..da88aa5547 --- /dev/null +++ b/src/dusk/hash.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include +#include +#include + +namespace dusk::hash { + +class Sha256 { +public: + void update(std::span bytes) { + mHasher.process(bytes.begin(), bytes.end()); + } + + std::string finish() { + mHasher.finish(); + return picosha2::get_hash_hex_string(mHasher); + } + +private: + picosha2::hash256_one_by_one mHasher; +}; + +} // namespace dusk::hash diff --git a/src/dusk/iso_validate.hpp b/src/dusk/iso_validate.hpp index e5a8eac1ef..b7740b636f 100644 --- a/src/dusk/iso_validate.hpp +++ b/src/dusk/iso_validate.hpp @@ -38,7 +38,7 @@ using VerificationStatus = borealis::disc::Progress; struct DiscInfo { Platform platform = Platform::Unknown; Region region = Region::NorthAmerica; - std::uint8_t revision = 0; + uint8_t revision = 0; }; ValidationError inspect(const char* path, DiscInfo& info); diff --git a/src/dusk/mod_loader.hpp b/src/dusk/mod_loader.hpp index d07f0d6f2c..fc419e7367 100644 --- a/src/dusk/mod_loader.hpp +++ b/src/dusk/mod_loader.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace dusk::mods { @@ -83,6 +84,19 @@ struct ModSearchDir { std::filesystem::path nativeLibDir; }; +struct ModOperation { + enum class State : u8 { + Pending, + Succeeded, + Failed, + }; + + State state = State::Pending; + std::string message; +}; + +using ModOperationHandle = std::shared_ptr; + struct ModMetaParsed { uint32_t abiVersion = 0; std::vector imports; @@ -177,6 +191,14 @@ enum class NativeModStatus : u8 { }; struct LoadedMod { + struct FileIdentity { + std::uintmax_t size = 0; + std::filesystem::file_time_type modified{}; + bool valid = false; + + bool operator==(const FileIdentity&) const = default; + }; + ModMetadata metadata; std::filesystem::path modPath; std::filesystem::path dir; @@ -186,8 +208,12 @@ struct LoadedMod { std::string dataDirUtf8; uint32_t searchDirIndex = 0; - // Native lib is dlopen'd in place and stays resident for the session. Reload is unsupported. - bool inPlace = false; + bool fromDirectory = false; + // Native lib is dlopen'd in place. + bool nativeInPlace = false; + bool hasUserPackage = false; + bool hasBundledCopy = false; + FileIdentity fileIdentity; std::unique_ptr> cvarIsEnabled; config::Subscription enabledSubscription = 0; @@ -229,8 +255,15 @@ struct LoadedMod { // Mods this mod imports services from, and mods importing services from this mod. std::vector dependencies; std::vector dependents; + + [[nodiscard]] bool is_enabled() const { + return cvarIsEnabled != nullptr && cvarIsEnabled->getValue(); + } + [[nodiscard]] bool activation_failed() const { return loadFailed || (is_enabled() && !active); } }; +struct PackageCandidate; + class ModLoader { public: static ModLoader& instance(); @@ -243,9 +276,19 @@ public: void request_enable(std::string_view id); void request_disable(std::string_view id); - void request_reload(std::string_view id); + ModOperationHandle request_reload(std::string_view id); + ModOperationHandle request_install(std::filesystem::path path); + ModOperationHandle request_uninstall(std::string_view id); + ModOperationHandle request_reactivate(std::string_view id); void notify_mod_failure(LoadedMod& mod, bool firstFailure); + [[nodiscard]] std::filesystem::path user_mods_dir() const; + [[nodiscard]] bool can_uninstall(const LoadedMod& mod) const; + [[nodiscard]] bool can_update(const LoadedMod& mod) const; + [[nodiscard]] LoadedMod* find_mod(std::string_view id); + [[nodiscard]] const LoadedMod* find_mod(std::string_view id) const; + [[nodiscard]] uint64_t generation() const noexcept { return m_generation; } + [[nodiscard]] auto mods() const { return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; }); } @@ -255,10 +298,30 @@ public: } private: - enum class RequestKind : u8 { Enable, Disable, Reload }; - struct Request { + enum class LifecycleAction : u8 { Enable, Disable, Reactivate }; + struct LifecycleRequest { std::string modId; - RequestKind kind; + LifecycleAction action; + std::shared_ptr operation; + }; + struct InstallRequest { + std::filesystem::path stagedPath; + std::shared_ptr operation; + }; + struct ReloadRequest { + std::string modId; + std::shared_ptr operation; + }; + struct UninstallRequest { + std::string modId; + std::shared_ptr operation; + }; + using Request = std::variant; + + struct OperationResult { + bool success = true; + std::string message; + LoadedMod* mod = nullptr; }; // ModLoader::tick runs inside fapGm_Execute, so code from an unloading mod can still be // live on the stack (its frame unwinds after the tick). dlclose is therefore deferred to @@ -274,10 +337,12 @@ private: std::vector m_pendingRequests; std::vector m_pendingFailures; std::vector m_retiredNatives; + uint64_t m_generation = 0; bool m_initialized = false; bool m_startupComplete = false; - void try_load_mod(const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex); + LoadedMod* try_load_mod(const std::filesystem::path& modPath, bool fromDir, + uint32_t searchDirIndex, std::unique_ptr bundle = {}); void load_native(LoadedMod& mod, const std::string& dllEntry, const std::vector& runtimeEntries); bool load_native_if_present(LoadedMod& mod); @@ -296,20 +361,31 @@ private: [[nodiscard]] std::string describe_missing_import( const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion) const; - LoadedMod* find_mod(std::string_view id) const; void drain_retired_natives(); void apply_pending_requests(); + [[nodiscard]] OperationResult install_staged(const std::filesystem::path& path); + [[nodiscard]] OperationResult load_runtime_mod(const std::filesystem::path& path); + [[nodiscard]] OperationResult reload_runtime_mod( + LoadedMod& mod, const PackageCandidate* replacement = nullptr); + [[nodiscard]] OperationResult uninstall_runtime_mod(LoadedMod& mod); + [[nodiscard]] OperationResult runtime_result(LoadedMod& mod); + void forget_mod(LoadedMod& mod); void flush_toasts(); void on_enabled_changed(LoadedMod& mod); // Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the // bundle from disk, then reactivates whatever the current cvar/provider state allows. - void apply_lifecycle_change(LoadedMod& target, bool reload); + void apply_lifecycle_change( + LoadedMod& target, bool reload, const PackageCandidate* replacement = nullptr); // `target` plus transitive active/suspended dependents, in m_mods (init) order. - std::vector collect_lifecycle_set(LoadedMod& target); + std::vector collect_lifecycle_set(LoadedMod& target) const; + void resume_lifecycle_set(const std::vector& mods); bool reload_bundle(LoadedMod& mod); bool ensure_native_loaded(LoadedMod& mod); }; +bool inspect_mod_bundle(const std::filesystem::path& path, ModMetadata& metadata, + std::string& error, bool* hasNative = nullptr) noexcept; + using ModIndex = std::ranges::range_difference_t().mods())>; } // namespace dusk::mods diff --git a/src/dusk/mods/catalog.cpp b/src/dusk/mods/catalog.cpp new file mode 100644 index 0000000000..4ba118cd5b --- /dev/null +++ b/src/dusk/mods/catalog.cpp @@ -0,0 +1,458 @@ +#include "catalog.hpp" + +#include "dusk/app_info.hpp" +#include "fmt/format.h" +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#endif + +namespace dusk::mods::catalog { +namespace { + +using json = nlohmann::json; +using namespace std::chrono_literals; + +constexpr std::string_view catalogUrl = + "https://staging.twilitrealm.workers.dev/api/v1/games/dusklight/mods"; + +std::string_view sort_value(Sort sort) noexcept { + switch (sort) { + case Sort::Endorsements: + return "endorsements"; + case Sort::Updated: + return "updated"; + case Sort::Newest: + return "newest"; + case Sort::Name: + return "name"; + case Sort::Downloads: + default: + return "downloads"; + } +} + +std::string_view catalog_platform() noexcept { +#if defined(_WIN32) && defined(_M_ARM64) + return "windows-arm64"; +#elif defined(_WIN32) && defined(_M_X64) + return "windows-amd64"; +#elif defined(__ANDROID__) && defined(__aarch64__) + return "android-aarch64"; +#elif defined(__APPLE__) && TARGET_OS_IOS + return "ios-arm64"; +#elif defined(__APPLE__) && !TARGET_OS_TV && defined(__aarch64__) + return "macos-arm64"; +#elif defined(__APPLE__) && !TARGET_OS_TV && defined(__x86_64__) + return "macos-x86_64"; +#elif defined(__linux__) && defined(__aarch64__) + return "linux-aarch64"; +#elif defined(__linux__) && defined(__x86_64__) + return "linux-x86_64"; +#else + // The catalog rejects platforms outside its published package matrix. + return {}; +#endif +} + +std::string url_encode(std::string_view value) { + constexpr char hex[] = "0123456789ABCDEF"; + std::string encoded; + encoded.reserve(value.size()); + for (const unsigned char c : value) { + const bool unreserved = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || + c == '~'; + if (unreserved) { + encoded.push_back(static_cast(c)); + } else { + encoded.push_back('%'); + encoded.push_back(hex[c >> 4]); + encoded.push_back(hex[c & 0x0f]); + } + } + return encoded; +} + +void append_query(std::string& url, std::string_view name, std::string_view value) { + fmt::format_to(std::back_inserter(url), "{}{}={}", + url.find('?') == std::string::npos ? '?' : '&', name, url_encode(value)); +} + +std::string make_url(const Query& query) { + std::string url{catalogUrl}; + if (!query.search.empty()) { + append_query(url, "q", query.search); + } + if (!query.category.empty()) { + append_query(url, "category", query.category); + } + append_query(url, "sort", sort_value(query.sort)); + append_query(url, "page", fmt::format("{}", std::max(query.page, 1))); + if (query.thisDevice) { + const auto platform = catalog_platform(); + if (!platform.empty()) { + append_query(url, "platform", platform); + } + } + return url; +} + +std::string make_detail_url(std::string_view id) { + return fmt::format("{}/{}", catalogUrl, url_encode(id)); +} + +const json& required_field(const json& object, const char* name) { + if (!object.is_object()) { + throw std::runtime_error{"expected an object"}; + } + const auto iter = object.find(name); + if (iter == object.end()) { + throw std::runtime_error{fmt::format("missing field '{}'", name)}; + } + return *iter; +} + +std::string required_string(const json& object, const char* name) { + const auto& value = required_field(object, name); + if (!value.is_string()) { + throw std::runtime_error{fmt::format("field '{}' is not a string", name)}; + } + return value.get(); +} + +bool required_bool(const json& object, const char* name) { + const auto& value = required_field(object, name); + if (!value.is_boolean()) { + throw std::runtime_error{fmt::format("field '{}' is not a boolean", name)}; + } + return value.get(); +} + +uint64_t required_count(const json& object, const char* name) { + const auto& value = required_field(object, name); + if (value.is_number_unsigned()) { + return value.get(); + } + if (value.is_number_integer()) { + const auto count = value.get(); + if (count >= 0) { + return static_cast(count); + } + } + throw std::runtime_error{fmt::format("field '{}' is not a non-negative integer", name)}; +} + +int required_int(const json& object, const char* name) { + const uint64_t value = required_count(object, name); + if (value > static_cast(std::numeric_limits::max())) { + throw std::runtime_error{fmt::format("field '{}' is too large", name)}; + } + return static_cast(value); +} + +std::optional optional_string(const json& object, const char* name) { + const auto& value = required_field(object, name); + if (value.is_null()) { + return std::nullopt; + } + if (!value.is_string()) { + throw std::runtime_error{fmt::format("field '{}' is not a string or null", name)}; + } + return value.get(); +} + +uint16_t required_u16(const json& object, const char* name) { + const auto value = required_count(object, name); + if (value > std::numeric_limits::max()) { + throw std::runtime_error{fmt::format("field '{}' is too large", name)}; + } + return static_cast(value); +} + +Image parse_image(const json& value) { + const auto width = required_count(value, "width"); + const auto height = required_count(value, "height"); + if (width > std::numeric_limits::max() || + height > std::numeric_limits::max()) + { + throw std::runtime_error{"image dimensions are too large"}; + } + Image image{ + .width = static_cast(width), + .height = static_cast(height), + }; + const auto& sources = required_field(value, "sources"); + if (!sources.is_array()) { + throw std::runtime_error{"field 'sources' is not an array"}; + } + image.sources.reserve(sources.size()); + for (const auto& source : sources) { + const auto sourceWidth = required_count(source, "width"); + if (sourceWidth > std::numeric_limits::max()) { + throw std::runtime_error{"image source width is too large"}; + } + image.sources.push_back({ + .width = static_cast(sourceWidth), + .pngUrl = required_string(source, "png_url"), + }); + } + if (image.sources.empty()) { + throw std::runtime_error{"image has no sources"}; + } + return image; +} + +Category parse_category(const json& value) { + return { + .slug = required_string(value, "slug"), + .name = required_string(value, "name"), + .modCount = required_count(value, "mod_count"), + }; +} + +Category parse_mod_category(const json& value) { + return { + .slug = required_string(value, "slug"), + .name = required_string(value, "name"), + }; +} + +Tag parse_tag(const json& value) { + return { + .slug = required_string(value, "slug"), + .name = required_string(value, "name"), + }; +} + +Author parse_author(const json& value) { + return { + .name = required_string(value, "name"), + .handle = required_string(value, "handle"), + .official = required_bool(value, "official"), + }; +} + +Mod parse_mod(const json& value) { + Mod mod{ + .id = required_string(value, "id"), + .name = required_string(value, "name"), + .version = required_string(value, "version"), + .author = parse_author(required_field(value, "author")), + .summary = required_string(value, "summary"), + .downloads = required_count(value, "downloads"), + .endorsements = required_count(value, "endorsements"), + .publishedAt = required_string(value, "published_at"), + .updatedAt = required_string(value, "updated_at"), + .packageSize = required_count(value, "package_size"), + .containsNativeCode = required_bool(value, "contains_native_code"), + }; + + const auto& category = required_field(value, "category"); + if (!category.is_null()) { + mod.category = parse_mod_category(category); + } + + const auto& tags = required_field(value, "tags"); + if (!tags.is_array()) { + throw std::runtime_error{"field 'tags' is not an array"}; + } + mod.tags.reserve(tags.size()); + for (const auto& tag : tags) { + mod.tags.push_back(parse_tag(tag)); + } + + const auto& platforms = required_field(value, "supported_platforms"); + if (!platforms.is_array()) { + throw std::runtime_error{"field 'supported_platforms' is not an array"}; + } + mod.supportedPlatforms.reserve(platforms.size()); + for (const auto& platform : platforms) { + if (!platform.is_string()) { + throw std::runtime_error{"supported platform is not a string"}; + } + mod.supportedPlatforms.push_back(platform.get()); + } + + const auto& icon = required_field(value, "icon"); + if (!icon.is_null()) { + mod.icon = parse_image(icon); + } + const auto& banner = required_field(value, "banner"); + if (!banner.is_null()) { + mod.banner = parse_image(banner); + } + return mod; +} + +Detail parse_detail(std::string_view body) { + const json root = json::parse(body); + Detail detail{ + .mod = parse_mod(root), + .siteUrl = required_string(root, "site_url"), + .sourceUrl = optional_string(root, "source_url"), + .license = optional_string(root, "license"), + .descriptionHtml = required_string(root, "description_html"), + .changelogHtml = required_string(root, "changelog_html"), + }; + + const auto& download = required_field(root, "download"); + if (!download.is_object()) { + throw std::runtime_error{"field 'download' is not an object"}; + } + detail.download = { + .url = required_string(download, "url"), + .sha256 = required_string(download, "sha256"), + .size = required_count(download, "size"), + }; + + const auto& modAbi = required_field(root, "mod_abi"); + if (!modAbi.is_null()) { + const auto value = required_count(root, "mod_abi"); + if (value > std::numeric_limits::max()) { + throw std::runtime_error{"field 'mod_abi' is too large"}; + } + detail.modAbi = static_cast(value); + } + + const auto& screenshots = required_field(root, "screenshots"); + if (!screenshots.is_array()) { + throw std::runtime_error{"field 'screenshots' is not an array"}; + } + detail.screenshots.reserve(screenshots.size()); + for (const auto& screenshot : screenshots) { + detail.screenshots.push_back({ + .altText = required_string(screenshot, "alt_text"), + .image = parse_image(required_field(screenshot, "image")), + }); + } + + const auto& imports = required_field(root, "service_imports"); + if (!imports.is_array()) { + throw std::runtime_error{"field 'service_imports' is not an array"}; + } + detail.serviceImports.reserve(imports.size()); + for (const auto& import : imports) { + detail.serviceImports.push_back({ + .id = required_string(import, "id"), + .major = required_u16(import, "major"), + .minMinor = required_u16(import, "min_minor"), + .optional = required_bool(import, "optional"), + }); + } + return detail; +} + +Page parse_page(std::string_view body) { + const json root = json::parse(body); + const auto& game = required_field(root, "game"); + if (required_string(game, "id") != "dusklight") { + throw std::runtime_error{"catalog response is for a different game"}; + } + + Page page; + const auto& categories = required_field(root, "categories"); + if (!categories.is_array()) { + throw std::runtime_error{"field 'categories' is not an array"}; + } + page.categories.reserve(categories.size()); + for (const auto& category : categories) { + page.categories.push_back(parse_category(category)); + } + + const auto& mods = required_field(root, "mods"); + if (!mods.is_array()) { + throw std::runtime_error{"field 'mods' is not an array"}; + } + page.mods.reserve(mods.size()); + for (const auto& mod : mods) { + page.mods.push_back(parse_mod(mod)); + } + + const auto& pagination = required_field(root, "pagination"); + page.pagination = { + .page = required_int(pagination, "page"), + .pageSize = required_int(pagination, "page_size"), + .pageCount = required_int(pagination, "page_count"), + .total = required_count(pagination, "total"), + }; + return page; +} + +std::string api_error(const borealis::http::Response& response) { + try { + const auto body = json::parse(response.body); + const auto& error = required_field(body, "error"); + return required_string(error, "message"); + } catch (...) { + return fmt::format("The catalog returned HTTP {}.", response.statusCode); + } +} + +FetchResult finish_request(borealis::http::Result result) { + if (result.error != borealis::http::Error::None) { + return {.error = result.message.empty() ? "The catalog request failed." : + std::move(result.message)}; + } + if (result.response.statusCode != 200) { + return {.error = api_error(result.response)}; + } + try { + return {.page = parse_page(result.response.body)}; + } catch (const std::exception& exception) { + return {.error = fmt::format("The catalog response was invalid: {}", exception.what())}; + } catch (...) { + return {.error = "The catalog response was invalid."}; + } +} + +DetailFetchResult finish_detail_request(borealis::http::Result result) { + if (result.error != borealis::http::Error::None) { + return {.error = + result.message.empty() ? "The mod request failed." : std::move(result.message)}; + } + if (result.response.statusCode != 200) { + return {.error = api_error(result.response)}; + } + try { + return {.detail = parse_detail(result.response.body)}; + } catch (const std::exception& exception) { + return {.error = fmt::format("The mod response was invalid: {}", exception.what())}; + } catch (...) { + return {.error = "The mod response was invalid."}; + } +} + +borealis::http::Request make_request(std::string url) { + return { + .url = std::move(url), + .headers = + { + {.name = "User-Agent", .value = borealis::user_agent(dusk::AppInfo)}, + {.name = "Accept", .value = "application/json"}, + }, + .connectTimeout = 10s, + .idleTimeout = 10s, + .totalTimeout = 20s, + }; +} + +} // namespace + +borealis::Task fetch_page(Query query) { + return borealis::http::start(make_request(make_url(query))).map(finish_request); +} + +borealis::Task fetch_detail(std::string id) { + return borealis::http::start(make_request(make_detail_url(id))).map(finish_detail_request); +} + +} // namespace dusk::mods::catalog diff --git a/src/dusk/mods/catalog.hpp b/src/dusk/mods/catalog.hpp new file mode 100644 index 0000000000..5605cb6e9a --- /dev/null +++ b/src/dusk/mods/catalog.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace dusk::mods::catalog { + +enum class Sort { + Downloads, + Endorsements, + Updated, + Newest, + Name, +}; + +struct Query { + std::string search; + std::string category; + Sort sort = Sort::Downloads; + int page = 1; + bool thisDevice = true; +}; + +struct Category { + std::string slug; + std::string name; + uint64_t modCount = 0; +}; + +struct Tag { + std::string slug; + std::string name; +}; + +struct Author { + std::string name; + std::string handle; + bool official = false; +}; + +struct ImageSource { + uint32_t width = 0; + std::string pngUrl; +}; + +struct Image { + uint32_t width = 0; + uint32_t height = 0; + std::vector sources; +}; + +struct Mod { + std::string id; + std::string name; + std::string version; + Author author; + std::string summary; + std::optional category; + std::vector tags; + uint64_t downloads = 0; + uint64_t endorsements = 0; + std::string publishedAt; + std::string updatedAt; + uint64_t packageSize = 0; + bool containsNativeCode = false; + std::vector supportedPlatforms; + std::optional icon; + std::optional banner; +}; + +struct Screenshot { + std::string altText; + Image image; +}; + +struct ServiceImport { + std::string id; + uint16_t major = 0; + uint16_t minMinor = 0; + bool optional = false; +}; + +struct Download { + std::string url; + std::string sha256; + uint64_t size = 0; +}; + +struct Detail { + Mod mod; + std::string siteUrl; + std::optional sourceUrl; + std::optional license; + std::string descriptionHtml; + std::string changelogHtml; + Download download; + std::optional modAbi; + std::vector screenshots; + std::vector serviceImports; +}; + +struct Pagination { + int page = 1; + int pageSize = 0; + int pageCount = 0; + uint64_t total = 0; +}; + +struct Page { + std::vector categories; + std::vector mods; + Pagination pagination; +}; + +struct FetchResult { + std::optional page; + std::string error; +}; + +struct DetailFetchResult { + std::optional detail; + std::string error; +}; + +/** Fetches one filtered page from the Dusklight catalog. */ +borealis::Task fetch_page(Query query); + +/** Fetches the full catalog record for one mod. */ +borealis::Task fetch_detail(std::string id); + +} // namespace dusk::mods::catalog diff --git a/src/dusk/mods/loader/bundle_zip.cpp b/src/dusk/mods/loader/bundle_zip.cpp index 6075dd5dc1..630fa3ae97 100644 --- a/src/dusk/mods/loader/bundle_zip.cpp +++ b/src/dusk/mods/loader/bundle_zip.cpp @@ -1,69 +1,25 @@ #include "loader.hpp" -#include - -#include +#include namespace dusk::mods { -ModBundleZip::ModBundleZip(std::vector&& data) : zip_data(std::move(data)) { - if (!mz_zip_reader_init_mem(&res_zip, zip_data.data(), zip_data.size(), 0)) { - const auto error = mz_zip_get_last_error(&res_zip); - throw std::runtime_error( - fmt::format("Opening zip failed: {}", mz_zip_get_error_string(error))); +ModBundleZip::ModBundleZip(const std::filesystem::path& path) : m_archive{path} { + if (m_archive.package_format() != archive::PackageFormat::Mod) { + throw std::runtime_error("Archive is not a valid mod package"); } } -ModBundleZip::~ModBundleZip() { - mz_zip_reader_end(&res_zip); -} - std::vector ModBundleZip::readFile(const std::string& fileName) { - std::lock_guard lock{m_mutex}; - size_t size; - const auto ptr = mz_zip_reader_extract_file_to_heap(&res_zip, fileName.c_str(), &size, 0); - - if (!ptr) { - throw std::runtime_error(fmt::format("File does not exist: {}", fileName)); - } - - std::span data(static_cast(ptr), size); - std::vector vec(data.begin(), data.end()); - - mz_free(ptr); - - return vec; + return m_archive.read_file(fileName); } std::vector ModBundleZip::getFileNames() { - std::lock_guard lock{m_mutex}; - std::vector results; - - for (mz_uint i = 0, n = mz_zip_reader_get_num_files(&res_zip); i < n; ++i) { - mz_zip_archive_file_stat stat{}; - if (!mz_zip_reader_file_stat(&res_zip, i, &stat)) { - continue; - } - if (mz_zip_reader_is_file_a_directory(&res_zip, i)) { - continue; - } - - results.emplace_back(stat.m_filename); - } - - return results; + return m_archive.file_names(); } size_t ModBundleZip::getFileSize(const std::string& fileName) { - std::lock_guard lock{m_mutex}; - const auto idx = mz_zip_reader_locate_file(&res_zip, fileName.c_str(), nullptr, 0); - if (idx < 0) { - throw std::runtime_error(fmt::format("Unable to locate file in zip: {}", fileName)); - } - - mz_zip_archive_file_stat stat{}; - mz_zip_reader_file_stat(&res_zip, idx, &stat); - return stat.m_uncomp_size; + return m_archive.file_size(fileName); } } // namespace dusk::mods diff --git a/src/dusk/mods/loader/code_patch_macos.cpp b/src/dusk/mods/loader/code_patch_macos.cpp new file mode 100644 index 0000000000..64a7b4a606 --- /dev/null +++ b/src/dusk/mods/loader/code_patch_macos.cpp @@ -0,0 +1,223 @@ +#include "code_patch_macos.hpp" + +#include +#include +#include +#include +#include +#include + +#define PATCH_CODE \ + __attribute__((section("__TEXT,__code_patch,regular,pure_instructions"), noinline)) + +extern const char kPatchBegin[] asm("section$start$__TEXT$__code_patch"); +extern const char kPatchEnd[] asm("section$end$__TEXT$__code_patch"); + +namespace { + +constexpr unsigned kMaxThreads = 1024; +constexpr unsigned kMaxAttempts = 16; +constexpr size_t kMaxPatchSize = 16; +pthread_mutex_t sPatchMutex = PTHREAD_MUTEX_INITIALIZER; + +struct ThreadList { + thread_act_array_t threads = nullptr; + mach_msg_type_number_t count = 0; +}; + +PATCH_CODE void release_threads(ThreadList& list) { + for (unsigned i = 0; i < list.count; ++i) { + mach_port_deallocate(mach_task_self(), list.threads[i]); + } + if (list.threads != nullptr) { + vm_deallocate(mach_task_self(), reinterpret_cast(list.threads), + list.count * sizeof(thread_t)); + } +} + +PATCH_CODE kern_return_t read_pc(thread_t thread, uintptr_t& pc) { +#if defined(__aarch64__) + arm_thread_state64_t state{}; + mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT; + const auto result = thread_get_state( + thread, ARM_THREAD_STATE64, reinterpret_cast(&state), &count); + pc = arm_thread_state64_get_pc(state); +#elif defined(__x86_64__) + x86_thread_state64_t state{}; + mach_msg_type_number_t count = x86_THREAD_STATE64_COUNT; + const auto result = thread_get_state( + thread, x86_THREAD_STATE64, reinterpret_cast(&state), &count); + pc = state.__rip; +#else +#error Unsupported macOS architecture +#endif + return result; +} + +PATCH_CODE __attribute__((aligned(16384))) kern_return_t commit_patch(uintptr_t target, + const unsigned char* expected, const unsigned char* replacement, size_t size, uintptr_t page, + size_t pageSize, thread_t currentThread) { + ThreadList initial; + auto result = task_threads(mach_task_self(), &initial.threads, &initial.count); + if (result != KERN_SUCCESS) { + return result; + } + thread_t suspended[kMaxThreads]; + unsigned suspendedCount = 0; + if (initial.count > kMaxThreads) { + release_threads(initial); + return KERN_RESOURCE_SHORTAGE; + } + + for (unsigned i = 0; i < initial.count; ++i) { + const auto thread = initial.threads[i]; + if (thread == currentThread) { + continue; + } + result = thread_suspend(thread); + if (result != KERN_SUCCESS) { + break; + } + suspended[suspendedCount++] = thread; + uintptr_t pc = 0; + result = read_pc(thread, pc); + if (result != KERN_SUCCESS) { + break; + } + if (pc >= target && pc < target + size) { + result = KERN_ABORTED; + break; + } + } + + if (result == KERN_SUCCESS) { + ThreadList current; + result = task_threads(mach_task_self(), ¤t.threads, ¤t.count); + if (result == KERN_SUCCESS) { + for (unsigned i = 0; i < current.count; ++i) { + bool known = false; + for (unsigned j = 0; j < initial.count; ++j) { + known |= current.threads[i] == initial.threads[j]; + } + if (!known) { + result = KERN_ABORTED; + break; + } + } + } + release_threads(current); + } + + if (result == KERN_SUCCESS) { + const auto* bytes = reinterpret_cast(target); + for (size_t i = 0; i < size; ++i) { + if (bytes[i] != expected[i]) { + result = KERN_INVALID_VALUE; + break; + } + } + } + + if (result == KERN_SUCCESS) { + result = mach_vm_protect( + mach_task_self(), page, pageSize, false, VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY); + if (result == KERN_SUCCESS) { + auto* bytes = reinterpret_cast(target); + for (size_t i = 0; i < size; ++i) { + bytes[i] = replacement[i]; + } + sys_icache_invalidate(reinterpret_cast(target), size); + result = mach_vm_protect( + mach_task_self(), page, pageSize, false, VM_PROT_READ | VM_PROT_EXECUTE); + if (result != KERN_SUCCESS) { + for (size_t i = 0; i < size; ++i) { + bytes[i] = expected[i]; + } + sys_icache_invalidate(reinterpret_cast(target), size); + if (mach_vm_protect(mach_task_self(), page, pageSize, false, + VM_PROT_READ | VM_PROT_EXECUTE) != KERN_SUCCESS) + { + __builtin_trap(); // Can't resume into non-executable code + } + } + } + } + + for (unsigned i = 0; i < suspendedCount; ++i) { + if (thread_resume(suspended[i]) != KERN_SUCCESS) { + __builtin_trap(); + } + } + release_threads(initial); + return result; +} + +} // namespace + +extern "C" int commit_code_patch( + void* targetPointer, const void* expected, const void* replacement, size_t size) { + if (targetPointer == nullptr || expected == nullptr || replacement == nullptr || size == 0 || + size > kMaxPatchSize) + { + return KERN_INVALID_ARGUMENT; + } + const auto target = reinterpret_cast(targetPointer); + const size_t pageSize = vm_page_size; + if (target > UINTPTR_MAX - size - pageSize) { + return KERN_INVALID_ADDRESS; + } + const auto page = target & ~(pageSize - 1); + const size_t length = ((target + size + pageSize - 1) & ~(pageSize - 1)) - page; + if (page < reinterpret_cast(kPatchEnd) && + page + length > reinterpret_cast(kPatchBegin)) + { + return KERN_PROTECTION_FAILURE; + } + + unsigned char oldCode[kMaxPatchSize]; + unsigned char newCode[kMaxPatchSize]; + for (size_t i = 0; i < size; ++i) { + oldCode[i] = static_cast(expected)[i]; + newCode[i] = static_cast(replacement)[i]; + } + + pthread_mutex_lock(&sPatchMutex); + mach_vm_address_t region = page; + mach_vm_size_t regionSize = 0; + vm_region_basic_info_data_64_t info{}; + mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object = MACH_PORT_NULL; + auto result = mach_vm_region(mach_task_self(), ®ion, ®ionSize, VM_REGION_BASIC_INFO_64, + reinterpret_cast(&info), &count, &object); + if (object != MACH_PORT_NULL) { + mach_port_deallocate(mach_task_self(), object); + } + if (result == KERN_SUCCESS && (region > page || regionSize < page + length - region || + info.protection != (VM_PROT_READ | VM_PROT_EXECUTE))) + { + result = KERN_PROTECTION_FAILURE; + } + if (result == KERN_SUCCESS) { + const auto currentThread = mach_thread_self(); + uintptr_t pc = 0; + read_pc(currentThread, pc); + thread_suspend(MACH_PORT_NULL); + thread_resume(MACH_PORT_NULL); + vm_deallocate(mach_task_self(), 0, 0); + mach_port_deallocate(mach_task_self(), MACH_PORT_NULL); + sys_icache_invalidate(targetPointer, size); + result = mach_vm_protect(mach_task_self(), page, length, false, info.protection); + if (result == KERN_SUCCESS) { + for (unsigned attempt = 0; attempt < kMaxAttempts; ++attempt) { + result = commit_patch(target, oldCode, newCode, size, page, length, currentThread); + if (result != KERN_ABORTED || attempt + 1 == kMaxAttempts) { + break; + } + usleep(1000); + } + } + mach_port_deallocate(mach_task_self(), currentThread); + } + pthread_mutex_unlock(&sPatchMutex); + return result; +} diff --git a/src/dusk/mods/loader/code_patch_macos.hpp b/src/dusk/mods/loader/code_patch_macos.hpp new file mode 100644 index 0000000000..540e806404 --- /dev/null +++ b/src/dusk/mods/loader/code_patch_macos.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +int commit_code_patch(void* target, const void* expected, const void* replacement, size_t size); + +#ifdef __cplusplus +} +#endif diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 71b17b7cff..16c49f8c45 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -1,536 +1,80 @@ #include "loader.hpp" -#include "../manifest.hpp" #include "depgraph.hpp" +#include "manifest.hpp" #include "native_module.hpp" +#include "natives.hpp" +#include "packages.hpp" #if DUSK_HAS_PREPATCH #include "prepatch.hpp" #endif #include "dusk/config.hpp" #include "dusk/data.hpp" -#include "dusk/io.hpp" #include "dusk/logging.h" #include "dusk/mod_loader.hpp" #include "dusk/mods/log_buffer.hpp" +#include "dusk/mods/manifest.hpp" +#include "dusk/mods/path.hpp" +#include "dusk/mods/queue.hpp" #include "dusk/mods/svc/config.hpp" #include "dusk/mods/svc/hook.hpp" #include "dusk/mods/svc/registry.hpp" +#include "dusk/ui/mod_texture_provider.hpp" #include "dusk/ui/mods_window.hpp" #include "dusk/ui/ui.hpp" #include -#include -#include +#include +#include #include -#include #include #include -#include #include #include #include #include -using namespace std::string_literals; -using namespace std::string_view_literals; - -#if defined(_WIN32) -#if defined(_M_ARM64) -static constexpr std::string_view k_nativePlatform = "windows-arm64"sv; -#elif defined(_M_X64) -static constexpr std::string_view k_nativePlatform = "windows-amd64"sv; -#elif defined(_M_IX86) -static constexpr std::string_view k_nativePlatform = "windows-x86"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -#endif -static constexpr std::string_view k_nativeLibName = "mod.dll"sv; -#elif defined(__ANDROID__) -#if defined(__aarch64__) -static constexpr std::string_view k_nativePlatform = "android-aarch64"sv; -#elif defined(__x86_64__) -static constexpr std::string_view k_nativePlatform = "android-x86_64"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -#endif -static constexpr std::string_view k_nativeLibName = "mod.so"sv; -#elif defined(__APPLE__) -#include -#if TARGET_OS_IOS -static constexpr std::string_view k_nativePlatform = "ios-arm64"sv; -#elif TARGET_OS_TV -static constexpr std::string_view k_nativePlatform = "tvos-arm64"sv; -#elif defined(__aarch64__) -static constexpr std::string_view k_nativePlatform = "macos-arm64"sv; -#elif defined(__x86_64__) -static constexpr std::string_view k_nativePlatform = "macos-x86_64"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -#endif -static constexpr std::string_view k_nativeLibName = "mod.so"sv; -#elif defined(__linux__) -#if defined(__aarch64__) -static constexpr std::string_view k_nativePlatform = "linux-aarch64"sv; -#elif defined(__x86_64__) -static constexpr std::string_view k_nativePlatform = "linux-x86_64"sv; -#elif defined(__i386__) -static constexpr std::string_view k_nativePlatform = "linux-x86"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -#endif -static constexpr std::string_view k_nativeLibName = "mod.so"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -static constexpr std::string_view k_nativeLibName = ""sv; -#endif +namespace fs = std::filesystem; namespace dusk::mods { namespace { constexpr borealis::Log Log{"dusk::mods::loader"}; ModLoader g_modLoader; -constexpr std::string_view k_nativeLibDir = "lib/"sv; -class DirectoryRollback { -public: - ~DirectoryRollback() { - if (!mPath.empty()) { - std::error_code ec; - std::filesystem::remove_all(mPath, ec); - } - } - - void set_path(std::filesystem::path path) { mPath = std::move(path); } - void release() { mPath.clear(); } - -private: - std::filesystem::path mPath; -}; - -std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir) { - if (fromDir) { - return std::make_unique(modPath); - } else { - std::vector data = io::FileStream::ReadAllBytes(modPath); - return std::make_unique(std::move(data)); +void complete_operation(const std::shared_ptr& operation, const bool success = true, + std::string message = {}) { + if (operation == nullptr) { + return; } + operation->state = success ? ModOperation::State::Succeeded : ModOperation::State::Failed; + operation->message = std::move(message); } -struct NativeRuntimeLocation { - std::string entry; - std::vector runtimeEntries; - bool anyLibs = false; -}; - -struct NativeLocateFailure { - NativeModStatus status; - std::string logMessage; -}; - -using NativeLocateResult = std::variant; - -bool has_native_library_extension(std::string_view name) { - const auto endsWith = [name](std::string_view extension) { - if (name.size() < extension.size()) { - return false; - } - const auto suffix = name.substr(name.size() - extension.size()); - return std::ranges::equal(suffix, extension, [](char lhs, char rhs) { - const auto lower = [](char value) { - return value >= 'A' && value <= 'Z' ? static_cast(value + ('a' - 'A')) : - value; - }; - return lower(lhs) == lower(rhs); - }); - }; - return endsWith(".dll"sv) || endsWith(".so"sv) || endsWith(".dylib"sv); -} - -NativeLocateResult locate_native_runtime(ModBundle& bundle) { - NativeRuntimeLocation result; - const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); - const std::string nativeEntry = platformPrefix + std::string{k_nativeLibName}; - for (const auto& name : bundle.getFileNames()) { - if (name.find('/') == std::string::npos && has_native_library_extension(name)) { - return NativeLocateFailure{ - NativeModStatus::InvalidBundle, - fmt::format( - "native library '{}' found at the root (natives go in /lib/{{platform}})", - name), - }; - } - if (!name.starts_with(k_nativeLibDir)) { - continue; - } - - const std::string_view libPath{ - name.data() + k_nativeLibDir.size(), name.size() - k_nativeLibDir.size()}; - const auto platformEnd = libPath.find('/'); - if (platformEnd != std::string_view::npos) { - const auto entryName = libPath.substr(platformEnd + 1); - if (entryName.find('/') == std::string_view::npos && - (entryName == "mod.dll"sv || entryName == "mod.so"sv)) - { - result.anyLibs = true; - } - } - - if (!k_nativePlatform.empty() && name.starts_with(platformPrefix)) { - const std::string_view relativeName{ - name.data() + platformPrefix.size(), name.size() - platformPrefix.size()}; - if (!is_safe_resource_path(relativeName)) { - continue; - } - result.runtimeEntries.push_back(name); - } - if (name == nativeEntry) { - result.entry = name; - } +LoadedMod::FileIdentity file_identity(const fs::path& path) { + std::error_code error; + const bool isDirectory = fs::is_directory(path, error); + if (error) { + return {}; } - std::ranges::sort(result.runtimeEntries); - result.runtimeEntries.erase( - std::unique(result.runtimeEntries.begin(), result.runtimeEntries.end()), - result.runtimeEntries.end()); - return result; + const auto size = isDirectory ? 0 : fs::file_size(path, error); + if (error) { + return {}; + } + const auto modified = fs::last_write_time(path, error); + if (error) { + return {}; + } + return {.size = size, .modified = modified, .valid = true}; } + } // namespace ModLoader& ModLoader::instance() { return g_modLoader; } -class InvalidModDataException : public std::runtime_error { -public: - explicit InvalidModDataException(const std::string& msg) : runtime_error(msg) {} - explicit InvalidModDataException(const char* msg) : runtime_error(msg) {} -}; - -static void validate_mod_id(std::string_view const str) { - if (str.empty()) { - throw InvalidModDataException("Missing ID value in mod metadata!"); - } - - bool lastWasPeriod = false; - for (auto const chr : str) { - if (chr == '.') { - if (lastWasPeriod) { - throw InvalidModDataException("Cannot have two consecutive periods in mod ID!"); - } - lastWasPeriod = true; - continue; - } - - lastWasPeriod = false; - - if (chr == '_') - continue; - - if (chr >= '0' && chr <= '9') - continue; - - if (chr >= 'a' && chr <= 'z') - continue; - - if (chr >= 'A' && chr <= 'Z') - continue; - - throw InvalidModDataException( - fmt::format("Invalid character '{}' in mod ID. Valid characters are period, " - "underscore, and alphanumerics.", - chr)); - } -} - -static bool bundle_has_file(ModBundle& bundle, const std::string& path) { - try { - bundle.getFileSize(path); - return true; - } catch (const std::runtime_error&) { - return false; - } -} - -static std::string resolve_image_path(ModBundle& bundle, const std::string& modId, - std::string_view key, const std::string& manifestPath, const std::string& defaultPath) { - if (!manifestPath.empty()) { - if (!is_safe_resource_path(manifestPath)) { - log::write( - modId, LOG_LEVEL_WARN, "invalid {} path '{}' in mod.json", key, manifestPath); - } else if (!bundle_has_file(bundle, manifestPath)) { - log::write( - modId, LOG_LEVEL_WARN, "{} path '{}' not found in bundle", key, manifestPath); - } else { - return manifestPath; - } - } - if (bundle_has_file(bundle, defaultPath)) { - return defaultPath; - } - return {}; -} - -struct LoadedManifest { - ModMetadata metadata; - std::optional runtime; -}; - -static uint16_t parse_runtime_version_component(std::string_view text, std::string_view fieldName) { - uint32_t value = 0; - const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); - if (text.empty() || error != std::errc{} || end != text.data() + text.size() || - value > UINT16_MAX) - { - throw InvalidModDataException(fmt::format("Invalid {} in runtime version pin", fieldName)); - } - return static_cast(value); -} - -static std::optional parse_runtime(const nlohmann::json& manifest) { - const auto field = manifest.find("runtime"); - if (field == manifest.end()) { - return std::nullopt; - } - if (!field->is_string()) { - throw InvalidModDataException("runtime must be a string"); - } - - const std::string pin = field->get(); - const auto at = pin.rfind('@'); - if (at == std::string::npos || at == 0 || at + 1 == pin.size() || pin.find('@') != at || - at >= MOD_META_SERVICE_ID_SIZE) - { - throw InvalidModDataException( - "runtime must be a service id followed by @major or @major.minor"); - } - - const std::string_view version{pin.data() + at + 1, pin.size() - at - 1}; - const auto dot = version.find('.'); - if (dot != std::string_view::npos && version.find('.', dot + 1) != std::string_view::npos) { - throw InvalidModDataException("runtime version pin has too many components"); - } - - DelegatedModRuntime result; - result.id = pin.substr(0, at); - result.major = parse_runtime_version_component( - dot == std::string_view::npos ? version : version.substr(0, dot), "major version"); - if (dot != std::string_view::npos) { - result.minMinor = parse_runtime_version_component(version.substr(dot + 1), "minor version"); - } - return result; -} - -static LoadedManifest load_manifest(const std::filesystem::path& modPath, ModBundle& bundle) { - const auto metaJson = bundle.readFile("mod.json"); - auto j = nlohmann::json::parse(metaJson); - - std::string metaId = j.value("id", ""); - std::string metaName = j.value("name", ""); - std::string metaVersion = j.value("version", ""); - std::string metaAuthor = j.value("author", ""); - std::string metaDescription = j.value("description", ""); - std::string metaIcon = j.value("icon", ""); - std::string metaBanner = j.value("banner", ""); - - validate_mod_id(metaId); - - if (metaName.empty()) { - metaName = borealis::io::fs_path_to_string(modPath.stem()); - } - if (metaVersion.empty()) { - metaVersion = "?"s; - } - if (metaAuthor.empty()) { - metaAuthor = "unknown"s; - } - - std::string iconPath = resolve_image_path(bundle, metaId, "icon", metaIcon, "res/icon.png"s); - std::string bannerPath = - resolve_image_path(bundle, metaId, "banner", metaBanner, "res/banner.png"s); - - return LoadedManifest{ - .metadata = - { - std::move(metaId), - std::move(metaName), - std::move(metaVersion), - std::move(metaAuthor), - std::move(metaDescription), - std::move(iconPath), - std::move(bannerPath), - }, - .runtime = parse_runtime(j), - }; -} - -// True if the first `capacity` bytes of `str` contain a NUL. -static bool terminated_within(const char* str, size_t capacity) { - return std::memchr(str, '\0', capacity) != nullptr; -} - -static bool parse_meta(NativeMod& native, LoadedMod& mod) { - const ModMeta* meta = native.meta; - if (meta->struct_size < sizeof(ModMeta)) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta descriptor has invalid size {}", - meta->struct_size); - mod.nativeStatus = NativeModStatus::InvalidMetadata; - return false; - } - const auto* cursor = static_cast(meta->records_begin); - const auto* end = static_cast(meta->records_end); - if (cursor == nullptr || end == nullptr || cursor > end || - (reinterpret_cast(cursor) & 7) != 0) - { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta section bounds are invalid"); - mod.nativeStatus = NativeModStatus::InvalidMetadata; - return false; - } - - ModMetaParsed parsed; - size_t headerCount = 0; - const auto invalid = [&](std::string_view why) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "invalid metadata record at offset {}: {}", - cursor - static_cast(meta->records_begin), why); - mod.nativeStatus = NativeModStatus::InvalidMetadata; - return false; - }; - - while (cursor < end) { - if (end - cursor < 8) { - return invalid("trailing bytes"); - } - uint64_t first = 0; - std::memcpy(&first, cursor, sizeof(first)); - if (first == 0) { // linker padding / bounds sentinel - cursor += 8; - continue; - } - - const auto* rec = reinterpret_cast(cursor); - const size_t size = rec->size; - if (size < 8 || size % 8 != 0 || size > static_cast(end - cursor)) { - return invalid("bad record size"); - } - - switch (rec->kind) { - case MOD_META_PAD: - break; - case MOD_META_HEADER: { - if (size < sizeof(ModMetaHeader)) { - return invalid("truncated header record"); - } - const auto* header = reinterpret_cast(rec); - ++headerCount; - parsed.abiVersion = header->abi_version; - break; - } - case MOD_META_IMPORT: { - if (size < sizeof(ModMetaImport)) { - return invalid("truncated import record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { - return invalid("unterminated import service id"); - } - parsed.imports.push_back(record); - break; - } - case MOD_META_EXPORT: { - if (size < sizeof(ModMetaExport)) { - return invalid("truncated export record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { - return invalid("unterminated export service id"); - } - parsed.exports.push_back(record); - break; - } - case MOD_META_HOOK_FN: { - if (size < sizeof(ModMetaHookFn)) { - return invalid("truncated hook record"); - } - parsed.hookFns.push_back( - reinterpret_cast(const_cast(cursor))); - break; - } - case MOD_META_HOOK_MEM: { - if (size <= sizeof(ModMetaHookMem)) { - return invalid("truncated hook record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMem); - const size_t capacity = size - sizeof(ModMetaHookMem); - if (!terminated_within(strings, capacity)) { - return invalid("unterminated hook vtable symbol"); - } - const size_t vtableLen = std::char_traits::length(strings); - if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { - return invalid("unterminated hook display name"); - } - parsed.hookMems.push_back(record); - break; - } - case MOD_META_HOOK_MEM_EXT: { - if (size <= sizeof(ModMetaHookMemExt)) { - return invalid("truncated extended hook record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - if (record->pmf_size <= MOD_META_HOOK_MEM_CAPACITY || - record->pmf_size > MOD_META_HOOK_MEM_EXT_CAPACITY || record->materialize == nullptr) - { - return invalid("bad extended hook member-pointer size"); - } - const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMemExt); - const size_t capacity = size - sizeof(ModMetaHookMemExt); - if (!terminated_within(strings, capacity)) { - return invalid("unterminated extended hook vtable symbol"); - } - const size_t vtableLen = std::char_traits::length(strings); - if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { - return invalid("unterminated extended hook display name"); - } - parsed.hookMemExts.push_back(record); - break; - } - case MOD_META_HOOK_NAME: { - if (size <= sizeof(ModMetaHookName)) { - return invalid("truncated hook record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - const char* name = reinterpret_cast(cursor) + sizeof(ModMetaHookName); - if (!terminated_within(name, size - sizeof(ModMetaHookName))) { - return invalid("unterminated hook symbol name"); - } - parsed.hookNames.push_back(record); - break; - } - default: - // Additive record kinds may appear within a format version; skip them. - log::write(mod.metadata.id, LOG_LEVEL_DEBUG, "skipping unknown metadata record kind {}", - rec->kind); - break; - } - cursor += size; - } - - if (headerCount != 1) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expected 1 metadata header record, found {}", - headerCount); - mod.nativeStatus = NativeModStatus::InvalidMetadata; - return false; - } - if (parsed.abiVersion != MOD_ABI_VERSION) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", - parsed.abiVersion, MOD_ABI_VERSION); - mod.nativeStatus = NativeModStatus::ApiVersionMismatch; - return false; - } - - native.parsed = std::move(parsed); - return true; -} - static std::string lifecycle_error_message( const char* fnName, const ModResult result, const ModError& error) { if (error.message[0] != '\0') { @@ -539,269 +83,6 @@ static std::string lifecycle_error_message( return fmt::format("{} failed with result {}", fnName, static_cast(result)); } -static std::string native_status_message(const NativeModStatus status) { - switch (status) { - case NativeModStatus::BuildDisabled: - return "Code mods are disabled on this Dusklight build"; - case NativeModStatus::ModMissingPlatform: - return fmt::format("Mod not supported on this platform ({})", k_nativePlatform); - case NativeModStatus::ApiVersionMismatch: - // TODO: differentiate whether mod or Dusklight is out of date - return "Mod ABI version mismatch"; - case NativeModStatus::MissingExport: - return "Missing required mod API exports"; - case NativeModStatus::InvalidMetadata: - return "Invalid mod metadata records"; - case NativeModStatus::InvalidBundle: - return "Invalid mod bundle layout (old mod?)"; - case NativeModStatus::Unknown: - return "Unknown mod load failure"; - case NativeModStatus::None: - case NativeModStatus::Loaded: - break; - } - return "native mod failed to load"; -} - -std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { - namespace fs = std::filesystem; - if (k_nativeLibName.empty()) { - return {}; - } - const auto& libDir = m_searchDirs[mod.searchDirIndex].nativeLibDir; - if (libDir.empty()) { - return {}; - } - fs::path path = libDir / fs::path(mod.metadata.id + borealis::io::fs_path_to_string( - fs::path(k_nativeLibName).extension())); - std::error_code ec; - if (!fs::is_regular_file(path, ec)) { - return {}; - } - return path; -} - -void ModLoader::load_native( - LoadedMod& mod, const std::string& dllEntry, const std::vector& runtimeEntries) { - if (!EnableCodeMods) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); - mod.nativeStatus = NativeModStatus::BuildDisabled; - return; - } - - namespace fs = std::filesystem; - - const fs::path cacheDir = m_cacheDir / mod.metadata.id; - const fs::path scratchDir = cacheDir / "data"; - std::error_code ec; - fs::create_directories(scratchDir, ec); - if (ec) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to create mod directory {}: {}", - data::abbreviated_path_string(scratchDir), ec.message()); - return; - } - mod.dir = fs::absolute(scratchDir); - mod.dirUtf8 = borealis::io::fs_path_to_string(mod.dir); - - fs::path libPath; - fs::path runtimeDir; - DirectoryRollback runtimeDirRollback; - if (mod.inPlace) { - if (!dllEntry.empty()) { - libPath = mod.modPath / dllEntry; - } else if (auto external = external_native_lib_path(mod); !external.empty()) { - libPath = std::move(external); - } else { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "no native library named {} found; skipping", k_nativeLibName); - mod.nativeStatus = NativeModStatus::ModMissingPlatform; - return; - } - runtimeDir = libPath.parent_path(); - } else { - if (dllEntry.empty()) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "no native library named {} found; skipping", k_nativeLibName); - mod.nativeStatus = NativeModStatus::ModMissingPlatform; - return; - } - - // Every generation gets a new directory. The main module and all of its runtime - // libraries therefore have fresh paths and can coexist with a previous generation - // that is still unwinding after a reload. - runtimeDir = cacheDir / fmt::format("g{}", ++mod.cacheGeneration); - runtimeDirRollback.set_path(runtimeDir); - fs::create_directories(runtimeDir, ec); - if (ec) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "failed to create native runtime directory {}: {}", - data::abbreviated_path_string(runtimeDir), ec.message()); - return; - } - - const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); - for (const auto& entry : runtimeEntries) { - if (!entry.starts_with(platformPrefix)) { - continue; - } - const std::string_view relativeName{ - entry.data() + platformPrefix.size(), entry.size() - platformPrefix.size()}; - if (!is_safe_resource_path(relativeName)) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "unsafe native runtime path '{}'; skipping", entry); - return; - } - - const fs::path outputPath = runtimeDir / fs::path{relativeName}; - fs::create_directories(outputPath.parent_path(), ec); - if (ec) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "failed to create directory for {}: {}", entry, ec.message()); - return; - } - - std::vector data; - try { - data = mod.bundle->readFile(entry); - } catch (const std::exception& e) { - log::write( - mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}: {}", entry, e.what()); - return; - } - - std::ofstream out(outputPath, std::ios::binary | std::ios::out); - if (!out) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); - return; - } - out.write(reinterpret_cast(data.data()), - static_cast(data.size())); - if (!out) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); - return; - } - } - - libPath = runtimeDir / fs::path{dllEntry}.filename(); - } - - auto nativeMod = std::make_unique(); - try { - nativeMod->handle = std::make_unique(libPath); - } catch (const std::runtime_error& e) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to open {}: {}", - data::abbreviated_path_string(libPath), e.what()); - return; - } - - nativeMod->meta = nativeMod->handle->LookupSymbol("mod_meta"); - nativeMod->contextSymbol = nativeMod->handle->LookupSymbol("mod_ctx"); - nativeMod->fn_initialize = nativeMod->handle->LookupSymbol("mod_initialize"); - nativeMod->fn_update = nativeMod->handle->LookupSymbol("mod_update"); - nativeMod->fn_shutdown = nativeMod->handle->LookupSymbol("mod_shutdown"); - - if (!nativeMod->meta || !nativeMod->contextSymbol || !nativeMod->fn_initialize || - !nativeMod->fn_update || !nativeMod->fn_shutdown) - { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "{} missing required mod API exports; skipping", - data::abbreviated_path_string(libPath)); - mod.nativeStatus = NativeModStatus::MissingExport; - return; - } - - if (!parse_meta(*nativeMod, mod)) { - return; - } - - if (nativeMod->contextSymbol == nullptr) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "missing required mod_ctx export"); - mod.nativeStatus = NativeModStatus::MissingExport; - return; - } - *nativeMod->contextSymbol = mod.context.get(); - - mod.nativePath = fs::absolute(libPath); - mod.nativeDir = fs::absolute(runtimeDir); - mod.nativeDirUtf8 = borealis::io::fs_path_to_string(mod.nativeDir); - mod.native = std::move(nativeMod); - mod.nativeStatus = NativeModStatus::Loaded; - runtimeDirRollback.release(); -} - -bool ModLoader::load_native_if_present(LoadedMod& mod) { - const auto result = locate_native_runtime(*mod.bundle); - if (const auto* failure = std::get_if(&result)) { - mod.nativeStatus = failure->status; - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "{}", failure->logMessage); - fail_mod(mod, MOD_ERROR, native_status_message(failure->status)); - return false; - } - - const auto& native = std::get(result); - if (mod.runtime.has_value() && - (native.anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty()))) - { - mod.nativeStatus = NativeModStatus::InvalidBundle; - fail_mod(mod, MOD_CONFLICT, "A mod cannot declare both runtime and native code"); - return false; - } - if (!native.anyLibs && !(mod.inPlace && !external_native_lib_path(mod).empty())) { - mod.nativeStatus = NativeModStatus::None; - return true; - } - - mod.nativeStatus = NativeModStatus::Unknown; - load_native(mod, native.entry, native.runtimeEntries); - if (mod.nativeStatus != NativeModStatus::Loaded) { - fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); - return false; - } - return true; -} - -void ModLoader::unload_native(LoadedMod& mod) { - if (!mod.native || mod.inPlace) { - return; - } - // Deferred dlclose: this mod's code may still be on the stack below the current tick - m_retiredNatives.push_back({std::move(mod.native), std::move(mod.nativeDir)}); - mod.nativePath.clear(); - mod.nativeDir.clear(); - mod.nativeDirUtf8.clear(); -} - -void ModLoader::drain_retired_natives() { - for (auto& retired : m_retiredNatives) { - retired.native.reset(); - if (!retired.directory.empty()) { - std::error_code ec; - std::filesystem::remove_all(retired.directory, ec); - } - } - m_retiredNatives.clear(); -} - -static ModManifestInfo build_manifest_info(const ModMetaParsed& parsed) { - ModManifestInfo info; - info.imports.reserve(parsed.imports.size()); - for (const auto* record : parsed.imports) { - if (!svc::valid_service_id(record->service_id.chars)) { - continue; - } - info.imports.push_back({record->service_id.chars, record->major_version, - record->min_minor_version, (record->rec.flags & SERVICE_IMPORT_OPTIONAL) == 0}); - } - info.exports.reserve(parsed.exports.size()); - for (const auto* record : parsed.exports) { - if (!svc::valid_service_id(record->service_id.chars)) { - continue; - } - info.exports.push_back({record->service_id.chars, record->major_version}); - } - return info; -} - std::string escape_mod_id_for_config(std::string_view const id) { std::string buf; @@ -851,16 +132,16 @@ static void warn_unpublished_deferred_exports(const LoadedMod& mod) { } } -void ModLoader::try_load_mod( - const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex) { - namespace fs = std::filesystem; - - std::unique_ptr bundle; - try { - bundle = load_bundle(modPath, fromDir); - } catch (const std::exception& e) { - Log.error("Failed to open {} bundle: {}", data::abbreviated_path_string(modPath), e.what()); - return; +LoadedMod* ModLoader::try_load_mod(const fs::path& modPath, bool fromDir, uint32_t searchDirIndex, + std::unique_ptr bundle) { + if (bundle == nullptr) { + try { + bundle = load_bundle(modPath, fromDir); + } catch (const std::exception& e) { + Log.error( + "Failed to open {} bundle: {}", data::abbreviated_path_string(modPath), e.what()); + return nullptr; + } } LoadedManifest manifest; @@ -868,7 +149,7 @@ void ModLoader::try_load_mod( manifest = load_manifest(modPath, *bundle); } catch (const std::exception& e) { Log.error("bad mod.json in {}: {}", data::abbreviated_path_string(modPath), e.what()); - return; + return nullptr; } if (const auto* existing = find_mod(manifest.metadata.id)) { @@ -881,7 +162,7 @@ void ModLoader::try_load_mod( log::write(manifest.metadata.id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}", data::abbreviated_path_string(modPath)); } - return; + return nullptr; } const auto& inserted = m_mods.emplace_back(std::make_unique()); @@ -889,7 +170,9 @@ void ModLoader::try_load_mod( mod.active = true; mod.modPath = fs::absolute(modPath); mod.searchDirIndex = searchDirIndex; - mod.inPlace = m_searchDirs[searchDirIndex].inPlaceNative && fromDir; + mod.fromDirectory = fromDir; + mod.nativeInPlace = m_searchDirs[searchDirIndex].inPlaceNative && fromDir; + mod.fileIdentity = file_identity(modPath); mod.metadata = std::move(manifest.metadata); mod.runtime = std::move(manifest.runtime); mod.bundle = std::move(bundle); @@ -919,6 +202,7 @@ void ModLoader::try_load_mod( log::write(mod.metadata.id, LOG_LEVEL_INFO, "found '{}' v{} by {} ({})", mod.metadata.name, mod.metadata.version, mod.metadata.author, data::abbreviated_path_string(modPath)); + return &mod; } bool ModLoader::activate_mod(LoadedMod& mod) { @@ -1072,55 +356,30 @@ void ModLoader::init() { m_cacheDir = m_searchDirs.front().path / ".cache"; } - namespace fs = std::filesystem; std::error_code ec; // Stale libs from previous sessions (see load_native). fs::remove_all(m_cacheDir, ec); - for (size_t dirIndex = 0; dirIndex < m_searchDirs.size(); ++dirIndex) { - const auto& searchDir = m_searchDirs[dirIndex]; - - // --mods can point the user dir at the bundled dir; don't scan the same dir twice. - bool alreadyScanned = false; - for (size_t earlier = 0; earlier < dirIndex && !alreadyScanned; ++earlier) { - alreadyScanned = fs::equivalent(m_searchDirs[earlier].path, searchDir.path, ec); - } - if (alreadyScanned) { + const auto packages = scan_packages(m_searchDirs); + for (const auto& package : packages) { + const auto* selected = select_package(packages, package.metadata.id); + if (selected != &package) { + log::write(package.metadata.id, LOG_LEVEL_INFO, "{} v{} shadowed by {} v{}", + data::abbreviated_path_string(package.path), package.metadata.version, + data::abbreviated_path_string(selected->path), selected->metadata.version); continue; } - - if (!fs::is_directory(searchDir.path)) { - if (dirIndex == 0) { - Log.info( - "mods directory '{}' not found", data::abbreviated_path_string(searchDir.path)); - } else { - Log.debug( - "mods directory '{}' not found", data::abbreviated_path_string(searchDir.path)); - } - continue; - } - - std::vector entries; - for (auto& e : fs::directory_iterator(searchDir.path, ec)) { - if (e.is_directory() && std::filesystem::exists(e.path() / "mod.json")) { - entries.push_back(e); - } else if (e.is_regular_file() && e.path().extension() == ".dusk") { - entries.push_back(e); - } - } - std::sort(entries.begin(), entries.end(), - [](const fs::directory_entry& a, const fs::directory_entry& b) { - return a.path().filename() < b.path().filename(); - }); - - for (auto& entry : entries) { - try_load_mod(entry.path(), entry.is_directory(), static_cast(dirIndex)); + if (auto* mod = try_load_mod(package.path, package.fromDirectory, package.searchDirIndex)) { + record_package_sources(*mod, packages); } } if (m_mods.empty()) { + init_services(); Log.info("no mods found"); + svc::modules_lifecycle_applied(); + m_startupComplete = true; return; } @@ -1185,10 +444,19 @@ void ModLoader::init() { m_startupComplete = true; } -LoadedMod* ModLoader::find_mod(std::string_view id) const { - for (auto& mod : mods()) { - if (mod.metadata.id == id) { - return &mod; +LoadedMod* ModLoader::find_mod(std::string_view id) { + for (auto& mod : m_mods) { + if (mod->metadata.id == id) { + return mod.get(); + } + } + return nullptr; +} + +const LoadedMod* ModLoader::find_mod(std::string_view id) const { + for (const auto& mod : m_mods) { + if (mod->metadata.id == id) { + return mod.get(); } } return nullptr; @@ -1206,8 +474,61 @@ void ModLoader::request_disable(std::string_view id) { } } -void ModLoader::request_reload(std::string_view id) { - m_pendingRequests.push_back({std::string{id}, RequestKind::Reload}); +ModOperationHandle ModLoader::request_reload(std::string_view id) { + auto operation = std::make_shared(); + if (find_mod(id) != nullptr) { + m_pendingRequests.push_back(ReloadRequest{ + .modId = std::string{id}, + .operation = operation, + }); + } else { + complete_operation(operation, false, "The mod is no longer installed"); + } + return operation; +} + +ModOperationHandle ModLoader::request_install(fs::path path) { + auto operation = std::make_shared(); + m_pendingRequests.push_back(InstallRequest{ + .stagedPath = std::move(path), + .operation = operation, + }); + return operation; +} + +ModOperationHandle ModLoader::request_uninstall(std::string_view id) { + auto operation = std::make_shared(); + if (find_mod(id) != nullptr) { + m_pendingRequests.push_back(UninstallRequest{ + .modId = std::string{id}, + .operation = operation, + }); + } else { + complete_operation(operation); + } + return operation; +} + +ModOperationHandle ModLoader::request_reactivate(std::string_view id) { + auto operation = std::make_shared(); + m_pendingRequests.push_back(LifecycleRequest{ + .modId = std::string{id}, + .action = LifecycleAction::Reactivate, + .operation = operation, + }); + return operation; +} + +fs::path ModLoader::user_mods_dir() const { + return m_searchDirs.empty() ? fs::path{} : m_searchDirs.front().path; +} + +bool ModLoader::can_uninstall(const LoadedMod& mod) const { + return mod.hasUserPackage; +} + +bool ModLoader::can_update(const LoadedMod& mod) const { + return mod.searchDirIndex != 0 || (!mod.fromDirectory && can_uninstall(mod)); } void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) { @@ -1218,7 +539,10 @@ void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) { if (!m_startupComplete) { return; } - m_pendingRequests.push_back({mod.metadata.id, RequestKind::Disable}); + m_pendingRequests.emplace_back(LifecycleRequest{ + .modId = mod.metadata.id, + .action = LifecycleAction::Disable, + }); } void ModLoader::flush_toasts() { @@ -1251,7 +575,7 @@ void ModLoader::flush_toasts() { ui::push_toast(std::move(toast)); } -std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) { +std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) const { std::vector included{&target}; std::vector pending{&target}; while (!pending.empty()) { @@ -1280,15 +604,7 @@ std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) { return ordered; } -bool ModLoader::ensure_native_loaded(LoadedMod& mod) { - if (mod.native || mod.nativeStatus == NativeModStatus::None) { - return true; - } - return load_native_if_present(mod); -} - bool ModLoader::reload_bundle(LoadedMod& mod) { - namespace fs = std::filesystem; log::write(mod.metadata.id, LOG_LEVEL_INFO, "reloading from {}", data::abbreviated_path_string(mod.modPath)); @@ -1314,6 +630,7 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { mod.runtime = std::move(newManifest.runtime); // In-flight readers of the old bundle keep it alive through their shared_ptr. mod.bundle = std::move(newBundle); + mod.fileIdentity = file_identity(mod.modPath); mod.loadFailed = false; mod.failureReason.clear(); @@ -1343,7 +660,48 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { return true; } -void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { +void ModLoader::resume_lifecycle_set(const std::vector& affected) { + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!ensure_native_loaded(*mod)) { + continue; + } + if (mod->native && !mod->servicesRegistered) { + if (register_static_service_exports(*mod)) { + mod->servicesRegistered = true; + } else { + log::write(mod->metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); + deactivate_mod(*mod); + } + } + } + + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!required_deps_active(*mod)) { + mod->suspendedByProvider = true; + log::write( + mod->metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled"); + continue; + } + mod->suspendedByProvider = false; + activate_mod(*mod); + } + + for (auto* mod : affected) { + if (!mod->active && mod->servicesRegistered) { + svc::remove_services_for_provider(*mod); + mod->servicesRegistered = false; + } + } +} + +void ModLoader::apply_lifecycle_change( + LoadedMod& target, const bool reload, const PackageCandidate* replacement) { auto affected = collect_lifecycle_set(target); // Dependents first (reverse init order), like shutdown. @@ -1363,6 +721,17 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { } } + if (replacement != nullptr) { + target.modPath = replacement->path; + target.searchDirIndex = replacement->searchDirIndex; + target.fromDirectory = replacement->fromDirectory; + target.nativeInPlace = + replacement->fromDirectory && m_searchDirs[replacement->searchDirIndex].inPlaceNative; + std::stable_sort(m_mods.begin(), m_mods.end(), + [](const auto& a, const auto& b) { return a->searchDirIndex > b->searchDirIndex; }); + loader::sort_mods(m_mods); + } + if (reload) { // On failure the target is failed and stays down; dependents get resume attempts below // and suspend against the failed provider where required. @@ -1380,48 +749,7 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { affected = std::move(reordered); } - // Mirror startup: publish every candidate's static exports before any of them initialize, - // so importers within the set resolve providers regardless of initialization order - // (optional cycles rely on this). - for (auto* mod : affected) { - if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { - continue; - } - if (!ensure_native_loaded(*mod)) { - continue; - } - if (mod->native && !mod->servicesRegistered) { - if (register_static_service_exports(*mod)) { - mod->servicesRegistered = true; - } else { - log::write(mod->metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); - deactivate_mod(*mod); - } - } - } - - // Providers first (init order). The target is naturally first among the affected mods. - for (auto* mod : affected) { - if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { - continue; - } - if (!required_deps_active(*mod)) { - mod->suspendedByProvider = true; - log::write( - mod->metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled"); - continue; - } - mod->suspendedByProvider = false; - activate_mod(*mod); - } - - // Mods that stayed down must not leave their exports resolvable. - for (auto* mod : affected) { - if (!mod->active && mod->servicesRegistered) { - svc::remove_services_for_provider(*mod); - mod->servicesRegistered = false; - } - } + resume_lifecycle_set(affected); } void ModLoader::on_enabled_changed(LoadedMod& mod) { @@ -1435,13 +763,325 @@ void ModLoader::on_enabled_changed(LoadedMod& mod) { } if (mod.suspendedByProvider) { if (!mod.cvarIsEnabled->getValue()) { - // The user disabled a suspended mod; stop waiting for its providers. mod.suspendedByProvider = false; } return; } - m_pendingRequests.push_back({mod.metadata.id, - mod.cvarIsEnabled->getValue() ? RequestKind::Enable : RequestKind::Disable}); + m_pendingRequests.push_back(LifecycleRequest{ + .modId = mod.metadata.id, + .action = + mod.cvarIsEnabled->getValue() ? LifecycleAction::Enable : LifecycleAction::Disable, + }); +} + +void ModLoader::forget_mod(LoadedMod& mod) { + auto affected = collect_lifecycle_set(mod); + std::vector blocked; + std::vector pending{&mod}; + while (!pending.empty()) { + auto* provider = pending.back(); + pending.pop_back(); + for (const auto& edge : provider->dependents) { + if (!edge.required || edge.mod == nullptr || + std::ranges::find(blocked, edge.mod) != blocked.end()) + { + continue; + } + blocked.push_back(edge.mod); + pending.push_back(edge.mod); + } + } + + for (auto* affectedMod : affected | std::views::reverse) { + const bool wasActive = affectedMod->active; + if (affectedMod->active || affectedMod->initialized || affectedMod->native != nullptr) { + log::write(affectedMod->metadata.id, LOG_LEVEL_INFO, "deactivating mod"); + deactivate_mod(*affectedMod); + } + if (affectedMod != &mod && wasActive) { + affectedMod->suspendedByProvider = true; + } + } + + const std::string modId = mod.metadata.id; + auto* removedMod = &mod; + if (mod.enabledSubscription != 0) { + config::unsubscribe(mod.enabledSubscription); + mod.enabledSubscription = 0; + } + unregister(*mod.cvarIsEnabled); + + std::vector remaining; + remaining.reserve(affected.size()); + for (auto* affectedMod : affected) { + if (affectedMod != &mod) { + remaining.push_back(affectedMod); + } + } + std::erase_if( + m_mods, [removedMod](const auto& candidate) { return candidate.get() == removedMod; }); + loader::sort_mods(m_mods); + + std::vector resumable; + for (auto* affectedMod : remaining) { + const bool needsRemovedProvider = std::ranges::find(blocked, affectedMod) != blocked.end(); + affectedMod->suspendedByProvider = needsRemovedProvider; + if (needsRemovedProvider) { + log::write(affectedMod->metadata.id, LOG_LEVEL_INFO, + "suspended: required provider '{}' was removed", modId); + } else { + resumable.push_back(affectedMod); + } + } + resume_lifecycle_set(resumable); + + ++m_generation; + log::write(modId, LOG_LEVEL_INFO, "forgot removed package"); +} + +ModLoader::OperationResult ModLoader::load_runtime_mod(const fs::path& requestedPath) { + const auto path = fs::absolute(requestedPath).lexically_normal(); + std::error_code error; + const auto status = fs::status(path, error); + if (error || !fs::exists(status)) { + return { + .success = false, + .message = error ? fmt::format("Could not inspect the package: {}", error.message()) : + "The package was not found", + }; + } + + const bool fromDir = fs::is_directory(status); + std::unique_ptr bundle; + std::optional metadata; + try { + bundle = load_bundle(path, fromDir); + metadata = load_manifest(path, *bundle).metadata; + } catch (const std::exception& exception) { + return { + .success = false, + .message = fmt::format("Invalid mod package: {}", exception.what()), + }; + } + if (const auto* duplicate = find_mod(metadata->id)) { + return { + .success = false, + .message = fmt::format("A mod with this ID is already loaded from {}", + data::abbreviated_path_string(duplicate->modPath)), + }; + } + auto* mod = try_load_mod(path, fromDir, 0, std::move(bundle)); + if (mod == nullptr) { + return { + .success = false, + .message = "The mod could not be loaded", + }; + } + + mod->enabledSubscription = Register( + *mod->cvarIsEnabled, [this, mod](const bool&, const bool&) { on_enabled_changed(*mod); }); + loader::sort_mods(m_mods); + if (!mod->cvarIsEnabled->getValue()) { + mod->active = false; + mod->suspendedByProvider = false; + log::write(mod->metadata.id, LOG_LEVEL_INFO, "installed disabled by config"); + } else if (!mod->loadFailed) { + mod->active = false; + apply_lifecycle_change(*mod, false); + } + ++m_generation; + log::write(mod->metadata.id, LOG_LEVEL_INFO, "installed at runtime"); + return runtime_result(*mod); +} + +ModLoader::OperationResult ModLoader::reload_runtime_mod( + LoadedMod& mod, const PackageCandidate* replacement) { + if (mod.nativeInPlace && replacement == nullptr) { + return { + .success = false, + .message = "An in-place native library cannot be reloaded", + .mod = &mod, + }; + } + apply_lifecycle_change(mod, true, replacement); + ++m_generation; + return runtime_result(mod); +} + +ModLoader::OperationResult ModLoader::uninstall_runtime_mod(LoadedMod& mod) { + std::vector packages; + try { + packages = scan_packages(m_searchDirs); + } catch (const std::exception& exception) { + return {.success = false, .message = exception.what()}; + } + record_package_sources(mod, packages); + if (!can_uninstall(mod)) { + return {.success = false, .message = "No installed package to remove"}; + } + + std::string removalError; + std::erase_if(packages, [&](const auto& package) { + if (package.metadata.id != mod.metadata.id || package.searchDirIndex != 0 || + package.fromDirectory || package.symlink) + { + return false; + } + std::error_code error; + fs::remove(package.path, error); + if (error) { + removalError = fmt::format("Could not remove {}: {}", + data::abbreviated_path_string(package.path), error.message()); + } + return !error; + }); + + OperationResult result; + if (const auto* selected = select_package(packages, mod.metadata.id)) { + if (selected->path != mod.modPath) { + result = reload_runtime_mod(mod, selected); + } else { + result.mod = &mod; + ++m_generation; + } + record_package_sources(mod, packages); + } else { + forget_mod(mod); + } + if (!removalError.empty()) { + result.success = false; + result.message = std::move(removalError); + } + return result; +} + +ModLoader::OperationResult ModLoader::runtime_result(LoadedMod& mod) { + if (mod.loadFailed) { + return { + .success = false, + .message = mod.failureReason.empty() ? "Mod failed to activate" : mod.failureReason, + .mod = &mod, + }; + } + if (mod.cvarIsEnabled->getValue() && !mod.active) { + return { + .success = false, + .message = "A required provider is unavailable", + .mod = &mod, + }; + } + if (!mod.cvarIsEnabled->getValue()) { + return { + .message = "Installed, disabled by config", + .mod = &mod, + }; + } + return {.mod = &mod}; +} + +ModLoader::OperationResult ModLoader::install_staged(const fs::path& requestedPath) { + if (m_searchDirs.empty()) { + return { + .success = false, + .message = "No writable mods directory is configured", + }; + } + std::error_code error; + const auto userDir = fs::weakly_canonical(m_searchDirs.front().path, error); + if (error) { + return { + .success = false, + .message = + fmt::format("Could not resolve the user mods directory: {}", error.message()), + }; + } + const auto stagingDir = userDir / ".staging"; + const auto path = fs::weakly_canonical(requestedPath, error); + const bool stagedName = path.extension() == ".part" && path.stem().extension() == ".dusk"; + if (error || path.parent_path() != stagingDir || !stagedName || + !fs::is_regular_file(path, error)) + { + Log.error("refusing staged install from {}", data::abbreviated_path_string(requestedPath)); + return { + .success = false, + .message = "The package is not in the mod staging directory", + }; + } + + ModMetadata metadata; + std::string validationError; + if (!inspect_mod_bundle(path, metadata, validationError)) { + return { + .success = false, + .message = fmt::format("Invalid mod package: {}", validationError), + }; + } + + const auto destination = userDir / fmt::format("{}.dusk", safe_filename(metadata.id)); + auto* installed = find_mod(metadata.id); + if (installed != nullptr && !can_update(*installed)) { + return { + .success = false, + .message = "Cannot install mod over a development directory", + }; + } + + for (const auto& mod : mods()) { + if (mod.metadata.id != metadata.id && fs::equivalent(mod.modPath, destination, error)) { + return { + .success = false, + .message = "The destination filename belongs to a different mod", + }; + } + } + error.clear(); + + if (!borealis::update::parse_version(metadata.version)) { + return {.success = false, .message = "The package version is invalid"}; + } + std::vector packages; + try { + packages = scan_packages(m_searchDirs); + } catch (const std::exception& exception) { + return {.success = false, .message = exception.what()}; + } + if (const auto* selected = select_package(packages, metadata.id); + selected && compare_package_versions(metadata.version, selected->metadata.version) < 0) + { + return { + .success = false, + .message = fmt::format( + "A newer version ({}) is already installed", selected->metadata.version), + }; + } + + const auto packageResult = install_package(path, destination, metadata.id); + if (!packageResult.replaced) { + return {.success = false, .message = packageResult.error}; + } + std::erase_if(packages, [&](const auto& package) { + if (package.metadata.id != metadata.id || package.searchDirIndex != 0 || + package.fromDirectory) + { + return false; + } + std::error_code statusError; + return fs::equivalent(package.path, destination, statusError) || + !fs::exists(package.path, statusError); + }); + packages.push_back({.path = destination, .metadata = metadata}); + const auto* selected = select_package(packages, metadata.id); + auto result = installed != nullptr ? reload_runtime_mod(*installed, selected) : + load_runtime_mod(selected->path); + if (result.mod != nullptr) { + record_package_sources(*result.mod, packages); + } + + if (result.success && !packageResult.error.empty()) { + result.success = false; + result.message = packageResult.error; + } + return result; } void ModLoader::apply_pending_requests() { @@ -1452,16 +1092,90 @@ void ModLoader::apply_pending_requests() { return; } - // Coalesce per mod, last request wins. Failures during apply re-enqueue for next tick. const auto requests = std::exchange(m_pendingRequests, {}); - std::vector coalesced; + std::vector coalesced; for (const auto& request : requests) { - const auto existing = std::ranges::find_if( - coalesced, [&](const Request& r) { return r.modId == request.modId; }); + if (const auto* install = std::get_if(&request)) { + auto result = install_staged(install->stagedPath); + if (result.success && result.mod != nullptr) { + const auto& metadata = result.mod->metadata; + const std::string iconRml = + metadata.iconPath.empty() ? + std::string{} : + fmt::format(R"()", + ui::escape(ui::mod_image_source(*result.mod, metadata.iconPath))); + ui::push_toast({ + .type = "mod-installed", + .title = "Mod installed", + .content = fmt::format( + R"({}{}v{}{})", + iconRml, ui::escape(metadata.name), ui::escape(metadata.version), + ui::escape(metadata.author)), + .duration = std::chrono::seconds{4}, + }); + } else if (!result.success && result.mod == nullptr) { + ui::push_toast({ + .type = "warning", + .title = "Mod install failed", + .content = + result.message.empty() ? "The loader rejected the package" : result.message, + .duration = std::chrono::seconds{6}, + }); + } + if (!result.success && !m_searchDirs.empty()) { + // request_install owns only files under the configured staging directory. + std::error_code error; + const auto userDir = fs::weakly_canonical(m_searchDirs.front().path, error); + if (!error) { + const auto stagedPath = fs::weakly_canonical(install->stagedPath, error); + if (!error && stagedPath.parent_path() == userDir / ".staging") { + fs::remove(stagedPath, error); + } + } + } + complete_operation(install->operation, result.success, std::move(result.message)); + continue; + } + if (const auto* reload = std::get_if(&request)) { + auto* mod = find_mod(reload->modId); + if (mod == nullptr) { + complete_operation(reload->operation, false, "The mod is no longer installed"); + continue; + } + auto result = reload_runtime_mod(*mod); + complete_operation(reload->operation, result.success, std::move(result.message)); + continue; + } + if (const auto* uninstall = std::get_if(&request)) { + auto* mod = find_mod(uninstall->modId); + if (mod == nullptr) { + complete_operation(uninstall->operation); + continue; + } + const auto removedName = mod->metadata.name; + const auto removedId = mod->metadata.id; + auto result = uninstall_runtime_mod(*mod); + if (result.success) { + queue::remove_by_mod_id(removedId); + ui::push_toast({ + .title = result.mod != nullptr ? "User update removed" : "Mod uninstalled", + .content = removedName, + .duration = std::chrono::seconds{2}, + }); + } + complete_operation(uninstall->operation, result.success, std::move(result.message)); + continue; + } + + const auto& lifecycle = std::get(request); + const auto existing = + std::ranges::find(coalesced, lifecycle.modId, &LifecycleRequest::modId); if (existing != coalesced.end()) { - existing->kind = request.kind; + complete_operation( + existing->operation, false, "Superseded by a newer lifecycle request"); + *existing = lifecycle; } else { - coalesced.push_back(request); + coalesced.push_back(lifecycle); } } @@ -1469,19 +1183,36 @@ void ModLoader::apply_pending_requests() { auto* mod = find_mod(request.modId); if (mod == nullptr) { Log.warn("lifecycle request for unknown mod '{}'", request.modId); + complete_operation(request.operation, false, "The mod is no longer installed"); continue; } - if (request.kind == RequestKind::Reload && mod->inPlace) { - log::write(mod->metadata.id, LOG_LEVEL_WARN, "is a built-in mod and can't be reloaded"); + if (request.action == LifecycleAction::Enable && mod->enabledApplied) { continue; } - if (request.kind == RequestKind::Enable && mod->enabledApplied) { + if (request.action == LifecycleAction::Disable && !mod->enabledApplied && !mod->active) { continue; } - if (request.kind == RequestKind::Disable && !mod->enabledApplied && !mod->active) { - continue; + + if (request.action == LifecycleAction::Reactivate) { + mod->loadFailed = false; + mod->failureReason.clear(); + mod->suspendedByProvider = false; + if (!mod->cvarIsEnabled->getValue()) { + mod->cvarIsEnabled->setValue(true); + } + } + apply_lifecycle_change(*mod, false); + + if (request.action == LifecycleAction::Reactivate) { + std::string error; + if (mod->loadFailed) { + error = + mod->failureReason.empty() ? "The mod failed to activate" : mod->failureReason; + } else if (mod->cvarIsEnabled->getValue() && !mod->active) { + error = "A required provider is unavailable"; + } + complete_operation(request.operation, error.empty(), std::move(error)); } - apply_lifecycle_change(*mod, request.kind == RequestKind::Reload); } svc::modules_lifecycle_applied(); diff --git a/src/dusk/mods/loader/loader.hpp b/src/dusk/mods/loader/loader.hpp index 6b9fd39232..d5708c9be1 100644 --- a/src/dusk/mods/loader/loader.hpp +++ b/src/dusk/mods/loader/loader.hpp @@ -1,10 +1,9 @@ #pragma once #include -#include #include -#include "miniz.h" +#include "dusk/archive.hpp" #include "dusk/mod_loader.hpp" namespace dusk::mods { @@ -28,17 +27,14 @@ public: class ModBundleZip final : public ModBundle { public: - explicit ModBundleZip(std::vector&& data); - ~ModBundleZip() override; + explicit ModBundleZip(const std::filesystem::path& path); + ~ModBundleZip() override = default; std::vector readFile(const std::string& fileName) override; std::vector getFileNames() override; size_t getFileSize(const std::string& fileName) override; private: - std::vector zip_data; - mz_zip_archive res_zip{}; - bool res_zip_open = false; - std::mutex m_mutex; + archive::ZipArchive m_archive; }; class ModBundleDisk final : public ModBundle { diff --git a/src/dusk/mods/loader/manifest.cpp b/src/dusk/mods/loader/manifest.cpp new file mode 100644 index 0000000000..8c8243bd0a --- /dev/null +++ b/src/dusk/mods/loader/manifest.cpp @@ -0,0 +1,203 @@ +#include "manifest.hpp" + +#include "loader.hpp" +#include "natives.hpp" +#include "packages.hpp" + +#include "dusk/mods/log_buffer.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +using namespace std::string_literals; +namespace fs = std::filesystem; + +namespace dusk::mods { +namespace { + +class InvalidModDataException : public std::runtime_error { +public: + explicit InvalidModDataException(const std::string& msg) : runtime_error(msg) {} + explicit InvalidModDataException(const char* msg) : runtime_error(msg) {} +}; + +void validate_mod_id(std::string_view const str) { + if (str.empty()) { + throw InvalidModDataException("Missing ID value in mod metadata"); + } + + bool lastWasPeriod = false; + for (auto const chr : str) { + if (chr == '.') { + if (lastWasPeriod) { + throw InvalidModDataException("Cannot have two consecutive periods in mod ID"); + } + lastWasPeriod = true; + continue; + } + + lastWasPeriod = false; + + if (chr == '_') + continue; + + if (chr >= '0' && chr <= '9') + continue; + + if (chr >= 'a' && chr <= 'z') + continue; + + if (chr >= 'A' && chr <= 'Z') + continue; + + throw InvalidModDataException( + fmt::format("Invalid character '{}' in mod ID. Valid characters are period, " + "underscore, and alphanumerics.", + chr)); + } +} + +bool bundle_has_file(ModBundle& bundle, const std::string& path) { + try { + bundle.getFileSize(path); + return true; + } catch (const std::runtime_error&) { + return false; + } +} + +std::string resolve_image_path(ModBundle& bundle, const std::string& modId, std::string_view key, + const std::string& manifestPath, const std::string& defaultPath) { + if (!manifestPath.empty()) { + if (!is_safe_resource_path(manifestPath)) { + log::write( + modId, LOG_LEVEL_WARN, "invalid {} path '{}' in mod.json", key, manifestPath); + } else if (!bundle_has_file(bundle, manifestPath)) { + log::write( + modId, LOG_LEVEL_WARN, "{} path '{}' not found in bundle", key, manifestPath); + } else { + return manifestPath; + } + } + if (bundle_has_file(bundle, defaultPath)) { + return defaultPath; + } + return {}; +} + +uint16_t parse_runtime_version_component(std::string_view text, std::string_view fieldName) { + uint32_t value = 0; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (text.empty() || error != std::errc{} || end != text.data() + text.size() || + value > UINT16_MAX) + { + throw InvalidModDataException(fmt::format("Invalid {} in runtime version pin", fieldName)); + } + return static_cast(value); +} + +std::optional parse_runtime(const nlohmann::json& manifest) { + const auto field = manifest.find("runtime"); + if (field == manifest.end()) { + return std::nullopt; + } + if (!field->is_string()) { + throw InvalidModDataException("runtime must be a string"); + } + + const std::string pin = field->get(); + const auto at = pin.rfind('@'); + if (at == std::string::npos || at == 0 || at + 1 == pin.size() || pin.find('@') != at || + at >= MOD_META_SERVICE_ID_SIZE) + { + throw InvalidModDataException( + "runtime must be a service id followed by @major or @major.minor"); + } + + const std::string_view version{pin.data() + at + 1, pin.size() - at - 1}; + const auto dot = version.find('.'); + if (dot != std::string_view::npos && version.find('.', dot + 1) != std::string_view::npos) { + throw InvalidModDataException("runtime version pin has too many components"); + } + + DelegatedModRuntime result; + result.id = pin.substr(0, at); + result.major = parse_runtime_version_component( + dot == std::string_view::npos ? version : version.substr(0, dot), "major version"); + if (dot != std::string_view::npos) { + result.minMinor = parse_runtime_version_component(version.substr(dot + 1), "minor version"); + } + return result; +} + +} // namespace + +LoadedManifest load_manifest(const std::filesystem::path& modPath, ModBundle& bundle) { + const auto metaJson = bundle.readFile("mod.json"); + auto j = nlohmann::json::parse(metaJson); + + std::string metaId = j.value("id", ""); + std::string metaName = j.value("name", ""); + std::string metaVersion = j.value("version", ""); + std::string metaAuthor = j.value("author", ""); + std::string metaDescription = j.value("description", ""); + std::string metaIcon = j.value("icon", ""); + std::string metaBanner = j.value("banner", ""); + + validate_mod_id(metaId); + + if (metaName.empty()) { + metaName = borealis::io::fs_path_to_string(modPath.stem()); + } + if (metaVersion.empty()) { + metaVersion = "?"s; + } + if (metaAuthor.empty()) { + metaAuthor = "unknown"s; + } + + std::string iconPath = resolve_image_path(bundle, metaId, "icon", metaIcon, "res/icon.png"s); + std::string bannerPath = + resolve_image_path(bundle, metaId, "banner", metaBanner, "res/banner.png"s); + + return LoadedManifest{ + .metadata = + { + std::move(metaId), + std::move(metaName), + std::move(metaVersion), + std::move(metaAuthor), + std::move(metaDescription), + std::move(iconPath), + std::move(bannerPath), + }, + .runtime = parse_runtime(j), + }; +} + +bool inspect_mod_bundle( + const fs::path& path, ModMetadata& metadata, std::string& error, bool* hasNative) noexcept { + try { + auto bundle = load_bundle(path, false); + metadata = load_manifest(path, *bundle).metadata; + if (hasNative != nullptr) { + *hasNative = std::ranges::any_of(bundle->getFileNames(), + [](const auto& name) { return has_native_library_extension(name); }); + } + error.clear(); + return true; + } catch (const std::exception& exception) { + error = exception.what(); + } catch (...) { + error = "Unknown bundle validation error"; + } + return false; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/manifest.hpp b/src/dusk/mods/loader/manifest.hpp new file mode 100644 index 0000000000..e3eea70298 --- /dev/null +++ b/src/dusk/mods/loader/manifest.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "dusk/mod_loader.hpp" + +namespace dusk::mods { + +struct LoadedManifest { + ModMetadata metadata; + std::optional runtime; +}; + +LoadedManifest load_manifest(const std::filesystem::path& modPath, ModBundle& bundle); + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/natives.cpp b/src/dusk/mods/loader/natives.cpp new file mode 100644 index 0000000000..e3802f4b52 --- /dev/null +++ b/src/dusk/mods/loader/natives.cpp @@ -0,0 +1,620 @@ +#include "natives.hpp" + +#include "loader.hpp" +#include "native_module.hpp" + +#include "dusk/data.hpp" +#include "dusk/mods/log_buffer.hpp" +#include "dusk/mods/svc/registry.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::string_view_literals; +namespace fs = std::filesystem; + +#if defined(_WIN32) +#if defined(_M_ARM64) +static constexpr std::string_view k_nativePlatform = "windows-arm64"sv; +#elif defined(_M_X64) +static constexpr std::string_view k_nativePlatform = "windows-amd64"sv; +#elif defined(_M_IX86) +static constexpr std::string_view k_nativePlatform = "windows-x86"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +#endif +static constexpr std::string_view k_nativeLibName = "mod.dll"sv; +#elif defined(__ANDROID__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativePlatform = "android-aarch64"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativePlatform = "android-x86_64"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +#endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; +#elif defined(__APPLE__) +#include +#if TARGET_OS_IOS +static constexpr std::string_view k_nativePlatform = "ios-arm64"sv; +#elif TARGET_OS_TV +static constexpr std::string_view k_nativePlatform = "tvos-arm64"sv; +#elif defined(__aarch64__) +static constexpr std::string_view k_nativePlatform = "macos-arm64"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativePlatform = "macos-x86_64"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +#endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; +#elif defined(__linux__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativePlatform = "linux-aarch64"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativePlatform = "linux-x86_64"sv; +#elif defined(__i386__) +static constexpr std::string_view k_nativePlatform = "linux-x86"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +#endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +static constexpr std::string_view k_nativeLibName = ""sv; +#endif + +namespace dusk::mods { + +bool has_native_library_extension(std::string_view name) { + const auto endsWith = [name](std::string_view extension) { + if (name.size() < extension.size()) { + return false; + } + const auto suffix = name.substr(name.size() - extension.size()); + return std::ranges::equal(suffix, extension, [](char lhs, char rhs) { + const auto lower = [](char value) { + return value >= 'A' && value <= 'Z' ? static_cast(value + ('a' - 'A')) : + value; + }; + return lower(lhs) == lower(rhs); + }); + }; + return endsWith(".dll"sv) || endsWith(".so"sv) || endsWith(".dylib"sv); +} + +namespace { + +constexpr std::string_view k_nativeLibDir = "lib/"sv; + +class DirectoryRollback { +public: + ~DirectoryRollback() { + if (!mPath.empty()) { + std::error_code ec; + fs::remove_all(mPath, ec); + } + } + + void set_path(fs::path path) { mPath = std::move(path); } + void release() { mPath.clear(); } + +private: + fs::path mPath; +}; + +struct NativeRuntimeLocation { + std::string entry; + std::vector runtimeEntries; + bool anyLibs = false; +}; + +struct NativeLocateFailure { + NativeModStatus status; + std::string logMessage; +}; + +using NativeLocateResult = std::variant; + +NativeLocateResult locate_native_runtime(ModBundle& bundle) { + NativeRuntimeLocation result; + const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); + const std::string nativeEntry = platformPrefix + std::string{k_nativeLibName}; + for (const auto& name : bundle.getFileNames()) { + if (name.find('/') == std::string::npos && has_native_library_extension(name)) { + return NativeLocateFailure{ + NativeModStatus::InvalidBundle, + fmt::format( + "native library '{}' found at the root (natives go in /lib/{{platform}})", + name), + }; + } + if (!name.starts_with(k_nativeLibDir)) { + continue; + } + + const std::string_view libPath{ + name.data() + k_nativeLibDir.size(), name.size() - k_nativeLibDir.size()}; + const auto platformEnd = libPath.find('/'); + if (platformEnd != std::string_view::npos) { + const auto entryName = libPath.substr(platformEnd + 1); + if (entryName.find('/') == std::string_view::npos && + (entryName == "mod.dll"sv || entryName == "mod.so"sv)) + { + result.anyLibs = true; + } + } + + if (!k_nativePlatform.empty() && name.starts_with(platformPrefix)) { + const std::string_view relativeName{ + name.data() + platformPrefix.size(), name.size() - platformPrefix.size()}; + if (!is_safe_resource_path(relativeName)) { + continue; + } + result.runtimeEntries.push_back(name); + } + if (name == nativeEntry) { + result.entry = name; + } + } + std::ranges::sort(result.runtimeEntries); + result.runtimeEntries.erase( + std::unique(result.runtimeEntries.begin(), result.runtimeEntries.end()), + result.runtimeEntries.end()); + return result; +} + +// True if the first `capacity` bytes of `str` contain a NUL. +bool terminated_within(const char* str, size_t capacity) { + return std::memchr(str, '\0', capacity) != nullptr; +} + +bool parse_meta(NativeMod& native, LoadedMod& mod) { + const ModMeta* meta = native.meta; + if (meta->struct_size < sizeof(ModMeta)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta descriptor has invalid size {}", + meta->struct_size); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + } + const auto* cursor = static_cast(meta->records_begin); + const auto* end = static_cast(meta->records_end); + if (cursor == nullptr || end == nullptr || cursor > end || + (reinterpret_cast(cursor) & 7) != 0) + { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta section bounds are invalid"); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + } + + ModMetaParsed parsed; + size_t headerCount = 0; + const auto invalid = [&](std::string_view why) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "invalid metadata record at offset {}: {}", + cursor - static_cast(meta->records_begin), why); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + }; + + while (cursor < end) { + if (end - cursor < 8) { + return invalid("trailing bytes"); + } + uint64_t first = 0; + std::memcpy(&first, cursor, sizeof(first)); + if (first == 0) { // linker padding / bounds sentinel + cursor += 8; + continue; + } + + const auto* rec = reinterpret_cast(cursor); + const size_t size = rec->size; + if (size < 8 || size % 8 != 0 || size > static_cast(end - cursor)) { + return invalid("bad record size"); + } + + switch (rec->kind) { + case MOD_META_PAD: + break; + case MOD_META_HEADER: { + if (size < sizeof(ModMetaHeader)) { + return invalid("truncated header record"); + } + const auto* header = reinterpret_cast(rec); + ++headerCount; + parsed.abiVersion = header->abi_version; + break; + } + case MOD_META_IMPORT: { + if (size < sizeof(ModMetaImport)) { + return invalid("truncated import record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { + return invalid("unterminated import service id"); + } + parsed.imports.push_back(record); + break; + } + case MOD_META_EXPORT: { + if (size < sizeof(ModMetaExport)) { + return invalid("truncated export record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { + return invalid("unterminated export service id"); + } + parsed.exports.push_back(record); + break; + } + case MOD_META_HOOK_FN: { + if (size < sizeof(ModMetaHookFn)) { + return invalid("truncated hook record"); + } + parsed.hookFns.push_back( + reinterpret_cast(const_cast(cursor))); + break; + } + case MOD_META_HOOK_MEM: { + if (size <= sizeof(ModMetaHookMem)) { + return invalid("truncated hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMem); + const size_t capacity = size - sizeof(ModMetaHookMem); + if (!terminated_within(strings, capacity)) { + return invalid("unterminated hook vtable symbol"); + } + const size_t vtableLen = std::char_traits::length(strings); + if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { + return invalid("unterminated hook display name"); + } + parsed.hookMems.push_back(record); + break; + } + case MOD_META_HOOK_MEM_EXT: { + if (size <= sizeof(ModMetaHookMemExt)) { + return invalid("truncated extended hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (record->pmf_size <= MOD_META_HOOK_MEM_CAPACITY || + record->pmf_size > MOD_META_HOOK_MEM_EXT_CAPACITY || record->materialize == nullptr) + { + return invalid("bad extended hook member-pointer size"); + } + const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMemExt); + const size_t capacity = size - sizeof(ModMetaHookMemExt); + if (!terminated_within(strings, capacity)) { + return invalid("unterminated extended hook vtable symbol"); + } + const size_t vtableLen = std::char_traits::length(strings); + if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { + return invalid("unterminated extended hook display name"); + } + parsed.hookMemExts.push_back(record); + break; + } + case MOD_META_HOOK_NAME: { + if (size <= sizeof(ModMetaHookName)) { + return invalid("truncated hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + const char* name = reinterpret_cast(cursor) + sizeof(ModMetaHookName); + if (!terminated_within(name, size - sizeof(ModMetaHookName))) { + return invalid("unterminated hook symbol name"); + } + parsed.hookNames.push_back(record); + break; + } + default: + // Additive record kinds may appear within a format version; skip them. + log::write(mod.metadata.id, LOG_LEVEL_DEBUG, "skipping unknown metadata record kind {}", + rec->kind); + break; + } + cursor += size; + } + + if (headerCount != 1) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expected 1 metadata header record, found {}", + headerCount); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + } + if (parsed.abiVersion != MOD_ABI_VERSION) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", + parsed.abiVersion, MOD_ABI_VERSION); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + + native.parsed = std::move(parsed); + return true; +} + +std::string native_status_message(const NativeModStatus status) { + switch (status) { + case NativeModStatus::BuildDisabled: + return "Code mods are disabled on this Dusklight build"; + case NativeModStatus::ModMissingPlatform: + return fmt::format("Mod not supported on this platform ({})", k_nativePlatform); + case NativeModStatus::ApiVersionMismatch: + // TODO: differentiate whether mod or Dusklight is out of date + return "Mod ABI version mismatch"; + case NativeModStatus::MissingExport: + return "Missing required mod API exports"; + case NativeModStatus::InvalidMetadata: + return "Invalid mod metadata records"; + case NativeModStatus::InvalidBundle: + return "Invalid mod bundle layout (old mod?)"; + case NativeModStatus::Unknown: + return "Unknown mod load failure"; + case NativeModStatus::None: + case NativeModStatus::Loaded: + break; + } + return "native mod failed to load"; +} + +} // namespace + +fs::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { + if (k_nativeLibName.empty()) { + return {}; + } + const auto& libDir = m_searchDirs[mod.searchDirIndex].nativeLibDir; + if (libDir.empty()) { + return {}; + } + const auto filename = fmt::format("{}{}", mod.metadata.id, + borealis::io::fs_path_to_string(fs::path{k_nativeLibName}.extension())); + fs::path path = libDir / fs::path{filename}; + std::error_code ec; + if (!fs::is_regular_file(path, ec)) { + return {}; + } + return path; +} + +void ModLoader::load_native( + LoadedMod& mod, const std::string& dllEntry, const std::vector& runtimeEntries) { + if (!EnableCodeMods) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); + mod.nativeStatus = NativeModStatus::BuildDisabled; + return; + } + + const fs::path cacheDir = m_cacheDir / mod.metadata.id; + const fs::path scratchDir = cacheDir / "data"; + std::error_code ec; + fs::create_directories(scratchDir, ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to create mod directory {}: {}", + data::abbreviated_path_string(scratchDir), ec.message()); + return; + } + mod.dir = fs::absolute(scratchDir); + mod.dirUtf8 = borealis::io::fs_path_to_string(mod.dir); + + fs::path libPath; + fs::path runtimeDir; + DirectoryRollback runtimeDirRollback; + if (mod.nativeInPlace) { + if (!dllEntry.empty()) { + libPath = mod.modPath / dllEntry; + } else if (auto external = external_native_lib_path(mod); !external.empty()) { + libPath = std::move(external); + } else { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + runtimeDir = libPath.parent_path(); + } else { + if (dllEntry.empty()) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + + // Every generation gets a new directory. The main module and all of its runtime + // libraries therefore have fresh paths and can coexist with a previous generation + // that is still unwinding after a reload. + runtimeDir = cacheDir / fmt::format("g{}", ++mod.cacheGeneration); + runtimeDirRollback.set_path(runtimeDir); + fs::create_directories(runtimeDir, ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "failed to create native runtime directory {}: {}", + data::abbreviated_path_string(runtimeDir), ec.message()); + return; + } + + const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); + for (const auto& entry : runtimeEntries) { + if (!entry.starts_with(platformPrefix)) { + continue; + } + const std::string_view relativeName{ + entry.data() + platformPrefix.size(), entry.size() - platformPrefix.size()}; + if (!is_safe_resource_path(relativeName)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "unsafe native runtime path '{}'; skipping", entry); + return; + } + + const fs::path outputPath = runtimeDir / fs::path{relativeName}; + fs::create_directories(outputPath.parent_path(), ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "failed to create directory for {}: {}", entry, ec.message()); + return; + } + + std::vector data; + try { + data = mod.bundle->readFile(entry); + } catch (const std::exception& e) { + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}: {}", entry, e.what()); + return; + } + + std::ofstream out(outputPath, std::ios::binary | std::ios::out); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); + return; + } + out.write(reinterpret_cast(data.data()), + static_cast(data.size())); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); + return; + } + } + + libPath = runtimeDir / fs::path{dllEntry}.filename(); + } + + auto nativeMod = std::make_unique(); + try { + nativeMod->handle = std::make_unique(libPath); + } catch (const std::runtime_error& e) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to open {}: {}", + data::abbreviated_path_string(libPath), e.what()); + return; + } + + nativeMod->meta = nativeMod->handle->LookupSymbol("mod_meta"); + nativeMod->contextSymbol = nativeMod->handle->LookupSymbol("mod_ctx"); + nativeMod->fn_initialize = nativeMod->handle->LookupSymbol("mod_initialize"); + nativeMod->fn_update = nativeMod->handle->LookupSymbol("mod_update"); + nativeMod->fn_shutdown = nativeMod->handle->LookupSymbol("mod_shutdown"); + + if (!nativeMod->meta || !nativeMod->contextSymbol || !nativeMod->fn_initialize || + !nativeMod->fn_update || !nativeMod->fn_shutdown) + { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "{} missing required mod API exports; skipping", + data::abbreviated_path_string(libPath)); + mod.nativeStatus = NativeModStatus::MissingExport; + return; + } + + if (!parse_meta(*nativeMod, mod)) { + return; + } + + if (nativeMod->contextSymbol == nullptr) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "missing required mod_ctx export"); + mod.nativeStatus = NativeModStatus::MissingExport; + return; + } + *nativeMod->contextSymbol = mod.context.get(); + + mod.nativePath = fs::absolute(libPath); + mod.nativeDir = fs::absolute(runtimeDir); + mod.nativeDirUtf8 = borealis::io::fs_path_to_string(mod.nativeDir); + mod.native = std::move(nativeMod); + mod.nativeStatus = NativeModStatus::Loaded; + runtimeDirRollback.release(); +} + +bool ModLoader::load_native_if_present(LoadedMod& mod) { + const auto result = locate_native_runtime(*mod.bundle); + if (const auto* failure = std::get_if(&result)) { + mod.nativeStatus = failure->status; + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "{}", failure->logMessage); + fail_mod(mod, MOD_ERROR, native_status_message(failure->status)); + return false; + } + + const auto& native = std::get(result); + if (mod.runtime.has_value() && + (native.anyLibs || (mod.nativeInPlace && !external_native_lib_path(mod).empty()))) + { + mod.nativeStatus = NativeModStatus::InvalidBundle; + fail_mod(mod, MOD_CONFLICT, "A mod cannot declare both runtime and native code"); + return false; + } + if (!native.anyLibs && !(mod.nativeInPlace && !external_native_lib_path(mod).empty())) { + mod.nativeStatus = NativeModStatus::None; + return true; + } + + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, native.entry, native.runtimeEntries); + if (mod.nativeStatus != NativeModStatus::Loaded) { + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + return false; + } + return true; +} + +void ModLoader::unload_native(LoadedMod& mod) { + if (!mod.native) { + return; + } + // Deferred dlclose: this mod's code may still be on the stack below the current tick + m_retiredNatives.push_back( + {std::move(mod.native), mod.nativeInPlace ? fs::path{} : std::move(mod.nativeDir)}); + mod.nativePath.clear(); + mod.nativeDir.clear(); + mod.nativeDirUtf8.clear(); +} + +void ModLoader::drain_retired_natives() { + for (auto& retired : m_retiredNatives) { + retired.native.reset(); + if (!retired.directory.empty()) { + std::error_code ec; + fs::remove_all(retired.directory, ec); + } + } + m_retiredNatives.clear(); +} + +bool ModLoader::ensure_native_loaded(LoadedMod& mod) { + if (mod.native || mod.nativeStatus == NativeModStatus::None) { + return true; + } + return load_native_if_present(mod); +} + +ModManifestInfo build_manifest_info(const ModMetaParsed& parsed) { + ModManifestInfo info; + info.imports.reserve(parsed.imports.size()); + for (const auto* record : parsed.imports) { + if (!svc::valid_service_id(record->service_id.chars)) { + continue; + } + info.imports.push_back({ + .id = record->service_id.chars, + .major = record->major_version, + .minMinor = record->min_minor_version, + .required = (record->rec.flags & SERVICE_IMPORT_OPTIONAL) == 0, + }); + } + info.exports.reserve(parsed.exports.size()); + for (const auto* record : parsed.exports) { + if (!svc::valid_service_id(record->service_id.chars)) { + continue; + } + info.exports.push_back({ + .id = record->service_id.chars, + .major = record->major_version, + }); + } + return info; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/natives.hpp b/src/dusk/mods/loader/natives.hpp new file mode 100644 index 0000000000..657f3d011a --- /dev/null +++ b/src/dusk/mods/loader/natives.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace dusk::mods { + +struct ModManifestInfo; +struct ModMetaParsed; + +bool has_native_library_extension(std::string_view name); +ModManifestInfo build_manifest_info(const ModMetaParsed& parsed); + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/packages.cpp b/src/dusk/mods/loader/packages.cpp new file mode 100644 index 0000000000..071f5b276e --- /dev/null +++ b/src/dusk/mods/loader/packages.cpp @@ -0,0 +1,201 @@ +#include "packages.hpp" + +#include "loader.hpp" +#include "manifest.hpp" + +#include "dusk/data.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace dusk::mods { +namespace { + +constexpr borealis::Log Log{"dusk::mods::loader"}; + +} // namespace + +std::unique_ptr load_bundle(const fs::path& modPath, bool fromDir) { + if (fromDir) { + return std::make_unique(modPath); + } else { + return std::make_unique(modPath); + } +} + +int compare_package_versions(std::string_view lhs, std::string_view rhs) { + const auto left = borealis::update::parse_version(lhs); + const auto right = borealis::update::parse_version(rhs); + if (left && right) { + return borealis::update::compare_version(*left, *right); + } + return static_cast(left.has_value()) - static_cast(right.has_value()); +} + +std::vector scan_packages(std::span searchDirs) { + std::vector packages; + for (size_t dirIndex = 0; dirIndex < searchDirs.size(); ++dirIndex) { + const auto& searchDir = searchDirs[dirIndex]; + std::error_code error; + bool alreadyScanned = false; + for (size_t earlier = 0; earlier < dirIndex && !alreadyScanned; ++earlier) { + alreadyScanned = fs::equivalent(searchDirs[earlier].path, searchDir.path, error); + } + if (alreadyScanned || !fs::is_directory(searchDir.path)) { + continue; + } + std::vector entries; + for (const auto& entry : fs::directory_iterator{searchDir.path}) { + if ((entry.is_directory() && fs::exists(entry.path() / "mod.json")) || + (entry.is_regular_file() && entry.path().extension() == ".dusk")) + { + entries.push_back(entry); + } + } + std::ranges::sort(entries, {}, &fs::directory_entry::path); + for (const auto& entry : entries) { + try { + const bool fromDirectory = entry.is_directory(); + auto bundle = load_bundle(entry.path(), fromDirectory); + packages.push_back({ + .path = fs::absolute(entry.path()), + .metadata = load_manifest(entry.path(), *bundle).metadata, + .searchDirIndex = static_cast(dirIndex), + .fromDirectory = fromDirectory, + .symlink = entry.is_symlink(), + }); + } catch (const std::exception& exception) { + Log.error("bad mod package {}: {}", + data::abbreviated_path_string(entry.path()), exception.what()); + } + } + } + return packages; +} + +const PackageCandidate* select_package(std::span packages, + std::string_view modId) { + const PackageCandidate* selected = nullptr; + for (const auto& package : packages) { + if (package.metadata.id != modId) { + continue; + } + const int order = selected ? + compare_package_versions(package.metadata.version, selected->metadata.version) : 1; + if (order > 0 || (order == 0 && + (package.searchDirIndex < selected->searchDirIndex || + (package.searchDirIndex == selected->searchDirIndex && package.path < selected->path)))) + { + selected = &package; + } + } + return selected; +} + +void record_package_sources(LoadedMod& mod, std::span packages) { + mod.hasUserPackage = false; + mod.hasBundledCopy = false; + for (const auto& package : packages) { + if (package.metadata.id != mod.metadata.id) { + continue; + } + if (package.searchDirIndex != 0) { + mod.hasBundledCopy = true; + } else if (!package.fromDirectory && !package.symlink) { + mod.hasUserPackage = true; + } + } +} + +PackageInstallResult install_package(const fs::path& path, const fs::path& destination, + std::string_view modId) { + const auto userDir = destination.parent_path(); + std::error_code error; + std::string validationError; + const auto destinationStatus = fs::symlink_status(destination, error); + if (error == std::errc::no_such_file_or_directory) { + error.clear(); + } + if (error) { + return {.replaced = false, + .error = fmt::format("Could not inspect the destination: {}", error.message())}; + } + if (fs::exists(destinationStatus)) { + ModMetadata existingMetadata; + if (!fs::is_regular_file(destinationStatus) || + !inspect_mod_bundle(destination, existingMetadata, validationError) || + existingMetadata.id != modId) + { + return { + .replaced = false, + .error = "The destination filename is already used by another package", + }; + } + } + std::vector duplicates; + for (fs::directory_iterator entry{userDir, error}, end; !error && entry != end; + entry.increment(error)) + { + const auto& candidate = entry->path(); + if (candidate.extension() != ".dusk") { + continue; + } + const bool regularFile = entry->is_regular_file(error); + if (error) { + break; + } + if (!regularFile) { + continue; + } + ModMetadata candidateMetadata; + if (inspect_mod_bundle(candidate, candidateMetadata, validationError) && + candidateMetadata.id == modId) + { + duplicates.push_back(candidate); + } + } + if (error) { + return {.replaced = false, + .error = fmt::format("Could not scan the mods directory: {}", error.message())}; + } + + std::string replaceError; + if (!borealis::io::atomic_replace(path, destination, replaceError)) { + return { + .replaced = false, + .error = std::move(replaceError), + }; + } + + std::string cleanupError; + for (const auto& duplicate : duplicates) { + // Replacing a file on a case-insensitive filesystem can preserve its old spelling. + const bool destinationAlias = + fs::equivalent(duplicate, destination, error) && !fs::is_symlink(duplicate, error); + error.clear(); + if (destinationAlias) { + if (duplicate.filename() == destination.filename()) { + continue; + } + fs::rename(duplicate, destination, error); + } else { + fs::remove(duplicate, error); + } + if (error) { + cleanupError = fmt::format("Installed, but could not consolidate '{}': {}", + data::abbreviated_path_string(duplicate), error.message()); + Log.warn("{}", cleanupError); + } + } + return {.replaced = true, .error = std::move(cleanupError)}; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/packages.hpp b/src/dusk/mods/loader/packages.hpp new file mode 100644 index 0000000000..0271dca5fe --- /dev/null +++ b/src/dusk/mods/loader/packages.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "dusk/mod_loader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { + +struct PackageCandidate { + std::filesystem::path path; + ModMetadata metadata; + uint32_t searchDirIndex = 0; + bool fromDirectory = false; + bool symlink = false; +}; + +std::vector scan_packages(std::span searchDirs); +int compare_package_versions(std::string_view lhs, std::string_view rhs); +const PackageCandidate* select_package( + std::span packages, std::string_view modId); +void record_package_sources(LoadedMod& mod, std::span packages); + +std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir); + +struct PackageInstallResult { + bool replaced = false; + std::string error; +}; + +PackageInstallResult install_package(const std::filesystem::path& path, + const std::filesystem::path& destination, std::string_view modId); + +} // namespace dusk::mods diff --git a/src/dusk/mods/path.hpp b/src/dusk/mods/path.hpp new file mode 100644 index 0000000000..4213c3fbf6 --- /dev/null +++ b/src/dusk/mods/path.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace dusk::mods { + +inline std::string safe_filename(std::string_view value) { + std::string result{value}; + std::ranges::replace_if( + result, + [](char character) { + return !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '.' || + character == '_' || character == '-'); + }, + '_'); + return result; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/queue.cpp b/src/dusk/mods/queue.cpp new file mode 100644 index 0000000000..4be07cb2a3 --- /dev/null +++ b/src/dusk/mods/queue.cpp @@ -0,0 +1,769 @@ +#include "queue.hpp" + +#include "dusk/hash.hpp" +#include "dusk/mod_loader.hpp" +#include "dusk/mods/path.hpp" +#include "dusk/ui/ui.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::queue { +namespace { + +using clock = std::chrono::steady_clock; + +struct VerifyResult { + std::string error; + ModMetadata metadata; + std::filesystem::path stagedPath; + bool canceled = false; +}; + +enum class PendingIntent { None, Pause, Cancel }; + +struct QueueItem { + std::string key; + Request request; + State state = State::Queued; + std::filesystem::path partialPath; + uint64_t completed = 0; + uint64_t total = 0; + std::string message; + int retryCount = 0; + clock::time_point retryAt{}; + borealis::Task task; + borealis::Task verification; + ModOperationHandle operation; + PendingIntent pendingIntent = PendingIntent::None; +}; + +std::vector queueItems; +uint64_t nextQueueKey = 1; + +QueueItem* find_queue_item(std::string_view key) { + const auto item = std::ranges::find(queueItems, key, + [](const QueueItem& candidate) { return std::string_view{candidate.key}; }); + return item == queueItems.end() ? nullptr : &*item; +} + +QueueItem* find_queue_item_by_mod_id(std::string_view id) { + const auto item = std::ranges::find(queueItems, id, + [](const QueueItem& candidate) { return std::string_view{candidate.request.id}; }); + return item == queueItems.end() ? nullptr : &*item; +} + +const Url* url_source(const QueueItem& item) { + return std::get_if(&item.request.source); +} + +const LocalFile* local_source(const QueueItem& item) { + return std::get_if(&item.request.source); +} + +std::string lowercase(std::string value) { + std::ranges::transform(value, value.begin(), [](char character) { + return character >= 'A' && character <= 'Z' ? static_cast(character + ('a' - 'A')) : + character; + }); + return value; +} + +bool valid_sha256(std::string_view value) { + return value.size() == 64 && std::ranges::all_of(value, [](char character) { + return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'); + }); +} + +std::string sha256_file( + const std::filesystem::path& path, borealis::TaskContext& context, std::string& error) { + std::ifstream input{path, std::ios::binary}; + if (!input) { + error = "Could not open the downloaded package"; + return {}; + } + + hash::Sha256 hash; + std::array buffer{}; + uint64_t completed = 0; + while (input) { + if (context.cancel_requested()) { + error = "Canceled"; + return {}; + } + input.read(reinterpret_cast(buffer.data()), buffer.size()); + const auto count = input.gcount(); + if (count > 0) { + hash.update(std::span{buffer.data(), static_cast(count)}); + completed += static_cast(count); + context.report_progress(completed); + } + } + if (!input.eof()) { + error = "Could not read the downloaded package"; + return {}; + } + + return hash.finish(); +} + +std::filesystem::path staging_path( + const std::filesystem::path& stagingDir, std::string_view modId, std::string_view key) { + return stagingDir / fmt::format("{}-{}.dusk.part", safe_filename(modId), safe_filename(key)); +} + +bool copy_to_staging(const std::filesystem::path& source, const std::filesystem::path& destination, + uint64_t total, borealis::TaskContext& context, std::string& error) { + std::error_code filesystemError; + std::filesystem::create_directories(destination.parent_path(), filesystemError); + if (filesystemError) { + error = + fmt::format("Could not create the staging directory: {}", filesystemError.message()); + return false; + } + std::ifstream input{source, std::ios::binary}; + std::ofstream output{destination, std::ios::binary | std::ios::trunc}; + if (!input || !output) { + error = "Could not stage the local package"; + return false; + } + std::array buffer{}; + uint64_t completed = 0; + while (input) { + if (context.cancel_requested()) { + error = "Canceled"; + output.close(); + std::filesystem::remove(destination, filesystemError); + return false; + } + input.read(buffer.data(), buffer.size()); + const auto count = input.gcount(); + if (count > 0) { + output.write(buffer.data(), count); + completed += static_cast(count); + context.report_progress(completed, total); + } + } + output.close(); + if (!input.eof() || !output) { + error = "Could not copy the local package"; + std::filesystem::remove(destination, filesystemError); + return false; + } + return true; +} + +VerifyResult verify_url_package(const std::filesystem::path& path, const Request& request, + const Url& source, const std::filesystem::path& stagingDir, std::string key, + borealis::TaskContext& context) { + std::error_code ec; + const auto actualSize = std::filesystem::file_size(path, ec); + if (ec) { + return {.error = fmt::format("Could not read the downloaded package: {}", ec.message())}; + } + if (actualSize != source.size) { + return {.error = "Package size mismatch"}; + } + + std::string error; + const auto actualHash = sha256_file(path, context, error); + if (!error.empty()) { + return {.error = std::move(error)}; + } + if (context.cancel_requested()) { + return {.canceled = true}; + } + if (actualHash != lowercase(source.sha256)) { + return {.error = "Package checksum mismatch"}; + } + + ModMetadata metadata; + if (!inspect_mod_bundle(path, metadata, error)) { + return {.error = fmt::format("Invalid mod package: {}", error)}; + } + if (metadata.id != request.id) { + return {.error = "Package ID does not match the catalog entry"}; + } + if (metadata.version != request.version) { + return {.error = "Package version does not match the catalog entry"}; + } + if (context.cancel_requested()) { + return {.canceled = true}; + } + const auto stagedPath = staging_path(stagingDir, metadata.id, key); + std::filesystem::create_directories(stagedPath.parent_path(), ec); + if (ec) { + return {.error = fmt::format("Could not create the staging directory: {}", ec.message())}; + } + std::string replaceError; + if (!borealis::io::atomic_replace(path, stagedPath, replaceError)) { + return {.error = std::move(replaceError)}; + } + return {.metadata = std::move(metadata), .stagedPath = stagedPath}; +} + +VerifyResult verify_local_package(const LocalFile& source, const std::filesystem::path& stagingDir, + std::string key, borealis::TaskContext& context) { + std::error_code ec; + const auto size = std::filesystem::file_size(source.path, ec); + if (ec) { + return {.error = fmt::format("Could not read the local package: {}", ec.message())}; + } + context.report_progress(0, size); + const auto stagedPath = stagingDir / fmt::format("{}.dusk.part", key); + std::string error; + if (!copy_to_staging(source.path, stagedPath, size, context, error)) { + return {.error = std::move(error), .canceled = context.cancel_requested()}; + } + // Validate the bytes handed to the loader; the source may change during copying. + ModMetadata metadata; + if (!inspect_mod_bundle(stagedPath, metadata, error)) { + std::filesystem::remove(stagedPath, ec); + return {.error = fmt::format("Invalid mod package: {}", error)}; + } + return {.metadata = std::move(metadata), .stagedPath = stagedPath}; +} + +void remove_partial(const QueueItem& item) { + std::error_code ec; + if (!item.partialPath.empty()) { + std::filesystem::remove(item.partialPath, ec); + auto metadataPath = item.partialPath; + metadataPath += ".borealis-resume.json"; + std::filesystem::remove(metadataPath, ec); + } +} + +void fail(QueueItem& item, std::string message, bool discardPartial) { + item.task = {}; + item.verification = {}; + item.state = State::Failed; + item.message = std::move(message); + item.pendingIntent = PendingIntent::None; + if (discardPartial) { + remove_partial(item); + item.completed = 0; + } + const char* title = + local_source(item) != nullptr ? "Mod package failed" : "Mod download failed"; + ui::push_toast({ + .type = "warning", + .title = title, + .content = fmt::format("{}: {}", item.request.name, item.message), + .duration = std::chrono::seconds{6}, + }); +} + +bool retryable(const borealis::http::Result& result) { + if (result.error == borealis::http::Error::Network || + result.error == borealis::http::Error::Timeout) + { + return true; + } + const int status = result.response.statusCode; + return result.error == borealis::http::Error::None && + (status == 408 || status == 425 || status == 429 || status >= 500); +} + +void schedule_retry(QueueItem& item, std::string message) { + ++item.retryCount; + const int delaySeconds = std::min(30, 1 << std::min(item.retryCount, 4)); + item.retryAt = clock::now() + std::chrono::seconds{delaySeconds}; + item.state = State::Retrying; + item.message = std::move(message); + item.task = {}; +} + +void start_download(QueueItem& item) { + const auto* source = url_source(item); + if (source == nullptr) { + fail(item, "The install source is not a URL", false); + return; + } + const auto userDir = ModLoader::instance().user_mods_dir(); + if (userDir.empty()) { + fail(item, "No writable mods directory is configured", false); + return; + } + + item.partialPath = + userDir / ".downloads" / fmt::format("{}.dusk.part", safe_filename(item.request.id)); + std::error_code ec; + std::filesystem::create_directories(item.partialPath.parent_path(), ec); + if (ec) { + fail(item, fmt::format("Could not create the download directory: {}", ec.message()), false); + return; + } + + item.pendingIntent = PendingIntent::None; + item.message.clear(); + item.total = source->size; + item.state = State::Downloading; + item.task = borealis::http::start({ + .url = source->url, + .downloadTo = item.partialPath, + .connectTimeout = std::chrono::seconds{10}, + .idleTimeout = std::chrono::seconds{15}, + .totalTimeout = std::nullopt, + }); +} + +void start_local_verification(QueueItem& item) { + const auto* source = local_source(item); + if (source == nullptr) { + fail(item, "The install source is not a local file", false); + return; + } + const auto userDir = ModLoader::instance().user_mods_dir(); + if (userDir.empty()) { + fail(item, "No writable mods directory is configured", false); + return; + } + item.state = State::Verifying; + item.message.clear(); + item.completed = 0; + std::error_code error; + item.total = std::filesystem::file_size(source->path, error); + const auto stagingDir = userDir / ".staging"; + const auto local = *source; + item.verification = + borealis::spawn([local, stagingDir, key = item.key](borealis::TaskContext& context) { + return verify_local_package(local, stagingDir, key, context); + }); +} + +void finish_download(QueueItem& item) { + const auto progress = item.task.progress(); + item.completed = std::max(item.completed, progress.completed); + + std::optional completed; + std::string taskError; + bool taskFailed = false; + try { + completed = item.task.try_take(); + } catch (const std::exception& exception) { + taskError = exception.what(); + taskFailed = true; + } catch (...) { + taskError = "The download failed"; + taskFailed = true; + } + if (!completed && !taskFailed) { + return; + } + item.task = {}; + + switch (std::exchange(item.pendingIntent, PendingIntent::None)) { + case PendingIntent::Cancel: + remove_partial(item); + item.completed = 0; + item.state = State::Canceled; + return; + case PendingIntent::Pause: + return; + case PendingIntent::None: + break; + } + + if (taskFailed) { + schedule_retry(item, std::move(taskError)); + return; + } + + if (completed->error != borealis::http::Error::None || completed->response.statusCode < 200 || + completed->response.statusCode >= 300) + { + const auto message = !completed->message.empty() ? completed->message : + completed->response.statusCode != 0 ? + fmt::format("Server returned HTTP {}", + completed->response.statusCode) : + "The download failed"; + if (retryable(*completed)) { + schedule_retry(item, message); + } else { + fail(item, message, true); + } + return; + } + + const auto* source = url_source(item); + if (source == nullptr) { + fail(item, "The install source changed", true); + return; + } + item.completed = source->size; + item.state = State::Verifying; + item.message.clear(); + const auto stagingDir = ModLoader::instance().user_mods_dir() / ".staging"; + item.verification = + borealis::spawn([path = item.partialPath, request = item.request, source = *source, + stagingDir, key = item.key](borealis::TaskContext& context) { + return verify_url_package(path, request, source, stagingDir, key, context); + }); +} + +void finish_verification(QueueItem& item) { + VerifyResult result; + try { + auto completed = item.verification.try_take(); + if (!completed) { + return; + } + result = std::move(*completed); + } catch (const std::exception& exception) { + result.error = exception.what(); + } catch (...) { + result.error = "Package verification failed"; + } + item.verification = {}; + if (result.canceled || item.pendingIntent == PendingIntent::Cancel) { + item.pendingIntent = PendingIntent::None; + if (!result.stagedPath.empty()) { + std::error_code error; + std::filesystem::remove(result.stagedPath, error); + } + remove_partial(item); + item.state = State::Canceled; + item.completed = 0; + return; + } + if (!result.error.empty()) { + fail(item, std::move(result.error), true); + return; + } + remove_partial(item); + item.partialPath = result.stagedPath; + if (const auto duplicate = find_queue_item_by_mod_id(result.metadata.id); + duplicate != nullptr && duplicate != &item && !is_terminal(duplicate->state)) + { + fail(item, "This mod already has an active install", true); + return; + } + if (local_source(item) != nullptr && !item.request.id.empty() && + (item.request.id != result.metadata.id || item.request.version != result.metadata.version)) + { + fail(item, "The local package changed after confirmation", true); + return; + } + item.request.id = result.metadata.id; + item.request.name = result.metadata.name; + item.request.version = result.metadata.version; + item.completed = item.total; + item.state = State::Handoff; + item.operation = ModLoader::instance().request_install(std::move(result.stagedPath)); +} + +Item snapshot(const QueueItem& item) { + Item result{ + .id = item.key, + .modId = item.request.id, + .name = item.request.name, + .version = item.request.version, + .state = item.state, + .completed = item.completed, + .total = item.total, + .message = item.message, + .local = local_source(item) != nullptr, + .icon = item.request.icon, + }; + if (item.task) { + result.completed = std::max(result.completed, item.task.progress().completed); + } + if (item.verification) { + const auto progress = item.verification.progress(); + result.completed = progress.completed; + if (progress.total) { + result.total = *progress.total; + } + } + if (item.state == State::Retrying) { + const auto remaining = item.retryAt - clock::now(); + result.retrySeconds = std::max( + 0, static_cast(std::chrono::ceil(remaining).count())); + } + return result; +} + +} // namespace + +bool enqueue(Request request, std::string* keyOut) { + const auto* source = std::get_if(&request.source); + const auto* local = std::get_if(&request.source); + if (source != nullptr) { + if (request.id.empty() || request.version.empty() || source->size == 0 || + !source->url.starts_with("https://") || !valid_sha256(source->sha256)) + { + return false; + } + } else if (local == nullptr || request.id.empty() || request.version.empty()) { + return false; + } + const auto total = source == nullptr ? 0 : source->size; + if (request.name.empty()) { + request.name = request.id; + } + + if (auto* existing = find_queue_item_by_mod_id(request.id)) { + if (!is_terminal(existing->state)) { + return false; + } + existing->request = std::move(request); + existing->state = State::Queued; + existing->completed = 0; + existing->total = total; + existing->partialPath.clear(); + existing->message.clear(); + existing->retryCount = 0; + existing->operation.reset(); + existing->pendingIntent = PendingIntent::None; + if (keyOut != nullptr) { + *keyOut = existing->key; + } + return true; + } + + const auto key = fmt::format("queue-{}", nextQueueKey++); + if (keyOut != nullptr) { + *keyOut = key; + } + queueItems.push_back({.key = key, .request = std::move(request), .total = total}); + return true; +} + +void update() { + for (auto item = queueItems.begin(); item != queueItems.end();) { + if (item->task && item->task.ready()) { + finish_download(*item); + } + if (item->state == State::Verifying && item->verification && item->verification.ready()) { + finish_verification(*item); + } + if (item->state == State::Handoff && item->operation && + item->operation->state != ModOperation::State::Pending) + { + item->message = item->operation->message; + item->state = item->operation->state == ModOperation::State::Succeeded ? + State::Installed : + State::InstallFailed; + item->operation.reset(); + } + ++item; + } + + for (auto& item : queueItems) { + if (item.task || item.verification || item.operation) { + return; + } + if (is_terminal(item.state) || item.state == State::Paused) { + continue; + } + if (item.state == State::Downloading || item.state == State::Verifying) { + return; + } + if (item.state == State::Retrying && clock::now() < item.retryAt) { + return; + } + if (item.state == State::Queued || item.state == State::Retrying) { + if (local_source(item) != nullptr) { + start_local_verification(item); + } else { + start_download(item); + } + } + return; + } +} + +void shutdown() noexcept { + for (auto& item : queueItems) { + if (item.task) { + item.task.cancel(); + } + if (item.verification) { + item.verification.cancel(); + } + } + queueItems.clear(); +} + +std::vector items() { + std::vector result; + result.reserve(queueItems.size()); + for (const auto& item : queueItems) { + result.push_back(snapshot(item)); + } + return result; +} + +std::optional find(std::string_view id) { + const auto* item = find_queue_item(id); + return item == nullptr ? std::nullopt : std::optional{snapshot(*item)}; +} + +std::optional find_by_mod_id(std::string_view id) { + const auto* item = find_queue_item_by_mod_id(id); + return item == nullptr ? std::nullopt : std::optional{snapshot(*item)}; +} + +bool has_active_items() { + return std::ranges::any_of( + queueItems, [](const QueueItem& item) { return !is_terminal(item.state); }); +} + +size_t item_count() noexcept { + return queueItems.size(); +} + +size_t active_count() noexcept { + return static_cast(std::ranges::count_if( + queueItems, [](const QueueItem& item) { return !is_terminal(item.state); })); +} + +std::optional first_active() { + const auto item = std::ranges::find_if( + queueItems, [](const QueueItem& candidate) { return !is_terminal(candidate.state); }); + return item == queueItems.end() ? std::nullopt : std::optional{snapshot(*item)}; +} + +size_t active_items_ahead(std::string_view id) noexcept { + size_t result = 0; + for (const auto& item : queueItems) { + if (item.request.id == id) { + break; + } + if (!is_terminal(item.state)) { + ++result; + } + } + return result; +} + +void pause(std::string_view id) { + auto* item = find_queue_item(id); + if (item == nullptr || local_source(*item) != nullptr || + item->pendingIntent == PendingIntent::Cancel) + { + return; + } + if (item->state == State::Queued || item->state == State::Retrying) { + item->state = State::Paused; + return; + } + if (item->state == State::Downloading && item->task) { + item->completed = std::max(item->completed, item->task.progress().completed); + item->pendingIntent = PendingIntent::Pause; + item->state = State::Paused; + item->task.cancel(); + } +} + +void resume(std::string_view id) { + auto* item = find_queue_item(id); + if (item == nullptr || item->state != State::Paused || + item->pendingIntent == PendingIntent::Cancel) + { + return; + } + item->state = State::Queued; +} + +void retry(std::string_view id) { + auto* item = find_queue_item(id); + if (item == nullptr) { + return; + } + if (item->state == State::InstallFailed) { + auto* mod = ModLoader::instance().find_mod(item->request.id); + if (mod != nullptr) { + if (mod->activation_failed()) { + item->message.clear(); + item->state = State::Handoff; + item->operation = ModLoader::instance().request_reactivate(item->request.id); + } else { + item->message.clear(); + item->state = State::Installed; + } + return; + } + } else if (item->state != State::Failed) { + return; + } + remove_partial(*item); + item->completed = 0; + item->retryCount = 0; + item->message.clear(); + item->state = State::Queued; +} + +void cancel(std::string_view id) { + auto* item = find_queue_item(id); + if (item == nullptr || item->state == State::Handoff || is_terminal(item->state)) { + return; + } + if (item->task) { + item->pendingIntent = PendingIntent::Cancel; + item->message = "Canceling..."; + item->task.cancel(); + return; + } + if (item->verification) { + item->pendingIntent = PendingIntent::Cancel; + item->message = "Canceling..."; + item->verification.cancel(); + return; + } + remove_partial(*item); + item->pendingIntent = PendingIntent::None; + item->completed = 0; + item->state = State::Canceled; +} + +void clear(std::string_view id) { + const auto item = std::ranges::find( + queueItems, id, [](const QueueItem& candidate) { return std::string_view{candidate.key}; }); + if (item != queueItems.end() && is_terminal(item->state)) { + queueItems.erase(item); + } +} + +void remove_by_mod_id(std::string_view id) { + std::erase_if(queueItems, + [id](const QueueItem& item) { return std::string_view{item.request.id} == id; }); +} + +void pause_all() { + std::vector ids; + for (const auto& item : queueItems) { + if (item.state == State::Queued || item.state == State::Retrying || + item.state == State::Downloading) + { + ids.push_back(item.key); + } + } + for (const auto& id : ids) { + pause(id); + } +} + +void clear_finished() { + std::erase_if(queueItems, [](const QueueItem& item) { return is_terminal(item.state); }); +} + +} // namespace dusk::mods::queue diff --git a/src/dusk/mods/queue.hpp b/src/dusk/mods/queue.hpp new file mode 100644 index 0000000000..85481d6b4a --- /dev/null +++ b/src/dusk/mods/queue.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::queue { + +enum class State { + Queued, + Downloading, + Paused, + Retrying, + Verifying, + Handoff, + Installed, + InstallFailed, + Failed, + Canceled, +}; + +[[nodiscard]] constexpr bool is_terminal(State state) noexcept { + return state == State::Installed || state == State::InstallFailed || state == State::Failed || + state == State::Canceled; +} + +[[nodiscard]] constexpr bool is_install_result(State state) noexcept { + return state == State::Installed || state == State::InstallFailed; +} + +struct Url { + std::string url; + std::string sha256; + uint64_t size = 0; +}; + +struct LocalFile { + std::filesystem::path path; +}; + +using Source = std::variant; + +struct Icon { + std::string url; + uint32_t width = 0; + uint32_t height = 0; +}; + +struct Request { + std::string id; + std::string name; + std::string version; + Source source; + std::optional icon; +}; + +struct Item { + // Queue key, independent of the mod ID. + std::string id; + std::string modId; + std::string name; + std::string version; + State state = State::Queued; + uint64_t completed = 0; + uint64_t total = 0; + std::string message; + int retrySeconds = 0; + bool local = false; + std::optional icon; +}; + +/** Adds an install, replacing failed or canceled work for the same package ID. */ +bool enqueue(Request request, std::string* key = nullptr); + +void update(); +void shutdown() noexcept; + +[[nodiscard]] std::vector items(); +[[nodiscard]] std::optional find(std::string_view key); +[[nodiscard]] std::optional find_by_mod_id(std::string_view id); +[[nodiscard]] bool has_active_items(); +[[nodiscard]] size_t item_count() noexcept; +[[nodiscard]] size_t active_count() noexcept; +[[nodiscard]] std::optional first_active(); +[[nodiscard]] size_t active_items_ahead(std::string_view id) noexcept; + +void pause(std::string_view id); +void resume(std::string_view id); +void retry(std::string_view id); +void cancel(std::string_view id); +void clear(std::string_view id); +void remove_by_mod_id(std::string_view id); +void pause_all(); +void clear_finished(); + +} // namespace dusk::mods::queue diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp index d8a9005deb..725e83ab47 100644 --- a/src/dusk/mods/svc/hook.cpp +++ b/src/dusk/mods/svc/hook.cpp @@ -297,27 +297,32 @@ bool install_backend( #endif } -void deactivate_backend(void* target, InstalledBackend& backend) { +bool deactivate_backend(void* target, InstalledBackend& backend) { #if DUSK_HAS_PREPATCH if (backend.kind == BackendKind::Prepatch) { prepatch::publish(backend.prepatchSite, nullptr); backend = {}; - return; + return true; } #endif #if DUSK_HAS_FUNCHOOK if (backend.kind == BackendKind::Funchook) { const int uninst = funchook_uninstall(backend.handle, 0); + if (uninst != 0) { + DuskLog.warn("HookSystem: funchook uninstall for {:p} failed: {}", target, + funchook_error_message(backend.handle)); + return false; + } const int destr = funchook_destroy(backend.handle); - if (uninst != 0 || destr != 0) { - DuskLog.warn("HookSystem: funchook uninstall/destroy for {:p} returned {}/{}", target, - uninst, destr); + if (destr != 0) { + DuskLog.warn("HookSystem: funchook destroy for {:p} returned {}", target, destr); } } #else (void)target; #endif backend = {}; + return true; } bool handoff_backend( @@ -352,8 +357,8 @@ bool handoff_hook(void* target, InstalledHook& entry) { #else constexpr bool prepatched = false; #endif - if (!prepatched) { - deactivate_backend(target, entry.backend); + if (!prepatched && !deactivate_backend(target, entry.backend)) { + DuskLog.fatal("HookSystem: cannot hand off a hook that remains installed at {:p}", target); } entry.active = nullptr; @@ -535,13 +540,20 @@ ModResult hook_uninstall(ModContext* context, void* fnAddr, void** originalFnSlo return MOD_INVALID_ARGUMENT; } + const bool removedActive = entry.activeStore == originalFnSlot; +#if DUSK_HAS_FUNCHOOK + if (removedActive && entry.backend.kind == BackendKind::Funchook && + !deactivate_backend(fnAddr, entry.backend)) { + return MOD_ERROR; + } +#endif + if (const auto registryIt = s_registry.find(key); registryIt != s_registry.end() && erase_callbacks(registryIt->second, context)) { s_registry.erase(registryIt); } - const bool removedActive = entry.activeStore == originalFnSlot; entry.candidates.erase(candidateIt); *originalFnSlot = nullptr; if (!removedActive) { @@ -897,7 +909,9 @@ void hook_remove_mod(LoadedMod& mod) { auto* target = reinterpret_cast(it->first); if (entry.candidates.empty()) { - deactivate_backend(target, entry.backend); + if (!deactivate_backend(target, entry.backend)) { + DuskLog.fatal("HookSystem: cannot detach a mod with a live hook at {:p}", target); + } it = s_installed.erase(it); continue; } diff --git a/src/dusk/mods/svc/http.cpp b/src/dusk/mods/svc/http.cpp index 2277e66e01..423bd50b70 100644 --- a/src/dusk/mods/svc/http.cpp +++ b/src/dusk/mods/svc/http.cpp @@ -179,7 +179,7 @@ borealis::http::Result publish_download(borealis::http::Result result, } std::filesystem::path temporary = destination; - temporary += "." + borealis::io::fs_path_to_string(staging.filename()) + ".part"; + temporary += fmt::format(".{}.part", borealis::io::fs_path_to_string(staging.filename())); std::error_code ec; std::filesystem::copy_file( staging, temporary, std::filesystem::copy_options::overwrite_existing, ec); @@ -188,7 +188,7 @@ borealis::http::Result publish_download(borealis::http::Result result, std::error_code ignored; std::filesystem::remove(temporary, ignored); result.error = borealis::http::Error::Io; - result.message = "Failed to publish download: " + copyError; + result.message = fmt::format("Failed to publish download: {}", copyError); return result; } @@ -196,14 +196,14 @@ borealis::http::Result publish_download(borealis::http::Result result, if (!borealis::io::atomic_replace(temporary, destination, replaceError)) { std::filesystem::remove(temporary, ec); result.error = borealis::http::Error::Io; - result.message = "Failed to publish download: " + replaceError; + result.message = fmt::format("Failed to publish download: {}", replaceError); return result; } std::filesystem::remove(staging, ec); return result; } catch (const std::exception& exception) { result.error = borealis::http::Error::Io; - result.message = std::string{"Failed to publish download: "} + exception.what(); + result.message = fmt::format("Failed to publish download: {}", exception.what()); return result; } catch (...) { result.error = borealis::http::Error::Io; diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 33ceede924..85035fd908 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -4,22 +4,25 @@ #include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" +#include + +#include +#include #include #include #include +#include #include namespace dusk::mods::svc { namespace { std::unordered_map s_services; +std::unordered_set s_unavailableServices; std::vector s_modules; std::string service_key(std::string_view id, const uint16_t majorVersion) { - std::string key{id}; - key.push_back('\x1f'); - key += std::to_string(majorVersion); - return key; + return fmt::format("{}\x1f{}", id, majorVersion); } const char* mod_id(const LoadedMod* mod) { @@ -48,6 +51,7 @@ bool validate_service_header(const ServiceHeader* header, const char* serviceId, void clear_services() { s_services.clear(); + s_unavailableServices.clear(); s_modules.clear(); } @@ -137,8 +141,38 @@ const ServiceRecord* find_service_record(const char* serviceId, const uint16_t m return it != s_services.end() ? &it->second : nullptr; } +std::string describe_missing_service(const char* serviceId, const uint16_t majorVersion, + const uint16_t minMinorVersion) { + const char* message = "Mod requires a service that is unavailable"; + if (std::string_view{serviceId}.starts_with(DUSKLIGHT_SERVICE_ID_PREFIX) && + !s_unavailableServices.contains(service_key(serviceId, majorVersion))) + { + if (const auto* record = find_service_record(serviceId, majorVersion)) { + if (record->provider == nullptr && record->service != nullptr && + record->minorVersion < minMinorVersion) + { + message = "Mod requires a newer Dusklight version"; + } + } else { + std::optional highestMajor; + for (const auto& [key, record] : s_services) { + if (record.provider == nullptr && record.service != nullptr && record.id == serviceId) { + highestMajor = std::max(highestMajor.value_or(0), record.majorVersion); + } + } + if (highestMajor) { + message = majorVersion > *highestMajor ? + "Mod requires a newer Dusklight version" : + "Mod must be updated for the current Dusklight version"; + } + } + } + return fmt::format("{} (missing: {})", message, serviceId); +} + ModResult register_module(const ServiceModule& module) { if (module.available != nullptr && !module.available()) { + s_unavailableServices.insert(service_key(module.id, module.majorVersion)); return MOD_UNAVAILABLE; } const auto result = register_service( @@ -146,6 +180,7 @@ ModResult register_module(const ServiceModule& module) { if (result != MOD_OK) { return result; } + s_unavailableServices.erase(service_key(module.id, module.majorVersion)); s_modules.push_back(&module); if (module.initialize != nullptr) { module.initialize(); @@ -271,6 +306,9 @@ bool ModLoader::register_static_service_exports(LoadedMod& mod) { std::string ModLoader::describe_missing_import( const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) const { + if (std::string_view{serviceId}.starts_with(DUSKLIGHT_SERVICE_ID_PREFIX)) { + return svc::describe_missing_service(serviceId, majorVersion, minMinorVersion); + } if (const auto* record = svc::find_service_record(serviceId, majorVersion)) { if (record->service == nullptr) { return fmt::format("Required service {}@{} was never published by provider '{}'", @@ -297,7 +335,7 @@ std::string ModLoader::describe_missing_import( } } - return fmt::format("Required service unavailable: {}@{}", serviceId, majorVersion); + return svc::describe_missing_service(serviceId, majorVersion, minMinorVersion); } bool ModLoader::resolve_service_imports(LoadedMod& mod) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 470208fad1..ac524384f3 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -58,6 +58,8 @@ const ServiceRecord* find_service( const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion); // Unlike find_service, also returns deferred records that have not been published yet. const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); +std::string describe_missing_service(const char* serviceId, uint16_t majorVersion, + uint16_t minMinorVersion); ModResult register_module(const ServiceModule& module); void modules_mod_deactivating(LoadedMod& mod); diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp index f10794d1ab..77f792b73c 100644 --- a/src/dusk/mods/svc/ui.cpp +++ b/src/dusk/mods/svc/ui.cpp @@ -111,9 +111,10 @@ struct UiSlot { std::string styleId; // Cached rendered values for element setters. These make the natural "set every update" // style cheap when the displayed value has not changed. - std::string elementRml; + std::string elementValue; float elementFloat = 0.0f; bool hasElementValue = false; + bool elementValueIsRml = false; }; SlotMap s_slots; @@ -547,7 +548,7 @@ ModResult ui_pane_add_text(LoadedMod& mod, uint64_t pane, const char* text, uint auto* elem = slot->pane->add_text(text); if (outElem != nullptr) { auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem); - elemSlot.elementRml = ui::escape(text); + elemSlot.elementValue = text; elemSlot.hasElementValue = true; track_element(*outElem, elemSlot, *elem); } @@ -562,8 +563,9 @@ ModResult ui_pane_add_rml(LoadedMod& mod, uint64_t pane, const char* rml, uint64 auto* elem = slot->pane->add_rml(rml); if (outElem != nullptr) { auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem); - elemSlot.elementRml = rml; + elemSlot.elementValue = rml; elemSlot.hasElementValue = true; + elemSlot.elementValueIsRml = true; track_element(*outElem, elemSlot, *elem); } return MOD_OK; @@ -793,13 +795,13 @@ ModResult ui_elem_set_text(LoadedMod& mod, uint64_t elem, const char* text) { if (slot == nullptr) { return MOD_INVALID_ARGUMENT; } - const std::string rml = ui::escape(text); - if (slot->hasElementValue && slot->elementRml == rml) { + if (slot->hasElementValue && !slot->elementValueIsRml && slot->elementValue == text) { return MOD_OK; } - slot->elementRml = rml; + slot->elementValue = text; slot->hasElementValue = true; - slot->element->SetInnerRML(slot->elementRml); + slot->elementValueIsRml = false; + ui::set_text_content(slot->element, slot->elementValue); return MOD_OK; } @@ -808,11 +810,12 @@ ModResult ui_elem_set_rml(LoadedMod& mod, uint64_t elem, const char* rml) { if (slot == nullptr) { return MOD_INVALID_ARGUMENT; } - if (slot->hasElementValue && slot->elementRml == rml) { + if (slot->hasElementValue && slot->elementValueIsRml && slot->elementValue == rml) { return MOD_OK; } - slot->elementRml = rml; + slot->elementValue = rml; slot->hasElementValue = true; + slot->elementValueIsRml = true; slot->element->SetInnerRML(rml); return MOD_OK; } @@ -936,7 +939,7 @@ ModResult ui_dialog_push(LoadedMod& mod, const UiDialogDesc& desc, uint64_t& out default: break; } - props.title = ui::escape(desc.title); + props.title = desc.title; props.bodyRml = desc.body_rml; props.icon = desc.icon != nullptr ? desc.icon : defaultIcon; props.onDismiss = [modPtr = &mod, handle, fn = desc.on_dismiss, userData = desc.user_data]( diff --git a/src/dusk/ui/achievements.cpp b/src/dusk/ui/achievements.cpp index 3fe713520e..29676f3a66 100644 --- a/src/dusk/ui/achievements.cpp +++ b/src/dusk/ui/achievements.cpp @@ -18,47 +18,39 @@ struct CategoryInfo { }; constexpr CategoryInfo kCategories[] = { - {AchievementCategory::Challenge, "Challenge"}, + {AchievementCategory::Challenge, "Challenge"}, {AchievementCategory::Collection, "Collection"}, - {AchievementCategory::Minigame, "Minigame"}, - {AchievementCategory::Misc, "Misc"}, - {AchievementCategory::Glitched, "Glitched"}, + {AchievementCategory::Minigame, "Minigame"}, + {AchievementCategory::Misc, "Misc"}, + {AchievementCategory::Glitched, "Glitched"}, }; -Rml::String build_achievement_info_rml(const Achievement& a) { - Rml::String s = fmt::format( - R"(
)" - R"({})" - R"({})" - R"(
)" - R"(

{}

)", - a.unlocked ? " unlocked" : "", - a.name, - a.unlocked ? " unlocked" : " locked", - a.unlocked ? "Unlocked" : "Locked", - a.description - ); +void append_achievement_info(Rml::Element* parent, const Achievement& a) { + auto* header = append(parent, "achievement-header"); + auto* name = append(header, "achievement-name"); + name->SetClass("unlocked", a.unlocked); + append_text(name, a.name); + auto* badge = append(header, "achievement-badge"); + badge->SetClass(a.unlocked ? "unlocked" : "locked", true); + append_text(badge, a.unlocked ? "Unlocked" : "Locked"); + auto* description = append(parent, "p"); + description->SetClass("achievement-desc", true); + append_text(description, a.description); if (a.isCounter) { - float fraction = a.goal > 0 ? float(a.progress) / float(a.goal) : 1.0f; - s += fmt::format( - R"()" - R"({} / {})", - fraction, - a.unlocked ? "progress-done" : "progress-ongoing", - a.progress, - a.goal - ); + const float fraction = a.goal > 0 ? float(a.progress) / float(a.goal) : 1.0f; + auto* progress = append(parent, "progress"); + progress->SetAttribute("value", fraction); + progress->SetClass(a.unlocked ? "progress-done" : "progress-ongoing", true); + append_text( + append(parent, "achievement-progress"), fmt::format("{} / {}", a.progress, a.goal)); } - - return s; } class AchievementRow : public FluentComponent { public: AchievementRow(Rml::Element* parent, const Achievement& a) - : FluentComponent(createRowRoot(parent)) - { + : FluentComponent(createRowRoot(parent)) { auto& btn = add_child - + @@ -162,22 +165,6 @@ DiscVerificationState verification_to_config(iso::ValidationError validation) { } } -std::string format_bytes(std::size_t bytes) { - constexpr double KiB = 1024.0; - constexpr double MiB = KiB * 1024.0; - constexpr double GiB = MiB * 1024.0; - if (bytes >= static_cast(GiB)) { - return fmt::format("{:.2f} GiB", static_cast(bytes) / GiB); - } - if (bytes >= static_cast(MiB)) { - return fmt::format("{:.0f} MiB", static_cast(bytes) / MiB); - } - if (bytes >= static_cast(KiB)) { - return fmt::format("{:.0f} KiB", static_cast(bytes) / KiB); - } - return fmt::format("{} B", bytes); -} - void begin_disc_verification(std::string path) noexcept { if (path.empty()) { return; @@ -329,7 +316,7 @@ void apply_disc_verification_result(const DiscVerificationResult& result) { state.pendingDiscPath = result.path; state.pendingDiscInfo = result.info; state.pendingDiscValidation = result.validation; - state.errorString = escape(get_error_msg(result.validation)); + state.errorString = get_error_msg(result.validation); return; } @@ -345,41 +332,34 @@ void apply_disc_verification_result(const DiscVerificationResult& result) { state.pendingDiscPath.clear(); state.pendingDiscInfo = {}; state.pendingDiscValidation = iso::ValidationError::Unknown; - state.errorString = escape(get_error_msg(result.validation)); + state.errorString = get_error_msg(result.validation); } class DiscVerificationModal : public WindowSmall { public: - DiscVerificationModal() : WindowSmall("modal", "modal-dialog") { - auto* header = append(mDialog, "div"); - header->SetClass("modal-header", true); + DiscVerificationModal() : WindowSmall("modal") { + auto* header = append(mDialog, "modal-header"); - auto* title = append(header, "div"); - title->SetClass("modal-title", true); - title->SetInnerRML("Verifying disc image"); + auto* title = append(header, "modal-title"); + append_text(title, "Verifying disc image"); auto* icon = append(header, "icon"); icon->SetClass("verifying", true); - auto* body = append(mDialog, "div"); - body->SetClass("modal-body", true); + auto* body = append(mDialog, "modal-body"); - auto* content = append(body, "div"); - content->SetClass("verification-progress", true); + auto* content = append(body, "verification-progress"); - mFileName = append(content, "div"); - mFileName->SetClass("verification-file", true); + mFileName = append(content, "verification-file"); mProgress = append(content, "progress"); mProgress->SetClass("progress-ongoing", true); mProgress->SetClass("verification-progress-bar", true); mProgress->SetAttribute("value", 0.f); - mDetail = append(content, "div"); - mDetail->SetClass("verification-detail", true); + mDetail = append(content, "verification-detail"); - auto* actions = append(mDialog, "div"); - actions->SetClass("modal-actions", true); + auto* actions = append(mDialog, "modal-actions"); mCancelButton = std::make_unique