import { DeviceProvider, EventListener, EventListenerOptions, EventListenerRegister, MixinProvider, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedInterfaceDescriptors, ScryptedInterfaceProperty } from "@scrypted/types"; import fs from 'fs'; import path from 'path'; import { PluginDevice } from "../db-types"; import { getDisplayType } from "../infer-defaults"; import { PrimitiveProxyHandler, RpcPeer } from "../rpc"; import { ScryptedRuntime } from "../runtime"; import { sleep } from "../sleep"; import { getState } from "../state"; import { AccessControls } from "./acl"; import { allInterfaceProperties, getInterfaceMethods, getPropertyInterfaces } from "./descriptor"; import { PluginError } from "./plugin-error"; interface MixinTable { mixinProviderId: string; entry: Promise; } interface MixinTableEntry { interfaces: Set; methods?: Set; allInterfaces: string[]; proxy: any; error?: Error; passthrough: boolean; } export const RefreshSymbol = Symbol('ScryptedDeviceRefresh'); export const QueryInterfaceSymbol = Symbol("ScryptedPluginDeviceQueryInterface"); export class PluginDeviceProxyHandler implements PrimitiveProxyHandler, ScryptedDevice { scrypted: ScryptedRuntime; id: string; mixinTable: MixinTable[]; releasing = new Set(); static sortInterfaces(interfaces: string[]): string[] { return [...new Set(interfaces || [])].sort(); } constructor(scrypted: ScryptedRuntime, id: string) { this.scrypted = scrypted; this.id = id; } toPrimitive() { return `PluginDevice-${this.id}`; } invalidateEntry(mixinEntry: MixinTable) { if (!mixinEntry?.mixinProviderId) return; (async () => { const mixinProvider = this.scrypted.getDevice(mixinEntry.mixinProviderId) as ScryptedDevice & MixinProvider; const { proxy } = await mixinEntry.entry; // allow mixins in the process of being released to manage final // events, etc, before teardown. this.releasing.add(proxy); mixinProvider?.releaseMixin(this.id, proxy).catch(() => { }); await sleep(1000); this.releasing.delete(proxy); })().catch(() => { }); } async getMixinProviderId(id: string, mixinDevice: any) { if (this.releasing.has(mixinDevice)) return true; await this.scrypted.devices[id].handler.ensureProxy(); for (const mixin of this.scrypted.devices[id].handler.mixinTable) { const { proxy } = await mixin.entry; if (proxy === mixinDevice) { return mixin.mixinProviderId || id; } } return undefined; } // should this be async? invalidate() { const mixinTable = this.mixinTable; this.mixinTable = undefined; for (const mixinEntry of (mixinTable || [])) { this.invalidateEntry(mixinEntry); } } /** * Rebuild the mixin table with any currently missing mixins. */ rebuildMixinTable() { if (!this.mixinTable) return this.invalidate(); let previousMixinIds = this.mixinTable?.map(entry => entry.mixinProviderId) || []; previousMixinIds.pop(); previousMixinIds = previousMixinIds.reverse(); const pluginDevice = this.scrypted.findPluginDeviceById(this.id); if (!pluginDevice) return this.invalidate(); const mixins = getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []; // iterate the new mixin table to find the last good mixin, // and resume creation from there. let lastValidMixinId: string; for (const mixinId of mixins) { if (!previousMixinIds.length) { // reached of the previous mixin table, meaning // mixins were added. break; } const check = previousMixinIds.shift(); if (check !== mixinId) break; lastValidMixinId = mixinId; } if (!lastValidMixinId) return this.invalidate(); // invalidate and remove everything up to lastValidMixinId while (true) { const entry = this.mixinTable[0]; if (entry.mixinProviderId === lastValidMixinId) break; this.mixinTable.shift(); this.invalidateEntry(entry); console.log('invalidating mixin', this.id, entry.mixinProviderId); } this.ensureProxy(lastValidMixinId); } // this must not be async, because it potentially changes execution order. ensureProxy(lastValidMixinId?: string): Promise { const pluginDevice = this.scrypted.findPluginDeviceById(this.id); if (!pluginDevice) throw new PluginError(`device ${this.id} does not exist`); if (!lastValidMixinId) { if (this.mixinTable) return Promise.resolve(pluginDevice); this.mixinTable = []; const entry = (async () => { let proxy; try { if (!pluginDevice.nativeId) { const plugin = this.scrypted.plugins[pluginDevice.pluginId]; proxy = await plugin.module; } else { const providerId = getState(pluginDevice, ScryptedInterfaceProperty.providerId); const provider = this.scrypted.getDevice(providerId) as ScryptedDevice & DeviceProvider; proxy = await provider.getDevice(pluginDevice.nativeId); } if (!proxy) console.warn('no device was returned by the plugin', this.id); } catch (e) { console.error('error occured retrieving device from plugin', e); } const interfaces: ScryptedInterface[] = getState(pluginDevice, ScryptedInterfaceProperty.providedInterfaces) || []; return { passthrough: false, proxy, interfaces: new Set(interfaces), allInterfaces: interfaces, } })(); this.mixinTable.unshift({ mixinProviderId: undefined, entry, }); } else { if (!this.mixinTable) throw new PluginError('mixin table partial invalidation was called with empty mixin table'); const prevTable = this.mixinTable.find(table => table.mixinProviderId === lastValidMixinId); if (!prevTable) throw new PluginError('mixin table partial invalidation was called with invalid lastValidMixinId'); } for (const mixinId of getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []) { if (lastValidMixinId) { if (mixinId === lastValidMixinId) lastValidMixinId = undefined; continue; } const wrappedMixinTable = this.mixinTable.slice(); const entry = this.rebuildEntry(pluginDevice, mixinId, Promise.resolve(wrappedMixinTable)); this.mixinTable.unshift({ mixinProviderId: mixinId, entry, }); } return this.mixinTable[0].entry.then(entry => { let persist = !entry.error; if (entry.error) { console.error('Mixin device creation completed with error.'); const allInterfaces = new Set(entry.allInterfaces); const previousInterfaces = getState(pluginDevice, ScryptedInterfaceProperty.interfaces) as string[] || []; let containsAll = true; while (containsAll && previousInterfaces.length) { containsAll &&= allInterfaces.has(previousInterfaces.pop()); } persist = containsAll; if (persist) console.log('Mixin device completed with interface same set or super set of previous set. Persisting.') } if (persist) { const changed = this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.interfaces, PluginDeviceProxyHandler.sortInterfaces(entry.allInterfaces)); if (changed) this.scrypted.notifyPluginDeviceDescriptorChanged(pluginDevice); } return pluginDevice; }); } async rebuildEntry(pluginDevice: PluginDevice, mixinId: string, wrappedMixinTablePromise: Promise): Promise { const wrappedMixinTable = await wrappedMixinTablePromise; const previousEntry = wrappedMixinTable[0].entry; const type = getDisplayType(pluginDevice); let { allInterfaces, error } = await previousEntry; try { const mixinProvider = this.scrypted.getDevice(mixinId) as ScryptedDevice & MixinProvider; const isMixinProvider = mixinProvider?.interfaces?.includes(ScryptedInterface.MixinProvider); const interfaces = isMixinProvider && await mixinProvider?.canMixin(type, allInterfaces) as any as ScryptedInterface[]; if (!interfaces) { console.log(`Mixin provider ${mixinId} can no longer mixin ${this.id}.`, { mixinProvider: !!mixinProvider, interfaces, }); if (!error) { if (!mixinProvider || (isMixinProvider && !interfaces)) { const mixins: string[] = getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []; this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.mixins, mixins.filter(mid => mid !== mixinId)); this.scrypted.notifyPluginDeviceDescriptorChanged(pluginDevice); this.scrypted.datastore.upsert(pluginDevice); } else { console.log(`Mixin provider ${mixinId} can not mixin ${this.id}. It is no longer a MixinProvider. This may be temporary. Passing through.`); } } else { console.error(`Error encountered in previous mixin entry may have caused mixin error. Ignoring.`); } // this is not an error // do not advertise interfaces so it is skipped during // vtable lookup. return { error, passthrough: true, allInterfaces, interfaces: new Set(), proxy: undefined as any, }; } const previousInterfaces = allInterfaces; allInterfaces = previousInterfaces.slice(); allInterfaces.push(...interfaces); const combinedInterfaces = [...new Set(allInterfaces)]; const wrappedHandler = new PluginDeviceProxyHandler(this.scrypted, this.id); wrappedHandler.mixinTable = wrappedMixinTable; const wrappedProxy = new Proxy(wrappedHandler, wrappedHandler); const implementer = await (mixinProvider as any)[QueryInterfaceSymbol](ScryptedInterface.MixinProvider); const host = this.scrypted.getPluginHostForDeviceId(implementer); const propertyInterfaces = getPropertyInterfaces(host.api.descriptors || ScryptedInterfaceDescriptors); // todo: remove this and pass the setter directly. const deviceState = await host.remote.createDeviceState(this.id, async (property, value) => this.scrypted.stateManager.setPluginDeviceStateFromMixin(pluginDevice, property, value, propertyInterfaces[property], mixinId)); const mixinProxy = await mixinProvider.getMixin(wrappedProxy, previousInterfaces as ScryptedInterface[], deviceState); if (!mixinProxy) throw new PluginError(`mixin provider ${mixinId} did not return mixin for ${this.id}`); // mixin is a passthrough of no interfaces changed, and the proxy is the same // a mixin can be a passthrough even if it implements an interface (like Settings), // so long as the wrapped proxy also implements that interface. // techically it is a passthrough if the proxies are the same instance, but // better to be explicit here about interface differences. const passthrough = wrappedProxy === mixinProxy && previousInterfaces.length === combinedInterfaces.length; return { error, passthrough, interfaces: new Set(interfaces), allInterfaces: combinedInterfaces, proxy: mixinProxy, }; } catch (e) { // on any error, do not advertise interfaces // on this mixin, so as to prevent total failure? // this has been the behavior for a while, // but maybe interfaces implemented by that mixin // should rethrow the error caught here in applyMixin. console.error('Mixin error', e); return { passthrough: false, allInterfaces, interfaces: new Set(), error: e, proxy: undefined as any, }; } } get(target: any, p: PropertyKey, receiver: any): any { if (RpcPeer.PROBED_PROPERTIES.has(p)) return; const handled = RpcPeer.handleFunctionInvocations(this, target, p, receiver); if (handled) return handled; const pluginDevice = this.scrypted.findPluginDeviceById(this.id); // device may be deleted. if (!pluginDevice) return; const prop = p.toString(); if (allInterfaceProperties.includes(prop)) return getState(pluginDevice, prop); if (p === RefreshSymbol || p === QueryInterfaceSymbol) return new Proxy(() => p, this); if (ScryptedInterfaceDescriptors[ScryptedInterface.ScryptedDevice].methods.includes(prop)) return (this as any)[p].bind(this); return new Proxy(() => prop, this); } listen(event: string | EventListenerOptions, callback: EventListener): EventListenerRegister { return this.scrypted.stateManager.listenDevice(this.id, event, (eventDetails, eventData) => callback(this.scrypted.getDevice(this.id), eventDetails, eventData)); } async setName(name: string): Promise { const device = this.scrypted.findPluginDeviceById(this.id); this.scrypted.stateManager.setPluginDeviceState(device, ScryptedInterfaceProperty.name, name); this.scrypted.stateManager.updateDescriptor(device); } async setRoom(room: string): Promise { const device = this.scrypted.findPluginDeviceById(this.id); this.scrypted.stateManager.setPluginDeviceState(device, ScryptedInterfaceProperty.room, room); this.scrypted.stateManager.updateDescriptor(device); } async setType(type: ScryptedDeviceType): Promise { const device = this.scrypted.findPluginDeviceById(this.id); this.scrypted.stateManager.setPluginDeviceState(device, ScryptedInterfaceProperty.type, type); this.scrypted.stateManager.updateDescriptor(device); } async probe(): Promise { try { await this.ensureProxy(); return true; } catch (e) { return false; } } async applyMixin(method: string, argArray?: any): Promise { const found = await this.findMethod(method); if (found) { const { mixin, entry } = found; const { proxy } = entry; if (!proxy) throw new PluginError(`device is unavailable ${this.id} (mixin ${mixin.mixinProviderId})`); return proxy[method](...argArray); } throw new PluginError(`${method} not implemented`) } async findMethod(method: string) { for (const mixin of this.mixinTable) { const entry = await mixin.entry; if (!entry.methods) { if (entry.interfaces.size) { const pluginDevice = this.scrypted.findPluginDeviceById(mixin.mixinProviderId || this.id); const plugin = this.scrypted.plugins[pluginDevice.pluginId]; let methods = new Set(getInterfaceMethods(ScryptedInterfaceDescriptors, entry.interfaces)) if (plugin?.api.descriptors) methods = new Set([...methods, ...getInterfaceMethods(plugin.api.descriptors, entry.interfaces)]); entry.methods = methods; } else { entry.methods = new Set(); } } if (entry.methods.has(method)) { return { mixin, entry }; } } } async findMixin(iface: string) { for (const mixin of this.mixinTable) { const entry = await mixin.entry; const { interfaces } = entry; if (interfaces.has(iface)) { return { mixin, entry }; } } } async apply(target: any, thisArg: any, argArray?: any): Promise { const method = target(); const { activeRpcPeer } = RpcPeer; const acl: AccessControls = activeRpcPeer?.tags?.acl; if (acl?.shouldRejectMethod(this.id, method)) acl.deny(); this.ensureProxy(); const pluginDevice = this.scrypted.findPluginDeviceById(this.id); if (method === RefreshSymbol) return this.applyMixin('refresh', argArray); if (method === QueryInterfaceSymbol) { const iface = argArray[0]; const found = await this.findMixin(iface); if (found?.entry.interfaces.has(iface)) { return found.mixin.mixinProviderId || this.id; } throw new PluginError(`${iface} not implemented`) } if (method === 'getReadmeMarkdown') { const pluginDevice = this.scrypted.findPluginDeviceById(this.id); if (pluginDevice && !pluginDevice.nativeId) { const plugin = this.scrypted.plugins[pluginDevice.pluginId]; if (!plugin.packageJson.scrypted.interfaces.includes(ScryptedInterface.Readme)) { const readmePath = path.join(plugin.unzippedPath, 'README.md'); if (fs.existsSync(readmePath)) { try { return fs.readFileSync(readmePath).toString(); } catch (e) { return "# Error loading Readme:\n\n" + e; } } } } } if (method === 'getPluginJson' && getState(pluginDevice, ScryptedInterfaceProperty.providedInterfaces)?.includes(ScryptedInterface.ScryptedPlugin)) { return this.scrypted.getPackageJson(pluginDevice.pluginId); } if (method === 'refresh') { const refreshInterface = argArray[0]; const userInitiated = argArray[1]; return this.scrypted.stateManager.refresh(this.id, refreshInterface, userInitiated); } if (method === 'createDevice') { const nativeId = await this.applyMixin(method, argArray); const newDevice = this.scrypted.findPluginDevice(pluginDevice.pluginId, nativeId); return newDevice._id; } return this.applyMixin(method, argArray); } }