From c20728b268e9a5d822a76be3ffdb45ee66af9ca4 Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Fri, 27 Jan 2023 16:37:21 +0100 Subject: [PATCH] feat: extension subsystem (working wip) --- frontend/package-lock.json | 14 +- frontend/package.json.md5 | 2 +- frontend/src/lib/extensions.ts | 19 --- frontend/src/lib/extensions/extension.ts | 97 ++++++++++++ frontend/src/lib/extensions/types.ts | 35 ++++ .../lib/extensions/workers/extensionHost.ts | 61 +++++++ .../src/lib/extensions/workers/tsconfig.json | 6 + frontend/src/store/api/types.ts | 8 +- frontend/src/store/extensions/reducer.ts | 73 +++++++++ frontend/src/store/index.ts | 2 + frontend/src/ui/App.tsx | 39 ++--- frontend/src/ui/components/Loading.tsx | 46 ++++++ frontend/src/ui/pages/Extensions.tsx | 149 +++++++++++++++++- frontend/tsconfig.json | 1 + 14 files changed, 491 insertions(+), 61 deletions(-) delete mode 100644 frontend/src/lib/extensions.ts create mode 100644 frontend/src/lib/extensions/extension.ts create mode 100644 frontend/src/lib/extensions/types.ts create mode 100644 frontend/src/lib/extensions/workers/extensionHost.ts create mode 100644 frontend/src/lib/extensions/workers/tsconfig.json create mode 100644 frontend/src/store/extensions/reducer.ts create mode 100644 frontend/src/ui/components/Loading.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5804e25..4610af4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,7 +25,7 @@ "@redux-devtools/extension": "^3.2.5", "@reduxjs/toolkit": "^1.9.1", "@stitches/react": "^1.2.8", - "@strimertul/kilovolt-client": "^7.0.0", + "@strimertul/kilovolt-client": "^7.0.1", "@types/node": "^18.11.18", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", @@ -1509,9 +1509,9 @@ } }, "node_modules/@strimertul/kilovolt-client": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.0.tgz", - "integrity": "sha512-SD3mfxAnZcMXMud8tGQKbvkrRQqtM6RrZ68vMyjmuMAnnKTa+xUu5GKDgrFGjoqsrS6YN64dhAofTjELHb/a+g==" + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.1.tgz", + "integrity": "sha512-G9xLgufk1aRmrzUnxZeQHbSLewEy199WAox4zGolWf9GdlKREKcfcLYWq/idxvTacRusRuex2Z7+Gl/lXh7Swg==" }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.1", @@ -6101,9 +6101,9 @@ "requires": {} }, "@strimertul/kilovolt-client": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.0.tgz", - "integrity": "sha512-SD3mfxAnZcMXMud8tGQKbvkrRQqtM6RrZ68vMyjmuMAnnKTa+xUu5GKDgrFGjoqsrS6YN64dhAofTjELHb/a+g==" + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.1.tgz", + "integrity": "sha512-G9xLgufk1aRmrzUnxZeQHbSLewEy199WAox4zGolWf9GdlKREKcfcLYWq/idxvTacRusRuex2Z7+Gl/lXh7Swg==" }, "@types/hoist-non-react-statics": { "version": "3.3.1", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 4840533..0fc7db6 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -685c0cbe0bfefb0bb19e2506207200a6 \ No newline at end of file +66a652086f52f01615c5cbe3a6b5474e \ No newline at end of file diff --git a/frontend/src/lib/extensions.ts b/frontend/src/lib/extensions.ts deleted file mode 100644 index afb77d2..0000000 --- a/frontend/src/lib/extensions.ts +++ /dev/null @@ -1,19 +0,0 @@ -export class Extension extends EventTarget { - private readonly worker: Worker; - - constructor(public readonly source: string) { - super(); - const blob = new Blob([source], { type: 'text/javascript' }); - this.worker = new Worker(URL.createObjectURL(blob)); - this.worker.onerror = (ev) => - this.dispatchEvent(new CustomEvent('error', { detail: ev })); - this.worker.onmessage = (ev) => - this.dispatchEvent(new CustomEvent('message', { detail: ev })); - } - - stop() { - this.worker.terminate(); - } -} - -export default { Extension }; diff --git a/frontend/src/lib/extensions/extension.ts b/frontend/src/lib/extensions/extension.ts new file mode 100644 index 0000000..a319ce6 --- /dev/null +++ b/frontend/src/lib/extensions/extension.ts @@ -0,0 +1,97 @@ +import { + ExtensionStatus, + ExtensionOptions, + ExtensionDependencies, + ExtensionHostMessage, + ExtensionHostCommand, +} from './types'; + +export class Extension extends EventTarget { + private readonly worker: Worker; + + private workerStatus = ExtensionStatus.GettingReady; + + private workerError?: ErrorEvent; + + constructor( + public readonly name: string, + public readonly source: string, + options: ExtensionOptions, + dependencies: ExtensionDependencies, + ) { + super(); + + this.worker = new Worker( + new URL('./workers/extensionHost.ts', import.meta.url), + { type: 'module' }, + ); + this.worker.onerror = (ev) => { + this.workerError = ev; + this.dispatchEvent(new CustomEvent('error', { detail: ev })); + }; + this.worker.onmessage = (ev: MessageEvent) => + this.messageReceived(ev); + + // Initialize ext host + this.send({ + kind: 'arguments', + source, + options, + dependencies, + }); + } + + private send(cmd: ExtensionHostCommand) { + console.log(cmd); + this.worker.postMessage(cmd); + } + + private messageReceived(ev: MessageEvent) { + const msg = ev.data; + switch (msg.kind) { + case 'status-change': + this.workerStatus = msg.status; + this.dispatchEvent( + new CustomEvent('statusChanged', { detail: msg.status }), + ); + break; + } + } + + public get status() { + return this.workerStatus; + } + + public get error() { + return this.workerError; + } + + start() { + switch (this.status) { + case ExtensionStatus.Ready: + return this.send({ + kind: 'start', + }); + case ExtensionStatus.GettingReady: + case ExtensionStatus.Error: + throw new Error('extension is not ready'); + case ExtensionStatus.Running: + case ExtensionStatus.Finished: + throw new Error('extension is already running'); + case ExtensionStatus.Terminated: + throw new Error( + 'extension has been terminated, did you forget to trash this instance?', + ); + } + } + + stop() { + this.worker.terminate(); + this.workerStatus = ExtensionStatus.Terminated; + this.dispatchEvent( + new CustomEvent('statusChanged', { detail: this.workerStatus }), + ); + } +} + +export default { Extension }; diff --git a/frontend/src/lib/extensions/types.ts b/frontend/src/lib/extensions/types.ts new file mode 100644 index 0000000..42af7d5 --- /dev/null +++ b/frontend/src/lib/extensions/types.ts @@ -0,0 +1,35 @@ +export interface ExtensionDependencies { + kilovolt: { + address: string; + password?: string; + }; +} + +export interface ExtensionOptions { + autostart: boolean; +} + +export enum ExtensionStatus { + GettingReady = 'not-ready', + Ready = 'ready', + Running = 'running', + Finished = 'main-loop-finished', + Error = 'error', + Terminated = 'terminated', +} + +export type ExtensionHostCommand = EHParamMessage | EHStartMessage; +export type ExtensionHostMessage = EHStatusChangeMessage; +interface EHParamMessage { + kind: 'arguments'; + options: ExtensionOptions; + dependencies: ExtensionDependencies; + source: string; +} +interface EHStartMessage { + kind: 'start'; +} +interface EHStatusChangeMessage { + kind: 'status-change'; + status: ExtensionStatus; +} diff --git a/frontend/src/lib/extensions/workers/extensionHost.ts b/frontend/src/lib/extensions/workers/extensionHost.ts new file mode 100644 index 0000000..af2a718 --- /dev/null +++ b/frontend/src/lib/extensions/workers/extensionHost.ts @@ -0,0 +1,61 @@ +import Kilovolt from '@strimertul/kilovolt-client'; +import { + ExtensionHostCommand, + ExtensionHostMessage, + ExtensionStatus, +} from '../types'; + +const sendMessage = ( + message: ExtensionHostMessage, + transfer?: Transferable[], +) => postMessage(message, transfer); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function +async function ExtensionFunction(kv: Kilovolt) {} + +let extFn: typeof ExtensionFunction = null; +let kv: Kilovolt; + +let extensionStatus = ExtensionStatus.GettingReady; +function setStatus(status: ExtensionStatus) { + extensionStatus = status; + sendMessage({ + kind: 'status-change', + status, + }); +} + +function start() { + if (!extFn || !kv || extensionStatus !== ExtensionStatus.Ready) + throw new Error('extension not ready'); + void extFn(kv).then(() => { + setStatus(ExtensionStatus.Finished); + }); + setStatus(ExtensionStatus.Running); +} + +onmessage = async (ev: MessageEvent) => { + const cmd = ev.data; + switch (cmd.kind) { + case 'arguments': { + // Create Kilovolt instance + kv = new Kilovolt( + cmd.dependencies.kilovolt.address, + cmd.dependencies.kilovolt.password, + ); + await kv.wait(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + extFn = ExtensionFunction.constructor('kv', cmd.source); + setStatus(ExtensionStatus.Ready); + + if (cmd.options.autostart) { + start(); + } + break; + } + case 'start': + start(); + break; + } +}; diff --git a/frontend/src/lib/extensions/workers/tsconfig.json b/frontend/src/lib/extensions/workers/tsconfig.json new file mode 100644 index 0000000..16bd24f --- /dev/null +++ b/frontend/src/lib/extensions/workers/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends":"../../../../tsconfig.json", + "compilerOptions": { + "lib": ["es2019", "WebWorker"], + } +} diff --git a/frontend/src/store/api/types.ts b/frontend/src/store/api/types.ts index 9bcc6ef..d213b2f 100644 --- a/frontend/src/store/api/types.ts +++ b/frontend/src/store/api/types.ts @@ -3,21 +3,21 @@ import KilovoltWS from '@strimertul/kilovolt-client'; import type { kvError } from '@strimertul/kilovolt-client/types/messages'; -interface HTTPConfig { +export interface HTTPConfig { bind: string; enable_static_server: boolean; kv_password: string; path: string; } -interface TwitchConfig { +export interface TwitchConfig { enabled: boolean; enable_bot: boolean; api_client_id: string; api_client_secret: string; } -interface TwitchBotConfig { +export interface TwitchBotConfig { username: string; oauth: string; channel: string; @@ -32,7 +32,7 @@ export const accessLevels = [ 'streamer', ] as const; -export type AccessLevelType = typeof accessLevels[number]; +export type AccessLevelType = (typeof accessLevels)[number]; export interface TwitchBotCustomCommand { description: string; diff --git a/frontend/src/store/extensions/reducer.ts b/frontend/src/store/extensions/reducer.ts new file mode 100644 index 0000000..921b174 --- /dev/null +++ b/frontend/src/store/extensions/reducer.ts @@ -0,0 +1,73 @@ +/* eslint-disable no-param-reassign */ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Extension } from '~/lib/extensions/extension'; +import { + ExtensionDependencies, + ExtensionOptions, +} from '~/lib/extensions/types'; +import { RootState } from '..'; +import { HTTPConfig } from '../api/types'; + +interface ExtensionsState { + ready: boolean; + installed: Record; + dependencies: ExtensionDependencies; +} + +export interface ExtensionEntry { + name: string; + source: string; + options: ExtensionOptions; +} + +const initialState: ExtensionsState = { + ready: false, + installed: {}, + dependencies: { + kilovolt: { address: '' }, + }, +}; + +const extensionsReducer = createSlice({ + name: 'extensions', + initialState, + reducers: { + initialized(state, { payload }: PayloadAction) { + state.dependencies = payload; + state.ready = true; + }, + extensionAdded(state, { payload }: PayloadAction) { + state.installed[payload.name] = new Extension( + payload.name, + payload.source, + payload.options, + { + kilovolt: { ...state.dependencies.kilovolt }, + }, + ); + }, + }, +}); + +export const initializeExtensions = createAsyncThunk( + 'extensions/initialize', + async (_: void, { getState, dispatch }) => { + // Get kv client + const { api } = getState() as RootState; + + // Get kilovolt endpoint/credentials + const httpConfig = await api.client.getJSON('http/config'); + + // Set dependencies + dispatch( + extensionsReducer.actions.initialized({ + kilovolt: { + address: `ws://${httpConfig.bind}/ws`, + password: httpConfig.kv_password, + }, + }), + ); + }, +); + +export default extensionsReducer; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index e5aa00c..8fab93f 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -4,11 +4,13 @@ import thunkMiddleware from 'redux-thunk'; import apiReducer from './api/reducer'; import loggingReducer from './logging/reducer'; +import extensionsReducer from './extensions/reducer'; const store = configureStore({ reducer: { api: apiReducer.reducer, logging: loggingReducer.reducer, + extensions: extensionsReducer.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 742d0e1..6560b7a 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -25,8 +25,7 @@ import { useAppDispatch, useAppSelector } from '~/store'; import { createWSClient, useAuthBypass } from '~/store/api/reducer'; import { ConnectionStatus } from '~/store/api/types'; import loggingReducer from '~/store/logging/reducer'; -// @ts-expect-error Asset import -import spinner from '~/assets/icon-loading.svg'; +import { initializeExtensions } from '~/store/extensions/reducer'; import LogViewer from './components/LogViewer'; import Sidebar, { RouteSection } from './components/Sidebar'; @@ -46,32 +45,9 @@ import StrimertulPage from './pages/Strimertul'; import TwitchSettingsPage from './pages/TwitchSettings'; import UISettingsPage from './pages/UISettingsPage'; import ExtensionsPage from './pages/Extensions'; -import { styled, TextBlock } from './theme'; +import { styled } from './theme'; +import Loading from './components/Loading'; -const LoadingDiv = styled('div', { - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - alignItems: 'center', - minHeight: '100vh', -}); - -const Spinner = styled('img', { - maxWidth: '100px', -}); - -interface LoadingProps { - message: string; -} - -function Loading({ message }: React.PropsWithChildren) { - return ( - - - {message} - - ); -} const sections: RouteSection[] = [ { title: 'menu.sections.monitor', @@ -220,9 +196,16 @@ export default function App(): JSX.Element { } if (!client) { void connectToKV(); + return; } if (connected === ConnectionStatus.AuthenticationNeeded) { + // If Kilovolt is protected by password (pretty much always) use the bypass void dispatch(useAuthBypass()); + return; + } + if (connected === ConnectionStatus.Connected) { + // Once connected, initialize UI subsystems + void dispatch(initializeExtensions()); } }, [ready, connected]); @@ -237,7 +220,7 @@ export default function App(): JSX.Element { }, [ready, uiConfig]); if (connected === ConnectionStatus.NotConnected) { - return ; + return ; } if (connected === ConnectionStatus.AuthenticationNeeded) { diff --git a/frontend/src/ui/components/Loading.tsx b/frontend/src/ui/components/Loading.tsx new file mode 100644 index 0000000..050b086 --- /dev/null +++ b/frontend/src/ui/components/Loading.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +// @ts-expect-error Asset import +import spinner from '~/assets/icon-loading.svg'; + +import { styled, TextBlock } from '../theme'; + +const variants = { + size: { + fullscreen: { + minHeight: '100vh', + }, + fill: { + flex: '1', + height: '100%', + }, + }, +}; + +const LoadingDiv = styled('div', { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + variants, +}); + +const Spinner = styled('img', { + maxWidth: '100px', +}); +interface LoadingProps { + size?: keyof typeof variants.size; + message: string; +} + +export default function Loading({ + message, + size, +}: React.PropsWithChildren) { + return ( + + + {message} + + ); +} diff --git a/frontend/src/ui/pages/Extensions.tsx b/frontend/src/ui/pages/Extensions.tsx index 4e5b8b7..96012ac 100644 --- a/frontend/src/ui/pages/Extensions.tsx +++ b/frontend/src/ui/pages/Extensions.tsx @@ -1,15 +1,160 @@ -import React from 'react'; +import Editor from '@monaco-editor/react'; +import { PlusIcon } from '@radix-ui/react-icons'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { PageContainer, PageHeader, PageTitle } from '../theme'; +import { useAppDispatch, useAppSelector } from '~/store'; +import extensionsReducer, { ExtensionEntry } from '~/store/extensions/reducer'; +import DialogContent from '../components/DialogContent'; +import Loading from '../components/Loading'; +import { + Button, + Dialog, + DialogActions, + Field, + FlexRow, + InputBox, + Label, + PageContainer, + PageHeader, + PageTitle, +} from '../theme'; export default function ExtensionsPage(): React.ReactElement { const { t } = useTranslation(); + const extensions = useAppSelector((state) => state.extensions); + const dispatch = useAppDispatch(); + const [currentExtension, setCurrentExtension] = useState<{ + open: boolean; + new: boolean; + entry: ExtensionEntry; + }>({ open: false, new: false, entry: null }); + const [filter, setFilter] = useState(''); + const filterLC = filter.toLowerCase(); + + if (!extensions.ready) { + return ( + + + {t('pages.extensions.title')} + + + + ); + } return ( {t('pages.extensions.title')} +
+ + + + setFilter(e.target.value)} + /> + + + {Object.values(extensions.installed) + ?.filter((r) => r.name.toLowerCase().includes(filterLC)) + .map((e) => ( +
{e.name}
+ ))} +
+ + setCurrentExtension({ ...currentExtension, open: state }) + } + > + +
{ + e.preventDefault(); + if ((e.target as HTMLFormElement).checkValidity()) { + dispatch( + extensionsReducer.actions.extensionAdded( + currentExtension.entry, + ), + ); + setCurrentExtension({ ...currentExtension, open: false }); + } + }} + > + + + + setCurrentExtension({ + ...currentExtension, + entry: { ...currentExtension.entry, name: e.target.value }, + }) + } + /> + + + + + setCurrentExtension({ + ...currentExtension, + entry: { ...currentExtension.entry, source: e }, + }) + } + /> + + + + + +
+
+
); } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 04f7368..d96c8af 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "esnext", + "module": "es2022", "moduleResolution": "node", "allowSyntheticDefaultImports": true, "jsx": "react-jsx",