Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions .github/workflows/discord-release.yml

This file was deleted.

4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
name: Create Release

on:
pull_request:
pull_request_target:
types: [closed]

jobs:
create-release:
runs-on: ubuntu-latest
if: github.event.pull_request.merged
if: github.event.pull_request.merged && (contains(github.event.pull_request.labels.*.name, 'major') || contains(github.event.pull_request.labels.*.name, 'minor') || contains(github.event.pull_request.labels.*.name, 'patch'))
steps:
- name: Checkout Code
uses: actions/checkout@v4
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
name: Tests

on: [push]
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:

runs-on: ubuntu-latest

steps:
- name: Checkout Code
uses: actions/checkout@v4
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"license": "LGPL-3.0-only",
"type": "module",
"dependencies": {
"@timesplinter/pimple": "^1.1.1",
"@timesplinter/pimple": "^2.0.0",
"buttplug": "^3.2.1",
"class-transformer": "^0.5.1",
"cors": "^2.8.5",
Expand All @@ -22,7 +22,7 @@
"socket.io": "^4.6.0",
"unique-names-generator": "^4.7.1",
"uuid": "^8.3.2",
"vm2": "^3.9.14"
"isolated-vm": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.22.8",
Expand Down Expand Up @@ -58,7 +58,7 @@
"node": ">=18.0.0 <21.0.0"
},
"scripts": {
"compile": "tsc && node dist/index.js --trace-warnings | pino-pretty",
"compile": "tsc && node dist/index.js --trace-warnings --no-node-snapshot | pino-pretty",
"dev": "nodemon",
"test": "jest",
"coverage": "jest --collect-coverage",
Expand Down
136 changes: 83 additions & 53 deletions src/automation/scriptRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,33 @@
import {NodeVM, VMScript} from "vm2";
import Device from "../device/device.js";
import DeviceRepositoryInterface from "../repository/deviceRepositoryInterface.js";
import fs, {WriteStream} from "fs";
import readLastLines from "read-last-lines/dist/index.js";
import EventEmitter from "events";
import AutomationEventType from "./automationEventType.js";
import DeviceManagerEvent from "../device/deviceManagerEvent.js";

type DeviceEvent = { type: string|null, device: Device|null }
type Sandbox = {
devices: DeviceRepositoryInterface,
event: DeviceEvent
context: { [key: string]: string }
}
import ivm, {TransferOptions, ArgumentType} from 'isolated-vm';
import Logger from "../logging/Logger.js";
import ClassToPlainSerializer from "../serialization/classToPlainSerializer.js";

type ScriptRuntimeEvents = {
[AutomationEventType.consoleLog]: (data: string) => void,
[AutomationEventType.scriptStarted]: () => void,
[AutomationEventType.scriptStopped]: () => void,
}

type EventFunction = ivm.Reference<(msg: string) => void>;

export class ScriptRuntime
{
private readonly eventEmitter: EventEmitter;

private scriptCode: VMScript = null;
private scriptCode: ivm.Script = null;

private vm: ivm.Isolate = null;

private vm: NodeVM = null;
private context: ivm.Context = null;

private sandbox: Sandbox;
private eventFunction: EventFunction = null;

private readonly deviceRepository: DeviceRepositoryInterface;

Expand All @@ -38,73 +37,104 @@ export class ScriptRuntime

private runningSince: Date = null;

public constructor(deviceRepository: DeviceRepositoryInterface, logPath: string, eventEmitter: EventEmitter) {
private readonly logger: Logger;

private readonly serializer: ClassToPlainSerializer;

public constructor(
deviceRepository: DeviceRepositoryInterface,
logPath: string,
eventEmitter: EventEmitter,
logger: Logger,
serializer: ClassToPlainSerializer
) {
this.eventEmitter = eventEmitter;
this.deviceRepository = deviceRepository;
this.logPath = logPath;
this.logger = logger.child({name: 'scriptRuntime'});
this.serializer = serializer;
}

public load(scriptCode: string): void
public async load(scriptCode: string): Promise<EventFunction>
{
this.scriptCode = new VMScript(scriptCode);
try {
this.logger.debug(`Instantiate isolate`)
this.vm = new ivm.Isolate({ memoryLimit: 128 /* MB */ , onCatastrophicError: message => console.error(message)});

this.sandbox = {
event: { type: null, device: null },
devices: this.deviceRepository,
context: {},
}

this.vm = new NodeVM({
console: 'redirect',
require: {
external: true,
root: './',
},
sandbox: this.sandbox
});
this.logger.debug(`Create isolate context`)
this.context = await this.vm.createContext();

this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`);

// Get a Reference{} to the global object within the context.
const jail = this.context.global;

// This makes the global object available in the context as `global`. We use `derefInto()` here
// because otherwise `global` would actually be a Reference{} object in the new isolate.
this.logger.debug(`Deref gloabal into jail`)
await jail.set('global', jail.derefInto());

this.logger.debug(`Create shared functions`)

// Create references to the main process functions
const deviceGetByIdRef = new ivm.Reference((uuid: string) => this.deviceRepository.getById(uuid));
const logRef = new ivm.Reference((data: string) => this.log(data));

// Define and expose wrapper functions
const deviceGetByIdWrapper = new ivm.Reference((...args: [uuid: ArgumentType<TransferOptions, string>]) => {
return deviceGetByIdRef.applySync(undefined, args);
});
const logWrapper = new ivm.Reference((...args: any[]) => {
// @ts-expect-error any stuff
return logRef.applySync(undefined, args);
});
const onEventWrapper = new ivm.Reference((...args: [message: ArgumentType<TransferOptions, string>]) => {
return logRef.applySync(undefined, args);
});

await jail.set('deviceGetById', deviceGetByIdWrapper);
await jail.set('log', logWrapper);
await jail.set('onEvent', onEventWrapper);

this.logger.debug(`Create shared functions -> done!`)

this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`)
// Compile and run the user code
this.logger.debug(`Compiling script...`)
this.scriptCode = await this.vm.compileScript(scriptCode);
this.logger.debug(`Compiling script done!`)

this.vm.on('console.log', (data: string) => {
console.log(`VM stdout: ${data}`);
void this.log(data);
this.eventEmitter.emit(AutomationEventType.consoleLog, data);
});
this.runningSince = new Date();

this.runningSince = new Date();
this.eventFunction = this.context.global.getSync('onDeviceEvent') as EventFunction;
this.logger.debug('Run script...')
await this.scriptCode.run(this.context);

this.eventEmitter.emit(AutomationEventType.scriptStarted);
console.log('script loaded')
this.eventEmitter.emit(AutomationEventType.scriptStarted);

return this.eventFunction;
} catch (e: unknown) {
const msg = (e as Error).message;
this.logger.error(`VM stdout: ${msg}`);
void this.log(msg);
this.eventEmitter.emit(AutomationEventType.consoleLog, (e as Error).toString());
}
}

public stop(): void
{
this.vm = null;
this.sandbox = null;
this.logWriter.close();
this.runningSince = null;

this.eventEmitter.emit(AutomationEventType.scriptStopped);
console.log('script stopped')
this.logger.info('script stopped');
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
public runForEvent(eventType: DeviceManagerEvent, device: Device): void
{
if (null === this.vm) {
return;
}

this.sandbox.event.type = eventType;
this.sandbox.event.device = device;

try {
this.vm.run(this.scriptCode);
} catch (e: unknown) {
const msg = (e as Error).message;
console.error(`VM stdout: ${msg}`);
void this.log(msg);
this.eventEmitter.emit(AutomationEventType.consoleLog, (e as Error).toString());
}
this.eventFunction.applySync(undefined, ['Hello world from the outside'], {timeout: 1000});
}

public async getLog(maxLines: number): Promise<string>
Expand Down
9 changes: 6 additions & 3 deletions src/controller/automation/runScriptController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default class RunScriptController implements ControllerInterface
this.scriptRuntime = scriptRuntime;
}

public execute(req: Request, res: Response): void
public async execute(req: Request, res: Response): Promise<void>
{
if(!req.is('text/plain')) {
res.status(400).send('Content-Type header must be text/plain');
Expand All @@ -20,8 +20,11 @@ export default class RunScriptController implements ControllerInterface

const scriptCode = req.body as string;

this.scriptRuntime.load(scriptCode);

try {
await this.scriptRuntime.load(scriptCode);
} catch (e) {
console.error(e);
}
const response = {
running: this.scriptRuntime.isRunning(),
runningSince: this.scriptRuntime.getRunningSince(),
Expand Down
11 changes: 7 additions & 4 deletions src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export default class ButtplugIoDeviceFactory
this.logger = logger;
}

public create(buttplugDevice: ButtplugClientDevice, provider: string): ButtplugIoDevice {
const knownDevice = this.createKnownDevice(buttplugDevice, provider);
public create(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): ButtplugIoDevice {
const knownDevice = this.createKnownDevice(buttplugDevice, provider, useDeviceNameAsId);

const deviceAttrs = ButtplugIoDeviceFactory.parseDeviceAttributes(buttplugDevice);

Expand Down Expand Up @@ -83,10 +83,13 @@ export default class ButtplugIoDeviceFactory
return attributeList;
}

private createKnownDevice(buttplugDevice: ButtplugClientDevice, provider: string): KnownDevice {
private createKnownDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): KnownDevice {
// Since we don't get a unique identifier for the Bluetooth device from Intiface,
// we need to use the index assigned to the device by Intiface. It's the best we have.
const deviceId = `buttplugio-${buttplugDevice.index}`;
// or the name if using Intiface-engine without id persistence
const nameString = buttplugDevice.name.replace(/[^a-zA-Z0-9]/g, '');
const deviceId = useDeviceNameAsId ? `buttplugio-${nameString}` : `buttplugio-${buttplugDevice.index}`;

let knownDevice = this.settings.getKnownDeviceById(deviceId)

if (null !== knownDevice) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,22 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider
private readonly buttplugIoDeviceFactory: ButtplugIoDeviceFactory;

private readonly websocketAddress: string;
private readonly autoScan: boolean;
private readonly useDeviceNameAsId: boolean;

public constructor(
eventEmitter: EventEmitter,
deviceFactory: ButtplugIoDeviceFactory,
websocketAddress: string,
autoScan: boolean,
useDeviceNameAsId: boolean,
logger: Logger
) {
super(eventEmitter, logger.child({ name: 'buttplugIoWebsocketDeviceProvider' }));
this.buttplugIoDeviceFactory = deviceFactory;
this.websocketAddress = websocketAddress;
this.autoScan = autoScan;
this.useDeviceNameAsId = useDeviceNameAsId;
}

public async init(): Promise<void>
Expand All @@ -52,6 +58,10 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider

setInterval(() => { this.connectToServer() }, 3000);

if (true === this.autoScan) {
setInterval(() => { this.discoverButtplugIoDevices() }, 60000);
}

resolve();
});
}
Expand All @@ -68,11 +78,29 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider
.catch((e: unknown) => this.logger.error(`Could not connect to buttplug.io server (${url})`, e));
}


private discoverButtplugIoDevices(): void {
if (false === this.buttplugClient.connected) {
return;
}

this.buttplugClient.startScanning()
.then(() => this.logger.info('Start scanning for Buttplug.io devices'))
.catch((e: unknown) => this.logger.error(`Could not start scanning for buttplug.io devices`, e));

setTimeout(() => {
this.buttplugClient.stopScanning()
.then(() => this.logger.info('Stop scanning for Buttplug.io devices'))
.catch((e: unknown) => this.logger.error(`Could not stop scanning for buttplug.io devices`, e));
}, 30000);
}


private addButtplugIoDevice(buttplugDevice: ButtplugClientDevice): void {
this.logger.info(`Buttplug.io device detected: ${buttplugDevice.name}`, buttplugDevice);

try {
const device = this.buttplugIoDeviceFactory.create(buttplugDevice, ButtplugIoWebsocketDeviceProvider.name);
const device = this.buttplugIoDeviceFactory.create(buttplugDevice, ButtplugIoWebsocketDeviceProvider.name, this.useDeviceNameAsId);
const deviceStatusUpdaterInterval = this.initDeviceStatusUpdater(device);

this.connectedDevices.set(buttplugDevice.index, device);
Expand Down
Loading