Check version (#10646)

- Close https://github.com/enso-org/cloud-v2/issues/1299
- Add component that checks whether the current version of the desktop app is out of date
- Add Devtools toggle so that the functionality is testable in dev servers

# Important Notes
- This functionality is disabled when it is not applicable:
- On the Electron watch mode (as development branches do not need to be the latest version)
- Note however that built apps (`./run ide build`) do still have the check enabled.
- On the cloud dashboard without Electron (as it cannot be updated)
This commit is contained in:
somebody1234 2024-07-24 18:52:14 +10:00 committed by GitHub
parent 3536a18efd
commit 7a26334519
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 226 additions and 7 deletions

View File

@ -49,6 +49,7 @@ import * as backendHooks from '#/hooks/backendHooks'
import AuthProvider, * as authProvider from '#/providers/AuthProvider' import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider from '#/providers/BackendProvider' import BackendProvider from '#/providers/BackendProvider'
import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
import * as httpClientProvider from '#/providers/HttpClientProvider' import * as httpClientProvider from '#/providers/HttpClientProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider' import InputBindingsProvider from '#/providers/InputBindingsProvider'
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider' import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
@ -72,6 +73,7 @@ import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess'
import type * as editor from '#/layouts/Editor' import type * as editor from '#/layouts/Editor'
import * as openAppWatcher from '#/layouts/OpenAppWatcher' import * as openAppWatcher from '#/layouts/OpenAppWatcher'
import VersionChecker from '#/layouts/VersionChecker'
import * as devtools from '#/components/Devtools' import * as devtools from '#/components/Devtools'
import * as errorBoundary from '#/components/ErrorBoundary' import * as errorBoundary from '#/components/ErrorBoundary'
@ -508,7 +510,12 @@ function AppRouter(props: AppRouterProps) {
</router.Routes> </router.Routes>
) )
let result = routes let result = (
<>
<VersionChecker />
{routes}
</>
)
result = <errorBoundary.ErrorBoundary>{result}</errorBoundary.ErrorBoundary> result = <errorBoundary.ErrorBoundary>{result}</errorBoundary.ErrorBoundary>
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider> result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
@ -555,8 +562,8 @@ function AppRouter(props: AppRouterProps) {
{result} {result}
</httpClientProvider.HttpClientProvider> </httpClientProvider.HttpClientProvider>
) )
result = <LoggerProvider logger={logger}>{result}</LoggerProvider> result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
result = <DevtoolsProvider>{result}</DevtoolsProvider>
return result return result
} }

View File

@ -7,11 +7,17 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query' import * as reactQuery from '@tanstack/react-query'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import DevtoolsLogo from '#/assets/enso_logo.svg' import DevtoolsLogo from '#/assets/enso_logo.svg'
import * as billing from '#/hooks/billing' import * as billing from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
import {
useEnableVersionChecker,
useSetEnableVersionChecker,
} from '#/providers/EnsoDevtoolsProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
@ -54,6 +60,8 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const { authQueryKey } = authProvider.useAuth() const { authQueryKey } = authProvider.useAuth()
const session = authProvider.useFullUserSession() const session = authProvider.useFullUserSession()
const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker()
const [features, setFeatures] = React.useState< const [features, setFeatures] = React.useState<
Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration> Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
@ -146,6 +154,31 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
<ariaComponents.Separator orientation="horizontal" className="my-3" /> <ariaComponents.Separator orientation="horizontal" className="my-3" />
<ariaComponents.Text variant="subtitle" className="mb-2">
{getText('productionOnlyFeatures')}
</ariaComponents.Text>
<div className="flex flex-col">
<aria.Switch
className="group flex items-center gap-1"
isSelected={enableVersionChecker ?? !IS_DEV_MODE}
onChange={setEnableVersionChecker}
>
<div className="box-border flex h-4 w-[28px] shrink-0 cursor-default items-center rounded-full bg-primary/30 bg-clip-padding p-0.5 shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-primary/60 group-selected:bg-primary group-selected:group-pressed:bg-primary/50">
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
</div>
<ariaComponents.Text className="flex-1">
{getText('enableVersionChecker')}
</ariaComponents.Text>
</aria.Switch>
<ariaComponents.Text variant="body" color="disabled">
{getText('enableVersionCheckerDescription')}
</ariaComponents.Text>
</div>
<ariaComponents.Separator orientation="horizontal" className="my-3" />
<ariaComponents.Text variant="subtitle" className="mb-2"> <ariaComponents.Text variant="subtitle" className="mb-2">
{getText('paywallDevtoolsPaywallFeaturesToggles')} {getText('paywallDevtoolsPaywallFeaturesToggles')}
</ariaComponents.Text> </ariaComponents.Text>

