From ed1c1ff88ec8b16a2e7d97892c74cde74bca39d1 Mon Sep 17 00:00:00 2001
From: Alex Freska <alex.freska@gmail.com>
Date: Thu, 25 Jan 2024 10:07:47 -0500
Subject: [PATCH] ability to upgrade to latest daemon while the app is running

---
 hostd/electron-src/download.ts               |  1 +
 hostd/electron-src/ipc.ts                    |  7 +-
 hostd/electron-src/preload.ts                |  1 +
 hostd/electron-src/startup.ts                |  8 ++-
 hostd/electron-src/state.ts                  |  3 +
 hostd/electron-src/tray.ts                   |  3 +-
 hostd/electron-src/window.ts                 |  9 +--
 hostd/renderer/components/UpdateBanner.tsx   | 76 +++++++++++++++++---
 hostd/renderer/renderer.d.ts                 |  1 +
 renterd/electron-src/download.ts             |  1 +
 renterd/electron-src/ipc.ts                  |  7 +-
 renterd/electron-src/preload.ts              |  1 +
 renterd/electron-src/startup.ts              |  8 ++-
 renterd/electron-src/state.ts                |  3 +
 renterd/electron-src/tray.ts                 |  3 +-
 renterd/electron-src/window.ts               |  9 +--
 renterd/renderer/components/UpdateBanner.tsx | 76 +++++++++++++++++---
 renterd/renderer/renderer.d.ts               |  1 +
 18 files changed, 176 insertions(+), 42 deletions(-)

