Skip to content

Commit c8e1e62

Browse files
joohwcursoragent
andcommitted
release(desktop): v0.1.4 with in-app desktop update
Add lightweight desktop self-update: check latest.txt, download the platform installer, launch it, and quit the app. Dev mode keeps update checks disabled like core updates. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c9b89e7 commit c8e1e62

13 files changed

Lines changed: 457 additions & 4 deletions

File tree

electron/desktop-update.js

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
const fs = require("node:fs");
2+
const https = require("node:https");
3+
const http = require("node:http");
4+
const os = require("node:os");
5+
const path = require("node:path");
6+
const { spawn } = require("node:child_process");
7+
const { URL } = require("node:url");
8+
9+
const DEFAULT_DOWNLOAD_BASE = "https://downloads.clovapi.com/desktop/latest";
10+
const DEFAULT_LATEST_URL = "https://downloads.clovapi.com/desktop/latest.txt";
11+
12+
const INSTALLER_BY_PLATFORM = {
13+
darwin: "clovapi-desktop-darwin-universal.dmg",
14+
win32: "clovapi-desktop-windows-x64.exe",
15+
};
16+
17+
function normalizeVersion(value) {
18+
return String(value || "")
19+
.trim()
20+
.replace(/^v/i, "");
21+
}
22+
23+
function compareVersions(left, right) {
24+
const a = normalizeVersion(left).split(".").map((part) => Number.parseInt(part, 10) || 0);
25+
const b = normalizeVersion(right).split(".").map((part) => Number.parseInt(part, 10) || 0);
26+
const length = Math.max(a.length, b.length);
27+
for (let index = 0; index < length; index += 1) {
28+
const av = a[index] || 0;
29+
const bv = b[index] || 0;
30+
if (av > bv) return 1;
31+
if (av < bv) return -1;
32+
}
33+
return 0;
34+
}
35+
36+
function isNewerVersion(latest, current) {
37+
if (!normalizeVersion(latest) || !normalizeVersion(current)) return false;
38+
return compareVersions(latest, current) > 0;
39+
}
40+
41+
function latestDesktopUrl() {
42+
return String(process.env.CLOVAPI_DESKTOP_LATEST_URL || DEFAULT_LATEST_URL).trim();
43+
}
44+
45+
function downloadBaseUrl() {
46+
return String(process.env.CLOVAPI_DESKTOP_DOWNLOAD_BASE || DEFAULT_DOWNLOAD_BASE).replace(/\/+$/, "");
47+
}
48+
49+
function installerFileName(platform = process.platform) {
50+
const name = INSTALLER_BY_PLATFORM[platform];
51+
if (!name) {
52+
throw new Error(`Desktop updates are not supported on ${platform}.`);
53+
}
54+
return name;
55+
}
56+
57+
function installerDownloadUrl(platform = process.platform) {
58+
return `${downloadBaseUrl()}/${installerFileName(platform)}`;
59+
}
60+
61+
function fetchText(url, timeoutMs = 15_000) {
62+
return new Promise((resolve, reject) => {
63+
const requestUrl = new URL(url);
64+
const transport = requestUrl.protocol === "http:" ? http : https;
65+
const request = transport.get(
66+
requestUrl,
67+
{
68+
headers: { "User-Agent": "ClovAPI-Switcher-Desktop-Update" },
69+
},
70+
(response) => {
71+
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
72+
response.resume();
73+
fetchText(new URL(response.headers.location, requestUrl).toString(), timeoutMs)
74+
.then(resolve)
75+
.catch(reject);
76+
return;
77+
}
78+
if (response.statusCode !== 200) {
79+
response.resume();
80+
reject(new Error(`HTTP ${response.statusCode || 0} fetching ${url}`));
81+
return;
82+
}
83+
const chunks = [];
84+
response.on("data", (chunk) => chunks.push(chunk));
85+
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
86+
},
87+
);
88+
request.setTimeout(timeoutMs, () => {
89+
request.destroy(new Error(`Timed out fetching ${url}`));
90+
});
91+
request.on("error", reject);
92+
});
93+
}
94+
95+
function downloadFile(url, outPath, timeoutMs = 30 * 60_000) {
96+
return new Promise((resolve, reject) => {
97+
const requestUrl = new URL(url);
98+
const transport = requestUrl.protocol === "http:" ? http : https;
99+
const request = transport.get(
100+
requestUrl,
101+
{
102+
headers: { "User-Agent": "ClovAPI-Switcher-Desktop-Update" },
103+
},
104+
(response) => {
105+
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
106+
response.resume();
107+
downloadFile(new URL(response.headers.location, requestUrl).toString(), outPath, timeoutMs)
108+
.then(resolve)
109+
.catch(reject);
110+
return;
111+
}
112+
if (response.statusCode !== 200) {
113+
response.resume();
114+
reject(new Error(`HTTP ${response.statusCode || 0} downloading ${url}`));
115+
return;
116+
}
117+
const file = fs.createWriteStream(outPath);
118+
response.pipe(file);
119+
file.on("finish", () => {
120+
file.close(() => resolve(outPath));
121+
});
122+
file.on("error", (error) => {
123+
file.close(() => {
124+
fs.rm(outPath, { force: true }, () => reject(error));
125+
});
126+
});
127+
},
128+
);
129+
request.setTimeout(timeoutMs, () => {
130+
request.destroy(new Error(`Timed out downloading ${url}`));
131+
});
132+
request.on("error", reject);
133+
});
134+
}
135+
136+
async function fetchLatestDesktopVersion() {
137+
const latest = (await fetchText(latestDesktopUrl())).trim();
138+
if (!latest) throw new Error("Desktop latest version response was empty.");
139+
return latest.startsWith("v") ? latest : `v${latest}`;
140+
}
141+
142+
async function checkDesktopUpdate(currentVersion) {
143+
const current = normalizeVersion(currentVersion);
144+
if (!current) {
145+
return { ok: false, error: "Current desktop version is unavailable." };
146+
}
147+
if (!INSTALLER_BY_PLATFORM[process.platform]) {
148+
return { ok: false, error: `Desktop updates are not supported on ${process.platform}.` };
149+
}
150+
151+
const latestTag = await fetchLatestDesktopVersion();
152+
const latest = normalizeVersion(latestTag);
153+
const upToDate = !isNewerVersion(latest, current);
154+
155+
return {
156+
ok: true,
157+
current_version: current,
158+
latest_version: latest,
159+
latest_tag: latestTag,
160+
up_to_date: upToDate,
161+
download_url: upToDate ? "" : installerDownloadUrl(),
162+
installer_name: installerFileName(),
163+
};
164+
}
165+
166+
function launchInstaller(installerPath) {
167+
if (process.platform === "win32") {
168+
spawn(installerPath, [], {
169+
detached: true,
170+
stdio: "ignore",
171+
windowsHide: false,
172+
}).unref();
173+
return;
174+
}
175+
if (process.platform === "darwin") {
176+
spawn("open", [installerPath], {
177+
detached: true,
178+
stdio: "ignore",
179+
}).unref();
180+
return;
181+
}
182+
throw new Error(`Desktop updates are not supported on ${process.platform}.`);
183+
}
184+
185+
async function downloadAndLaunchDesktopUpdate() {
186+
const fileName = installerFileName();
187+
const url = installerDownloadUrl();
188+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clovapi-desktop-update-"));
189+
const installerPath = path.join(tmpDir, fileName);
190+
await downloadFile(url, installerPath);
191+
launchInstaller(installerPath);
192+
return { ok: true, path: installerPath, url };
193+
}
194+
195+
module.exports = {
196+
compareVersions,
197+
isNewerVersion,
198+
normalizeVersion,
199+
installerDownloadUrl,
200+
fetchLatestDesktopVersion,
201+
checkDesktopUpdate,
202+
downloadAndLaunchDesktopUpdate,
203+
};

