From 991da3f835503714f9f2fb1a80a8c719a144e5a4 Mon Sep 17 00:00:00 2001 From: Andy Brenneke Date: Tue, 17 Oct 2023 16:04:35 -0700 Subject: [PATCH] In-app updater --- packages/app/src-tauri/Cargo.toml | 2 +- packages/app/src-tauri/tauri.conf.json | 5 +- packages/app/src/components/RivetApp.tsx | 13 +++ packages/app/src/components/UpdateModal.tsx | 105 ++++++++++++++++++ packages/app/src/hooks/useCheckForUpdate.tsx | 68 ++++++++++++ .../app/src/hooks/useMonitorUpdateStatus.ts | 34 ++++++ packages/app/src/index.css | 4 +- packages/app/src/state/settings.ts | 16 +++ 8 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/components/UpdateModal.tsx create mode 100644 packages/app/src/hooks/useCheckForUpdate.tsx create mode 100644 packages/app/src/hooks/useMonitorUpdateStatus.ts diff --git a/packages/app/src-tauri/Cargo.toml b/packages/app/src-tauri/Cargo.toml index 8559873e6..dc57254fd 100644 --- a/packages/app/src-tauri/Cargo.toml +++ b/packages/app/src-tauri/Cargo.toml @@ -17,7 +17,7 @@ tauri-build = { version = "1.2.1", features = [] } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -tauri = { version = "1.2.4", features = [ "shell-execute", "http-all", "path-all", "updater", "shell-open", "dialog-all", "fs-all", "global-shortcut-all", "shell-sidecar", "window-all", "devtools"] } +tauri = { version = "1.2.4", features = [ "process-relaunch", "shell-execute", "http-all", "path-all", "updater", "shell-open", "dialog-all", "fs-all", "global-shortcut-all", "shell-sidecar", "window-all", "devtools"] } tauri-plugin-persisted-scope = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } tar = "0.4.40" diff --git a/packages/app/src-tauri/tauri.conf.json b/packages/app/src-tauri/tauri.conf.json index eb60286fc..aa164b1cb 100644 --- a/packages/app/src-tauri/tauri.conf.json +++ b/packages/app/src-tauri/tauri.conf.json @@ -22,6 +22,9 @@ "dialog": { "all": true }, + "process": { + "relaunch": true + }, "shell": { "sidecar": true, "open": true, @@ -89,7 +92,7 @@ "updater": { "active": true, "endpoints": ["https://github.com/Ironclad/rivet/releases/latest/download/latest.json"], - "dialog": true, + "dialog": false, "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQwRTgxNDEzODJDREJDNzkKUldSNXZNMkNFeFRvUU5VK0NXdEIwNlc3NTREUWpNSmpaeGFwWjFwc21ZT0U5cFlzcy81QndDOXYK" }, "windows": [ diff --git a/packages/app/src/components/RivetApp.tsx b/packages/app/src/components/RivetApp.tsx index 48feb2756..fd44cfd37 100644 --- a/packages/app/src/components/RivetApp.tsx +++ b/packages/app/src/components/RivetApp.tsx @@ -21,6 +21,10 @@ import { useLoadStaticData } from '../hooks/useLoadStaticData'; import { DataStudioRenderer } from './dataStudio/DataStudio'; import { StatusBar } from './StatusBar'; import { PluginsOverlayRenderer } from './PluginsOverlay'; +import { useCheckForUpdate } from '../hooks/useCheckForUpdate'; +import useAsyncEffect from 'use-async-effect'; +import { UpdateModalRenderer } from './UpdateModal'; +import { useMonitorUpdateStatus } from '../hooks/useMonitorUpdateStatus'; const styles = css` overflow: hidden; @@ -40,6 +44,14 @@ export const RivetApp: FC = () => { onRunGraph: tryRunGraph, }); + const checkForUpdate = useCheckForUpdate(); + + useAsyncEffect(async () => { + await checkForUpdate(); + }, []); + + useMonitorUpdateStatus(); + return (
@@ -60,6 +72,7 @@ export const RivetApp: FC = () => { +
); diff --git a/packages/app/src/components/UpdateModal.tsx b/packages/app/src/components/UpdateModal.tsx new file mode 100644 index 000000000..c96a3d3f4 --- /dev/null +++ b/packages/app/src/components/UpdateModal.tsx @@ -0,0 +1,105 @@ +import { useState, type FC, useEffect } from 'react'; + +import Modal, { ModalTransition, ModalBody, ModalFooter, ModalHeader, ModalTitle } from '@atlaskit/modal-dialog'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { updateModalOpenState, updateStatusState } from '../state/settings'; +import Button from '@atlaskit/button'; +import useAsyncEffect from 'use-async-effect'; +import { checkUpdate, installUpdate, onUpdaterEvent } from '@tauri-apps/api/updater'; +import { getVersion } from '@tauri-apps/api/app'; +import { css } from '@emotion/react'; +import { relaunch } from '@tauri-apps/api/process'; + +const bodyStyle = css` + pre { + font-family: var(--font-family); + } +`; + +export const UpdateModalRenderer: FC = () => { + const [modalOpen] = useRecoilState(updateModalOpenState); + + return {modalOpen && }; +}; + +export const UpdateModal: FC = () => { + const setModalOpen = useSetRecoilState(updateModalOpenState); + const [isUpdating, setIsUpdating] = useState(false); + const [updateStatus, setUpdateStatus] = useRecoilState(updateStatusState); + + const [currentVersion, setCurrentVersion] = useState(''); + const [latestVersion, setLatestVersion] = useState(''); + const [updateBody, setUpdateBody] = useState(''); + + useAsyncEffect(async () => { + setCurrentVersion(await getVersion()); + const { manifest } = await checkUpdate(); + if (manifest) { + setLatestVersion(manifest.version); + setUpdateBody(manifest.body); + } + }, []); + + const doUpdate = async () => { + try { + setUpdateStatus('Starting update...'); + setIsUpdating(true); + + await installUpdate(); + } catch (err) { + console.error(err); + } + }; + + const handleModalClose = () => { + if (isUpdating) { + return; + } + setModalOpen(false); + }; + + useAsyncEffect(async () => { + if (updateStatus === 'Installed.') { + await relaunch(); + } + }, [updateStatus]); + + const skipUpdate = () => {}; + + const canRender = currentVersion && latestVersion && updateBody; + + return ( + canRender && ( + + + 🎉 Update Available + + +
+

+ A new version {latestVersion} of Rivet is available. You are on currently on version{' '} + {currentVersion}. Would you like to install it now? +

+

Update Notes:

+
{updateBody}
+
+
+ + {isUpdating ? ( +
{updateStatus}
+ ) : ( + <> + + + + + )} +
+
+ ) + ); +}; diff --git a/packages/app/src/hooks/useCheckForUpdate.tsx b/packages/app/src/hooks/useCheckForUpdate.tsx new file mode 100644 index 000000000..8fe04de3e --- /dev/null +++ b/packages/app/src/hooks/useCheckForUpdate.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; +import { checkUpdate, installUpdate, onUpdaterEvent } from '@tauri-apps/api/updater'; +import useAsyncEffect from 'use-async-effect'; +import { toast } from 'react-toastify'; +import { css } from '@emotion/react'; +import { isInTauri } from '../utils/tauri'; +import { useSetRecoilState } from 'recoil'; +import { updateModalOpenState } from '../state/settings'; + +const toastStyle = css` + display: flex; + flex-direction: column; + + .actions { + display: flex; + flex-direction: row; + justify-content: flex-end; + } + + button { + background-color: var(--grey); + color: var(--grey-lightest); + font-family: apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', + 'Droid Sans', 'Helvetica Neue', sans-serif; + border: 1px solid var(--grey-lightest); + padding: 8px 12px; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + font-size: 14px; + cursor: pointer; + + &.primary { + background-color: var(--primary); + color: var(--foreground-on-primary); + } + } +`; + +export function useCheckForUpdate() { + const setUpdateModalOpen = useSetRecoilState(updateModalOpenState); + + return async () => { + const { shouldUpdate, manifest } = await checkUpdate(); + + if (shouldUpdate) { + toast.success( + ({ closeToast }) => ( +
+
Rivet version {manifest?.version} is now available!
+
+ + + +
+
+ ), + { + autoClose: false, + closeButton: false, + }, + ); + } + }; +} diff --git a/packages/app/src/hooks/useMonitorUpdateStatus.ts b/packages/app/src/hooks/useMonitorUpdateStatus.ts new file mode 100644 index 000000000..933585c7f --- /dev/null +++ b/packages/app/src/hooks/useMonitorUpdateStatus.ts @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import { isInTauri } from '../utils/tauri'; +import { onUpdaterEvent } from '@tauri-apps/api/updater'; +import { useSetRecoilState } from 'recoil'; +import { updateStatusState } from '../state/settings'; +import { match } from 'ts-pattern'; +import useAsyncEffect from 'use-async-effect'; +import { relaunch } from '@tauri-apps/api/process'; + +export function useMonitorUpdateStatus() { + const setUpdateStatus = useSetRecoilState(updateStatusState); + + useAsyncEffect(async () => { + let unlisten: any | undefined = undefined; + + if (isInTauri()) { + unlisten = await onUpdaterEvent(({ error, status }) => { + match(status as typeof status | 'DOWNLOADED') // -.- + .with('PENDING', async () => setUpdateStatus('Downloading...')) + .with('DONE', async () => setUpdateStatus('Installed.')) + .with('ERROR', async () => setUpdateStatus(`Error - ${error}`)) + .with('UPTODATE', async () => setUpdateStatus('Up to date.')) + .with('DOWNLOADED', async () => setUpdateStatus('Installing...')) + .exhaustive(); + }); + } + + return () => { + if (unlisten) { + unlisten(); + } + }; + }, []); +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 5f84342c3..8aaabaf84 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,7 +1,9 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', + + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + font-family: var(--font-family); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; box-sizing: border-box; diff --git a/packages/app/src/state/settings.ts b/packages/app/src/state/settings.ts index 6ab67bc79..368bd5904 100644 --- a/packages/app/src/state/settings.ts +++ b/packages/app/src/state/settings.ts @@ -65,3 +65,19 @@ export const previousDataPerNodeToKeepState = atom({ default: -1, effects_UNSTABLE: [persistAtom], }); + +export const skippedMaxVersion = atom({ + key: 'skippedMaxVersion', + default: undefined, + effects_UNSTABLE: [persistAtom], +}); + +export const updateModalOpenState = atom({ + key: 'updateModalOpen', + default: false, +}); + +export const updateStatusState = atom({ + key: 'updateStatus', + default: undefined, +});