refactor(electron): 发布存储从自建服务迁移到 Cloudflare R2

替换 UPLOAD_TOKEN/UPLOAD_URL 为 R2(S3 兼容)对象存储:
- 新增 r2.js 封装上传/复制/删除/列举等操作
- 新增 release-index.js 从文件名解析平台/架构生成下载索引
- CI 环境变量同步切换为 R2_* 系列

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-05-22 10:31:46 +00:00
parent 025f45df0a
commit b1d5652bc7
6 changed files with 328 additions and 244 deletions

View File

@ -180,8 +180,11 @@ jobs:
- name: (Android) Upload File
if: matrix.build_type == 'android'
env:
UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
run: |
node ./electron/build.js android-upload
@ -219,8 +222,11 @@ jobs:
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
@ -230,8 +236,11 @@ jobs:
- name: (Windows) Build Client
if: matrix.build_type == 'windows'
env:
UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
shell: bash
@ -264,8 +273,11 @@ jobs:
- name: Upload Changelog & Publish to Website
env:
UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
run: |
pushd electron || exit
npm install

323
electron/build.js vendored
View File

@ -6,13 +6,14 @@ const child_process = require('child_process');
const ora = require('ora');
const yauzl = require('yauzl');
const axios = require('axios');
const FormData =require('form-data');
const tar = require('tar');
const utils = require('./lib/utils');
const r2 = require('./lib/r2');
const { buildReleaseIndex } = require('./lib/release-index');
const config = require('../package.json')
const env = require('dotenv').config({ path: './.env' })
const argv = process.argv;
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY, UPLOAD_TOKEN, UPLOAD_URL} = process.env;
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY} = process.env;
const electronDir = path.resolve(__dirname, "public");
const nativeCachePath = path.resolve(__dirname, ".native");
@ -311,202 +312,23 @@ function changeLog() {
}
/**
* 封装 axios 自动重试
* @param data // {axios: object{}, onRetry: function, retryNumber: number}
* @returns {Promise<unknown>}
* 上传单个文件到 R2 draft/<version>/ /spinner
*/
function axiosAutoTry(data) {
return new Promise((resolve, reject) => {
axios(data.axios).then(result => {
resolve(result)
}).catch(error => {
if (typeof data.retryNumber == 'number' && data.retryNumber > 0) {
data.retryNumber--;
if (typeof data.onRetry === "function") {
data.onRetry(error)
}
if (error.code == 'ECONNABORTED' || error.code == 'ECONNRESET') {
// 中止,超时
return resolve(axiosAutoTry(data))
} else {
if (error.response && error.response.status == 407) {
// 代理407
return setTimeout(v => {
resolve(axiosAutoTry(data))
}, 500 + Math.random() * 500)
} else if (error.response && error.response.status == 503) {
// 服务器异常
return setTimeout(v => {
resolve(axiosAutoTry(data))
}, 1000 + Math.random() * 500)
} else if (error.response && error.response.status == 429) {
// 并发超过限制
return setTimeout(v => {
resolve(axiosAutoTry(data))
}, 1000 + Math.random() * 1000)
}
}
}
reject(error)
})
})
}
/**
* 官网发布器
*/
class WebsitePublisher {
constructor({baseUrl, token, version}) {
this.baseUrl = baseUrl
this.token = token
this.version = version
async function uploadDraftFile(client, localFile, version) {
const filename = path.basename(localFile);
const key = `draft/${version}/${filename}`;
const startTime = Date.now();
const spinner = ora(`Upload [0%] ${filename}`).start();
try {
await r2.uploadFile(client, localFile, key, (loaded, total) => {
const pct = Math.min(99, Math.round((loaded / total) * 100)) + '%';
spinner.text = `Upload [${pct}] ${filename}`;
});
} catch (error) {
spinner.fail(`Upload [fail] ${filename} (${elapsedSeconds(startTime)}): ${error.message || error}`);
throw error;
}
/**
* 上传单个文件
* @param localFile 本地文件路径
* @param options { platform, arch } 可选有则为安装包
*/
async uploadPackage(localFile, options = {}) {
const filename = path.basename(localFile)
const startTime = Date.now()
let spinner = ora(`Upload [0%] ${filename}`).start()
const formData = new FormData()
formData.append("version", this.version)
if (options.platform) {
formData.append("platform", options.platform)
if (options.arch) {
formData.append("arch", options.arch)
}
}
formData.append("file", fs.createReadStream(localFile))
let result
try {
result = await axiosAutoTry({
axios: {
method: 'post',
url: `${this.baseUrl}/api/upload/package`,
data: formData,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
},
onUploadProgress: progress => {
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
spinner.text = `Upload [${complete}] ${filename}`
},
},
onRetry: (err) => {
const reason = err?.response?.status || err?.code || err?.message || ''
spinner.warn(`Upload [retry] ${filename} (${elapsedSeconds(startTime)})${reason ? ': ' + reason : ''}`)
spinner = ora(`Upload [0%] ${filename}`).start()
},
retryNumber: 3
})
} catch (error) {
const reason = error?.response?.status || error?.code || error?.message || 'unknown error'
spinner.fail(`Upload [fail] ${filename} (${elapsedSeconds(startTime)}): ${reason}`)
throw error
}
const {status, data} = result
if (status !== 200 || !utils.isJson(data) || !data.success) {
const reason = data?.message || `status ${status}`
spinner.fail(`Upload [fail] ${filename} (${elapsedSeconds(startTime)}): ${reason}`)
throw new Error(`Upload failed: ${filename}: ${reason}`)
}
spinner.succeed(`Upload [100%] ${filename} (${elapsedSeconds(startTime)})`)
}
/**
* 上传 changelog
*/
async uploadChangelog(content) {
const spinner = ora('Uploading changelog...').start()
const {status, data} = await axiosAutoTry({
axios: {
method: 'post',
url: `${this.baseUrl}/api/upload/changelog`,
data: { content },
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
},
retryNumber: 3
})
if (status !== 200 || !data.success) {
spinner.fail('Changelog upload failed')
throw new Error('Changelog upload failed')
}
spinner.succeed('Changelog uploaded')
}
/**
* 通知发布完成
*/
async release() {
const spinner = ora('Publishing release...').start()
const {status, data} = await axiosAutoTry({
axios: {
method: 'post',
url: `${this.baseUrl}/api/upload/release`,
data: { version: this.version },
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
},
retryNumber: 3
})
if (status !== 200 || !data.success) {
spinner.fail(`Release failed: ${data?.message || status}`)
throw new Error(`Release failed: ${data?.message || status}`)
}
spinner.succeed('Release published')
}
}
// 安装包扩展名
const INSTALLER_EXTS = ['.dmg', '.exe', '.msi', '.appimage', '.deb', '.rpm', '.apk']
/**
* 创建 WebsitePublisher 实例如果环境变量齐全
*/
function createPublisher() {
if (!UPLOAD_TOKEN || !UPLOAD_URL) {
return null
}
return new WebsitePublisher({
baseUrl: UPLOAD_URL.replace(/\/+$/, ''),
token: UPLOAD_TOKEN,
version: config.version
})
}
/**
* 从文件名判断是否为安装包
*/
function isInstaller(filename) {
return INSTALLER_EXTS.some(ext => filename.toLowerCase().endsWith(ext))
}
/**
* 从文件名提取 arch
*/
function parseArchFromFilename(filename) {
if (/-arm64[.-]/i.test(filename)) return 'arm64'
if (/-x64[.-]/i.test(filename)) return 'x64'
return null
}
/**
* 将构建平台名映射为 API platform
*/
function mapPlatform(buildPlatform) {
if (buildPlatform.includes('mac')) return 'mac'
if (buildPlatform.includes('win')) return 'win'
if (buildPlatform.includes('linux')) return 'linux'
return null
spinner.succeed(`Upload [100%] ${filename} (${elapsedSeconds(startTime)})`);
}
/**
@ -678,30 +500,22 @@ async function startBuild(data) {
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
child_process.execSync(`npm run ${platform}-publish`, {stdio: "inherit", cwd: "electron"});
}
// generic (build or publish)
appConfig.build.publish = data.publish
// generic (build or publish) —— 有 R2_PUBLIC_URL 时自动更新源指向 R2 release/
appConfig.build.publish = r2.R2_PUBLIC_URL
? { provider: 'generic', url: `${r2.R2_PUBLIC_URL.replace(/\/+$/, '')}/release` }
: data.publish
appConfig.build.directories.output = `${output}-generic`;
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
child_process.execSync(`npm run ${platform}`, {stdio: "inherit", cwd: "electron"});
if (publish === true) {
const publisher = createPublisher()
if (publisher) {
const outputDir = path.resolve(__dirname, appConfig.build.directories.output)
if (fs.existsSync(outputDir)) {
const apiPlatform = mapPlatform(platform)
const files = fs.readdirSync(outputDir)
for (const filename of files) {
const localFile = path.join(outputDir, filename)
const fileStat = fs.statSync(localFile)
if (!fileStat.isFile()) continue
if (isInstaller(filename) && apiPlatform) {
const arch = parseArchFromFilename(filename)
await publisher.uploadPackage(localFile, { platform: apiPlatform, arch })
} else {
await publisher.uploadPackage(localFile)
}
}
if (publish === true && r2.r2Configured()) {
const client = r2.createR2Client()
const outputDir = path.resolve(__dirname, appConfig.build.directories.output)
if (fs.existsSync(outputDir)) {
const files = fs.readdirSync(outputDir)
for (const filename of files) {
const localFile = path.join(outputDir, filename)
if (!fs.statSync(localFile).isFile()) continue
await uploadDraftFile(client, localFile, config.version)
}
}
}
@ -739,13 +553,13 @@ if (["dev"].includes(argv[2])) {
}
})
} else if (["android-upload"].includes(argv[2])) {
// 上传安卓文件GitHub Actions
// 上传安卓文件到 R2 draftGitHub Actions
(async () => {
const publisher = createPublisher()
if (!publisher) {
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
if (!r2.r2Configured()) {
console.error("缺少 R2_* 环境变量R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_ENDPOINT/R2_BUCKET")
process.exit(1)
}
const client = r2.createR2Client()
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
if (!fs.existsSync(releaseDir)) {
console.error("发布文件未找到")
@ -755,7 +569,7 @@ if (["dev"].includes(argv[2])) {
for (const filename of files) {
const localFile = path.join(releaseDir, filename)
if (/\.apk$/.test(filename) && fs.existsSync(localFile) && fs.statSync(localFile).isFile()) {
await publisher.uploadPackage(localFile, { platform: 'android' })
await uploadDraftFile(client, localFile, config.version)
}
}
})().catch(err => {
@ -763,24 +577,63 @@ if (["dev"].includes(argv[2])) {
process.exit(1)
})
} else if (["release"].includes(argv[2])) {
// 通知官网发布完成GitHub Actions
// R2 内提升draft/<version> → release/(当前版扁平,旧版归档 release/<prev>/
(async () => {
const publisher = createPublisher()
if (!publisher) {
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
if (!r2.r2Configured()) {
console.error("缺少 R2_* 环境变量")
process.exit(1)
}
await publisher.release()
const client = r2.createR2Client()
const version = config.version
const draftPrefix = `draft/${version}/`
const draftKeys = await r2.listKeys(client, draftPrefix)
if (!draftKeys.length) {
console.error(`draft/${version}/ 为空,无法发布`)
process.exit(1)
}
const names = draftKeys.map(k => k.slice(draftPrefix.length))
// 读 manifest 取上一发布版
const manifest = JSON.parse(await r2.getText(client, 'manifest.json') || '{"draft":null,"release":null}')
const prev = manifest.release
// 1. 归档上一版扁平文件 → release/<prev>/
if (prev && prev !== version) {
const prevRootKeys = await r2.listKeys(client, 'release/', '/')
for (const key of prevRootKeys) {
const name = key.slice('release/'.length)
await r2.copyObject(client, key, `release/${prev}/${name}`)
}
}
// 2. 清空扁平根层(仅根层对象,版本归档子目录不动)
const rootKeys = await r2.listKeys(client, 'release/', '/')
await r2.deleteKeys(client, rootKeys)
// 3. 铺新扁平:安装包/blockmap/zip 先latest*.yml 最后
const ymls = names.filter(n => /\.ya?ml$/i.test(n))
const others = names.filter(n => !/\.ya?ml$/i.test(n))
for (const name of others) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
for (const name of ymls) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
// 4. 下载索引
const index = buildReleaseIndex(names)
await r2.putText(client, 'release/index.json', JSON.stringify({ version, files: index }, null, 2))
// 5. 更新 manifest清理 draft
await r2.putText(client, 'manifest.json', JSON.stringify({ draft: null, release: version }, null, 2))
await r2.deleteKeys(client, draftKeys)
console.log(`Release published: v${version}`)
})().catch(err => {
console.error(err.message || err)
process.exit(1)
})
} else if (["upload-changelog"].includes(argv[2])) {
// 上传 changelogGitHub Actions
// 上传 changelog 到 R2GitHub Actions
(async () => {
const publisher = createPublisher()
if (!publisher) {
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
if (!r2.r2Configured()) {
console.error("缺少 R2_* 环境变量")
process.exit(1)
}
const changelogPath = path.resolve(__dirname, "../CHANGELOG.md")
@ -788,8 +641,10 @@ if (["dev"].includes(argv[2])) {
console.error("CHANGELOG.md 未找到")
process.exit(1)
}
const client = r2.createR2Client()
const content = fs.readFileSync(changelogPath, 'utf8')
await publisher.uploadChangelog(content)
await r2.putText(client, 'changelog.md', content)
console.log('Changelog uploaded')
})().catch(err => {
console.error(err.message || err)
process.exit(1)
@ -943,8 +798,8 @@ if (["dev"].includes(argv[2])) {
// 发布判断环境变量
if (answers.publish) {
if (!(UPLOAD_TOKEN && UPLOAD_URL) && !(GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/"))) {
console.error("发布需要 UPLOAD_TOKEN + UPLOAD_URL 或 GITHUB_TOKEN + GITHUB_REPOSITORY, 请检查环境变量!");
if (!r2.r2Configured() && !(GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/"))) {
console.error("发布需要 R2_* 或 GITHUB_TOKEN + GITHUB_REPOSITORY, 请检查环境变量!");
process.exit()
}
}

133
electron/lib/r2.js vendored Normal file
View File

@ -0,0 +1,133 @@
const fs = require('fs');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
CopyObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
} = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');
const {
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_ENDPOINT,
R2_BUCKET,
R2_PUBLIC_URL,
} = process.env;
function r2Configured() {
return !!(R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_ENDPOINT && R2_BUCKET);
}
function createR2Client() {
return new S3Client({
region: 'auto',
endpoint: R2_ENDPOINT,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
}
function contentTypeFor(name) {
if (/\.ya?ml$/i.test(name)) return 'text/yaml';
if (/\.json$/i.test(name)) return 'application/json';
if (/\.md$/i.test(name)) return 'text/markdown; charset=utf-8';
return 'application/octet-stream';
}
/** 流式上传本地文件onProgress(loaded, total) */
async function uploadFile(client, localFile, key, onProgress) {
const total = fs.statSync(localFile).size;
const upload = new Upload({
client,
params: {
Bucket: R2_BUCKET,
Key: key,
Body: fs.createReadStream(localFile),
ContentType: contentTypeFor(key),
},
});
if (onProgress) {
upload.on('httpUploadProgress', (p) => onProgress(p.loaded || 0, total));
}
await upload.done();
}
/** 写入文本对象 */
async function putText(client, key, text) {
await client.send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: key,
Body: text,
ContentType: contentTypeFor(key),
}));
}
/** 读取文本对象,不存在返回 null */
async function getText(client, key) {
try {
const res = await client.send(new GetObjectCommand({ Bucket: R2_BUCKET, Key: key }));
return await res.Body.transformToString();
} catch (err) {
if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) return null;
throw err;
}
}
/** 桶内服务端复制(文件名为安全 ASCII无需额外编码 */
async function copyObject(client, srcKey, destKey) {
await client.send(new CopyObjectCommand({
Bucket: R2_BUCKET,
CopySource: `${R2_BUCKET}/${srcKey}`,
Key: destKey,
ContentType: contentTypeFor(destKey),
MetadataDirective: 'REPLACE',
}));
}
/** 列举 keydelimiter='/' 时仅返回该前缀下的根层对象(子目录归 CommonPrefixes不返回 */
async function listKeys(client, prefix, delimiter) {
const keys = [];
let token;
do {
const res = await client.send(new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: prefix,
Delimiter: delimiter,
ContinuationToken: token,
}));
for (const o of res.Contents || []) keys.push(o.Key);
token = res.IsTruncated ? res.NextContinuationToken : undefined;
} while (token);
return keys;
}
/** 批量删除(每批 1000 */
async function deleteKeys(client, keys) {
for (let i = 0; i < keys.length; i += 1000) {
const batch = keys.slice(i, i + 1000);
if (!batch.length) continue;
await client.send(new DeleteObjectsCommand({
Bucket: R2_BUCKET,
Delete: { Objects: batch.map((Key) => ({ Key })) },
}));
}
}
module.exports = {
r2Configured,
createR2Client,
contentTypeFor,
uploadFile,
putText,
getText,
copyObject,
listKeys,
deleteKeys,
R2_BUCKET,
R2_PUBLIC_URL,
};

