diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43b0913..8b0fa55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -314,3 +314,38 @@ jobs: if: runner.os == 'macOS' && always() run: | security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true + + # 各 build slot 并行都写了 latest.json,last-writer-wins 会丢平台条目。 + # 这步等 build 全部完成,基于 release 上各 target 的 .sig 合并一份正确且跨平台的 latest.json 并覆盖。 + publish-json: + needs: [build] + if: always() && needs.build.result == 'success' && (needs.release.outputs.tag != '' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Determine tag + id: get-tag + shell: bash + run: | + if [ "${{ github.event_name }}" = 'workflow_dispatch' ]; then + echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ needs.release.outputs.tag }}" >> "$GITHUB_OUTPUT" + fi + + - name: Download all .sig artifacts from release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.get-tag.outputs.tag }} + shell: bash + run: | + mkdir -p sigs + gh release download "$TAG" --pattern '*.sig' --dir sigs + + - name: Build and upload merged latest.json + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.get-tag.outputs.tag }} + shell: bash + run: node scripts/publish-update-json.cjs diff --git a/scripts/publish-update-json.cjs b/scripts/publish-update-json.cjs new file mode 100644 index 0000000..53cbaf3 --- /dev/null +++ b/scripts/publish-update-json.cjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * 合并 GitHub Release 上各 build target 的 .sig 产物, + * 生成一份覆盖所有平台的 latest.json 并上传覆盖旧的 latest.json。 + * + * 解决 `tauri-action` 在 matrix 并行下 last-writer-wins 让 macOS 条目被吞的问题。 + * + * 输入:环境变量 TAG (GitHub Release tag)、sigs/ 目录里有 `gh release download --pattern '*.sig'` 拉下来的签名。 + * 输出:在 release 上覆盖 latest.json。 + * 不需要 macOS 签名/notarization 凭证 —— 只读签名文本,不再构建。 + */ +const { readFileSync, writeFileSync, readdirSync } = require("node:fs"); +const { execSync } = require("node:child_process"); +const path = require("node:path"); + +const TAG = process.env.TAG; +if (!TAG) { + console.error("TAG env required"); + process.exit(1); +} + +const root = path.resolve(__dirname, ".."); +const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); +const version = pkg.version; + +const sigsDir = path.join(root, "sigs"); +const sigs = new Map(); +for (const f of readdirSync(sigsDir)) { + if (f.endsWith(".sig")) sigs.set(f, readFileSync(path.join(sigsDir, f), "utf8").trim()); +} + +const repo = process.env.GITHUB_REPOSITORY || "lovstudio/lovcode"; +const baseUrl = `https://github.com/${repo}/releases/download/${TAG}`; + +function entryFor(file) { + const sigName = `${file}.sig`; + const sig = sigs.get(sigName); + if (!sig) return null; + return { url: `${baseUrl}/${file}`, signature: sig }; +} + +// 平台 key → 上传文件名。同一 .tar.gz 会映射到两个 key(如 darwin-aarch64 / -app), +// 见 tauri-plugin-updater 2.10 的候选列表 {os}-{arch}-{installer} 与 {os}-{arch}。 +// 顺序与 tauri-action 默认输出保持一致。 +const mappings = [ + ["darwin-aarch64", () => entryFor("lovcode_aarch64.app.tar.gz")], + ["darwin-aarch64-app", () => entryFor("lovcode_aarch64.app.tar.gz")], + ["darwin-x86_64", () => entryFor("lovcode_x64.app.tar.gz")], + ["darwin-x86_64-app", () => entryFor("lovcode_x64.app.tar.gz")], + ["linux-x86_64", () => entryFor(`lovcode_${version}_amd64.AppImage`)], + ["linux-x86_64-appimage", () => entryFor(`lovcode_${version}_amd64.AppImage`)], + ["linux-x86_64-deb", () => entryFor(`lovcode_${version}_amd64.deb`)], + ["linux-x86_64-rpm", () => entryFor(`lovcode-${version}-1.x86_64.rpm`)], + ["windows-x86_64", () => entryFor(`lovcode_${version}_x64_en-US.msi`)], + ["windows-x86_64-msi", () => entryFor(`lovcode_${version}_x64_en-US.msi`)], + ["windows-x86_64-nsis", () => entryFor(`lovcode_${version}_x64-setup.exe`)], +]; + +const platforms = {}; +for (const [key, fn] of mappings) { + if (platforms[key]) continue; // 同一 tarball 已用裸 key 占位,-app / -nsis 等后缀共用 entry + const e = fn(); + if (e) platforms[key] = e; + else console.warn(`skip ${key}: missing ${fn.toString().match(/entryFor\("([^"]+)"\)/)?.[1] || "unknown"}.sig`); +} + +const release = JSON.parse( + execSync(`gh release view "${TAG}" --json publishedAt`, { encoding: "utf8" }) +); + +const latest = { + version, + notes: "", // tauri-action 原从 CHANGELOG 提取;客户端 UpdateChecker 不读,后续可加 + pub_date: release.publishedAt, + platforms, +}; + +const outPath = path.join(root, "latest.json"); +writeFileSync(outPath, JSON.stringify(latest, null, 2)); +execSync(`gh release upload "${TAG}" "${outPath}" --clobber`, { stdio: "inherit" }); +console.log(`latest.json uploaded: keys=${Object.keys(platforms).join(", ")}`); diff --git a/src/components/UpdateChecker.tsx b/src/components/UpdateChecker.tsx index dea7010..be22700 100644 --- a/src/components/UpdateChecker.tsx +++ b/src/components/UpdateChecker.tsx @@ -10,6 +10,19 @@ import { useI18n } from "@/i18n"; export type UpdateStage = "checking" | "latest" | "available" | "downloading" | "done" | "error" | "disabled"; +// "Platform missing" 不是真正的检查失败 —— 该 release 没为当前平台打包。 +// tauri-plugin-updater 2.10 抛的两类: +// Error::TargetNotFound → "the platform `X` was not found in the response `platforms` object" +// Error::TargetsNotFound → "None of the fallback platforms `[..]` were found in the response `platforms` object" +function isUpdaterPlatformMissingError(e: unknown): boolean { + if (!(e instanceof Error)) return false; + const m = e.message; + return ( + m.includes("was not found in the response `platforms` object") || + m.includes("fallback platforms") + ); +} + interface UpdateState { stage: UpdateStage; update: Update | null; @@ -91,8 +104,13 @@ export function UpdateChecker() { .catch((e) => { window.clearTimeout(timeout); if (checkId.current !== currentCheckId) return; - const msg = e instanceof Error ? e.message : String(e); + if (isUpdaterPlatformMissingError(e)) { + // 该 release 没为当前平台出包,UI 不再尖叫 + setState({ stage: "latest", update: null, error: "" }); + return; + } console.error("[UpdateChecker]", e); + const msg = e instanceof Error ? e.message : String(e); setState({ stage: "error", update: null, error: msg }); }); }, [autoUpdateEnabled, setState, updaterProxy]);