Packaging terminal apps for Scrypted
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:
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
.