Skip to content

Commit 901f378

Browse files
committed
feat: surface app updates in the shell
Add a persistent in-window update banner that appears when a release is available, lets users download or restart to install, and remembers dismissals per remote version. Keep sidebar Settings pinned by making only the Agents list scroll, and update release metadata/docs to point electron-updater at the current beautyfree/skiller repository. Tests: bun run typecheck; bun run build Codex: implemented updater banner, sidebar layout fix, release repo config/docs update, and verification.
1 parent a30d698 commit 901f378

8 files changed

Lines changed: 366 additions & 59 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Theme, accent color, window blur, language, close behavior, and cache controls
8686

8787
## Installation
8888

89-
Grab the installer for your OS from the [**latest release**](https://github.com/beautyfree/skiller-skills-desktop-manager/releases/latest):
89+
Grab the installer for your OS from the [**latest release**](https://github.com/beautyfree/skiller/releases/latest):
9090

9191
| OS | File | Notes |
9292
| --- | --- | --- |
@@ -120,4 +120,4 @@ Open an issue first if you want to discuss a feature or behavior change.
120120

121121
## License
122122

123-
[MIT](./LICENSE)
123+
[MIT](./LICENSE)

docs/DEVELOPMENT.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ Inside a Windows 10/11 VM:
9494

9595
```powershell
9696
# Prereqs: Git, Bun, Visual Studio Build Tools (Desktop development with C++), Python 3
97-
git clone https://github.com/beautyfree/skiller-desktop-skills-manager
98-
cd skiller-desktop-skills-manager
97+
git clone https://github.com/beautyfree/skiller
98+
cd skiller
9999
bun install
100100
bunx electron-builder install-app-deps
101101
bun run dist:win
@@ -162,7 +162,7 @@ If UI renders in browser but not in Electron:
162162

163163
`electron-updater` is wired in `src/main/app-updater.ts`:
164164

165-
- **Config**`electron-builder.yml` sets `publish.provider: github` pointing at `beautyfree/skiller-desktop-skills-manager`. When you `bun run dist:mac/win/linux`, electron-builder writes `latest-mac.yml` / `latest-linux.yml` / `latest.yml` (for Windows) alongside the DMG/AppImage/EXE. GitHub Actions uploads all of them to the tagged Release.
165+
- **Config**`electron-builder.yml` sets `publish.provider: github` pointing at `beautyfree/skiller`. When you `bun run dist:mac/win/linux`, electron-builder writes `latest-mac.yml` / `latest-linux.yml` / `latest.yml` (for Windows) alongside the DMG/AppImage/EXE. GitHub Actions uploads all of them to the tagged Release.
166166
- **Runtime**`initAppUpdater()` runs `autoUpdater.checkForUpdates()` on launch and every 6 hours. `autoDownload = false`, so the user clicks **Download update** in Settings → App Updates after an update is announced. Progress + ready state stream to the renderer through the `app_update_status_changed` push channel; the Settings page renders the current state and offers **Restart & install** once the update is downloaded.
167167
- **Delta patches** — electron-builder generates `.blockmap` files automatically when `writeUpdateInfo: false` is not set. We currently ship full DMGs — blockmap-based deltas can be added later when update volume justifies the extra artifacts.
168168
- **Dev**`app.isPackaged === false` in dev, so the updater short-circuits and stays in the `idle` state. No spam.

electron-builder.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,4 @@ linux:
102102
publish:
103103
provider: github
104104
owner: beautyfree
105-
repo: skiller-desktop-skills-manager
105+
repo: skiller

src/mainview/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import OnboardingWizard from './components/OnboardingWizard'
1818
import { useTheme } from './hooks/useTheme'
1919
import CloseConfirmDialog from './components/CloseConfirmDialog'
2020
import { useToast } from './components/ToastProvider'
21+
import AppUpdateBanner from './components/AppUpdateBanner'
2122

2223
const GITHUB_REPO_URL =
2324
'https://github.com/beautyfree/skiller-skills-desktop-manager'
@@ -309,6 +310,7 @@ function AppInner() {
309310
open={closeDialogOpen}
310311
onDone={() => setCloseDialogOpen(false)}
311312
/>
313+
<AppUpdateBanner />
312314
{onboardingOpen && (
313315
<OnboardingWizard onClose={() => setOnboardingOpen(false)} />
314316
)}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { useCallback, useEffect, useMemo, useState } from 'react'
2+
import { Download, RotateCw, X } from 'lucide-react'
3+
import { useTranslation } from 'react-i18next'
4+
5+
import type { AppUpdateStatusJson } from '@/shared/rpc-schema'
6+
import { invoke, listen } from '@/mainview/lib/native'
7+
import { cn } from '@/mainview/lib/utils'
8+
import { Button } from '@/mainview/components/ui/button'
9+
import { useToast } from '@/mainview/components/ToastProvider'
10+
11+
const DISMISSED_UPDATE_VERSION_KEY = 'skiller.dismissed_update_version'
12+
13+
function readDismissedVersion(): string | null {
14+
try {
15+
return localStorage.getItem(DISMISSED_UPDATE_VERSION_KEY)
16+
} catch {
17+
return null
18+
}
19+
}
20+
21+
function writeDismissedVersion(version: string | null): void {
22+
try {
23+
if (version) localStorage.setItem(DISMISSED_UPDATE_VERSION_KEY, version)
24+
} catch {
25+
// Ignore storage errors in private/locked environments.
26+
}
27+
}
28+
29+
export default function AppUpdateBanner() {
30+
const { t } = useTranslation()
31+
const { toast } = useToast()
32+
const [status, setStatus] = useState<AppUpdateStatusJson | null>(null)
33+
const [busy, setBusy] = useState<'idle' | 'downloading' | 'applying'>('idle')
34+
const [dismissedVersion, setDismissedVersion] = useState(() =>
35+
readDismissedVersion(),
36+
)
37+
38+
useEffect(() => {
39+
let cancelled = false
40+
let unlisten: (() => void) | undefined
41+
42+
invoke('app_update_status')
43+
.then((snapshot) => {
44+
if (!cancelled) setStatus(snapshot)
45+
})
46+
.catch(() => {})
47+
48+
void listen<AppUpdateStatusJson>(
49+
'app_update_status_changed',
50+
({ payload }) => {
51+
setStatus(payload)
52+
if (
53+
payload.remoteVersion &&
54+
payload.remoteVersion !== dismissedVersion &&
55+
(payload.state === 'available' ||
56+
payload.state === 'downloading' ||
57+
payload.state === 'ready')
58+
) {
59+
setDismissedVersion(readDismissedVersion())
60+
}
61+
},
62+
).then((cleanup) => {
63+
if (cancelled) cleanup()
64+
else unlisten = cleanup
65+
})
66+
67+
return () => {
68+
cancelled = true
69+
unlisten?.()
70+
}
71+
}, [dismissedVersion])
72+
73+
const visible = useMemo(() => {
74+
if (!status?.remoteVersion) return false
75+
if (dismissedVersion === status.remoteVersion) return false
76+
return (
77+
status.state === 'available' ||
78+
status.state === 'downloading' ||
79+
status.state === 'ready'
80+
)
81+
}, [dismissedVersion, status])
82+
83+
const handleDismiss = useCallback(() => {
84+
const version = status?.remoteVersion ?? null
85+
writeDismissedVersion(version)
86+
setDismissedVersion(version)
87+
}, [status?.remoteVersion])
88+
89+
const handlePrimaryAction = useCallback(async () => {
90+
if (!status) return
91+
if (status.state === 'ready') {
92+
setBusy('applying')
93+
try {
94+
await invoke('app_update_apply')
95+
} catch (e) {
96+
toast(
97+
`${t('settings.updateStateError')}: ${
98+
e instanceof Error ? e.message : String(e)
99+
}`,
100+
'destructive',
101+
)
102+
setBusy('idle')
103+
}
104+
return
105+
}
106+
107+
setBusy('downloading')
108+
try {
109+
const snapshot = await invoke('app_update_download')
110+
if (snapshot) setStatus(snapshot)
111+
if (snapshot?.state === 'error' && snapshot.error) {
112+
toast(snapshot.error, 'destructive')
113+
}
114+
} catch (e) {
115+
toast(
116+
`${t('settings.updateStateError')}: ${
117+
e instanceof Error ? e.message : String(e)
118+
}`,
119+
'destructive',
120+
)
121+
} finally {
122+
setBusy('idle')
123+
}
124+
}, [status, t, toast])
125+
126+
if (!visible || !status) return null
127+
128+
const current = status.localVersion
129+
const latest = status.remoteVersion ?? ''
130+
const progress = typeof status.progress === 'number' ? status.progress : null
131+
const downloading = status.state === 'downloading' || busy === 'downloading'
132+
const ready = status.state === 'ready'
133+
134+
return (
135+
<div className="pointer-events-none fixed left-1/2 top-4 z-[120] w-[min(calc(100vw-2rem),38rem)] -translate-x-1/2">
136+
<section
137+
role="status"
138+
aria-live="polite"
139+
className="skiller-update-banner pointer-events-auto animate-update-banner-in overflow-hidden rounded-xl border text-card-foreground backdrop-blur-xl"
140+
>
141+
<div className="flex items-center gap-3 px-3 py-2.5">
142+
<div
143+
className={cn(
144+
'skiller-update-mark flex size-8 shrink-0 items-center justify-center rounded-lg text-primary',
145+
ready && 'skiller-update-mark-ready',
146+
)}
147+
>
148+
{ready ? (
149+
<RotateCw className="size-4" aria-hidden="true" />
150+
) : (
151+
<Download className="size-4" aria-hidden="true" />
152+
)}
153+
</div>
154+
155+
<div className="min-w-0 flex-1">
156+
<p className="truncate text-[13px] font-semibold leading-5">
157+
{ready
158+
? t('settings.updateBannerReadyTitle')
159+
: t('settings.updateBannerTitle', { version: latest })}
160+
</p>
161+
<p className="truncate text-xs leading-4 text-muted-foreground">
162+
{downloading
163+
? progress == null
164+
? t('settings.updateStateDownloading')
165+
: t('settings.updateBannerDownloading', {
166+
progress,
167+
})
168+
: t('settings.updateBannerDescription', {
169+
current,
170+
latest,
171+
})}
172+
</p>
173+
</div>
174+
175+
<Button
176+
size="sm"
177+
variant="outline"
178+
className="skiller-update-action shrink-0"
179+
onClick={handlePrimaryAction}
180+
disabled={downloading || busy === 'applying'}
181+
>
182+
{ready ? (
183+
<RotateCw className="size-3.5" aria-hidden="true" />
184+
) : (
185+
<Download className="size-3.5" aria-hidden="true" />
186+
)}
187+
{ready
188+
? t('settings.updateRestart')
189+
: downloading
190+
? t('settings.updateBannerUpdating')
191+
: t('settings.updateBannerAction')}
192+
</Button>
193+
194+
<Button
195+
type="button"
196+
variant="ghost"
197+
size="icon-sm"
198+
className="shrink-0 text-muted-foreground hover:text-foreground"
199+
onClick={handleDismiss}
200+
aria-label={t('settings.updateBannerDismiss')}
201+
>
202+
<X className="size-4" aria-hidden="true" />
203+
</Button>
204+
</div>
205+
206+
{downloading && (
207+
<div className="h-1 bg-muted">
208+
<div
209+
className={cn(
210+
'h-full bg-primary transition-[width] duration-300',
211+
progress == null && 'w-1/3 animate-pulse',
212+
)}
213+
style={progress == null ? undefined : { width: `${progress}%` }}
214+
/>
215+
</div>
216+
)}
217+
</section>
218+
</div>
219+
)
220+
}

0 commit comments

Comments
 (0)