mirror of
https://github.com/koush/scrypted.git
synced 2026-05-26 14:40:29 +01:00
server: mixin table refactor
This commit is contained in:
@@ -18,8 +18,12 @@ interface MixinTableEntry {
|
||||
allInterfaces: string[];
|
||||
proxy: any;
|
||||
error?: Error;
|
||||
passthrough: boolean;
|
||||
}
|
||||
|
||||
export const RefreshSymbol = Symbol('ScryptedDeviceRefresh');
|
||||
export const QueryInterfaceSymbol = Symbol("ScryptedPluginDeviceQueryInterface");
|
||||
|
||||
export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevice {
|
||||
scrypted: ScryptedRuntime;
|
||||
id: string;
|
||||
@@ -49,7 +53,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
}
|
||||
}
|
||||
|
||||
invalidateMixinTable() {
|
||||
rebuildMixinTable() {
|
||||
if (!this.mixinTable)
|
||||
return this.invalidate();
|
||||
|
||||
@@ -88,6 +92,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
break;
|
||||
this.mixinTable.shift();
|
||||
this.invalidateEntry(entry);
|
||||
console.log('invalidating mixin', entry.mixinProviderId);
|
||||
}
|
||||
|
||||
this.ensureProxy(lastValidMixinId);
|
||||
@@ -99,14 +104,13 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
if (!pluginDevice)
|
||||
throw new PluginError(`device ${this.id} does not exist`);
|
||||
|
||||
let previousEntry: Promise<MixinTableEntry>;
|
||||
if (!lastValidMixinId) {
|
||||
if (this.mixinTable)
|
||||
return Promise.resolve(pluginDevice);
|
||||
|
||||
this.mixinTable = [];
|
||||
|
||||
previousEntry = (async () => {
|
||||
const entry = (async () => {
|
||||
let proxy;
|
||||
try {
|
||||
if (!pluginDevice.nativeId) {
|
||||
@@ -128,6 +132,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
|
||||
const interfaces: ScryptedInterface[] = getState(pluginDevice, ScryptedInterfaceProperty.providedInterfaces) || [];
|
||||
return {
|
||||
passthrough: false,
|
||||
proxy,
|
||||
interfaces: new Set<string>(interfaces),
|
||||
allInterfaces: interfaces,
|
||||
@@ -136,7 +141,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
|
||||
this.mixinTable.unshift({
|
||||
mixinProviderId: undefined,
|
||||
entry: previousEntry,
|
||||
entry,
|
||||
});
|
||||
}
|
||||
else {
|
||||
@@ -145,11 +150,8 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
const prevTable = this.mixinTable.find(table => table.mixinProviderId === lastValidMixinId);
|
||||
if (!prevTable)
|
||||
throw new PluginError('mixin table partial invalidation was called with invalid lastValidMixinId');
|
||||
previousEntry = prevTable.entry;
|
||||
}
|
||||
|
||||
const type = getDisplayType(pluginDevice);
|
||||
|
||||
for (const mixinId of getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []) {
|
||||
if (lastValidMixinId) {
|
||||
if (mixinId === lastValidMixinId)
|
||||
@@ -158,65 +160,11 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
}
|
||||
|
||||
const wrappedMixinTable = this.mixinTable.slice();
|
||||
const entry = this.rebuildEntry(pluginDevice, mixinId, Promise.resolve(wrappedMixinTable));
|
||||
|
||||
this.mixinTable.unshift({
|
||||
mixinProviderId: mixinId,
|
||||
entry: (async () => {
|
||||
let { allInterfaces } = await previousEntry;
|
||||
try {
|
||||
const mixinProvider = this.scrypted.getDevice(mixinId) as ScryptedDevice & MixinProvider;
|
||||
const interfaces = await mixinProvider?.canMixin(type, allInterfaces) as any as ScryptedInterface[];
|
||||
if (!interfaces) {
|
||||
// this is not an error
|
||||
// do not advertise interfaces so it is skipped during
|
||||
// vtable lookup.
|
||||
console.log(`mixin provider ${mixinId} can no longer mixin ${this.id}`);
|
||||
const mixins: string[] = getState(pluginDevice, ScryptedInterfaceProperty.mixins) || [];
|
||||
this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.mixins, mixins.filter(mid => mid !== mixinId))
|
||||
this.scrypted.datastore.upsert(pluginDevice);
|
||||
return {
|
||||
allInterfaces,
|
||||
interfaces: new Set<string>(),
|
||||
proxy: undefined as any,
|
||||
};
|
||||
}
|
||||
|
||||
allInterfaces = allInterfaces.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 host = this.scrypted.getPluginHostForDeviceId(mixinId);
|
||||
const deviceState = await host.remote.createDeviceState(this.id,
|
||||
async (property, value) => this.scrypted.stateManager.setPluginDeviceState(pluginDevice, property, value));
|
||||
const mixinProxy = await mixinProvider.getMixin(wrappedProxy, allInterfaces as ScryptedInterface[], deviceState);
|
||||
if (!mixinProxy)
|
||||
throw new PluginError(`mixin provider ${mixinId} did not return mixin for ${this.id}`);
|
||||
|
||||
return {
|
||||
interfaces: new Set<string>(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.warn(e);
|
||||
return {
|
||||
allInterfaces,
|
||||
interfaces: new Set<string>(),
|
||||
error: e,
|
||||
proxy: undefined as any,
|
||||
};
|
||||
}
|
||||
})(),
|
||||
entry,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,6 +174,81 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
});
|
||||
}
|
||||
|
||||
async rebuildEntry(pluginDevice: PluginDevice, mixinId: string, wrappedMixinTablePromise: Promise<MixinTable[]>): Promise<MixinTableEntry> {
|
||||
const wrappedMixinTable = await wrappedMixinTablePromise;
|
||||
const previousEntry = wrappedMixinTable[0].entry;
|
||||
|
||||
const type = getDisplayType(pluginDevice);
|
||||
|
||||
let { allInterfaces } = await previousEntry;
|
||||
try {
|
||||
const mixinProvider = this.scrypted.getDevice(mixinId) as ScryptedDevice & MixinProvider;
|
||||
const interfaces = mixinProvider?.interfaces?.includes(ScryptedInterface.MixinProvider) && await mixinProvider?.canMixin(type, allInterfaces) as any as ScryptedInterface[];
|
||||
if (!interfaces) {
|
||||
// this is not an error
|
||||
// do not advertise interfaces so it is skipped during
|
||||
// vtable lookup.
|
||||
console.log(`mixin provider ${mixinId} can no longer mixin ${this.id}`);
|
||||
const mixins: string[] = getState(pluginDevice, ScryptedInterfaceProperty.mixins) || [];
|
||||
this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.mixins, mixins.filter(mid => mid !== mixinId))
|
||||
this.scrypted.datastore.upsert(pluginDevice);
|
||||
return {
|
||||
passthrough: true,
|
||||
allInterfaces,
|
||||
interfaces: new Set<string>(),
|
||||
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);
|
||||
// todo: remove this and pass the setter directly.
|
||||
const deviceState = await host.remote.createDeviceState(this.id,
|
||||
async (property, value) => this.scrypted.stateManager.setPluginDeviceState(pluginDevice, property, value));
|
||||
const mixinProxy = await mixinProvider.getMixin(wrappedProxy, allInterfaces 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 {
|
||||
passthrough,
|
||||
interfaces: new Set<string>(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.warn(e);
|
||||
return {
|
||||
passthrough: false,
|
||||
allInterfaces,
|
||||
interfaces: new Set<string>(),
|
||||
error: e,
|
||||
proxy: undefined as any,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
get(target: any, p: PropertyKey, receiver: any): any {
|
||||
if (p === 'constructor')
|
||||
return;
|
||||
@@ -241,7 +264,7 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
if (allInterfaceProperties.includes(prop))
|
||||
return getState(pluginDevice, prop);
|
||||
|
||||
if (p === RefreshSymbol)
|
||||
if (p === RefreshSymbol || p === QueryInterfaceSymbol)
|
||||
return new Proxy(() => p, this);
|
||||
|
||||
if (!isValidInterfaceMethod(pluginDevice.state.interfaces.value, prop))
|
||||
@@ -287,19 +310,28 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
if (!iface)
|
||||
throw new PluginError(`unknown method ${method}`);
|
||||
|
||||
for (const mixin of this.mixinTable) {
|
||||
const { interfaces, proxy } = await mixin.entry;
|
||||
// this could be null?
|
||||
if (interfaces.has(iface)) {
|
||||
if (!proxy)
|
||||
throw new PluginError(`device is unavailable ${this.id} (mixin ${mixin.mixinProviderId})`);
|
||||
return proxy[method](...argArray);
|
||||
}
|
||||
const found = await this.findMixin(iface);
|
||||
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 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<any> {
|
||||
const method = target();
|
||||
|
||||
@@ -308,6 +340,16 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
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 (!isValidInterfaceMethod(pluginDevice.state.interfaces.value, method))
|
||||
throw new PluginError(`device ${this.id} does not support method ${method}`);
|
||||
|
||||
@@ -326,5 +368,3 @@ export class PluginDeviceProxyHandler implements ProxyHandler<any>, ScryptedDevi
|
||||
return this.applyMixin(method, argArray);
|
||||
}
|
||||
}
|
||||
|
||||
export const RefreshSymbol = Symbol('ScryptedDeviceRefresh');
|
||||
|
||||
@@ -41,16 +41,38 @@ export class PluginHostAPI extends PluginAPIManagedListeners implements PluginAP
|
||||
this.pluginId = plugin._id;
|
||||
}
|
||||
|
||||
async onMixinEvent(id: string, nativeId: ScryptedNativeId, eventInterface: any, eventData?: any) {
|
||||
// do we care about mixin validation here?
|
||||
// maybe to prevent/notify errant dangling events?
|
||||
async onMixinEvent(id: string, nativeIdOrMixinDevice: ScryptedNativeId|any, eventInterface: any, eventData?: any) {
|
||||
// nativeId code path has been deprecated in favor of mixin object 12/10/2021
|
||||
const device = this.scrypted.findPluginDeviceById(id);
|
||||
const mixinProvider = this.scrypted.findPluginDevice(this.pluginId, nativeId);
|
||||
const mixins: string[] = getState(device, ScryptedInterfaceProperty.mixins) || [];
|
||||
if (!mixins.includes(mixinProvider._id))
|
||||
throw new Error(`${mixinProvider._id} is not a mixin provider for ${id}`);
|
||||
const tableEntry = this.scrypted.devices[device._id].handler.mixinTable.find(entry => entry.mixinProviderId === mixinProvider._id);
|
||||
const { interfaces } = await tableEntry.entry;
|
||||
if (!interfaces.has(eventInterface))
|
||||
throw new Error(`${mixinProvider._id} does not mixin ${eventInterface} for ${id}`);
|
||||
if (!nativeIdOrMixinDevice || typeof nativeIdOrMixinDevice === 'string') {
|
||||
// todo: deprecate this code path
|
||||
const mixinProvider = this.scrypted.findPluginDevice(this.pluginId, nativeIdOrMixinDevice);
|
||||
const mixins: string[] = getState(device, ScryptedInterfaceProperty.mixins) || [];
|
||||
if (!mixins.includes(mixinProvider._id))
|
||||
throw new Error(`${mixinProvider._id} is not a mixin provider for ${id}`);
|
||||
|
||||
this.scrypted.findPluginDevice(this.pluginId, nativeIdOrMixinDevice);
|
||||
const tableEntry = this.scrypted.devices[device._id].handler.mixinTable.find(entry => entry.mixinProviderId === mixinProvider._id);
|
||||
const { interfaces } = await tableEntry.entry;
|
||||
if (!interfaces.has(eventInterface))
|
||||
throw new Error(`${mixinProvider._id} does not mixin ${eventInterface} for ${id}`);
|
||||
}
|
||||
else {
|
||||
let found = false;
|
||||
for (const mixin of this.scrypted.devices[device._id].handler.mixinTable) {
|
||||
const { proxy } = await mixin.entry;
|
||||
if (proxy === nativeIdOrMixinDevice) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
const mixinProvider = this.scrypted.findPluginDevice(this.pluginId, nativeIdOrMixinDevice);
|
||||
throw new Error(`${mixinProvider._id} does not mixin ${eventInterface} for ${id}`);
|
||||
}
|
||||
}
|
||||
this.scrypted.stateManager.notifyInterfaceEvent(device, eventInterface, eventData);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ export class PluginHost {
|
||||
}
|
||||
this.ws = {};
|
||||
|
||||
// const pluginDevices = new Set<string>(Object.values(this.scrypted.pluginDevices).filter(d => d.pluginId === this.pluginId).map(d => d._id));
|
||||
// this.scrypted.invalidateMixins(pluginDevices);
|
||||
|
||||
for (const device of Object.values(this.scrypted.devices)) {
|
||||
const pluginDevice = this.scrypted.pluginDevices[device.handler.id];
|
||||
if (!pluginDevice) {
|
||||
|
||||
@@ -427,14 +427,35 @@ export class ScryptedRuntime {
|
||||
}
|
||||
|
||||
// should this be async?
|
||||
invalidatePluginDeviceMixins(id: string) {
|
||||
rebuildPluginDeviceMixinTable(id: string) {
|
||||
const proxyPair = this.devices[id];
|
||||
if (!proxyPair)
|
||||
return;
|
||||
proxyPair.handler.invalidateMixinTable();
|
||||
proxyPair.handler.rebuildMixinTable();
|
||||
return proxyPair;
|
||||
}
|
||||
|
||||
invalidateMixins(mixinIds: Set<string>) {
|
||||
// const ret = new Set(mixinIds);
|
||||
|
||||
// for (const device of Object.values(this.devices)) {
|
||||
// const pluginDevice = this.pluginDevices[device.handler.id];
|
||||
// if (ret.has(pluginDevice._id))
|
||||
// continue;
|
||||
// if (!pluginDevice) {
|
||||
// console.warn('PluginDevice missing?', device.handler.id);
|
||||
// continue;
|
||||
// }
|
||||
// for (const mixin of getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []) {
|
||||
// if (this.scrypted.findPluginDeviceById(mixin)?.pluginId === this.pluginId) {
|
||||
// device.handler.invalidate();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return ret;
|
||||
}
|
||||
|
||||
async installNpm(pkg: string, version?: string): Promise<PluginHost> {
|
||||
const registry = (await axios(`https://registry.npmjs.org/${pkg}`)).data;
|
||||
if (!version) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export class PluginComponent {
|
||||
const pluginDevice = this.scrypted.findPluginDeviceById(id);
|
||||
this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.mixins, [...new Set(mixins)] || []);
|
||||
await this.scrypted.datastore.upsert(pluginDevice);
|
||||
const device = this.scrypted.invalidatePluginDeviceMixins(id);
|
||||
const device = this.scrypted.rebuildPluginDeviceMixinTable(id);
|
||||
await device?.handler.ensureProxy();
|
||||
}
|
||||
async getMixins(id: string) {
|
||||
|
||||
Reference in New Issue
Block a user