also looking at this
fix(native): interaction, audio, document and checkpoint gaps stranded after #401
#415
19 files
+987
-50
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -1375,6 +1375,12 @@ screen closes. A permanent focus loss releases every player and sends each | ||
| 1375 | 1375 | sound's error event; replacement, decode failure, cancellation, and teardown |
| 1376 | 1376 | also release their player resources. |
| 1377 | 1377 | |
| 1378 | Checkpoint names use the same bounded filename contract on both native hosts: | |
| 1379 | letters, digits, `.`, `_`, and `-`, up to 128 characters, without a leading | |
| 1380 | dot. Non-JSON values and invalid names are rejected before a write is posted; | |
| 1381 | the previous confirmed checkpoint remains available after a failed or | |
| 1382 | interrupted replacement. | |
| 1383 | ||
| 1378 | 1384 | The `media` capability (iOS) keeps video and audio on the phone for offline, |
| 1379 | 1385 | in Application Support rather than the temporary directory, left out of |
| 1380 | 1386 | iCloud backups. `craft.media.keep(url, { headers })` downloads the file once |
| @@ -100,6 +100,14 @@ describe('the native shell prelude (JavaScriptSandbox has no synchronous host ca | ||
| 100 | 100 | expect(app.context.craft.snapshots.get('campaign')).toEqual({ tick: 24 }) |
| 101 | 101 | }) |
| 102 | 102 | |
| 103 | it('rejects invalid checkpoint names and values before posting a write', async () => { | |
| 104 | const app = isolate() | |
| 105 | await expect(app.context.craft.snapshots.set('../escape', { tick: 1 })).rejects.toThrow(/Snapshot names are letters/) | |
| 106 | await expect(app.context.craft.snapshots.set('valid', undefined)).rejects.toThrow(/is not JSON/) | |
| 107 | expect(app.context.craft.snapshots.get('../escape')).toBeNull() | |
| 108 | expect(app.posted.filter(message => message.type === 'SYNC_WRITE')).toHaveLength(0) | |
| 109 | }) | |
| 110 | ||
| 103 | 111 | it('follows another isolate\'s writes and tells the screen a snapshot changed', () => { |
| 104 | 112 | const app = isolate({ snapshots: { header: '{"userId":1}' } }) |
| 105 | 113 | app.deliver({ type: 'SYNC_UPDATE', payload: { store: 'snapshots', key: 'header', value: '{"userId":2}' } }) |
| @@ -373,6 +373,10 @@ internal class CraftNativeStores private constructor( | ||
| 373 | 373 | |
| 374 | 374 | /** `craft.snapshots.set`: null deletes it. `done` hears whether it was kept, on the main thread. */ |
| 375 | 375 | fun setSnapshot(name: String, json: String?, from: String?, done: (Boolean) -> Unit) { |
| 376 | if (!isSnapshotName(name)) { | |
| 377 | main.post { done(false) } | |
| 378 | return | |
| 379 | } | |
| 376 | 380 | writer.execute { |
| 377 | 381 | val file = snapshotFile(name) |
| 378 | 382 | val previous = snapshotCache[name] |
| @@ -422,6 +426,8 @@ internal class CraftNativeStores private constructor( | ||
| 422 | 426 | CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from) |
| 423 | 427 | } |
| 424 | 428 | |
| 429 | private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}")) | |
| 430 | ||
| 425 | 431 | private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json") |
| 426 | 432 | |
| 427 | 433 | private fun snapshotName(file: File): String? = |
| @@ -659,6 +665,9 @@ internal object CraftNativePrelude { | ||
| 659 | 665 | if (json === null || json === undefined) return null; |
| 660 | 666 | try { return JSON.parse(json); } catch (error) { return null; } |
| 661 | 667 | } |
| 668 | function validSnapshotName(name) { | |
| 669 | return /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(name); | |
| 670 | } | |
| 662 | 671 | var craft = globalThis.craft = globalThis.craft || {}; |
| 663 | 672 | function keep(name, extras) { |
| 664 | 673 | var current = {}; |
| @@ -681,12 +690,18 @@ internal object CraftNativePrelude { | ||
| 681 | 690 | } |
| 682 | 691 | }); |
| 683 | 692 | keep('snapshots', { |
| 684 | get: function(name) { return parsed(sync.snapshots[String(name)]); }, | |
| 693 | get: function(name) { | |
| 694 | name = String(name); | |
| 695 | return validSnapshotName(name) ? parsed(sync.snapshots[name]) : null; | |
| 696 | }, | |
| 685 | 697 | set: function(name, value) { |
| 686 | var json = value === null || value === undefined ? null : JSON.stringify(value); | |
| 687 | if (json === null) delete sync.snapshots[String(name)]; | |
| 688 | else sync.snapshots[String(name)] = json; | |
| 689 | return wait('snapshots.set', { store: 'snapshots', key: String(name), value: json }); | |
| 698 | name = String(name); | |
| 699 | if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_" and "-"')); | |
| 700 | var json = value === null ? null : JSON.stringify(value); | |
| 701 | if (json === undefined) return Promise.reject(new TypeError('Snapshot ' + name + ' is not JSON')); | |
| 702 | if (json === null) delete sync.snapshots[name]; | |
| 703 | else sync.snapshots[name] = json; | |
| 704 | return wait('snapshots.set', { store: 'snapshots', key: name, value: json }); | |
| 690 | 705 | }, |
| 691 | 706 | clear: function(keepNames) { |
| 692 | 707 | var names = Array.isArray(keepNames) ? keepNames.map(String) : []; |