build: publish desktop installer via Gitea releases
Some checks failed
Build and tests / test (push) Has been cancelled
Some checks failed
Build and tests / test (push) Has been cancelled
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -133,6 +133,5 @@ backend/*.backup.*
|
|||||||
/work/
|
/work/
|
||||||
/desktop-client/design-qa.md
|
/desktop-client/design-qa.md
|
||||||
|
|
||||||
# 桌面端历史安装包仅保留在发布服务器,仓库只跟踪当前版本
|
# 桌面安装包通过 Gitea Release 发布,Git 仅保存校验元数据
|
||||||
/frontend/downloads/*.exe
|
/frontend/downloads/*.exe
|
||||||
!/frontend/downloads/wanwan-cloud-desktop_v0.1.38_x64-setup.exe
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ npm audit
|
|||||||
4. `npm ci` 依赖严格同步的 lock 文件;禁止在部署脚本中删除 `package-lock.json`。
|
4. `npm ci` 依赖严格同步的 lock 文件;禁止在部署脚本中删除 `package-lock.json`。
|
||||||
5. Express 中间件顺序是安全边界:requestId/安全头/CORS/HTTPS/静态资源/API 限流/请求体/CSRF,不得无验证调整。
|
5. Express 中间件顺序是安全边界:requestId/安全头/CORS/HTTPS/静态资源/API 限流/请求体/CSRF,不得无验证调整。
|
||||||
6. 桌面安装包目录 `frontend/downloads/` 是运行时发布目录,更新前必须备份且不得放进 `frontend/dist/` 的清理生命周期。
|
6. 桌面安装包目录 `frontend/downloads/` 是运行时发布目录,更新前必须备份且不得放进 `frontend/dist/` 的清理生命周期。
|
||||||
|
7. 桌面安装包必须作为 Gitea Release 附件发布;Git 只提交 `frontend/desktop-release.json`,禁止提交 `.exe`。
|
||||||
|
8. 更新 Release 元数据时必须核对标签、文件名、字节数和 SHA256,并运行 `npm run check:desktop-release`。
|
||||||
|
|
||||||
## 改动原则
|
## 改动原则
|
||||||
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -217,7 +217,16 @@ SMTP密码: 你的授权码
|
|||||||
1. 在登录页点击"下载桌面客户端",或访问后端更新接口获取安装包信息
|
1. 在登录页点击"下载桌面客户端",或访问后端更新接口获取安装包信息
|
||||||
2. 安装后使用网页端账号登录
|
2. 安装后使用网页端账号登录
|
||||||
3. 在客户端中浏览文件、上传/下载、创建分享或直链
|
3. 在客户端中浏览文件、上传/下载、创建分享或直链
|
||||||
4. 如需发布新版本,将安装包放到 `frontend/downloads/` 并更新后台桌面端版本配置
|
4. 桌面安装包由 Gitea Release 发布,部署脚本会按 `frontend/desktop-release.json` 下载并校验 SHA256
|
||||||
|
|
||||||
|
发布新桌面版本时,先创建对应的 `vX.Y.Z` Release 并上传安装包,再更新
|
||||||
|
`frontend/desktop-release.json` 的版本、文件名、大小和 SHA256。不要把 `.exe`
|
||||||
|
提交到 Git。可用以下命令同步当前版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run sync:desktop-release
|
||||||
|
```
|
||||||
|
|
||||||
## 📁 项目结构
|
## 📁 项目结构
|
||||||
|
|
||||||
@@ -362,7 +371,9 @@ cd ../frontend && npm ci --include=dev && npm run build
|
|||||||
sudo systemctl restart wanwanyun
|
sudo systemctl restart wanwanyun
|
||||||
```
|
```
|
||||||
|
|
||||||
`install.sh --update` 会在替换线上文件前构建前端,并保留 `frontend/downloads/` 中由管理端发布的桌面安装包。完整工程约束见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
|
`install.sh --update` 会在替换线上文件前同步并校验 Gitea Release、构建前端,
|
||||||
|
同时保留 `frontend/downloads/` 中已验证的运行时安装包。完整工程约束见
|
||||||
|
[CONTRIBUTING.md](./CONTRIBUTING.md)。
|
||||||
|
|
||||||
## 📊 性能优化建议
|
## 📊 性能优化建议
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,25 @@ test('生产部署统一使用 Vite dist 且不会删除 lock 文件', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('桌面安装包由 Gitea Release 发布且不会再次提交到 Git', () => {
|
||||||
|
const gitignore = fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8');
|
||||||
|
const release = JSON.parse(fs.readFileSync(
|
||||||
|
path.join(projectRoot, 'frontend', 'desktop-release.json'),
|
||||||
|
'utf8'
|
||||||
|
));
|
||||||
|
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
|
||||||
|
const compose = fs.readFileSync(path.join(projectRoot, 'docker-compose.yml'), 'utf8');
|
||||||
|
|
||||||
|
assert.ok(gitignore.split(/\r?\n/).includes('/frontend/downloads/*.exe'));
|
||||||
|
assert.strictEqual(gitignore.includes('!/frontend/downloads/'), false);
|
||||||
|
assert.match(release.sha256, /^[a-f0-9]{64}$/);
|
||||||
|
assert.ok(Number.isSafeInteger(release.size) && release.size > 0);
|
||||||
|
assert.strictEqual(release.tag, `v${release.version}`);
|
||||||
|
assert.strictEqual(release.publicPath, `/downloads/${release.asset}`);
|
||||||
|
assert.ok(installer.includes('sync_desktop_release'));
|
||||||
|
assert.ok(compose.includes('desktop-release:'));
|
||||||
|
});
|
||||||
|
|
||||||
test('Docker 构建上下文排除依赖、环境配置和运行时数据', () => {
|
test('Docker 构建上下文排除依赖、环境配置和运行时数据', () => {
|
||||||
const dockerIgnore = fs.readFileSync(path.join(projectRoot, 'backend', '.dockerignore'), 'utf8');
|
const dockerIgnore = fs.readFileSync(path.join(projectRoot, 'backend', '.dockerignore'), 'utf8');
|
||||||
for (const requiredEntry of ['node_modules', '.env', 'data/*', 'storage/*']) {
|
for (const requiredEntry of ['node_modules', '.env', 'data/*', 'storage/*']) {
|
||||||
|
|||||||
73
backend/tests/release-sync-tests.js
Normal file
73
backend/tests/release-sync-tests.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||||
|
const syncScript = path.join(projectRoot, 'frontend', 'scripts', 'sync-desktop-release.js');
|
||||||
|
const results = { passed: 0, failed: 0 };
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
results.passed += 1;
|
||||||
|
console.log(` [PASS] ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
results.failed += 1;
|
||||||
|
console.error(` [FAIL] ${name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n========== 桌面安装包发布同步测试 ==========\n');
|
||||||
|
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-release-sync-'));
|
||||||
|
try {
|
||||||
|
const destinationDirectory = path.join(tempRoot, 'downloads');
|
||||||
|
const asset = 'wanwan-cloud-desktop_v9.9.9_x64-setup.exe';
|
||||||
|
const assetContent = Buffer.from('verified desktop release fixture');
|
||||||
|
const sha256 = crypto.createHash('sha256').update(assetContent).digest('hex');
|
||||||
|
const metadataPath = path.join(tempRoot, 'desktop-release.json');
|
||||||
|
fs.mkdirSync(destinationDirectory, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(destinationDirectory, asset), assetContent);
|
||||||
|
fs.writeFileSync(metadataPath, JSON.stringify({
|
||||||
|
version: '9.9.9',
|
||||||
|
tag: 'v9.9.9',
|
||||||
|
asset,
|
||||||
|
downloadUrl: `https://example.test/releases/download/v9.9.9/${asset}`,
|
||||||
|
publicPath: `/downloads/${asset}`,
|
||||||
|
sha256,
|
||||||
|
size: assetContent.length
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runCheck = () => spawnSync(process.execPath, [
|
||||||
|
syncScript,
|
||||||
|
'--metadata', metadataPath,
|
||||||
|
'--destination-dir', destinationDirectory,
|
||||||
|
'--check-only'
|
||||||
|
], { encoding: 'utf8' });
|
||||||
|
|
||||||
|
test('已缓存安装包通过大小与 SHA256 校验', () => {
|
||||||
|
const result = runCheck();
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /Desktop release verified/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('损坏的安装包会让只读校验失败', () => {
|
||||||
|
fs.appendFileSync(path.join(destinationDirectory, asset), 'corrupt');
|
||||||
|
const result = runCheck();
|
||||||
|
assert.notStrictEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /Desktop release check failed/);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n========================================');
|
||||||
|
console.log('测试总结');
|
||||||
|
console.log('========================================');
|
||||||
|
console.log(`通过: ${results.passed}`);
|
||||||
|
console.log(`失败: ${results.failed}`);
|
||||||
|
|
||||||
|
process.exit(results.failed > 0 ? 1 : 0);
|
||||||
@@ -11,6 +11,7 @@ const testFiles = [
|
|||||||
'domain-utils-tests.js',
|
'domain-utils-tests.js',
|
||||||
'middleware-tests.js',
|
'middleware-tests.js',
|
||||||
'architecture-tests.js',
|
'architecture-tests.js',
|
||||||
|
'release-sync-tests.js',
|
||||||
'archive-tests.js',
|
'archive-tests.js',
|
||||||
'network-concurrent-tests.js',
|
'network-concurrent-tests.js',
|
||||||
'state-consistency-tests.js'
|
'state-consistency-tests.js'
|
||||||
|
|||||||
@@ -8,6 +8,17 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
desktop-release:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: wanwanyun-desktop-release
|
||||||
|
restart: "no"
|
||||||
|
working_dir: /workspace/frontend
|
||||||
|
command: ["node", "scripts/sync-desktop-release.js"]
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/workspace/frontend
|
||||||
|
networks:
|
||||||
|
- wanwanyun-network
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 后端服务
|
# 后端服务
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -26,6 +37,9 @@ services:
|
|||||||
# - ADMIN_PASSWORD=<至少8位且至少包含两类字符的强密码>
|
# - ADMIN_PASSWORD=<至少8位且至少包含两类字符的强密码>
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
|
depends_on:
|
||||||
|
desktop-release:
|
||||||
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
# 数据持久化
|
# 数据持久化
|
||||||
- ./backend/data:/app/data
|
- ./backend/data:/app/data
|
||||||
@@ -64,7 +78,10 @@ services:
|
|||||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
# - ./certbot/www:/var/www/certbot:ro
|
# - ./certbot/www:/var/www/certbot:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
desktop-release:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- wanwanyun-network
|
- wanwanyun-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
9
frontend/desktop-release.json
Normal file
9
frontend/desktop-release.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.38",
|
||||||
|
"tag": "v0.1.38",
|
||||||
|
"asset": "wanwan-cloud-desktop_v0.1.38_x64-setup.exe",
|
||||||
|
"downloadUrl": "https://git.workyai.cn/237899745/vue-driven-cloud-storage/releases/download/v0.1.38/wanwan-cloud-desktop_v0.1.38_x64-setup.exe",
|
||||||
|
"publicPath": "/downloads/wanwan-cloud-desktop_v0.1.38_x64-setup.exe",
|
||||||
|
"sha256": "67373bc05df4c1e22927ea0123065f32c9b8303ff0d23a4e1d2e830bb2f31ba7",
|
||||||
|
"size": 8674567
|
||||||
|
}
|
||||||
12
frontend/downloads/README.md
Normal file
12
frontend/downloads/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Desktop release cache
|
||||||
|
|
||||||
|
Desktop installers are Gitea Release assets and are not stored in Git.
|
||||||
|
|
||||||
|
Run the following command from `frontend/` to download and verify the release
|
||||||
|
declared in `desktop-release.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/sync-desktop-release.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer is written to this directory and served from `/downloads/`.
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build && node scripts/copy-runtime-assets.js && node scripts/verify-build.js",
|
"build": "vite build && node scripts/copy-runtime-assets.js && node scripts/verify-build.js",
|
||||||
|
"sync:desktop-release": "node scripts/sync-desktop-release.js",
|
||||||
|
"check:desktop-release": "node scripts/sync-desktop-release.js --check-only",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
227
frontend/scripts/sync-desktop-release.js
Normal file
227
frontend/scripts/sync-desktop-release.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
mkdir,
|
||||||
|
open,
|
||||||
|
readFile,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
stat
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const scriptPath = fileURLToPath(import.meta.url);
|
||||||
|
const frontendRoot = path.resolve(path.dirname(scriptPath), '..');
|
||||||
|
|
||||||
|
function parseArguments(argv) {
|
||||||
|
const options = {
|
||||||
|
metadataPath: path.join(frontendRoot, 'desktop-release.json'),
|
||||||
|
destinationDirectory: path.join(frontendRoot, 'downloads'),
|
||||||
|
checkOnly: false
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const argument = argv[index];
|
||||||
|
if (argument === '--check-only') {
|
||||||
|
options.checkOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (argument === '--metadata' || argument === '--destination-dir') {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!value) throw new Error(`${argument} requires a path`);
|
||||||
|
index += 1;
|
||||||
|
if (argument === '--metadata') options.metadataPath = path.resolve(value);
|
||||||
|
if (argument === '--destination-dir') options.destinationDirectory = path.resolve(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown argument: ${argument}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMetadata(value) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error('Desktop release metadata must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
version: String(value.version || '').trim(),
|
||||||
|
tag: String(value.tag || '').trim(),
|
||||||
|
asset: String(value.asset || '').trim(),
|
||||||
|
downloadUrl: String(value.downloadUrl || '').trim(),
|
||||||
|
publicPath: String(value.publicPath || '').trim(),
|
||||||
|
sha256: String(value.sha256 || '').trim().toLowerCase(),
|
||||||
|
size: Number(value.size)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(metadata.version)) {
|
||||||
|
throw new Error('Desktop release version is invalid');
|
||||||
|
}
|
||||||
|
if (metadata.tag !== `v${metadata.version}`) {
|
||||||
|
throw new Error('Desktop release tag must match the version');
|
||||||
|
}
|
||||||
|
if (!metadata.asset || path.basename(metadata.asset) !== metadata.asset) {
|
||||||
|
throw new Error('Desktop release asset must be a file name');
|
||||||
|
}
|
||||||
|
if (!/\.exe$/i.test(metadata.asset)) {
|
||||||
|
throw new Error('Desktop release asset must be an EXE file');
|
||||||
|
}
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(metadata.sha256)) {
|
||||||
|
throw new Error('Desktop release SHA256 is invalid');
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(metadata.size) || metadata.size <= 0) {
|
||||||
|
throw new Error('Desktop release size is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
let releaseUrl;
|
||||||
|
try {
|
||||||
|
releaseUrl = new URL(metadata.downloadUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Desktop release download URL is invalid');
|
||||||
|
}
|
||||||
|
if (releaseUrl.protocol !== 'https:') {
|
||||||
|
throw new Error('Desktop release download URL must use HTTPS');
|
||||||
|
}
|
||||||
|
if (decodeURIComponent(path.basename(releaseUrl.pathname)) !== metadata.asset) {
|
||||||
|
throw new Error('Desktop release URL does not match the asset name');
|
||||||
|
}
|
||||||
|
if (metadata.publicPath !== `/downloads/${metadata.asset}`) {
|
||||||
|
throw new Error('Desktop release public path does not match the asset name');
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeFileSha256(filePath) {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
for await (const chunk of createReadStream(filePath)) {
|
||||||
|
hash.update(chunk);
|
||||||
|
}
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectInstaller(filePath, metadata) {
|
||||||
|
try {
|
||||||
|
const fileStats = await stat(filePath);
|
||||||
|
if (!fileStats.isFile() || fileStats.size !== metadata.size) {
|
||||||
|
return { valid: false, reason: 'size_mismatch' };
|
||||||
|
}
|
||||||
|
const digest = await computeFileSha256(filePath);
|
||||||
|
return {
|
||||||
|
valid: digest === metadata.sha256,
|
||||||
|
reason: digest === metadata.sha256 ? 'verified' : 'sha256_mismatch'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return { valid: false, reason: 'missing' };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadInstaller(metadata, temporaryPath) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 120_000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(metadata.downloadUrl, {
|
||||||
|
headers: { 'user-agent': 'wanwanyun-release-sync/1.0' },
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Desktop release download failed with HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
if (new URL(response.url).protocol !== 'https:') {
|
||||||
|
throw new Error('Desktop release redirected to an insecure URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
const output = await open(temporaryPath, 'wx', 0o600);
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
try {
|
||||||
|
for await (const chunk of Readable.fromWeb(response.body)) {
|
||||||
|
const buffer = Buffer.from(chunk);
|
||||||
|
downloadedBytes += buffer.length;
|
||||||
|
hash.update(buffer);
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
const { bytesWritten } = await output.write(buffer, offset);
|
||||||
|
if (bytesWritten <= 0) throw new Error('Desktop release write made no progress');
|
||||||
|
offset += bytesWritten;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await output.sync();
|
||||||
|
} finally {
|
||||||
|
await output.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = hash.digest('hex');
|
||||||
|
if (downloadedBytes !== metadata.size) {
|
||||||
|
throw new Error(`Desktop release size mismatch: expected ${metadata.size}, received ${downloadedBytes}`);
|
||||||
|
}
|
||||||
|
if (digest !== metadata.sha256) {
|
||||||
|
throw new Error('Desktop release SHA256 verification failed');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceInstaller(temporaryPath, destinationPath) {
|
||||||
|
try {
|
||||||
|
await rename(temporaryPath, destinationPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!['EEXIST', 'EPERM'].includes(error?.code)) throw error;
|
||||||
|
await rm(destinationPath, { force: true });
|
||||||
|
await rename(temporaryPath, destinationPath);
|
||||||
|
}
|
||||||
|
await chmod(destinationPath, 0o644);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncDesktopRelease(options) {
|
||||||
|
const rawMetadata = JSON.parse(await readFile(options.metadataPath, 'utf8'));
|
||||||
|
const metadata = validateMetadata(rawMetadata);
|
||||||
|
const destinationDirectory = path.resolve(options.destinationDirectory);
|
||||||
|
const destinationPath = path.join(destinationDirectory, metadata.asset);
|
||||||
|
await mkdir(destinationDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const existing = await inspectInstaller(destinationPath, metadata);
|
||||||
|
if (existing.valid) {
|
||||||
|
return { status: 'verified', destinationPath, metadata };
|
||||||
|
}
|
||||||
|
if (options.checkOnly) {
|
||||||
|
throw new Error(`Desktop release check failed: ${existing.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryPath = path.join(
|
||||||
|
destinationDirectory,
|
||||||
|
`.${metadata.asset}.${process.pid}.${Date.now()}.tmp`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await downloadInstaller(metadata, temporaryPath);
|
||||||
|
await replaceInstaller(temporaryPath, destinationPath);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporaryPath, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'downloaded', destinationPath, metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArguments(process.argv.slice(2));
|
||||||
|
const result = await syncDesktopRelease(options);
|
||||||
|
console.log(
|
||||||
|
`Desktop release ${result.status}: ${result.metadata.asset} ` +
|
||||||
|
`(${result.metadata.sha256})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`Desktop release sync failed: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
28
install.sh
28
install.sh
@@ -2022,6 +2022,24 @@ download_project() {
|
|||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sync_desktop_release() {
|
||||||
|
local project_root="${1:-$PROJECT_DIR}"
|
||||||
|
local sync_script="${project_root}/frontend/scripts/sync-desktop-release.js"
|
||||||
|
|
||||||
|
print_step "同步桌面客户端 Release..."
|
||||||
|
if [[ ! -f "$sync_script" ]]; then
|
||||||
|
print_error "桌面安装包同步脚本不存在: $sync_script"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if node "$sync_script"; then
|
||||||
|
print_success "桌面客户端 Release 已下载并通过校验"
|
||||||
|
else
|
||||||
|
print_error "桌面客户端 Release 同步失败,停止部署"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
configure_admin_account() {
|
configure_admin_account() {
|
||||||
print_step "配置管理员账号"
|
print_step "配置管理员账号"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -3539,6 +3557,13 @@ update_pull_latest_code() {
|
|||||||
cp -a "$TEMP_BACKUP/frontend-downloads/." "/tmp/${PROJECT_NAME}-update/frontend/downloads/"
|
cp -a "$TEMP_BACKUP/frontend-downloads/." "/tmp/${PROJECT_NAME}-update/frontend/downloads/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! sync_desktop_release "/tmp/${PROJECT_NAME}-update"; then
|
||||||
|
if command -v pm2 &> /dev/null; then
|
||||||
|
pm2 restart ${PROJECT_NAME}-backend || true
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
print_info "构建前端生产资源..."
|
print_info "构建前端生产资源..."
|
||||||
cd "/tmp/${PROJECT_NAME}-update/frontend"
|
cd "/tmp/${PROJECT_NAME}-update/frontend"
|
||||||
if ! npm ci --include=dev || ! npm run build; then
|
if ! npm ci --include=dev || ! npm run build; then
|
||||||
@@ -4230,6 +4255,9 @@ main() {
|
|||||||
# 安装前端依赖并生成 dist
|
# 安装前端依赖并生成 dist
|
||||||
install_frontend_dependencies
|
install_frontend_dependencies
|
||||||
|
|
||||||
|
# 从 Gitea Release 下载并校验桌面安装包
|
||||||
|
sync_desktop_release "$PROJECT_DIR"
|
||||||
|
|
||||||
# 创建配置文件
|
# 创建配置文件
|
||||||
create_env_file
|
create_env_file
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user