@quran-lab/tajweedformer 2.0.0 → 2.0.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.
@@ -1,366 +1,484 @@
1
- // The Quran Lab platform client for the web product: the
2
- // developer key, an unattested app token, register, the signed config (Ed25519 over the exact bytes,
3
- // pinned key ids, ETag, 60 s refresh while in use), the tajweed block into the model's settings,
4
- // telemetry v2 (on by default, anonymous), issues, and the learner's sharing routes. Nothing here sits
5
- // in the recognition path: a failing network never stops recognition.
6
- //
7
- // import { QuranLab } from '@quran-lab/tajweedformer/platform';
8
- // const ql = await QuranLab.configure({ key: 'qlk_v1_...', appVersion: '1.4.0' });
9
- // const tf = await TajweedFormer.load({ platform: ql }); // the recommended model, settings, telemetry
10
-
11
- import { Telemetry, deviceInfo, utcDay } from './telemetry.js';
12
- import { localState } from './storage.js';
13
- import { QURANLAB_PUBLIC_KEY_HEX, hexToBytes, bytesToHex } from '../tajweed/manifest.js';
14
- import { decode } from '../tajweed/decode.js';
15
-
16
- export const SDK_VERSION = '2.0.0';
17
- export const API_URL = 'https://api.quranlab.ai';
18
- export const CDN_URL = 'https://cdn.quranlab.ai';
19
- /** Where model files are served: {base}/{owner}/{repo}/{revision}/{path}. The signed config's `base`
20
- * replaces it when present; a cdnUrl given without modelsUrl keeps the old {cdn}/hf/ path. */
21
- export const MODELS_URL = 'https://cdn.quranlab.ai/models';
22
- const SAFE_BASE = /^(https:\/\/[A-Za-z0-9.-]+(:\d+)?|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)(\/[A-Za-z0-9._~\/-]*)?$/;
23
- const REGIONAL_CDN = /^https:\/\/((weur|eeur|enam|wnam|apac|oc)\.)?cdn\.quranlab\.ai$/;
24
- const ISSUE_KINDS = ['engine_failed', 'model_load', 'checksum_mismatch', 'signature_invalid', 'update_failed', 'fallback',
25
- 'self_test_failed', 'audio_input', 'other'];
26
- /** Codes after which the client stops all network calls until the next launch (error table). */
27
- const STOP_CODES = new Set(['invalid_key', 'unauthorized', 'developer_not_approved', 'not_a_member', 'key_type_mismatch',
28
- 'origin_not_allowed', 'app_not_allowed', 'app_blocked', 'environment_mismatch', 'sdk_too_old']);
29
-
30
- /** A platform failure: { status, code } from the API's {"error","message"} body. */
31
- export class PlatformError extends Error {
32
- constructor(status, code, message) { super(`${status} ${code}${message ? ': ' + message : ''}`); this.name = 'PlatformError'; this.status = status; this.code = code; }
33
- }
34
-
35
- const subtle = () => globalThis.crypto.subtle;
36
- const b64uToBytes = (s) => {
37
- const t = s.replace(/-/g, '+').replace(/_/g, '/');
38
- const bin = atob(t + '='.repeat((4 - (t.length % 4)) % 4));
39
- return Uint8Array.from(bin, (c) => c.charCodeAt(0));
40
- };
41
-
42
- /** The key id of a raw 32-byte Ed25519 public key: the first 8 bytes of its SHA-256, hex. */
43
- export async function keyId(raw) {
44
- const b = typeof raw === 'string' ? hexToBytes(raw) : raw;
45
- return bytesToHex(new Uint8Array(await subtle().digest('SHA-256', b)).subarray(0, 8));
46
- }
47
-
48
- /**
49
- * Verify a signed config response (PLATFORM_CLIENT.md section 3): the key id pinned, Ed25519 over
50
- * the exact body bytes, v 1, not expired. Returns the parsed config, or throws PlatformError
51
- * 'signature_invalid' / 'config_expired'.
52
- * @param {Uint8Array} body the body bytes as received
53
- * @param {string|null} sigHeader x-qlab-signature ("ed25519=<base64url>")
54
- * @param {string|null} kid x-qlab-key-id
55
- * @param {Array<string|Uint8Array>} trustedKeys raw public keys (hex or bytes)
56
- */
57
- export async function verifySignedConfig(body, sigHeader, kid, trustedKeys, now = Date.now()) {
58
- if (!sigHeader || !sigHeader.startsWith('ed25519=') || !kid) throw new PlatformError(0, 'signature_invalid', 'no signature');
59
- let sig;
60
- try { sig = b64uToBytes(sigHeader.slice(8)); } catch (_) { throw new PlatformError(0, 'signature_invalid', 'bad signature encoding'); }
61
- let ok = false;
62
- for (const k of trustedKeys) {
63
- if ((await keyId(k)) !== kid) continue;
64
- const key = await subtle().importKey('raw', typeof k === 'string' ? hexToBytes(k) : k, { name: 'Ed25519' }, false, ['verify']);
65
- if (await subtle().verify({ name: 'Ed25519' }, key, sig, body)) { ok = true; break; }
66
- }
67
- if (!ok) throw new PlatformError(0, 'signature_invalid', `the config does not verify with a pinned key (kid ${kid})`);
68
- const cfg = JSON.parse(new TextDecoder().decode(body));
69
- if (cfg.v !== 1) throw new PlatformError(0, 'signature_invalid', `config v ${cfg.v}`);
70
- if (!(typeof cfg.expires_at === 'number' && cfg.expires_at * 1000 > now)) throw new PlatformError(0, 'config_expired', 'the config has expired');
71
- return cfg;
72
- }
73
-
74
- /**
75
- * One configured client. Use QuranLab.configure(...).
76
- */
77
- export class QuranLab {
78
- /**
79
- * @param {object} o
80
- * @param {string} o.key the developer key qlk_v1_... (a web key in browsers; a desktop key in Node)
81
- * @param {string} [o.appVersion]
82
- * @param {string} [o.appName]
83
- * @param {string} [o.platform] 'web' (browsers, default), or 'linux' / 'windows' / 'web' in Node
84
- * @param {string} [o.bundleId] web: the page's origin (default location.origin); desktop: the app id
85
- * @param {string} [o.origin] Node with a web key: the Origin header to send
86
- * @param {'on'|'off'} [o.analytics='on'] the one developer opt-out; never a prompt
87
- * @param {boolean} [o.issues=true]
88
- * @param {boolean} [o.contributions=false] recitation sharing (asks the learner first)
89
- * @param {'general'|'children'} [o.audience]
90
- * @param {number} [o.sharingBitrate] Opus bit/s for shared sessions (default 192000, SHARING_BITRATE_DEFAULT)
91
- * @param {object} [o.state] persistence (default localStorage; Node: dirState(dir))
92
- * @param {Array<string|Uint8Array>} [o.trustedKeys] pinned config keys (default Quran Lab's)
93
- * @param {string} [o.apiUrl] [o.modelsUrl] [o.cdnUrl] [o.fetch] [o.clock] o.modelsUrl: model files (default the
94
- * signed config's base, else cdn.quranlab.ai/models); o.cdnUrl alone: the old {cdn}/hf/ path
95
- */
96
- constructor(o) {
97
- if (!o || typeof o.key !== 'string' || !/^qlk_v1_/.test(o.key)) throw new TypeError('QuranLab: a developer key qlk_v1_... is needed');
98
- const inBrowser = typeof globalThis.window !== 'undefined' && !!globalThis.location;
99
- this.key = o.key;
100
- this.apiUrl = (o.apiUrl || API_URL).replace(/\/$/, '');
101
- this.cdnUrl = (o.cdnUrl || CDN_URL).replace(/\/$/, '');
102
- this.modelsUrl = o.modelsUrl ? o.modelsUrl.replace(/\/+$/, '') : null;
103
- this.legacyCdn = !!o.cdnUrl && !o.modelsUrl;
104
- this.platform = o.platform || 'web';
105
- this.bundleId = o.bundleId || (inBrowser ? globalThis.location.origin : null);
106
- if (!this.bundleId) throw new TypeError('QuranLab: bundleId is needed outside a browser (the origin for a web key, the app id for a desktop key)');
107
- this.origin = o.origin || null; // Node only: browsers set Origin themselves
108
- this.appVersion = o.appVersion || '0';
109
- this.appName = o.appName || null;
110
- this.fetch = o.fetch || globalThis.fetch.bind(globalThis);
111
- this.clock = o.clock || Date.now;
112
- this.state = o.state || localState();
113
- this.trustedKeys = o.trustedKeys || [QURANLAB_PUBLIC_KEY_HEX];
114
- this.analyticsOn = o.analytics !== 'off';
115
- this.issuesOn = o.issues !== false;
116
- this.contributions = !!o.contributions;
117
- this.audience = o.audience || 'general';
118
- this.sharingBitrate = o.sharingBitrate || null;
119
- this.stopped = null; // the code that stopped the network this launch
120
- this.config = null;
121
- this._token = null;
122
- this._timer = null;
123
- this.telemetry = new Telemetry({ state: this.state, product: 'web', platform: this.platform, sdkVersion: SDK_VERSION,
124
- appVersion: this.appVersion, device: deviceInfo(), clock: this.clock, enabled: this.analyticsOn });
125
- // the stored signed config, if still valid, until the network answers
126
- const saved = this.state.get('config');
127
- if (saved && saved.cfg && saved.cfg.expires_at * 1000 > this.clock()) this.config = saved.cfg;
128
- if (this.state.get('bucket') === null) this.state.set('bucket', Math.floor(Math.random() * 100));
129
- }
130
-
131
- /** Configure and run the launch sequence (token, register, config, pending telemetry and issues).
132
- * Never throws for network trouble; check `ql.stopped` and `ql.lastError`. */
133
- static async configure(o) {
134
- const ql = new QuranLab(o);
135
- await ql.launch();
136
- return ql;
137
- }
138
-
139
- get flags() { return (this.config && this.config.flags) || { analytics: true, issues: true, contributions: false }; }
140
-
141
- // ---- HTTP ----
142
- async _call(method, path, { body = null, auth = 'token', headers = {}, raw = false } = {}) {
143
- if (this.stopped) throw new PlatformError(0, this.stopped, 'stopped for this launch');
144
- const h = { ...headers };
145
- if (this.origin) h.origin = this.origin;
146
- if (auth === 'key') h['x-qlab-key'] = this.key;
147
- else if (auth === 'token') h.authorization = `Bearer ${await this.token()}`;
148
- if (body !== null) h['content-type'] = 'application/json';
149
- const r = await this.fetch(this.apiUrl + path, { method, headers: h, body: body === null ? undefined : JSON.stringify(body) });
150
- if (raw) return r;
151
- if (r.status >= 400) {
152
- let e = {};
153
- try { e = await r.json(); } catch (_) { /* no body */ }
154
- const err = new PlatformError(r.status, e.error || 'http_' + r.status, e.message || '');
155
- if (STOP_CODES.has(err.code)) this.stopped = err.code;
156
- if (err.code === 'invalid_token' && auth === 'token' && !headers['x-retried']) {
157
- this._token = null; this.state.set('token', null);
158
- return this._call(method, path, { body, auth, headers: { ...headers, 'x-retried': '1' } }).catch((e2) => { throw e2; });
159
- }
160
- throw err;
161
- }
162
- return r.status === 204 || r.status === 304 ? null : r.json();
163
- }
164
-
165
- /** An unattested app token (24 h), renewed when under 1 h is left. */
166
- async token() {
167
- const now = this.clock() / 1000;
168
- const t = this._token || this.state.get('token');
169
- if (t && t.exp - now > 3600) { this._token = t; return t.token; }
170
- const r = await this._call('POST', '/v1/attest', { auth: 'key', body: { platform: this.platform, bundle_id: this.bundleId } });
171
- this._token = { token: r.token, exp: now + (r.expires_in || 86400), attested: !!r.attested };
172
- this.state.set('token', this._token);
173
- return r.token;
174
- }
175
-
176
- // ---- launch ----
177
- async launch() {
178
- const steps = [() => this.register(), () => this.refreshConfig(), () => this.flush()];
179
- for (const s of steps) {
180
- try { await s(); } catch (e) { this.lastError = e; if (this.stopped) break; }
181
- }
182
- }
183
-
184
- /** POST /v1/register, once per launch. */
185
- async register() {
186
- if (this._registered) return this._registered;
187
- const first = this.state.get('installed') === null;
188
- if (first) this.state.set('installed', true);
189
- const pendingNew = this.state.get('register.pending_new') === true;
190
- const newInstall = first || pendingNew;
191
- this.state.set('register.pending_new', newInstall);
192
- const body = { platform: this.platform, bundle_id: this.bundleId, app_version: this.appVersion, sdk_version: SDK_VERSION, new_install: newInstall };
193
- if (this.appName) body.app_name = this.appName;
194
- if (this.modelRevision) body.model_revision = this.modelRevision;
195
- this._registered = await this._call('POST', '/v1/register', { body });
196
- this.state.set('register.pending_new', null);
197
- return this._registered;
198
- }
199
-
200
- /**
201
- * GET /v1/config with If-None-Match; verifies and stores it. A failure keeps the last good config.
202
- * Returns the config in force (or null).
203
- */
204
- async refreshConfig() {
205
- const saved = this.state.get('config');
206
- const nearExpiry = saved && saved.cfg && saved.cfg.expires_at * 1000 - this.clock() < 3600_000;
207
- const q = new URLSearchParams({ bucket: String(this.state.get('bucket')), sdk: SDK_VERSION });
208
- const headers = {};
209
- if (saved && saved.etag && !nearExpiry) headers['if-none-match'] = saved.etag;
210
- const r = await this._call('GET', `/v1/config?${q}`, { headers, raw: true });
211
- if (r.status === 304 && saved) { this.config = saved.cfg; return this.config; }
212
- if (r.status >= 400) {
213
- let e = {};
214
- try { e = await r.json(); } catch (_) { /* none */ }
215
- const err = new PlatformError(r.status, e.error || 'http_' + r.status, e.message);
216
- if (STOP_CODES.has(err.code)) this.stopped = err.code;
217
- throw err;
218
- }
219
- const body = new Uint8Array(await r.arrayBuffer());
220
- let cfg;
221
- try {
222
- cfg = await verifySignedConfig(body, r.headers.get('x-qlab-signature'), r.headers.get('x-qlab-key-id'), this.trustedKeys, this.clock());
223
- } catch (e) {
224
- this.reportIssue('signature_invalid', 'config:' + e.code);
225
- throw e;
226
- }
227
- this.config = cfg;
228
- this.state.set('config', { cfg, etag: r.headers.get('etag'), sig: r.headers.get('x-qlab-signature'), kid: r.headers.get('x-qlab-key-id') });
229
- if (cfg.flags && cfg.flags.analytics === false) this.telemetry.clear();
230
- this.telemetry.enabled = this.analyticsOn && !(cfg.flags && cfg.flags.analytics === false);
231
- for (const tf of this._models || []) tf.setRemoteConfig(cfg);
232
- return cfg;
233
- }
234
-
235
- /** Refresh the config every `ms` (60 s) while the app is in use; call stop() when it is not. */
236
- startRefresh(ms = 60_000) {
237
- this.stopRefresh();
238
- this._timer = setInterval(() => { this.refreshConfig().catch(() => {}); }, ms);
239
- if (this._timer && typeof this._timer.unref === 'function') this._timer.unref();
240
- }
241
- stopRefresh() { if (this._timer) clearInterval(this._timer); this._timer = null; }
242
-
243
- /** Where the recommended model lives on the CDN: { url, headers } for TajweedFormer.load. */
244
- async modelSource() {
245
- const cfg = this.config || (await this.refreshConfig().catch(() => null));
246
- const rec = cfg && cfg.recommended;
247
- if (!rec || !rec.revision) throw new PlatformError(0, 'no_model', 'the config recommends no model for this app and SDK');
248
- const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
249
- this.modelRevision = `${rec.model_id || 'v4.0'}+${rec.revision}`.slice(0, 64);
250
- // the base: the app's modelsUrl, else the signed config's base, else the app's cdnUrl (old /hf/ path), else
251
- // cdn.quranlab.ai/models. A bare cdn.quranlab.ai base always means the /hf/ path.
252
- const signed = typeof cfg.base === 'string' && SAFE_BASE.test(cfg.base) ? cfg.base.replace(/\/+$/, '') : null;
253
- let base = this.modelsUrl || signed || (this.legacyCdn ? this.cdnUrl + '/hf' : MODELS_URL);
254
- if (REGIONAL_CDN.test(base)) base += '/hf';
255
- return { url: `${base}/${enc(rec.model)}/${enc(rec.revision)}/${enc(rec.folder || 'sdk/' + rec.model_id)}/`,
256
- headers: { 'x-qlab-key': this.key, ...(this.origin ? { origin: this.origin } : {}) } };
257
- }
258
-
259
- /** Called by TajweedFormer.load({ platform }): the portal's settings and the telemetry hooks. */
260
- attach(tf) {
261
- this._models = (this._models || []).filter((x) => x._eng);
262
- this._models.push(tf);
263
- if (this.config) tf.setRemoteConfig(this.config);
264
- tf._telemetry = this;
265
- this.telemetry.modelLoaded(tf, tf.loadMs || 0);
266
- if (tf.delivery) {
267
- this.telemetry.download({ revision: this.modelRevision || 'unknown', profile: 'web-cpu', bytes: tf.delivery.bytesDownloaded,
268
- success: true, resumed: tf.delivery.rangesResumed > 0 });
269
- }
270
- }
271
-
272
- // hooks the model calls (tajweedformer.js)
273
- streamStarted(tf, s) {
274
- const self = this;
275
- const timings = [];
276
- let samples = 0, firstAt = null;
277
- const t0 = this.clock();
278
- const push = s.pushAudio.bind(s), finish = s.finish.bind(s), reset = s.reset.bind(s), free = s.free.bind(s);
279
- const timed = (fn, n) => { const a = performance.now(); const k = fn(); const ms = performance.now() - a; if (k > 0) for (let i = 0; i < k; i++) timings.push(ms / k); return k; };
280
- const end = (aborted) => {
281
- if (!samples) return;
282
- self.telemetry.session(tf, { seconds: samples / tf.sampleRate, chunkMs: timings.splice(0), aborted, firstMs: firstAt });
283
- try { self.telemetry.decoded(tf, decode(s.heads(), {})); } catch (_) { /* freed */ }
284
- samples = 0;
285
- };
286
- s.pushAudio = (pcm) => { if (firstAt === null) firstAt = self.clock() - t0; samples += pcm.length; return timed(() => push(pcm)); };
287
- s.finish = () => { const k = timed(finish); end(false); return k; };
288
- s.reset = () => { end(true); return reset(); };
289
- s.free = () => { end(true); return free(); };
290
- if (this.sharing && typeof this.sharing.streamStarted === 'function') this.sharing.streamStarted(tf, s);
291
- }
292
- scored(tf, result) { this.telemetry.scored(tf, result); }
293
-
294
- // ---- telemetry and issues ----
295
- /** Send every completed day (202 drop, 400 drop, 401 renew once, else keep) and queued issues. */
296
- async flush() {
297
- let sent = 0;
298
- if (this.telemetry.enabled && this.flags.analytics !== false) {
299
- for (const p of this.telemetry.pending()) {
300
- try { await this._call('POST', '/v1/events', { body: p }); this.telemetry.drop(p.day); sent++; } catch (e) {
301
- if (e.status === 400 || e.status === 413) this.telemetry.drop(p.day); else break;
302
- }
303
- }
304
- } else if (this.flags.analytics === false) this.telemetry.clear();
305
- const issues = this.state.get('issues.queue') || [];
306
- const keep = [];
307
- for (const i of issues) {
308
- if (!this.issuesOn || this.flags.issues === false) break;
309
- try { await this._call('POST', '/v1/issues', { body: i }); } catch (e) { if (!(e.status === 400)) keep.push(i); }
310
- }
311
- this.state.set('issues.queue', this.flags.issues === false ? [] : keep);
312
- return sent;
313
- }
314
-
315
- /** Queue an issue report (no free text, no paths); one per kind+code per day. */
316
- reportIssue(kind, code, frames = []) {
317
- if (!this.issuesOn) return false;
318
- const k = ISSUE_KINDS.includes(kind) ? kind : 'other';
319
- const c = String(code).replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 64) || 'unknown';
320
- const day = utcDay(this.clock());
321
- const seen = this.state.get('issues.seen') || {};
322
- const sig = `${day}|${k}|${c}`;
323
- if (seen[sig]) return false;
324
- for (const s of Object.keys(seen)) if (!s.startsWith(day)) delete seen[s];
325
- seen[sig] = 1;
326
- this.state.set('issues.seen', seen);
327
- const ua = (globalThis.navigator && globalThis.navigator.userAgent) || '';
328
- const q = this.state.get('issues.queue') || [];
329
- q.push({ day, platform: this.platform, sdk_version: SDK_VERSION, app_version: this.appVersion,
330
- os_version: String(this.telemetry.device.os_major), device_class: this.telemetry.device.class, backend: 'exact',
331
- ...(this.modelRevision ? { revision: this.modelRevision } : {}), kind: k, code: c,
332
- ...(frames.length ? { frames: frames.slice(0, 16).map((f) => String(f).slice(0, 80)) } : {}) });
333
- this.state.set('issues.queue', q.slice(-20));
334
- void ua;
335
- return true;
336
- }
337
-
338
- // ---- the learner's sharing routes (section 9) ----
339
- /** The contribution code (32 hex), created on first use, stored per origin. */
340
- contributor() {
341
- let c = this.state.get('contributor');
342
- if (!c) {
343
- const b = new Uint8Array(16);
344
- globalThis.crypto.getRandomValues(b);
345
- c = bytesToHex(b);
346
- this.state.set('contributor', c);
347
- }
348
- return c;
349
- }
350
- resetContributor() { this.state.set('contributor', null); }
351
-
352
- /** GET /v1/contributions/summary (real numbers only; null when it fails). */
353
- async contributionSummary() {
354
- try { return await this._call('GET', '/v1/contributions/summary', { headers: { 'x-qlab-contributor': this.contributor() } }); } catch (_) { return null; }
355
- }
356
-
357
- /** Whether this install may upload under the signed config (flags.contributions and upload_trust). */
358
- sharingAllowed() {
359
- const f = this.flags;
360
- if (!this.contributions || f.contributions !== true) return false;
361
- // owner decision (11-07): web and desktop upload at the lower trust tier (`capped`, attested=false);
362
- // an absent upload_trust entry means that tier; `off` or `attested` (which they cannot meet) mean no
363
- const trust = (f.upload_trust && f.upload_trust[this.platform]) || 'capped';
364
- return trust === 'capped';
365
- }
366
- }
1
+ // The Quran Lab platform client for the web product: the
2
+ // developer key, an unattested app token, register, the signed config (Ed25519 over the exact bytes,
3
+ // pinned key ids, ETag, 60 s refresh while in use), the tajweed block into the model's settings,
4
+ // telemetry v2 (on by default, anonymous), issues, and the learner's sharing routes. Nothing here sits
5
+ // in the recognition path: a failing network never stops recognition.
6
+ //
7
+ // import { QuranLab } from '@quran-lab/tajweedformer/platform';
8
+ // const ql = await QuranLab.configure({ key: 'qlk_v1_...', appVersion: '1.4.0' });
9
+ // const tf = await TajweedFormer.load({ platform: ql }); // the recommended model, settings, telemetry
10
+
11
+ import { Telemetry, deviceInfo, utcDay, productOf } from './telemetry.js';
12
+ import { localState } from './storage.js';
13
+ import { QURANLAB_PUBLIC_KEY_HEX, hexToBytes, bytesToHex } from '../tajweed/manifest.js';
14
+ import { decode } from '../tajweed/decode.js';
15
+ import { noticeSdkLatest } from './update-notice.js';
16
+
17
+ export const SDK_VERSION = '2.0.1';
18
+ export const API_URL = 'https://api.quranlab.ai';
19
+ export const CDN_URL = 'https://cdn.quranlab.ai';
20
+ /** Where model files are served: {base}/{owner}/{repo}/{revision}/{path}. The signed config's `base`
21
+ * replaces it when present; a cdnUrl given without modelsUrl keeps the old {cdn}/hf/ path. */
22
+ export const MODELS_URL = 'https://cdn.quranlab.ai/models';
23
+ const SAFE_BASE = /^(https:\/\/[A-Za-z0-9.-]+(:\d+)?|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)(\/[A-Za-z0-9._~\/-]*)?$/;
24
+ const REGIONAL_CDN = /^https:\/\/((weur|eeur|enam|wnam|apac|oc)\.)?cdn\.quranlab\.ai$/;
25
+ const ISSUE_KINDS = ['engine_failed', 'model_load', 'checksum_mismatch', 'signature_invalid', 'update_failed', 'fallback',
26
+ 'self_test_failed', 'audio_input', 'other'];
27
+ /** Codes after which the client stops all network calls until the next launch (error table). */
28
+ const STOP_CODES = new Set(['invalid_key', 'unauthorized', 'developer_not_approved', 'not_a_member', 'key_type_mismatch',
29
+ 'origin_not_allowed', 'app_not_allowed', 'app_blocked', 'environment_mismatch', 'sdk_too_old']);
30
+ /** A telemetry delta every this long while the app is in use (2.0.1: Quran Lab's choice, 15 minutes). */
31
+ export const DELTA_INTERVAL_MS = 15 * 60_000;
32
+ /** How often the schedule looks at the clock. */
33
+ const TICK_MS = 60_000;
34
+
35
+ /** A platform failure: { status, code } from the API's {"error","message"} body. */
36
+ export class PlatformError extends Error {
37
+ constructor(status, code, message) { super(`${status} ${code}${message ? ': ' + message : ''}`); this.name = 'PlatformError'; this.status = status; this.code = code; }
38
+ }
39
+
40
+ const subtle = () => globalThis.crypto.subtle;
41
+ const b64uToBytes = (s) => {
42
+ const t = s.replace(/-/g, '+').replace(/_/g, '/');
43
+ const bin = atob(t + '='.repeat((4 - (t.length % 4)) % 4));
44
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
45
+ };
46
+
47
+ /** The key id of a raw 32-byte Ed25519 public key: the first 8 bytes of its SHA-256, hex. */
48
+ export async function keyId(raw) {
49
+ const b = typeof raw === 'string' ? hexToBytes(raw) : raw;
50
+ return bytesToHex(new Uint8Array(await subtle().digest('SHA-256', b)).subarray(0, 8));
51
+ }
52
+
53
+ /**
54
+ * Verify a signed config response (PLATFORM_CLIENT.md section 3): the key id pinned, Ed25519 over
55
+ * the exact body bytes, v 1, not expired. Returns the parsed config, or throws PlatformError
56
+ * 'signature_invalid' / 'config_expired'.
57
+ * @param {Uint8Array} body the body bytes as received
58
+ * @param {string|null} sigHeader x-qlab-signature ("ed25519=<base64url>")
59
+ * @param {string|null} kid x-qlab-key-id
60
+ * @param {Array<string|Uint8Array>} trustedKeys raw public keys (hex or bytes)
61
+ */
62
+ export async function verifySignedConfig(body, sigHeader, kid, trustedKeys, now = Date.now()) {
63
+ if (!sigHeader || !sigHeader.startsWith('ed25519=') || !kid) throw new PlatformError(0, 'signature_invalid', 'no signature');
64
+ let sig;
65
+ try { sig = b64uToBytes(sigHeader.slice(8)); } catch (_) { throw new PlatformError(0, 'signature_invalid', 'bad signature encoding'); }
66
+ let ok = false;
67
+ for (const k of trustedKeys) {
68
+ if ((await keyId(k)) !== kid) continue;
69
+ const key = await subtle().importKey('raw', typeof k === 'string' ? hexToBytes(k) : k, { name: 'Ed25519' }, false, ['verify']);
70
+ if (await subtle().verify({ name: 'Ed25519' }, key, sig, body)) { ok = true; break; }
71
+ }
72
+ if (!ok) throw new PlatformError(0, 'signature_invalid', `the config does not verify with a pinned key (kid ${kid})`);
73
+ const cfg = JSON.parse(new TextDecoder().decode(body));
74
+ if (cfg.v !== 1) throw new PlatformError(0, 'signature_invalid', `config v ${cfg.v}`);
75
+ if (!(typeof cfg.expires_at === 'number' && cfg.expires_at * 1000 > now)) throw new PlatformError(0, 'config_expired', 'the config has expired');
76
+ return cfg;
77
+ }
78
+
79
+ /**
80
+ * One configured client. Use QuranLab.configure(...).
81
+ */
82
+ export class QuranLab {
83
+ /**
84
+ * @param {object} o
85
+ * @param {string} o.key the developer key qlk_v1_... (a web key in browsers; a desktop key in Node)
86
+ * @param {string} [o.appVersion]
87
+ * @param {string} [o.appName]
88
+ * @param {string} [o.platform] 'web' (browsers, default), or 'linux' / 'windows' / 'web' in Node
89
+ * @param {string} [o.bundleId] web: the page's origin (default location.origin); desktop: the app id
90
+ * @param {string} [o.origin] Node with a web key: the Origin header to send
91
+ * @param {'on'|'off'} [o.analytics='on'] the one developer opt-out; never a prompt
92
+ * @param {boolean} [o.issues=true]
93
+ * @param {boolean} [o.contributions] ignored since 2.0.1: whether the sharing sheet is offered is Quran Lab's
94
+ * decision, in the signed config (flags.contributions); the SDK still asks the learner before anything is
95
+ * recorded for upload
96
+ * @param {'general'|'children'} [o.audience]
97
+ * @param {number} [o.sharingBitrate] Opus bit/s for shared sessions (default 192000, SHARING_BITRATE_DEFAULT)
98
+ * @param {object} [o.state] persistence (default localStorage; Node: dirState(dir))
99
+ * @param {Array<string|Uint8Array>} [o.trustedKeys] pinned config keys (default Quran Lab's)
100
+ * @param {string} [o.apiUrl] [o.modelsUrl] [o.cdnUrl] [o.fetch] [o.clock] o.modelsUrl: model files (default the
101
+ * signed config's base, else cdn.quranlab.ai/models); o.cdnUrl alone: the old {cdn}/hf/ path
102
+ * @param {boolean} [o.autoSend=true] telemetry deltas on the SDK's own schedule (every 15 minutes while the page
103
+ * is visible, when it is hidden or left); false: the app calls ql.foreground() / ql.background() / ql.sendTelemetry()
104
+ * @param {{document?: EventTarget & {visibilityState?: string}, window?: EventTarget}} [o.lifecycle] where the
105
+ * page's visibilitychange and pagehide come from (default the page's own)
106
+ * @param {boolean} [o.debug] a development build: the "newer SDK available" line is logged (default: when
107
+ * process.env.NODE_ENV is not 'production' where it resolves; a plain browser page stays silent)
108
+ */
109
+ constructor(o) {
110
+ if (!o || typeof o.key !== 'string' || !/^qlk_v1_/.test(o.key)) throw new TypeError('QuranLab: a developer key qlk_v1_... is needed');
111
+ const inBrowser = typeof globalThis.window !== 'undefined' && !!globalThis.location;
112
+ this.key = o.key;
113
+ this.apiUrl = (o.apiUrl || API_URL).replace(/\/$/, '');
114
+ this.cdnUrl = (o.cdnUrl || CDN_URL).replace(/\/$/, '');
115
+ this.modelsUrl = o.modelsUrl ? o.modelsUrl.replace(/\/+$/, '') : null;
116
+ this.legacyCdn = !!o.cdnUrl && !o.modelsUrl;
117
+ this.platform = o.platform || 'web';
118
+ this.bundleId = o.bundleId || (inBrowser ? globalThis.location.origin : null);
119
+ if (!this.bundleId) throw new TypeError('QuranLab: bundleId is needed outside a browser (the origin for a web key, the app id for a desktop key)');
120
+ this.origin = o.origin || null; // Node only: browsers set Origin themselves
121
+ this.appVersion = o.appVersion || '0';
122
+ this.appName = o.appName || null;
123
+ this.fetch = o.fetch || globalThis.fetch.bind(globalThis);
124
+ this.clock = o.clock || Date.now;
125
+ this.state = o.state || localState();
126
+ this.trustedKeys = o.trustedKeys || [QURANLAB_PUBLIC_KEY_HEX];
127
+ this.analyticsOn = o.analytics !== 'off';
128
+ this.issuesOn = o.issues !== false;
129
+ this.contributions = !!o.contributions;
130
+ this.audience = o.audience || 'general';
131
+ this.sharingBitrate = o.sharingBitrate || null;
132
+ this.autoSend = o.autoSend !== false;
133
+ this.debug = typeof o.debug === 'boolean' ? o.debug : undefined;
134
+ this.lifecycle = o.lifecycle || { document: globalThis.document, window: typeof globalThis.window !== 'undefined' ? globalThis.window : null };
135
+ this._lastSend = null;
136
+ this._hidden = false;
137
+ this.stopped = null; // the code that stopped the network this launch
138
+ this.config = null;
139
+ this._token = null;
140
+ this._timer = null;
141
+ this.telemetry = new Telemetry({ state: this.state, product: productOf(this.platform), platform: this.platform, sdkVersion: SDK_VERSION,
142
+ appVersion: this.appVersion, device: deviceInfo(), clock: this.clock, enabled: this.analyticsOn });
143
+ // the stored signed config, if still valid, until the network answers
144
+ const saved = this.state.get('config');
145
+ if (saved && saved.cfg && saved.cfg.expires_at * 1000 > this.clock()) this.config = saved.cfg;
146
+ if (this.state.get('bucket') === null) this.state.set('bucket', Math.floor(Math.random() * 100));
147
+ }
148
+
149
+ /** Configure and run the launch sequence (token, register, config, pending telemetry and issues).
150
+ * Never throws for network trouble; check `ql.stopped` and `ql.lastError`. */
151
+ static async configure(o) {
152
+ const ql = new QuranLab(o);
153
+ await ql.launch();
154
+ return ql;
155
+ }
156
+
157
+ get flags() { return (this.config && this.config.flags) || { analytics: true, issues: true, contributions: false }; }
158
+
159
+ // ---- HTTP ----
160
+ async _call(method, path, { body = null, auth = 'token', headers = {}, raw = false, keepalive = false } = {}) {
161
+ if (this.stopped) throw new PlatformError(0, this.stopped, 'stopped for this launch');
162
+ const h = { ...headers };
163
+ if (this.origin) h.origin = this.origin;
164
+ if (auth === 'key') h['x-qlab-key'] = this.key;
165
+ else if (auth === 'token') h.authorization = `Bearer ${await this.token()}`;
166
+ if (body !== null) h['content-type'] = 'application/json';
167
+ const init = { method, headers: h, body: body === null ? undefined : typeof body === 'string' ? body : JSON.stringify(body) };
168
+ if (keepalive) init.keepalive = true; // a page being hidden or left: the request outlives it
169
+ const r = await this.fetch(this.apiUrl + path, init);
170
+ if (raw) return r;
171
+ if (r.status >= 400) {
172
+ let e = {};
173
+ try { e = await r.json(); } catch (_) { /* no body */ }
174
+ const err = new PlatformError(r.status, e.error || 'http_' + r.status, e.message || '');
175
+ if (STOP_CODES.has(err.code)) this.stopped = err.code;
176
+ if (err.code === 'invalid_token' && auth === 'token' && !headers['x-retried']) {
177
+ this._token = null; this.state.set('token', null);
178
+ return this._call(method, path, { body, auth, headers: { ...headers, 'x-retried': '1' }, keepalive }).catch((e2) => { throw e2; });
179
+ }
180
+ throw err;
181
+ }
182
+ return r.status === 204 || r.status === 304 ? null : r.json();
183
+ }
184
+
185
+ /** An unattested app token (24 h), renewed when under 1 h is left. */
186
+ async token() {
187
+ const now = this.clock() / 1000;
188
+ const t = this._token || this.state.get('token');
189
+ if (t && t.exp - now > 3600) { this._token = t; return t.token; }
190
+ const r = await this._call('POST', '/v1/attest', { auth: 'key', body: { platform: this.platform, bundle_id: this.bundleId } });
191
+ this._token = { token: r.token, exp: now + (r.expires_in || 86400), attested: !!r.attested };
192
+ this.state.set('token', this._token);
193
+ return r.token;
194
+ }
195
+
196
+ // ---- launch ----
197
+ /** The launch sequence: a launch counted, register (once per UTC day), the signed config, the telemetry
198
+ * reports left from earlier runs and this launch's delta, queued issues; then the delta schedule. */
199
+ async launch() {
200
+ this.telemetry.count('launches');
201
+ this.telemetry.launched();
202
+ const steps = [() => this.register(), () => this.refreshConfig(), () => this.sendTelemetry('launch')];
203
+ for (const s of steps) {
204
+ try { await s(); } catch (e) { this.lastError = e; if (this.stopped) break; }
205
+ }
206
+ if (this.autoSend) this._startSchedule();
207
+ }
208
+
209
+ /** POST /v1/register, at most once per UTC day per install (the first launch or return to the foreground of
210
+ * the day): the day is stamped only after a 200, so a failed attempt is made again. Returns the answer, or
211
+ * null when this install already registered today. */
212
+ async register() {
213
+ const today = utcDay(this.clock());
214
+ if (this.state.get('register.day') === today) return null;
215
+ if (this._registering) return this._registering;
216
+ this._registering = this._register(today).finally(() => { this._registering = null; });
217
+ return this._registering;
218
+ }
219
+
220
+ async _register(today) {
221
+ const first = this.state.get('installed') === null;
222
+ if (first) this.state.set('installed', true);
223
+ const pendingNew = this.state.get('register.pending_new') === true;
224
+ const newInstall = first || pendingNew;
225
+ this.state.set('register.pending_new', newInstall);
226
+ const body = { platform: this.platform, bundle_id: this.bundleId, app_version: this.appVersion, sdk_version: SDK_VERSION, new_install: newInstall };
227
+ if (this.appName) body.app_name = this.appName;
228
+ if (this.modelRevision) body.model_revision = this.modelRevision;
229
+ const r = await this._call('POST', '/v1/register', { body });
230
+ this.state.set('register.pending_new', null);
231
+ this.state.set('register.day', today);
232
+ return r;
233
+ }
234
+
235
+ // ---- in use, background, the delta schedule ----
236
+ /** The app came back to the foreground (the page became visible again): a launch counted, register if this
237
+ * is the first foreground of a new UTC day. Called by the SDK's own page listeners; call it yourself with
238
+ * autoSend false. */
239
+ foreground() {
240
+ this._hidden = false;
241
+ this.telemetry.count('launches');
242
+ if (!this.stopped) this.register().catch((e) => { this.lastError = e; });
243
+ }
244
+
245
+ /** The app goes to the background (the page is hidden or left): the delta since the last send point is
246
+ * sealed and sent with keepalive requests. */
247
+ background() {
248
+ this._hidden = true;
249
+ return this.sendTelemetry('background', { keepalive: true });
250
+ }
251
+
252
+ /** A send point: seal what was counted since the last one and send every report due (oldest first). */
253
+ async sendTelemetry(reason = 'manual', { keepalive = false } = {}) {
254
+ this._lastSend = this.clock();
255
+ if (this.telemetry.enabled && this.flags.analytics !== false) this.telemetry.seal();
256
+ return this.flush({ keepalive });
257
+ }
258
+
259
+ /** The schedule looks at the clock (every minute): a delta every 15 minutes while the page is visible. */
260
+ _tick() {
261
+ const doc = this.lifecycle.document;
262
+ const visible = !(doc && doc.visibilityState === 'hidden');
263
+ if (!visible || this.stopped) return false;
264
+ if (this._lastSend !== null && this.clock() - this._lastSend < DELTA_INTERVAL_MS) return false;
265
+ this.sendTelemetry('interval').catch(() => {});
266
+ return true;
267
+ }
268
+
269
+ _startSchedule() {
270
+ if (this._schedule) return;
271
+ const doc = this.lifecycle.document, win = this.lifecycle.window;
272
+ const onVisibility = () => { if (doc.visibilityState === 'hidden') this.background().catch(() => {}); else if (this._hidden) this.foreground(); };
273
+ const onPageHide = () => { if (!this._hidden) this.background().catch(() => {}); };
274
+ if (doc && typeof doc.addEventListener === 'function') doc.addEventListener('visibilitychange', onVisibility);
275
+ if (win && typeof win.addEventListener === 'function') win.addEventListener('pagehide', onPageHide);
276
+ const timer = setInterval(() => this._tick(), TICK_MS);
277
+ if (timer && typeof timer.unref === 'function') timer.unref();
278
+ this._schedule = { timer, onVisibility, onPageHide };
279
+ }
280
+
281
+ /** Stop the SDK's delta schedule and page listeners (a test, or an app tearing the client down). */
282
+ stopSchedule() {
283
+ const s = this._schedule;
284
+ if (!s) return;
285
+ clearInterval(s.timer);
286
+ const doc = this.lifecycle.document, win = this.lifecycle.window;
287
+ if (doc && typeof doc.removeEventListener === 'function') doc.removeEventListener('visibilitychange', s.onVisibility);
288
+ if (win && typeof win.removeEventListener === 'function') win.removeEventListener('pagehide', s.onPageHide);
289
+ this._schedule = null;
290
+ }
291
+
292
+ /**
293
+ * GET /v1/config with If-None-Match; verifies and stores it. A failure keeps the last good config.
294
+ * Returns the config in force (or null).
295
+ */
296
+ async refreshConfig() {
297
+ const saved = this.state.get('config');
298
+ const nearExpiry = saved && saved.cfg && saved.cfg.expires_at * 1000 - this.clock() < 3600_000;
299
+ const q = new URLSearchParams({ bucket: String(this.state.get('bucket')), sdk: SDK_VERSION });
300
+ const headers = {};
301
+ if (saved && saved.etag && !nearExpiry) headers['if-none-match'] = saved.etag;
302
+ const r = await this._call('GET', `/v1/config?${q}`, { headers, raw: true });
303
+ if (r.status === 304 && saved) { this.config = saved.cfg; this._noticeSdkLatest(); return this.config; }
304
+ if (r.status >= 400) {
305
+ let e = {};
306
+ try { e = await r.json(); } catch (_) { /* none */ }
307
+ const err = new PlatformError(r.status, e.error || 'http_' + r.status, e.message);
308
+ if (STOP_CODES.has(err.code)) this.stopped = err.code;
309
+ throw err;
310
+ }
311
+ const body = new Uint8Array(await r.arrayBuffer());
312
+ let cfg;
313
+ try {
314
+ cfg = await verifySignedConfig(body, r.headers.get('x-qlab-signature'), r.headers.get('x-qlab-key-id'), this.trustedKeys, this.clock());
315
+ } catch (e) {
316
+ this.reportIssue('signature_invalid', 'config:' + e.code);
317
+ throw e;
318
+ }
319
+ this.config = cfg;
320
+ this.state.set('config', { cfg, etag: r.headers.get('etag'), sig: r.headers.get('x-qlab-signature'), kid: r.headers.get('x-qlab-key-id') });
321
+ if (cfg.flags && cfg.flags.analytics === false) this.telemetry.clear();
322
+ this.telemetry.enabled = this.analyticsOn && !(cfg.flags && cfg.flags.analytics === false);
323
+ for (const tf of this._models || []) tf.setRemoteConfig(cfg);
324
+ this._noticeSdkLatest();
325
+ return cfg;
326
+ }
327
+
328
+ // the signed config's sdk_latest: one line for the developer per launch, never in production
329
+ // (update-notice.js); unknown or malformed fields of the config are ignored like every other one
330
+ _noticeSdkLatest() { noticeSdkLatest(this.config, SDK_VERSION, { debug: this.debug }); }
331
+
332
+ /** Refresh the config every `ms` (60 s) while the app is in use; call stop() when it is not. */
333
+ startRefresh(ms = 60_000) {
334
+ this.stopRefresh();
335
+ this._timer = setInterval(() => { this.refreshConfig().catch(() => {}); }, ms);
336
+ if (this._timer && typeof this._timer.unref === 'function') this._timer.unref();
337
+ }
338
+ stopRefresh() { if (this._timer) clearInterval(this._timer); this._timer = null; }
339
+
340
+ /** Where the recommended model lives on the CDN: { url, headers } for TajweedFormer.load. */
341
+ async modelSource() {
342
+ const cfg = this.config || (await this.refreshConfig().catch(() => null));
343
+ const rec = cfg && cfg.recommended;
344
+ if (!rec || !rec.revision) throw new PlatformError(0, 'no_model', 'the config recommends no model for this app and SDK');
345
+ const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
346
+ this.modelRevision = `${rec.model_id || 'v4.0'}+${rec.revision}`.slice(0, 64);
347
+ // the base: the app's modelsUrl, else the signed config's base, else the app's cdnUrl (old /hf/ path), else
348
+ // cdn.quranlab.ai/models. A bare cdn.quranlab.ai base always means the /hf/ path.
349
+ const signed = typeof cfg.base === 'string' && SAFE_BASE.test(cfg.base) ? cfg.base.replace(/\/+$/, '') : null;
350
+ let base = this.modelsUrl || signed || (this.legacyCdn ? this.cdnUrl + '/hf' : MODELS_URL);
351
+ if (REGIONAL_CDN.test(base)) base += '/hf';
352
+ return { url: `${base}/${enc(rec.model)}/${enc(rec.revision)}/${enc(rec.folder || 'sdk/' + rec.model_id)}/`,
353
+ headers: { 'x-qlab-key': this.key, ...(this.origin ? { origin: this.origin } : {}) } };
354
+ }
355
+
356
+ /** Called by TajweedFormer.load({ platform }): the portal's settings and the telemetry hooks. */
357
+ attach(tf) {
358
+ this._models = (this._models || []).filter((x) => x._eng);
359
+ this._models.push(tf);
360
+ if (this.config) tf.setRemoteConfig(this.config);
361
+ tf._telemetry = this;
362
+ // the sharing sheet is offered where the signed config says so, whether or not the app called
363
+ // enableSharing (2.0.0 needed that call and the contributions option): in a page, the SDK wires its own
364
+ // sheet here. Nothing is captured before the learner answers "Share" on it.
365
+ if (!this.sharing && !this._sharingWiring && typeof globalThis.document !== 'undefined') {
366
+ this._sharingWiring = import('../consent/index.js').then((m) => { if (!this.sharing) m.enableSharing(this); }).catch(() => {});
367
+ }
368
+ this.telemetry.modelLoaded(tf, tf.loadMs || 0);
369
+ if (tf.delivery) {
370
+ this.telemetry.download({ revision: this.modelRevision || 'unknown', profile: 'web-cpu', bytes: tf.delivery.bytesDownloaded,
371
+ success: true, resumed: tf.delivery.rangesResumed > 0 });
372
+ }
373
+ }
374
+
375
+ // hooks the model calls (tajweedformer.js)
376
+ streamStarted(tf, s) {
377
+ const self = this;
378
+ const timings = [];
379
+ let samples = 0, firstAt = null;
380
+ const t0 = this.clock();
381
+ const push = s.pushAudio.bind(s), finish = s.finish.bind(s), reset = s.reset.bind(s), free = s.free.bind(s);
382
+ const timed = (fn, n) => { const a = performance.now(); const k = fn(); const ms = performance.now() - a; if (k > 0) for (let i = 0; i < k; i++) timings.push(ms / k); return k; };
383
+ const end = (aborted) => {
384
+ if (!samples) return;
385
+ self.telemetry.session(tf, { seconds: samples / tf.sampleRate, chunkMs: timings.splice(0), aborted, firstMs: firstAt });
386
+ try { self.telemetry.decoded(tf, decode(s.heads(), {})); } catch (_) { /* freed */ }
387
+ samples = 0;
388
+ };
389
+ s.pushAudio = (pcm) => { if (firstAt === null) firstAt = self.clock() - t0; samples += pcm.length; return timed(() => push(pcm)); };
390
+ s.finish = () => { const k = timed(finish); end(false); return k; };
391
+ s.reset = () => { end(true); return reset(); };
392
+ s.free = () => { end(true); return free(); };
393
+ if (this.sharing && typeof this.sharing.streamStarted === 'function') this.sharing.streamStarted(tf, s);
394
+ }
395
+ scored(tf, result) { this.telemetry.scored(tf, result); }
396
+
397
+ // ---- telemetry and issues ----
398
+ /** Send the sealed telemetry reports that are due, oldest first, each with its stored bytes (202 deletes, 400 and
399
+ * 413 drop, 401 renews the token once then keeps, 429 / 5xx / 503 / network keep it with backoff: 1 min, 10 min,
400
+ * the next launch), then the queued issues. Returns the reports accepted. */
401
+ async flush({ keepalive = false } = {}) {
402
+ let sent = 0;
403
+ if (this.stopped) return 0;
404
+ if (this.telemetry.enabled && this.flags.analytics !== false) {
405
+ for (const r of this.telemetry.due()) {
406
+ try {
407
+ await this._call('POST', '/v1/events', { body: r.body, keepalive });
408
+ this.telemetry.dropReport(r.report_id);
409
+ sent++;
410
+ } catch (e) {
411
+ if (e.status === 400 || e.status === 413) {
412
+ this.telemetry.dropReport(r.report_id);
413
+ if (e.status === 400) this.reportIssue('other', `telemetry:${e.code || 'bad_request'}`);
414
+ } else {
415
+ this.telemetry.deferReport(r.report_id);
416
+ break;
417
+ }
418
+ }
419
+ }
420
+ } else if (this.flags.analytics === false) this.telemetry.clear();
421
+ const issues = this.state.get('issues.queue') || [];
422
+ const keep = [];
423
+ for (const i of issues) {
424
+ if (!this.issuesOn || this.flags.issues === false) break;
425
+ try { await this._call('POST', '/v1/issues', { body: i }); } catch (e) { if (!(e.status === 400)) keep.push(i); }
426
+ }
427
+ this.state.set('issues.queue', this.flags.issues === false ? [] : keep);
428
+ return sent;
429
+ }
430
+
431
+ /** Queue an issue report (no free text, no paths); one per kind+code per day. */
432
+ reportIssue(kind, code, frames = []) {
433
+ if (!this.issuesOn) return false;
434
+ const k = ISSUE_KINDS.includes(kind) ? kind : 'other';
435
+ const c = String(code).replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 64) || 'unknown';
436
+ const day = utcDay(this.clock());
437
+ const seen = this.state.get('issues.seen') || {};
438
+ const sig = `${day}|${k}|${c}`;
439
+ if (seen[sig]) return false;
440
+ for (const s of Object.keys(seen)) if (!s.startsWith(day)) delete seen[s];
441
+ seen[sig] = 1;
442
+ this.state.set('issues.seen', seen);
443
+ const ua = (globalThis.navigator && globalThis.navigator.userAgent) || '';
444
+ const q = this.state.get('issues.queue') || [];
445
+ q.push({ day, platform: this.platform, sdk_version: SDK_VERSION, app_version: this.appVersion,
446
+ os_version: String(this.telemetry.device.os_major), device_class: this.telemetry.device.class, backend: 'exact',
447
+ ...(this.modelRevision ? { revision: this.modelRevision } : {}), kind: k, code: c,
448
+ ...(frames.length ? { frames: frames.slice(0, 16).map((f) => String(f).slice(0, 80)) } : {}) });
449
+ this.state.set('issues.queue', q.slice(-20));
450
+ void ua;
451
+ return true;
452
+ }
453
+
454
+ // ---- the learner's sharing routes (section 9) ----
455
+ /** The contribution code (32 hex), created on first use, stored per origin. */
456
+ contributor() {
457
+ let c = this.state.get('contributor');
458
+ if (!c) {
459
+ const b = new Uint8Array(16);
460
+ globalThis.crypto.getRandomValues(b);
461
+ c = bytesToHex(b);
462
+ this.state.set('contributor', c);
463
+ }
464
+ return c;
465
+ }
466
+ resetContributor() { this.state.set('contributor', null); }
467
+
468
+ /** GET /v1/contributions/summary (real numbers only; null when it fails). */
469
+ async contributionSummary() {
470
+ try { return await this._call('GET', '/v1/contributions/summary', { headers: { 'x-qlab-contributor': this.contributor() } }); } catch (_) { return null; }
471
+ }
472
+
473
+ /** Whether the signed config offers sharing to this app (flags.contributions and upload_trust). Quran Lab
474
+ * decides this per app; the app's `contributions` option no longer does (2.0.1). No signed config in force:
475
+ * no. This never means the learner agreed: the consent state (consent/state.js) does. */
476
+ sharingAllowed() {
477
+ const f = this.config && this.config.flags;
478
+ if (!f || f.contributions !== true) return false;
479
+ // owner decision (11-07): web and desktop upload at the lower trust tier (`capped`, attested=false);
480
+ // an absent upload_trust entry means that tier; `off` or `attested` (which they cannot meet) mean no
481
+ const trust = (f.upload_trust && f.upload_trust[this.platform]) || 'capped';
482
+ return trust === 'capped';
483
+ }
484
+ }