Skip to content

Commit 4d1d34f

Browse files
committed
feat(auto-update): add auto-update
1 parent 651530b commit 4d1d34f

35 files changed

Lines changed: 2039 additions & 15 deletions

.github/workflows/desktop-package.yml

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ jobs:
8181
needs: prepare
8282
env:
8383
NODE_OPTIONS: --max-old-space-size=6144
84+
BITFUN_ENABLE_UPDATER_ARTIFACTS: ${{ needs.prepare.outputs.upload_to_release }}
85+
TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json
86+
TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }}
87+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
88+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
8489

8590
strategy:
8691
fail-fast: false
@@ -105,7 +110,16 @@ jobs:
105110
- os: windows-latest
106111
name: windows-x64
107112
target: x86_64-pc-windows-msvc
108-
build_command: pnpm run installer:build
113+
build_command: |
114+
$ErrorActionPreference = 'Stop'
115+
pnpm run desktop:build:nsis --target x86_64-pc-windows-msvc --verbose
116+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
117+
$desktopExe = "target/x86_64-pc-windows-msvc/release/bitfun-desktop.exe"
118+
if (-not (Test-Path $desktopExe)) {
119+
throw "Desktop executable was not found after NSIS build: $desktopExe"
120+
}
121+
pnpm run installer:build:only
122+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
109123
110124
steps:
111125
- name: Checkout
@@ -118,6 +132,29 @@ jobs:
118132
shell: pwsh
119133
run: ./scripts/ci/setup-openssl-windows.ps1
120134

135+
- name: Install NSIS (Windows)
136+
if: runner.os == 'Windows'
137+
shell: pwsh
138+
run: |
139+
$ErrorActionPreference = 'Stop'
140+
choco install nsis -y --no-progress
141+
$nsisRoot = "${env:ProgramFiles(x86)}\NSIS"
142+
$nsisBin = "${env:ProgramFiles(x86)}\NSIS\Bin"
143+
if (Test-Path $nsisRoot) {
144+
Add-Content $env:GITHUB_PATH $nsisRoot
145+
}
146+
if (Test-Path $nsisBin) {
147+
Add-Content $env:GITHUB_PATH $nsisBin
148+
}
149+
150+
- name: Verify NSIS (Windows)
151+
if: runner.os == 'Windows'
152+
shell: pwsh
153+
run: |
154+
$ErrorActionPreference = 'Stop'
155+
where.exe makensis
156+
makensis /VERSION
157+
121158
- name: Install Linux system dependencies (Tauri bundler)
122159
if: runner.os == 'Linux'
123160
shell: bash
@@ -198,8 +235,13 @@ jobs:
198235
needs: [prepare, package]
199236
if: needs.prepare.outputs.upload_to_release == 'true'
200237
runs-on: ubuntu-latest
238+
env:
239+
REQUIRED_UPDATER_PLATFORMS: windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64
201240

202241
steps:
242+
- name: Checkout
243+
uses: actions/checkout@v4
244+
203245
- name: Download bundled artifacts
204246
uses: actions/download-artifact@v4
205247
with:
@@ -212,14 +254,51 @@ jobs:
212254
echo "Release assets:"
213255
find release-assets -type f | sort
214256
257+
- name: Collect updater assets
258+
run: |
259+
node scripts/collect-tauri-updater-assets.mjs \
260+
--assets-dir release-assets \
261+
--version "${{ needs.prepare.outputs.version }}" \
262+
--out-dir release-updater-assets \
263+
--required-platforms "${REQUIRED_UPDATER_PLATFORMS}"
264+
265+
- name: Generate updater manifest
266+
run: |
267+
node scripts/generate-tauri-latest-json.mjs \
268+
--assets-dir release-updater-assets \
269+
--version "${{ needs.prepare.outputs.version }}" \
270+
--tag "${{ needs.prepare.outputs.release_tag }}" \
271+
--repo "GCWing/BitFun" \
272+
--out release-updater-assets/latest.json \
273+
--required-platforms "${REQUIRED_UPDATER_PLATFORMS}"
274+
275+
- name: Verify updater manifest
276+
run: |
277+
node scripts/verify-tauri-latest-json.mjs \
278+
--manifest release-updater-assets/latest.json \
279+
--version "${{ needs.prepare.outputs.version }}" \
280+
--required-platforms "${REQUIRED_UPDATER_PLATFORMS}"
281+
215282
- name: Upload to release
216283
uses: softprops/action-gh-release@v2
217284
with:
218285
tag_name: ${{ needs.prepare.outputs.release_tag }}
219286
files: |
287+
release-updater-assets/*
220288
release-assets/**/*.AppImage
221289
release-assets/**/*.deb
222290
release-assets/**/*.dmg
223291
release-assets/**/*.rpm
224292
release-assets/**/*bitfun-installer.exe
225293
fail_on_unmatched_files: true
294+
295+
- name: Verify published updater manifest
296+
run: |
297+
curl -fsSL --retry 5 --retry-delay 3 \
298+
"https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/latest.json" \
299+
-o latest.published.json
300+
node scripts/verify-tauri-latest-json.mjs \
301+
--manifest latest.published.json \
302+
--version "${{ needs.prepare.outputs.version }}" \
303+
--required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \
304+
--check-urls true

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ apps/desktop/gen/
4444
src/apps/desktop/gen/
4545
src-tauri/gen/
4646

