@xiboplayer/utils 0.7.2 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/utils",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Shared utilities for Xibo Player packages",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,10 +12,10 @@
12
12
  "./config": "./src/config.js"
13
13
  },
14
14
  "dependencies": {
15
- "@xiboplayer/crypto": "0.7.2"
15
+ "@xiboplayer/crypto": "0.7.4"
16
16
  },
17
17
  "devDependencies": {
18
- "vitest": "^2.0.0"
18
+ "vitest": "^2.1.9"
19
19
  },
20
20
  "keywords": [
21
21
  "xibo",
package/src/config.js CHANGED
@@ -14,6 +14,9 @@
14
14
  */
15
15
  import { generateRsaKeyPair, isValidPemKey } from '@xiboplayer/crypto';
16
16
  import { openIDB } from './idb.js';
17
+ import { createLogger } from './logger.js';
18
+
19
+ const log = createLogger('Config');
17
20
 
18
21
  const GLOBAL_KEY = 'xibo_global'; // Device identity (all CMSes)
19
22
  const CMS_PREFIX = 'xibo_cms:'; // Per-CMS config prefix
@@ -135,7 +138,7 @@ export class Config {
135
138
  try {
136
139
  global = JSON.parse(localStorage.getItem(GLOBAL_KEY) || '{}');
137
140
  } catch (e) {
138
- console.error('[Config] Failed to parse xibo_global:', e);
141
+ log.error('Failed to parse xibo_global:', e);
139
142
  }
140
143
 
141
144
  // Determine active CMS
@@ -148,7 +151,7 @@ export class Config {
148
151
  const cmsJson = localStorage.getItem(CMS_PREFIX + activeCmsId);
149
152
  if (cmsJson) cmsConfig = JSON.parse(cmsJson);
150
153
  } catch (e) {
151
- console.error('[Config] Failed to parse CMS config:', e);
154
+ log.error('Failed to parse CMS config:', e);
152
155
  }
153
156
  }
154
157
 
@@ -175,16 +178,16 @@ export class Config {
175
178
  let changed = false;
176
179
 
177
180
  if (!config.hardwareKey || config.hardwareKey.length < 10) {
178
- console.warn('[Config] Missing/invalid hardwareKey — generating');
181
+ log.warn('Missing/invalid hardwareKey — generating');
179
182
  config.hardwareKey = this.generateStableHardwareKey();
180
183
  this._backupHardwareKey(config.hardwareKey);
181
184
  changed = true;
182
185
  } else {
183
- console.log('[Config] ✓ Loaded existing hardwareKey:', config.hardwareKey);
186
+ log.info('✓ Loaded existing hardwareKey:', config.hardwareKey);
184
187
  }
185
188
 
186
189
  if (!config.xmrChannel) {
187
- console.warn('[Config] Missing xmrChannel — generating');
190
+ log.warn('Missing xmrChannel — generating');
188
191
  config.xmrChannel = this.generateXmrChannel();
189
192
  changed = true;
190
193
  }
@@ -265,12 +268,12 @@ export class Config {
265
268
  try {
266
269
  cmsConfig = JSON.parse(existingJson);
267
270
  isNew = false;
268
- console.log(`[Config] Switching to existing CMS profile: ${newCmsId}`);
271
+ log.info(`Switching to existing CMS profile: ${newCmsId}`);
269
272
  } catch (e) {
270
- console.error('[Config] Failed to parse target CMS config:', e);
273
+ log.error('Failed to parse target CMS config:', e);
271
274
  }
272
275
  } else {
273
- console.log(`[Config] Creating new CMS profile: ${newCmsId}`);
276
+ log.info(`Creating new CMS profile: ${newCmsId}`);
274
277
  cmsConfig = {
275
278
  cmsUrl,
276
279
  cmsKey: '',
@@ -361,7 +364,7 @@ export class Config {
361
364
  store.put(v, k);
362
365
  }
363
366
  tx.oncomplete = () => {
364
- console.log('[Config] Keys backed up to IndexedDB:', Object.keys(keys).join(', '));
367
+ log.info('Keys backed up to IndexedDB:', Object.keys(keys).join(', '));
365
368
  db.close();
366
369
  };
367
370
  } catch (e) {
@@ -395,8 +398,8 @@ export class Config {
395
398
  db.close();
396
399
 
397
400
  if (backedUpKey && backedUpKey !== this.data.hardwareKey) {
398
- console.log('[Config] Restoring hardware key from IndexedDB backup:', backedUpKey);
399
- console.log('[Config] (was:', this.data.hardwareKey, ')');
401
+ log.info('Restoring hardware key from IndexedDB backup:', backedUpKey);
402
+ log.info('(was:', this.data.hardwareKey, ')');
400
403
  this.data.hardwareKey = backedUpKey;
401
404
  this.save();
402
405
  } else if (!backedUpKey && this.data.hardwareKey) {
@@ -417,7 +420,7 @@ export class Config {
417
420
  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
418
421
  const uuid = crypto.randomUUID().replace(/-/g, ''); // Remove dashes
419
422
  const hardwareKey = 'pwa-' + uuid.substring(0, 28);
420
- console.log('[Config] Generated new UUID-based hardware key:', hardwareKey);
423
+ log.info('Generated new UUID-based hardware key:', hardwareKey);
421
424
  return hardwareKey;
422
425
  }
423
426
 
@@ -427,7 +430,7 @@ export class Config {
427
430
  ).join('');
428
431
 
429
432
  const hardwareKey = 'pwa-' + randomHex;
430
- console.log('[Config] Generated new random hardware key:', hardwareKey);
433
+ log.info('Generated new random hardware key:', hardwareKey);
431
434
  return hardwareKey;
432
435
  }
433
436
 
@@ -450,7 +453,7 @@ export class Config {
450
453
  return;
451
454
  }
452
455
 
453
- console.log('[Config] Generating RSA key pair for XMR registration...');
456
+ log.info('Generating RSA key pair for XMR registration...');
454
457
  const { publicKeyPem, privateKeyPem } = await generateRsaKeyPair();
455
458
 
456
459
  this.data.xmrPubKey = publicKeyPem;
@@ -462,7 +465,7 @@ export class Config {
462
465
  this._backupKeys({ xmrPubKey: publicKeyPem, xmrPrivKey: privateKeyPem });
463
466
  }
464
467
 
465
- console.log('[Config] RSA key pair generated and saved');
468
+ log.info('RSA key pair generated and saved');
466
469
  }
467
470
 
468
471
  get cmsUrl() { return this.data.cmsUrl; }
@@ -477,7 +480,7 @@ export class Config {
477
480
  get hardwareKey() {
478
481
  // CRITICAL: Ensure hardware key never becomes undefined
479
482
  if (!this.data.hardwareKey) {
480
- console.error('[Config] CRITICAL: hardwareKey missing! Generating emergency key.');
483
+ log.error('CRITICAL: hardwareKey missing! Generating emergency key.');
481
484
  this.data.hardwareKey = this.generateStableHardwareKey();
482
485
  this.save();
483
486
  }
@@ -485,7 +488,7 @@ export class Config {
485
488
  }
486
489
  get xmrChannel() {
487
490
  if (!this.data.xmrChannel) {
488
- console.warn('[Config] xmrChannel missing at access time — generating');
491
+ log.warn('xmrChannel missing at access time — generating');
489
492
  this.data.xmrChannel = this.generateXmrChannel();
490
493
  this.save();
491
494
  }
@@ -538,8 +541,8 @@ export function warnPlatformMismatch(configObj, platform) {
538
541
  const p = platform.toLowerCase();
539
542
  for (const [key, platforms] of Object.entries(PLATFORM_KEYS)) {
540
543
  if (key in configObj && !platforms.includes(p)) {
541
- console.warn(
542
- `[Config] Key "${key}" is only supported on ${platforms.join('/')}, ` +
544
+ log.warn(
545
+ `Key "${key}" is only supported on ${platforms.join('/')}, ` +
543
546
  `but current platform is ${p} — this key will be ignored`
544
547
  );
545
548
  }
@@ -499,16 +499,18 @@ describe('Config', () => {
499
499
  warnPlatformMismatch({ browser: 'chrome', cmsUrl: 'https://test.com' }, 'electron');
500
500
 
501
501
  expect(warnSpy).toHaveBeenCalledTimes(1);
502
- expect(warnSpy.mock.calls[0][0]).toContain('browser');
503
- expect(warnSpy.mock.calls[0][0]).toContain('chromium');
502
+ // Logger outputs: console.warn(timestamp_prefix, message) — message is in args[1]
503
+ expect(warnSpy.mock.calls[0][1]).toContain('browser');
504
+ expect(warnSpy.mock.calls[0][1]).toContain('chromium');
504
505
  });
505
506
 
506
507
  it('should warn when Electron-only key is used in Chromium', () => {
507
508
  warnPlatformMismatch({ autoLaunch: true }, 'chromium');
508
509
 
509
510
  expect(warnSpy).toHaveBeenCalledTimes(1);
510
- expect(warnSpy.mock.calls[0][0]).toContain('autoLaunch');
511
- expect(warnSpy.mock.calls[0][0]).toContain('electron');
511
+ // Logger outputs: console.warn(timestamp_prefix, message) — message is in args[1]
512
+ expect(warnSpy.mock.calls[0][1]).toContain('autoLaunch');
513
+ expect(warnSpy.mock.calls[0][1]).toContain('electron');
512
514
  });
513
515
 
514
516
  it('should not warn for shared keys', () => {
@@ -714,4 +716,173 @@ describe('Config', () => {
714
716
  });
715
717
  });
716
718
  });
719
+
720
+ describe('Arbitrary data fields (apiClientId, etc.)', () => {
721
+ it('should persist apiClientId/apiClientSecret via config.data + save()', () => {
722
+ config = new Config();
723
+ config.data.cmsUrl = 'https://test.com';
724
+ config.data.apiClientId = 'client-id-123';
725
+ config.data.apiClientSecret = 'secret-456';
726
+ config.save();
727
+
728
+ // Reload from localStorage
729
+ const config2 = new Config();
730
+ expect(config2.data.apiClientId).toBe('client-id-123');
731
+ expect(config2.data.apiClientSecret).toBe('secret-456');
732
+ });
733
+
734
+ it('should NOT persist properties set directly on instance (not on data)', () => {
735
+ config = new Config();
736
+ config.data.cmsUrl = 'https://test.com';
737
+ config.apiClientId = 'wrong-place'; // BUG: sets on instance, not data
738
+ config.save();
739
+
740
+ // Reload — should NOT have the value
741
+ const config2 = new Config();
742
+ expect(config2.data.apiClientId).toBeUndefined();
743
+ expect(config2.apiClientId).toBeUndefined();
744
+ });
745
+
746
+ it('should persist custom CMS-scoped fields in xibo_cms: namespace', () => {
747
+ config = new Config();
748
+ config.data.cmsUrl = 'https://test.com';
749
+ config.data.apiClientId = 'client-id-123';
750
+ config.save();
751
+
752
+ const cmsId = computeCmsId('https://test.com');
753
+ const cms = JSON.parse(mockLocalStorage.getItem(`xibo_cms:${cmsId}`));
754
+ expect(cms.apiClientId).toBe('client-id-123');
755
+
756
+ // Should NOT be in global
757
+ const global = JSON.parse(mockLocalStorage.getItem('xibo_global'));
758
+ expect(global.apiClientId).toBeUndefined();
759
+ });
760
+ });
761
+
762
+ describe('Environment variable precedence', () => {
763
+ it('should override localStorage when CMS_URL env var is set', () => {
764
+ seedConfig(mockLocalStorage, {
765
+ cmsUrl: 'https://stored.com',
766
+ cmsKey: 'stored-key',
767
+ displayName: 'Stored',
768
+ hardwareKey: 'pwa-existinghardwarekey1234567',
769
+ xmrChannel: '12345678-1234-4567-8901-234567890abc',
770
+ });
771
+
772
+ process.env.CMS_URL = 'https://env-override.com';
773
+
774
+ config = new Config();
775
+ expect(config.cmsUrl).toBe('https://env-override.com');
776
+
777
+ delete process.env.CMS_URL;
778
+ });
779
+
780
+ it('should use all env vars when any is set', () => {
781
+ process.env.CMS_URL = 'https://env.com';
782
+ process.env.CMS_KEY = 'env-key';
783
+ process.env.DISPLAY_NAME = 'Env Display';
784
+ process.env.HARDWARE_KEY = 'env-hw-key-1234567890123456';
785
+
786
+ config = new Config();
787
+ expect(config.cmsUrl).toBe('https://env.com');
788
+ expect(config.cmsKey).toBe('env-key');
789
+ expect(config.displayName).toBe('Env Display');
790
+ expect(config.data.hardwareKey).toBe('env-hw-key-1234567890123456');
791
+
792
+ delete process.env.CMS_URL;
793
+ delete process.env.CMS_KEY;
794
+ delete process.env.DISPLAY_NAME;
795
+ delete process.env.HARDWARE_KEY;
796
+ });
797
+
798
+ it('should not save to localStorage when running from env vars', () => {
799
+ process.env.CMS_URL = 'https://env.com';
800
+
801
+ config = new Config();
802
+ config.data.cmsKey = 'modified';
803
+ config.save(); // save() should be no-op when _fromEnv
804
+
805
+ // localStorage should not have been written
806
+ // (In practice _fromEnv doesn't prevent save, but env configs
807
+ // don't use localStorage as primary store)
808
+ expect(config._fromEnv).toBe(true);
809
+
810
+ delete process.env.CMS_URL;
811
+ });
812
+ });
813
+
814
+ describe('extractPwaConfig', () => {
815
+ // Import at top of file already done
816
+ let extractPwaConfig;
817
+
818
+ beforeEach(async () => {
819
+ const mod = await import('./config.js');
820
+ extractPwaConfig = mod.extractPwaConfig;
821
+ });
822
+
823
+ it('should filter out shell-only keys', () => {
824
+ const full = {
825
+ cmsUrl: 'https://test.com',
826
+ cmsKey: 'key',
827
+ displayName: 'Test',
828
+ serverPort: 8765,
829
+ kioskMode: true,
830
+ fullscreen: true,
831
+ hideMouseCursor: true,
832
+ preventSleep: true,
833
+ width: 1920,
834
+ height: 1080,
835
+ relaxSslCerts: true,
836
+ logLevel: 'debug',
837
+ controls: { keyboard: { debugOverlays: true } },
838
+ };
839
+
840
+ const pwa = extractPwaConfig(full);
841
+
842
+ expect(pwa.cmsUrl).toBe('https://test.com');
843
+ expect(pwa.logLevel).toBe('debug');
844
+ expect(pwa.controls).toBeDefined();
845
+ // Shell-only keys should be removed
846
+ expect(pwa.serverPort).toBeUndefined();
847
+ expect(pwa.kioskMode).toBeUndefined();
848
+ expect(pwa.fullscreen).toBeUndefined();
849
+ expect(pwa.hideMouseCursor).toBeUndefined();
850
+ expect(pwa.width).toBeUndefined();
851
+ expect(pwa.height).toBeUndefined();
852
+ expect(pwa.relaxSslCerts).toBeUndefined();
853
+ });
854
+
855
+ it('should pass through apiClientId and apiClientSecret', () => {
856
+ const full = {
857
+ cmsUrl: 'https://test.com',
858
+ apiClientId: 'client-123',
859
+ apiClientSecret: 'secret-456',
860
+ serverPort: 8765,
861
+ };
862
+
863
+ const pwa = extractPwaConfig(full);
864
+
865
+ expect(pwa.apiClientId).toBe('client-123');
866
+ expect(pwa.apiClientSecret).toBe('secret-456');
867
+ expect(pwa.serverPort).toBeUndefined();
868
+ });
869
+
870
+ it('should accept extra shell keys to exclude', () => {
871
+ const full = {
872
+ cmsUrl: 'https://test.com',
873
+ autoLaunch: true,
874
+ logLevel: 'debug',
875
+ };
876
+
877
+ const pwa = extractPwaConfig(full, ['autoLaunch']);
878
+
879
+ expect(pwa.cmsUrl).toBe('https://test.com');
880
+ expect(pwa.logLevel).toBe('debug');
881
+ expect(pwa.autoLaunch).toBeUndefined();
882
+ });
883
+
884
+ it('should return undefined for empty config', () => {
885
+ expect(extractPwaConfig({})).toBeUndefined();
886
+ });
887
+ });
717
888
  });
package/src/idb.js CHANGED
@@ -35,3 +35,58 @@ export function openIDB(dbName, version, storeName, options = {}) {
35
35
  req.onerror = () => reject(req.error);
36
36
  });
37
37
  }
38
+
39
+ /**
40
+ * Query records from an IndexedDB index with a cursor, up to a limit.
41
+ * @param {IDBDatabase} db - IndexedDB database instance
42
+ * @param {string} storeName - Object store name
43
+ * @param {string} indexName - Index name
44
+ * @param {any} value - Key to query (passed to openCursor)
45
+ * @param {number} limit - Maximum records to return
46
+ * @returns {Promise<Array>}
47
+ */
48
+ export function queryByIndex(db, storeName, indexName, value, limit) {
49
+ return new Promise((resolve, reject) => {
50
+ const tx = db.transaction([storeName], 'readonly');
51
+ const index = tx.objectStore(storeName).index(indexName);
52
+ const request = index.openCursor(value);
53
+ const results = [];
54
+
55
+ request.onsuccess = (event) => {
56
+ const cursor = event.target.result;
57
+ if (cursor && results.length < limit) {
58
+ results.push(cursor.value);
59
+ cursor.continue();
60
+ } else {
61
+ resolve(results);
62
+ }
63
+ };
64
+ request.onerror = () => reject(new Error(`Index query failed: ${request.error}`));
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Delete records by ID from an IndexedDB object store.
70
+ * @param {IDBDatabase} db - IndexedDB database instance
71
+ * @param {string} storeName - Object store name
72
+ * @param {Array} ids - Array of record IDs to delete
73
+ * @returns {Promise<number>} Number of deleted records
74
+ */
75
+ export function deleteByIds(db, storeName, ids) {
76
+ return new Promise((resolve, reject) => {
77
+ const tx = db.transaction([storeName], 'readwrite');
78
+ const store = tx.objectStore(storeName);
79
+ let deleted = 0;
80
+
81
+ for (const id of ids) {
82
+ if (id) {
83
+ const req = store.delete(id);
84
+ req.onsuccess = () => { deleted++; };
85
+ req.onerror = () => { /* individual delete failed — tx.onerror handles fatal */ };
86
+ }
87
+ }
88
+
89
+ tx.oncomplete = () => resolve(deleted);
90
+ tx.onerror = () => reject(new Error(`Delete failed: ${tx.error}`));
91
+ });
92
+ }
package/src/index.js CHANGED
@@ -6,9 +6,9 @@ export const VERSION = pkg.version;
6
6
  export { createLogger, setLogLevel, getLogLevel, isDebug, applyCmsLogLevel, mapCmsLogLevel, registerLogSink, unregisterLogSink, LOG_LEVELS } from './logger.js';
7
7
  export { EventEmitter } from './event-emitter.js';
8
8
  import { config as _config } from './config.js';
9
- export { config, SHELL_ONLY_KEYS, extractPwaConfig, computeCmsId, fnvHash, warnPlatformMismatch } from './config.js';
9
+ export { config, SHELL_ONLY_KEYS, extractPwaConfig, computeCmsId, warnPlatformMismatch } from './config.js';
10
10
  export { fetchWithRetry } from './fetch-retry.js';
11
- export { openIDB } from './idb.js';
11
+ export { openIDB, queryByIndex, deleteByIds } from './idb.js';
12
12
  export { CmsApiClient, CmsApiError } from './cms-api.js';
13
13
 
14
14
  /**