Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(desktop): implement auto-update functionality #1839

Merged
merged 9 commits into from
Dec 18, 2024
4 changes: 2 additions & 2 deletions apps/desktop/dev-app-update.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: desktop-updater
url: http://localhost:8080/
updaterCacheDirName: mqttx-updater
3 changes: 0 additions & 3 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,3 @@ linux:
maintainer: electronjs.org
category: Utility
npmRebuild: false
# publish:
# provider: generic
# url: https://example.com/auto-updates
5 changes: 5 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export default defineConfig({
'pinia',
],

// Auto import functions from Element Plus, e.g. ElMessage, ElMessageBox... (with style)
resolvers: [
ElementPlusResolver(),
],

// Auto import for module exports under directories
// by default it only scan one level of modules under the directory
dirs: [
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@mqttx/ui": "workspace:*",
"better-sqlite3": "^11.6.0",
"drizzle-orm": "^0.36.4",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"element-plus": "^2.8.7",
"markdown-it": "^14.1.0",
Expand Down
18 changes: 11 additions & 7 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@ import { app, BrowserWindow, ipcMain, shell } from 'electron'
import icon from '../../resources/icon.png?asset'
import { db, execute, runMigrate } from '../database/db.main'
import { type SelectSettings, settings } from '../database/schemas/settings'
import { useAppUpdater } from './update'

// const IsMacOS = process.platform === 'darwin'

let existingSettings: SelectSettings | undefined

async function createWindow() {
let data: SelectSettings | undefined
data = await db.query.settings.findFirst()
if (!data) {
existingSettings = await db.query.settings.findFirst()
if (!existingSettings) {
await db.insert(settings).values({})
}
data = await db.query.settings.findFirst() as SelectSettings
existingSettings = await db.query.settings.findFirst() as SelectSettings

const width = data.width || 1024
const height = data.height || 749
const currentTheme = data.currentTheme || 'light'
const width = existingSettings.width || 1024
const height = existingSettings.height || 749
const currentTheme = existingSettings.currentTheme || 'light'
const bgColor = {
dark: '#232323',
night: '#212328',
Expand Down Expand Up @@ -93,6 +95,8 @@ app.whenReady().then(async () => {

await createWindow()

useAppUpdater(existingSettings!)

app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/main/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { Lang } from 'mqttx'
import type { SelectSettings } from '../database/schemas/settings'
import type { UpdateEvent } from '../preload/index.d'
import { app, BrowserWindow, ipcMain } from 'electron'
import Store from 'electron-store'
import pkg, { CancellationToken } from 'electron-updater'

// FIXME: https://github.com/sindresorhus/electron-store/issues/276
const store = new Store() as any
const { autoUpdater } = pkg

autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false

autoUpdater.on('checking-for-update', () => {
sendUpdateStatus({ status: 'checking-for-update' })
})

autoUpdater.on('update-available', async (info) => {
const releaseNotes = await fetchReleaseNotes(info.version)
sendUpdateStatus({ status: 'update-available', data: { info, releaseNotes } })
})

autoUpdater.on('update-not-available', (info) => {
sendUpdateStatus({ status: 'update-not-available', data: info })
})

autoUpdater.on('download-progress', (progressInfo) => {
sendUpdateStatus({ status: 'download-progress', data: progressInfo })
})

autoUpdater.on('update-downloaded', (info) => {
sendUpdateStatus({ status: 'update-downloaded', data: info })
})

autoUpdater.on('error', (error) => {
sendUpdateStatus({ status: 'error', data: error })
})

async function fetchReleaseNotes(version: string): Promise<string> {
// TODO: Remove before official release
version = '1.11.1'
return fetch(`https://community-sites.emqx.com/api/v1/changelogs?product=MQTTX&version=${version}`)
.then(response => response.json())
.then((data) => {
return data.data?.changelog ?? data.detail
})
.catch((error) => {
return error.message
})
}

async function showReleaseNotesWindow(lang: Lang) {
const language = ['en', 'zh', 'ja'].includes(lang) ? lang : 'en'
const version = app.getVersion()
const link = `https://mqttx.app/${language}/changelogs/v${version}`

try {
const response = await fetch(link, { method: 'GET', signal: AbortSignal.timeout(5000) })
if (response.status !== 200) {
return
}
const releaseNotesWindow = new BrowserWindow({
width: 600,
height: 500,
alwaysOnTop: true,
})
releaseNotesWindow.loadURL(link)
} catch (e) {
console.error(e)
}
}

async function getLatestVersion(): Promise<string> {
try {
const response = await fetch('https://community-sites.emqx.com/api/v1/all_version?product=MQTTX')
const data = await response.json()
return data.data?.[0]
} catch (error) {
console.error('Failed to get latest version:', error)
return app.getVersion()
}
}

function sendUpdateStatus(updateEvent: UpdateEvent) {
const windows = BrowserWindow.getAllWindows()
windows.forEach((window) => {
if ('data' in updateEvent) {
window.webContents.send('update-status', updateEvent.status, updateEvent.data)
} else {
window.webContents.send('update-status', updateEvent.status)
}
})
}

let downloadCancelToken: CancellationToken | null = null

function useAppUpdater(settings: SelectSettings) {
const version = app.getVersion()
if (store.get('version') !== version) {
showReleaseNotesWindow(settings.currentLang)
store.set('version', version)
}
ipcMain.handle('check-for-updates', async () => {
if (process.env.NODE_ENV === 'development') {
autoUpdater.forceDevUpdateConfig = true
} else {
const latestVersion = await getLatestVersion()
const feedUrl = `https://www.emqx.com/en/downloads/MQTTX/${latestVersion}`
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl })
}
return await autoUpdater.checkForUpdates()
})
ipcMain.handle('download-update', async () => {
downloadCancelToken = new CancellationToken()
return await autoUpdater.downloadUpdate(downloadCancelToken)
})
ipcMain.handle('cancel-download', () => {
if (downloadCancelToken) {
downloadCancelToken.cancel()
downloadCancelToken = null
}
})
ipcMain.handle('install-update', async () => {
autoUpdater.quitAndInstall()
})
}

export { useAppUpdater }
15 changes: 15 additions & 0 deletions apps/desktop/src/preload/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
import type { Electron } from 'electron'
import type { ProgressInfo, UpdateCheckResult, UpdateDownloadedEvent, UpdateInfo } from 'electron-updater'

declare global {
interface Window {
electron: ElectronAPI
api: {
execute: (...args: any[]) => Promise<any>
onUpdateStatus: (callback: (event: Electron.IpcRendererEvent, updateEvent: UpdateEvent) => void) => void
checkForUpdates: () => Promise<UpdateCheckResult | null>
downloadUpdate: () => Promise<void>
cancelDownload: () => Promise<void>
installUpdate: () => Promise<void>
}
}
}

export type UpdateEvent =
| { status: 'checking-for-update' }
| { status: 'update-available', data: { info: UpdateInfo, releaseNotes: string } }
| { status: 'update-not-available', data: UpdateInfo }
| { status: 'download-progress', data: ProgressInfo }
| { status: 'update-downloaded', data: UpdateDownloadedEvent }
| { status: 'error', data: Error }
11 changes: 8 additions & 3 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron'

// Custom APIs for renderer
const api = {
const api: Window['api'] = {
execute: (...args) => ipcRenderer.invoke('db:execute', ...args),
onUpdateStatus: callback => ipcRenderer.on('update-status', (event, status, data) => {
callback(event, { status, data })
}),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
installUpdate: () => ipcRenderer.invoke('install-update'),
}

// Use `contextBridge` APIs to expose Electron APIs to
Expand All @@ -17,8 +24,6 @@ if (process.contextIsolated) {
console.error(error)
}
} else {
// @ts-expect-error (define in dts)
window.electron = electronAPI
// @ts-expect-error (define in dts)
window.api = api
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
Expand Down Expand Up @@ -97,6 +98,7 @@ declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly ElMessage: UnwrapRef<typeof import('element-plus/es')['ElMessage']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/renderer/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ declare module 'vue' {
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElIconDelete: typeof import('@element-plus/icons-vue')['Delete']
ElIconDownload: typeof import('@element-plus/icons-vue')['Download']
Expand All @@ -36,6 +37,7 @@ declare module 'vue' {
ElMain: typeof import('element-plus/es')['ElMain']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
Expand All @@ -45,5 +47,8 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingsView: typeof import('./../../../../packages/ui/src/components/SettingsView.vue')['default']
UpdateAvailable: typeof import('./src/components/update/Available.vue')['default']
UpdateDownloadProgress: typeof import('./src/components/update/DownloadProgress.vue')['default']
UpdateView: typeof import('./src/components/update/View.vue')['default']
}
}
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src *"
/>
</head>

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ console.log('MQTTX Web App init...')
<ElContainer>
<CommonLeftMenu />
<CommonMainView />
<UpdateView />
</ElContainer>
</ElConfigProvider>
</template>
61 changes: 61 additions & 0 deletions apps/desktop/src/renderer/src/components/update/Available.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
const props = defineProps<{
version: string
releaseNotes: string
}>()

const dialogVisible = defineModel<boolean>({ default: false })

const { version, releaseNotes } = toRefs(props)

function ignoreCurrentVersion() {
localStorage.setItem('ignoreVersion', version.value)
dialogVisible.value = false
}

function remindLater() {
localStorage.removeItem('ignoreVersion')
dialogVisible.value = false
}

function downloadUpdate() {
window.api.downloadUpdate()
dialogVisible.value = false
}

watch(releaseNotes, () => {
nextTick(() => {
const links = document.querySelectorAll('.prose a')
links.forEach((link) => {
link.setAttribute('target', '_blank')
})
})
})
</script>

<template>
<MyDialog
v-model="dialogVisible"
:title="$t('update.updateTitle', { version })"
width="min(100vw - 80px, 960px)"
>
<div class="h-[min(50vh,400px)] overflow-y-auto">
<div class="prose prose-sm" v-html="releaseNotes" />
</div>
<template #footer>
<div class="flex gap-6 justify-between">
<ElButton @click="ignoreCurrentVersion">
{{ $t('update.ignoreVersion') }}
</ElButton>
<div>
<ElButton @click="remindLater">
{{ $t('update.nextRemind') }}
</ElButton>
<ElButton type="primary" @click="downloadUpdate">
{{ $t('update.update') }}
</ElButton>
</div>
</div>
</template>
</MyDialog>
</template>
Loading
Loading