1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-18 01:50:50 +00:00

feat: extension subsystem (working wip)

This commit is contained in:
Ash Keel 2023-01-27 16:37:21 +01:00
parent 3cfcb326fc
commit c20728b268
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
14 changed files with 491 additions and 61 deletions

View file

@ -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",

View file

@ -1 +1 @@
685c0cbe0bfefb0bb19e2506207200a6
66a652086f52f01615c5cbe3a6b5474e

View file

@ -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 };

View file

@ -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<ExtensionHostMessage>) =>
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<ExtensionHostMessage>) {
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 };

View file

@ -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;
}

View file

@ -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<ExtensionHostCommand>) => {
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;
}
};

View file

@ -0,0 +1,6 @@
{
"extends":"../../../../tsconfig.json",
"compilerOptions": {
"lib": ["es2019", "WebWorker"],
}
}

View file

@ -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;

View file

@ -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<string, Extension>;
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<ExtensionDependencies>) {
state.dependencies = payload;
state.ready = true;
},
extensionAdded(state, { payload }: PayloadAction<ExtensionEntry>) {
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<HTTPConfig>('http/config');
// Set dependencies
dispatch(
extensionsReducer.actions.initialized({
kilovolt: {
address: `ws://${httpConfig.bind}/ws`,
password: httpConfig.kv_password,
},
}),
);
},
);
export default extensionsReducer;

View file

@ -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({

View file

@ -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<LoadingProps>) {
return (
<LoadingDiv>
<Spinner src={spinner as string} alt="Loading..." />
<TextBlock>{message}</TextBlock>
</LoadingDiv>
);
}
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 <Loading message={t('special.loading')} />;
return <Loading size="fullscreen" message={t('special.loading')} />;
}
if (connected === ConnectionStatus.AuthenticationNeeded) {

View file

@ -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<LoadingProps>) {
return (
<LoadingDiv size={size}>
<Spinner src={spinner as string} alt="Loading..." />
<TextBlock>{message}</TextBlock>
</LoadingDiv>
);
}

View file

@ -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 (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.extensions.title')}</PageTitle>
</PageHeader>
<Loading
size="fill"
message={'one second, the extension subsystem is still not ready'}
/>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.extensions.title')}</PageTitle>
</PageHeader>
<div>
<Field size="fullWidth" spacing="none">
<FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
<Button
variation="primary"
onClick={() => {
setCurrentExtension({
open: true,
new: true,
entry: {
name: '',
source: '',
options: {
autostart: true,
},
},
});
}}
>
<PlusIcon /> new extension
</Button>
<InputBox
css={{ flex: 1 }}
placeholder="search by name"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</FlexRow>
</Field>
{Object.values(extensions.installed)
?.filter((r) => r.name.toLowerCase().includes(filterLC))
.map((e) => (
<div key={e.name}>{e.name}</div>
))}
</div>
<Dialog
open={currentExtension.open}
onOpenChange={(state) =>
setCurrentExtension({ ...currentExtension, open: state })
}
>
<DialogContent
title={currentExtension.new ? 'new extension' : 'edit extension'}
closeButton={true}
>
<form
onSubmit={(e) => {
e.preventDefault();
if ((e.target as HTMLFormElement).checkValidity()) {
dispatch(
extensionsReducer.actions.extensionAdded(
currentExtension.entry,
),
);
setCurrentExtension({ ...currentExtension, open: false });
}
}}
>
<Field size="fullWidth" spacing="narrow">
<Label htmlFor="d-name">name</Label>
<InputBox
disabled={!currentExtension.new}
id="d-name"
value={currentExtension.entry?.name ?? ''}
required={true}
onChange={(e) =>
setCurrentExtension({
...currentExtension,
entry: { ...currentExtension.entry, name: e.target.value },
})
}
/>
</Field>
<Field size="fullWidth" spacing="narrow">
<Label>source</Label>
<Editor
height="50vh"
defaultLanguage="javascript"
defaultValue="// some comment"
theme="vs-dark"
value={currentExtension.entry?.source}
onChange={(e) =>
setCurrentExtension({
...currentExtension,
entry: { ...currentExtension.entry, source: e },
})
}
/>
</Field>
<DialogActions>
<Button variation="primary" type="submit">
{t('form-actions.save')}
</Button>
<Button
onClick={() =>
setCurrentExtension({ ...currentExtension, open: false })
}
type="button"
>
{t('form-actions.cancel')}
</Button>
</DialogActions>
</form>
</DialogContent>
</Dialog>
</PageContainer>
);
}

View file

@ -1,6 +1,7 @@
{
"compilerOptions": {
"target": "esnext",
"module": "es2022",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx",