♻️ refactor: implement config-based update system with version compatibility control (#11147)

* ♻️ refactor: implement config-based update system with version compatibility control

Replace GitHub API-based update discovery with JSON config file system. Support
version gating (users below v1.7 must upgrade to v1.7.0 before v2.0). Auto-select
GitHub/GitCode config source based on IP location. Simplify fallback logic.

Changes:
- Add update-config.json with version compatibility rules
- Implement _fetchUpdateConfig() and _findCompatibleChannel()
- Remove legacy _getReleaseVersionFromGithub() and GitHub API dependency
- Refactor _setFeedUrl() with simplified fallback to default feed URLs
- Add design documentation in docs/UPDATE_CONFIG_DESIGN.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(i18n): Auto update translations for PR #11147

* format code

* 🔧 chore: update config for v1.7.5 → v2.0.0 → v2.1.6 upgrade path

Update version configuration to support multi-step upgrade path:
- v1.6.x users → v1.7.5 (last v1.x release)
- v1.7.x users → v2.0.0 (v2.x intermediate version)
- v2.0.0+ users → v2.1.6 (current latest)

Changes:
- Update 1.7.0 → 1.7.5 with fixed feedUrl
- Set 2.0.0 as intermediate version with fixed feedUrl
- Add 2.1.6 as current latest pointing to releases/latest

This ensures users upgrade through required intermediate versions
before jumping to major releases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* 🔧 chore: refactor update config with constants and adjust versions

Refactor update configuration system and adjust to actual versions:

- Add UpdateConfigUrl enum in constant.ts for centralized config URLs
- Point to test server (birdcat.top) for development testing
- Update AppUpdater.ts to use UpdateConfigUrl constants
- Adjust update-config.json to actual v1.6.7 with rc/beta channels
- Remove v2.1.6 entry (not yet released)
- Set package version to 1.6.5 for testing upgrade path
- Add update-config.example.json for reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* update version

*  test: add comprehensive unit tests for AppUpdater config system

Add extensive test coverage for new config-based update system including:
- Config fetching with IP-based source selection (GitHub/GitCode)
- Channel compatibility matching with version constraints
- Smart fallback from rc/beta to latest when appropriate
- Multi-step upgrade path validation (1.6.3 → 1.6.7 → 2.0.0)
- Error handling for network and HTTP failures

Test Coverage:
- _fetchUpdateConfig: 4 tests (GitHub/GitCode selection, error handling)
- _findCompatibleChannel: 9 tests (channel matching, version comparison)
- Upgrade Path: 3 tests (version gating scenarios)
- Total: 30 tests, 100% passing

Also optimize _findCompatibleChannel logic with better variable naming
and log messages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

*  test: add complete multi-step upgrade path tests (1.6.3 → 1.7.5 → 2.0.0 → 2.1.6)

Add comprehensive test suite for complete upgrade journey including:
- Individual step validation (1.6.3→1.7.5, 1.7.5→2.0.0, 2.0.0→2.1.6)
- Full multi-step upgrade simulation with version progression
- Version gating enforcement (block skipping intermediate versions)
- Verification that 1.6.3 cannot directly upgrade to 2.0.0 or 2.1.6
- Verification that 1.7.5 cannot skip 2.0.0 to reach 2.1.6

Test Coverage:
- 6 new tests for complete upgrade path scenarios
- Total: 36 tests, 100% passing

This ensures the version compatibility system correctly enforces
intermediate version upgrades for major releases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* 📝 docs: reorganize update config documentation with English translation

Move update configuration design document to docs/technical/ directory
and add English translation for international contributors.

Changes:
- Move docs/UPDATE_CONFIG_DESIGN.md → docs/technical/app-update-config-zh.md
- Add docs/technical/app-update-config-en.md (English translation)
- Organize technical documentation in dedicated directory

Documentation covers:
- Config-based update system design and rationale
- JSON schema with version compatibility control
- Multi-step upgrade path examples (1.6.3 → 1.7.5 → 2.0.0 → 2.1.6)
- TypeScript type definitions and matching algorithms
- GitHub/GitCode source selection for different regions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* format code

*  test: add tests for latest channel self-comparison prevention

Add tests to verify the optimization that prevents comparing latest
channel with itself when latest is requested, and ensures rc/beta
channels are returned when they are newer than latest.

New tests:
- should not compare latest with itself when requesting latest channel
- should return rc when rc version > latest version
- should return beta when beta version > latest version

These tests ensure the requestedChannel !== UpgradeChannel.LATEST
check works correctly and users get the right channel based on
version comparisons.

Test Coverage: 39 tests, 100% passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* update github/gitcode

* format code

* update rc version

* ♻️ refactor: merge update configs into single multi-mirror file

- Merge app-upgrade-config-github.json and app-upgrade-config-gitcode.json into single app-upgrade-config.json
- Add UpdateMirror enum for type-safe mirror selection
- Optimize _fetchUpdateConfig to receive mirror parameter, eliminating duplicate IP country checks
- Update ChannelConfig interface to use Record<UpdateMirror, string> for feedUrls
- Rename documentation files from app-update-config-* to app-upgrade-config-*
- Update docs with new multi-mirror configuration structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

*  test: update AppUpdater tests for multi-mirror configuration

- Add UpdateMirror enum import
- Update _fetchUpdateConfig tests to accept mirror parameter
- Convert all feedUrl to feedUrls structure in test mocks
- Update test expectations to match new ChannelConfig interface
- All 39 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* format code

* delete files

* 📝 docs: add UpdateMirror enum to type definitions

- Add UpdateMirror enum definition in both EN and ZH docs
- Update ChannelConfig to use Record<UpdateMirror, string>
- Add comments showing equivalent structure for clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* 🐛 fix: return actual channel from _findCompatibleChannel

Fix channel mismatch issue where requesting rc/beta but getting latest:
- Change _findCompatibleChannel return type to include actual channel
- Return { config, channel } instead of just config
- Update _setFeedUrl to use actualChannel instead of requestedChannel
- Update all test expectations to match new return structure
- Add channel assertions to key tests

This ensures autoUpdater.channel matches the actual feed URL being used.

Fixes issue where:
- User requests 'rc' channel
- latest >= rc, so latest config is returned
- But channel was set to 'rc' with latest URL 
- Now channel is correctly set to 'latest' 

All 39 tests passing 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* update version

* udpate version

* update config

* add no cache header

* update files

* 🤖 chore: automate app upgrade config updates

* format code

* update workflow

* update get method

* docs: document upgrade workflow automation

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
beyondkmp
2025-11-14 17:49:40 +08:00
committed by GitHub
parent 68ee5164f0
commit 038d30831c
14 changed files with 2778 additions and 835 deletions

View File

@@ -0,0 +1,212 @@
name: Update App Upgrade Config
on:
release:
types:
- released
- prereleased
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v1.2.3)"
required: true
type: string
is_prerelease:
description: "Mark the tag as a prerelease when running manually"
required: false
default: false
type: boolean
permissions:
contents: write
pull-requests: write
jobs:
propose-update:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.draft == false)
steps:
- name: Check if should proceed
id: check
run: |
EVENT="${{ github.event_name }}"
if [ "$EVENT" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG="${{ github.event.release.tag_name }}"
fi
latest_tag=$(
curl -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ github.token }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/releases/latest \
| jq -r '.tag_name'
)
if [ "$EVENT" = "workflow_dispatch" ]; then
MANUAL_IS_PRERELEASE="${{ github.event.inputs.is_prerelease }}"
if [ -z "$MANUAL_IS_PRERELEASE" ]; then
MANUAL_IS_PRERELEASE="false"
fi
if [ "$MANUAL_IS_PRERELEASE" = "true" ]; then
if ! echo "$TAG" | grep -E '(-beta([.-][0-9]+)?|-rc([.-][0-9]+)?)' >/dev/null; then
echo "Manual prerelease flag set but tag $TAG lacks beta/rc suffix. Skipping." >&2
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$MANUAL_IS_PRERELEASE" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
IS_PRERELEASE="${{ github.event.release.prerelease }}"
if [ "$IS_PRERELEASE" = "true" ]; then
if ! echo "$TAG" | grep -E '(-beta([.-][0-9]+)?|-rc([.-][0-9]+)?)' >/dev/null; then
echo "Release marked as prerelease but tag $TAG lacks beta/rc suffix. Skipping." >&2
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "Release is prerelease, proceeding"
exit 0
fi
if [[ "${latest_tag}" == "$TAG" ]]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "Release is latest, proceeding"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "Release is neither prerelease nor latest, skipping"
fi
- name: Prepare metadata
id: meta
if: steps.check.outputs.should_run == 'true'
run: |
EVENT="${{ github.event_name }}"
LATEST_TAG="${{ steps.check.outputs.latest_tag }}"
if [ "$EVENT" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
PRE="${{ github.event.release.prerelease }}"
if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" = "$TAG" ]; then
LATEST="true"
else
LATEST="false"
fi
TRIGGER="release"
else
TAG="${{ github.event.inputs.tag }}"
PRE="${{ github.event.inputs.is_prerelease }}"
if [ -z "$PRE" ]; then
PRE="false"
fi
if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" = "$TAG" ] && [ "$PRE" != "true" ]; then
LATEST="true"
else
LATEST="false"
fi
TRIGGER="manual"
fi
SAFE_TAG=$(echo "$TAG" | sed 's/[^A-Za-z0-9._-]/-/g')
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "safe_tag=$SAFE_TAG" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
echo "trigger=$TRIGGER" >> "$GITHUB_OUTPUT"
- name: Checkout default branch
if: steps.check.outputs.should_run == 'true'
uses: actions/checkout@v5
with:
ref: ${{ github.event.repository.default_branch }}
path: main
fetch-depth: 0
- name: Checkout cs-releases branch
if: steps.check.outputs.should_run == 'true'
uses: actions/checkout@v5
with:
ref: cs-releases
path: cs
fetch-depth: 0
- name: Setup Node.js
if: steps.check.outputs.should_run == 'true'
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable Corepack
if: steps.check.outputs.should_run == 'true'
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install dependencies
if: steps.check.outputs.should_run == 'true'
working-directory: main
run: yarn install --immutable
- name: Update upgrade config
if: steps.check.outputs.should_run == 'true'
working-directory: main
env:
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }}
run: |
yarn tsx scripts/update-app-upgrade-config.ts \
--tag "$RELEASE_TAG" \
--config ../cs/app-upgrade-config.json \
--is-prerelease "$IS_PRERELEASE"
- name: Detect changes
if: steps.check.outputs.should_run == 'true'
id: diff
working-directory: cs
run: |
if git diff --quiet -- app-upgrade-config.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
path: cs
base: cs-releases
branch: chore/update-app-upgrade-config/${{ steps.meta.outputs.safe_tag }}
commit-message: "🤖 chore: sync app-upgrade-config for ${{ steps.meta.outputs.tag }}"
title: "chore: update app-upgrade-config for ${{ steps.meta.outputs.tag }}"
body: |
Automated update triggered by `${{ steps.meta.outputs.trigger }}`.
- Source tag: `${{ steps.meta.outputs.tag }}`
- Pre-release: `${{ steps.meta.outputs.prerelease }}`
- Latest: `${{ steps.meta.outputs.latest }}`
- Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
labels: |
automation
app-upgrade
- name: No changes detected
if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed != 'true'
run: echo "No updates required for cs-releases/app-upgrade-config.json"

