diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 4889be69..5ab4f633 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -377,7 +377,8 @@ Development `--static-dir` resources continue to be read from disk on each reque | GET | `/api/config/desktop-multi-instance` | read global desktop multi-instance preference | | PUT | `/api/config/desktop-multi-instance` | save global desktop multi-instance preference | | PUT | `/api/config/ui-preferences` | write UI preferences | -| GET | `/api/themes` | downloadable catalogue plus installed local AI themes | +| GET | `/api/themes` | built-in and dynamic workshop catalogues plus installed local AI themes | +| GET | `/api/themes/workshop//thumbnail` | authenticated proxy for a public workshop PNG thumbnail | | POST | `/api/themes/first-run` | legacy compatibility: durably claim the one-time National Day startup attempt | | POST | `/api/themes/import/preview` | validate a raw theme ZIP and return a read-only preview | | POST | `/api/themes/import?sha256=` | import the same previewed ZIP after confirmation | @@ -3826,6 +3827,25 @@ endpoint. Their IDs match `^ai-[a-z0-9]+(?:-[a-z0-9]+)*$` and are at most 64 characters. Definitions accept light or dark mode and all 28 HEX tokens. The definition and image routes work offline. Built-in IDs cannot be overwritten. +Workshop discovery reads every public `/api/themes?page=N` page from the update +host at its effective port plus 10000 (82 → 10082, 1234 → 11234), retaining the +HTTP/HTTPS scheme and clearing the update path. `source:"workshop"` entries use +dynamic `ai-*` IDs, names, versions, swatches and a content-addressed `key`. +Each ID appears once, at its highest public semantic version, merged with local +`installed`, `installed_version` and `update_available` state. `package` includes +the exact bytes, SHA-256 and a daemon-constructed workshop download URL. +The thumbnail proxy accepts only a 64-character hex key and constructs the +resource URL from the current configured host; remote arbitrary URLs are ignored. + +`refresh=1` updates both catalogues. Workshop failure returns `workshop_error` +without reusing old remote offers; installed themes remain as local entries. +Workshop installs recheck the public list and the confirmed package metadata, +then reuse the local import validation and atomic transaction. A withdrawn theme +cannot be newly installed; an existing installation remains usable offline. +Settings refreshes discovery on open, window focus and update-source changes. +Deleting an installed workshop theme leaves its public offer available to install +again. Pending/rejected submissions are never advertised by the public API. + Schema version 1 definitions may also include an `appearance` object: ```json diff --git a/docs/themes.md b/docs/themes.md index 127401c7..aacb9d6e 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -201,6 +201,16 @@ Use a new version if any approved artwork or palette changes after publication. ## Independent publication +The Appearance theme grid also reads approved themes dynamically from the +independent workshop. Its address uses the update server's effective port plus +10000 (for example, 82 → 10082). Opening Settings, returning from the workshop, +or changing the update source refreshes the list. All public pages are read; +the newest semantic version of each theme ID appears as one card with its local +installation state. Select a card to download, verify, install and apply it; +there is no manual ZIP import step. Workshop updates use the same confirmation +and progress flow as built-in downloads. Installed themes remain usable offline +or after withdrawal. A workshop connection failure shows a retry action. + The current aupdate root is `J:/jenkins_green/aupdate`, publicly served at `http://2017studio.imwork.net:82/aupdate/`. Copy the versioned ZIP and thumbnail under `themes/eva-01/1.0.2/`, verify the public downloads against their SHA-256 diff --git a/scripts/verify_theme_workshop.py b/scripts/verify_theme_workshop.py new file mode 100644 index 00000000..c9cf1c6a --- /dev/null +++ b/scripts/verify_theme_workshop.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Exercise the real workshop through an isolated Windows ACECode daemon. + +No production configuration or workshop data is changed. --serve keeps the +isolated daemon alive for UI verification; stop it using the printed PID. +""" +import argparse +import json +import os +from pathlib import Path +import secrets +import socket +import subprocess +import tempfile +import time +import urllib.request + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--binary', type=Path, required=True) + parser.add_argument('--update-url', default='http://2017studio.imwork.net:82/aupdate/') + parser.add_argument('--serve', action='store_true') + args = parser.parse_args() + if os.name != 'nt': + raise SystemExit('This isolated USERPROFILE harness is Windows-only.') + root = Path(tempfile.mkdtemp(prefix='ace_workshop_verify_')) + data = root / '.acecode' + data.mkdir() + (data / 'config.json').write_text(json.dumps({'upgrade': {'base_url': args.update_url, 'auto_check': False}}), encoding='utf-8') + workspace = root / 'workspace' + workspace.mkdir() + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + token = secrets.token_hex(24) + base = f'http://127.0.0.1:{port}' + env = dict(os.environ, USERPROFILE=str(root)) + static = Path(__file__).resolve().parents[1] / 'web' / 'dist' + log = (root / 'daemon.log').open('wb') + process = subprocess.Popen([str(args.binary.resolve()), 'daemon', '--foreground', f'--port={port}', + f'--token={token}', f'--cwd={workspace}', f'--run-dir={root / "run"}', f'--static-dir={static}'], + env=env, stdout=log, stderr=subprocess.STDOUT, creationflags=subprocess.CREATE_NO_WINDOW) + checks = [] + + def request(path, method='GET', payload=None, binary=False): + body = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request(base + path, body, {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, method=method) + with urllib.request.urlopen(req, timeout=45) as response: + content = response.read() + return content if binary else json.loads(content) + + def check(condition, name): + if not condition: + raise AssertionError(name) + checks.append(name) + + keep = False + try: + for _ in range(90): + if process.poll() is not None: + raise RuntimeError(f'Daemon exited; see {root / "daemon.log"}') + try: + request('/api/config/upgrade') + break + except (OSError, ValueError): + time.sleep(1) + else: + raise RuntimeError('Daemon did not become ready') + catalog = request('/api/themes?refresh=1') + offers = [entry for entry in catalog['themes'] if entry.get('source') == 'workshop'] + check(bool(offers), 'Public workshop offers appear in the client catalog') + check(len({entry['id'] for entry in catalog['themes']}) == len(catalog['themes']), 'No duplicate theme IDs') + theme = offers[0] + check(not theme['installed'], 'Fresh profile has not installed the workshop theme') + png = request(f'/api/themes/workshop/{theme["key"]}/thumbnail', binary=True) + check(png.startswith(b'\x89PNG\r\n\x1a\n'), 'Authenticated thumbnail proxy returns PNG') + request(f'/api/themes/{theme["id"]}/install', 'POST', {'confirm_download': True, 'version': theme['version'], + 'bytes': theme['package']['bytes'], 'sha256': theme['package']['sha256']}) + for _ in range(180): + job = request('/api/themes/job') + if job['state'] not in ('downloading', 'installing'): + break + time.sleep(0.25) + check(job['state'] == 'completed', f'Verified installation completes: {job.get("error", "ok")}') + definition = request(f'/api/themes/{theme["id"]}') + check(definition['id'] == theme['id'] and definition['version'] == theme['version'], 'Installed identity and version match') + check(request(f'/api/themes/{theme["id"]}/images/background', binary=True).startswith(b'\x89PNG'), 'Installed background is readable') + refreshed = request('/api/themes?refresh=1')['themes'] + matching = [entry for entry in refreshed if entry['id'] == theme['id']] + check(len(matching) == 1 and matching[0]['installed'], 'Refresh merges installed state into one workshop card') + request(f'/api/themes/{theme["id"]}', 'DELETE') + restored = next(entry for entry in request('/api/themes?refresh=1')['themes'] if entry['id'] == theme['id']) + check(not restored['installed'], 'Deleting the isolated install leaves the public offer available') + report = {'checks': checks, 'theme': {'id': theme['id'], 'name': theme['name'], 'version': theme['version']}, + 'root': str(root), 'base_url': base, 'pid': process.pid} + (root / 'result.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') + (root / 'browser-auth.txt').write_text(token, encoding='utf-8') + print(json.dumps(report, ensure_ascii=False), flush=True) + keep = args.serve + finally: + if not keep: + process.terminate() + process.wait(timeout=15) + log.close() + + +if __name__ == '__main__': + main() diff --git a/src/adapters/themes/theme_store.cpp b/src/adapters/themes/theme_store.cpp index 4ae05a7b..77d6b290 100644 --- a/src/adapters/themes/theme_store.cpp +++ b/src/adapters/themes/theme_store.cpp @@ -20,6 +20,7 @@ #include #include #include +#include namespace acecode::themes { namespace fs = std::filesystem; @@ -307,6 +308,29 @@ std::map unpack_import(const std::string& bytes) { } } // namespace +std::string theme_workshop_base(const std::string& update_base) { + std::unique_ptr url(curl_url(), curl_url_cleanup); + const auto invalid = [] { return ThemeError(400, "THEME_WORKSHOP_ADDRESS_INVALID", "Invalid workshop address in update configuration"); }; + if (!url || update_base.find('\0') != std::string::npos || + curl_url_set(url.get(), CURLUPART_URL, update_base.c_str(), CURLU_DISALLOW_USER) != CURLUE_OK) throw invalid(); + const auto part = [&](CURLUPart key, unsigned flags = 0) { + char* raw = nullptr; + const auto status = curl_url_get(url.get(), key, &raw, flags); + std::unique_ptr value(raw, curl_free); + if (status != CURLUE_OK) throw invalid(); + return std::string(value.get()); + }; + const auto scheme = part(CURLUPART_SCHEME); + if (scheme != "http" && scheme != "https") throw invalid(); + const auto port = std::stoi(part(CURLUPART_PORT, CURLU_DEFAULT_PORT)); + if (port < 1 || port > 55535) throw invalid(); + if (curl_url_set(url.get(), CURLUPART_PORT, std::to_string(port + 10000).c_str(), 0) != CURLUE_OK || + curl_url_set(url.get(), CURLUPART_PATH, "/", 0) != CURLUE_OK || + curl_url_set(url.get(), CURLUPART_QUERY, nullptr, 0) != CURLUE_OK || + curl_url_set(url.get(), CURLUPART_FRAGMENT, nullptr, 0) != CURLUE_OK) throw invalid(); + return part(CURLUPART_URL); +} + ThemeError::ThemeError(int status, std::string code, const std::string& message, std::string path) : std::runtime_error(message), status(status), code(std::move(code)), path(std::move(path)) {} @@ -417,10 +441,13 @@ ThemeStore::~ThemeStore() { json ThemeStore::catalog(bool refresh) { std::lock_guard fetch_lock(catalog_mu_); - const auto current_base = theme_base(update_base_()); + const auto update_base = update_base_(); + const auto current_base = theme_base(update_base); if (base_ != current_base) { base_ = current_base; catalog_ = nullptr; + workshop_ = nullptr; + workshop_error_ = nullptr; } bool offline = false; auto catalog_url = base_ + "catalog-v2.json"; @@ -446,8 +473,19 @@ json ThemeStore::catalog(bool refresh) { } catch (const std::exception& error) { offline = true; failure_message = error.what(); } catch (...) { offline = true; } } + if (refresh || workshop_.is_null()) { + workshop_error_ = nullptr; + try { workshop_ = fetch_workshop(theme_workshop_base(update_base)); } + catch (const std::exception&) { + // Do not keep remote offers which might have been withdrawn. + workshop_ = json::array(); + workshop_error_ = {{"error", "THEME_WORKSHOP_UNAVAILABLE"}, + {"message", "Could not load workshop themes"}}; + } + } const auto local_themes = local_catalog(); - if (catalog_.is_null() && local_themes.empty() && !installed("eva-01") && !installed(kNationalDayThemeId)) + if (catalog_.is_null() && local_themes.empty() && workshop_.empty() && !workshop_error_.is_null() && + !installed("eva-01") && !installed(kNationalDayThemeId)) throw ThemeError(503, "THEME_CATALOG_UNAVAILABLE", failure_message, catalog_url); auto result = catalog_.is_null() ? json{{"schema_version", 1}, {"themes", json::array()}} : catalog_; for (const auto* id : {kNationalDayThemeId, "eva-01"}) { @@ -481,7 +519,23 @@ json ThemeStore::catalog(bool refresh) { entry["package"]["url"] = base_ + entry["package"]["path"].get(); entry["thumbnail"]["url"] = base_ + entry["thumbnail"]["path"].get(); } - for (const auto& entry : local_themes) result["themes"].push_back(entry); + std::set workshop_ids; + for (auto entry : workshop_) { + const auto id = entry.at("id").get(); + workshop_ids.insert(id); + auto local = std::find_if(local_themes.begin(), local_themes.end(), + [&](const json& value) { return value.at("id") == id; }); + entry["installed"] = local != local_themes.end(); + entry["installed_version"] = local == local_themes.end() ? "" : local->at("version").get(); + const auto installed_version = upgrade::parse_sem_version(entry["installed_version"].get()); + const auto remote_version = upgrade::parse_sem_version(entry["version"].get()); + entry["update_available"] = installed_version && remote_version && + upgrade::compare_sem_version(*remote_version, *installed_version) > 0; + result["themes"].push_back(std::move(entry)); + } + for (const auto& entry : local_themes) + if (!workshop_ids.count(entry.at("id").get())) result["themes"].push_back(entry); + if (!workshop_error_.is_null()) result["workshop_error"] = workshop_error_; result["offline"] = offline; if (offline) result["catalog_error"] = {{"error", "THEME_CATALOG_UNAVAILABLE"}, {"message", failure_message}, {"error_path", catalog_url}}; @@ -490,14 +544,73 @@ json ThemeStore::catalog(bool refresh) { } json ThemeStore::descriptor(const std::string& id) { - if (!is_downloadable_theme(id)) throw ThemeError(404, "THEME_NOT_FOUND", "Unknown theme"); - const auto entries = catalog().at("themes"); + if (!is_downloadable_theme(id) && !is_local_theme(id)) throw ThemeError(404, "THEME_NOT_FOUND", "Unknown theme"); + // A workshop installation must still be public and match the confirmed offer. + const auto entries = catalog(is_local_theme(id)).at("themes"); for (const auto& entry : entries) { if (entry.at("id") == id && entry.contains("package")) return entry; } throw ThemeError(503, "THEME_CATALOG_UNAVAILABLE", "Theme catalog is unavailable", base_ + "catalog-v2.json"); } +json ThemeStore::fetch_workshop(const std::string& base) { + std::map latest; + std::size_t received = 0; + for (std::size_t page = 1; ; ++page) { + const auto response = transport_.fetch(base + "api/themes?page=" + std::to_string(page)); + if (response.status_code != 200 || !response.error.empty() || response.body.size() > 256 * 1024) + throw ThemeError(503, "THEME_WORKSHOP_UNAVAILABLE", "Workshop catalog request failed"); + const auto data = json::parse(response.body); + if (!data.at("themes").is_array() || !data.at("total").is_number_unsigned() || + !data.at("page_size").is_number_unsigned() || data.at("page") != page) + throw ThemeError(503, "THEME_WORKSHOP_UNAVAILABLE", "Invalid workshop pagination"); + const auto total = data.at("total").get(); + const auto size = data.at("page_size").get(); + const auto& items = data.at("themes"); + if (total > 10000 || size < 1 || size > 100 || items.size() > size || received + items.size() > total || + (items.empty() && received < total)) + throw ThemeError(503, "THEME_WORKSHOP_UNAVAILABLE", "Invalid workshop pagination"); + for (const auto& item : items) { + const auto id = item.at("id").get(); + const auto version = item.at("version").get(); + const auto name = item.at("name").get(); + const auto key = item.at("key").get(); + const json package = {{"bytes", item.at("bytes")}, {"sha256", item.at("sha256")}}; + if (item.at("status") != "approved" || !is_local_theme(id) || !version_ok(version) || + !upgrade::parse_sem_version(version) || name.empty() || name.size() > 256 || + !hex(key, 64) || item.at("sha256") != key || !asset_ok(package, kMaxPackageBytes) || + !item.at("swatches").is_array() || item.at("swatches").size() != 3 || + (item.at("mode") != "dark" && item.at("mode") != "light")) + throw ThemeError(503, "THEME_WORKSHOP_UNAVAILABLE", "Invalid workshop theme"); + for (const auto& color : item.at("swatches")) + if (!hex(color, 7, true)) throw ThemeError(503, "THEME_WORKSHOP_UNAVAILABLE", "Invalid workshop swatch"); + auto found = latest.find(id); + if (found != latest.end() && upgrade::compare_sem_version(*upgrade::parse_sem_version(version), + *upgrade::parse_sem_version(found->second.at("version").get())) <= 0) continue; + json entry = {{"id", id}, {"name", name}, {"version", version}, {"mode", item.at("mode")}, + {"source", "workshop"}, {"available", true}, {"key", key}, + {"swatches", item.at("swatches")}, {"package", package}}; + entry["package"]["url"] = base + "api/themes/" + key + "/download"; + latest[id] = std::move(entry); + } + received += items.size(); + if (received >= total) break; + if (items.size() != size || page >= 10000) + throw ThemeError(503, "THEME_WORKSHOP_UNAVAILABLE", "Incomplete workshop catalog"); + } + json entries = json::array(); + for (auto& [id, entry] : latest) entries.push_back(std::move(entry)); + return entries; +} + +std::string ThemeStore::workshop_thumbnail(const std::string& key) { + if (!hex(key, 64)) throw ThemeError(400, "THEME_INVALID_ID", "Invalid workshop key"); + const auto response = transport_.fetch(theme_workshop_base(update_base_()) + "api/themes/" + key + "/thumbnail"); + if (response.status_code != 200 || !response.error.empty() || response.body.size() > kMaxPackageBytes || !png(response.body)) + throw ThemeError(502, "THEME_PREVIEW_FAILED", "Could not load workshop thumbnail"); + return response.body; +} + json ThemeStore::claim_startup_theme() { // Atomic directory creation arbitrates across windows, stores and processes. // Retain the marker on failure or interruption: this is one attempt, not a retry policy. @@ -869,6 +982,10 @@ std::string ThemeStore::export_download(const std::string& job_id) const { json ThemeStore::remove_local(const std::string& id, const std::function& commit) { require_local(id); + std::lock_guard start_lock(start_mu_); + const auto active_job = job(); + if (busy(active_job) && active_job.value("id", "") == id) + throw ThemeError(409, "THEME_BUSY", "Theme installation is in progress"); std::lock_guard lock(local_state_->mutex); if (local_state_->active_exports.count(id)) throw ThemeError(409, "THEME_BUSY", "Theme export is in progress"); definition(id); @@ -961,13 +1078,24 @@ json ThemeStore::cancel() { cancel_.store(true); return job(); } void ThemeStore::install(json entry) { const auto id = entry.at("id").get(); const auto version = entry.at("version").get(); - const auto staging = root_ / id / (version + ".staging"); + const bool workshop = entry.value("source", "") == "workshop"; + // Workshop imports own their staging directory under the shared local lock. + const auto staging = root_ / id / (version + (workshop ? ".workshop.staging" : ".staging")); const auto archive = root_ / id / (version + ".download"); const auto download_url = entry.at("package").at("url").get(); auto failure_path = download_url; const auto expected = entry.at("package").at("bytes").get(); - auto cleanup = [&] { std::error_code ec; fs::remove(archive, ec); fs::remove_all(staging, ec); }; + bool paths_checked = !workshop; + auto cleanup = [&] { + if (!paths_checked) return; + std::error_code ec; fs::remove(archive, ec); fs::remove_all(staging, ec); + }; try { + if (workshop) { + checked_path(root_, archive.lexically_relative(root_)); + checked_path(root_, staging.lexically_relative(root_)); + paths_checked = true; + } failure_path = path_to_utf8(archive); fs::create_directories(archive.parent_path()); cleanup(); @@ -986,7 +1114,15 @@ void ThemeStore::install(json entry) { } // Keep the existing built-in download checks for its original images; // local imports and newly supported optional backgrounds are decoded. - validate_theme_files(definition, files, false); + validate_theme_files(definition, files, workshop); + if (workshop) { + if (cancel_.load()) throw ThemeError(499, "THEME_CANCELLED", "Theme download cancelled"); + // Reuse local import's immutable transaction, export lock and full ZIP validation. + import_archive(read_file(archive, kMaxPackageBytes), entry.at("package").at("sha256")); + update_job({{"state", "completed"}, {"bytes_downloaded", expected}}); + cleanup(); + return; + } failure_path = path_to_utf8(staging); for (const auto& [name, bytes] : files) write_file(staging / name, bytes); if (cancel_.load()) throw ThemeError(499, "THEME_CANCELLED", "Theme download cancelled"); diff --git a/src/adapters/themes/theme_store.hpp b/src/adapters/themes/theme_store.hpp index 6515b311..e7fab5cf 100644 --- a/src/adapters/themes/theme_store.hpp +++ b/src/adapters/themes/theme_store.hpp @@ -31,6 +31,7 @@ bool valid_theme_definition(const nlohmann::json& definition); bool valid_theme_colors(const nlohmann::json& colors); bool valid_theme_appearance(const nlohmann::json& appearance); bool valid_theme_catalog(const nlohmann::json& catalog); +std::string theme_workshop_base(const std::string& update_base); // Fixed resource names only; optional images must also be declared in the definition. std::map theme_image_files(const nlohmann::json& definition); @@ -56,6 +57,7 @@ class ThemeStore { nlohmann::json definition(const std::string& id) const; bool installed(const std::string& id) const; std::string image(const std::string& id, const std::string& kind); + std::string workshop_thumbnail(const std::string& key); nlohmann::json start(const std::string& id, const nlohmann::json& consent); nlohmann::json job() const; nlohmann::json cancel(); @@ -85,6 +87,8 @@ class ThemeStore { std::mutex start_mu_; std::mutex preview_mu_; nlohmann::json catalog_; + nlohmann::json workshop_; + nlohmann::json workshop_error_; nlohmann::json job_ = {{"state", "idle"}}; std::atomic cancel_{false}; acecode::JoiningThread worker_; @@ -98,6 +102,7 @@ class ThemeStore { void install(nlohmann::json entry); void update_job(const nlohmann::json& patch); nlohmann::json local_catalog() const; + nlohmann::json fetch_workshop(const std::string& base); }; } // namespace acecode::themes diff --git a/src/apps/web/routes/routes_themes.cpp b/src/apps/web/routes/routes_themes.cpp index b7c61e50..55109d5e 100644 --- a/src/apps/web/routes/routes_themes.cpp +++ b/src/apps/web/routes/routes_themes.cpp @@ -54,6 +54,8 @@ void WebServer::Impl::register_themes() { ([this](const crow::request& req) { return cors_preflight(req); }); CROW_ROUTE(app, "/api/themes/job/cancel").methods(crow::HTTPMethod::Options) ([this](const crow::request& req) { return cors_preflight(req); }); + CROW_ROUTE(app, "/api/themes/workshop//thumbnail").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req, const std::string&) { return cors_preflight(req); }); CROW_ROUTE(app, "/api/themes/").methods(crow::HTTPMethod::Options) ([this](const crow::request& req, const std::string&) { return cors_preflight(req); }); CROW_ROUTE(app, "/api/themes//install").methods(crow::HTTPMethod::Options) @@ -74,6 +76,22 @@ void WebServer::Impl::register_themes() { if (auto rejected = require_auth(req)) return std::move(*rejected); return respond(req, [&] { return theme_store->catalog(req.url_params.get("refresh") != nullptr); }); }); + CROW_ROUTE(app, "/api/themes/workshop//thumbnail").methods(crow::HTTPMethod::GET) + ([this, respond](const crow::request& req, const std::string& key) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + try { + crow::response response(200); + response.body = theme_store->workshop_thumbnail(key); + response.add_header("Content-Type", "image/png"); + response.add_header("Cache-Control", "private, no-store"); + response.add_header("X-Content-Type-Options", "nosniff"); + return with_cors(req, std::move(response)); + } catch (const themes::ThemeError& error) { + return respond(req, [&]() -> json { throw error; }); + } catch (...) { + return respond(req, []() -> json { throw themes::ThemeError(502, "THEME_PREVIEW_FAILED", "Could not load workshop thumbnail"); }); + } + }); CROW_ROUTE(app, "/api/themes/job").methods(crow::HTTPMethod::GET) ([this, respond](const crow::request& req) { if (auto rejected = require_auth(req)) return std::move(*rejected); diff --git a/tests/themes/theme_import_test.cpp b/tests/themes/theme_import_test.cpp index a9a0b5f4..6de5ef66 100644 --- a/tests/themes/theme_import_test.cpp +++ b/tests/themes/theme_import_test.cpp @@ -5,6 +5,7 @@ #include "utils/uuid.hpp" #include "test_support/themes/theme_test_resources.hpp" #include +#include namespace { namespace fs = std::filesystem; @@ -99,4 +100,135 @@ TEST_F(ThemeImportTest, ExistingVersionIsIdempotentButCannotBeOverwritten) { EXPECT_THROW(store.import_archive(bytes, acecode::sha256_hex(bytes)), ThemeError); EXPECT_NE(store.definition("ai-example")["name"], "Conflicting"); } + +TEST(ThemeWorkshopAddress, UsesEffectivePortAndRejectsUnsafeAddresses) { + EXPECT_EQ(theme_workshop_base("http://host:82/aupdate/?x=1#part"), "http://host:10082/"); + EXPECT_EQ(theme_workshop_base("http://host:1234/custom"), "http://host:11234/"); + EXPECT_EQ(theme_workshop_base("http://host/aupdate/"), "http://host:10080/"); + EXPECT_EQ(theme_workshop_base("https://host/aupdate/"), "https://host:10443/"); + EXPECT_EQ(theme_workshop_base("http://[::1]:1234/a"), "http://[::1]:11234/"); + EXPECT_EQ(theme_workshop_base("http://host:55535/a"), "http://host:65535/"); + for (const auto* url : {"", "file:///tmp", "ftp://host/a", "http://user:pass@host", "http://host:0", "http://host:55536"}) + EXPECT_THROW(theme_workshop_base(url), ThemeError) << url; +} + +class ThemeWorkshopTest : public ThemeImportTest { +protected: + json offers = json::array(); + std::string bytes; + std::vector urls; + bool offline = false; + bool corrupt = false; + std::size_t page_size = 1; + void SetUp() override { ThemeImportTest::SetUp(); publish(); } + void publish() { + bytes = package(); + const auto key = acecode::sha256_hex(bytes); + offers.push_back({{"id", definition["id"]}, {"name", definition["name"]}, {"version", definition["version"]}, + {"key", key}, {"sha256", key}, {"bytes", bytes.size()}, {"status", "approved"}, {"mode", "dark"}, + {"swatches", {"#ABCDEF", "#FFFFFF", "#123456"}}, {"download_url", "http://untrusted.invalid/ignored"}}); + } + ThemeTransport transport() { + return { + [this](const std::string& url) { + urls.push_back(url); + acecode::upgrade::HttpTextResult response; + response.status_code = offline ? 503 : 200; + if (url.find("/thumbnail") != std::string::npos) { response.body = png; return response; } + const auto marker = url.find("api/themes?page="); + if (marker == std::string::npos) { response.status_code = 404; return response; } + const auto page = std::stoul(url.substr(marker + 16)); + json items = json::array(); + for (auto i = (page - 1) * page_size; i < std::min(page * page_size, offers.size()); ++i) items.push_back(offers[i]); + response.body = json{{"themes", items}, {"total", offers.size()}, {"page", page}, {"page_size", page_size}}.dump(); + return response; + }, + [this](const std::string& url, const fs::path& file, const acecode::upgrade::DownloadProgressCallback& progress, + const acecode::upgrade::HttpCancelCheck&) { + urls.push_back(url); + auto downloaded = corrupt ? bytes + "tampered" : bytes; + std::ofstream stream(file, std::ios::binary); + stream.write(downloaded.data(), downloaded.size()); stream.close(); + if (progress) progress({downloaded.size()}); + acecode::upgrade::DownloadResult result; + result.status_code = 200; result.bytes_written = downloaded.size(); return result; + } + }; + } + json offer(ThemeStore& store) { + const auto current = store.catalog(true); + for (const auto& entry : current["themes"]) + if (entry["id"] == "ai-example") return entry; + return nullptr; + } + json install(ThemeStore& store, const json& entry) { + store.start("ai-example", {{"confirm_download", true}, {"version", entry["version"]}, + {"bytes", entry["package"]["bytes"]}, {"sha256", entry["package"]["sha256"]}}); + for (int i = 0; i < 1000; ++i) { + const auto job = store.job(); + if (job["state"] != "downloading" && job["state"] != "installing") return job; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ADD_FAILURE() << "Workshop installation did not finish"; + return store.job(); + } +}; + +TEST_F(ThemeWorkshopTest, ReadsAllPagesChoosesLatestAndBuildsOwnUrls) { + definition["version"] = "1.1.0"; publish(); + definition["id"] = "ai-second"; publish(); + ThemeStore store(root / "themes", "http://host:82/aupdate/", transport()); + const auto catalog = store.catalog(true); + ASSERT_EQ(catalog["themes"].size(), 3u); // Offline EVA plus two dynamic IDs. + const auto entry = offer(store); + EXPECT_EQ(entry["version"], "1.1.0"); + EXPECT_EQ(entry["source"], "workshop"); + EXPECT_EQ(entry["installed"], false); + EXPECT_EQ(entry["package"]["url"], "http://host:10082/api/themes/" + offers[1]["key"].get() + "/download"); + EXPECT_NE(std::find(urls.begin(), urls.end(), "http://host:10082/api/themes?page=3"), urls.end()); + EXPECT_EQ(store.workshop_thumbnail(offers[0]["key"]), png); + EXPECT_THROW(store.workshop_thumbnail("../escape"), ThemeError); +} + +TEST_F(ThemeWorkshopTest, InstallRefreshUpdateWithdrawalAndOfflinePreserveLocalTheme) { + ThemeStore store(root / "themes", "http://host:82/aupdate/", transport()); + EXPECT_EQ(install(store, offer(store))["state"], "completed"); + EXPECT_EQ(store.definition("ai-example")["appearance"], definition["appearance"]); + EXPECT_EQ(offer(store)["installed"], true); + definition["version"] = "1.1.0"; publish(); + EXPECT_EQ(offer(store)["update_available"], true); + EXPECT_EQ(install(store, offer(store))["state"], "completed"); + EXPECT_EQ(offer(store)["installed_version"], "1.1.0"); + offers = json::array(); + EXPECT_EQ(offer(store)["source"], "local"); + offline = true; + EXPECT_EQ(offer(store)["installed"], true); + EXPECT_EQ(store.image("ai-example", "thumbnail"), png); + EXPECT_TRUE(store.catalog(true).contains("workshop_error")); +} + +TEST_F(ThemeWorkshopTest, RevalidatesApprovalAndIntegrityBeforeInstallation) { + ThemeStore store(root / "themes", "http://host:82/aupdate/", transport()); + const auto entry = offer(store); + offers[0]["status"] = "pending"; + EXPECT_THROW(install(store, entry), ThemeError); + EXPECT_FALSE(store.installed("ai-example")); + offers[0]["status"] = "approved"; + corrupt = true; + EXPECT_EQ(install(store, entry)["state"], "failed"); + EXPECT_FALSE(store.installed("ai-example")); + corrupt = false; + definition["version"] = "1.2.0"; publish(); + EXPECT_THROW(install(store, entry), ThemeError); +} + +TEST_F(ThemeWorkshopTest, SourceSwitchAndWithdrawalCannotReuseRemoteOffers) { + std::string base = "http://first:82/aupdate/"; + ThemeStore store(root / "themes", [&] { return base; }, transport()); + EXPECT_EQ(offer(store)["source"], "workshop"); + base = "http://second:1234/custom/"; + EXPECT_EQ(offer(store)["package"]["url"], "http://second:11234/api/themes/" + offers[0]["key"].get() + "/download"); + offers = json::array(); + EXPECT_TRUE(offer(store).is_null()); +} } diff --git a/tests/themes/theme_store_test.cpp b/tests/themes/theme_store_test.cpp index ef5da302..d627aa9d 100644 --- a/tests/themes/theme_store_test.cpp +++ b/tests/themes/theme_store_test.cpp @@ -201,9 +201,10 @@ TEST_F(ThemeStoreTest, LegacyMirrorFallsBackOnlyWhenExpandedCatalogIsMissing) { }; ThemeStore store(root / "cache", "https://example.com/aupdate/", io); EXPECT_EQ(store.catalog()["themes"][0]["id"], "eva-01"); - ASSERT_EQ(urls.size(), 2u); + ASSERT_EQ(urls.size(), 3u); EXPECT_EQ(urls[0], "https://example.com/aupdate/themes/catalog-v2.json"); EXPECT_EQ(urls[1], "https://example.com/aupdate/themes/catalog.json"); + EXPECT_EQ(urls[2], "https://example.com:10443/api/themes?page=1"); EXPECT_EQ(downloads.load(), 0); } diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 5266576e..5c77f600 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,10 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + '已安装': 'Installed', + '未安装': 'Not installed', + '下载{{p0}}主题': 'Download {{p0}} theme', + '暂时无法获取工坊主题,请检查网络后重试': 'Could not load workshop themes. Check your connection and try again.', // Settings > Appearance: variants and motion for themes with the HUD chrome. '主题风格': 'Theme style', '切换当前主题的配色风格': 'Switch the color style of the current theme', diff --git a/web/src/components/ThemeCards.jsx b/web/src/components/ThemeCards.jsx index 04a5aa7b..b091a2d8 100644 --- a/web/src/components/ThemeCards.jsx +++ b/web/src/components/ThemeCards.jsx @@ -7,28 +7,36 @@ import { api } from '../lib/api.js'; import { BUILTIN_THEME_CARDS, EVA_THEME_ID, NATIONAL_DAY_THEME_ID, themeDownloadPercent, themeJobActive, themePackageSize } from '../lib/themePackages.js'; import { canManageTheme, createThemeExportController, EMPTY_THEME_EXPORT, themeExportBusy, themeExportProgress, themeManagementFailure } from '../lib/themeExports.js'; -export function LocalThemeCard({ entry, selected, onSelect, onExport, onDelete, actionsDisabled, deleting, exporting }) { +export function LocalThemeCard({ entry, selected, onSelect, onExport, onDelete, actionsDisabled, deleting, exporting, job, onCancel, busy = false }) { const [thumbnail, setThumbnail] = useState(''); useEffect(() => { let active = true, url = ''; setThumbnail(''); - api.readThemeImage(entry.id, 'thumbnail', entry.version).then((blob) => { + const request = entry.source === 'workshop' && !entry.installed + ? api.readWorkshopThumbnail(entry.key) + : api.readThemeImage(entry.id, 'thumbnail', entry.installed_version || entry.version); + request.then((blob) => { if (!active) return; url = URL.createObjectURL(blob); setThumbnail(url); }).catch(() => {}); return () => { active = false; if (url) URL.revokeObjectURL(url); }; - }, [entry.id, entry.version]); + }, [entry.id, entry.version, entry.installed_version, entry.source, entry.key, entry.installed]); + const downloading = themeJobActive(job) && job.id === entry.id; + const canDownload = entry.source === 'workshop' && (!entry.installed || entry.update_available) && !busy; return (
{thumbnail && } - + {downloading && } {canManageTheme(entry) && (
- {downloading && ( -
-
- {job.state === 'installing' ? '正在校验并安装…' : `下载中 ${themeDownloadPercent(job)}%`} - -
-
- -
-
{themePackageSize(job.bytes_downloaded)} / {themePackageSize(job.bytes_total)}
-
- )} + {downloading && downloads.controller.cancel()} />}
; })}
@@ -139,11 +138,14 @@ export function ThemeCards({ options, selected, onSelect, downloads, onCreateAiT
- {(downloads?.localEntries || []).map((local) => { void exports.start(theme); }} onDelete={(theme) => { setDeleteError(null); setDeleteTarget(theme); }} - actionsDisabled={exportBusy || deleting || !!downloads?.deletingId} deleting={downloads?.deletingId === local.id} + actionsDisabled={busy || exportBusy || deleting || !!downloads?.deletingId} deleting={downloads?.deletingId === local.id} + busy={busy} job={job} onCancel={() => downloads.controller.cancel()} exporting={exportBusy && exportState.entry?.id === local.id} />)}
+ {downloads?.workshopError &&

{downloads.workshopError}

} {downloads?.error &&

{downloads.error}

} {deleteTarget && ( { if (!deleting) setDeleteTarget(null); }} dismissOnBackdrop={!deleting} dismissOnEscape={!deleting}> @@ -187,9 +189,9 @@ export function ThemeCards({ options, selected, onSelect, downloads, onCreateAiT )} {confirmation && ( - setConfirmation(null)}> + setConfirmation(null)} dismissOnBackdrop={false}>
-

{confirmation.id === NATIONAL_DAY_THEME_ID ? '下载国庆节主题' : '下载 EVA 初号机主题'}

+

{`下载${confirmation.name || (confirmation.id === NATIONAL_DAY_THEME_ID ? '国庆节' : 'EVA 初号机')}主题`}

需要下载 {themePackageSize(confirmation.package.bytes)},下载完成后自动应用。

{confirmation.installed && ( +
+
+ +
+
{themePackageSize(job.bytes_downloaded)} / {themePackageSize(job.bytes_total)}
+ ; +} + function ThemeActionError({ failure }) { return

{failure.message}

diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 605fea72..e8ba0410 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -264,6 +264,7 @@ export const sourceCatalogs = { "s_150dd0cecdc803d0": "当前工作区", "s_150f3c28d9d036af": "已清除标题", "s_155c2f5d0a3b2394": "笔记本", + "s_156219e305f6cebc": "未安装", "s_15701b7e88887282": "来源:", "s_1573c0b4957ae7cf": "需要你确认:{{p0}}", "s_15a81b9294c7cefe": "基础信息", @@ -369,7 +370,6 @@ export const sourceCatalogs = { "s_1d1e80375fb32df2": "中断当前任务", "s_1d2a90550a9d6317": "重新获取", "s_1d37a7a0e04922a7": "记住规则失败", - "s_1d4800bfa2d1c0f3": "下载国庆节主题", "s_1d4ce5a56a78c984": "{{p0}} 行 · {{p1}} 字符", "s_1d662cbe70af4028": "加载技能失败:", "s_1d74dc41f6a79b29": "展开/收起右侧面板", @@ -918,6 +918,7 @@ export const sourceCatalogs = { "s_4e033adb8cd398ff": "复制附件名", "s_4e55dcd70d111b0c": "无法获取文件夹路径", "s_4e6fd0e28c55860b": "停用", + "s_4e71ae67935607dc": "下载{{p0}}主题", "s_4e8c405c2b796248": "此组合键由系统、浏览器或文本编辑保留,请换一组按键。", "s_4eafa9e925b30bcd": "自定义", "s_4eb2c65beb7f5731": "使用已下载版本", @@ -1352,7 +1353,6 @@ export const sourceCatalogs = { "s_754a9d5828d502f1": "四", "s_756119c3f91c025b": "收起项目栏", "s_7580b1501edb6fff": "此电脑", - "s_7587607a6342dc0b": "下载 EVA 初号机主题", "s_758d1aea9c4b5b50": "侧边聊天存续时间", "s_75c306a7bcac0b15": "来自审计记录。加入白名单后记得保存;加入黑名单会让 AI 明确知道这条路径不可碰。", "s_75d889ac43a261ad": "已允许", @@ -1955,6 +1955,7 @@ export const sourceCatalogs = { "s_a898478be1813b95": "连接中", "s_a898d7c3d78cb1c0": "下一个", "s_a8a4f1b7076301d3": "复制失败:", + "s_a8b6c39dcabfc14b": "已安装", "s_a8c4c1a5fed8bdad": "请填写 model", "s_a8c81f93f2e0e3b6": "浏览", "s_a8ca3c13eb75866d": "工具调用", @@ -2074,6 +2075,7 @@ export const sourceCatalogs = { "s_b387dca9c27793f0": "取消导出失败,请重试", "s_b38dd6d2c500dda6": "显示桌面宠物", "s_b3bc6035b32575a6": "discord bot token 机器人 服务器 im channel 消息通道 连接 取消连接", + "s_b3c5b8ae0fe9104a": "暂时无法获取工坊主题,请检查网络后重试", "s_b3dd7d131a1959f4": "直接用自然语言交代任务,或输入 / 查看可用命令。发送后 ACECode 会在所选范围内工作。", "s_b40cae1f1f4446f0": "关闭窗口时您希望执行什么操作?退出应用将停止所有正在运行的任务和定时任务。", "s_b40f2c82dca202be": "当前后端不隔离网络。", @@ -3227,6 +3229,7 @@ export const sourceCatalogs = { "s_150dd0cecdc803d0": "Current workspace", "s_150f3c28d9d036af": "Title cleared", "s_155c2f5d0a3b2394": "Notebook", + "s_156219e305f6cebc": "Not installed", "s_15701b7e88887282": "source:", "s_1573c0b4957ae7cf": "Needs your confirmation: {{p0}}", "s_15a81b9294c7cefe": "Basic information", @@ -3332,7 +3335,6 @@ export const sourceCatalogs = { "s_1d1e80375fb32df2": "Interrupt current task", "s_1d2a90550a9d6317": "Retry", "s_1d37a7a0e04922a7": "Could not remember rule", - "s_1d4800bfa2d1c0f3": "Download National Day theme", "s_1d4ce5a56a78c984": "{{p0}} lines · {{p1}} characters", "s_1d662cbe70af4028": "Failed to load skills:", "s_1d74dc41f6a79b29": "Toggle right panel", @@ -3881,6 +3883,7 @@ export const sourceCatalogs = { "s_4e033adb8cd398ff": "Copy attachment name", "s_4e55dcd70d111b0c": "Unable to get folder path", "s_4e6fd0e28c55860b": "deactivate", + "s_4e71ae67935607dc": "Download {{p0}} theme", "s_4e8c405c2b796248": "This combination is reserved by the system, browser or text editor. Choose another combination.", "s_4eafa9e925b30bcd": "Custom", "s_4eb2c65beb7f5731": "Use downloaded version", @@ -4315,7 +4318,6 @@ export const sourceCatalogs = { "s_754a9d5828d502f1": "Four", "s_756119c3f91c025b": "Collapse sidebar", "s_7580b1501edb6fff": "This PC", - "s_7587607a6342dc0b": "Download EVA Unit-01 theme", "s_758d1aea9c4b5b50": "Side chat retention time", "s_75c306a7bcac0b15": "From the audit log. Remember to save after adding to an allow list; adding to the deny list tells the AI explicitly that the path is off limits.", "s_75d889ac43a261ad": "allowed", @@ -4918,6 +4920,7 @@ export const sourceCatalogs = { "s_a898478be1813b95": "Connecting", "s_a898d7c3d78cb1c0": "Next", "s_a8a4f1b7076301d3": "Copy failed:", + "s_a8b6c39dcabfc14b": "Installed", "s_a8c4c1a5fed8bdad": "Please fill in model", "s_a8c81f93f2e0e3b6": "Browse", "s_a8ca3c13eb75866d": "Tool call", @@ -5037,6 +5040,7 @@ export const sourceCatalogs = { "s_b387dca9c27793f0": "Could not cancel the export. Try again.", "s_b38dd6d2c500dda6": "Show desktop pet", "s_b3bc6035b32575a6": "discord bot token server im channel messaging connect disconnect", + "s_b3c5b8ae0fe9104a": "Could not load workshop themes. Check your connection and try again.", "s_b3dd7d131a1959f4": "Tell tasks directly in natural language, or enter / view available commands. After sending ACECode will work within the selected range.", "s_b40cae1f1f4446f0": "What should happen when you close the window? Exiting the app stops all running tasks and scheduled tasks.", "s_b40f2c82dca202be": "The current backend does not isolate the network.", diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 6cde0508..024856f8 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -684,6 +684,7 @@ export function createApi(base = null) { logoutGrok: () => request('DELETE', '/api/grok/auth', undefined, base), getUiPreferences: () => request('GET', '/api/config/ui-preferences', undefined, base), getThemes: (refresh = false) => request('GET', `/api/themes${refresh ? '?refresh=1' : ''}`, undefined, base), + readWorkshopThumbnail: (key) => request('GET', `/api/themes/workshop/${encodeURIComponent(key)}/thumbnail`, undefined, base, { responseType: 'blob' }), claimStartupTheme: () => request('POST', '/api/themes/first-run', {}, base), getTheme: (id) => request('GET', `/api/themes/${encodeURIComponent(id)}`, undefined, base), getThemeJob: () => request('GET', '/api/themes/job', undefined, base), diff --git a/web/src/lib/themeExports.js b/web/src/lib/themeExports.js index 3fe335fe..563b570e 100644 --- a/web/src/lib/themeExports.js +++ b/web/src/lib/themeExports.js @@ -5,7 +5,7 @@ const EXPORT_STATES = new Set(['preparing', 'compressing', 'saving', 'completed' export const EMPTY_THEME_EXPORT = Object.freeze({ status: 'idle', entry: null, job: null, filename: '', hadCompression: false, cancelling: false, canCancel: true, error: null }); export function canManageTheme(entry) { - return isAiColorTheme(entry?.id) && entry.source === 'local' && entry.installed === true; + return isAiColorTheme(entry?.id) && ['local', 'workshop'].includes(entry.source) && entry.installed === true; } export function themeExportFilename(entry) { diff --git a/web/src/lib/themeManagementUi.test.js b/web/src/lib/themeManagementUi.test.js index c4ea8fb9..14a5f120 100644 --- a/web/src/lib/themeManagementUi.test.js +++ b/web/src/lib/themeManagementUi.test.js @@ -32,6 +32,21 @@ function components(states = {}) { const local = { id: 'ai-eva', name: 'EVA 初号机 · 自定义', source: 'local', installed: true, version: '1.0.0', swatches: ['#ABCDEF', '#FFFFFF', '#123456'] }; const options = [{ key: 'blue', label: '蓝色' }, { key: 'orange', label: '橙色' }]; const props = { options, selected: local.id, onSelect() {}, onCreateAiTheme() {}, downloads: { localEntries: [local], entry: { installed: true } } }; +await run('dynamic workshop card renders before installation and installed IDs appear once', () => { + const { ThemeCards } = components(); + const remote = { ...local, source: 'workshop', installed: false, key: 'a'.repeat(64), name: '工坊动态主题' }; + const html = renderToStaticMarkup(React.createElement(ThemeCards, { ...props, selected: 'blue', + downloads: { localEntries: [local], workshopEntries: [remote], job: { state: 'idle' } } })); + assert.equal((html.match(/data-theme-id="ai-eva"/g) || []).length, 1); + assert.match(html, /工坊动态主题/); + assert.match(html, /未安装/); + assert.doesNotMatch(html, /导出主题|删除主题/); + const installed = renderToStaticMarkup(React.createElement(ThemeCards, { ...props, + downloads: { localEntries: [local], workshopEntries: [{ ...remote, installed: true, update_available: true }], job: { state: 'idle' } } })); + assert.equal((installed.match(/data-theme-id="ai-eva"/g) || []).length, 1); + assert.match(installed, /可更新/); + assert.match(installed, /导出主题/); +}); function descendants(element) { if (!React.isValidElement(element)) return []; return [element, ...React.Children.toArray(element.props.children).flatMap(descendants)]; diff --git a/web/src/lib/themePackages.js b/web/src/lib/themePackages.js index c75c0411..3a8cec75 100644 --- a/web/src/lib/themePackages.js +++ b/web/src/lib/themePackages.js @@ -221,7 +221,7 @@ export function applyInstalledTheme(root, definition, backgroundUrl, extraUrls = } export function themeDownloadConsent(entry) { - if (!isDownloadableColorTheme(entry?.id) || !Number.isSafeInteger(entry?.package?.bytes) + if (!(isDownloadableColorTheme(entry?.id) || (isAiColorTheme(entry?.id) && entry.source === 'workshop')) || !Number.isSafeInteger(entry?.package?.bytes) || entry.package.bytes <= 0 || !/^[a-f0-9]{64}$/i.test(entry.package.sha256) || typeof entry.version !== 'string') throw new Error('暂时无法获取主题下载大小,请重试'); return { confirm_download: true, version: entry.version, bytes: entry.package.bytes, sha256: entry.package.sha256 }; @@ -244,6 +244,7 @@ export function themeErrorText(error) { if (code === 'THEME_INVALID_PACKAGE') return '主题包校验失败,请重新下载'; if (code === 'THEME_NOT_INSTALLED') return '主题资源尚未下载或已损坏,请重新下载'; if (code === 'THEME_CATALOG_UNAVAILABLE') return '暂时无法获取主题信息,请检查网络后重试'; + if (code === 'THEME_WORKSHOP_UNAVAILABLE') return '暂时无法获取工坊主题,请检查网络后重试'; if (code === 'THEME_DOWNLOAD_BUSY') return '主题正在下载,请稍候'; if (code === 'THEME_SAVE_FAILED') return '无法保存主题资源,请检查文件写入权限'; return '主题下载或应用失败,请重试'; @@ -259,7 +260,7 @@ export function themeFailure(error, fallbackPath = '') { export function localThemeEntries(catalog) { const seen = new Set(); return (Array.isArray(catalog?.themes) ? catalog.themes : []).filter((entry) => { - if (!isAiColorTheme(entry?.id) || entry.source !== 'local' || entry.installed !== true + if (!isAiColorTheme(entry?.id) || !['local', 'workshop'].includes(entry.source) || entry.installed !== true || typeof entry.name !== 'string' || !entry.name.trim() || seen.has(entry.id)) return false; seen.add(entry.id); return true; @@ -275,10 +276,10 @@ export function releaseThemeResource(cache, id, revoke = (url) => URL.revokeObje // The controller outlives Settings. A later selection revokes an older // download's automatic application, while the useful local install can finish. export function createThemeDownloadController({ api, prepare, apply, remove = (id) => api.deleteTheme(id), forget, onChange, wait = () => new Promise((resolve) => setTimeout(resolve, 400)) }) { - let active = true, revision = 0, polling = null, intent = null; + let active = true, revision = 0, catalogRevision = 0, polling = null, intent = null; let startup = null, userIntent = false; const removedIds = new Set(); - let state = { entry: null, entries: [], localEntries: [], deletingId: '', loading: false, error: '', failure: null, job: { state: 'idle' } }; + let state = { entry: null, entries: [], localEntries: [], workshopEntries: [], workshopError: '', deletingId: '', loading: false, error: '', failure: null, job: { state: 'idle' } }; const patch = (value) => { state = { ...state, ...value }; if (active) onChange?.(state); }; const report = (error, notify = false, path = state.entry?.package?.url || state.entry?.package?.path || '', silent = false) => { if (silent) return; @@ -286,18 +287,24 @@ export function createThemeDownloadController({ api, prepare, apply, remove = (i patch({ error: failure.message, ...(notify ? { failure } : {}) }); }; const consumeJob = async (job, notify = false) => { + const finishedActive = themeJobActive(state.job) && state.job.id === job.id && state.job.version === job.version; job = { automatic: state.job.automatic === true && state.job.id === job.id, ...job }; patch({ job }); if (job.state === 'completed') { + if (removedIds.has(job.id)) return; + catalogRevision += 1; // A passive refresh can replay completion of an older version. It must // not clear an update offered by a newer catalogue. - const updated = (entry) => entry?.id === job.id ? { + const updated = (entry) => entry?.id === job.id && (entry.installed || finishedActive) ? { ...entry, installed: true, installed_version: job.version || entry.installed_version, update_available: job.version === entry.version ? false : entry.update_available, } : entry; - patch({ entry: updated(state.entry), entries: state.entries.map(updated), error: '' }); + const workshopEntries = state.workshopEntries.map(updated); + patch({ entry: updated(state.entry), entries: state.entries.map(updated), workshopEntries, loading: false, + localEntries: [...state.localEntries.filter((entry) => !workshopEntries.some((remote) => remote.id === entry.id)), + ...workshopEntries.filter((entry) => entry.installed)], error: '' }); const pending = intent; if (pending && pending.revision === revision && pending.id === job.id && pending.version === job.version) { intent = null; @@ -330,23 +337,41 @@ export function createThemeDownloadController({ api, prepare, apply, remove = (i state: () => state, dismissFailure() { patch({ failure: null }); }, activate() { active = true; }, - dispose() { active = false; revision += 1; intent = null; }, + dispose() { active = false; revision += 1; catalogRevision += 1; intent = null; }, async refresh({ notify = false, id = EVA_THEME_ID, silent = false } = {}) { + const requestRevision = ++catalogRevision; const notifyFailure = notify || themeJobActive(state.job); patch({ loading: true }); try { const catalog = await api.getThemes(true); + if (!active || requestRevision !== catalogRevision) return null; const entries = (catalog?.themes || []).filter((item) => isDownloadableColorTheme(item.id)); + const workshopEntries = (catalog?.themes || []).filter((item) => isAiColorTheme(item.id) && item.source === 'workshop'); const entry = entries.find((item) => item.id === EVA_THEME_ID) || null; // Local themes remain available when the remote catalogue is offline. // An installed EVA descriptor can also lack download metadata offline. - for (const item of entries) if (!item.installed && item.available !== false) themeDownloadConsent(item); - patch({ entry, entries, localEntries: localThemeEntries(catalog).filter((item) => !removedIds.has(item.id)), loading: false, + for (const item of [...entries, ...workshopEntries]) if (!item.installed && item.available !== false) themeDownloadConsent(item); + patch({ entry, entries, workshopEntries, workshopError: catalog.workshop_error ? themeErrorText(catalog.workshop_error.error) : '', + localEntries: localThemeEntries(catalog).filter((item) => !removedIds.has(item.id)), loading: false, error: catalog.catalog_error && !silent && (notify || !state.job.automatic) ? themeErrorText(catalog.catalog_error.error) : '' }); - await consumeJob(catalog.job || { state: 'idle' }, themeJobActive(state.job)); + try { await consumeJob(catalog.job || { state: 'idle' }, themeJobActive(state.job)); } + catch (error) { if (active) report(error, true, undefined, state.job.automatic); } if (themeJobActive(state.job)) void poll(); - return entries.find((item) => item.id === id) || null; - } catch (error) { patch({ loading: false }); report(error, notifyFailure, undefined, silent || (!notify && state.job.automatic)); return null; } + return [...entries, ...workshopEntries].find((item) => item.id === id) || null; + } catch (error) { + if (active && requestRevision === catalogRevision) { + this.invalidateCatalog(); + patch({ loading: false }); report(error, notifyFailure, undefined, silent || (!notify && state.job.automatic)); + } + return null; + } + }, + invalidateCatalog() { + catalogRevision += 1; + patch({ entry: null, entries: [], workshopEntries: [], workshopError: '', localEntries: state.localEntries.map((entry) => { + const { package: unusedPackage, key: unusedKey, ...local } = entry; + return { ...local, source: 'local', version: entry.installed_version || entry.version, update_available: false }; + }) }); }, applyStartupTheme(expectedAppearance) { if (startup) return startup; @@ -415,9 +440,11 @@ export function createThemeDownloadController({ api, prepare, apply, remove = (i const result = await remove(id); if (result?.deleted !== true || result.id !== id) throw new Error('删除主题返回了无效结果,请刷新后重试'); removedIds.add(id); + catalogRevision += 1; if (intent?.id === id) intent = null; forget?.(id); - patch({ localEntries: state.localEntries.filter((item) => item.id !== id) }); + patch({ localEntries: state.localEntries.filter((item) => item.id !== id), + workshopEntries: state.workshopEntries.map((item) => item.id === id ? { ...item, installed: false, installed_version: '', update_available: false } : item) }); // Removing a local theme succeeds offline; never await the remote // catalogue before removing its card and releasing its image. void this.refresh(); @@ -426,10 +453,12 @@ export function createThemeDownloadController({ api, prepare, apply, remove = (i }, async install(entry, { automatic = false, expectedAppearance } = {}) { if (!automatic) userIntent = true; + catalogRevision += 1; + removedIds.delete(entry.id); const selected = ++revision; intent = { id: entry.id, version: entry.version, revision: selected, automatic, expectedAppearance }; try { - patch({ error: '', failure: null, job: { id: entry.id, state: 'downloading', bytes_downloaded: 0, bytes_total: entry.package.bytes, automatic } }); + patch({ loading: false, error: '', failure: null, job: { id: entry.id, state: 'downloading', bytes_downloaded: 0, bytes_total: entry.package.bytes, automatic } }); const consent = themeDownloadConsent(entry); const job = await api.installTheme(entry.id, automatic ? { ...consent, automatic: true } : consent); // Even a very fast install is observed by polling once so the same diff --git a/web/src/lib/themePackages.test.js b/web/src/lib/themePackages.test.js index 691e140c..f8792557 100644 --- a/web/src/lib/themePackages.test.js +++ b/web/src/lib/themePackages.test.js @@ -9,6 +9,81 @@ async function run(name, fn) { await fn(); console.log(`[pass] ${name}`); } const entry = { id: 'eva-01', version: '1.0.0', installed: false, package: { bytes: 2000000, sha256: 'a'.repeat(64) } }; const pending = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; const tick = () => new Promise((resolve) => setImmediate(resolve)); + +await run('workshop themes are discovered dynamically and use the existing verified install and apply flow', async () => { + const remote = { ...entry, id: 'ai-dynamic-workshop', name: 'Dynamic theme', source: 'workshop' }; + const f = fixture(remote); + await f.controller.refresh(); + assert.equal(f.controller.state().workshopEntries[0].id, remote.id); + assert.deepEqual(f.controller.state().localEntries, []); + await f.controller.install(remote); + assert.equal(f.requests[0].id, remote.id); + assert.equal(f.requests[0].consent.sha256, remote.package.sha256); + f.complete(); await tick(); + assert.deepEqual(f.applies, [remote.id]); + assert.equal(f.controller.state().workshopEntries[0].installed, true); + assert.equal(f.controller.state().localEntries[0].id, remote.id); + f.controller.dispose(); +}); + +await run('newer workshop refresh wins and offline refresh preserves installed themes without stale offers', async () => { + const first = pending(), second = pending(); + const remote = { ...entry, id: 'ai-workshop', name: 'Remote', source: 'workshop', installed: true, installed_version: '1.0.0' }; + let calls = 0; + const controller = createThemeDownloadController({ api: { getThemes: () => ++calls === 1 ? first.promise : second.promise }, prepare() {}, apply() {} }); + const older = controller.refresh(); + controller.invalidateCatalog(); + const newer = controller.refresh(); + second.resolve({ themes: [remote] }); await newer; + first.resolve({ themes: [{ ...remote, id: 'ai-stale' }] }); await older; + assert.deepEqual(controller.state().workshopEntries.map((item) => item.id), ['ai-workshop']); + controller.invalidateCatalog(); + assert.deepEqual(controller.state().workshopEntries, []); + assert.equal(controller.state().localEntries[0].source, 'local'); + assert.equal(controller.state().localEntries[0].package, undefined); + controller.dispose(); +}); + +await run('withdrawn themes disappear while installed themes and workshop errors remain available', async () => { + const remote = { ...entry, id: 'ai-workshop', name: 'Remote', source: 'workshop' }; + let catalog = { themes: [remote] }; + const controller = createThemeDownloadController({ api: { getThemes: async () => catalog }, prepare() {}, apply() {} }); + await controller.refresh(); + catalog = { themes: [] }; + await controller.refresh(); + assert.deepEqual(controller.state().workshopEntries, []); + catalog = { themes: [{ ...remote, source: 'local', installed: true }], workshop_error: { error: 'THEME_WORKSHOP_UNAVAILABLE' } }; + await controller.refresh(); + assert.equal(controller.state().localEntries[0].id, remote.id); + assert.match(controller.state().workshopError, /工坊/); + controller.dispose(); +}); + +await run('an old completed job cannot resurrect an uninstalled workshop theme', async () => { + const remote = { ...entry, id: 'ai-workshop', name: 'Remote', source: 'workshop', installed: false }; + const controller = createThemeDownloadController({ api: { getThemes: async () => ({ themes: [remote], + job: { id: remote.id, version: remote.version, state: 'completed' } }) }, prepare() {}, apply() {} }); + await controller.refresh(); + assert.equal(controller.state().workshopEntries[0].installed, false); + assert.deepEqual(controller.state().localEntries, []); + controller.dispose(); +}); + +await run('an in-flight catalog cannot roll back a completed installation', async () => { + const remote = { ...entry, id: 'ai-workshop', name: 'Remote', source: 'workshop' }; + const f = fixture(remote); + await f.controller.refresh(); + const pendingCatalog = pending(); + f.api.getThemes = () => pendingCatalog.promise; + const refreshing = f.controller.refresh(); + await f.controller.install(remote); + f.complete(); await tick(); + pendingCatalog.resolve({ themes: [remote], job: { state: 'idle' } }); + await refreshing; + assert.equal(f.controller.state().workshopEntries[0].installed, true); + assert.deepEqual(f.applies, [remote.id]); + f.controller.dispose(); +}); function fixture(themeEntry = entry) { const completion = pending(), applies = [], applyOptions = [], prepared = [], preparations = [], requests = []; let currentJob = { id: themeEntry.id, version: themeEntry.version, state: 'downloading', bytes_total: 2000000, bytes_downloaded: 20 }; diff --git a/web/src/lib/useThemeDownloads.js b/web/src/lib/useThemeDownloads.js index 4ce9dfa2..8764f3ac 100644 --- a/web/src/lib/useThemeDownloads.js +++ b/web/src/lib/useThemeDownloads.js @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from './api.js'; import { createThemeDownloadController } from './themePackages.js'; +import { UPGRADE_CONFIG_CHANGED_EVENT } from './themeImports.js'; export function useThemeDownloads({ enabled, prepare, apply, remove, forget }) { const callbacks = useRef({ prepare, apply, remove, forget }); @@ -18,6 +19,17 @@ export function useThemeDownloads({ enabled, prepare, apply, remove, forget }) { controller.activate(); return () => controller.dispose(); }, [controller]); - useEffect(() => { if (enabled) void controller.refresh(); }, [controller, enabled]); + useEffect(() => { + if (!enabled) return undefined; + const refresh = () => { void controller.refresh(); }; + const changed = () => { controller.invalidateCatalog(); refresh(); }; + refresh(); + window.addEventListener('focus', refresh); + window.addEventListener(UPGRADE_CONFIG_CHANGED_EVENT, changed); + return () => { + window.removeEventListener('focus', refresh); + window.removeEventListener(UPGRADE_CONFIG_CHANGED_EVENT, changed); + }; + }, [controller, enabled]); return { ...state, controller }; }