From 15af66b045d3485164297b242890d10bddb99f96 Mon Sep 17 00:00:00 2001 From: c9 <3124495484@qq.com> Date: Thu, 10 Sep 2026 14:09:20 +0800 Subject: [PATCH] feat(desktop): ship Tauri 0.1.28 Electron migrate trampoline Prompt existing clients to download GitHub Latest, keep this tag off Latest so Electron updates keep working, and copy latest.json onto the current Latest release. --- .github/workflows/release.yml | 13 ++++ announcements/announcements.json | 50 +++++++++++++- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/app/overlays/AppOverlays.tsx | 2 + src/app/overlays/ElectronMigrateDialog.tsx | 68 +++++++++++++++++++ src/i18n/locales/en.json | 8 +++ src/i18n/locales/ja.json | 8 +++ src/i18n/locales/zh-TW.json | 8 +++ src/i18n/locales/zh.json | 8 +++ src/lib/electronMigrate.ts | 4 ++ .../components/ElectronMigrateDialog.test.tsx | 42 ++++++++++++ 14 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 src/app/overlays/ElectronMigrateDialog.tsx create mode 100644 src/lib/electronMigrate.ts create mode 100644 tests/components/ElectronMigrateDialog.test.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2084399c..009282bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -649,7 +649,20 @@ jobs: tag_name: ${{ github.ref_name }} name: StackFerry ${{ github.ref_name }} prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: "false" body_path: ${{ steps.release-notes.outputs.path }} files: release-assets/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Attach trampoline latest.json to GitHub Latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + test -f release-assets/latest.json + LATEST_TAG=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name) + if [ "$LATEST_TAG" = "$GITHUB_REF_NAME" ]; then + echo "Trampoline release became GitHub Latest; Electron updater would lose latest.yml" >&2 + exit 1 + fi + gh release upload "$LATEST_TAG" release-assets/latest.json --clobber diff --git a/announcements/announcements.json b/announcements/announcements.json index 8433d6ce..5dab185b 100644 --- a/announcements/announcements.json +++ b/announcements/announcements.json @@ -1,13 +1,59 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-04T09:34:00Z", + "generatedAt": "2026-09-10T06:00:00Z", "announcements": [ + { + "id": "2026-09-electron-migrate", + "category": "maintenance", + "severity": "critical", + "publishedAt": "2026-09-10T06:00:00Z", + "expiresAt": null, + "minAppVersion": null, + "maxAppVersion": "0.1.28", + "platforms": ["windows", "macos", "linux"], + "channels": ["stable"], + "relatedVersion": "0.1.28", + "locales": { + "zh": { + "title": "请安装新版 StackFerry", + "summary": "桌面端已经换新。请从 GitHub Latest 下载 Electron 安装包。", + "body": "当前 GitHub Latest 已是新桌面端(Electron 1.0.3)。应用内更新无法把这一版 Tauri 客户端直接换成新软件,请打开 GitHub Latest 下载对应系统的安装包。\n\n本应用数据目录里的供应商列表不会自动迁移。~/.codex、~/.claude、~/.grok 里的 CLI 配置可以继续用。\n\nWindows 和 Linux 可以先和新版并存,再卸载这一份。macOS 安装包未签名,第一次打开可能被 Gatekeeper 拦截。" + }, + "en": { + "title": "Install the new StackFerry", + "summary": "This desktop app has been rebuilt. Download the Electron installer from GitHub Latest.", + "body": "GitHub Latest now hosts the new desktop app (Electron 1.0.3). In-app update cannot replace this Tauri build with that installer, so open GitHub Latest and download the package for your OS.\n\nProvider settings live in this app’s data folder and will not copy themselves over. CLI config in ~/.codex, ~/.claude, and ~/.grok is unchanged.\n\nWindows and Linux can install the new build alongside this one, then uninstall this copy. macOS builds are unsigned; Gatekeeper may block the first launch." + }, + "zh-TW": { + "title": "請安裝新版 StackFerry", + "summary": "桌面端已經換新。請從 GitHub Latest 下載 Electron 安裝包。", + "body": "目前 GitHub Latest 已是新桌面端(Electron 1.0.3)。應用程式內更新無法把這一版 Tauri 用戶端直接換成新軟體,請打開 GitHub Latest 下載對應系統的安裝包。\n\n本應用資料目錄裡的供應商清單不會自動遷移。~/.codex、~/.claude、~/.grok 裡的 CLI 設定可以繼續使用。\n\nWindows 與 Linux 可以先與新版並存,再解除安裝這一份。macOS 安裝包未簽名,第一次開啟可能被 Gatekeeper 攔截。" + }, + "ja": { + "title": "新しい StackFerry をインストールしてください", + "summary": "デスクトップ版は作り直されました。GitHub Latest から Electron インストーラーをダウンロードしてください。", + "body": "GitHub Latest は新しいデスクトップ版(Electron 1.0.3)です。アプリ内更新では、この Tauri 版を新しいインストーラーに置き換えられません。GitHub Latest を開いて、お使いの OS 用パッケージをダウンロードしてください。\n\nこのアプリのデータフォルダーにあるプロバイダー一覧は自動では移行されません。~/.codex、~/.claude、~/.grok の CLI 設定はそのまま使えます。\n\nWindows と Linux では新版と併存してから、このコピーをアンインストールできます。macOS ビルドは未署名のため、初回起動が Gatekeeper にブロックされる場合があります。" + } + }, + "actions": [ + { + "type": "external", + "url": "https://github.com/Ninthless/StackFerry/releases/latest", + "label": { + "zh": "下载新版 StackFerry", + "en": "Download new StackFerry", + "zh-TW": "下載新版 StackFerry", + "ja": "新しい StackFerry をダウンロード" + } + } + ] + }, { "id": "2026-09-stackferry-rebuild", "category": "maintenance", "severity": "important", "publishedAt": "2026-09-04T09:34:00Z", - "expiresAt": null, + "expiresAt": "2026-09-10T00:00:00Z", "minAppVersion": null, "maxAppVersion": null, "platforms": ["windows", "macos", "linux"], diff --git a/package.json b/package.json index 30dd410d..6f31f81b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stackferry", - "version": "0.1.27", + "version": "0.1.28", "description": "Provider and configuration manager for AI coding tools", "type": "module", "packageManager": "pnpm@10.12.3", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b2c3e329..0ca054ec 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5257,7 +5257,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stackferry" -version = "0.1.27" +version = "0.1.28" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 24652a34..88101404 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stackferry" -version = "0.1.27" +version = "0.1.28" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Ninthless", "CC Switch contributors"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4c437532..e7f244e7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "StackFerry", - "version": "0.1.27", + "version": "0.1.28", "identifier": "com.stackferry.desktop", "build": { "frontendDist": "../dist", diff --git a/src/app/overlays/AppOverlays.tsx b/src/app/overlays/AppOverlays.tsx index 1b1ba3f8..3de017d5 100644 --- a/src/app/overlays/AppOverlays.tsx +++ b/src/app/overlays/AppOverlays.tsx @@ -12,6 +12,7 @@ import UsageScriptModal from "@/features/usage/UsageScriptModal"; import { ConfirmDialog } from "@/shared/ui/ConfirmDialog"; import { CriticalAnnouncementDialog } from "@/features/announcements/CriticalAnnouncementDialog"; import { DeepLinkImportDialog } from "./DeepLinkImportDialog"; +import { ElectronMigrateDialog } from "./ElectronMigrateDialog"; import { FirstRunNoticeDialog } from "./FirstRunNoticeDialog"; import type { AppView } from "@/app/shell/types"; @@ -180,6 +181,7 @@ export function AppOverlays({ onCancel={() => onLaunchDashboardOpenChange(false)} /> + > ); diff --git a/src/app/overlays/ElectronMigrateDialog.tsx b/src/app/overlays/ElectronMigrateDialog.tsx new file mode 100644 index 00000000..2f4e2b0e --- /dev/null +++ b/src/app/overlays/ElectronMigrateDialog.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { ArrowUpCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { settingsApi } from "@/platform/tauri/api"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { + ELECTRON_DOWNLOAD_URL, + ELECTRON_MIGRATE_DISMISSED_KEY, +} from "@/lib/electronMigrate"; + +export function ElectronMigrateDialog() { + const { t } = useTranslation(); + const [open, setOpen] = useState( + () => localStorage.getItem(ELECTRON_MIGRATE_DISMISSED_KEY) !== "1", + ); + const [opening, setOpening] = useState(false); + + async function handleDownload(): Promise { + setOpening(true); + try { + await settingsApi.openExternal(ELECTRON_DOWNLOAD_URL); + } finally { + setOpening(false); + } + } + + function handleLater(): void { + localStorage.setItem(ELECTRON_MIGRATE_DISMISSED_KEY, "1"); + setOpen(false); + } + + return ( + + event.preventDefault()} + > + + + + {t("electronMigrate.title")} + + {t("electronMigrate.summary")} + + + {t("electronMigrate.body")} + + + + {t("electronMigrate.later")} + + void handleDownload()} disabled={opening}> + {opening ? t("electronMigrate.opening") : t("electronMigrate.download")} + + + + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index fa564a3e..11a8fdd6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -53,6 +53,14 @@ "bodyOfficial": "An “Official” preset is also included in the list — click it any time you want to switch back to the official default. StackFerry automatically backs up your current config to “default” before switching, so you can move back and forth freely. That's how StackFerry works 😊", "confirm": "Got it" }, + "electronMigrate": { + "title": "Install the new StackFerry", + "summary": "This desktop app has been rebuilt. Download the current GitHub Latest installer.", + "body": "The version you are running is the last Tauri build.\n\nProvider settings live in this app’s data folder and will not copy themselves over. CLI config in ~/.codex, ~/.claude, and ~/.grok is unchanged.\n\nWindows and Linux can install the new build alongside this one, then uninstall this copy. macOS builds are unsigned; Gatekeeper may block the first launch.", + "download": "Download new StackFerry", + "opening": "Opening…", + "later": "Later" + }, "apiKeyInput": { "placeholder": "Enter API Key", "show": "Show API Key", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index add08abc..db25e70d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -53,6 +53,14 @@ "bodyOfficial": "リストには「Official(公式)」プリセットも用意されており、公式バージョンに戻したくなったらクリックするだけで切り替えられます。切り替える前に現在の設定は自動で default にバックアップされるので、自由に行き来できます。これが StackFerry の仕組みです 😊", "confirm": "了解しました" }, + "electronMigrate": { + "title": "新しい StackFerry をインストールしてください", + "summary": "デスクトップ版は作り直されました。GitHub Latest のインストーラーをダウンロードしてください。", + "body": "実行中のビルドは最後の Tauri 版です。\n\nこのアプリのデータフォルダーにあるプロバイダー一覧は自動では移行されません。~/.codex、~/.claude、~/.grok の CLI 設定はそのまま使えます。\n\nWindows と Linux では新版と併存してから、このコピーをアンインストールできます。macOS ビルドは未署名のため、初回起動が Gatekeeper にブロックされる場合があります。", + "download": "新しい StackFerry をダウンロード", + "opening": "開いています…", + "later": "後で" + }, "apiKeyInput": { "placeholder": "API Key を入力", "show": "API Key を表示", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 47a1131d..f9453f53 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -53,6 +53,14 @@ "bodyOfficial": "清單中還預設了「官方 (Official)」供應商,隨時點擊即可切換回官方預設設定。切換前 StackFerry 會自動將目前設定備份回 default,可以放心來回切換。這就是 StackFerry 的運作方式。", "confirm": "我了解了" }, + "electronMigrate": { + "title": "請安裝新版 StackFerry", + "summary": "桌面端已經換新。請下載 GitHub Latest 上的安裝包。", + "body": "你正在使用的是最後一版 Tauri 用戶端。\n\n本應用資料目錄裡的供應商清單不會自動遷移。~/.codex、~/.claude、~/.grok 裡的 CLI 設定可以繼續使用。\n\nWindows 與 Linux 可以先與新版並存,再解除安裝這一份。macOS 安裝包未簽名,第一次開啟可能被 Gatekeeper 攔截。", + "download": "下載新版 StackFerry", + "opening": "正在開啟…", + "later": "稍後" + }, "apiKeyInput": { "placeholder": "請輸入 API Key", "show": "顯示 API Key", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b4434047..ac29331e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -53,6 +53,14 @@ "bodyOfficial": "列表里还预置了 “官方(Official)” 供应商,随时点一下即可切回官方默认配置。切换前 StackFerry 会自动把当前配置备份回 default,可以放心来回切。StackFerry 就是这样工作的 😊", "confirm": "我知道了" }, + "electronMigrate": { + "title": "请安装新版 StackFerry", + "summary": "桌面端已经换新。请下载 GitHub Latest 上的安装包。", + "body": "你正在使用的是最后一版 Tauri 客户端。\n\n本应用数据目录里的供应商列表不会自动迁移。~/.codex、~/.claude、~/.grok 里的 CLI 配置可以继续用。\n\nWindows 和 Linux 可以先和新版并存,再卸载这一份。macOS 安装包未签名,第一次打开可能被 Gatekeeper 拦截。", + "download": "下载新版 StackFerry", + "opening": "正在打开…", + "later": "稍后" + }, "apiKeyInput": { "placeholder": "请输入API Key", "show": "显示API Key", diff --git a/src/lib/electronMigrate.ts b/src/lib/electronMigrate.ts new file mode 100644 index 00000000..3e7ac6db --- /dev/null +++ b/src/lib/electronMigrate.ts @@ -0,0 +1,4 @@ +export const ELECTRON_DOWNLOAD_URL = + "https://github.com/Ninthless/StackFerry/releases/latest"; + +export const ELECTRON_MIGRATE_DISMISSED_KEY = "stackferry:electronMigrate:dismissed"; diff --git a/tests/components/ElectronMigrateDialog.test.tsx b/tests/components/ElectronMigrateDialog.test.tsx new file mode 100644 index 00000000..d9107fe0 --- /dev/null +++ b/tests/components/ElectronMigrateDialog.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ElectronMigrateDialog } from "@/app/overlays/ElectronMigrateDialog"; +import { ELECTRON_DOWNLOAD_URL, ELECTRON_MIGRATE_DISMISSED_KEY } from "@/lib/electronMigrate"; + +const openExternal = vi.fn().mockResolvedValue(undefined); + +vi.mock("@/platform/tauri/api", () => ({ + settingsApi: { + openExternal: (...args: unknown[]) => openExternal(...args), + }, +})); + +describe("ElectronMigrateDialog", () => { + beforeEach(() => { + openExternal.mockClear(); + localStorage.clear(); + }); + + it("opens the Electron GitHub Latest download", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "electronMigrate.download" })); + await waitFor(() => { + expect(openExternal).toHaveBeenCalledWith(ELECTRON_DOWNLOAD_URL); + }); + expect(ELECTRON_DOWNLOAD_URL).toBe( + "https://github.com/Ninthless/StackFerry/releases/latest", + ); + }); + + it("stays closed after the user postpones migration", () => { + localStorage.setItem(ELECTRON_MIGRATE_DISMISSED_KEY, "1"); + render(); + expect(screen.queryByText("electronMigrate.title")).toBeNull(); + }); + + it("persists postpone so the next launch stays quiet", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "electronMigrate.later" })); + expect(localStorage.getItem(ELECTRON_MIGRATE_DISMISSED_KEY)).toBe("1"); + }); +});