coursecode 0.1.57 → 0.1.59

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.
Files changed (95) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/css/components/accordions.css +117 -31
  4. package/framework/css/layouts/article.css +7 -0
  5. package/framework/docs/COURSE_AUTHORING_GUIDE.md +4 -2
  6. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  7. package/framework/docs/USER_GUIDE.md +14 -0
  8. package/framework/js/app/AppActions.js +17 -23
  9. package/framework/js/app/AppUI.js +7 -4
  10. package/framework/js/assessment/AssessmentActions.js +16 -15
  11. package/framework/js/assessment/AssessmentFactory.js +44 -15
  12. package/framework/js/automation/api-engagement.js +6 -3
  13. package/framework/js/components/interactions/drag-drop.js +4 -4
  14. package/framework/js/components/interactions/fill-in.js +7 -4
  15. package/framework/js/components/interactions/hotspot.js +2 -2
  16. package/framework/js/components/interactions/matching.js +4 -3
  17. package/framework/js/components/ui-components/audio-player.js +30 -8
  18. package/framework/js/components/ui-components/collapse.js +4 -2
  19. package/framework/js/components/ui-components/dropdown.js +7 -3
  20. package/framework/js/components/ui-components/embed-frame.js +5 -1
  21. package/framework/js/components/ui-components/flip-card.js +12 -6
  22. package/framework/js/components/ui-components/interactive-image.js +28 -52
  23. package/framework/js/components/ui-components/lightbox.js +19 -5
  24. package/framework/js/components/ui-components/modal.js +20 -7
  25. package/framework/js/components/ui-components/notifications.js +8 -3
  26. package/framework/js/components/ui-components/video-player.js +46 -20
  27. package/framework/js/core/event-bus.js +31 -28
  28. package/framework/js/dev/runtime-linter.js +6 -2
  29. package/framework/js/drivers/cmi5-driver.js +186 -83
  30. package/framework/js/drivers/driver-factory.js +7 -1
  31. package/framework/js/drivers/driver-interface.js +1 -1
  32. package/framework/js/drivers/http-driver-base.js +6 -0
  33. package/framework/js/drivers/lti-driver.js +112 -47
  34. package/framework/js/drivers/proxy-driver.js +347 -49
  35. package/framework/js/drivers/scorm-12-driver.js +213 -130
  36. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  37. package/framework/js/drivers/scorm-driver-base.js +12 -0
  38. package/framework/js/drivers/standalone-driver.js +139 -0
  39. package/framework/js/engagement/engagement-trackers.js +38 -13
  40. package/framework/js/engagement/requirement-strategies.js +5 -1
  41. package/framework/js/main.js +7 -0
  42. package/framework/js/managers/assessment-manager.js +16 -1
  43. package/framework/js/managers/audio-manager.js +50 -16
  44. package/framework/js/managers/flag-manager.js +5 -1
  45. package/framework/js/managers/objective-manager.js +15 -7
  46. package/framework/js/managers/score-manager.js +27 -6
  47. package/framework/js/managers/video-manager.js +149 -70
  48. package/framework/js/navigation/NavigationActions.js +18 -4
  49. package/framework/js/navigation/document-gallery.js +7 -2
  50. package/framework/js/navigation/navigation-validators.js +3 -2
  51. package/framework/js/state/lms-connection.js +41 -16
  52. package/framework/js/state/state-commits.js +10 -5
  53. package/framework/js/state/state-manager.js +95 -9
  54. package/framework/js/state/state-validation.js +44 -11
  55. package/framework/js/utilities/course-helpers.js +3 -2
  56. package/framework/js/utilities/media-utils.js +28 -0
  57. package/framework/js/utilities/portable-assets.js +151 -0
  58. package/framework/js/utilities/ui-initializer.js +79 -5
  59. package/framework/js/utilities/utilities.js +32 -20
  60. package/framework/js/utilities/view-manager.js +16 -1
  61. package/framework/js/validation/scorm-validators.js +69 -0
  62. package/framework/version.json +2 -2
  63. package/lib/authoring-api.js +37 -18
  64. package/lib/build-linter.js +192 -2
  65. package/lib/build-packaging.js +47 -3
  66. package/lib/build.js +69 -8
  67. package/lib/cloud.js +40 -11
  68. package/lib/convert.js +30 -2
  69. package/lib/create.js +5 -3
  70. package/lib/dev.js +1 -1
  71. package/lib/headless-browser.js +66 -14
  72. package/lib/manifest/cmi5-manifest.js +26 -4
  73. package/lib/manifest/lti-tool-config.js +7 -2
  74. package/lib/manifest/manifest-factory.d.ts +1 -1
  75. package/lib/manifest/manifest-factory.js +3 -2
  76. package/lib/manifest/scorm-12-manifest.js +2 -2
  77. package/lib/manifest/scorm-2004-manifest.js +3 -28
  78. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  79. package/lib/manifest/xml-utils.js +10 -0
  80. package/lib/narration.js +2 -2
  81. package/lib/portable-html.js +196 -0
  82. package/lib/preview-export.js +1 -1
  83. package/lib/preview-server.js +27 -6
  84. package/lib/project-utils.js +9 -1
  85. package/lib/proxy-templates/proxy.html +4 -1
  86. package/lib/proxy-templates/scorm-bridge.js +82 -10
  87. package/lib/scaffold.js +9 -4
  88. package/lib/stub-player/lms-api.js +63 -8
  89. package/package.json +5 -1
  90. package/schemas/adlcp_rootv1p2.xsd +110 -0
  91. package/schemas/coursecode_scorm12_package.xsd +5 -0
  92. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  93. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  94. package/template/course/slides/example-ui-showcase.js +1 -1
  95. package/template/vite.config.js +50 -22
