5 minute read

The major reimplementation of Scrypted’s management UI in 2024 introduced a way for plugins to expose custom interactive terminals to users. Similar to how Scrypted’s management UI provides shell access via a web terminal (which itself allows for some neat tricks), this feature provides an xterm.js window on the plugin’s device page, allowing plugins to render interactive terminal apps directly to the browser.

Looking to add new terminal apps? Skip to the relevant technical part.

Examples

Some examples of terminal applications in Scrypted.

The most well-known usage of Scrypted’s terminal feature is TerminalService, provided by @scrypted/core. This device connects the browser to an interactive shell and is the backend to the management UI’s main web terminal.

btop, a system monitoring tool, can be installed by @scrypted/btop. Notice that the plugin’s device page renders the terminal app directly, which supports both key strokes and mouse clicks in the browser.

fastfetch, a system information tool, can be installed by @scrypted/fastfetch. Again, loading the plugin’s device page runs the tool, printing its output to the screen.

There are also less serious plugins that use this custom terminal. @bjia56/scrypted-2048 allows users to play the classic 2048 game in the browser. @bjia56/scrypted-pipes-screensaver renders a 2D version of the classic pipes screensaver in the same terminal.

How it works

The new Scrypted management UI added support for two device interfaces: StreamService and TTY. StreamService has existed for some time now, being used as the transit protocol of the web terminal, plugin console, and plugin REPL. The TTY interface is comparatively newer and signals to the management UI that a Scrypted device supports interactive terminals. When both interfaces are present, the UI renders an xterm.js window and calls connectStream (a StreamService function) to create a bidirectional terminal connection, sending application output to the browser and user input to the application.

Adding additional applications

Before packaging an application in a plugin, it’s important to consider the platforms that will be able to run the program. As of this writing, Scrypted’s supported platforms include Linux (x86_64 and arm64), MacOS (x86_64 and arm64), and Windows (x86_64). Ideally, a program should have available distributions for each of these platforms for the broadest user accessibility, but it is not required. If a platform is unsupported, the best practice is for the plugin to state which ones it does support in documentation and display an appropriate error on unsupported platforms.

// sample code to display an unsupported platform alert in the management UI
if (process.platform === "win32") {
    log.a("Windows is not supported");
}

Distributing the application

If the application is a compiled binary, consider downloading it at plugin launch from a trusted location, such as GitHub releases, over HTTPS. The binary can be downloaded to process.env.SCRYPTED_PLUGIN_VOLUME, which contains the root directory of the plugin under Scrypted’s volume directory tree. It’s recommended to name the download file with the host OS and architecture (or otherwise store such information on disk) and check it on plugin startup, to account for users zipping up their volumes directory and moving it to a different host. Additionally, consider storing version or checksum information, so the binary does not need to be redownloaded on every plugin startup while still retaining the ability to update it when a newer version is available.

Scrypted plugins are published as npm packages, so a possible (but not recommended) method is to bundle the application with the plugin bundle. This can be done by placing the application file(s) under the fs directory before building and publishing. After installation, the files will be placed under the following path:

process.env.SCRYPTED_PLUGIN_VOLUME + "/zip/unzipped/fs/" + your file

While this approach works well for platform-agnostic applications (such as scripts), it’s not ideal for compiled binaries, since multiple binaries may need to be included in the same bundle to cover all supported platforms.

Running the application in Scrypted

As mentioned earlier, Scrypted devices must implement the StreamService and TTY devices. The core functionality lies in the connectStream function of StreamService, where implementations must launch the application as a subprocess, create a pty, then connect the pty with the management UI’s stream. It’s also good practice to implement flow control in this stream, so both the terminal application and the management UI are not overloaded with large amounts of data.

This all sounds like a lot of work to get a simple application running. Thankfully, we have an easier way: leverage TerminalService under @scrypted/core to handle this for us! We can simply fetch a reference to TerminalService (which implements StreamService and already provides flow control) through the Scrypted SDK, tell it which program to execute, then bridge the two connectStream functions.

class MyDevice implements StreamService {
    async connectStream(input: AsyncGenerator<any, void>): Promise<AsyncGenerator<any, void>> {
        // get a reference to @scrypted/core
        const core = sdk.systemManager.getDeviceByName<DeviceProvider>("@scrypted/core");
        // get a reference to TerminalService
        const terminalService = await core.getDevice("terminalservice");
        // recommended: connect directly to the device to skip a round trip through Scrypted server
        const terminalServiceDirect = await sdk.connectRPCObject(terminalService);
        // bridge the connection
        return await terminalServiceDirect.connectStream(input, {
            cmd: ["path/to/executable", "arg1", "arg2"]
        });
    }
}

Sometimes, it might be useful for the downloaded application to be added to the user’s PATH for convenient use in the main Scrypted interactive terminal. To do this, implement the optional TTYSettings interface and provide a getTTYSettings function:

class MyDevice implements TTYSettings {
    async function getTTYSettings(): Promise<{
        paths?: string[];
    }> {
        return {
            paths: ["directory/containing/executable"],
        };
    }
}

Finally, the top-level device of the plugin (i.e. the plugin itself) must implement DeviceProvider and return in getDevice a reference to the device in that handles the terminal applications. This is to allow the Scrypted management UI to construct a unique websocket connection for the terminal connection, ensuring the UI remains responsive even when the terminal connection processes a large amount of data. For plugins where only one device exists (i.e. the plugin itself implements StreamService and TTY), it’s sufficient to implement a getDevice function that returns this (or self in Python).

For live examples, check out the source code of @scrypted/btop and @scrypted/fastfetch.

Categories:

Updated: