@xiboplayer/utils 0.6.13 → 0.7.1
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 +2 -2
- package/src/config.js +12 -85
- package/src/config.test.js +0 -130
- package/src/idb.js +37 -0
- package/src/index.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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.
|
|
15
|
+
"@xiboplayer/crypto": "0.7.1"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^2.0.0"
|
package/src/config.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* In browser (PWA player): localStorage is primary, env vars override if set.
|
|
14
14
|
*/
|
|
15
15
|
import { generateRsaKeyPair, isValidPemKey } from '@xiboplayer/crypto';
|
|
16
|
+
import { openIDB } from './idb.js';
|
|
16
17
|
|
|
17
18
|
const GLOBAL_KEY = 'xibo_global'; // Device identity (all CMSes)
|
|
18
19
|
const CMS_PREFIX = 'xibo_cms:'; // Per-CMS config prefix
|
|
@@ -351,26 +352,17 @@ export class Config {
|
|
|
351
352
|
* IndexedDB survives "Clear site data" in some browsers where localStorage doesn't.
|
|
352
353
|
* @param {Object} keys - Key-value pairs to store (e.g. { hardwareKey: '...', xmrPubKey: '...' })
|
|
353
354
|
*/
|
|
354
|
-
_backupKeys(keys) {
|
|
355
|
+
async _backupKeys(keys) {
|
|
355
356
|
try {
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const tx = db.transaction('keys', 'readwrite');
|
|
366
|
-
const store = tx.objectStore('keys');
|
|
367
|
-
for (const [k, v] of Object.entries(keys)) {
|
|
368
|
-
store.put(v, k);
|
|
369
|
-
}
|
|
370
|
-
tx.oncomplete = () => {
|
|
371
|
-
console.log('[Config] Keys backed up to IndexedDB:', Object.keys(keys).join(', '));
|
|
372
|
-
db.close();
|
|
373
|
-
};
|
|
357
|
+
const db = await openIDB(HW_DB_NAME, HW_DB_VERSION, 'keys');
|
|
358
|
+
const tx = db.transaction('keys', 'readwrite');
|
|
359
|
+
const store = tx.objectStore('keys');
|
|
360
|
+
for (const [k, v] of Object.entries(keys)) {
|
|
361
|
+
store.put(v, k);
|
|
362
|
+
}
|
|
363
|
+
tx.oncomplete = () => {
|
|
364
|
+
console.log('[Config] Keys backed up to IndexedDB:', Object.keys(keys).join(', '));
|
|
365
|
+
db.close();
|
|
374
366
|
};
|
|
375
367
|
} catch (e) {
|
|
376
368
|
// IndexedDB not available — localStorage-only mode
|
|
@@ -390,19 +382,8 @@ export class Config {
|
|
|
390
382
|
* differs from the current one, it restores the original key.
|
|
391
383
|
*/
|
|
392
384
|
async _restoreHardwareKeyFromBackup() {
|
|
393
|
-
if (typeof indexedDB === 'undefined') return;
|
|
394
385
|
try {
|
|
395
|
-
const db = await
|
|
396
|
-
const req = indexedDB.open(HW_DB_NAME, HW_DB_VERSION);
|
|
397
|
-
req.onupgradeneeded = () => {
|
|
398
|
-
const db = req.result;
|
|
399
|
-
if (!db.objectStoreNames.contains('keys')) {
|
|
400
|
-
db.createObjectStore('keys');
|
|
401
|
-
}
|
|
402
|
-
};
|
|
403
|
-
req.onsuccess = () => resolve(req.result);
|
|
404
|
-
req.onerror = () => reject(req.error);
|
|
405
|
-
});
|
|
386
|
+
const db = await openIDB(HW_DB_NAME, HW_DB_VERSION, 'keys');
|
|
406
387
|
|
|
407
388
|
const tx = db.transaction('keys', 'readonly');
|
|
408
389
|
const store = tx.objectStore('keys');
|
|
@@ -450,32 +431,6 @@ export class Config {
|
|
|
450
431
|
return hardwareKey;
|
|
451
432
|
}
|
|
452
433
|
|
|
453
|
-
getCanvasFingerprint() {
|
|
454
|
-
// Generate stable canvas fingerprint (same for same GPU/driver)
|
|
455
|
-
try {
|
|
456
|
-
const canvas = document.createElement('canvas');
|
|
457
|
-
const ctx = canvas.getContext('2d');
|
|
458
|
-
if (!ctx) return 'no-canvas';
|
|
459
|
-
|
|
460
|
-
// Draw test pattern (same rendering = same device)
|
|
461
|
-
ctx.textBaseline = 'top';
|
|
462
|
-
ctx.font = '14px Arial';
|
|
463
|
-
ctx.fillStyle = '#f60';
|
|
464
|
-
ctx.fillRect(125, 1, 62, 20);
|
|
465
|
-
ctx.fillStyle = '#069';
|
|
466
|
-
ctx.fillText('Xibo Player', 2, 15);
|
|
467
|
-
|
|
468
|
-
return canvas.toDataURL();
|
|
469
|
-
} catch (e) {
|
|
470
|
-
return 'canvas-error';
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
generateHardwareKey() {
|
|
475
|
-
// For backwards compatibility
|
|
476
|
-
return this.generateStableHardwareKey();
|
|
477
|
-
}
|
|
478
|
-
|
|
479
434
|
generateXmrChannel() {
|
|
480
435
|
// Generate UUID for XMR channel
|
|
481
436
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
@@ -510,34 +465,6 @@ export class Config {
|
|
|
510
465
|
console.log('[Config] RSA key pair generated and saved');
|
|
511
466
|
}
|
|
512
467
|
|
|
513
|
-
hash(str) {
|
|
514
|
-
// FNV-1a hash algorithm (better distribution than simple hash)
|
|
515
|
-
// Produces high-entropy 32-character hex string
|
|
516
|
-
let hash = 2166136261; // FNV offset basis
|
|
517
|
-
|
|
518
|
-
for (let i = 0; i < str.length; i++) {
|
|
519
|
-
hash ^= str.charCodeAt(i);
|
|
520
|
-
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// Convert to unsigned 32-bit integer
|
|
524
|
-
hash = hash >>> 0;
|
|
525
|
-
|
|
526
|
-
// Extend to 32 characters by hashing multiple times with different seeds
|
|
527
|
-
let result = '';
|
|
528
|
-
for (let round = 0; round < 4; round++) {
|
|
529
|
-
let roundHash = hash + round * 1234567;
|
|
530
|
-
for (let i = 0; i < str.length; i++) {
|
|
531
|
-
roundHash ^= str.charCodeAt(i) + round;
|
|
532
|
-
roundHash += (roundHash << 1) + (roundHash << 4) + (roundHash << 7) + (roundHash << 8) + (roundHash << 24);
|
|
533
|
-
}
|
|
534
|
-
roundHash = roundHash >>> 0;
|
|
535
|
-
result += roundHash.toString(16).padStart(8, '0');
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
return result.substring(0, 32);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
468
|
get cmsUrl() { return this.data.cmsUrl; }
|
|
542
469
|
set cmsUrl(val) { this.data.cmsUrl = val; this.save(); }
|
|
543
470
|
|
package/src/config.test.js
CHANGED
|
@@ -224,106 +224,6 @@ describe('Config', () => {
|
|
|
224
224
|
});
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
-
describe('Hash Function (FNV-1a)', () => {
|
|
228
|
-
beforeEach(() => {
|
|
229
|
-
config = new Config();
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('should generate 32-character hex hash', () => {
|
|
233
|
-
const hash = config.hash('test string');
|
|
234
|
-
|
|
235
|
-
expect(hash).toMatch(/^[0-9a-f]{32}$/);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it('should be deterministic for same input', () => {
|
|
239
|
-
const hash1 = config.hash('test');
|
|
240
|
-
const hash2 = config.hash('test');
|
|
241
|
-
|
|
242
|
-
expect(hash1).toBe(hash2);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it('should produce different hashes for different inputs', () => {
|
|
246
|
-
const hash1 = config.hash('test1');
|
|
247
|
-
const hash2 = config.hash('test2');
|
|
248
|
-
|
|
249
|
-
expect(hash1).not.toBe(hash2);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it('should handle empty string', () => {
|
|
253
|
-
const hash = config.hash('');
|
|
254
|
-
|
|
255
|
-
expect(hash).toMatch(/^[0-9a-f]{32}$/);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it('should produce good entropy for similar inputs', () => {
|
|
259
|
-
const hash1 = config.hash('a');
|
|
260
|
-
const hash2 = config.hash('b');
|
|
261
|
-
|
|
262
|
-
// Hashes should be completely different (not just 1 character difference)
|
|
263
|
-
let differences = 0;
|
|
264
|
-
for (let i = 0; i < hash1.length; i++) {
|
|
265
|
-
if (hash1[i] !== hash2[i]) differences++;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
expect(differences).toBeGreaterThan(15); // At least half different
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
describe('Canvas Fingerprint', () => {
|
|
273
|
-
let createElementSpy;
|
|
274
|
-
|
|
275
|
-
beforeEach(() => {
|
|
276
|
-
config = new Config();
|
|
277
|
-
|
|
278
|
-
// Mock canvas via spying on document.createElement
|
|
279
|
-
const mockCanvas = {
|
|
280
|
-
getContext: vi.fn(() => ({
|
|
281
|
-
textBaseline: '',
|
|
282
|
-
font: '',
|
|
283
|
-
fillStyle: '',
|
|
284
|
-
fillRect: vi.fn(),
|
|
285
|
-
fillText: vi.fn()
|
|
286
|
-
})),
|
|
287
|
-
toDataURL: vi.fn(() => 'data:image/png;base64,mockdata')
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
afterEach(() => {
|
|
294
|
-
createElementSpy.mockRestore();
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it('should generate canvas fingerprint', () => {
|
|
298
|
-
const fingerprint = config.getCanvasFingerprint();
|
|
299
|
-
|
|
300
|
-
expect(fingerprint).toBe('data:image/png;base64,mockdata');
|
|
301
|
-
expect(createElementSpy).toHaveBeenCalledWith('canvas');
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('should return "no-canvas" when canvas context unavailable', () => {
|
|
305
|
-
const mockCanvas = {
|
|
306
|
-
getContext: vi.fn(() => null)
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
createElementSpy.mockReturnValue(mockCanvas);
|
|
310
|
-
|
|
311
|
-
const fingerprint = config.getCanvasFingerprint();
|
|
312
|
-
|
|
313
|
-
expect(fingerprint).toBe('no-canvas');
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it('should return "canvas-error" on exception', () => {
|
|
317
|
-
createElementSpy.mockImplementation(() => {
|
|
318
|
-
throw new Error('Canvas not supported');
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
const fingerprint = config.getCanvasFingerprint();
|
|
322
|
-
|
|
323
|
-
expect(fingerprint).toBe('canvas-error');
|
|
324
|
-
});
|
|
325
|
-
});
|
|
326
|
-
|
|
327
227
|
describe('Configuration Getters/Setters', () => {
|
|
328
228
|
beforeEach(() => {
|
|
329
229
|
config = new Config();
|
|
@@ -436,20 +336,6 @@ describe('Config', () => {
|
|
|
436
336
|
});
|
|
437
337
|
});
|
|
438
338
|
|
|
439
|
-
describe('Backwards Compatibility', () => {
|
|
440
|
-
beforeEach(() => {
|
|
441
|
-
config = new Config();
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
it('should support generateHardwareKey() alias', () => {
|
|
445
|
-
const key1 = config.generateHardwareKey();
|
|
446
|
-
const key2 = config.generateStableHardwareKey();
|
|
447
|
-
|
|
448
|
-
// Both should generate valid keys
|
|
449
|
-
expect(key1).toMatch(/^pwa-[0-9a-f]{28}$/);
|
|
450
|
-
expect(key2).toMatch(/^pwa-[0-9a-f]{28}$/);
|
|
451
|
-
});
|
|
452
|
-
});
|
|
453
339
|
|
|
454
340
|
describe('Edge Cases', () => {
|
|
455
341
|
it('should handle missing hardwareKey in loaded config', () => {
|
|
@@ -482,22 +368,6 @@ describe('Config', () => {
|
|
|
482
368
|
expect(config.cmsUrl).toBe('');
|
|
483
369
|
});
|
|
484
370
|
|
|
485
|
-
it('should handle very long strings', () => {
|
|
486
|
-
config = new Config();
|
|
487
|
-
|
|
488
|
-
const longString = 'a'.repeat(10000);
|
|
489
|
-
const hash = config.hash(longString);
|
|
490
|
-
|
|
491
|
-
expect(hash).toMatch(/^[0-9a-f]{32}$/);
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
it('should handle unicode in hash', () => {
|
|
495
|
-
config = new Config();
|
|
496
|
-
|
|
497
|
-
const hash = config.hash('测试中文🎉');
|
|
498
|
-
|
|
499
|
-
expect(hash).toMatch(/^[0-9a-f]{32}$/);
|
|
500
|
-
});
|
|
501
371
|
});
|
|
502
372
|
|
|
503
373
|
describe('Persistence', () => {
|
package/src/idb.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* Shared IndexedDB open helper — avoids duplicating the open/upgrade
|
|
5
|
+
* boilerplate across stats, core, and config packages.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} dbName - Database name
|
|
8
|
+
* @param {number} version - Schema version
|
|
9
|
+
* @param {string} storeName - Object store to create on upgrade
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {string} [options.keyPath] - Key path for the store (auto-increment if set)
|
|
12
|
+
* @param {string} [options.indexName] - Index to create on the store
|
|
13
|
+
* @param {string} [options.indexKey] - Key for the index
|
|
14
|
+
* @returns {Promise<IDBDatabase>}
|
|
15
|
+
*/
|
|
16
|
+
export function openIDB(dbName, version, storeName, options = {}) {
|
|
17
|
+
if (typeof indexedDB === 'undefined') {
|
|
18
|
+
return Promise.reject(new Error('IndexedDB not available'));
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const req = indexedDB.open(dbName, version);
|
|
22
|
+
req.onupgradeneeded = () => {
|
|
23
|
+
const db = req.result;
|
|
24
|
+
if (!db.objectStoreNames.contains(storeName)) {
|
|
25
|
+
const storeOpts = options.keyPath
|
|
26
|
+
? { keyPath: options.keyPath, autoIncrement: true }
|
|
27
|
+
: undefined;
|
|
28
|
+
const store = db.createObjectStore(storeName, storeOpts);
|
|
29
|
+
if (options.indexName && options.indexKey) {
|
|
30
|
+
store.createIndex(options.indexName, options.indexKey, { unique: false });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
req.onsuccess = () => resolve(req.result);
|
|
35
|
+
req.onerror = () => reject(req.error);
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { EventEmitter } from './event-emitter.js';
|
|
|
8
8
|
import { config as _config } from './config.js';
|
|
9
9
|
export { config, SHELL_ONLY_KEYS, extractPwaConfig, computeCmsId, fnvHash, warnPlatformMismatch } from './config.js';
|
|
10
10
|
export { fetchWithRetry } from './fetch-retry.js';
|
|
11
|
+
export { openIDB } from './idb.js';
|
|
11
12
|
export { CmsApiClient, CmsApiError } from './cms-api.js';
|
|
12
13
|
|
|
13
14
|
/**
|