electron/desktop-update.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const assert = require("node:assert/strict");
2+
const test = require("node:test");
3+
4+
const { compareVersions, isNewerVersion, normalizeVersion } = require("./desktop-update");
5+
6+
test("normalizeVersion strips leading v", () => {
7+
assert.equal(normalizeVersion("v0.1.3"), "0.1.3");
8+
assert.equal(normalizeVersion("0.1.3"), "0.1.3");
9+
});
10+
11+
test("compareVersions orders semver parts", () => {
12+
assert.equal(compareVersions("0.1.3", "0.1.2"), 1);
13+
assert.equal(compareVersions("0.1.2", "0.1.3"), -1);
14+
assert.equal(compareVersions("0.1.2", "0.1.2"), 0);
15+
assert.equal(compareVersions("1.0.0", "0.9.9"), 1);
16+
});
17+
18+
test("isNewerVersion detects newer desktop releases", () => {
19+
assert.equal(isNewerVersion("0.1.4", "0.1.3"), true);
20+
assert.equal(isNewerVersion("0.1.3", "0.1.3"), false);
21+
assert.equal(isNewerVersion("0.1.2", "0.1.3"), false);
22+
});

electron/main.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
} = require("./clovapi-exec");
1313
const { buildTrayMenuModel, isValidTrayTab, trayStatusSummary, trayTooltip } = require("./tray-menu");
1414
const { cliBinPath, electronDevUserDataDir, electronUserDataDir } = require("./config-paths");
15+
const { checkDesktopUpdate, downloadAndLaunchDesktopUpdate } = require("./desktop-update");
1516
const { sanitizeForIpc } = require("./ipc-utils");
1617

