chore: app updater UI (#5054)

* chore: initial app updater UI and download management enhance

* chore: revert package version

* chore: update conditional app updater

* chore: remove console

* chore: add utils isDev

* chore: close popup when user click download

* revert yarn lock
This commit is contained in:
Faisal Amir 2025-05-22 11:54:06 +07:00 committed by GitHub
parent a7d2e72313
commit 434abaaca6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 344 additions and 18 deletions

View File

@ -16,6 +16,7 @@
"core:window:allow-set-focus",
"os:default",
"log:default",
"updater:default",
"dialog:default",
"core:webview:allow-create-webview-window",
{

View File

@ -19,6 +19,7 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
// FS commands - Deperecate soon

View File

@ -32,6 +32,7 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"@uiw/react-textarea-code-editor": "^3.1.1",

View File

@ -5,18 +5,22 @@ import {
} from '@/components/ui/popover'
import { Progress } from '@/components/ui/progress'
import { useDownloadStore } from '@/hooks/useDownloadStore'
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useAppUpdater } from '@/hooks/useAppUpdater'
import { abortDownload } from '@/services/models'
import { getProviders } from '@/services/providers'
import { DownloadEvent, DownloadState, events } from '@janhq/core'
import { IconX } from '@tabler/icons-react'
import { IconDownload, IconX } from '@tabler/icons-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
export function DownloadManagement() {
const { setProviders } = useModelProvider()
const { open: isLeftPanelOpen } = useLeftPanel()
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const { downloads, updateProgress, removeDownload } = useDownloadStore()
const { updateState } = useAppUpdater()
const downloadCount = useMemo(
() => Object.keys(downloads).length,
[downloads]
@ -114,21 +118,36 @@ export function DownloadManagement() {
return (
<>
{downloadCount > 0 && (
{(downloadCount > 0 ||
(!updateState.isDownloading && updateState.downloadProgress > 0)) && (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger>
<div className="bg-left-panel-fg/10 hover:bg-left-panel-fg/12 p-2 rounded-md my-1 relative border border-left-panel-fg/10 cursor-pointer text-left">
<div className="bg-primary font-bold size-5 rounded-full absolute -top-2 -right-1 flex items-center justify-center text-primary-fg">
{downloadCount}
<PopoverTrigger asChild>
{isLeftPanelOpen ? (
<div className="bg-left-panel-fg/10 hover:bg-left-panel-fg/12 p-2 rounded-md my-1 relative border border-left-panel-fg/10 cursor-pointer text-left">
<div className="bg-primary font-bold size-5 rounded-full absolute -top-2 -right-1 flex items-center justify-center text-primary-fg">
{downloadCount}
</div>
<p className="text-left-panel-fg/80 font-medium">Downloads</p>
<div className="mt-2 flex items-center justify-between space-x-2">
<Progress value={overallProgress * 100} />
<span className="text-xs font-medium text-main-view-fg/80 shrink-0">
{Math.round(overallProgress * 100)}%
</span>
</div>
</div>
<p className="text-left-panel-fg/80 font-medium">Downloads</p>
<div className="mt-2 flex items-center justify-between space-x-2">
<Progress value={overallProgress * 100} />
<span className="text-xs font-medium text-main-view-fg/80 shrink-0">
{Math.round(overallProgress * 100)}%
</span>
) : (
<div className="fixed bottom-4 left-4 z-50 size-10 bg-main-view border-2 border-main-view-fg/10 rounded-full shadow-md cursor-pointer flex items-center justify-center">
<div className="relative">
<IconDownload
className="text-main-view-fg/50 -mt-1"
size={20}
/>
<div className="bg-primary font-bold size-5 rounded-full absolute -top-2 -right-1 flex items-center justify-center text-primary-fg">
{downloadCount}
</div>
</div>
</div>
</div>
)}
</PopoverTrigger>
<PopoverContent
@ -143,6 +162,24 @@ export function DownloadManagement() {
<p className="text-xs text-main-view-fg/70">Downloading</p>
</div>
<div className="p-2 max-h-[300px] overflow-y-auto space-y-2">
{!updateState.isDownloading &&
updateState.downloadProgress > 0 && (
<div className="bg-main-view-fg/4 rounded-md p-2">
<div className="flex items-center justify-between">
<p className="truncate text-main-view-fg/80">
App Update
</p>
</div>
<Progress
value={updateState.downloadProgress * 100}
className="my-2"
/>
<p className="text-main-view-fg/60 text-xs">
{`${renderGB(updateState.downloadedBytes)} / ${renderGB(updateState.totalBytes)}`}{' '}
GB ({Math.round(updateState.downloadProgress * 100)}%)
</p>
</div>
)}
{downloadProcesses.map((download) => (
<div className="bg-main-view-fg/4 rounded-md p-2">
<div className="flex items-center justify-between">

View File

@ -39,7 +39,7 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { DownloadManagement } from './DownloadManegement'
import { DownloadManagement } from '@/containers/DownloadManegement'
const mainMenus = [
{
@ -97,7 +97,9 @@ const LeftPanel = () => {
<aside
className={cn(
'w-48 shrink-0 rounded-lg m-1.5 mr-0 text-left-panel-fg',
open ? 'block' : 'hidden'
open
? 'opacity-100 visibility-visible'
: 'w-0 absolute -top-100 -left-100 visibility-hidden'
)}
>
<div className="relative h-8">
@ -223,8 +225,8 @@ const LeftPanel = () => {
<DialogHeader>
<DialogTitle>Delete All Threads</DialogTitle>
<DialogDescription>
All threads will be deleted.
This action cannot be undone.
All threads will be deleted. This action cannot
be undone.
</DialogDescription>
<DialogFooter className="mt-2">
<DialogClose asChild>

View File

@ -0,0 +1,119 @@
import { useAppUpdater } from '@/hooks/useAppUpdater'
import { IconDownload } from '@tabler/icons-react'
import { Button } from '@/components/ui/button'
import { useState, useEffect } from 'react'
import { useReleaseNotes } from '@/hooks/useReleaseNotes'
import { RenderMarkdown } from '../RenderMarkdown'
import { isDev } from '@/lib/utils'
const DialogAppUpdater = () => {
const { updateState, downloadAndInstallUpdate } = useAppUpdater()
const [showReleaseNotes, setShowReleaseNotes] = useState(false)
const [remindMeLater, setRemindMeLater] = useState(false)
const handleUpdate = () => {
downloadAndInstallUpdate()
setRemindMeLater(true)
}
const beta = VERSION.includes('beta')
const nightly = VERSION.includes('-')
const { release, fetchLatestRelease } = useReleaseNotes()
useEffect(() => {
if (!isDev()) {
fetchLatestRelease(beta ? true : false)
}
}, [beta, fetchLatestRelease])
if (remindMeLater) return null
return (
<>
{updateState.isUpdateAvailable && (
<div className="fixed z-50 w-[400px] bottom-2 right-2 bg-main-view text-main-view-fg flex items-center justify-center border border-main-view-fg/10 rounded-lg shadow-md">
<div className="px-0 py-4">
<div className="px-4">
<div className="flex items-start gap-2">
<IconDownload
size={20}
className="shrink-0 text-main-view-fg/60 mt-1"
/>
<div>
<div className="text-base font-medium">
New Version: Jan {updateState.updateInfo?.version}
</div>
<div className="mt-1 text-main-view-fg/70 font-normal">
There's a new app update available to download.
</div>
</div>
</div>
</div>
{showReleaseNotes && (
<div className="max-h-[500px] py-2 overflow-y-scroll px-4 text-sm font-normal leading-relaxed">
{nightly ? (
<p className="mt-2 text-sm font-normal">
You are using a nightly build. This version is built from
the latest development branch and may not have release
notes.
</p>
) : (
<RenderMarkdown
components={{
a: ({ ...props }) => (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
/>
),
h2: ({ ...props }) => (
<h2 {...props} className="!text-xl !mt-0" />
),
}}
content={release?.body}
/>
)}
</div>
)}
<div className="pt-3 px-4">
<div className="flex gap-x-4 w-full items-center justify-between">
<Button
variant="link"
className="px-0 text-main-view-fg/70"
onClick={() => setShowReleaseNotes(!showReleaseNotes)}
>
{showReleaseNotes ? 'Hide' : 'Show'} release notes
</Button>
<div className="flex gap-x-5">
<Button
variant="link"
className="px-0 text-main-view-fg/70 remind-me-later"
onClick={() => setRemindMeLater(true)}
>
Remind me later
</Button>
<Button
onClick={handleUpdate}
disabled={updateState.isDownloading}
>
{updateState.isDownloading
? 'Downloading...'
: 'Update Now'}
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</>
)
}
export default DialogAppUpdater

View File

@ -0,0 +1,103 @@
import { isDev } from '@/lib/utils'
import { check, Update } from '@tauri-apps/plugin-updater'
import { useState, useCallback } from 'react'
export interface UpdateState {
isUpdateAvailable: boolean
updateInfo: Update | null
isDownloading: boolean
downloadProgress: number
downloadedBytes: number
totalBytes: number
}
export const useAppUpdater = () => {
const [updateState, setUpdateState] = useState<UpdateState>({
isUpdateAvailable: false,
updateInfo: null,
isDownloading: false,
downloadProgress: 0,
downloadedBytes: 0,
totalBytes: 0,
})
const checkForUpdate = useCallback(async () => {
try {
if (!isDev()) {
const update = await check()
if (update) {
setUpdateState((prev) => ({
...prev,
isUpdateAvailable: true,
updateInfo: update,
}))
return update
}
}
return null
} catch (error) {
console.error('Error checking for updates:', error)
return null
}
}, [])
const downloadAndInstallUpdate = useCallback(async () => {
if (!updateState.updateInfo) return
try {
setUpdateState((prev) => ({
...prev,
isDownloading: true,
}))
let downloaded = 0
let contentLength = 0
await updateState.updateInfo.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength || 0
setUpdateState((prev) => ({
...prev,
totalBytes: contentLength,
}))
console.log(`Started downloading ${contentLength} bytes`)
break
case 'Progress': {
downloaded += event.data.chunkLength
const progress = contentLength > 0 ? downloaded / contentLength : 0
setUpdateState((prev) => ({
...prev,
downloadProgress: progress,
downloadedBytes: downloaded,
}))
console.log(`Downloaded ${downloaded} from ${contentLength}`)
break
}
case 'Finished':
console.log('Download finished')
setUpdateState((prev) => ({
...prev,
isDownloading: false,
downloadProgress: 1,
}))
break
}
})
console.log('Update installed')
} catch (error) {
console.error('Error downloading update:', error)
setUpdateState((prev) => ({
...prev,
isDownloading: false,
}))
}
}, [updateState.updateInfo])
return {
updateState,
checkForUpdate,
downloadAndInstallUpdate,
}
}

View File

@ -0,0 +1,49 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// stores/useReleaseStore.ts
import { create } from 'zustand'
type Release = {
tag_name: string
prerelease: boolean
draft: boolean
[key: string]: any
}
type ReleaseState = {
release: Release | null
loading: boolean
error: string | null
fetchLatestRelease: (includeBeta: boolean) => Promise<void>
}
export const useReleaseNotes = create<ReleaseState>((set) => ({
release: null,
loading: false,
error: null,
fetchLatestRelease: async (includeBeta: boolean) => {
set({ loading: true, error: null })
try {
const res = await fetch(
'https://api.github.com/repos/menloresearch/jan/releases'
)
if (!res.ok) throw new Error('Failed to fetch releases')
const releases = await res.json()
const stableRelease = releases.find(
(release: { prerelease: boolean; draft: boolean }) =>
!release.prerelease && !release.draft
)
const betaRelease = releases.find(
(release: { prerelease: boolean }) => release.prerelease
)
const selected = includeBeta
? (betaRelease ?? stableRelease)
: stableRelease
set({ release: selected, loading: false })
} catch (err: any) {
set({ error: err.message, loading: false })
}
},
}))

View File

@ -142,3 +142,7 @@ export function formatMegaBytes(mb: number) {
return `${gb.toFixed(2)} GB`
}
}
export function isDev() {
return window.location.host.startsWith('localhost:')
}

View File

@ -1,6 +1,7 @@
import { useMessages } from '@/hooks/useMessages'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useThreads } from '@/hooks/useThreads'
import { useAppUpdater } from '@/hooks/useAppUpdater'
import { fetchMessages } from '@/services/messages'
import { fetchModels } from '@/services/models'
import { getProviders } from '@/services/providers'
@ -12,6 +13,7 @@ export function DataProvider() {
const { setProviders } = useModelProvider()
const { setThreads } = useThreads()
const { setMessages } = useMessages()
const { checkForUpdate } = useAppUpdater()
useEffect(() => {
fetchModels().then((models) => {
@ -33,5 +35,11 @@ export function DataProvider() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Check for app updates
useEffect(() => {
checkForUpdate()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return null
}

View File

@ -2,6 +2,7 @@ import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router'
// import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import LeftPanel from '@/containers/LeftPanel'
import DialogAppUpdater from '@/containers/dialogs/AppUpdater'
import { Fragment } from 'react/jsx-runtime'
import { AppearanceProvider } from '@/providers/AppearanceProvider'
import { ThemeProvider } from '@/providers/ThemeProvider'
@ -22,7 +23,7 @@ const AppLayout = () => {
<main className="relative h-svh text-sm antialiased select-none bg-app">
{/* Fake absolute panel top to enable window drag */}
<div className="absolute w-full h-10 z-10" data-tauri-drag-region />
<DialogAppUpdater />
<div className="flex h-full">
{/* left content panel - only show if not logs route */}
<LeftPanel />