diff --git a/.github/workflows/ui-build-self-hosted.yml b/.github/workflows/ui-build-self-hosted.yml index e5d576cda..7b7f8b600 100644 --- a/.github/workflows/ui-build-self-hosted.yml +++ b/.github/workflows/ui-build-self-hosted.yml @@ -28,13 +28,6 @@ jobs: run: npm run build working-directory: tools/ui - - name: Generate checksums - run: | - cd tools/ui/dist - for f in *; do - sha256sum "$f" | awk '{print $1, $2}' >> checksums.txt - done - - name: Upload built UI uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/ui-build.yml b/.github/workflows/ui-build.yml index 7ff5860ef..85642f3f4 100644 --- a/.github/workflows/ui-build.yml +++ b/.github/workflows/ui-build.yml @@ -32,7 +32,7 @@ jobs: - name: Build application env: HF_UI_VERSION: ${{ inputs.hf_ui_version || '' }} - LLAMA_UI_VERSION: ${{ inputs.hf_ui_version || 'b0000' }} + LLAMA_BUILD_NUMBER: ${{ inputs.hf_ui_version || 'b0000' }} run: npm run build working-directory: tools/ui @@ -40,13 +40,6 @@ jobs: run: npx vitest --project=unit --run tests/unit/pwa.spec.ts working-directory: tools/ui - - name: Generate checksums - run: | - cd tools/ui/dist - for f in *; do - sha256sum "$f" | awk '{print $1, $2}' >> checksums.txt - done - - name: Upload built UI uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/ui-publish.yml b/.github/workflows/ui-publish.yml index cec0fa52a..c3b7343c6 100644 --- a/.github/workflows/ui-publish.yml +++ b/.github/workflows/ui-publish.yml @@ -40,6 +40,12 @@ jobs: name: ui-build path: tools/ui/dist/ + - name: Create distribution archive + run: | + tar -czf dist.tar.gz -C tools/ui/dist . + sha256sum dist.tar.gz > dist.tar.gz.sha256 + mv dist.tar.gz dist.tar.gz.sha256 tools/ui/dist/ + - name: Install Hugging Face Hub CLI run: pip install -U huggingface_hub diff --git a/scripts/ui-assets.cmake b/scripts/ui-assets.cmake index 6069d9816..e5b47863e 100644 --- a/scripts/ui-assets.cmake +++ b/scripts/ui-assets.cmake @@ -4,8 +4,9 @@ # 1. Pre-built assets in SRC_DIST_DIR (manually built by user) # 2. If BUILD_UI=ON: npm build # 3. If above did not produce assets and HF_ENABLED=ON: HF Bucket download +# of dist.tar.gz (verified against dist.tar.gz.sha256) -cmake_minimum_required(VERSION 3.16) +cmake_minimum_required(VERSION 3.18) set(UI_SOURCE_DIR "" CACHE STRING "UI source directory (to run npm build)") set(UI_BINARY_DIR "" CACHE STRING "UI binary directory (to store generated files)") @@ -16,80 +17,21 @@ set(HF_ENABLED "" CACHE STRING "Whether to allow HF Bucket download (ON/O set(BUILD_UI "" CACHE STRING "Build UI via npm (ON/OFF)") set(LLAMA_UI_EMBED "" CACHE STRING "Path to llama-ui-embed helper") -# IMPORTANT: When adding PWA assets, sync across all 3 places: -# 1. tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES, PUBLIC_ENDPOINTS) -# 2. tools/server/server-http.cpp (public_endpoints) -# 3. scripts/ui-assets.cmake (ASSETS list) -# - C++ (server-http.cpp) - public endpoints (splash screens generated via helper) -# - TypeScript (constants/pwa.ts) - APPLE_DEVICES, PWA_MANIFEST, PUBLIC_ENDPOINTS +# IMPORTANT: When adding PWA assets, sync: +# - tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES, PWA_MANIFEST) # -# When adding/changing PWA assets, update tools/ui/src/lib/constants/pwa.ts first, -# then sync any new file names here and in server-http.cpp. -set(ASSETS +# The HTTP server registers routes and public endpoints for every embedded asset. +set(REQUIRED_ASSETS index.html loading.html - # PWA assets - favicon.ico - favicon-dark.ico - favicon.svg - favicon-dark.svg - pwa-64x64.png - pwa-192x192.png - pwa-512x512.png - maskable-icon-512x512.png - apple-touch-icon-180x180.png - # iOS splash screens - apple-splash-portrait-640x1136.png - apple-splash-landscape-1136x640.png - apple-splash-portrait-750x1334.png - apple-splash-landscape-1334x750.png - apple-splash-portrait-1170x2532.png - apple-splash-landscape-2532x1170.png - apple-splash-portrait-1179x2556.png - apple-splash-landscape-2556x1179.png - apple-splash-portrait-1206x2622.png - apple-splash-landscape-2622x1206.png - apple-splash-portrait-1284x2778.png - apple-splash-landscape-2778x1284.png - apple-splash-portrait-1290x2796.png - apple-splash-landscape-2796x1290.png - apple-splash-portrait-1320x2868.png - apple-splash-landscape-2868x1320.png - apple-splash-portrait-1488x2266.png - apple-splash-landscape-2266x1488.png - apple-splash-portrait-1640x2360.png - apple-splash-landscape-2360x1640.png - apple-splash-portrait-1668x2388.png - apple-splash-landscape-2388x1668.png - apple-splash-portrait-2048x2732.png - apple-splash-landscape-2732x2048.png - # iOS dark splash screens - apple-splash-portrait-dark-640x1136.png - apple-splash-landscape-dark-1136x640.png - apple-splash-portrait-dark-750x1334.png - apple-splash-landscape-dark-1334x750.png - apple-splash-portrait-dark-1170x2532.png - apple-splash-landscape-dark-2532x1170.png - apple-splash-portrait-dark-1179x2556.png - apple-splash-landscape-dark-2556x1179.png - apple-splash-portrait-dark-1206x2622.png - apple-splash-landscape-dark-2622x1206.png - apple-splash-portrait-dark-1284x2778.png - apple-splash-landscape-dark-2778x1284.png - apple-splash-portrait-dark-1290x2796.png - apple-splash-landscape-dark-2796x1290.png - apple-splash-portrait-dark-1320x2868.png - apple-splash-landscape-dark-2868x1320.png - apple-splash-portrait-dark-1640x2360.png - apple-splash-landscape-dark-2360x1640.png - apple-splash-portrait-dark-1668x2388.png - apple-splash-landscape-dark-2388x1668.png - apple-splash-portrait-dark-2048x2732.png - apple-splash-landscape-dark-2732x2048.png manifest.webmanifest sw.js - _app/version.json build.json + # post-build.js flattens and dehashes these to fixed names in the dist dir + bundle.js + bundle.css + workbox.js + version.json ) set(DIST_DIR "${UI_BINARY_DIR}/dist") @@ -98,10 +40,10 @@ set(STAMP_FILE "${UI_BINARY_DIR}/.ui-stamp") set(UI_CPP "${UI_BINARY_DIR}/ui.cpp") set(UI_H "${UI_BINARY_DIR}/ui.h") -function(assets_present out_var) +function(assets_present dir out_var) set(present TRUE) - foreach(asset ${ASSETS}) - if(NOT EXISTS "${DIST_DIR}/${asset}") + foreach(asset ${REQUIRED_ASSETS}) + if(NOT EXISTS "${dir}/${asset}") set(present FALSE) break() endif() @@ -109,30 +51,10 @@ function(assets_present out_var) set(${out_var} ${present} PARENT_SCOPE) endfunction() -function(copy_src_dist out_var) - set(${out_var} FALSE PARENT_SCOPE) - - foreach(asset ${ASSETS}) - if(NOT EXISTS "${SRC_DIST_DIR}/${asset}") - return() - endif() - endforeach() - - file(MAKE_DIRECTORY "${DIST_DIR}") - message(STATUS "UI: using pre-built assets from ${SRC_DIST_DIR}") - foreach(asset ${ASSETS}) - execute_process( - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${SRC_DIST_DIR}/${asset}" "${DIST_DIR}/${asset}" - ) - endforeach() - set(${out_var} TRUE PARENT_SCOPE) -endfunction() - function(npm_build_should_skip out_var) set(${out_var} FALSE PARENT_SCOPE) - assets_present(present) + assets_present("${DIST_DIR}" present) if(NOT present) return() endif() @@ -240,7 +162,7 @@ function(npm_build out_var) return() endif() - assets_present(present) + assets_present("${DIST_DIR}" present) if(NOT present) message(STATUS "UI: npm build finished but assets missing in ${DIST_DIR}") return() @@ -272,7 +194,7 @@ function(hf_download version out_var out_resolved) set(${out_var} FALSE PARENT_SCOPE) set(${out_resolved} "" PARENT_SCOPE) - file(MAKE_DIRECTORY "${DIST_DIR}") + set(archive "${UI_BINARY_DIR}/dist.tar.gz") set(candidates "") if(NOT "${version}" STREQUAL "") @@ -281,97 +203,67 @@ function(hf_download version out_var out_resolved) list(APPEND candidates "latest") foreach(resolved ${candidates}) - set(base "https://huggingface.co/buckets/ggml-org/${HF_BUCKET}/resolve/${resolved}") + set(base "https://huggingface.co/buckets/${HF_BUCKET}/resolve/${resolved}") - message(STATUS "UI: downloading from ${resolved}: ${base}") + message(STATUS "UI: downloading from ${resolved}: ${base}/dist.tar.gz") - set(ok TRUE) - foreach(asset ${ASSETS}) - file(DOWNLOAD "${base}/${asset}?download=true" "${DIST_DIR}/${asset}" - STATUS status TIMEOUT 60 - ) - list(GET status 0 rc) - if(NOT rc EQUAL 0) - list(GET status 1 errmsg) - message(STATUS "UI: download ${asset} from ${resolved} failed: ${errmsg}") - set(ok FALSE) - break() - endif() - message(STATUS "UI: downloaded ${asset}") - endforeach() - - if(NOT ok) + file(DOWNLOAD "${base}/dist.tar.gz?download=true" "${archive}" + STATUS status TIMEOUT 300 + ) + list(GET status 0 rc) + if(NOT rc EQUAL 0) + list(GET status 1 errmsg) + message(STATUS "UI: download dist.tar.gz from ${resolved} failed: ${errmsg}") continue() endif() - # Best-effort checksum verification - file(DOWNLOAD "${base}/checksums.txt?download=true" "${DIST_DIR}/checksums.txt" - STATUS cs_status TIMEOUT 30 + file(DOWNLOAD "${base}/dist.tar.gz.sha256?download=true" "${archive}.sha256" + STATUS status TIMEOUT 30 ) - list(GET cs_status 0 cs_rc) - if(cs_rc EQUAL 0) - message(STATUS "UI: verifying checksums") - file(STRINGS "${DIST_DIR}/checksums.txt" cs_lines) - foreach(asset ${ASSETS}) - file(SHA256 "${DIST_DIR}/${asset}" h) - string(TOLOWER "${h}" h) - string(REGEX MATCH "${h}[ \t]+${asset}" m "${cs_lines}") - if(NOT m) - message(WARNING "UI: checksum verification failed for ${asset}") - set(ok FALSE) - break() - endif() - endforeach() - if(ok) - message(STATUS "UI: all checksums verified") - endif() + list(GET status 0 rc) + if(NOT rc EQUAL 0) + list(GET status 1 errmsg) + message(STATUS "UI: download dist.tar.gz.sha256 from ${resolved} failed: ${errmsg}") + continue() endif() - if(ok) - set(${out_var} TRUE PARENT_SCOPE) - set(${out_resolved} "${resolved}" PARENT_SCOPE) - return() + # Validate sha256 checkums + file(READ "${archive}.sha256" expected) + string(REGEX MATCH "^[0-9a-fA-F]+" expected "${expected}") + string(TOLOWER "${expected}" expected) + file(SHA256 "${archive}" actual) + if("${expected}" STREQUAL "" OR NOT "${actual}" STREQUAL "${expected}") + message(STATUS "UI: checksum mismatch for dist.tar.gz from ${resolved}") + continue() endif() + + # Clear DIST_DIR to remove stale files first + file(REMOVE_RECURSE "${DIST_DIR}") + + file(ARCHIVE_EXTRACT INPUT "${archive}" DESTINATION "${DIST_DIR}") + + assets_present("${DIST_DIR}" present) + if(NOT present) + message(STATUS "UI: archive from ${resolved} is missing required assets") + continue() + endif() + + message(STATUS "UI: archive verified and extracted") + set(${out_var} TRUE PARENT_SCOPE) + set(${out_resolved} "${resolved}" PARENT_SCOPE) + return() endforeach() endfunction() -function(emit_files) - assets_present(present) +function(emit_files dist_dir) + assets_present("${dist_dir}" present) set(args "${UI_CPP}" "${UI_H}") if(present) - foreach(asset ${ASSETS}) - list(APPEND args "${asset}" "${DIST_DIR}/${asset}") - endforeach() - - # Bundle files live in _app/immutable/ — vanilla SvelteKit output, no plugin - # rewriting. Embedded names must match the exact _app/ paths that index.html - # and sw.js reference. - file(GLOB_RECURSE detected_bundle_js "${DIST_DIR}/_app/immutable/bundle.*.js") - file(GLOB_RECURSE detected_bundle_css "${DIST_DIR}/_app/immutable/assets/bundle.*.css") - file(GLOB_RECURSE detected_workbox "${DIST_DIR}/workbox-*.js") - # Compute relative path from DIST_DIR to each found file. - # e.g. /path/to/build/tools/ui/dist/_app/immutable/bundle.XXX.js - # -> _app/immutable/bundle.XXX.js - foreach(f ${detected_bundle_js}) - string(REPLACE "${DIST_DIR}/" "" rel "${f}") - list(APPEND args "${rel}" "${f}") - endforeach() - foreach(f ${detected_bundle_css}) - string(REPLACE "${DIST_DIR}/" "" rel "${f}") - list(APPEND args "${rel}" "${f}") - endforeach() - foreach(f ${detected_workbox}) - string(REPLACE "${DIST_DIR}/" "" rel "${f}") - list(APPEND args "${rel}" "${f}") - endforeach() + # llama-ui-embed embeds every top-level file in dist_dir + list(APPEND args "${dist_dir}") endif() - # Create build.json with the llama.cpp build number for UI version display. - # This is separate from SvelteKit's _app/version.json (used for SW cache invalidation). - # build.json is generated by the vite plugin (buildInfoPlugin) during npm build. - # CMake just embeds it from the dist that npm produced. - execute_process( COMMAND "${LLAMA_UI_EMBED}" ${args} RESULT_VARIABLE rc @@ -384,9 +276,10 @@ endfunction() # --------------------------------------------------------------------------- # 1. Priority 1: pre-built assets supplied in tools/ui/dist # --------------------------------------------------------------------------- -copy_src_dist(SRC_OK) +assets_present("${SRC_DIST_DIR}" SRC_OK) if(SRC_OK) - emit_files() + message(STATUS "UI: using pre-built assets from ${SRC_DIST_DIR}") + emit_files("${SRC_DIST_DIR}") return() endif() @@ -419,7 +312,7 @@ if(NOT provisioned AND HF_ENABLED) endif() endif() - assets_present(have_assets) + assets_present("${DIST_DIR}" have_assets) if(stamp_ok AND have_assets) message(STATUS "UI: HF stamp '${stamped}' matches version, skipping HF fetch") set(provisioned TRUE) @@ -439,7 +332,7 @@ endif() # 4. Fallback: warn about stale or missing assets, then emit whatever we have # --------------------------------------------------------------------------- if(NOT provisioned) - assets_present(have_assets) + assets_present("${DIST_DIR}" have_assets) if(have_assets) message(WARNING "UI: provisioning failed; embedding stale assets from ${DIST_DIR}") else() @@ -451,4 +344,4 @@ if(NOT provisioned) endif() endif() -emit_files() +emit_files("${DIST_DIR}") diff --git a/tools/server/server-http.cpp b/tools/server/server-http.cpp index 272702ce5..bb63cc598 100644 --- a/tools/server/server-http.cpp +++ b/tools/server/server-http.cpp @@ -26,6 +26,52 @@ server_http_context::server_http_context() server_http_context::~server_http_context() = default; +// transform path --> asset name ; rules: +// delete "_app/" prefix +// delete hash, for ex: bundle.HCjcCZFH.css --> bundle.css +// workbox-12bd46aa.js --> workbox.js +static std::string asset_name_from_path(const std::string & path) { + // Strip leading slash + std::string s = (!path.empty() && path[0] == '/') ? path.substr(1) : path; + // Strip _app/ prefix + if (s.size() > 5 && s.compare(0, 5, "_app/") == 0) { + s = s.substr(5); + } + // Strip hash segment from filename: + // bundle.HCjcCZFH.css -> bundle.css (name.HASH.ext) + // workbox-12bd46aa.js -> workbox.js (name-HEXHASH.ext) + size_t slash = s.rfind('/'); + std::string dir = (slash != std::string::npos) ? s.substr(0, slash + 1) : ""; + std::string file = (slash != std::string::npos) ? s.substr(slash + 1) : s; + + auto is_alnum_hash = [](const std::string & h) { + if (h.size() < 6 || h.size() > 16) return false; + for (char c : h) { if (!isalnum((unsigned char)c)) return false; } + return true; + }; + auto is_hex_hash = [](const std::string & h) { + if (h.size() < 6 || h.size() > 16) return false; + for (char c : h) { if (!isxdigit((unsigned char)c)) return false; } + return true; + }; + + size_t dot1 = file.find('.'); + if (dot1 != std::string::npos) { + size_t dot2 = file.find('.', dot1 + 1); + if (dot2 != std::string::npos && is_alnum_hash(file.substr(dot1 + 1, dot2 - dot1 - 1))) { + file = file.substr(0, dot1) + file.substr(dot2); + } else { + size_t dot = file.rfind('.'); + size_t dash = file.rfind('-', dot); + if (dash != std::string::npos && is_hex_hash(file.substr(dash + 1, dot - dash - 1))) { + file = file.substr(0, dash) + file.substr(dot); + } + } + } + + return dir + file; +} + static void log_server_request(const httplib::Request & req, const httplib::Response & res) { // skip logging requests that are regularly sent, to avoid log spam if (req.path == "/health" @@ -169,91 +215,21 @@ bool server_http_context::init(const common_params & params) { SRV_INF("api_keys: %zu keys loaded\n", params.api_keys.size()); } - // - // Helper: Generate iOS splash screen paths from device dimensions - // This centralizes PWA asset paths to avoid duplication across CMake, C++, and TypeScript. - // Source of truth: tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES) - // - auto generate_splash_endpoints = []() -> std::vector { - // Apple device dimensions (width x height) with orientation and color scheme - // Format: "orientation-dimension1xdimension2.png" or "orientation-dark-dimension1xdimension2.png" - // Based on https://developer.apple.com/design/human-interface-guidelines/app-icons - static const std::vector> splash_specs = { - // Portrait screens (light) - {"portrait", "640x1136"}, {"portrait", "750x1334"}, - {"portrait", "1170x2532"}, {"portrait", "1179x2556"}, - {"portrait", "1206x2622"}, {"portrait", "1284x2778"}, - {"portrait", "1290x2796"}, {"portrait", "1320x2868"}, - {"portrait", "1488x2266"}, {"portrait", "1640x2360"}, - {"portrait", "1668x2388"}, {"portrait", "2048x2732"}, - // Landscape screens (light) - dimensions swapped - {"landscape", "1136x640"}, {"landscape", "1334x750"}, - {"landscape", "2532x1170"}, {"landscape", "2556x1179"}, - {"landscape", "2622x1206"}, {"landscape", "2778x1284"}, - {"landscape", "2796x1290"}, {"landscape", "2868x1320"}, - {"landscape", "2266x1488"}, {"landscape", "2360x1640"}, - {"landscape", "2388x1668"}, {"landscape", "2732x2048"}, - // Portrait screens (dark) - {"portrait-dark", "640x1136"}, {"portrait-dark", "750x1334"}, - {"portrait-dark", "1170x2532"}, {"portrait-dark", "1179x2556"}, - {"portrait-dark", "1206x2622"}, {"portrait-dark", "1284x2778"}, - {"portrait-dark", "1290x2796"}, {"portrait-dark", "1320x2868"}, - {"portrait-dark", "1488x2266"}, {"portrait-dark", "1640x2360"}, - {"portrait-dark", "1668x2388"}, {"portrait-dark", "2048x2732"}, - // Landscape screens (dark) - {"landscape-dark", "1136x640"}, {"landscape-dark", "1334x750"}, - {"landscape-dark", "2532x1170"}, {"landscape-dark", "2556x1179"}, - {"landscape-dark", "2622x1206"}, {"landscape-dark", "2778x1284"}, - {"landscape-dark", "2796x1290"}, {"landscape-dark", "2868x1320"}, - {"landscape-dark", "2266x1488"}, {"landscape-dark", "2360x1640"}, - {"landscape-dark", "2388x1668"}, {"landscape-dark", "2732x2048"} - }; - - std::vector endpoints; - endpoints.reserve(splash_specs.size()); - for (const auto & [orientation, dimensions] : splash_specs) { - endpoints.push_back("/apple-splash-" + orientation + "-" + dimensions + ".png"); - } - return endpoints; - }; - // // Middlewares // - // Public endpoints list - includes health, UI, and PWA assets - // Source of truth for splash screen paths: tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES) - static const std::unordered_set get_public_endpoints = [generate_splash_endpoints]() { + // Public endpoints - API routes plus all embedded UI assets + static const std::unordered_set get_public_endpoints = []() { std::unordered_set endpoints { "/health", "/v1/health", "/models", "/v1/models", "/", - "/index.html", - // PWA assets - "/favicon.ico", - "/favicon-dark.ico", - "/favicon.svg", - "/favicon-dark.svg", - "/pwa-64x64.png", - "/pwa-192x192.png", - "/pwa-512x512.png", - "/maskable-icon-512x512.png", - "/apple-touch-icon-180x180.png", - // iOS splash screens (generated from APPLE_DEVICES in TypeScript) - // PWA runtime files - "/manifest.webmanifest", - "/sw.js", - "/version.json", - "/workbox-.js", - "/_app/version.json", - "/build.json" }; - // Add all splash screen endpoints - auto splash = generate_splash_endpoints(); - for (const auto & path : splash) { - endpoints.insert(path); + for (const llama_ui_asset & a : llama_ui_get_assets()) { + endpoints.insert("/" + a.name); } return endpoints; }(); @@ -264,13 +240,9 @@ bool server_http_context::init(const common_params & params) { return true; } - // If path is public or static file, skip validation - if (get_public_endpoints.find(req.path) != get_public_endpoints.end()) { - return true; - } - // Static assets (_app/ files, workbox runtime). These are embedded at build time - // so no API key is needed — browsers fetch them directly. - if (req.path.find("/_app/") == 0 || req.path.find("/workbox-") == 0) { + // If path is public or a UI asset (including hashed paths like /_app/bundle.XXX.js), + // skip validation + if (get_public_endpoints.count("/" + asset_name_from_path(req.path))) { return true; } @@ -394,151 +366,71 @@ bool server_http_context::init(const common_params & params) { } } else { #if defined(LLAMA_UI_HAS_ASSETS) - // Embedded assets are immutable — cache aggressively for PWA/sw offline support. - // PWA runtime files (sw.js, manifest, version.json) use no-cache for revalidation. - // Bundle files use Vite content hashes (bundle..js/css) so each build - // produces a different filename — browsers naturally get a fresh copy on upgrade. - auto serve_asset_cached = [](const std::string & name, const char * mime, bool with_isolation_headers) { - return [name, mime, with_isolation_headers](const httplib::Request & req, httplib::Response & res) { - const llama_ui_asset * a = llama_ui_find_asset(name.c_str()); - if (!a) { - res.status = 404; - return false; - } + auto serve_asset_cached = [](const std::string & name, bool isolation) { + return [name, isolation](const httplib::Request & req, httplib::Response & res) { + const llama_ui_asset * a = llama_ui_find_asset(name); + if (!a) { res.status = 404; return false; } res.set_header("ETag", a->etag); - // Check If-None-Match for conditional GET (304 Not Modified) if (const std::string & inm = req.get_header_value("If-None-Match"); !inm.empty() && (inm == a->etag || inm == std::string("W/") + a->etag)) { res.status = 304; return false; } - if (with_isolation_headers) { - // COEP and COOP headers, required by pyodide (python interpreter) + if (isolation) { res.set_header("Cross-Origin-Embedder-Policy", "require-corp"); - res.set_header("Cross-Origin-Opener-Policy", "same-origin"); + res.set_header("Cross-Origin-Opener-Policy", "same-origin"); } res.set_header("Cache-Control", "public, max-age=31536000, immutable"); - res.set_content(reinterpret_cast(a->data), a->size, mime); + res.set_content(reinterpret_cast(a->data), a->size, a->type.c_str()); return false; }; }; - auto serve_asset_nocache = [](const std::string & name, const char * mime, bool with_isolation_headers) { - return [name, mime, with_isolation_headers](const httplib::Request & /*req*/, httplib::Response & res) { - const llama_ui_asset * a = llama_ui_find_asset(name.c_str()); + auto serve_asset_nocache = [](const std::string & name) { + return [name](const httplib::Request & /*req*/, httplib::Response & res) { + const llama_ui_asset * a = llama_ui_find_asset(name); if (!a) { res.status = 404; return false; } - if (with_isolation_headers) { - res.set_header("Cross-Origin-Embedder-Policy", "require-corp"); - res.set_header("Cross-Origin-Opener-Policy", "same-origin"); - } res.set_header("Cache-Control", "no-cache"); - res.set_content(reinterpret_cast(a->data), a->size, mime); + res.set_content(reinterpret_cast(a->data), a->size, a->type.c_str()); return false; }; }; - // Bundle files in _app/immutable/ — SvelteKit outputs them here and index.html - // and sw.js reference them via these paths (vanilla build, no plugin). - auto serve_bundle = [serve_asset_cached](const httplib::Request & req, httplib::Response & res) { - std::string path = req.path; - std::string name; - const char * mime; - if (path.rfind("/_app/immutable/bundle.", 0) == 0 && path.size() > 22) { - name = path.substr(1); // strip leading / - mime = "application/javascript; charset=utf-8"; - } else if (path.rfind("/_app/immutable/assets/bundle.", 0) == 0 && path.size() > 30) { - name = path.substr(1); // strip leading / - mime = "text/css; charset=utf-8"; + // Hashed routes: browser requests contain the build hash, assets are stored without. + auto serve_hashed = [serve_asset_cached](const std::string & name) { + return serve_asset_cached(name, false); + }; + srv->Get(params.api_prefix + R"(/_app/immutable/bundle\.[^/]+\.js)", serve_hashed("bundle.js")); + srv->Get(params.api_prefix + R"(/_app/immutable/assets/bundle\.[^/]+\.css)", serve_hashed("bundle.css")); + srv->Get(params.api_prefix + R"(/workbox-[^/]+\.js)", serve_hashed("workbox.js")); + + // SPA entry — also aliased at "/_app/version.json" (referenced by the service worker) + srv->Get(params.api_prefix + "/", serve_asset_cached ("index.html", true)); + srv->Get(params.api_prefix + "/_app/version.json", serve_asset_nocache("version.json")); + + // All remaining assets registered directly from the embedded asset table. + // PWA revalidation files (sw.js, manifest, version.json) use no-cache; + // everything else is immutable. + static const std::unordered_set no_cache_names = { + "sw.js", + "manifest.webmanifest", + "version.json", + "build.json" + }; + // index.html also accessible at /index.html (with the same isolation headers as /) + srv->Get(params.api_prefix + "/index.html", serve_asset_cached("index.html", true)); + + for (const auto & a : llama_ui_get_assets()) { + if (a.name == "index.html") continue; // served at "/" and "/index.html" above + if (no_cache_names.count(a.name)) { + srv->Get(params.api_prefix + "/" + a.name, serve_asset_nocache(a.name)); } else { - res.status = 404; - return false; + srv->Get(params.api_prefix + "/" + a.name, serve_asset_cached(a.name, false)); } - return serve_asset_cached(name, mime, false)(req, res); - }; - - // _app/ paths — vanilla SvelteKit output, index.html and sw.js reference - // bundles and version.json here directly. - srv->Get(params.api_prefix + R"(/_app/immutable/bundle\.[^/]+\.js)", serve_bundle); - srv->Get(params.api_prefix + R"(/_app/immutable/assets/bundle\.[^/]+\.css)", serve_bundle); - srv->Get(params.api_prefix + "/_app/version.json", serve_asset_cached("_app/version.json", "application/json; charset=utf-8", false)); - - auto serve_workbox = [serve_asset_cached](const httplib::Request & req, httplib::Response & res) { - std::string name = req.path.substr(1); - if (name.rfind("workbox-", 0) == 0 && name.size() > 10) { - return serve_asset_cached(name, "application/javascript; charset=utf-8", false)(req, res); - } - res.status = 404; - return false; - }; - srv->Get(params.api_prefix + R"(/workbox-[^/]+\.js)", serve_workbox); - srv->Get(params.api_prefix + R"(/sw\.js)", serve_asset_cached("sw.js", "application/javascript; charset=utf-8", false)); - srv->Get(params.api_prefix + "/manifest.webmanifest", serve_asset_cached("manifest.webmanifest", "application/manifest+json; charset=utf-8", false)); - srv->Get(params.api_prefix + "/version.json", serve_asset_cached("_app/version.json", "application/json; charset=utf-8", false)); - srv->Get(params.api_prefix + "/build.json", serve_asset_cached("build.json", "application/json; charset=utf-8", false)); - - // Finally serve index.html for all other routes (SPA fallback) - srv->Get(params.api_prefix + "/", serve_asset_cached("index.html", "text/html; charset=utf-8", true)); - srv->Get(params.api_prefix + "/favicon.ico", serve_asset_cached("favicon.ico", "image/x-icon", false)); - srv->Get(params.api_prefix + "/favicon-dark.ico", serve_asset_cached("favicon-dark.ico", "image/x-icon", false)); - srv->Get(params.api_prefix + "/favicon.svg", serve_asset_cached("favicon.svg", "image/svg+xml", false)); - srv->Get(params.api_prefix + "/favicon-dark.svg", serve_asset_cached("favicon-dark.svg", "image/svg+xml", false)); - srv->Get(params.api_prefix + "/pwa-64x64.png", serve_asset_cached("pwa-64x64.png", "image/png", false)); - srv->Get(params.api_prefix + "/pwa-192x192.png", serve_asset_cached("pwa-192x192.png", "image/png", false)); - srv->Get(params.api_prefix + "/pwa-512x512.png", serve_asset_cached("pwa-512x512.png", "image/png", false)); - srv->Get(params.api_prefix + "/maskable-icon-512x512.png", serve_asset_cached("maskable-icon-512x512.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-touch-icon-180x180.png", serve_asset_cached("apple-touch-icon-180x180.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-640x1136.png", serve_asset_cached("apple-splash-portrait-640x1136.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-1136x640.png", serve_asset_cached("apple-splash-landscape-1136x640.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-750x1334.png", serve_asset_cached("apple-splash-portrait-750x1334.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-1334x750.png", serve_asset_cached("apple-splash-landscape-1334x750.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1170x2532.png", serve_asset_cached("apple-splash-portrait-1170x2532.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2532x1170.png", serve_asset_cached("apple-splash-landscape-2532x1170.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1179x2556.png", serve_asset_cached("apple-splash-portrait-1179x2556.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2556x1179.png", serve_asset_cached("apple-splash-landscape-2556x1179.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1206x2622.png", serve_asset_cached("apple-splash-portrait-1206x2622.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2622x1206.png", serve_asset_cached("apple-splash-landscape-2622x1206.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1284x2778.png", serve_asset_cached("apple-splash-portrait-1284x2778.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2778x1284.png", serve_asset_cached("apple-splash-landscape-2778x1284.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1290x2796.png", serve_asset_cached("apple-splash-portrait-1290x2796.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2796x1290.png", serve_asset_cached("apple-splash-landscape-2796x1290.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1320x2868.png", serve_asset_cached("apple-splash-portrait-1320x2868.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2868x1320.png", serve_asset_cached("apple-splash-landscape-2868x1320.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1488x2266.png", serve_asset_cached("apple-splash-portrait-1488x2266.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2266x1488.png", serve_asset_cached("apple-splash-landscape-2266x1488.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1640x2360.png", serve_asset_cached("apple-splash-portrait-1640x2360.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2360x1640.png", serve_asset_cached("apple-splash-landscape-2360x1640.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-1668x2388.png", serve_asset_cached("apple-splash-portrait-1668x2388.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2388x1668.png", serve_asset_cached("apple-splash-landscape-2388x1668.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-2048x2732.png", serve_asset_cached("apple-splash-portrait-2048x2732.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-2732x2048.png", serve_asset_cached("apple-splash-landscape-2732x2048.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-640x1136.png", serve_asset_cached("apple-splash-portrait-dark-640x1136.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-1136x640.png", serve_asset_cached("apple-splash-landscape-dark-1136x640.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-750x1334.png", serve_asset_cached("apple-splash-portrait-dark-750x1334.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-1334x750.png", serve_asset_cached("apple-splash-landscape-dark-1334x750.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1170x2532.png", serve_asset_cached("apple-splash-portrait-dark-1170x2532.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2532x1170.png", serve_asset_cached("apple-splash-landscape-dark-2532x1170.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1179x2556.png", serve_asset_cached("apple-splash-portrait-dark-1179x2556.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2556x1179.png", serve_asset_cached("apple-splash-landscape-dark-2556x1179.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1206x2622.png", serve_asset_cached("apple-splash-portrait-dark-1206x2622.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2622x1206.png", serve_asset_cached("apple-splash-landscape-dark-2622x1206.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1284x2778.png", serve_asset_cached("apple-splash-portrait-dark-1284x2778.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2778x1284.png", serve_asset_cached("apple-splash-landscape-dark-2778x1284.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1290x2796.png", serve_asset_cached("apple-splash-portrait-dark-1290x2796.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2796x1290.png", serve_asset_cached("apple-splash-landscape-dark-2796x1290.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1320x2868.png", serve_asset_cached("apple-splash-portrait-dark-1320x2868.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2868x1320.png", serve_asset_cached("apple-splash-landscape-dark-2868x1320.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1640x2360.png", serve_asset_cached("apple-splash-portrait-dark-1640x2360.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2360x1640.png", serve_asset_cached("apple-splash-landscape-dark-2360x1640.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1668x2388.png", serve_asset_cached("apple-splash-portrait-dark-1668x2388.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2388x1668.png", serve_asset_cached("apple-splash-landscape-dark-2388x1668.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-portrait-dark-2048x2732.png", serve_asset_cached("apple-splash-portrait-dark-2048x2732.png", "image/png", false)); - srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2732x2048.png", serve_asset_cached("apple-splash-landscape-dark-2732x2048.png", "image/png", false)); - srv->Get(params.api_prefix + "/manifest.webmanifest", serve_asset_nocache("manifest.webmanifest", "application/manifest+json", false)); - srv->Get(params.api_prefix + "/sw.js", serve_asset_nocache("sw.js", "application/javascript; charset=utf-8", false)); - srv->Get(params.api_prefix + "/version.json", serve_asset_nocache("version.json", "application/json", false)); + } #endif } diff --git a/tools/ui/CMakeLists.txt b/tools/ui/CMakeLists.txt index b17f85f3b..abea13228 100644 --- a/tools/ui/CMakeLists.txt +++ b/tools/ui/CMakeLists.txt @@ -1,6 +1,6 @@ set(TARGET llama-ui) -set(LLAMA_UI_HF_BUCKET "llama-ui" CACHE STRING "Hugging Face bucket name for prebuilt UI assets") +set(LLAMA_UI_HF_BUCKET "ggml-org/llama-ui" CACHE STRING "Hugging Face bucket name for prebuilt UI assets") # Backward compat: forward old var to new one if(DEFINED LLAMA_BUILD_WEBUI) diff --git a/tools/ui/embed.cpp b/tools/ui/embed.cpp index f603b8569..24617e05f 100644 --- a/tools/ui/embed.cpp +++ b/tools/ui/embed.cpp @@ -1,16 +1,42 @@ // llama-ui-embed: generate ui.cpp / ui.h that embed UI assets as C arrays. // // Usage: -// llama-ui-embed [ ]... +// llama-ui-embed [] +// +// Embeds every regular file directly under (non-recursive). +// Without , emits an empty asset table. +#include #include +#include #include #include + +#include +#include #include #include #include -#include -#include + + +static const char * mime_from_ext(const std::string & name) { + auto ext = name.rfind('.'); + if (ext == std::string::npos) return "application/octet-stream"; + std::string e = name.substr(ext + 1); + if (e == "html") return "text/html; charset=utf-8"; + if (e == "css") return "text/css"; + if (e == "js") return "application/javascript"; + if (e == "json") return "application/json"; + if (e == "webmanifest") return "application/manifest+json"; + if (e == "svg") return "image/svg+xml"; + if (e == "png") return "image/png"; + if (e == "jpg" || + e == "jpeg") return "image/jpeg"; + if (e == "ico") return "image/x-icon"; + if (e == "woff") return "font/woff"; + if (e == "woff2") return "font/woff2"; + return "application/octet-stream"; +} // Computes FNV-1a hash of the data static uint64_t fnv_hash(const uint8_t * data, size_t len) { @@ -24,10 +50,10 @@ static uint64_t fnv_hash(const uint8_t * data, size_t len) { return hash; } -static bool read_file(const std::string & path, std::vector & out) { +static bool read_file(const std::filesystem::path & path, std::vector & out) { std::ifstream f(path, std::ios::binary | std::ios::ate); if (!f) { - fprintf(stderr, "embed: cannot open %s\n", path.c_str()); + fprintf(stderr, "embed: cannot open %s\n", path.string().c_str()); return false; } const auto sz = f.tellg(); @@ -89,70 +115,124 @@ static std::string fmt(const char * pattern, ...) { return (n > 0) ? std::string(tmp, static_cast(n)) : std::string(); } +struct asset_entry { + std::string name; + std::filesystem::path path; +}; + int main(int argc, char ** argv) { - if (argc < 3 || ((argc - 3) % 2) != 0) { - fprintf(stderr, "usage: %s [ ]...\n", argv[0]); + if (argc < 3 || argc > 4) { + fprintf(stderr, "usage: %s []\n", argv[0]); return 1; } const std::string out_cpp = argv[1]; const std::string out_h = argv[2]; - const int n_assets = (argc - 3) / 2; + + std::vector assets; + if (argc == 4) { + const std::filesystem::path dir = argv[3]; + + std::error_code ec; + std::filesystem::directory_iterator it(dir, ec); + if (ec) { + fprintf(stderr, "embed: cannot iterate %s: %s\n", argv[3], ec.message().c_str()); + return 1; + } + for (const auto & entry : it) { + if (!entry.is_regular_file()) { + continue; + } + assets.push_back({ entry.path().filename().generic_string(), entry.path() }); + } + + // directory iteration order is unspecified; sort for reproducible output + std::sort(assets.begin(), assets.end(), + [](const asset_entry & a, const asset_entry & b) { return a.name < b.name; }); + } + + const int n_assets = static_cast(assets.size()); + + if (n_assets > 0) { + bool has_index = false, has_bundle_js = false, has_bundle_css = false, has_version = false; + for (const auto & a : assets) { + if (a.name == "index.html") has_index = true; + if (a.name == "bundle.js") has_bundle_js = true; + if (a.name == "bundle.css") has_bundle_css = true; + if (a.name == "version.json") has_version = true; + } + if (!has_index || !has_bundle_js || !has_bundle_css || !has_version) { + fprintf(stderr, "embed: missing required assets (need index.html, bundle.js, bundle.css, version.json); got:\n"); + for (const auto & a : assets) { + fprintf(stderr, " %s\n", a.name.c_str()); + } + return 1; + } + } std::string h; - h += "#pragma once\n\n#include \n\n"; + h += "#pragma once\n\n#include \n#include \n\n"; if (n_assets > 0) { h += "#define LLAMA_UI_HAS_ASSETS 1\n\n"; } h += "struct llama_ui_asset {\n" - " const char * name;\n" + " std::string name;\n" " const unsigned char * data;\n" - " size_t size;\n" - " const char * etag;\n" + " std::size_t size;\n" + " std::string etag;\n" + " std::string type;\n" "};\n\n" - "const llama_ui_asset * llama_ui_find_asset(const char * name);\n"; + "const llama_ui_asset * llama_ui_find_asset(const std::string & name);\n"; + h += fmt("const std::array & llama_ui_get_assets();\n", n_assets); std::string cpp; - cpp += "#include \"ui.h\"\n\n#include \n\n"; + cpp += "#include \"ui.h\"\n\n"; if (n_assets > 0) { for (int i = 0; i < n_assets; i++) { - const char * path = argv[3 + i * 2 + 1]; std::vector bytes; - if (!read_file(path, bytes)) { + if (!read_file(assets[i].path, bytes)) { return 1; } cpp += fmt("static const unsigned char asset_%d_data[] = {", i); append_bytes_hex(cpp, bytes); const auto hash = fnv_hash(bytes.data(), bytes.size()); - cpp += fmt("};\nstatic const size_t asset_%d_size = %zu;\n", + cpp += fmt("};\nstatic const std::size_t asset_%d_size = %zu;\n", i, bytes.size()); - cpp += fmt("static const char asset_%d_etag[] = \"\\\"0x%016" PRIx64 "\\\"\";\n\n", + cpp += fmt("static const char asset_%d_etag[] = \"\\\"0x%016" PRIx64 "\\\"\";\n\n", i, hash); } - cpp += "static const llama_ui_asset g_assets[] = {\n"; + cpp += fmt("static const std::array g_assets = {{\n", n_assets); for (int i = 0; i < n_assets; i++) { - cpp += fmt(" { \"%s\", asset_%d_data, asset_%d_size, asset_%d_etag },\n", - argv[3 + i * 2], i, i, i); + const std::string & name = assets[i].name; + cpp += fmt(" { \"%s\", asset_%d_data, asset_%d_size, asset_%d_etag, \"%s\" },\n", + name.c_str(), i, i, i, mime_from_ext(name)); } - cpp += "};\n\n"; + cpp += "}};\n\n"; cpp += - "const llama_ui_asset * llama_ui_find_asset(const char * name) {\n" + "const llama_ui_asset * llama_ui_find_asset(const std::string & name) {\n" " for (const auto & a : g_assets) {\n" - " if (strcmp(a.name, name) == 0) {\n" + " if (a.name == name) {\n" " return &a;\n" " }\n" " }\n" " return nullptr;\n" "}\n"; + cpp += fmt("const std::array & llama_ui_get_assets() {\n", n_assets); + cpp += " return g_assets;\n" + "}\n"; } else { cpp += - "const llama_ui_asset * llama_ui_find_asset(const char *) {\n" + "const llama_ui_asset * llama_ui_find_asset(const std::string &) {\n" " return nullptr;\n" + "}\n" + "const std::array & llama_ui_get_assets() {\n" + " static const std::array empty{};\n" + " return empty;\n" "}\n"; } diff --git a/tools/ui/package.json b/tools/ui/package.json index 6c750431f..15dca7888 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "type": "module", "scripts": { - "build": "npm run build-pwa-assets && vite build", + "build": "npm run build-pwa-assets && vite build && node scripts/post-build.js", "build-pwa-assets": "npx @vite-pwa/assets-generator --root . --config pwa-assets.config.ts && npx @vite-pwa/assets-generator --root . --config pwa-assets-dark.config.ts && node scripts/make-icons-circular.js", "dev": "bash scripts/dev.sh", "preview": "vite preview", diff --git a/tools/ui/scripts/post-build.js b/tools/ui/scripts/post-build.js new file mode 100644 index 000000000..f2e72ce89 --- /dev/null +++ b/tools/ui/scripts/post-build.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// Post-build: copy hashed/nested assets to predictable flat names. +// No file content is modified — the C++ server handles routing hashed URLs +// to the correct stored asset at runtime. +// +// Copies: +// _app/immutable/bundle.HASH.js -> bundle.js +// _app/immutable/assets/bundle.HASH.css -> bundle.css +// workbox-HEXHASH.js -> workbox.js +// _app/version.json -> version.json + +import fs from 'fs'; +import path from 'path'; + +const outDir = process.env.LLAMA_UI_OUT_DIR ?? './dist'; + +function findOne(dir, pattern) { + const files = fs.readdirSync(dir).filter((f) => pattern.test(f)); + if (files.length === 0) throw new Error(`post-build: no file matching ${pattern} in ${dir}`); + return path.join(dir, files[0]); +} + +function copyFlat(src, destName) { + const dest = path.join(outDir, destName); + fs.copyFileSync(src, dest); + console.log(`post-build: ${path.relative(outDir, src)} -> ${destName}`); +} + +const bundleJs = findOne(path.join(outDir, '_app/immutable'), /^bundle\.[^.]+\.js$/); +const bundleCss = findOne(path.join(outDir, '_app/immutable/assets'), /^bundle\.[^.]+\.css$/); +const workbox = findOne(outDir, /^workbox-[0-9a-f]+\.js$/); + +copyFlat(bundleJs, 'bundle.js'); +copyFlat(bundleCss, 'bundle.css'); +copyFlat(workbox, 'workbox.js'); + +const versionSrc = path.join(outDir, '_app/version.json'); +if (fs.existsSync(versionSrc)) { + copyFlat(versionSrc, 'version.json'); +} diff --git a/tools/ui/scripts/vite-plugin-build-info.ts b/tools/ui/scripts/vite-plugin-build-info.ts index a2dc4acae..972ba3b66 100644 --- a/tools/ui/scripts/vite-plugin-build-info.ts +++ b/tools/ui/scripts/vite-plugin-build-info.ts @@ -23,8 +23,7 @@ export function buildInfoPlugin(): Plugin { if (processed) return; processed = true; - const buildNumber = process.env.LLAMA_BUILD_NUMBER; - if (!buildNumber) return; + const buildNumber = process.env.LLAMA_BUILD_NUMBER || 'b0000'; const outDir = resolve(OUTPUT_DIR); const indexPath = resolve(outDir, 'index.html');