Compare commits
12 Commits
copilot/fi
...
v1.7.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c020f0d56 | ||
|
|
e033eb5b5c | ||
|
|
073d43c7cb | ||
|
|
fa7646e18f | ||
|
|
038d30831c | ||
|
|
68ee5164f0 | ||
|
|
a1a3b9bd96 | ||
|
|
4e699c48bc | ||
|
|
75fcf8fbb5 | ||
|
|
35aa9d7355 | ||
|
|
b08aecb22b | ||
|
|
45fc6c2afd |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -3,3 +3,4 @@
|
||||
/src/main/services/ConfigManager.ts @0xfullex
|
||||
/packages/shared/IpcChannel.ts @0xfullex
|
||||
/src/main/ipc.ts @0xfullex
|
||||
/app-upgrade-config.json @kangfenmao
|
||||
|
||||
212
.github/workflows/update-app-upgrade-config.yml
vendored
Normal file
212
.github/workflows/update-app-upgrade-config.yml
vendored
Normal 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 x-files/app-upgrade-config branch
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: x-files/app-upgrade-config
|
||||
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: x-files/app-upgrade-config
|
||||
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 x-files/app-upgrade-config/app-upgrade-config.json"
|
||||
@@ -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": [
|
||||
{
|
||||
14
.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch
vendored
Normal file
14
.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch
vendored
Normal 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
49
app-upgrade-config.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
config/app-upgrade-segments.json
Normal file
81
config/app-upgrade-segments.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
430
docs/technical/app-upgrade-config-en.md
Normal file
430
docs/technical/app-upgrade-config-en.md
Normal 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 `x-files/app-upgrade-config/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 `x-files/app-upgrade-config`.
|
||||
|
||||
### 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 `x-files/app-upgrade-config` 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 `x-files/app-upgrade-config` 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 `x-files/app-upgrade-config` 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/x-files/app-upgrade-config/app-upgrade-config.json`
|
||||
- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json`
|
||||
|
||||
**Note**: Both mirrors provide the same configuration file hosted on the `x-files/app-upgrade-config` 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 `x-files/app-upgrade-config` 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 `x-files/app-upgrade-config` working tree.
|
||||
3. If the file changed, it opens a PR against `x-files/app-upgrade-config` 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 aren’t 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
|
||||
430
docs/technical/app-upgrade-config-zh.md
Normal file
430
docs/technical/app-upgrade-config-zh.md
Normal 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
|
||||
|
||||
## 自动化工作流
|
||||
|
||||
`x-files/app-upgrade-config/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 更新 `x-files/app-upgrade-config` 分支上的配置文件。
|
||||
|
||||
### 触发条件
|
||||
|
||||
- **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/`,长期维护的 `x-files/app-upgrade-config` 分支则在 `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>`,并向 `x-files/app-upgrade-config` 提 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. 启动运行并等待完成,随后到 `x-files/app-upgrade-config` 分支的 PR 查看 `app-upgrade-config.json` 的变更并在验证后合并。
|
||||
|
||||
## JSON 配置文件格式
|
||||
|
||||
### 文件位置
|
||||
|
||||
- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json`
|
||||
- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json`
|
||||
|
||||
**说明**:两个镜像源提供相同的配置文件,统一托管在 `x-files/app-upgrade-config` 分支上。客户端根据 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 仓库默认分支(用于脚本)和 `x-files/app-upgrade-config` 分支(真实托管配置的分支)。
|
||||
2. 在默认分支目录执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json`,直接重写 `x-files/app-upgrade-config` 分支里的配置文件。
|
||||
3. 如果 `app-upgrade-config.json` 有变化,则通过 `peter-evans/create-pull-request` 自动创建一个指向 `x-files/app-upgrade-config` 的 PR,Diff 仅包含该文件。
|
||||
|
||||
如需本地调试,可执行 `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 测试和灰度发布
|
||||
- 支持配置文件的本地缓存和过期策略
|
||||
@@ -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.
|
||||
@@ -135,42 +134,58 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
<!--LANG:en-->
|
||||
What's New in v1.7.0-beta.6
|
||||
What's New in v1.7.0-rc.1
|
||||
|
||||
New Features:
|
||||
- Enhanced Input Bar: Completely redesigned input bar with improved responsiveness and functionality
|
||||
- Better File Handling: Improved drag-and-drop and paste support for images and documents
|
||||
- Smart Tool Suggestions: Enhanced quick panel with better item selection and keyboard shortcuts
|
||||
🎉 MAJOR NEW FEATURE: AI Agents
|
||||
- Create and manage custom AI agents with specialized tools and permissions
|
||||
- Dedicated agent sessions with persistent SQLite storage, separate from regular chats
|
||||
- Real-time tool approval system - review and approve agent actions dynamically
|
||||
- MCP (Model Context Protocol) integration for connecting external tools
|
||||
- Slash commands support for quick agent interactions
|
||||
- OpenAI-compatible REST API for agent access
|
||||
|
||||
Improvements:
|
||||
- Smoother Input Experience: Better auto-resizing and text handling in chat input
|
||||
- Enhanced AI Performance: Improved connection stability and response speed
|
||||
- More Reliable File Uploads: Better support for various file types and upload scenarios
|
||||
- Cleaner Interface: Optimized UI elements for better visual consistency
|
||||
✨ New Features:
|
||||
- AI Providers: Added support for Hugging Face, Mistral, Perplexity, and SophNet
|
||||
- Knowledge Base: OpenMinerU document preprocessor, full-text search in notes, enhanced tool selection
|
||||
- Image & OCR: Intel OVMS painting provider and Intel OpenVINO (NPU) OCR support
|
||||
- MCP Management: Redesigned interface with dual-column layout for easier management
|
||||
- Languages: Added German language support
|
||||
|
||||
Bug Fixes:
|
||||
- Fixed image selection issue when adding custom AI providers
|
||||
- Fixed file upload problems with certain API configurations
|
||||
- Fixed input bar responsiveness issues
|
||||
- Fixed quick panel not working properly in some situations
|
||||
⚡ Improvements:
|
||||
- Upgraded to Electron 38.7.0
|
||||
- Enhanced system shutdown handling and automatic update checks
|
||||
- Improved proxy bypass rules
|
||||
|
||||
🐛 Important Bug Fixes:
|
||||
- Fixed streaming response issues across multiple AI providers
|
||||
- Fixed session list scrolling problems
|
||||
- Fixed knowledge base deletion errors
|
||||
|
||||
<!--LANG:zh-CN-->
|
||||
v1.7.0-beta.6 新特性
|
||||
v1.7.0-rc.1 新特性
|
||||
|
||||
新功能:
|
||||
- 增强输入栏:完全重新设计的输入栏,响应更灵敏,功能更强大
|
||||
- 更好的文件处理:改进的拖拽和粘贴功能,支持图片和文档
|
||||
- 智能工具建议:增强的快速面板,更好的项目选择和键盘快捷键
|
||||
🎉 重大更新:AI Agent 智能体系统
|
||||
- 创建和管理专属 AI Agent,配置专用工具和权限
|
||||
- 独立的 Agent 会话,使用 SQLite 持久化存储,与普通聊天分离
|
||||
- 实时工具审批系统 - 动态审查和批准 Agent 操作
|
||||
- MCP(模型上下文协议)集成,连接外部工具
|
||||
- 支持斜杠命令快速交互
|
||||
- 兼容 OpenAI 的 REST API 访问
|
||||
|
||||
改进:
|
||||
- 更流畅的输入体验:聊天输入框的自动调整和文本处理更佳
|
||||
- 增强 AI 性能:改进连接稳定性和响应速度
|
||||
- 更可靠的文件上传:更好地支持各种文件类型和上传场景
|
||||
- 更简洁的界面:优化 UI 元素,视觉一致性更好
|
||||
✨ 新功能:
|
||||
- AI 提供商:新增 Hugging Face、Mistral、Perplexity 和 SophNet 支持
|
||||
- 知识库:OpenMinerU 文档预处理器、笔记全文搜索、增强的工具选择
|
||||
- 图像与 OCR:Intel OVMS 绘图提供商和 Intel OpenVINO (NPU) OCR 支持
|
||||
- MCP 管理:重构管理界面,采用双列布局,更加方便管理
|
||||
- 语言:新增德语支持
|
||||
|
||||
问题修复:
|
||||
- 修复添加自定义 AI 提供商时的图片选择问题
|
||||
- 修复某些 API 配置下的文件上传问题
|
||||
- 修复输入栏响应性问题
|
||||
- 修复快速面板在某些情况下无法正常工作的问题
|
||||
⚡ 改进:
|
||||
- 升级到 Electron 38.7.0
|
||||
- 增强的系统关机处理和自动更新检查
|
||||
- 改进的代理绕过规则
|
||||
|
||||
🐛 重要修复:
|
||||
- 修复多个 AI 提供商的流式响应问题
|
||||
- 修复会话列表滚动问题
|
||||
- 修复知识库删除错误
|
||||
<!--LANG:END-->
|
||||
|
||||
13
package.json
13
package.json
@@ -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",
|
||||
@@ -259,12 +260,12 @@
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"electron": "38.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron": "38.7.0",
|
||||
"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,13 +382,11 @@
|
||||
"@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",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"node-abi": "4.12.0",
|
||||
"node-abi": "4.24.0",
|
||||
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.5.0",
|
||||
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.5.0",
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
|
||||
@@ -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/x-files/app-upgrade-config/app-upgrade-config.json',
|
||||
GITCODE = 'https://raw.gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/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']
|
||||
|
||||
532
scripts/update-app-upgrade-config.ts
Normal file
532
scripts/update-app-upgrade-config.ts
Normal 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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -375,13 +375,16 @@ export class WindowService {
|
||||
|
||||
mainWindow.hide()
|
||||
|
||||
// TODO: don't hide dock icon when close to tray
|
||||
// will cause the cmd+h behavior not working
|
||||
// after the electron fix the bug, we can restore this code
|
||||
// //for mac users, should hide dock icon if close to tray
|
||||
// if (isMac && isTrayOnClose) {
|
||||
// app.dock?.hide()
|
||||
// }
|
||||
//for mac users, should hide dock icon if close to tray
|
||||
if (isMac && isTrayOnClose) {
|
||||
app.dock?.hide()
|
||||
|
||||
mainWindow.once('show', () => {
|
||||
//restore the window can hide by cmd+h when the window is shown again
|
||||
// https://github.com/electron/electron/pull/47970
|
||||
app.dock?.show()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiHost: this.formatApiHost() }
|
||||
if (this.provider.apiVersion === 'preview') {
|
||||
if (this.provider.apiVersion === 'preview' || this.provider.apiVersion === 'v1') {
|
||||
return this
|
||||
} else {
|
||||
return this.client
|
||||
|
||||
@@ -189,9 +189,11 @@ export function providerToAiSdkConfig(
|
||||
}
|
||||
}
|
||||
// azure
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#responses-api
|
||||
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
|
||||
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
||||
if (actualProvider.apiVersion === 'preview') {
|
||||
// extraOptions.apiVersion = actualProvider.apiVersion === 'preview' ? 'v1' : actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
||||
if (actualProvider.apiVersion === 'preview' || actualProvider.apiVersion === 'v1') {
|
||||
extraOptions.mode = 'responses'
|
||||
} else {
|
||||
extraOptions.mode = 'chat'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||
import { scrollElementIntoView } from '@renderer/utils'
|
||||
import { Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
||||
@@ -181,17 +182,14 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
// 3. 将当前项滚动到视图中
|
||||
// 获取第一个文本节点的父元素来进行滚动
|
||||
const parentElement = currentMatchRange.startContainer.parentElement
|
||||
if (shouldScroll) {
|
||||
parentElement?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
})
|
||||
if (shouldScroll && parentElement) {
|
||||
// 优先在指定的滚动容器内滚动,避免滚动整个页面导致索引错乱/看起来"跳到第一条"
|
||||
scrollElementIntoView(parentElement, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[allRanges, currentIndex]
|
||||
[allRanges, currentIndex, target]
|
||||
)
|
||||
|
||||
const search = useCallback(
|
||||
|
||||
@@ -81,6 +81,16 @@ export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions
|
||||
* Hide the scrollbar automatically when scrolling is stopped
|
||||
*/
|
||||
autoHideScrollbar?: boolean
|
||||
|
||||
/**
|
||||
* Header content to display above the list
|
||||
*/
|
||||
header?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Additional CSS class name for the container
|
||||
*/
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
@@ -95,6 +105,8 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
itemContainerStyle,
|
||||
scrollerStyle,
|
||||
autoHideScrollbar = false,
|
||||
header,
|
||||
className,
|
||||
...restOptions
|
||||
} = props
|
||||
|
||||
@@ -189,7 +201,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
return (
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="dynamic-virtual-list"
|
||||
className={className ? `dynamic-virtual-list ${className}` : 'dynamic-virtual-list'}
|
||||
role="region"
|
||||
aria-label="Dynamic Virtual List"
|
||||
aria-hidden={!showScrollbar}
|
||||
@@ -200,6 +212,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
|
||||
...scrollerStyle
|
||||
}}>
|
||||
{header}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
|
||||
@@ -1003,6 +1003,18 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
provider: 'minimax',
|
||||
name: 'minimax-01',
|
||||
group: 'minimax-01'
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2',
|
||||
provider: 'minimax',
|
||||
name: 'MiniMax M2',
|
||||
group: 'minimax-m2'
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2-Stable',
|
||||
provider: 'minimax',
|
||||
name: 'MiniMax M2 Stable',
|
||||
group: 'minimax-m2'
|
||||
}
|
||||
],
|
||||
hyperbolic: [
|
||||
|
||||
@@ -472,7 +472,8 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
name: 'MiniMax',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.minimax.com/v1/',
|
||||
apiHost: 'https://api.minimaxi.com/v1',
|
||||
anthropicApiHost: 'https://api.minimaxi.com/anthropic',
|
||||
models: SYSTEM_MODELS.minimax,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
@@ -1072,7 +1073,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
},
|
||||
minimax: {
|
||||
api: {
|
||||
url: 'https://api.minimax.com/v1/'
|
||||
url: 'https://api.minimaxi.com/v1/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://platform.minimaxi.com/',
|
||||
|
||||
@@ -4343,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the preview version"
|
||||
"tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the v1 version"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
|
||||
@@ -4343,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 preview 版本"
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 v1 版本"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
|
||||
@@ -4343,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,請輸入 preview 版本"
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,請輸入 v1 版本"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
|
||||
@@ -4343,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Η έκδοση του API για Azure OpenAI. Αν θέλετε να χρησιμοποιήσετε το Response API, εισάγετε μια προεπισκόπηση έκδοσης"
|
||||
"tip": "Η έκδοση του API για Azure OpenAI. Αν θέλετε να χρησιμοποιήσετε το Response API, εισάγετε μια v1 έκδοσης"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
|
||||
@@ -4343,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Version de l'API Azure OpenAI, veuillez saisir une version preview si vous souhaitez utiliser l'API de réponse"
|
||||
"tip": "Version de l'API Azure OpenAI, veuillez saisir une version v1 si vous souhaitez utiliser l'API de réponse"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
|
||||
@@ -4343,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Azure OpenAIのAPIバージョン。Response APIを使用する場合は、previewバージョンを入力してください"
|
||||
"tip": "Azure OpenAIのAPIバージョン。Response APIを使用する場合は、v1バージョンを入力してください"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
|
||||
@@ -4343,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Versão da API do Azure OpenAI. Se desejar usar a API de Resposta, insira a versão de visualização"
|
||||
"tip": "Versão da API do Azure OpenAI. Se desejar usar a API de Resposta, insira a versão de v1"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
|
||||
@@ -4343,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Версия API Azure OpenAI. Если вы хотите использовать Response API, введите версию preview"
|
||||
"tip": "Версия API Azure OpenAI. Если вы хотите использовать Response API, введите версию v1"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
|
||||
@@ -11,7 +11,8 @@ import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Popover } from 'antd'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useChatMaxWidth } from '../Chat'
|
||||
@@ -43,9 +44,6 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(messageLength - 1)
|
||||
|
||||
// Refs
|
||||
const prevMessageLengthRef = useRef(messageLength)
|
||||
|
||||
// 对于单模型消息,采用简单的样式,避免 overflow 影响内部的 sticky 效果
|
||||
const multiModelMessageStyle = useMemo(
|
||||
() => (messageLength < 2 ? 'fold' : _multiModelMessageStyle),
|
||||
@@ -83,24 +81,6 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
},
|
||||
[editMessage, selectedMessageId, setTimeoutTimer]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (messageLength > prevMessageLengthRef.current) {
|
||||
setSelectedIndex(messageLength - 1)
|
||||
const lastMessage = messages[messageLength - 1]
|
||||
if (lastMessage) {
|
||||
setSelectedMessage(lastMessage)
|
||||
}
|
||||
} else {
|
||||
const newIndex = messages.findIndex((msg) => msg.id === selectedMessageId)
|
||||
if (newIndex !== -1) {
|
||||
setSelectedIndex(newIndex)
|
||||
}
|
||||
}
|
||||
prevMessageLengthRef.current = messageLength
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messageLength])
|
||||
|
||||
// 添加对流程图节点点击事件的监听
|
||||
useEffect(() => {
|
||||
// 只在组件挂载和消息数组变化时添加监听器
|
||||
@@ -223,7 +203,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
message,
|
||||
topic,
|
||||
index: message.index
|
||||
}
|
||||
} satisfies ComponentProps<typeof MessageItem>
|
||||
|
||||
const messageContent = (
|
||||
<MessageWrapper
|
||||
@@ -277,7 +257,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
isGrouped,
|
||||
topic,
|
||||
multiModelMessageStyle,
|
||||
messages.length,
|
||||
messages,
|
||||
selectedMessageId,
|
||||
onUpdateUseful,
|
||||
groupContextMessageId,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
@@ -99,38 +98,36 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="sessions-tab">
|
||||
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
|
||||
{t('agent.session.add.title')}
|
||||
</AddButton>
|
||||
{/* h-9 */}
|
||||
<DynamicVirtualList
|
||||
list={sessions}
|
||||
estimateSize={() => 9 * 4}
|
||||
scrollerStyle={{
|
||||
// FIXME: This component only supports CSSProperties
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
autoHideScrollbar>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
agentId={agentId}
|
||||
onDelete={() => handleDeleteSession(session.id)}
|
||||
onPress={() => setActiveSessionId(agentId, session.id)}
|
||||
/>
|
||||
)}
|
||||
</DynamicVirtualList>
|
||||
</Container>
|
||||
<StyledVirtualList
|
||||
className="sessions-tab"
|
||||
list={sessions}
|
||||
estimateSize={() => 9 * 4}
|
||||
// FIXME: This component only supports CSSProperties
|
||||
scrollerStyle={{ overflowX: 'hidden' }}
|
||||
autoHideScrollbar
|
||||
header={
|
||||
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
|
||||
{t('agent.session.add.title')}
|
||||
</AddButton>
|
||||
}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
agentId={agentId}
|
||||
onDelete={() => handleDeleteSession(session.id)}
|
||||
onPress={() => setActiveSessionId(agentId, session.id)}
|
||||
/>
|
||||
)}
|
||||
</StyledVirtualList>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(Scrollbar)`
|
||||
const StyledVirtualList = styled(DynamicVirtualList)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 10px;
|
||||
overflow-x: hidden;
|
||||
`
|
||||
height: 100%;
|
||||
` as typeof DynamicVirtualList
|
||||
|
||||
export default memo(Sessions)
|
||||
|
||||
@@ -81,7 +81,8 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [
|
||||
SystemProviderIds.aihubmix,
|
||||
SystemProviderIds.grok,
|
||||
SystemProviderIds.cherryin,
|
||||
SystemProviderIds.longcat
|
||||
SystemProviderIds.longcat,
|
||||
SystemProviderIds.minimax
|
||||
] as const
|
||||
type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number]
|
||||
|
||||
@@ -547,6 +548,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
onChange={(e) => setAnthropicHost(e.target.value)}
|
||||
onBlur={onUpdateAnthropicHost}
|
||||
/>
|
||||
{/* TODO: Add a reset button here. */}
|
||||
</Space.Compact>
|
||||
<SettingHelpTextRow style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px' }}>
|
||||
<SettingHelpText style={{ marginLeft: 6, whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
|
||||
|
||||
@@ -128,9 +128,9 @@ export async function uploadNotes(files: File[], targetPath: string): Promise<Up
|
||||
function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number {
|
||||
switch (sortType) {
|
||||
case 'sort_a2z':
|
||||
return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' })
|
||||
return (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'accent' })
|
||||
case 'sort_z2a':
|
||||
return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' })
|
||||
return (a, b) => b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'accent' })
|
||||
case 'sort_updated_desc':
|
||||
return (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt)
|
||||
case 'sort_updated_asc':
|
||||
@@ -140,7 +140,7 @@ function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode
|
||||
case 'sort_created_asc':
|
||||
return (a, b) => getTime(a.createdAt) - getTime(b.createdAt)
|
||||
default:
|
||||
return (a, b) => a.name.localeCompare(b.name)
|
||||
return (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'accent' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1446,7 +1446,7 @@ export const appendAssistantResponseThunk =
|
||||
}
|
||||
|
||||
// 2. Create the new assistant message stub
|
||||
const newAssistantStub = createAssistantMessage(assistant.id, topicId, {
|
||||
const newAssistantMessageStub = createAssistantMessage(assistant.id, topicId, {
|
||||
askId: askId, // Crucial: Use the original askId
|
||||
model: newModel,
|
||||
modelId: newModel.id,
|
||||
@@ -1459,9 +1459,14 @@ export const appendAssistantResponseThunk =
|
||||
const insertAtIndex = existingMessageIndex !== -1 ? existingMessageIndex + 1 : currentTopicMessageIds.length
|
||||
|
||||
// 4. Update Database (Save the stub to the topic's message list)
|
||||
await saveMessageAndBlocksToDB(newAssistantStub, [], insertAtIndex)
|
||||
await saveMessageAndBlocksToDB(newAssistantMessageStub, [], insertAtIndex)
|
||||
|
||||
dispatch(newMessagesActions.insertMessageAtIndex({ topicId, message: newAssistantStub, index: insertAtIndex }))
|
||||
dispatch(
|
||||
newMessagesActions.insertMessageAtIndex({ topicId, message: newAssistantMessageStub, index: insertAtIndex })
|
||||
)
|
||||
|
||||
dispatch(updateMessageAndBlocksThunk(topicId, { id: existingAssistantMessageId, foldSelected: false }, []))
|
||||
dispatch(updateMessageAndBlocksThunk(topicId, { id: newAssistantMessageStub.id, foldSelected: true }, []))
|
||||
|
||||
// 5. Prepare and queue the processing task
|
||||
const assistantConfigForThisCall = {
|
||||
@@ -1475,7 +1480,7 @@ export const appendAssistantResponseThunk =
|
||||
getState,
|
||||
topicId,
|
||||
assistantConfigForThisCall,
|
||||
newAssistantStub // Pass the newly created stub
|
||||
newAssistantMessageStub // Pass the newly created stub
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
55
src/renderer/src/utils/dom.ts
Normal file
55
src/renderer/src/utils/dom.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Simple wrapper for scrollIntoView with common default options.
|
||||
* Provides a unified interface with sensible defaults.
|
||||
*
|
||||
* @param element - The target element to scroll into view
|
||||
* @param options - Scroll options. If not provided, uses { behavior: 'smooth', block: 'center', inline: 'nearest' }
|
||||
*/
|
||||
export function scrollIntoView(element: HTMLElement, options?: ScrollIntoViewOptions): void {
|
||||
const defaultOptions: ScrollIntoViewOptions = {
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
}
|
||||
element.scrollIntoView(options ?? defaultOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligently scrolls an element into view at the center position.
|
||||
* Prioritizes scrolling within the specified container to avoid scrolling the entire page.
|
||||
*
|
||||
* @param element - The target element to scroll into view
|
||||
* @param scrollContainer - Optional scroll container. If provided and scrollable, scrolling happens within it; otherwise uses browser default scrolling
|
||||
* @param behavior - Scroll behavior, defaults to 'smooth'
|
||||
*/
|
||||
export function scrollElementIntoView(
|
||||
element: HTMLElement,
|
||||
scrollContainer?: HTMLElement | null,
|
||||
behavior: ScrollBehavior = 'smooth'
|
||||
): void {
|
||||
if (!scrollContainer) {
|
||||
// No container specified, use browser default scrolling
|
||||
scrollIntoView(element, { behavior, block: 'center', inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
// Check if container is scrollable
|
||||
const canScroll =
|
||||
scrollContainer.scrollHeight > scrollContainer.clientHeight ||
|
||||
scrollContainer.scrollWidth > scrollContainer.clientWidth
|
||||
|
||||
if (canScroll) {
|
||||
// Container is scrollable, scroll within the container
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const elRect = element.getBoundingClientRect()
|
||||
|
||||
// Calculate element's scrollable offset position relative to the container
|
||||
const elementTopWithinContainer = elRect.top - containerRect.top + scrollContainer.scrollTop
|
||||
const desiredTop = elementTopWithinContainer - Math.max(0, scrollContainer.clientHeight - elRect.height) / 2
|
||||
|
||||
scrollContainer.scrollTo({ top: Math.max(0, desiredTop), behavior })
|
||||
} else {
|
||||
// Container is not scrollable, fallback to browser default scrolling
|
||||
scrollIntoView(element, { behavior, block: 'center', inline: 'nearest' })
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ export function uniqueObjectArray<T>(array: T[]): T[] {
|
||||
export * from './api'
|
||||
export * from './collection'
|
||||
export * from './dataLimit'
|
||||
export * from './dom'
|
||||
export * from './file'
|
||||
export * from './image'
|
||||
export * from './json'
|
||||
|
||||
Reference in New Issue
Block a user