View File

@ -0,0 +1,88 @@
/** @file Check the version. */
import * as React from 'react'
import { useQuery } from '@tanstack/react-query'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useEnableVersionChecker } from '#/providers/EnsoDevtoolsProvider'
import { useText } from '#/providers/TextProvider'
import { Button, ButtonGroup, Dialog, Text } from '#/components/AriaComponents'
import { download } from '#/utilities/download'
import { getDownloadUrl, getLatestRelease, LATEST_RELEASE_PAGE_URL } from '#/utilities/github'
// ======================
// === VersionChecker ===
// ======================
/** Check the version. */
export default function VersionChecker() {
const [isOpen, setIsOpen] = React.useState(false)
const { getText } = useText()
const toastAndLog = useToastAndLog()
const localBackend = useLocalBackend()
const supportsLocalBackend = localBackend != null
const enableVersionChecker = useEnableVersionChecker() ?? (!IS_DEV_MODE && supportsLocalBackend)
const metadataQuery = useQuery({
queryKey: ['latestRelease', enableVersionChecker],
queryFn: () => (enableVersionChecker ? getLatestRelease() : null),
})
const latestVersion = metadataQuery.data?.tag_name
React.useEffect(() => {
if (latestVersion != null && latestVersion !== process.env.ENSO_CLOUD_DASHBOARD_VERSION) {
setIsOpen(true)
}
}, [latestVersion])
return (
<Dialog
title={getText('versionOutdatedTitle')}
modalProps={{ isOpen }}
onOpenChange={setIsOpen}
>
<div className="flex flex-col gap-3">
<Text className="text-center text-sm">{getText('versionOutdatedPrompt')}</Text>
<div className="flex flex-col">
<Text className="text-center text-sm">
{getText('yourVersion')}{' '}
<Text className="text-sm font-semibold text-danger">
{process.env.ENSO_CLOUD_DASHBOARD_VERSION ?? getText('unknownPlaceholder')}
</Text>
</Text>
<Text className="text-center text-sm">
{getText('latestVersion')}{' '}
<Text className="text-sm font-semibold text-accent">
{latestVersion ?? getText('unknownPlaceholder')}
</Text>
</Text>
</div>
<ButtonGroup className="justify-center">
<Button
size="medium"
variant="tertiary"
onPress={async () => {
const downloadUrl = await getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download(downloadUrl)
}
}}
>
{getText('download')}
</Button>
<Button size="medium" href={LATEST_RELEASE_PAGE_URL} target="_blank">
{getText('seeLatestRelease')}
</Button>
</ButtonGroup>
</div>
</Dialog>
)
}

View File

