@xiboplayer/utils 0.6.3 → 0.6.4

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/README.md CHANGED
@@ -6,11 +6,13 @@
6
6
 
7
7
  Foundation utilities used across the SDK:
8
8
 
9
- - **Logger** structured logging with configurable levels (`DEBUG`, `INFO`, `WARNING`, `ERROR`) and per-module tags
10
- - **EventEmitter** lightweight pub/sub event system
11
- - **fetchWithRetry** HTTP fetch with exponential backoff, jitter, and configurable retries
12
- - **CMS REST client** JSON API client with ETag caching
13
- - **Config** hardware key management and IndexedDB-backed configuration
9
+ - **Logger** -- structured logging with configurable levels (DEBUG, INFO, WARNING, ERROR, NONE) and per-module tags
10
+ - **Log sinks** -- pluggable log destinations (console, LogReporter for CMS submission)
11
+ - **EventEmitter** -- lightweight pub/sub event system
12
+ - **fetchWithRetry** -- HTTP fetch with exponential backoff, jitter, and configurable retries
13
+ - **Config** -- hardware key management, IndexedDB-backed configuration, CMS ID computation
14
+ - **CMS REST API client** -- 77-method JSON API client with ETag caching and JWT auth
15
+ - **PLAYER_API** -- configurable base path for media/widget/dependency URLs
14
16
 
15
17
  ## Installation
16
18
 
@@ -20,17 +22,104 @@ npm install @xiboplayer/utils
20
22
 
21
23
  ## Usage
22
24
 
25
+ ### Logger
26
+
23
27
  ```javascript
24
- import { createLogger, EventEmitter, fetchWithRetry } from '@xiboplayer/utils';
28
+ import { createLogger, setLogLevel, applyCmsLogLevel, registerLogSink } from '@xiboplayer/utils';
25
29
 
26
30
  const log = createLogger('my-module');
27
31
  log.info('Starting...');
28
- log.debug('Detailed info');
32
+ log.debug('Detailed info', { key: 'value' });
33
+ log.warn('Something unexpected');
34
+ log.error('Critical failure', error);
35
+
36
+ // Set global log level
37
+ setLogLevel('DEBUG'); // DEBUG, INFO, WARNING, ERROR, NONE
29
38
 
30
- const emitter = new EventEmitter();
31
- emitter.on('event', (data) => console.log(data));
39
+ // Apply CMS log level (maps CMS values to SDK levels)
40
+ applyCmsLogLevel('audit'); // maps to DEBUG
32
41
 
33
- const response = await fetchWithRetry(url, { retries: 3 });
42
+ // Register a custom log sink (e.g., CMS log reporter)
43
+ registerLogSink({
44
+ log(level, tag, ...args) {
45
+ cmsReporter.log(level, args.join(' '), tag);
46
+ }
47
+ });
48
+ ```
49
+
50
+ ### EventEmitter
51
+
52
+ ```javascript
53
+ import { EventEmitter } from '@xiboplayer/utils';
54
+
55
+ class MyClass extends EventEmitter {
56
+ doSomething() {
57
+ this.emit('done', { result: 42 });
58
+ }
59
+ }
60
+
61
+ const obj = new MyClass();
62
+ obj.on('done', (data) => console.log(data));
63
+ obj.once('done', (data) => console.log('First time only'));
64
+ obj.off('done', handler);
65
+ ```
66
+
67
+ ### fetchWithRetry
68
+
69
+ ```javascript
70
+ import { fetchWithRetry } from '@xiboplayer/utils';
71
+
72
+ const response = await fetchWithRetry(url, {
73
+ retries: 3, // Max retry attempts (default: 2)
74
+ baseDelay: 2000, // Base delay in ms (default: 2000)
75
+ // Backoff: baseDelay * 2^attempt + random jitter
76
+ headers: { 'Content-Type': 'application/json' },
77
+ method: 'POST',
78
+ body: JSON.stringify(data),
79
+ });
80
+ ```
81
+
82
+ ### Config
83
+
84
+ ```javascript
85
+ import { config, computeCmsId } from '@xiboplayer/utils';
86
+
87
+ // Read config values (from localStorage / IndexedDB)
88
+ const cmsUrl = config.data.cmsUrl;
89
+ const hardwareKey = config.data.hardwareKey;
90
+
91
+ // Compute CMS ID for namespacing databases
92
+ const cmsId = computeCmsId('https://cms.example.com', 'display-key');
93
+ // Returns FNV hash string for unique IndexedDB names per CMS+display pair
94
+ ```
95
+
96
+ ### CMS REST API client
97
+
98
+ ```javascript
99
+ import { CmsApiClient } from '@xiboplayer/utils';
100
+
101
+ const api = new CmsApiClient({
102
+ baseUrl: 'https://cms.example.com',
103
+ clientId: 'oauth-client-id',
104
+ clientSecret: 'oauth-client-secret',
105
+ });
106
+
107
+ // 77 methods covering all CMS entities
108
+ const displays = await api.getDisplays();
109
+ const layouts = await api.getLayouts();
110
+ await api.authorizeDisplay(displayId);
111
+ ```
112
+
113
+ ### PLAYER_API
114
+
115
+ ```javascript
116
+ import { PLAYER_API, setPlayerApi } from '@xiboplayer/utils';
117
+
118
+ // Default: '/api/v2/player'
119
+ console.log(PLAYER_API); // '/api/v2/player'
120
+
121
+ // Override before route registration (e.g., in proxy setup)
122
+ setPlayerApi('/custom/player/path');
34
123
  ```
35
124
 
36
125
  ## Exports
@@ -38,9 +127,28 @@ const response = await fetchWithRetry(url, { retries: 3 });
38
127
  | Export | Description |
