@xiboplayer/utils 0.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/docs/README.md +61 -0
- package/package.json +36 -0
- package/src/cms-api.js +764 -0
- package/src/cms-api.test.js +803 -0
- package/src/config.js +288 -0
- package/src/config.test.js +473 -0
- package/src/event-emitter.js +77 -0
- package/src/event-emitter.test.js +432 -0
- package/src/fetch-retry.js +61 -0
- package/src/fetch-retry.test.js +108 -0
- package/src/index.js +6 -0
- package/src/logger.js +237 -0
- package/src/logger.test.js +477 -0
- package/vitest.config.js +8 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration management with priority: env vars → localStorage → defaults
|
|
3
|
+
*
|
|
4
|
+
* In Node.js (tests, CLI): environment variables are the only source.
|
|
5
|
+
* In browser (PWA player): localStorage is primary, env vars override if set.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'xibo_config';
|
|
9
|
+
const HW_DB_NAME = 'xibo-hw-backup';
|
|
10
|
+
const HW_DB_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check for environment variable config (highest priority).
|
|
14
|
+
* Env vars: CMS_ADDRESS, CMS_KEY, DISPLAY_NAME, HARDWARE_KEY, XMR_CHANNEL
|
|
15
|
+
* Returns config object if any env vars are set, null otherwise.
|
|
16
|
+
*/
|
|
17
|
+
function loadFromEnv() {
|
|
18
|
+
// Check if process.env is available (Node.js or bundler injection)
|
|
19
|
+
const env = typeof process !== 'undefined' && process.env ? process.env : {};
|
|
20
|
+
|
|
21
|
+
const envConfig = {
|
|
22
|
+
cmsAddress: env.CMS_ADDRESS || env.CMS_URL || '',
|
|
23
|
+
cmsKey: env.CMS_KEY || '',
|
|
24
|
+
displayName: env.DISPLAY_NAME || '',
|
|
25
|
+
hardwareKey: env.HARDWARE_KEY || '',
|
|
26
|
+
xmrChannel: env.XMR_CHANNEL || '',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Return env config if any value is set
|
|
30
|
+
const hasEnvValues = Object.values(envConfig).some(v => v !== '');
|
|
31
|
+
return hasEnvValues ? envConfig : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class Config {
|
|
35
|
+
constructor() {
|
|
36
|
+
this.data = this.load();
|
|
37
|
+
// Async: try to restore hardware key from IndexedDB if localStorage lost it
|
|
38
|
+
// (only when not running from env vars)
|
|
39
|
+
if (!this._fromEnv) {
|
|
40
|
+
this._restoreHardwareKeyFromBackup();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
load() {
|
|
45
|
+
// Priority 1: Environment variables (Node.js, tests, CI)
|
|
46
|
+
const envConfig = loadFromEnv();
|
|
47
|
+
if (envConfig) {
|
|
48
|
+
this._fromEnv = true;
|
|
49
|
+
return envConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Priority 2: localStorage (browser)
|
|
53
|
+
if (typeof localStorage === 'undefined') {
|
|
54
|
+
return { cmsAddress: '', cmsKey: '', displayName: '', hardwareKey: '', xmrChannel: '' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Try to load from localStorage
|
|
58
|
+
const json = localStorage.getItem(STORAGE_KEY);
|
|
59
|
+
|
|
60
|
+
if (json) {
|
|
61
|
+
try {
|
|
62
|
+
const config = JSON.parse(json);
|
|
63
|
+
|
|
64
|
+
// CRITICAL: Hardware key must persist
|
|
65
|
+
if (!config.hardwareKey || config.hardwareKey.length < 10) {
|
|
66
|
+
console.error('[Config] CRITICAL: Invalid/missing hardwareKey in localStorage!');
|
|
67
|
+
config.hardwareKey = this.generateStableHardwareKey();
|
|
68
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
|
69
|
+
this._backupHardwareKey(config.hardwareKey);
|
|
70
|
+
} else {
|
|
71
|
+
console.log('[Config] ✓ Loaded existing hardwareKey:', config.hardwareKey);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return config;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.error('[Config] Failed to parse config from localStorage:', e);
|
|
77
|
+
// Fall through to create new config
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// No config in localStorage - first time setup
|
|
82
|
+
console.log('[Config] No config in localStorage - first time setup');
|
|
83
|
+
|
|
84
|
+
const newConfig = {
|
|
85
|
+
cmsAddress: '',
|
|
86
|
+
cmsKey: '',
|
|
87
|
+
displayName: '',
|
|
88
|
+
hardwareKey: this.generateStableHardwareKey(),
|
|
89
|
+
xmrChannel: this.generateXmrChannel()
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Save immediately
|
|
93
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConfig));
|
|
94
|
+
this._backupHardwareKey(newConfig.hardwareKey);
|
|
95
|
+
console.log('[Config] ✓ Saved new config to localStorage');
|
|
96
|
+
console.log('[Config] Hardware key will persist across reloads:', newConfig.hardwareKey);
|
|
97
|
+
|
|
98
|
+
return newConfig;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Backup hardware key to IndexedDB (more persistent than localStorage).
|
|
103
|
+
* IndexedDB survives "Clear site data" in some browsers where localStorage doesn't.
|
|
104
|
+
*/
|
|
105
|
+
_backupHardwareKey(key) {
|
|
106
|
+
try {
|
|
107
|
+
const req = indexedDB.open(HW_DB_NAME, HW_DB_VERSION);
|
|
108
|
+
req.onupgradeneeded = () => {
|
|
109
|
+
const db = req.result;
|
|
110
|
+
if (!db.objectStoreNames.contains('keys')) {
|
|
111
|
+
db.createObjectStore('keys');
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
req.onsuccess = () => {
|
|
115
|
+
const db = req.result;
|
|
116
|
+
const tx = db.transaction('keys', 'readwrite');
|
|
117
|
+
tx.objectStore('keys').put(key, 'hardwareKey');
|
|
118
|
+
tx.oncomplete = () => {
|
|
119
|
+
console.log('[Config] Hardware key backed up to IndexedDB');
|
|
120
|
+
db.close();
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// IndexedDB not available — localStorage-only mode
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Restore hardware key from IndexedDB if localStorage was cleared.
|
|
130
|
+
* Runs async after construction — if a backed-up key is found and
|
|
131
|
+
* differs from the current one, it restores the original key.
|
|
132
|
+
*/
|
|
133
|
+
async _restoreHardwareKeyFromBackup() {
|
|
134
|
+
if (typeof indexedDB === 'undefined') return;
|
|
135
|
+
try {
|
|
136
|
+
const db = await new Promise((resolve, reject) => {
|
|
137
|
+
const req = indexedDB.open(HW_DB_NAME, HW_DB_VERSION);
|
|
138
|
+
req.onupgradeneeded = () => {
|
|
139
|
+
const db = req.result;
|
|
140
|
+
if (!db.objectStoreNames.contains('keys')) {
|
|
141
|
+
db.createObjectStore('keys');
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
req.onsuccess = () => resolve(req.result);
|
|
145
|
+
req.onerror = () => reject(req.error);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const tx = db.transaction('keys', 'readonly');
|
|
149
|
+
const store = tx.objectStore('keys');
|
|
150
|
+
const backedUpKey = await new Promise((resolve) => {
|
|
151
|
+
const req = store.get('hardwareKey');
|
|
152
|
+
req.onsuccess = () => resolve(req.result);
|
|
153
|
+
req.onerror = () => resolve(null);
|
|
154
|
+
});
|
|
155
|
+
db.close();
|
|
156
|
+
|
|
157
|
+
if (backedUpKey && backedUpKey !== this.data.hardwareKey) {
|
|
158
|
+
console.log('[Config] Restoring hardware key from IndexedDB backup:', backedUpKey);
|
|
159
|
+
console.log('[Config] (was:', this.data.hardwareKey, ')');
|
|
160
|
+
this.data.hardwareKey = backedUpKey;
|
|
161
|
+
this.save();
|
|
162
|
+
} else if (!backedUpKey && this.data.hardwareKey) {
|
|
163
|
+
// No backup yet — save current key as backup
|
|
164
|
+
this._backupHardwareKey(this.data.hardwareKey);
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// IndexedDB not available — that's fine
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
save() {
|
|
172
|
+
if (typeof localStorage !== 'undefined') {
|
|
173
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
isConfigured() {
|
|
178
|
+
return !!(this.data.cmsAddress && this.data.cmsKey && this.data.displayName);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
generateStableHardwareKey() {
|
|
182
|
+
// Generate a stable UUID-based hardware key
|
|
183
|
+
// CRITICAL: This is generated ONCE and saved to localStorage
|
|
184
|
+
// It NEVER changes unless localStorage is cleared manually
|
|
185
|
+
|
|
186
|
+
// Use crypto.randomUUID if available (best randomness)
|
|
187
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
188
|
+
const uuid = crypto.randomUUID().replace(/-/g, ''); // Remove dashes
|
|
189
|
+
const hardwareKey = 'pwa-' + uuid.substring(0, 28);
|
|
190
|
+
console.log('[Config] Generated new UUID-based hardware key:', hardwareKey);
|
|
191
|
+
return hardwareKey;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fallback: Generate random hex string
|
|
195
|
+
const randomHex = Array.from({ length: 28 }, () =>
|
|
196
|
+
Math.floor(Math.random() * 16).toString(16)
|
|
197
|
+
).join('');
|
|
198
|
+
|
|
199
|
+
const hardwareKey = 'pwa-' + randomHex;
|
|
200
|
+
console.log('[Config] Generated new random hardware key:', hardwareKey);
|
|
201
|
+
return hardwareKey;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
getCanvasFingerprint() {
|
|
205
|
+
// Generate stable canvas fingerprint (same for same GPU/driver)
|
|
206
|
+
try {
|
|
207
|
+
const canvas = document.createElement('canvas');
|
|
208
|
+
const ctx = canvas.getContext('2d');
|
|
209
|
+
if (!ctx) return 'no-canvas';
|
|
210
|
+
|
|
211
|
+
// Draw test pattern (same rendering = same device)
|
|
212
|
+
ctx.textBaseline = 'top';
|
|
213
|
+
ctx.font = '14px Arial';
|
|
214
|
+
ctx.fillStyle = '#f60';
|
|
215
|
+
ctx.fillRect(125, 1, 62, 20);
|
|
216
|
+
ctx.fillStyle = '#069';
|
|
217
|
+
ctx.fillText('Xibo Player', 2, 15);
|
|
218
|
+
|
|
219
|
+
return canvas.toDataURL();
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return 'canvas-error';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
generateHardwareKey() {
|
|
226
|
+
// For backwards compatibility
|
|
227
|
+
return this.generateStableHardwareKey();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
generateXmrChannel() {
|
|
231
|
+
// Generate UUID for XMR channel
|
|
232
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
233
|
+
const r = Math.random() * 16 | 0;
|
|
234
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
235
|
+
return v.toString(16);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
hash(str) {
|
|
240
|
+
// FNV-1a hash algorithm (better distribution than simple hash)
|
|
241
|
+
// Produces high-entropy 32-character hex string
|
|
242
|
+
let hash = 2166136261; // FNV offset basis
|
|
243
|
+
|
|
244
|
+
for (let i = 0; i < str.length; i++) {
|
|
245
|
+
hash ^= str.charCodeAt(i);
|
|
246
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Convert to unsigned 32-bit integer
|
|
250
|
+
hash = hash >>> 0;
|
|
251
|
+
|
|
252
|
+
// Extend to 32 characters by hashing multiple times with different seeds
|
|
253
|
+
let result = '';
|
|
254
|
+
for (let round = 0; round < 4; round++) {
|
|
255
|
+
let roundHash = hash + round * 1234567;
|
|
256
|
+
for (let i = 0; i < str.length; i++) {
|
|
257
|
+
roundHash ^= str.charCodeAt(i) + round;
|
|
258
|
+
roundHash += (roundHash << 1) + (roundHash << 4) + (roundHash << 7) + (roundHash << 8) + (roundHash << 24);
|
|
259
|
+
}
|
|
260
|
+
roundHash = roundHash >>> 0;
|
|
261
|
+
result += roundHash.toString(16).padStart(8, '0');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return result.substring(0, 32);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get cmsAddress() { return this.data.cmsAddress; }
|
|
268
|
+
set cmsAddress(val) { this.data.cmsAddress = val; this.save(); }
|
|
269
|
+
|
|
270
|
+
get cmsKey() { return this.data.cmsKey; }
|
|
271
|
+
set cmsKey(val) { this.data.cmsKey = val; this.save(); }
|
|
272
|
+
|
|
273
|
+
get displayName() { return this.data.displayName; }
|
|
274
|
+
set displayName(val) { this.data.displayName = val; this.save(); }
|
|
275
|
+
|
|
276
|
+
get hardwareKey() {
|
|
277
|
+
// CRITICAL: Ensure hardware key never becomes undefined
|
|
278
|
+
if (!this.data.hardwareKey) {
|
|
279
|
+
console.error('[Config] CRITICAL: hardwareKey missing! Generating emergency key.');
|
|
280
|
+
this.data.hardwareKey = this.generateStableHardwareKey();
|
|
281
|
+
this.save();
|
|
282
|
+
}
|
|
283
|
+
return this.data.hardwareKey;
|
|
284
|
+
}
|
|
285
|
+
get xmrChannel() { return this.data.xmrChannel; }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const config = new Config();
|