diff --git a/.changeset/cozy-maps-wait.md b/.changeset/cozy-maps-wait.md new file mode 100644 index 0000000..3516133 --- /dev/null +++ b/.changeset/cozy-maps-wait.md @@ -0,0 +1,5 @@ +--- +"@elgato/streamdeck": major +--- + +Removed `useExperimentalMessageIdentifiers`, now enabled by default. diff --git a/.changeset/gentle-parrots-teach.md b/.changeset/gentle-parrots-teach.md new file mode 100644 index 0000000..73fadd2 --- /dev/null +++ b/.changeset/gentle-parrots-teach.md @@ -0,0 +1,5 @@ +--- +"@elgato/streamdeck": major +--- + +Updated `onDidReceiveSettings` and `onDidReceiveGlobalSettings` to only fire when settings are changed in the property inspector (requires Stream Deck 7.1 or higher). This behavior can be temporarily reverted by configuring `streamDeck.settings.useLegacySettingsBehavior` to be `true`. diff --git a/packages/plugin/src/plugin/__tests__/settings.test.ts b/packages/plugin/src/plugin/__tests__/settings.test.ts index 98a46eb..ce05817 100644 --- a/packages/plugin/src/plugin/__tests__/settings.test.ts +++ b/packages/plugin/src/plugin/__tests__/settings.test.ts @@ -8,6 +8,8 @@ import { type GetGlobalSettings, type SetGlobalSettings, } from "../../api/index.js"; +import type { Action } from "../actions/action.js"; +import { settingsCache } from "../actions/cache.js"; import { actionStore } from "../actions/store.js"; import { connection } from "../connection.js"; import { Device } from "../devices/device.js"; @@ -97,8 +99,48 @@ describe("settings", () => { }); }); - describe("receiving emits with useExperimentalMessageIdentifiers set to false", () => { - beforeAll(() => (settings.useExperimentalMessageIdentifiers = false)); + describe("useLegacySettingsBehavior", () => { + /** + * Asserts that switching useLegacySettingsBehavior clears the settings cache. + */ + it("clears settings cache when switching modes", () => { + // Arrange. + const testContext = "cache-clear-test-context"; + settings.useLegacySettingsBehavior = false; + settingsCache.set(testContext, { name: "Cached" }); + expect(settingsCache.get(testContext)).toBeDefined(); + + // Act. + settings.useLegacySettingsBehavior = true; + + // Assert. + expect(settingsCache.get(testContext)).toBeUndefined(); + + // Cleanup. + settings.useLegacySettingsBehavior = false; + }); + + /** + * Asserts that switching useLegacySettingsBehavior back to false also clears the cache. + */ + it("clears settings cache when switching from true to false", () => { + // Arrange. + const testContext = "cache-clear-test-context-2"; + settings.useLegacySettingsBehavior = true; + settingsCache.set(testContext, { name: "Cached" }); + expect(settingsCache.get(testContext)).toBeDefined(); + + // Act. + settings.useLegacySettingsBehavior = false; + + // Assert. + expect(settingsCache.get(testContext)).toBeUndefined(); + }); + }); + + describe("receiving emits with useLegacySettingsBehavior set to true", () => { + beforeAll(() => (settings.useLegacySettingsBehavior = true)); + afterAll(() => (settings.useLegacySettingsBehavior = false)); /** * Asserts {@link onDidReceiveGlobalSettings} is invoked when `didReceiveGlobalSettings` is emitted. @@ -170,7 +212,7 @@ describe("settings", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DidReceiveSettingsEvent]>({ - action: actionStore.getActionById(ev.context)!, + action: actionStore.getActionById(ev.context)! as Action, payload: ev.payload, type: "didReceiveSettings", }); @@ -184,9 +226,8 @@ describe("settings", () => { }); }); - describe("receiving does not emit with useExperimentalMessageIdentifiers set to true", () => { - beforeAll(() => (settings.useExperimentalMessageIdentifiers = true)); - afterAll(() => (settings.useExperimentalMessageIdentifiers = false)); + describe("receiving does not emit with useLegacySettingsBehavior set to false", () => { + beforeAll(() => (settings.useLegacySettingsBehavior = false)); test("didReceiveGlobalSettings", () => { // Arrange. diff --git a/packages/plugin/src/plugin/__tests__/ui.test.ts b/packages/plugin/src/plugin/__tests__/ui.test.ts index 597df47..478905a 100644 --- a/packages/plugin/src/plugin/__tests__/ui.test.ts +++ b/packages/plugin/src/plugin/__tests__/ui.test.ts @@ -8,6 +8,7 @@ import { type PropertyInspectorDidDisappear, type SendToPropertyInspector, } from "../../api/index.js"; +import type { Action } from "../actions/action.js"; import { KeyAction } from "../actions/key.js"; import { actionStore } from "../actions/store.js"; import { connection } from "../connection.js"; @@ -64,7 +65,7 @@ describe("UIController", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidAppearEvent]>({ - action: actionStore.getActionById(ev.context)!, + action: actionStore.getActionById(ev.context) as Action, type: "propertyInspectorDidAppear", }); @@ -96,7 +97,7 @@ describe("UIController", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidDisappearEvent]>({ - action: actionStore.getActionById(ev.context)!, + action: actionStore.getActionById(ev.context) as Action, type: "propertyInspectorDidDisappear", }); @@ -130,7 +131,7 @@ describe("UIController", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[SendToPluginEvent]>({ - action: actionStore.getActionById(ev.context)!, + action: actionStore.getActionById(ev.context) as Action, payload: { name: "Hello world", }, diff --git a/packages/plugin/src/plugin/actions/__tests__/action.test.ts b/packages/plugin/src/plugin/actions/__tests__/action.test.ts index 61ffb53..436bd1a 100644 --- a/packages/plugin/src/plugin/actions/__tests__/action.test.ts +++ b/packages/plugin/src/plugin/actions/__tests__/action.test.ts @@ -53,7 +53,6 @@ describe("Action", () => { beforeAll(() => vi.spyOn(deviceStore, "getDeviceById").mockReturnValue(device)); afterEach(() => { - actionConfig.useExperimentalMessageIdentifiers = false; settingsCache.delete(source.context); vi.clearAllMocks(); }); @@ -75,14 +74,8 @@ describe("Action", () => { expect(deviceStore.getDeviceById).toHaveBeenLastCalledWith(source.device); }); - describe("with useExperimentalMessageIdentifiers set to true", () => { - beforeEach(() => { - actionConfig.useExperimentalMessageIdentifiers = true; - }); - - afterAll(() => { - actionConfig.useExperimentalMessageIdentifiers = false; - }); + describe("with useLegacySettingsBehavior set to false", () => { + beforeEach(() => (actionConfig.useLegacySettingsBehavior = false)); /** * Asserts {@link ActionBase.getSettings} returns cached settings when the cache is valid. @@ -112,13 +105,12 @@ describe("Action", () => { }); }); - describe("with useExperimentalMessageIdentifiers set to false", () => { - beforeAll(() => { - actionConfig.useExperimentalMessageIdentifiers = false; - }); + describe("with useLegacySettingsBehavior set to true", () => { + beforeAll(() => (actionConfig.useLegacySettingsBehavior = true)); + afterAll(() => (actionConfig.useLegacySettingsBehavior = false)); /** - * Asserts {@link ActionBase.getSettings} ignores cached settings when experimental message identifiers are disabled. + * Asserts {@link ActionBase.getSettings} ignores cached settings when legacy settings behavior is enabled. */ it("getSettings ignores cached settings", async () => { // Arrange. @@ -292,7 +284,7 @@ describe("Action", () => { describe("sending", () => { let action!: KeyAction; - beforeAll(() => (action = new KeyAction(source))); + beforeAll(() => (action = new KeyAction(source as WillAppear))); /** * Asserts {@link ActionBase.setSettings} invalidates the settings cache. diff --git a/packages/plugin/src/plugin/actions/__tests__/service.test.ts b/packages/plugin/src/plugin/actions/__tests__/service.test.ts index b8e0efa..b1e9828 100644 --- a/packages/plugin/src/plugin/actions/__tests__/service.test.ts +++ b/packages/plugin/src/plugin/actions/__tests__/service.test.ts @@ -73,7 +73,6 @@ describe("actions", () => { }); afterEach(() => { - actionConfig.useExperimentalMessageIdentifiers = false; vi.clearAllMocks(); }); @@ -581,7 +580,7 @@ describe("actions", () => { */ it("updates settings cache on willAppear/didReceiveSettings and clears on willDisappear", () => { // Arrange. - actionConfig.useExperimentalMessageIdentifiers = true; + actionConfig.useLegacySettingsBehavior = false; const context = "cache-lifecycle-context"; settingsCache.delete(context); @@ -654,6 +653,65 @@ describe("actions", () => { connection.emit("willDisappear", willDisappear); expect(settingsCache.get(context)).toBeUndefined(); }); + + /** + * Asserts settings cache is not updated when legacy settings behavior is enabled. + */ + it("does not update settings cache when useLegacySettingsBehavior is true", () => { + // Arrange. + actionConfig.useLegacySettingsBehavior = true; + const context = "cache-skip-context"; + settingsCache.delete(context); + + const willAppear = { + action: "com.elgato.test.key", + context, + device: "device123", + event: "willAppear", + payload: { + controller: "Keypad", + coordinates: { + column: 1, + row: 1, + }, + isInMultiAction: false, + resources: {}, + settings: { + name: "FromAppear", + }, + }, + } satisfies WillAppear; + + const didReceiveSettings = { + action: "com.elgato.test.key", + context, + device: "device123", + event: "didReceiveSettings", + payload: { + controller: "Keypad", + coordinates: { + column: 1, + row: 1, + }, + isInMultiAction: false, + resources: {}, + settings: { + name: "Updated", + }, + }, + } satisfies DidReceiveSettings; + + // Act, assert (cache should NOT be set on appear). + connection.emit("willAppear", willAppear); + expect(settingsCache.get(context)).toBeUndefined(); + + // Act, assert (cache should NOT be updated on settings event). + connection.emit("didReceiveSettings", didReceiveSettings); + expect(settingsCache.get(context)).toBeUndefined(); + + // Cleanup. + actionConfig.useLegacySettingsBehavior = false; + }); }); describe("registering an action", () => { diff --git a/packages/plugin/src/plugin/actions/action-base.ts b/packages/plugin/src/plugin/actions/action-base.ts index 2de89a6..f7519d9 100644 --- a/packages/plugin/src/plugin/actions/action-base.ts +++ b/packages/plugin/src/plugin/actions/action-base.ts @@ -45,7 +45,7 @@ export class ActionBase extends ActionContext { * @returns Promise containing the action instance's settings. */ public async getSettings(): Promise { - if (actionConfig.useExperimentalMessageIdentifiers) { + if (!actionConfig.useLegacySettingsBehavior) { const cached = settingsCache.get(this.id); if (cached !== undefined) { logger.trace( @@ -56,6 +56,7 @@ export class ActionBase extends ActionContext { settings: cached, }), ); + return cached as TSettings; } } diff --git a/packages/plugin/src/plugin/actions/cache.ts b/packages/plugin/src/plugin/actions/cache.ts index 9c80f9e..7a58829 100644 --- a/packages/plugin/src/plugin/actions/cache.ts +++ b/packages/plugin/src/plugin/actions/cache.ts @@ -9,6 +9,13 @@ class SettingsCache { */ readonly #entries = new Map(); + /** + * Clears the cached settings. + */ + public clear(): void { + this.#entries.clear(); + } + /** * Removes the cached settings for the specified action. * @param id Action instance identifier. diff --git a/packages/plugin/src/plugin/actions/config.ts b/packages/plugin/src/plugin/actions/config.ts index 7e17599..6cc0197 100644 --- a/packages/plugin/src/plugin/actions/config.ts +++ b/packages/plugin/src/plugin/actions/config.ts @@ -3,7 +3,15 @@ */ export const actionConfig = { /** - * Determines whether settings requests should use message identifiers and action settings cache behavior. + * Determines the behavior of when `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are fired. + * + * - `false` (default) — `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are only fired + * after the settings were updated within the property inspector. + * - `true` — `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are fired after the settings + * were updated within the property inspector, and after calling `action.getSettings()` and + * `streamDeck.settings.getGlobalSettings()` respectively. + * + * This option replaces `useExperimentalMessageIdentifiers`, with inverted behavior. */ - useExperimentalMessageIdentifiers: false, + useLegacySettingsBehavior: false, }; diff --git a/packages/plugin/src/plugin/actions/service.ts b/packages/plugin/src/plugin/actions/service.ts index b8df016..b07fa94 100644 --- a/packages/plugin/src/plugin/actions/service.ts +++ b/packages/plugin/src/plugin/actions/service.ts @@ -57,14 +57,14 @@ class ActionService extends ReadOnlyActionStore { const action = this.#createAction(ev); actionStore.set(action); - if (actionConfig.useExperimentalMessageIdentifiers) { + if (!actionConfig.useLegacySettingsBehavior) { settingsCache.set(ev.context, ev.payload.settings); } }); // Update the settings cache when settings are received. connection.prependListener("didReceiveSettings", (ev) => { - if (actionConfig.useExperimentalMessageIdentifiers) { + if (!actionConfig.useLegacySettingsBehavior) { settingsCache.set(ev.context, ev.payload.settings); } }); diff --git a/packages/plugin/src/plugin/actions/singleton-action.ts b/packages/plugin/src/plugin/actions/singleton-action.ts index 48c76a0..f9e0d3e 100644 --- a/packages/plugin/src/plugin/actions/singleton-action.ts +++ b/packages/plugin/src/plugin/actions/singleton-action.ts @@ -68,7 +68,10 @@ export class SingletonAction { public onDidReceiveResources?(ev: DidReceiveResourcesEvent): Promise | void; /** - * Occurs when the settings associated with an action instance are requested using {@link ActionBase.getSettings}, or when the settings were updated by the property inspector. + * Occurs when the settings were updated within the property inspector. + * + * When `streamDeck.settings.useLegacySettingsBehavior` is set to `true`, this also fires after + * calling `getSettings()`. * @param ev Information about the event, including the source action and contextual payload information. */ public onDidReceiveSettings?(ev: DidReceiveSettingsEvent): Promise | void; diff --git a/packages/plugin/src/plugin/index.ts b/packages/plugin/src/plugin/index.ts index 4865779..7415641 100644 --- a/packages/plugin/src/plugin/index.ts +++ b/packages/plugin/src/plugin/index.ts @@ -8,7 +8,7 @@ import { deviceService, type DeviceService } from "./devices/service.js"; import { fileSystemLocaleProvider } from "./i18n.js"; import { logger } from "./logging/index.js"; import * as profiles from "./profiles.js"; -import { settings } from "./settings.js"; +import { settings, validateSettingsBehavior } from "./settings.js"; import * as system from "./system.js"; import { ui, type UIController } from "./ui.js"; @@ -113,10 +113,10 @@ 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 { + validateSettingsBehavior(); + await connection.connect(); }, }; diff --git a/packages/plugin/src/plugin/settings.ts b/packages/plugin/src/plugin/settings.ts index 48f6fa8..fb01169 100644 --- a/packages/plugin/src/plugin/settings.ts +++ b/packages/plugin/src/plugin/settings.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import type { DidReceiveGlobalSettings, DidReceiveSettings } from "../api/index.js"; import type { Action } from "./actions/action.js"; +import { settingsCache } from "./actions/cache.js"; import { actionConfig } from "./actions/config.js"; import { actionStore } from "./actions/store.js"; import { connection } from "./connection.js"; @@ -12,27 +13,46 @@ import { requiresVersion } from "./validation.js"; export const settings = { /** - * Available from Stream Deck 7.1; determines whether message identifiers should be sent when getting - * action-instance or global settings. + * Determines the behavior of when `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are fired. * - * When `true`, the did-receive events associated with settings are only emitted when the action-instance - * or global settings are changed in the property inspector. - * @returns The value. + * - `false` (default) — `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are only fired + * after the settings were updated within the property inspector. + * - `true` — `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are fired after the settings + * were updated within the property inspector, and after calling `action.getSettings()` and + * `streamDeck.settings.getGlobalSettings()` respectively. + * + * This option replaces `useExperimentalMessageIdentifiers`, with inverted behavior. */ - get useExperimentalMessageIdentifiers(): boolean { - return actionConfig.useExperimentalMessageIdentifiers; + get useLegacySettingsBehavior(): boolean { + return actionConfig.useLegacySettingsBehavior; }, /** - * Available from Stream Deck 7.1; determines whether message identifiers should be sent when getting - * action-instance or global settings. + * Determines the behavior of when `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are fired. + * + * - `false` (default) — `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are only fired + * after the settings were updated within the property inspector. + * - `true` — `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are fired after the settings + * were updated within the property inspector, and after calling `action.getSettings()` and + * `streamDeck.settings.getGlobalSettings()` respectively. * - * When `true`, the did-receive events associated with settings are only emitted when the action-instance - * or global settings are changed in the property inspector. + * This option replaces `useExperimentalMessageIdentifiers`, with inverted behavior. */ - set useExperimentalMessageIdentifiers(value: boolean) { - requiresVersion(7.1, connection.version, "Message identifiers"); - actionConfig.useExperimentalMessageIdentifiers = value; + set useLegacySettingsBehavior(value: boolean) { + const prev = actionConfig.useLegacySettingsBehavior; + if (prev === value) { + return; + } + + try { + actionConfig.useLegacySettingsBehavior = value; + validateSettingsBehavior(); + + settingsCache.clear(); + } catch (err) { + actionConfig.useLegacySettingsBehavior = prev; + throw err; + } }, /** @@ -52,8 +72,10 @@ export const settings = { }, /** - * Occurs when the global settings are requested, or when the the global settings were updated in - * the property inspector. + * Occurs when the global settings were updated within the property inspector. + * + * When `streamDeck.settings.useLegacySettingsBehavior` is set to `true`, this event will also + * occur when calling `getGlobalSettings()`. * @template T The type of settings associated with the action. * @param listener Function to be invoked when the event occurs. * @returns A disposable that removes the listener. @@ -63,7 +85,7 @@ export const settings = { ): IDisposable => { return connection.disposableOn("didReceiveGlobalSettings", (ev: DidReceiveGlobalSettings) => { // Do nothing when the global settings were requested. - if (settings.useExperimentalMessageIdentifiers && ev.id) { + if (!settings.useLegacySettingsBehavior && ev.id) { return; } @@ -72,8 +94,10 @@ export const settings = { }, /** - * Occurs when the settings associated with an action instance are requested, or when the the settings - * were updated in the property inspector. + * Occurs when the settings, associated with an action, were updated within the property inspector. + * + * When `streamDeck.settings.useLegacySettingsBehavior` is set to `true`, this event will also + * occur when calling `getSettings()` on an action. * @template T The type of settings associated with the action. * @param listener Function to be invoked when the event occurs. * @returns A disposable that removes the listener. @@ -83,7 +107,7 @@ export const settings = { ): IDisposable => { return connection.disposableOn("didReceiveSettings", (ev: DidReceiveSettings) => { // Do nothing when the action's settings were requested. - if (settings.useExperimentalMessageIdentifiers && ev.id) { + if (!settings.useLegacySettingsBehavior && ev.id) { return; } @@ -112,3 +136,13 @@ export const settings = { }); }, }; + +/** + * Validates the current settings behavior is compatible with the Stream Deck version, and the + * minimum version defined within the manifest. + */ +export function validateSettingsBehavior(): never | void { + if (!settings.useLegacySettingsBehavior) { + requiresVersion(7.1, connection.version, "Default onDidReceiveSettings/onDidReceiveGlobalSettings behavior"); + } +}