{{ t('updater.downloadComplete') }}
{{ t('updater.clickInstallToRestart') }}
@@ -241,7 +265,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NAlert, NButton, NCard, NProgress, NSpace, NSpin, NTag, NText } from 'naive-ui'
-import { isRunningInElectron } from '@prompt-optimizer/core'
import { useUpdater } from '../composables/system/useUpdater'
import Modal from './Modal.vue'
@@ -264,10 +287,43 @@ const {
installUpdate,
ignoreUpdate,
unignoreUpdate,
+ openVersionReleaseUrl,
downloadStableVersion,
downloadPrereleaseVersion
} = useUpdater()
+const isManualUpdateMode = computed(() => state.updateDelivery?.mode !== 'in-app')
+
+const showManualUpdateGuidance = computed(() => (
+ isManualUpdateMode.value && (state.hasStableUpdate || state.hasPrereleaseUpdate)
+))
+
+const isUpdatePolicyUnavailable = computed(() => (
+ !state.updateDelivery || state.updateDelivery.reason === 'policy-unavailable'
+))
+
+const manualUpdateTitle = computed(() => (
+ isUpdatePolicyUnavailable.value
+ ? t('updater.updatePolicyUnavailableTitle')
+ : t('updater.manualUpdateTitle')
+))
+
+const manualUpdateDescription = computed(() => (
+ isUpdatePolicyUnavailable.value
+ ? t('updater.updatePolicyUnavailableDescription')
+ : t('updater.manualUpdateDescription')
+))
+
+const stablePrimaryActionLabel = computed(() => {
+ if (isManualUpdateMode.value) return t('updater.openReleasePage')
+ return state.isDownloadingStable ? t('updater.downloadingShort') : t('updater.download')
+})
+
+const prereleasePrimaryActionLabel = computed(() => {
+ if (isManualUpdateMode.value) return t('updater.openReleasePage')
+ return state.isDownloadingPrerelease ? t('updater.downloadingShort') : t('updater.download')
+})
+
const downloadMessageAlertType = computed(() => {
const type = state.downloadMessage?.type
if (type === 'error') return 'error'
@@ -297,30 +353,26 @@ const handleInstallUpdate = async () => {
const openStableReleaseUrl = async () => {
- if (!state.stableReleaseUrl || !isRunningInElectron() || !window.electronAPI?.shell) return
-
- try {
- await window.electronAPI.shell.openExternal(state.stableReleaseUrl)
- } catch (error) {
- console.error('[UpdaterModal] Open stable release URL error:', error)
- }
+ await openVersionReleaseUrl('stable')
}
const openPrereleaseReleaseUrl = async () => {
- if (!state.prereleaseReleaseUrl || !isRunningInElectron() || !window.electronAPI?.shell) return
-
- try {
- await window.electronAPI.shell.openExternal(state.prereleaseReleaseUrl)
- } catch (error) {
- console.error('[UpdaterModal] Open prerelease release URL error:', error)
- }
+ await openVersionReleaseUrl('prerelease')
}
const handleDownloadStable = async () => {
+ if (isManualUpdateMode.value) {
+ await openStableReleaseUrl()
+ return
+ }
await downloadStableVersion()
}
const handleDownloadPrerelease = async () => {
+ if (isManualUpdateMode.value) {
+ await openPrereleaseReleaseUrl()
+ return
+ }
await downloadPrereleaseVersion()
}
diff --git a/packages/ui/src/composables/system/useUpdater.ts b/packages/ui/src/composables/system/useUpdater.ts
index d623e090..735f3fce 100644
--- a/packages/ui/src/composables/system/useUpdater.ts
+++ b/packages/ui/src/composables/system/useUpdater.ts
@@ -5,11 +5,19 @@ import { usePreferences } from '../storage/usePreferenceManager'
import { useI18n } from 'vue-i18n'
import { asExtendedError } from '../../utils/error'
// 移除过度抽象的 Hook,直接使用 window.electronAPI
-import type { DownloadProgress, UpdateInfo } from '@/types/electron'
+import type { DownloadProgress, UpdateDelivery, UpdateInfo } from '@/types/electron'
import type { AppServices } from '../../types/services'
// 类型定义现在从 @/types/electron 导入,保持统一
+const POLICY_UNAVAILABLE_DELIVERY: UpdateDelivery = {
+ mode: 'manual-release',
+ reason: 'policy-unavailable',
+ platform: 'unknown',
+ arch: 'unknown',
+ fallbackReleaseUrl: null,
+}
+
export interface UpdaterState {
hasUpdate: boolean
updateInfo: UpdateInfo | null
@@ -23,6 +31,8 @@ export interface UpdaterState {
stableReleaseUrl: string | null
prereleaseVersion: string | null
prereleaseReleaseUrl: string | null
+ updateDelivery: UpdateDelivery | null
+ releasePageError: string | null
hasStableUpdate: boolean
hasPrereleaseUpdate: boolean
currentVersion: string | null
@@ -35,6 +45,22 @@ export interface UpdaterState {
isPrereleaseVersionIgnored: boolean
}
+// A terminal check failure invalidates the remote-version snapshot, but must
+// not disturb an update that is already downloading or ready to install.
+export const invalidateActionableCheckState = (state: UpdaterState): void => {
+ state.hasUpdate = false
+ state.updateInfo = null
+ state.hasStableUpdate = false
+ state.hasPrereleaseUpdate = false
+ state.stableVersion = null
+ state.stableReleaseUrl = null
+ state.prereleaseVersion = null
+ state.prereleaseReleaseUrl = null
+ state.isStableVersionIgnored = false
+ state.isPrereleaseVersionIgnored = false
+ state.releasePageError = null
+}
+
// 更新器实例类型
interface UpdaterInstance {
state: UpdaterState
@@ -43,7 +69,7 @@ interface UpdaterInstance {
installUpdate: () => Promise
ignoreUpdate: (version?: string, versionType?: 'stable' | 'prerelease') => Promise
unignoreUpdate: (versionType: 'stable' | 'prerelease') => Promise
- openReleaseUrl: () => Promise
+ openVersionReleaseUrl: (versionType: 'stable' | 'prerelease') => Promise
downloadStableVersion: () => Promise
downloadPrereleaseVersion: () => Promise
}
@@ -76,6 +102,8 @@ export function useUpdater() {
stableReleaseUrl: null,
prereleaseVersion: null,
prereleaseReleaseUrl: null,
+ updateDelivery: null,
+ releasePageError: null,
hasStableUpdate: false,
hasPrereleaseUpdate: false,
currentVersion: null,
@@ -91,7 +119,7 @@ export function useUpdater() {
installUpdate: () => Promise.resolve(),
ignoreUpdate: () => Promise.resolve(),
unignoreUpdate: () => Promise.resolve(),
- openReleaseUrl: () => Promise.resolve(),
+ openVersionReleaseUrl: () => Promise.resolve(false),
downloadStableVersion: () => Promise.resolve(),
downloadPrereleaseVersion: () => Promise.resolve()
}
@@ -120,6 +148,8 @@ export function useUpdater() {
stableReleaseUrl: null,
prereleaseVersion: null,
prereleaseReleaseUrl: null,
+ updateDelivery: null,
+ releasePageError: null,
hasStableUpdate: false,
hasPrereleaseUpdate: false,
currentVersion: null,
@@ -158,6 +188,53 @@ export function useUpdater() {
throw new Error('No results returned from version check')
}
+ // A concurrent caller must not turn an in-progress response into a completed
+ // "no update" result or clear an existing download state.
+ if (results.inProgress) {
+ if (results.updateDelivery) {
+ state.updateDelivery = results.updateDelivery
+ }
+ console.log('[useUpdater] Update check is already in progress; preserving current state')
+ return
+ }
+
+ // Clear the previous download message only for an accepted, completed check.
+ state.downloadMessage = null
+
+ // Reset stale check results only after the main process accepted and completed
+ // this request. An in-progress response must leave the existing UI untouched.
+ if (!state.isDownloading) {
+ state.isDownloaded = false
+ state.downloadProgress = null
+ state.hasUpdate = false
+ state.updateInfo = null
+ state.lastCheckResult = 'none'
+ state.lastCheckMessage = ''
+ state.hasStableUpdate = false
+ state.hasPrereleaseUpdate = false
+ state.stableVersion = null
+ state.stableReleaseUrl = null
+ state.prereleaseVersion = null
+ state.prereleaseReleaseUrl = null
+ state.updateDelivery = null
+ state.releasePageError = null
+ // Keep ignore flags; they are synchronized with the main process below.
+ console.log('[useUpdater] Reset stale state for completed update check')
+ }
+
+ // Unknown/missing capability must fail closed: checking is safe, downloading is not.
+ state.updateDelivery = results.updateDelivery || POLICY_UNAVAILABLE_DELIVERY
+ state.releasePageError = null
+
+ // 手动交付模式不能保留任何应用内下载或安装状态。
+ if (state.updateDelivery.mode !== 'in-app') {
+ state.isDownloading = false
+ state.isDownloadingStable = false
+ state.isDownloadingPrerelease = false
+ state.isDownloaded = false
+ state.downloadProgress = null
+ }
+
// 保存正式版信息
if (results.stable && !results.stable.error && !results.stable.noVersionFound) {
const newStableVersion = results.stable.remoteVersion || null
@@ -244,6 +321,7 @@ export function useUpdater() {
} catch (error) {
console.error('[useUpdater] Error checking all versions:', error)
+ invalidateActionableCheckState(state)
state.lastCheckResult = 'error'
state.lastCheckMessage = error instanceof Error ? error.message : String(error)
} finally {
@@ -431,33 +509,6 @@ export function useUpdater() {
try {
state.isCheckingUpdate = true
- // 清除之前的下载消息,因为这是一个新的检查操作
- state.downloadMessage = null
-
- // 智能状态重置:只在没有下载进行时才重置下载相关状态
- if (!state.isDownloading) {
- state.isDownloaded = false
- state.downloadProgress = null
- state.hasUpdate = false
- state.updateInfo = null
- state.lastCheckResult = 'none'
- state.lastCheckMessage = ''
- // 重置版本更新状态,确保每次检测都能正确更新
- state.hasStableUpdate = false
- state.hasPrereleaseUpdate = false
- state.stableVersion = null
- state.stableReleaseUrl = null
- state.prereleaseVersion = null
- state.prereleaseReleaseUrl = null
- // 注意:不重置忽略状态,让用户的忽略选择在新检查中保持有效
- // state.isStableVersionIgnored 和 state.isPrereleaseVersionIgnored 保持不变
- // 清除持久化的检测状态
- await saveUpdateState()
- console.log('[useUpdater] Reset states for new update check (keeping ignore states)')
- } else {
- console.log('[useUpdater] Download in progress, preserving download states')
- }
-
// 检查两种版本:正式版和预览版
await checkBothVersions()
} catch (error) {
@@ -514,6 +565,11 @@ export function useUpdater() {
// 安装更新
const installUpdate = async () => {
+ if (state.updateDelivery?.mode !== 'in-app') {
+ console.warn('[useUpdater] In-app installation is unavailable for this build')
+ return
+ }
+
if (!window.electronAPI?.updater) {
console.warn('[useUpdater] Electron updater API not available')
return
@@ -655,6 +711,30 @@ export function useUpdater() {
+ const openVersionReleaseUrl = async (versionType: 'stable' | 'prerelease'): Promise => {
+ const version = versionType === 'stable'
+ ? state.stableVersion
+ : state.prereleaseVersion
+
+ state.releasePageError = null
+
+ if (!window.electronAPI?.updater?.openReleasePage) {
+ console.warn('[useUpdater] Updater release-page API is unavailable')
+ state.releasePageError = t('updater.releasePageUnavailable')
+ return false
+ }
+
+ try {
+ await window.electronAPI.updater.openReleasePage(version || undefined)
+ console.log(`[useUpdater] Opened ${versionType} release URL successfully`)
+ return true
+ } catch (error) {
+ console.error(`[useUpdater] Failed to open ${versionType} release URL:`, error)
+ state.releasePageError = t('updater.openReleaseFailed')
+ return false
+ }
+ }
+
// 下载正式版(使用原子操作)
const downloadStableVersion = async () => {
if (!state.stableVersion) {
@@ -664,6 +744,12 @@ export function useUpdater() {
return
}
+ if (state.updateDelivery?.mode !== 'in-app') {
+ state.lastDownloadAttempt = 'stable'
+ await openVersionReleaseUrl('stable')
+ return
+ }
+
// 防止重复点击 - 检查所有下载状态
if (state.isDownloadingStable || state.isDownloadingPrerelease || state.isDownloading) {
console.log('[useUpdater] Download already in progress')
@@ -739,6 +825,12 @@ export function useUpdater() {
return
}
+ if (state.updateDelivery?.mode !== 'in-app') {
+ state.lastDownloadAttempt = 'prerelease'
+ await openVersionReleaseUrl('prerelease')
+ return
+ }
+
// 防止重复点击 - 检查所有下载状态
if (state.isDownloadingStable || state.isDownloadingPrerelease || state.isDownloading) {
console.log('[useUpdater] Download already in progress')
@@ -805,21 +897,6 @@ export function useUpdater() {
}
}
- // 打开发布页面
- const openReleaseUrl = async () => {
- if (!state.updateInfo?.releaseUrl || !window.electronAPI?.shell) {
- console.warn('[useUpdater] Release URL or shell API not available')
- return
- }
-
- try {
- await window.electronAPI.shell.openExternal(state.updateInfo.releaseUrl)
- console.log('[useUpdater] Release URL opened successfully')
- } catch (error) {
- console.error('[useUpdater] Open release URL error:', error)
- }
- }
-
// 设置IPC事件监听器 - 现在主要用于下载相关事件
const setupEventListeners = () => {
if (!window.electronAPI?.on) {
@@ -980,7 +1057,7 @@ export function useUpdater() {
installUpdate,
ignoreUpdate,
unignoreUpdate,
- openReleaseUrl,
+ openVersionReleaseUrl,
downloadStableVersion,
downloadPrereleaseVersion
}
diff --git a/packages/ui/src/i18n/locales/en-US/image.ts b/packages/ui/src/i18n/locales/en-US/image.ts
index 20e22f17..bcb2d4c6 100644
--- a/packages/ui/src/i18n/locales/en-US/image.ts
+++ b/packages/ui/src/i18n/locales/en-US/image.ts
@@ -329,6 +329,13 @@ const messages = {
"devEnvironment": "Development Environment: Update checking is disabled",
"clickToCheck": "Click to check for updates",
"viewOnGitHub": "View on GitHub",
+ "manualUpdateTitle": "macOS currently requires a manual update",
+ "manualUpdateDescription": "The current macOS build is not signed with an Apple Developer ID, so it cannot be installed through the in-app updater. Open the release page, download the DMG for this Mac, quit the app, then drag it into Applications and choose Replace. If macOS blocks the first launch, allow it in System Settings → Privacy & Security.",
+ "updatePolicyUnavailableTitle": "A manual update is currently required",
+ "updatePolicyUnavailableDescription": "The app could not confirm whether this build supports in-app installation. To avoid a failed update, open the release page and install the package manually.",
+ "openReleasePage": "Open GitHub Release",
+ "openReleaseFailed": "Could not open the release page. Please try again later.",
+ "releasePageUnavailable": "The release page address is unavailable. Check for updates again and retry.",
"noReleasesFound": "No releases found. This project may not have published any versions yet.",
"noStableReleasesFound": "No stable releases found. Only prerelease versions may be available."
}
diff --git a/packages/ui/src/i18n/locales/zh-CN/image.ts b/packages/ui/src/i18n/locales/zh-CN/image.ts
index 0bc8ed37..14c5a9de 100644
--- a/packages/ui/src/i18n/locales/zh-CN/image.ts
+++ b/packages/ui/src/i18n/locales/zh-CN/image.ts
@@ -329,6 +329,13 @@ const messages = {
"viewOnGitHub": "在 GitHub 上查看",
"devEnvironment": "开发环境:更新检查已禁用",
"clickToCheck": "点击检查更新",
+ "manualUpdateTitle": "macOS 当前需要手动更新",
+ "manualUpdateDescription": "当前 macOS 发布包未使用 Apple Developer ID 签名,无法在应用内完成安装。请前往版本发布页下载适用于本机的 DMG,退出应用后将其拖入“应用程序”并选择替换。若首次打开被拦截,请在“系统设置 → 隐私与安全性”中允许打开。",
+ "updatePolicyUnavailableTitle": "当前只能手动更新",
+ "updatePolicyUnavailableDescription": "无法确认当前构建是否支持应用内安装。为避免更新失败,请前往版本发布页手动下载安装包。",
+ "openReleasePage": "打开 GitHub Release",
+ "openReleaseFailed": "无法打开版本发布页,请稍后重试。",
+ "releasePageUnavailable": "当前未获取到版本发布页地址,请重新检查更新后再试。",
"noReleasesFound": "未找到发布版本。此项目可能尚未发布任何版本。",
"noStableReleasesFound": "未找到稳定版本。可能只有预发布版本可用。"
}
diff --git a/packages/ui/src/i18n/locales/zh-TW/image.ts b/packages/ui/src/i18n/locales/zh-TW/image.ts
index f7d1b6e5..86421426 100644
--- a/packages/ui/src/i18n/locales/zh-TW/image.ts
+++ b/packages/ui/src/i18n/locales/zh-TW/image.ts
@@ -329,6 +329,13 @@ const messages = {
"viewOnGitHub": "在 GitHub 上檢視",
"devEnvironment": "開發環境:更新檢查已停用",
"clickToCheck": "點選檢查更新",
+ "manualUpdateTitle": "macOS 目前需要手動更新",
+ "manualUpdateDescription": "目前的 macOS 發布套件未使用 Apple Developer ID 簽署,無法在應用程式內完成安裝。請前往版本發布頁下載適用於本機的 DMG,結束應用程式後將其拖入「應用程式」並選擇取代。若首次開啟遭到阻擋,請在「系統設定 → 隱私權與安全性」中允許開啟。",
+ "updatePolicyUnavailableTitle": "目前只能手動更新",
+ "updatePolicyUnavailableDescription": "無法確認目前的建置是否支援應用程式內安裝。為避免更新失敗,請前往版本發布頁手動下載安裝套件。",
+ "openReleasePage": "開啟 GitHub Release",
+ "openReleaseFailed": "無法開啟版本發布頁,請稍後再試。",
+ "releasePageUnavailable": "目前未取得版本發布頁位址,請重新檢查更新後再試。",
"noReleasesFound": "未找到發布版本。此專案可能尚未發布任何版本。",
"noStableReleasesFound": "未找到穩定版本。可能只有預發布版本可用。"
}
diff --git a/packages/ui/src/types/electron.d.ts b/packages/ui/src/types/electron.d.ts
index e42afc4f..f005eaed 100644
--- a/packages/ui/src/types/electron.d.ts
+++ b/packages/ui/src/types/electron.d.ts
@@ -38,13 +38,24 @@ interface AppAPI {
}
// 更新器相关API - 简单直接的类型定义
+interface UpdateDelivery {
+ mode: 'in-app' | 'manual-release'
+ reason: 'macos-unsigned' | 'policy-unavailable' | null
+ platform: string
+ arch: string
+ fallbackReleaseUrl: string | null
+}
+
interface UpdaterAPI {
checkUpdate(): Promise
checkAllVersions(): Promise<{
currentVersion: string
- stable?: {
- remoteVersion?: string
- remoteReleaseUrl?: string
+ updateDelivery: UpdateDelivery
+ inProgress?: boolean
+ message?: string
+ stable: {
+ remoteVersion?: string | null
+ remoteReleaseUrl?: string | null
error?: string
noVersionFound?: boolean
hasUpdate?: boolean
@@ -52,10 +63,10 @@ interface UpdaterAPI {
versionType?: string
releaseDate?: string
releaseNotes?: string
- }
- prerelease?: {
- remoteVersion?: string
- remoteReleaseUrl?: string
+ } | null
+ prerelease: {
+ remoteVersion?: string | null
+ remoteReleaseUrl?: string | null
error?: string
noVersionFound?: boolean
hasUpdate?: boolean
@@ -63,8 +74,9 @@ interface UpdaterAPI {
versionType?: string
releaseDate?: string
releaseNotes?: string
- }
+ } | null
}>
+ openReleasePage(version?: string): Promise<{ url: string }>
downloadSpecificVersion(versionType: 'stable' | 'prerelease'): Promise<{
hasUpdate: boolean
message: string
@@ -275,6 +287,7 @@ export type {
ElectronAPI,
DownloadProgress,
UpdateInfo,
+ UpdateDelivery,
VersionCheckResult,
DownloadResult
}
diff --git a/packages/ui/tests/unit/components/UpdaterModal.spec.ts b/packages/ui/tests/unit/components/UpdaterModal.spec.ts
new file mode 100644
index 00000000..ef88abae
--- /dev/null
+++ b/packages/ui/tests/unit/components/UpdaterModal.spec.ts
@@ -0,0 +1,296 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h, reactive } from 'vue'
+import { flushPromises, mount } from '@vue/test-utils'
+
+const updaterModuleMock = vi.hoisted(() => ({
+ useUpdater: vi.fn(),
+}))
+
+vi.mock('../../../src/composables/system/useUpdater', () => ({
+ useUpdater: updaterModuleMock.useUpdater,
+}))
+
+import UpdaterModal from '../../../src/components/UpdaterModal.vue'
+
+const ModalStub = defineComponent({
+ name: 'Modal',
+ setup(_, { slots }) {
+ return () => h('section', { 'data-testid': 'modal-stub' }, [
+ slots.title?.(),
+ slots.default?.(),
+ slots.footer?.(),
+ ])
+ },
+})
+
+const PassthroughStub = defineComponent({
+ inheritAttrs: false,
+ setup(_, { attrs, slots }) {
+ return () => h('div', attrs, [
+ slots.header?.(),
+ slots.default?.(),
+ slots.description?.(),
+ ])
+ },
+})
+
+const AlertStub = defineComponent({
+ name: 'NAlert',
+ inheritAttrs: false,
+ props: {
+ type: String,
+ },
+ setup(props, { attrs, slots }) {
+ return () => h('div', {
+ ...attrs,
+ 'data-stub': 'alert',
+ 'data-alert-type': props.type,
+ }, slots.default?.())
+ },
+})
+
+const ButtonStub = defineComponent({
+ name: 'NButton',
+ inheritAttrs: false,
+ props: {
+ disabled: Boolean,
+ loading: Boolean,
+ },
+ emits: ['click'],
+ setup(props, { attrs, emit, slots }) {
+ return () => h('button', {
+ ...attrs,
+ disabled: props.disabled,
+ 'data-loading': String(props.loading),
+ onClick: () => emit('click'),
+ }, slots.default?.())
+ },
+})
+
+const ProgressStub = defineComponent({
+ name: 'NProgress',
+ setup() {
+ return () => h('div', { 'data-testid': 'download-progress' })
+ },
+})
+
+type UpdateDelivery = {
+ mode: 'in-app' | 'manual-release'
+ reason: 'macos-unsigned' | null
+ platform: string
+ arch: string
+ fallbackReleaseUrl: string | null
+}
+
+const manualDelivery = (arch = 'arm64'): UpdateDelivery => ({
+ mode: 'manual-release',
+ reason: 'macos-unsigned',
+ platform: 'darwin',
+ arch,
+ fallbackReleaseUrl: 'https://github.com/linshenkx/prompt-optimizer/releases',
+})
+
+const inAppDelivery: UpdateDelivery = {
+ mode: 'in-app',
+ reason: null,
+ platform: 'win32',
+ arch: 'x64',
+ fallbackReleaseUrl: 'https://github.com/linshenkx/prompt-optimizer/releases',
+}
+
+const createState = (overrides: Record = {}) => reactive({
+ hasUpdate: false,
+ updateInfo: null,
+ downloadProgress: null,
+ isDownloading: false,
+ isDownloaded: false,
+ isCheckingUpdate: false,
+ lastCheckResult: 'none',
+ lastCheckMessage: '',
+ stableVersion: null,
+ stableReleaseUrl: null,
+ prereleaseVersion: null,
+ prereleaseReleaseUrl: null,
+ updateDelivery: null,
+ releasePageError: null,
+ hasStableUpdate: false,
+ hasPrereleaseUpdate: false,
+ currentVersion: '2.11.7',
+ isDownloadingStable: false,
+ isDownloadingPrerelease: false,
+ downloadMessage: null,
+ lastDownloadAttempt: null,
+ isStableVersionIgnored: false,
+ isPrereleaseVersionIgnored: false,
+ ...overrides,
+})
+
+const createUpdater = (stateOverrides: Record = {}) => {
+ const updater = {
+ state: createState(stateOverrides),
+ checkUpdate: vi.fn().mockResolvedValue(undefined),
+ installUpdate: vi.fn().mockResolvedValue(undefined),
+ ignoreUpdate: vi.fn().mockResolvedValue(undefined),
+ unignoreUpdate: vi.fn().mockResolvedValue(undefined),
+ openVersionReleaseUrl: vi.fn().mockResolvedValue(true),
+ downloadStableVersion: vi.fn().mockResolvedValue(undefined),
+ downloadPrereleaseVersion: vi.fn().mockResolvedValue(undefined),
+ }
+
+ updaterModuleMock.useUpdater.mockReturnValue(updater)
+ return updater
+}
+
+const mountUpdaterModal = () => mount(UpdaterModal, {
+ props: {
+ modelValue: true,
+ },
+ global: {
+ stubs: {
+ Modal: ModalStub,
+ NAlert: AlertStub,
+ NButton: ButtonStub,
+ NCard: PassthroughStub,
+ NProgress: ProgressStub,
+ NSpace: PassthroughStub,
+ NSpin: PassthroughStub,
+ NTag: PassthroughStub,
+ NText: PassthroughStub,
+ },
+ },
+})
+
+describe('UpdaterModal update delivery actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('shows manual guidance and opens the stable release page without downloading', async () => {
+ const updater = createUpdater({
+ updateDelivery: manualDelivery('arm64'),
+ stableVersion: '2.12.0',
+ stableReleaseUrl: 'https://github.com/linshenkx/prompt-optimizer/releases/tag/v2.12.0',
+ hasStableUpdate: true,
+ hasUpdate: true,
+ })
+ const wrapper = mountUpdaterModal()
+
+ const guidance = wrapper.get('[data-testid="macos-manual-update-guidance"]')
+ expect(guidance.text()).toContain('macOS currently requires a manual update')
+
+ await wrapper.get('[data-testid="stable-update-primary-action"]').trigger('click')
+ await flushPromises()
+
+ expect(updater.openVersionReleaseUrl).toHaveBeenCalledOnce()
+ expect(updater.openVersionReleaseUrl).toHaveBeenCalledWith('stable')
+ expect(updater.downloadStableVersion).not.toHaveBeenCalled()
+ expect(updater.downloadPrereleaseVersion).not.toHaveBeenCalled()
+ })
+
+ it('opens the prerelease page in manual mode without starting a prerelease download', async () => {
+ const updater = createUpdater({
+ updateDelivery: manualDelivery('x64'),
+ prereleaseVersion: '2.12.0-rc.1',
+ prereleaseReleaseUrl: 'https://github.com/linshenkx/prompt-optimizer/releases/tag/v2.12.0-rc.1',
+ hasPrereleaseUpdate: true,
+ hasUpdate: true,
+ })
+ const wrapper = mountUpdaterModal()
+
+ await wrapper.get('[data-testid="prerelease-update-primary-action"]').trigger('click')
+ await flushPromises()
+
+ expect(updater.openVersionReleaseUrl).toHaveBeenCalledOnce()
+ expect(updater.openVersionReleaseUrl).toHaveBeenCalledWith('prerelease')
+ expect(updater.downloadPrereleaseVersion).not.toHaveBeenCalled()
+ expect(updater.downloadStableVersion).not.toHaveBeenCalled()
+ })
+
+ it('does not recommend a specific installer architecture', () => {
+ createUpdater({
+ updateDelivery: manualDelivery('x64'),
+ stableVersion: '2.12.0',
+ hasStableUpdate: true,
+ hasUpdate: true,
+ })
+ const wrapper = mountUpdaterModal()
+
+ const guidance = wrapper.get('[data-testid="macos-manual-update-guidance"]')
+ expect(guidance.text()).not.toContain('mac-arm64.dmg')
+ expect(guidance.text()).not.toContain('mac-x64.dmg')
+ })
+
+ it('fails closed when update delivery policy is unavailable', async () => {
+ const updater = createUpdater({
+ updateDelivery: null,
+ stableVersion: '2.12.0',
+ hasStableUpdate: true,
+ hasUpdate: true,
+ })
+ const wrapper = mountUpdaterModal()
+
+ await wrapper.get('[data-testid="stable-update-primary-action"]').trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('A manual update is currently required')
+ expect(updater.openVersionReleaseUrl).toHaveBeenCalledWith('stable')
+ expect(updater.downloadStableVersion).not.toHaveBeenCalled()
+ })
+
+ it('hides in-app progress and installation UI when manual delivery state is stale', () => {
+ const updater = createUpdater({
+ updateDelivery: manualDelivery(),
+ stableVersion: '2.12.0',
+ hasStableUpdate: true,
+ hasUpdate: true,
+ isDownloading: true,
+ isDownloadingStable: true,
+ downloadProgress: {
+ percent: 50,
+ transferred: 512,
+ total: 1024,
+ bytesPerSecond: 128,
+ },
+ isDownloaded: true,
+ })
+ const wrapper = mountUpdaterModal()
+
+ expect(wrapper.find('[data-testid="download-progress"]').exists()).toBe(false)
+ expect(wrapper.find('[data-stub="alert"][data-alert-type="success"]').exists()).toBe(false)
+ expect(updater.installUpdate).not.toHaveBeenCalled()
+ })
+
+ it('keeps stable and prerelease download actions in in-app delivery mode', async () => {
+ const updater = createUpdater({
+ updateDelivery: inAppDelivery,
+ stableVersion: '2.12.0',
+ stableReleaseUrl: 'https://github.com/linshenkx/prompt-optimizer/releases/tag/v2.12.0',
+ prereleaseVersion: '2.13.0-beta.1',
+ prereleaseReleaseUrl: 'https://github.com/linshenkx/prompt-optimizer/releases/tag/v2.13.0-beta.1',
+ hasStableUpdate: true,
+ hasPrereleaseUpdate: true,
+ hasUpdate: true,
+ })
+ const wrapper = mountUpdaterModal()
+
+ expect(wrapper.find('[data-testid="macos-manual-update-guidance"]').exists()).toBe(false)
+
+ await wrapper.get('[data-testid="stable-update-primary-action"]').trigger('click')
+ await wrapper.get('[data-testid="prerelease-update-primary-action"]').trigger('click')
+ await flushPromises()
+
+ expect(updater.downloadStableVersion).toHaveBeenCalledOnce()
+ expect(updater.downloadPrereleaseVersion).toHaveBeenCalledOnce()
+ expect(updater.openVersionReleaseUrl).not.toHaveBeenCalled()
+ })
+
+ it('renders a release-page error from updater state', () => {
+ createUpdater({
+ updateDelivery: manualDelivery(),
+ releasePageError: 'The release page could not be opened.',
+ })
+ const wrapper = mountUpdaterModal()
+
+ expect(wrapper.text()).toContain('The release page could not be opened.')
+ })
+})
diff --git a/packages/ui/tests/unit/composables/useUpdater.failure-state.spec.ts b/packages/ui/tests/unit/composables/useUpdater.failure-state.spec.ts
new file mode 100644
index 00000000..1ff5eed5
--- /dev/null
+++ b/packages/ui/tests/unit/composables/useUpdater.failure-state.spec.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ invalidateActionableCheckState,
+ type UpdaterState,
+} from '../../../src/composables/system/useUpdater'
+
+const createState = (overrides: Partial = {}): UpdaterState => ({
+ hasUpdate: true,
+ updateInfo: { version: '2.12.0' },
+ downloadProgress: { percent: 42, transferred: 42, total: 100, bytesPerSecond: 10 },
+ isDownloading: true,
+ isDownloaded: false,
+ isCheckingUpdate: true,
+ lastCheckResult: 'available',
+ lastCheckMessage: 'New version available',
+ stableVersion: '2.12.0',
+ stableReleaseUrl: 'https://github.com/example/repo/releases/tag/v2.12.0',
+ prereleaseVersion: '2.13.0-beta.1',
+ prereleaseReleaseUrl: 'https://github.com/example/repo/releases/tag/v2.13.0-beta.1',
+ updateDelivery: {
+ mode: 'in-app',
+ reason: null,
+ platform: 'win32',
+ arch: 'x64',
+ fallbackReleaseUrl: 'https://github.com/example/repo/releases',
+ },
+ releasePageError: 'Old release error',
+ hasStableUpdate: true,
+ hasPrereleaseUpdate: true,
+ currentVersion: '2.11.7',
+ isDownloadingStable: true,
+ isDownloadingPrerelease: false,
+ downloadMessage: { type: 'info', content: 'Download started' },
+ lastDownloadAttempt: 'stable',
+ isStableVersionIgnored: true,
+ isPrereleaseVersionIgnored: true,
+ ...overrides,
+})
+
+describe('invalidateActionableCheckState', () => {
+ it('removes stale version actions while preserving an active download and delivery policy', () => {
+ const state = createState()
+ const deliveryPolicy = state.updateDelivery
+ const downloadMessage = state.downloadMessage
+
+ invalidateActionableCheckState(state)
+
+ expect(state).toMatchObject({
+ hasUpdate: false,
+ updateInfo: null,
+ hasStableUpdate: false,
+ hasPrereleaseUpdate: false,
+ stableVersion: null,
+ stableReleaseUrl: null,
+ prereleaseVersion: null,
+ prereleaseReleaseUrl: null,
+ isStableVersionIgnored: false,
+ isPrereleaseVersionIgnored: false,
+ releasePageError: null,
+ isDownloading: true,
+ isDownloadingStable: true,
+ })
+ expect(state.downloadProgress?.percent).toBe(42)
+ expect(state.downloadMessage).toBe(downloadMessage)
+ expect(state.updateDelivery).toBe(deliveryPolicy)
+ })
+
+ it('preserves an update that is already ready to install', () => {
+ const state = createState({
+ isDownloading: false,
+ isDownloadingStable: false,
+ isDownloaded: true,
+ downloadProgress: null,
+ })
+
+ invalidateActionableCheckState(state)
+
+ expect(state.isDownloaded).toBe(true)
+ })
+})
diff --git a/scripts/desktop-ipc-handlers.test.mjs b/scripts/desktop-ipc-handlers.test.mjs
index 2ba8b419..bfa25ba9 100644
--- a/scripts/desktop-ipc-handlers.test.mjs
+++ b/scripts/desktop-ipc-handlers.test.mjs
@@ -50,6 +50,169 @@ test('desktop preload IPC channels have main-process handlers', () => {
assert.deepEqual(missingHandlers, [])
})
+test('macOS manual-update policy guards every mutating updater entry point first', () => {
+ const main = readText('packages/desktop/main.js')
+
+ for (const [eventName, signature] of [
+ ['UPDATE_START_DOWNLOAD', 'async \\(\\)'],
+ ['UPDATE_INSTALL', 'async \\(\\)'],
+ ['UPDATE_DOWNLOAD_SPECIFIC_VERSION', 'async \\(event, versionType\\)'],
+ ]) {
+ const handlerStartsWithGuard = new RegExp(
+ `ipcMain\\.handle\\(IPC_EVENTS\\.${eventName}, ${signature} => \\{\\s*` +
+ 'if \\(isManualReleaseDelivery\\(updateDelivery\\)\\)',
+ )
+ assert.match(main, handlerStartsWithGuard, `${eventName} must fail before mutating updater state`)
+ }
+})
+
+test('global updater errors do not release handler-owned operation locks', () => {
+ const main = readText('packages/desktop/main.js')
+ const errorHandlerStart = main.indexOf("autoUpdater.on('error'")
+ const errorHandlerEnd = main.indexOf("autoUpdater.on('download-progress'", errorHandlerStart)
+
+ assert.notEqual(errorHandlerStart, -1)
+ assert.notEqual(errorHandlerEnd, -1)
+
+ const errorHandler = main.slice(errorHandlerStart, errorHandlerEnd)
+ for (const lock of ['isCheckingForUpdate', 'isDownloadingUpdate', 'isInstallingUpdate']) {
+ assert.doesNotMatch(errorHandler, new RegExp(`${lock}\\s*=\\s*false`))
+ }
+
+ for (const [startMarker, endMarker] of [
+ ['ipcMain.handle(IPC_EVENTS.UPDATE_CHECK,', '// 统一检查所有版本'],
+ ['ipcMain.handle(IPC_EVENTS.UPDATE_CHECK_ALL_VERSIONS', '// Open only a main-process'],
+ ]) {
+ const handlerStart = main.indexOf(startMarker)
+ const handlerEnd = main.indexOf(endMarker, handlerStart)
+ assert.notEqual(handlerStart, -1)
+ assert.notEqual(handlerEnd, -1)
+
+ const handler = main.slice(handlerStart, handlerEnd)
+ assert.match(handler, /finally\s*\{[\s\S]*isCheckingForUpdate\s*=\s*false/)
+ }
+})
+
+test('concurrent unified update checks keep the delivery-policy response contract', () => {
+ const main = readText('packages/desktop/main.js')
+ const handlerStart = main.indexOf('ipcMain.handle(IPC_EVENTS.UPDATE_CHECK_ALL_VERSIONS')
+ const lockStart = main.indexOf('// 设置检查状态锁', handlerStart)
+
+ assert.notEqual(handlerStart, -1)
+ assert.notEqual(lockStart, -1)
+
+ const inProgressBranch = main.slice(handlerStart, lockStart)
+ assert.match(inProgressBranch, /const currentVersion = require\('\.\/package\.json'\)\.version/)
+ assert.match(inProgressBranch, /currentVersion/)
+ assert.match(inProgressBranch, /updateDelivery/)
+ assert.match(inProgressBranch, /stable:\s*null/)
+ assert.match(inProgressBranch, /prerelease:\s*null/)
+ assert.match(inProgressBranch, /inProgress:\s*true/)
+})
+
+test('renderer preserves updater state while a unified check is already in progress', () => {
+ const updater = readText('packages/ui/src/composables/system/useUpdater.ts')
+ const inProgressStart = updater.indexOf('if (results.inProgress)')
+ const completedResultStart = updater.indexOf('// Unknown/missing capability', inProgressStart)
+
+ assert.notEqual(inProgressStart, -1)
+ assert.notEqual(completedResultStart, -1)
+
+ const inProgressBranch = updater.slice(inProgressStart, completedResultStart)
+ assert.match(inProgressBranch, /return/)
+ assert.doesNotMatch(inProgressBranch, /state\.isDownloading\s*=\s*false/)
+
+ const checkUpdateStart = updater.indexOf('const checkUpdate = async')
+ const checkBothVersionsCall = updater.indexOf('await checkBothVersions()', checkUpdateStart)
+ assert.notEqual(checkUpdateStart, -1)
+ assert.notEqual(checkBothVersionsCall, -1)
+
+ const preRequestState = updater.slice(checkUpdateStart, checkBothVersionsCall)
+ assert.doesNotMatch(preRequestState, /state\.stableVersion\s*=\s*null/)
+ assert.doesNotMatch(preRequestState, /state\.updateDelivery\s*=\s*null/)
+ assert.doesNotMatch(preRequestState, /state\.downloadMessage\s*=\s*null/)
+})
+
+test('renderer invalidates actionable version state after a terminal unified-check failure', () => {
+ const updater = readText('packages/ui/src/composables/system/useUpdater.ts')
+ const checkStart = updater.indexOf('const checkBothVersions = async')
+ const catchStart = updater.indexOf("console.error('[useUpdater] Error checking all versions:'", checkStart)
+ const finallyStart = updater.indexOf('} finally {', catchStart)
+
+ assert.notEqual(checkStart, -1)
+ assert.notEqual(catchStart, -1)
+ assert.notEqual(finallyStart, -1)
+
+ const terminalErrorBranch = updater.slice(catchStart, finallyStart)
+ assert.match(terminalErrorBranch, /invalidateActionableCheckState\(state\)/)
+ assert.match(terminalErrorBranch, /state\.lastCheckResult\s*=\s*'error'/)
+})
+
+test('updater feed, delivery policy, and release URLs share one repository snapshot', () => {
+ const main = readText('packages/desktop/main.js')
+ const setupStart = main.indexOf('async function setupUpdateHandlers()')
+ const eventHandlersStart = main.indexOf('// 设置更新事件处理', setupStart)
+
+ assert.notEqual(setupStart, -1)
+ assert.notEqual(eventHandlersStart, -1)
+
+ const repositorySetup = main.slice(setupStart, eventHandlersStart)
+ assert.match(repositorySetup, /resolveUpdateRepositoryConfig\(\)/)
+ assert.match(repositorySetup, /shouldOverrideFeed/)
+ assert.match(repositorySetup, /getUpdateDeliveryPolicy\(\{ repositoryInfo \}\)/)
+ assert.match(repositorySetup, /const \{ owner, repo \} = repositoryInfo/)
+ assert.match(repositorySetup, /repositoryInfo = packagedRepositoryInfo/)
+ assert.doesNotMatch(repositorySetup, /process\.env\.DEV_REPO_/)
+})
+
+test('updater release navigation is constructed and opened in the main process', () => {
+ const main = readText('packages/desktop/main.js')
+ const handlerStart = main.indexOf('ipcMain.handle(IPC_EVENTS.UPDATE_OPEN_RELEASE_PAGE')
+ const handlerEnd = main.indexOf('ipcMain.handle(IPC_EVENTS.UPDATE_START_DOWNLOAD', handlerStart)
+
+ assert.notEqual(handlerStart, -1)
+ assert.notEqual(handlerEnd, -1)
+
+ const handler = main.slice(handlerStart, handlerEnd)
+ assert.match(handler, /buildReleaseUrl\(version, repositoryInfo\)/)
+ assert.match(handler, /updateDelivery\.fallbackReleaseUrl/)
+ assert.match(handler, /shell\.openExternal\(releaseUrl\)/)
+ assert.doesNotMatch(handler, /shell\.openExternal\(version\)/)
+})
+
+test('manual release workflow normalizes tags used by release-page navigation', () => {
+ const workflow = readText('.github/workflows/release.yml')
+ const normalization = /VERSION="\$\{\{ inputs\.version \}\}"\s+if \[\[ "\$VERSION" != v\* \]\]; then\s+VERSION="v\$VERSION"\s+fi/g
+
+ assert.equal([...workflow.matchAll(normalization)].length, 2)
+ assert.match(workflow, /gh release create "\$VERSION"/)
+})
+
+test('desktop release jobs keep repository metadata and updater publish config aligned', () => {
+ const workflow = readText('.github/workflows/release.yml')
+ const jobBlock = (jobName, nextJobName) => {
+ const start = workflow.indexOf(` ${jobName}:`)
+ const end = workflow.indexOf(` ${nextJobName}:`, start)
+ assert.notEqual(start, -1, `${jobName} job must exist`)
+ assert.notEqual(end, -1, `${nextJobName} job must follow ${jobName}`)
+ return workflow.slice(start, end)
+ }
+
+ const windowsJob = jobBlock('build-windows', 'build-macos')
+ const macosJob = jobBlock('build-macos', 'build-linux')
+ const linuxJob = jobBlock('build-linux', 'build-extension')
+
+ assert.match(windowsJob, /\$pkgJson\.repository\.url\s*=\s*\$repoUrl/)
+ assert.match(windowsJob, /\$pkgJson\.build\.publish\.owner\s*=\s*\$repoOwner/)
+ assert.match(windowsJob, /\$pkgJson\.build\.publish\.repo\s*=\s*\$repoName/)
+
+ for (const [name, job] of [['build-macos', macosJob], ['build-linux', linuxJob]]) {
+ assert.match(job, /\.repository\.url\s*=\s*\$repo_url/, `${name} must update repository.url`)
+ assert.match(job, /\.build\.publish\.owner\s*=\s*\$repo_owner/, `${name} must update publish owner`)
+ assert.match(job, /\.build\.publish\.repo\s*=\s*\$repo_name/, `${name} must update publish repo`)
+ }
+})
+
test('desktop remote storage handler routes S3-compatible operations through AWS SDK commands', async () => {
const { handleRemoteStorageOperation } = await import('../packages/desktop/remote-storage.js')
const sentCommands = []