@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 +120 -12
- package/package.json +2 -2
- package/src/config.js +295 -21
- package/src/config.test.js +294 -31
- package/src/index.js +3 -3
package/README.md
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
Foundation utilities used across the SDK:
|
|
8
8
|
|
|
9
|
-
- **Logger**
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **Config**
|
|
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,
|
|
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
|
-
|
|
31
|
-
|
|
39
|
+
// Apply CMS log level (maps CMS values to SDK levels)
|
|
40
|
+
applyCmsLogLevel('audit'); // maps to DEBUG
|
|
32
41
|
|
|
33
|
-
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
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
|
+
"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.
|
|
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
|
|
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
|
-
//
|
|
60
|
-
const
|
|
61
|
-
|
|
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
|
-
|
|
142
|
+
let cmsConfig = {};
|
|
143
|
+
if (activeCmsId) {
|
|
64
144
|
try {
|
|
65
|
-
|
|
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
|
|
148
|
+
console.error('[Config] Failed to parse CMS config:', e);
|
|
68
149
|
}
|
|
69
150
|
}
|
|
70
151
|
|
|
71
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
/**
|
package/src/config.test.js
CHANGED
|
@@ -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
|
|
98
|
-
expect(
|
|
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
|
|
137
|
+
seedConfig(mockLocalStorage, existingConfig);
|
|
111
138
|
|
|
112
139
|
config = new Config();
|
|
113
140
|
|
|
114
|
-
expect(config.data).
|
|
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
|
-
|
|
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('
|
|
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
|
|
314
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
expect(
|
|
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
|
|
401
|
-
|
|
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
|
|
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
|
|
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
|
|
541
|
+
it('should persist keys to xibo_global', async () => {
|
|
510
542
|
await config.ensureXmrKeyPair();
|
|
511
543
|
|
|
512
|
-
const
|
|
513
|
-
expect(
|
|
514
|
-
expect(
|
|
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
|
|
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
|
|
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). */
|