Skip to content

Commit c878230

Browse files
author
joo
committed
feat: polish desktop update flow
1 parent cf744b5 commit c878230

15 files changed

Lines changed: 289 additions & 30 deletions

electron/desktop-update.js

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,25 @@ function fetchText(url, timeoutMs = 15_000) {
109109
});
110110
}
111111

112-
function downloadFile(url, outPath, timeoutMs = 30 * 60_000) {
112+
function normalizeDownloadProgress(downloadedBytes, totalBytes) {
113+
const downloaded = Math.max(0, Number(downloadedBytes) || 0);
114+
const total = Math.max(0, Number(totalBytes) || 0);
115+
const percent = total > 0 ? Math.min(100, Math.max(0, Math.round((downloaded / total) * 100))) : 0;
116+
return {
117+
received_bytes: downloaded,
118+
total_bytes: total,
119+
percent,
120+
};
121+
}
122+
123+
function downloadFile(url, outPath, timeoutMs = 30 * 60_000, onProgress = null) {
113124
return new Promise((resolve, reject) => {
114125
const requestUrl = new URL(url);
115126
const transport = requestUrl.protocol === "http:" ? http : https;
127+
const emitProgress =
128+
typeof onProgress === "function"
129+
? (downloadedBytes, totalBytes) => onProgress(normalizeDownloadProgress(downloadedBytes, totalBytes))
130+
: () => {};
116131
const request = transport.get(
117132
requestUrl,
118133
{
@@ -121,7 +136,7 @@ function downloadFile(url, outPath, timeoutMs = 30 * 60_000) {
121136
(response) => {
122137
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
123138
response.resume();
124-
downloadFile(new URL(response.headers.location, requestUrl).toString(), outPath, timeoutMs)
139+
downloadFile(new URL(response.headers.location, requestUrl).toString(), outPath, timeoutMs, onProgress)
125140
.then(resolve)
126141
.catch(reject);
127142
return;
@@ -131,10 +146,21 @@ function downloadFile(url, outPath, timeoutMs = 30 * 60_000) {
131146
reject(new Error(`HTTP ${response.statusCode || 0} downloading ${url}`));
132147
return;
133148
}
149+
const totalBytes = Number.parseInt(String(response.headers["content-length"] || ""), 10) || 0;
150+
let downloadedBytes = 0;
151+
emitProgress(0, totalBytes);
152+
response.on("data", (chunk) => {
153+
downloadedBytes += chunk.length;
154+
emitProgress(downloadedBytes, totalBytes);
155+
});
156+
response.on("error", reject);
134157
const file = fs.createWriteStream(outPath);
135158
response.pipe(file);
136159
file.on("finish", () => {
137-
file.close(() => resolve(outPath));
160+
file.close(() => {
161+
emitProgress(totalBytes > 0 ? totalBytes : downloadedBytes, totalBytes || downloadedBytes);
162+
resolve(outPath);
163+
});
138164
});
139165
file.on("error", (error) => {
140166
file.close(() => {
@@ -242,6 +268,98 @@ function installerLaunchArgs(installDir = resolveDesktopInstallDir()) {
242268
return [];
243269
}
244270

271+
function macOSInstallScriptContent() {
272+
return `#!/bin/sh
273+
set -u
274+
275+
DMG_PATH="$1"
276+
TARGET_APP="$2"
277+
PARENT_PID="$3"
278+
LOG_PATH="$4"
279+
280+
log() {
281+
printf '%s %s\\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_PATH"
282+
}
283+
284+
fallback_open_dmg() {
285+
log "Falling back to opening DMG"
286+
open "$DMG_PATH" >> "$LOG_PATH" 2>&1 || true
287+
}
288+
289+
while kill -0 "$PARENT_PID" 2>/dev/null; do
290+
sleep 0.2
291+
done
292+
293+
MOUNT_DIR="$(mktemp -d "/tmp/clovapi-desktop-dmg.XXXXXX")"
294+
cleanup() {
295+
hdiutil detach "$MOUNT_DIR" -quiet >> "$LOG_PATH" 2>&1 || true
296+
rmdir "$MOUNT_DIR" >> "$LOG_PATH" 2>&1 || true
297+
}
298+
trap cleanup EXIT
299+
300+
log "Attaching DMG: $DMG_PATH"
301+
if ! hdiutil attach "$DMG_PATH" -nobrowse -quiet -mountpoint "$MOUNT_DIR" >> "$LOG_PATH" 2>&1; then
302+
fallback_open_dmg
303+
exit 1
304+
fi
305+
306+
SOURCE_APP="$(find "$MOUNT_DIR" -maxdepth 1 -type d -name "*.app" | head -n 1)"
307+
if [ -z "$SOURCE_APP" ]; then
308+
log "No .app bundle found in DMG"
309+
fallback_open_dmg
310+
exit 1
311+
fi
312+
313+
TARGET_DIR="$(dirname "$TARGET_APP")"
314+
TARGET_NAME="$(basename "$TARGET_APP")"
315+
STAGING_APP="$TARGET_DIR/.$TARGET_NAME.updating.$$"
316+
317+
log "Copying $SOURCE_APP to staging path $STAGING_APP"
318+
rm -rf "$STAGING_APP" >> "$LOG_PATH" 2>&1 || true
319+
if ! ditto "$SOURCE_APP" "$STAGING_APP" >> "$LOG_PATH" 2>&1; then
320+
rm -rf "$STAGING_APP" >> "$LOG_PATH" 2>&1 || true
321+
fallback_open_dmg
322+
exit 1
323+
fi
324+
325+
log "Replacing installed app at $TARGET_APP"
326+
if ! rm -rf "$TARGET_APP" >> "$LOG_PATH" 2>&1; then
327+
rm -rf "$STAGING_APP" >> "$LOG_PATH" 2>&1 || true
328+
fallback_open_dmg
329+
exit 1
330+
fi
331+
if ! mv "$STAGING_APP" "$TARGET_APP" >> "$LOG_PATH" 2>&1; then
332+
fallback_open_dmg
333+
exit 1
334+
fi
335+
336+
xattr -dr com.apple.quarantine "$TARGET_APP" >> "$LOG_PATH" 2>&1 || true
337+
log "Opening updated app: $TARGET_APP"
338+
open "$TARGET_APP" >> "$LOG_PATH" 2>&1 || true
339+
`;
340+
}
341+
342+
function launchMacOSInstaller(installerPath) {
343+
const targetApp = resolveDesktopInstallDir();
344+
if (!targetApp || !targetApp.endsWith(".app")) {
345+
spawn("open", [installerPath], {
346+
detached: true,
347+
stdio: "ignore",
348+
}).unref();
349+
return { mode: "open-dmg" };
350+
}
351+
352+
const workDir = path.dirname(installerPath);
353+
const scriptPath = path.join(workDir, "install-macos.sh");
354+
const logPath = path.join(workDir, "install-macos.log");
355+
fs.writeFileSync(scriptPath, macOSInstallScriptContent(), { mode: 0o700 });
356+
spawn("sh", [scriptPath, installerPath, targetApp, String(process.pid), logPath], {
357+
detached: true,
358+
stdio: "ignore",
359+
}).unref();
360+
return { mode: "auto-install", target_app: targetApp, log_path: logPath };
361+
}
362+
245363
function launchInstaller(installerPath) {
246364
if (process.platform === "win32") {
247365
spawn(installerPath, installerLaunchArgs(), {
@@ -252,31 +370,28 @@ function launchInstaller(installerPath) {
252370
return;
253371
}
254372
if (process.platform === "darwin") {
255-
spawn("open", [installerPath], {
256-
detached: true,
257-
stdio: "ignore",
258-
}).unref();
259-
return;
373+
return launchMacOSInstaller(installerPath);
260374
}
261375
throw new Error(`Desktop updates are not supported on ${process.platform}.`);
262376
}
263377

264-
async function downloadAndLaunchDesktopUpdate() {
378+
async function downloadAndLaunchDesktopUpdate(options = {}) {
379+
const onProgress = typeof options?.onProgress === "function" ? options.onProgress : null;
265380
const latestTag = await fetchLatestDesktopVersion();
266381
const fileName = installerFileName();
267382
const url = installerDownloadUrl(latestTag);
268383
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clovapi-desktop-update-"));
269384
const installerPath = path.join(tmpDir, fileName);
270-
await downloadFile(url, installerPath);
385+
await downloadFile(url, installerPath, undefined, onProgress);
271386
let verified = false;
272387
try {
273388
verified = await verifyInstallerChecksum(installerPath, latestTag);
274389
} catch (error) {
275390
fs.rm(tmpDir, { recursive: true, force: true }, () => {});
276391
throw error;
277392
}
278-
launchInstaller(installerPath);
279-
return { ok: true, path: installerPath, url, latest_tag: latestTag, checksum_verified: verified };
393+
const launch = launchInstaller(installerPath) || {};
394+
return { ok: true, path: installerPath, url, latest_tag: latestTag, checksum_verified: verified, launch };
280395
}
281396

282397
module.exports = {
@@ -290,7 +405,10 @@ module.exports = {
290405
verifyInstallerChecksum,
291406
fetchLatestDesktopVersion,
292407
checkDesktopUpdate,
408+
downloadFile,
293409
downloadAndLaunchDesktopUpdate,
410+
normalizeDownloadProgress,
294411
resolveDesktopInstallDir,
295412
installerLaunchArgs,
413+
macOSInstallScriptContent,
296414
};

electron/desktop-update.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
const assert = require("node:assert/strict");
2+
const fs = require("node:fs/promises");
3+
const http = require("node:http");
4+
const os = require("node:os");
5+
const path = require("node:path");
26
const test = require("node:test");
37

48
const {
59
compareVersions,
10+
downloadFile,
611
installerDownloadUrl,
712
isNewerVersion,
13+
macOSInstallScriptContent,
814
normalizeVersion,
915
} = require("./desktop-update");
1016

@@ -49,3 +55,44 @@ test("installerLaunchArgs uses NSIS silent flags on Windows", () => {
4955
]);
5056
assert.deepEqual(installerLaunchArgs(""), ["/S"]);
5157
});
58+
59+
test("macOS installer script mounts DMG, replaces app, and reopens it", () => {
60+
const script = macOSInstallScriptContent();
61+
assert.match(script, /hdiutil attach "\$DMG_PATH"/);
62+
assert.match(script, /find "\$MOUNT_DIR" .* -name "\*\.app"/);
63+
assert.match(script, /ditto "\$SOURCE_APP" "\$STAGING_APP"/);
64+
assert.match(script, /rm -rf "\$TARGET_APP"/);
65+
assert.match(script, /mv "\$STAGING_APP" "\$TARGET_APP"/);
66+
assert.match(script, /open "\$TARGET_APP"/);
67+
assert.match(script, /fallback_open_dmg/);
68+
});
69+
70+
test("downloadFile emits byte progress", async () => {
71+
const body = Buffer.from("clovapi update progress");
72+
const server = http.createServer((request, response) => {
73+
response.writeHead(200, {
74+
"Content-Length": body.length,
75+
"Content-Type": "application/octet-stream",
76+
});
77+
response.end(body);
78+
});
79+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
80+
const address = server.address();
81+
assert.equal(typeof address, "object");
82+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clovapi-update-test-"));
83+
const outPath = path.join(tmpDir, "installer.bin");
84+
const progress = [];
85+
86+
try {
87+
await downloadFile(`http://127.0.0.1:${address.port}/installer.bin`, outPath, 5_000, (entry) => {
88+
progress.push(entry);
89+
});
90+
assert.deepEqual(await fs.readFile(outPath), body);
91+
assert.equal(progress.at(-1)?.percent, 100);
92+
assert.equal(progress.at(-1)?.received_bytes, body.length);
93+
assert.equal(progress.at(-1)?.total_bytes, body.length);
94+
} finally {
95+
server.close();
96+
await fs.rm(tmpDir, { recursive: true, force: true });
97+
}
98+
});

electron/main.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,18 @@ ipcMain.handle("desktop:install-update", async () => {
644644
};
645645
}
646646
try {
647-
const detail = await downloadAndLaunchDesktopUpdate();
647+
const detail = await downloadAndLaunchDesktopUpdate({
648+
onProgress(progress) {
649+
dispatchRendererEvent({
650+
type: "desktop-update-progress",
651+
...sanitizeForIpc(progress),
652+
});
653+
},
654+
});
655+
dispatchRendererEvent({
656+
type: "desktop-update-progress",
657+
percent: 100,
658+
});
648659
setTimeout(() => {
649660
quitting = true;
650661
app.quit();

electron/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

electron/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "clovapi-switcher",
33
"private": true,
4-
"version": "0.2.4",
4+
"version": "0.2.5",
55
"description": "ClovAPI Switcher desktop app",
66
"main": "main.js",
77
"type": "commonjs",
@@ -86,7 +86,9 @@
8686
"target": [
8787
{
8888
"target": "nsis",
89-
"arch": ["x64"]
89+
"arch": [
90+
"x64"
91+
]
9092
}
9193
],
9294
"artifactName": "clovapi-desktop-windows-${arch}.${ext}"

electron/ui/src/App.svelte

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
1919
const inElectron = isElectronRenderer();
2020
const showAppUpdateBadge = $derived(inElectron && store.appUpdateAvailable);
21+
const appUpdateProgress = $derived(Math.min(100, Math.max(0, Math.round(store.appUpdateProgress || 0))));
2122
2223
onMount(() => {
2324
void initApp();
@@ -54,6 +55,9 @@
5455
}),
5556
};
5657
});
58+
const appUpdateButtonTitle = $derived(
59+
store.appUpdating ? `${copy.updateBadge} · ${appUpdateProgress}%` : copy.updateBadge,
60+
);
5761
</script>
5862

5963
{#if !inElectron}
@@ -81,12 +85,39 @@
8185
variant="default"
8286
size="icon-sm"
8387
class="rounded-full"
84-
aria-label={copy.updateBadge}
85-
title={copy.updateBadge}
88+
aria-label={appUpdateButtonTitle}
89+
title={appUpdateButtonTitle}
8690
disabled={store.appUpdating}
8791
onclick={() => void installAppUpdate()}
8892
>
89-
<ArrowUpIcon />
93+
{#if store.appUpdating}
94+
<span class="grid size-5 place-items-center" aria-hidden="true">
95+
<svg class="size-5 -rotate-90" viewBox="0 0 36 36">
96+
<circle
97+
cx="18"
98+
cy="18"
99+
r="15.5"
100+
fill="none"
101+
stroke="currentColor"
102+
stroke-width="4"
103+
opacity="0.25"
104+
/>
105+
<circle
106+
cx="18"
107+
cy="18"
108+
r="15.5"
109+
fill="none"
110+
stroke="currentColor"
111+
stroke-width="4"
112+
stroke-linecap="round"
113+
pathLength="100"
114+
stroke-dasharray={`${appUpdateProgress} 100`}
115+
/>
116+
</svg>
117+
</span>
118+
{:else}
119+
<ArrowUpIcon />
120+
{/if}
90121
</Button>
91122
</div>
92123
{/if}

0 commit comments

Comments
 (0)