diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f5f466aa3..7324baa1d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,12 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: package.json + package-manager-cache: false + - name: Reject CRLF in shell and deploy assets run: | bad=$(git ls-files -z \ @@ -53,6 +59,9 @@ jobs: done < <(git ls-files -z '*.sh' '*.bash') exit "$rc" + - name: Verify release and version-generation contracts + run: node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs + - name: Verify minisign download fallback run: | set -euo pipefail diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 03c2202abc..32a861682b 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -233,6 +233,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify release version metadata + run: node scripts/verify-release-version-sync.mjs --version "${{ needs.prepare.outputs.version }}" + - name: Build desktop app run: ${{ matrix.platform.build_command }} @@ -505,6 +508,20 @@ jobs: echo "Relay image descriptor:" find relay-image-assets -type f | sort + - name: Prepare versioned Windows installer + run: | + node scripts/prepare-windows-installer-asset.mjs \ + --assets-dir release-assets \ + --version "${{ needs.prepare.outputs.version }}" \ + --out-dir release-manual-assets + + - name: Sign versioned Windows installer + shell: bash + env: + BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bash scripts/sign-release-assets.sh release-manual-assets/*.exe + - name: Collect updater assets run: | node scripts/collect-tauri-updater-assets.mjs \ @@ -517,6 +534,7 @@ jobs: run: | node scripts/generate-tauri-latest-json.mjs \ --assets-dir release-updater-assets \ + --manual-assets-dir release-manual-assets \ --version "${{ needs.prepare.outputs.version }}" \ --tag "${{ needs.prepare.outputs.release_tag }}" \ --repo "GCWing/BitFun" \ @@ -528,7 +546,8 @@ jobs: node scripts/verify-tauri-latest-json.mjs \ --manifest release-updater-assets/latest.json \ --version "${{ needs.prepare.outputs.version }}" \ - --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ + --required-manual-platforms "windows-x86_64" - name: Generate Linux binaries manifest run: | @@ -556,7 +575,7 @@ jobs: mapfile -t assets < <( find release-assets -type f \ \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ - -o -name '*.dmg' -o -name '*bitfun-installer.exe' \) | sort + -o -name '*.dmg' \) | sort ) if [[ "${#assets[@]}" -eq 0 ]]; then echo "No installer packages found to sign." @@ -575,11 +594,12 @@ jobs: generate_release_notes: true files: | release-updater-assets/* + release-manual-assets/*.exe + release-manual-assets/*.exe.sig release-assets/**/*.AppImage release-assets/**/*.deb release-assets/**/*.dmg release-assets/**/*.rpm - release-assets/**/*bitfun-installer.exe release-assets/**/*.sig release-assets/minisign.pub linux-release-assets/bitfun-cli-*.tar.gz @@ -602,6 +622,7 @@ jobs: --manifest latest.published.json \ --version "${{ needs.prepare.outputs.version }}" \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ + --required-manual-platforms "windows-x86_64" \ --check-urls true - name: Verify published Linux binaries manifest diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 16473c1eea..8c6b44a652 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -180,6 +180,12 @@ jobs: const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); pkg.version = process.env.NIGHTLY_VERSION.split('+')[0]; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + for (const file of ['BitFun-Installer/package.json', 'BitFun-Installer/package-lock.json']) { + const data = JSON.parse(fs.readFileSync(file, 'utf-8')); + data.version = pkg.version; + if (data.packages?.['']) data.packages[''].version = pkg.version; + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n'); + } " # Patch Cargo workspace version (semver: nightly suffix uses hyphen) @@ -187,6 +193,8 @@ jobs: CARGO_VERSION="$(echo "$NIGHTLY_VERSION" | sed 's/+.*//')" sed -i.bak "s/^version = \".*\" # x-release-please-version/version = \"${CARGO_VERSION}\" # x-release-please-version/" Cargo.toml rm -f Cargo.toml.bak + sed -i.bak "0,/^version = \".*\"/s//version = \"${CARGO_VERSION}\"/" BitFun-Installer/src-tauri/Cargo.toml + rm -f BitFun-Installer/src-tauri/Cargo.toml.bak echo "package.json version: $(jq -r '.version' package.json)" echo "Cargo.toml version: $(grep 'x-release-please-version' Cargo.toml)" @@ -418,6 +426,15 @@ jobs: --repo "GCWing/BitFun" \ --out linux-release-assets/linux-binaries.json + - name: Prepare versioned Windows installer + env: + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + run: | + node scripts/prepare-windows-installer-asset.mjs \ + --assets-dir release-assets \ + --version "${NIGHTLY_VERSION%%+*}" \ + --out-dir release-manual-assets + # The Tauri bundler signs the five updater artifacts during `tauri build`, # but the installers people download by hand from the release page — dmg, # deb, rpm, the Windows installer and the direct AppImages — shipped with @@ -432,11 +449,12 @@ jobs: BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} run: | set -euo pipefail - mapfile -t assets < <( + mapfile -t assets < <({ find release-assets -type f \ \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ - -o -name '*.dmg' -o -name '*bitfun-installer.exe' \) | sort - ) + -o -name '*.dmg' \) + find release-manual-assets -type f -name '*.exe' + } | sort) if [[ "${#assets[@]}" -eq 0 ]]; then echo "No installer packages found to sign." exit 0 @@ -466,8 +484,9 @@ jobs: release-assets/**/*.deb release-assets/**/*.dmg release-assets/**/*.rpm - release-assets/**/*bitfun-installer.exe release-assets/**/*.sig + release-manual-assets/*.exe + release-manual-assets/*.exe.sig release-assets/minisign.pub release-assets/**/bitfun-cli-*-apple-darwin.tar.gz release-assets/**/bitfun-cli-*-apple-darwin.tar.gz.sha256 diff --git a/package.json b/package.json index 71bceae0b0..d37c05c7e5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "copy-monaco": "copyfiles -u 5 \"src/web-ui/node_modules/monaco-editor/min/vs/**/*\" src/web-ui/public/monaco-editor && node scripts/prune-monaco-nls.cjs", "copy-icons": "copyfiles -f \"src/apps/desktop/icons/Logo-ICON.png\" \"src/web-ui/public/\"", "copy-assets": "pnpm run copy-monaco && pnpm run copy-icons", - "generate-version": "node scripts/generate-version.cjs", + "generate-version": "node scripts/generate-version.cjs --build-env production", + "generate-version:dev": "node scripts/generate-version.cjs --build-env development", "generate-startup-appearance-bootstrap": "node scripts/generate-startup-appearance-bootstrap.mjs", "generate-all": "pnpm run generate-version && pnpm run generate-startup-appearance-bootstrap", "postinstall": "pnpm run copy-assets", diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 3e7518258a..24bf28cf31 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -216,7 +216,7 @@ export function prepareTauriConfig( endpoints: [primaryEndpoint, fallbackEndpoint], pubkey, windows: { - installMode: 'passive', + installMode: 'quiet', }, }, }; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 777b0eb079..4f15bc11bd 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -125,6 +125,39 @@ test('Desktop Tauri projection consumes only the resolved member identity', () = } }); +test('Windows updater installs NSIS packages without showing its progress window', () => { + const fixture = join(tmpdir(), `bitfun-tauri-updater-${process.pid}-${Date.now()}`); + const baseConfig = join(fixture, 'tauri.conf.json'); + const updaterEnv = { + BITFUN_ENABLE_UPDATER_ARTIFACTS: process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS, + TAURI_SIGNING_PRIVATE_KEY: process.env.TAURI_SIGNING_PRIVATE_KEY, + TAURI_UPDATER_PUBKEY: process.env.TAURI_UPDATER_PUBKEY, + }; + mkdirSync(fixture, { recursive: true }); + writeFileSync(baseConfig, JSON.stringify({ bundle: { resources: {} } })); + process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS = 'true'; + process.env.TAURI_SIGNING_PRIVATE_KEY = 'test-private-key'; + process.env.TAURI_UPDATER_PUBKEY = 'test-public-key'; + + try { + const generated = prepareTauriConfig(baseConfig, { + desktopDir: fixture, + flashgrepBinary: join(fixture, 'flashgrep'), + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.equal(config.plugins.updater.windows.installMode, 'quiet'); + } finally { + for (const [name, value] of Object.entries(updaterEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + rmSync(fixture, { force: true, recursive: true }); + } +}); + test('Desktop release config bundles models.dev notices and provenance', () => { const config = JSON.parse( readFileSync(join(ROOT, 'src', 'apps', 'desktop', 'tauri.conf.json'), 'utf8') diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 39918b36aa..d218cb06b4 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -666,7 +666,11 @@ async function main() { }, { name: 'Generate version info', - promise: runCommandPrefixed('version', 'node', ['scripts/generate-version.cjs']), + promise: runCommandPrefixed('version', 'node', [ + 'scripts/generate-version.cjs', + '--build-env', + 'development', + ]), }, ]; diff --git a/scripts/generate-tauri-latest-json.mjs b/scripts/generate-tauri-latest-json.mjs index b0f6ee57d0..f8f47f6c1d 100644 --- a/scripts/generate-tauri-latest-json.mjs +++ b/scripts/generate-tauri-latest-json.mjs @@ -9,6 +9,7 @@ const tag = requireArg(args, 'tag'); const repo = requireArg(args, 'repo'); const out = requireArg(args, 'out'); const requiredPlatforms = parseListArg(args['required-platforms'] || ''); +const manualAssetsDir = args['manual-assets-dir']; if (!existsSync(assetsDir)) { fail(`Assets directory does not exist: ${assetsDir}`); @@ -55,6 +56,22 @@ const manifest = { platforms, }; +if (manualAssetsDir) { + const installerName = `BitFun_${version}_windows-x86_64-installer.exe`; + const installerPath = join(manualAssetsDir, installerName); + const signaturePath = `${installerPath}.sig`; + if (!existsSync(installerPath) || !existsSync(signaturePath)) { + fail(`Missing signed manual installer pair: ${installerPath} and ${signaturePath}`); + } + const assetUrl = `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(installerName)}`; + manifest.manual_installers = { + 'windows-x86_64': { + url: assetUrl, + signature_url: `${assetUrl}.sig`, + }, + }; +} + mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); console.log(`[latest-json] Wrote ${out}`); diff --git a/scripts/generate-version.cjs b/scripts/generate-version.cjs index b6c2acf273..0081e52f2e 100644 --- a/scripts/generate-version.cjs +++ b/scripts/generate-version.cjs @@ -19,6 +19,20 @@ const { const packageJsonPath = path.resolve(__dirname, '../package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); +function parseBuildEnv(args) { + const index = args.indexOf('--build-env'); + const buildEnv = index >= 0 ? args[index + 1] : undefined; + if (!['development', 'production', 'preview'].includes(buildEnv)) { + throw new Error('Expected --build-env development|production|preview'); + } + return buildEnv; +} + +function readArg(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + function getGitInfo() { try { const gitCommitFull = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim(); @@ -40,11 +54,10 @@ function getGitInfo() { } } -function generateVersionInfo() { +function generateVersionInfo(buildEnv) { const gitInfo = getGitInfo(); const buildDate = new Date().toISOString(); const buildTimestamp = Date.now(); - const buildEnv = process.env.NODE_ENV || 'development'; const isDev = buildEnv === 'development'; const versionInfo = { @@ -60,8 +73,8 @@ function generateVersionInfo() { return versionInfo; } -function saveVersionInfoToJson(versionInfo) { - const outputPath = path.resolve(__dirname, '../src/web-ui/public/version.json'); +function saveVersionInfoToJson(versionInfo, outputRoot) { + const outputPath = path.resolve(outputRoot, 'src/web-ui/public/version.json'); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) { @@ -75,8 +88,8 @@ function saveVersionInfoToJson(versionInfo) { ); } -function saveVersionInfoToTS(versionInfo) { - const outputPath = path.resolve(__dirname, '../src/web-ui/src/generated/version.ts'); +function saveVersionInfoToTS(versionInfo, outputRoot) { + const outputPath = path.resolve(outputRoot, 'src/web-ui/src/generated/version.ts'); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) { @@ -104,13 +117,16 @@ function generateHtmlInjectionScript(versionInfo) { } function main() { - const versionInfo = generateVersionInfo(); + const args = process.argv.slice(2); + const buildEnv = parseBuildEnv(args); + const outputRoot = path.resolve(readArg(args, '--output-root') || path.resolve(__dirname, '..')); + const versionInfo = generateVersionInfo(buildEnv); - saveVersionInfoToJson(versionInfo); - saveVersionInfoToTS(versionInfo); + saveVersionInfoToJson(versionInfo, outputRoot); + saveVersionInfoToTS(versionInfo, outputRoot); const htmlScript = generateHtmlInjectionScript(versionInfo); - const htmlScriptPath = path.resolve(__dirname, '../src/web-ui/src/generated/version-injection.html'); + const htmlScriptPath = path.resolve(outputRoot, 'src/web-ui/src/generated/version-injection.html'); const htmlDir = path.dirname(htmlScriptPath); if (!fs.existsSync(htmlDir)) { @@ -123,12 +139,11 @@ function main() { printSuccess(`${versionInfo.name} v${versionInfo.version}${gitStr}`); } -// On failure: warn and exit 0 so build is not interrupted try { main(); } catch (err) { - printWarning('Version info generation failed, skipped: ' + (err.message || err)); - process.exit(0); + printWarning('Version info generation failed: ' + (err.message || err)); + process.exit(1); } diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs index 50862f1d8b..01dcc038f4 100644 --- a/scripts/linux-binaries-manifest.test.mjs +++ b/scripts/linux-binaries-manifest.test.mjs @@ -181,7 +181,7 @@ test('openbitfun sync mirrors the website installer from the exact updater relea source "$SYNC_SCRIPT" VERSION_DIR="$TEST_VERSION_DIR" RELEASE_ASSET_BASE_URL="https://github.com/GCWing/BitFun/releases/download/v1.2.3" - WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" + LATEST_JSON="$TEST_LATEST_JSON" download_asset() { printf '%s\\t%s\\n' "$1" "$2" >> "$DOWNLOAD_CALLS" } @@ -194,6 +194,14 @@ test('openbitfun sync mirrors the website installer from the exact updater relea DOWNLOAD_CALLS: calls, SYNC_SCRIPT: path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), TEST_VERSION_DIR: versionDir, + TEST_LATEST_JSON: JSON.stringify({ + manual_installers: { + 'windows-x86_64': { + url: 'https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe', + signature_url: 'https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig', + }, + }, + }), }, } ); @@ -201,8 +209,42 @@ test('openbitfun sync mirrors the website installer from the exact updater relea const downloads = fs.readFileSync(calls, 'utf8').trim().split('\n'); assert.deepEqual(downloads, [ - `https://github.com/GCWing/BitFun/releases/download/v1.2.3/bitfun-installer.exe\t${versionDir}/bitfun-installer.exe`, - `https://github.com/GCWing/BitFun/releases/download/v1.2.3/bitfun-installer.exe.sig\t${versionDir}/bitfun-installer.exe.sig`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe\t${versionDir}/BitFun_1.2.3_windows-x86_64-installer.exe`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig\t${versionDir}/BitFun_1.2.3_windows-x86_64-installer.exe.sig`, + ]); +}); + +test('openbitfun sync retains the legacy fixed installer fallback', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-legacy-installer-mirror-')); + const versionDir = path.join(temp, 'release', '1.2.2'); + const calls = path.join(temp, 'download-calls.tsv'); + fs.mkdirSync(versionDir, { recursive: true }); + + const result = spawnSync( + 'bash', + ['-c', ` + source "$SYNC_SCRIPT" + VERSION_DIR="$TEST_VERSION_DIR" + RELEASE_ASSET_BASE_URL="https://github.com/GCWing/BitFun/releases/download/v1.2.2" + download_asset() { + printf '%s\\t%s\\n' "$1" "$2" >> "$DOWNLOAD_CALLS" + } + mirror_windows_installer + `], + { + encoding: 'utf8', + env: { + ...process.env, + DOWNLOAD_CALLS: calls, + SYNC_SCRIPT: path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), + TEST_VERSION_DIR: versionDir, + }, + } + ); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(fs.readFileSync(calls, 'utf8').trim().split('\n'), [ + `https://github.com/GCWing/BitFun/releases/download/v1.2.2/bitfun-installer.exe\t${versionDir}/bitfun-installer.exe`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.2/bitfun-installer.exe.sig\t${versionDir}/bitfun-installer.exe.sig`, ]); }); @@ -278,6 +320,12 @@ test('website download manifest uses installer while updater manifest keeps setu url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_darwin-aarch64.app.tar.gz', }, }, + manual_installers: { + 'windows-x86_64': { + url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe', + signature_url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig', + }, + }, }; fs.writeFileSync(updaterPath, `${JSON.stringify(updater, null, 2)}\n`); @@ -314,11 +362,11 @@ test('website download manifest uses installer while updater manifest keeps setu assert.equal(website.version, '1.2.3'); assert.equal( website.platforms['windows-x86_64'].url, - 'https://openbitfun.test/release/1.2.3/bitfun-installer.exe' + 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe' ); assert.equal( website.platforms['windows-x86_64'].signatureUrl, - 'https://openbitfun.test/release/1.2.3/bitfun-installer.exe.sig' + 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig' ); assert.equal( website.platforms['darwin-aarch64'].url, diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index b880a26f36..b29f3fcade 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -15,9 +15,9 @@ # The published release/latest.json is the Tauri updater fallback endpoint. # When GitHub is unreachable, the desktop client automatically falls through # to https://openbitfun.com/release/latest.json and downloads from this mirror. -# The published release/downloads.json is for the website. Its Windows URL -# points at bitfun-installer.exe while latest.json deliberately keeps the Tauri -# updater's versioned setup.exe URL. +# The published release/downloads.json is for the website. Its Windows URL uses +# latest.json's manual_installers entry while the updater keeps the versioned +# Tauri setup.exe URL. # # Cron (every 10 minutes): # */10 * * * * /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ @@ -48,7 +48,10 @@ GITHUB_RELAY_IMAGE_URL="https://github.com/GCWing/BitFun/releases/latest/downloa OPENBITFUN_BASE_URL="https://openbitfun.com/release" WEBSITE_RELEASE_DIR="/root/repos/BitFun-Website/dist/release" LOCK_FILE="/root/repos/BitFun-AutoUpdate/sync.lock" -WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" +LEGACY_WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" +WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME" +WINDOWS_INSTALLER_URL="" +WINDOWS_INSTALLER_SIGNATURE_URL="" WEBSITE_DOWNLOADS_MANIFEST="downloads.json" # Keep enough releases that the mirror still serves a Desktop build a few # versions behind and SSH Dispatch can finish an already-confirmed install even @@ -110,22 +113,47 @@ publish_file_atomically() { # /releases/latest/download so the installer and setup package cannot come from # different releases while GitHub is advancing the latest-release pointer. mirror_windows_installer() { - local installer_url - installer_url="${RELEASE_ASSET_BASE_URL}/${WINDOWS_INSTALLER_FILENAME}" + local installer_url signature_url metadata + installer_url="${WINDOWS_INSTALLER_URL:-${RELEASE_ASSET_BASE_URL}/${WINDOWS_INSTALLER_FILENAME}}" + signature_url="${WINDOWS_INSTALLER_SIGNATURE_URL:-${installer_url}.sig}" + + if [ -n "${LATEST_JSON:-}" ]; then + metadata=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " +import json, sys +data = json.load(sys.stdin) +entry = data.get('manual_installers', {}).get('windows-x86_64') +if entry: + print(entry['url']) + print(entry.get('signature_url', entry['url'] + '.sig')) +") + if [ -n "$metadata" ]; then + installer_url=$(printf '%s\n' "$metadata" | sed -n '1p') + signature_url=$(printf '%s\n' "$metadata" | sed -n '2p') + if [ "${installer_url%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then + log "ERROR: Manual installer URL does not belong to the updater release: $installer_url" + return 1 + fi + if [ "$signature_url" != "${installer_url}.sig" ]; then + log "ERROR: Manual installer signature URL does not match the installer URL" + return 1 + fi + WINDOWS_INSTALLER_FILENAME="${installer_url##*/}" + fi + fi log " Mirroring website Windows installer: ${WINDOWS_INSTALLER_FILENAME}" download_asset \ "$installer_url" \ "${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}" || exit 1 download_asset \ - "${installer_url}.sig" \ + "$signature_url" \ "${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}.sig" || exit 1 } # Build a website-only manifest from the already rewritten updater manifest. # All non-Windows targets continue to use their mirrored updater packages. The -# Windows target alone is replaced with the custom installer URL; latest.json -# is never modified and remains a valid Tauri updater contract. +# Windows target alone is replaced with the custom installer URL. The updater +# URL remains untouched; manual_installers is a mirror/website extension only. write_website_download_manifest() { local output="${VERSION_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}" local output_tmp="${output}.part" @@ -152,9 +180,14 @@ windows = platforms.get("windows-x86_64") if windows is None: raise SystemExit("latest.json is missing windows-x86_64") -version_base = f"{base}/{version}" -windows["url"] = f"{version_base}/{windows_installer}" -windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" +manual = updater.get("manual_installers", {}).get("windows-x86_64") +if manual: + windows["url"] = manual["url"] + windows["signatureUrl"] = manual.get("signature_url", manual["url"] + ".sig") +else: + version_base = f"{base}/{version}" + windows["url"] = f"{version_base}/{windows_installer}" + windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" website = { "schemaVersion": 1, @@ -480,8 +513,8 @@ main() { log "Latest version: $VERSION" # Resolve the exact tagged release directory from the updater URLs. Using - # this base for the standalone installer avoids a latest-release race where - # latest.json and bitfun-installer.exe could otherwise resolve to different +# this base for the standalone installer avoids a latest-release race where +# latest.json and the manual installer could otherwise resolve to different # versions during publication. RELEASE_ASSET_BASE_URL=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " import json, sys @@ -495,6 +528,35 @@ print(bases.pop()) exit 1 } + INSTALLER_METADATA=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " +import json, sys +data = json.load(sys.stdin) +entry = data.get('manual_installers', {}).get('windows-x86_64') +if entry: + print(entry['url']) + print(entry.get('signature_url', entry['url'] + '.sig')) +") || { + log "ERROR: Failed to resolve the manual Windows installer from latest.json" + exit 1 + } + if [ -n "$INSTALLER_METADATA" ]; then + WINDOWS_INSTALLER_URL=$(printf '%s\n' "$INSTALLER_METADATA" | sed -n '1p') + WINDOWS_INSTALLER_SIGNATURE_URL=$(printf '%s\n' "$INSTALLER_METADATA" | sed -n '2p') + if [ "${WINDOWS_INSTALLER_URL%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then + log "ERROR: Manual installer URL does not belong to release $VERSION" + exit 1 + fi + if [ "$WINDOWS_INSTALLER_SIGNATURE_URL" != "${WINDOWS_INSTALLER_URL}.sig" ]; then + log "ERROR: Manual installer signature URL does not match the installer URL" + exit 1 + fi + WINDOWS_INSTALLER_FILENAME="${WINDOWS_INSTALLER_URL##*/}" + else + WINDOWS_INSTALLER_URL="${RELEASE_ASSET_BASE_URL}/${LEGACY_WINDOWS_INSTALLER_FILENAME}" + WINDOWS_INSTALLER_SIGNATURE_URL="${WINDOWS_INSTALLER_URL}.sig" + WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME" + fi + # 3. Create version directory VERSION_DIR="${WEBSITE_RELEASE_DIR}/${VERSION}" mkdir -p "$VERSION_DIR" @@ -524,8 +586,7 @@ for p, info in data.get('platforms', {}).items(): download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 done <<< "$ASSET_LIST" - # latest.json only lists the Tauri setup.exe. Mirror the custom installer - # separately for website users while preserving the updater contract. + # Mirror the manual installer separately while preserving the updater URL. mirror_windows_installer # 6. Rewrite URLs in latest.json to point at openbitfun.com @@ -538,6 +599,10 @@ base = '${OPENBITFUN_BASE_URL}/' + version for p, info in data.get('platforms', {}).items(): fname = info['url'].split('/')[-1] info['url'] = base + '/' + fname +for p, info in data.get('manual_installers', {}).items(): + for key in ('url', 'signature_url'): + if info.get(key): + info[key] = base + '/' + info[key].split('/')[-1] print(json.dumps(data, indent=2)) " > "$LATEST_MANIFEST_TMP" mv "$LATEST_MANIFEST_TMP" "${VERSION_DIR}/latest.json" diff --git a/scripts/prepare-windows-installer-asset.mjs b/scripts/prepare-windows-installer-asset.mjs new file mode 100644 index 0000000000..3761d58142 --- /dev/null +++ b/scripts/prepare-windows-installer-asset.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'fs'; +import { basename, join } from 'path'; + +const args = parseArgs(process.argv.slice(2)); +const assetsDir = requireArg(args, 'assets-dir'); +const version = requireArg(args, 'version'); +const outDir = requireArg(args, 'out-dir'); + +if (!existsSync(assetsDir)) { + fail(`Assets directory does not exist: ${assetsDir}`); +} +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + fail(`Version is not safe for a release asset name: ${version}`); +} + +const candidates = walkFiles(assetsDir).filter( + (file) => basename(file).toLowerCase() === 'bitfun-installer.exe' +); +if (candidates.length !== 1) { + fail(`Expected exactly one bitfun-installer.exe, found ${candidates.length}`); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const outputName = `BitFun_${version}_windows-x86_64-installer.exe`; +const outputPath = join(outDir, outputName); +copyFileSync(candidates[0], outputPath); +console.log(`[manual-installer] ${candidates[0]} -> ${outputPath}`); + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) continue; + const key = arg.slice(2); + const value = rawArgs[i + 1]; + if (!value || value.startsWith('--')) fail(`Missing value for --${key}`); + parsed[key] = value; + i += 1; + } + return parsed; +} + +function requireArg(parsed, key) { + const value = parsed[key]; + if (!value) fail(`Missing required argument --${key}`); + return value; +} + +function walkFiles(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) files.push(...walkFiles(fullPath)); + else if (entry.isFile()) files.push(fullPath); + } + return files; +} + +function fail(message) { + console.error(`[manual-installer] ${message}`); + process.exit(1); +} diff --git a/scripts/tauri-release-manifest.test.mjs b/scripts/tauri-release-manifest.test.mjs new file mode 100644 index 0000000000..17c4a162f2 --- /dev/null +++ b/scripts/tauri-release-manifest.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); + +test('release version metadata is synchronized', () => { + const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + const result = run('scripts/verify-release-version-sync.mjs', ['--version', version]); + assert.equal(result.status, 0, result.stderr); +}); + +test('prepares a versioned custom Windows installer asset', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-manual-installer-')); + const assets = path.join(temp, 'assets', 'nested'); + const out = path.join(temp, 'manual'); + fs.mkdirSync(assets, { recursive: true }); + fs.writeFileSync(path.join(assets, 'bitfun-installer.exe'), 'installer'); + + const result = run('scripts/prepare-windows-installer-asset.mjs', [ + '--assets-dir', path.join(temp, 'assets'), + '--version', '1.2.3', + '--out-dir', out, + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(path.join(out, 'BitFun_1.2.3_windows-x86_64-installer.exe'), 'utf8'), + 'installer' + ); +}); + +test('latest.json keeps the updater URL separate from the manual installer URL', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-latest-manual-')); + const updater = path.join(temp, 'updater'); + const manual = path.join(temp, 'manual'); + const out = path.join(temp, 'latest.json'); + fs.mkdirSync(updater, { recursive: true }); + fs.mkdirSync(manual, { recursive: true }); + + const updaterName = 'BitFun_1.2.3_windows-x86_64-setup.exe'; + fs.writeFileSync(path.join(updater, updaterName), 'setup'); + fs.writeFileSync(path.join(updater, `${updaterName}.sig`), 'inline-updater-signature'); + const installerName = 'BitFun_1.2.3_windows-x86_64-installer.exe'; + fs.writeFileSync(path.join(manual, installerName), 'installer'); + fs.writeFileSync(path.join(manual, `${installerName}.sig`), 'detached-signature'); + + const generated = run('scripts/generate-tauri-latest-json.mjs', [ + '--assets-dir', updater, + '--manual-assets-dir', manual, + '--version', '1.2.3', + '--tag', 'v1.2.3', + '--repo', 'GCWing/BitFun', + '--out', out, + '--required-platforms', 'windows-x86_64', + ]); + assert.equal(generated.status, 0, generated.stderr); + + const manifest = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.match(manifest.platforms['windows-x86_64'].url, /-setup\.exe$/); + assert.match(manifest.manual_installers['windows-x86_64'].url, /-installer\.exe$/); + assert.equal( + manifest.manual_installers['windows-x86_64'].signature_url, + `${manifest.manual_installers['windows-x86_64'].url}.sig` + ); + + const verified = run('scripts/verify-tauri-latest-json.mjs', [ + '--manifest', out, + '--version', '1.2.3', + '--required-platforms', 'windows-x86_64', + '--required-manual-platforms', 'windows-x86_64', + ]); + assert.equal(verified.status, 0, verified.stderr); +}); + +function run(script, args) { + return spawnSync(process.execPath, [script, ...args], { + cwd: root, + encoding: 'utf8', + }); +} diff --git a/scripts/verify-release-version-sync.mjs b/scripts/verify-release-version-sync.mjs new file mode 100644 index 0000000000..9f9ddd49ee --- /dev/null +++ b/scripts/verify-release-version-sync.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { readFileSync } from 'fs'; + +const args = parseArgs(process.argv.slice(2)); +const expected = requireArg(args, 'version'); +const versions = new Map([ + ['package.json', readJsonVersion('package.json')], + ['package-lock.json', readJsonVersion('package-lock.json')], + ['Cargo.toml', readTomlVersion('Cargo.toml', /version = "([^"]+)" # x-release-please-version/)], + ['BitFun-Installer/package.json', readJsonVersion('BitFun-Installer/package.json')], + ['BitFun-Installer/package-lock.json', readJsonVersion('BitFun-Installer/package-lock.json')], + ['BitFun-Installer/src-tauri/Cargo.toml', readTomlVersion('BitFun-Installer/src-tauri/Cargo.toml', /^version = "([^"]+)"/m)], +]); + +const mismatches = [...versions].filter(([, version]) => version !== expected); +if (mismatches.length > 0) { + for (const [file, version] of mismatches) { + console.error(`[release-version] ${file}: expected ${expected}, found ${version}`); + } + process.exit(1); +} +console.log(`[release-version] OK: ${expected}`); + +function readJsonVersion(file) { + return JSON.parse(readFileSync(file, 'utf8')).version; +} + +function readTomlVersion(file, pattern) { + const match = pattern.exec(readFileSync(file, 'utf8')); + if (!match) throw new Error(`Version was not found in ${file}`); + return match[1]; +} + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) continue; + parsed[arg.slice(2)] = rawArgs[i + 1]; + i += 1; + } + return parsed; +} + +function requireArg(parsed, key) { + if (!parsed[key]) throw new Error(`Missing required argument --${key}`); + return parsed[key]; +} diff --git a/scripts/verify-tauri-latest-json.mjs b/scripts/verify-tauri-latest-json.mjs index 81d7c00afc..a4386d12b3 100644 --- a/scripts/verify-tauri-latest-json.mjs +++ b/scripts/verify-tauri-latest-json.mjs @@ -5,6 +5,7 @@ const args = parseArgs(process.argv.slice(2)); const manifestPath = requireArg(args, 'manifest'); const version = args.version; const requiredPlatforms = parseListArg(args['required-platforms'] || ''); +const requiredManualPlatforms = parseListArg(args['required-manual-platforms'] || ''); const checkUrls = ['1', 'true', 'yes'].includes(String(args['check-urls'] || '').toLowerCase()); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); @@ -33,10 +34,33 @@ for (const [platform, entry] of Object.entries(manifest.platforms)) { } } +const manualInstallers = manifest.manual_installers || {}; +const missingManual = requiredManualPlatforms.filter((platform) => !manualInstallers[platform]); +if (missingManual.length > 0) { + fail(`Missing required manual installers: ${missingManual.join(', ')}`); +} +for (const [platform, entry] of Object.entries(manualInstallers)) { + if (!entry || typeof entry !== 'object') fail(`Invalid manual installer entry for ${platform}`); + if (!entry.url || typeof entry.url !== 'string') fail(`Missing manual installer URL for ${platform}`); + if (!entry.signature_url || typeof entry.signature_url !== 'string') { + fail(`Missing manual installer signature URL for ${platform}`); + } + if (entry.signature_url !== `${entry.url}.sig`) { + fail(`Manual installer signature URL for ${platform} must equal url + .sig`); + } + if (manifest.platforms[platform]?.url === entry.url) { + fail(`Manual installer URL for ${platform} must not replace the updater URL`); + } +} + if (checkUrls) { for (const [platform, entry] of Object.entries(manifest.platforms)) { await assertUrlAvailable(platform, entry.url); } + for (const [platform, entry] of Object.entries(manualInstallers)) { + await assertUrlAvailable(`manual installer ${platform}`, entry.url); + await assertUrlAvailable(`manual installer signature ${platform}`, entry.signature_url); + } } console.log(`[verify-latest-json] OK: ${Object.keys(manifest.platforms).sort().join(', ')}`); diff --git a/scripts/version-generation.test.mjs b/scripts/version-generation.test.mjs new file mode 100644 index 0000000000..bd599fcfea --- /dev/null +++ b/scripts/version-generation.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); +const expectedVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + +for (const [buildEnv, isDev] of [['production', false], ['development', true]]) { + test(`generates ${buildEnv} version metadata explicitly`, () => { + const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), `bitfun-version-${buildEnv}-`)); + const result = run(['--build-env', buildEnv, '--output-root', outputRoot]); + assert.equal(result.status, 0, result.stderr); + const generated = JSON.parse( + fs.readFileSync(path.join(outputRoot, 'src/web-ui/public/version.json'), 'utf8') + ); + assert.equal(generated.version, expectedVersion); + assert.equal(generated.buildEnv, buildEnv); + assert.equal(generated.isDev, isDev); + }); +} + +test('fails instead of reusing stale metadata when build environment is missing', () => { + const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-version-missing-')); + const result = run(['--output-root', outputRoot]); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}\n${result.stderr}`, /Expected --build-env/); +}); + +function run(args) { + return spawnSync(process.execPath, ['scripts/generate-version.cjs', ...args], { + cwd: root, + encoding: 'utf8', + }); +} diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index 13953b80cf..b0a4175c82 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -10,7 +10,7 @@ import { Tooltip, Modal, Button, Alert } from '@/component-library'; import { Copy, Check, Download, CheckCircle2 } from 'lucide-react'; import { getAboutInfo, - formatVersion, + formatDisplayedVersion, formatBuildDate } from '@/shared/utils/version'; import { createLogger } from '@/shared/utils/logger'; @@ -42,6 +42,7 @@ export const AboutDialog: React.FC = ({ const [manualCheckErrorMessage, setManualCheckErrorMessage] = useState(null); const [manualOpen, setManualOpen] = useState(false); const [manualData, setManualData] = useState(null); + const [nativeVersion, setNativeVersion] = useState(null); const updateStatus = useUpdateInstallStore(state => state.status); const updateProgress = useUpdateInstallStore(state => state.progress); const updateError = useUpdateInstallStore(state => state.error); @@ -49,6 +50,13 @@ export const AboutDialog: React.FC = ({ const aboutInfo = getAboutInfo(); const { version, license } = aboutInfo; + const nativeRuntime = isTauriRuntime(); + const displayedVersion = formatDisplayedVersion( + version, + nativeVersion, + nativeRuntime, + import.meta.env.DEV + ); const updateProgressPercent = updateProgress.total != null && updateProgress.total > 0 ? Math.min(100, Math.round((updateProgress.downloaded / updateProgress.total) * 100)) @@ -61,6 +69,21 @@ export const AboutDialog: React.FC = ({ } }, [isOpen]); + useEffect(() => { + if (!isOpen || !nativeRuntime) return; + let active = true; + void systemAPI.getAppVersion() + .then(currentVersion => { + if (active) setNativeVersion(currentVersion); + }) + .catch(error => { + log.warn('get_app_version failed; using generated version metadata', error); + }); + return () => { + active = false; + }; + }, [isOpen, nativeRuntime]); + const handleCheckForUpdates = useCallback(async () => { if (!isTauriRuntime()) { return; @@ -134,7 +157,7 @@ export const AboutDialog: React.FC = ({

{version.name}

- {t('about.version', { version: formatVersion(version.version, version.isDev) })} + {t('about.version', { version: displayedVersion })}
@@ -146,7 +169,7 @@ export const AboutDialog: React.FC = ({ {/* Scrollable area */}
- {isTauriRuntime() ? ( + {nativeRuntime ? (
{ + it('uses the native package version without a dev suffix in production', () => { + expect(formatDisplayedVersion(productionInfo, '1.2.3', true, false)).toBe('1.2.3'); + }); + + it('marks a native development runtime as dev', () => { + expect(formatDisplayedVersion(productionInfo, '1.2.3', true, true)).toBe('1.2.3-dev'); + }); + + it('uses generated metadata for the web surface', () => { + const developmentInfo = { ...productionInfo, isDev: true, buildEnv: 'development' as const }; + expect(formatDisplayedVersion(developmentInfo, null, false, false)).toBe('1.2.2-dev'); + }); +}); diff --git a/src/web-ui/src/shared/utils/version.ts b/src/web-ui/src/shared/utils/version.ts index 54afec3e7c..843cf6f5cc 100644 --- a/src/web-ui/src/shared/utils/version.ts +++ b/src/web-ui/src/shared/utils/version.ts @@ -6,11 +6,11 @@ import { i18nService } from '@/infrastructure/i18n'; const DEFAULT_VERSION_INFO: VersionInfo = { name: 'BitFun', - version: '0.2.15', - buildDate: new Date().toISOString(), - buildTimestamp: Date.now(), + version: '0.0.0', + buildDate: new Date(0).toISOString(), + buildTimestamp: 0, isDev: import.meta.env.DEV, - buildEnv: import.meta.env.MODE as 'development' | 'production' | 'preview' + buildEnv: import.meta.env.MODE as VersionInfo['buildEnv'] }; @@ -43,6 +43,17 @@ export function formatVersion(version: string, isDev: boolean): string { return version; } +export function formatDisplayedVersion( + versionInfo: VersionInfo, + nativeVersion: string | null, + nativeRuntime: boolean, + frontendDev: boolean +): string { + const version = nativeVersion ?? versionInfo.version; + const isDev = nativeRuntime ? frontendDev : versionInfo.isDev; + return formatVersion(version, isDev); +} + export function formatBuildDate(buildDate: string): string { try {