47+
# Updater signing private key (never commit; pubkey is in tauri.conf.json)
48+
src/apps/desktop/.tauri/*.key
49+
4750
# Editor directories and files
4851
.vscode/*
4952
!.vscode/extensions.json

BitFun-Installer/scripts/build-installer.cjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ function getCandidateAppExePaths(mode) {
152152
const candidates = [];
153153
for (const profile of preferredProfiles) {
154154
candidates.push(
155+
path.join(
156+
BITFUN_ROOT,
157+
"target",
158+
"x86_64-pc-windows-msvc",
159+
profile,
160+
"bitfun-desktop.exe"
161+
),
162+
path.join(
163+
BITFUN_ROOT,
164+
"target",
165+
"x86_64-pc-windows-msvc",
166+
profile,
167+
"BitFun.exe"
168+
),
155169
path.join(
156170
BITFUN_ROOT,
157171
"src",

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ tauri-plugin-fs = "2"
109109
tauri-plugin-log = "2"
110110
tauri-plugin-autostart = "2"
111111
tauri-plugin-notification = "2"
112+
tauri-plugin-updater = "2"
112113
tauri-build = { version = "2", features = [] }
113114

114115
# Windows-specific dependencies

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
"desktop:build:exe": "node scripts/desktop-tauri-build.mjs --no-bundle",
3737
"desktop:build:nsis": "node scripts/desktop-tauri-build.mjs --bundles nsis",
3838
"desktop:build:nsis:fast": "node scripts/desktop-tauri-build.mjs --bundles nsis -- --profile release-fast --features devtools",
39-
"desktop:build:arm64": "node scripts/desktop-tauri-build.mjs --target aarch64-apple-darwin --bundles dmg",
40-
"desktop:build:x86_64": "node scripts/desktop-tauri-build.mjs --target x86_64-apple-darwin --bundles dmg",
39+
"desktop:build:arm64": "node scripts/desktop-tauri-build.mjs --target aarch64-apple-darwin --bundles app,dmg",
40+
"desktop:build:x86_64": "node scripts/desktop-tauri-build.mjs --target x86_64-apple-darwin --bundles app,dmg",
4141
"desktop:build:linux": "node scripts/desktop-tauri-build.mjs",
4242
"desktop:build:linux:deb": "node scripts/desktop-tauri-build.mjs --bundles deb",
4343
"desktop:build:linux:rpm": "node scripts/desktop-tauri-build.mjs --bundles rpm",
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env node
2+
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'fs';
3+
import { basename, join } from 'path';
4+
5+
const args = parseArgs(process.argv.slice(2));
6+
const assetsDir = requireArg(args, 'assets-dir');
7+
const version = requireArg(args, 'version');
8+
const outDir = requireArg(args, 'out-dir');
9+
10+
const requiredPlatforms = parseListArg(
11+
args['required-platforms'] ||
12+
'windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64'
13+
);
14+
15+
if (!existsSync(assetsDir)) {
16+
fail(`Assets directory does not exist: ${assetsDir}`);
17+
}
18+
19+
rmSync(outDir, { recursive: true, force: true });
20+
mkdirSync(outDir, { recursive: true });
21+
22+
const collected = {};
23+
for (const sigPath of walkFiles(assetsDir).filter((file) => file.endsWith('.sig'))) {
24+
const bundlePath = sigPath.slice(0, -'.sig'.length);
25+
if (!existsSync(bundlePath) || !isUpdaterBundle(bundlePath)) {
26+
continue;
27+
}
28+
29+
const platform = inferPlatform(bundlePath);
30+
if (!platform) {
31+
console.warn(`[collect-updater] Skipping artifact with unknown platform: ${bundlePath}`);
32+
continue;
33+
}
34+
35+
if (collected[platform]) {
36+
fail(
37+
`Duplicate updater artifact for ${platform}: ${bundlePath} conflicts with ${collected[platform].source}`
38+
);
39+
}
40+
41+
const outputName = updaterOutputName(bundlePath, version, platform);
42+
const outputPath = join(outDir, outputName);
43+
const outputSigPath = `${outputPath}.sig`;
44+
copyFileSync(bundlePath, outputPath);
45+
copyFileSync(sigPath, outputSigPath);
46+
47+
collected[platform] = {
48+
source: bundlePath,
49+
output: outputPath,
50+
signature: outputSigPath,
51+
};
52+
console.log(`[collect-updater] ${platform}: ${basename(outputPath)}`);
53+
}
54+
55+
const missing = requiredPlatforms.filter((platform) => !collected[platform]);
56+
if (missing.length > 0) {
57+
const found = Object.keys(collected).sort();
58+
const signedArtifacts = walkFiles(assetsDir)
59+
.filter((file) => file.endsWith('.sig'))
60+
.map((file) => file.replace(/\\/g, '/'))
61+
.sort();
62+
console.error(`[collect-updater] Found platforms: ${found.length > 0 ? found.join(', ') : '(none)'}`);
63+
console.error('[collect-updater] Signed artifacts found:');
64+
for (const artifact of signedArtifacts) {
65+
console.error(`[collect-updater] ${artifact}`);
66+
}
67+
fail(`Missing required updater platforms: ${missing.join(', ')}`);
68+
}
69+
70+
console.log(`[collect-updater] Wrote ${Object.keys(collected).length} updater artifacts to ${outDir}`);
71+
72+
function parseArgs(rawArgs) {
73+
const parsed = {};
74+
for (let i = 0; i < rawArgs.length; i += 1) {
75+
const arg = rawArgs[i];
76+
if (!arg.startsWith('--')) {
77+
continue;
78+
}
79+
const key = arg.slice(2);
80+
const value = rawArgs[i + 1];
81+
if (!value || value.startsWith('--')) {
82+
fail(`Missing value for --${key}`);
83+
}
84+
parsed[key] = value;
85+
i += 1;
86+
}
87+
return parsed;
88+
}
89+
90+
function requireArg(parsed, key) {
91+
const value = parsed[key];
92+
if (!value) {
93+
fail(`Missing required argument --${key}`);
94+
}
95+
return value;
96+
}
97+
98+
function parseListArg(value) {
99+
return String(value)
100+
.split(',')
101+
.map((item) => item.trim())
102+
.filter(Boolean);
103+
}
104+
105+
function walkFiles(dir) {
106+
const files = [];
107+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
108+
const fullPath = join(dir, entry.name);
109+
if (entry.isDirectory()) {
110+
files.push(...walkFiles(fullPath));
111+
} else if (entry.isFile()) {
112+
files.push(fullPath);
113+
}
114+
}
115+
return files;
116+
}
117+
118+
function isUpdaterBundle(file) {
119+
const lower = file.toLowerCase();
120+
return (
121+
lower.endsWith('.appimage') ||
122+
lower.endsWith('.app.tar.gz') ||
123+
lower.endsWith('.tar.gz') ||
124+
lower.endsWith('.zip') ||
125+
lower.endsWith('.exe')
126+
);
127+
}
128+
129+
function updaterOutputName(file, version, platform) {
130+
const lower = file.toLowerCase();
131+
if (lower.endsWith('.app.tar.gz')) {
132+
return `BitFun_${version}_${platform}.app.tar.gz`;
133+
}
134+
if (lower.endsWith('.appimage')) {
135+
return `BitFun_${version}_${platform}.AppImage`;
136+
}
137+
if (lower.endsWith('.zip')) {
138+
return `BitFun_${version}_${platform}.zip`;
139+
}
140+
if (lower.endsWith('.exe')) {
141+
return `BitFun_${version}_${platform}-setup.exe`;
142+
}
143+
if (lower.endsWith('.tar.gz')) {
144+
return `BitFun_${version}_${platform}.tar.gz`;
145+
}
146+
fail(`Unsupported updater artifact extension: ${file}`);
147+
}
148+
149+
function inferPlatform(file) {
150+
const lower = file.replace(/\\/g, '/').toLowerCase();
151+
const arch = inferArch(lower);
152+
if (!arch) {
153+
return null;
154+
}
155+
156+
if (lower.endsWith('.zip') || lower.includes('setup.exe')) {
157+
return `windows-${arch}`;
158+
}
159+
if (lower.endsWith('.appimage')) {
160+
return `linux-${arch}`;
161+
}
162+
if (lower.includes('.appimage.tar.gz')) {
163+
return `linux-${arch}`;
164+
}
165+
if (lower.includes('.app.tar.gz')) {
166+
return `darwin-${arch}`;
167+
}
168+
169+
return null;
170+
}
171+
172+
function inferArch(name) {
173+
if (/(^|[\\/_.-])(x86_64|x64|amd64)([\\/_.-]|$)/.test(name)) {
174+
return 'x86_64';
175+
}
176+
if (/(^|[\\/_.-])(aarch64|arm64)([\\/_.-]|$)/.test(name)) {
177+
return 'aarch64';
178+
}
179+
return null;
180+
}
181+
182+
function fail(message) {
183+
console.error(`[collect-updater] ${message}`);
184+
process.exit(1);
185+
}

0 commit comments

Comments
 (0)