View File

@@ -1,276 +0,0 @@
diff --git a/out/macPackager.js b/out/macPackager.js
index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda1811e924b8 100644
--- a/out/macPackager.js
+++ b/out/macPackager.js
@@ -423,7 +423,7 @@ class MacPackager extends platformPackager_1.PlatformPackager {
}
appPlist.CFBundleName = appInfo.productName;
appPlist.CFBundleDisplayName = appInfo.productName;
- const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion;
+ const minimumSystemVersion = this.platformSpecificBuildOptions.LSMinimumSystemVersion;
if (minimumSystemVersion != null) {
appPlist.LSMinimumSystemVersion = minimumSystemVersion;
}
diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
--- a/out/publish/updateInfoBuilder.js
+++ b/out/publish/updateInfoBuilder.js
@@ -133,6 +133,7 @@ async function createUpdateInfo(version, event, releaseInfo) {
const customUpdateInfo = event.updateInfo;
const url = path.basename(event.file);
const sha512 = (customUpdateInfo == null ? null : customUpdateInfo.sha512) || (await (0, hash_1.hashFile)(event.file));
+ const minimumSystemVersion = customUpdateInfo == null ? null : customUpdateInfo.minimumSystemVersion;
const files = [{ url, sha512 }];
const result = {
// @ts-ignore
@@ -143,9 +144,13 @@ async function createUpdateInfo(version, event, releaseInfo) {
path: url /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
// @ts-ignore
sha512 /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
+ minimumSystemVersion,
...releaseInfo,
};
if (customUpdateInfo != null) {
+ if (customUpdateInfo.minimumSystemVersion) {
+ delete customUpdateInfo.minimumSystemVersion;
+ }
// file info or nsis web installer packages info
Object.assign("sha512" in customUpdateInfo ? files[0] : result, customUpdateInfo);
}
diff --git a/out/targets/ArchiveTarget.js b/out/targets/ArchiveTarget.js
index e1f52a5fa86fff6643b2e57eaf2af318d541f865..47cc347f154a24b365e70ae5e1f6d309f3582ed0 100644
--- a/out/targets/ArchiveTarget.js
+++ b/out/targets/ArchiveTarget.js
@@ -69,6 +69,9 @@ class ArchiveTarget extends core_1.Target {
}
}
}
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
+ }
await packager.info.emitArtifactBuildCompleted({
updateInfo,
file: artifactPath,
diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js
index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01de4b89b1 100644
--- a/out/targets/nsis/NsisTarget.js
+++ b/out/targets/nsis/NsisTarget.js
@@ -305,6 +305,9 @@ class NsisTarget extends core_1.Target {
if (updateInfo != null && isPerMachine && (oneClick || options.packElevateHelper)) {
updateInfo.isAdminRightsRequired = true;
}
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
+ }
await packager.info.emitArtifactBuildCompleted({
file: installerPath,
updateInfo,
diff --git a/out/util/yarn.js b/out/util/yarn.js
index 1ee20f8b252a8f28d0c7b103789cf0a9a427aec1..c2878ec54d57da50bf14225e0c70c9c88664eb8a 100644
--- a/out/util/yarn.js
+++ b/out/util/yarn.js
@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) {
arch,
platform,
buildFromSource,
+ ignoreModules: config.excludeReBuildModules || undefined,
projectRootPath: projectDir,
mode: config.nativeRebuilder || "sequential",
disablePreGypCopy: true,
diff --git a/scheme.json b/scheme.json
index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a74dda74c9 100644
--- a/scheme.json
+++ b/scheme.json
@@ -1825,6 +1825,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableArgs": {
"anyOf": [
{
@@ -1975,6 +1989,13 @@
],
"description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing."
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"packageCategory": {
"description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place",
"type": [
@@ -2327,6 +2348,13 @@
"MacConfiguration": {
"additionalProperties": false,
"properties": {
+ "LSMinimumSystemVersion": {
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"additionalArguments": {
"anyOf": [
{
@@ -2527,6 +2555,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -2737,7 +2779,7 @@
"type": "boolean"
},
"minimumSystemVersion": {
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "description": "The minimum os kernel version required to install the application.",
"type": [
"null",
"string"
@@ -2959,6 +3001,13 @@
"MasConfiguration": {
"additionalProperties": false,
"properties": {
+ "LSMinimumSystemVersion": {
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"additionalArguments": {
"anyOf": [
{
@@ -3159,6 +3208,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -3369,7 +3432,7 @@
"type": "boolean"
},
"minimumSystemVersion": {
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "description": "The minimum os kernel version required to install the application.",
"type": [
"null",
"string"
@@ -6381,6 +6444,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -6507,6 +6584,13 @@
"string"
]
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"protocols": {
"anyOf": [
{
@@ -7153,6 +7237,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -7376,6 +7474,13 @@
],
"description": "MAS (Mac Application Store) development options (`mas-dev` target)."
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"msi": {
"anyOf": [
{

View File

@@ -0,0 +1,14 @@
diff --git a/out/util.js b/out/util.js
index 9294ffd6ca8f02c2e0f90c663e7e9cdc02c1ac37..f52107493e2995320ee4efd0eb2a8c9bf03291a2 100644
--- a/out/util.js
+++ b/out/util.js
@@ -23,7 +23,8 @@ function newUrlFromBase(pathname, baseUrl, addRandomQueryToAvoidCaching = false)
result.search = search;
}
else if (addRandomQueryToAvoidCaching) {
- result.search = `noCache=${Date.now().toString(32)}`;
+ // use no cache header instead
+ // result.search = `noCache=${Date.now().toString(32)}`;
}
return result;
}

49
app-upgrade-config.json Normal file
View File

@@ -0,0 +1,49 @@
{
"lastUpdated": "2025-11-10T08:14:28Z",
"versions": {
"1.6.7": {
"metadata": {
"segmentId": "legacy-v1",
"segmentType": "legacy"
},
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"channels": {
"latest": {
"version": "1.6.7",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7",
"gitcode": "https://releases.cherry-ai.com"
}
},
"rc": {
"version": "1.6.0-rc.5",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5"
}
},
"beta": {
"version": "1.7.0-beta.3",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3"
}
}
}
},
"2.0.0": {
"metadata": {
"segmentId": "gateway-v2",
"segmentType": "breaking"
},
"minCompatibleVersion": "1.7.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channels": {
"latest": null,
"rc": null,
"beta": null
}
}
}
}

View File

@@ -0,0 +1,81 @@
{
"segments": [
{
"id": "legacy-v1",
"type": "legacy",
"match": {
"range": ">=1.0.0 <2.0.0"
},
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://releases.cherry-ai.com"
}
},
"rc": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
},
"beta": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
}
}
},
{
"id": "gateway-v2",
"type": "breaking",
"match": {
"exact": ["2.0.0"]
},
"lockedVersion": "2.0.0",
"minCompatibleVersion": "1.7.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
}
}
},
{
"id": "current-v2",
"type": "latest",
"match": {
"range": ">=2.0.0 <3.0.0",
"excludeExact": ["2.0.0"]
},
"minCompatibleVersion": "2.0.0",
"description": "Current latest v2.x release",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
},
"rc": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
},
"beta": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
}
}
}
]
}

View File

@@ -0,0 +1,430 @@
# Update Configuration System Design Document
## Background
Currently, AppUpdater directly queries the GitHub API to retrieve beta and rc update information. To support users in China, we need to fetch a static JSON configuration file from GitHub/GitCode based on IP geolocation, which contains update URLs for all channels.
## Design Goals
1. Support different configuration sources based on IP geolocation (GitHub/GitCode)
2. Support version compatibility control (e.g., users below v1.x must upgrade to v1.7.0 before upgrading to v2.0)
3. Easy to extend, supporting future multi-major-version upgrade paths (v1.6 → v1.7 → v2.0 → v2.8 → v3.0)
4. Maintain compatibility with existing electron-updater mechanism
## Current Version Strategy
- **v1.7.x** is the last version of the 1.x series
- Users **below v1.7.0** must first upgrade to v1.7.0 (or higher 1.7.x version)
- Users **v1.7.0 and above** can directly upgrade to v2.x.x
## Automation Workflow
The `cs-releases/app-upgrade-config.json` file is synchronized by the [`Update App Upgrade Config`](../../.github/workflows/update-app-upgrade-config.yml) workflow. The workflow runs the [`scripts/update-app-upgrade-config.ts`](../../scripts/update-app-upgrade-config.ts) helper so that every release tag automatically updates the JSON in `cs-releases`.
### Trigger Conditions
- **Release events (`release: released/prereleased`)**
- Draft releases are ignored.
- When GitHub marks the release as _prerelease_, the tag must include `-beta`/`-rc` (with optional numeric suffix). Otherwise the workflow exits early.
- When GitHub marks the release as stable, the tag must match the latest release returned by the GitHub API. This prevents out-of-order updates when publishing historical tags.
- If the guard clauses pass, the version is tagged as `latest` or `beta/rc` based on its semantic suffix and propagated to the script through the `IS_PRERELEASE` flag.
- **Manual dispatch (`workflow_dispatch`)**
- Required input: `tag` (e.g., `v2.0.1`). Optional input: `is_prerelease` (defaults to `false`).
- When `is_prerelease=true`, the tag must carry a beta/rc suffix, mirroring the automatic validation.
- Manual runs still download the latest release metadata so that the workflow knows whether the tag represents the newest stable version (for documentation inside the PR body).
### Workflow Steps
1. **Guard + metadata preparation** the `Check if should proceed` and `Prepare metadata` steps compute the target tag, prerelease flag, whether the tag is the newest release, and a `safe_tag` slug used for branch names. When any rule fails, the workflow stops without touching the config.
2. **Checkout source branches** the default branch is checked out into `main/`, while the long-lived `cs-releases` branch lives in `cs/`. All modifications happen in the latter directory.
3. **Install toolchain** Node.js 22, Corepack, and frozen Yarn dependencies are installed inside `main/`.
4. **Run the update script** `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>` updates the JSON in-place.
- The script normalizes the tag (e.g., strips `v` prefix), detects the release channel (`latest`, `rc`, `beta`), and loads segment rules from `config/app-upgrade-segments.json`.
- It validates that prerelease flags and semantic suffixes agree, enforces locked segments, builds mirror feed URLs, and performs release-availability checks (GitHub HEAD request for every channel; GitCode GET for latest channels, falling back to `https://releases.cherry-ai.com` when gitcode is delayed).
- After updating the relevant channel entry, the script rewrites the config with semver-sort order and a new `lastUpdated` timestamp.
5. **Detect changes + create PR** if `cs/app-upgrade-config.json` changed, the workflow opens a PR `chore/update-app-upgrade-config/<safe_tag>` against `cs-releases` with a commit message `🤖 chore: sync app-upgrade-config for <tag>`. Otherwise it logs that no update is required.
### Manual Trigger Guide
1. Open the Cherry Studio repository on GitHub → **Actions** tab → select **Update App Upgrade Config**.
2. Click **Run workflow**, choose the default branch (usually `main`), and fill in the `tag` input (e.g., `v2.1.0`).
3. Toggle `is_prerelease` only when the tag carries a prerelease suffix (`-beta`, `-rc`). Leave it unchecked for stable releases.
4. Start the run and wait for it to finish. Check the generated PR in the `cs-releases` branch, verify the diff in `app-upgrade-config.json`, and merge once validated.
## JSON Configuration File Format
### File Location
- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/cs-releases/app-upgrade-config.json`
- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/cs-releases/app-upgrade-config.json`
**Note**: Both mirrors provide the same configuration file hosted on the `cs-releases` branch. The client automatically selects the optimal mirror based on IP geolocation.
### Configuration Structure (Current Implementation)
```json
{
"lastUpdated": "2025-01-05T00:00:00Z",
"versions": {
"1.6.7": {
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"channels": {
"latest": {
"version": "1.6.7",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v1.6.7"
}
},
"rc": {
"version": "1.6.0-rc.5",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5"
}
},
"beta": {
"version": "1.6.7-beta.3",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3"
}
}
}
},
"2.0.0": {
"minCompatibleVersion": "1.7.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channels": {
"latest": null,
"rc": null,
"beta": null
}
}
}
}
```
### Future Extension Example
When releasing v3.0, if users need to first upgrade to v2.8, you can add:
```json
{
"2.8.0": {
"minCompatibleVersion": "2.0.0",
"description": "Stable v2.8 - required for v3 upgrade",
"channels": {
"latest": {
"version": "2.8.0",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v2.8.0",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v2.8.0"
}
},
"rc": null,
"beta": null
}
},
"3.0.0": {
"minCompatibleVersion": "2.8.0",
"description": "Major release v3.0",
"channels": {
"latest": {
"version": "3.0.0",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/latest",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/latest"
}
},
"rc": {
"version": "3.0.0-rc.1",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1"
}
},
"beta": null
}
}
}
```
### Field Descriptions
- `lastUpdated`: Last update time of the configuration file (ISO 8601 format)
- `versions`: Version configuration object, key is the version number, sorted by semantic versioning
- `minCompatibleVersion`: Minimum compatible version that can upgrade to this version
- `description`: Version description
- `channels`: Update channel configuration
- `latest`: Stable release channel
- `rc`: Release Candidate channel
- `beta`: Beta testing channel
- Each channel contains:
- `version`: Version number for this channel
- `feedUrls`: Multi-mirror URL configuration
- `github`: electron-updater feed URL for GitHub mirror
- `gitcode`: electron-updater feed URL for GitCode mirror
- `metadata`: Stable mapping info for automation
- `segmentId`: ID from `config/app-upgrade-segments.json`
- `segmentType`: Optional flag (`legacy` | `breaking` | `latest`) for documentation/debugging
## TypeScript Type Definitions
```typescript
// Mirror enum
enum UpdateMirror {
GITHUB = 'github',
GITCODE = 'gitcode'
}
interface UpdateConfig {
lastUpdated: string
versions: {
[versionKey: string]: VersionConfig
}
}
interface VersionConfig {
minCompatibleVersion: string
description: string
channels: {
latest: ChannelConfig | null
rc: ChannelConfig | null
beta: ChannelConfig | null
}
metadata?: {
segmentId: string
segmentType?: 'legacy' | 'breaking' | 'latest'
}
}
interface ChannelConfig {
version: string
feedUrls: Record<UpdateMirror, string>
// Equivalent to:
// feedUrls: {
// github: string
// gitcode: string
// }
}
```
## Segment Metadata & Breaking Markers
- **Segment definitions** now live in `config/app-upgrade-segments.json`. Each segment describes a semantic-version range (or exact matches) plus metadata such as `segmentId`, `segmentType`, `minCompatibleVersion`, and per-channel feed URL templates.
- Each entry under `versions` carries a `metadata.segmentId`. This acts as the stable key that scripts use to decide which slot to update, even if the actual semantic version string changes.
- Mark major upgrade gateways (e.g., `2.0.0`) by giving the related segment a `segmentType: "breaking"` and (optionally) `lockedVersion`. This prevents automation from accidentally moving that entry when other 2.x builds ship.
- Adding another breaking hop (e.g., `3.0.0`) only requires defining a new segment in the JSON file; the automation will pick it up on the next run.
## Automation Workflow
Starting from this change, `.github/workflows/update-app-upgrade-config.yml` listens to GitHub release events (published + prerelease). The workflow:
1. Checks out the default branch (for scripts) and the `cs-releases` branch (where the config is hosted).
2. Runs `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json` to regenerate the config directly inside the `cs-releases` working tree.
3. If the file changed, it opens a PR against `cs-releases` via `peter-evans/create-pull-request`, with the generated diff limited to `app-upgrade-config.json`.
You can run the same script locally via `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json` (add `--dry-run` to preview) to reproduce or debug whatever the workflow does. Passing `--skip-release-checks` along with `--dry-run` lets you bypass the release-page existence check (useful when the GitHub/GitCode pages arent published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes.
## Version Matching Logic
### Algorithm Flow
1. Get user's current version (`currentVersion`) and requested channel (`requestedChannel`)
2. Get all version numbers from configuration file, sort in descending order by semantic versioning
3. Iterate through the sorted version list:
- Check if `currentVersion >= minCompatibleVersion`
- Check if the requested `channel` exists and is not `null`
- If conditions are met, return the channel configuration
4. If no matching version is found, return `null`
### Pseudocode Implementation
```typescript
function findCompatibleVersion(
currentVersion: string,
requestedChannel: UpgradeChannel,
config: UpdateConfig
): ChannelConfig | null {
// Get all version numbers and sort in descending order
const versions = Object.keys(config.versions).sort(semver.rcompare)
for (const versionKey of versions) {
const versionConfig = config.versions[versionKey]
const channelConfig = versionConfig.channels[requestedChannel]
// Check version compatibility and channel availability
if (
semver.gte(currentVersion, versionConfig.minCompatibleVersion) &&
channelConfig !== null
) {
return channelConfig
}
}
return null // No compatible version found
}
```
## Upgrade Path Examples
### Scenario 1: v1.6.5 User Upgrade (Below 1.7)
- **Current Version**: 1.6.5
- **Requested Channel**: latest
- **Match Result**: 1.7.0
- **Reason**: 1.6.5 >= 0.0.0 (satisfies 1.7.0's minCompatibleVersion), but doesn't satisfy 2.0.0's minCompatibleVersion (1.7.0)
- **Action**: Prompt user to upgrade to 1.7.0, which is the required intermediate version for v2.x upgrade
### Scenario 2: v1.6.5 User Requests rc/beta
- **Current Version**: 1.6.5
- **Requested Channel**: rc or beta
- **Match Result**: 1.7.0 (latest)
- **Reason**: 1.7.0 version doesn't provide rc/beta channels (values are null)
- **Action**: Upgrade to 1.7.0 stable version
### Scenario 3: v1.7.0 User Upgrades to Latest
- **Current Version**: 1.7.0
- **Requested Channel**: latest
- **Match Result**: 2.0.0
- **Reason**: 1.7.0 >= 1.7.0 (satisfies 2.0.0's minCompatibleVersion)
- **Action**: Directly upgrade to 2.0.0 (current latest stable version)
### Scenario 4: v1.7.2 User Upgrades to RC Version
- **Current Version**: 1.7.2
- **Requested Channel**: rc
- **Match Result**: 2.0.0-rc.1
- **Reason**: 1.7.2 >= 1.7.0 (satisfies 2.0.0's minCompatibleVersion), and rc channel exists
- **Action**: Upgrade to 2.0.0-rc.1
### Scenario 5: v1.7.0 User Upgrades to Beta Version
- **Current Version**: 1.7.0
- **Requested Channel**: beta
- **Match Result**: 2.0.0-beta.1
- **Reason**: 1.7.0 >= 1.7.0, and beta channel exists
- **Action**: Upgrade to 2.0.0-beta.1
### Scenario 6: v2.5.0 User Upgrade (Future)
Assuming v2.8.0 and v3.0.0 configurations have been added:
- **Current Version**: 2.5.0
- **Requested Channel**: latest
- **Match Result**: 2.8.0
- **Reason**: 2.5.0 >= 2.0.0 (satisfies 2.8.0's minCompatibleVersion), but doesn't satisfy 3.0.0's requirement
- **Action**: Prompt user to upgrade to 2.8.0, which is the required intermediate version for v3.x upgrade
## Code Changes
### Main Modifications
1. **New Methods**
- `_fetchUpdateConfig(ipCountry: string): Promise<UpdateConfig | null>` - Fetch configuration file based on IP
- `_findCompatibleChannel(currentVersion: string, channel: UpgradeChannel, config: UpdateConfig): ChannelConfig | null` - Find compatible channel configuration
2. **Modified Methods**
- `_getReleaseVersionFromGithub()` → Remove or refactor to `_getChannelFeedUrl()`
- `_setFeedUrl()` - Use new configuration system to replace existing logic
3. **New Type Definitions**
- `UpdateConfig`
- `VersionConfig`
- `ChannelConfig`
### Mirror Selection Logic
The client automatically selects the optimal mirror based on IP geolocation:
```typescript
private async _setFeedUrl() {
const currentVersion = app.getVersion()
const testPlan = configManager.getTestPlan()
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
// Determine mirror based on IP country
const ipCountry = await getIpCountry()
const mirror = ipCountry.toLowerCase() === 'cn' ? 'gitcode' : 'github'
// Fetch update config
const config = await this._fetchUpdateConfig(mirror)
if (config) {
const channelConfig = this._findCompatibleChannel(currentVersion, requestedChannel, config)
if (channelConfig) {
// Select feed URL from the corresponding mirror
const feedUrl = channelConfig.feedUrls[mirror]
this._setChannel(requestedChannel, feedUrl)
return
}
}
// Fallback logic
const defaultFeedUrl = mirror === 'gitcode'
? FeedUrl.PRODUCTION
: FeedUrl.GITHUB_LATEST
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
}
private async _fetchUpdateConfig(mirror: 'github' | 'gitcode'): Promise<UpdateConfig | null> {
const configUrl = mirror === 'gitcode'
? UpdateConfigUrl.GITCODE
: UpdateConfigUrl.GITHUB
try {
const response = await net.fetch(configUrl, {
headers: {
'User-Agent': generateUserAgent(),
'Accept': 'application/json',
'X-Client-Id': configManager.getClientId()
}
})
return await response.json() as UpdateConfig
} catch (error) {
logger.error('Failed to fetch update config:', error)
return null
}
}
```
## Fallback and Error Handling Strategy
1. **Configuration file fetch failure**: Log error, return current version, don't offer updates
2. **No matching version**: Notify user that current version doesn't support automatic upgrade
3. **Network exception**: Cache last successfully fetched configuration (optional)
## GitHub Release Requirements
To support intermediate version upgrades, the following files need to be retained:
- **v1.7.0 release** and its latest*.yml files (as upgrade target for users below v1.7)
- Future intermediate versions (e.g., v2.8.0) need to retain corresponding release and latest*.yml files
- Complete installation packages for each version
### Currently Required Releases
| Version | Purpose | Must Retain |
|---------|---------|-------------|
| v1.7.0 | Upgrade target for users below 1.7 | ✅ Yes |
| v2.0.0-rc.1 | RC testing channel | ❌ Optional |
| v2.0.0-beta.1 | Beta testing channel | ❌ Optional |
| latest | Latest stable version (automatic) | ✅ Yes |
## Advantages
1. **Flexibility**: Supports arbitrarily complex upgrade paths
2. **Extensibility**: Adding new versions only requires adding new entries to the configuration file
3. **Maintainability**: Configuration is separated from code, allowing upgrade strategy adjustments without releasing new versions
4. **Multi-source support**: Automatically selects optimal configuration source based on geolocation
5. **Version control**: Enforces intermediate version upgrades, ensuring data migration and compatibility
## Future Extensions
- Support more granular version range control (e.g., `>=1.5.0 <1.8.0`)
- Support multi-step upgrade path hints (e.g., notify user needs 1.5 → 1.8 → 2.0)
- Support A/B testing and gradual rollout
- Support local caching and expiration strategy for configuration files

View File

@@ -0,0 +1,430 @@
# 更新配置系统设计文档
## 背景
当前 AppUpdater 直接请求 GitHub API 获取 beta 和 rc 的更新信息。为了支持国内用户,需要根据 IP 地理位置,分别从 GitHub/GitCode 获取一个固定的 JSON 配置文件,该文件包含所有渠道的更新地址。
## 设计目标
1. 支持根据 IP 地理位置选择不同的配置源GitHub/GitCode
2. 支持版本兼容性控制(如 v1.x 以下必须先升级到 v1.7.0 才能升级到 v2.0
3. 易于扩展支持未来多个主版本的升级路径v1.6 → v1.7 → v2.0 → v2.8 → v3.0
4. 保持与现有 electron-updater 机制的兼容性
## 当前版本策略
- **v1.7.x** 是 1.x 系列的最后版本
- **v1.7.0 以下**的用户必须先升级到 v1.7.0(或更高的 1.7.x 版本)
- **v1.7.0 及以上**的用户可以直接升级到 v2.x.x
## 自动化工作流
`cs-releases/app-upgrade-config.json` 由 [`Update App Upgrade Config`](../../.github/workflows/update-app-upgrade-config.yml) workflow 自动同步。工作流会调用 [`scripts/update-app-upgrade-config.ts`](../../scripts/update-app-upgrade-config.ts) 脚本,根据指定 tag 更新 `cs-releases` 分支上的配置文件。
### 触发条件
- **Release 事件(`release: released/prereleased`**
- Draft release 会被忽略。
- 当 GitHub 将 release 标记为 *prerelease*tag 必须包含 `-beta`/`-rc`(可带序号),否则直接跳过。
- 当 release 标记为稳定版时tag 必须与 GitHub API 返回的最新稳定版本一致,防止发布历史 tag 时意外挂起工作流。
- 满足上述条件后,工作流会根据语义化版本判断渠道(`latest`/`beta`/`rc`),并通过 `IS_PRERELEASE` 传递给脚本。
- **手动触发(`workflow_dispatch`**
- 必填:`tag`(例:`v2.0.1`);选填:`is_prerelease`(默认 `false`)。
-`is_prerelease=true` 时,同样要求 tag 带有 beta/rc 后缀。
- 手动运行仍会请求 GitHub 最新 release 信息,用于在 PR 说明中标注该 tag 是否是最新稳定版。
### 工作流步骤
1. **检查与元数据准备**`Check if should proceed``Prepare metadata` 步骤会计算 tag、prerelease 标志、是否最新版本以及用于分支名的 `safe_tag`。若任意校验失败,工作流立即退出。
2. **检出分支**:默认分支被检出到 `main/`,长期维护的 `cs-releases` 分支则在 `cs/` 中,所有改动都发生在 `cs/`
3. **安装工具链**:安装 Node.js 22、启用 Corepack并在 `main/` 目录执行 `yarn install --immutable`
4. **运行更新脚本**:执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>`
- 脚本会标准化 tag去掉 `v` 前缀等)、识别渠道、加载 `config/app-upgrade-segments.json` 中的分段规则。
- 校验 prerelease 标志与语义后缀是否匹配、强制锁定的 segment 是否满足、生成镜像的下载地址,并检查 release 是否已经在 GitHub/GitCode 可用latest 渠道在 GitCode 不可用时会回退到 `https://releases.cherry-ai.com`)。
- 更新对应的渠道配置后,脚本会按 semver 排序写回 JSON并刷新 `lastUpdated`
5. **检测变更并创建 PR**:若 `cs/app-upgrade-config.json` 有变更,则创建 `chore/update-app-upgrade-config/<safe_tag>` 分支,提交信息为 `🤖 chore: sync app-upgrade-config for <tag>`,并向 `cs-releases` 提 PR无变更则输出提示。
### 手动触发指南
1. 进入 Cherry Studio 仓库的 GitHub **Actions** 页面,选择 **Update App Upgrade Config** 工作流。
2. 点击 **Run workflow**,保持默认分支(通常为 `main`),填写 `tag`(如 `v2.1.0`)。
3. 只有在 tag 带 `-beta`/`-rc` 后缀时才勾选 `is_prerelease`,稳定版保持默认。
4. 启动运行并等待完成,随后到 `cs-releases` 分支的 PR 查看 `app-upgrade-config.json` 的变更并在验证后合并。
## JSON 配置文件格式
### 文件位置
- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/cs-releases/app-upgrade-config.json`
- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/cs-releases/app-upgrade-config.json`
**说明**:两个镜像源提供相同的配置文件,统一托管在 `cs-releases` 分支上。客户端根据 IP 地理位置自动选择最优镜像源。
### 配置结构(当前实际配置)
```json
{
"lastUpdated": "2025-01-05T00:00:00Z",
"versions": {
"1.6.7": {
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"channels": {
"latest": {
"version": "1.6.7",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v1.6.7"
}
},
"rc": {
"version": "1.6.0-rc.5",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5"
}
},
"beta": {
"version": "1.6.7-beta.3",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3"
}
}
}
},
"2.0.0": {
"minCompatibleVersion": "1.7.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channels": {
"latest": null,
"rc": null,
"beta": null
}
}
}
}
```
### 未来扩展示例
当需要发布 v3.0 时,如果需要强制用户先升级到 v2.8,可以添加:
```json
{
"2.8.0": {
"minCompatibleVersion": "2.0.0",
"description": "Stable v2.8 - required for v3 upgrade",
"channels": {
"latest": {
"version": "2.8.0",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v2.8.0",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v2.8.0"
}
},
"rc": null,
"beta": null
}
},
"3.0.0": {
"minCompatibleVersion": "2.8.0",
"description": "Major release v3.0",
"channels": {
"latest": {
"version": "3.0.0",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/latest",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/latest"
}
},
"rc": {
"version": "3.0.0-rc.1",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1"
}
},
"beta": null
}
}
}
```
### 字段说明
- `lastUpdated`: 配置文件最后更新时间ISO 8601 格式)
- `versions`: 版本配置对象key 为版本号,按语义化版本排序
- `minCompatibleVersion`: 可以升级到此版本的最低兼容版本
- `description`: 版本描述
- `channels`: 更新渠道配置
- `latest`: 稳定版渠道
- `rc`: Release Candidate 渠道
- `beta`: Beta 测试渠道
- 每个渠道包含:
- `version`: 该渠道的版本号
- `feedUrls`: 多镜像源 URL 配置
- `github`: GitHub 镜像源的 electron-updater feed URL
- `gitcode`: GitCode 镜像源的 electron-updater feed URL
- `metadata`: 自动化匹配所需的稳定标识
- `segmentId`: 来自 `config/app-upgrade-segments.json` 的段位 ID
- `segmentType`: 可选字段(`legacy` | `breaking` | `latest`),便于文档/调试
## TypeScript 类型定义
```typescript
// 镜像源枚举
enum UpdateMirror {
GITHUB = 'github',
GITCODE = 'gitcode'
}
interface UpdateConfig {
lastUpdated: string
versions: {
[versionKey: string]: VersionConfig
}
}
interface VersionConfig {
minCompatibleVersion: string
description: string
channels: {
latest: ChannelConfig | null
rc: ChannelConfig | null
beta: ChannelConfig | null
}
metadata?: {
segmentId: string
segmentType?: 'legacy' | 'breaking' | 'latest'
}
}
interface ChannelConfig {
version: string
feedUrls: Record<UpdateMirror, string>
// 等同于:
// feedUrls: {
// github: string
// gitcode: string
// }
}
```
## 段位元数据Break Change 标记)
- 所有段位定义(如 `legacy-v1``gateway-v2` 等)集中在 `config/app-upgrade-segments.json`,用于描述匹配范围、`segmentId``segmentType`、默认 `minCompatibleVersion/description` 以及各渠道的 URL 模板。
- `versions` 下的每个节点都会带上 `metadata.segmentId`。自动脚本始终依据该 ID 来定位并更新条目,即便 key 从 `2.1.5` 切换到 `2.1.6` 也不会错位。
- 如果某段需要锁死在特定版本(例如 `2.0.0` 的 break change可在段定义中设置 `segmentType: "breaking"` 并提供 `lockedVersion`,脚本在遇到不匹配的 tag 时会短路报错,保证升级路径安全。
- 面对未来新的断层(例如 `3.0.0`),只需要在段定义里新增一段,自动化即可识别并更新。
## 自动化工作流
`.github/workflows/update-app-upgrade-config.yml` 会在 GitHub Release包含正常发布与 Pre Release触发
1. 同时 Checkout 仓库默认分支(用于脚本)和 `cs-releases` 分支(真实托管配置的分支)。
2. 在默认分支目录执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json`,直接重写 `cs-releases` 分支里的配置文件。
3. 如果 `app-upgrade-config.json` 有变化,则通过 `peter-evans/create-pull-request` 自动创建一个指向 `cs-releases` 的 PRDiff 仅包含该文件。
如需本地调试,可执行 `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。
## 版本匹配逻辑
### 算法流程
1. 获取用户当前版本(`currentVersion`)和请求的渠道(`requestedChannel`
2. 获取配置文件中所有版本号,按语义化版本从大到小排序
3. 遍历排序后的版本列表:
- 检查 `currentVersion >= minCompatibleVersion`
- 检查请求的 `channel` 是否存在且不为 `null`
- 如果满足条件,返回该渠道配置
4. 如果没有找到匹配版本,返回 `null`
### 伪代码实现
```typescript
function findCompatibleVersion(
currentVersion: string,
requestedChannel: UpgradeChannel,
config: UpdateConfig
): ChannelConfig | null {
// 获取所有版本号并从大到小排序
const versions = Object.keys(config.versions).sort(semver.rcompare)
for (const versionKey of versions) {
const versionConfig = config.versions[versionKey]
const channelConfig = versionConfig.channels[requestedChannel]
// 检查版本兼容性和渠道可用性
if (
semver.gte(currentVersion, versionConfig.minCompatibleVersion) &&
channelConfig !== null
) {
return channelConfig
}
}
return null // 没有找到兼容版本
}
```
## 升级路径示例
### 场景 1: v1.6.5 用户升级(低于 1.7
- **当前版本**: 1.6.5
- **请求渠道**: latest
- **匹配结果**: 1.7.0
- **原因**: 1.6.5 >= 0.0.0(满足 1.7.0 的 minCompatibleVersion但不满足 2.0.0 的 minCompatibleVersion (1.7.0)
- **操作**: 提示用户升级到 1.7.0,这是升级到 v2.x 的必要中间版本
### 场景 2: v1.6.5 用户请求 rc/beta
- **当前版本**: 1.6.5
- **请求渠道**: rc 或 beta
- **匹配结果**: 1.7.0 (latest)
- **原因**: 1.7.0 版本不提供 rc/beta 渠道(值为 null
- **操作**: 升级到 1.7.0 稳定版
### 场景 3: v1.7.0 用户升级到最新版
- **当前版本**: 1.7.0
- **请求渠道**: latest
- **匹配结果**: 2.0.0
- **原因**: 1.7.0 >= 1.7.0(满足 2.0.0 的 minCompatibleVersion
- **操作**: 直接升级到 2.0.0(当前最新稳定版)
### 场景 4: v1.7.2 用户升级到 RC 版本
- **当前版本**: 1.7.2
- **请求渠道**: rc
- **匹配结果**: 2.0.0-rc.1
- **原因**: 1.7.2 >= 1.7.0(满足 2.0.0 的 minCompatibleVersion且 rc 渠道存在
- **操作**: 升级到 2.0.0-rc.1
### 场景 5: v1.7.0 用户升级到 Beta 版本
- **当前版本**: 1.7.0
- **请求渠道**: beta
- **匹配结果**: 2.0.0-beta.1
- **原因**: 1.7.0 >= 1.7.0,且 beta 渠道存在
- **操作**: 升级到 2.0.0-beta.1
### 场景 6: v2.5.0 用户升级(未来)
假设已添加 v2.8.0 和 v3.0.0 配置:
- **当前版本**: 2.5.0
- **请求渠道**: latest
- **匹配结果**: 2.8.0
- **原因**: 2.5.0 >= 2.0.0(满足 2.8.0 的 minCompatibleVersion但不满足 3.0.0 的要求
- **操作**: 提示用户升级到 2.8.0,这是升级到 v3.x 的必要中间版本
## 代码改动计划
### 主要修改
1. **新增方法**
- `_fetchUpdateConfig(ipCountry: string): Promise<UpdateConfig | null>` - 根据 IP 获取配置文件
- `_findCompatibleChannel(currentVersion: string, channel: UpgradeChannel, config: UpdateConfig): ChannelConfig | null` - 查找兼容的渠道配置
2. **修改方法**
- `_getReleaseVersionFromGithub()` → 移除或重构为 `_getChannelFeedUrl()`
- `_setFeedUrl()` - 使用新的配置系统替代现有逻辑
3. **新增类型定义**
- `UpdateConfig`
- `VersionConfig`
- `ChannelConfig`
### 镜像源选择逻辑
客户端根据 IP 地理位置自动选择最优镜像源:
```typescript
private async _setFeedUrl() {
const currentVersion = app.getVersion()
const testPlan = configManager.getTestPlan()
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
// 根据 IP 国家确定镜像源
const ipCountry = await getIpCountry()
const mirror = ipCountry.toLowerCase() === 'cn' ? 'gitcode' : 'github'
// 获取更新配置
const config = await this._fetchUpdateConfig(mirror)
if (config) {
const channelConfig = this._findCompatibleChannel(currentVersion, requestedChannel, config)
if (channelConfig) {
// 从配置中选择对应镜像源的 URL
const feedUrl = channelConfig.feedUrls[mirror]
this._setChannel(requestedChannel, feedUrl)
return
}
}
// Fallback 逻辑
const defaultFeedUrl = mirror === 'gitcode'
? FeedUrl.PRODUCTION
: FeedUrl.GITHUB_LATEST
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
}
private async _fetchUpdateConfig(mirror: 'github' | 'gitcode'): Promise<UpdateConfig | null> {
const configUrl = mirror === 'gitcode'
? UpdateConfigUrl.GITCODE
: UpdateConfigUrl.GITHUB
try {
const response = await net.fetch(configUrl, {
headers: {
'User-Agent': generateUserAgent(),
'Accept': 'application/json',
'X-Client-Id': configManager.getClientId()
}
})
return await response.json() as UpdateConfig
} catch (error) {
logger.error('Failed to fetch update config:', error)
return null
}
}
```
## 降级和容错策略
1. **配置文件获取失败**: 记录错误日志,返回当前版本,不提供更新
2. **没有匹配的版本**: 提示用户当前版本不支持自动升级
3. **网络异常**: 缓存上次成功获取的配置(可选)
## GitHub Release 要求
为支持中间版本升级,需要保留以下文件:
- **v1.7.0 release** 及其 latest*.yml 文件(作为 v1.7 以下用户的升级目标)
- 未来如需强制中间版本(如 v2.8.0),需要保留对应的 release 和 latest*.yml 文件
- 各版本的完整安装包
### 当前需要的 Release
| 版本 | 用途 | 必须保留 |
|------|------|---------|
| v1.7.0 | 1.7 以下用户的升级目标 | ✅ 是 |
| v2.0.0-rc.1 | RC 测试渠道 | ❌ 可选 |
| v2.0.0-beta.1 | Beta 测试渠道 | ❌ 可选 |
| latest | 最新稳定版(自动) | ✅ 是 |
## 优势
1. **灵活性**: 支持任意复杂的升级路径
2. **可扩展性**: 新增版本只需在配置文件中添加新条目
3. **可维护性**: 配置与代码分离,无需发版即可调整升级策略
4. **多源支持**: 自动根据地理位置选择最优配置源
5. **版本控制**: 强制中间版本升级,确保数据迁移和兼容性
## 未来扩展
- 支持更细粒度的版本范围控制(如 `>=1.5.0 <1.8.0`
- 支持多步升级路径提示(如提示用户需要 1.5 → 1.8 → 2.0
- 支持 A/B 测试和灰度发布
- 支持配置文件的本地缓存和过期策略

View File

@@ -97,7 +97,6 @@ mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
artifactName: ${productName}-${version}-${arch}.${ext}
minimumSystemVersion: "20.1.0" # 最低支持 macOS 11.0
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.0-beta.3",
"version": "1.6.5",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -58,6 +58,7 @@
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"update:languages": "tsx scripts/update-languages.ts",
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
"test": "vitest run --silent",
"test:main": "vitest run --project main",
"test:renderer": "vitest run --project renderer",
@@ -260,11 +261,11 @@
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"electron": "38.7.0",
"electron-builder": "26.0.15",
"electron-builder": "26.1.0",
"electron-devtools-installer": "^3.2.0",
"electron-reload": "^2.0.0-alpha.1",
"electron-store": "^8.2.0",
"electron-updater": "6.6.4",
"electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
"electron-vite": "4.0.1",
"electron-window-state": "^5.0.3",
"emittery": "^1.0.3",
@@ -381,8 +382,6 @@
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"esbuild": "^0.25.0",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",

View File

@@ -197,12 +197,22 @@ export enum FeedUrl {
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
}
export enum UpdateConfigUrl {
GITHUB = 'https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/cs-releases/app-upgrade-config.json',
GITCODE = 'https://raw.gitcode.com/CherryHQ/cherry-studio/raw/cs-releases/app-upgrade-config.json'
}
export enum UpgradeChannel {
LATEST = 'latest', // 最新稳定版本
RC = 'rc', // 公测版本
BETA = 'beta' // 预览版本
}
export enum UpdateMirror {
GITHUB = 'github',
GITCODE = 'gitcode'
}
export const defaultTimeout = 10 * 1000 * 60
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']

View File

@@ -0,0 +1,532 @@
import fs from 'fs/promises'
import path from 'path'
import semver from 'semver'
type UpgradeChannel = 'latest' | 'rc' | 'beta'
type UpdateMirror = 'github' | 'gitcode'
const CHANNELS: UpgradeChannel[] = ['latest', 'rc', 'beta']
const MIRRORS: UpdateMirror[] = ['github', 'gitcode']
const GITHUB_REPO = 'CherryHQ/cherry-studio'
const GITCODE_REPO = 'CherryHQ/cherry-studio'
const DEFAULT_FEED_TEMPLATES: Record<UpdateMirror, string> = {
github: `https://github.com/${GITHUB_REPO}/releases/download/{{tag}}`,
gitcode: `https://gitcode.com/${GITCODE_REPO}/releases/download/{{tag}}`
}
const GITCODE_LATEST_FALLBACK = 'https://releases.cherry-ai.com'
interface CliOptions {
tag?: string
configPath?: string
segmentsPath?: string
dryRun?: boolean
skipReleaseChecks?: boolean
isPrerelease?: boolean
}
interface ChannelTemplateConfig {
feedTemplates?: Partial<Record<UpdateMirror, string>>
}
interface SegmentMatchRule {
range?: string
exact?: string[]
excludeExact?: string[]
}
interface SegmentDefinition {
id: string
type: 'legacy' | 'breaking' | 'latest'
match: SegmentMatchRule
lockedVersion?: string
minCompatibleVersion: string
description: string
channelTemplates?: Partial<Record<UpgradeChannel, ChannelTemplateConfig>>
}
interface SegmentMetadataFile {
segments: SegmentDefinition[]
}
interface ChannelConfig {
version: string
feedUrls: Record<UpdateMirror, string>
}
interface VersionMetadata {
segmentId: string
segmentType?: string
}
interface VersionEntry {
metadata?: VersionMetadata
minCompatibleVersion: string
description: string
channels: Record<UpgradeChannel, ChannelConfig | null>
}
interface UpgradeConfigFile {
lastUpdated: string
versions: Record<string, VersionEntry>
}
interface ReleaseInfo {
tag: string
version: string
channel: UpgradeChannel
}
interface UpdateVersionsResult {
versions: Record<string, VersionEntry>
updated: boolean
}
const ROOT_DIR = path.resolve(__dirname, '..')
const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'app-upgrade-config.json')
const DEFAULT_SEGMENTS_PATH = path.join(ROOT_DIR, 'config/app-upgrade-segments.json')
async function main() {
const options = parseArgs()
const releaseTag = resolveTag(options)
const normalizedVersion = normalizeVersion(releaseTag)
const releaseChannel = detectChannel(normalizedVersion)
if (!releaseChannel) {
console.warn(`[update-app-upgrade-config] Tag ${normalizedVersion} does not map to beta/rc/latest. Skipping.`)
return
}
// Validate version format matches prerelease status
if (options.isPrerelease !== undefined) {
const hasPrereleaseSuffix = releaseChannel === 'beta' || releaseChannel === 'rc'
if (options.isPrerelease && !hasPrereleaseSuffix) {
console.warn(
`[update-app-upgrade-config] ⚠️ Release marked as prerelease but version ${normalizedVersion} has no beta/rc suffix. Skipping.`
)
return
}
if (!options.isPrerelease && hasPrereleaseSuffix) {
console.warn(
`[update-app-upgrade-config] ⚠️ Release marked as latest but version ${normalizedVersion} has prerelease suffix (${releaseChannel}). Skipping.`
)
return
}
}
const [config, segmentFile] = await Promise.all([
readJson<UpgradeConfigFile>(options.configPath ?? DEFAULT_CONFIG_PATH),
readJson<SegmentMetadataFile>(options.segmentsPath ?? DEFAULT_SEGMENTS_PATH)
])
const segment = pickSegment(segmentFile.segments, normalizedVersion)
if (!segment) {
throw new Error(`Unable to find upgrade segment for version ${normalizedVersion}`)
}
if (segment.lockedVersion && segment.lockedVersion !== normalizedVersion) {
throw new Error(`Segment ${segment.id} is locked to ${segment.lockedVersion}, but received ${normalizedVersion}`)
}
const releaseInfo: ReleaseInfo = {
tag: formatTag(releaseTag),
version: normalizedVersion,
channel: releaseChannel
}
const { versions: updatedVersions, updated } = await updateVersions(
config.versions,
segment,
releaseInfo,
Boolean(options.skipReleaseChecks)
)
if (!updated) {
throw new Error(
`[update-app-upgrade-config] Feed URLs are not ready for ${releaseInfo.version} (${releaseInfo.channel}). Try again after the release mirrors finish syncing.`
)
}
const updatedConfig: UpgradeConfigFile = {
...config,
lastUpdated: new Date().toISOString(),
versions: updatedVersions
}
const output = JSON.stringify(updatedConfig, null, 2) + '\n'
if (options.dryRun) {
console.log('Dry run enabled. Generated configuration:\n')
console.log(output)
return
}
await fs.writeFile(options.configPath ?? DEFAULT_CONFIG_PATH, output, 'utf-8')
console.log(
`✅ Updated ${path.relative(process.cwd(), options.configPath ?? DEFAULT_CONFIG_PATH)} for ${segment.id} (${releaseInfo.channel}) -> ${releaseInfo.version}`
)
}
function parseArgs(): CliOptions {
const args = process.argv.slice(2)
const options: CliOptions = {}
for (let i = 0; i < args.length; i += 1) {
const arg = args[i]
if (arg === '--tag') {
options.tag = args[i + 1]
i += 1
} else if (arg === '--config') {
options.configPath = args[i + 1]
i += 1
} else if (arg === '--segments') {
options.segmentsPath = args[i + 1]
i += 1
} else if (arg === '--dry-run') {
options.dryRun = true
} else if (arg === '--skip-release-checks') {
options.skipReleaseChecks = true
} else if (arg === '--is-prerelease') {
options.isPrerelease = args[i + 1] === 'true'
i += 1
} else if (arg === '--help') {
printHelp()
process.exit(0)
} else {
console.warn(`Ignoring unknown argument "${arg}"`)
}
}
if (options.skipReleaseChecks && !options.dryRun) {
throw new Error('--skip-release-checks can only be used together with --dry-run')
}
return options
}
function printHelp() {
console.log(`Usage: tsx scripts/update-app-upgrade-config.ts [options]
Options:
--tag <tag> Release tag (e.g. v2.1.6). Falls back to GITHUB_REF_NAME/RELEASE_TAG.
--config <path> Path to app-upgrade-config.json.
--segments <path> Path to app-upgrade-segments.json.
--is-prerelease <true|false> Whether this is a prerelease (validates version format).
--dry-run Print the result without writing to disk.
--skip-release-checks Skip release page availability checks (only valid with --dry-run).
--help Show this help message.`)
}
function resolveTag(options: CliOptions): string {
const envTag = process.env.RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? process.env.TAG_NAME
const tag = options.tag ?? envTag
if (!tag) {
throw new Error('A release tag is required. Pass --tag or set RELEASE_TAG/GITHUB_REF_NAME.')
}
return tag
}
function normalizeVersion(tag: string): string {
const cleaned = semver.clean(tag, { loose: true })
if (!cleaned) {
throw new Error(`Tag "${tag}" is not a valid semantic version`)
}
const valid = semver.valid(cleaned, { loose: true })
if (!valid) {
throw new Error(`Unable to normalize tag "${tag}" to a valid semantic version`)
}
return valid
}
function detectChannel(version: string): UpgradeChannel | null {
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
if (!parsed) {
return null
}
if (parsed.prerelease.length === 0) {
return 'latest'
}
const label = String(parsed.prerelease[0]).toLowerCase()
if (label === 'beta') {
return 'beta'
}
if (label === 'rc') {
return 'rc'
}
return null
}
async function readJson<T>(filePath: string): Promise<T> {
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
const data = await fs.readFile(absolute, 'utf-8')
return JSON.parse(data) as T
}
function pickSegment(segments: SegmentDefinition[], version: string): SegmentDefinition | null {
for (const segment of segments) {
if (matchesSegment(segment.match, version)) {
return segment
}
}
return null
}
function matchesSegment(matchRule: SegmentMatchRule, version: string): boolean {
if (matchRule.exact && matchRule.exact.includes(version)) {
return true
}
if (matchRule.excludeExact && matchRule.excludeExact.includes(version)) {
return false
}
if (matchRule.range && !semver.satisfies(version, matchRule.range, { includePrerelease: true })) {
return false
}
if (matchRule.exact) {
return matchRule.exact.includes(version)
}
return Boolean(matchRule.range)
}
function formatTag(tag: string): string {
if (tag.startsWith('refs/tags/')) {
return tag.replace('refs/tags/', '')
}
return tag
}
async function updateVersions(
versions: Record<string, VersionEntry>,
segment: SegmentDefinition,
releaseInfo: ReleaseInfo,
skipReleaseValidation: boolean
): Promise<UpdateVersionsResult> {
const versionsCopy: Record<string, VersionEntry> = { ...versions }
const existingKey = findVersionKeyBySegment(versionsCopy, segment.id)
const targetKey = resolveVersionKey(existingKey, segment, releaseInfo)
const shouldRename = existingKey && existingKey !== targetKey
let entry: VersionEntry
if (existingKey) {
entry = { ...versionsCopy[existingKey], channels: { ...versionsCopy[existingKey].channels } }
} else {
entry = createEmptyVersionEntry()
}
entry.channels = ensureChannelSlots(entry.channels)
const channelUpdated = await applyChannelUpdate(entry, segment, releaseInfo, skipReleaseValidation)
if (!channelUpdated) {
return { versions, updated: false }
}
if (shouldRename && existingKey) {
delete versionsCopy[existingKey]
}
entry.metadata = {
segmentId: segment.id,
segmentType: segment.type
}
entry.minCompatibleVersion = segment.minCompatibleVersion
entry.description = segment.description
versionsCopy[targetKey] = entry
return {
versions: sortVersionMap(versionsCopy),
updated: true
}
}
function findVersionKeyBySegment(versions: Record<string, VersionEntry>, segmentId: string): string | null {
for (const [key, value] of Object.entries(versions)) {
if (value.metadata?.segmentId === segmentId) {
return key
}
}
return null
}
function resolveVersionKey(existingKey: string | null, segment: SegmentDefinition, releaseInfo: ReleaseInfo): string {
if (segment.lockedVersion) {
return segment.lockedVersion
}
if (releaseInfo.channel === 'latest') {
return releaseInfo.version
}
if (existingKey) {
return existingKey
}
const baseVersion = getBaseVersion(releaseInfo.version)
return baseVersion ?? releaseInfo.version
}
function getBaseVersion(version: string): string | null {
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
if (!parsed) {
return null
}
return `${parsed.major}.${parsed.minor}.${parsed.patch}`
}
function createEmptyVersionEntry(): VersionEntry {
return {
minCompatibleVersion: '',
description: '',
channels: {
latest: null,
rc: null,
beta: null
}
}
}
function ensureChannelSlots(
channels: Record<UpgradeChannel, ChannelConfig | null>
): Record<UpgradeChannel, ChannelConfig | null> {
return CHANNELS.reduce(
(acc, channel) => {
acc[channel] = channels[channel] ?? null
return acc
},
{} as Record<UpgradeChannel, ChannelConfig | null>
)
}
async function applyChannelUpdate(
entry: VersionEntry,
segment: SegmentDefinition,
releaseInfo: ReleaseInfo,
skipReleaseValidation: boolean
): Promise<boolean> {
if (!CHANNELS.includes(releaseInfo.channel)) {
throw new Error(`Unsupported channel "${releaseInfo.channel}"`)
}
const feedUrls = buildFeedUrls(segment, releaseInfo)
if (skipReleaseValidation) {
console.warn(
`[update-app-upgrade-config] Skipping release availability validation for ${releaseInfo.version} (${releaseInfo.channel}).`
)
} else {
const availability = await ensureReleaseAvailability(releaseInfo)
if (!availability.github) {
return false
}
if (releaseInfo.channel === 'latest' && !availability.gitcode) {
console.warn(
`[update-app-upgrade-config] gitcode release page not ready for ${releaseInfo.tag}. Falling back to ${GITCODE_LATEST_FALLBACK}.`
)
feedUrls.gitcode = GITCODE_LATEST_FALLBACK
}
}
entry.channels[releaseInfo.channel] = {
version: releaseInfo.version,
feedUrls
}
return true
}
function buildFeedUrls(segment: SegmentDefinition, releaseInfo: ReleaseInfo): Record<UpdateMirror, string> {
return MIRRORS.reduce(
(acc, mirror) => {
const template = resolveFeedTemplate(segment, releaseInfo, mirror)
acc[mirror] = applyTemplate(template, releaseInfo)
return acc
},
{} as Record<UpdateMirror, string>
)
}
function resolveFeedTemplate(segment: SegmentDefinition, releaseInfo: ReleaseInfo, mirror: UpdateMirror): string {
if (mirror === 'gitcode' && releaseInfo.channel !== 'latest') {
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.github ?? DEFAULT_FEED_TEMPLATES.github
}
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.[mirror] ?? DEFAULT_FEED_TEMPLATES[mirror]
}
function applyTemplate(template: string, releaseInfo: ReleaseInfo): string {
return template.replace(/{{\s*tag\s*}}/gi, releaseInfo.tag).replace(/{{\s*version\s*}}/gi, releaseInfo.version)
}
function sortVersionMap(versions: Record<string, VersionEntry>): Record<string, VersionEntry> {
const sorted = Object.entries(versions).sort(([a], [b]) => semver.rcompare(a, b))
return sorted.reduce(
(acc, [version, entry]) => {
acc[version] = entry
return acc
},
{} as Record<string, VersionEntry>
)
}
interface ReleaseAvailability {
github: boolean
gitcode: boolean
}
async function ensureReleaseAvailability(releaseInfo: ReleaseInfo): Promise<ReleaseAvailability> {
const mirrorsToCheck: UpdateMirror[] = releaseInfo.channel === 'latest' ? MIRRORS : ['github']
const availability: ReleaseAvailability = {
github: false,
gitcode: releaseInfo.channel === 'latest' ? false : true
}
for (const mirror of mirrorsToCheck) {
const url = getReleasePageUrl(mirror, releaseInfo.tag)
try {
const response = await fetch(url, {
method: mirror === 'github' ? 'HEAD' : 'GET',
redirect: 'follow'
})
if (response.ok) {
availability[mirror] = true
} else {
console.warn(
`[update-app-upgrade-config] ${mirror} release not available for ${releaseInfo.tag} (status ${response.status}, ${url}).`
)
availability[mirror] = false
}
} catch (error) {
console.warn(
`[update-app-upgrade-config] Failed to verify ${mirror} release page for ${releaseInfo.tag} (${url}). Continuing.`,
error
)
availability[mirror] = false
}
}
return availability
}
function getReleasePageUrl(mirror: UpdateMirror, tag: string): string {
if (mirror === 'github') {
return `https://github.com/${GITHUB_REPO}/releases/tag/${encodeURIComponent(tag)}`
}
// Use latest.yml download URL for GitCode to check if release exists
// Note: GitCode returns 401 for HEAD requests, so we use GET in ensureReleaseAvailability
return `https://gitcode.com/${GITCODE_REPO}/releases/download/${encodeURIComponent(tag)}/latest.yml`
}
main().catch((error) => {
console.error('❌ Failed to update app-upgrade-config:', error)
process.exit(1)
})

View File

@@ -2,7 +2,7 @@ import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { FeedUrl, UpdateConfigUrl, UpdateMirror, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import type { UpdateInfo } from 'builder-util-runtime'
import { CancellationToken } from 'builder-util-runtime'
@@ -22,7 +22,29 @@ const LANG_MARKERS = {
EN_START: '<!--LANG:en-->',
ZH_CN_START: '<!--LANG:zh-CN-->',
END: '<!--LANG:END-->'
} as const
}
interface UpdateConfig {
lastUpdated: string
versions: {
[versionKey: string]: VersionConfig
}
}
interface VersionConfig {
minCompatibleVersion: string
description: string
channels: {
latest: ChannelConfig | null
rc: ChannelConfig | null
beta: ChannelConfig | null
}
}
interface ChannelConfig {
version: string
feedUrls: Record<UpdateMirror, string>
}
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
@@ -37,7 +59,9 @@ export default class AppUpdater {
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent(),
'X-Client-Id': configManager.getClientId()
'X-Client-Id': configManager.getClientId(),
// no-cache
'Cache-Control': 'no-cache'
}
autoUpdater.on('error', (error) => {
@@ -75,61 +99,6 @@ export default class AppUpdater {
this.autoUpdater = autoUpdater
}
private async _getReleaseVersionFromGithub(channel: UpgradeChannel) {
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Accept-Language': 'en-US,en;q=0.9'
}
try {
logger.info(`get release version from github: ${channel}`)
const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
headers
})
const data = (await responses.json()) as GithubReleaseInfo[]
let mightHaveLatest = false
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
if (!item.draft && !item.prerelease) {
mightHaveLatest = true
}
return item.prerelease && item.tag_name.includes(`-${channel}.`)
})
if (!release) {
return null
}
// if the release version is the same as the current version, return null
if (release.tag_name === app.getVersion()) {
return null
}
if (mightHaveLatest) {
logger.info(`might have latest release, get latest release`)
const latestReleaseResponse = await net.fetch(
'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest',
{
headers
}
)
const latestRelease = (await latestReleaseResponse.json()) as GithubReleaseInfo
if (semver.gt(latestRelease.tag_name, release.tag_name)) {
logger.info(
`latest release version is ${latestRelease.tag_name}, prerelease version is ${release.tag_name}, return null`
)
return null
}
}
logger.info(`release url is ${release.tag_name}, set channel to ${channel}`)
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
} catch (error) {
logger.error('Failed to get latest not draft version from github:', error as Error)
return null
}
}
public setAutoUpdate(isActive: boolean) {
autoUpdater.autoDownload = isActive
autoUpdater.autoInstallOnAppQuit = isActive
@@ -161,6 +130,88 @@ export default class AppUpdater {
return UpgradeChannel.LATEST
}
/**
* Fetch update configuration from GitHub or GitCode based on mirror
* @param mirror - Mirror to fetch config from
* @returns UpdateConfig object or null if fetch fails
*/
private async _fetchUpdateConfig(mirror: UpdateMirror): Promise<UpdateConfig | null> {
const configUrl = mirror === UpdateMirror.GITCODE ? UpdateConfigUrl.GITCODE : UpdateConfigUrl.GITHUB
try {
logger.info(`Fetching update config from ${configUrl} (mirror: ${mirror})`)
const response = await net.fetch(configUrl, {
headers: {
'User-Agent': generateUserAgent(),
Accept: 'application/json',
'X-Client-Id': configManager.getClientId(),
// no-cache
'Cache-Control': 'no-cache'
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const config = (await response.json()) as UpdateConfig
logger.info(`Update config fetched successfully, last updated: ${config.lastUpdated}`)
return config
} catch (error) {
logger.error('Failed to fetch update config:', error as Error)
return null
}
}
/**
* Find compatible channel configuration based on current version
* @param currentVersion - Current app version
* @param requestedChannel - Requested upgrade channel (latest/rc/beta)
* @param config - Update configuration object
* @returns Object containing ChannelConfig and actual channel if found, null otherwise
*/
private _findCompatibleChannel(
currentVersion: string,
requestedChannel: UpgradeChannel,
config: UpdateConfig
): { config: ChannelConfig; channel: UpgradeChannel } | null {
// Get all version keys and sort descending (newest first)
const versionKeys = Object.keys(config.versions).sort(semver.rcompare)
logger.info(
`Finding compatible channel for version ${currentVersion}, requested channel: ${requestedChannel}, available versions: ${versionKeys.join(', ')}`
)
for (const versionKey of versionKeys) {
const versionConfig = config.versions[versionKey]
const channelConfig = versionConfig.channels[requestedChannel]
const latestChannelConfig = versionConfig.channels[UpgradeChannel.LATEST]
// Check version compatibility and channel availability
if (semver.gte(currentVersion, versionConfig.minCompatibleVersion) && channelConfig !== null) {
logger.info(
`Found compatible version: ${versionKey} (minCompatibleVersion: ${versionConfig.minCompatibleVersion}), version: ${channelConfig.version}`
)
if (
requestedChannel !== UpgradeChannel.LATEST &&
latestChannelConfig &&
semver.gte(latestChannelConfig.version, channelConfig.version)
) {
logger.info(
`latest channel version is greater than the requested channel version: ${latestChannelConfig.version} > ${channelConfig.version}, using latest instead`
)
return { config: latestChannelConfig, channel: UpgradeChannel.LATEST }
}
return { config: channelConfig, channel: requestedChannel }
}
}
logger.warn(`No compatible channel found for version ${currentVersion} and channel ${requestedChannel}`)
return null
}
private _setChannel(channel: UpgradeChannel, feedUrl: string) {
this.autoUpdater.channel = channel
this.autoUpdater.setFeedURL(feedUrl)
@@ -172,33 +223,42 @@ export default class AppUpdater {
}
private async _setFeedUrl() {
const currentVersion = app.getVersion()
const testPlan = configManager.getTestPlan()
if (testPlan) {
const channel = this._getTestChannel()
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
if (channel === UpgradeChannel.LATEST) {
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
return
}
const releaseUrl = await this._getReleaseVersionFromGithub(channel)
if (releaseUrl) {
logger.info(`release url is ${releaseUrl}, set channel to ${channel}`)
this._setChannel(channel, releaseUrl)
return
}
// if no prerelease url, use github latest to get release
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
return
}
this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION)
// Determine mirror based on IP country
const ipCountry = await getIpCountry()
logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`)
if (ipCountry.toLowerCase() !== 'cn') {
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
const mirror = ipCountry.toLowerCase() === 'cn' ? UpdateMirror.GITCODE : UpdateMirror.GITHUB
logger.info(
`Setting feed URL for version ${currentVersion}, testPlan: ${testPlan}, requested channel: ${requestedChannel}, mirror: ${mirror} (IP country: ${ipCountry})`
)
// Try to fetch update config from remote
const config = await this._fetchUpdateConfig(mirror)
if (config) {
// Use new config-based system
const result = this._findCompatibleChannel(currentVersion, requestedChannel, config)
if (result) {
const { config: channelConfig, channel: actualChannel } = result
const feedUrl = channelConfig.feedUrls[mirror]
logger.info(
`Using config-based feed URL: ${feedUrl} for channel ${actualChannel} (requested: ${requestedChannel}, mirror: ${mirror})`
)
this._setChannel(actualChannel, feedUrl)
return
}
}
logger.info('Failed to fetch update config, falling back to default feed URL')
// Fallback: use default feed URL based on mirror
const defaultFeedUrl = mirror === UpdateMirror.GITCODE ? FeedUrl.PRODUCTION : FeedUrl.GITHUB_LATEST
logger.info(`Using fallback feed URL: ${defaultFeedUrl}`)
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
}
public cancelDownload() {
@@ -320,8 +380,3 @@ export default class AppUpdater {
return processedInfo
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}

View File

@@ -85,6 +85,9 @@ vi.mock('electron-updater', () => ({
}))
// Import after mocks
import { UpdateMirror } from '@shared/config/constant'
import { app, net } from 'electron'
import AppUpdater from '../AppUpdater'
import { configManager } from '../ConfigManager'
@@ -274,4 +277,711 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('_fetchUpdateConfig', () => {
const mockConfig = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.6.7': {
minCompatibleVersion: '1.0.0',
description: 'Test version',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: null,
beta: null
}
}
}
}
it('should fetch config from GitHub mirror', async () => {
vi.mocked(net.fetch).mockResolvedValue({
ok: true,
json: async () => mockConfig
} as any)
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
expect(result).toEqual(mockConfig)
expect(net.fetch).toHaveBeenCalledWith(expect.stringContaining('github'), expect.any(Object))
})
it('should fetch config from GitCode mirror', async () => {
vi.mocked(net.fetch).mockResolvedValue({
ok: true,
json: async () => mockConfig
} as any)
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITCODE)
expect(result).toEqual(mockConfig)
// GitCode URL may vary, just check that fetch was called
expect(net.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object))
})
it('should return null on HTTP error', async () => {
vi.mocked(net.fetch).mockResolvedValue({
ok: false,
status: 404
} as any)
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
expect(result).toBeNull()
})
it('should return null on network error', async () => {
vi.mocked(net.fetch).mockRejectedValue(new Error('Network error'))
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
expect(result).toBeNull()
})
})
describe('_findCompatibleChannel', () => {
const mockConfig = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.6.7': {
minCompatibleVersion: '1.0.0',
description: 'v1.6.7',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: {
version: '1.7.0-beta.3',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.3',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
}
}
}
},
'2.0.0': {
minCompatibleVersion: '1.7.0',
description: 'v2.0.0',
channels: {
latest: null,
rc: null,
beta: null
}
}
}
}
it('should find compatible latest channel', () => {
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'latest', mockConfig)
expect(result?.config).toEqual({
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
})
expect(result?.channel).toBe('latest')
})
it('should find compatible rc channel', () => {
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'rc', mockConfig)
expect(result?.config).toEqual({
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
})
expect(result?.channel).toBe('rc')
})
it('should find compatible beta channel', () => {
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'beta', mockConfig)
expect(result?.config).toEqual({
version: '1.7.0-beta.3',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.3',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
}
})
expect(result?.channel).toBe('beta')
})
it('should return latest when latest version >= rc version', () => {
const configWithNewerLatest = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'rc', configWithNewerLatest)
// Should return latest instead of rc because 1.7.0 >= 1.7.0-rc.1
expect(result?.config).toEqual({
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
})
expect(result?.channel).toBe('latest') // ✅ 返回 latest 频道
})
it('should return latest when latest version >= beta version', () => {
const configWithNewerLatest = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
},
rc: null,
beta: {
version: '1.6.8-beta.1',
feedUrls: {
github: 'https://github.com/test/v1.6.8-beta.1',
gitcode: 'https://gitcode.com/test/v1.6.8-beta.1'
}
}
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'beta', configWithNewerLatest)
// Should return latest instead of beta because 1.7.0 >= 1.6.8-beta.1
expect(result?.config).toEqual({
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
})
})
it('should not compare latest with itself when requesting latest channel', () => {
const config = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'latest', config)
// Should return latest directly without comparing with itself
expect(result?.config).toEqual({
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
})
})
it('should return rc when rc version > latest version', () => {
const configWithNewerRc = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'rc', configWithNewerRc)
// Should return rc because 1.7.0-rc.1 > 1.6.7
expect(result?.config).toEqual({
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
})
})
it('should return beta when beta version > latest version', () => {
const configWithNewerBeta = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: null,
beta: {
version: '1.7.0-beta.5',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.5',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.5'
}
}
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'beta', configWithNewerBeta)
// Should return beta because 1.7.0-beta.5 > 1.6.7
expect(result?.config).toEqual({
version: '1.7.0-beta.5',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.5',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.5'
}
})
})
it('should return lower version when higher version has no compatible channel', () => {
vi.mocked(app.getVersion).mockReturnValue('1.8.0')
const result = (appUpdater as any)._findCompatibleChannel('1.8.0', 'latest', mockConfig)
// 1.8.0 >= 1.7.0 but 2.0.0 has no latest channel, so return 1.6.7
expect(result?.config).toEqual({
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
})
})
it('should return null when current version does not meet minCompatibleVersion', () => {
vi.mocked(app.getVersion).mockReturnValue('0.9.0')
const result = (appUpdater as any)._findCompatibleChannel('0.9.0', 'latest', mockConfig)
// 0.9.0 < 1.0.0 (minCompatibleVersion)
expect(result).toBeNull()
})
it('should return lower version rc when higher version has no rc channel', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.8.0', 'rc', mockConfig)
// 1.8.0 >= 1.7.0 but 2.0.0 has no rc channel, so return 1.6.7 rc
expect(result?.config).toEqual({
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
})
})
it('should return null when no version has the requested channel', () => {
const configWithoutRc = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.6.7': {
minCompatibleVersion: '1.0.0',
description: 'v1.6.7',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: null,
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'rc', configWithoutRc)
expect(result).toBeNull()
})
})
describe('Upgrade Path', () => {
const fullConfig = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.6.7': {
minCompatibleVersion: '1.0.0',
description: 'Last v1.x',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: {
version: '1.7.0-beta.3',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.3',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
}
}
}
},
'2.0.0': {
minCompatibleVersion: '1.7.0',
description: 'First v2.x',
channels: {
latest: null,
rc: null,
beta: null
}
}
}
}
it('should upgrade from 1.6.3 to 1.6.7', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullConfig)
expect(result?.config).toEqual({
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
})
})
it('should block upgrade from 1.6.7 to 2.0.0 (minCompatibleVersion not met)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.6.7', 'latest', fullConfig)
// Should return 1.6.7, not 2.0.0, because 1.6.7 < 1.7.0 (minCompatibleVersion of 2.0.0)
expect(result?.config).toEqual({
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
})
})
it('should allow upgrade from 1.7.0 to 2.0.0', () => {
const configWith2x = {
...fullConfig,
versions: {
...fullConfig.versions,
'2.0.0': {
minCompatibleVersion: '1.7.0',
description: 'First v2.x',
channels: {
latest: {
version: '2.0.0',
feedUrls: {
github: 'https://github.com/test/v2.0.0',
gitcode: 'https://gitcode.com/test/v2.0.0'
}
},
rc: null,
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.7.0', 'latest', configWith2x)
expect(result?.config).toEqual({
version: '2.0.0',
feedUrls: {
github: 'https://github.com/test/v2.0.0',
gitcode: 'https://gitcode.com/test/v2.0.0'
}
})
})
})
describe('Complete Multi-Step Upgrade Path', () => {
const fullUpgradeConfig = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.5': {
minCompatibleVersion: '1.0.0',
description: 'Last v1.x stable',
channels: {
latest: {
version: '1.7.5',
feedUrls: {
github: 'https://github.com/test/v1.7.5',
gitcode: 'https://gitcode.com/test/v1.7.5'
}
},
rc: null,
beta: null
}
},
'2.0.0': {
minCompatibleVersion: '1.7.0',
description: 'First v2.x - intermediate version',
channels: {
latest: {
version: '2.0.0',
feedUrls: {
github: 'https://github.com/test/v2.0.0',
gitcode: 'https://gitcode.com/test/v2.0.0'
}
},
rc: null,
beta: null
}
},
'2.1.6': {
minCompatibleVersion: '2.0.0',
description: 'Current v2.x stable',
channels: {
latest: {
version: '2.1.6',
feedUrls: {
github: 'https://github.com/test/latest',
gitcode: 'https://gitcode.com/test/latest'
}
},
rc: null,
beta: null
}
}
}
}
it('should upgrade from 1.6.3 to 1.7.5 (step 1)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullUpgradeConfig)
expect(result?.config).toEqual({
version: '1.7.5',
feedUrls: {
github: 'https://github.com/test/v1.7.5',
gitcode: 'https://gitcode.com/test/v1.7.5'
}
})
})
it('should upgrade from 1.7.5 to 2.0.0 (step 2)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.7.5', 'latest', fullUpgradeConfig)
expect(result?.config).toEqual({
version: '2.0.0',
feedUrls: {
github: 'https://github.com/test/v2.0.0',
gitcode: 'https://gitcode.com/test/v2.0.0'
}
})
})
it('should upgrade from 2.0.0 to 2.1.6 (step 3)', () => {
const result = (appUpdater as any)._findCompatibleChannel('2.0.0', 'latest', fullUpgradeConfig)
expect(result?.config).toEqual({
version: '2.1.6',
feedUrls: {
github: 'https://github.com/test/latest',
gitcode: 'https://gitcode.com/test/latest'
}
})
})
it('should complete full upgrade path: 1.6.3 -> 1.7.5 -> 2.0.0 -> 2.1.6', () => {
// Step 1: 1.6.3 -> 1.7.5
let currentVersion = '1.6.3'
let result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
expect(result?.config.version).toBe('1.7.5')
// Step 2: 1.7.5 -> 2.0.0
currentVersion = result?.config.version!
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
expect(result?.config.version).toBe('2.0.0')
// Step 3: 2.0.0 -> 2.1.6
currentVersion = result?.config.version!
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
expect(result?.config.version).toBe('2.1.6')
// Final: 2.1.6 is the latest, no more upgrades
currentVersion = result?.config.version!
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
expect(result?.config.version).toBe('2.1.6')
})
it('should block direct upgrade from 1.6.3 to 2.0.0 (skip intermediate)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullUpgradeConfig)
// Should return 1.7.5, not 2.0.0, because 1.6.3 < 1.7.0 (minCompatibleVersion of 2.0.0)
expect(result?.config.version).toBe('1.7.5')
expect(result?.config.version).not.toBe('2.0.0')
})
it('should block direct upgrade from 1.7.5 to 2.1.6 (skip intermediate)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.7.5', 'latest', fullUpgradeConfig)
// Should return 2.0.0, not 2.1.6, because 1.7.5 < 2.0.0 (minCompatibleVersion of 2.1.6)
expect(result?.config.version).toBe('2.0.0')
expect(result?.config.version).not.toBe('2.1.6')
})
})
})

632
yarn.lock

File diff suppressed because it is too large Load Diff