1718
const isElectronDev =
@@ -594,6 +595,49 @@ function stopRunningProcess() {
594595

595596
ipcMain.handle("app:version", () => app.getVersion());
596597

598+
ipcMain.handle("desktop:check-update", async () => {
599+
if (isElectronDev) {
600+
return {
601+
ok: false,
602+
error: "Desktop update is disabled in Electron dev mode (ELECTRON_DEV=1).",
603+
};
604+
}
605+
try {
606+
const detail = await checkDesktopUpdate(app.getVersion());
607+
if (!detail.ok) {
608+
return { ok: false, error: detail.error || "Failed to check desktop update." };
609+
}
610+
return { ok: true, detail: sanitizeForIpc(detail) };
611+
} catch (error) {
612+
return {
613+
ok: false,
614+
error: error instanceof Error ? error.message : "Failed to check desktop update.",
615+
};
616+
}
617+
});
618+
619+
ipcMain.handle("desktop:install-update", async () => {
620+
if (isElectronDev) {
621+
return {
622+
ok: false,
623+
error: "Desktop update is disabled in Electron dev mode (ELECTRON_DEV=1).",
624+
};
625+
}
626+
try {
627+
const detail = await downloadAndLaunchDesktopUpdate();
628+
setTimeout(() => {
629+
quitting = true;
630+
app.quit();
631+
}, 400);
632+
return { ok: true, detail: sanitizeForIpc(detail) };
633+
} catch (error) {
634+
return {
635+
ok: false,
636+
error: error instanceof Error ? error.message : "Failed to install desktop update.",
637+
};
638+
}
639+
});
640+
597641
ipcMain.handle("cli:run", async (_event, payload) => {
598642
if (runningProcess) {
599643
return { ok: false, error: "A command is already running." };

electron/package.json

Lines changed: 2 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.1.3",
4+
"version": "0.1.4",
55
"description": "ClovAPI Switcher desktop app",
66
"main": "main.js",
77
"type": "commonjs",
@@ -13,7 +13,7 @@
1313
"build:icons": "node scripts/build-icons.mjs",
1414
"build:mac": "npm run build:ui && npm run build:icons && electron-builder --mac dmg",
1515
"build:win": "npm run build:ui && electron-builder --win nsis",
16-
"test": "node --test proxy-manager.test.js clovapi-exec.test.js tray-menu.test.js",
16+
"test": "node --test proxy-manager.test.js clovapi-exec.test.js tray-menu.test.js desktop-update.test.js",
1717
"start": "npm run build:ui && electron ."
1818
},
1919
"devDependencies": {

electron/preload.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ try {
102102
ipcRenderer.on("app:event", listener);
103103
return () => ipcRenderer.removeListener("app:event", listener);
104104
},
105+
checkUpdate() {
106+
return ipcRenderer.invoke("desktop:check-update");
107+
},
108+
installUpdate() {
109+
return ipcRenderer.invoke("desktop:install-update");
110+
},
105111
});
106112

107113
contextBridge.exposeInMainWorld("clovapiProfiles", {

electron/ui/src/components/ProxyPanel.svelte

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import { isElectronDev } from "../lib/constants";
55
import { i18n, t } from "../lib/i18n";
66
import {
7+
checkAppUpdate,
78
checkCoreUpdate,
9+
installAppUpdate,
810
installCoreUpdate,
911
restartLocalProxy,
1012
runProxyHealthTest,
@@ -35,6 +37,7 @@
3537
restart: t("proxy.restart"),
3638
checkUpdate: t("proxy.checkUpdate"),
3739
installUpdate: t("proxy.installUpdate"),
40+
appInstallUpdate: t("proxy.appInstallUpdate"),
3841
updating: t("proxy.updating"),
3942
updateDisabledInDev: t("proxy.updateDisabledInDev"),
4043
};
@@ -44,6 +47,9 @@
4447
4548
const proxyHealthTest = $derived(store.proxyHealthTest);
4649
const proxyHealthTesting = $derived(proxyHealthTest?.status === "testing");
50+
const appUpdateCheck = $derived(store.appUpdateCheck);
51+
const appUpdateTesting = $derived(appUpdateCheck?.status === "testing" || store.appUpdating);
52+
const appUpdateBusy = $derived(store.running || appUpdateTesting);
4753
const coreUpdateCheck = $derived(store.coreUpdateCheck);
4854
const coreUpdateTesting = $derived(coreUpdateCheck?.status === "testing" || store.coreUpdating);
4955
const coreUpdateBusy = $derived(store.running || coreUpdateTesting);
@@ -77,8 +83,29 @@
7783
{/snippet}
7884
</ListRow>
7985

80-
<ListRow title={copy.appVersion} lines={[copy.appVersionLine]}>
81-
{#snippet actions()}{/snippet}
86+
<ListRow
87+
title={copy.appVersion}
88+
lines={electronDev ? [copy.appVersionLine, copy.updateDisabledInDev] : [copy.appVersionLine]}
89+
testStatus={electronDev ? "" : rowTestStatus(appUpdateCheck?.status)}
90+
testSummary={electronDev ? "" : appUpdateCheck?.summary || ""}
91+
>
92+
{#snippet actions()}
93+
{#if !electronDev}
94+
<Button
95+
size="sm"
96+
variant="outline"
97+
disabled={appUpdateBusy}
98+
onclick={() => void checkAppUpdate()}
99+
>
100+
{appUpdateTesting && !store.appUpdating ? copy.testing : copy.checkUpdate}
101+
</Button>
102+
{#if store.appUpdateAvailable}
103+
<Button size="sm" disabled={appUpdateBusy} onclick={() => void installAppUpdate()}>
104+
{store.appUpdating ? copy.updating : copy.appInstallUpdate}
105+
</Button>
106+
{/if}
107+
{/if}
108+
{/snippet}
82109
</ListRow>
83110

84111
<ListRow

electron/ui/src/global.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ type AppEventPayload =
8080

8181
type DesktopBridge = {
8282
onAppEvent(callback: (payload: AppEventPayload) => void): () => void;
83+
checkUpdate?(): Promise<{
84+
ok?: boolean;
85+
error?: string;
86+
detail?: {
87+
current_version?: string;
88+
latest_version?: string;
89+
up_to_date?: boolean;
90+
download_url?: string;
91+
};
92+
}>;
93+
installUpdate?(): Promise<{ ok?: boolean; error?: string }>;
8394
};
8495

8596
type ProxyConfig = {

0 commit comments

Comments
 (0)