39
128
  |--------|-------------|
40
129
  | `createLogger(tag)` | Create a tagged logger instance |
41
- | `EventEmitter` | Pub/sub event emitter |
130
+ | `setLogLevel(level)` | Set global log level |
131
+ | `getLogLevel()` | Get current log level |
132
+ | `isDebug()` | Check if DEBUG level is active |
133
+ | `applyCmsLogLevel(cmsLevel)` | Map CMS log level to SDK level |
134
+ | `registerLogSink(sink)` | Add custom log destination |
135
+ | `unregisterLogSink(sink)` | Remove log destination |
136
+ | `LOG_LEVELS` | Level constants: DEBUG, INFO, WARNING, ERROR, NONE |
137
+ | `EventEmitter` | Pub/sub event emitter class |
42
138
  | `fetchWithRetry(url, opts)` | Fetch with exponential backoff |
43
- | `Config` | Hardware key and IndexedDB config management |
139
+ | `config` | Global config instance (localStorage/IndexedDB) |
140
+ | `extractPwaConfig(config)` | Extract PWA-relevant keys from shell config |
141
+ | `computeCmsId(url, key)` | FNV hash for CMS+display namespacing |
142
+ | `SHELL_ONLY_KEYS` | Config keys not forwarded to PWA |
143
+ | `CmsApiClient` | CMS REST API client (77 methods) |
144
+ | `CmsApiError` | API error class with status code |
145
+ | `PLAYER_API` | Media/widget base path (configurable) |
146
+ | `setPlayerApi(base)` | Override PLAYER_API at runtime |
147
+ | `VERSION` | Package version from package.json |
148
+
149
+ ## Dependencies
150
+
151
+ No external runtime dependencies.
44
152
 
45
153
  ---
46
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/utils",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Shared utilities for Xibo Player packages",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,7 +12,7 @@
12
12
  "./config": "./src/config.js"
13
13
  },