@@ -11,6 +11,18 @@
11
11
  */
12
12
 
13
13
  import { logger } from '../utilities/logger.js';
14
+ import LZString from 'lz-string';
15
+ import {
16
+ mapStatusTo12,
17
+ mapObjectiveStatusTo12,
18
+ convertTimeFormat2004To12,
19
+ convertTimestamp2004To12,
20
+ createScorm12DietState,
21
+ expandScorm12DietState,
22
+ encodeScorm12SuspendState,
23
+ decodeScorm12SuspendState
24
+ } from './scorm-12-driver.js';
25
+ import { serializeInteractionForScorm12 } from '../validation/scorm-validators.js';
14
26
 
15
27
  // Message timeout (ms) — if proxy doesn't respond, something is wrong
16
28
  const MESSAGE_TIMEOUT = 5000;
@@ -25,6 +37,7 @@ export class ProxyDriver {
25
37
  this._isTerminated = false;
26
38
  this._msgId = 0;
27
39
  this._pending = new Map(); // id -> { resolve, reject, timeout }
40
+ this._pendingWrites = [];
28
41
 
29
42
  // Origin of the parent proxy frame (set during initialize)
30
43
  // Used for postMessage origin validation
@@ -39,12 +52,19 @@ export class ProxyDriver {
39
52
  completion: 'unknown',
40
53
  success: 'unknown'
41
54
  };
55
+ this._scoreCache = null;
56
+ this._objectiveIdToIndex = new Map();
57
+ this._objectivesCount = 0;
58
+ this._interactionsCount = 0;
59
+ this._supportsObjectives = this._baseFormat === 'scorm2004';
60
+ this._supportsInteractions = this._baseFormat === 'scorm2004';
61
+ this._scoreChildren = new Set(['raw']);
62
+ this._handshake = null;
42
63
 
43
64
  this._suspendDataCache = null;
44
65
 
45
- // Listen for responses from proxy bridge
46
66
  this._handleMessage = this._handleMessage.bind(this);
47
- window.addEventListener('message', this._handleMessage);
67
+ this._isListening = false;
48
68
  }
49
69
 
50
70
  // =========================================================================
