From 65df80091c6c7fc47003aefb939326fbcb50d8b1 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 7 Aug 2026 13:47:49 +0800 Subject: [PATCH 1/3] fix(openai-compatibility): append chat and models paths directly to base URLs --- src/services/modelService.ts | 16 +++++----- src/services/providerHealthCheck.ts | 8 ++++- tests/providerHealthCheck.test.ts | 46 +++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/services/modelService.ts b/src/services/modelService.ts index 75663c6..e4d3591 100644 --- a/src/services/modelService.ts +++ b/src/services/modelService.ts @@ -40,21 +40,23 @@ const stripKnownSuffix = (baseUrl: string) => .replace(/\/(?:v1beta|v1)\/models$/i, '') .replace(/\/models$/i, ''); -const endpointCandidates = (provider: ModelProvider, baseUrl: string): string[] => { +export const modelEndpointCandidates = (provider: ModelProvider, baseUrl: string): string[] => { const resolvedBaseUrl = baseUrl.trim() || (provider === 'gemini' ? DEFAULT_GEMINI_BASE_URL : provider === 'claude' ? DEFAULT_CLAUDE_BASE_URL : ''); - const base = stripKnownSuffix(resolvedBaseUrl); - if (!base) return []; + const normalized = normalizeBaseUrl(resolvedBaseUrl); + if (!normalized) return []; + if (provider === 'openai') { + return [/\/models$/i.test(normalized) ? normalized : `${normalized}/models`]; + } + const base = stripKnownSuffix(normalized); const withoutVersion = base.replace(/\/(?:v1beta|v1)$/i, ''); if (provider === 'gemini') return [`${withoutVersion}/v1beta/models`]; if (provider === 'claude') return [`${withoutVersion}/v1/models`]; - const first = /\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`; - const fallback = `${base}/models`; - return first === fallback ? [first] : [first, fallback]; + return [/\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`]; }; const normalizeModelList = (payload: unknown): ModelOption[] => { @@ -95,7 +97,7 @@ export async function fetchModels( timeoutMs?: number, ): Promise { const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : ''; - const candidates = endpointCandidates(provider, normalized); + const candidates = modelEndpointCandidates(provider, normalized); if (candidates.length === 0) throw new Error(modelText('model.error.baseUrlRequired')); const headers: Record = { ...customHeaders }; const hasHeader = (name: string) => diff --git a/src/services/providerHealthCheck.ts b/src/services/providerHealthCheck.ts index 7b88e27..d6efdba 100644 --- a/src/services/providerHealthCheck.ts +++ b/src/services/providerHealthCheck.ts @@ -44,6 +44,12 @@ const endpointRoot = (provider: ModelProvider, baseUrl: string) => { return normalized.replace(/\/(?:v1beta|v1)$/i, ''); }; +const openAIChatCompletionsEndpoint = (baseUrl: string) => { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) return ''; + return `${normalized}/chat/completions`; +}; + const hasHeader = (headers: Record, name: string) => Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase()); @@ -148,7 +154,7 @@ export function buildProviderHealthProbe( } return { - url: `${root}/v1/chat/completions`, + url: openAIChatCompletionsEndpoint(baseUrl), header: headers, protocol: 'openai-chat', data: JSON.stringify({ diff --git a/tests/providerHealthCheck.test.ts b/tests/providerHealthCheck.test.ts index 42650a9..35bd9da 100644 --- a/tests/providerHealthCheck.test.ts +++ b/tests/providerHealthCheck.test.ts @@ -5,6 +5,7 @@ import { primaryProviderHealthCredential, runProviderModelHealthChecks, } from '../src/services/providerHealthCheck'; +import { modelEndpointCandidates } from '../src/services/modelService'; describe('API 接入健康检测', () => { it('健康检测只保留已勾选模型,并使用发现结果补充模型信息', () => { @@ -59,6 +60,51 @@ describe('API 接入健康检测', () => { expect(probe.protocol).toBe('openai-chat'); }); + it('为非 v1 的 OpenAI 兼容版本前缀保留原始版本号', () => { + const probe = buildProviderHealthProbe( + 'openai', + 'https://open.bigmodel.cn/api/coding/paas/v4', + 'glm-test', + 'secret-key', + ); + + expect(probe.url).toBe( + 'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions', + ); + expect(modelEndpointCandidates( + 'openai', + 'https://open.bigmodel.cn/api/coding/paas/v4', + )).toEqual([ + 'https://open.bigmodel.cn/api/coding/paas/v4/models', + ]); + }); + + it('把不含版本号的中转地址视为完整 API 路径前缀', () => { + const baseUrl = 'https://relay.example/custom/openai'; + const probe = buildProviderHealthProbe( + 'openai', + baseUrl, + 'mapped-model', + 'secret-key', + ); + + expect(probe.url).toBe( + 'https://relay.example/custom/openai/chat/completions', + ); + expect(modelEndpointCandidates('openai', baseUrl)).toEqual([ + 'https://relay.example/custom/openai/models', + ]); + expect(modelEndpointCandidates( + 'openai', + 'https://relay.example/custom/openai/v1/models', + )).toEqual([ + 'https://relay.example/custom/openai/v1/models', + ]); + expect(modelEndpointCandidates('codex', 'https://codex.example')).toEqual([ + 'https://codex.example/v1/models', + ]); + }); + it('不会覆盖供应商已有的自定义认证头', () => { const probe = buildProviderHealthProbe( 'claude', From dd86eabefacd3220a42d61d81a65a164ad0fba33 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 7 Aug 2026 14:28:57 +0800 Subject: [PATCH 2/3] refactor(scripts): split and harden release tooling --- .github/workflows/gitcode-mirror.yml | 85 -------- .github/workflows/release.yml | 12 +- build.ps1 | 4 +- build.sh | 2 +- .../generate-portable-update-manifest.d.mts | 22 -- ...table-update-manifest.mjs => manifest.mjs} | 18 +- .../{prepare-portable.mjs => portable.mjs} | 7 +- scripts/publish-gitcode-release.mjs | 188 ------------------ scripts/set-app-version.mjs | 45 ----- scripts/set-version.mjs | 110 ++++++++++ scripts/{app-version.mjs => version.mjs} | 0 tests/app-version.test.ts | 87 +++++++- tests/gitcodeRelease.test.ts | 94 --------- tests/portableUpdateManifest.test.ts | 2 +- 14 files changed, 218 insertions(+), 458 deletions(-) delete mode 100644 .github/workflows/gitcode-mirror.yml delete mode 100644 scripts/generate-portable-update-manifest.d.mts rename scripts/{generate-portable-update-manifest.mjs => manifest.mjs} (92%) rename scripts/{prepare-portable.mjs => portable.mjs} (95%) delete mode 100644 scripts/publish-gitcode-release.mjs delete mode 100644 scripts/set-app-version.mjs create mode 100644 scripts/set-version.mjs rename scripts/{app-version.mjs => version.mjs} (100%) delete mode 100644 tests/gitcodeRelease.test.ts diff --git a/.github/workflows/gitcode-mirror.yml b/.github/workflows/gitcode-mirror.yml deleted file mode 100644 index c4ba34c..0000000 --- a/.github/workflows/gitcode-mirror.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Mirror GitHub release to GitCode - -run-name: >- - GitCode mirror ${{ inputs.release_tag || github.event.workflow_run.display_title }} - -on: - workflow_run: - workflows: - - Build portable releases - types: - - completed - workflow_dispatch: - inputs: - release_tag: - description: Existing GitHub release tag to mirror - required: true - type: string - -permissions: - contents: read - -concurrency: - group: gitcode-mirror-${{ inputs.release_tag || github.event.workflow_run.id }} - cancel-in-progress: false - -jobs: - mirror: - name: Mirror release assets - if: >- - vars.GITCODE_REPOSITORY != '' - && ( - github.event_name == 'workflow_dispatch' - || github.event.workflow_run.conclusion == 'success' - ) - runs-on: ubuntu-24.04 - timeout-minutes: 35 - steps: - - uses: actions/checkout@v7 - - name: Resolve release tag - id: release - shell: bash - env: - INPUT_RELEASE_TAG: ${{ inputs.release_tag }} - RUN_TITLE: ${{ github.event.workflow_run.display_title }} - run: | - set -euo pipefail - - release_tag="$INPUT_RELEASE_TAG" - if [ -z "$release_tag" ]; then - case "$RUN_TITLE" in - "Portable release "*) release_tag="${RUN_TITLE#Portable release }" ;; - *) echo "Cannot resolve a release tag from workflow title: $RUN_TITLE" >&2; exit 1 ;; - esac - fi - case "$release_tag" in - v*) ;; - *) release_tag="v$release_tag" ;; - esac - if ! node scripts/app-version.mjs "${release_tag#v}" >/dev/null 2>&1; then - if [ -n "$INPUT_RELEASE_TAG" ]; then - echo "Invalid release tag: $release_tag" >&2 - exit 1 - fi - echo "Skipping GitCode mirror because the completed run did not publish a release" - echo "should_mirror=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "tag=$release_tag" >> "$GITHUB_OUTPUT" - echo "should_mirror=true" >> "$GITHUB_OUTPUT" - - name: Download GitHub release assets - if: steps.release.outputs.should_mirror == 'true' - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run: gh release download "${{ steps.release.outputs.tag }}" --dir artifacts - - name: Publish GitCode mirror - if: steps.release.outputs.should_mirror == 'true' - timeout-minutes: 30 - env: - ARTIFACT_DIRECTORY: artifacts - GITCODE_ACCESS_TOKEN: ${{ secrets.GITCODE_ACCESS_TOKEN }} - GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }} - GITCODE_TARGET_COMMITISH: ${{ vars.GITCODE_TARGET_COMMITISH || 'main' }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} - run: node scripts/publish-gitcode-release.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6b4e4b..d42b2d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: run: | set -euo pipefail - source_version="$(node scripts/app-version.mjs)" + source_version="$(node scripts/version.mjs)" release_tag="$REQUESTED_VERSION" if [ -z "$release_tag" ]; then release_tag="v$source_version" @@ -55,7 +55,7 @@ jobs: esac fi - version="$(node scripts/app-version.mjs "${release_tag#v}")" + version="$(node scripts/version.mjs "${release_tag#v}")" echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$release_tag" >> "$GITHUB_OUTPUT" @@ -77,7 +77,7 @@ jobs: with: components: clippy, rustfmt - name: Apply release version - run: node scripts/set-app-version.mjs "${{ needs.version.outputs.version }}" + run: node scripts/set-version.mjs "${{ needs.version.outputs.version }}" - name: Install Linux dependencies run: | sudo apt-get update @@ -150,7 +150,7 @@ jobs: bun-version: 1.3.14 - uses: dtolnay/rust-toolchain@stable - name: Apply release version - run: node scripts/set-app-version.mjs "${{ needs.version.outputs.version }}" + run: node scripts/set-version.mjs "${{ needs.version.outputs.version }}" - name: Resolve release package name id: package shell: bash @@ -174,7 +174,7 @@ jobs: shell: bash - name: Prepare portable directory run: >- - node scripts/prepare-portable.mjs + node scripts/portable.mjs --binary "src-tauri/target/release/cpa-gui${{ matrix.os == 'windows' && '.exe' || '' }}" --output "release/${{ steps.package.outputs.stem }}" --os "${{ matrix.os }}" @@ -414,7 +414,7 @@ jobs: env: RELEASE_TAG: ${{ needs.version.outputs.tag }} run: >- - node scripts/generate-portable-update-manifest.mjs + node scripts/manifest.mjs --directory artifacts --output artifacts/portable-update-windows.json --repository "${{ github.repository }}" diff --git a/build.ps1 b/build.ps1 index 160403a..afab82e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -12,7 +12,7 @@ $RootDir = $PSScriptRoot $AppBin = Join-Path $RootDir 'src-tauri\target\release\cpa-gui.exe' $BinDir = Join-Path $RootDir 'bin-work' $BinOut = Join-Path $BinDir 'EasyCLIProxyAPI.exe' -$PreparePortable = Join-Path $RootDir 'scripts\prepare-portable.mjs' +$PortableScript = Join-Path $RootDir 'scripts\portable.mjs' Set-Location -LiteralPath $RootDir @@ -72,7 +72,7 @@ if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) { throw "Build finished, but executable not found: $AppBin" } -& bun $PreparePortable --binary $AppBin --output $BinDir --download true --preserve-runtime-config true +& bun $PortableScript --binary $AppBin --output $BinDir --download true --preserve-runtime-config true if ($LASTEXITCODE -ne 0) { throw "Portable preparation failed with exit code $LASTEXITCODE." } diff --git a/build.sh b/build.sh index e711a6b..bb91c99 100755 --- a/build.sh +++ b/build.sh @@ -26,7 +26,7 @@ if [ ! -x "$APP_BIN" ]; then fi mkdir -p "$BIN_DIR" -bun "$ROOT_DIR/scripts/prepare-portable.mjs" \ +bun "$ROOT_DIR/scripts/portable.mjs" \ --binary "$APP_BIN" \ --output "$BIN_DIR" \ --download true diff --git a/scripts/generate-portable-update-manifest.d.mts b/scripts/generate-portable-update-manifest.d.mts deleted file mode 100644 index 739c082..0000000 --- a/scripts/generate-portable-update-manifest.d.mts +++ /dev/null @@ -1,22 +0,0 @@ -export type PortableUpdateManifestAsset = { - url: string; - sha256: string; - sizeBytes: number; -}; - -export type PortableUpdateManifest = { - schemaVersion: number; - version: string; - publishedAt: string; - releaseUrl: string; - assets: Record; - fullAssets: Record; -}; - -export function generatePortableUpdateManifest(options: { - directory?: string; - output?: string; - repository?: string; - tag?: string; - publishedAt?: string; -}): Promise; diff --git a/scripts/generate-portable-update-manifest.mjs b/scripts/manifest.mjs similarity index 92% rename from scripts/generate-portable-update-manifest.mjs rename to scripts/manifest.mjs index 1091f7c..e78c75b 100644 --- a/scripts/generate-portable-update-manifest.mjs +++ b/scripts/manifest.mjs @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { readFile, stat, writeFile } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { validateAppVersion } from './version.mjs'; export async function generatePortableUpdateManifest({ directory, @@ -18,11 +19,8 @@ export async function generatePortableUpdateManifest({ const normalizedRawTag = String(rawTag ?? '').trim(); const tag = normalizedRawTag.startsWith('v') ? normalizedRawTag : `v${normalizedRawTag}`; const version = tag.slice(1); - const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; - if (!semverPattern.test(version)) { - throw new Error(`Invalid release tag: ${normalizedRawTag}`); - } + validateAppVersion(version, 'release tag'); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedRepository)) { throw new Error(`Invalid GitHub repository: ${resolvedRepository}`); } @@ -75,6 +73,12 @@ export async function generatePortableUpdateManifest({ return manifest; } +export function gitcodeReleaseAttachmentUrl(repository, tag, filename) { + const [owner, repo] = repository.split('/'); + return `https://api.gitcode.com/api/v5/repos/${owner}/${repo}` + + `/releases/${tag}/attach_files/${filename}/download`; +} + async function main() { const args = new Map(); for (let index = 2; index < process.argv.length; index += 2) { @@ -92,12 +96,6 @@ async function main() { console.log(`Generated ${basename(output)} for v${manifest.version}`); } -export function gitcodeReleaseAttachmentUrl(repository, tag, filename) { - const [owner, repo] = repository.split('/'); - return `https://api.gitcode.com/api/v5/repos/${owner}/${repo}` - + `/releases/${tag}/attach_files/${filename}/download`; -} - const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; if (import.meta.url === entryPoint) { await main(); diff --git a/scripts/prepare-portable.mjs b/scripts/portable.mjs similarity index 95% rename from scripts/prepare-portable.mjs rename to scripts/portable.mjs index e4bc7b7..27726e1 100644 --- a/scripts/prepare-portable.mjs +++ b/scripts/portable.mjs @@ -1,15 +1,16 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { basename, join, resolve } from 'node:path'; -import { readAppVersion } from './app-version.mjs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readAppVersion } from './version.mjs'; const args = new Map(); for (let index = 2; index < process.argv.length; index += 2) { args.set(process.argv[index], process.argv[index + 1]); } -const root = resolve(import.meta.dirname, '..'); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const output = resolve(args.get('--output') ?? join(root, 'bin-work')); const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target', 'release', process.platform === 'win32' ? 'cpa-gui.exe' : 'cpa-gui')); const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform]; diff --git a/scripts/publish-gitcode-release.mjs b/scripts/publish-gitcode-release.mjs deleted file mode 100644 index d6fb237..0000000 --- a/scripts/publish-gitcode-release.mjs +++ /dev/null @@ -1,188 +0,0 @@ -import { spawn } from 'node:child_process'; -import { readdir } from 'node:fs/promises'; -import { basename, join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -const API_BASE = 'https://api.gitcode.com/api/v5'; -const API_TIMEOUT_MS = 30_000; -const UPLOAD_TIMEOUT_SECONDS = 180; - -function parseRepository(value) { - const repository = String(value ?? '').trim(); - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { - throw new Error(`Invalid GitCode repository: ${repository}`); - } - return repository.split('/'); -} - -function authenticatedUrl(path, token, query = {}) { - const url = new URL(`${API_BASE}${path}`); - url.searchParams.set('access_token', token); - for (const [name, value] of Object.entries(query)) { - url.searchParams.set(name, value); - } - return url; -} - -async function apiRequest(path, { - token, - method = 'GET', - body, - query, - expected = [200], - fetchImpl = fetch, -}) { - let response; - try { - response = await fetchImpl(authenticatedUrl(path, token, query), { - method, - headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(API_TIMEOUT_MS), - }); - } catch (error) { - throw new Error(`GitCode API request failed for ${path}: ${error instanceof Error ? error.message : error}`); - } - if (!expected.includes(response.status)) { - const details = (await response.text()).slice(0, 500); - throw new Error(`GitCode API ${method} ${path} failed: HTTP ${response.status}: ${details}`); - } - return response.status === 204 || response.status === 404 ? null : response.json(); -} - -export function uploadFileWithCurl({ url, headers, path }) { - const args = [ - '--fail-with-body', - '--silent', - '--show-error', - '--location', - '--connect-timeout', '30', - '--speed-limit', '1', - '--speed-time', '30', - '--max-time', String(UPLOAD_TIMEOUT_SECONDS), - '--request', 'PUT', - '--upload-file', path, - '--output', process.platform === 'win32' ? 'NUL' : '/dev/null', - ]; - for (const [name, value] of Object.entries(headers)) { - args.push('--header', `${name}: ${value}`); - } - args.push(url); - - return new Promise((resolveUpload, rejectUpload) => { - const child = spawn('curl', args, { stdio: ['ignore', 'inherit', 'inherit'] }); - child.once('error', rejectUpload); - child.once('exit', (code, signal) => { - if (code === 0) { - resolveUpload(); - } else { - rejectUpload(new Error(`curl upload failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`)); - } - }); - }); -} - -export async function publishGitcodeRelease({ - directory, - repository, - token, - tag, - targetCommitish = 'main', - prerelease = false, - fetchImpl = fetch, - uploadImpl = uploadFileWithCurl, -}) { - const [owner, repo] = parseRepository(repository); - if (!token) throw new Error('GITCODE_ACCESS_TOKEN is required'); - if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(tag)) { - throw new Error(`Invalid release tag: ${tag}`); - } - - const encodedOwner = encodeURIComponent(owner); - const encodedRepo = encodeURIComponent(repo); - const encodedTag = encodeURIComponent(tag); - const releaseByTagPath = `/repos/${encodedOwner}/${encodedRepo}/releases/tags/${encodedTag}`; - let release; - const existing = await apiRequest(releaseByTagPath, { - token, - expected: [200, 404], - fetchImpl, - }); - if (existing?.tag_name === tag) { - release = existing; - } else { - release = await apiRequest(`/repos/${encodedOwner}/${encodedRepo}/releases`, { - token, - method: 'POST', - body: { - tag_name: tag, - name: `EasyCLIProxyAPI ${tag}`, - body: 'Mirror of the GitHub release for users who cannot access GitHub.', - target_commitish: targetCommitish, - release_status: prerelease ? 'pre' : 'latest', - }, - fetchImpl, - }); - } - - const existingAssets = new Set((release.assets ?? []).map((asset) => asset.name)); - const entries = (await readdir(directory, { withFileTypes: true })) - .filter((entry) => entry.isFile()) - .sort((left, right) => left.name.localeCompare(right.name)); - if (entries.length === 0) throw new Error(`No release artifacts found in ${directory}`); - - for (const entry of entries) { - if (existingAssets.has(entry.name)) { - console.log(`GitCode asset already exists, skipping: ${entry.name}`); - continue; - } - const upload = await apiRequest( - `/repos/${encodedOwner}/${encodedRepo}/releases/${encodedTag}/upload_url`, - { token, query: { file_name: entry.name }, fetchImpl }, - ); - const descriptor = upload?.data ?? upload; - const uploadUrl = descriptor?.upload_url ?? descriptor?.url; - if (typeof uploadUrl !== 'string' || !uploadUrl.startsWith('https://')) { - throw new Error(`GitCode returned an invalid upload URL for ${entry.name}`); - } - const rawHeaders = descriptor?.headers ?? descriptor?.upload_headers ?? descriptor?.header; - const headers = Array.isArray(rawHeaders) - ? Object.fromEntries(rawHeaders.map(({ key, name, value }) => [key ?? name, value])) - : rawHeaders && typeof rawHeaders === 'object' ? rawHeaders : {}; - console.log(`Uploading GitCode asset: ${entry.name}`); - try { - await uploadImpl({ - url: uploadUrl, - headers, - path: join(directory, entry.name), - }); - } catch (error) { - console.warn(`GitCode upload command did not finish cleanly; verifying asset: ${entry.name}`); - const refreshedRelease = await apiRequest(releaseByTagPath, { token, fetchImpl }); - const uploadedDespiteError = (refreshedRelease.assets ?? []) - .some((asset) => asset.name === entry.name); - if (!uploadedDespiteError) throw error; - console.log(`GitCode asset exists despite upload error: ${entry.name}`); - } - existingAssets.add(entry.name); - console.log(`Uploaded GitCode asset: ${entry.name}`); - } -} - -async function main() { - const directory = resolve(process.env.ARTIFACT_DIRECTORY ?? 'artifacts'); - await publishGitcodeRelease({ - directory, - repository: process.env.GITCODE_REPOSITORY, - token: process.env.GITCODE_ACCESS_TOKEN, - tag: process.env.RELEASE_TAG, - targetCommitish: process.env.GITCODE_TARGET_COMMITISH || 'main', - prerelease: process.env.PRERELEASE === 'true' || process.env.RELEASE_TAG.includes('-'), - }); - console.log(`Published ${basename(directory)} to GitCode ${process.env.GITCODE_REPOSITORY}`); -} - -const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; -if (import.meta.url === entryPoint) { - await main(); -} diff --git a/scripts/set-app-version.mjs b/scripts/set-app-version.mjs deleted file mode 100644 index 6c839a3..0000000 --- a/scripts/set-app-version.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - parseCargoPackageVersion, - setCargoLockPackageVersion, - setCargoPackageVersion, - validateAppVersion, -} from './app-version.mjs'; - -const requestedVersion = process.argv[2]; -if (!requestedVersion) { - throw new Error('Usage: node scripts/set-app-version.mjs '); -} - -const version = validateAppVersion(requestedVersion, 'requested app version'); -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const manifest = join(root, 'src-tauri', 'Cargo.toml'); -const lockfile = join(root, 'src-tauri', 'Cargo.lock'); -const contents = await readFile(manifest, 'utf8'); -const lockContents = await readFile(lockfile, 'utf8'); -const previousVersion = parseCargoPackageVersion(contents, manifest); - -if (previousVersion !== version) { - await Promise.all([ - writeFile(manifest, setCargoPackageVersion(contents, version, manifest)), - writeFile(lockfile, setCargoLockPackageVersion(lockContents, 'cpa-gui', version, lockfile)), - ]); -} - -const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; -const metadata = spawnSync(cargo, [ - 'metadata', - '--manifest-path', manifest, - '--no-deps', - '--format-version', '1', -], { stdio: 'ignore' }); - -if (metadata.error) throw metadata.error; -if (metadata.status !== 0) { - throw new Error(`cargo metadata failed with exit code ${metadata.status}`); -} - -console.log(`Applied app version ${version}${previousVersion === version ? ' (unchanged)' : ''}`); diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs new file mode 100644 index 0000000..ab8646b --- /dev/null +++ b/scripts/set-version.mjs @@ -0,0 +1,110 @@ +import { spawnSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + parseCargoPackageVersion, + setCargoLockPackageVersion, + setCargoPackageVersion, + validateAppVersion, +} from './version.mjs'; + +const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const errorMessage = (error) => error instanceof Error ? error.message : String(error); + +export function runCargoMetadata(manifest, spawnImpl = spawnSync) { + const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; + const metadata = spawnImpl(cargo, [ + 'metadata', + '--manifest-path', manifest, + '--no-deps', + '--format-version', '1', + ], { + encoding: 'utf8', + windowsHide: true, + }); + + if (metadata.error) throw metadata.error; + if (metadata.status !== 0) { + const detail = String(metadata.stderr || metadata.stdout || '').trim(); + throw new Error( + `cargo metadata failed with exit code ${metadata.status}${detail ? `: ${detail}` : ''}`, + ); + } +} + +export async function applyAppVersion(requestedVersion, { + rootDir = defaultRoot, + readText = readFile, + writeText = writeFile, + validateProject = runCargoMetadata, +} = {}) { + const version = validateAppVersion(requestedVersion, 'requested app version'); + const manifest = join(rootDir, 'src-tauri', 'Cargo.toml'); + const lockfile = join(rootDir, 'src-tauri', 'Cargo.lock'); + const [contents, lockContents] = await Promise.all([ + readText(manifest, 'utf8'), + readText(lockfile, 'utf8'), + ]); + const previousVersion = parseCargoPackageVersion(contents, manifest); + + const nextContents = setCargoPackageVersion(contents, version, manifest); + const nextLockContents = setCargoLockPackageVersion( + lockContents, + 'cpa-gui', + version, + lockfile, + ); + const manifestChanged = nextContents !== contents; + const lockfileChanged = nextLockContents !== lockContents; + let manifestWriteAttempted = false; + let lockfileWriteAttempted = false; + + try { + if (manifestChanged) { + manifestWriteAttempted = true; + await writeText(manifest, nextContents); + } + if (lockfileChanged) { + lockfileWriteAttempted = true; + await writeText(lockfile, nextLockContents); + } + await validateProject(manifest); + } catch (error) { + const rollbackTasks = []; + if (manifestWriteAttempted) rollbackTasks.push(writeText(manifest, contents)); + if (lockfileWriteAttempted) rollbackTasks.push(writeText(lockfile, lockContents)); + const rollbackResults = await Promise.allSettled(rollbackTasks); + const rollbackFailures = rollbackResults + .filter((result) => result.status === 'rejected') + .map((result) => errorMessage(result.reason)); + if (rollbackFailures.length > 0) { + throw new Error( + `${errorMessage(error)}; rollback failed: ${rollbackFailures.join('; ')}`, + { cause: error }, + ); + } + throw error; + } + + return { + version, + previousVersion, + changed: manifestChanged || lockfileChanged, + }; +} + +async function main() { + const requestedVersion = process.argv[2]; + if (!requestedVersion || process.argv.length > 3) { + throw new Error('Usage: node scripts/set-version.mjs '); + } + const result = await applyAppVersion(requestedVersion); + console.log(`Applied app version ${result.version}${result.changed ? '' : ' (unchanged)'}`); +} + +const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; +if (import.meta.url === entryPoint) { + await main(); +} diff --git a/scripts/app-version.mjs b/scripts/version.mjs similarity index 100% rename from scripts/app-version.mjs rename to scripts/version.mjs diff --git a/tests/app-version.test.ts b/tests/app-version.test.ts index 3f9824f..e7c6585 100644 --- a/tests/app-version.test.ts +++ b/tests/app-version.test.ts @@ -4,7 +4,8 @@ import { setCargoLockPackageVersion, setCargoPackageVersion, validateAppVersion, -} from '../scripts/app-version.mjs'; +} from '../scripts/version.mjs'; +import { applyAppVersion, runCargoMetadata } from '../scripts/set-version.mjs'; describe('app version', () => { it('reads the version from the package section', () => { @@ -60,4 +61,88 @@ source = "registry+https://github.com/rust-lang/crates.io-index" expect(updated).toContain('name = "cpa-gui"\nversion = "2.0.0"'); expect(updated).toContain('name = "dependency"\nversion = "1.2.3"'); }); + + it('repairs a stale lockfile even when Cargo.toml already has the requested version', async () => { + const files = new Map([ + ['Cargo.toml', '[package]\nname = "cpa-gui"\nversion = "2.0.0"\n'], + ['Cargo.lock', '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n'], + ]); + const writes: string[] = []; + + const result = await applyAppVersion('2.0.0', { + rootDir: 'test-root', + readText: async (path: string) => files.get( + path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock', + )!, + writeText: async (path: string, contents: string) => { + const name = path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock'; + writes.push(name); + files.set(name, contents); + }, + validateProject: async () => {}, + }); + + expect(result).toMatchObject({ version: '2.0.0', previousVersion: '2.0.0', changed: true }); + expect(writes).toEqual(['Cargo.lock']); + expect(files.get('Cargo.lock')).toContain('version = "2.0.0"'); + }); + + it('restores both files when cargo metadata validation fails', async () => { + const originalManifest = '[package]\nname = "cpa-gui"\nversion = "1.0.0"\n'; + const originalLockfile = '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n'; + const files = new Map([ + ['Cargo.toml', originalManifest], + ['Cargo.lock', originalLockfile], + ]); + + await expect(applyAppVersion('2.0.0', { + rootDir: 'test-root', + readText: async (path: string) => files.get( + path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock', + )!, + writeText: async (path: string, contents: string) => { + files.set(path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock', contents); + }, + validateProject: async () => { throw new Error('metadata validation failed'); }, + })).rejects.toThrow('metadata validation failed'); + + expect(files.get('Cargo.toml')).toBe(originalManifest); + expect(files.get('Cargo.lock')).toBe(originalLockfile); + }); + + it('restores the manifest when writing the lockfile fails', async () => { + const originalManifest = '[package]\nname = "cpa-gui"\nversion = "1.0.0"\n'; + const originalLockfile = '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n'; + const files = new Map([ + ['Cargo.toml', originalManifest], + ['Cargo.lock', originalLockfile], + ]); + let lockfileWrites = 0; + + await expect(applyAppVersion('2.0.0', { + rootDir: 'test-root', + readText: async (path: string) => files.get( + path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock', + )!, + writeText: async (path: string, contents: string) => { + const name = path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock'; + if (name === 'Cargo.lock' && lockfileWrites++ === 0) { + throw new Error('lockfile write failed'); + } + files.set(name, contents); + }, + validateProject: async () => {}, + })).rejects.toThrow('lockfile write failed'); + + expect(files.get('Cargo.toml')).toBe(originalManifest); + expect(files.get('Cargo.lock')).toBe(originalLockfile); + }); + + it('includes cargo stderr when metadata validation fails', () => { + expect(() => runCargoMetadata('Cargo.toml', () => ({ + status: 101, + stderr: 'manifest parse error', + stdout: '', + }))).toThrow('cargo metadata failed with exit code 101: manifest parse error'); + }); }); diff --git a/tests/gitcodeRelease.test.ts b/tests/gitcodeRelease.test.ts deleted file mode 100644 index c038d28..0000000 --- a/tests/gitcodeRelease.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { describe, expect, test } from 'bun:test'; -import { publishGitcodeRelease } from '../scripts/publish-gitcode-release.mjs'; - -describe('GitCode release mirror', () => { - test('creates a release and uploads compiled artifacts', async () => { - const directory = await mkdtemp(join(tmpdir(), 'easycli-gitcode-release-')); - const calls: Array<{ url: string; method: string; body: unknown }> = []; - const uploads: Array<{ url: string; path: string }> = []; - try { - await writeFile(join(directory, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'), 'artifact'); - const fetchImpl = async (input: URL | RequestInfo, init: RequestInit = {}) => { - const url = String(input); - const method = init.method ?? 'GET'; - calls.push({ url, method, body: init.body }); - if (method === 'GET' && url.includes('/releases/tags/')) { - return new Response('', { status: 404 }); - } - if (method === 'POST' && url.includes('/releases?')) { - return Response.json({ tag_name: 'v1.2.3', assets: [] }); - } - if (method === 'GET' && url.includes('/upload_url?')) { - return Response.json({ - upload_url: 'https://file-cdn.gitcode.com/presigned-upload', - headers: { 'x-upload-token': 'signed' }, - }); - } - return new Response('', { status: 500 }); - }; - - await publishGitcodeRelease({ - directory, - repository: 'mirror-owner/EasyCLIProxyAPI', - token: 'secret-token', - tag: 'v1.2.3', - fetchImpl, - uploadImpl: async ({ url, path }) => { - uploads.push({ url, path }); - }, - }); - - expect(calls.map(({ method }) => method)).toEqual(['GET', 'POST', 'GET']); - expect(calls[0].url).toContain('/repos/mirror-owner/EasyCLIProxyAPI/releases/tags/v1.2.3'); - expect(calls[0].url).toContain('access_token=secret-token'); - expect(calls[2].url).toContain('file_name=EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'); - expect(uploads).toEqual([{ - url: 'https://file-cdn.gitcode.com/presigned-upload', - path: join(directory, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'), - }]); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); - - test('continues when GitCode stores an asset but the upload response times out', async () => { - const directory = await mkdtemp(join(tmpdir(), 'easycli-gitcode-release-')); - const artifactName = 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'; - let releaseRequests = 0; - try { - await writeFile(join(directory, artifactName), 'artifact'); - const fetchImpl = async (input: URL | RequestInfo) => { - const url = String(input); - if (url.includes('/releases/tags/')) { - releaseRequests += 1; - return Response.json({ - tag_name: 'v1.2.3', - assets: releaseRequests === 1 ? [] : [{ name: artifactName }], - }); - } - if (url.includes('/upload_url?')) { - return Response.json({ upload_url: 'https://file-cdn.gitcode.com/presigned-upload' }); - } - return new Response('', { status: 500 }); - }; - - await expect(publishGitcodeRelease({ - directory, - repository: 'mirror-owner/EasyCLIProxyAPI', - token: 'secret-token', - tag: 'v1.2.3', - fetchImpl, - uploadImpl: async () => { - throw new Error('upload response timed out'); - }, - })).resolves.toBeUndefined(); - - expect(releaseRequests).toBe(2); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); -}); diff --git a/tests/portableUpdateManifest.test.ts b/tests/portableUpdateManifest.test.ts index d4bc8b9..713d6a5 100644 --- a/tests/portableUpdateManifest.test.ts +++ b/tests/portableUpdateManifest.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { describe, expect, test } from 'bun:test'; import { generatePortableUpdateManifest, -} from '../scripts/generate-portable-update-manifest.mjs'; +} from '../scripts/manifest.mjs'; describe('Windows 便携更新清单', () => { test('URL、大小和哈希与实际上传资产一致', async () => { From e8b2ced3f87499e3da38bf2261792fe11c96b3bb Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 7 Aug 2026 15:20:30 +0800 Subject: [PATCH 3/3] feat(updater): add GitCode fallback for GUI and CLIProxyAPI core releases - split GitCode repositories into GUI and core build variables - query GitCode latest release when GitHub version discovery fails - construct GitCode attachment URLs from repository, tag, and asset filename - retry core downloads through GitCode when GitHub downloads fail --- .github/workflows/release.yml | 5 +- build.ps1 | 32 ++++-- build.sh | 8 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/build.rs | 2 + src-tauri/src/main.rs | 200 ++++++++++++++++++++++++++++------ 7 files changed, 200 insertions(+), 51 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d42b2d0..e9066f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,7 +166,8 @@ jobs: sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - name: Build GUI env: - GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }} + GITCODE_GUI_REPOSITORY: ${{ vars.GITCODE_GUI_REPOSITORY }} + GITCODE_CORE_REPOSITORY: ${{ vars.GITCODE_CORE_REPOSITORY }} RUSTFLAGS: -D warnings run: | bun install --frozen-lockfile @@ -418,7 +419,7 @@ jobs: --directory artifacts --output artifacts/portable-update-windows.json --repository "${{ github.repository }}" - --gitcode-repository "${{ vars.GITCODE_REPOSITORY }}" + --gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}" --tag "$RELEASE_TAG" - name: Publish release shell: bash diff --git a/build.ps1 b/build.ps1 index afab82e..914fe6c 100644 --- a/build.ps1 +++ b/build.ps1 @@ -2,7 +2,9 @@ param( [int]$BuildJobs, - [string]$GitCodeRepository = 'lzt404/EasyCLIProxyAPI' + [string]$GitCodeGuiRepository = 'lzt404/EasyCLIProxyAPI', + + [string]$GitCodeCoreRepository = 'lzt404/CLIProxyAPI' ) Set-StrictMode -Version Latest @@ -27,8 +29,11 @@ if (-not $PSBoundParameters.ContainsKey('BuildJobs')) { if ($BuildJobs -lt 1 -or $BuildJobs -gt 256) { throw 'BuildJobs must be between 1 and 256.' } -if ($GitCodeRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { - throw 'GitCodeRepository must use the owner/repository format.' +if ($GitCodeGuiRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { + throw 'GitCodeGuiRepository must use the owner/repository format.' +} +if ($GitCodeCoreRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { + throw 'GitCodeCoreRepository must use the owner/repository format.' } if (-not (Get-Command bun -ErrorAction SilentlyContinue)) { @@ -36,7 +41,8 @@ if (-not (Get-Command bun -ErrorAction SilentlyContinue)) { } Write-Host "Cargo build jobs: $BuildJobs" -Write-Host "GitCode fallback repository: $GitCodeRepository" +Write-Host "GitCode GUI fallback repository: $GitCodeGuiRepository" +Write-Host "GitCode core fallback repository: $GitCodeCoreRepository" & bun install if ($LASTEXITCODE -ne 0) { @@ -44,10 +50,12 @@ if ($LASTEXITCODE -ne 0) { } $PreviousBuildJobs = $env:CARGO_BUILD_JOBS -$PreviousGitCodeRepository = $env:GITCODE_REPOSITORY +$PreviousGitCodeGuiRepository = $env:GITCODE_GUI_REPOSITORY +$PreviousGitCodeCoreRepository = $env:GITCODE_CORE_REPOSITORY try { $env:CARGO_BUILD_JOBS = [string]$BuildJobs - $env:GITCODE_REPOSITORY = $GitCodeRepository + $env:GITCODE_GUI_REPOSITORY = $GitCodeGuiRepository + $env:GITCODE_CORE_REPOSITORY = $GitCodeCoreRepository & bun tauri build --no-bundle if ($LASTEXITCODE -ne 0) { throw "Tauri build failed with exit code $LASTEXITCODE." @@ -60,11 +68,17 @@ finally { else { $env:CARGO_BUILD_JOBS = $PreviousBuildJobs } - if ($null -eq $PreviousGitCodeRepository) { - Remove-Item Env:GITCODE_REPOSITORY -ErrorAction SilentlyContinue + if ($null -eq $PreviousGitCodeGuiRepository) { + Remove-Item Env:GITCODE_GUI_REPOSITORY -ErrorAction SilentlyContinue } else { - $env:GITCODE_REPOSITORY = $PreviousGitCodeRepository + $env:GITCODE_GUI_REPOSITORY = $PreviousGitCodeGuiRepository + } + if ($null -eq $PreviousGitCodeCoreRepository) { + Remove-Item Env:GITCODE_CORE_REPOSITORY -ErrorAction SilentlyContinue + } + else { + $env:GITCODE_CORE_REPOSITORY = $PreviousGitCodeCoreRepository } } diff --git a/build.sh b/build.sh index bb91c99..9ecabcf 100755 --- a/build.sh +++ b/build.sh @@ -5,7 +5,8 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT_DIR" BUILD_JOBS="${CARGO_BUILD_JOBS:-20}" -GITCODE_REPOSITORY="${GITCODE_REPOSITORY:-lzt404/EasyCLIProxyAPI}" +GITCODE_GUI_REPOSITORY="${GITCODE_GUI_REPOSITORY:-lzt404/EasyCLIProxyAPI}" +GITCODE_CORE_REPOSITORY="${GITCODE_CORE_REPOSITORY:-lzt404/CLIProxyAPI}" APP_BIN="$ROOT_DIR/src-tauri/target/release/cpa-gui" BIN_DIR="$ROOT_DIR/bin-work" BIN_OUT="$BIN_DIR/EasyCLIProxyAPI" @@ -16,9 +17,10 @@ if ! command -v bun >/dev/null 2>&1; then fi echo "Cargo build jobs: $BUILD_JOBS" -echo "GitCode fallback repository: $GITCODE_REPOSITORY" +echo "GitCode GUI fallback repository: $GITCODE_GUI_REPOSITORY" +echo "GitCode core fallback repository: $GITCODE_CORE_REPOSITORY" bun install -CARGO_BUILD_JOBS="$BUILD_JOBS" GITCODE_REPOSITORY="$GITCODE_REPOSITORY" bun tauri build --no-bundle +CARGO_BUILD_JOBS="$BUILD_JOBS" GITCODE_GUI_REPOSITORY="$GITCODE_GUI_REPOSITORY" GITCODE_CORE_REPOSITORY="$GITCODE_CORE_REPOSITORY" bun tauri build --no-bundle if [ ! -x "$APP_BIN" ]; then echo "Build finished, but executable not found: $APP_BIN" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b00fbcc..b490e1e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.14" +version = "0.2.16" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 83b3518..0bc8bbf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.14" +version = "0.2.16" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..d6dc042 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,5 @@ fn main() { + println!("cargo:rerun-if-env-changed=GITCODE_GUI_REPOSITORY"); + println!("cargo:rerun-if-env-changed=GITCODE_CORE_REPOSITORY"); tauri_build::build() } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d9571c0..a1e5ad9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1563,6 +1563,8 @@ struct GithubRelease { struct GithubAsset { name: String, browser_download_url: String, + #[serde(default)] + fallback_download_urls: Vec, size: Option, digest: Option, } @@ -10784,7 +10786,7 @@ async fn fetch_portable_update_manifest( match fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL).await { Ok(manifest) => Ok(manifest), Err(github_error) => { - let Some(repository) = configured_gitcode_repository() else { + let Some(repository) = configured_gitcode_gui_repository() else { return Err(github_error); }; let fallback = async { @@ -10840,8 +10842,16 @@ async fn fetch_portable_update_manifest_url( Ok(manifest) } -fn configured_gitcode_repository() -> Option<&'static str> { - let repository = option_env!("GITCODE_REPOSITORY")?.trim(); +fn configured_gitcode_gui_repository() -> Option<&'static str> { + configured_gitcode_repository(option_env!("GITCODE_GUI_REPOSITORY")) +} + +fn configured_gitcode_core_repository() -> Option<&'static str> { + configured_gitcode_repository(option_env!("GITCODE_CORE_REPOSITORY")) +} + +fn configured_gitcode_repository(repository: Option<&'static str>) -> Option<&'static str> { + let repository = repository?.trim(); let mut parts = repository.split('/'); let valid_part = |part: &str| { !part.is_empty() @@ -11048,7 +11058,7 @@ fn validate_portable_update_asset_fallbacks( asset, tag, expected_filenames, - configured_gitcode_repository(), + configured_gitcode_gui_repository(), ) } @@ -11807,17 +11817,7 @@ async fn install_core_version_inner( .ok_or_else(|| format!("非法 asset 文件名: {}", asset.name))?; let archive_path = download_dir.join(archive_file_name); - let downloaded = download_asset( - &client, - &asset.browser_download_url, - &archive_path, - asset.size, - asset.digest.as_deref(), - window, - state, - &token, - ) - .await?; + let downloaded = download_asset(&client, asset, &archive_path, window, state, &token).await?; validate_downloaded_asset(asset, &downloaded)?; ensure_not_cancelled(&token, Some(&archive_path))?; @@ -11933,14 +11933,50 @@ async fn fetch_release( return Ok(release_from_tag(version)); } let atom_result = fetch_release_from_atom(client).await; - match atom_result { + let github_result = match atom_result { Ok(release) => Ok(release), Err(atom_error) => fetch_release_from_page(client).await.map_err(|page_error| { format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}") }), + }; + match github_result { + Ok(release) => Ok(release), + Err(github_error) => { + let Some(repository) = configured_gitcode_core_repository() else { + return Err(github_error); + }; + fetch_release_from_gitcode(client, repository) + .await + .map_err(|gitcode_error| { + format!( + "GitHub 内核发布源失败: {github_error};GitCode 回退源失败: {gitcode_error}" + ) + }) + } } } +async fn fetch_release_from_gitcode( + client: &reqwest::Client, + repository: &str, +) -> Result { + let release_url = format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest"); + let release = client + .get(release_url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, USER_AGENT) + .send() + .await + .map_err(|error| format!("查询 GitCode 最新内核发行版失败: {error}"))? + .error_for_status() + .map_err(|error| format!("读取 GitCode 最新内核发行版失败: {error}"))? + .json::() + .await + .map_err(|error| format!("解析 GitCode 最新内核发行版失败: {error}"))?; + validate_release_tag(&release.tag_name)?; + Ok(release_from_gitcode_tag(&release.tag_name, repository)) +} + async fn fetch_release_from_page(client: &reqwest::Client) -> Result { let response = client .get(RELEASE_PAGE_URL) @@ -12010,6 +12046,18 @@ fn release_tag_from_atom(xml: &str) -> Option { } fn release_from_tag(tag: &str) -> GithubRelease { + release_from_tag_for_repositories(tag, configured_gitcode_core_repository(), false) +} + +fn release_from_gitcode_tag(tag: &str, repository: &str) -> GithubRelease { + release_from_tag_for_repositories(tag, Some(repository), true) +} + +fn release_from_tag_for_repositories( + tag: &str, + gitcode_repository: Option<&str>, + prefer_gitcode: bool, +) -> GithubRelease { let tag = normalize_version(tag); let version = tag.trim_start_matches('v'); let assets = [ @@ -12023,8 +12071,20 @@ fn release_from_tag(tag: &str) -> GithubRelease { .into_iter() .map(|(os, arch, extension)| { let name = format!("CLIProxyAPI_{version}_{os}_{arch}.{extension}"); + let github_url = format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}"); + let gitcode_url = gitcode_repository + .map(|repository| gitcode_release_attachment_url(repository, &tag, &name)); + let (browser_download_url, fallback_download_urls) = if prefer_gitcode { + ( + gitcode_url.unwrap_or_else(|| github_url.clone()), + Vec::new(), + ) + } else { + (github_url, gitcode_url.into_iter().collect()) + }; GithubAsset { - browser_download_url: format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}"), + browser_download_url, + fallback_download_urls, name, size: None, digest: None, @@ -12099,6 +12159,7 @@ fn parse_release_assets(html: &str) -> Vec { assets.push(GithubAsset { name: name.to_string(), browser_download_url, + fallback_download_urls: Vec::new(), size: None, digest, }); @@ -12136,6 +12197,7 @@ async fn fetch_release_cancelable( fn http_client() -> Result { reqwest::Client::builder() + .redirect(release_https_redirect_policy()) .connect_timeout(Duration::from_secs(15)) .read_timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(600)) @@ -12176,30 +12238,53 @@ fn core_release_asset_name(version: &str, platform: &CorePlatform) -> String { #[allow(clippy::too_many_arguments)] async fn download_asset( client: &reqwest::Client, - url: &str, + asset: &GithubAsset, archive_path: &Path, - expected_total: Option, - expected_digest: Option<&str>, window: &tauri::Window, state: &CoreDownloadState, token: &CancellationToken, ) -> Result { - let result = download_asset_inner( - client, - url, - archive_path, - expected_total, - expected_digest, - window, - state, - token, - ) - .await; - if result.is_err() { - let _ = fs::remove_file(archive_path); + let urls = + std::iter::once(&asset.browser_download_url).chain(asset.fallback_download_urls.iter()); + let mut failures = Vec::new(); + for (index, url) in urls.enumerate() { + if index > 0 { + state.progress( + window, + "GitHub 下载失败,正在切换到 GitCode", + 0, + asset.size, + true, + ); + } + let result = download_asset_inner( + client, + url, + archive_path, + asset.size, + asset.digest.as_deref(), + window, + state, + token, + ) + .await; + match result { + Ok(downloaded) => return Ok(downloaded), + Err(error) if token.is_cancelled() => { + let _ = fs::remove_file(archive_path); + return Err(error); + } + Err(error) => { + let _ = fs::remove_file(archive_path); + failures.push(error); + } + } } - - result + if failures.is_empty() { + let _ = fs::remove_file(archive_path); + return Err("内核发行版没有可用的下载地址".to_string()); + } + Err(format!("所有内核下载源均失败: {}", failures.join(";"))) } #[allow(clippy::too_many_arguments)] @@ -24027,6 +24112,51 @@ custom_option = "keep-original" ); } + #[test] + fn synthetic_core_release_uses_gitcode_as_download_fallback() { + let release = release_from_tag_for_repositories( + "7.2.80", + Some("lzt404/CLIProxyAPI"), + false, + ); + let platform = CorePlatform { + os: "windows".to_string(), + arch: "x86_64".to_string(), + asset_os: "windows".to_string(), + asset_arch: "amd64".to_string(), + archive_kind: "zip".to_string(), + }; + let asset = select_release_asset(&release, &platform).unwrap(); + + assert_eq!( + asset.browser_download_url, + "https://github.com/router-for-me/CLIProxyAPI/releases/download/v7.2.80/CLIProxyAPI_7.2.80_windows_amd64.zip" + ); + assert_eq!( + asset.fallback_download_urls, + ["https://api.gitcode.com/api/v5/repos/lzt404/CLIProxyAPI/releases/v7.2.80/attach_files/CLIProxyAPI_7.2.80_windows_amd64.zip/download"] + ); + } + + #[test] + fn gitcode_discovered_core_release_downloads_from_gitcode_first() { + let release = release_from_gitcode_tag("v7.2.80", "lzt404/CLIProxyAPI"); + let platform = CorePlatform { + os: "linux".to_string(), + arch: "aarch64".to_string(), + asset_os: "linux".to_string(), + asset_arch: "aarch64".to_string(), + archive_kind: "tar.gz".to_string(), + }; + let asset = select_release_asset(&release, &platform).unwrap(); + + assert_eq!( + asset.browser_download_url, + "https://api.gitcode.com/api/v5/repos/lzt404/CLIProxyAPI/releases/v7.2.80/attach_files/CLIProxyAPI_7.2.80_linux_aarch64.tar.gz/download" + ); + assert!(asset.fallback_download_urls.is_empty()); + } + #[test] fn release_asset_names_cover_the_six_supported_gui_targets() { let targets = [