diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f62f72797..efdf9b361 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/electron/build.js b/electron/build.js index 36cbb50c2..69c5d90ea 100644 --- a/electron/build.js +++ b/electron/build.js @@ -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} + * 上传单个文件到 R2 的 draft// 目录(带进度/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 draft(GitHub 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/ → release/(当前版扁平,旧版归档 release//) (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// + 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])) { - // 上传 changelog(GitHub Actions) + // 上传 changelog 到 R2(GitHub 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() } } diff --git a/electron/lib/r2.js b/electron/lib/r2.js new file mode 100644 index 000000000..c594391db --- /dev/null +++ b/electron/lib/r2.js @@ -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', + })); +} + +/** 列举 key;delimiter='/' 时仅返回该前缀下的根层对象(子目录归 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, +}; diff --git a/electron/lib/release-index.js b/electron/lib/release-index.js new file mode 100644 index 000000000..4bf218c8a --- /dev/null +++ b/electron/lib/release-index.js @@ -0,0 +1,46 @@ +// 仅这些扩展名进入下载索引(排除 .zip:mac 自动更新增量包,非下载按钮目标) +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 }; +} + +/** + * 生成下载索引:{ "": { "": 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 }; diff --git a/electron/lib/release-index.test.js b/electron/lib/release-index.test.js new file mode 100644 index 000000000..a70e1225f --- /dev/null +++ b/electron/lib/release-index.test.js @@ -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' }, + }); +}); diff --git a/electron/package.json b/electron/package.json index 21060d961..bdf6559b0 100755 --- a/electron/package.json +++ b/electron/package.json @@ -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": {