Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Extensions #197

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions .github/workflows/node.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,26 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Use Node.js 18
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Enable Corepack
run: corepack enable
- name: apt-get update
run: sudo apt-get update
- name: Install libasound2-dev
run: sudo apt-get install -y libasound2-dev
- name: Install libudev-dev
run: sudo apt-get install -y libudev-dev
- name: restore node_modules
- name: Restore node_modules
uses: actions/cache@v3
with:
path: |
node_modules
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
- name: Prepare Environment
run: |
yarn --ignore-engines --frozen-lockfile --network-timeout 1000000
yarn --frozen-lockfile --network-timeout 1000000
- name: Typecheck
run: yarn build
- name: Lint
Expand All @@ -50,18 +52,20 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Use Node.js 18
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '18'
- name: restore node_modules
- name: Enable Corepack
run: corepack enable
- name: Restore node_modules
uses: actions/cache@v3
with:
path: |
node_modules
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
- name: Prepare Environment
run: |
yarn --ignore-engines --frozen-lockfile --network-timeout 1000000
yarn --frozen-lockfile --network-timeout 1000000
- name: Build
run: |
yarn build
Expand All @@ -72,18 +76,20 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Use Node.js 18
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '18'
- name: restore node_modules
- name: Enable Corepack
run: corepack enable
- name: Restore node_modules
uses: actions/cache@v3
with:
path: |
node_modules
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
- name: Prepare Environment
run: |
yarn --ignore-engines --frozen-lockfile --network-timeout 1000000
yarn --frozen-lockfile --network-timeout 1000000
- name: Build
run: |
yarn build
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,12 @@ yarn-error.log*
lerna-debug.log*
.vscode/*
!.vscode/launch.json

# yarn
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
1 change: 1 addition & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
2 changes: 1 addition & 1 deletion apps/app/nodemon.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"watch": ["src/**/*"],
"ignore": ["*.test.ts", "src/react/*", "README"],
"exec": "tsc -p tsconfig.electron.json && electron ./dist/main.js --inspect=9229",
"exec": "yarn run tsc -p tsconfig.electron.json && electron ./dist/main.js --inspect=9229",
"ext": "ts"
}
13 changes: 8 additions & 5 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@
},
"scripts": {
"build": "rimraf ./dist && tsc && webpack",
"build:electron": "tsc -p tsconfig.electron.json",
"build:electron": "run -T tsc -p tsconfig.electron.json",
"build:extensionHost": "run -T tsc -p tsconfig.worker.json",
"build:binary": "electron-builder",
"start": "yarn build && electron dist/main.js",
"react:dev": "webpack serve --mode=development",
"extensionHost:dev": "run -T tsc -p tsconfig.worker.json --watch",
"electron:dev": "nodemon",
"dev": "concurrently --kill-others \"yarn react:dev\" \"yarn electron:dev\"",
"test": "jest",
"precommit": "lint-staged",
"lint:raw": "eslint --ext .ts --ext .js --ext .tsx --ext .jsx --ignore-pattern dist"
"dev": "concurrently --kill-others \"yarn react:dev\" \"yarn electron:dev\" \"yarn extensionHost:dev\"",
"test": "run -T jest",
"tsc": "run -T tsc",
"precommit": "run -T lint-staged",
"lint:raw": "run -T eslint --ext .ts --ext .js --ext .tsx --ext .jsx --ignore-pattern dist"
},
"repository": {
"type": "git",
Expand Down
25 changes: 24 additions & 1 deletion apps/app/src/electron/SuperConductor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { SystemMessageOptions } from '../ipc/IPCAPI'
import { getTimelineForGroup } from '../lib/timeline'
import { TSRTimeline } from 'timeline-state-resolver-types'
import { UndoLedgerService } from './UndoService'
import { ExtensionHandler } from './extensionHandler'

export class SuperConductor {
ipcServer: EverythingService
Expand All @@ -57,7 +58,9 @@ export class SuperConductor {
triggers: TriggersHandler
analogHandler: AnalogHandler
bridgeHandler: BridgeHandler
extensionHandler: ExtensionHandler

private hasLoaded = false
private shuttingDown = false
private resourceUpdatesToSend = new Map<ResourceId, ResourceAny | null>()
private metadataUpdatesToSend = new Map<TSRDeviceId, MetadataAny | null>()
Expand Down Expand Up @@ -233,6 +236,20 @@ export class SuperConductor {
this.clientEventBus.updateUndoLedgers(data)
})

this.extensionHandler = new ExtensionHandler(this.log, this.storage, CURRENT_VERSION, {
onExtensionAdded: (extensionManifest, baseUrl) => {
this.sendSystemMessage(`New extension: "${extensionManifest.name}" "${baseUrl}"`, {
variant: 'success',
})
},
onExtensionRemoved: (extensionName) => {
this.sendSystemMessage(`Extension removed: "${extensionName}"`, {
variant: 'success',
})
},
})
this.extensionHandler.init()

this.ipcServer = new EverythingService(
this.log,
this.renderLog,
Expand Down Expand Up @@ -302,7 +319,13 @@ export class SuperConductor {
// TODO: now this becomes an API that also serves the contents of the Electron window - it should not be disabled
this.log.info(`Internal HTTP API disabled`)
} else {
this.httpAPI = new ApiServer(this.internalHttpApiPort, this.ipcServer, this.clientEventBus, this.log)
this.httpAPI = new ApiServer(
this.internalHttpApiPort,
this.ipcServer,
this.clientEventBus,
this.storage,
this.log
)
}

this._restoreTimelines()
Expand Down
26 changes: 24 additions & 2 deletions apps/app/src/electron/api/ApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,32 @@ import { RundownService, RUNDOWN_CHANNEL_PREFIX } from './RundownService'
import { LegacyService } from './LegacyService'
import { ReportingService } from './ReportingService'
import { PROJECTS_CHANNEL_PREFIX, ProjectService } from './ProjectService'
import { ClientMethods, ProjectsEvents, RundownsEvents, ServiceName, ServiceTypes } from '../../ipc/IPCAPI'
import {
ClientMethods,
ExtensionsEvents,
ProjectsEvents,
RundownsEvents,
ServiceName,
ServiceTypes,
} from '../../ipc/IPCAPI'
import { Project, ProjectBase } from '../../models/project/Project'
import { PartService } from './PartService'
import { GroupService } from './GroupService'
import { unReplaceUndefined } from '../../lib/util'
import { ExtensionsService } from './ExtensionService'
import { StorageHandler } from '../storageHandler'
import { CURRENT_VERSION } from '../bridgeHandler'

export class ApiServer {
private app = koa<ServiceTypes>(feathers())

constructor(port: number, ipcServer: EverythingService, clientEventBus: ClientEventBus, log: LoggerLike) {
constructor(
port: number,
ipcServer: EverythingService,
clientEventBus: ClientEventBus,
storageHandler: StorageHandler,
log: LoggerLike
) {
this.app.use(serveStatic('src'))

this.app.use(
Expand Down Expand Up @@ -77,6 +93,12 @@ export class ApiServer {
return this.app.channel(RUNDOWN_CHANNEL_PREFIX + data.id)
})

this.app.use(ServiceName.EXTENSIONS, new ExtensionsService(this.app, storageHandler, CURRENT_VERSION, log), {
methods: ClientMethods[ServiceName.EXTENSIONS],
serviceEvents: [ExtensionsEvents.ADDED, ExtensionsEvents.REMOVED],
events: [],
})

this.app
.service(ServiceName.PROJECTS)
.publish((_data: string | Project | ProjectBase, _context: HookContext) => {
Expand Down
113 changes: 113 additions & 0 deletions apps/app/src/electron/api/ExtensionService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Application, Params } from '@feathersjs/feathers'
import { NotFound } from '@feathersjs/errors'
import EventEmitter from 'node:events'
import fs from 'node:fs/promises'
import path from 'node:path'
import { ServiceTypes } from '../../ipc/IPCAPI'
import { StorageHandler } from '../storageHandler'
import { ExtensionData, ExtensionManifest, ExtensionName } from 'src/models/GUI/Extension'

Check failure on line 8 in apps/app/src/electron/api/ExtensionService.ts

View workflow job for this annotation

GitHub Actions / Typecheck, Lint & Test

"src/models/GUI/Extension" is not found

Check failure on line 8 in apps/app/src/electron/api/ExtensionService.ts

View workflow job for this annotation

GitHub Actions / Typecheck, Lint & Test

"src/models/GUI/Extension" is not found
import { IDisposable } from 'src/lib/util'

Check failure on line 9 in apps/app/src/electron/api/ExtensionService.ts

View workflow job for this annotation

GitHub Actions / Typecheck, Lint & Test

"src/lib/util" is not found

Check failure on line 9 in apps/app/src/electron/api/ExtensionService.ts

View workflow job for this annotation

GitHub Actions / Typecheck, Lint & Test

"src/lib/util" is not found
import { LoggerLike } from '@shared/api'
import { pathToFileURL } from 'node:url'

export const EXTENSIONS_CHANNEL_PREFIX = 'projects'
export class ExtensionsService extends EventEmitter {
private extensionWatcher: IDisposable | undefined
extensions: Map<ExtensionName, ExtensionData> = new Map()

constructor(
private app: Application<ServiceTypes, any>,
private storageHandler: StorageHandler,
private appVersion: string,
public log: LoggerLike
) {
super()
this.extensionWatcher = this.storageHandler.createExtensionWatcher(this.onExtensionChanged)
}

get = async (name: ExtensionName): Promise<ExtensionData> => {
const extension = this.extensions.get(name)
if (!extension) {
throw new NotFound()
}

return extension
}

getAll = async (params: Params): Promise<ExtensionData[]> => {
if (params.connection) {
// TODO: this will include organizationId in the future
this.app.channel(EXTENSIONS_CHANNEL_PREFIX).join(params.connection) // automatically subscribes to updates
}
return Array.from(this.extensions.values())
}

find = async (): Promise<ExtensionData[]> => {
return Array.from(this.extensions.values())
}

private onExtensionChanged = (type: 'added' | 'removed', filePath: string): void => {
if (type === 'added') {
this.runTask(async () => this.registerExtension(filePath))
} else {
this.runTask(async () => this.unregisterExtension(filePath))
}
}

private runTask = (fnc: () => Promise<void>) => {
fnc().catch((err) => {
this.log.error(err)
})
}

private registerExtension = async (filePath: string): Promise<void> => {
const manifestStr = await fs.readFile(path.join(filePath, 'package.json'), {
encoding: 'utf-8',
})
this.log.debug(manifestStr)
const manifest = JSON.parse(manifestStr) as ExtensionManifest
if (typeof manifest !== 'object' || typeof manifest.name !== 'string')
throw new Error(`Extension has invalid package.json: "${filePath}"`)
if (!this.isPackageCompatible(manifest))
throw new Error(
`Incompatible package engine version: "${manifest.engines.superconductor}", "${this.appVersion}"`
)

const entryPointFileName = manifest.main ?? 'index.js'
const entryPointFilePath = path.join(filePath, entryPointFileName)
await fs.access(entryPointFilePath)

const url = pathToFileURL(entryPointFilePath).toString()
this.extensions.set(manifest.name, {
name: manifest.name,
manifest,
filePath,
url,
})
this.emit('added', manifest.name, manifest, url)
}

private unregisterExtension = async (filePath: string): Promise<void> => {
const data = Array.from(this.extensions.entries()).find(([_name, data]) => data.filePath === filePath)
if (!data) return
const name = data[0]
this.extensions.delete(name)
this.emit('removed', name)
}

private isPackageCompatible = (manifest: ExtensionManifest): boolean => {
if (manifest.engines.superconductor !== this.appVersion) return false
return true
}

unsubscribe = async (params: Params): Promise<void> => {
if (params.connection) {
// TODO: this will include organizationId in the future
this.app.channel(EXTENSIONS_CHANNEL_PREFIX).leave(params.connection)
}
}

terminate = async (): Promise<void> => {
this.extensionWatcher?.dispose()
}
}
Loading
Loading