mirror of
https://github.com/kuaifan/dootask.git
synced 2026-05-31 20:58:29 +00:00
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:
parent
025f45df0a
commit
b1d5652bc7
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@ -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
323
electron/build.js
vendored
@ -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 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/<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])) {
|
||||
// 上传 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()
|
||||
}
|
||||
}
|
||||
|
||||
133
electron/lib/r2.js
vendored
Normal file
133
electron/lib/r2.js
vendored
Normal 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',
|
||||
}));
|
||||
}
|
||||
|
||||
/** 列举 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,
|
||||
};
|
||||
46
electron/lib/release-index.js
vendored
Normal file
46
electron/lib/release-index.js
vendored
Normal file
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下载索引:{ "<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
36
electron/lib/release-index.test.js
vendored
Normal 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' },
|
||||
});
|
||||
});
|
||||
@ -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": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user