Skip to content

Commit

Permalink
wip: adding and removing custom modules
Browse files Browse the repository at this point in the history
  • Loading branch information
Julusian committed Oct 8, 2024
1 parent 63e9578 commit cee3706
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 110 deletions.
29 changes: 25 additions & 4 deletions companion/lib/Instance/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import type { ExportInstanceFullv4, ExportInstanceMinimalv4 } from '@companion-a
import type { ClientSocket } from '../UI/Handler.js'
import { ConnectionConfigStore } from './ConnectionConfigStore.js'
import { InstanceInstalledModulesManager } from './InstalledModulesManager.js'
import { ModuleVersionInfo } from '@companion-app/shared/Model/ModuleInfo.js'
import type { ModuleVersionInfo } from '@companion-app/shared/Model/ModuleInfo.js'
import type { ModuleDirs } from './types.js'
import path from 'path'
import { isPackaged } from '../Resources/Util.js'
import { fileURLToPath } from 'url'

const InstancesRoom = 'instances'

Expand Down Expand Up @@ -70,11 +74,26 @@ export class InstanceController extends CoreBase<InstanceControllerEvents> {

this.#configStore = new ConnectionConfigStore(registry.db, this.broadcastChanges.bind(this))

function generatePath(subpath: string): string {
if (isPackaged()) {
return path.join(__dirname, subpath)
} else {
return fileURLToPath(new URL(path.join('../../..', subpath), import.meta.url))
}
}

const moduleDirs: ModuleDirs = {
bundledLegacyModulesDir: path.resolve(generatePath('modules')),
bundledModulesDir: path.resolve(generatePath('bundled-modules')),
storeModulesDir: path.join(registry.appInfo.configDir, 'store-modules'),
customModulesDir: path.join(registry.appInfo.configDir, 'custom-modules'),
}

this.definitions = new InstanceDefinitions(registry)
this.status = new InstanceStatus(registry.io, registry.controls)
this.moduleHost = new ModuleHost(registry, this.status, this.#configStore)
this.modules = new InstanceModules(registry.io, registry.api_router, this)
this.userModulesManager = new InstanceInstalledModulesManager(this.modules, registry.db, registry.appInfo)
this.modules = new InstanceModules(registry.io, registry.api_router, this, moduleDirs)
this.userModulesManager = new InstanceInstalledModulesManager(this.modules, registry.db, moduleDirs)

// Prepare for clients already
this.broadcastChanges(this.#configStore.getAllInstanceIds())
Expand Down Expand Up @@ -121,7 +140,9 @@ export class InstanceController extends CoreBase<InstanceControllerEvents> {
this.emit('connection_added')
}

reloadUsesOfModule(moduleId: string): void {
async reloadUsesOfModule(moduleId: string, mode: 'release' | 'custom', versionId: string): Promise<void> {
// TODO - use the version!

// restart usages of this module
const { connectionIds, labels } = this.#configStore.findActiveUsagesOfModule(moduleId)
for (const id of connectionIds) {
Expand Down
71 changes: 45 additions & 26 deletions companion/lib/Instance/InstalledModulesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import * as ts from 'tar-stream'
import { Readable } from 'node:stream'
import { ModuleManifest } from '@companion-module/base'
import * as tarfs from 'tar-fs'
import { head } from 'lodash-es'
import { EventEmitter } from 'node:events'
import type { ModuleDirs } from './types.js'

interface InstalledModulesEvents {
installed: [moduleDir: string, manifest: ModuleManifest]
installed: [moduleDir: string, type: 'custom' | 'release', manifest: ModuleManifest]
uninstalled: [moduleId: string, type: 'custom' | 'release', versionId: string]
}

export class InstanceInstalledModulesManager extends EventEmitter {
export class InstanceInstalledModulesManager {
readonly #logger = LogController.createLogger('Instance/UserModulesManager')

/**
Expand All @@ -43,27 +43,25 @@ export class InstanceInstalledModulesManager extends EventEmitter {
*/
#store: UserModuleEntry[]

/**
* The directory store fetched modules will be stored in
*/
get storeModulesDir(): string {
return this.#storeModulesDir
}

/**
* The directory user loaded modules will be stored in
*/
get customModulesDir(): string {
return this.#customModulesDir
}

constructor(modulesManager: InstanceModules, db: DataDatabase, appInfo: AppInfo) {
super()

// /**
// * The directory store fetched modules will be stored in
// */
// get storeModulesDir(): string {
// return this.#storeModulesDir
// }

// /**
// * The directory user loaded modules will be stored in
// */
// get customModulesDir(): string {
// return this.#customModulesDir
// }

constructor(modulesManager: InstanceModules, db: DataDatabase, dirs: ModuleDirs) {
this.#modulesManager = modulesManager
this.#db = db
this.#storeModulesDir = path.join(appInfo.configDir, 'store-modules')
this.#customModulesDir = path.join(appInfo.configDir, 'custom-modules')
this.#storeModulesDir = dirs.storeModulesDir
this.#customModulesDir = dirs.customModulesDir

this.#store = db.getKey('user-modules', [])
}
Expand Down Expand Up @@ -108,14 +106,35 @@ export class InstanceInstalledModulesManager extends EventEmitter {
try {
await fs.mkdirp(moduleDir)

Readable.from(uncompressedData).pipe(tarfs.extract(moduleDir, { strip: 1 }))
await new Promise((resolve) => {
Readable.from(uncompressedData)
.pipe(tarfs.extract(moduleDir, { strip: 1 }))
.on('finish', resolve)
})

console.log('extracted to', moduleDir)
} catch (e) {
// cleanup the dir, just to be sure it doesn't get stranded
await fs.rmdir(moduleDir, { recursive: true }).catch(() => null)
await fs.rm(moduleDir, { recursive: true }).catch(() => null)
}

// Let other interested parties know that a module has been installed
this.emit('installed', moduleDir, manifestJson)
await this.#modulesManager.loadInstalledModule(moduleDir, 'custom', manifestJson)

return null
})

client.onPromise('modules:uninstall-custom-module', async (moduleId, versionId) => {
console.log('modules:uninstall-custom-module', moduleId, versionId)

const moduleDir = path.join(this.#customModulesDir, `${moduleId}-${versionId}`)
if (!fs.existsSync(moduleDir)) return `Module ${moduleId} v${versionId} doesn't exist`

// Stop any usages of the module
await this.#modulesManager.uninstallModule(moduleId, 'custom', versionId)

// Delete the module code
await fs.rm(moduleDir, { recursive: true }).catch(() => null)

return null
})
Expand Down
167 changes: 122 additions & 45 deletions companion/lib/Instance/Modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@
*/

import fs from 'fs-extra'
import { isPackaged } from '../Resources/Util.js'
import path from 'path'
import { fileURLToPath } from 'url'
import { compact } from 'lodash-es'
import { cloneDeep, compact } from 'lodash-es'
import { InstanceModuleScanner } from './ModuleScanner.js'
import LogController from '../Log/Controller.js'
import type express from 'express'
Expand All @@ -32,8 +30,10 @@ import type {
} from '@companion-app/shared/Model/ModuleInfo.js'
import type { ClientSocket, UIHandler } from '../UI/Handler.js'
import type { HelpDescription } from '@companion-app/shared/Model/Common.js'
import { InstanceController } from './Controller.js'
import type { InstanceController } from './Controller.js'
import semver from 'semver'
import jsonPatch from 'fast-json-patch'
import { ModuleDirs } from './types.js'

const ModulesRoom = 'modules'

Expand Down Expand Up @@ -148,13 +148,79 @@ export class InstanceModules {
*/
readonly #moduleScanner = new InstanceModuleScanner()

constructor(io: UIHandler, api_router: express.Router, instance: InstanceController) {
readonly #moduleDirs: ModuleDirs

constructor(io: UIHandler, api_router: express.Router, instance: InstanceController, moduleDirs: ModuleDirs) {
this.#io = io
this.#instanceController = instance
this.#moduleDirs = moduleDirs

api_router.get('/help/module/:moduleId/:versionMode/:versionId/*', this.#getHelpAsset)
}

async loadInstalledModule(moduleDir: string, mode: 'custom' | 'release', manifest: ModuleManifest): Promise<void> {
this.#logger.info(`New ${mode} module installed: ${manifest.id}`)

switch (mode) {
case 'custom': {
const customModule = await this.#moduleScanner.loadInfoForModule(moduleDir, false)

if (!customModule) throw new Error(`Failed to load custom module. Missing from disk at "${moduleDir}"`)
if (customModule?.manifest.id !== manifest.id)
throw new Error(`Mismatched module id: ${customModule?.manifest.id} !== ${manifest.id}`)

// Update the module info
const moduleInfo = this.#getOrCreateModuleEntry(manifest.id)
moduleInfo.customVersions[customModule.display.version] = {
...customModule,
type: 'custom',
versionId: customModule.display.version,
}

// Notify clients
this.#emitModuleUpdate(manifest.id)

// Ensure any modules using this version are started
await this.#instanceController.reloadUsesOfModule(manifest.id, 'custom', manifest.version)

break
}
case 'release': {
// TODO
break
}
default:
this.#logger.info(`Unknown module type: ${mode}`)
}
}

async uninstallModule(moduleId: string, mode: 'custom' | 'release', versionId: string): Promise<void> {
const moduleInfo = this.#knownModules.get(moduleId)
if (!moduleInfo) throw new Error('Module not found when removing version')

switch (mode) {
case 'custom': {
delete moduleInfo.customVersions[versionId]

break
}
case 'release': {
delete moduleInfo.releaseVersions[versionId]

break
}
default:
this.#logger.info(`Unknown module type: ${mode}`)
return
}

// Notify clients
this.#emitModuleUpdate(moduleId)

// Ensure any modules using this version are started
await this.#instanceController.reloadUsesOfModule(moduleId, mode, versionId)
}

/**
*
*/
Expand All @@ -172,16 +238,8 @@ export class InstanceModules {
* @param extraModulePath - extra directory to search for modules
*/
async initInstances(extraModulePath: string): Promise<void> {
function generatePath(subpath: string): string {
if (isPackaged()) {
return path.join(__dirname, subpath)
} else {
return fileURLToPath(new URL(path.join('../../..', subpath), import.meta.url))
}
}

const legacyCandidates = await this.#moduleScanner.loadInfoForModulesInDir(
generatePath('bundled-modules/_legacy'),
this.#moduleDirs.bundledLegacyModulesDir,
false
)

Expand All @@ -199,11 +257,10 @@ export class InstanceModules {
}

// Load bundled modules
const candidates = await this.#moduleScanner.loadInfoForModulesInDir(
path.resolve(generatePath('bundled-modules')),
false
)
for (const candidate of candidates) {
const bundledModules = await this.#moduleScanner.loadInfoForModulesInDir(this.#moduleDirs.bundledModulesDir, false)
// And moduels from the store
const storeModules = await this.#moduleScanner.loadInfoForModulesInDir(this.#moduleDirs.storeModulesDir, true)
for (const candidate of bundledModules.concat(storeModules)) {
const moduleInfo = this.#getOrCreateModuleEntry(candidate.manifest.id)
moduleInfo.releaseVersions[candidate.display.version] = {
...candidate,
Expand All @@ -214,7 +271,16 @@ export class InstanceModules {
}
}

// TODO - search other user dirs
// Search for custom modules
const customModules = await this.#moduleScanner.loadInfoForModulesInDir(this.#moduleDirs.customModulesDir, false)
for (const customModule of customModules) {
const moduleInfo = this.#getOrCreateModuleEntry(customModule.manifest.id)
moduleInfo.customVersions[customModule.display.version] = {
...customModule,
type: 'custom',
versionId: customModule.display.version,
}
}

if (extraModulePath) {
this.#logger.info(`Looking for extra modules in: ${extraModulePath}`)
Expand Down Expand Up @@ -295,38 +361,49 @@ export class InstanceModules {
// isOverride: true,
// })

// const newJson = cloneDeep(this.getModulesJson())

// // Now broadcast to any interested clients
// if (this.#io.countRoomMembers(ModulesRoom) > 0) {
// const oldObj = this.#lastModulesJson?.[reloadedModule.manifest.id]
// if (oldObj) {
// const patch = jsonPatch.compare(oldObj, reloadedModule.display)
// if (patch.length > 0) {
// this.#io.emitToRoom(ModulesRoom, `modules:patch`, {
// type: 'update',
// id: reloadedModule.manifest.id,
// patch,
// })
// }
// } else {
// this.#io.emitToRoom(ModulesRoom, `modules:patch`, {
// type: 'add',
// id: reloadedModule.manifest.id,
// info: reloadedModule.display,
// })
// }
// }

// this.#lastModulesJson = newJson
// this.#emitModuleUpdate(reloadedModule.manifest.id)

// // restart usages of this module
// this.#instanceController.reloadUsesOfModule(reloadedModule.manifest.id)
// await this.#instanceController.reloadUsesOfModule(reloadedModule.manifest.id)
// } else {
// this.#logger.info(`Failed to find module in: ${fullpath}`)
// }
}

#emitModuleUpdate = (changedModuleId: string): void => {
const newJson = cloneDeep(this.getModulesJson())

const newObj = newJson[changedModuleId]

// Now broadcast to any interested clients
if (this.#io.countRoomMembers(ModulesRoom) > 0) {
const oldObj = this.#lastModulesJson?.[changedModuleId]
if (!newObj) {
this.#io.emitToRoom(ModulesRoom, `modules:patch`, {
type: 'remove',
id: changedModuleId,
})
} else if (oldObj) {
const patch = jsonPatch.compare(oldObj, newObj)
if (patch.length > 0) {
this.#io.emitToRoom(ModulesRoom, `modules:patch`, {
type: 'update',
id: changedModuleId,
patch,
})
}
} else {
this.#io.emitToRoom(ModulesRoom, `modules:patch`, {
type: 'add',
id: changedModuleId,
info: newObj,
})
}
}

this.#lastModulesJson = newJson
}

/**
* Checks whether an instance_type has been renamed
* @returns the instance_type that should be used (often the provided parameter)
Expand Down
6 changes: 6 additions & 0 deletions companion/lib/Instance/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface ModuleDirs {
readonly bundledLegacyModulesDir: string
readonly bundledModulesDir: string
readonly customModulesDir: string
readonly storeModulesDir: string
}
Loading

0 comments on commit cee3706

Please sign in to comment.