diff --git a/hostd/electron-src/download.ts b/hostd/electron-src/download.ts
index 49b5753..d9bb7ff 100644
--- a/hostd/electron-src/download.ts
+++ b/hostd/electron-src/download.ts
@@ -18,6 +18,7 @@ export async function downloadRelease(): Promise<void> {
 
     const release = releaseData.data
     const asset = release.assets.find((asset) => asset.name === releaseAsset())
+    console.log(`Downloading ${releaseAsset()}`)
 
     if (asset) {
       console.log('Release name:', release.name)
diff --git a/hostd/electron-src/ipc.ts b/hostd/electron-src/ipc.ts
index f346296..ff3a90b 100644
--- a/hostd/electron-src/ipc.ts
+++ b/hostd/electron-src/ipc.ts
@@ -14,6 +14,7 @@ import {
   getIsConfigured,
   saveConfig,
 } from './config'
+import { downloadRelease } from './download'
 
 export function initIpc() {
   ipcMain.handle('open-browser', (_, url: string) => {
@@ -26,8 +27,10 @@ export function initIpc() {
     await stopDaemon()
   })
   ipcMain.handle('daemon-is-running', (_) => {
-    const isDaemonRunning = getIsDaemonRunning()
-    return isDaemonRunning
+    return getIsDaemonRunning()
+  })
+  ipcMain.handle('daemon-update', async (_) => {
+    await downloadRelease()
   })
   ipcMain.handle('config-get', (_) => {
     const config = getConfig()
diff --git a/hostd/electron-src/preload.ts b/hostd/electron-src/preload.ts
index fa30894..76eb4f4 100644
--- a/hostd/electron-src/preload.ts
+++ b/hostd/electron-src/preload.ts
@@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('electron', {
   checkIsDaemonRunning: () => ipcRenderer.invoke('daemon-is-running'),
   daemonStart: () => ipcRenderer.invoke('daemon-start'),
   daemonStop: () => ipcRenderer.invoke('daemon-stop'),
+  daemonUpdate: () => ipcRenderer.invoke('daemon-update'),
   openBrowser: (url: string) => ipcRenderer.invoke('open-browser', url),
   getConfig: () => ipcRenderer.invoke('config-get'),
   saveConfig: (config: Config) => ipcRenderer.invoke('config-save', config),
diff --git a/hostd/electron-src/startup.ts b/hostd/electron-src/startup.ts
index e8c30b5..df6a892 100644
--- a/hostd/electron-src/startup.ts
+++ b/hostd/electron-src/startup.ts
@@ -1,6 +1,6 @@
 import { startDaemon } from './daemon'
 import { getIsConfigured } from './config'
-import { state } from './state'
+import { state, system } from './state'
 
 export function startup() {
   // If the app is already configured, start the daemon and open browser
@@ -9,4 +9,10 @@ export function startup() {
     startDaemon()
     state.mainWindow?.close()
   }
+
+  if (system.isDev) {
+    state.mainWindow?.setMaximumSize(2000, 2000)
+    state.mainWindow?.setSize(1000, 800)
+    state.mainWindow?.webContents.openDevTools()
+  }
 }
diff --git a/hostd/electron-src/state.ts b/hostd/electron-src/state.ts
index 9f6088a..b27989c 100644
--- a/hostd/electron-src/state.ts
+++ b/hostd/electron-src/state.ts
@@ -1,5 +1,6 @@
 import { ChildProcess } from 'child_process'
 import { BrowserWindow, Tray } from 'electron'
+import isDev from 'electron-is-dev'
 
 export let state: {
   mainWindow: BrowserWindow | null
@@ -14,10 +15,12 @@ export let state: {
 }
 
 export const system: {
+  isDev: boolean
   isDarwin: boolean
   isLinux: boolean
   isWindows: boolean
 } = {
+  isDev,
   isDarwin: process.platform === 'darwin',
   isLinux: process.platform === 'linux',
   isWindows: process.platform === 'win32',
diff --git a/hostd/electron-src/tray.ts b/hostd/electron-src/tray.ts
index fbce2de..7769a6f 100644
--- a/hostd/electron-src/tray.ts
+++ b/hostd/electron-src/tray.ts
@@ -1,11 +1,10 @@
 import path from 'path'
 import { app, Tray, Menu } from 'electron'
-import isDev from 'electron-is-dev'
 import { state, system } from './state'
 
 export function initTray() {
   const iconName = system.isDarwin ? 'tray.png' : 'tray-win.png'
-  const iconPath = isDev
+  const iconPath = system.isDev
     ? path.join(process.cwd(), 'assets', iconName)
     : path.join(__dirname, '../assets', iconName)
 
diff --git a/hostd/electron-src/window.ts b/hostd/electron-src/window.ts
index 5d222c5..f8caffc 100644
--- a/hostd/electron-src/window.ts
+++ b/hostd/electron-src/window.ts
@@ -1,6 +1,5 @@
 import path, { join } from 'path'
 import { BrowserWindow, app } from 'electron'
-import isDev from 'electron-is-dev'
 import { format } from 'url'
 import { state, system } from './state'
 
@@ -45,7 +44,7 @@ export function initWindow() {
     return false
   })
 
-  const url = isDev
+  const url = system.isDev
     ? 'http://localhost:8000/'
     : format({
         pathname: path.join(__dirname, '../renderer/out/index.html'),
@@ -53,10 +52,4 @@ export function initWindow() {
         slashes: true,
       })
   state.mainWindow.loadURL(url)
-
-  if (isDev) {
-    state.mainWindow.setMaximumSize(2000, 2000)
-    state.mainWindow.setSize(1000, 800)
-    state.mainWindow.webContents.openDevTools()
-  }
 }
diff --git a/hostd/renderer/components/UpdateBanner.tsx b/hostd/renderer/components/UpdateBanner.tsx
index 35a9547..4b363d1 100644
--- a/hostd/renderer/components/UpdateBanner.tsx
+++ b/hostd/renderer/components/UpdateBanner.tsx
@@ -1,21 +1,81 @@
 'use client'
 
-import { Text } from '@siafoundation/design-system'
+import {
+  LoadingDots,
+  Text,
+  triggerErrorToast,
+  triggerSuccessToast,
+} from '@siafoundation/design-system'
 import { Upgrade16 } from '@siafoundation/react-icons'
 import { useLatestVersion } from './useLatestVersion'
 import { useInstalledVersion } from './useInstalledVersion'
+import { useCallback, useState } from 'react'
 
 export function UpdateBanner() {
   const latestVersion = useLatestVersion()
   const installedVersion = useInstalledVersion()
+  const [isUpdating, setIsUpdating] = useState(false)
+  const [isRestarting, setIsRestarting] = useState(false)
+
+  const update = useCallback(async () => {
+    try {
+      setIsUpdating(true)
+      await window.electron.daemonUpdate()
+    } catch (e) {
+      console.log(e)
+      triggerErrorToast('Error downloading update. Please try again.')
+      setIsUpdating(false)
+      setIsRestarting(false)
+      return
+    }
+    try {
+      setIsRestarting(true)
+      await window.electron.daemonStart()
+    } catch (e) {
+      console.log(e)
+      triggerErrorToast('Error restarting daemon. Please try again.')
+      setIsUpdating(false)
+      setIsRestarting(false)
+      return
+    }
+    triggerSuccessToast(`Updated to hostd version ${installedVersion.data}.`)
+    setIsUpdating(false)
+    setIsRestarting(false)
+  }, [setIsUpdating, installedVersion.data])
+
+  if (latestVersion.data === installedVersion.data) {
+    return null
+  }
+
   return (
-    <div className="flex w-full gap-2 items-center justify-center py-2 px-3 bg-amber-600 dark:bg-amber-500">
-      <Text color="lo">
-        <Upgrade16 />
-      </Text>
-      <Text size="14" color="lo">
-        An update to version {latestVersion.data} is available.
-      </Text>
+    <div
+      className="flex w-full gap-2 items-center justify-center py-2 px-3 bg-amber-600 dark:bg-amber-500 cursor-pointer"
+      onClick={update}
+    >
+      {isRestarting ? (
+        <>
+          <LoadingDots />
+          <Text size="14" color="lo">
+            Restarting hostd daemon {latestVersion.data}
+          </Text>
+        </>
+      ) : isUpdating ? (
+        <>
+          <LoadingDots />
+          <Text size="14" color="lo">
+            Updating to hostd {latestVersion.data}
+          </Text>
+        </>
+      ) : (
+        <>
+          <Text color="lo">
+            <Upgrade16 />
+          </Text>
+          <Text size="14" color="lo">
+            An update to hostd {latestVersion.data} is available.
+          </Text>
+        </>
+      )}
     </div>
   )
 }
diff --git a/hostd/renderer/renderer.d.ts b/hostd/renderer/renderer.d.ts
index 074d20d..5653a01 100644
--- a/hostd/renderer/renderer.d.ts
+++ b/hostd/renderer/renderer.d.ts
@@ -5,6 +5,7 @@ export interface API {
   openBrowser: (url: string) => Promise<void>
   daemonStart: () => Promise<void>
   daemonStop: () => Promise<void>
+  daemonUpdate: () => Promise<void>
   getConfig: () => Promise<Config>
   openDataDirectory: () => Promise<void>
   getIsConfigured: () => Promise<boolean>
diff --git a/renterd/electron-src/download.ts b/renterd/electron-src/download.ts
index bdfd94f..1c28afa 100644
--- a/renterd/electron-src/download.ts
+++ b/renterd/electron-src/download.ts
@@ -18,6 +18,7 @@ export async function downloadRelease(): Promise<void> {
 
     const release = releaseData.data
     const asset = release.assets.find((asset) => asset.name === releaseAsset())
+    console.log(`Downloading ${releaseAsset()}`)
 
     if (asset) {
       console.log('Release name:', release.name)
diff --git a/renterd/electron-src/ipc.ts b/renterd/electron-src/ipc.ts
index f346296..ff3a90b 100644
--- a/renterd/electron-src/ipc.ts
+++ b/renterd/electron-src/ipc.ts
@@ -14,6 +14,7 @@ import {
   getIsConfigured,
   saveConfig,
 } from './config'
+import { downloadRelease } from './download'
 
 export function initIpc() {
   ipcMain.handle('open-browser', (_, url: string) => {
@@ -26,8 +27,10 @@ export function initIpc() {
     await stopDaemon()
   })
   ipcMain.handle('daemon-is-running', (_) => {
-    const isDaemonRunning = getIsDaemonRunning()
-    return isDaemonRunning
+    return getIsDaemonRunning()
+  })
+  ipcMain.handle('daemon-update', async (_) => {
+    await downloadRelease()
   })
   ipcMain.handle('config-get', (_) => {
     const config = getConfig()
diff --git a/renterd/electron-src/preload.ts b/renterd/electron-src/preload.ts
index fa30894..76eb4f4 100644
--- a/renterd/electron-src/preload.ts
+++ b/renterd/electron-src/preload.ts
@@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('electron', {
   checkIsDaemonRunning: () => ipcRenderer.invoke('daemon-is-running'),
   daemonStart: () => ipcRenderer.invoke('daemon-start'),
   daemonStop: () => ipcRenderer.invoke('daemon-stop'),
+  daemonUpdate: () => ipcRenderer.invoke('daemon-update'),
   openBrowser: (url: string) => ipcRenderer.invoke('open-browser', url),
   getConfig: () => ipcRenderer.invoke('config-get'),
   saveConfig: (config: Config) => ipcRenderer.invoke('config-save', config),
diff --git a/renterd/electron-src/startup.ts b/renterd/electron-src/startup.ts
index 9bd4544..83b160f 100644
--- a/renterd/electron-src/startup.ts
+++ b/renterd/electron-src/startup.ts
@@ -1,7 +1,7 @@
 import { shell } from 'electron'
 import { startDaemon } from './daemon'
 import { getConfig, getIsConfigured } from './config'
-import { state } from './state'
+import { state, system } from './state'
 
 export function startup() {
   // If the app is already configured, start the daemon and open browser
@@ -11,4 +11,10 @@ export function startup() {
     state.mainWindow?.close()
     shell.openExternal(`http://${getConfig().http.address}`)
   }
+
+  if (system.isDev) {
+    state.mainWindow?.setMaximumSize(2000, 2000)
+    state.mainWindow?.setSize(1000, 800)
+    state.mainWindow?.webContents.openDevTools()
+  }
 }
diff --git a/renterd/electron-src/state.ts b/renterd/electron-src/state.ts
index 9f6088a..f8f7e8c 100644
--- a/renterd/electron-src/state.ts
+++ b/renterd/electron-src/state.ts
@@ -1,5 +1,6 @@
 import { ChildProcess } from 'child_process'
 import { BrowserWindow, Tray } from 'electron'
+import isDev from 'electron-is-dev'
 
 export let state: {
   mainWindow: BrowserWindow | null
@@ -14,10 +15,12 @@ export let state: {
 }
 
 export const system: {
+  isDev: boolean
   isDarwin: boolean
   isLinux: boolean
   isWindows: boolean
 } = {
+  isDev: isDev,
   isDarwin: process.platform === 'darwin',
   isLinux: process.platform === 'linux',
   isWindows: process.platform === 'win32',
diff --git a/renterd/electron-src/tray.ts b/renterd/electron-src/tray.ts
index fbce2de..7769a6f 100644
--- a/renterd/electron-src/tray.ts
+++ b/renterd/electron-src/tray.ts
@@ -1,11 +1,10 @@
 import path from 'path'
 import { app, Tray, Menu } from 'electron'
-import isDev from 'electron-is-dev'
 import { state, system } from './state'
 
 export function initTray() {
   const iconName = system.isDarwin ? 'tray.png' : 'tray-win.png'
-  const iconPath = isDev
+  const iconPath = system.isDev
     ? path.join(process.cwd(), 'assets', iconName)
     : path.join(__dirname, '../assets', iconName)
 
diff --git a/renterd/electron-src/window.ts b/renterd/electron-src/window.ts
index 5d222c5..f8caffc 100644
--- a/renterd/electron-src/window.ts
+++ b/renterd/electron-src/window.ts
@@ -1,6 +1,5 @@
 import path, { join } from 'path'
 import { BrowserWindow, app } from 'electron'
-import isDev from 'electron-is-dev'
 import { format } from 'url'
 import { state, system } from './state'
 
@@ -45,7 +44,7 @@ export function initWindow() {
     return false
   })
 
-  const url = isDev
+  const url = system.isDev
     ? 'http://localhost:8000/'
     : format({
         pathname: path.join(__dirname, '../renderer/out/index.html'),
@@ -53,10 +52,4 @@ export function initWindow() {
         slashes: true,
       })
   state.mainWindow.loadURL(url)
-
-  if (isDev) {
-    state.mainWindow.setMaximumSize(2000, 2000)
-    state.mainWindow.setSize(1000, 800)
-    state.mainWindow.webContents.openDevTools()
-  }
 }
diff --git a/renterd/renderer/components/UpdateBanner.tsx b/renterd/renderer/components/UpdateBanner.tsx
index 35a9547..30ba31a 100644
--- a/renterd/renderer/components/UpdateBanner.tsx
+++ b/renterd/renderer/components/UpdateBanner.tsx
@@ -1,21 +1,81 @@
 'use client'
 
-import { Text } from '@siafoundation/design-system'
+import {
+  LoadingDots,
+  Text,
+  triggerErrorToast,
+  triggerSuccessToast,
+} from '@siafoundation/design-system'
 import { Upgrade16 } from '@siafoundation/react-icons'
 import { useLatestVersion } from './useLatestVersion'
 import { useInstalledVersion } from './useInstalledVersion'
+import { useCallback, useState } from 'react'
 
 export function UpdateBanner() {
   const latestVersion = useLatestVersion()
   const installedVersion = useInstalledVersion()
+  const [isUpdating, setIsUpdating] = useState(false)
+  const [isRestarting, setIsRestarting] = useState(false)
+
+  const update = useCallback(async () => {
+    try {
+      setIsUpdating(true)
+      await window.electron.daemonUpdate()
+    } catch (e) {
+      console.log(e)
+      triggerErrorToast('Error downloading update. Please try again.')
+      setIsUpdating(false)
+      setIsRestarting(false)
+      return
+    }
+    try {
+      setIsRestarting(true)
+      await window.electron.daemonStart()
+    } catch (e) {
+      console.log(e)
+      triggerErrorToast('Error restarting daemon. Please try again.')
+      setIsUpdating(false)
+      setIsRestarting(false)
+      return
+    }
+    triggerSuccessToast(`Updated to renterd version ${installedVersion.data}.`)
+    setIsUpdating(false)
+    setIsRestarting(false)
+  }, [setIsUpdating, installedVersion.data])
+
+  if (latestVersion.data === installedVersion.data) {
+    return null
+  }
+
   return (
-    <div className="flex w-full gap-2 items-center justify-center py-2 px-3 bg-amber-600 dark:bg-amber-500">
-      <Text color="lo">
-        <Upgrade16 />
-      </Text>
-      <Text size="14" color="lo">
-        An update to version {latestVersion.data} is available.
-      </Text>
+    <div
+      className="flex w-full gap-2 items-center justify-center py-2 px-3 bg-amber-600 dark:bg-amber-500 cursor-pointer"
+      onClick={update}
+    >
+      {isRestarting ? (
+        <>
+          <LoadingDots />
+          <Text size="14" color="lo">
+            Restarting renterd daemon {latestVersion.data}
+          </Text>
+        </>
+      ) : isUpdating ? (
+        <>
+          <LoadingDots />
+          <Text size="14" color="lo">
+            Updating to renterd {latestVersion.data}
+          </Text>
+        </>
+      ) : (
+        <>
+          <Text color="lo">
+            <Upgrade16 />
+          </Text>
+          <Text size="14" color="lo">
+            An update to renterd {latestVersion.data} is available.
+          </Text>
+        </>
+      )}
     </div>
   )
 }
diff --git a/renterd/renderer/renderer.d.ts b/renterd/renderer/renderer.d.ts
index 074d20d..5653a01 100644
--- a/renterd/renderer/renderer.d.ts
+++ b/renterd/renderer/renderer.d.ts
@@ -5,6 +5,7 @@ export interface API {
   openBrowser: (url: string) => Promise<void>
   daemonStart: () => Promise<void>
   daemonStop: () => Promise<void>
+  daemonUpdate: () => Promise<void>
   getConfig: () => Promise<Config>
   openDataDirectory: () => Promise<void>
   getIsConfigured: () => Promise<boolean>