diff --git a/Taskfile.yml b/Taskfile.yml index 9fbdf2752..269ff874b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -54,6 +54,8 @@ tasks: generates: - frontend/types/gotypes.d.ts - pkg/wshrpc/wshclient/wshclient.go + - frontend/app/store/services.ts + - frontend/app/store/wshserver.ts build:server: desc: Build the wavesrv component. diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index ec41c494b..56a9d9d66 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -73,7 +73,7 @@ const config = { }, publish: { provider: "generic", - url: "https://dl.waveterm.dev/releases", + url: "https://dl.waveterm.dev/releases-w2", }, }; diff --git a/emain/emain.ts b/emain/emain.ts index 3d8dd9ed7..b9aa84ec9 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -3,6 +3,7 @@ import { WaveDevVarName, WaveDevViteVarName } from "@/util/isdev"; import * as electron from "electron"; +import { autoUpdater } from "electron-updater"; import fs from "fs"; import * as child_process from "node:child_process"; import os from "os"; @@ -536,21 +537,60 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro } function makeAppMenu() { - const fileMenu: Electron.MenuItemConstructorOptions[] = []; - fileMenu.push({ - label: "New Window", - accelerator: "CommandOrControl+N", - click: () => fireAndForget(createNewWaveWindow), - }); - fileMenu.push({ - role: "close", - click: () => { - electron.BrowserWindow.getFocusedWindow()?.close(); + const fileMenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "New Window", + accelerator: "CommandOrControl+N", + click: () => fireAndForget(createNewWaveWindow), }, - }); + { + role: "close", + click: () => { + electron.BrowserWindow.getFocusedWindow()?.close(); + }, + }, + ]; + const appMenu: Electron.MenuItemConstructorOptions[] = [ + { + role: "about", + }, + { + label: "Check for Updates", + click: () => { + const checkingNotification = new electron.Notification({ + title: "Wave Terminal", + body: "Checking for updates.", + }); + checkingNotification.show(); + fireAndForget(() => checkForUpdates()); + }, + }, + { + type: "separator", + }, + { + role: "services", + }, + { + type: "separator", + }, + { + role: "hide", + }, + { + role: "hideOthers", + }, + { + type: "separator", + }, + { + role: "quit", + }, + ]; const menuTemplate: Electron.MenuItemConstructorOptions[] = [ { role: "appMenu", + submenu: appMenu, }, { role: "fileMenu", @@ -596,6 +636,170 @@ process.on("uncaughtException", (error) => { electron.app.quit(); }); +// ====== AUTO-UPDATER ====== // +let autoUpdateLock = false; +let autoUpdateInterval: NodeJS.Timeout | null = null; +let availableUpdateReleaseName: string | null = null; +let availableUpdateReleaseNotes: string | null = null; +let appUpdateStatus = "unavailable"; +let lastUpdateCheck: Date = null; + +/** + * Sets the app update status and sends it to the main window + * @param status The AppUpdateStatus to set, either "ready" or "unavailable" + */ +function setAppUpdateStatus(status: string) { + appUpdateStatus = status; + electron.BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send("app-update-status", appUpdateStatus); + }); +} + +/** + * Checks if an hour has passed since the last update check, and if so, checks for updates using the `autoUpdater` object + */ +async function checkForUpdates() { + const autoUpdateOpts = (await services.FileService.GetSettingsConfig()).autoupdate; + + if (!autoUpdateOpts.enabled) { + console.log("Auto update is disabled in settings. Removing the auto update interval."); + clearInterval(autoUpdateInterval); + autoUpdateInterval = null; + return; + } + const now = new Date(); + if (!lastUpdateCheck || Math.abs(now.getTime() - lastUpdateCheck.getTime()) > autoUpdateOpts.intervalms) { + fireAndForget(() => autoUpdater.checkForUpdates()); + lastUpdateCheck = now; + } +} + +/** + * Initializes the updater and sets up event listeners + */ +function initUpdater() { + if (isDev) { + console.log("skipping auto-updater in dev mode"); + return null; + } + + setAppUpdateStatus("unavailable"); + + autoUpdater.removeAllListeners(); + + autoUpdater.on("error", (err) => { + console.log("updater error"); + console.log(err); + }); + + autoUpdater.on("checking-for-update", () => { + console.log("checking-for-update"); + }); + + autoUpdater.on("update-available", () => { + console.log("update-available; downloading..."); + }); + + autoUpdater.on("update-not-available", () => { + console.log("update-not-available"); + }); + + autoUpdater.on("update-downloaded", (event) => { + console.log("update-downloaded", [event]); + availableUpdateReleaseName = event.releaseName; + availableUpdateReleaseNotes = event.releaseNotes as string | null; + + // Display the update banner and create a system notification + setAppUpdateStatus("ready"); + const updateNotification = new electron.Notification({ + title: "Wave Terminal", + body: "A new version of Wave Terminal is ready to install.", + }); + updateNotification.on("click", () => { + fireAndForget(() => installAppUpdate()); + }); + updateNotification.show(); + }); +} + +/** + * Starts the auto update check interval. + * @returns The timeout object for the auto update checker. + */ +function startAutoUpdateInterval(): NodeJS.Timeout { + // check for updates right away and keep checking later + checkForUpdates(); + return setInterval(() => { + checkForUpdates(); + }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if an hour has passed. +} + +/** + * Prompts the user to install the downloaded application update and restarts the application + */ +async function installAppUpdate() { + const dialogOpts: Electron.MessageBoxOptions = { + type: "info", + buttons: ["Restart", "Later"], + title: "Application Update", + message: process.platform === "win32" ? availableUpdateReleaseNotes : availableUpdateReleaseName, + detail: "A new version has been downloaded. Restart the application to apply the updates.", + }; + + const allWindows = electron.BrowserWindow.getAllWindows(); + if (allWindows.length > 0) { + await electron.dialog + .showMessageBox(electron.BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts) + .then(({ response }) => { + if (response === 0) autoUpdater.quitAndInstall(); + }); + } +} + +electron.ipcMain.on("install-app-update", () => fireAndForget(() => installAppUpdate())); +electron.ipcMain.on("get-app-update-status", (event) => { + event.returnValue = appUpdateStatus; +}); + +/** + * Configures the auto-updater based on the user's preference + * @param enabled Whether the auto-updater should be enabled + */ +async function configureAutoUpdater() { + // simple lock to prevent multiple auto-update configuration attempts, this should be very rare + if (autoUpdateLock) { + console.log("auto-update configuration already in progress, skipping"); + return; + } + + autoUpdateLock = true; + + const autoUpdateEnabled = (await services.FileService.GetSettingsConfig()).autoupdate.enabled; + + try { + console.log("Configuring updater"); + initUpdater(); + } catch (e) { + console.warn("error configuring updater", e.toString()); + } + + if (autoUpdateEnabled && autoUpdateInterval == null) { + lastUpdateCheck = null; + try { + console.log("configuring auto update interval"); + autoUpdateInterval = startAutoUpdateInterval(); + } catch (e) { + console.log("error configuring auto update interval", e.toString()); + } + } else if (!autoUpdateEnabled && autoUpdateInterval != null) { + console.log("disabling auto updater"); + clearInterval(autoUpdateInterval); + autoUpdateInterval = null; + } + autoUpdateLock = false; +} +// ====== AUTO-UPDATER ====== // + async function appMain() { const startTs = Date.now(); const instanceLock = electronApp.requestSingleInstanceLock(); @@ -637,6 +841,7 @@ async function appMain() { console.log("show", win.waveWindowId); win.show(); } + configureAutoUpdater(); globalIsStarting = false; electronApp.on("activate", () => { diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 63dfac9df..24434bb7b 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -49,7 +49,7 @@ class FileServiceType { DeleteFile(arg1: string): Promise { return WOS.callBackendService("file", "DeleteFile", Array.from(arguments)) } - GetSettingsConfig(): Promise { + GetSettingsConfig(): Promise { return WOS.callBackendService("file", "GetSettingsConfig", Array.from(arguments)) } GetWaveFile(arg1: string, arg2: string): Promise { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index b7372433f..263c17e79 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -5,6 +5,12 @@ declare global { + // wconfig.AutoUpdateOpts + type AutoUpdateOpts = { + enabled: boolean; + intervalms: number; + }; + // wstore.Block type Block = WaveObj & { blockdef: BlockDef; @@ -214,6 +220,7 @@ declare global { term: TerminalConfigType; widgets: WidgetsConfigType[]; blockheader: BlockHeaderOpts; + autoupdate: AutoUpdateOpts; }; // wstore.StickerClickOptsType diff --git a/package.json b/package.json index a2f7b8a21..0fbb76e1f 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "base64-js": "^1.5.1", "clsx": "^2.1.1", "dayjs": "^1.11.11", + "electron-updater": "6.2.1", "html-to-image": "^1.11.11", "immer": "^10.1.1", "jotai": "^2.8.0", diff --git a/pkg/service/fileservice/fileservice.go b/pkg/service/fileservice/fileservice.go index 32b15fc8c..ba738894f 100644 --- a/pkg/service/fileservice/fileservice.go +++ b/pkg/service/fileservice/fileservice.go @@ -161,7 +161,7 @@ func (fs *FileService) DeleteFile(path string) error { return os.Remove(cleanedPath) } -func (fs *FileService) GetSettingsConfig() interface{} { +func (fs *FileService) GetSettingsConfig() wconfig.SettingsConfigType { watcher := wconfig.GetWatcher() return watcher.GetSettingsConfig() } diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 08c5ddb2e..2671a3bb6 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -36,11 +36,17 @@ type BlockHeaderOpts struct { ShowBlockIds bool `json:"showblockids"` } +type AutoUpdateOpts struct { + Enabled bool `json:"enabled"` + IntervalMs uint32 `json:"intervalms"` +} + type SettingsConfigType struct { MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` Term TerminalConfigType `json:"term"` Widgets []WidgetsConfigType `json:"widgets"` BlockHeader BlockHeaderOpts `json:"blockheader"` + AutoUpdate AutoUpdateOpts `json:"autoupdate"` } func getSettingsConfigDefaults() SettingsConfigType { @@ -97,5 +103,9 @@ func getSettingsConfigDefaults() SettingsConfigType { }, }, }, + AutoUpdate: AutoUpdateOpts{ + Enabled: true, + IntervalMs: 3600000, + }, } } diff --git a/yarn.lock b/yarn.lock index 2237bf358..d1fb6dc7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7154,6 +7154,22 @@ __metadata: languageName: node linkType: hard +"electron-updater@npm:6.2.1": + version: 6.2.1 + resolution: "electron-updater@npm:6.2.1" + dependencies: + builder-util-runtime: "npm:9.2.4" + fs-extra: "npm:^10.1.0" + js-yaml: "npm:^4.1.0" + lazy-val: "npm:^1.0.5" + lodash.escaperegexp: "npm:^4.1.2" + lodash.isequal: "npm:^4.5.0" + semver: "npm:^7.3.8" + tiny-typed-emitter: "npm:^2.1.0" + checksum: 10c0/b376e13bf2b4675ca853c4164a4caf9454d0d41e797c0f8fd011d66693d4eb5ece020953f3d06c0a63d9d5077a9ae9e8447f26c602da317edb1521c4bd99e2f8 + languageName: node + linkType: hard + "electron-vite@npm:^2.2.0": version: 2.2.0 resolution: "electron-vite@npm:2.2.0" @@ -9970,6 +9986,20 @@ __metadata: languageName: node linkType: hard +"lodash.escaperegexp@npm:^4.1.2": + version: 4.1.2 + resolution: "lodash.escaperegexp@npm:4.1.2" + checksum: 10c0/484ad4067fa9119bb0f7c19a36ab143d0173a081314993fe977bd00cf2a3c6a487ce417a10f6bac598d968364f992153315f0dbe25c9e38e3eb7581dd333e087 + languageName: node + linkType: hard + +"lodash.isequal@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.isequal@npm:4.5.0" + checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f + languageName: node + linkType: hard + "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -13504,6 +13534,7 @@ __metadata: dayjs: "npm:^1.11.11" electron: "npm:^31.1.0" electron-builder: "npm:^24.13.3" + electron-updater: "npm:6.2.1" electron-vite: "npm:^2.2.0" eslint: "npm:^9.2.0" eslint-config-prettier: "npm:^9.1.0" @@ -13573,6 +13604,13 @@ __metadata: languageName: node linkType: hard +"tiny-typed-emitter@npm:^2.1.0": + version: 2.1.0 + resolution: "tiny-typed-emitter@npm:2.1.0" + checksum: 10c0/522bed4c579ee7ee16548540cb693a3d098b137496110f5a74bff970b54187e6b7343a359b703e33f77c5b4b90ec6cebc0d0ec3dbdf1bd418723c5c3ce36d8a2 + languageName: node + linkType: hard + "tinybench@npm:^2.5.1": version: 2.8.0 resolution: "tinybench@npm:2.8.0"