From 9d47b36711d30f83e463238a81de46833a92b84e Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Tue, 26 May 2026 20:02:47 -0400 Subject: [PATCH 01/18] add debug adapter --- .gitignore | 3 + .../plugin/src/plugin/__tests__/index.test.ts | 4 + .../src/plugin/debug/__mocks__/adapter.ts | 5 + .../plugin/debug/__tests__/adapter.test.ts | 308 ++++++++++++ .../src/plugin/debug/__tests__/socket.test.ts | 147 ++++++ packages/plugin/src/plugin/debug/adapter.ts | 468 ++++++++++++++++++ packages/plugin/src/plugin/debug/socket.ts | 225 +++++++++ packages/plugin/src/plugin/index.ts | 6 +- 8 files changed, 1164 insertions(+), 2 deletions(-) create mode 100644 packages/plugin/src/plugin/debug/__mocks__/adapter.ts create mode 100644 packages/plugin/src/plugin/debug/__tests__/adapter.test.ts create mode 100644 packages/plugin/src/plugin/debug/__tests__/socket.test.ts create mode 100644 packages/plugin/src/plugin/debug/adapter.ts create mode 100644 packages/plugin/src/plugin/debug/socket.ts diff --git a/.gitignore b/.gitignore index d34c33fd..1965213f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ dist/ # Shared package files packages/plugin/README.md packages/plugin/LICENSE + +# OS +.DS_Store diff --git a/packages/plugin/src/plugin/__tests__/index.test.ts b/packages/plugin/src/plugin/__tests__/index.test.ts index ceff8b40..65957215 100644 --- a/packages/plugin/src/plugin/__tests__/index.test.ts +++ b/packages/plugin/src/plugin/__tests__/index.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { BarSubType, DeviceType, Target } from "../../api/index.js"; import { SingletonAction } from "../actions/singleton-action.js"; import { connection } from "../connection.js"; +import { debug } from "../debug/adapter.js"; import streamDeckAsDefaultExport, { streamDeck } from "../index.js"; import { logger } from "../logging/index.js"; @@ -10,6 +11,7 @@ vi.mock("../../common/i18n.js"); vi.mock("../logging/index.js"); vi.mock("../manifest.js"); vi.mock("../connection.js"); +vi.mock("../debug/adapter.js"); describe("index", () => { /** @@ -47,10 +49,12 @@ describe("index", () => { it("connects", async () => { // Arrange. const spyOnConnect = vi.spyOn(connection, "connect"); + const spyOnStart = vi.spyOn(debug, "start"); // Act, assert. await streamDeck.connect(); expect(spyOnConnect).toHaveBeenCalledTimes(1); + expect(spyOnStart).toHaveBeenCalledTimes(1); }); /** diff --git a/packages/plugin/src/plugin/debug/__mocks__/adapter.ts b/packages/plugin/src/plugin/debug/__mocks__/adapter.ts new file mode 100644 index 00000000..3ecab578 --- /dev/null +++ b/packages/plugin/src/plugin/debug/__mocks__/adapter.ts @@ -0,0 +1,5 @@ +import { vi } from "vitest"; + +export const debug = { + start: vi.fn().mockResolvedValue(undefined), +}; \ No newline at end of file diff --git a/packages/plugin/src/plugin/debug/__tests__/adapter.test.ts b/packages/plugin/src/plugin/debug/__tests__/adapter.test.ts new file mode 100644 index 00000000..3e91851d --- /dev/null +++ b/packages/plugin/src/plugin/debug/__tests__/adapter.test.ts @@ -0,0 +1,308 @@ +import type { JsonRpcRequest, JsonRpcResponse } from "@elgato/utils/rpc"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DeviceType, type DeviceDidChange, type DidReceiveSettings, type WillAppear, type WillDisappear } from "../../../api/index.js"; + +vi.mock("../../connection.js"); +vi.mock("../../manifest.js"); + +describe("debug adapter", () => { + let adapter: Awaited["debug"]; + let connection: Awaited["connection"]; + let sent: Array; + + beforeEach(async () => { + vi.resetModules(); + sent = []; + + ({ connection } = await import("../../connection.js")); + ({ debug: adapter } = await import("../adapter.js")); + adapter.attachRpc(async (value) => { + sent.push(value); + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("builds a snapshot from manifest actions and visible instances", () => { + connection.emit("willAppear", createKeyWillAppear()); + + expect(adapter.getSnapshot()).toEqual({ + actions: [ + { + instances: [ + { + context: "ctx_001", + controller: "Keypad", + device: "Device One", + position: { + column: 2, + kind: "key", + row: 3, + }, + settings: { + count: 42, + }, + }, + ], + name: "Action One", + uuid: "com.elgato.test.key", + }, + { + instances: [], + name: "Action Two", + uuid: "com.elgato.test.dial", + }, + ], + plugin: { + name: "Test Plugin", + uuid: "com.elgato.test", + version: "1.0.0", + }, + }); + }); + + it("publishes snapshot changes for visible instances", () => { + connection.emit("willAppear", createKeyWillAppear()); + + expect(sent).toEqual([ + { + jsonrpc: "2.0", + method: "streamDeck.debug.snapshotChanged", + params: adapter.getSnapshot(), + }, + ]); + }); + + it("normalizes dial and multi-action positions", () => { + connection.emit("willAppear", createDialWillAppear()); + + expect(adapter.getSnapshot().actions[1].instances[0]?.position).toEqual({ + column: 1, + index: 1, + kind: "dial", + row: 0, + }); + + connection.emit("willAppear", createMultiActionWillAppear()); + + expect(adapter.getSnapshot().actions[0].instances[0]?.position).toEqual({ + kind: "multi-action", + }); + }); + + it("updates settings, device details, and removals from connection events", () => { + connection.emit("willAppear", createKeyWillAppear()); + clearMessages(sent); + + connection.emit("didReceiveSettings", createDidReceiveSettings()); + expect(adapter.getSnapshot().actions[0].instances[0]?.settings).toEqual({ + count: 7, + }); + expect(sent).toHaveLength(1); + + clearMessages(sent); + connection.emit("deviceDidChange", createDeviceDidChange()); + expect(adapter.getSnapshot().actions[0].instances[0]?.device).toBe("Renamed Device"); + expect(sent).toHaveLength(1); + + clearMessages(sent); + connection.emit("willDisappear", createWillDisappear()); + expect(adapter.getSnapshot().actions[0].instances).toEqual([]); + expect(sent).toHaveLength(1); + }); + + it("responds to getSnapshot RPC requests", async () => { + connection.emit("willAppear", createKeyWillAppear()); + clearMessages(sent); + + const handled = await adapter.receive({ + id: "request-1", + jsonrpc: "2.0", + method: "streamDeck.debug.getSnapshot", + }); + + expect(handled).toBe(true); + expect(sent).toEqual([ + { + id: "request-1", + jsonrpc: "2.0", + result: adapter.getSnapshot(), + }, + ]); + }); + + it("does not start the websocket transport outside debug mode", async () => { + vi.resetModules(); + vi.doMock("../../common/utils.js", async () => { + const actual = await vi.importActual("../../common/utils.js"); + + return { + ...actual, + isDebugMode: vi.fn().mockReturnValue(false), + }; + }); + vi.doMock("../socket.js", () => ({ + debugSocket: { + start: vi.fn().mockResolvedValue(undefined), + }, + })); + + const { debug } = await import("../adapter.js"); + const { debugSocket } = await import("../socket.js"); + + await debug.start(); + + expect(debugSocket.start).not.toHaveBeenCalled(); + }); +}); + +/** + * Clears recorded JSON-RPC messages between assertions. + * @param messages Recorded messages. + */ +function clearMessages(messages: Array): void { + messages.splice(0, messages.length); +} + +/** + * Creates a device-change event for the seeded mock device. + * @returns Device-change event. + */ +function createDeviceDidChange(): DeviceDidChange { + return { + device: "DEV1", + deviceInfo: { + name: "Renamed Device", + size: { + columns: 5, + rows: 3, + }, + type: DeviceType.StreamDeckXL, + }, + event: "deviceDidChange", + }; +} + +/** + * Creates a settings update for the visible test key. + * @returns Did-receive-settings event. + */ +function createDidReceiveSettings(): DidReceiveSettings<{ count: number }> { + return { + action: "com.elgato.test.key", + context: "ctx_001", + device: "DEV1", + event: "didReceiveSettings", + payload: { + controller: "Keypad", + coordinates: { + column: 2, + row: 3, + }, + isInMultiAction: false, + resources: {}, + settings: { + count: 7, + }, + }, + }; +} + +/** + * Creates a visible dial instance event. + * @returns Will-appear event for a dial. + */ +function createDialWillAppear(): WillAppear<{ target: string }> { + return { + action: "com.elgato.test.dial", + context: "ctx_002", + device: "DEV1", + event: "willAppear", + payload: { + controller: "Encoder", + coordinates: { + column: 1, + row: 0, + }, + isInMultiAction: false, + resources: {}, + settings: { + target: "master", + }, + }, + }; +} + +/** + * Creates a visible key instance event. + * @returns Will-appear event for a key. + */ +function createKeyWillAppear(): WillAppear<{ count: number }> { + return { + action: "com.elgato.test.key", + context: "ctx_001", + device: "DEV1", + event: "willAppear", + payload: { + controller: "Keypad", + coordinates: { + column: 2, + row: 3, + }, + isInMultiAction: false, + resources: {}, + settings: { + count: 42, + }, + }, + }; +} + +/** + * Creates a visible multi-action key instance event. + * @returns Will-appear event for a multi-action key. + */ +function createMultiActionWillAppear(): WillAppear<{ count: number }> { + return { + action: "com.elgato.test.key", + context: "ctx_003", + device: "DEV1", + event: "willAppear", + payload: { + controller: "Keypad", + isInMultiAction: true, + resources: {}, + settings: { + count: 99, + }, + }, + }; +} + +/** + * Creates a disappearance event for the visible test key. + * @returns Will-disappear event for a key. + */ +function createWillDisappear(): WillDisappear<{ count: number }> { + return { + action: "com.elgato.test.key", + context: "ctx_001", + device: "DEV1", + event: "willDisappear", + payload: { + controller: "Keypad", + coordinates: { + column: 2, + row: 3, + }, + isInMultiAction: false, + resources: {}, + settings: { + count: 7, + }, + }, + }; +} \ No newline at end of file diff --git a/packages/plugin/src/plugin/debug/__tests__/socket.test.ts b/packages/plugin/src/plugin/debug/__tests__/socket.test.ts new file mode 100644 index 00000000..a9869b7e --- /dev/null +++ b/packages/plugin/src/plugin/debug/__tests__/socket.test.ts @@ -0,0 +1,147 @@ +import { withResolvers } from "@elgato/utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { WillAppear } from "../../../api/index.js"; + +vi.mock("../../connection.js"); +vi.mock("../../common/utils.js", async () => { + const actual = await vi.importActual("../../common/utils.js"); + + return { + ...actual, + isDebugMode: vi.fn().mockReturnValue(true), + }; +}); +vi.mock("../../manifest.js"); +vi.mock("../../logging/index.js"); + +describe("debug socket", () => { + let WebSocket: Awaited["default"]; + let debug: Awaited["debug"]; + let connection: Awaited["connection"]; + let debugSocket: Awaited["debugSocket"]; + + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("ws"); + ({ default: WebSocket } = await import("ws")); + ({ debug } = await import("../adapter.js")); + ({ connection } = await import("../../connection.js")); + ({ debugSocket } = await import("../socket.js")); + }); + + afterEach(async () => { + await debugSocket.stop(); + vi.clearAllMocks(); + }); + + it("serves snapshot requests over websocket", async () => { + await debug.start(); + + const client = new WebSocket(getSocketUrl()); + await open(client); + + const response = await getSnapshot(client); + expect(JSON.parse(response)).toEqual({ + id: "request-1", + jsonrpc: "2.0", + result: debug.getSnapshot(), + }); + + client.close(); + }); + + it("forwards snapshot change notifications to the connected websocket client", async () => { + await debug.start(); + + const client = new WebSocket(getSocketUrl()); + await open(client); + + const notification = receive(client); + connection.emit("willAppear", createKeyWillAppear()); + + expect(JSON.parse(await notification)).toEqual({ + jsonrpc: "2.0", + method: "streamDeck.debug.snapshotChanged", + params: debug.getSnapshot(), + }); + + client.close(); + }); +}); + +/** + * Creates a visible key instance event. + * @returns Will-appear event for a key. + */ +function createKeyWillAppear(): WillAppear<{ count: number }> { + return { + action: "com.elgato.test.key", + context: "ctx_001", + device: "DEV1", + event: "willAppear", + payload: { + controller: "Keypad", + coordinates: { + column: 2, + row: 3, + }, + isInMultiAction: false, + resources: {}, + settings: { + count: 42, + }, + }, + }; +} + +/** + * Gets the test websocket URL. + * @returns Test websocket URL. + */ +function getSocketUrl(): string { + return "ws://127.0.0.1:13345"; +} + +/** + * Waits for the websocket client to open. + * @param client Websocket client. + */ +async function open(client: WebSocketClient): Promise { + const opened = withResolvers(); + client.once("open", () => opened.resolve()); + client.once("error", (err) => opened.reject(err)); + await opened.promise; +} + +/** + * Requests a snapshot over the websocket client. + * @param client Websocket client. + * @returns JSON-RPC response message. + */ +function getSnapshot(client: WebSocketClient): Promise { + const response = receive(client); + client.send( + JSON.stringify({ + id: "request-1", + jsonrpc: "2.0", + method: "streamDeck.debug.getSnapshot", + }), + ); + + return response; +} + +/** + * Receives the next websocket message from the client. + * @param client Websocket client. + * @returns Received message. + */ +function receive(client: WebSocketClient): Promise { + const message = withResolvers(); + client.once("message", (data) => message.resolve(data.toString())); + client.once("error", (err) => message.reject(err)); + return message.promise; +} + +type WebSocketClient = InstanceType["default"]>; \ No newline at end of file diff --git a/packages/plugin/src/plugin/debug/adapter.ts b/packages/plugin/src/plugin/debug/adapter.ts new file mode 100644 index 00000000..49a2fa22 --- /dev/null +++ b/packages/plugin/src/plugin/debug/adapter.ts @@ -0,0 +1,468 @@ +import type { IDisposable, JsonObject, JsonValue } from "@elgato/utils"; +import { createRpcServerClient, type RpcSender } from "@elgato/utils/rpc"; + +import type { + DeviceDidChange, + DeviceDidConnect, + DidReceiveSettings, + Manifest, + WillAppear, + WillDisappear, +} from "../../api/index.js"; +import { isDebugMode } from "../common/utils.js"; +import { connection } from "../connection.js"; +import { logger } from "../logging/index.js"; +import { getManifest } from "../manifest.js"; +import { debugSocket } from "./socket.js"; + +/** + * Internal JSON-RPC adapter that projects connection events into a serialized debug state snapshot. + */ +class DebugAdapter { + /** + * Tracked device names indexed by device identifier. + */ + readonly #devices = new Map(); + + /** + * Tracked visible action instances indexed by action context. + */ + readonly #instances = new Map(); + + /** + * Registered disposables associated with adapter listeners and methods. + */ + readonly #disposables: IDisposable[] = []; + + /** + * Parsed plugin manifest, when available. + */ + #manifest: Manifest | null | undefined; + + /** + * Internal RPC client/server used by the adapter. + */ + #rpc: ReturnType | undefined; + + /** + * Last serialized snapshot emitted by the adapter. + */ + #lastSnapshot: string; + + /** + * Determines whether registration devices have been seeded into the internal device map. + */ + #seededDevices = false; + + /** + * Initializes a new debug adapter instance. + */ + constructor() { + this.#disposables.push( + connection.disposableOn("deviceDidChange", (ev: DeviceDidChange) => { + this.#devices.set(ev.device, ev.deviceInfo.name); + this.#publishChanges(); + }), + ); + this.#disposables.push( + connection.disposableOn("deviceDidConnect", (ev: DeviceDidConnect) => { + this.#devices.set(ev.device, ev.deviceInfo.name); + this.#publishChanges(); + }), + ); + this.#disposables.push( + connection.disposableOn("didReceiveSettings", (ev: DidReceiveSettings) => { + const instance = this.#instances.get(ev.context); + if (!instance) { + return; + } + + instance.controller = ev.payload.controller; + instance.deviceId = ev.device; + instance.position = this.#toPosition(ev.payload); + instance.settings = ev.payload.settings; + this.#publishChanges(); + }), + ); + this.#disposables.push( + connection.disposableOn("willAppear", (ev: WillAppear) => { + this.#instances.set(ev.context, { + context: ev.context, + controller: ev.payload.controller, + deviceId: ev.device, + manifestId: ev.action, + position: this.#toPosition(ev.payload), + settings: ev.payload.settings, + }); + + this.#publishChanges(); + }), + ); + this.#disposables.push( + connection.disposableOn("willDisappear", (ev: WillDisappear) => { + if (this.#instances.delete(ev.context)) { + this.#publishChanges(); + } + }), + ); + + this.#lastSnapshot = JSON.stringify(this.getSnapshot()); + } + + /** + * Attaches an RPC transport to the adapter. + * @param send Function responsible for sending JSON-RPC requests and responses. + */ + public attachRpc(send: RpcSender): void { + this.#rpc = createRpcServerClient(send); + this.#rpc.addMethod(debugRpcMethods.getSnapshot, () => this.getSnapshot()); + } + + /** + * Disposes the adapter and unregisters all connection listeners. + */ + public dispose(): void { + this.#disposables.forEach((disposable) => disposable.dispose()); + } + + /** + * Builds the current debug state snapshot. + * @returns Current debug state snapshot. + */ + public getSnapshot(): DebugSnapshot { + this.#seedDevices(); + + const manifestActions = this.#getManifest()?.Actions ?? []; + const groups = new Map(); + + manifestActions.forEach((action) => { + groups.set(action.UUID, { + instances: [], + name: action.Name, + uuid: action.UUID, + }); + }); + + this.#instances.forEach((instance) => { + const action = groups.get(instance.manifestId) ?? { + instances: [], + name: instance.manifestId, + uuid: instance.manifestId, + }; + + action.instances.push({ + context: instance.context, + controller: instance.controller, + device: this.#getDeviceName(instance.deviceId), + position: instance.position, + settings: instance.settings, + }); + + groups.set(instance.manifestId, action); + }); + + return { + actions: [...groups.values()], + plugin: { + name: this.#getManifest()?.Name ?? connection.registrationParameters.info.plugin.uuid, + uuid: this.#getManifest()?.UUID ?? connection.registrationParameters.info.plugin.uuid, + version: this.#getManifest()?.Version ?? connection.registrationParameters.info.plugin.version, + }, + }; + } + + /** + * Attempts to process the specified JSON-RPC message. + * @param value Value to process. + * @returns `true` when the value was handled by the adapter. + */ + public receive(value: JsonValue): Promise { + return this.#rpc ? this.#rpc.receive(value) : Promise.resolve(false); + } + + /** + * Starts the adapter's debug transport when the plugin is running in debug mode. + * @returns A promise resolved when startup is complete. + */ + public async start(): Promise { + if (!isDebugMode()) { + return; + } + + try { + await debugSocket.start(this); + } catch (err) { + logger.warn("Failed to start debug websocket", err); + } + } + + /** + * Gets the name associated with a device identifier. + * @param id Device identifier. + * @returns Device name when known; otherwise the identifier. + */ + #getDeviceName(id: string): string { + return this.#devices.get(id) ?? id; + } + + /** + * Schedules publication of the latest snapshot when it changed. + */ + #publishChanges(): void { + void this.#notifyIfChanged(); + } + + /** + * Publishes the current snapshot when it differs from the last emitted state. + */ + async #notifyIfChanged(): Promise { + const snapshot = this.getSnapshot(); + const serialized = JSON.stringify(snapshot); + + if (serialized === this.#lastSnapshot) { + return; + } + + this.#lastSnapshot = serialized; + if (this.#rpc) { + await this.#rpc.notify(debugRpcMethods.snapshotChanged, snapshot); + } + } + + /** + * Seeds the internal device map from Stream Deck registration parameters. + */ + #seedDevices(): void { + if (this.#seededDevices) { + return; + } + + connection.registrationParameters.info.devices.forEach((device) => { + this.#devices.set(device.id, device.name); + }); + + this.#seededDevices = true; + } + + /** + * Gets the parsed manifest, loading it on first access. + * @returns Parsed manifest, or `null` when unavailable. + */ + #getManifest(): Manifest | null { + return (this.#manifest ??= getManifest()); + } + + /** + * Converts raw Stream Deck payload coordinates into a normalized position object. + * @param payload Action payload associated with a visible instance. + * @returns Normalized position snapshot. + */ + #toPosition(payload: DidReceiveSettings["payload"] | WillAppear["payload"]): DebugPosition { + if (payload.controller === "Encoder") { + return { + column: payload.coordinates.column, + index: payload.coordinates.column, + kind: "dial", + row: payload.coordinates.row, + }; + } + + if (payload.isInMultiAction) { + return { + kind: "multi-action", + }; + } + + return { + column: payload.coordinates.column, + kind: "key", + row: payload.coordinates.row, + }; + } +} + +/** + * Singleton internal debug adapter. + */ +export const debug = new DebugAdapter(); + +const debugRpcMethods = { + /** + * Request the current debug state snapshot. + */ + getSnapshot: "streamDeck.debug.getSnapshot", + + /** + * Notification emitted when the debug state snapshot changes. + */ + snapshotChanged: "streamDeck.debug.snapshotChanged", +} as const; + +/** + * Serializable snapshot of the plugin's debug state. + */ +type DebugSnapshot = { + /** + * Actions known to the plugin, including currently visible instances. + */ + actions: DebugActionState[]; + + /** + * Plugin metadata associated with the snapshot. + */ + plugin: DebugPluginState; +}; + +/** + * Snapshot of a manifest action and its currently visible instances. + */ +type DebugActionState = { + /** + * Visible instances associated with the action. + */ + instances: DebugActionInstanceState[]; + + /** + * Human-readable action name. + */ + name: string; + + /** + * Manifest action UUID. + */ + uuid: string; +}; + +/** + * Snapshot of a visible action instance. + */ +type DebugActionInstanceState = { + /** + * Unique context identifier for the action instance. + */ + context: string; + + /** + * Controller type associated with the instance. + */ + controller: "Encoder" | "Keypad"; + + /** + * Name of the device the instance is currently shown on. + */ + device: string; + + /** + * Normalized position information for the instance. + */ + position: DebugPosition; + + /** + * Persisted action settings. + */ + settings: JsonObject; +}; + +/** + * Serializable plugin information exposed by the debug state snapshot. + */ +type DebugPluginState = { + /** + * Human-readable plugin name. + */ + name: string; + + /** + * Plugin UUID. + */ + uuid: string; + + /** + * Plugin version. + */ + version: string; +}; + +/** + * Normalized position associated with an action instance. + */ +type DebugPosition = + | { + /** + * Dial column reported by Stream Deck. + */ + column: number; + + /** + * Position kind for encoder instances. + */ + kind: "dial"; + + /** + * Dial index used for extension-side display. + */ + index: number; + + /** + * Dial row reported by Stream Deck. + */ + row: number; + } + | { + /** + * Key column reported by Stream Deck. + */ + column: number; + + /** + * Position kind for keypad instances. + */ + kind: "key"; + + /** + * Key row reported by Stream Deck. + */ + row: number; + } + | { + /** + * Position kind for keypad multi-action instances. + */ + kind: "multi-action"; + }; + +/** + * Internal snapshot of a visible action instance. + */ +type InternalInstanceState = { + /** + * Unique action context identifier. + */ + context: string; + + /** + * Controller associated with the instance. + */ + controller: "Encoder" | "Keypad"; + + /** + * Device identifier associated with the instance. + */ + deviceId: string; + + /** + * Manifest action UUID associated with the instance. + */ + manifestId: string; + + /** + * Normalized position for the instance. + */ + position: DebugPosition; + + /** + * Last known settings associated with the instance. + */ + settings: JsonObject; +}; + diff --git a/packages/plugin/src/plugin/debug/socket.ts b/packages/plugin/src/plugin/debug/socket.ts new file mode 100644 index 00000000..ad8c7b2f --- /dev/null +++ b/packages/plugin/src/plugin/debug/socket.ts @@ -0,0 +1,225 @@ +import { withResolvers, type JsonValue } from "@elgato/utils"; +import type { RpcSender } from "@elgato/utils/rpc"; +import WebSocket, { WebSocketServer, type RawData } from "ws"; + +import { connection } from "../connection.js"; +import { logger } from "../logging/index.js"; + +/** + * Hosts the internal debug adapter over a localhost websocket. + */ +class DebugSocket { + /** + * Offset applied to the Stream Deck websocket port to derive the debug websocket port. + */ + static readonly #debugPortOffset = 1000; + + /** + * Logger scoped to the debug websocket transport. + */ + readonly #logger = logger.createScope("DebugSocket"); + + /** + * RPC host bound to the websocket transport. + */ + #rpcHost: DebugSocketRpcHost | undefined; + + /** + * Underlying websocket server. + */ + #server: WebSocketServer | undefined; + + /** + * Currently connected debug client. + */ + #client: WebSocket | undefined; + + /** + * Promise representing startup of the websocket server. + */ + #startPromise: Promise | undefined; + + /** + * Starts the debug websocket server. + * @param rpcHost RPC host bound to the websocket transport. + * @returns A promise resolved when the websocket server is listening. + */ + public async start(rpcHost: DebugSocketRpcHost): Promise { + this.#rpcHost = rpcHost; + + if (this.#startPromise) { + return this.#startPromise; + } + + this.#startPromise = this.#listen(); + + try { + await this.#startPromise; + } catch (err) { + this.#startPromise = undefined; + throw err; + } + } + + /** + * Stops the debug websocket server. + * @returns A promise resolved when the websocket server is closed. + */ + public async stop(): Promise { + this.#client?.close(); + this.#client = undefined; + + const server = this.#server; + this.#server = undefined; + this.#startPromise = undefined; + + if (server) { + const closed = withResolvers(); + server.close((err) => { + if (err) { + closed.reject(err); + return; + } + + closed.resolve(); + }); + + await closed.promise; + } + } + + /** + * Port used by the debug websocket server. + * @returns Plugin-specific debug websocket port. + */ + get #port(): number { + const port = Number(connection.registrationParameters.port); + if (!Number.isInteger(port) || port < 0) { + throw new Error(`Invalid Stream Deck connection port: ${connection.registrationParameters.port}`); + } + + return port + DebugSocket.#debugPortOffset; + } + + /** + * Begins listening for debug websocket connections. + * @returns A promise resolved after the server is listening. + */ + async #listen(): Promise { + if (!this.#rpcHost) { + throw new Error("Debug websocket started without an RPC host"); + } + + this.#rpcHost.attachRpc(async (message) => { + await this.#send(message); + }); + + const server = new WebSocketServer({ + host: "127.0.0.1", + port: this.#port, + }); + + this.#server = server; + server.on("connection", (socket) => this.#handleConnection(socket)); + server.on("error", (err) => { + this.#logger.error("Failed to host debug websocket", err); + }); + + const listening = withResolvers(); + server.once("listening", () => listening.resolve()); + server.once("error", (err) => listening.reject(err)); + await listening.promise; + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Debug websocket did not expose a TCP address"); + } + + this.#logger.debug(`Debug websocket listening on ws://127.0.0.1:${address.port}`); + } + + /** + * Handles a newly connected debug client. + * @param socket Connected websocket client. + */ + #handleConnection(socket: WebSocket): void { + if (this.#client && this.#client !== socket && this.#client.readyState === WebSocket.OPEN) { + this.#client.close(1000, "Replaced by a new debug client"); + } + + this.#client = socket; + socket.on("close", () => { + if (this.#client === socket) { + this.#client = undefined; + } + }); + socket.on("error", (err) => { + this.#logger.error("Debug websocket client error", err); + }); + socket.on("message", (data) => { + void this.#handleMessage(data); + }); + } + + /** + * Handles a message sent by the connected debug client. + * @param data Raw websocket message. + */ + async #handleMessage(data: RawData): Promise { + try { + const handled = await this.#rpcHost?.receive(JSON.parse(data.toString())) ?? false; + if (!handled) { + this.#logger.warn(`Unhandled debug RPC message: ${data.toString()}`); + } + } catch (err) { + this.#logger.error(`Failed to process debug websocket message: ${data.toString()}`, err); + } + } + + /** + * Sends a JSON-RPC payload to the active websocket client. + * @param message Message to send. + * @returns A promise resolved when the message is written. + */ + async #send(message: unknown): Promise { + const client = this.#client; + if (!client || client.readyState !== WebSocket.OPEN) { + return; + } + + const sent = withResolvers(); + client.send(JSON.stringify(message), (err) => { + if (err) { + sent.reject(err); + return; + } + + sent.resolve(); + }); + + await sent.promise; + } +} + +/** + * Singleton debug websocket transport. + */ +export const debugSocket = new DebugSocket(); + +/** + * Minimal RPC host interface required by the debug websocket transport. + */ +type DebugSocketRpcHost = { + /** + * Attaches the websocket transport to the RPC host. + * @param send Sender used for outbound JSON-RPC messages. + */ + attachRpc(send: RpcSender): void; + + /** + * Attempts to process a JSON-RPC payload received from the websocket client. + * @param value Received JSON value. + * @returns `true` when the value was handled by the RPC host. + */ + receive(value: JsonValue): Promise; +}; \ No newline at end of file diff --git a/packages/plugin/src/plugin/index.ts b/packages/plugin/src/plugin/index.ts index feaf1e19..eda77533 100644 --- a/packages/plugin/src/plugin/index.ts +++ b/packages/plugin/src/plugin/index.ts @@ -4,6 +4,7 @@ import type { Logger } from "@elgato/utils/logging"; import type { Language, RegistrationInfo } from "../api/index.js"; import { actionService, type ActionService } from "./actions/service.js"; import { connection } from "./connection.js"; +import { debug } from "./debug/adapter.js"; import { deviceService, type DeviceService } from "./devices/service.js"; import { fileSystemLocaleProvider } from "./i18n.js"; import { logger } from "./logging/index.js"; @@ -114,8 +115,9 @@ export const streamDeck = { * Connects the plugin to the Stream Deck. * @returns A promise resolved when a connection has been established. */ - connect(): Promise { - return connection.connect(); + async connect(): Promise { + await connection.connect(); + await debug.start(); }, }; From 20302883089494dffd81208853ebf6307c85583d Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Thu, 28 May 2026 17:51:45 -0400 Subject: [PATCH 02/18] read port from file --- .../src/plugin/debug/__tests__/socket.test.ts | 75 +++++--- packages/plugin/src/plugin/debug/socket.ts | 182 +++++------------- 2 files changed, 90 insertions(+), 167 deletions(-) diff --git a/packages/plugin/src/plugin/debug/__tests__/socket.test.ts b/packages/plugin/src/plugin/debug/__tests__/socket.test.ts index a9869b7e..c6138941 100644 --- a/packages/plugin/src/plugin/debug/__tests__/socket.test.ts +++ b/packages/plugin/src/plugin/debug/__tests__/socket.test.ts @@ -1,4 +1,7 @@ import { withResolvers } from "@elgato/utils"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WillAppear } from "../../../api/index.js"; @@ -20,11 +23,16 @@ describe("debug socket", () => { let debug: Awaited["debug"]; let connection: Awaited["connection"]; let debugSocket: Awaited["debugSocket"]; + let cwd: string; + let originalCwd: string; beforeEach(async () => { vi.resetModules(); vi.doUnmock("ws"); ({ default: WebSocket } = await import("ws")); + originalCwd = process.cwd(); + cwd = await mkdtemp(join(tmpdir(), "streamdeck-debug-test-")); + process.chdir(cwd); ({ debug } = await import("../adapter.js")); ({ connection } = await import("../../connection.js")); ({ debugSocket } = await import("../socket.js")); @@ -32,14 +40,14 @@ describe("debug socket", () => { afterEach(async () => { await debugSocket.stop(); + process.chdir(originalCwd); + await rm(cwd, { force: true, recursive: true }); vi.clearAllMocks(); }); - it("serves snapshot requests over websocket", async () => { + it("writes the port file and serves snapshot requests", async () => { await debug.start(); - - const client = new WebSocket(getSocketUrl()); - await open(client); + const client = await connect(); const response = await getSnapshot(client); expect(JSON.parse(response)).toEqual({ @@ -51,11 +59,9 @@ describe("debug socket", () => { client.close(); }); - it("forwards snapshot change notifications to the connected websocket client", async () => { + it("forwards snapshot change notifications to the connected client", async () => { await debug.start(); - - const client = new WebSocket(getSocketUrl()); - await open(client); + const client = await connect(); const notification = receive(client); connection.emit("willAppear", createKeyWillAppear()); @@ -68,6 +74,38 @@ describe("debug socket", () => { client.close(); }); + + it("removes the port file when stopped", async () => { + await debug.start(); + await readPort(); + + await debugSocket.stop(); + await expect(readPort()).rejects.toThrow(); + }); + + /** + * Reads the port the plugin advertised in its bundle. + * @returns Advertised port. + */ + async function readPort(): Promise { + const data = await readFile(join(cwd, ".debug", "vscode-debug.json"), "utf-8"); + return (JSON.parse(data) as { port: number }).port; + } + + /** + * Connects a websocket client to the debug socket. + * @returns Opened websocket client. + */ + async function connect(): Promise { + const client = new WebSocket(`ws://127.0.0.1:${await readPort()}`); + + const opened = withResolvers(); + client.once("open", () => opened.resolve()); + client.once("error", (err) => opened.reject(err)); + await opened.promise; + + return client; + } }); /** @@ -95,25 +133,6 @@ function createKeyWillAppear(): WillAppear<{ count: number }> { }; } -/** - * Gets the test websocket URL. - * @returns Test websocket URL. - */ -function getSocketUrl(): string { - return "ws://127.0.0.1:13345"; -} - -/** - * Waits for the websocket client to open. - * @param client Websocket client. - */ -async function open(client: WebSocketClient): Promise { - const opened = withResolvers(); - client.once("open", () => opened.resolve()); - client.once("error", (err) => opened.reject(err)); - await opened.promise; -} - /** * Requests a snapshot over the websocket client. * @param client Websocket client. @@ -144,4 +163,4 @@ function receive(client: WebSocketClient): Promise { return message.promise; } -type WebSocketClient = InstanceType["default"]>; \ No newline at end of file +type WebSocketClient = InstanceType["default"]>; diff --git a/packages/plugin/src/plugin/debug/socket.ts b/packages/plugin/src/plugin/debug/socket.ts index ad8c7b2f..91ff1599 100644 --- a/packages/plugin/src/plugin/debug/socket.ts +++ b/packages/plugin/src/plugin/debug/socket.ts @@ -1,23 +1,24 @@ -import { withResolvers, type JsonValue } from "@elgato/utils"; +import { type JsonValue } from "@elgato/utils"; import type { RpcSender } from "@elgato/utils/rpc"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import { dirname, join } from "node:path"; import WebSocket, { WebSocketServer, type RawData } from "ws"; -import { connection } from "../connection.js"; import { logger } from "../logging/index.js"; +// The plugin's working directory is its .sdPlugin bundle (see manifest.ts), so the debug file lives alongside the manifest. +const debugFile = join(process.cwd(), ".debug", "vscode-debug.json"); + /** - * Hosts the internal debug adapter over a localhost websocket. + * Hosts the internal debug adapter over a localhost websocket and writes its port into the + * plugin bundle so the VS Code extension can discover and connect to it. */ class DebugSocket { /** - * Offset applied to the Stream Deck websocket port to derive the debug websocket port. - */ - static readonly #debugPortOffset = 1000; - - /** - * Logger scoped to the debug websocket transport. + * Connected VS Code debug client. */ - readonly #logger = logger.createScope("DebugSocket"); + #client: WebSocket | undefined; /** * RPC host bound to the websocket transport. @@ -30,40 +31,33 @@ class DebugSocket { #server: WebSocketServer | undefined; /** - * Currently connected debug client. - */ - #client: WebSocket | undefined; - - /** - * Promise representing startup of the websocket server. - */ - #startPromise: Promise | undefined; - - /** - * Starts the debug websocket server. + * Starts the debug websocket server and writes its port for the VS Code extension. * @param rpcHost RPC host bound to the websocket transport. - * @returns A promise resolved when the websocket server is listening. */ public async start(rpcHost: DebugSocketRpcHost): Promise { - this.#rpcHost = rpcHost; - - if (this.#startPromise) { - return this.#startPromise; + if (this.#server) { + return; } - this.#startPromise = this.#listen(); + this.#rpcHost = rpcHost; + rpcHost.attachRpc((message) => this.#send(message)); - try { - await this.#startPromise; - } catch (err) { - this.#startPromise = undefined; - throw err; - } + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + this.#server = server; + server.on("connection", (socket) => this.#onConnection(socket)); + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", (err) => reject(err)); + }); + + const { port } = server.address() as AddressInfo; + await mkdir(dirname(debugFile), { recursive: true }); + await writeFile(debugFile, JSON.stringify({ port }), "utf-8"); + logger.debug(`Debug websocket listening on 127.0.0.1:${port}`); } /** - * Stops the debug websocket server. - * @returns A promise resolved when the websocket server is closed. + * Stops the debug websocket server and removes its port file. */ public async stop(): Promise { this.#client?.close(); @@ -71,133 +65,43 @@ class DebugSocket { const server = this.#server; this.#server = undefined; - this.#startPromise = undefined; - if (server) { - const closed = withResolvers(); - server.close((err) => { - if (err) { - closed.reject(err); - return; - } - - closed.resolve(); - }); - - await closed.promise; - } - } - - /** - * Port used by the debug websocket server. - * @returns Plugin-specific debug websocket port. - */ - get #port(): number { - const port = Number(connection.registrationParameters.port); - if (!Number.isInteger(port) || port < 0) { - throw new Error(`Invalid Stream Deck connection port: ${connection.registrationParameters.port}`); - } - - return port + DebugSocket.#debugPortOffset; - } - - /** - * Begins listening for debug websocket connections. - * @returns A promise resolved after the server is listening. - */ - async #listen(): Promise { - if (!this.#rpcHost) { - throw new Error("Debug websocket started without an RPC host"); - } - - this.#rpcHost.attachRpc(async (message) => { - await this.#send(message); - }); - - const server = new WebSocketServer({ - host: "127.0.0.1", - port: this.#port, - }); - - this.#server = server; - server.on("connection", (socket) => this.#handleConnection(socket)); - server.on("error", (err) => { - this.#logger.error("Failed to host debug websocket", err); - }); - - const listening = withResolvers(); - server.once("listening", () => listening.resolve()); - server.once("error", (err) => listening.reject(err)); - await listening.promise; - - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Debug websocket did not expose a TCP address"); + await rm(debugFile, { force: true }); + await new Promise((resolve) => server.close(() => resolve())); } - - this.#logger.debug(`Debug websocket listening on ws://127.0.0.1:${address.port}`); } /** - * Handles a newly connected debug client. - * @param socket Connected websocket client. + * Binds a newly connected VS Code debug client. + * @param socket Connected websocket. */ - #handleConnection(socket: WebSocket): void { - if (this.#client && this.#client !== socket && this.#client.readyState === WebSocket.OPEN) { - this.#client.close(1000, "Replaced by a new debug client"); - } - + #onConnection(socket: WebSocket): void { + this.#client?.close(); this.#client = socket; + socket.on("message", (data) => this.#onMessage(data)); socket.on("close", () => { if (this.#client === socket) { this.#client = undefined; } }); - socket.on("error", (err) => { - this.#logger.error("Debug websocket client error", err); - }); - socket.on("message", (data) => { - void this.#handleMessage(data); - }); } /** - * Handles a message sent by the connected debug client. + * Forwards a JSON-RPC message from the client to the RPC host. * @param data Raw websocket message. */ - async #handleMessage(data: RawData): Promise { - try { - const handled = await this.#rpcHost?.receive(JSON.parse(data.toString())) ?? false; - if (!handled) { - this.#logger.warn(`Unhandled debug RPC message: ${data.toString()}`); - } - } catch (err) { - this.#logger.error(`Failed to process debug websocket message: ${data.toString()}`, err); - } + async #onMessage(data: RawData): Promise { + await this.#rpcHost?.receive(JSON.parse(data.toString())); } /** - * Sends a JSON-RPC payload to the active websocket client. + * Sends a JSON-RPC message to the connected client. * @param message Message to send. - * @returns A promise resolved when the message is written. */ async #send(message: unknown): Promise { - const client = this.#client; - if (!client || client.readyState !== WebSocket.OPEN) { - return; + if (this.#client?.readyState === WebSocket.OPEN) { + this.#client.send(JSON.stringify(message)); } - - const sent = withResolvers(); - client.send(JSON.stringify(message), (err) => { - if (err) { - sent.reject(err); - return; - } - - sent.resolve(); - }); - - await sent.promise; } } @@ -222,4 +126,4 @@ type DebugSocketRpcHost = { * @returns `true` when the value was handled by the RPC host. */ receive(value: JsonValue): Promise; -}; \ No newline at end of file +}; From e1c1dea28035a93bd99163e84c5b1686ad0e92b7 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Wed, 3 Jun 2026 15:37:13 -0400 Subject: [PATCH 03/18] tree view progress --- packages/plugin/src/plugin/debug/adapter.ts | 60 +++++++++++++++++---- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/plugin/src/plugin/debug/adapter.ts b/packages/plugin/src/plugin/debug/adapter.ts index 49a2fa22..facb5303 100644 --- a/packages/plugin/src/plugin/debug/adapter.ts +++ b/packages/plugin/src/plugin/debug/adapter.ts @@ -115,7 +115,8 @@ class DebugAdapter { */ public attachRpc(send: RpcSender): void { this.#rpc = createRpcServerClient(send); - this.#rpc.addMethod(debugRpcMethods.getSnapshot, () => this.getSnapshot()); + this.#rpc.addMethod("streamDeck.debug.getSnapshot", () => this.getSnapshot()); + this.#rpc.addMethod("streamDeck.debug.setSettings", (params) => this.#setSettings(params)); } /** @@ -196,6 +197,44 @@ class DebugAdapter { } } + /** + * Persists settings for a tracked action instance and re-publishes the snapshot. + * @param params Context and settings to persist. + * @returns `true` when the instance was known and the update was sent. + */ + async #setSettings(params: SetSettingsParams | undefined): Promise { + const { context, settings } = params ?? {}; + if (typeof context !== "string" || !this.#isJsonObject(settings)) { + return false; + } + + const instance = this.#instances.get(context); + if (!instance) { + return false; + } + + await connection.send({ + event: "setSettings", + context, + payload: settings, + }); + + // Stream Deck does not echo a didReceiveSettings for setSettings, so update the + // tracked instance optimistically to keep the published snapshot in sync. + instance.settings = settings; + this.#publishChanges(); + return true; + } + + /** + * Determines whether a value is a JSON object. + * @param value Value to inspect. + * @returns `true` when the value is a non-array object. + */ + #isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + /** * Gets the name associated with a device identifier. * @param id Device identifier. @@ -209,7 +248,7 @@ class DebugAdapter { * Schedules publication of the latest snapshot when it changed. */ #publishChanges(): void { - void this.#notifyIfChanged(); + this.#notifyIfChanged(); } /** @@ -225,7 +264,7 @@ class DebugAdapter { this.#lastSnapshot = serialized; if (this.#rpc) { - await this.#rpc.notify(debugRpcMethods.snapshotChanged, snapshot); + await this.#rpc.notify("streamDeck.debug.snapshotChanged", snapshot); } } @@ -286,17 +325,20 @@ class DebugAdapter { */ export const debug = new DebugAdapter(); -const debugRpcMethods = { +/** + * Parameters for the `streamDeck.debug.setSettings` RPC method. + */ +type SetSettingsParams = { /** - * Request the current debug state snapshot. + * Context identifier of the action instance to update. */ - getSnapshot: "streamDeck.debug.getSnapshot", + context: string; /** - * Notification emitted when the debug state snapshot changes. + * Settings to persist for the action instance. */ - snapshotChanged: "streamDeck.debug.snapshotChanged", -} as const; + settings: JsonObject; +}; /** * Serializable snapshot of the plugin's debug state. From f8969ee0d3f7ae6bf300f95d9b34a68c5e058fa2 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Wed, 3 Jun 2026 19:55:06 -0400 Subject: [PATCH 04/18] migrate to named pipes --- .../src/plugin/debug/__tests__/socket.test.ts | 111 +++++++++--------- packages/plugin/src/plugin/debug/adapter.ts | 5 +- packages/plugin/src/plugin/debug/pipe-path.ts | 24 ++++ packages/plugin/src/plugin/debug/socket.ts | 110 +++++++++++------ 4 files changed, 154 insertions(+), 96 deletions(-) create mode 100644 packages/plugin/src/plugin/debug/pipe-path.ts diff --git a/packages/plugin/src/plugin/debug/__tests__/socket.test.ts b/packages/plugin/src/plugin/debug/__tests__/socket.test.ts index c6138941..9931dc80 100644 --- a/packages/plugin/src/plugin/debug/__tests__/socket.test.ts +++ b/packages/plugin/src/plugin/debug/__tests__/socket.test.ts @@ -1,10 +1,11 @@ import { withResolvers } from "@elgato/utils"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { createConnection, type Socket } from "node:net"; +import { platform } from "node:os"; +import { access } from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WillAppear } from "../../../api/index.js"; +import { getDebugPipePath } from "../pipe-path.js"; vi.mock("../../connection.js"); vi.mock("../../common/utils.js", async () => { @@ -18,21 +19,16 @@ vi.mock("../../common/utils.js", async () => { vi.mock("../../manifest.js"); vi.mock("../../logging/index.js"); +// The plugin SDK derives its debug socket path from the (mocked) manifest UUID. +const pipePath = getDebugPipePath("com.elgato.test"); + describe("debug socket", () => { - let WebSocket: Awaited["default"]; let debug: Awaited["debug"]; let connection: Awaited["connection"]; let debugSocket: Awaited["debugSocket"]; - let cwd: string; - let originalCwd: string; beforeEach(async () => { vi.resetModules(); - vi.doUnmock("ws"); - ({ default: WebSocket } = await import("ws")); - originalCwd = process.cwd(); - cwd = await mkdtemp(join(tmpdir(), "streamdeck-debug-test-")); - process.chdir(cwd); ({ debug } = await import("../adapter.js")); ({ connection } = await import("../../connection.js")); ({ debugSocket } = await import("../socket.js")); @@ -40,12 +36,10 @@ describe("debug socket", () => { afterEach(async () => { await debugSocket.stop(); - process.chdir(originalCwd); - await rm(cwd, { force: true, recursive: true }); vi.clearAllMocks(); }); - it("writes the port file and serves snapshot requests", async () => { + it("serves snapshot requests over the debug socket", async () => { await debug.start(); const client = await connect(); @@ -56,7 +50,7 @@ describe("debug socket", () => { result: debug.getSnapshot(), }); - client.close(); + client.destroy(); }); it("forwards snapshot change notifications to the connected client", async () => { @@ -72,40 +66,20 @@ describe("debug socket", () => { params: debug.getSnapshot(), }); - client.close(); + client.destroy(); }); - it("removes the port file when stopped", async () => { + it("removes the socket file when stopped", async () => { await debug.start(); - await readPort(); + if (platform() !== "win32") { + await expect(access(pipePath)).resolves.toBeUndefined(); + } await debugSocket.stop(); - await expect(readPort()).rejects.toThrow(); + if (platform() !== "win32") { + await expect(access(pipePath)).rejects.toThrow(); + } }); - - /** - * Reads the port the plugin advertised in its bundle. - * @returns Advertised port. - */ - async function readPort(): Promise { - const data = await readFile(join(cwd, ".debug", "vscode-debug.json"), "utf-8"); - return (JSON.parse(data) as { port: number }).port; - } - - /** - * Connects a websocket client to the debug socket. - * @returns Opened websocket client. - */ - async function connect(): Promise { - const client = new WebSocket(`ws://127.0.0.1:${await readPort()}`); - - const opened = withResolvers(); - client.once("open", () => opened.resolve()); - client.once("error", (err) => opened.reject(err)); - await opened.promise; - - return client; - } }); /** @@ -134,33 +108,58 @@ function createKeyWillAppear(): WillAppear<{ count: number }> { } /** - * Requests a snapshot over the websocket client. - * @param client Websocket client. + * Connects a client to the debug socket. + * @returns Connected socket client. + */ +async function connect(): Promise { + const client = createConnection(pipePath); + client.setEncoding("utf-8"); + + const opened = withResolvers(); + client.once("connect", () => opened.resolve()); + client.once("error", (err) => opened.reject(err)); + await opened.promise; + + return client; +} + +/** + * Requests a snapshot over the socket client. + * @param client Socket client. * @returns JSON-RPC response message. */ -function getSnapshot(client: WebSocketClient): Promise { +function getSnapshot(client: Socket): Promise { const response = receive(client); - client.send( - JSON.stringify({ + client.write( + `${JSON.stringify({ id: "request-1", jsonrpc: "2.0", method: "streamDeck.debug.getSnapshot", - }), + })}\n`, ); return response; } /** - * Receives the next websocket message from the client. - * @param client Websocket client. - * @returns Received message. + * Receives the next newline-delimited message from the client. + * @param client Socket client. + * @returns Received message, without its trailing newline. */ -function receive(client: WebSocketClient): Promise { +function receive(client: Socket): Promise { const message = withResolvers(); - client.once("message", (data) => message.resolve(data.toString())); + + let buffer = ""; + const onData = (chunk: string): void => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline !== -1) { + client.off("data", onData); + message.resolve(buffer.slice(0, newline)); + } + }; + + client.on("data", onData); client.once("error", (err) => message.reject(err)); return message.promise; } - -type WebSocketClient = InstanceType["default"]>; diff --git a/packages/plugin/src/plugin/debug/adapter.ts b/packages/plugin/src/plugin/debug/adapter.ts index facb5303..a784913e 100644 --- a/packages/plugin/src/plugin/debug/adapter.ts +++ b/packages/plugin/src/plugin/debug/adapter.ts @@ -190,10 +190,11 @@ class DebugAdapter { return; } + const uuid = this.#getManifest()?.UUID ?? connection.registrationParameters.info.plugin.uuid; try { - await debugSocket.start(this); + await debugSocket.start(this, uuid); } catch (err) { - logger.warn("Failed to start debug websocket", err); + logger.warn("Failed to start debug socket", err); } } diff --git a/packages/plugin/src/plugin/debug/pipe-path.ts b/packages/plugin/src/plugin/debug/pipe-path.ts new file mode 100644 index 00000000..77bdd1c6 --- /dev/null +++ b/packages/plugin/src/plugin/debug/pipe-path.ts @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; +import { platform, tmpdir } from "node:os"; +import { join } from "node:path"; + +// NOTE: This derivation MUST stay byte-for-byte identical to the VS Code extension's copy +// (vscode-streamdeck: src/debug/pipe-path.ts). Both sides compute the rendezvous path from the +// plugin UUID independently; if they diverge, the extension can no longer find the plugin. + +/** + * Computes the debug transport pipe path for a plugin, derived deterministically from its UUID. + * + * The UUID is hashed to a short, filesystem-safe token so the resulting Unix domain socket path + * stays within the platform's path length limit (~104 bytes on macOS). + * @param uuid Plugin UUID. + * @returns Named pipe path (Windows) or Unix domain socket path (macOS/Linux). + */ +export function getDebugPipePath(uuid: string): string { + const token = `sd-${createHash("sha1").update(uuid).digest("hex").slice(0, 16)}`; + if (platform() === "win32") { + return `\\\\.\\pipe\\${token}`; + } + + return join(tmpdir(), `${token}.sock`); +} diff --git a/packages/plugin/src/plugin/debug/socket.ts b/packages/plugin/src/plugin/debug/socket.ts index 91ff1599..d480a119 100644 --- a/packages/plugin/src/plugin/debug/socket.ts +++ b/packages/plugin/src/plugin/debug/socket.ts @@ -1,40 +1,44 @@ import { type JsonValue } from "@elgato/utils"; import type { RpcSender } from "@elgato/utils/rpc"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import type { AddressInfo } from "node:net"; -import { dirname, join } from "node:path"; -import WebSocket, { WebSocketServer, type RawData } from "ws"; +import { rm } from "node:fs/promises"; +import { createServer, type Server, type Socket } from "node:net"; +import { platform } from "node:os"; import { logger } from "../logging/index.js"; - -// The plugin's working directory is its .sdPlugin bundle (see manifest.ts), so the debug file lives alongside the manifest. -const debugFile = join(process.cwd(), ".debug", "vscode-debug.json"); +import { getDebugPipePath } from "./pipe-path.js"; /** - * Hosts the internal debug adapter over a localhost websocket and writes its port into the - * plugin bundle so the VS Code extension can discover and connect to it. + * Hosts the internal debug adapter over a named pipe (Windows) or Unix domain socket + * (macOS/Linux) whose path is derived from the plugin UUID, so the VS Code extension can + * discover and connect to it without any port handshake. */ class DebugSocket { /** * Connected VS Code debug client. */ - #client: WebSocket | undefined; + #client: Socket | undefined; + + /** + * Path the server is currently listening on. + */ + #path: string | undefined; /** - * RPC host bound to the websocket transport. + * RPC host bound to the socket transport. */ #rpcHost: DebugSocketRpcHost | undefined; /** - * Underlying websocket server. + * Underlying socket server. */ - #server: WebSocketServer | undefined; + #server: Server | undefined; /** - * Starts the debug websocket server and writes its port for the VS Code extension. - * @param rpcHost RPC host bound to the websocket transport. + * Starts the debug socket server on the UUID-derived path so the VS Code extension can connect. + * @param rpcHost RPC host bound to the socket transport. + * @param uuid Plugin UUID used to derive the pipe path. */ - public async start(rpcHost: DebugSocketRpcHost): Promise { + public async start(rpcHost: DebugSocketRpcHost, uuid: string): Promise { if (this.#server) { return; } @@ -42,56 +46,86 @@ class DebugSocket { this.#rpcHost = rpcHost; rpcHost.attachRpc((message) => this.#send(message)); - const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const path = getDebugPipePath(uuid); + this.#path = path; + + // A Unix domain socket leaves a file behind if the previous process crashed; remove any + // stale socket before listening to avoid EADDRINUSE. Windows named pipes self-clean. + if (platform() !== "win32") { + await rm(path, { force: true }); + } + + const server = createServer((socket) => this.#onConnection(socket)); this.#server = server; - server.on("connection", (socket) => this.#onConnection(socket)); await new Promise((resolve, reject) => { server.once("listening", () => resolve()); server.once("error", (err) => reject(err)); + server.listen(path); }); - const { port } = server.address() as AddressInfo; - await mkdir(dirname(debugFile), { recursive: true }); - await writeFile(debugFile, JSON.stringify({ port }), "utf-8"); - logger.debug(`Debug websocket listening on 127.0.0.1:${port}`); + logger.debug(`Debug socket listening on ${path}`); } /** - * Stops the debug websocket server and removes its port file. + * Stops the debug socket server and removes its socket file. */ public async stop(): Promise { - this.#client?.close(); + this.#client?.destroy(); this.#client = undefined; const server = this.#server; + const path = this.#path; this.#server = undefined; + this.#path = undefined; if (server) { - await rm(debugFile, { force: true }); await new Promise((resolve) => server.close(() => resolve())); + if (path && platform() !== "win32") { + await rm(path, { force: true }); + } } } /** * Binds a newly connected VS Code debug client. - * @param socket Connected websocket. + * @param socket Connected socket. */ - #onConnection(socket: WebSocket): void { - this.#client?.close(); + #onConnection(socket: Socket): void { + this.#client?.destroy(); this.#client = socket; - socket.on("message", (data) => this.#onMessage(data)); + + let buffer = ""; + socket.setEncoding("utf-8"); + socket.on("data", (chunk: string) => { + buffer += chunk; + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.length > 0) { + void this.#onMessage(line); + } + + newline = buffer.indexOf("\n"); + } + }); socket.on("close", () => { if (this.#client === socket) { this.#client = undefined; } }); + socket.on("error", () => { + if (this.#client === socket) { + this.#client = undefined; + } + }); } /** * Forwards a JSON-RPC message from the client to the RPC host. - * @param data Raw websocket message. + * @param line Newline-delimited JSON message. */ - async #onMessage(data: RawData): Promise { - await this.#rpcHost?.receive(JSON.parse(data.toString())); + async #onMessage(line: string): Promise { + await this.#rpcHost?.receive(JSON.parse(line)); } /** @@ -99,29 +133,29 @@ class DebugSocket { * @param message Message to send. */ async #send(message: unknown): Promise { - if (this.#client?.readyState === WebSocket.OPEN) { - this.#client.send(JSON.stringify(message)); + if (this.#client && !this.#client.destroyed) { + this.#client.write(`${JSON.stringify(message)}\n`); } } } /** - * Singleton debug websocket transport. + * Singleton debug socket transport. */ export const debugSocket = new DebugSocket(); /** - * Minimal RPC host interface required by the debug websocket transport. + * Minimal RPC host interface required by the debug socket transport. */ type DebugSocketRpcHost = { /** - * Attaches the websocket transport to the RPC host. + * Attaches the socket transport to the RPC host. * @param send Sender used for outbound JSON-RPC messages. */ attachRpc(send: RpcSender): void; /** - * Attempts to process a JSON-RPC payload received from the websocket client. + * Attempts to process a JSON-RPC payload received from the socket client. * @param value Received JSON value. * @returns `true` when the value was handled by the RPC host. */ From 1599ed11ded9732f1c739eb21cb31bb723f42449 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Thu, 4 Jun 2026 15:12:56 -0400 Subject: [PATCH 05/18] folder restructure --- packages/plugin/src/plugin/__tests__/index.test.ts | 2 +- .../plugin/src/plugin/{debug => bridge}/__mocks__/adapter.ts | 0 .../src/plugin/{debug => bridge}/__tests__/adapter.test.ts | 0 .../src/plugin/{debug => bridge}/__tests__/socket.test.ts | 0 packages/plugin/src/plugin/{debug => bridge}/adapter.ts | 0 packages/plugin/src/plugin/{debug => bridge}/pipe-path.ts | 0 packages/plugin/src/plugin/{debug => bridge}/socket.ts | 0 packages/plugin/src/plugin/index.ts | 2 +- 8 files changed, 2 insertions(+), 2 deletions(-) rename packages/plugin/src/plugin/{debug => bridge}/__mocks__/adapter.ts (100%) rename packages/plugin/src/plugin/{debug => bridge}/__tests__/adapter.test.ts (100%) rename packages/plugin/src/plugin/{debug => bridge}/__tests__/socket.test.ts (100%) rename packages/plugin/src/plugin/{debug => bridge}/adapter.ts (100%) rename packages/plugin/src/plugin/{debug => bridge}/pipe-path.ts (100%) rename packages/plugin/src/plugin/{debug => bridge}/socket.ts (100%) diff --git a/packages/plugin/src/plugin/__tests__/index.test.ts b/packages/plugin/src/plugin/__tests__/index.test.ts index 65957215..5daf1a27 100644 --- a/packages/plugin/src/plugin/__tests__/index.test.ts +++ b/packages/plugin/src/plugin/__tests__/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { BarSubType, DeviceType, Target } from "../../api/index.js"; import { SingletonAction } from "../actions/singleton-action.js"; import { connection } from "../connection.js"; -import { debug } from "../debug/adapter.js"; +import { debug } from "../bridge/adapter.js"; import streamDeckAsDefaultExport, { streamDeck } from "../index.js"; import { logger } from "../logging/index.js"; diff --git a/packages/plugin/src/plugin/debug/__mocks__/adapter.ts b/packages/plugin/src/plugin/bridge/__mocks__/adapter.ts similarity index 100% rename from packages/plugin/src/plugin/debug/__mocks__/adapter.ts rename to packages/plugin/src/plugin/bridge/__mocks__/adapter.ts diff --git a/packages/plugin/src/plugin/debug/__tests__/adapter.test.ts b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts similarity index 100% rename from packages/plugin/src/plugin/debug/__tests__/adapter.test.ts rename to packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts diff --git a/packages/plugin/src/plugin/debug/__tests__/socket.test.ts b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts similarity index 100% rename from packages/plugin/src/plugin/debug/__tests__/socket.test.ts rename to packages/plugin/src/plugin/bridge/__tests__/socket.test.ts diff --git a/packages/plugin/src/plugin/debug/adapter.ts b/packages/plugin/src/plugin/bridge/adapter.ts similarity index 100% rename from packages/plugin/src/plugin/debug/adapter.ts rename to packages/plugin/src/plugin/bridge/adapter.ts diff --git a/packages/plugin/src/plugin/debug/pipe-path.ts b/packages/plugin/src/plugin/bridge/pipe-path.ts similarity index 100% rename from packages/plugin/src/plugin/debug/pipe-path.ts rename to packages/plugin/src/plugin/bridge/pipe-path.ts diff --git a/packages/plugin/src/plugin/debug/socket.ts b/packages/plugin/src/plugin/bridge/socket.ts similarity index 100% rename from packages/plugin/src/plugin/debug/socket.ts rename to packages/plugin/src/plugin/bridge/socket.ts diff --git a/packages/plugin/src/plugin/index.ts b/packages/plugin/src/plugin/index.ts index eda77533..1a70f238 100644 --- a/packages/plugin/src/plugin/index.ts +++ b/packages/plugin/src/plugin/index.ts @@ -4,7 +4,7 @@ import type { Logger } from "@elgato/utils/logging"; import type { Language, RegistrationInfo } from "../api/index.js"; import { actionService, type ActionService } from "./actions/service.js"; import { connection } from "./connection.js"; -import { debug } from "./debug/adapter.js"; +import { debug } from "./bridge/adapter.js"; import { deviceService, type DeviceService } from "./devices/service.js"; import { fileSystemLocaleProvider } from "./i18n.js"; import { logger } from "./logging/index.js"; From e7287e9f8b9c207659709d888221836317f09d71 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Thu, 4 Jun 2026 15:48:44 -0400 Subject: [PATCH 06/18] clarify names and structures --- .../plugin/src/plugin/__tests__/index.test.ts | 6 +- .../src/plugin/bridge/__mocks__/adapter.ts | 4 +- .../plugin/bridge/__tests__/adapter.test.ts | 18 +++--- .../plugin/bridge/__tests__/socket.test.ts | 33 +++++----- packages/plugin/src/plugin/bridge/adapter.ts | 61 +++++++++---------- .../plugin/src/plugin/bridge/pipe-path.ts | 6 +- packages/plugin/src/plugin/bridge/socket.ts | 30 ++++----- packages/plugin/src/plugin/index.ts | 4 +- 8 files changed, 80 insertions(+), 82 deletions(-) diff --git a/packages/plugin/src/plugin/__tests__/index.test.ts b/packages/plugin/src/plugin/__tests__/index.test.ts index 5daf1a27..9d567071 100644 --- a/packages/plugin/src/plugin/__tests__/index.test.ts +++ b/packages/plugin/src/plugin/__tests__/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { BarSubType, DeviceType, Target } from "../../api/index.js"; import { SingletonAction } from "../actions/singleton-action.js"; import { connection } from "../connection.js"; -import { debug } from "../bridge/adapter.js"; +import { bridge } from "../bridge/adapter.js"; import streamDeckAsDefaultExport, { streamDeck } from "../index.js"; import { logger } from "../logging/index.js"; @@ -11,7 +11,7 @@ vi.mock("../../common/i18n.js"); vi.mock("../logging/index.js"); vi.mock("../manifest.js"); vi.mock("../connection.js"); -vi.mock("../debug/adapter.js"); +vi.mock("../bridge/adapter.js"); describe("index", () => { /** @@ -49,7 +49,7 @@ describe("index", () => { it("connects", async () => { // Arrange. const spyOnConnect = vi.spyOn(connection, "connect"); - const spyOnStart = vi.spyOn(debug, "start"); + const spyOnStart = vi.spyOn(bridge, "start"); // Act, assert. await streamDeck.connect(); diff --git a/packages/plugin/src/plugin/bridge/__mocks__/adapter.ts b/packages/plugin/src/plugin/bridge/__mocks__/adapter.ts index 3ecab578..9935d7d6 100644 --- a/packages/plugin/src/plugin/bridge/__mocks__/adapter.ts +++ b/packages/plugin/src/plugin/bridge/__mocks__/adapter.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -export const debug = { +export const bridge = { start: vi.fn().mockResolvedValue(undefined), -}; \ No newline at end of file +}; diff --git a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts index 3e91851d..1c9e077a 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts @@ -7,7 +7,7 @@ vi.mock("../../connection.js"); vi.mock("../../manifest.js"); describe("debug adapter", () => { - let adapter: Awaited["debug"]; + let adapter: Awaited["bridge"]; let connection: Awaited["connection"]; let sent: Array; @@ -16,7 +16,7 @@ describe("debug adapter", () => { sent = []; ({ connection } = await import("../../connection.js")); - ({ debug: adapter } = await import("../adapter.js")); + ({ bridge: adapter } = await import("../adapter.js")); adapter.attachRpc(async (value) => { sent.push(value); }); @@ -70,7 +70,7 @@ describe("debug adapter", () => { expect(sent).toEqual([ { jsonrpc: "2.0", - method: "streamDeck.debug.snapshotChanged", + method: "streamDeck.bridge.snapshotChanged", params: adapter.getSnapshot(), }, ]); @@ -121,7 +121,7 @@ describe("debug adapter", () => { const handled = await adapter.receive({ id: "request-1", jsonrpc: "2.0", - method: "streamDeck.debug.getSnapshot", + method: "streamDeck.bridge.getSnapshot", }); expect(handled).toBe(true); @@ -145,17 +145,17 @@ describe("debug adapter", () => { }; }); vi.doMock("../socket.js", () => ({ - debugSocket: { + socket: { start: vi.fn().mockResolvedValue(undefined), }, })); - const { debug } = await import("../adapter.js"); - const { debugSocket } = await import("../socket.js"); + const { bridge } = await import("../adapter.js"); + const { socket } = await import("../socket.js"); - await debug.start(); + await bridge.start(); - expect(debugSocket.start).not.toHaveBeenCalled(); + expect(socket.start).not.toHaveBeenCalled(); }); }); diff --git a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts index 9931dc80..ce777bcf 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts @@ -5,7 +5,7 @@ import { access } from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WillAppear } from "../../../api/index.js"; -import { getDebugPipePath } from "../pipe-path.js"; +import { getPipePath } from "../pipe-path.js"; vi.mock("../../connection.js"); vi.mock("../../common/utils.js", async () => { @@ -19,42 +19,41 @@ vi.mock("../../common/utils.js", async () => { vi.mock("../../manifest.js"); vi.mock("../../logging/index.js"); -// The plugin SDK derives its debug socket path from the (mocked) manifest UUID. -const pipePath = getDebugPipePath("com.elgato.test"); +const pipePath = getPipePath("com.elgato.test"); -describe("debug socket", () => { - let debug: Awaited["debug"]; +describe("bridge socket", () => { + let bridge: Awaited["bridge"]; let connection: Awaited["connection"]; - let debugSocket: Awaited["debugSocket"]; + let socket: Awaited["socket"]; beforeEach(async () => { vi.resetModules(); - ({ debug } = await import("../adapter.js")); + ({ bridge } = await import("../adapter.js")); ({ connection } = await import("../../connection.js")); - ({ debugSocket } = await import("../socket.js")); + ({ socket } = await import("../socket.js")); }); afterEach(async () => { - await debugSocket.stop(); + await socket.stop(); vi.clearAllMocks(); }); it("serves snapshot requests over the debug socket", async () => { - await debug.start(); + await bridge.start(); const client = await connect(); const response = await getSnapshot(client); expect(JSON.parse(response)).toEqual({ id: "request-1", jsonrpc: "2.0", - result: debug.getSnapshot(), + result: bridge.getSnapshot(), }); client.destroy(); }); it("forwards snapshot change notifications to the connected client", async () => { - await debug.start(); + await bridge.start(); const client = await connect(); const notification = receive(client); @@ -62,20 +61,20 @@ describe("debug socket", () => { expect(JSON.parse(await notification)).toEqual({ jsonrpc: "2.0", - method: "streamDeck.debug.snapshotChanged", - params: debug.getSnapshot(), + method: "streamDeck.bridge.snapshotChanged", + params: bridge.getSnapshot(), }); client.destroy(); }); it("removes the socket file when stopped", async () => { - await debug.start(); + await bridge.start(); if (platform() !== "win32") { await expect(access(pipePath)).resolves.toBeUndefined(); } - await debugSocket.stop(); + await socket.stop(); if (platform() !== "win32") { await expect(access(pipePath)).rejects.toThrow(); } @@ -134,7 +133,7 @@ function getSnapshot(client: Socket): Promise { `${JSON.stringify({ id: "request-1", jsonrpc: "2.0", - method: "streamDeck.debug.getSnapshot", + method: "streamDeck.bridge.getSnapshot", })}\n`, ); diff --git a/packages/plugin/src/plugin/bridge/adapter.ts b/packages/plugin/src/plugin/bridge/adapter.ts index a784913e..b7dcaedc 100644 --- a/packages/plugin/src/plugin/bridge/adapter.ts +++ b/packages/plugin/src/plugin/bridge/adapter.ts @@ -13,12 +13,12 @@ import { isDebugMode } from "../common/utils.js"; import { connection } from "../connection.js"; import { logger } from "../logging/index.js"; import { getManifest } from "../manifest.js"; -import { debugSocket } from "./socket.js"; +import { socket } from "./socket.js"; /** - * Internal JSON-RPC adapter that projects connection events into a serialized debug state snapshot. + * Internal JSON-RPC adapter that projects connection events into a serialized plugin snapshot. */ -class DebugAdapter { +class BridgeAdapter { /** * Tracked device names indexed by device identifier. */ @@ -55,7 +55,7 @@ class DebugAdapter { #seededDevices = false; /** - * Initializes a new debug adapter instance. + * Initializes a new bridge adapter instance. */ constructor() { this.#disposables.push( @@ -115,8 +115,8 @@ class DebugAdapter { */ public attachRpc(send: RpcSender): void { this.#rpc = createRpcServerClient(send); - this.#rpc.addMethod("streamDeck.debug.getSnapshot", () => this.getSnapshot()); - this.#rpc.addMethod("streamDeck.debug.setSettings", (params) => this.#setSettings(params)); + this.#rpc.addMethod("streamDeck.bridge.getSnapshot", () => this.getSnapshot()); + this.#rpc.addMethod("streamDeck.bridge.setSettings", (params) => this.#setSettings(params)); } /** @@ -127,14 +127,14 @@ class DebugAdapter { } /** - * Builds the current debug state snapshot. - * @returns Current debug state snapshot. + * Builds the current plugin snapshot. + * @returns Current plugin snapshot. */ - public getSnapshot(): DebugSnapshot { + public getSnapshot(): PluginSnapshot { this.#seedDevices(); const manifestActions = this.#getManifest()?.Actions ?? []; - const groups = new Map(); + const groups = new Map(); manifestActions.forEach((action) => { groups.set(action.UUID, { @@ -182,7 +182,7 @@ class DebugAdapter { } /** - * Starts the adapter's debug transport when the plugin is running in debug mode. + * Starts the bridge transport when the plugin is running in debug mode. * @returns A promise resolved when startup is complete. */ public async start(): Promise { @@ -192,9 +192,9 @@ class DebugAdapter { const uuid = this.#getManifest()?.UUID ?? connection.registrationParameters.info.plugin.uuid; try { - await debugSocket.start(this, uuid); + await socket.start(this, uuid); } catch (err) { - logger.warn("Failed to start debug socket", err); + logger.warn("Failed to start bridge socket", err); } } @@ -265,7 +265,7 @@ class DebugAdapter { this.#lastSnapshot = serialized; if (this.#rpc) { - await this.#rpc.notify("streamDeck.debug.snapshotChanged", snapshot); + await this.#rpc.notify("streamDeck.bridge.snapshotChanged", snapshot); } } @@ -297,7 +297,7 @@ class DebugAdapter { * @param payload Action payload associated with a visible instance. * @returns Normalized position snapshot. */ - #toPosition(payload: DidReceiveSettings["payload"] | WillAppear["payload"]): DebugPosition { + #toPosition(payload: DidReceiveSettings["payload"] | WillAppear["payload"]): ActionPosition { if (payload.controller === "Encoder") { return { column: payload.coordinates.column, @@ -322,12 +322,12 @@ class DebugAdapter { } /** - * Singleton internal debug adapter. + * Singleton bridge adapter. */ -export const debug = new DebugAdapter(); +export const bridge = new BridgeAdapter(); /** - * Parameters for the `streamDeck.debug.setSettings` RPC method. + * Parameters for the `streamDeck.bridge.setSettings` RPC method. */ type SetSettingsParams = { /** @@ -342,28 +342,28 @@ type SetSettingsParams = { }; /** - * Serializable snapshot of the plugin's debug state. + * Serializable snapshot of the plugin's visible action state. */ -type DebugSnapshot = { +type PluginSnapshot = { /** * Actions known to the plugin, including currently visible instances. */ - actions: DebugActionState[]; + actions: ActionState[]; /** * Plugin metadata associated with the snapshot. */ - plugin: DebugPluginState; + plugin: PluginState; }; /** * Snapshot of a manifest action and its currently visible instances. */ -type DebugActionState = { +type ActionState = { /** * Visible instances associated with the action. */ - instances: DebugActionInstanceState[]; + instances: ActionInstanceState[]; /** * Human-readable action name. @@ -379,7 +379,7 @@ type DebugActionState = { /** * Snapshot of a visible action instance. */ -type DebugActionInstanceState = { +type ActionInstanceState = { /** * Unique context identifier for the action instance. */ @@ -398,7 +398,7 @@ type DebugActionInstanceState = { /** * Normalized position information for the instance. */ - position: DebugPosition; + position: ActionPosition; /** * Persisted action settings. @@ -407,9 +407,9 @@ type DebugActionInstanceState = { }; /** - * Serializable plugin information exposed by the debug state snapshot. + * Serializable plugin information exposed by the snapshot. */ -type DebugPluginState = { +type PluginState = { /** * Human-readable plugin name. */ @@ -429,7 +429,7 @@ type DebugPluginState = { /** * Normalized position associated with an action instance. */ -type DebugPosition = +type ActionPosition = | { /** * Dial column reported by Stream Deck. @@ -501,11 +501,10 @@ type InternalInstanceState = { /** * Normalized position for the instance. */ - position: DebugPosition; + position: ActionPosition; /** * Last known settings associated with the instance. */ settings: JsonObject; }; - diff --git a/packages/plugin/src/plugin/bridge/pipe-path.ts b/packages/plugin/src/plugin/bridge/pipe-path.ts index 77bdd1c6..3cc068ba 100644 --- a/packages/plugin/src/plugin/bridge/pipe-path.ts +++ b/packages/plugin/src/plugin/bridge/pipe-path.ts @@ -3,18 +3,18 @@ import { platform, tmpdir } from "node:os"; import { join } from "node:path"; // NOTE: This derivation MUST stay byte-for-byte identical to the VS Code extension's copy -// (vscode-streamdeck: src/debug/pipe-path.ts). Both sides compute the rendezvous path from the +// (vscode-streamdeck: src/bridge/pipe-path.ts). Both sides compute the rendezvous path from the // plugin UUID independently; if they diverge, the extension can no longer find the plugin. /** - * Computes the debug transport pipe path for a plugin, derived deterministically from its UUID. + * Computes the pipe path for the bridge transport, derived deterministically from the plugin UUID. * * The UUID is hashed to a short, filesystem-safe token so the resulting Unix domain socket path * stays within the platform's path length limit (~104 bytes on macOS). * @param uuid Plugin UUID. * @returns Named pipe path (Windows) or Unix domain socket path (macOS/Linux). */ -export function getDebugPipePath(uuid: string): string { +export function getPipePath(uuid: string): string { const token = `sd-${createHash("sha1").update(uuid).digest("hex").slice(0, 16)}`; if (platform() === "win32") { return `\\\\.\\pipe\\${token}`; diff --git a/packages/plugin/src/plugin/bridge/socket.ts b/packages/plugin/src/plugin/bridge/socket.ts index d480a119..ac7286df 100644 --- a/packages/plugin/src/plugin/bridge/socket.ts +++ b/packages/plugin/src/plugin/bridge/socket.ts @@ -5,16 +5,16 @@ import { createServer, type Server, type Socket } from "node:net"; import { platform } from "node:os"; import { logger } from "../logging/index.js"; -import { getDebugPipePath } from "./pipe-path.js"; +import { getPipePath } from "./pipe-path.js"; /** - * Hosts the internal debug adapter over a named pipe (Windows) or Unix domain socket + * Hosts the bridge adapter over a named pipe (Windows) or Unix domain socket * (macOS/Linux) whose path is derived from the plugin UUID, so the VS Code extension can * discover and connect to it without any port handshake. */ -class DebugSocket { +class BridgeSocket { /** - * Connected VS Code debug client. + * Connected VS Code client. */ #client: Socket | undefined; @@ -26,7 +26,7 @@ class DebugSocket { /** * RPC host bound to the socket transport. */ - #rpcHost: DebugSocketRpcHost | undefined; + #rpcHost: SocketRpcHost | undefined; /** * Underlying socket server. @@ -34,11 +34,11 @@ class DebugSocket { #server: Server | undefined; /** - * Starts the debug socket server on the UUID-derived path so the VS Code extension can connect. + * Starts the bridge socket server on the UUID-derived path so the VS Code extension can connect. * @param rpcHost RPC host bound to the socket transport. * @param uuid Plugin UUID used to derive the pipe path. */ - public async start(rpcHost: DebugSocketRpcHost, uuid: string): Promise { + public async start(rpcHost: SocketRpcHost, uuid: string): Promise { if (this.#server) { return; } @@ -46,7 +46,7 @@ class DebugSocket { this.#rpcHost = rpcHost; rpcHost.attachRpc((message) => this.#send(message)); - const path = getDebugPipePath(uuid); + const path = getPipePath(uuid); this.#path = path; // A Unix domain socket leaves a file behind if the previous process crashed; remove any @@ -63,11 +63,11 @@ class DebugSocket { server.listen(path); }); - logger.debug(`Debug socket listening on ${path}`); + logger.debug(`Bridge socket listening on ${path}`); } /** - * Stops the debug socket server and removes its socket file. + * Stops the bridge socket server and removes its socket file. */ public async stop(): Promise { this.#client?.destroy(); @@ -86,7 +86,7 @@ class DebugSocket { } /** - * Binds a newly connected VS Code debug client. + * Binds a newly connected VS Code client. * @param socket Connected socket. */ #onConnection(socket: Socket): void { @@ -140,14 +140,14 @@ class DebugSocket { } /** - * Singleton debug socket transport. + * Singleton bridge socket transport. */ -export const debugSocket = new DebugSocket(); +export const socket = new BridgeSocket(); /** - * Minimal RPC host interface required by the debug socket transport. + * Minimal RPC host interface required by the bridge socket transport. */ -type DebugSocketRpcHost = { +type SocketRpcHost = { /** * Attaches the socket transport to the RPC host. * @param send Sender used for outbound JSON-RPC messages. diff --git a/packages/plugin/src/plugin/index.ts b/packages/plugin/src/plugin/index.ts index 1a70f238..09b19bd1 100644 --- a/packages/plugin/src/plugin/index.ts +++ b/packages/plugin/src/plugin/index.ts @@ -4,7 +4,7 @@ import type { Logger } from "@elgato/utils/logging"; import type { Language, RegistrationInfo } from "../api/index.js"; import { actionService, type ActionService } from "./actions/service.js"; import { connection } from "./connection.js"; -import { debug } from "./bridge/adapter.js"; +import { bridge } from "./bridge/adapter.js"; import { deviceService, type DeviceService } from "./devices/service.js"; import { fileSystemLocaleProvider } from "./i18n.js"; import { logger } from "./logging/index.js"; @@ -117,7 +117,7 @@ export const streamDeck = { */ async connect(): Promise { await connection.connect(); - await debug.start(); + await bridge.start(); }, }; From 704dc30015b31834e7ed5e9a3071b90f3a3f2a11 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Fri, 5 Jun 2026 17:42:36 -0400 Subject: [PATCH 07/18] clean up connection race condition --- .../plugin/bridge/__tests__/socket.test.ts | 61 ++++++++++++++++++- packages/plugin/src/plugin/bridge/socket.ts | 39 +++++++++--- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts index ce777bcf..3baaff25 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts @@ -1,4 +1,5 @@ -import { withResolvers } from "@elgato/utils"; +import { type JsonValue, withResolvers } from "@elgato/utils"; +import type { RpcSender } from "@elgato/utils/rpc"; import { createConnection, type Socket } from "node:net"; import { platform } from "node:os"; import { access } from "node:fs/promises"; @@ -68,6 +69,51 @@ describe("bridge socket", () => { client.destroy(); }); + it("rebinds the RPC host for an active socket when started again", async () => { + await bridge.start(); + const client = await connect(); + let send: RpcSender | undefined; + + await socket.start( + { + attachRpc: (nextSend) => { + send = nextSend; + }, + receive: async (value: JsonValue) => { + const id = getRequestId(value); + if (!id || !send) { + return false; + } + + await send({ + id, + jsonrpc: "2.0", + result: "rebound", + }); + return true; + }, + }, + "com.elgato.test", + ); + + const response = receive(client); + client.write( + `${JSON.stringify({ + id: "request-1", + jsonrpc: "2.0", + method: "test.rebound", + })}\n`, + ); + + expect(JSON.parse(await response)).toEqual({ + id: "request-1", + jsonrpc: "2.0", + result: "rebound", + }); + + client.destroy(); + }); + it("removes the socket file when stopped", async () => { await bridge.start(); if (platform() !== "win32") { @@ -140,6 +186,19 @@ function getSnapshot(client: Socket): Promise { return response; } +/** + * Gets a JSON-RPC request id from a received message. + * @param value Received JSON value. + * @returns Request id when present. + */ +function getRequestId(value: JsonValue): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + + return typeof value.id === "string" ? value.id : undefined; +} + /** * Receives the next newline-delimited message from the client. * @param client Socket client. diff --git a/packages/plugin/src/plugin/bridge/socket.ts b/packages/plugin/src/plugin/bridge/socket.ts index ac7286df..e4690e9f 100644 --- a/packages/plugin/src/plugin/bridge/socket.ts +++ b/packages/plugin/src/plugin/bridge/socket.ts @@ -39,13 +39,15 @@ class BridgeSocket { * @param uuid Plugin UUID used to derive the pipe path. */ public async start(rpcHost: SocketRpcHost, uuid: string): Promise { + this.#rpcHost = rpcHost; if (this.#server) { + if (this.#client) { + this.#attachRpc(this.#client); + } + return; } - this.#rpcHost = rpcHost; - rpcHost.attachRpc((message) => this.#send(message)); - const path = getPipePath(uuid); this.#path = path; @@ -92,17 +94,22 @@ class BridgeSocket { #onConnection(socket: Socket): void { this.#client?.destroy(); this.#client = socket; + this.#attachRpc(socket); let buffer = ""; socket.setEncoding("utf-8"); - socket.on("data", (chunk: string) => { + socket.on("data", async (chunk: string) => { buffer += chunk; let newline = buffer.indexOf("\n"); while (newline !== -1) { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); if (line.length > 0) { - void this.#onMessage(line); + try { + await this.#onMessage(socket, line); + } catch { + // Ignore malformed or stale bridge messages; reconnect will refresh state. + } } newline = buffer.indexOf("\n"); @@ -120,21 +127,35 @@ class BridgeSocket { }); } + /** + * Attaches the RPC host to a socket-specific sender. + * @param socket Socket used by the RPC sender. + */ + #attachRpc(socket: Socket): void { + this.#rpcHost?.attachRpc((message) => this.#send(socket, message)); + } + /** * Forwards a JSON-RPC message from the client to the RPC host. + * @param socket Socket that received the message. * @param line Newline-delimited JSON message. */ - async #onMessage(line: string): Promise { + async #onMessage(socket: Socket, line: string): Promise { + if (this.#client !== socket) { + return; + } + await this.#rpcHost?.receive(JSON.parse(line)); } /** * Sends a JSON-RPC message to the connected client. + * @param socket Socket associated with the RPC exchange. * @param message Message to send. */ - async #send(message: unknown): Promise { - if (this.#client && !this.#client.destroyed) { - this.#client.write(`${JSON.stringify(message)}\n`); + async #send(socket: Socket, message: unknown): Promise { + if (this.#client === socket && !socket.destroyed) { + socket.write(`${JSON.stringify(message)}\n`); } } } From 4892be45ba6ebe0cfdd90d248840a2b40144267d Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Fri, 5 Jun 2026 17:54:52 -0400 Subject: [PATCH 08/18] clean up error handling --- packages/plugin/src/plugin/bridge/socket.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugin/src/plugin/bridge/socket.ts b/packages/plugin/src/plugin/bridge/socket.ts index e4690e9f..c6b764af 100644 --- a/packages/plugin/src/plugin/bridge/socket.ts +++ b/packages/plugin/src/plugin/bridge/socket.ts @@ -105,11 +105,7 @@ class BridgeSocket { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); if (line.length > 0) { - try { - await this.#onMessage(socket, line); - } catch { - // Ignore malformed or stale bridge messages; reconnect will refresh state. - } + await this.#onMessage(socket, line); } newline = buffer.indexOf("\n"); @@ -145,7 +141,11 @@ class BridgeSocket { return; } - await this.#rpcHost?.receive(JSON.parse(line)); + try { + await this.#rpcHost?.receive(JSON.parse(line)); + } catch { + // Ignore malformed or stale bridge messages; reconnect will refresh state. + } } /** From 86904aab72e79cda47126e0931d45c9792f92a02 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Fri, 5 Jun 2026 18:05:01 -0400 Subject: [PATCH 09/18] fix cicd --- packages/plugin/src/plugin/bridge/__tests__/socket.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts index 3baaff25..c25b1c7d 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts @@ -56,6 +56,7 @@ describe("bridge socket", () => { it("forwards snapshot change notifications to the connected client", async () => { await bridge.start(); const client = await connect(); + await getSnapshot(client); // Wait for the initial snapshot response to ensure the RPC connection is ready. const notification = receive(client); connection.emit("willAppear", createKeyWillAppear()); From 7501bdc9e76a27e212a60a5518a9daf6a59010c6 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Tue, 9 Jun 2026 11:35:19 -0400 Subject: [PATCH 10/18] remove unecessary function --- packages/plugin/src/plugin/bridge/adapter.ts | 33 +++++++++----------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/plugin/src/plugin/bridge/adapter.ts b/packages/plugin/src/plugin/bridge/adapter.ts index b7dcaedc..5e60bb2b 100644 --- a/packages/plugin/src/plugin/bridge/adapter.ts +++ b/packages/plugin/src/plugin/bridge/adapter.ts @@ -245,27 +245,24 @@ class BridgeAdapter { return this.#devices.get(id) ?? id; } - /** - * Schedules publication of the latest snapshot when it changed. - */ - #publishChanges(): void { - this.#notifyIfChanged(); - } - /** * Publishes the current snapshot when it differs from the last emitted state. */ - async #notifyIfChanged(): Promise { - const snapshot = this.getSnapshot(); - const serialized = JSON.stringify(snapshot); - - if (serialized === this.#lastSnapshot) { - return; - } - - this.#lastSnapshot = serialized; - if (this.#rpc) { - await this.#rpc.notify("streamDeck.bridge.snapshotChanged", snapshot); + async #publishChanges(): Promise { + try { + const snapshot = this.getSnapshot(); + const serialized = JSON.stringify(snapshot); + + if (serialized === this.#lastSnapshot) { + return; + } + + this.#lastSnapshot = serialized; + if (this.#rpc) { + await this.#rpc.notify("streamDeck.bridge.snapshotChanged", snapshot); + } + } catch { + // Swallow transport errors to avoid disrupting the plugin; } } From e1629699a6c9967e29cfccbc00dfa39d59c639da Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Tue, 9 Jun 2026 11:36:56 -0400 Subject: [PATCH 11/18] update test name --- packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts index 1c9e077a..4d393ce2 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts @@ -134,7 +134,7 @@ describe("debug adapter", () => { ]); }); - it("does not start the websocket transport outside debug mode", async () => { + it("does not start the socket transport outside debug mode", async () => { vi.resetModules(); vi.doMock("../../common/utils.js", async () => { const actual = await vi.importActual("../../common/utils.js"); From 5ea245afc144e088cf467a2cc2f3ebb3fdb21455 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Tue, 9 Jun 2026 12:56:13 -0400 Subject: [PATCH 12/18] refactor: improve socket server initialization in BridgeSocket --- .../plugin/bridge/__tests__/socket.test.ts | 50 ++++++++++++++++++- packages/plugin/src/plugin/bridge/socket.ts | 4 +- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts index c25b1c7d..404f4b8f 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts @@ -1,6 +1,7 @@ import { type JsonValue, withResolvers } from "@elgato/utils"; import type { RpcSender } from "@elgato/utils/rpc"; -import { createConnection, type Socket } from "node:net"; +import { EventEmitter } from "node:events"; +import { createConnection, type Server, type Socket } from "node:net"; import { platform } from "node:os"; import { access } from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -115,6 +116,53 @@ describe("bridge socket", () => { client.destroy(); }); + it("retries listening after a startup error", async () => { + vi.resetModules(); + const listen = vi.fn(); + + vi.doMock("node:net", () => ({ + createServer: vi.fn(() => { + const server = new EventEmitter() as EventEmitter & Pick; + server.close = ((callback?: (err?: Error) => void) => { + callback?.(); + return server; + }) as Server["close"]; + server.listen = ((path: string) => { + listen(path); + const attempt = listen.mock.calls.length; + + queueMicrotask(() => { + if (attempt === 1) { + server.emit("error", new Error("address in use")); + } else { + server.emit("listening"); + } + }); + + return server; + }) as Server["listen"]; + + return server; + }), + })); + + try { + const { socket: retrySocket } = await import("../socket.js"); + const rpcHost = { + attachRpc: vi.fn(), + receive: vi.fn().mockResolvedValue(false), + }; + + await expect(retrySocket.start(rpcHost, "com.elgato.test")).rejects.toThrow("address in use"); + await expect(retrySocket.start(rpcHost, "com.elgato.test")).resolves.toBeUndefined(); + await retrySocket.stop(); + } finally { + vi.doUnmock("node:net"); + } + + expect(listen).toHaveBeenCalledTimes(2); + }); + it("removes the socket file when stopped", async () => { await bridge.start(); if (platform() !== "win32") { diff --git a/packages/plugin/src/plugin/bridge/socket.ts b/packages/plugin/src/plugin/bridge/socket.ts index c6b764af..51c5013f 100644 --- a/packages/plugin/src/plugin/bridge/socket.ts +++ b/packages/plugin/src/plugin/bridge/socket.ts @@ -49,7 +49,6 @@ class BridgeSocket { } const path = getPipePath(uuid); - this.#path = path; // A Unix domain socket leaves a file behind if the previous process crashed; remove any // stale socket before listening to avoid EADDRINUSE. Windows named pipes self-clean. @@ -58,13 +57,14 @@ class BridgeSocket { } const server = createServer((socket) => this.#onConnection(socket)); - this.#server = server; await new Promise((resolve, reject) => { server.once("listening", () => resolve()); server.once("error", (err) => reject(err)); server.listen(path); }); + this.#server = server; + this.#path = path; logger.debug(`Bridge socket listening on ${path}`); } From 36f0bef890fa3a5a33f74ffe70dbe6dc228c877f Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Tue, 9 Jun 2026 13:49:09 -0400 Subject: [PATCH 13/18] add snapshot default value --- packages/plugin/src/plugin/bridge/adapter.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/plugin/src/plugin/bridge/adapter.ts b/packages/plugin/src/plugin/bridge/adapter.ts index 5e60bb2b..b5d1354c 100644 --- a/packages/plugin/src/plugin/bridge/adapter.ts +++ b/packages/plugin/src/plugin/bridge/adapter.ts @@ -47,7 +47,7 @@ class BridgeAdapter { /** * Last serialized snapshot emitted by the adapter. */ - #lastSnapshot: string; + #lastSnapshot = ""; /** * Determines whether registration devices have been seeded into the internal device map. @@ -105,8 +105,6 @@ class BridgeAdapter { } }), ); - - this.#lastSnapshot = JSON.stringify(this.getSnapshot()); } /** From 6a87c14b456346404e5c3486b6e40d9b02b9acb1 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Tue, 9 Jun 2026 14:12:28 -0400 Subject: [PATCH 14/18] queue snapshot promise --- packages/plugin/src/plugin/bridge/adapter.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/plugin/src/plugin/bridge/adapter.ts b/packages/plugin/src/plugin/bridge/adapter.ts index b5d1354c..aeceaad8 100644 --- a/packages/plugin/src/plugin/bridge/adapter.ts +++ b/packages/plugin/src/plugin/bridge/adapter.ts @@ -49,6 +49,11 @@ class BridgeAdapter { */ #lastSnapshot = ""; + /** + * Tail of the serialized publication promise chain. + */ + #publishQueue: Promise = Promise.resolve(); + /** * Determines whether registration devices have been seeded into the internal device map. */ @@ -243,10 +248,17 @@ class BridgeAdapter { return this.#devices.get(id) ?? id; } + /** + * Enqueues a snapshot publication, ensuring notifications are emitted in call order. + */ + #publishChanges(): void { + this.#publishQueue = this.#publishQueue.then(() => this.#doPublish()); + } + /** * Publishes the current snapshot when it differs from the last emitted state. */ - async #publishChanges(): Promise { + async #doPublish(): Promise { try { const snapshot = this.getSnapshot(); const serialized = JSON.stringify(snapshot); From 1468daabcdde1245655a644aac59d7eb219e7d4b Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Tue, 9 Jun 2026 15:26:25 -0400 Subject: [PATCH 15/18] fix socket data race condition --- .../plugin/bridge/__tests__/adapter.test.ts | 21 +++++++++++++++++-- packages/plugin/src/plugin/bridge/socket.ts | 20 +++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts index 4d393ce2..0ec93267 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts @@ -64,8 +64,9 @@ describe("debug adapter", () => { }); }); - it("publishes snapshot changes for visible instances", () => { + it("publishes snapshot changes for visible instances", async () => { connection.emit("willAppear", createKeyWillAppear()); + await waitForMessages(sent, 1); expect(sent).toEqual([ { @@ -93,11 +94,13 @@ describe("debug adapter", () => { }); }); - it("updates settings, device details, and removals from connection events", () => { + it("updates settings, device details, and removals from connection events", async () => { connection.emit("willAppear", createKeyWillAppear()); + await waitForMessages(sent, 1); clearMessages(sent); connection.emit("didReceiveSettings", createDidReceiveSettings()); + await waitForMessages(sent, 1); expect(adapter.getSnapshot().actions[0].instances[0]?.settings).toEqual({ count: 7, }); @@ -105,17 +108,20 @@ describe("debug adapter", () => { clearMessages(sent); connection.emit("deviceDidChange", createDeviceDidChange()); + await waitForMessages(sent, 1); expect(adapter.getSnapshot().actions[0].instances[0]?.device).toBe("Renamed Device"); expect(sent).toHaveLength(1); clearMessages(sent); connection.emit("willDisappear", createWillDisappear()); + await waitForMessages(sent, 1); expect(adapter.getSnapshot().actions[0].instances).toEqual([]); expect(sent).toHaveLength(1); }); it("responds to getSnapshot RPC requests", async () => { connection.emit("willAppear", createKeyWillAppear()); + await waitForMessages(sent, 1); clearMessages(sent); const handled = await adapter.receive({ @@ -167,6 +173,17 @@ function clearMessages(messages: Array): void messages.splice(0, messages.length); } +/** + * Waits for the adapter's queued JSON-RPC messages to be sent. + * @param messages Recorded messages. + * @param length Expected message count. + */ +async function waitForMessages(messages: Array, length: number): Promise { + await vi.waitFor(() => { + expect(messages).toHaveLength(length); + }); +} + /** * Creates a device-change event for the seeded mock device. * @returns Device-change event. diff --git a/packages/plugin/src/plugin/bridge/socket.ts b/packages/plugin/src/plugin/bridge/socket.ts index 51c5013f..9dcd43a9 100644 --- a/packages/plugin/src/plugin/bridge/socket.ts +++ b/packages/plugin/src/plugin/bridge/socket.ts @@ -1,4 +1,4 @@ -import { type JsonValue } from "@elgato/utils"; +import { type JsonValue, withResolvers } from "@elgato/utils"; import type { RpcSender } from "@elgato/utils/rpc"; import { rm } from "node:fs/promises"; import { createServer, type Server, type Socket } from "node:net"; @@ -97,15 +97,29 @@ class BridgeSocket { this.#attachRpc(socket); let buffer = ""; + let messageReceived = Promise.resolve(); + const receiveMessage = async (line: string): Promise => { + const previousMessageReceived = messageReceived; + const received = withResolvers(); + messageReceived = received.promise; + + try { + await previousMessageReceived; + await this.#onMessage(socket, line); + } finally { + received.resolve(); + } + }; + socket.setEncoding("utf-8"); - socket.on("data", async (chunk: string) => { + socket.on("data", (chunk: string) => { buffer += chunk; let newline = buffer.indexOf("\n"); while (newline !== -1) { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); if (line.length > 0) { - await this.#onMessage(socket, line); + receiveMessage(line); } newline = buffer.indexOf("\n"); From 0d677afb55d5997703c46a51f5e8213b17a6e627 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Tue, 9 Jun 2026 15:45:23 -0400 Subject: [PATCH 16/18] add changeset --- .changeset/curly-poets-run.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/curly-poets-run.md diff --git a/.changeset/curly-poets-run.md b/.changeset/curly-poets-run.md new file mode 100644 index 00000000..53947fe4 --- /dev/null +++ b/.changeset/curly-poets-run.md @@ -0,0 +1,5 @@ +--- +"@elgato/streamdeck": minor +--- + +Add connection bridge to enable communication with the VS Code extension. From 335b3419bcf23a4c366e7fe074685810aaebdb74 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Wed, 10 Jun 2026 18:58:27 -0400 Subject: [PATCH 17/18] remove unecessary types, add resources to vscode adapter --- .../plugin/bridge/__tests__/adapter.test.ts | 41 ++++-- packages/plugin/src/plugin/bridge/adapter.ts | 135 +++++++----------- 2 files changed, 84 insertions(+), 92 deletions(-) diff --git a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts index 0ec93267..0543831c 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts @@ -36,12 +36,15 @@ describe("debug adapter", () => { { context: "ctx_001", controller: "Keypad", - device: "Device One", - position: { + coordinates: { column: 2, - kind: "key", row: 3, }, + device: "Device One", + isInMultiAction: false, + resources: { + thumbnail: "imgs/key.png", + }, settings: { count: 42, }, @@ -77,20 +80,21 @@ describe("debug adapter", () => { ]); }); - it("normalizes dial and multi-action positions", () => { + it("preserves coordinates and multi-action state", () => { connection.emit("willAppear", createDialWillAppear()); - expect(adapter.getSnapshot().actions[1].instances[0]?.position).toEqual({ + expect(adapter.getSnapshot().actions[1].instances[0]?.coordinates).toEqual({ column: 1, - index: 1, - kind: "dial", row: 0, }); connection.emit("willAppear", createMultiActionWillAppear()); - expect(adapter.getSnapshot().actions[0].instances[0]?.position).toEqual({ - kind: "multi-action", + const instance = adapter.getSnapshot().actions[0].instances[0]; + expect(instance?.isInMultiAction).toBe(true); + expect(instance?.coordinates).toEqual({ + column: 4, + row: 2, }); }); @@ -104,6 +108,9 @@ describe("debug adapter", () => { expect(adapter.getSnapshot().actions[0].instances[0]?.settings).toEqual({ count: 7, }); + expect(adapter.getSnapshot().actions[0].instances[0]?.resources).toEqual({ + thumbnail: "imgs/updated.png", + }); expect(sent).toHaveLength(1); clearMessages(sent); @@ -220,7 +227,9 @@ function createDidReceiveSettings(): DidReceiveSettings<{ count: number }> { row: 3, }, isInMultiAction: false, - resources: {}, + resources: { + thumbnail: "imgs/updated.png", + }, settings: { count: 7, }, @@ -245,7 +254,9 @@ function createDialWillAppear(): WillAppear<{ target: string }> { row: 0, }, isInMultiAction: false, - resources: {}, + resources: { + thumbnail: "imgs/dial.png", + }, settings: { target: "master", }, @@ -270,7 +281,9 @@ function createKeyWillAppear(): WillAppear<{ count: number }> { row: 3, }, isInMultiAction: false, - resources: {}, + resources: { + thumbnail: "imgs/key.png", + }, settings: { count: 42, }, @@ -290,6 +303,10 @@ function createMultiActionWillAppear(): WillAppear<{ count: number }> { event: "willAppear", payload: { controller: "Keypad", + coordinates: { + column: 4, + row: 2, + }, isInMultiAction: true, resources: {}, settings: { diff --git a/packages/plugin/src/plugin/bridge/adapter.ts b/packages/plugin/src/plugin/bridge/adapter.ts index aeceaad8..3a75b64d 100644 --- a/packages/plugin/src/plugin/bridge/adapter.ts +++ b/packages/plugin/src/plugin/bridge/adapter.ts @@ -2,10 +2,12 @@ import type { IDisposable, JsonObject, JsonValue } from "@elgato/utils"; import { createRpcServerClient, type RpcSender } from "@elgato/utils/rpc"; import type { + Coordinates, DeviceDidChange, DeviceDidConnect, DidReceiveSettings, Manifest, + Resources, WillAppear, WillDisappear, } from "../../api/index.js"; @@ -81,22 +83,29 @@ class BridgeAdapter { if (!instance) { return; } + const payload = ev.payload as PayloadWithCoordinates; instance.controller = ev.payload.controller; instance.deviceId = ev.device; - instance.position = this.#toPosition(ev.payload); + instance.isInMultiAction = payload.isInMultiAction ?? false; + instance.coordinates = payload.coordinates; + instance.resources = payload.resources ?? {}; instance.settings = ev.payload.settings; this.#publishChanges(); }), ); this.#disposables.push( connection.disposableOn("willAppear", (ev: WillAppear) => { + const payload = ev.payload as PayloadWithCoordinates; + this.#instances.set(ev.context, { context: ev.context, controller: ev.payload.controller, deviceId: ev.device, + isInMultiAction: payload.isInMultiAction ?? false, manifestId: ev.action, - position: this.#toPosition(ev.payload), + coordinates: payload.coordinates, + resources: payload.resources ?? {}, settings: ev.payload.settings, }); @@ -157,8 +166,10 @@ class BridgeAdapter { action.instances.push({ context: instance.context, controller: instance.controller, + coordinates: instance.coordinates, device: this.#getDeviceName(instance.deviceId), - position: instance.position, + isInMultiAction: instance.isInMultiAction, + resources: instance.resources, settings: instance.settings, }); @@ -298,34 +309,6 @@ class BridgeAdapter { #getManifest(): Manifest | null { return (this.#manifest ??= getManifest()); } - - /** - * Converts raw Stream Deck payload coordinates into a normalized position object. - * @param payload Action payload associated with a visible instance. - * @returns Normalized position snapshot. - */ - #toPosition(payload: DidReceiveSettings["payload"] | WillAppear["payload"]): ActionPosition { - if (payload.controller === "Encoder") { - return { - column: payload.coordinates.column, - index: payload.coordinates.column, - kind: "dial", - row: payload.coordinates.row, - }; - } - - if (payload.isInMultiAction) { - return { - kind: "multi-action", - }; - } - - return { - column: payload.coordinates.column, - kind: "key", - row: payload.coordinates.row, - }; - } } /** @@ -397,15 +380,25 @@ type ActionInstanceState = { */ controller: "Encoder" | "Keypad"; + /** + * Coordinates associated with the instance. + */ + coordinates: Coordinates; + /** * Name of the device the instance is currently shown on. */ device: string; /** - * Normalized position information for the instance. + * Determines whether the instance is part of a multi-action. + */ + isInMultiAction: boolean; + + /** + * Resources associated with the instance. */ - position: ActionPosition; + resources: Resources; /** * Persisted action settings. @@ -434,52 +427,24 @@ type PluginState = { }; /** - * Normalized position associated with an action instance. + * Runtime payload shape used by the bridge for visible action positions. */ -type ActionPosition = - | { - /** - * Dial column reported by Stream Deck. - */ - column: number; - - /** - * Position kind for encoder instances. - */ - kind: "dial"; - - /** - * Dial index used for extension-side display. - */ - index: number; - - /** - * Dial row reported by Stream Deck. - */ - row: number; - } - | { - /** - * Key column reported by Stream Deck. - */ - column: number; - - /** - * Position kind for keypad instances. - */ - kind: "key"; - - /** - * Key row reported by Stream Deck. - */ - row: number; - } - | { - /** - * Position kind for keypad multi-action instances. - */ - kind: "multi-action"; - }; +type PayloadWithCoordinates = { + /** + * Coordinates reported by Stream Deck. + */ + coordinates: Coordinates; + + /** + * Determines whether the instance is part of a multi-action. + */ + isInMultiAction?: boolean; + + /** + * Resources associated with the instance. + */ + resources?: Resources; +}; /** * Internal snapshot of a visible action instance. @@ -495,20 +460,30 @@ type InternalInstanceState = { */ controller: "Encoder" | "Keypad"; + /** + * Coordinates associated with the instance. + */ + coordinates: Coordinates; + /** * Device identifier associated with the instance. */ deviceId: string; + /** + * Determines whether the instance is part of a multi-action. + */ + isInMultiAction: boolean; + /** * Manifest action UUID associated with the instance. */ manifestId: string; /** - * Normalized position for the instance. + * Last known resources associated with the instance. */ - position: ActionPosition; + resources: Resources; /** * Last known settings associated with the instance. From b74e1e3c6b1339a3a96639c59177bddaa0a62d82 Mon Sep 17 00:00:00 2001 From: Zack Hoherchak Date: Fri, 12 Jun 2026 14:04:50 -0400 Subject: [PATCH 18/18] rename snapshot to state --- .../plugin/bridge/__tests__/adapter.test.ts | 28 +++++------ .../plugin/bridge/__tests__/socket.test.ts | 20 ++++---- packages/plugin/src/plugin/bridge/adapter.ts | 50 +++++++++---------- 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts index 0543831c..bc95d29c 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts @@ -26,10 +26,10 @@ describe("debug adapter", () => { vi.clearAllMocks(); }); - it("builds a snapshot from manifest actions and visible instances", () => { + it("builds plugin state from manifest actions and visible instances", () => { connection.emit("willAppear", createKeyWillAppear()); - expect(adapter.getSnapshot()).toEqual({ + expect(adapter.getPluginState()).toEqual({ actions: [ { instances: [ @@ -67,15 +67,15 @@ describe("debug adapter", () => { }); }); - it("publishes snapshot changes for visible instances", async () => { + it("publishes plugin state changes for visible instances", async () => { connection.emit("willAppear", createKeyWillAppear()); await waitForMessages(sent, 1); expect(sent).toEqual([ { jsonrpc: "2.0", - method: "streamDeck.bridge.snapshotChanged", - params: adapter.getSnapshot(), + method: "streamDeck.bridge.pluginStateChanged", + params: adapter.getPluginState(), }, ]); }); @@ -83,14 +83,14 @@ describe("debug adapter", () => { it("preserves coordinates and multi-action state", () => { connection.emit("willAppear", createDialWillAppear()); - expect(adapter.getSnapshot().actions[1].instances[0]?.coordinates).toEqual({ + expect(adapter.getPluginState().actions[1].instances[0]?.coordinates).toEqual({ column: 1, row: 0, }); connection.emit("willAppear", createMultiActionWillAppear()); - const instance = adapter.getSnapshot().actions[0].instances[0]; + const instance = adapter.getPluginState().actions[0].instances[0]; expect(instance?.isInMultiAction).toBe(true); expect(instance?.coordinates).toEqual({ column: 4, @@ -105,10 +105,10 @@ describe("debug adapter", () => { connection.emit("didReceiveSettings", createDidReceiveSettings()); await waitForMessages(sent, 1); - expect(adapter.getSnapshot().actions[0].instances[0]?.settings).toEqual({ + expect(adapter.getPluginState().actions[0].instances[0]?.settings).toEqual({ count: 7, }); - expect(adapter.getSnapshot().actions[0].instances[0]?.resources).toEqual({ + expect(adapter.getPluginState().actions[0].instances[0]?.resources).toEqual({ thumbnail: "imgs/updated.png", }); expect(sent).toHaveLength(1); @@ -116,17 +116,17 @@ describe("debug adapter", () => { clearMessages(sent); connection.emit("deviceDidChange", createDeviceDidChange()); await waitForMessages(sent, 1); - expect(adapter.getSnapshot().actions[0].instances[0]?.device).toBe("Renamed Device"); + expect(adapter.getPluginState().actions[0].instances[0]?.device).toBe("Renamed Device"); expect(sent).toHaveLength(1); clearMessages(sent); connection.emit("willDisappear", createWillDisappear()); await waitForMessages(sent, 1); - expect(adapter.getSnapshot().actions[0].instances).toEqual([]); + expect(adapter.getPluginState().actions[0].instances).toEqual([]); expect(sent).toHaveLength(1); }); - it("responds to getSnapshot RPC requests", async () => { + it("responds to getPluginState RPC requests", async () => { connection.emit("willAppear", createKeyWillAppear()); await waitForMessages(sent, 1); clearMessages(sent); @@ -134,7 +134,7 @@ describe("debug adapter", () => { const handled = await adapter.receive({ id: "request-1", jsonrpc: "2.0", - method: "streamDeck.bridge.getSnapshot", + method: "streamDeck.bridge.getPluginState", }); expect(handled).toBe(true); @@ -142,7 +142,7 @@ describe("debug adapter", () => { { id: "request-1", jsonrpc: "2.0", - result: adapter.getSnapshot(), + result: adapter.getPluginState(), }, ]); }); diff --git a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts index 404f4b8f..ab670b0b 100644 --- a/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts +++ b/packages/plugin/src/plugin/bridge/__tests__/socket.test.ts @@ -40,32 +40,32 @@ describe("bridge socket", () => { vi.clearAllMocks(); }); - it("serves snapshot requests over the debug socket", async () => { + it("serves plugin state requests over the debug socket", async () => { await bridge.start(); const client = await connect(); - const response = await getSnapshot(client); + const response = await getPluginState(client); expect(JSON.parse(response)).toEqual({ id: "request-1", jsonrpc: "2.0", - result: bridge.getSnapshot(), + result: bridge.getPluginState(), }); client.destroy(); }); - it("forwards snapshot change notifications to the connected client", async () => { + it("forwards plugin state change notifications to the connected client", async () => { await bridge.start(); const client = await connect(); - await getSnapshot(client); // Wait for the initial snapshot response to ensure the RPC connection is ready. + await getPluginState(client); // Wait for the initial plugin state response to ensure the RPC connection is ready. const notification = receive(client); connection.emit("willAppear", createKeyWillAppear()); expect(JSON.parse(await notification)).toEqual({ jsonrpc: "2.0", - method: "streamDeck.bridge.snapshotChanged", - params: bridge.getSnapshot(), + method: "streamDeck.bridge.pluginStateChanged", + params: bridge.getPluginState(), }); client.destroy(); @@ -218,17 +218,17 @@ async function connect(): Promise { } /** - * Requests a snapshot over the socket client. + * Requests plugin state over the socket client. * @param client Socket client. * @returns JSON-RPC response message. */ -function getSnapshot(client: Socket): Promise { +function getPluginState(client: Socket): Promise { const response = receive(client); client.write( `${JSON.stringify({ id: "request-1", jsonrpc: "2.0", - method: "streamDeck.bridge.getSnapshot", + method: "streamDeck.bridge.getPluginState", })}\n`, ); diff --git a/packages/plugin/src/plugin/bridge/adapter.ts b/packages/plugin/src/plugin/bridge/adapter.ts index 3a75b64d..8ab29fed 100644 --- a/packages/plugin/src/plugin/bridge/adapter.ts +++ b/packages/plugin/src/plugin/bridge/adapter.ts @@ -18,7 +18,7 @@ import { getManifest } from "../manifest.js"; import { socket } from "./socket.js"; /** - * Internal JSON-RPC adapter that projects connection events into a serialized plugin snapshot. + * Internal JSON-RPC adapter that projects connection events into serialized plugin state. */ class BridgeAdapter { /** @@ -47,9 +47,9 @@ class BridgeAdapter { #rpc: ReturnType | undefined; /** - * Last serialized snapshot emitted by the adapter. + * Last serialized plugin state emitted by the adapter. */ - #lastSnapshot = ""; + #lastPluginState = ""; /** * Tail of the serialized publication promise chain. @@ -127,7 +127,7 @@ class BridgeAdapter { */ public attachRpc(send: RpcSender): void { this.#rpc = createRpcServerClient(send); - this.#rpc.addMethod("streamDeck.bridge.getSnapshot", () => this.getSnapshot()); + this.#rpc.addMethod("streamDeck.bridge.getPluginState", () => this.getPluginState()); this.#rpc.addMethod("streamDeck.bridge.setSettings", (params) => this.#setSettings(params)); } @@ -139,10 +139,10 @@ class BridgeAdapter { } /** - * Builds the current plugin snapshot. - * @returns Current plugin snapshot. + * Builds the current plugin state. + * @returns Current plugin state. */ - public getSnapshot(): PluginSnapshot { + public getPluginState(): PluginState { this.#seedDevices(); const manifestActions = this.#getManifest()?.Actions ?? []; @@ -213,7 +213,7 @@ class BridgeAdapter { } /** - * Persists settings for a tracked action instance and re-publishes the snapshot. + * Persists settings for a tracked action instance and re-publishes plugin state. * @param params Context and settings to persist. * @returns `true` when the instance was known and the update was sent. */ @@ -235,7 +235,7 @@ class BridgeAdapter { }); // Stream Deck does not echo a didReceiveSettings for setSettings, so update the - // tracked instance optimistically to keep the published snapshot in sync. + // tracked instance optimistically to keep the published plugin state in sync. instance.settings = settings; this.#publishChanges(); return true; @@ -260,27 +260,27 @@ class BridgeAdapter { } /** - * Enqueues a snapshot publication, ensuring notifications are emitted in call order. + * Enqueues a plugin state publication, ensuring notifications are emitted in call order. */ #publishChanges(): void { this.#publishQueue = this.#publishQueue.then(() => this.#doPublish()); } /** - * Publishes the current snapshot when it differs from the last emitted state. + * Publishes the current plugin state when it differs from the last emitted state. */ async #doPublish(): Promise { try { - const snapshot = this.getSnapshot(); - const serialized = JSON.stringify(snapshot); + const pluginState = this.getPluginState(); + const serialized = JSON.stringify(pluginState); - if (serialized === this.#lastSnapshot) { + if (serialized === this.#lastPluginState) { return; } - this.#lastSnapshot = serialized; + this.#lastPluginState = serialized; if (this.#rpc) { - await this.#rpc.notify("streamDeck.bridge.snapshotChanged", snapshot); + await this.#rpc.notify("streamDeck.bridge.pluginStateChanged", pluginState); } } catch { // Swallow transport errors to avoid disrupting the plugin; @@ -332,22 +332,22 @@ type SetSettingsParams = { }; /** - * Serializable snapshot of the plugin's visible action state. + * Serializable state of the plugin's visible actions. */ -type PluginSnapshot = { +type PluginState = { /** * Actions known to the plugin, including currently visible instances. */ actions: ActionState[]; /** - * Plugin metadata associated with the snapshot. + * Plugin metadata associated with the state. */ - plugin: PluginState; + plugin: PluginMetadata; }; /** - * Snapshot of a manifest action and its currently visible instances. + * State of a manifest action and its currently visible instances. */ type ActionState = { /** @@ -367,7 +367,7 @@ type ActionState = { }; /** - * Snapshot of a visible action instance. + * State of a visible action instance. */ type ActionInstanceState = { /** @@ -407,9 +407,9 @@ type ActionInstanceState = { }; /** - * Serializable plugin information exposed by the snapshot. + * Serializable plugin metadata exposed by the state. */ -type PluginState = { +type PluginMetadata = { /** * Human-readable plugin name. */ @@ -447,7 +447,7 @@ type PayloadWithCoordinates = { }; /** - * Internal snapshot of a visible action instance. + * Internal state of a visible action instance. */ type InternalInstanceState = { /**