import AdmZip from 'adm-zip' ; import { Volume } from 'memfs' ; import path from 'path' ; import { ScryptedNativeId , DeviceManager , Logger , Device , DeviceManifest , DeviceState , EndpointManager , SystemDeviceState , ScryptedStatic , SystemManager , MediaManager , ScryptedMimeTypes , ScryptedInterface , ScryptedInterfaceProperty , HttpRequest } from '@scrypted/sdk/types' ; import { PluginAPI , PluginLogger , PluginRemote , PluginRemoteLoadZipOptions } from './plugin-api' ; import { SystemManagerImpl } from './system' ; import { RpcPeer , RPCResultError , PROPERTY_PROXY_ONEWAY_METHODS , PROPERTY_JSON_DISABLE_SERIALIZATION } from '../rpc' ; import { BufferSerializer } from './buffer-serializer' ; import { createWebSocketClass , WebSocketConnectCallbacks , WebSocketMethods } from './plugin-remote-websocket' ; import fs from 'fs' ; const { link } = require ( 'linkfs' ) ; class DeviceLogger implements Logger { nativeId : ScryptedNativeId ; api : PluginAPI ; logger : Promise < PluginLogger > ; constructor ( api , nativeId , public console ) { this . api = api ; this . nativeId = nativeId ; } async ensureLogger ( ) { if ( ! this . logger ) this . logger = this . api . getLogger ( this . nativeId ) ; return await this . logger ; } async log ( level , message ) { ( await this . ensureLogger ( ) ) . log ( level , message ) ; } a ( msg ) { this . log ( 'a' , msg ) ; } async clear ( ) { ( await this . ensureLogger ( ) ) . clear ( ) ; } async clearAlert ( msg ) { ( await this . ensureLogger ( ) ) . clearAlert ( msg ) ; } async clearAlerts ( ) { ( await this . ensureLogger ( ) ) . clearAlerts ( ) ; } d ( msg ) { this . log ( 'd' , msg ) ; } e ( msg ) { this . log ( 'e' , msg ) ; } i ( msg ) { this . log ( 'i' , msg ) ; } v ( msg ) { this . log ( 'v' , msg ) ; } w ( msg ) { this . log ( 'w' , msg ) ; } } class EndpointManagerImpl implements EndpointManager { deviceManager : DeviceManagerImpl ; api : PluginAPI ; pluginId : string ; mediaManager : MediaManager ; getEndpoint ( nativeId ? ) { if ( ! nativeId ) return this . pluginId ; const id = this . deviceManager . nativeIds . get ( nativeId ) ?. id ; if ( ! id ) throw new Error ( 'invalid nativeId ' + nativeId ) ; return id ; } async getUrlSafeIp ( ) { const ip = await this . api . getComponent ( 'SCRYPTED_IP_ADDRESS' ) ; return ip ?. includes ( ':' ) ? `[${ ip }]` : ip ; } async getAuthenticatedPath ( nativeId ? ) { return `/endpoint/${ this . getEndpoint ( nativeId ) }/` ; } async getInsecurePublicLocalEndpoint ( nativeId ? ) { return `http://${ await this . getUrlSafeIp ( ) }:${ await this . api . getComponent ( 'SCRYPTED_INSECURE_PORT' ) }/endpoint/${ this . getEndpoint ( nativeId ) }/public/` ; } async getPublicCloudEndpoint ( nativeId ? ) { const local = await this . getPublicLocalEndpoint ( nativeId ) ; const mo = this . mediaManager . createMediaObject ( local , ScryptedMimeTypes . LocalUrl ) ; return this . mediaManager . convertMediaObjectToUrl ( mo , ScryptedMimeTypes . LocalUrl ) ; } async getPublicLocalEndpoint ( nativeId ? ) { return `https://${ await this . getUrlSafeIp ( ) }:${ await this . api . getComponent ( 'SCRYPTED_SECURE_PORT' ) }/endpoint/${ this . getEndpoint ( nativeId ) }/public/` ; } async getPublicPushEndpoint ( nativeId ? ) { const mo = this . mediaManager . createMediaObject ( Buffer . from ( this . getEndpoint ( nativeId ) ) , ScryptedMimeTypes . PushEndpoint ) ; return this . mediaManager . convertMediaObjectToUrl ( mo , ScryptedMimeTypes . PushEndpoint ) ; } async deliverPush ( endpoint , request ) { return this . api . deliverPush ( endpoint , request ) ; } } class DeviceStateProxyHandler implements ProxyHandler < any > { constructor ( public deviceManager , public id , public setState ) { } get ? ( target , p , receiver ) { if ( p === 'id' ) return this . id ; return this . deviceManager . systemManager . state [ this . id ] [ p as string ] ?. value ; } set ? ( target , p , value , receiver ) { if ( p === ScryptedInterfaceProperty . id ) throw new Error ( "id is read only" ) ; if ( p === ScryptedInterfaceProperty . mixins ) throw new Error ( "mixins is read only" ) ; if ( p === ScryptedInterfaceProperty . interfaces ) throw new Error ( "interfaces is a read only post-mixin computed property, use providedInterfaces" ) ; const now = Date . now ( ) ; this . deviceManager . systemManager . state [ this . id ] [ p as string ] = { lastEventTime : now , stateTime : now , value , } ; this . setState ( p . toString ( ) , value ) ; return true ; } } interface DeviceManagerDevice { id : string ; storage : { [ key ] : any ; } ; } class DeviceManagerImpl implements DeviceManager { api : PluginAPI ; nativeIds = new Map < string , DeviceManagerDevice > ( ) ; constructor ( public systemManager , public getDeviceConsole ? , public getMixinConsole ? ) { } async requestRestart ( ) { return this . api . requestRestart ( ) ; } getDeviceLogger ( nativeId ? ) { return new DeviceLogger ( this . api , nativeId , this . getDeviceConsole ?. ( nativeId ) || console ) ; } getDeviceState ( nativeId ? ) { const handler = new DeviceStateProxyHandler ( this , this . nativeIds . get ( nativeId ) . id , ( property , value ) => this . api . setState ( nativeId , property , value ) ) ; return new Proxy ( handler , handler ) ; } getDeviceStorage ( nativeId ? ) { return new StorageImpl ( this , nativeId ) ; } getMixinStorage ( id , nativeId ? ) { return new StorageImpl ( this , nativeId , `mixin:${ id }:` ) ; } async onMixinEvent ( id , nativeId , eventInterface , eventData ) { return this . api . onMixinEvent ( id , nativeId , eventInterface , eventData ) ; } getNativeIds ( ) { return Array . from ( this . nativeIds . keys ( ) ) ; } async onDeviceDiscovered ( device ) { return this . api . onDeviceDiscovered ( device ) ; } async onDeviceRemoved ( nativeId ) { return this . api . onDeviceRemoved ( nativeId ) ; } async onDeviceEvent ( nativeId , eventInterface , eventData ? ) { return this . api . onDeviceEvent ( nativeId , eventInterface , eventData ) ; } async onDevicesChanged ( devices ) { return this . api . onDevicesChanged ( devices ) ; } } class StorageImpl implements Storage { api : PluginAPI ; [ name ] : any ; private static allowedMethods = [ 'length' , 'clear' , 'getItem' , 'setItem' , 'key' , 'removeItem' , ] ; private static indexedHandler : ProxyHandler < StorageImpl > = { get ( target , property ) { if ( StorageImpl . allowedMethods . includes ( property . toString ( ) ) ) { const prop = property . toString ( ) ; const f = target [ property . toString ( ) ] ; if ( prop === 'length' ) return f ; return f . bind ( target ) ; } return target . getItem ( property . toString ( ) ) ; } , set ( target , property , value ) { target . setItem ( property . toString ( ) , value ) ; return true ; } } ; constructor ( public deviceManager , public nativeId , public prefix ? ) { this . deviceManager = deviceManager ; this . api = deviceManager . api ; this . nativeId = nativeId ; if ( ! this . prefix ) this . prefix = '' ; return new Proxy ( this , StorageImpl . indexedHandler ) ; } get storage ( ) : { [ key ] : any ; } { return this . deviceManager . nativeIds . get ( this . nativeId ) . storage ; } get length ( ) : number { return Object . keys ( this . storage ) . filter ( key => key . startsWith ( this . prefix ) ) . length ; } clear ( ) { if ( ! this . prefix ) { this . deviceManager . nativeIds . get ( this . nativeId ) . storage = { } ; } else { const storage = this . storage ; Object . keys ( this . storage ) . filter ( key => key . startsWith ( this . prefix ) ) . forEach ( key => delete storage [ key ] ) ; } this . api . setStorage ( this . nativeId , this . storage ) ; } getItem ( key ) { return this . storage [ this . prefix + key ] ; } key ( index ) { if ( ! this . prefix ) { return Object . keys ( this . storage ) [ index ] ; } return Object . keys ( this . storage ) . filter ( key => key . startsWith ( this . prefix ) ) [ index ] . substring ( this . prefix . length ) ; } removeItem ( key ) { delete this . storage [ this . prefix + key ] ; this . api . setStorage ( this . nativeId , this . storage ) ; } setItem ( key , value ) { this . storage [ this . prefix + key ] = value ; this . api . setStorage ( this . nativeId , this . storage ) ; } } export async function setupPluginRemote ( peer , api , pluginId , getSystemState ) { try { peer . addSerializer ( Buffer , 'Buffer' , new BufferSerializer ( ) ) ; const getRemote = await peer . getParam ( 'getRemote' ) ; const remote = await getRemote ( api , pluginId ) ; await remote . setSystemState ( getSystemState ( ) ) ; api . listen ( ( id , eventDetails , eventData ) => { if ( eventDetails . eventInterface === ScryptedInterface . ScryptedDevice ) { if ( eventDetails . property === ScryptedInterfaceProperty . id ) { remote . updateDeviceState ( eventData , undefined ) ; } else { remote . updateDeviceState ( id , getSystemState ( ) [ id ] ) ; } return ; } if ( eventDetails . property ) { remote . notify ( id , eventDetails . eventTime , eventDetails . eventInterface , eventDetails . property , getSystemState ( ) [ id ] ?. [ eventDetails . property ] , eventDetails . changed ) ; } else { remote . notify ( id , eventDetails . eventTime , eventDetails . eventInterface , eventDetails . property , eventData , eventDetails . changed ) ; } } ) ; return remote ; } catch ( e ) { throw new RPCResultError ( peer , 'error while retrieving PluginRemote' , e ) ; } } export interface WebSocketCustomHandler { id : string ; methods : WebSocketMethods ; } export type PluginReader = ( name : string ) => Buffer ; export interface PluginRemoteAttachOptions { createMediaManager ? : ( systemManager : SystemManager ) => Promise < MediaManager > ; getServicePort ? : ( name : string , ... args : any [ ] ) => Promise < number > ; getDeviceConsole ? : ( nativeId ? : ScryptedNativeId ) => Console ; getPluginConsole ? : ( ) => Console ; getMixinConsole ? : ( id : string , nativeId ? : ScryptedNativeId ) => Console ; onLoadZip ? : ( pluginReader : PluginReader , packageJson : any ) => Promise < void > ; onGetRemote ? : ( api : PluginAPI , pluginId : string ) => Promise < void > ; onPluginReady ? : ( scrypted : ScryptedStatic , params : any , plugin : any ) => Promise < void > ; } export function attachPluginRemote ( peer , options ? ) { const { createMediaManager , getServicePort , getDeviceConsole , getMixinConsole , getPluginConsole } = options || { } ; peer . addSerializer ( Buffer , 'Buffer' , new BufferSerializer ( ) ) ; let done ; const retPromise = new Promise < ScryptedStatic > ( resolve => done = resolve ) ; peer . params . getRemote = async ( api , pluginId ) => { await options ?. onGetRemote ?. ( api , pluginId ) ; const systemManager = new SystemManagerImpl ( ) ; const deviceManager = new DeviceManagerImpl ( systemManager , getDeviceConsole , getMixinConsole ) ; const endpointManager = new EndpointManagerImpl ( ) ; const mediaManager = await api . getMediaManager ( ) || await createMediaManager ( systemManager ) ; peer . params [ 'mediaManager' ] = mediaManager ; const ioSockets = { } ; systemManager . api = api ; deviceManager . api = api ; const log = deviceManager . getDeviceLogger ( undefined ) ; systemManager . log = log ; const ret = { systemManager , deviceManager , endpointManager , mediaManager , log , } ; delete peer . params . getRemote ; endpointManager . api = api ; endpointManager . deviceManager = deviceManager ; endpointManager . mediaManager = mediaManager ; endpointManager . pluginId = pluginId ; const localStorage = new StorageImpl ( deviceManager , undefined ) ; const remote = { [ PROPERTY_JSON_DISABLE_SERIALIZATION ] : true , [ PROPERTY_PROXY_ONEWAY_METHODS ] : [ 'notify' , 'updateDeviceState' , 'setSystemState' , 'ioEvent' , 'setNativeId' , ] , getServicePort , createDeviceState ( id , setState ) { const handler = new DeviceStateProxyHandler ( deviceManager , id , setState ) ; return new Proxy ( handler , handler ) ; } , async ioEvent ( id , event , message ? ) { const io = ioSockets [ id ] ; if ( ! io ) return ; switch ( event ) { case 'message' : io . data ( message ) ; break ; case 'close' : io . end ( ) ; delete ioSockets [ id ] ; break ; } } , async setNativeId ( nativeId , id , storage ) { if ( nativeId === null ) nativeId = undefined ; if ( id ) { deviceManager . nativeIds . set ( nativeId ?. toString ( ) , { id , storage , } ) ; } else { deviceManager . nativeIds . delete ( nativeId ) ; } } , async updateDeviceState ( id , state ) { if ( ! state ) { delete systemManager . state [ id ] ; systemManager . events . notify ( undefined , undefined , ScryptedInterface . ScryptedDevice , ScryptedInterfaceProperty . id , id , true ) ; } else { systemManager . state [ id ] = state ; systemManager . events . notify ( id , undefined , ScryptedInterface . ScryptedDevice , undefined , state , true ) ; } } , async notify ( id , eventTime , eventInterface , property , value , changed ? ) { if ( property ) { const state = systemManager . state ?. [ id ] ; if ( ! state ) { log . w ( `state not found for ${ id }` ) ; return ; } state [ property ] = value ; systemManager . events . notify ( id , eventTime , eventInterface , property , value . value , changed ) ; } else { systemManager . events . notify ( id , eventTime , eventInterface , property , value , changed ) ; } } , async setSystemState ( state ) { systemManager . state = state ; done ( ret ) ; } , async loadZip ( packageJson , zipData , zipOptions ? ) { const pluginConsole = getPluginConsole ?. ( ) ; let volume ; let pluginReader ; if ( zipOptions ?. unzippedPath && fs . existsSync ( zipOptions ?. unzippedPath ) ) { volume = link ( fs , [ '' , path . join ( zipOptions . unzippedPath , 'fs' ) ] ) ; pluginReader = name => { const filename = path . join ( zipOptions . unzippedPath , name ) ; if ( ! fs . existsSync ( filename ) ) return ; return fs . readFileSync ( filename ) ; } ; } else { const admZip = new AdmZip ( zipData ) ; volume = new Volume ( ) ; for ( const entry of admZip . getEntries ( ) ) { if ( entry . isDirectory ) continue ; if ( ! entry . entryName . startsWith ( 'fs/' ) ) continue ; const name = entry . entryName . substring ( 'fs/' . length ) ; volume . mkdirpSync ( path . dirname ( name ) ) ; const data = entry . getData ( ) ; volume . writeFileSync ( name , data ) ; } pluginReader = name => { const entry = admZip . getEntry ( name ) ; if ( ! entry ) return ; return entry . getData ( ) ; } ; } zipData = undefined ; await options ?. onLoadZip ?. ( pluginReader , packageJson ) ; const main = pluginReader ( 'main.nodejs.js' ) ; pluginReader = undefined ; const script = main . toString ( ) ; const window = { } ; const exports = window ; window . exports = exports ; function websocketConnect ( url , protocols , callbacks ) { if ( url . startsWith ( 'io://' ) || url . startsWith ( 'ws://' ) ) { const id = url . substring ( 'xx://' . length ) ; ioSockets [ id ] = callbacks ; callbacks . connect ( undefined , { close : ( ) => api . ioClose ( id ) , send : ( message ) => api . ioSend ( id , message ) , } ) ; } else { throw new Error ( 'unsupported websocket' ) ; } } const params = { __filename : undefined , exports , window , require : ( name ) => { if ( name === 'fakefs' || ( name === 'fs' && ! packageJson . scrypted . realfs ) ) { return volume ; } if ( name === 'realfs' ) { return require ( 'fs' ) ; } const module = require ( name ) ; return module ; } , deviceManager , systemManager , mediaManager , endpointManager , log , localStorage , pluginHostAPI : api , WebSocket : createWebSocketClass ( websocketConnect ) , } ; params . console = pluginConsole ; try { peer . evalLocal ( script , zipOptions ?. filename || '/plugin/main.nodejs.js' , params ) ; pluginConsole ?. log ( 'plugin successfully loaded' ) ; await options ?. onPluginReady ?. ( ret , params , exports . default ) ; return exports . default ; } catch ( e ) { pluginConsole ?. error ( 'plugin failed to load' , e ) ; throw e ; } } , } ; return remote ; } ; return retPromise ; }