14
14
  "dependencies": {
15
- "@xiboplayer/crypto": "0.6.3"
15
+ "@xiboplayer/crypto": "0.6.4"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
package/src/config.js CHANGED
@@ -1,15 +1,70 @@
1
1
  /**
2
2
  * Configuration management with priority: env vars → localStorage → defaults
3
3
  *
4
+ * Storage layout (per-CMS namespacing):
5
+ * xibo_global — device identity: hardwareKey, xmrPubKey, xmrPrivKey
6
+ * xibo_cms:{cmsId} — CMS-scoped: cmsUrl, cmsKey, displayName, xmrChannel, ...
7
+ * xibo_active_cms — string cmsId of the currently active CMS
8
+ * xibo_config — legacy flat key (written for rollback compatibility)
9
+ *
4
10
  * In Node.js (tests, CLI): environment variables are the only source.
5
11
  * In browser (PWA player): localStorage is primary, env vars override if set.
6
12
  */
7
13
  import { generateRsaKeyPair, isValidPemKey } from '@xiboplayer/crypto';
8
14
 
9
- const STORAGE_KEY = 'xibo_config';
15
+ const GLOBAL_KEY = 'xibo_global'; // Device identity (all CMSes)
16
+ const CMS_PREFIX = 'xibo_cms:'; // Per-CMS config prefix
17
+ const ACTIVE_CMS_KEY = 'xibo_active_cms'; // Active CMS ID
10
18
  const HW_DB_NAME = 'xibo-hw-backup';
11
19
  const HW_DB_VERSION = 1;
12
20
 
21
+ // Keys that belong to device identity (global, not CMS-scoped)
22
+ const GLOBAL_KEYS = new Set(['hardwareKey', 'xmrPubKey', 'xmrPrivKey']);
23
+
24
+ /**
25
+ * FNV-1a hash producing a 12-character hex string.
26
+ * Deterministic: same input always produces same output.
27
+ * @param {string} str - Input string to hash
28
+ * @returns {string} 12-character lowercase hex string
29
+ */
30
+ export function fnvHash(str) {
31
+ let hash = 2166136261; // FNV offset basis
32
+ for (let i = 0; i < str.length; i++) {
33
+ hash ^= str.charCodeAt(i);
34
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
35
+ }
36
+ hash = hash >>> 0;
37
+
38
+ // Extend to 12 chars with a second round using a different seed
39
+ let hash2 = hash + 1234567;
40
+ for (let i = 0; i < str.length; i++) {
41
+ hash2 ^= str.charCodeAt(i) + 1;
42
+ hash2 += (hash2 << 1) + (hash2 << 4) + (hash2 << 7) + (hash2 << 8) + (hash2 << 24);
43
+ }
44
+ hash2 = hash2 >>> 0;
45
+
46
+ return (hash.toString(16).padStart(8, '0') + hash2.toString(16).padStart(8, '0')).substring(0, 12);
47
+ }
48
+
49
+ /**
50
+ * Compute a deterministic CMS ID from a CMS URL.
51
+ * Format: {hostname}-{fnvHash12}
52
+ *
53
+ * @param {string} cmsUrl - Full CMS URL (e.g. "https://displays.superpantalles.com")
54
+ * @returns {string} CMS ID (e.g. "displays.superpantalles.com-a1b2c3d4e5f6")
55
+ */
56
+ export function computeCmsId(cmsUrl) {
57
+ if (!cmsUrl) return null;
58
+ try {
59
+ const url = new URL(cmsUrl);
60
+ const origin = url.origin;
61
+ return `${url.hostname}-${fnvHash(origin)}`;
62
+ } catch (e) {
63
+ // Invalid URL — hash the raw string
64
+ return `unknown-${fnvHash(cmsUrl)}`;
65
+ }
66
+ }
67
+
13
68
  /**
14
69
  * Check for environment variable config (highest priority).
15
70
  * Env vars: CMS_URL, CMS_KEY, DISPLAY_NAME, HARDWARE_KEY, XMR_CHANNEL
@@ -35,6 +90,7 @@ function loadFromEnv() {
35
90
 
36
91
  export class Config {
37
92
  constructor() {
93
+ this._activeCmsId = null;
38
94
  this.data = this.load();
39
95
  // Async: try to restore hardware key from IndexedDB if localStorage lost it
40
96
  // (only when not running from env vars)
@@ -56,19 +112,63 @@ export class Config {
56
112
  return { cmsUrl: '', cmsKey: '', displayName: '', hardwareKey: '', xmrChannel: '' };
57
113
  }
58
114
 
59
- // Try to load from localStorage
60
- const json = localStorage.getItem(STORAGE_KEY);
61
- let config = {};
115
+ // Load from split storage (or fresh install)
116
+ const globalJson = localStorage.getItem(GLOBAL_KEY);
117
+
118
+ if (globalJson) {
119
+ return this._loadSplit();
120
+ }
121
+
122
+ // Fresh install — no config at all
123
+ return this._loadFresh();
124
+ }
125
+
126
+ /**
127
+ * Load from split storage (new format).
128
+ * Merges xibo_global + xibo_cms:{activeCmsId} into a single data object.
129
+ */
130
+ _loadSplit() {
131
+ let global = {};
132
+ try {
133
+ global = JSON.parse(localStorage.getItem(GLOBAL_KEY) || '{}');
134
+ } catch (e) {
135
+ console.error('[Config] Failed to parse xibo_global:', e);
136
+ }
137
+
138
+ // Determine active CMS
139
+ const activeCmsId = localStorage.getItem(ACTIVE_CMS_KEY) || null;
140
+ this._activeCmsId = activeCmsId;
62
141
 
63
- if (json) {
142
+ let cmsConfig = {};
143
+ if (activeCmsId) {
64
144
  try {
65
- config = JSON.parse(json);
145
+ const cmsJson = localStorage.getItem(CMS_PREFIX + activeCmsId);
146
+ if (cmsJson) cmsConfig = JSON.parse(cmsJson);
66
147
  } catch (e) {
67
- console.error('[Config] Failed to parse localStorage config:', e);
148
+ console.error('[Config] Failed to parse CMS config:', e);
68
149
  }
69
150
  }
70
151
 
71
- // ── Single validation gate (same path for fresh + pre-seeded) ──
152
+ // Merge global + CMS-scoped
153
+ const config = { ...global, ...cmsConfig };
154
+
155
+ // Validate and generate missing keys
156
+ return this._validateConfig(config);
157
+ }
158
+
159
+ /**
160
+ * Fresh install — no existing config.
161
+ */
162
+ _loadFresh() {
163
+ const config = {};
164
+ return this._validateConfig(config);
165
+ }
166
+
167
+ /**
168
+ * Validate config, generate missing hardwareKey/xmrChannel.
169
+ * Shared by all load paths.
170
+ */
171
+ _validateConfig(config) {
72
172
  let changed = false;
73
173
 
74
174
  if (!config.hardwareKey || config.hardwareKey.length < 10) {
@@ -91,13 +191,159 @@ export class Config {
91
191
  config.cmsKey = config.cmsKey || '';
92
192
  config.displayName = config.displayName || '';
93
193
 
94
- if (changed) {
95
- localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
194
+ if (changed && typeof localStorage !== 'undefined') {
195
+ // Save via split storage
196
+ this._saveSplit(config);
96
197
  }
97
198
 
98
199
  return config;
99
200
  }
100
201
 
202
+ save() {
203
+ if (typeof localStorage === 'undefined') return;
204
+ this._saveSplit(this.data);
205
+ }
206
+
207
+ /**
208
+ * Write data to split storage: xibo_global + xibo_cms:{id} + legacy xibo_config.
209
+ */
210
+ _saveSplit(data) {
211
+ if (typeof localStorage === 'undefined') return;
212
+
213
+ // Split into global and CMS-scoped
214
+ const global = {};
215
+ const cmsScoped = {};
216
+ for (const [key, value] of Object.entries(data)) {
217
+ if (GLOBAL_KEYS.has(key)) {
218
+ global[key] = value;
219
+ } else {
220
+ cmsScoped[key] = value;
221
+ }
222
+ }
223
+
224
+ localStorage.setItem(GLOBAL_KEY, JSON.stringify(global));
225
+
226
+ // Compute CMS ID (may update if cmsUrl changed)
227
+ const cmsId = computeCmsId(data.cmsUrl);
228
+ if (cmsId) {
229
+ localStorage.setItem(CMS_PREFIX + cmsId, JSON.stringify(cmsScoped));
230
+ localStorage.setItem(ACTIVE_CMS_KEY, cmsId);
231
+ this._activeCmsId = cmsId;
232
+ }
233
+
234
+ // Legacy flat key for rollback compatibility (index.html gate, tests, etc.)
235
+ localStorage.setItem('xibo_config', JSON.stringify(data));
236
+ }
237
+
238
+ /**
239
+ * Switch to a different CMS. Saves the current CMS profile,
240
+ * loads (or creates) the target CMS profile.
241
+ *
242
+ * @param {string} cmsUrl - New CMS URL to switch to
243
+ * @returns {{ cmsId: string, isNew: boolean }} The new CMS ID and whether it was newly created
244
+ */
245
+ switchCms(cmsUrl) {
246
+ if (typeof localStorage === 'undefined') {
247
+ throw new Error('switchCms requires localStorage (browser only)');
248
+ }
249
+
250
+ // Save current state
251
+ this.save();
252
+
253
+ const newCmsId = computeCmsId(cmsUrl);
254
+ if (!newCmsId) throw new Error('Invalid CMS URL');
255
+
256
+ // Try to load existing CMS profile
257
+ const existingJson = localStorage.getItem(CMS_PREFIX + newCmsId);
258
+ let cmsConfig = {};
259
+ let isNew = true;
260
+
261
+ if (existingJson) {
262
+ try {
263
+ cmsConfig = JSON.parse(existingJson);
264
+ isNew = false;
265
+ console.log(`[Config] Switching to existing CMS profile: ${newCmsId}`);
266
+ } catch (e) {
267
+ console.error('[Config] Failed to parse target CMS config:', e);
268
+ }
269
+ } else {
270
+ console.log(`[Config] Creating new CMS profile: ${newCmsId}`);
271
+ cmsConfig = {
272
+ cmsUrl,
273
+ cmsKey: '',
274
+ displayName: '',
275
+ xmrChannel: this.generateXmrChannel(),
276
+ };
277
+ localStorage.setItem(CMS_PREFIX + newCmsId, JSON.stringify(cmsConfig));
278
+ }
279
+
280
+ // Update active CMS
281
+ localStorage.setItem(ACTIVE_CMS_KEY, newCmsId);
282
+ this._activeCmsId = newCmsId;
283
+
284
+ // Merge global + new CMS config into data
285
+ let global = {};
286
+ try {
287
+ global = JSON.parse(localStorage.getItem(GLOBAL_KEY) || '{}');
288
+ } catch (_) {}
289
+
290
+ this.data = { ...global, ...cmsConfig };
291
+
292
+ // Ensure cmsUrl is set (in case the profile was pre-existing without it)
293
+ if (!this.data.cmsUrl) {
294
+ this.data.cmsUrl = cmsUrl;
295
+ }
296
+
297
+ return { cmsId: newCmsId, isNew };
298
+ }
299
+
300
+ /**
301
+ * List all CMS profiles stored in localStorage.
302
+ * @returns {Array<{ cmsId: string, cmsUrl: string, displayName: string, isActive: boolean }>}
303
+ */
304
+ listCmsProfiles() {
305
+ if (typeof localStorage === 'undefined') return [];
306
+
307
+ const profiles = [];
308
+ const activeCmsId = localStorage.getItem(ACTIVE_CMS_KEY) || null;
309
+
310
+ for (let i = 0; i < localStorage.length; i++) {
311
+ const key = localStorage.key(i);
312
+ if (!key.startsWith(CMS_PREFIX)) continue;
313
+
314
+ const cmsId = key.slice(CMS_PREFIX.length);
315
+ try {
316
+ const data = JSON.parse(localStorage.getItem(key));
317
+ profiles.push({
318
+ cmsId,
319
+ cmsUrl: data.cmsUrl || '',
320
+ displayName: data.displayName || '',
321
+ isActive: cmsId === activeCmsId,
322
+ });
323
+ } catch (_) {}
324
+ }
325
+
326
+ return profiles;
327
+ }
328
+
329
+ /**
330
+ * Get the active CMS ID (deterministic hash of the CMS URL origin).
331
+ * Returns null if no CMS is configured.
332
+ * @returns {string|null}
333
+ */
334
+ get activeCmsId() {
335
+ // Return cached value if available
336
+ if (this._activeCmsId) return this._activeCmsId;
337
+ // Compute from current cmsUrl
338
+ const id = computeCmsId(this.data?.cmsUrl);
339
+ this._activeCmsId = id;
340
+ return id;
341
+ }
342
+
343
+ isConfigured() {
344
+ return !!(this.data.cmsUrl && this.data.cmsKey && this.data.displayName);
345
+ }
346
+
101
347
  /**
102
348
  * Backup keys to IndexedDB (more persistent than localStorage).
103
349
  * IndexedDB survives "Clear site data" in some browsers where localStorage doesn't.
@@ -179,16 +425,6 @@ export class Config {
179
425
  }
180
426
  }
181
427
 
182
- save() {
183
- if (typeof localStorage !== 'undefined') {
184
- localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data));
185
- }
186
- }
187
-
188
- isConfigured() {
189
- return !!(this.data.cmsUrl && this.data.cmsKey && this.data.displayName);
190
- }
191
-
192
428
  generateStableHardwareKey() {
193
429
  // Generate a stable UUID-based hardware key
194
430
  // CRITICAL: This is generated ONCE and saved to localStorage
@@ -331,6 +567,10 @@ export class Config {
331
567
 
332
568
  get googleGeoApiKey() { return this.data.googleGeoApiKey || ''; }
333
569
  set googleGeoApiKey(val) { this.data.googleGeoApiKey = val; this.save(); }
570
+
571
+ get controls() { return this.data.controls || {}; }
572
+ get transport() { return this.data.transport || 'auto'; }
573
+ get debug() { return this.data.debug || {}; }
334
574
  }
335
575
 
336
576
  export const config = new Config();
@@ -343,16 +583,50 @@ export const config = new Config();
343
583
  * to extractPwaConfig().
344
584
  *
345
585
  * Electron extras: autoLaunch
346
- * Chromium extras: browser, extraBrowserFlags, relaxSslCerts
586
+ * Chromium extras: browser, extraBrowserFlags
347
587
  */
588
+ /**
589
+ * Keys that are specific to a particular shell platform.
590
+ * Used by warnPlatformMismatch() to detect config.json mistakes.
591
+ */
592
+ const PLATFORM_KEYS = {
593
+ kioskMode: ['electron', 'chromium'],
594
+ autoLaunch: ['electron'],
595
+ allowShellCommands: ['electron', 'chromium'],
596
+ browser: ['chromium'],
597
+ extraBrowserFlags: ['chromium'],
598
+ };
599
+
600
+ /**
601
+ * Log warnings for config keys that don't belong to the current platform.
602
+ * Informational only — does not prevent startup.
603
+ *
604
+ * @param {Object} configObj - The full config.json object
605
+ * @param {string} platform - Current platform: 'electron' or 'chromium'
606
+ */
607
+ export function warnPlatformMismatch(configObj, platform) {
608
+ if (!configObj || !platform) return;
609
+ const p = platform.toLowerCase();
610
+ for (const [key, platforms] of Object.entries(PLATFORM_KEYS)) {
611
+ if (key in configObj && !platforms.includes(p)) {
612
+ console.warn(
613
+ `[Config] Key "${key}" is only supported on ${platforms.join('/')}, ` +
614
+ `but current platform is ${p} — this key will be ignored`
615
+ );
616
+ }
617
+ }
618
+ }
619
+
348
620
  export const SHELL_ONLY_KEYS = new Set([
349
621
  'serverPort',
350
622
  'kioskMode',
351
623
  'fullscreen',
352
624
  'hideMouseCursor',
353
625
  'preventSleep',
626
+ 'allowShellCommands',
354
627
  'width',
355
628
  'height',
629
+ 'relaxSslCerts',
356
630
  ]);
357
631
 
358
632
  /**
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Config Tests
3
3
  *
4
- * Tests for configuration management with localStorage persistence
4
+ * Tests for configuration management with split localStorage persistence
5
+ * (xibo_global + xibo_cms:{cmsId} + xibo_active_cms)
5
6
  */
6
7
 
7
8
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -24,7 +25,27 @@ vi.mock('@xiboplayer/crypto', () => {
24
25
  };
25
26
  });
26
27
 
27
- import { Config } from './config.js';
28
+ import { Config, computeCmsId, fnvHash, warnPlatformMismatch } from './config.js';
29
+
30
+ /**
31
+ * Seed split localStorage with a full config (global + CMS-scoped).
32
+ * Helper to set up pre-existing config in the new format.
33
+ */
34
+ function seedConfig(storage, data) {
35
+ const GLOBAL_KEYS = new Set(['hardwareKey', 'xmrPubKey', 'xmrPrivKey']);
36
+ const global = {};
37
+ const cms = {};
38
+ for (const [k, v] of Object.entries(data)) {
39
+ if (GLOBAL_KEYS.has(k)) global[k] = v;
40
+ else cms[k] = v;
41
+ }
42
+ storage.setItem('xibo_global', JSON.stringify(global));
43
+ const cmsId = computeCmsId(data.cmsUrl);
44
+ if (cmsId) {
45
+ storage.setItem(`xibo_cms:${cmsId}`, JSON.stringify(cms));
46
+ storage.setItem('xibo_active_cms', cmsId);
47
+ }
48
+ }
28
49
 
29
50
  describe('Config', () => {
30
51
  let config;
@@ -32,7 +53,7 @@ describe('Config', () => {
32
53
  let mockRandomUUID;
33
54
 
34
55
  beforeEach(() => {
35
- // Mock localStorage
56
+ // Mock localStorage (with key/length for iteration in listCmsProfiles)
36
57
  mockLocalStorage = {
37
58
  data: {},
38
59
  getItem(key) {
@@ -46,6 +67,12 @@ describe('Config', () => {
46
67
  },
47
68
  clear() {
48
69
  this.data = {};
70
+ },
71
+ key(index) {
72
+ return Object.keys(this.data)[index] || null;
73
+ },
74
+ get length() {
75
+ return Object.keys(this.data).length;
49
76
  }
50
77
  };
51
78
 
@@ -91,14 +118,14 @@ describe('Config', () => {
91
118
  expect(hwKey).toBe('pwa-1234567812344567890123456789');
92
119
  });
93
120
 
94
- it('should save config to localStorage on creation', () => {
121
+ it('should save config to split localStorage on creation', () => {
95
122
  config = new Config();
96
123
 
97
- const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
98
- expect(stored).toEqual(config.data);
124
+ const global = JSON.parse(mockLocalStorage.getItem('xibo_global'));
125
+ expect(global.hardwareKey).toBe(config.data.hardwareKey);
99
126
  });
100
127
 
101
- it('should load existing config from localStorage', () => {
128
+ it('should load existing config from split localStorage', () => {
102
129
  const existingConfig = {
103
130
  cmsUrl: 'https://test.cms.com',
104
131
  cmsKey: 'test-key',
@@ -107,23 +134,25 @@ describe('Config', () => {
107
134
  xmrChannel: '12345678-1234-4567-8901-234567890abc'
108
135
  };
109
136
 
110
- mockLocalStorage.setItem('xibo_config', JSON.stringify(existingConfig));
137
+ seedConfig(mockLocalStorage, existingConfig);
111
138
 
112
139
  config = new Config();
113
140
 
114
- expect(config.data).toEqual(existingConfig);
141
+ expect(config.data.cmsUrl).toBe('https://test.cms.com');
142
+ expect(config.data.cmsKey).toBe('test-key');
143
+ expect(config.data.displayName).toBe('Test Display');
144
+ expect(config.data.hardwareKey).toBe('pwa-existinghardwarekey1234567');
145
+ expect(config.data.xmrChannel).toBe('12345678-1234-4567-8901-234567890abc');
115
146
  });
116
147
 
117
148
  it('should regenerate hardware key if invalid in stored config', () => {
118
- const invalidConfig = {
149
+ seedConfig(mockLocalStorage, {
119
150
  cmsUrl: 'https://test.cms.com',
120
151
  cmsKey: 'test-key',
121
152
  displayName: 'Test Display',
122
153
  hardwareKey: 'short', // Invalid: too short
123
154
  xmrChannel: '12345678-1234-4567-8901-234567890abc'
124
- };
125
-
126
- mockLocalStorage.setItem('xibo_config', JSON.stringify(invalidConfig));
155
+ });
127
156
 
128
157
  config = new Config();
129
158
 
@@ -132,7 +161,7 @@ describe('Config', () => {
132
161
  });
133
162
 
134
163
  it('should handle corrupted JSON in localStorage', () => {
135
- mockLocalStorage.setItem('xibo_config', 'invalid-json{');
164
+ mockLocalStorage.setItem('xibo_global', 'invalid-json{');
136
165
 
137
166
  config = new Config();
138
167
 
@@ -307,11 +336,12 @@ describe('Config', () => {
307
336
  expect(config.data.cmsUrl).toBe('https://new.cms.com');
308
337
  });
309
338
 
310
- it('should save to localStorage when cmsUrl set', () => {
339
+ it('should save to split localStorage when cmsUrl set', () => {
311
340
  config.cmsUrl = 'https://test.com';
312
341
 
313
- const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
314
- expect(stored.cmsUrl).toBe('https://test.com');
342
+ const cmsId = computeCmsId('https://test.com');
343
+ const cms = JSON.parse(mockLocalStorage.getItem(`xibo_cms:${cmsId}`));
344
+ expect(cms.cmsUrl).toBe('https://test.com');
315
345
  });
316
346
 
317
347
  it('should get/set cmsKey', () => {
@@ -383,22 +413,24 @@ describe('Config', () => {
383
413
  config = new Config();
384
414
  });
385
415
 
386
- it('should save current config to localStorage', () => {
416
+ it('should save current config to split localStorage', () => {
387
417
  config.data.cmsUrl = 'https://manual.com';
388
418
  config.data.cmsKey = 'manual-key';
389
419
 
390
420
  config.save();
391
421
 
392
- const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
393
- expect(stored.cmsUrl).toBe('https://manual.com');
394
- expect(stored.cmsKey).toBe('manual-key');
422
+ const cmsId = computeCmsId('https://manual.com');
423
+ const cms = JSON.parse(mockLocalStorage.getItem(`xibo_cms:${cmsId}`));
424
+ expect(cms.cmsUrl).toBe('https://manual.com');
425
+ expect(cms.cmsKey).toBe('manual-key');
395
426
  });
396
427
 
397
428
  it('should auto-save when setters used', () => {
398
429
  config.cmsUrl = 'https://auto.com';
399
430
 
400
- const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
401
- expect(stored.cmsUrl).toBe('https://auto.com');
431
+ const cmsId = computeCmsId('https://auto.com');
432
+ const cms = JSON.parse(mockLocalStorage.getItem(`xibo_cms:${cmsId}`));
433
+ expect(cms.cmsUrl).toBe('https://auto.com');
402
434
  });
403
435
  });
404
436
 
@@ -419,12 +451,12 @@ describe('Config', () => {
419
451
 
420
452
  describe('Edge Cases', () => {
421
453
  it('should handle missing hardwareKey in loaded config', () => {
422
- mockLocalStorage.setItem('xibo_config', JSON.stringify({
454
+ seedConfig(mockLocalStorage, {
423
455
  cmsUrl: 'https://test.com',
424
456
  cmsKey: 'test-key',
425
457
  displayName: 'Test'
426
458
  // hardwareKey missing
427
- }));
459
+ });
428
460
 
429
461
  config = new Config();
430
462
 
@@ -433,13 +465,13 @@ describe('Config', () => {
433
465
  });
434
466
 
435
467
  it('should handle null values in config', () => {
436
- mockLocalStorage.setItem('xibo_config', JSON.stringify({
468
+ seedConfig(mockLocalStorage, {
437
469
  cmsUrl: null,
438
470
  cmsKey: null,
439
471
  displayName: null,
440
472
  hardwareKey: 'pwa-1234567812344567890123456789',
441
473
  xmrChannel: '12345678-1234-4567-8901-234567890abc'
442
- }));
474
+ });
443
475
 
444
476
  config = new Config();
445
477
 
@@ -506,12 +538,12 @@ describe('Config', () => {
506
538
  expect(config.data.xmrPrivKey).toMatch(/^-----BEGIN PRIVATE KEY-----/);
507
539
  });
508
540
 
509
- it('should persist keys to localStorage', async () => {
541
+ it('should persist keys to xibo_global', async () => {
510
542
  await config.ensureXmrKeyPair();
511
543
 
512
- const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
513
- expect(stored.xmrPubKey).toMatch(/^-----BEGIN PUBLIC KEY-----/);
514
- expect(stored.xmrPrivKey).toMatch(/^-----BEGIN PRIVATE KEY-----/);
544
+ const global = JSON.parse(mockLocalStorage.getItem('xibo_global'));
545
+ expect(global.xmrPubKey).toMatch(/^-----BEGIN PUBLIC KEY-----/);
546
+ expect(global.xmrPrivKey).toMatch(/^-----BEGIN PRIVATE KEY-----/);
515
547
  });
516
548
 
517
549
  it('should be idempotent — second call preserves existing keys', async () => {
@@ -579,4 +611,235 @@ describe('Config', () => {
579
611
  expect(config.xmrPrivKey).toMatch(/^-----BEGIN PRIVATE KEY-----/);
580
612
  });
581
613
  });
614
+
615
+ describe('warnPlatformMismatch()', () => {
616
+ let warnSpy;
617
+
618
+ beforeEach(() => {
619
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
620
+ });
621
+
622
+ afterEach(() => {
623
+ warnSpy.mockRestore();
624
+ });
625
+
626
+ it('should warn when Chromium-only key is used in Electron', () => {
627
+ warnPlatformMismatch({ browser: 'chrome', cmsUrl: 'https://test.com' }, 'electron');
628
+
629
+ expect(warnSpy).toHaveBeenCalledTimes(1);
630
+ expect(warnSpy.mock.calls[0][0]).toContain('browser');
631
+ expect(warnSpy.mock.calls[0][0]).toContain('chromium');
632
+ });
633
+
634
+ it('should warn when Electron-only key is used in Chromium', () => {
635
+ warnPlatformMismatch({ autoLaunch: true }, 'chromium');
636
+
637
+ expect(warnSpy).toHaveBeenCalledTimes(1);
638
+ expect(warnSpy.mock.calls[0][0]).toContain('autoLaunch');
639
+ expect(warnSpy.mock.calls[0][0]).toContain('electron');
640
+ });
641
+
642
+ it('should not warn for shared keys', () => {
643
+ warnPlatformMismatch({ kioskMode: true, cmsUrl: 'https://test.com' }, 'electron');
644
+
645
+ expect(warnSpy).not.toHaveBeenCalled();
646
+ });
647
+
648
+ it('should not warn when config or platform is missing', () => {
649
+ warnPlatformMismatch(null, 'electron');
650
+ warnPlatformMismatch({ browser: 'chrome' }, '');
651
+
652
+ expect(warnSpy).not.toHaveBeenCalled();
653
+ });
654
+
655
+ it('should warn for multiple mismatched keys', () => {
656
+ warnPlatformMismatch({ browser: 'chrome', extraBrowserFlags: '--flag' }, 'electron');
657
+
658
+ expect(warnSpy).toHaveBeenCalledTimes(2);
659
+ });
660
+ });
661
+
662
+ describe('Per-CMS Namespacing', () => {
663
+ describe('fnvHash()', () => {
664
+ it('should produce 12-char hex string', () => {
665
+ expect(fnvHash('test')).toMatch(/^[0-9a-f]{12}$/);
666
+ });
667
+
668
+ it('should be deterministic', () => {
669
+ expect(fnvHash('hello')).toBe(fnvHash('hello'));
670
+ });
671
+
672
+ it('should differ for different inputs', () => {
673
+ expect(fnvHash('hello')).not.toBe(fnvHash('world'));
674
+ });
675
+ });
676
+
677
+ describe('computeCmsId()', () => {
678
+ it('should produce hostname-hash format', () => {
679
+ const id = computeCmsId('https://displays.superpantalles.com');
680
+ expect(id).toMatch(/^displays\.superpantalles\.com-[0-9a-f]{12}$/);
681
+ });
682
+
683
+ it('should handle localhost with port', () => {
684
+ const id = computeCmsId('http://localhost:8080');
685
+ expect(id).toMatch(/^localhost-[0-9a-f]{12}$/);
686
+ });
687
+
688
+ it('should return null for empty URL', () => {
689
+ expect(computeCmsId('')).toBeNull();
690
+ expect(computeCmsId(null)).toBeNull();
691
+ expect(computeCmsId(undefined)).toBeNull();
692
+ });
693
+
694
+ it('should be deterministic for same URL', () => {
695
+ const id1 = computeCmsId('https://cms.example.com');
696
+ const id2 = computeCmsId('https://cms.example.com');
697
+ expect(id1).toBe(id2);
698
+ });
699
+
700
+ it('should differ for different URLs', () => {
701
+ const id1 = computeCmsId('https://cms1.example.com');
702
+ const id2 = computeCmsId('https://cms2.example.com');
703
+ expect(id1).not.toBe(id2);
704
+ });
705
+
706
+ it('should handle invalid URL gracefully', () => {
707
+ const id = computeCmsId('not-a-url');
708
+ expect(id).toMatch(/^unknown-[0-9a-f]{12}$/);
709
+ });
710
+ });
711
+
712
+ describe('activeCmsId', () => {
713
+ it('should return null when no CMS configured', () => {
714
+ config = new Config();
715
+ expect(config.activeCmsId).toBeNull();
716
+ });
717
+
718
+ it('should return CMS ID when configured', () => {
719
+ seedConfig(mockLocalStorage, {
720
+ cmsUrl: 'https://test.cms.com',
721
+ cmsKey: 'key',
722
+ displayName: 'Test',
723
+ hardwareKey: 'pwa-existinghardwarekey1234567',
724
+ xmrChannel: '12345678-1234-4567-8901-234567890abc',
725
+ });
726
+
727
+ config = new Config();
728
+ expect(config.activeCmsId).toBe(computeCmsId('https://test.cms.com'));
729
+ });
730
+ });
731
+
732
+ describe('switchCms()', () => {
733
+ it('should switch to a new CMS and preserve hardwareKey', () => {
734
+ seedConfig(mockLocalStorage, {
735
+ cmsUrl: 'https://cms1.com',
736
+ cmsKey: 'key1',
737
+ displayName: 'Display on CMS1',
738
+ hardwareKey: 'pwa-existinghardwarekey1234567',
739
+ xmrChannel: '12345678-1234-4567-8901-234567890abc',
740
+ });
741
+
742
+ config = new Config();
743
+ const hwKey = config.hardwareKey;
744
+
745
+ const result = config.switchCms('https://cms2.com');
746
+
747
+ expect(result.isNew).toBe(true);
748
+ expect(result.cmsId).toBe(computeCmsId('https://cms2.com'));
749
+ expect(config.hardwareKey).toBe(hwKey); // Same device identity
750
+ expect(config.cmsUrl).toBe('https://cms2.com');
751
+ expect(config.cmsKey).toBe(''); // New CMS, no key yet
752
+ });
753
+
754
+ it('should switch back to a previously known CMS', () => {
755
+ seedConfig(mockLocalStorage, {
756
+ cmsUrl: 'https://cms1.com',
757
+ cmsKey: 'key1',
758
+ displayName: 'Display on CMS1',
759
+ hardwareKey: 'pwa-existinghardwarekey1234567',
760
+ xmrChannel: '12345678-1234-4567-8901-234567890abc',
761
+ });
762
+
763
+ config = new Config();
764
+
765
+ // Switch to CMS2
766
+ config.switchCms('https://cms2.com');
767
+ config.cmsKey = 'key2';
768
+ config.displayName = 'Display on CMS2';
769
+
770
+ // Switch back to CMS1
771
+ const result = config.switchCms('https://cms1.com');
772
+
773
+ expect(result.isNew).toBe(false);
774
+ expect(config.cmsKey).toBe('key1');
775
+ expect(config.displayName).toBe('Display on CMS1');
776
+ });
777
+ });
778
+
779
+ describe('listCmsProfiles()', () => {
780
+ it('should list all CMS profiles', () => {
781
+ seedConfig(mockLocalStorage, {
782
+ cmsUrl: 'https://cms1.com',
783
+ cmsKey: 'key1',
784
+ displayName: 'Display 1',
785
+ hardwareKey: 'pwa-existinghardwarekey1234567',
786
+ xmrChannel: '12345678-1234-4567-8901-234567890abc',
787
+ });
788
+
789
+ config = new Config();
790
+ config.switchCms('https://cms2.com');
791
+ config.displayName = 'Display 2';
792
+ config.save();
793
+
794
+ const profiles = config.listCmsProfiles();
795
+
796
+ expect(profiles.length).toBe(2);
797
+ expect(profiles.find(p => p.cmsUrl === 'https://cms1.com')).toBeTruthy();
798
+ expect(profiles.find(p => p.cmsUrl === 'https://cms2.com')).toBeTruthy();
799
+ // Only one should be active
800
+ expect(profiles.filter(p => p.isActive).length).toBe(1);
801
+ });
802
+ });
803
+
804
+ describe('Split storage save/load', () => {
805
+ it('should write split keys on save', () => {
806
+ config = new Config();
807
+ config.data.cmsUrl = 'https://test.com';
808
+ config.data.cmsKey = 'k';
809
+ config.save();
810
+
811
+ // Global key should exist
812
+ expect(mockLocalStorage.getItem('xibo_global')).toBeTruthy();
813
+ // CMS key should exist
814
+ const cmsId = computeCmsId('https://test.com');
815
+ expect(mockLocalStorage.getItem(`xibo_cms:${cmsId}`)).toBeTruthy();
816
+
817
+ // Global should have hardwareKey, not cmsUrl
818
+ const global = JSON.parse(mockLocalStorage.getItem('xibo_global'));
819
+ expect(global.hardwareKey).toBeTruthy();
820
+ expect(global.cmsUrl).toBeUndefined();
821
+
822
+ // CMS should have cmsUrl, not hardwareKey
823
+ const cms = JSON.parse(mockLocalStorage.getItem(`xibo_cms:${cmsId}`));
824
+ expect(cms.cmsUrl).toBe('https://test.com');
825
+ expect(cms.hardwareKey).toBeUndefined();
826
+ });
827
+
828
+ it('should reload from split storage correctly', () => {
829
+ // Write config
830
+ config = new Config();
831
+ config.data.cmsUrl = 'https://reload.com';
832
+ config.data.cmsKey = 'reload-key';
833
+ config.data.displayName = 'Reload Test';
834
+ config.save();
835
+
836
+ // Reload
837
+ const config2 = new Config();
838
+ expect(config2.cmsUrl).toBe('https://reload.com');
839
+ expect(config2.cmsKey).toBe('reload-key');
840
+ expect(config2.displayName).toBe('Reload Test');
841
+ expect(config2.hardwareKey).toBe(config.hardwareKey);
842
+ });
843
+ });
844
+ });
582
845
  });
package/src/index.js CHANGED
@@ -4,7 +4,7 @@ export const VERSION = pkg.version;
4
4
  export { createLogger, setLogLevel, getLogLevel, isDebug, applyCmsLogLevel, mapCmsLogLevel, registerLogSink, unregisterLogSink, LOG_LEVELS } from './logger.js';
5
5
  export { EventEmitter } from './event-emitter.js';
6
6
  import { config as _config } from './config.js';
7
- export { config, SHELL_ONLY_KEYS, extractPwaConfig } from './config.js';
7
+ export { config, SHELL_ONLY_KEYS, extractPwaConfig, computeCmsId, fnvHash, warnPlatformMismatch } from './config.js';
8
8
  export { fetchWithRetry } from './fetch-retry.js';
9
9
  export { CmsApiClient, CmsApiError } from './cms-api.js';
10
10
 
@@ -12,14 +12,14 @@ export { CmsApiClient, CmsApiError } from './cms-api.js';
12
12
  * CMS Player API base path — all media, dependencies, and widgets are served
13
13
  * under this prefix.
14
14
  *
15
- * Default: '/api/v2/player' (standalone index.php endpoint).
15
+ * Default: '/player/api/v2' (standalone index.php endpoint).
16
16
  * Override: set `playerApiBase` in config.json / localStorage, or call
17
17
  * setPlayerApi('/new/path') before route registration (proxy).
18
18
  *
19
19
  * Browser: reads from config.data.playerApiBase at import time.
20
20
  * Node: call setPlayerApi() before createProxyApp().
21
21
  */
22
- const DEFAULT_PLAYER_API = '/api/v2/player';
22
+ const DEFAULT_PLAYER_API = '/player/api/v2';
23
23
  let _playerApi = _config.data?.playerApiBase || DEFAULT_PLAYER_API;
24
24
 
25
25
  /** Current Player API base path (no trailing slash). */