46
electron/lib/release-index.js vendored Normal file
View File

@ -0,0 +1,46 @@
// 仅这些扩展名进入下载索引(排除 .zipmac 自动更新增量包,非下载按钮目标)
const DOWNLOAD_EXTS = ['.dmg', '.exe', '.msi', '.appimage', '.deb', '.rpm', '.apk', '.pkg'];
/**
* 从文件名解析 platform/arch与官网 storage.ts 规则保持一致
* @returns {{platform: string, arch: string|null}|null}
*/
function parseFilename(filename) {
const lower = filename.toLowerCase();
if (lower.endsWith('.apk')) {
return { platform: 'android', arch: null };
}
if (!DOWNLOAD_EXTS.some((ext) => lower.endsWith(ext))) {
return null;
}
let platform = null;
if (/-mac-/i.test(filename) || lower.endsWith('.dmg') || lower.endsWith('.pkg')) {
platform = 'mac';
} else if (/-win-/i.test(filename) || /-win\./i.test(filename) || lower.endsWith('.msi')) {
platform = 'win';
} else if (/-linux-/i.test(filename) || lower.endsWith('.appimage') || lower.endsWith('.deb') || lower.endsWith('.rpm')) {
platform = 'linux';
}
if (!platform) return null;
let arch = null;
if (/-arm64[.-]/i.test(filename)) arch = 'arm64';
else if (/-x64[.-]/i.test(filename)) arch = 'x64';
return { platform, arch };
}
/**
* 生成下载索引{ "<platform>": { "<arch|default>": filename } }
*/
function buildReleaseIndex(filenames) {
const index = {};
for (const filename of filenames) {
const parsed = parseFilename(filename);
if (!parsed) continue;
const archKey = parsed.arch || 'default';
index[parsed.platform] = index[parsed.platform] || {};
index[parsed.platform][archKey] = filename;
}
return index;
}
module.exports = { parseFilename, buildReleaseIndex, DOWNLOAD_EXTS };