@ -0,0 +1,80 @@
/** @file The React provider (and associated hooks) for Data Catalog state. */
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as zustand from 'zustand'
// =========================
// === EnsoDevtoolsStore ===
// =========================
/** The state of this zustand store. */
interface EnsoDevtoolsStore {
readonly showVersionChecker: boolean | null
readonly setEnableVersionChecker: (showVersionChecker: boolean | null) => void
}
// =======================
// === ProjectsContext ===
// =======================
/** State contained in a `ProjectsContext`. */
export interface ProjectsContextType extends zustand.StoreApi<EnsoDevtoolsStore> {}
const EnsoDevtoolsContext = React.createContext<ProjectsContextType | null>(null)
/** Props for a {@link EnsoDevtoolsProvider}. */
export interface ProjectsProviderProps extends Readonly<React.PropsWithChildren> {}
// ========================
// === ProjectsProvider ===
// ========================
/** A React provider (and associated hooks) for determining whether the current area
* containing the current element is focused. */
export default function EnsoDevtoolsProvider(props: ProjectsProviderProps) {
const { children } = props
const [store] = React.useState(() => {
return zustand.createStore<EnsoDevtoolsStore>(set => ({
showVersionChecker: false,
setEnableVersionChecker: showVersionChecker => {
set({ showVersionChecker })
},
}))
})
return <EnsoDevtoolsContext.Provider value={store}>{children}</EnsoDevtoolsContext.Provider>
}
// ============================
// === useEnsoDevtoolsStore ===
// ============================
/** The Enso devtools store. */
function useEnsoDevtoolsStore() {
const store = React.useContext(EnsoDevtoolsContext)
invariant(store, 'Enso Devtools store can only be used inside an `EnsoDevtoolsProvider`.')
return store
}
// ===============================
// === useEnableVersionChecker ===
// ===============================
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useEnableVersionChecker() {
const store = useEnsoDevtoolsStore()
return zustand.useStore(store, state => state.showVersionChecker)
}
// ==================================
// === useSetEnableVersionChecker ===
// ==================================
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useSetEnableVersionChecker() {
const store = useEnsoDevtoolsStore()
return zustand.useStore(store, state => state.setEnableVersionChecker)
}

View File

