console and repl

This commit is contained in:
Koushik Dutta
2021-08-30 13:36:34 -07:00
parent 4805766d73
commit 74bfd5808e
26 changed files with 824 additions and 512 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/core",
"version": "0.0.50",
"version": "0.0.51",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/core",
"version": "0.0.50",
"version": "0.0.51",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/sdk": "file:../../sdk",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/core",
"version": "0.0.50",
"version": "0.0.51",
"description": "Scrypted Core plugin. Provides the UI, websocket, and engine.io APIs.",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -16,6 +16,7 @@ import fs from 'fs';
import { sendJSON } from './http-helpers';
import { Automation } from './automation';
import { AggregateDevice, createAggregateDevice } from './aggregate';
import net from 'net';
const indexHtml = fs.readFileSync('dist/index.html').toString();
@@ -132,9 +133,30 @@ class ScryptedCore extends ScryptedDeviceBase implements HttpRequestHandler, Eng
async discoverDevices(duration: number) {
}
async checkService(request: HttpRequest, ws: WebSocket, name: string): Promise<boolean> {
const check = `/endpoint/@scrypted/core/engine.io/${name}/`;
if (!request.url.startsWith(check))
return false;
const deviceId = request.url.substr(check.length).split('/')[0];
const plugins = await systemManager.getComponent('plugins');
const { nativeId, pluginId } = await plugins.getDeviceInfo(deviceId);
const port = await plugins.getRemoteServicePort(pluginId, name);
const socket = net.connect(port);
socket.on('data', data => ws.send(data));
socket.resume();
socket.write(nativeId?.toString() || 'undefined');
ws.onclose = () => socket.destroy();
ws.onmessage = message => socket.write(message.data);
return true;
}
async onConnection(request: HttpRequest, webSocketUrl: string): Promise<void> {
const ws = new WebSocket(webSocketUrl);
if (await this.checkService(request, ws, 'console') || await this.checkService(request, ws, 'repl')) {
return;
}
if (request.isPublicEndpoint) {
ws.close();
return;

View File

@@ -38,7 +38,8 @@
"vue2-google-maps": "^0.10.7",
"vuetify": "^2.5.8",
"vuex": "^3.6.2",
"xterm": "^4.13.0"
"xterm": "^4.13.0",
"xterm-addon-fit": "^0.5.0"
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.13.0",
@@ -49,6 +50,8 @@
"@babel/preset-typescript": "^7.13.0",
"@types/blob-stream": "^0.1.30",
"@types/dom-webcodecs": "^0.1.0",
"@types/engine.io": "^3.1.7",
"@types/engine.io-client": "^3.1.5",
"@types/lodash": "^4.14.172",
"@types/mkdirp": "^1.0.1",
"@types/node": "^16.0.0",
@@ -2155,6 +2158,24 @@
"integrity": "sha512-e5DuY7mZedrV8rrgEnm1gPmy4h2x11ryUTnkjmbdR6SWGCS3Qvz1ZJudtBIrbPNEhnpGM634SL7ZHQDz7dsBCg==",
"dev": true
},
"node_modules/@types/engine.io": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/engine.io/-/engine.io-3.1.7.tgz",
"integrity": "sha512-qNjVXcrp+1sS8YpRUa714r0pgzOwESdW5UjHL7D/2ZFdBX0BXUXtg1LUrp+ylvqbvMcMWUy73YpRoxPN2VoKAQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/engine.io-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/engine.io-client/-/engine.io-client-3.1.5.tgz",
"integrity": "sha512-h2BKMLNVMQGQ0Q7WaQCJ/63LooRQhItnoFNXhbNVnjhNheWEO+sdpTFjZpzWP7TDl4M5lI3fsEAk3PFZfV67Yg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.13",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
@@ -18742,6 +18763,14 @@
"resolved": "https://registry.npmjs.org/xterm/-/xterm-4.13.0.tgz",
"integrity": "sha512-HVW1gdoLOTnkMaqQCr2r3mQy4fX9iSa5gWxKZ2UTYdLa4iqavv7QxJ8n1Ypse32shPVkhTYPLS6vHEFZp5ghzw=="
},
"node_modules/xterm-addon-fit": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz",
"integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==",
"peerDependencies": {
"xterm": "^4.0.0"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
@@ -20492,6 +20521,24 @@
"integrity": "sha512-e5DuY7mZedrV8rrgEnm1gPmy4h2x11ryUTnkjmbdR6SWGCS3Qvz1ZJudtBIrbPNEhnpGM634SL7ZHQDz7dsBCg==",
"dev": true
},
"@types/engine.io": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/engine.io/-/engine.io-3.1.7.tgz",
"integrity": "sha512-qNjVXcrp+1sS8YpRUa714r0pgzOwESdW5UjHL7D/2ZFdBX0BXUXtg1LUrp+ylvqbvMcMWUy73YpRoxPN2VoKAQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/engine.io-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/engine.io-client/-/engine.io-client-3.1.5.tgz",
"integrity": "sha512-h2BKMLNVMQGQ0Q7WaQCJ/63LooRQhItnoFNXhbNVnjhNheWEO+sdpTFjZpzWP7TDl4M5lI3fsEAk3PFZfV67Yg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/express": {
"version": "4.17.13",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
@@ -33900,6 +33947,12 @@
"resolved": "https://registry.npmjs.org/xterm/-/xterm-4.13.0.tgz",
"integrity": "sha512-HVW1gdoLOTnkMaqQCr2r3mQy4fX9iSa5gWxKZ2UTYdLa4iqavv7QxJ8n1Ypse32shPVkhTYPLS6vHEFZp5ghzw=="
},
"xterm-addon-fit": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz",
"integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==",
"requires": {}
},
"y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",

View File

@@ -40,7 +40,8 @@
"vue2-google-maps": "^0.10.7",
"vuetify": "^2.5.8",
"vuex": "^3.6.2",
"xterm": "^4.13.0"
"xterm": "^4.13.0",
"xterm-addon-fit": "^0.5.0"
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.13.0",
@@ -51,6 +52,8 @@
"@babel/preset-typescript": "^7.13.0",
"@types/blob-stream": "^0.1.30",
"@types/dom-webcodecs": "^0.1.0",
"@types/engine.io": "^3.1.7",
"@types/engine.io-client": "^3.1.5",
"@types/lodash": "^4.14.172",
"@types/mkdirp": "^1.0.1",
"@types/node": "^16.0.0",

View File

