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:
parent
a7d2e72313
commit
434abaaca6
@ -16,6 +16,7 @@
|
||||
"core:window:allow-set-focus",
|
||||
"os:default",
|
||||
"log:default",
|
||||
"updater:default",
|
||||
"dialog:default",
|
||||
"core:webview:allow-create-webview-window",
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
119
web-app/src/containers/dialogs/AppUpdater.tsx
Normal file
119
web-app/src/containers/dialogs/AppUpdater.tsx
Normal 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
|
||||
103
web-app/src/hooks/useAppUpdater.ts
Normal file
103
web-app/src/hooks/useAppUpdater.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
49
web-app/src/hooks/useReleaseNotes.ts
Normal file
49
web-app/src/hooks/useReleaseNotes.ts
Normal 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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
@ -142,3 +142,7 @@ export function formatMegaBytes(mb: number) {
|
||||
return `${gb.toFixed(2)} GB`
|
||||
}
|
||||
}
|
||||
|
||||
export function isDev() {
|
||||
return window.location.host.startsWith('localhost:')
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user