diff --git a/app.go b/app.go index 29fe35d..8d4da2b 100644 --- a/app.go +++ b/app.go @@ -2,6 +2,7 @@ package main import ( "context" + "runtime/debug" "strconv" "github.com/strimertul/strimertul/docs" @@ -143,3 +144,16 @@ func (a *App) GetLastLogs() []LogEntry { func (a *App) GetDocumentation() map[string]docs.KeyObject { return docs.Keys } + +type VersionInfo struct { + Release string `json:"release"` + BuildInfo *debug.BuildInfo `json:"build"` +} + +func (a *App) GetAppVersion() VersionInfo { + info, _ := debug.ReadBuildInfo() + return VersionInfo{ + Release: appVersion, + BuildInfo: info, + } +} diff --git a/frontend/src/lib/extensions/extension.ts b/frontend/src/lib/extensions/extension.ts index 95f36ec..7506761 100644 --- a/frontend/src/lib/extensions/extension.ts +++ b/frontend/src/lib/extensions/extension.ts @@ -12,6 +12,7 @@ export const blankTemplate = (slug: string) => `// ==Extension== // @version 1.0 // @author Put your name here! // @description A new extension for strimertul +// @apiversion 3.1.0 // ==/Extension== `; diff --git a/frontend/src/lib/extensions/metadata.ts b/frontend/src/lib/extensions/metadata.ts index 4ef4ace..a5e1bca 100644 --- a/frontend/src/lib/extensions/metadata.ts +++ b/frontend/src/lib/extensions/metadata.ts @@ -6,10 +6,11 @@ // ==/Extension== interface ExtensionMetadata { - name: string; - version: string; - author: string; - description: string; + name?: string; + version?: string; + author?: string; + description?: string; + apiversion: string; } export function parseExtensionMetadata( @@ -40,6 +41,7 @@ export function parseExtensionMetadata( version: metadata.version, author: metadata.author, description: metadata.description, + apiversion: metadata.apiversion ?? '3.1.0', }; } diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index aefdb68..2cc76ff 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -332,7 +332,9 @@ "error": "Error encountered", "terminated": "Stopped" }, - "error-alert": "Error details for {{name}}" + "error-alert": "Error details for {{name}}", + "incompatible-body": "This extension requires {{APPNAME}} version {{version}} and up, you are currently running {{appversion}}, which may be too old and miss required features", + "incompatible-warning": "This extension is not compatible" } }, "form-actions": { diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 8fab93f..5245b91 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -5,12 +5,14 @@ import thunkMiddleware from 'redux-thunk'; import apiReducer from './api/reducer'; import loggingReducer from './logging/reducer'; import extensionsReducer from './extensions/reducer'; +import serverReducer from './server/reducer'; const store = configureStore({ reducer: { api: apiReducer.reducer, logging: loggingReducer.reducer, extensions: extensionsReducer.reducer, + server: serverReducer.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/frontend/src/store/server/reducer.ts b/frontend/src/store/server/reducer.ts new file mode 100644 index 0000000..b7fa4af --- /dev/null +++ b/frontend/src/store/server/reducer.ts @@ -0,0 +1,31 @@ +/* eslint-disable no-param-reassign */ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { GetAppVersion } from '@wailsapp/go/main/App'; +import { main } from '@wailsapp/go/models'; + +interface ServerState { + version: main.VersionInfo; +} + +const initialState: ServerState = { + version: null, +}; + +const serverReducer = createSlice({ + name: 'server', + initialState, + reducers: { + loadedVersionData(state, { payload }: PayloadAction) { + state.version = payload; + }, + }, +}); + +export const initializeServerInfo = createAsyncThunk( + 'server/init-info', + async (_: void, { dispatch }) => { + dispatch(serverReducer.actions.loadedVersionData(await GetAppVersion())); + }, +); + +export default serverReducer; diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 6560b7a..6e0876f 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -26,6 +26,7 @@ import { createWSClient, useAuthBypass } from '~/store/api/reducer'; import { ConnectionStatus } from '~/store/api/types'; import loggingReducer from '~/store/logging/reducer'; import { initializeExtensions } from '~/store/extensions/reducer'; +import { initializeServerInfo } from '~/store/server/reducer'; import LogViewer from './components/LogViewer'; import Sidebar, { RouteSection } from './components/Sidebar'; @@ -165,6 +166,11 @@ export default function App(): JSX.Element { ); }; + // Fill application info + useEffect(() => { + void dispatch(initializeServerInfo()); + }); + // Get application logs useEffect(() => { void GetLastLogs().then((logs) => { diff --git a/frontend/src/ui/components/Sidebar.tsx b/frontend/src/ui/components/Sidebar.tsx index 6a70151..7fc73bf 100644 --- a/frontend/src/ui/components/Sidebar.tsx +++ b/frontend/src/ui/components/Sidebar.tsx @@ -1,13 +1,12 @@ import { styled } from '@stitches/react'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; import { Link, useMatch, useResolvedPath } from 'react-router-dom'; // @ts-expect-error Asset import import logo from '~/assets/icon-logo.svg'; -import { RootState } from '~/store'; +import { useAppSelector } from '~/store'; import { APPNAME, APPREPO } from '../theme'; import BrowserLink from './BrowserLink'; import Scrollbar from './utils/Scrollbar'; @@ -160,18 +159,12 @@ export default function Sidebar({ const { t } = useTranslation(); const resolved = useResolvedPath('/about'); const matchApp = useMatch({ path: resolved.pathname, end: true }); - const client = useSelector((state: RootState) => state.api.client); - const [version, setVersion] = useState(null); + const version = useAppSelector((state) => state.server.version?.release); const [lastVersion, setLastVersion] = useState<{ url: string; name: string }>( null, ); const dev = version && version.startsWith('v0.0.0'); - async function fetchVersion() { - const versionString = await client.getKey('stul-meta/version'); - setVersion(versionString); - } - async function fetchLastVersion() { try { const req = await fetch( @@ -197,12 +190,6 @@ export default function Sidebar({ void fetchLastVersion(); }, []); - useEffect(() => { - if (client) { - void fetchVersion(); - } - }, [client]); - return ( @@ -219,11 +206,14 @@ export default function Sidebar({ {version && !dev ? version : t('debug.dev-build')} - {!dev && lastVersion && !version.startsWith(lastVersion.name) && ( - - {t('menu.messages.update-available')} - - )} + {!dev && + version && + lastVersion && + !version.startsWith(lastVersion.name) && ( + + {t('menu.messages.update-available')} + + )} {sections.map(({ title: sectionTitle, links }) => ( diff --git a/frontend/src/ui/pages/Extensions.tsx b/frontend/src/ui/pages/Extensions.tsx index c3d2bb8..22af659 100644 --- a/frontend/src/ui/pages/Extensions.tsx +++ b/frontend/src/ui/pages/Extensions.tsx @@ -145,6 +145,11 @@ type ExtensionListItemProps = { function ExtensionListItem(props: ExtensionListItemProps) { const { t } = useTranslation(); const metadata = parseExtensionMetadata(props.entry.source); + const version = useAppSelector((state) => state.server.version?.release); + const isDev = version && version.startsWith('v0.0.0'); + const showIncompatibleWarning = + !isDev && version && version < `v${metadata.apiversion}`; + return ( {t(`pages.extensions.statuses.${props.status}`)} + {showIncompatibleWarning ? ( + + + + + + {t('pages.extensions.incompatible-body', { + version: metadata.apiversion, + appversion: version, + })} + + + ) : null} {props.error ? ( diff --git a/frontend/src/ui/theme/forms.ts b/frontend/src/ui/theme/forms.ts index 5b11659..6d9df77 100644 --- a/frontend/src/ui/theme/forms.ts +++ b/frontend/src/ui/theme/forms.ts @@ -231,6 +231,19 @@ const button = { }, }, }, + warning: { + border: '1px solid $yellow6', + backgroundColor: '$yellow4', + '&:not(:disabled)': { + '&:hover': { + backgroundColor: '$yellow5', + borderColor: '$yellow8', + }, + '&:active': { + background: '$yellow6', + }, + }, + }, danger: { border: '1px solid $red6', backgroundColor: '$red4', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index c52ee51..e6d6806 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -1,10 +1,15 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT import {main} from '../models'; +import {map[string]docs} from '../models'; import {helix} from '../models'; export function AuthenticateKVClient(arg1:string):Promise; +export function GetAppVersion():Promise; + +export function GetDocumentation():Promise; + export function GetKilovoltBind():Promise; export function GetLastLogs():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index b96562d..31872f5 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -6,6 +6,14 @@ export function AuthenticateKVClient(arg1) { return window['go']['main']['App']['AuthenticateKVClient'](arg1); } +export function GetAppVersion() { + return window['go']['main']['App']['GetAppVersion'](); +} + +export function GetDocumentation() { + return window['go']['main']['App']['GetDocumentation'](); +} + export function GetKilovoltBind() { return window['go']['main']['App']['GetKilovoltBind'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index db1f912..0af6707 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -76,6 +76,39 @@ export namespace main { this.data = source["data"]; } } + export class VersionInfo { + release: string; + // Go type: debug.BuildInfo + build?: any; + + static createFrom(source: any = {}) { + return new VersionInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.release = source["release"]; + this.build = this.convertValues(source["build"], null); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } }