UNPKG

@elgato/streamdeck

Version:

The official Node.js SDK for creating Stream Deck plugins.

1,438 lines (1,410 loc) 111 kB
/**! * @author Elgato * @module elgato/streamdeck * @license MIT * @copyright Copyright (c) Corsair Memory Inc. */ export { BarSubType, DeviceType } from '@elgato/schemas/streamdeck/plugins'; import WebSocket from 'ws'; import path, { join } from 'node:path'; import { cwd } from 'node:process'; import fs, { existsSync, readFileSync } from 'node:fs'; /** * Languages supported by Stream Deck. */ const supportedLanguages = ["de", "en", "es", "fr", "ja", "ko", "zh_CN", "zh_TW"]; /** * Defines the type of argument supplied by Stream Deck. */ var RegistrationParameter; (function (RegistrationParameter) { /** * Identifies the argument that specifies the web socket port that Stream Deck is listening on. */ RegistrationParameter["Port"] = "-port"; /** * Identifies the argument that supplies information about the Stream Deck and the plugin. */ RegistrationParameter["Info"] = "-info"; /** * Identifies the argument that specifies the unique identifier that can be used when registering the plugin. */ RegistrationParameter["PluginUUID"] = "-pluginUUID"; /** * Identifies the argument that specifies the event to be sent to Stream Deck as part of the registration procedure. */ RegistrationParameter["RegisterEvent"] = "-registerEvent"; })(RegistrationParameter || (RegistrationParameter = {})); /** * Defines the target of a request, i.e. whether the request should update the Stream Deck hardware, Stream Deck software (application), or both, when calling `setImage` and `setState`. */ var Target; (function (Target) { /** * Hardware and software should be updated as part of the request. */ Target[Target["HardwareAndSoftware"] = 0] = "HardwareAndSoftware"; /** * Hardware only should be updated as part of the request. */ Target[Target["Hardware"] = 1] = "Hardware"; /** * Software only should be updated as part of the request. */ Target[Target["Software"] = 2] = "Software"; })(Target || (Target = {})); /** * Prevents the modification of existing property attributes and values on the value, and all of its child properties, and prevents the addition of new properties. * @param value Value to freeze. */ function freeze(value) { if (value !== undefined && value !== null && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); Object.values(value).forEach(freeze); } } /** * Gets the value at the specified {@link path}. * @param path Path to the property to get. * @param source Source object that is being read from. * @returns Value of the property. */ function get(path, source) { const props = path.split("."); return props.reduce((obj, prop) => obj && obj[prop], source); } /** * Internalization provider, responsible for managing localizations and translating resources. */ class I18nProvider { language; readTranslations; /** * Default language to be used when a resource does not exist for the desired language. */ static DEFAULT_LANGUAGE = "en"; /** * Map of localized resources, indexed by their language. */ _translations = new Map(); /** * Initializes a new instance of the {@link I18nProvider} class. * @param language The default language to be used when retrieving translations for a given key. * @param readTranslations Function responsible for loading translations. */ constructor(language, readTranslations) { this.language = language; this.readTranslations = readTranslations; } /** * Translates the specified {@link key}, as defined within the resources for the {@link language}. When the key is not found, the default language is checked. * * Alias of `I18nProvider.translate(string, Language)` * @param key Key of the translation. * @param language Optional language to get the translation for; otherwise the default language. * @returns The translation; otherwise the key. */ t(key, language = this.language) { return this.translate(key, language); } /** * Translates the specified {@link key}, as defined within the resources for the {@link language}. When the key is not found, the default language is checked. * @param key Key of the translation. * @param language Optional language to get the translation for; otherwise the default language. * @returns The translation; otherwise the key. */ translate(key, language = this.language) { // When the language and default are the same, only check the language. if (language === I18nProvider.DEFAULT_LANGUAGE) { return get(key, this.getTranslations(language))?.toString() || key; } // Otherwise check the language and default. return (get(key, this.getTranslations(language))?.toString() || get(key, this.getTranslations(I18nProvider.DEFAULT_LANGUAGE))?.toString() || key); } /** * Gets the translations for the specified language. * @param language Language whose translations are being retrieved. * @returns The translations, otherwise `null`. */ getTranslations(language) { let translations = this._translations.get(language); if (translations === undefined) { translations = supportedLanguages.includes(language) ? this.readTranslations(language) : null; freeze(translations); this._translations.set(language, translations); } return translations; } } /** * Parses the localizations from the specified contents, or throws a `TypeError` when unsuccessful. * @param contents Contents that represent the stringified JSON containing the localizations. * @returns The localizations; otherwise a `TypeError`. */ function parseLocalizations(contents) { const json = JSON.parse(contents); if (json !== undefined && json !== null && typeof json === "object" && "Localization" in json) { return json["Localization"]; } throw new TypeError(`Translations must be a JSON object nested under a property named "Localization"`); } /** * Levels of logging. */ var LogLevel; (function (LogLevel) { /** * Error message used to indicate an error was thrown, or something critically went wrong. */ LogLevel[LogLevel["ERROR"] = 0] = "ERROR"; /** * Warning message used to indicate something went wrong, but the application is able to recover. */ LogLevel[LogLevel["WARN"] = 1] = "WARN"; /** * Information message for general usage. */ LogLevel[LogLevel["INFO"] = 2] = "INFO"; /** * Debug message used to detail information useful for profiling the applications runtime. */ LogLevel[LogLevel["DEBUG"] = 3] = "DEBUG"; /** * Trace message used to monitor low-level information such as method calls, performance tracking, etc. */ LogLevel[LogLevel["TRACE"] = 4] = "TRACE"; })(LogLevel || (LogLevel = {})); /** * Provides a {@link LogTarget} that logs to the console. */ class ConsoleTarget { /** * @inheritdoc */ write(entry) { switch (entry.level) { case LogLevel.ERROR: console.error(...entry.data); break; case LogLevel.WARN: console.warn(...entry.data); break; default: console.log(...entry.data); } } } // Remove any dependencies on node. const EOL = "\n"; /** * Creates a new string log entry formatter. * @param opts Options that defines the type for the formatter. * @returns The string {@link LogEntryFormatter}. */ function stringFormatter(opts) { { return (entry) => { const { data, level, scope } = entry; let prefix = `${new Date().toISOString()} ${LogLevel[level].padEnd(5)} `; if (scope) { prefix += `${scope}: `; } return `${prefix}${reduce(data)}`; }; } } /** * Stringifies the provided data parameters that make up the log entry. * @param data Data parameters. * @returns The data represented as a single `string`. */ function reduce(data) { let result = ""; let previousWasError = false; for (const value of data) { // When the value is an error, write the stack. if (typeof value === "object" && value instanceof Error) { result += `${EOL}${value.stack}`; previousWasError = true; continue; } // When the previous was an error, write a new line. if (previousWasError) { result += EOL; previousWasError = false; } result += typeof value === "object" ? JSON.stringify(value) : value; result += " "; } return result.trimEnd(); } /** * Logger capable of forwarding messages to a {@link LogTarget}. */ class Logger { /** * Backing field for the {@link Logger.level}. */ _level; /** * Options that define the loggers behavior. */ options; /** * Scope associated with this {@link Logger}. */ scope; /** * Initializes a new instance of the {@link Logger} class. * @param opts Options that define the loggers behavior. */ constructor(opts) { this.options = { minimumLevel: LogLevel.TRACE, ...opts }; this.scope = this.options.scope === undefined || this.options.scope.trim() === "" ? "" : this.options.scope; if (typeof this.options.level !== "function") { this.setLevel(this.options.level); } } /** * Gets the {@link LogLevel}. * @returns The {@link LogLevel}. */ get level() { if (this._level !== undefined) { return this._level; } return typeof this.options.level === "function" ? this.options.level() : this.options.level; } /** * Creates a scoped logger with the given {@link scope}; logs created by scoped-loggers include their scope to enable their source to be easily identified. * @param scope Value that represents the scope of the new logger. * @returns The scoped logger, or this instance when {@link scope} is not defined. */ createScope(scope) { scope = scope.trim(); if (scope === "") { return this; } return new Logger({ ...this.options, level: () => this.level, scope: this.options.scope ? `${this.options.scope}->${scope}` : scope, }); } /** * Writes the arguments as a debug log entry. * @param data Message or data to log. * @returns This instance for chaining. */ debug(...data) { return this.write({ level: LogLevel.DEBUG, data, scope: this.scope }); } /** * Writes the arguments as error log entry. * @param data Message or data to log. * @returns This instance for chaining. */ error(...data) { return this.write({ level: LogLevel.ERROR, data, scope: this.scope }); } /** * Writes the arguments as an info log entry. * @param data Message or data to log. * @returns This instance for chaining. */ info(...data) { return this.write({ level: LogLevel.INFO, data, scope: this.scope }); } /** * Sets the log-level that determines which logs should be written. The specified level will be inherited by all scoped loggers unless they have log-level explicitly defined. * @param level The log-level that determines which logs should be written; when `undefined`, the level will be inherited from the parent logger, or default to the environment level. * @returns This instance for chaining. */ setLevel(level) { if (level !== undefined && level > this.options.minimumLevel) { this._level = LogLevel.INFO; this.warn(`Log level cannot be set to ${LogLevel[level]} whilst not in debug mode.`); } else { this._level = level; } return this; } /** * Writes the arguments as a trace log entry. * @param data Message or data to log. * @returns This instance for chaining. */ trace(...data) { return this.write({ level: LogLevel.TRACE, data, scope: this.scope }); } /** * Writes the arguments as a warning log entry. * @param data Message or data to log. * @returns This instance for chaining. */ warn(...data) { return this.write({ level: LogLevel.WARN, data, scope: this.scope }); } /** * Writes the log entry. * @param entry Log entry to write. * @returns This instance for chaining. */ write(entry) { if (entry.level <= this.level) { this.options.targets.forEach((t) => t.write(entry)); } return this; } } // Polyfill, explicit resource management https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management // eslint-disable-next-line @typescript-eslint/no-explicit-any Symbol.dispose ??= Symbol("Symbol.dispose"); /** * Creates a {@link IDisposable} that defers the disposing to the {@link dispose} function; disposing is guarded so that it may only occur once. * @param dispose Function responsible for disposing. * @returns Disposable whereby the disposing is delegated to the {@link dispose} function. */ function deferredDisposable(dispose) { let isDisposed = false; const guardedDispose = () => { if (!isDisposed) { dispose(); isDisposed = true; } }; return { [Symbol.dispose]: guardedDispose, dispose: guardedDispose, }; } /** * An event emitter that enables the listening for, and emitting of, events. */ class EventEmitter { /** * Underlying collection of events and their listeners. */ events = new Map(); /** * Adds the event {@link listener} for the event named {@link eventName}. * @param eventName Name of the event. * @param listener Event handler function. * @returns This instance with the {@link listener} added. */ addListener(eventName, listener) { return this.on(eventName, listener); } /** * Adds the event {@link listener} for the event named {@link eventName}, and returns a disposable capable of removing the event listener. * @param eventName Name of the event. * @param listener Event handler function. * @returns A disposable that removes the listener when disposed. */ disposableOn(eventName, listener) { this.addListener(eventName, listener); return deferredDisposable(() => this.removeListener(eventName, listener)); } /** * Emits the {@link eventName}, invoking all event listeners with the specified {@link args}. * @param eventName Name of the event. * @param args Arguments supplied to each event listener. * @returns `true` when there was a listener associated with the event; otherwise `false`. */ emit(eventName, ...args) { const listeners = this.events.get(eventName); if (listeners === undefined) { return false; } for (let i = 0; i < listeners.length;) { const { listener, once } = listeners[i]; if (once) { listeners.splice(i, 1); } else { i++; } listener(...args); } return true; } /** * Gets the event names with event listeners. * @returns Event names. */ eventNames() { return Array.from(this.events.keys()); } /** * Gets the number of event listeners for the event named {@link eventName}. When a {@link listener} is defined, only matching event listeners are counted. * @param eventName Name of the event. * @param listener Optional event listener to count. * @returns Number of event listeners. */ listenerCount(eventName, listener) { const listeners = this.events.get(eventName); if (listeners === undefined || listener == undefined) { return listeners?.length || 0; } let count = 0; listeners.forEach((ev) => { if (ev.listener === listener) { count++; } }); return count; } /** * Gets the event listeners for the event named {@link eventName}. * @param eventName Name of the event. * @returns The event listeners. */ listeners(eventName) { return Array.from(this.events.get(eventName) || []).map(({ listener }) => listener); } /** * Removes the event {@link listener} for the event named {@link eventName}. * @param eventName Name of the event. * @param listener Event handler function. * @returns This instance with the event {@link listener} removed. */ off(eventName, listener) { const listeners = this.events.get(eventName) || []; for (let i = listeners.length - 1; i >= 0; i--) { if (listeners[i].listener === listener) { listeners.splice(i, 1); } } return this; } /** * Adds the event {@link listener} for the event named {@link eventName}. * @param eventName Name of the event. * @param listener Event handler function. * @returns This instance with the event {@link listener} added. */ on(eventName, listener) { return this.add(eventName, (listeners) => listeners.push({ listener })); } /** * Adds the **one-time** event {@link listener} for the event named {@link eventName}. * @param eventName Name of the event. * @param listener Event handler function. * @returns This instance with the event {@link listener} added. */ once(eventName, listener) { return this.add(eventName, (listeners) => listeners.push({ listener, once: true })); } /** * Adds the event {@link listener} to the beginning of the listeners for the event named {@link eventName}. * @param eventName Name of the event. * @param listener Event handler function. * @returns This instance with the event {@link listener} prepended. */ prependListener(eventName, listener) { return this.add(eventName, (listeners) => listeners.splice(0, 0, { listener })); } /** * Adds the **one-time** event {@link listener} to the beginning of the listeners for the event named {@link eventName}. * @param eventName Name of the event. * @param listener Event handler function. * @returns This instance with the event {@link listener} prepended. */ prependOnceListener(eventName, listener) { return this.add(eventName, (listeners) => listeners.splice(0, 0, { listener, once: true })); } /** * Removes all event listeners for the event named {@link eventName}. * @param eventName Name of the event. * @returns This instance with the event listeners removed */ removeAllListeners(eventName) { this.events.delete(eventName); return this; } /** * Removes the event {@link listener} for the event named {@link eventName}. * @param eventName Name of the event. * @param listener Event handler function. * @returns This instance with the event {@link listener} removed. */ removeListener(eventName, listener) { return this.off(eventName, listener); } /** * Adds the event {@link listener} for the event named {@link eventName}. * @param eventName Name of the event. * @param fn Function responsible for adding the new event handler function. * @returns This instance with event {@link listener} added. */ add(eventName, fn) { let listeners = this.events.get(eventName); if (listeners === undefined) { listeners = []; this.events.set(eventName, listeners); } fn(listeners); return this; } } /** * Determines whether the specified {@link value} is a {@link RawMessageResponse}. * @param value Value. * @returns `true` when the value of a {@link RawMessageResponse}; otherwise `false`. */ function isRequest(value) { return isMessage(value, "request") && has(value, "unidirectional", "boolean"); } /** * Determines whether the specified {@link value} is a {@link RawMessageResponse}. * @param value Value. * @returns `true` when the value of a {@link RawMessageResponse; otherwise `false`. */ function isResponse(value) { return isMessage(value, "response") && has(value, "status", "number"); } /** * Determines whether the specified {@link value} is a message of type {@link type}. * @param value Value. * @param type Message type. * @returns `true` when the value of a {@link Message} of type {@link type}; otherwise `false`. */ function isMessage(value, type) { // The value should be an object. if (value === undefined || value === null || typeof value !== "object") { return false; } // The value should have a __type property of "response". if (!("__type" in value) || value.__type !== type) { return false; } // The value should should have at least an id, status, and path1. return has(value, "id", "string") && has(value, "path", "string"); } /** * Determines whether the specified {@link key} exists in {@link obj}, and is typeof {@link type}. * @param obj Object to check. * @param key key to check for. * @param type Expected type. * @returns `true` when the {@link key} exists in the {@link obj}, and is typeof {@link type}. */ function has(obj, key, type) { return key in obj && typeof obj[key] === type; } /** * Message responder responsible for responding to a request. */ class MessageResponder { request; proxy; /** * Indicates whether a response has already been sent in relation to the response. */ _responded = false; /** * Initializes a new instance of the {@link MessageResponder} class. * @param request The request the response is associated with. * @param proxy Proxy responsible for forwarding the response to the client. */ constructor(request, proxy) { this.request = request; this.proxy = proxy; } /** * Indicates whether a response can be sent. * @returns `true` when a response has not yet been set. */ get canRespond() { return !this._responded; } /** * Sends a failure response with a status code of `500`. * @param body Optional response body. * @returns Promise fulfilled once the response has been sent. */ fail(body) { return this.send(500, body); } /** * Sends the {@link body} as a response with the {@link status} * @param status Response status. * @param body Optional response body. * @returns Promise fulfilled once the response has been sent. */ async send(status, body) { if (this.canRespond) { await this.proxy({ __type: "response", id: this.request.id, path: this.request.path, body, status, }); this._responded = true; } } /** * Sends a success response with a status code of `200`. * @param body Optional response body. * @returns Promise fulfilled once the response has been sent. */ success(body) { return this.send(200, body); } } /** * Default request timeout. */ const DEFAULT_TIMEOUT = 5000; const PUBLIC_PATH_PREFIX = "public:"; const INTERNAL_PATH_PREFIX = "internal:"; /** * Message gateway responsible for sending, routing, and receiving requests and responses. */ class MessageGateway extends EventEmitter { proxy; actionProvider; /** * Requests with pending responses. */ requests = new Map(); /** * Registered routes, and their respective handlers. */ routes = new EventEmitter(); /** * Initializes a new instance of the {@link MessageGateway} class. * @param proxy Proxy capable of sending messages to the plugin / property inspector. * @param actionProvider Action provider responsible for retrieving actions associated with source messages. */ constructor(proxy, actionProvider) { super(); this.proxy = proxy; this.actionProvider = actionProvider; } /** * Sends the {@link requestOrPath} to the server; the server should be listening on {@link MessageGateway.route}. * @param requestOrPath The request, or the path of the request. * @param bodyOrUndefined Request body, or moot when constructing the request with {@link MessageRequestOptions}. * @returns The response. */ async fetch(requestOrPath, bodyOrUndefined) { const id = crypto.randomUUID(); const { body, path, timeout = DEFAULT_TIMEOUT, unidirectional = false, } = typeof requestOrPath === "string" ? { body: bodyOrUndefined, path: requestOrPath } : requestOrPath; // Initialize the response handler. const response = new Promise((resolve) => { this.requests.set(id, (res) => { if (res.status !== 408) { clearTimeout(timeoutMonitor); } resolve(res); }); }); // Start the timeout, and send the request. const timeoutMonitor = setTimeout(() => this.handleResponse({ __type: "response", id, path, status: 408 }), timeout); const accepted = await this.proxy({ __type: "request", body, id, path, unidirectional, }); // When the server did not accept the request, return a 406. if (!accepted) { this.handleResponse({ __type: "response", id, path, status: 406 }); } return response; } /** * Attempts to process the specified {@link message}. * @param message Message to process. * @returns `true` when the {@link message} was processed by this instance; otherwise `false`. */ async process(message) { if (isRequest(message.payload)) { // Server-side handling. const action = this.actionProvider(message); if (await this.handleRequest(action, message.payload)) { return; } this.emit("unhandledRequest", message); } else if (isResponse(message.payload) && this.handleResponse(message.payload)) { // Response handled successfully. return; } this.emit("unhandledMessage", message); } /** * Maps the specified {@link path} to the {@link handler}, allowing for requests from the client. * @param path Path used to identify the route. * @param handler Handler to be invoked when the request is received. * @param options Optional routing configuration. * @returns Disposable capable of removing the route handler. */ route(path, handler, options) { options = { filter: () => true, ...options }; return this.routes.disposableOn(path, async (ev) => { if (options?.filter && options.filter(ev.request.action)) { await ev.routed(); try { // Invoke the handler; when data was returned, propagate it as part of the response (if there wasn't already a response). const result = await handler(ev.request, ev.responder); if (result !== undefined) { await ev.responder.send(200, result); } } catch (err) { // Respond with an error before throwing. await ev.responder.send(500); throw err; } } }); } /** * Handles inbound requests. * @param action Action associated with the request. * @param source The request. * @returns `true` when the request was handled; otherwise `false`. */ async handleRequest(action, source) { const responder = new MessageResponder(source, this.proxy); const request = { action, path: source.path, unidirectional: source.unidirectional, body: source.body, }; // Get handlers of the path, and invoke them; filtering is applied by the handlers themselves let routed = false; const routes = this.routes.listeners(source.path); for (const route of routes) { await route({ request, responder, routed: async () => { // Flags the path as handled, sending an immediate 202 if the request was unidirectional. if (request.unidirectional) { await responder.send(202); } routed = true; }, }); } // The request was successfully routed, so fallback to a 200. if (routed) { await responder.send(200); return true; } // When there were no applicable routes, return not-handled. await responder.send(501); return false; } /** * Handles inbound response. * @param res The response. * @returns `true` when the response was handled; otherwise `false`. */ handleResponse(res) { const handler = this.requests.get(res.id); this.requests.delete(res.id); // Determine if there is a request pending a response. if (handler) { handler(new MessageResponse(res)); return true; } return false; } } /** * Message response, received from the server. */ class MessageResponse { /** * Body of the response. */ body; /** * Status of the response. * - `200` the request was successful. * - `202` the request was unidirectional, and does not have a response. * - `406` the request could not be accepted by the server. * - `408` the request timed-out. * - `500` the request failed. * - `501` the request is not implemented by the server, and could not be fulfilled. */ status; /** * Initializes a new instance of the {@link MessageResponse} class. * @param res The status code, or the response. */ constructor(res) { this.body = res.body; this.status = res.status; } /** * Indicates whether the request was successful. * @returns `true` when the status indicates a success; otherwise `false`. */ get ok() { return this.status >= 200 && this.status < 300; } } const LOGGER_WRITE_PATH = `${INTERNAL_PATH_PREFIX}logger.write`; /** * Registers a route handler on the router, propagating any log entries to the specified logger for writing. * @param router Router to receive inbound log entries on. * @param logger Logger responsible for logging log entries. */ function registerCreateLogEntryRoute(router, logger) { router.route(LOGGER_WRITE_PATH, (req, res) => { if (req.body === undefined) { return res.fail(); } const { level, message, scope } = req.body; if (level === undefined) { return res.fail(); } logger.write({ level, data: [message], scope }); return res.success(); }); } /** * Provides information for events received from Stream Deck. */ class Event { /** * Event that occurred. */ type; /** * Initializes a new instance of the {@link Event} class. * @param source Source of the event, i.e. the original message from Stream Deck. */ constructor(source) { this.type = source.event; } } /** * Provides information for an event relating to an action. */ class ActionWithoutPayloadEvent extends Event { action; /** * Initializes a new instance of the {@link ActionWithoutPayloadEvent} class. * @param action Action that raised the event. * @param source Source of the event, i.e. the original message from Stream Deck. */ constructor(action, source) { super(source); this.action = action; } } /** * Provides information for an event relating to an action. */ class ActionEvent extends ActionWithoutPayloadEvent { /** * Provides additional information about the event that occurred, e.g. how many `ticks` the dial was rotated, the current `state` of the action, etc. */ payload; /** * Initializes a new instance of the {@link ActionEvent} class. * @param action Action that raised the event. * @param source Source of the event, i.e. the original message from Stream Deck. */ constructor(action, source) { super(action, source); this.payload = source.payload; } } /** * Provides event information for when the plugin received the global settings. */ class DidReceiveGlobalSettingsEvent extends Event { /** * Settings associated with the event. */ settings; /** * Initializes a new instance of the {@link DidReceiveGlobalSettingsEvent} class. * @param source Source of the event, i.e. the original message from Stream Deck. */ constructor(source) { super(source); this.settings = source.payload.settings; } } /** * Provides a wrapper around a value that is lazily instantiated. */ class Lazy { /** * Private backing field for {@link Lazy.value}. */ #value = undefined; /** * Factory responsible for instantiating the value. */ #valueFactory; /** * Initializes a new instance of the {@link Lazy} class. * @param valueFactory The factory responsible for instantiating the value. */ constructor(valueFactory) { this.#valueFactory = valueFactory; } /** * Gets the value. * @returns The value. */ get value() { if (this.#value === undefined) { this.#value = this.#valueFactory(); } return this.#value; } } /** * Wraps an underlying Promise{T}, exposing the resolve and reject delegates as methods, allowing for it to be awaited, resolved, or rejected externally. */ class PromiseCompletionSource { /** * The underlying promise that this instance is managing. */ _promise; /** * Delegate used to reject the promise. */ _reject; /** * Delegate used to resolve the promise. */ _resolve; /** * Wraps an underlying Promise{T}, exposing the resolve and reject delegates as methods, allowing for it to be awaited, resolved, or rejected externally. */ constructor() { this._promise = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; }); } /** * Gets the underlying promise being managed by this instance. * @returns The promise. */ get promise() { return this._promise; } /** * Rejects the promise, causing any awaited calls to throw. * @param reason The reason for rejecting the promise. */ setException(reason) { if (this._reject) { this._reject(reason); } } /** * Sets the result of the underlying promise, allowing any awaited calls to continue invocation. * @param value The value to resolve the promise with. */ setResult(value) { if (this._resolve) { this._resolve(value); } } } /** * Provides information for a version, as parsed from a string denoted as a collection of numbers separated by a period, for example `1.45.2`, `4.0.2.13098`. Parsing is opinionated * and strings should strictly conform to the format `{major}[.{minor}[.{patch}[.{build}]]]`; version numbers that form the version are optional, and when `undefined` will default to * 0, for example the `minor`, `patch`, or `build` number may be omitted. * * NB: This implementation should be considered fit-for-purpose, and should be used sparing. */ class Version { /** * Build version number. */ build; /** * Major version number. */ major; /** * Minor version number. */ minor; /** * Patch version number. */ patch; /** * Initializes a new instance of the {@link Version} class. * @param value Value to parse the version from. */ constructor(value) { const result = value.match(/^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?$/); if (result === null) { throw new Error(`Invalid format; expected "{major}[.{minor}[.{patch}[.{build}]]]" but was "${value}"`); } [, this.major, this.minor, this.patch, this.build] = [...result.map((value) => parseInt(value) || 0)]; } /** * Compares this instance to the {@link other} {@link Version}. * @param other The {@link Version} to compare to. * @returns `-1` when this instance is less than the {@link other}, `1` when this instance is greater than {@link other}, otherwise `0`. */ compareTo(other) { const segments = ({ major, minor, build, patch }) => [major, minor, build, patch]; const thisSegments = segments(this); const otherSegments = segments(other); for (let i = 0; i < 4; i++) { if (thisSegments[i] < otherSegments[i]) { return -1; } else if (thisSegments[i] > otherSegments[i]) { return 1; } } return 0; } /** @inheritdoc */ toString() { return `${this.major}.${this.minor}`; } } let __isDebugMode = undefined; /** * Determines whether the current plugin is running in a debug environment; this is determined by the command-line arguments supplied to the plugin by Stream. Specifically, the result * is `true` when either `--inspect`, `--inspect-brk` or `--inspect-port` are present as part of the processes' arguments. * @returns `true` when the plugin is running in debug mode; otherwise `false`. */ function isDebugMode() { if (__isDebugMode === undefined) { __isDebugMode = process.execArgv.some((arg) => { const name = arg.split("=")[0]; return name === "--inspect" || name === "--inspect-brk" || name === "--inspect-port"; }); } return __isDebugMode; } /** * Gets the plugin's unique-identifier from the current working directory. * @returns The plugin's unique-identifier. */ function getPluginUUID() { const name = path.basename(process.cwd()); const suffixIndex = name.lastIndexOf(".sdPlugin"); return suffixIndex < 0 ? name : name.substring(0, suffixIndex); } /** * Provides a {@link LogTarget} capable of logging to a local file system. */ class FileTarget { options; /** * File path where logs will be written. */ filePath; /** * Current size of the logs that have been written to the {@link FileTarget.filePath}. */ size = 0; /** * Initializes a new instance of the {@link FileTarget} class. * @param options Options that defines how logs should be written to the local file system. */ constructor(options) { this.options = options; this.filePath = this.getLogFilePath(); this.reIndex(); } /** * @inheritdoc */ write(entry) { const fd = fs.openSync(this.filePath, "a"); try { const msg = this.options.format(entry); fs.writeSync(fd, msg + "\n"); this.size += msg.length; } finally { fs.closeSync(fd); } if (this.size >= this.options.maxSize) { this.reIndex(); this.size = 0; } } /** * Gets the file path to an indexed log file. * @param index Optional index of the log file to be included as part of the file name. * @returns File path that represents the indexed log file. */ getLogFilePath(index = 0) { return path.join(this.options.dest, `${this.options.fileName}.${index}.log`); } /** * Gets the log files associated with this file target, including past and present. * @returns Log file entries. */ getLogFiles() { const regex = /^\.(\d+)\.log$/; return fs .readdirSync(this.options.dest, { withFileTypes: true }) .reduce((prev, entry) => { if (entry.isDirectory() || entry.name.indexOf(this.options.fileName) < 0) { return prev; } const match = entry.name.substring(this.options.fileName.length).match(regex); if (match?.length !== 2) { return prev; } prev.push({ path: path.join(this.options.dest, entry.name), index: parseInt(match[1]), }); return prev; }, []) .sort(({ index: a }, { index: b }) => { return a < b ? -1 : a > b ? 1 : 0; }); } /** * Re-indexes the existing log files associated with this file target, removing old log files whose index exceeds the {@link FileTargetOptions.maxFileCount}, and renaming the * remaining log files, leaving index "0" free for a new log file. */ reIndex() { // When the destination directory is new, create it, and return. if (!fs.existsSync(this.options.dest)) { fs.mkdirSync(this.options.dest); return; } const logFiles = this.getLogFiles(); for (let i = logFiles.length - 1; i >= 0; i--) { const log = logFiles[i]; if (i >= this.options.maxFileCount - 1) { fs.rmSync(log.path); } else { fs.renameSync(log.path, this.getLogFilePath(i + 1)); } } } } // Log all entires to a log file. const fileTarget = new FileTarget({ dest: path.join(cwd(), "logs"), fileName: getPluginUUID(), format: stringFormatter(), maxFileCount: 10, maxSize: 50 * 1024 * 1024, }); // Construct the log targets. const targets = [fileTarget]; if (isDebugMode()) { targets.splice(0, 0, new ConsoleTarget()); } /** * Logger responsible for capturing log messages. */ const logger = new Logger({ level: isDebugMode() ? LogLevel.DEBUG : LogLevel.INFO, minimumLevel: isDebugMode() ? LogLevel.TRACE : LogLevel.DEBUG, targets, }); process.once("uncaughtException", (err) => logger.error("Process encountered uncaught exception", err)); /** * Provides a connection between the plugin and the Stream Deck allowing for messages to be sent and received. */ class Connection extends EventEmitter { /** * Private backing field for {@link Connection.registrationParameters}. */ _registrationParameters; /** * Private backing field for {@link Connection.version}. */ _version; /** * Used to ensure {@link Connection.connect} is invoked as a singleton; `false` when a connection is occurring or established. */ canConnect = true; /** * Underlying web socket connection. */ connection = new PromiseCompletionSource(); /** * Logger scoped to the connection. */ logger = logger.createScope("Connection"); /** * Underlying connection information provided to the plugin to establish a connection with Stream Deck. * @returns The registration parameters. */ get registrationParameters() { return (this._registrationParameters ??= this.getRegistrationParameters()); } /** * Version of Stream Deck this instance is connected to. * @returns The version. */ get version() { return (this._version ??= new Version(this.registrationParameters.info.application.version)); } /** * Establishes a connection with the Stream Deck, allowing for the plugin to send and receive messages. * @returns A promise that is resolved when a connection has been established. */ async connect() { // Ensure we only establish a single connection. if (this.canConnect) { this.canConnect = false; const webSocket = new WebSocket(`ws://127.0.0.1:${this.registrationParameters.port}`); webSocket.onmessage = (ev) => this.tryEmit(ev); webSocket.onopen = () => { webSocket.send(JSON.stringify({ event: this.registrationParameters.registerEvent, uuid: this.registrationParameters.pluginUUID, })); // Web socket established a connection with the Stream Deck and the plugin was registered. this.connection.setResult(webSocket); this.emit("connected", this.registrationParameters.info); }; } await this.connection.promise; } /** * Sends the commands to the Stream Deck, once the connection has been established and registered. * @param command Command being sent. * @returns `Promise` resolved when the command is sent to Stream Deck. */ async send(command) { const connection = await this.connection.promise; const message = JSON.stringify(command); this.logger.trace(message); connection.send(message); } /** * Gets the registration parameters, provided by Stream Deck, that provide information to the plugin, including how to establish a connection. * @returns Parsed registration parameters. */ getRegistrationParameters() { const params = { port: undefined, info: undefined, pluginUUID: undefined, registerEvent: undefined, }; const scopedLogger = logger.createScope("RegistrationParameters"); for (let i = 0; i < process.argv.length - 1; i++) { const param = process.argv[i]; const value = process.argv[++i]; switch (param) { case RegistrationParameter.Port: scopedLogger.debug(`port=${value}`); params.port = value; break; case RegistrationParameter.PluginUUID: scopedLogger.debug(`pluginUUID=${value}`); params.pluginUUID = value; break; case RegistrationParameter.RegisterEvent: scopedLogger.debug(`registerEvent=${value}`); params.registerEvent = value; break; case RegistrationParameter.Info: scopedLogger.debug(`info=${value}`); params.info = JSON.parse(value); break; default: i--; break; } } const invalidArgs = []; const validate = (name, value) => { if (value === undefined) { invalidArgs.push(name); } }; validate(RegistrationParameter.Port, params.port); validate(RegistrationParameter.PluginUUID, params.pluginUUID); validate(RegistrationParameter.RegisterEvent, params.registerEvent); validate(RegistrationParameter.Info, params.info); if (invalidArgs.length > 0) { throw new Error(`Unable to establish a connection with Stream Deck, missing command line arguments: ${invalidArgs.join(", ")}`); } return params; } /** * Attempts to emit the {@link ev} that was received from the {@link Connection.connection}. * @param ev Event message data received from Stream Deck. */ tryEmit(ev) { try { const message = JSON.parse(ev.data.toString()); if (message.event) { this.logger.trace(ev.data.toString()); this.emit(message.event, message); } else { this.logger.warn(`Received unknown message: ${ev.data}`); } } catch (err) { this.logger.error(`Failed to parse message: ${ev.data}`, err); } } } const connection = new Connection(); let manifest$1; let softwareMinimumVersion; /** * Gets the minimum version that this plugin required, as defined within the manifest. * @returns Minimum required version. */ function getSoftwareMinimumVersion() { return (softwareMinimumVersion ??= new Version(getManifest().Software.MinimumVersion)); } /** * Gets the manifest associated with the plugin. * @returns The manifest. */ function getManifest() { return (manifest$1 ??= readManifest()); } /** * Reads the manifest associated with the plugin from the `manifest.json` file. * @returns The manifest. */ function readManifest() { const path = join(process.cwd(), "manifest.json"); if (!existsSync(path)) { throw new Error("Failed to read manifest.json as the file does not exist."); } return JSON.parse(readFileSync(path, { encoding: "utf-8", flag: "r", }).toString()); } /** * Provides a read-only iterable collection of items that also acts as a partial polyfill for iterator helpers. */ class Enumerable { /** * Backing function responsible for providing the iterator of items. */ #items; /** * Backing function for {@link Enumerable.length}. */ #length; /**