36
electron/lib/release-index.test.js vendored Normal file
View File

@ -0,0 +1,36 @@
const test = require('node:test');
const assert = require('node:assert');
const { parseFilename, buildReleaseIndex } = require('./release-index');
test('parseFilename: win exe x64', () => {
assert.deepStrictEqual(parseFilename('DooTask-v1.7.56-win-x64.exe'), { platform: 'win', arch: 'x64' });
});
test('parseFilename: mac dmg arm64', () => {
assert.deepStrictEqual(parseFilename('DooTask-v1.7.56-mac-arm64.dmg'), { platform: 'mac', arch: 'arm64' });
});
test('parseFilename: android apk has null arch', () => {
assert.deepStrictEqual(parseFilename('app-release.apk'), { platform: 'android', arch: null });
});
test('parseFilename: ignores yml/blockmap/zip', () => {
assert.strictEqual(parseFilename('latest.yml'), null);
assert.strictEqual(parseFilename('DooTask-v1.7.56-win-x64.exe.blockmap'), null);
assert.strictEqual(parseFilename('DooTask-v1.7.56-mac-arm64.zip'), null);
});
test('buildReleaseIndex: groups by platform/arch, .zip never overwrites .dmg', () => {
const index = buildReleaseIndex([
'DooTask-v1.7.56-mac-arm64.dmg',
'DooTask-v1.7.56-mac-arm64.zip',
'DooTask-v1.7.56-win-x64.exe',
'latest.yml',
'app-release.apk',
]);
assert.deepStrictEqual(index, {
mac: { arm64: 'DooTask-v1.7.56-mac-arm64.dmg' },
win: { x64: 'DooTask-v1.7.56-win-x64.exe' },
android: { default: 'app-release.apk' },
});
});

View File

@ -42,6 +42,8 @@
"ora": "^4.1.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1052.0",
"@aws-sdk/lib-storage": "^3.1052.0",
"@dootask/electron-dl": "^4.0.0-rc.2",
"axios": "^1.11.0",
"crc": "^3.8.0",
@ -60,8 +62,8 @@
"request": "^2.88.2",
"tar": "^7.4.3",
"turndown": "^7.2.2",
"zod": "^3.23.8",
"yauzl": "^3.2.0"
"yauzl": "^3.2.0",
"zod": "^3.23.8"
},
"trayIcon": {
"dev": {