@@ -0,0 +1,39 @@
<template>
<v-card raised>
<v-toolbar dark color="blue"> Console </v-toolbar>
<div ref="terminal"></div>
</v-card>
</template>
<script>
import { Terminal } from "xterm";
import { FitAddon } from 'xterm-addon-fit';
import eio from "engine.io-client";
export default {
props: ["deviceId"],
socket: null,
mounted() {
const term = new Terminal({
convertEol: true,
disableStdin: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(this.$refs.terminal);
fitAddon.fit();
const endpointPath = `/endpoint/@scrypted/core`;
const options = {
path: `${endpointPath}/engine.io/console/${this.deviceId}`,
};
const rootLocation = `${window.location.protocol}//${window.location.host}`;
this.socket = eio(rootLocation, options);
this.socket.on('message', data => term.write(new Uint8Array(data)));
},
destroyed() {
this.socket?.close();
}
};
</script>

View File

@@ -3,208 +3,213 @@
<v-flex xs12 md6 v-if="name != null">
<v-layout row wrap>
<v-flex xs12>
<v-flex>
<div v-if="deviceAlerts.length" class="pb-5">
<v-alert
dismissible
@input="removeAlert(alert)"
v-for="alert in deviceAlerts"
:key="alert.id"
xs12
md6
lg6
outlined
text
color="primary"
icon="mdi-vuetify"
border="left"
<div v-if="deviceAlerts.length" class="pb-5">
<v-alert
dismissible
@input="removeAlert(alert)"
v-for="alert in deviceAlerts"
:key="alert.id"
xs12
md6
lg6
outlined
text
color="primary"
icon="mdi-vuetify"
border="left"
>
<template v-slot:prepend>
<v-icon class="white--text mr-3" size="sm" color="#a9afbb">{{
getAlertIcon(alert)
}}</v-icon>
</template>
<div class="caption">{{ alert.title }}</div>
<div
v-linkified:options="{ className: 'alert-link' }"
v-html="alert.message"
style="color: white"
></div>
</v-alert>
</div>
<v-card raised>
<v-card-title class="orange-gradient subtitle-1 font-weight-light">
{{ name || "No Device Name" }}
<v-layout
row
justify-end
align-center
v-if="cardHeaderInterfaces.length"
>
<template v-slot:prepend>
<v-icon
class="white--text mr-3"
size="sm"
color="#a9afbb"
>{{ getAlertIcon(alert) }}</v-icon>
</template>
<div class="caption">{{ alert.title }}</div>
<div
v-linkified:options="{ className: 'alert-link' }"
v-html="alert.message"
style="color: white"
></div>
</v-alert>
</div>
<v-card raised >
<v-card-title
class="orange-gradient subtitle-1 font-weight-light"
>
{{ name || "No Device Name" }}
<v-layout row justify-end align-center v-if="cardHeaderInterfaces.length">
<component
:value="deviceState"
:device="device"
:is="iface"
v-for="iface in cardHeaderInterfaces"
:key="iface"
></component>
</v-layout>
</v-card-title>
<v-flex v-if="cardButtonInterfaces.length">
<v-layout align-center justify-center>
<component
v-for="iface in cardButtonInterfaces"
:key="iface"
:value="deviceState"
:device="device"
:is="iface"
></component>
</v-layout>
</v-flex>
<v-form>
<v-container>
<v-layout>
<v-flex xs12>
<v-text-field
v-model="name"
label="Name"
required
></v-text-field>
<v-select
v-if="inferredTypes.length > 1"
:items="inferredTypes"
label="Type"
outlined
v-model="type"
></v-select>
<v-combobox
v-if="
hasFixedPhysicalLocation(type, deviceState.interfaces)
"
:items="existingRooms"
outlined
v-model="room"
label="Room"
required
></v-combobox>
</v-flex>
</v-layout>
</v-container>
</v-form>
<v-card-actions>
<component
v-for="iface in cardActionInterfaces"
:value="deviceState"
:device="device"
:is="iface"
v-for="iface in cardHeaderInterfaces"
:key="iface"
></component>
</v-layout>
</v-card-title>
<v-flex v-if="cardButtonInterfaces.length">
<v-layout align-center justify-center>
<component
v-for="iface in cardButtonInterfaces"
:key="iface"
:value="deviceState"
:device="device"
:is="iface"
></component>
<v-spacer></v-spacer>
</v-layout>
</v-flex>
<v-btn color="info" text @click="openLogs" v-if="!loading"
>Logs</v-btn
>
<v-form>
<v-container>
<v-layout>
<v-flex xs12>
<v-text-field
v-model="name"
label="Name"
required
></v-text-field>
<v-select
v-if="inferredTypes.length > 1"
:items="inferredTypes"
label="Type"
outlined
v-model="type"
></v-select>
<v-combobox
v-if="
hasFixedPhysicalLocation(type, deviceState.interfaces)
"
:items="existingRooms"
outlined
v-model="room"
label="Room"
required
></v-combobox>
</v-flex>
</v-layout>
</v-container>
</v-form>
<v-dialog v-if="!loading" v-model="showDelete" width="500">
<template v-slot:activator="{ on }">
<v-btn color="error" text v-on="on">Delete</v-btn>
</template>
<v-card-actions>
<component
v-for="iface in cardActionInterfaces"
:key="iface"
:value="deviceState"
:device="device"
:is="iface"
></component>
<v-spacer></v-spacer>
<v-card>
<v-card-title
style="margin-bottom: 8px"
class="red font-weight-light white--text"
primary-title
>Delete Device</v-card-title
<v-btn color="info" text @click="openConsole" v-if="!loading"
>Console</v-btn
>
<v-btn color="info" text @click="openRepl" v-if="!loading"
>REPL</v-btn
>
<v-btn color="info" text @click="openLogs" v-if="!loading"
>Logs</v-btn
>
<v-dialog v-if="!loading" v-model="showDelete" width="500">
<template v-slot:activator="{ on }">
<v-btn color="error" text v-on="on">Delete</v-btn>
</template>
<v-card>
<v-card-title
style="margin-bottom: 8px"
class="red font-weight-light white--text"
primary-title
>Delete Device</v-card-title
>
<v-card-text
>This will permanently delete the device. It can not be
undone.</v-card-text
>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="showDelete = false"
>Cancel</v-btn
>
<v-card-text
>This will permanently delete the device. It can not be
undone.</v-card-text
<v-btn color="red" text @click="remove"
>Delete Device</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="showDelete = false"
>Cancel</v-btn
>
<v-btn color="red" text @click="remove"
>Delete Device</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<v-btn color="primary" v-if="!loading" text @click="save"
>Save</v-btn
>
</v-card-actions>
</v-card>
<v-alert
outlined
v-model="showSave"
dismissible
close-text="Close Alert"
type="success"
>Saved.</v-alert
>
<v-alert
outlined
v-model="showSaveError"
dismissible
close-text="Close Alert"
type="success"
>There was an error while saving. Please check the logs.</v-alert
>
</v-flex>
<v-btn color="primary" v-if="!loading" text @click="save"
>Save</v-btn
>
</v-card-actions>
</v-card>
<v-alert
outlined
v-model="showSave"
dismissible
close-text="Close Alert"
type="success"
>Saved.</v-alert
>
<v-alert
outlined
v-model="showSaveError"
dismissible
close-text="Close Alert"
type="success"
>There was an error while saving. Please check the logs.</v-alert
>
</v-flex>
<v-flex xs12 v-if="!ownerDevice && pluginData">
<v-flex>
<v-card raised >
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
<v-card raised>
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
>
<font-awesome-icon size="sm" icon="database" />
&nbsp;&nbsp;Plugin Management
</v-card-title>
<v-card-text></v-card-text>
<v-container>
<v-layout>
<v-flex>
<v-btn outlined color="blue" @click="reloadPlugin"
>Reload Plugin</v-btn
>
</v-flex>
</v-layout></v-container
>
<v-card-actions>
<v-btn text color="primary" @click="showStorage = !showStorage"
>Storage</v-btn
>
<font-awesome-icon size="sm" icon="database" />
&nbsp;&nbsp;Plugin Management
</v-card-title>
<v-card-text></v-card-text>
<v-container>
<v-layout>
<v-flex>
<v-btn outlined color="blue" @click="reloadPlugin"
>Reload Plugin</v-btn
>
</v-flex>
</v-layout></v-container
>
<v-card-actions>
<v-btn text color="primary" @click="showStorage = !showStorage"
>Storage</v-btn
>
<v-spacer></v-spacer>
<v-btn
v-if="!pluginData.updateAvailable"
text
color="blue"
@click="openNpm"
xs4
>{{ pluginData.packageJson.name }}@{{
pluginData.packageJson.version
}}</v-btn
>
<v-btn v-else color="orange" @click="doInstall" dark
>Install Update {{ pluginData.updateAvailable }}</v-btn
>
</v-card-actions>
</v-card>
</v-flex>
<v-spacer></v-spacer>
<v-btn
v-if="!pluginData.updateAvailable"
text
color="blue"
@click="openNpm"
xs4
>{{ pluginData.packageJson.name }}@{{
pluginData.packageJson.version
}}</v-btn
>
<v-btn v-else color="orange" @click="doInstall" dark
>Install Update {{ pluginData.updateAvailable }}</v-btn
>
</v-card-actions>
</v-card>
</v-flex>
<v-flex xs12 v-if="deviceComponent">
@@ -217,113 +222,98 @@
</v-flex>
<v-flex xs12 v-if="ownerDevice && pluginData">
<v-flex>
<v-card raised >
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
<v-card raised>
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
>
<font-awesome-icon size="sm" icon="server" />
&nbsp;&nbsp;Managed Device
</v-card-title>
<v-card-text></v-card-text>
<v-card-text>
<b>Native ID:</b>
{{ pluginData.nativeId }}
</v-card-text>
<v-card-actions>
<v-btn text color="primary" @click="showStorage = !showStorage"
>Storage</v-btn
>
<font-awesome-icon size="sm" icon="server" />
&nbsp;&nbsp;Managed Device
</v-card-title>
<v-card-text></v-card-text>
<v-card-text>
<b>Native ID:</b>
{{ pluginData.nativeId }}
</v-card-text>
<v-card-actions>
<v-btn text color="primary" @click="showStorage = !showStorage"
>Storage</v-btn
>
<v-spacer></v-spacer>
<v-btn text color="blue" :to="`/device/${ownerDevice.id}`">{{
ownerDevice.name
}}</v-btn>
</v-card-actions>
</v-card>
</v-flex>
<v-spacer></v-spacer>
<v-btn text color="blue" :to="`/device/${ownerDevice.id}`">{{
ownerDevice.name
}}</v-btn>
</v-card-actions>
</v-card>
</v-flex>
<v-flex xs12 v-if="availableMixins.length">
<v-flex>
<v-card raised >
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
<v-card raised>
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
>
<font-awesome-icon size="sm" icon="puzzle-piece" />
&nbsp;&nbsp;Integrations and Extensions
</v-card-title>
<v-list-item-group>
<v-list-item
@click="
mixin.enabled = !mixin.enabled;
toggleMixin(mixin);
"
v-for="mixin in availableMixins"
:key="mixin.id"
inactive
>
<font-awesome-icon size="sm" icon="puzzle-piece" />
&nbsp;&nbsp;Integrations and Extensions
</v-card-title>
<v-list-item-action>
<v-checkbox
@click.stop
@change="toggleMixin(mixin)"
v-model="mixin.enabled"
color="primary"
></v-checkbox>
</v-list-item-action>
<v-list-item-group>
<v-list-item
@click="
mixin.enabled = !mixin.enabled;
toggleMixin(mixin);
"
v-for="mixin in availableMixins"
:key="mixin.id"
inactive
>
<v-list-item-action>
<v-checkbox
@click.stop
@change="toggleMixin(mixin)"
v-model="mixin.enabled"
color="primary"
></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>{{ mixin.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-card>
</v-flex>
<v-list-item-content>
<v-list-item-title>{{ mixin.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-card>
</v-flex>
<v-flex xs12 v-if="showStorage">
<v-flex>
<v-card raised >
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
>Storage</v-card-title
>
<v-form>
<v-container>
<v-layout>
<v-flex xs12>
<Storage
v-model="pluginData.storage"
@input="onChange"
></Storage>
</v-flex>
</v-layout>
</v-container>
</v-form>
</v-card>
</v-flex>
<v-card raised>
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
>Storage</v-card-title
>
<v-form>
<v-container>
<v-layout>
<v-flex xs12>
<Storage
v-model="pluginData.storage"
@input="onChange"
></Storage>
</v-flex>
</v-layout>
</v-container>
</v-form>
</v-card>
</v-flex>
<v-flex xs12>
<v-flex>
<v-card
raised
v-for="iface in cardUnderInterfaces"
:key="iface"
>
<v-card-title
class="orange-gradient subtitle-1 font-weight-light"
>
{{ iface }}
</v-card-title>
<component
:value="deviceState"
:device="device"
:is="iface"
></component>
</v-card>
</v-flex>
<v-card raised v-for="iface in cardUnderInterfaces" :key="iface">
<v-card-title class="orange-gradient subtitle-1 font-weight-light">
{{ iface }}
</v-card-title>
<component
:value="deviceState"
:device="device"
:is="iface"
></component>
</v-card>
</v-flex>
</v-layout>
</v-flex>
@@ -331,18 +321,15 @@
<v-flex xs12 md6 lg6>
<v-layout row wrap>
<v-flex xs12 v-for="iface in noCardInterfaces" :key="iface">
<v-flex v-if="name != null">
<component
<component v-if="name != null"
:value="deviceState"
:device="device"
:is="iface"
></component>
</v-flex>
</v-flex>
<v-flex xs12 v-for="iface in cardInterfaces" :key="iface">
<v-flex v-if="name != null">
<v-card>
<v-card v-if="name != null">
<v-card-title
class="red-gradient white--text subtitle-1 font-weight-light"
>{{ iface }}</v-card-title
@@ -353,15 +340,18 @@
:is="iface"
></component>
</v-card>
</v-flex>
</v-flex>
<v-flex ref="logsEl">
<LogCard
v-if="showLogs"
:rows="15"
:logRoute="`/device/${id}/`"
></LogCard>
<v-flex v-if="showLogs" ref="logsEl">
<LogCard :rows="15" :logRoute="`/device/${id}/`"></LogCard>
</v-flex>
<v-flex xs12 v-if="showConsole" ref="consoleEl">
<ConsoleCard :deviceId="id"></ConsoleCard>
</v-flex>
<v-flex xs12 v-if="showRepl" ref="replEl">
<REPLCard :deviceId="id"></REPLCard>
</v-flex>
</v-layout>
</v-flex>
@@ -372,6 +362,8 @@ import VueSlider from "vue-slider-component";
import "vue-slider-component/theme/material.css";
import LogCard from "./builtin/LogCard.vue";
import ConsoleCard from "./ConsoleCard.vue";
import REPLCard from "./REPLCard.vue";
import {
inferTypesFromInterfaces,
getComponentWebPath,
@@ -411,6 +403,7 @@ import Storage from "../common/Storage.vue";
import { checkUpdate, installNpm, getNpmPath } from "./script/plugin";
import AggregateDevice from "./aggregate/AggregateDevice.vue";
import Automation from "./automation/Automation.vue";
import Vue from "vue";
const cardHeaderInterfaces = [
ScryptedInterface.OccupancySensor,
@@ -424,9 +417,7 @@ const cardHeaderInterfaces = [
const cardUnderInterfaces = [ScryptedInterface.DeviceProvider];
const noCardInterfaces = [
ScryptedInterface.Settings,
]
const noCardInterfaces = [ScryptedInterface.Settings];
const cardInterfaces = [
ScryptedInterface.Brightness,
@@ -464,7 +455,7 @@ function filterInterfaces(interfaces) {
);
if (ret.includes(ScryptedInterface.Camera)) {
ret = ret.filter(iface => iface !== ScryptedInterface.VideoCamera);
ret = ret.filter((iface) => iface !== ScryptedInterface.VideoCamera);
}
return ret;
};
@@ -505,6 +496,8 @@ export default {
VueSlider,
LogCard,
ConsoleCard,
REPLCard,
Storage,
@@ -544,6 +537,8 @@ export default {
initialState() {
return {
showLogs: false,
showConsole: false,
showRepl: false,
showDelete: false,
showSave: false,
showSaveError: false,
@@ -566,9 +561,24 @@ export default {
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
},
openLogs() {
async openConsole() {
this.showConsole = !this.showConsole;
if (this.showConsole) {
await Vue.nextTick();
this.$vuetify.goTo(this.$refs.consoleEl);
}
},
async openRepl() {
this.showRepl = !this.showRepl;
if (this.showRepl) {
await Vue.nextTick();
this.$vuetify.goTo(this.$refs.replEl);
}
},
async openLogs() {
this.showLogs = !this.showLogs;
if (this.showLogs) {
await Vue.nextTick();
this.$vuetify.goTo(this.$refs.logsEl);
}
},
@@ -614,7 +624,7 @@ export default {
);
this.pluginData = pluginData;
checkUpdate(pluginData.pluginId, pluginData.packageJson.version).then(
updateAvailable => (pluginData.updateAvailable = updateAvailable)
(updateAvailable) => (pluginData.updateAvailable = updateAvailable)
);
const device = this.device;

View File

@@ -0,0 +1,48 @@
<template>
<v-card raised>
<v-toolbar dark color="blue"> JavaScript REPL </v-toolbar>
<div ref="terminal"></div>
</v-card>
</template>
<script>
import { Terminal } from "xterm";
import { FitAddon } from 'xterm-addon-fit';
import eio from "engine.io-client";
export default {
props: ["deviceId"],
socket: null,
mounted() {
const term = new Terminal({
convertEol: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(this.$refs.terminal);
fitAddon.fit();
const endpointPath = `/endpoint/@scrypted/core`;
const options = {
path: `${endpointPath}/engine.io/repl/${this.deviceId}`,
};
const rootLocation = `${window.location.protocol}//${window.location.host}`;
this.socket = eio(rootLocation, options);
this.socket.on('message', data => {
term.write(new Uint8Array(data));
});
term.onData(data => {
this.socket.send(data);
});
term.onBinary(data => {
this.socket.send(data);
});
},
destroyed() {
this.socket?.close();
}
};
</script>

View File

@@ -1,3 +1,4 @@
import "xterm/css/xterm.css";
import '@fortawesome/fontawesome-free/css/all.css'
import Vue from 'vue'
import Vuetify, {

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/google-home",
"version": "0.0.4",
"version": "0.0.5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/google-home",
"version": "0.0.4",
"version": "0.0.5",
"dependencies": {
"actions-on-google": "^2.13.0",
"axios": "^0.21.1",

View File

@@ -35,5 +35,5 @@
"@types/mdns": "^0.0.34",
"@types/url-parse": "^1.4.3"
},
"version": "0.0.4"
"version": "0.0.5"
}

View File

@@ -83,7 +83,7 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
this.plugins = systemManager.getComponent('plugins');
this.localEndpoint = new http.Server((req, res) => {
this.log.i('got request');
this.console.log('got request');
res.writeHead(404);
res.end();
});
@@ -91,7 +91,7 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
endpointManager.getInsecurePublicLocalEndpoint().then(endpoint => {
const url = new URL(endpoint);
this.log.i(endpoint);
this.console.log(endpoint);
const ad = mdns.createAdvertisement(mdns.tcp('scrypted-gh'), parseInt(url.port));
ad.start();
});
@@ -237,7 +237,7 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
for (const queryDevice of input.payload.devices) {
const device = systemManager.getDeviceById(queryDevice.id);
if (!device) {
this.log.e(`query for missing device ${queryDevice.id}`);
this.console.error(`query for missing device ${queryDevice.id}`);
ret.payload.devices[queryDevice.id] = {
online: false,
};
@@ -247,7 +247,7 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
const { type } = device;
const supportedType = supportedTypes[type];
if (!supportedType) {
this.log.e(`query for unsupported type ${type}`);
this.console.error(`query for unsupported type ${type}`);
ret.payload.devices[queryDevice.id] = {
online: false,
};
@@ -264,7 +264,7 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
}, status);
}
catch (e) {
this.log.e(`query failure for ${device.name}`);
this.console.error(`query failure for ${device.name}`);
ret.payload.devices[queryDevice.id] = {
status: 'ERROR',
online: false,
@@ -289,6 +289,7 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
for (const commandDevice of command.devices) {
const device = systemManager.getDeviceById(commandDevice.id);
if (!device) {
this.log.e(`execute failed, device not found ${JSON.stringify(commandDevice)}`);
const error: SmartHomeV1ExecuteResponseCommands = {
ids: [commandDevice.id],
status: 'ERROR',
@@ -298,9 +299,12 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
continue;
}
this.log.i(`executing command on ${device.name}`);
for (const execution of command.execution) {
const commandHandler = commandHandlers[execution.command]
if (!commandHandler) {
this.log.e(`execute failed, command not supported ${JSON.stringify(execution)}`);
const error: SmartHomeV1ExecuteResponseCommands = {
ids: [commandDevice.id],
status: 'ERROR',
@@ -315,6 +319,7 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
ret.payload.commands.push(result);
}
catch (e) {
this.log.e(`execution failed ${e}`);
const error: SmartHomeV1ExecuteResponseCommands = {
ids: [commandDevice.id],
status: 'ERROR',
@@ -375,12 +380,12 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
if (!Object.keys(report.payload.devices.states).length)
return;
this.log.i('reporting state:');
this.log.i(JSON.stringify(report, undefined, 2));
this.console.log('reporting state:');
this.console.log(JSON.stringify(report, undefined, 2));
if (this.app.jwt) {
const result = await this.app.reportState(report);
this.log.i('report state result:')
this.log.i(result);
this.console.log('report state result:')
this.console.log(result);
return;
}
@@ -397,8 +402,8 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
Authorization: `Bearer ${token_info}`
},
});
this.log.i('report state result:');
this.log.i(JSON.stringify(response.data));
this.console.log('report state result:');
this.console.log(JSON.stringify(response.data));
}
async requestSync() {
@@ -420,8 +425,8 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
Authorization: `Bearer ${token_info}`
}
});
this.log.i('request sync result:');
this.log.i(JSON.stringify(response.data));
this.console.log('request sync result:');
this.console.log(JSON.stringify(response.data));
}
async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
@@ -432,19 +437,19 @@ class GoogleHome extends ScryptedDeviceBase implements HttpRequestHandler, Engin
return;
}
this.log.i(request.body);
this.console.log(request.body);
const body = JSON.parse(request.body);
try {
const result = await this.app.handler(body, request.headers as Headers);
const res = JSON.stringify(result.body);
this.log.i(res);
this.console.log(res);
response.send(res, {
headers: result.headers,
code: result.status,
});
}
catch (e) {
this.log.e(`request error ${e}`);
this.console.error(`request error ${e}`);
response.send(e.message, {
code: 500,
});

View File

@@ -1,3 +1,6 @@
const { Console } = require('console');
const { PassThrough } = require('stream');
class ScryptedDeviceBase {
constructor(nativeId) {
this.nativeId = nativeId;
@@ -17,6 +20,14 @@ class ScryptedDeviceBase {
return this._log;
}
get console() {
if (!this._console) {
this._console = deviceManager.getDeviceConsole(this.nativeId);
}
return this._console;
}
_lazyLoadDeviceState() {
if (!this._deviceState) {
if (this.nativeId) {
@@ -51,33 +62,33 @@ class MixinDeviceBase {
}
}
(function() {
function _createGetState(state) {
return function() {
this._lazyLoadDeviceState();
return this._deviceState[state];
};
}
(function () {
function _createGetState(state) {
return function () {
this._lazyLoadDeviceState();
return this._deviceState[state];
};
}
function _createSetState(state) {
return function(value) {
this._lazyLoadDeviceState();
this._deviceState[state] = value;
};
}
function _createSetState(state) {
return function (value) {
this._lazyLoadDeviceState();
this._deviceState[state] = value;
};
}
var fields = ["id","interfaces","metadata","name","providedInterfaces","providedName","providedRoom","providedType","providerId","room","type","on","brightness","colorTemperature","rgb","hsv","running","paused","docked","temperature","temperatureUnit","humidity","thermostatAvailableModes","thermostatMode","thermostatSetpoint","thermostatSetpointHigh","thermostatSetpointLow","lockState","entryOpen","batteryLevel","online","updateAvailable","fromMimeType","toMimeType","binaryState","intrusionDetected","powerDetected","motionDetected","occupied","flooded","ultraviolet","luminance","position",
];
for (var field of fields) {
Object.defineProperty(ScryptedDeviceBase.prototype, field, {
set: _createSetState(field),
get: _createGetState(field),
});
Object.defineProperty(MixinDeviceBase.prototype, field, {
set: _createSetState(field),
get: _createGetState(field),
});
}
var fields = ["id", "interfaces", "metadata", "name", "providedInterfaces", "providedName", "providedRoom", "providedType", "providerId", "room", "type", "on", "brightness", "colorTemperature", "rgb", "hsv", "running", "paused", "docked", "temperature", "temperatureUnit", "humidity", "thermostatAvailableModes", "thermostatMode", "thermostatSetpoint", "thermostatSetpointHigh", "thermostatSetpointLow", "lockState", "entryOpen", "batteryLevel", "online", "updateAvailable", "fromMimeType", "toMimeType", "binaryState", "intrusionDetected", "powerDetected", "motionDetected", "occupied", "flooded", "ultraviolet", "luminance", "position",
];
for (var field of fields) {
Object.defineProperty(ScryptedDeviceBase.prototype, field, {
set: _createSetState(field),
get: _createGetState(field),
});
Object.defineProperty(MixinDeviceBase.prototype, field, {
set: _createSetState(field),
get: _createGetState(field),
});
}
})();

View File

@@ -1,48 +1,13 @@
var sdk = require('./index.generated.js');
try {
var mediaManagerProxy;
try {
mediaManagerProxy = mediaManager;
}
catch (e) {
var mediaManagerApply = function(target, prop, argumentsList) {
var copy = [];
if (argumentsList) {
for (var i in argumentsList) {
copy.push(NativeBuffer.from(argumentsList[i]));
}
}
var ret = mediaManager[prop].apply(mediaManager, copy);
var p = global['Promise'];
if (!p || (!prop.startsWith('convert'))) {
return ret;
}
// convert the promise to the globally available Promise.
return new p((resolve, reject) => {
// todo: dont use native buffer as a return value
ret.then(r => NativeBuffer.toBuffer(resolve(r)))
.catch(e => reject(e));
});
};
mediaManagerProxy = new Proxy(function(){}, {
get: function(target, prop) {
return function() {
return mediaManagerApply(target, prop, arguments)
}
},
apply: mediaManagerApply,
})
}
sdk = Object.assign(sdk, {
log,
android,
deviceManager,
endpointManager,
mediaManager: mediaManagerProxy,
mediaManager,
systemManager,
pluginHostAPI,
});
}
catch (e) {

4
sdk/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/sdk",
"version": "0.0.64",
"version": "0.0.67",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/sdk",
"version": "0.0.64",
"version": "0.0.67",
"license": "ISC",
"dependencies": {
"@babel/core": "^7.2.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/sdk",
"version": "0.0.65",
"version": "0.0.67",
"description": "",
"main": "index.js",
"scripts": {

39
sdk/types.d.ts vendored
View File

@@ -1,4 +1,3 @@
/**
* DeviceState is returned by DeviceManager.getDeviceState, and allows getting/setting of a device provided by a DeviceProvider.
*/
@@ -662,14 +661,14 @@ export interface DeviceManager {
getDeviceLogger(nativeId: string): Logger;
/**
* Get the device state maintained by Scrypted. Setting properties on this state will update the state in Scrypted.
* Get the console for the device given a native id.
*/
getDeviceState(): DeviceState;
getDeviceConsole?(nativeId?: string): Console;
/**
* Get the device state maintained by Scrypted. Setting properties on this state will update the state in Scrypted.
*/
getDeviceState(nativeId: string): DeviceState;
getDeviceState(nativeId?: string): DeviceState;
/**
* Get the per script Storage object.
@@ -835,22 +834,6 @@ export interface SystemManager {
*/
removeDevice(id: string): Promise<void>;
}
/**
* Android provides limited access to the Android system, to send Intents to other applications, such as Tasker. See Android SDK documentation for more information.
*/
export interface Android {
/**
* Create a new Intent. Use one of the send methods to send broadcasts, start activities, or start services.
*/
newIntent(): Intent;
sendBroadcast(intent: Intent): Promise<void>;
startActivity(intent: Intent): Promise<void>;
startService(intent: Intent): Promise<void>;
}
/**
* MixinProviders can add and intercept interfaces to other devices to add or augment their behavior.
@@ -996,7 +979,6 @@ export enum ScryptedInterface {
MediaSource = "MediaSource",
MessagingEndpoint = "MessagingEndpoint",
OauthClient = "OauthClient",
Android = "Android",
MixinProvider = "MixinProvider",
HttpRequestHandler = "HttpRequestHandler",
EngineIOHandler = "EngineIOHandler",
@@ -1066,16 +1048,6 @@ export enum ScryptedMimeTypes {
RTCAVAnswer = 'x-scrypted/x-rtc-av-answer',
}
// export interface ZwaveManagerDevice extends ZwaveManager, ScryptedDevice {
// }
/**
* Android Intent.
* See: https://developer.android.com/reference/android/content/Intent
*/
interface Intent {
}
export interface ScryptedInterfaceDescriptor {
name: string;
properties: ScryptedInterfaceProperty[];
@@ -1092,14 +1064,13 @@ export interface ScryptedStatic {
* @deprecated
*/
log?: Logger,
scriptSettings?: Settings,
android?: Android,
deviceManager?: DeviceManager,
endpointManager?: EndpointManager,
mediaManager?: MediaManager,
systemManager: SystemManager,
// zwaveManager?: ZwaveManagerDevice,
pluginHostAPI?: any;
}

View File

@@ -104,7 +104,6 @@ module.exports.ScryptedInterface = {
MediaSource: "MediaSource",
MessagingEndpoint: "MessagingEndpoint",
OauthClient: "OauthClient",
Android: "Android",
MixinProvider: "MixinProvider",
HttpRequestHandler: "HttpRequestHandler",
EngineIOHandler: "EngineIOHandler",
@@ -501,17 +500,6 @@ module.exports.ScryptedInterfaceDescriptors = {
"onOauthCallback",
]
},
Android: {
name: "Android",
properties: [
],
methods: [
"newIntent",
"sendBroadcast",
"startActivity",
"startService",
]
},
MixinProvider: {
name: "MixinProvider",
properties: [

View File

@@ -75,4 +75,8 @@ export class PluginComponent {
id: this.scrypted.findPluginDevice(pluginId),
}
}
async getRemoteServicePort(pluginId: string, name: string): Promise<number> {
return this.scrypted.plugins[pluginId].remote.getServicePort(name);
}
}

View File

@@ -0,0 +1,15 @@
import net from 'net';
import { once } from 'events';
export async function listenZeroCluster(server: net.Server) {
while (true) {
const port = 10000 + Math.round(Math.random() * 30000);
server.listen(port);
try {
await once(server, 'listening');
return (server.address() as net.AddressInfo).port;
}
catch (e) {
}
}
}

View File

@@ -7,8 +7,7 @@ import { once } from 'events';
import fs from 'fs';
import tmp from 'tmp';
import net from 'net';
import { sleep } from "../sleep";
import { AddressInfo } from "ws";
import { listenZeroCluster } from "./cluster-helper";
const wrtc = require('wrtc');
Object.assign(global, wrtc);
@@ -22,18 +21,6 @@ interface RTCSession {
const rtcSessions: { [id: string]: RTCSession } = {};
async function listenZeroCluster(server: net.Server) {
while (true) {
const port = 10000 + Math.round(Math.random() * 30000);
server.listen(port);
try {
await once(server, 'listening');
return (server.address() as AddressInfo).port;
}
catch (e) {
}
}
}
function addBuiltins(converters: BufferConverter[]) {
converters.push({

View File

@@ -43,6 +43,8 @@ export interface PluginRemote {
ioEvent(id: string, event: string, message?: any): Promise<void>;
createDeviceState(id: string, setState: (property: string, value: any) => Promise<void>): Promise<any>;
getServicePort(name: string): Promise<number>;
}
export interface MediaObjectRemote extends MediaObject {

View File

@@ -1,7 +1,7 @@
import cluster from 'cluster';
import { RpcMessage, RpcPeer } from '../rpc';
import AdmZip from 'adm-zip';
import { ScryptedDevice, Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, EngineIOHandler, ScryptedInterfaceProperty, MediaManager, SystemDeviceState } from '@scrypted/sdk/types'
import { ScryptedDevice, Device, DeviceManifest, EventDetails, EventListenerOptions, EventListenerRegister, EngineIOHandler, ScryptedInterfaceProperty, MediaManager, SystemDeviceState, ScryptedStatic } from '@scrypted/sdk/types'
import { ScryptedRuntime } from '../runtime';
import { Plugin } from '../db-types';
import io from 'engine.io';
@@ -10,7 +10,14 @@ import { PluginAPI, PluginRemote } from './plugin-api';
import { Logger } from '../logger';
import { MediaManagerImpl } from './media';
import { getState } from '../state';
import WebSocket from 'ws';
import WebSocket, { EventEmitter } from 'ws';
import { listenZeroCluster } from './cluster-helper';
import { Server } from 'net';
import repl, { REPLServer } from 'repl';
import { once } from 'events';
import { PassThrough } from 'stream';
import { Console } from 'console'
import util from 'util';
export class PluginHost {
worker: cluster.Worker;
@@ -58,6 +65,10 @@ export class PluginHost {
if (true) {
this.worker = cluster.fork();
this.worker.process.stdout.on('data', data => {
process.stdout.write(data);
});
this.worker.process.stderr.on('data', data => process.stderr.write(data));
let connected = true;
this.worker.on('disconnect', () => {
@@ -107,7 +118,9 @@ export class PluginHost {
}
});
attachPluginRemote(remote, async (systemManager) => new MediaManagerImpl(systemManager));
attachPluginRemote(remote, {
createMediaManager: async (systemManager) => new MediaManagerImpl(systemManager),
});
}
@@ -151,6 +164,7 @@ export class PluginHost {
const device = scrypted.findPluginDevice(plugin._id, nativeId);
return self.scrypted.getDeviceLogger(device);
}
getComponent(id: string): Promise<any> {
return self.scrypted.getComponent(id);
}
@@ -283,7 +297,149 @@ export class PluginHost {
}
}
export async function startPluginCluster() {
async function createConsoleServer(events: EventEmitter): Promise<number> {
const outputs = new Map<string, Buffer[]>();
const appendOutput = (data: Buffer, nativeId: string) => {
if (!nativeId)
nativeId = undefined;
let buffers = outputs.get(nativeId);
if (!buffers) {
buffers = [];
outputs.set(nativeId, buffers);
}
buffers.push(data);
};
events.on('stdout', appendOutput);
events.on('stderr', appendOutput);
const server = new Server(async (socket) => {
let [filter] = await once(socket, 'data');
filter = filter.toString().trim();
if (filter === 'undefined')
filter = undefined;
const buffers = outputs.get(filter);
if (buffers) {
const concat = Buffer.concat(buffers);
outputs.set(filter, [concat]);
socket.write(concat);
}
const cb = (data: Buffer, nativeId: string) => {
if (nativeId !== filter)
return;
socket.write(data);
};
events.on('stdout', cb)
events.on('stderr', cb)
const cleanup = () => {
events.removeListener('stdout', cb);
events.removeListener('stderr', cb);
};
socket.on('close', cleanup);
socket.on('error', cleanup);
socket.on('end', cleanup);
});
return listenZeroCluster(server);
}
async function createREPLServer(events: EventEmitter): Promise<number> {
const [[scrypted], [params], [plugin]] = await Promise.all([once(events, 'scrypted'), once(events, 'params'), once(events, 'plugin')]);
const { deviceManager, systemManager } = scrypted;
const server = new Server(async (socket) => {
let [filter] = await once(socket, 'data');
filter = filter.toString().trim();
if (filter === 'undefined')
filter = undefined;
const chain: string[] = [];
const nativeIds: Map<string, any> = deviceManager.nativeIds;
const reversed = new Map<string, string>();
for (const nativeId of nativeIds.keys()) {
reversed.set(nativeIds.get(nativeId).id, nativeId);
}
while (filter) {
const { id } = nativeIds.get(filter);
const d = await systemManager.getDeviceById(id);
chain.push(filter);
filter = reversed.get(d.providerId);
}
chain.reverse();
let device = plugin;
for (const c of chain) {
device = await device.getDevice(c);
}
const r = repl.start({
terminal: true,
input: socket,
output: socket,
// writer(this: REPLServer, obj: any) {
// const ret = util.inspect(obj, {
// colors: true,
// });
// return ret;//.replaceAll('\n', '\r\n');
// },
preview: false,
});
const ctx = Object.assign(params, {
device
});
delete ctx.console;
Object.assign(r.context, ctx);
const cleanup = () => {
r.close();
};
socket.on('close', cleanup);
socket.on('error', cleanup);
socket.on('end', cleanup);
});
return listenZeroCluster(server);
}
export function startPluginClusterWorker() {
const events = new EventEmitter();
const getDeviceConsole = (nativeId?: string) => {
const stdout = new PassThrough();
const stderr = new PassThrough();
stdout.on('data', data => events.emit('stdout', data, nativeId));
stderr.on('data', data => this.events.emit('stderr', data, nativeId));
const ret = new Console(stdout, stderr);
const methods = [
'log', 'warn',
'dir', 'time',
'timeEnd', 'timeLog',
'trace', 'assert',
'clear', 'count',
'countReset', 'group',
'groupEnd', 'table',
'debug', 'info',
'dirxml', 'error',
'groupCollapsed',
];
for (const m of methods) {
const old = (ret as any)[m].bind(ret);
(ret as any)[m] = (...args: any[]) => {
(console as any)[m](...args);
old(...args);
}
}
return ret;
}
const peer = new RpcPeer((message, reject) => process.send(message, undefined, {
swallowErrors: !reject,
}, e => {
@@ -291,16 +447,34 @@ export async function startPluginCluster() {
reject(e);
}));
process.on('message', message => peer.handleMessage(message as RpcMessage));
const scrypted = await attachPluginRemote(peer, async (systemManager) => new MediaManagerImpl(systemManager));
process.on('uncaughtException', e => {
scrypted.log.e('uncaughtException');
scrypted.log.e(e.toString());
scrypted.log.e(e.stack);
});
process.on('unhandledRejection', e => {
scrypted.log.e('unhandledRejection');
scrypted.log.e(e.toString());
});
const consolePort = createConsoleServer(events);
const replPort = createREPLServer(events);
attachPluginRemote(peer, {
createMediaManager: async (systemManager) => new MediaManagerImpl(systemManager),
events,
getDeviceConsole,
async getServicePort(name) {
if (name === 'repl')
return replPort;
if (name === 'console')
return consolePort;
throw new Error(`unknown service ${name}`);
}
}).then(scrypted => {
events.emit('scrypted', scrypted);
process.on('uncaughtException', e => {
scrypted.log.e('uncaughtException');
scrypted.log.e(e.toString());
scrypted.log.e(e.stack);
});
process.on('unhandledRejection', e => {
scrypted.log.e('unhandledRejection');
scrypted.log.e(e.toString());
});
})
}
class LazyRemote implements PluginRemote {
@@ -331,4 +505,9 @@ class LazyRemote implements PluginRemote {
async createDeviceState(id: string, setState: (property: string, value: any) => Promise<void>): Promise<any> {
return (await this.init).createDeviceState(id, setState);
}
async getServicePort(name: string): Promise<number> {
return (await this.init).getServicePort(name);
}
}

View File

@@ -7,13 +7,16 @@ import { PluginAPI, PluginLogger, PluginRemote } from './plugin-api';
import { SystemManagerImpl } from './system';
import { RpcPeer } from '../rpc';
import { BufferSerializer } from './buffer-serializer';
import { Console } from 'console';
import { EventEmitter, PassThrough } from 'stream';
import { Writable } from 'node:stream';
class DeviceLogger implements Logger {
nativeId: string;
api: PluginAPI;
logger: Promise<PluginLogger>;
constructor(api: PluginAPI, nativeId: string) {
constructor(api: PluginAPI, nativeId: string, public console: any) {
this.api = api;
this.nativeId = nativeId;
}
@@ -25,7 +28,7 @@ class DeviceLogger implements Logger {
}
async log(level: string, message: string) {
console.log(message);
this.console.log(message);
(await this.ensureLogger()).log(level, message);
}
@@ -135,12 +138,12 @@ class DeviceManagerImpl implements DeviceManager {
nativeIds = new Map<string, DeviceManagerDevice>();
systemManager: SystemManagerImpl;
constructor(systemManager: SystemManagerImpl) {
constructor(systemManager: SystemManagerImpl, public events?: EventEmitter, public getDeviceConsole?: (nativeId?: string) => Console) {
this.systemManager = systemManager;
}
getDeviceLogger(nativeId?: string): Logger {
return new DeviceLogger(this.api, nativeId);
return new DeviceLogger(this.api, nativeId, this.getDeviceConsole?.(nativeId) || console);
}
getDeviceState(nativeId?: any): DeviceState {
@@ -172,27 +175,6 @@ class DeviceManagerImpl implements DeviceManager {
}
}
class PushManagerImpl implements PushManager {
getSubscription(): Promise<PushSubscription> {
throw new Error('Method not implemented.');
}
permissionState(options?: PushSubscriptionOptionsInit): Promise<PushPermissionState> {
throw new Error('Method not implemented.');
}
subscribe(options?: PushSubscriptionOptionsInit): Promise<PushSubscription> {
throw new Error('Method not implemented.');
}
getRegistrationId(): string {
return 'no-registration-id-fix-this';
}
getSenderId(): string {
return 'no-sender-id-fix-this';
}
}
class StorageImpl implements Storage {
api: PluginAPI;
@@ -297,19 +279,25 @@ export async function setupPluginRemote(peer: RpcPeer, api: PluginAPI, pluginId:
return ret;
}
export async function attachPluginRemote(peer: RpcPeer, createMediaManager?: (systemManager: SystemManager) => Promise<MediaManager>): Promise<ScryptedStatic> {
export interface PluginRemoteAttachOptions {
createMediaManager?: (systemManager: SystemManager) => Promise<MediaManager>;
getServicePort?: (name: string) => Promise<number>;
getDeviceConsole?: (nativeId?: string) => Console;
events?: EventEmitter;
}
export function attachPluginRemote(peer: RpcPeer, options?: PluginRemoteAttachOptions): Promise<ScryptedStatic> {
const { createMediaManager, getServicePort, events, getDeviceConsole } = options || {};
peer.addSerializer(Buffer, 'Buffer', new BufferSerializer());
let done: any;
const retPromise = new Promise<ScryptedStatic>((resolve, reject) => {
done = resolve;
});
let done: (scrypted: ScryptedStatic) => void;
const retPromise = new Promise<ScryptedStatic>(resolve => done = resolve);
peer.params.getRemote = async (api: PluginAPI, pluginId: string) => {
const systemManager = new SystemManagerImpl();
const deviceManager = new DeviceManagerImpl(systemManager);
const deviceManager = new DeviceManagerImpl(systemManager, events, getDeviceConsole);
const endpointManager = new EndpointManagerImpl();
const pushManager = new PushManagerImpl();
const ioSockets: { [id: string]: WebSocketCallbacks } = {};
const mediaManager = await api.getMediaManager() || await createMediaManager(systemManager);
@@ -336,6 +324,7 @@ export async function attachPluginRemote(peer: RpcPeer, createMediaManager?: (sy
const localStorage = new StorageImpl(deviceManager, undefined);
const remote: PluginRemote = {
getServicePort,
createDeviceState(id: string, setState: (property: string, value: any) => Promise<void>) {
const handler = new DeviceStateProxyHandler(deviceManager, id, setState);
return new Proxy(handler, handler);
@@ -378,7 +367,7 @@ export async function attachPluginRemote(peer: RpcPeer, createMediaManager?: (sy
}
},
async notify(id: string, eventTime: number, eventInterface: string, property: string, value: SystemDeviceState|any, changed?: boolean) {
async notify(id: string, eventTime: number, eventInterface: string, property: string, value: SystemDeviceState | any, changed?: boolean) {
if (property) {
const state = systemManager.state?.[id];
if (!state) {
@@ -416,7 +405,7 @@ export async function attachPluginRemote(peer: RpcPeer, createMediaManager?: (sy
volume.writeFileSync(name, entry.getData());
}
const params = {
const params: any = {
// legacy
android: {},
@@ -429,7 +418,7 @@ export async function attachPluginRemote(peer: RpcPeer, createMediaManager?: (sy
error,
end
};
connect(undefined, {
close: () => api.ioClose(id),
}, (message: string) => api.ioSend(id, message));
@@ -442,7 +431,7 @@ export async function attachPluginRemote(peer: RpcPeer, createMediaManager?: (sy
error,
end
};
connect(undefined, {
close: () => api.ioClose(id),
}, (message: string) => api.ioSend(id, message));
@@ -463,18 +452,24 @@ export async function attachPluginRemote(peer: RpcPeer, createMediaManager?: (sy
const module = require(name);
return module;
},
pushManager,
deviceManager,
systemManager,
mediaManager,
endpointManager,
log,
localStorage,
zwaveManager: null as any,
pluginHostAPI: api,
};
if (getDeviceConsole) {
params.console = getDeviceConsole(undefined);
}
events?.emit('params', params);
try {
peer.evalLocal(script, '/plugin/main.nodejs.js', params);
events?.emit('plugin', exports.default);
return exports.default;
}
catch (e) {

View File

@@ -170,9 +170,23 @@ export class ScryptedRuntime {
async endpointHandler(req: Request, res: Response, isPublicEndpoint: boolean, isEngineIOEndpoint: boolean,
handler: (req: Request, res: Response, endpointRequest: HttpRequest, pluginHost: PluginHost, pluginDevice: PluginDevice) => void) {
const isUpgrade = !!(req as any).upgradeHead;
const end = (code: number, message: string) => {
if (isUpgrade) {
const socket = res.socket;
socket.write(`HTTP/1.1 ${code} ${message}\r\n` +
'\r\n');
socket.destroy();
}
else {
res.status(code);
res.send(message);
}
};
if (!isPublicEndpoint && !res.locals.username) {
res.status(401);
res.send('Not logged in');
end(401, 'Not Authorized');
return;
}
@@ -192,17 +206,17 @@ export class ScryptedRuntime {
}
const pluginDevice = this.findPluginDevice(endpoint) ?? this.findPluginDeviceById(endpoint);
if (!pluginHost || !pluginDevice) {
if (req.headers.connection?.toLowerCase() === 'upgrade' && (req.headers.upgrade?.toLowerCase() !== 'websocket' || !pluginDevice?.state.interfaces.value.includes(ScryptedInterface.EngineIOHandler))) {
const socket = res.socket;
socket.write('HTTP/1.1 404 Not Found\r\n' +
'\r\n');
socket.destroy();
// check if upgrade requests can be handled. must be websocket.
if (isUpgrade) {
if (req.headers.upgrade?.toLowerCase() !== 'websocket' || !pluginDevice?.state.interfaces.value.includes(ScryptedInterface.EngineIOHandler)) {
end(404, 'Not Found');
return;
}
else if (!pluginDevice?.state.interfaces.value.includes(ScryptedInterface.HttpRequestHandler)) {
res.status(404);
res.send()
}
else {
if (!pluginDevice?.state.interfaces.value.includes(ScryptedInterface.HttpRequestHandler)) {
end(404, 'Not Found');
return;
}
}
@@ -300,7 +314,7 @@ export class ScryptedRuntime {
endpointRequest,
pluginDevice,
};
if (req.headers.upgrade)
if ((req as any).upgradeHead)
pluginHost.io.handleUpgrade(req, res.socket, (req as any).upgradeHead)
else
pluginHost.io.handleRequest(req, res);

View File

@@ -8,7 +8,7 @@ import express from 'express';
import bodyParser from 'body-parser';
import cluster from 'cluster';
import net from 'net';
import { startPluginCluster } from './plugin/plugin-host';
import { startPluginClusterWorker as startPluginRemoteClusterWorker } from './plugin/plugin-host';
import { ScryptedRuntime } from './runtime';
import level from './level';
import { Plugin, ScryptedUser, Settings } from './db-types';
@@ -28,7 +28,7 @@ process.on('unhandledRejection', error => {
if (!cluster.isMaster) {
startPluginCluster();
startPluginRemoteClusterWorker();
}
else {
let workerInspectPort: number = undefined;