fix: updater progress jumping and click-twice issues

- Guard installUpdate() with isDownloading flag to prevent concurrent downloads
- Deduplicate checkForUpdates() with checkInProgress promise
- Track monotonic progress (peakPercent never decreases)
- Reduce startup check delay from 5s to 1s
- Bump desktop to 0.2.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:26:35 +08:00
co-authored by Claude Opus 4.6
parent ded1b5b4c0
commit 6242bda36b
4 changed files with 76 additions and 56 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "heicode-desktop",
"private": true,
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "heicode-desktop"
version = "0.2.2"
version = "0.2.4"
edition = "2021"
[lib]
+2 -2
View File
@@ -1,13 +1,13 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "HeiCode",
"version": "0.2.2",
"version": "0.2.4",
"identifier": "com.heicode.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "bun run build:sidecars && bun run dev",
"beforeBuildCommand": "bun run build && bun run build:sidecars"
"beforeBuildCommand": ""
},
"app": {
"windows": [
+72 -52
View File
@@ -36,6 +36,8 @@ type UpdateStore = {
let pendingUpdate: Update | null = null
let startupCheckPromise: Promise<void> | null = null
let periodicCheckTimer: ReturnType<typeof setInterval> | null = null
let isDownloading = false
let checkInProgress: Promise<Update | null> | null = null
// How often the long-running client re-polls the updater manifest while
// idle. 2 hours strikes a balance between "user sees a new release the
@@ -98,7 +100,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
if (!isTauriRuntime()) return
if (!startupCheckPromise) {
startupCheckPromise = (async () => {
await new Promise((resolve) => setTimeout(resolve, 5000))
await new Promise((resolve) => setTimeout(resolve, 1000))
await get().checkForUpdates({ silent: true })
})().finally(() => {
startupCheckPromise = null
@@ -123,73 +125,85 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
checkForUpdates: async ({ silent = false } = {}) => {
if (!isTauriRuntime()) return null
set((state) => ({
...state,
status: 'checking',
error: null,
}))
if (checkInProgress) return checkInProgress
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
await setPendingUpdate(update)
const doCheck = async (): Promise<Update | null> => {
if (isDownloading) return pendingUpdate
const checkedAt = Date.now()
set((state) => ({
...state,
status: 'checking',
error: null,
}))
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
await setPendingUpdate(update)
const checkedAt = Date.now()
if (!update) {
writeDismissedUpdateVersion(null)
set((state) => ({
...state,
status: 'up-to-date',
availableVersion: null,
releaseNotes: null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
checkedAt,
error: null,
shouldPrompt: false,
}))
return null
}
const dismissedVersion = readDismissedUpdateVersion()
const shouldPrompt = dismissedVersion !== update.version
if (!update) {
writeDismissedUpdateVersion(null)
set((state) => ({
...state,
status: 'up-to-date',
availableVersion: null,
releaseNotes: null,
status: 'available',
availableVersion: update.version,
releaseNotes: update.body ?? null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
checkedAt,
error: null,
shouldPrompt: false,
shouldPrompt,
}))
return update
} catch (error) {
if (!silent) {
set((state) => ({
...state,
status: 'error',
error: getErrorMessage(error),
checkedAt: Date.now(),
}))
} else {
set((state) => ({
...state,
status: state.availableVersion ? 'available' : 'idle',
checkedAt: Date.now(),
}))
}
return null
}
const dismissedVersion = readDismissedUpdateVersion()
const shouldPrompt = dismissedVersion !== update.version
set((state) => ({
...state,
status: 'available',
availableVersion: update.version,
releaseNotes: update.body ?? null,
progressPercent: 0,
downloadedBytes: 0,
totalBytes: null,
checkedAt,
error: null,
shouldPrompt,
}))
return update
} catch (error) {
if (!silent) {
set((state) => ({
...state,
status: 'error',
error: getErrorMessage(error),
checkedAt: Date.now(),
}))
} else {
set((state) => ({
...state,
status: state.availableVersion ? 'available' : 'idle',
checkedAt: Date.now(),
}))
}
return null
}
checkInProgress = doCheck().finally(() => {
checkInProgress = null
})
return checkInProgress
},
installUpdate: async () => {
if (!isTauriRuntime()) return
if (isDownloading) return
let update = pendingUpdate
if (!update) {
@@ -197,6 +211,8 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
if (!update) return
}
isDownloading = true
set((state) => ({
...state,
status: 'downloading',
@@ -213,11 +229,13 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
const { relaunch } = await import('@tauri-apps/plugin-process')
let totalBytes: number | null = null
let downloadedBytes = 0
let peakPercent = 0
await update.download((event) => {
if (event.event === 'Started') {
totalBytes = event.data.contentLength ?? null
downloadedBytes = 0
peakPercent = 0
set((state) => ({
...state,
totalBytes,
@@ -226,16 +244,17 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
}))
} else if (event.event === 'Progress') {
downloadedBytes += event.data.chunkLength
const progressPercent =
const rawPercent =
totalBytes && totalBytes > 0
? Math.min(Math.round((downloadedBytes / totalBytes) * 100), 100)
: 0
peakPercent = Math.max(peakPercent, rawPercent)
set((state) => ({
...state,
downloadedBytes,
totalBytes,
progressPercent,
progressPercent: peakPercent,
}))
} else if (event.event === 'Finished') {
set((state) => ({
@@ -256,6 +275,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
await relaunch()
} catch (error) {
isDownloading = false
set((state) => ({
...state,
status: 'available',