@clockworkdog/cogs-client 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export { SurfaceManager } from './state-based/SurfaceManager';
7
7
  export { MediaPreloader } from './state-based/MediaPreloader';
8
8
  export { assetUrl, preloadUrl } from './utils/urls';
9
9
  export { getStateAtTime } from './utils/getStateAtTime';
10
+ export { NetworkHostPattern } from './utils/NetworkHostPattern';
10
11
  export * from './types/CogsPluginManifest';
11
12
  export * as ManifestTypes from './types/ManifestTypes';
12
13
  export * from './DataStore';
14
+ export { getPluginManifestErrors } from './utils/getPluginManifestErrors';
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ export { SurfaceManager } from './state-based/SurfaceManager';
5
5
  export { MediaPreloader } from './state-based/MediaPreloader';
6
6
  export { assetUrl, preloadUrl } from './utils/urls';
7
7
  export { getStateAtTime } from './utils/getStateAtTime';
8
+ export { NetworkHostPattern } from './utils/NetworkHostPattern';
8
9
  export * from './types/CogsPluginManifest';
9
10
  export * as ManifestTypes from './types/ManifestTypes';
10
11
  export * from './DataStore';
12
+ export { getPluginManifestErrors } from './utils/getPluginManifestErrors';
@@ -43,7 +43,7 @@ interface TemporalSyncState {
43
43
  * Makes sure the media is at the correct time and speed.
44
44
  * - Algorithms and constants defined above
45
45
  */
46
- export declare function assertTemporalProperties(mediaElement: HTMLMediaElement, properties: TemporalProperties, keyframes: VideoState['keyframes'], syncState: TemporalSyncState, enablePlaybackRateAdjustment: boolean): TemporalSyncState;
46
+ export declare function assertTemporalProperties(mediaElement: HTMLMediaElement, properties: TemporalProperties, keyframes: VideoState['keyframes'], currentTime: number, syncState: TemporalSyncState, enablePlaybackRateAdjustment: boolean): TemporalSyncState;
47
47
  export declare class ImageManager extends MediaClipManager<ImageState> {
48
48
  private imageElement;
49
49
  protected update(): void;
@@ -181,7 +181,8 @@ const SYNC_INNER_TARGET_THRESHOLD_MS = 5;
181
181
  const SYNC_MAX_THRESHOLD_MS = 1_000;
182
182
  const SYNC_SEEK_LOOKAHEAD_MS = 10;
183
183
  // If the media is scheduled to go back to the start close in time to the end of the video, we'll use the loop attribute.
184
- const LOOPING_EPSILON_MS = 5;
184
+ // This value allows disagreement between HTMLVideoElement.duration and the length of the different audio streams we have in COGS.
185
+ const LOOPING_EPSILON_MS = 100;
185
186
  const PLAYBACK_ADJUSTMENT_SMOOTHING = 0.3;
186
187
  const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.1;
187
188
  function playbackSmoothing(deltaTime) {
@@ -204,7 +205,7 @@ function assertPlaybackRate(mediaElement, playbackRate) {
204
205
  * Makes sure the media is at the correct time and speed.
205
206
  * - Algorithms and constants defined above
206
207
  */
207
- export function assertTemporalProperties(mediaElement, properties, keyframes, syncState, enablePlaybackRateAdjustment) {
208
+ export function assertTemporalProperties(mediaElement, properties, keyframes, currentTime, syncState, enablePlaybackRateAdjustment) {
208
209
  // On Webkit (using the simulator on safari and COGS mobile app on iOS) changes to currentTime and playbackRate are much less responsive.
209
210
  // We make sure we only do lower frequency updates, and don't change playbackRate.
210
211
  const playbackRateSync = enablePlaybackRateAdjustment && !IS_WEBKIT;
@@ -212,20 +213,23 @@ export function assertTemporalProperties(mediaElement, properties, keyframes, sy
212
213
  // Sounds like looping to me!
213
214
  let isLooping = false;
214
215
  if (mediaElement.duration) {
215
- const nextTemporalKeyframe = keyframes.filter(([t, kf]) => t > properties.t && (kf?.set?.t !== undefined || kf?.set?.rate !== undefined))[0];
216
+ const futureTemporalKeyframes = keyframes
217
+ .filter(([t]) => t > keyframes[0][0] && t > currentTime)
218
+ .filter(([, props]) => props?.set?.t !== undefined || props?.set?.rate !== undefined);
219
+ const nextTemporalKeyframe = futureTemporalKeyframes[0];
216
220
  if (nextTemporalKeyframe?.[1]?.set?.t === 0) {
217
- const timeRemaining = (mediaElement.duration - properties.t) / properties.rate;
218
- const timeUntilKeyframe = nextTemporalKeyframe[0] - properties.t;
221
+ const timeRemaining = (mediaElement.duration * 1000 - properties.t) / properties.rate;
222
+ const timeUntilKeyframe = nextTemporalKeyframe[0] - currentTime;
219
223
  isLooping = Math.abs(timeRemaining - timeUntilKeyframe) <= LOOPING_EPSILON_MS;
220
224
  if (mediaElement.loop !== isLooping) {
221
225
  mediaElement.loop = isLooping;
222
226
  }
223
227
  }
224
228
  }
225
- const currentTime = mediaElement.currentTime * 1000;
229
+ const currentMediaTime = mediaElement.currentTime * 1000;
226
230
  const deltaTime = isLooping && mediaElement.duration !== undefined
227
- ? moduloDiff(currentTime, properties.t, mediaElement.duration * 1000)
228
- : currentTime - properties.t;
231
+ ? moduloDiff(currentMediaTime, properties.t, mediaElement.duration * 1000)
232
+ : currentMediaTime - properties.t;
229
233
  const deltaTimeAbs = Math.abs(deltaTime);
230
234
  switch (true) {
231
235
  /**
@@ -362,7 +366,8 @@ export class AudioManager extends MediaClipManager {
362
366
  audioElement;
363
367
  volume = 1;
364
368
  update() {
365
- const currentState = getStateAtTime(this._state, Date.now());
369
+ const now = Date.now();
370
+ const currentState = getStateAtTime(this._state, now);
366
371
  if (currentState) {
367
372
  this.audioElement = assertElement(this.audioElement, this.clipElement, this._state, this.constructAssetURL, this.mediaPreloader);
368
373
  }
@@ -373,7 +378,7 @@ export class AudioManager extends MediaClipManager {
373
378
  return;
374
379
  const sinkId = this.getAudioOutput(this._state.audioOutput);
375
380
  assertAudialProperties(this.audioElement, currentState, sinkId, this.volume);
376
- const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState, this._state.enablePlaybackRateAdjustment);
381
+ const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, now, this.syncState, this._state.enablePlaybackRateAdjustment);
377
382
  this.syncState = nextSyncState;
378
383
  }
379
384
  destroy() {
@@ -392,7 +397,8 @@ export class VideoManager extends MediaClipManager {
392
397
  videoElement;
393
398
  volume = 1;
394
399
  update() {
395
- const currentState = getStateAtTime(this._state, Date.now());
400
+ const now = Date.now();
401
+ const currentState = getStateAtTime(this._state, now);
396
402
  if (currentState) {
397
403
  this.videoElement = assertElement(this.videoElement, this.clipElement, this._state, this.constructAssetURL, this.mediaPreloader);
398
404
  }
@@ -404,7 +410,7 @@ export class VideoManager extends MediaClipManager {
404
410
  const sinkId = this.getAudioOutput(this._state.audioOutput);
405
411
  assertVisualProperties(this.videoElement, currentState, this._state.fit);
406
412
  assertAudialProperties(this.videoElement, currentState, sinkId, this.volume);
407
- const nextSyncState = assertTemporalProperties(this.videoElement, currentState, this._state.keyframes, this.syncState, this._state.enablePlaybackRateAdjustment);
413
+ const nextSyncState = assertTemporalProperties(this.videoElement, currentState, this._state.keyframes, now, this.syncState, this._state.enablePlaybackRateAdjustment);
408
414
  this.syncState = nextSyncState;
409
415
  }
410
416
  destroy() {
@@ -41,6 +41,40 @@ export type PluginManifestStateJson = {
41
41
  writableFromCogs?: true;
42
42
  writableFromClient?: true;
43
43
  };
44
+ export type PluginManifestNetworkAccessRuleJson = {
45
+ /**
46
+ * Host patterns this rule grants outbound network access to.
47
+ *
48
+ * Every pattern must include an explicit port.
49
+ *
50
+ * | Pattern | Meaning |
51
+ * | --- | --- |
52
+ * | `"localhost:8080"` | Port 8080 on `localhost` / `127.0.0.1` / `::1` |
53
+ * | `"private:443"` | Port 443 on any RFC 1918 private address range (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) |
54
+ * | `"api.vendor.com:443"` | An exact hostname and port |
55
+ * | `"*.vendor.com:443"` | A wildcard subdomain, exact port |
56
+ * | `"foo.example.com:*"` | Any port on an exact hostname |
57
+ * | `"[fd00::1]:443"` | An IPv6 literal, using bracket syntax |
58
+ * | `"*:*"` | Any host, any port |
59
+ *
60
+ * CIDR ranges (e.g. `"192.168.1.0/24:443"`) are not supported.
61
+ *
62
+ * Every port on `localhost` / `127.0.0.1` / `::1` is always implicitly reachable regardless of
63
+ * these rules, since plugins connect to the COGS server at `localhost:12095` — a `"localhost:*"`
64
+ * pattern here is never required.
65
+ */
66
+ hosts: string[];
67
+ /**
68
+ * Paths, relative to the root of the packaged `.cogsplugin` archive, of PEM-encoded CA
69
+ * certificates to trust for HTTPS connections to the hosts matched by `hosts`.
70
+ *
71
+ * Use this to reach local devices with vendor-issued certificates that aren't in the system CA
72
+ * store (e.g. a Philips Hue bridge). For matched hosts, the certificate chain is verified
73
+ * against these CAs instead of the system store; all other hosts continue to use the system
74
+ * store.
75
+ */
76
+ caCertificates?: string[];
77
+ };
44
78
  /**
45
79
  * `cogs-plugin-manifest.json` is a JSON manifest file describing the content of a COGS plugin or COGS Media Master custom content.
46
80
  *
@@ -141,6 +175,28 @@ export interface CogsPluginManifestJson {
141
175
  };
142
176
  };
143
177
  };
178
+ /**
179
+ * Elevated permissions for this plugin.
180
+ *
181
+ * **Only applies to verified, packaged (`.cogsplugin`) plugins.** It is ignored for plugins
182
+ * loaded from a folder, to maintain compatibility with the existing folder-based plugin
183
+ * structure.
184
+ */
185
+ permissions?: {
186
+ network?: {
187
+ /**
188
+ * Rules granting this plugin outbound network access to specific hosts, optionally with
189
+ * a bundled CA certificate for hosts using vendor-issued (rather than publicly-trusted)
190
+ * TLS certificates.
191
+ *
192
+ * Requests to hosts not matched by any rule here are blocked. By default a plugin has no
193
+ * network access beyond the COGS server itself.
194
+ *
195
+ * Added in COGS 5.11.0
196
+ */
197
+ access?: PluginManifestNetworkAccessRuleJson[];
198
+ };
199
+ };
144
200
  }
145
201
  /**
146
202
  * A readonly version of `PluginManifestJson`
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A single host pattern from a `PluginManifestNetworkAccessRuleJson`'s `hosts` array
3
+ * (e.g. `"private:443"`, `"*.vendor.com:443"`).
4
+ */
5
+ export declare class NetworkHostPattern {
6
+ private readonly host;
7
+ private readonly port;
8
+ /**
9
+ * @throws If `pattern` has no port (or `*`) component.
10
+ */
11
+ constructor(pattern: string);
12
+ /** Checks whether `host` and `port` are matched by this pattern. */
13
+ validate(host: string, port: number): boolean;
14
+ private matchesHost;
15
+ }
@@ -0,0 +1,65 @@
1
+ import { Address4, Address6 } from 'ip-address';
2
+ /**
3
+ * A single host pattern from a `PluginManifestNetworkAccessRuleJson`'s `hosts` array
4
+ * (e.g. `"private:443"`, `"*.vendor.com:443"`).
5
+ */
6
+ export class NetworkHostPattern {
7
+ host;
8
+ port;
9
+ /**
10
+ * @throws If `pattern` has no port (or `*`) component.
11
+ */
12
+ constructor(pattern) {
13
+ // Split on the last `:` rather than the first, since a bracketed IPv6 host (e.g. "[fd00::1]")
14
+ // contains colons of its own.
15
+ const lastColonIndex = pattern.lastIndexOf(':');
16
+ const host = pattern.slice(0, lastColonIndex);
17
+ const port = pattern.slice(lastColonIndex + 1);
18
+ if (lastColonIndex === -1 || !/^(\d+|\*)$/.test(port)) {
19
+ throw new Error(`Invalid host pattern "${pattern}": a port (or "*") is required`);
20
+ }
21
+ this.host = host;
22
+ this.port = port;
23
+ }
24
+ /** Checks whether `host` and `port` are matched by this pattern. */
25
+ validate(host, port) {
26
+ return this.matchesHost(host) && (this.port === '*' || Number(this.port) === port);
27
+ }
28
+ matchesHost(host) {
29
+ // Hostnames are case-insensitive; IP literals are unaffected by lowercasing.
30
+ const patternHost = this.host.toLowerCase();
31
+ host = host.toLowerCase();
32
+ if (patternHost === '*') {
33
+ return true;
34
+ }
35
+ if (patternHost === 'localhost') {
36
+ return host === 'localhost' || isLoopback(host);
37
+ }
38
+ if (patternHost === 'private') {
39
+ return isPrivateIpv4(host);
40
+ }
41
+ if (patternHost.startsWith('*.')) {
42
+ // The leading dot in the suffix guarantees a full-label boundary, so "*.vendor.com"
43
+ // matches "api.vendor.com" but not "vendor.com".
44
+ return host.endsWith(patternHost.slice(1));
45
+ }
46
+ return patternHost === host;
47
+ }
48
+ }
49
+ /**
50
+ * RFC 1918 private address ranges: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`.
51
+ */
52
+ function isPrivateIpv4(host) {
53
+ return Address4.isValid(host) && new Address4(host).isPrivate();
54
+ }
55
+ /**
56
+ * Loopback ranges: `127.0.0.0/8` (RFC 5735) and `::1` (RFC 4291).
57
+ */
58
+ function isLoopback(host) {
59
+ // Only IPv6 literals are ever bracketed (e.g. "[::1]"); `Address6` doesn't accept the brackets itself.
60
+ if (host.startsWith('[') && host.endsWith(']')) {
61
+ const address = host.slice(1, -1);
62
+ return Address6.isValid(address) && new Address6(address).isLoopback();
63
+ }
64
+ return (Address4.isValid(host) && new Address4(host).isLoopback()) || (Address6.isValid(host) && new Address6(host).isLoopback());
65
+ }
@@ -0,0 +1,6 @@
1
+ import { CogsPluginManifestJson, CogsValueTypeOptionWithDefault } from '../types/CogsPluginManifest';
2
+ import { z } from 'zod/v4';
3
+ export declare const cogsValueTypeOptionWithDefaultSchema: (options: {
4
+ defaultRequired: boolean;
5
+ }) => z.ZodType<CogsValueTypeOptionWithDefault>;
6
+ export declare function getPluginManifestErrors(manifest: CogsPluginManifestJson): string[] | null;
@@ -0,0 +1,167 @@
1
+ import { z } from 'zod/v4';
2
+ import semver from 'semver';
3
+ import { NetworkHostPattern } from './NetworkHostPattern';
4
+ const uniqueStringArray = z.array(z.string().min(1)).refine((items) => new Set(items).size === items.length, {
5
+ message: 'Array items must be unique',
6
+ });
7
+ const cogsValueTypeStringSchema = z.strictObject({
8
+ type: z.literal('string'),
9
+ });
10
+ const cogsValueTypeNumberSchema = z.strictObject({
11
+ type: z.literal('number'),
12
+ integer: z.literal(true).optional(),
13
+ min: z.number().optional(),
14
+ max: z.number().optional(),
15
+ });
16
+ const cogsValueTypeBooleanSchema = z.strictObject({
17
+ type: z.literal('boolean'),
18
+ });
19
+ const cogsValueTypeOptionSchema = z.strictObject({
20
+ type: z.literal('option'),
21
+ options: uniqueStringArray,
22
+ });
23
+ const pluginCogsValueTypeJsonSchema = z.union([
24
+ cogsValueTypeStringSchema,
25
+ cogsValueTypeNumberSchema,
26
+ cogsValueTypeBooleanSchema,
27
+ cogsValueTypeOptionSchema,
28
+ ]);
29
+ const cogsValueTypeStringWithDefaultSchema = (options) => (options.defaultRequired
30
+ ? z.strictObject({ type: z.literal('string'), default: z.string() })
31
+ : z.strictObject({ type: z.literal('string'), default: z.string().optional() }));
32
+ const cogsValueTypeNumberWithDefaultSchema = (options) => (options.defaultRequired
33
+ ? z.strictObject({
34
+ type: z.literal('number'),
35
+ integer: z.boolean().optional(),
36
+ min: z.number().optional(),
37
+ max: z.number().optional(),
38
+ default: z.number(),
39
+ })
40
+ : z.strictObject({
41
+ type: z.literal('number'),
42
+ integer: z.boolean().optional(),
43
+ min: z.number().optional(),
44
+ max: z.number().optional(),
45
+ default: z.number().optional(),
46
+ }));
47
+ const cogsValueTypeBooleanWithDefaultSchema = (options) => (options.defaultRequired
48
+ ? z.strictObject({ type: z.literal('boolean'), default: z.boolean() })
49
+ : z.strictObject({ type: z.literal('boolean'), default: z.boolean().optional() }));
50
+ export const cogsValueTypeOptionWithDefaultSchema = (options) => (options.defaultRequired
51
+ ? z.strictObject({
52
+ type: z.literal('option'),
53
+ options: uniqueStringArray.optional(),
54
+ default: z.string().min(1),
55
+ })
56
+ : z.strictObject({
57
+ type: z.literal('option'),
58
+ options: uniqueStringArray.optional(),
59
+ default: z.string().min(1).optional(),
60
+ }));
61
+ const pluginCogsValueTypeWithDefaultJsonSchema = (options) => z.union([
62
+ cogsValueTypeStringWithDefaultSchema(options),
63
+ cogsValueTypeNumberWithDefaultSchema(options),
64
+ cogsValueTypeBooleanWithDefaultSchema(options),
65
+ cogsValueTypeOptionWithDefaultSchema(options),
66
+ ]);
67
+ const pluginManifestConfigJsonSchema = z.strictObject({
68
+ name: z.string().min(1),
69
+ value: pluginCogsValueTypeWithDefaultJsonSchema({ defaultRequired: false }),
70
+ });
71
+ const pluginManifestEventJsonSchema = z.strictObject({
72
+ name: z.string().min(1),
73
+ value: pluginCogsValueTypeJsonSchema.optional(),
74
+ });
75
+ const pluginManifestStateJsonSchema = z.strictObject({
76
+ name: z.string().min(1),
77
+ value: pluginCogsValueTypeWithDefaultJsonSchema({ defaultRequired: true }),
78
+ writableFromCogs: z.literal(true).optional(),
79
+ writableFromClient: z.literal(true).optional(),
80
+ });
81
+ const validateNetworkHostPattern = (pattern) => {
82
+ try {
83
+ new NetworkHostPattern(pattern);
84
+ return true;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ };
90
+ const cogsPluginManifestJsonSchema = z
91
+ .strictObject({
92
+ version: z.union([
93
+ z.templateLiteral([z.uint32()]),
94
+ z.templateLiteral([z.uint32(), z.literal('.'), z.uint32()]),
95
+ z.templateLiteral([z.uint32(), z.literal('.'), z.uint32(), z.literal('.'), z.uint32()]),
96
+ ]),
97
+ name: z.string(),
98
+ minCogsVersion: z.templateLiteral([z.uint32(), z.literal('.'), z.uint32(), z.literal('.'), z.uint32()]).optional(),
99
+ description: z.string().optional(),
100
+ icon: z.string().optional(),
101
+ indexPath: z.string().optional(),
102
+ window: z
103
+ .object({
104
+ width: z.number().gt(0).int(),
105
+ height: z.number().gt(0).int(),
106
+ visible: z.boolean().optional(),
107
+ })
108
+ .optional(),
109
+ config: z.array(pluginManifestConfigJsonSchema).optional(),
110
+ events: z
111
+ .object({
112
+ fromCogs: z.array(pluginManifestEventJsonSchema).optional(),
113
+ toCogs: z.array(pluginManifestEventJsonSchema).optional(),
114
+ })
115
+ .optional(),
116
+ state: z.array(pluginManifestStateJsonSchema).optional(),
117
+ media: z
118
+ .object({
119
+ audio: z.literal(true).optional(),
120
+ video: z.literal(true).optional(),
121
+ images: z.literal(true).optional(),
122
+ })
123
+ .optional(),
124
+ store: z
125
+ .object({
126
+ items: z
127
+ .record(z.string(), z.strictObject({
128
+ persistValue: z.boolean().optional(),
129
+ }))
130
+ .optional(),
131
+ })
132
+ .optional(),
133
+ permissions: z
134
+ .strictObject({
135
+ network: z
136
+ .strictObject({
137
+ access: z
138
+ .array(z.strictObject({
139
+ hosts: z.array(z.string().refine(validateNetworkHostPattern, { error: 'Invalid network host pattern' })).min(1),
140
+ caCertificate: z.string().optional(),
141
+ }))
142
+ .optional(),
143
+ })
144
+ .optional(),
145
+ })
146
+ .optional(),
147
+ })
148
+ // Check that network access rules are only present if minCogsVersion is at least 5.11.0
149
+ .refine((manifest) => {
150
+ if (!manifest.permissions?.network?.access) {
151
+ return true;
152
+ }
153
+ if (manifest.minCogsVersion && semver.gte(manifest.minCogsVersion, '5.11.0')) {
154
+ return true;
155
+ }
156
+ return false;
157
+ }, {
158
+ path: ['permissions', 'network', 'access'],
159
+ message: 'minCogsVersion must be at least 5.11.0',
160
+ });
161
+ const validate = cogsPluginManifestJsonSchema;
162
+ export function getPluginManifestErrors(manifest) {
163
+ const result = validate.safeParse(manifest);
164
+ if (result.success)
165
+ return null;
166
+ return result.error.issues.map((issue) => `${issue.path.join('.') || 'root'}: ${issue.message}`);
167
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Connect to COGS to build a custom Media Master",
4
4
  "author": "Clockwork Dog <info@clockwork.dog>",
5
5
  "homepage": "https://github.com/clockwork-dog/cogs-sdk/tree/main/packages/javascript",
6
- "version": "3.0.3",
6
+ "version": "3.1.0",
7
7
  "keywords": [],
8
8
  "license": "MIT",
9
9
  "repository": {
@@ -37,8 +37,10 @@
37
37
  "cy:generate": "cypress run --e2e"
38
38
  },
39
39
  "dependencies": {
40
- "@clockworkdog/timesync": "^3.0.3",
40
+ "@clockworkdog/timesync": "^3.1.0",
41
+ "ip-address": "^10.2.0",
41
42
  "reconnecting-websocket": "^4.4.0",
43
+ "semver": "^7.8.5",
42
44
  "zod": "^4.1.13"
43
45
  },
44
46
  "devDependencies": {
@@ -46,6 +48,7 @@
46
48
  "@eslint/js": "^9.17.0",
47
49
  "@types/jsdom": "^27",
48
50
  "@types/node": "^22.10.2",
51
+ "@types/semver": "^7",
49
52
  "cypress": "^14.5.4",
50
53
  "eslint": "^9.17.0",
51
54
  "eslint-config-prettier": "^9.1.0",