@ -9,13 +9,14 @@ import * as detect from 'enso-common/src/detect'
// ================= // =================
const ONE_HOUR_MS = 3_600_000 const ONE_HOUR_MS = 3_600_000
export const LATEST_RELEASE_PAGE_URL = 'https://github.com/enso-org/enso/releases/latest'
// ================== // ==================
// === GitHub API === // === GitHub API ===
// ================== // ==================
/** Metadata for a GitHub user. */ /** Metadata for a GitHub user. */
interface GithubSimpleUser { interface GitHubSimpleUser {
readonly name?: string readonly name?: string
readonly email?: string readonly email?: string
readonly login: string readonly login: string
@ -59,7 +60,7 @@ interface GitHubReleaseAsset {
readonly download_count: number readonly download_count: number
readonly created_at: string readonly created_at: string
readonly updated_at: string readonly updated_at: string
readonly uploader?: GithubSimpleUser readonly uploader?: GitHubSimpleUser
} }
/** Metadata for a GitHub release. */ /** Metadata for a GitHub release. */
@ -71,7 +72,7 @@ interface GitHubRelease {
readonly tarball_url?: string readonly tarball_url?: string
readonly zipball_url?: string readonly zipball_url?: string
readonly id: number readonly id: number
readonly author: GithubSimpleUser readonly author: GitHubSimpleUser
readonly node_id: string readonly node_id: string
/** The name of the tag. */ /** The name of the tag. */
readonly tag_name: string readonly tag_name: string
@ -101,7 +102,7 @@ interface CachedRelease {
const LOCAL_STORAGE_KEY = `${common.PRODUCT_NAME.toLowerCase()}-cached-release` const LOCAL_STORAGE_KEY = `${common.PRODUCT_NAME.toLowerCase()}-cached-release`
/** Gets the metadata for the latest release of the app. */ /** Gets the metadata for the latest release of the app. */
async function getLatestRelease() { export async function getLatestRelease() {
const savedCachedRelease = localStorage.getItem(LOCAL_STORAGE_KEY) const savedCachedRelease = localStorage.getItem(LOCAL_STORAGE_KEY)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const cachedRelease: CachedRelease | null = const cachedRelease: CachedRelease | null =

View File

@ -189,6 +189,7 @@
"views": "Views", "views": "Views",
"likes": "Likes", "likes": "Likes",
"shortcuts": "Shortcuts", "shortcuts": "Shortcuts",
"download": "Download",
"email": "Email", "email": "Email",
"emailIsRequired": "Email is required", "emailIsRequired": "Email is required",
"emailIsInvalid": "Email is invalid", "emailIsInvalid": "Email is invalid",
@ -226,6 +227,7 @@
"metadata": "Metadata", "metadata": "Metadata",
"path": "Path", "path": "Path",
"reload": "Reload", "reload": "Reload",
"seeLatestRelease": "See latest release",
"enterSecretPath": "Enter secret path", "enterSecretPath": "Enter secret path",
"enterText": "Enter text", "enterText": "Enter text",
@ -281,6 +283,7 @@
"stopExecution": "Stop execution", "stopExecution": "Stop execution",
"openInEditor": "Open in editor", "openInEditor": "Open in editor",
"unknownPlaceholder": "unknown",
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse", "collapse": "Collapse",
"sortAscending": "Sort Ascending", "sortAscending": "Sort Ascending",
@ -303,12 +306,16 @@
"cancelEdit": "Cancel Edit", "cancelEdit": "Cancel Edit",
"loadingAppMessage": "Logging in to Enso...", "loadingAppMessage": "Logging in to Enso...",
"appErroredMessage": "Enso encountered an unrecoverable error.", "appErroredMessage": "Enso encountered an unrecoverable error.",
"appErroredPrompt": "Please try refreshing or installing an updateed version.", "appErroredPrompt": "Please try refreshing or installing an updated version.",
"discoverWhatsNew": "Discover whats new", "discoverWhatsNew": "Discover whats new",
"sampleAndCommunityProjects": "Sample and community projects", "sampleAndCommunityProjects": "Sample and community projects",
"startWithATemplate": "Start with a template", "startWithATemplate": "Start with a template",
"openInfoMenu": "Open info menu", "openInfoMenu": "Open info menu",
"noProjectIsCurrentlyOpen": "No project is currently open.", "noProjectIsCurrentlyOpen": "No project is currently open.",
"versionOutdatedTitle": "Upgrade Enso Now",
"versionOutdatedPrompt": "Download the latest version to get the latest upgrades and Cloud functionality.",
"yourVersion": "Your version:",
"latestVersion": "Latest version:",
"offlineTitle": "You are offline", "offlineTitle": "You are offline",
"offlineErrorMessage": "It seems like you are offline. Please make sure you are connected to the internet and try again", "offlineErrorMessage": "It seems like you are offline. Please make sure you are connected to the internet and try again",
"offlineToastMessage": "You are offline. Some features may be unavailable.", "offlineToastMessage": "You are offline. Some features may be unavailable.",
@ -319,6 +326,7 @@
"cloudUnavailableOfflineDescriptionOfferLocal": "Alternatively, you can work on your local projects.", "cloudUnavailableOfflineDescriptionOfferLocal": "Alternatively, you can work on your local projects.",
"loginUnavailableOffline": "Login functionality is unavailable offline. Please connect to the internet to log in.", "loginUnavailableOffline": "Login functionality is unavailable offline. Please connect to the internet to log in.",
"loginUnavailableOfflineLocal": "After logging in, you can work offline with your local projects.", "loginUnavailableOfflineLocal": "After logging in, you can work offline with your local projects.",
"productionOnlyFeatures": "Production-only features",
"switchToLocal": "Switch to Local", "switchToLocal": "Switch to Local",
"switchToCloud": "Switch to Cloud", "switchToCloud": "Switch to Cloud",
"notEnabledTitle": "Your cloud experience is in progress", "notEnabledTitle": "Your cloud experience is in progress",
@ -401,6 +409,8 @@
"showPassword": "Show password", "showPassword": "Show password",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "Copied to clipboard",
"noResultsFound": "No results found.", "noResultsFound": "No results found.",
"enableVersionChecker": "Enable Version Checker",
"enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.",
"deleteLabelActionText": "delete the label '$0'", "deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "delete '$0'", "deleteSelectedAssetActionText": "delete '$0'",