From 2acae1bb10d5ccc0ef77710859759f7c0a72c47f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 4 Sep 2026 19:37:34 +0800 Subject: [PATCH 1/5] fix: complete OpenBitFun server cutover --- deploy/openbitfun-host/README.md | 29 ++ .../openbitfun-host/migrate-market-data-v1.py | 387 ++++++++++++++++++ .../test_migrate_market_data_v1.py | 254 ++++++++++++ scripts/product-identity-audit.mjs | 10 + scripts/product-identity-audit.test.mjs | 15 + src/apps/relay-server/Dockerfile | 7 + src/apps/relay-server/README.md | 16 + src/apps/relay-server/deploy.sh | 17 +- src/apps/relay-server/docker-compose.yml | 4 + .../product-domains/src/appearance_market.rs | 25 ++ .../product-domains/src/miniapp/market.rs | 25 ++ .../miniapp-market-service/src/routes.rs | 1 + .../RemoteConnectDialog/AccountPanel.tsx | 38 +- .../RemoteConnectDialog.contract.test.ts | 16 + 14 files changed, 832 insertions(+), 12 deletions(-) create mode 100755 deploy/openbitfun-host/migrate-market-data-v1.py create mode 100755 deploy/openbitfun-host/test_migrate_market_data_v1.py diff --git a/deploy/openbitfun-host/README.md b/deploy/openbitfun-host/README.md index e69e95acef..a0a30494d8 100644 --- a/deploy/openbitfun-host/README.md +++ b/deploy/openbitfun-host/README.md @@ -412,6 +412,35 @@ test "$(git -C /srv/openbitfun-skin-market/app rev-parse HEAD)" = "$SKIN_REV"' recreate / 验证,不要走「初次开放市场」生成新 OAuth。 - Skin:[../skin-market/README.md](../skin-market/README.md) 同样只 recreate。 +#### 从 BitFun 0.2.x 原地升级时的一次性数据转换 + +新服务只接受 `minOpenBitFunVersion`、`openbitfun.appearance`、 +`.openbitfun-appearance` 和 `openbitfun_relay.db`,运行时不保留旧字段或旧文件名回退。 +因此只能在旧容器停止后,先把数据库和 artifacts 复制到新的 +`/srv/openbitfun-*` 目录,再对**新副本**运行迁移工具。`--live-database` 是防误操作 +护栏;若目标仍指向旧生产库,工具会拒绝执行。 + +```bash +python3 /root/repos/OpenBitFun/deploy/openbitfun-host/migrate-market-data-v1.py \ + miniapp \ + --database /srv/openbitfun-miniapp-market/data/market.sqlite \ + --live-database /srv/bitfun-miniapp-market/data/market.sqlite + +python3 /root/repos/OpenBitFun/deploy/openbitfun-host/migrate-market-data-v1.py \ + skin \ + --database /srv/openbitfun-skin-market/data/market.sqlite \ + --artifacts /srv/openbitfun-skin-market/artifacts \ + --live-database /srv/bitfun-skin-market/data/market.sqlite \ + --live-artifacts /srv/bitfun-skin-market/artifacts +``` + +Skin 工具会重写包内 schema,重新计算包 SHA、大小和审核 bundle hash,并把旧扩展名 +文件移动到新 artifacts 目录内的 `migrated-bitfun-v1/`,以便人工回滚。运行后必须 +检查 SQLite `integrity_check`、确认活跃 `packages/` 中没有旧扩展名,再启动新容器。 +Relay 是零知识存储,不能在服务端改写加密设置内容;停止旧 Relay 后,用 SQLite +`.backup` 把卷内 `bitfun_relay.db` 复制为 `openbitfun_relay.db`,保留旧文件作为回滚 +副本。旧云设置由新版客户端明确选择“使用本地配置”覆盖,不能静默兼容解析。 + ### 8. New API 这是改过的 `new-api` 镜像,**OpenBitFun clone 恢复不了**。旧机事实: diff --git a/deploy/openbitfun-host/migrate-market-data-v1.py b/deploy/openbitfun-host/migrate-market-data-v1.py new file mode 100755 index 0000000000..aeec894fcf --- /dev/null +++ b/deploy/openbitfun-host/migrate-market-data-v1.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +"""One-time BitFun 0.2.x -> OpenBitFun 1.x market data migration. + +Run this only against an offline copy of the production database and artifact +tree. The OpenBitFun services intentionally keep no legacy field aliases, so +the persisted JSON and Skin archives must be converted before first startup. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import sqlite3 +import tempfile +from typing import Any +import zipfile + + +CURRENT_VERSION_FIELD = "minOpenBitFunVersion" +RETIRED_VERSION_FIELDS = ("minBitfunVersion", "minOpenbitfunVersion") +OPENBITFUN_MINIMUM_VERSION = "1.0.0" +RETIRED_APPEARANCE_SCHEMA = "bitfun.appearance" +OPENBITFUN_APPEARANCE_SCHEMA = "openbitfun.appearance" +RETIRED_APPEARANCE_EXTENSION = "bitfun-appearance" +OPENBITFUN_APPEARANCE_EXTENSION = "openbitfun-appearance" +RETIRED_ARCHIVE_ROOT = "migrated-bitfun-v1" + + +def compact_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def load_object(raw: str, context: str) -> dict[str, Any]: + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise RuntimeError(f"{context} is not valid JSON: {error}") from error + if not isinstance(value, dict): + raise RuntimeError(f"{context} must be a JSON object") + return value + + +def migrate_version_field(value: dict[str, Any], context: str) -> bool: + present_retired = [field for field in RETIRED_VERSION_FIELDS if field in value] + if CURRENT_VERSION_FIELD in value: + if present_retired: + raise RuntimeError( + f"{context} contains both {CURRENT_VERSION_FIELD} and {present_retired}" + ) + return False + if len(present_retired) != 1: + raise RuntimeError( + f"{context} must contain exactly one retired minimum-version field" + ) + value.pop(present_retired[0]) + value[CURRENT_VERSION_FIELD] = OPENBITFUN_MINIMUM_VERSION + return True + + +def migrate_json_column( + connection: sqlite3.Connection, + table: str, + id_column: str, + json_column: str, +) -> int: + changed = 0 + rows = connection.execute( + f"SELECT {id_column}, {json_column} FROM {table} ORDER BY {id_column}" + ).fetchall() + for row_id, raw in rows: + value = load_object(raw, f"{table}.{json_column}[{row_id}]") + if not migrate_version_field(value, f"{table}.{json_column}[{row_id}]"): + continue + connection.execute( + f"UPDATE {table} SET {json_column} = ? WHERE {id_column} = ?", + (compact_json(value), row_id), + ) + changed += 1 + return changed + + +def ensure_offline_copy(database: Path, live_database: Path | None) -> None: + database = database.resolve() + if live_database is not None and database == live_database.resolve(): + raise RuntimeError("refusing to migrate the live database in place") + if not database.is_file(): + raise RuntimeError(f"database does not exist: {database}") + + +def ensure_offline_artifacts(artifacts: Path, live_artifacts: Path | None) -> None: + artifacts = artifacts.resolve() + if live_artifacts is not None and artifacts == live_artifacts.resolve(): + raise RuntimeError("refusing to migrate the live artifact tree in place") + if not artifacts.is_dir(): + raise RuntimeError(f"artifact directory does not exist: {artifacts}") + + +def connect_database(database: Path) -> sqlite3.Connection: + connection = sqlite3.connect(database) + connection.execute("PRAGMA foreign_keys = ON") + if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + connection.close() + raise RuntimeError("database integrity_check failed before migration") + return connection + + +def migrate_miniapp(database: Path, live_database: Path | None) -> None: + ensure_offline_copy(database, live_database) + connection = connect_database(database) + try: + connection.execute("BEGIN IMMEDIATE") + submissions = migrate_json_column( + connection, "submissions", "id", "metadata_json" + ) + releases = migrate_json_column(connection, "releases", "id", "metadata_json") + connection.commit() + if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise RuntimeError("database integrity_check failed after migration") + except Exception: + connection.rollback() + raise + finally: + connection.close() + print(f"MiniApp market migrated: submissions={submissions}, releases={releases}") + + +def validate_archive_name(name: str) -> None: + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or "\\" in name: + raise RuntimeError(f"unsafe Skin archive entry: {name}") + + +def write_migrated_skin_archive(source: Path, temporary_root: Path) -> tuple[Path, str, int, str]: + source_stat = source.stat() + temporary_root.mkdir(parents=True, exist_ok=True) + file_descriptor, temporary_name = tempfile.mkstemp( + prefix="skin-v1-", suffix=".openbitfun-appearance", dir=temporary_root + ) + os.close(file_descriptor) + temporary = Path(temporary_name) + manifest: dict[str, Any] | None = None + try: + with zipfile.ZipFile(source, "r") as incoming, zipfile.ZipFile( + temporary, "w", allowZip64=True + ) as outgoing: + seen: set[str] = set() + for entry in incoming.infolist(): + validate_archive_name(entry.filename) + if entry.filename in seen: + raise RuntimeError(f"duplicate Skin archive entry: {entry.filename}") + seen.add(entry.filename) + content = incoming.read(entry) + if entry.filename == "appearance.json": + manifest = load_object(content.decode("utf-8"), "appearance.json") + schema = manifest.get("schema") + if schema != RETIRED_APPEARANCE_SCHEMA: + raise RuntimeError( + f"appearance.json schema must be {RETIRED_APPEARANCE_SCHEMA}, found {schema!r}" + ) + if manifest.get("schemaVersion") != 1: + raise RuntimeError("appearance.json schemaVersion must be 1") + manifest["schema"] = OPENBITFUN_APPEARANCE_SCHEMA + content = compact_json(manifest).encode("utf-8") + outgoing.writestr(entry, content) + if manifest is None: + raise RuntimeError("Skin archive is missing appearance.json") + digest = hashlib.sha256() + size = 0 + with temporary.open("rb") as migrated: + while chunk := migrated.read(1024 * 1024): + digest.update(chunk) + size += len(chunk) + os.chmod(temporary, source_stat.st_mode & 0o777) + if hasattr(os, "chown") and os.geteuid() == 0: + os.chown(temporary, source_stat.st_uid, source_stat.st_gid) + return temporary, digest.hexdigest(), size, compact_json(manifest) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def skin_package_path(artifacts: Path, digest: str, extension: str) -> Path: + return artifacts / "packages" / digest[:2] / f"{digest}.{extension}" + + +def ensure_directory_like(path: Path, reference: Path) -> None: + if path.exists(): + return + reference_stat = reference.stat() + path.mkdir(parents=True) + os.chmod(path, reference_stat.st_mode & 0o777) + if hasattr(os, "chown") and os.geteuid() == 0: + os.chown(path, reference_stat.st_uid, reference_stat.st_gid) + + +def review_bundle_hash( + package_sha256: str, + draft_json: str, + package_meta_json: str, + manifest_json: str, + preview_sha256: str, +) -> str: + metadata = compact_json( + { + "draft": json.loads(draft_json), + "package": json.loads(package_meta_json), + "manifest": json.loads(manifest_json), + } + ) + digest = hashlib.sha256() + digest.update(package_sha256.encode()) + digest.update(b"\0") + digest.update(metadata.encode()) + digest.update(b"\0") + digest.update(preview_sha256.encode()) + return digest.hexdigest() + + +def migrate_skin_packages( + connection: sqlite3.Connection, + artifacts: Path, +) -> int: + rows = connection.execute( + """ + SELECT DISTINCT package_sha256 + FROM ( + SELECT package_sha256 FROM submissions WHERE package_sha256 IS NOT NULL + UNION ALL + SELECT package_sha256 FROM releases + ) + ORDER BY package_sha256 + """ + ).fetchall() + temporary_root = artifacts / ".migration-openbitfun-v1" + migrated_packages = 0 + for (old_sha256,) in rows: + old_path = skin_package_path( + artifacts, old_sha256, RETIRED_APPEARANCE_EXTENSION + ) + new_existing = skin_package_path( + artifacts, old_sha256, OPENBITFUN_APPEARANCE_EXTENSION + ) + if not old_path.is_file(): + if new_existing.is_file(): + continue + raise RuntimeError(f"referenced Skin package is missing: {old_sha256}") + temporary, new_sha256, new_size, manifest_json = write_migrated_skin_archive( + old_path, temporary_root + ) + new_path = skin_package_path( + artifacts, new_sha256, OPENBITFUN_APPEARANCE_EXTENSION + ) + ensure_directory_like(new_path.parent, old_path.parent) + if new_path.exists(): + if hashlib.sha256(new_path.read_bytes()).hexdigest() != new_sha256: + temporary.unlink(missing_ok=True) + raise RuntimeError(f"Skin migration target hash mismatch: {new_path}") + temporary.unlink(missing_ok=True) + else: + os.replace(temporary, new_path) + + connection.execute( + """ + UPDATE submissions + SET package_sha256 = ?, package_size = ?, manifest_json = ? + WHERE package_sha256 = ? + """, + (new_sha256, new_size, manifest_json, old_sha256), + ) + release_rows = connection.execute( + """ + SELECT id, draft_json, package_meta_json, preview_sha256 + FROM releases WHERE package_sha256 = ? ORDER BY id + """, + (old_sha256,), + ).fetchall() + for release_id, draft_json, package_meta_json, preview_sha256 in release_rows: + bundle_hash = review_bundle_hash( + new_sha256, + draft_json, + package_meta_json, + manifest_json, + preview_sha256, + ) + connection.execute( + """ + UPDATE releases + SET package_sha256 = ?, package_size = ?, manifest_json = ?, review_bundle_hash = ? + WHERE id = ? + """, + (new_sha256, new_size, manifest_json, bundle_hash, release_id), + ) + migrated_packages += 1 + return migrated_packages + + +def archive_retired_skin_packages(artifacts: Path) -> int: + archive_root = artifacts / RETIRED_ARCHIVE_ROOT + retired_paths = sorted( + (artifacts / "packages").glob(f"*/*.{RETIRED_APPEARANCE_EXTENSION}") + ) + archived = 0 + for source in retired_paths: + relative = source.relative_to(artifacts) + target = archive_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + if hashlib.sha256(target.read_bytes()).digest() != hashlib.sha256( + source.read_bytes() + ).digest(): + raise RuntimeError(f"retired Skin archive collision: {target}") + source.unlink() + else: + os.replace(source, target) + archived += 1 + return archived + + +def migrate_skin( + database: Path, + artifacts: Path, + live_database: Path | None, + live_artifacts: Path | None, +) -> None: + ensure_offline_copy(database, live_database) + ensure_offline_artifacts(artifacts, live_artifacts) + connection = connect_database(database) + try: + connection.execute("BEGIN IMMEDIATE") + submissions = migrate_json_column(connection, "submissions", "id", "draft_json") + releases = migrate_json_column(connection, "releases", "id", "draft_json") + packages = migrate_skin_packages(connection, artifacts) + connection.commit() + if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise RuntimeError("database integrity_check failed after migration") + except Exception: + connection.rollback() + raise + finally: + connection.close() + archived = archive_retired_skin_packages(artifacts) + print( + "Skin market migrated: " + f"submissions={submissions}, releases={releases}, " + f"packages={packages}, retired_archives={archived}" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="market", required=True) + for name in ("miniapp", "skin"): + subparser = subparsers.add_parser(name) + subparser.add_argument("--database", type=Path, required=True) + subparser.add_argument( + "--live-database", + type=Path, + help="Optional live path that the tool must refuse to modify", + ) + if name == "skin": + subparser.add_argument("--artifacts", type=Path, required=True) + subparser.add_argument( + "--live-artifacts", + type=Path, + help="Optional live artifact path that the tool must refuse to modify", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.market == "miniapp": + migrate_miniapp(args.database, args.live_database) + else: + migrate_skin( + args.database, + args.artifacts, + args.live_database, + args.live_artifacts, + ) + + +if __name__ == "__main__": + main() diff --git a/deploy/openbitfun-host/test_migrate_market_data_v1.py b/deploy/openbitfun-host/test_migrate_market_data_v1.py new file mode 100755 index 0000000000..4607952513 --- /dev/null +++ b/deploy/openbitfun-host/test_migrate_market_data_v1.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import contextlib +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import sqlite3 +import tempfile +import unittest +import zipfile + + +SCRIPT = Path(__file__).with_name("migrate-market-data-v1.py") +SPEC = importlib.util.spec_from_file_location("migrate_market_data_v1", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +migration = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(migration) + + +def compact_json(value: object) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +class MarketDataMigrationTest(unittest.TestCase): + def test_miniapp_migrates_to_the_single_openbitfun_field_and_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + database = Path(temporary) / "market.sqlite" + connection = sqlite3.connect(database) + connection.executescript( + """ + CREATE TABLE submissions (id TEXT PRIMARY KEY, metadata_json TEXT NOT NULL); + CREATE TABLE releases (id TEXT PRIMARY KEY, metadata_json TEXT NOT NULL); + """ + ) + connection.execute( + "INSERT INTO submissions VALUES (?, ?)", + ( + "submission-1", + compact_json( + {"name": "示例", migration.RETIRED_VERSION_FIELDS[0]: "0.2.19"} + ), + ), + ) + connection.execute( + "INSERT INTO releases VALUES (?, ?)", + ( + "release-1", + compact_json( + {"name": "示例", migration.RETIRED_VERSION_FIELDS[0]: "0.2.19"} + ), + ), + ) + connection.commit() + connection.close() + + with contextlib.redirect_stdout(io.StringIO()): + migration.migrate_miniapp(database, None) + migration.migrate_miniapp(database, None) + + connection = sqlite3.connect(database) + rows = connection.execute( + "SELECT metadata_json FROM submissions UNION ALL SELECT metadata_json FROM releases" + ).fetchall() + connection.close() + self.assertEqual(len(rows), 2) + for (raw,) in rows: + value = json.loads(raw) + self.assertEqual(value["minOpenBitFunVersion"], "1.0.0") + for retired_field in migration.RETIRED_VERSION_FIELDS: + self.assertNotIn(retired_field, value) + + def test_migration_refuses_the_live_database(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + database = Path(temporary) / "market.sqlite" + database.touch() + with self.assertRaisesRegex(RuntimeError, "live database"): + migration.ensure_offline_copy(database, database) + + def test_skin_migration_refuses_the_live_artifact_tree(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + artifacts = Path(temporary) / "artifacts" + artifacts.mkdir() + with self.assertRaisesRegex(RuntimeError, "live artifact tree"): + migration.ensure_offline_artifacts(artifacts, artifacts) + + def test_skin_rewrites_packages_hashes_and_review_bundles(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + database = root / "market.sqlite" + artifacts = root / "artifacts" + artifacts.mkdir() + + archive_bytes = io.BytesIO() + retired_manifest = { + "schema": migration.RETIRED_APPEARANCE_SCHEMA, + "schemaVersion": 1, + "id": "community.example", + "name": "示例皮肤", + } + with zipfile.ZipFile(archive_bytes, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("appearance.json", compact_json(retired_manifest)) + archive.writestr("assets/token.txt", "asset") + retired_bytes = archive_bytes.getvalue() + retired_sha = hashlib.sha256(retired_bytes).hexdigest() + retired_path = migration.skin_package_path( + artifacts, retired_sha, migration.RETIRED_APPEARANCE_EXTENSION + ) + retired_path.parent.mkdir(parents=True) + retired_path.write_bytes(retired_bytes) + + draft = { + "slug": "example-skin", + "releaseNumber": 1, + migration.RETIRED_VERSION_FIELDS[0]: "0.2.18", + } + package_meta = { + "packageId": "community.example", + "name": "示例皮肤", + "mode": "dark", + "packageVersion": "1.0.0", + } + preview_sha = "a" * 64 + connection = sqlite3.connect(database) + connection.executescript( + """ + CREATE TABLE submissions ( + id TEXT PRIMARY KEY, + draft_json TEXT NOT NULL, + package_sha256 TEXT, + package_size INTEGER, + manifest_json TEXT + ); + CREATE TABLE releases ( + id TEXT PRIMARY KEY, + draft_json TEXT NOT NULL, + package_meta_json TEXT NOT NULL, + manifest_json TEXT NOT NULL, + package_sha256 TEXT NOT NULL, + package_size INTEGER NOT NULL, + preview_sha256 TEXT NOT NULL, + review_bundle_hash TEXT NOT NULL + ); + """ + ) + connection.execute( + "INSERT INTO submissions VALUES (?, ?, ?, ?, ?)", + ( + "submission-1", + compact_json(draft), + retired_sha, + len(retired_bytes), + compact_json(retired_manifest), + ), + ) + connection.execute( + "INSERT INTO releases VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + "release-1", + compact_json(draft), + compact_json(package_meta), + compact_json(retired_manifest), + retired_sha, + len(retired_bytes), + preview_sha, + "retired-review-hash", + ), + ) + connection.commit() + connection.close() + + with contextlib.redirect_stdout(io.StringIO()): + migration.migrate_skin(database, artifacts, None, None) + migration.migrate_skin(database, artifacts, None, None) + + connection = sqlite3.connect(database) + submission = connection.execute( + "SELECT draft_json, package_sha256, package_size, manifest_json FROM submissions" + ).fetchone() + release = connection.execute( + """ + SELECT draft_json, package_meta_json, manifest_json, package_sha256, + package_size, preview_sha256, review_bundle_hash + FROM releases + """ + ).fetchone() + self.assertEqual( + connection.execute("PRAGMA integrity_check").fetchone()[0], "ok" + ) + connection.close() + assert submission is not None and release is not None + + draft_json, new_sha, new_size, manifest_json = submission + migrated_manifest = json.loads(manifest_json) + self.assertEqual(json.loads(draft_json)["minOpenBitFunVersion"], "1.0.0") + self.assertEqual(migrated_manifest["schema"], "openbitfun.appearance") + self.assertNotEqual(new_sha, retired_sha) + + migrated_path = migration.skin_package_path( + artifacts, new_sha, migration.OPENBITFUN_APPEARANCE_EXTENSION + ) + self.assertTrue(migrated_path.is_file()) + self.assertEqual( + hashlib.sha256(migrated_path.read_bytes()).hexdigest(), new_sha + ) + self.assertEqual(migrated_path.stat().st_size, new_size) + with zipfile.ZipFile(migrated_path) as archive: + archived_manifest = json.loads(archive.read("appearance.json")) + self.assertEqual(archived_manifest, migrated_manifest) + + ( + release_draft, + release_package_meta, + release_manifest, + release_sha, + release_size, + release_preview_sha, + release_review_hash, + ) = release + self.assertEqual(release_sha, new_sha) + self.assertEqual(release_size, new_size) + self.assertEqual( + release_review_hash, + migration.review_bundle_hash( + release_sha, + release_draft, + release_package_meta, + release_manifest, + release_preview_sha, + ), + ) + self.assertFalse(retired_path.exists()) + self.assertTrue( + ( + artifacts + / migration.RETIRED_ARCHIVE_ROOT + / retired_path.relative_to(artifacts) + ).is_file() + ) + self.assertEqual( + list( + (artifacts / "packages").glob( + f"*/*.{migration.RETIRED_APPEARANCE_EXTENSION}" + ) + ), + [], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/product-identity-audit.mjs b/scripts/product-identity-audit.mjs index de741740d8..fdb356bc1d 100644 --- a/scripts/product-identity-audit.mjs +++ b/scripts/product-identity-audit.mjs @@ -20,6 +20,14 @@ const repositoryRoot = path.resolve(path.dirname(scriptPath), '..'); const retiredProductToken = `${'bit'}${'fun'}`; const shortPrefix = `${'b'}${'f'}`; const productIdentityOwner = 'src/crates/contracts/core-types/src/product_identity.rs'; +const retiredIdentityDataBoundaryFiles = new Set([ + 'deploy/openbitfun-host/README.md', + 'deploy/openbitfun-host/migrate-market-data-v1.py', + 'src/apps/relay-server/README.md', +]); +const noncanonicalIdentityDataBoundaryFiles = new Set([ + 'deploy/openbitfun-host/migrate-market-data-v1.py', +]); const identityRules = Object.freeze([ Object.freeze({ @@ -27,6 +35,7 @@ const identityRules = Object.freeze([ description: 'non-canonical OpenBitFun casing', pattern: /openbitfun/giu, isViolation: (value) => !['OpenBitFun', 'openBitFun', 'openbitfun', 'OPENBITFUN'].includes(value), + allowedFiles: noncanonicalIdentityDataBoundaryFiles, }), Object.freeze({ id: 'abbreviated-openbitfun-name', @@ -40,6 +49,7 @@ const identityRules = Object.freeze([ allowedMatch: ({ location }) => location.file === '.gitignore' && location.location === 'content' && location.lineText?.trim() === `.${retiredProductToken}/`, + allowedFiles: retiredIdentityDataBoundaryFiles, }), Object.freeze({ id: 'retired-css-token-prefix', diff --git a/scripts/product-identity-audit.test.mjs b/scripts/product-identity-audit.test.mjs index 7068fdeeee..cddc17ecc5 100644 --- a/scripts/product-identity-audit.test.mjs +++ b/scripts/product-identity-audit.test.mjs @@ -104,6 +104,21 @@ test('allows only the exact legacy data-directory ignore entry', () => { assert.ok(violations.every((violation) => violation.rule === 'retired-product-name')); }); +test('limits retired identity data to the one-time production migration boundary', () => { + const retiredField = ['min', 'Bit', 'fun', 'Version'].join(''); + assert.deepEqual( + violationsFor( + `RETIRED_VERSION_FIELDS = ("${retiredField}",)`, + 'deploy/openbitfun-host/migrate-market-data-v1.py', + ), + [], + ); + assert.equal( + violationsFor(`const field = "${retiredField}";`, 'src/example.ts').length, + 1, + ); +}); + test('rejects retired short CSS, DOM, dataset, layer, and environment prefixes', () => { const source = [ `--${shortPrefix}-surface: white;`, diff --git a/src/apps/relay-server/Dockerfile b/src/apps/relay-server/Dockerfile index cfb1325da6..e885877cbd 100644 --- a/src/apps/relay-server/Dockerfile +++ b/src/apps/relay-server/Dockerfile @@ -128,9 +128,16 @@ RUN --mount=type=cache,id=openbitfun-relay-cargo-registry,target=/usr/local/carg FROM debian:bookworm-slim +ARG RELAY_GIT_COMMIT=unknown ARG OPENBITFUN_USE_CN_MIRROR=0 ARG OPENBITFUN_APT_MIRROR=mirrors.aliyun.com +LABEL org.opencontainers.image.title="OpenBitFun Relay Server" \ + org.opencontainers.image.description="Self-hosted relay server for OpenBitFun" \ + org.opencontainers.image.source="https://github.com/GCWing/OpenBitFun" \ + org.opencontainers.image.revision="${RELAY_GIT_COMMIT}" \ + org.opencontainers.image.licenses="MIT" + ENV DEBIAN_FRONTEND=noninteractive RUN set -eux; \ diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index f5313a5738..d4ad0281f4 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -286,6 +286,22 @@ Docker build layout rather than duplicating the shared service. The standalone library facade is `openbitfun_relay_server`; reusable relay runtime ownership remains in the internal `openbitfun-relay-service` crate. +Source builds are tagged as `openbitfun-relay:` by `deploy.sh` and +carry the same commit in the `org.opencontainers.image.revision` label. The +resolved commit is persisted in the local root-only `.env`, so `start.sh` and +`restart.sh` keep selecting the deployed image instead of a floating tag. + +```bash +docker inspect --format '{{.Config.Image}} {{index .Config.Labels "org.opencontainers.image.revision"}}' \ + openbitfun-relay +``` + +The image must report `/app/openbitfun-relay-server` as its command and account +mode must use `/app/data/openbitfun_relay.db`. A pre-1.0 deployment that still +has `bitfun_relay.db` must be stopped and copied with SQLite's `.backup` command +to the new filename before the OpenBitFun image is started. The runtime has no +fallback to the retired filename. + ## Quick Start (service ops) ### Recommended: Run on the target server diff --git a/src/apps/relay-server/deploy.sh b/src/apps/relay-server/deploy.sh index 9d6f4477d1..8cd57a7ba3 100755 --- a/src/apps/relay-server/deploy.sh +++ b/src/apps/relay-server/deploy.sh @@ -125,19 +125,31 @@ fi resolve_compose echo "Compose: ${COMPOSE[*]}" +# Stamp source-built images with the exact checkout revision. A copied source +# tree without Git metadata remains explicitly identifiable as unknown. +if [ -z "${RELAY_GIT_COMMIT:-}" ]; then + if RELAY_GIT_COMMIT="$(git -C "${SCRIPT_DIR}/../../.." rev-parse HEAD 2>/dev/null)"; then + : + else + RELAY_GIT_COMMIT=unknown + fi +fi +export RELAY_GIT_COMMIT + # Persist compose build-args for CN builds (and subsequent restarts). touch .env chmod 600 .env 2>/dev/null || true # Refresh OpenBitFun-managed mirror keys without wiping unrelated .env entries. if [ -f .env ]; then tmp_env="$(mktemp)" - grep -Ev '^(OPENBITFUN_USE_CN_MIRROR|OPENBITFUN_APT_MIRROR|OPENBITFUN_CARGO_SPARSE_URL)=' .env >"$tmp_env" || true + grep -Ev '^(OPENBITFUN_USE_CN_MIRROR|OPENBITFUN_APT_MIRROR|OPENBITFUN_CARGO_SPARSE_URL|RELAY_GIT_COMMIT)=' .env >"$tmp_env" || true mv "$tmp_env" .env fi { echo "OPENBITFUN_USE_CN_MIRROR=${OPENBITFUN_USE_CN_MIRROR:-0}" echo "OPENBITFUN_APT_MIRROR=${OPENBITFUN_APT_MIRROR:-mirrors.aliyun.com}" echo "OPENBITFUN_CARGO_SPARSE_URL=${OPENBITFUN_CARGO_SPARSE_URL:-sparse+https://rsproxy.cn/index/}" + echo "RELAY_GIT_COMMIT=${RELAY_GIT_COMMIT}" } >>.env # Build first so a compile failure does not take down a running relay. @@ -171,11 +183,12 @@ else OPENBITFUN_USE_CN_MIRROR="${OPENBITFUN_USE_CN_MIRROR:-0}" \ OPENBITFUN_APT_MIRROR="${OPENBITFUN_APT_MIRROR:-mirrors.aliyun.com}" \ OPENBITFUN_CARGO_SPARSE_URL="${OPENBITFUN_CARGO_SPARSE_URL:-sparse+https://rsproxy.cn/index/}" \ + RELAY_GIT_COMMIT="${RELAY_GIT_COMMIT}" \ docker compose --progress=plain build "${BUILD_ARGS[@]}" ;; sg) # shellcheck disable=SC2086 - sg docker -c "env DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS='${BUILDKIT_PROGRESS}' OPENBITFUN_USE_CN_MIRROR='${OPENBITFUN_USE_CN_MIRROR:-0}' OPENBITFUN_APT_MIRROR='${OPENBITFUN_APT_MIRROR:-mirrors.aliyun.com}' OPENBITFUN_CARGO_SPARSE_URL='${OPENBITFUN_CARGO_SPARSE_URL:-sparse+https://rsproxy.cn/index/}' docker compose --progress=plain build ${BUILD_ARGS[*]}" + sg docker -c "env DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS='${BUILDKIT_PROGRESS}' OPENBITFUN_USE_CN_MIRROR='${OPENBITFUN_USE_CN_MIRROR:-0}' OPENBITFUN_APT_MIRROR='${OPENBITFUN_APT_MIRROR:-mirrors.aliyun.com}' OPENBITFUN_CARGO_SPARSE_URL='${OPENBITFUN_CARGO_SPARSE_URL:-sparse+https://rsproxy.cn/index/}' RELAY_GIT_COMMIT='${RELAY_GIT_COMMIT}' docker compose --progress=plain build ${BUILD_ARGS[*]}" ;; *) if [ "${#COMPOSE[@]}" -ge 2 ] && [ "${COMPOSE[0]}" = "docker" ] && [ "${COMPOSE[1]}" = "compose" ]; then diff --git a/src/apps/relay-server/docker-compose.yml b/src/apps/relay-server/docker-compose.yml index 67cd03edba..69971a9543 100644 --- a/src/apps/relay-server/docker-compose.yml +++ b/src/apps/relay-server/docker-compose.yml @@ -7,10 +7,14 @@ services: # Pass through optional parallelism limit for low-memory hosts: # RELAY_CARGO_BUILD_JOBS=1 docker compose build CARGO_BUILD_JOBS: ${RELAY_CARGO_BUILD_JOBS:-} + # Production sets this to the exact checkout commit so the runtime + # image can be audited just like the two marketplace images. + RELAY_GIT_COMMIT: ${RELAY_GIT_COMMIT:-unknown} # China mirror switch (set by deploy.sh / OPENBITFUN_MIRROR=cn): OPENBITFUN_USE_CN_MIRROR: ${OPENBITFUN_USE_CN_MIRROR:-0} OPENBITFUN_APT_MIRROR: ${OPENBITFUN_APT_MIRROR:-mirrors.aliyun.com} OPENBITFUN_CARGO_SPARSE_URL: ${OPENBITFUN_CARGO_SPARSE_URL:-sparse+https://rsproxy.cn/index/} + image: openbitfun-relay:${RELAY_GIT_COMMIT:-local} # Intentionally no `platform:` pin — build/run for the host CPU # (linux/amd64 on x86_64, linux/arm64 on aarch64). container_name: openbitfun-relay diff --git a/src/crates/contracts/product-domains/src/appearance_market.rs b/src/crates/contracts/product-domains/src/appearance_market.rs index 2684240b30..16d4b16856 100644 --- a/src/crates/contracts/product-domains/src/appearance_market.rs +++ b/src/crates/contracts/product-domains/src/appearance_market.rs @@ -82,6 +82,7 @@ pub struct AppearanceMarketListingSummary { pub mode: AppearancePackageMode, pub package_version: String, pub latest_release: u32, + #[serde(rename = "minOpenBitFunVersion")] pub min_openbitfun_version: String, pub required_capabilities: Vec, pub owner: AppearanceMarketUserSummary, @@ -109,6 +110,7 @@ pub struct AppearanceMarketRelease { pub listing_id: String, pub release_number: u32, pub package_version: String, + #[serde(rename = "minOpenBitFunVersion")] pub min_openbitfun_version: String, pub package_sha256: String, pub package_size: u64, @@ -158,6 +160,7 @@ pub struct AppearanceMarketSubmission { pub mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub package_version: Option, + #[serde(rename = "minOpenBitFunVersion")] pub min_openbitfun_version: String, pub required_capabilities: Vec, pub changelog: String, @@ -186,6 +189,7 @@ pub struct AppearanceMarketSubmissionDraftRequest { pub listing_id: Option, pub slug: String, pub release_number: u32, + #[serde(rename = "minOpenBitFunVersion")] pub min_openbitfun_version: String, pub changelog: String, pub license: AppearanceMarketLicense, @@ -338,4 +342,25 @@ mod tests { assert_eq!(listing.summary.package_id, "community.ocean-night"); assert_eq!(listing.releases[0].package_version, "2.1.0"); } + + #[test] + fn listing_contract_rejects_the_retired_product_version_field() { + let fixture = include_str!( + "../../../../shared/appearance-market-contract-fixtures/listing-detail.json" + ); + let mut listing: serde_json::Value = serde_json::from_str(fixture).unwrap(); + let value = listing + .as_object_mut() + .unwrap() + .remove("minOpenBitFunVersion") + .unwrap(); + let retired_field = ["min", "Bit", "fun", "Version"].concat(); + listing + .as_object_mut() + .unwrap() + .insert(retired_field, value); + + let error = serde_json::from_value::(listing).unwrap_err(); + assert!(error.to_string().contains("minOpenBitFunVersion")); + } } diff --git a/src/crates/contracts/product-domains/src/miniapp/market.rs b/src/crates/contracts/product-domains/src/miniapp/market.rs index 0137b6a153..750037f1fc 100644 --- a/src/crates/contracts/product-domains/src/miniapp/market.rs +++ b/src/crates/contracts/product-domains/src/miniapp/market.rs @@ -77,6 +77,7 @@ pub struct MarketListingSummary { pub tags: Vec, pub owner: MarketUserSummary, pub latest_release: u32, + #[serde(rename = "minOpenBitFunVersion")] pub min_openbitfun_version: String, pub permissions: MiniAppPermissions, pub screenshot_urls: Vec, @@ -111,6 +112,7 @@ pub struct MarketRelease { pub release_id: String, pub listing_id: String, pub release_number: u32, + #[serde(rename = "minOpenBitFunVersion")] pub min_openbitfun_version: String, pub changelog: String, pub package_sha256: String, @@ -155,6 +157,7 @@ pub struct MarketSubmission { pub icon: String, pub category: String, pub tags: Vec, + #[serde(rename = "minOpenBitFunVersion")] pub min_openbitfun_version: String, pub changelog: String, pub license: MarketLicense, @@ -186,6 +189,7 @@ pub struct MarketSubmissionDraftRequest { pub category: String, #[serde(default)] pub tags: Vec, + #[serde(rename = "minOpenBitFunVersion")] pub min_openbitfun_version: String, pub changelog: String, pub license: MarketLicense, @@ -346,4 +350,25 @@ mod tests { .enabled ); } + + #[test] + fn listing_contract_rejects_the_retired_product_version_field() { + let fixture = include_str!( + "../../../../../shared/miniapp-market-contract-fixtures/listing-detail.json" + ); + let mut listing: serde_json::Value = serde_json::from_str(fixture).unwrap(); + let value = listing + .as_object_mut() + .unwrap() + .remove("minOpenBitFunVersion") + .unwrap(); + let retired_field = ["min", "Bit", "fun", "Version"].concat(); + listing + .as_object_mut() + .unwrap() + .insert(retired_field, value); + + let error = serde_json::from_value::(listing).unwrap_err(); + assert!(error.to_string().contains("minOpenBitFunVersion")); + } } diff --git a/src/crates/services/miniapp-market-service/src/routes.rs b/src/crates/services/miniapp-market-service/src/routes.rs index efe5d89166..2d6a910c75 100644 --- a/src/crates/services/miniapp-market-service/src/routes.rs +++ b/src/crates/services/miniapp-market-service/src/routes.rs @@ -53,6 +53,7 @@ struct StoredSubmissionMetadata { icon: String, category: String, tags: Vec, + #[serde(rename = "minOpenBitFunVersion")] min_openbitfun_version: String, changelog: String, license: MarketLicense, diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index f7866e33c7..cd3db551a3 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -734,6 +734,11 @@ export const AccountPanel: React.FC = ({ startBackgroundSync(lastSyncIsFirstLogin ?? false); }, [lastSyncIsFirstLogin, startBackgroundSync, syncStatus]); + const handleOverwriteCloudSettings = useCallback(() => { + if (syncStatus !== 'failed' || syncInFlightRef.current) return; + startBackgroundSync(true); + }, [startBackgroundSync, syncStatus]); + /** Landing path after a completed login: devices view + background sync. */ const completeLogin = useCallback(( relayUrl: string, @@ -1233,16 +1238,29 @@ export const AccountPanel: React.FC = ({ {syncStatus === 'failed' && syncFailureMessage(t, lastSyncError)} {syncStatus === 'failed' && ( - + <> + + + )} {syncStatus === 'syncing' && ( diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts index f1b533c549..5dd762f484 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts @@ -239,6 +239,22 @@ describe('Remote Connect safety contracts', () => { expect(backgroundSync.match(/accountAutoSync/g)).toHaveLength(1); }); + it('offers an explicit local overwrite when a new-format cloud pull cannot be applied', () => { + const overwriteHandler = accountPanelSource.slice( + accountPanelSource.indexOf('const handleOverwriteCloudSettings'), + accountPanelSource.indexOf('/** Landing path after a completed login'), + ); + const failedSyncActions = accountPanelSource.slice( + accountPanelSource.indexOf("{syncStatus === 'failed' && ("), + accountPanelSource.indexOf("{syncStatus === 'syncing' && ("), + ); + + expect(overwriteHandler).toContain('startBackgroundSync(true)'); + expect(failedSyncActions).toContain('handleOverwriteCloudSettings'); + expect(failedSyncActions).toContain("accountLogin.useLocalTitle"); + expect(failedSyncActions).toContain("accountLogin.useLocalDesc"); + }); + it('binds overwrite finalize and cleanup to an opaque pending login id', () => { expect(accountPanelSource).toContain('pendingLoginIdRef.current = result.pending_login_id'); expect(accountPanelSource).toContain('accountFinalizeLogin(pendingLoginId)'); From c02c84796a7a07da0357e9f5acb2c7484e187907 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 4 Sep 2026 19:47:43 +0800 Subject: [PATCH 2/5] fix: build skin market theme package in container --- src/apps/skin-market-server/Dockerfile | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/apps/skin-market-server/Dockerfile b/src/apps/skin-market-server/Dockerfile index 46102806c6..108a00b790 100644 --- a/src/apps/skin-market-server/Dockerfile +++ b/src/apps/skin-market-server/Dockerfile @@ -7,13 +7,22 @@ RUN corepack enable && corepack prepare pnpm@10.15.0 --activate COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY patches/ patches/ +COPY design-system/LICENSE design-system/LICENSE +COPY design-system/tooling/token-engine/package.json design-system/tooling/token-engine/package.json +COPY design-system/packages/design-tokens/package.json design-system/packages/design-tokens/package.json +COPY design-system/packages/theme-openbitfun/package.json design-system/packages/theme-openbitfun/package.json COPY src/skin-market-web/package.json src/skin-market-web/package.json RUN --mount=type=cache,id=openbitfun-skin-market-pnpm,target=/root/.local/share/pnpm/store,sharing=locked \ - pnpm install --frozen-lockfile --ignore-scripts --filter openbitfun-skin-market-web + pnpm install --frozen-lockfile --ignore-scripts --filter openbitfun-skin-market-web... +COPY design-system/tooling/token-engine/ design-system/tooling/token-engine/ +COPY design-system/packages/design-tokens/ design-system/packages/design-tokens/ +COPY design-system/packages/theme-openbitfun/ design-system/packages/theme-openbitfun/ COPY src/skin-market-web/ src/skin-market-web/ COPY src/shared/appearance-market-contract-fixtures/ src/shared/appearance-market-contract-fixtures/ -RUN pnpm --dir src/skin-market-web build +RUN pnpm --dir design-system/packages/design-tokens build \ + && pnpm --dir design-system/packages/theme-openbitfun build \ + && pnpm --dir src/skin-market-web build FROM rust:1.96-bookworm AS rust-builder From 7e2f145f9103acafe368690f1ad9d8d308220466 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 4 Sep 2026 19:49:27 +0800 Subject: [PATCH 3/5] fix: include skin market theme sources in image context --- .../Dockerfile.dockerignore | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/apps/skin-market-server/Dockerfile.dockerignore b/src/apps/skin-market-server/Dockerfile.dockerignore index 5837344ff4..3e642b697d 100644 --- a/src/apps/skin-market-server/Dockerfile.dockerignore +++ b/src/apps/skin-market-server/Dockerfile.dockerignore @@ -9,6 +9,27 @@ !patches/ !patches/** +!design-system/ +!design-system/LICENSE +!design-system/packages/ +!design-system/packages/design-tokens/ +!design-system/packages/design-tokens/package.json +!design-system/packages/design-tokens/scripts/ +!design-system/packages/design-tokens/scripts/** +!design-system/packages/design-tokens/src/ +!design-system/packages/design-tokens/src/** +!design-system/packages/theme-openbitfun/ +!design-system/packages/theme-openbitfun/package.json +!design-system/packages/theme-openbitfun/scripts/ +!design-system/packages/theme-openbitfun/scripts/** +!design-system/packages/theme-openbitfun/src/ +!design-system/packages/theme-openbitfun/src/** +!design-system/tooling/ +!design-system/tooling/token-engine/ +!design-system/tooling/token-engine/package.json +!design-system/tooling/token-engine/src/ +!design-system/tooling/token-engine/src/** + !src/ !src/**/ !src/**/Cargo.toml From 7325307b082b2ac7a7c848d0f94393699166ea59 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 4 Sep 2026 21:05:09 +0800 Subject: [PATCH 4/5] fix: keep relay reset migration server-side --- .../RemoteConnectDialog/AccountPanel.tsx | 38 +++++-------------- .../RemoteConnectDialog.contract.test.ts | 16 -------- 2 files changed, 10 insertions(+), 44 deletions(-) diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index cd3db551a3..f7866e33c7 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -734,11 +734,6 @@ export const AccountPanel: React.FC = ({ startBackgroundSync(lastSyncIsFirstLogin ?? false); }, [lastSyncIsFirstLogin, startBackgroundSync, syncStatus]); - const handleOverwriteCloudSettings = useCallback(() => { - if (syncStatus !== 'failed' || syncInFlightRef.current) return; - startBackgroundSync(true); - }, [startBackgroundSync, syncStatus]); - /** Landing path after a completed login: devices view + background sync. */ const completeLogin = useCallback(( relayUrl: string, @@ -1238,29 +1233,16 @@ export const AccountPanel: React.FC = ({ {syncStatus === 'failed' && syncFailureMessage(t, lastSyncError)} {syncStatus === 'failed' && ( - <> - - - + )} {syncStatus === 'syncing' && ( diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts index 5dd762f484..f1b533c549 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts @@ -239,22 +239,6 @@ describe('Remote Connect safety contracts', () => { expect(backgroundSync.match(/accountAutoSync/g)).toHaveLength(1); }); - it('offers an explicit local overwrite when a new-format cloud pull cannot be applied', () => { - const overwriteHandler = accountPanelSource.slice( - accountPanelSource.indexOf('const handleOverwriteCloudSettings'), - accountPanelSource.indexOf('/** Landing path after a completed login'), - ); - const failedSyncActions = accountPanelSource.slice( - accountPanelSource.indexOf("{syncStatus === 'failed' && ("), - accountPanelSource.indexOf("{syncStatus === 'syncing' && ("), - ); - - expect(overwriteHandler).toContain('startBackgroundSync(true)'); - expect(failedSyncActions).toContain('handleOverwriteCloudSettings'); - expect(failedSyncActions).toContain("accountLogin.useLocalTitle"); - expect(failedSyncActions).toContain("accountLogin.useLocalDesc"); - }); - it('binds overwrite finalize and cleanup to an opaque pending login id', () => { expect(accountPanelSource).toContain('pendingLoginIdRef.current = result.pending_login_id'); expect(accountPanelSource).toContain('accountFinalizeLogin(pendingLoginId)'); From ad850ac781b1ef2eeaf3621f86189db1eed2ff9e Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 4 Sep 2026 21:12:33 +0800 Subject: [PATCH 5/5] chore: refresh generated OpenBitFun macOS icons --- .../src-tauri/icons/openbitfun-app-icon.icns | Bin 212958 -> 212958 bytes .../desktop/icons/openbitfun-app-icon.icns | Bin 212958 -> 212958 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns b/OpenBitFun-Installer/src-tauri/icons/openbitfun-app-icon.icns index 367b52742d7d546769869d6975a8882ffcf50554..8acffe8cdac28c1eaadb5c2f9b1c36cec2b6b398 100644 GIT binary patch delta 67 zcmV-J0KEU+{|w&$3 w+1x>*b1{sbvhS^3Lycv3LyehbhCtv Zg_N_Eakc}u;wb|r3$sTyPCKy)J2shi8H@k` delta 72 zcmV-O0Js0%{|w&$3=C;wZgT(wKi+9$F*CPn(*ip-w^hypPCK`GzXG-cvuK|=7q|2( e0)>==ptiT5wgFUhu?mHhw=cs2CJVEXl-3G&Iv-;I diff --git a/src/apps/desktop/icons/openbitfun-app-icon.icns b/src/apps/desktop/icons/openbitfun-app-icon.icns index 367b52742d7d546769869d6975a8882ffcf50554..8acffe8cdac28c1eaadb5c2f9b1c36cec2b6b398 100644 GIT binary patch delta 67 zcmV-J0KEU+{|w&$3 w+1x>*b1{sbvhS^3Lycv3LyehbhCtv Zg_N_Eakc}u;wb|r3$sTyPCKy)J2shi8H@k` delta 72 zcmV-O0Js0%{|w&$3=C;wZgT(wKi+9$F*CPn(*ip-w^hypPCK`GzXG-cvuK|=7q|2( e0)>==ptiT5wgFUhu?mHhw=cs2CJVEXl-3G&Iv-;I