ReviewOS

also looking at this

craft-native/craft

fix(native): interaction, audio, document and checkpoint gaps stranded after #401

#415
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
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.

docs/guides/native-stx.mdmodified+6-0
Changes to docs/guides/native-stx.md
@@ -1375,6 +1375,12 @@ screen closes. A permanent focus loss releases every player and sends each
13751375sound's error event; replacement, decode failure, cancellation, and teardown
13761376also release their player resources.
13771377
1378Checkpoint names use the same bounded filename contract on both native hosts:
1379letters, digits, `.`, `_`, and `-`, up to 128 characters, without a leading
1380dot. Non-JSON values and invalid names are rejected before a write is posted;
1381the previous confirmed checkpoint remains available after a failed or
1382interrupted replacement.
1383
13781384The `media` capability (iOS) keeps video and audio on the phone for offline,
13791385in Application Support rather than the temporary directory, left out of
13801386iCloud backups. `craft.media.keep(url, { headers })` downloads the file once
packages/android/src/native-shell.test.tsmodified+8-0
Changes to packages/android/src/native-shell.test.ts
@@ -100,6 +100,14 @@ describe('the native shell prelude (JavaScriptSandbox has no synchronous host ca
100100 expect(app.context.craft.snapshots.get('campaign')).toEqual({ tick: 24 })
101101 })
102102
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
103111 it('follows another isolate\'s writes and tells the screen a snapshot changed', () => {
104112 const app = isolate({ snapshots: { header: '{"userId":1}' } })
105113 app.deliver({ type: 'SYNC_UPDATE', payload: { store: 'snapshots', key: 'header', value: '{"userId":2}' } })
packages/android/templates/CraftNativeShell.kt.templatemodified+20-5
Changes to packages/android/templates/CraftNativeShell.kt.template
@@ -373,6 +373,10 @@ internal class CraftNativeStores private constructor(
373373
374374 /** `craft.snapshots.set`: null deletes it. `done` hears whether it was kept, on the main thread. */
375375 fun setSnapshot(name: String, json: String?, from: String?, done: (Boolean) -> Unit) {
376 if (!isSnapshotName(name)) {
377 main.post { done(false) }
378 return
379 }
376380 writer.execute {
377381 val file = snapshotFile(name)
378382 val previous = snapshotCache[name]
@@ -422,6 +426,8 @@ internal class CraftNativeStores private constructor(
422426 CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from)
423427 }
424428
429 private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}"))
430
425431 private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json")
426432
427433 private fun snapshotName(file: File): String? =
@@ -659,6 +665,9 @@ internal object CraftNativePrelude {
659665 if (json === null || json === undefined) return null;
660666 try { return JSON.parse(json); } catch (error) { return null; }
661667 }
668 function validSnapshotName(name) {
669 return /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(name);
670 }
662671 var craft = globalThis.craft = globalThis.craft || {};
663672 function keep(name, extras) {
664673 var current = {};
@@ -681,12 +690,18 @@ internal object CraftNativePrelude {
681690 }
682691 });
683692 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 },
685697 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 });
690705 },
691706 clear: function(keepNames) {
692707 var names = Array.isArray(keepNames) ? keepNames.map(String) : [];