Skip to content

Commit 0e6745f

Browse files
joohwcursoragent
andcommitted
release(desktop): v0.1.6 with header update badge and versioned downloads
Poll for desktop updates every 10 minutes, show a header install button only when an update is available, download from versioned R2 paths to avoid CDN stale latest artifacts, and tighten Windows title bar drag handling. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f85e61d commit 0e6745f

14 files changed

Lines changed: 179 additions & 58 deletions

File tree

electron/desktop-update.js

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const path = require("node:path");
66
const { spawn } = require("node:child_process");
77
const { URL } = require("node:url");
88

9-
const DEFAULT_DOWNLOAD_BASE = "https://downloads.clovapi.com/desktop/latest";
9+
const DEFAULT_DOWNLOAD_ROOT = "https://downloads.clovapi.com/desktop";
1010
const DEFAULT_LATEST_URL = "https://downloads.clovapi.com/desktop/latest.txt";
1111

1212
const INSTALLER_BY_PLATFORM = {
@@ -42,8 +42,20 @@ function latestDesktopUrl() {
4242
return String(process.env.CLOVAPI_DESKTOP_LATEST_URL || DEFAULT_LATEST_URL).trim();
4343
}
4444

45-
function downloadBaseUrl() {
46-
return String(process.env.CLOVAPI_DESKTOP_DOWNLOAD_BASE || DEFAULT_DOWNLOAD_BASE).replace(/\/+$/, "");
45+
function desktopDownloadRoot() {
46+
const override = String(process.env.CLOVAPI_DESKTOP_DOWNLOAD_ROOT || "").trim();
47+
if (override) return override.replace(/\/+$/, "");
48+
const legacyBase = String(process.env.CLOVAPI_DESKTOP_DOWNLOAD_BASE || "").trim().replace(/\/+$/, "");
49+
if (legacyBase.endsWith("/latest")) {
50+
return legacyBase.slice(0, -"/latest".length);
51+
}
52+
return DEFAULT_DOWNLOAD_ROOT;
53+
}
54+
55+
function normalizeTag(value) {
56+
const trimmed = String(value || "").trim();
57+
if (!trimmed) return "";
58+
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
4759
}
4860

4961
function installerFileName(platform = process.platform) {
@@ -54,8 +66,12 @@ function installerFileName(platform = process.platform) {
5466
return name;
5567
}
5668

57-
function installerDownloadUrl(platform = process.platform) {
58-
return `${downloadBaseUrl()}/${installerFileName(platform)}`;
69+
function installerDownloadUrl(versionTag, platform = process.platform) {
70+
const tag = normalizeTag(versionTag);
71+
if (!tag) {
72+
throw new Error("Desktop version tag is required.");
73+
}
74+
return `${desktopDownloadRoot()}/${tag}/${installerFileName(platform)}`;
5975
}
6076

6177
function fetchText(url, timeoutMs = 15_000) {
@@ -158,7 +174,7 @@ async function checkDesktopUpdate(currentVersion) {
158174
latest_version: latest,
159175
latest_tag: latestTag,
160176
up_to_date: upToDate,
161-
download_url: upToDate ? "" : installerDownloadUrl(),
177+
download_url: upToDate ? "" : installerDownloadUrl(latestTag),
162178
installer_name: installerFileName(),
163179
};
164180
}
@@ -183,19 +199,22 @@ function launchInstaller(installerPath) {
183199
}
184200

185201
async function downloadAndLaunchDesktopUpdate() {
202+
const latestTag = await fetchLatestDesktopVersion();
186203
const fileName = installerFileName();
187-
const url = installerDownloadUrl();
204+
const url = installerDownloadUrl(latestTag);
188205
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clovapi-desktop-update-"));
189206
const installerPath = path.join(tmpDir, fileName);
190207
await downloadFile(url, installerPath);
191208
launchInstaller(installerPath);
192-
return { ok: true, path: installerPath, url };
209+
return { ok: true, path: installerPath, url, latest_tag: latestTag };
193210
}
194211

195212
module.exports = {
196213
compareVersions,
197214
isNewerVersion,
198215
normalizeVersion,
216+
normalizeTag,
217+
desktopDownloadRoot,
199218
installerDownloadUrl,
200219
fetchLatestDesktopVersion,
201220
checkDesktopUpdate,

electron/desktop-update.test.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
const assert = require("node:assert/strict");
22
const test = require("node:test");
33

4-
const { compareVersions, isNewerVersion, normalizeVersion } = require("./desktop-update");
4+
const {
5+
compareVersions,
6+
installerDownloadUrl,
7+
isNewerVersion,
8+
normalizeVersion,
9+
} = require("./desktop-update");
510

611
test("normalizeVersion strips leading v", () => {
712
assert.equal(normalizeVersion("v0.1.3"), "0.1.3");
@@ -20,3 +25,14 @@ test("isNewerVersion detects newer desktop releases", () => {
2025
assert.equal(isNewerVersion("0.1.3", "0.1.3"), false);
2126
assert.equal(isNewerVersion("0.1.2", "0.1.3"), false);
2227
});
28+
29+
test("installerDownloadUrl uses versioned desktop path", () => {
30+
assert.equal(
31+
installerDownloadUrl("v0.1.5", "win32"),
32+
"https://downloads.clovapi.com/desktop/v0.1.5/clovapi-desktop-windows-x64.exe",
33+
);
34+
assert.equal(
35+
installerDownloadUrl("0.1.5", "darwin"),
36+
"https://downloads.clovapi.com/desktop/v0.1.5/clovapi-desktop-darwin-universal.dmg",
37+
);
38+
});

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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "clovapi-switcher",
33
"private": true,
4-
"version": "0.1.5",
4+
"version": "0.1.6",
55
"description": "ClovAPI Switcher desktop app",
66
"main": "main.js",
77
"type": "commonjs",

electron/ui/src/App.svelte

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
import SettingsPanel from "./components/SettingsPanel.svelte";
1010
import ProfileDialog from "./components/ProfileDialog.svelte";
1111
import ModelDialog from "./components/ModelDialog.svelte";
12+
import { Button } from "$lib/components/ui/button/index.js";
1213
import { Toaster } from "$lib/components/ui/sonner/index.js";
14+
import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
1315
import { i18n, t } from "./lib/i18n";
14-
import { initApp, setActiveTab, store, type TabId } from "./lib/store.svelte";
16+
import { initApp, installAppUpdate, setActiveTab, store, type TabId } from "./lib/store.svelte";
1517
import { isElectronRenderer } from "./lib/constants";
1618
1719
const inElectron = isElectronRenderer();
20+
const showAppUpdateBadge = $derived(inElectron && store.appUpdateAvailable);
1821
1922
onMount(() => {
2023
void initApp();
@@ -46,6 +49,9 @@
4649
settings: t("tabs.settings"),
4750
},
4851
browserBanner: t("toast.profilesBridgeBrowser"),
52+
updateBadge: t("app.updateBadge", {
53+
latest: store.appLatestVersion || store.appVersion || "?",
54+
}),
4955
};
5056
});
5157
</script>
@@ -56,12 +62,35 @@
5662
</div>
5763
{/if}
5864

65+
{#if inElectron}
66+
<div class="electron-titlebar-drag-region" aria-hidden="true"></div>
67+
{/if}
68+
5969
<main
6070
class="mx-auto flex min-h-svh w-full max-w-3xl flex-col px-5 py-5 {inElectron ? 'electron-window-chrome' : ''}"
6171
>
62-
<header class="mb-5 shrink-0 {inElectron ? 'electron-titlebar-drag' : ''}">
63-
<h1 class="text-lg font-semibold tracking-tight">ClovAPI Switcher</h1>
64-
<p class="mt-1 text-xs text-muted-foreground">{copy.subtitle}</p>
72+
<header class="mb-5 shrink-0 select-none {inElectron ? 'pt-4' : ''}">
73+
<div class="flex items-center justify-between gap-3">
74+
<div class="min-w-0 flex-1 {inElectron ? 'electron-titlebar-drag' : ''}">
75+
<h1 class="text-lg font-semibold tracking-tight">ClovAPI Switcher</h1>
76+
<p class="mt-1 text-xs text-muted-foreground">{copy.subtitle}</p>
77+
</div>
78+
{#if showAppUpdateBadge}
79+
<div class="electron-no-drag shrink-0">
80+
<Button
81+
variant="default"
82+
size="icon-sm"
83+
class="rounded-full"
84+
aria-label={copy.updateBadge}
85+
title={copy.updateBadge}
86+
disabled={store.appUpdating}
87+
onclick={() => void installAppUpdate()}
88+
>
89+
<ArrowUpIcon />
90+
</Button>
91+
</div>
92+
{/if}
93+
</div>
6594
</header>
6695

6796
<Tabs.Root value={store.activeTab} onValueChange={onTabChange} class="flex min-h-0 flex-1 flex-col gap-4">

electron/ui/src/app.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,23 @@
100100
padding-top: env(titlebar-area-height, 32px);
101101
}
102102

103+
.electron-titlebar-drag-region {
104+
position: fixed;
105+
top: env(titlebar-area-y, 0);
106+
left: env(titlebar-area-x, 0);
107+
width: env(titlebar-area-width, 100%);
108+
height: env(titlebar-area-height, 32px);
109+
-webkit-app-region: drag;
110+
z-index: 20;
111+
}
112+
103113
.electron-titlebar-drag {
104114
-webkit-app-region: drag;
105115
}
116+
117+
.electron-no-drag {
118+
-webkit-app-region: no-drag;
119+
}
106120
}
107121

108122
[data-sonner-toaster] {

electron/ui/src/lib/components/ui/tabs/tabs-list.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { tv, type VariantProps } from "tailwind-variants";
33
44
export const tabsListVariants = tv({
5-
base: "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
5+
base: "select-none rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
66
variants: {
77
variant: {
88
default: "cn-tabs-list-variant-default bg-muted",

electron/ui/src/lib/components/ui/tabs/tabs-trigger.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
bind:ref
1414
data-slot="tabs-trigger"
1515
class={cn(
16-
"gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground data-[state=active]:text-foreground dark:text-muted-foreground dark:hover:text-foreground dark:data-[state=active]:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
16+
"select-none gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground data-[state=active]:text-foreground dark:text-muted-foreground dark:hover:text-foreground dark:data-[state=active]:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
1717
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
1818
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
1919
className

electron/ui/src/lib/i18n/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const en = {
22
app: {
33
subtitle: "Manage local Agent APIs in one place — ClaudeCli / CodexCli subscriptions supported",
4+
updateBadge: "Install update v{latest}",
45
},
56
tabs: {
67
cli: "CLI",

electron/ui/src/lib/i18n/locales/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { MessageTree } from "./en";
33
const zh = {
44
app: {
55
subtitle: "一键管理本地所有 Agent 的 API,支持 ClaudeCli / CodexCli 订阅",
6+
updateBadge: "安装更新 v{latest}",
67
},
78
tabs: {
89
cli: "CLI",

0 commit comments

Comments
 (0)