@@ -67,10 +87,10 @@ export class ProxyDriver {
67
87
  // Mirror base format capabilities, but note async commit
68
88
  const isScorm2004 = this._baseFormat === 'scorm2004';
69
89
  return {
70
- supportsObjectives: true,
71
- supportsInteractions: true,
90
+ supportsObjectives: this._supportsObjectives,
91
+ supportsInteractions: this._supportsInteractions,
72
92
  supportsComments: isScorm2004,
73
- supportsEmergencySave: false,
93
+ supportsEmergencySave: true,
74
94
  maxSuspendDataBytes: isScorm2004 ? 65536 : 4096,
75
95
  asyncCommit: true // postMessage is inherently async
76
96
  };
@@ -87,24 +107,22 @@ export class ProxyDriver {
87
107
  throw new Error('ProxyDriver: Not running in iframe - proxy mode requires parent frame');
88
108
  }
89
109
 
90
- // Derive expected parent origin from document.referrer
91
- // The referrer is the LMS/proxy page that loaded this iframe
110
+ // Referrer is only a target-origin optimization. Some real LMSs use a
111
+ // no-referrer policy, so identity is established with a nonce handshake
112
+ // bound to window.parent and then locked to the response origin.
113
+ let handshakeTarget = '*';
92
114
  try {
93
115
  if (document.referrer) {
94
116
  const referrerUrl = new URL(document.referrer);
95
- this._parentOrigin = referrerUrl.origin;
96
- logger.debug('ProxyDriver: Parent origin from referrer:', this._parentOrigin);
117
+ handshakeTarget = referrerUrl.origin;
97
118
  } else if (window.location.ancestorOrigins?.[0]) {
98
- this._parentOrigin = new URL(window.location.ancestorOrigins[0]).origin;
119
+ handshakeTarget = new URL(window.location.ancestorOrigins[0]).origin;
99
120
  }
100
- } catch {
101
- this._parentOrigin = null;
102
- }
103
- if (!this._parentOrigin) {
104
- throw new Error('ProxyDriver: Cannot determine parent origin; refusing insecure wildcard postMessage transport');
105
- }
121
+ } catch { /* Handshake safely discovers the parent origin. */ }
106
122
 
123
+ this._startListening();
107
124
  try {
125
+ await this._establishHandshake(handshakeTarget);
108
126
  const result = await this._sendMessage('Initialize');
109
127
  if (result === true || result === 'true') {
110
128
  this._isConnected = true;
@@ -117,6 +135,8 @@ export class ProxyDriver {
117
135
  }
118
136
  throw new Error('ProxyDriver: Initialize returned false');
119
137
  } catch (error) {
138
+ this._isConnected = false;
139
+ this._stopListening('initialization failed');
120
140
  logger.error('ProxyDriver: Initialize failed', error);
121
141
  throw error;
122
142
  }
@@ -134,16 +154,18 @@ export class ProxyDriver {
134
154
  }
135
155
 
136
156
  try {
157
+ await this._flushPendingWrites();
137
158
  const result = await this._sendMessage('Terminate');
159
+ if (result !== true && result !== 'true') {
160
+ throw new Error('ProxyDriver: LMS Terminate returned false');
161
+ }
138
162
  this._isTerminated = true;
139
163
  this._isConnected = false;
140
- window.removeEventListener('message', this._handleMessage);
164
+ this._stopListening('session terminated');
141
165
  logger.info('ProxyDriver: Terminated');
142
- return result === true || result === 'true';
166
+ return true;
143
167
  } catch (error) {
144
168
  logger.error('ProxyDriver: Terminate failed', error);
145
- this._isTerminated = true;
146
- this._isConnected = false;
147
169
  throw error;
148
170
  }
149
171
  }
