From 0e4f58292ad43d1cc0cd29251653e03eeb57458b Mon Sep 17 00:00:00 2001 From: linshen <32978552+linshenkx@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:45:24 +0800 Subject: [PATCH] fix(desktop): route unsigned macOS updates to releases (#313) Refs #313 --- .github/workflows/release.yml | 22 +- packages/desktop/config/constants.js | 1 + packages/desktop/config/update-config.js | 152 +++++++-- packages/desktop/config/update-config.test.js | 192 ++++++++++++ .../desktop/config/update-delivery-policy.js | 62 ++++ .../config/update-delivery-policy.test.js | 77 +++++ packages/desktop/main.js | 97 ++++-- packages/desktop/preload.js | 29 +- packages/ui/src/components/UpdaterModal.vue | 102 ++++-- .../ui/src/composables/system/useUpdater.ts | 169 +++++++--- packages/ui/src/i18n/locales/en-US/image.ts | 7 + packages/ui/src/i18n/locales/zh-CN/image.ts | 7 + packages/ui/src/i18n/locales/zh-TW/image.ts | 7 + packages/ui/src/types/electron.d.ts | 29 +- .../unit/components/UpdaterModal.spec.ts | 296 ++++++++++++++++++ .../useUpdater.failure-state.spec.ts | 81 +++++ scripts/desktop-ipc-handlers.test.mjs | 163 ++++++++++ 17 files changed, 1351 insertions(+), 142 deletions(-) create mode 100644 packages/desktop/config/update-config.test.js create mode 100644 packages/desktop/config/update-delivery-policy.js create mode 100644 packages/desktop/config/update-delivery-policy.test.js create mode 100644 packages/ui/tests/unit/components/UpdaterModal.spec.ts create mode 100644 packages/ui/tests/unit/composables/useUpdater.failure-state.spec.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13c1ca3c..a65a0d3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: inputs: version: - description: '当前所选 ref 上待发布的版本号(例如: v2.8.0)' + description: '当前所选 ref 上待发布的版本号(支持 v2.8.0 或 2.8.0)' required: true type: string @@ -37,6 +37,9 @@ jobs: run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ inputs.version }}" + if [[ "$VERSION" != v* ]]; then + VERSION="v$VERSION" + fi echo "📝 Using manual input version: $VERSION" else VERSION="${GITHUB_REF#refs/tags/}" @@ -73,6 +76,9 @@ jobs: run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ inputs.version }}" + if [[ "$VERSION" != v* ]]; then + VERSION="v$VERSION" + fi echo "📝 Using manual input version: $VERSION" else VERSION="${GITHUB_REF#refs/tags/}" @@ -174,6 +180,8 @@ jobs: $repoInfo = "${{ github.repository }}".split("/") $repoOwner = $repoInfo[0] $repoName = $repoInfo[1] + $pkgJson.build.publish.owner = $repoOwner + $pkgJson.build.publish.repo = $repoName $pkgJson | ConvertTo-Json -Depth 10 | Set-Content $pkgPath -Encoding UTF8 @@ -265,8 +273,12 @@ jobs: jq --arg version "$TAG_VERSION" \ --arg repo_url "$REPO_URL" \ + --arg repo_owner "$REPO_OWNER" \ + --arg repo_name "$REPO_NAME" \ '.version = $version | - .repository.url = $repo_url' \ + .repository.url = $repo_url | + .build.publish.owner = $repo_owner | + .build.publish.repo = $repo_name' \ "$PKG_PATH" > "${PKG_PATH}.tmp" && mv "${PKG_PATH}.tmp" "$PKG_PATH" echo "✅ package.json updated successfully" @@ -355,8 +367,12 @@ jobs: jq --arg version "$TAG_VERSION" \ --arg repo_url "$REPO_URL" \ + --arg repo_owner "$REPO_OWNER" \ + --arg repo_name "$REPO_NAME" \ '.version = $version | - .repository.url = $repo_url' \ + .repository.url = $repo_url | + .build.publish.owner = $repo_owner | + .build.publish.repo = $repo_name' \ "$PKG_PATH" > "${PKG_PATH}.tmp" && mv "${PKG_PATH}.tmp" "$PKG_PATH" echo "✅ package.json updated successfully" diff --git a/packages/desktop/config/constants.js b/packages/desktop/config/constants.js index 79ba05e9..eb1c2c19 100644 --- a/packages/desktop/config/constants.js +++ b/packages/desktop/config/constants.js @@ -8,6 +8,7 @@ const IPC_EVENTS = { UPDATE_CHECK: 'updater-check-update', UPDATE_CHECK_ALL_VERSIONS: 'updater-check-all-versions', + UPDATE_OPEN_RELEASE_PAGE: 'updater-open-release-page', UPDATE_START_DOWNLOAD: 'updater-start-download', UPDATE_INSTALL: 'updater-install-update', UPDATE_IGNORE_VERSION: 'updater-ignore-version', diff --git a/packages/desktop/config/update-config.js b/packages/desktop/config/update-config.js index 32369b03..f7bd43f6 100644 --- a/packages/desktop/config/update-config.js +++ b/packages/desktop/config/update-config.js @@ -9,29 +9,93 @@ const { IPC_EVENTS, PREFERENCE_KEYS, DEFAULT_CONFIG } = require('./constants'); // 从package.json读取仓库信息 const packageJson = require('../package.json'); +const GITHUB_OWNER_PATTERN = /^(?!.*--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; +const GITHUB_REPO_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,100}$/; + +const parseRepositorySlug = (value) => { + if (typeof value !== 'string') return null; + + const parts = value.trim().split('/').map(part => part.trim()); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + if (parts.some(part => part.toLowerCase() === 'unknown')) return null; + if (!GITHUB_OWNER_PATTERN.test(parts[0]) || !GITHUB_REPO_PATTERN.test(parts[1])) return null; + + return { owner: parts[0], repo: parts[1] }; +}; + +const parseRepositoryUrl = (value) => { + if (typeof value !== 'string') return null; + + const repositoryUrl = value.trim(); + const scpMatch = repositoryUrl.match(/^git@github\.com:([^/\s]+)\/([^/\s]+)$/i); + if (scpMatch) { + return parseRepositorySlug(`${scpMatch[1]}/${scpMatch[2].replace(/\.git$/i, '')}`); + } + + const normalizedUrl = repositoryUrl.replace(/^git\+/, ''); + let parsedUrl; + try { + parsedUrl = new URL(normalizedUrl); + } catch { + return null; + } + + if ( + !['https:', 'http:', 'git:', 'ssh:'].includes(parsedUrl.protocol) || + parsedUrl.hostname.toLowerCase() !== 'github.com' + ) { + return null; + } + + const pathParts = parsedUrl.pathname.split('/').filter(Boolean); + if (pathParts.length !== 2) return null; + + return parseRepositorySlug(`${pathParts[0]}/${pathParts[1].replace(/\.git$/i, '')}`); +}; + +const getPublishRepositoryInfo = (packageMetadata = packageJson) => { + const publish = packageMetadata?.build?.publish; + if (!publish || typeof publish !== 'object' || Array.isArray(publish)) return null; + if (publish.provider !== 'github') return null; + if (typeof publish.owner !== 'string' || typeof publish.repo !== 'string') return null; + + return parseRepositorySlug(`${publish.owner}/${publish.repo}`); +}; + // 从环境变量或package.json获取仓库信息 -const getRepositoryInfo = () => { - // 优先使用环境变量 - if (process.env.GITHUB_REPOSITORY) { - const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); - return { owner, repo }; +const getRepositoryInfo = ({ + environment = process.env, + packageMetadata = packageJson, +} = {}) => { + // GitHub Actions/runtime 显式仓库优先 + const githubRepository = parseRepositorySlug(environment.GITHUB_REPOSITORY); + if (githubRepository) return githubRepository; + + // 本地开发更新源必须与 Release 页面使用同一仓库 + const devOwner = typeof environment.DEV_REPO_OWNER === 'string' + ? environment.DEV_REPO_OWNER.trim() + : ''; + const devRepo = typeof environment.DEV_REPO_NAME === 'string' + ? environment.DEV_REPO_NAME.trim() + : ''; + if (devOwner && devRepo) { + const devRepository = parseRepositorySlug(`${devOwner}/${devRepo}`); + if (devRepository) return devRepository; + } + + // electron-builder uses build.publish to generate app-update.yml, so this is + // the authoritative packaged updater source when no runtime override exists. + const publishRepository = getPublishRepositoryInfo(packageMetadata); + if (publishRepository) return publishRepository; + if (packageMetadata?.build?.publish != null) { + console.warn('[Update Config] Unsupported or invalid build.publish configuration'); + return { owner: 'unknown', repo: 'unknown' }; } - // 从package.json的repository字段获取 - if (packageJson.repository && packageJson.repository.url) { - const repoUrl = packageJson.repository.url; - const match = repoUrl.match(/github\.com[/:]([\w-]+)\/([\w-]+)/); - if (match) { - return { owner: match[1], repo: match[2] }; - } - } - - // 从build.publish配置获取 - if (packageJson.build && packageJson.build.publish) { - const { owner, repo } = packageJson.build.publish; - if (owner && repo) { - return { owner, repo }; - } + // repository.url is metadata only; use it as a fallback when publish config is absent. + if (packageMetadata?.repository?.url) { + const repositoryInfo = parseRepositoryUrl(packageMetadata.repository.url); + if (repositoryInfo) return repositoryInfo; } // 最后的fallback(应该避免到达这里) @@ -39,6 +103,34 @@ const getRepositoryInfo = () => { return { owner: 'unknown', repo: 'unknown' }; }; +const toRepositorySlug = (repositoryInfo) => { + if (!repositoryInfo || typeof repositoryInfo !== 'object') return null; + if (typeof repositoryInfo.owner !== 'string' || typeof repositoryInfo.repo !== 'string') return null; + + const parsed = parseRepositorySlug(`${repositoryInfo.owner}/${repositoryInfo.repo}`); + return parsed ? `${parsed.owner}/${parsed.repo}` : null; +}; + +const resolveUpdateRepositoryConfig = ({ + environment = process.env, + packageMetadata = packageJson, +} = {}) => { + const packagedRepositoryInfo = getRepositoryInfo({ environment: {}, packageMetadata }); + const repositoryInfo = getRepositoryInfo({ environment, packageMetadata }); + const packagedRepositorySlug = toRepositorySlug(packagedRepositoryInfo); + const repositorySlug = toRepositorySlug(repositoryInfo); + + return { + packagedRepositoryInfo, + repositoryInfo, + packagedRepositorySlug, + repositorySlug, + shouldOverrideFeed: Boolean( + repositorySlug && repositorySlug !== packagedRepositorySlug + ), + }; +}; + // 验证版本号格式 const validateVersion = (version) => { if (!version || typeof version !== 'string') { @@ -51,23 +143,33 @@ const validateVersion = (version) => { }; // 构建安全的Release URL -const buildReleaseUrl = (version) => { +const buildReleaseUrl = (version, repositoryInfo = getRepositoryInfo()) => { if (!validateVersion(version)) { throw new Error(`Invalid version format: ${version}`); } - const { owner, repo } = getRepositoryInfo(); + const resolvedRepository = ( + repositoryInfo && + typeof repositoryInfo === 'object' && + typeof repositoryInfo.owner === 'string' && + typeof repositoryInfo.repo === 'string' + ) ? parseRepositorySlug(`${repositoryInfo.owner}/${repositoryInfo.repo}`) : null; - if (owner === 'unknown' || repo === 'unknown') { + if ( + !resolvedRepository || + resolvedRepository.owner === 'unknown' || + resolvedRepository.repo === 'unknown' + ) { throw new Error('Repository information not available'); } + const { owner, repo } = resolvedRepository; // 确保版本号以v开头 const versionTag = version.startsWith('v') ? version : `v${version}`; // 使用URL构造器确保安全性 const baseUrl = 'https://github.com'; - return `${baseUrl}/${owner}/${repo}/releases/tag/${encodeURIComponent(versionTag)}`; + return `${baseUrl}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tag/${encodeURIComponent(versionTag)}`; }; // 注意:静态常量已移至 constants.js 文件 @@ -75,7 +177,9 @@ const buildReleaseUrl = (version) => { module.exports = { // 动态函数 + getPublishRepositoryInfo, getRepositoryInfo, + resolveUpdateRepositoryConfig, validateVersion, buildReleaseUrl, diff --git a/packages/desktop/config/update-config.test.js b/packages/desktop/config/update-config.test.js new file mode 100644 index 00000000..c56c3e6d --- /dev/null +++ b/packages/desktop/config/update-config.test.js @@ -0,0 +1,192 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildReleaseUrl, + getPublishRepositoryInfo, + getRepositoryInfo, + resolveUpdateRepositoryConfig, +} = require('./update-config'); + +const packageMetadata = { + repository: { + url: 'https://github.com/package-owner/package-repo.git', + }, + build: { + publish: { + provider: 'github', + owner: 'publish-owner', + repo: 'publish-repo', + }, + }, +}; + +test('runtime repository resolution uses one explicit precedence order', () => { + assert.deepEqual( + getRepositoryInfo({ + environment: { + GITHUB_REPOSITORY: 'actions-owner/actions-repo', + DEV_REPO_OWNER: 'dev-owner', + DEV_REPO_NAME: 'dev-repo', + }, + packageMetadata, + }), + { owner: 'actions-owner', repo: 'actions-repo' }, + ); + + assert.deepEqual( + getRepositoryInfo({ + environment: { + DEV_REPO_OWNER: 'dev-owner', + DEV_REPO_NAME: 'dev-repo', + }, + packageMetadata, + }), + { owner: 'dev-owner', repo: 'dev-repo' }, + ); + + assert.deepEqual( + getRepositoryInfo({ + environment: { DEV_REPO_OWNER: 'incomplete-owner' }, + packageMetadata, + }), + { owner: 'publish-owner', repo: 'publish-repo' }, + ); +}); + +test('packaged updater source prioritizes build.publish over repository metadata', () => { + assert.deepEqual( + getPublishRepositoryInfo(packageMetadata), + { owner: 'publish-owner', repo: 'publish-repo' }, + ); + assert.deepEqual( + getRepositoryInfo({ environment: {}, packageMetadata }), + { owner: 'publish-owner', repo: 'publish-repo' }, + ); +}); + +test('fork runtime override stays distinct from a conflicting packaged updater source', () => { + const forkPackageMetadata = { + repository: { url: 'https://github.com/fork-owner/fork-repo.git' }, + build: { + publish: { + provider: 'github', + owner: 'upstream-owner', + repo: 'upstream-repo', + }, + }, + }; + + const resolved = resolveUpdateRepositoryConfig({ + environment: { + DEV_REPO_OWNER: 'fork-owner', + DEV_REPO_NAME: 'fork-repo', + }, + packageMetadata: forkPackageMetadata, + }); + + assert.deepEqual(resolved.packagedRepositoryInfo, { owner: 'upstream-owner', repo: 'upstream-repo' }); + assert.deepEqual(resolved.repositoryInfo, { owner: 'fork-owner', repo: 'fork-repo' }); + assert.equal(resolved.packagedRepositorySlug, 'upstream-owner/upstream-repo'); + assert.equal(resolved.repositorySlug, 'fork-owner/fork-repo'); + assert.equal(resolved.shouldOverrideFeed, true); +}); + +test('unsupported publish shapes fail closed instead of selecting a different GitHub provider', () => { + for (const publish of [ + [ + { provider: 'generic', url: 'https://updates.example.com' }, + { provider: 'github', owner: 'secondary-owner', repo: 'secondary-repo' }, + ], + { provider: 'generic', url: 'https://updates.example.com' }, + ]) { + const metadata = { + repository: { url: 'https://github.com/metadata-owner/metadata-repo.git' }, + build: { publish }, + }; + + assert.equal(getPublishRepositoryInfo(metadata), null); + assert.deepEqual( + getRepositoryInfo({ environment: {}, packageMetadata: metadata }), + { owner: 'unknown', repo: 'unknown' }, + ); + } +}); + +test('repository metadata remains a fallback when publish config is absent', () => { + for (const url of [ + 'git@github.com:package-owner/package.repo.git', + 'https://github.com/package-owner/package.repo.git', + 'git+https://github.com/package-owner/package.repo.git', + ]) { + assert.deepEqual( + getRepositoryInfo({ + environment: {}, + packageMetadata: { repository: { url } }, + }), + { owner: 'package-owner', repo: 'package.repo' }, + ); + } +}); + +test('repository metadata fallback rejects non-GitHub and lookalike hosts', () => { + for (const url of [ + 'https://notgithub.com/attacker/repo', + 'https://github.com.evil.example/attacker/repo', + 'https://example.com/github.com/attacker/repo', + ]) { + assert.deepEqual( + getRepositoryInfo({ + environment: {}, + packageMetadata: { repository: { url } }, + }), + { owner: 'unknown', repo: 'unknown' }, + ); + } +}); + +test('malformed runtime repository overrides fall back to packaged publish config', () => { + for (const environment of [ + { GITHUB_REPOSITORY: '\\\\evil.example/repo' }, + { GITHUB_REPOSITORY: '../repo' }, + { GITHUB_REPOSITORY: 'double--hyphen/repo' }, + { GITHUB_REPOSITORY: 'unknown/repo' }, + { DEV_REPO_OWNER: '\\\\evil.example', DEV_REPO_NAME: 'repo' }, + { DEV_REPO_OWNER: 'valid-owner', DEV_REPO_NAME: '../repo' }, + ]) { + assert.deepEqual( + getRepositoryInfo({ environment, packageMetadata }), + { owner: 'publish-owner', repo: 'publish-repo' }, + ); + } +}); + +test('release URL uses the same resolved repository as the update feed', () => { + const repositoryInfo = getRepositoryInfo({ + environment: { + DEV_REPO_OWNER: 'custom-owner', + DEV_REPO_NAME: 'custom-repo', + }, + packageMetadata, + }); + + assert.equal( + buildReleaseUrl('2.12.0', repositoryInfo), + 'https://github.com/custom-owner/custom-repo/releases/tag/v2.12.0', + ); +}); + +test('release URL rejects malformed repository information', () => { + assert.throws( + () => buildReleaseUrl('2.12.0', { owner: '\\\\evil.example', repo: 'repo' }), + /Repository information not available/, + ); + assert.throws( + () => buildReleaseUrl('2.12.0', { owner: 'unknown', repo: 'unknown' }), + /Repository information not available/, + ); + assert.throws( + () => buildReleaseUrl('2.12.0', {}), + /Repository information not available/, + ); +}); diff --git a/packages/desktop/config/update-delivery-policy.js b/packages/desktop/config/update-delivery-policy.js new file mode 100644 index 00000000..2a30ce0f --- /dev/null +++ b/packages/desktop/config/update-delivery-policy.js @@ -0,0 +1,62 @@ +const { getRepositoryInfo } = require('./update-config'); + +const UPDATE_DELIVERY_MODES = Object.freeze({ + IN_APP: 'in-app', + MANUAL_RELEASE: 'manual-release', +}); + +const UPDATE_DELIVERY_REASONS = Object.freeze({ + MACOS_UNSIGNED: 'macos-unsigned', +}); + +const buildFallbackReleaseUrl = (repositoryInfo = getRepositoryInfo()) => { + const owner = typeof repositoryInfo?.owner === 'string' ? repositoryInfo.owner.trim() : ''; + const repo = typeof repositoryInfo?.repo === 'string' ? repositoryInfo.repo.trim() : ''; + + if (!owner || !repo || owner === 'unknown' || repo === 'unknown') { + return null; + } + + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`; +}; + +const getUpdateDeliveryPolicy = ({ + platform = process.platform, + arch = process.arch, + repositoryInfo = getRepositoryInfo(), +} = {}) => { + const isUnsignedMac = platform === 'darwin'; + + return { + mode: isUnsignedMac + ? UPDATE_DELIVERY_MODES.MANUAL_RELEASE + : UPDATE_DELIVERY_MODES.IN_APP, + reason: isUnsignedMac ? UPDATE_DELIVERY_REASONS.MACOS_UNSIGNED : null, + platform, + arch, + fallbackReleaseUrl: buildFallbackReleaseUrl(repositoryInfo), + }; +}; + +const isManualReleaseDelivery = (policy) => + policy?.mode === UPDATE_DELIVERY_MODES.MANUAL_RELEASE; + +const createManualUpdateRequiredError = (operation, policy, details = {}) => { + const error = new Error('In-app updates are unavailable for this build. Use the release page to update manually.'); + error.code = 'UPDATER_MANUAL_DOWNLOAD_REQUIRED'; + error.params = { + operation, + ...details, + updateDelivery: policy, + }; + return error; +}; + +module.exports = { + UPDATE_DELIVERY_MODES, + UPDATE_DELIVERY_REASONS, + buildFallbackReleaseUrl, + getUpdateDeliveryPolicy, + isManualReleaseDelivery, + createManualUpdateRequiredError, +}; diff --git a/packages/desktop/config/update-delivery-policy.test.js b/packages/desktop/config/update-delivery-policy.test.js new file mode 100644 index 00000000..84f4356d --- /dev/null +++ b/packages/desktop/config/update-delivery-policy.test.js @@ -0,0 +1,77 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildFallbackReleaseUrl, + createManualUpdateRequiredError, + getUpdateDeliveryPolicy, + isManualReleaseDelivery, +} = require('./update-delivery-policy'); + +const repositoryInfo = { + owner: 'linshenkx', + repo: 'prompt-optimizer', +}; + +test('macOS uses manual release delivery with runtime details', () => { + const policy = getUpdateDeliveryPolicy({ + platform: 'darwin', + arch: 'arm64', + repositoryInfo, + }); + + assert.deepEqual(policy, { + mode: 'manual-release', + reason: 'macos-unsigned', + platform: 'darwin', + arch: 'arm64', + fallbackReleaseUrl: 'https://github.com/linshenkx/prompt-optimizer/releases', + }); + assert.equal(isManualReleaseDelivery(policy), true); +}); + +test('Windows and Linux keep in-app update delivery', () => { + for (const [platform, arch] of [ + ['win32', 'x64'], + ['linux', 'arm64'], + ]) { + const policy = getUpdateDeliveryPolicy({ platform, arch, repositoryInfo }); + + assert.deepEqual(policy, { + mode: 'in-app', + reason: null, + platform, + arch, + fallbackReleaseUrl: 'https://github.com/linshenkx/prompt-optimizer/releases', + }); + assert.equal(isManualReleaseDelivery(policy), false); + } +}); + +test('fallback release URL reuses repository configuration safely', () => { + assert.equal( + buildFallbackReleaseUrl({ owner: 'example-owner', repo: 'example.repo' }), + 'https://github.com/example-owner/example.repo/releases', + ); + assert.equal(buildFallbackReleaseUrl({ owner: 'unknown', repo: 'unknown' }), null); + assert.equal(buildFallbackReleaseUrl({ owner: '', repo: 'prompt-optimizer' }), null); +}); + +test('manual-required error carries a structured code and delivery policy', () => { + const policy = getUpdateDeliveryPolicy({ + platform: 'darwin', + arch: 'x64', + repositoryInfo, + }); + + const error = createManualUpdateRequiredError('download-specific-version', policy, { + versionType: 'stable', + }); + + assert.equal(error.code, 'UPDATER_MANUAL_DOWNLOAD_REQUIRED'); + assert.deepEqual(error.params, { + operation: 'download-specific-version', + versionType: 'stable', + updateDelivery: policy, + }); +}); diff --git a/packages/desktop/main.js b/packages/desktop/main.js index a7132fc0..4e971013 100644 --- a/packages/desktop/main.js +++ b/packages/desktop/main.js @@ -26,11 +26,17 @@ const { app, BrowserWindow, ipcMain, shell, session, Menu, nativeImage } = requi const { autoUpdater } = require('electron-updater'); const { buildReleaseUrl, + resolveUpdateRepositoryConfig, validateVersion, IPC_EVENTS, PREFERENCE_KEYS, DEFAULT_CONFIG } = require('./config/update-config'); +const { + createManualUpdateRequiredError, + getUpdateDeliveryPolicy, + isManualReleaseDelivery, +} = require('./config/update-delivery-policy'); const { createGlobalDispatcherFromProxyDecision } = require('./config/proxy-dispatcher'); const { buildAppMenuTemplate, @@ -2431,21 +2437,21 @@ async function setupUpdateHandlers() { autoUpdater.allowPrerelease = DEFAULT_CONFIG.allowPrerelease; autoUpdater.allowDowngrade = false; // 默认不允许降级,只在渠道切换时临时启用 - // 环境变量动态配置支持(仅支持公开仓库) - const defaultRepo = 'linshenkx/prompt-optimizer'; - let currentRepo = null; - - // 检测环境变量中的仓库信息 - if (process.env.GITHUB_REPOSITORY) { - currentRepo = process.env.GITHUB_REPOSITORY; - } else if (process.env.DEV_REPO_OWNER && process.env.DEV_REPO_NAME) { - currentRepo = `${process.env.DEV_REPO_OWNER}/${process.env.DEV_REPO_NAME}`; - } + // Resolve the repository once so the feed, delivery policy, and Release URLs + // cannot diverge when development repository overrides are enabled. + const { + packagedRepositoryInfo, + repositoryInfo: resolvedRepositoryInfo, + packagedRepositorySlug: defaultRepo, + repositorySlug: currentRepo, + shouldOverrideFeed, + } = resolveUpdateRepositoryConfig(); + let repositoryInfo = resolvedRepositoryInfo; // 如果环境变量中的仓库与默认仓库不同,使用setFeedURL动态配置 - if (currentRepo && currentRepo !== defaultRepo) { + if (shouldOverrideFeed) { try { - const [owner, repo] = currentRepo.split('/'); + const { owner, repo } = repositoryInfo; const feedConfig = { provider: 'github', @@ -2465,11 +2471,16 @@ async function setupUpdateHandlers() { } catch (configError) { console.error('[Updater] Failed to configure custom repository:', configError); console.log('[Updater] Falling back to default configuration'); + repositoryInfo = packagedRepositoryInfo; } } else { - console.log('[Updater] Using default repository configuration:', defaultRepo); + console.log('[Updater] Using default repository configuration:', defaultRepo || 'unknown'); } + // Main-process source of truth for how this effective repository can deliver updates. + // macOS remains check-only until release artifacts are Developer ID signed. + const updateDelivery = getUpdateDeliveryPolicy({ repositoryInfo }); + // 开发模式下的更新检查配置 if (process.env.NODE_ENV === 'development' || !app.isPackaged) { console.log('[Updater] Development mode detected'); @@ -2523,7 +2534,7 @@ async function setupUpdateHandlers() { // 构建安全的GitHub Release页面链接 let releaseUrl; try { - releaseUrl = buildReleaseUrl(info.version); + releaseUrl = buildReleaseUrl(info.version, repositoryInfo); } catch (urlError) { console.error('[Updater] Failed to build release URL:', urlError); // 使用fallback URL或跳过URL @@ -2583,10 +2594,8 @@ async function setupUpdateHandlers() { console.log('[Updater Debug] ====================================='); } - // 重置所有状态锁,允许用户重试 - isCheckingForUpdate = false; - isDownloadingUpdate = false; - isInstallingUpdate = false; + // Operation handlers and download callbacks own their respective locks. + // A global updater error must not unlock an unrelated in-flight operation. // 创建详细的错误信息 const detailedErrorResponse = createDetailedErrorResponse(error); @@ -2710,7 +2719,7 @@ async function setupUpdateHandlers() { // 构建发布页面URL try { - responseData.remoteReleaseUrl = buildReleaseUrl(updateInfo.version); + responseData.remoteReleaseUrl = buildReleaseUrl(updateInfo.version, repositoryInfo); } catch (urlError) { console.warn('[Updater] Failed to build release URL:', urlError); } @@ -2758,11 +2767,16 @@ async function setupUpdateHandlers() { // 统一检查所有版本(解决并发冲突问题) ipcMain.handle(IPC_EVENTS.UPDATE_CHECK_ALL_VERSIONS, async () => { console.log('[Updater] Starting unified version check for all versions'); + const currentVersion = require('./package.json').version; // 检查是否已有更新检查在进行中 if (isCheckingForUpdate) { console.log('[Updater] Update check already in progress, ignoring request'); return createSuccessResponse({ + currentVersion, + updateDelivery, + stable: null, + prerelease: null, message: 'Update check already in progress', inProgress: true }); @@ -2772,10 +2786,9 @@ async function setupUpdateHandlers() { isCheckingForUpdate = true; try { - // 获取当前版本 - const currentVersion = require('./package.json').version; const results = { currentVersion, + updateDelivery, stable: null, prerelease: null }; @@ -2826,7 +2839,7 @@ async function setupUpdateHandlers() { // 构建发布页面URL try { - remoteReleaseUrl = buildReleaseUrl(updateInfo.version); + remoteReleaseUrl = buildReleaseUrl(updateInfo.version, repositoryInfo); } catch (urlError) { console.warn(`[Updater] Failed to build ${versionType} release URL:`, urlError); } @@ -2919,8 +2932,34 @@ async function setupUpdateHandlers() { } }); + // Open only a main-process constructed URL for an updater release page. + ipcMain.handle(IPC_EVENTS.UPDATE_OPEN_RELEASE_PAGE, async (event, version) => { + try { + const releaseUrl = version + ? buildReleaseUrl(version, repositoryInfo) + : updateDelivery.fallbackReleaseUrl; + + if (!releaseUrl) { + const error = new Error('Release page URL is unavailable'); + error.code = 'UPDATER_RELEASE_URL_UNAVAILABLE'; + throw error; + } + + await shell.openExternal(releaseUrl); + return createSuccessResponse({ url: releaseUrl }); + } catch (error) { + return createErrorResponse(error); + } + }); + // 开始下载更新 ipcMain.handle(IPC_EVENTS.UPDATE_START_DOWNLOAD, async () => { + if (isManualReleaseDelivery(updateDelivery)) { + return createErrorResponse( + createManualUpdateRequiredError('start-download', updateDelivery) + ); + } + // 检查是否已有下载在进行中 if (isDownloadingUpdate) { console.log('[Updater] Download already in progress, ignoring request'); @@ -2946,6 +2985,12 @@ async function setupUpdateHandlers() { // 安装更新 ipcMain.handle(IPC_EVENTS.UPDATE_INSTALL, async () => { + if (isManualReleaseDelivery(updateDelivery)) { + return createErrorResponse( + createManualUpdateRequiredError('install', updateDelivery) + ); + } + // 检查是否已有安装在进行中 if (isInstallingUpdate) { console.log('[Updater] Install already in progress, ignoring request'); @@ -3070,6 +3115,14 @@ async function setupUpdateHandlers() { // 下载特定版本(原子操作) ipcMain.handle(IPC_EVENTS.UPDATE_DOWNLOAD_SPECIFIC_VERSION, async (event, versionType) => { + if (isManualReleaseDelivery(updateDelivery)) { + return createErrorResponse( + createManualUpdateRequiredError('download-specific-version', updateDelivery, { + versionType, + }) + ); + } + try { console.log('[Updater] Starting atomic download for version type:', versionType); diff --git a/packages/desktop/preload.js b/packages/desktop/preload.js index 17c4769c..1a0b409d 100644 --- a/packages/desktop/preload.js +++ b/packages/desktop/preload.js @@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require('electron'); // IPC事件名称常量 - 直接内联避免沙箱环境的模块加载问题 const IPC_EVENTS = { UPDATE_CHECK: 'updater-check-update', + UPDATE_OPEN_RELEASE_PAGE: 'updater-open-release-page', UPDATE_START_DOWNLOAD: 'updater-start-download', UPDATE_INSTALL: 'updater-install-update', UPDATE_IGNORE_VERSION: 'updater-ignore-version', @@ -1370,6 +1371,17 @@ contextBridge.exposeInMainWorld('electronAPI', { } return result.data; }, + + openReleasePage: async (version) => { + const result = await withTimeout( + ipcRenderer.invoke(IPC_EVENTS.UPDATE_OPEN_RELEASE_PAGE, version), + 10000 + ); + if (!result.success) { + throw createIpcError(result.error); + } + return result.data; + }, startDownload: async () => { const result = await withTimeout( @@ -1377,11 +1389,7 @@ contextBridge.exposeInMainWorld('electronAPI', { 10000 // 10秒超时,启动下载应该很快 ); if (!result.success) { - // 保留完整的错误信息 - const error = new Error(result.error); - error.originalError = result.error; - error.detailedMessage = result.error; - throw error; + throw createIpcError(result.error); } return result.data; }, @@ -1391,11 +1399,7 @@ contextBridge.exposeInMainWorld('electronAPI', { 10000 // 10秒超时,安装启动应该很快 ); if (!result.success) { - // 保留完整的错误信息 - const error = new Error(result.error); - error.originalError = result.error; - error.detailedMessage = result.error; - throw error; + throw createIpcError(result.error); } return result.data; }, @@ -1448,10 +1452,7 @@ contextBridge.exposeInMainWorld('electronAPI', { 30000 // 30秒超时,现在只等待下载启动,不等待完成,所以30秒足够 ); if (!result.success) { - const error = new Error(result.error || 'Failed to download specific version'); - error.originalError = result.error; - error.detailedMessage = result.error; - throw error; + throw createIpcError(result.error || 'Failed to download specific version'); } return result.data; }, diff --git a/packages/ui/src/components/UpdaterModal.vue b/packages/ui/src/components/UpdaterModal.vue index 3bf1d23f..b022d146 100644 --- a/packages/ui/src/components/UpdaterModal.vue +++ b/packages/ui/src/components/UpdaterModal.vue @@ -18,6 +18,28 @@ + + + {{ manualUpdateTitle }} + {{ manualUpdateDescription }} + + + + + {{ state.releasePageError }} + + -
+
{{ t('updater.downloading') }}
- +
{{ 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 = []