@@ -151,8 +173,12 @@ export class ProxyDriver {
151
173
  async commit() {
152
174
  this._ensureConnected();
153
175
  try {
176
+ await this._flushPendingWrites();
154
177
  const result = await this._sendMessage('Commit');
155
- return result === true || result === 'true';
178
+ if (result !== true && result !== 'true') {
179
+ throw new Error('ProxyDriver: LMS Commit returned false');
180
+ }
181
+ return true;
156
182
  } catch (error) {
157
183
  logger.error('ProxyDriver: Commit failed', error);
158
184
  throw error;
@@ -161,12 +187,33 @@ export class ProxyDriver {
161
187
 
162
188
  ping() {
163
189
  if (this._isConnected && !this._isTerminated) {
164
- this._sendMessage('GetValue', 'cmi.learner_id').catch(() => {
190
+ const key = this._baseFormat === 'scorm1.2' ? 'cmi.core.student_id' : 'cmi.learner_id';
191
+ this._sendMessage('GetValue', key).catch(() => {
165
192
  // Ignore ping errors
166
193
  });
167
194
  }
168
195
  }
169
196
 
197
+ emergencySave() {
198
+ if (!this._isConnected || this._isTerminated || !this._parentOrigin) return;
199
+ try {
200
+ window.parent.postMessage({
201
+ type: 'scorm-proxy-request',
202
+ id: null,
203
+ method: 'EmergencySave',
204
+ args: []
205
+ }, this._parentOrigin);
206
+ } catch (error) {
207
+ logger.error('ProxyDriver: Emergency save message failed', {
208
+ domain: 'scorm-proxy',
209
+ operation: 'emergencySave',
210
+ format: this.getFormat(),
211
+ error: error.message,
212
+ stack: error.stack
213
+ });
214
+ }
215
+ }
216
+
170
217
  // =========================================================================
171
218
  // Semantic Reads (served from pre-fetched cache)
172
219
  // =========================================================================
@@ -187,6 +234,10 @@ export class ProxyDriver {
187
234
  return this._cache.success;
188
235
  }
189
236
 
237
+ getScore() {
238
+ return this._scoreCache ? { ...this._scoreCache } : null;
239
+ }
240
+
190
241
  getLearnerInfo() {
191
242
  return {
192
243
  id: this._cache.learnerId,
@@ -207,18 +258,34 @@ export class ProxyDriver {
207
258
  reportScore({ raw, scaled, min, max }) {
208
259
  const prefix = this._baseFormat === 'scorm1.2' ? 'cmi.core.score' : 'cmi.score';
209
260
  if (raw !== undefined) this._sendSetValue(`${prefix}.raw`, String(raw));
210
- if (min !== undefined) this._sendSetValue(`${prefix}.min`, String(min));
211
- if (max !== undefined) this._sendSetValue(`${prefix}.max`, String(max));
261
+ if (min !== undefined && (!this._isScorm12() || this._scoreChildren.has('min'))) {
262
+ this._sendSetValue(`${prefix}.min`, String(min));
263
+ }
264
+ if (max !== undefined && (!this._isScorm12() || this._scoreChildren.has('max'))) {
265
+ this._sendSetValue(`${prefix}.max`, String(max));
266
+ }
212
267
  if (scaled !== undefined && this._baseFormat === 'scorm2004') {
213
268
  this._sendSetValue('cmi.score.scaled', String(scaled));
214
269
  }
270
+ const resolvedRaw = raw ?? (scaled !== undefined ? scaled * 100 : null);
271
+ const resolvedScaled = scaled ?? (raw !== undefined ? raw / 100 : null);
272
+ if (resolvedRaw !== null || resolvedScaled !== null) {
273
+ this._scoreCache = {
274
+ raw: resolvedRaw,
275
+ scaled: resolvedScaled,
276
+ min: min ?? 0,
277
+ max: max ?? 100
278
+ };
279
+ }
215
280
  }
216
281
 
217
282
  reportCompletion(status) {
218
283
  this._cache.completion = status;
219
284
  if (this._baseFormat === 'scorm1.2') {
220
- // SCORM 1.2 uses combined lesson_status — proxy bridge handles mapping
221
- this._sendSetValue('cmi.completion_status', status);
285
+ this._sendSetValue(
286
+ 'cmi.core.lesson_status',
287
+ mapStatusTo12(this._cache.completion, this._cache.success)
288
+ );
222
289
  } else {
223
290
  this._sendSetValue('cmi.completion_status', status);
224
291
  }
@@ -227,7 +294,10 @@ export class ProxyDriver {
227
294
  reportSuccess(status) {
228
295
  this._cache.success = status;
229
296
  if (this._baseFormat === 'scorm1.2') {
230
- this._sendSetValue('cmi.success_status', status);
297
+ this._sendSetValue(
298
+ 'cmi.core.lesson_status',
299
+ mapStatusTo12(this._cache.completion, this._cache.success)
300
+ );
231
301
  } else {
232
302
  this._sendSetValue('cmi.success_status', status);
233
303
  }
@@ -241,8 +311,7 @@ export class ProxyDriver {
241
311
 
242
312
  reportSessionTime(duration) {
243
313
  if (this._baseFormat === 'scorm1.2') {
244
- // Proxy bridge will handle time format conversion
245
- this._sendSetValue('cmi.session_time', duration);
314
+ this._sendSetValue('cmi.core.session_time', convertTimeFormat2004To12(duration));
246
315
  } else {
247
316
  this._sendSetValue('cmi.session_time', duration);
248
317
  }
@@ -250,13 +319,74 @@ export class ProxyDriver {
250
319
 
251
320
  reportObjective(objective) {
252
321
  if (!objective || !objective.id) return;
253
- // Objectives are stored in suspend_data, not via CMI in proxy mode
254
- // The proxy bridge doesn't relay complex array writes reliably
322
+ if (!this._supportsObjectives) return;
323
+ const index = this._getOrCreateObjectiveIndex(objective.id);
324
+
325
+ if (this._baseFormat === 'scorm1.2') {
326
+ if (objective.score !== null && objective.score !== undefined) {
327
+ this._sendSetValue(`cmi.objectives.${index}.score.raw`, String(objective.score));
328
+ this._sendSetValue(`cmi.objectives.${index}.score.min`, '0');
329
+ this._sendSetValue(`cmi.objectives.${index}.score.max`, '100');
330
+ }
331
+ if (objective.completion_status !== undefined || objective.success_status !== undefined) {
332
+ this._sendSetValue(
333
+ `cmi.objectives.${index}.status`,
334
+ mapObjectiveStatusTo12(objective.completion_status, objective.success_status)
335
+ );
336
+ }
337
+ return;
338
+ }
339
+
340
+ if (objective.success_status) this._sendSetValue(`cmi.objectives.${index}.success_status`, objective.success_status);
341
+ if (objective.completion_status) this._sendSetValue(`cmi.objectives.${index}.completion_status`, objective.completion_status);
342
+ if (objective.score !== null && objective.score !== undefined) {
343
+ this._sendSetValue(`cmi.objectives.${index}.score.raw`, String(objective.score));
344
+ this._sendSetValue(`cmi.objectives.${index}.score.scaled`, String(objective.score / 100));
345
+ this._sendSetValue(`cmi.objectives.${index}.score.min`, '0');
346
+ this._sendSetValue(`cmi.objectives.${index}.score.max`, '100');
347
+ }
348
+ if (objective.progress_measure !== null && objective.progress_measure !== undefined) {
349
+ this._sendSetValue(`cmi.objectives.${index}.progress_measure`, String(objective.progress_measure));
350
+ }
351
+ if (objective.description) this._sendSetValue(`cmi.objectives.${index}.description`, objective.description);
255
352
  }
256
353
 
257
354
  reportInteraction(interaction) {
258
- if (!interaction || !interaction.id) return;
259
- // Interactions are stored in suspend_data, not via CMI in proxy mode
355
+ if (!interaction || !interaction.id || !interaction.type) return;
356
+ if (!this._supportsInteractions) {
357
+ return { ...interaction, _index: null, nativeCmiSkipped: true };
358
+ }
359
+ const index = this._interactionsCount++;
360
+ const is12 = this._baseFormat === 'scorm1.2';
361
+ const reported = is12 ? serializeInteractionForScorm12(interaction) : interaction;
362
+ const prefix = `cmi.interactions.${index}`;
363
+
364
+ this._sendSetValue(`${prefix}.id`, reported.id);
365
+ this._sendSetValue(`${prefix}.type`, reported.type);
366
+ if (reported.learner_response !== undefined && reported.learner_response !== null && reported.learner_response !== '') {
367
+ this._sendSetValue(`${prefix}.${is12 ? 'student_response' : 'learner_response'}`, reported.learner_response);
368
+ }
369
+ if (reported.result) this._sendSetValue(`${prefix}.result`, reported.result);
370
+ if (interaction.timestamp) {
371
+ this._sendSetValue(
372
+ `${prefix}.${is12 ? 'time' : 'timestamp'}`,
373
+ is12 ? convertTimestamp2004To12(interaction.timestamp) : interaction.timestamp
374
+ );
375
+ }
376
+ if (!is12 && interaction.description) this._sendSetValue(`${prefix}.description`, interaction.description);
377
+ if (interaction.weighting !== undefined && interaction.weighting !== null) {
378
+ this._sendSetValue(`${prefix}.weighting`, String(interaction.weighting));
379
+ }
380
+ if (interaction.latency) {
381
+ this._sendSetValue(`${prefix}.latency`, is12 ? convertTimeFormat2004To12(interaction.latency) : interaction.latency);
382
+ }
383
+ reported.correct_responses?.forEach((item, patternIndex) => {
384
+ const pattern = typeof item === 'object' && item !== null && 'pattern' in item ? item.pattern : item;
385
+ this._sendSetValue(`${prefix}.correct_responses.${patternIndex}.pattern`, pattern);
386
+ });
387
+ reported.objectives?.forEach((objectiveId, objectiveIndex) => {
388
+ this._sendSetValue(`${prefix}.objectives.${objectiveIndex}.id`, objectiveId);
389
+ });
260
390
  }
261
391
 
262
392
  setExitMode(mode) {
@@ -275,7 +405,16 @@ export class ProxyDriver {
275
405
 
276
406
  setSuspendData(data) {
277
407
  this._ensureConnected();
278
- const value = JSON.stringify(data);
408
+ const state = this._baseFormat === 'scorm1.2'
409
+ ? createScorm12DietState(data, this._cache.bookmark || null)
410
+ : data;
411
+ const value = this._isScorm12()
412
+ ? encodeScorm12SuspendState(state)
413
+ : LZString.compressToUTF16(JSON.stringify(state));
414
+ const limit = this._baseFormat === 'scorm1.2' ? 4096 : 64000;
415
+ if (value.length > limit) {
416
+ throw new Error(`${this.getFormat()} suspend_data exceeds the ${limit}-character limit (${value.length})`);
417
+ }
279
418
  this._sendSetValue('cmi.suspend_data', value);
280
419
  this._suspendDataCache = data;
281
420
  return true;
@@ -299,7 +438,14 @@ export class ProxyDriver {
299
438
  learnerId: 'cmi.core.student_id',
300
439
  learnerName: 'cmi.core.student_name',
301
440
  status: 'cmi.core.lesson_status',
302
- suspendData: 'cmi.suspend_data'
441
+ suspendData: 'cmi.suspend_data',
442
+ scoreRaw: 'cmi.core.score.raw',
443
+ scoreMin: 'cmi.core.score.min',
444
+ scoreMax: 'cmi.core.score.max',
445
+ objectivesCount: 'cmi.objectives._count',
446
+ interactionsCount: 'cmi.interactions._count',
447
+ children: 'cmi._children',
448
+ scoreChildren: 'cmi.core.score._children'
303
449
  } : {
304
450
  entry: 'cmi.entry',
305
451
  bookmark: 'cmi.location',
@@ -307,15 +453,27 @@ export class ProxyDriver {
307
453
  learnerName: 'cmi.learner_name',
308
454
  completion: 'cmi.completion_status',
309
455
  success: 'cmi.success_status',
310
- suspendData: 'cmi.suspend_data'
456
+ suspendData: 'cmi.suspend_data',
457
+ scoreScaled: 'cmi.score.scaled',
458
+ scoreRaw: 'cmi.score.raw',
459
+ scoreMin: 'cmi.score.min',
460
+ scoreMax: 'cmi.score.max',
461
+ objectivesCount: 'cmi.objectives._count',
462
+ interactionsCount: 'cmi.interactions._count'
311
463
  };
312
464
 
313
465
  // Fire all reads in parallel
314
466
  const results = {};
467
+ const requiredFields = new Set(is12
468
+ ? ['entry', 'bookmark', 'learnerId', 'learnerName', 'status', 'suspendData']
469
+ : ['entry', 'bookmark', 'learnerId', 'learnerName', 'completion', 'success', 'suspendData']);
315
470
  const promises = Object.entries(keys).map(async ([fieldName, cmiKey]) => {
316
471
  try {
317
472
  results[fieldName] = await this._sendMessage('GetValue', cmiKey);
318
- } catch {
473
+ } catch (error) {
474
+ if (requiredFields.has(fieldName)) {
475
+ throw new Error(`ProxyDriver: Cannot read required LMS value ${cmiKey}: ${error.message}`);
476
+ }
319
477
  results[fieldName] = '';
320
478
  }
321
479
  });
@@ -344,18 +502,55 @@ export class ProxyDriver {
344
502
  this._cache.success = 'unknown';
345
503
  }
346
504
  this._cache.entry = results.entry || '';
505
+ const supported = new Set(String(results.children || '').split(',').map(value => value.trim()));
506
+ this._supportsObjectives = supported.has('objectives');
507
+ this._supportsInteractions = supported.has('interactions');
508
+ this._scoreChildren = new Set([
509
+ 'raw',
510
+ ...String(results.scoreChildren || '').split(',').map(value => value.trim()).filter(Boolean)
511
+ ]);
347
512
  } else {
348
513
  this._cache.completion = results.completion || 'unknown';
349
514
  this._cache.success = results.success || 'unknown';
350
515
  this._cache.entry = results.entry || '';
351
516
  }
352
517
 
518
+ const parseOptionalNumber = value => {
519
+ if (value === '' || value === null || value === undefined) return NaN;
520
+ return Number(value);
521
+ };
522
+ const raw = parseOptionalNumber(results.scoreRaw);
523
+ const scaled = is12 ? raw / 100 : parseOptionalNumber(results.scoreScaled);
524
+ if (Number.isFinite(raw) || Number.isFinite(scaled)) {
525
+ this._scoreCache = {
526
+ raw: Number.isFinite(raw) ? raw : scaled * 100,
527
+ scaled: Number.isFinite(scaled) ? scaled : raw / 100,
528
+ min: Number.isFinite(parseOptionalNumber(results.scoreMin)) ? Number(results.scoreMin) : 0,
529
+ max: Number.isFinite(parseOptionalNumber(results.scoreMax)) ? Number(results.scoreMax) : 100
530
+ };
531
+ }
532
+
533
+ this._objectivesCount = this._supportsObjectives ? this._parseCount(results.objectivesCount) : 0;
534
+ this._interactionsCount = this._supportsInteractions ? this._parseCount(results.interactionsCount) : 0;
535
+ this._objectiveIdToIndex.clear();
536
+ for (let i = 0; i < this._objectivesCount; i++) {
537
+ try {
538
+ const id = await this._sendMessage('GetValue', `cmi.objectives.${i}.id`);
539
+ if (id) this._objectiveIdToIndex.set(id, i);
540
+ } catch {
541
+ // Keep the LMS count authoritative even if one row cannot be read.
542
+ }
543
+ }
544
+
353
545
  // Parse suspend_data
354
546
  if (results.suspendData) {
355
547
  try {
356
- this._suspendDataCache = JSON.parse(results.suspendData);
357
- } catch {
358
- this._suspendDataCache = null;
548
+ const parsed = is12
549
+ ? decodeScorm12SuspendState(results.suspendData)
550
+ : JSON.parse(LZString.decompressFromUTF16(results.suspendData));
551
+ this._suspendDataCache = is12 ? expandScorm12DietState(parsed) : parsed;
552
+ } catch (error) {
553
+ throw new Error(`ProxyDriver: Resume state cannot be safely restored: ${error.message}`);
359
554
  }
360
555
  }
361
556
 
@@ -370,6 +565,30 @@ export class ProxyDriver {
370
565
  // Private: postMessage Transport
371
566
  // =========================================================================
372
567
 
568
+ _startListening() {
569
+ if (this._isListening) return;
570
+ window.addEventListener('message', this._handleMessage);
571
+ this._isListening = true;
572
+ }
573
+
574
+ _stopListening(reason) {
575
+ if (this._isListening) {
576
+ window.removeEventListener('message', this._handleMessage);
577
+ this._isListening = false;
578
+ }
579
+
580
+ for (const pending of this._pending.values()) {
581
+ clearTimeout(pending.timeout);
582
+ pending.reject(new Error(`ProxyDriver: Request cancelled because ${reason}`));
583
+ }
584
+ this._pending.clear();
585
+ if (this._handshake) {
586
+ clearTimeout(this._handshake.timeout);
587
+ this._handshake.reject(new Error(`ProxyDriver: Handshake cancelled because ${reason}`));
588
+ this._handshake = null;
589
+ }
590
+ }
591
+
373
592
  _ensureConnected() {
374
593
  if (!this._isConnected) {
375
594
  throw new Error('ProxyDriver: Not connected');
@@ -379,13 +598,70 @@ export class ProxyDriver {
379
598
  }
380
599
  }
381
600
 
601
+ _parseCount(value) {
602
+ const parsed = Number(value);
603
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
604
+ }
605
+
606
+ _isScorm12() {
607
+ return this._baseFormat === 'scorm1.2';
608
+ }
609
+
610
+ _establishHandshake(targetOrigin) {
611
+ return new Promise((resolve, reject) => {
612
+ const nonce = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
613
+ const timeout = setTimeout(() => {
614
+ this._handshake = null;
615
+ reject(new Error('ProxyDriver: Timeout waiting for secure proxy handshake'));
616
+ }, MESSAGE_TIMEOUT);
617
+
618
+ this._handshake = { nonce, resolve, reject, timeout };
619
+ try {
620
+ window.parent.postMessage({
621
+ type: 'scorm-proxy-handshake',
622
+ nonce,
623
+ baseFormat: this._baseFormat
624
+ }, targetOrigin);
625
+ } catch (error) {
626
+ clearTimeout(timeout);
627
+ this._handshake = null;
628
+ reject(error);
629
+ }
630
+ });
631
+ }
632
+
633
+ _getOrCreateObjectiveIndex(objectiveId) {
634
+ if (this._objectiveIdToIndex.has(objectiveId)) {
635
+ return this._objectiveIdToIndex.get(objectiveId);
636
+ }
637
+ const index = this._objectivesCount++;
638
+ this._objectiveIdToIndex.set(objectiveId, index);
639
+ this._sendSetValue(`cmi.objectives.${index}.id`, objectiveId);
640
+ return index;
641
+ }
642
+
382
643
  /**
383
644
  * Fire-and-forget setValue to proxy bridge.
384
645
  */
385
646
  _sendSetValue(key, value) {
386
- this._sendMessage('SetValue', key, String(value)).catch(err => {
387
- logger.error(`ProxyDriver: SetValue failed for ${key}`, err);
388
- });
647
+ const write = this._sendMessage('SetValue', key, String(value))
648
+ .then(result => {
649
+ if (result !== true && result !== 'true') {
650
+ throw new Error(`ProxyDriver: LMS rejected SetValue('${key}')`);
651
+ }
652
+ return true;
653
+ })
654
+ .catch(error => ({ error }));
655
+ this._pendingWrites.push(write);
656
+ return write;
657
+ }
658
+
659
+ async _flushPendingWrites() {
660
+ const writes = this._pendingWrites.splice(0);
661
+ if (writes.length === 0) return;
662
+ const results = await Promise.all(writes);
663
+ const failure = results.find(result => result?.error);
664
+ if (failure) throw failure.error;
389
665
  }
390
666
 
391
667
  /**
@@ -402,12 +678,18 @@ export class ProxyDriver {
402
678
 
403
679
  this._pending.set(id, { resolve, reject, timeout });
404
680
 
405
- window.parent.postMessage({
406
- type: 'scorm-proxy-request',
407
- id,
408
- method,
409
- args
410
- }, this._parentOrigin);
681
+ try {
682
+ window.parent.postMessage({
683
+ type: 'scorm-proxy-request',
684
+ id,
685
+ method,
686
+ args
687
+ }, this._parentOrigin);
688
+ } catch (error) {
689
+ clearTimeout(timeout);
690
+ this._pending.delete(id);
691
+ reject(error);
692
+ }
411
693
  });
412
694
  }
413
695
 
@@ -417,6 +699,22 @@ export class ProxyDriver {
417
699
  _handleMessage(event) {
418
700
  const { data } = event;
419
701
 
702
+ if (data?.type === 'scorm-proxy-handshake-response') {
703
+ if (event.source !== window.parent || !this._handshake || data.nonce !== this._handshake.nonce) return;
704
+ const handshake = this._handshake;
705
+ clearTimeout(handshake.timeout);
706
+ this._handshake = null;
707
+ if (data.baseFormat !== this._baseFormat) {
708
+ handshake.reject(new Error(
709
+ `ProxyDriver: Proxy package format ${data.baseFormat} does not match course format ${this._baseFormat}`
710
+ ));
711
+ return;
712
+ }
713
+ this._parentOrigin = event.origin;
714
+ handshake.resolve(true);
715
+ return;
716
+ }
717
+
420
718
  if (!data || data.type !== 'scorm-proxy-response') {
421
719
  return;
422
720
  }