snapback4-expo 0.4.10 → 0.4.12
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 +18 -11
- package/export-ios.mjs +132 -0
- package/ios/Snapback4Device.xcframework/ios-arm64/libsnapback4_device.a +0 -0
- package/ios/Snapback4Device.xcframework/ios-arm64-simulator/libsnapback4_device.a +0 -0
- package/package.json +14 -4
- package/plugin.cjs +1 -1
- package/src/witness.ts +8 -0
package/README.md
CHANGED
|
@@ -31,6 +31,13 @@ SQLite and the native-query Wasm host. The earlier module measured 1.7 MB
|
|
|
31
31
|
linked and about a millisecond per call; those measurements predate the
|
|
32
32
|
native-query host.
|
|
33
33
|
|
|
34
|
+
The pinned Expo 57.0.22 apps list `snapback4-expo/plugin` in app.json's
|
|
35
|
+
`plugins`. Keep that entry when generating iOS: it supplies the scene lifecycle
|
|
36
|
+
required by Xcode 27, shared by the reference chat app and both starters.
|
|
37
|
+
After adding it, run `npx expo prebuild --platform ios`, rebuild Release and
|
|
38
|
+
reinstall. Preserve custom native changes when regenerating; the plugin handles
|
|
39
|
+
the standard Swift starter and refuses custom delegates/manifests.
|
|
40
|
+
|
|
34
41
|
Earlier iOS simulator evidence from the repository's witness in a development
|
|
35
42
|
build (before the published driver, recorded in
|
|
36
43
|
`metrics/witness.jsonl` as `dev-client-native`): a cold sync of 303 rows
|
|
@@ -59,13 +66,13 @@ runner, commands and artifact contract are in `snapback4 guide witness`
|
|
|
59
66
|
“Proving it on an iOS simulator”. No Expo Go or Metro process supplies a
|
|
60
67
|
Release witness.
|
|
61
68
|
|
|
62
|
-
At every milestone,
|
|
63
|
-
`npm run native:smoke`
|
|
64
|
-
The smoke
|
|
69
|
+
At every milestone, `npm run export:ios` keeps Hermes in `dist/` and also proves the app runs on iOS when Xcode/runtime are present (warm runs take about 18 s for the plain starter and 27 s for the social one, cold runs 175–230 s, on this mini).
|
|
70
|
+
`npm run native:smoke` always runs the full Release build; `SNAPBACK4_NATIVE_SMOKE=off` skips the automatic smoke. Unavailable hosts skip, app/SDK failures fail.
|
|
71
|
+
The smoke prepares and installs your Release app, chooses its own simulator and free
|
|
65
72
|
port, and keeps receipts in `artifacts/native-smoke/<timestamp>`. It proves local
|
|
66
73
|
store acquisition, cold offline reopen and reconnect. Pending write replay, media
|
|
67
74
|
and application assertions need the deeper scenario in `snapback4 guide witness`.
|
|
68
|
-
An empty starter proves lifecycle only;
|
|
75
|
+
An empty starter proves lifecycle only; a skipped smoke proves no native execution.
|
|
69
76
|
|
|
70
77
|
Own-build preparation (`witness ios --build . --smoke`) uses the witness helper
|
|
71
78
|
in matching current `snapback4` and `snapback4-expo` packages. Its synchronous native observation hashes
|
|
@@ -80,13 +87,13 @@ update selected at runtime. The JS smoke helper consumes compiler query facts
|
|
|
80
87
|
from the driver, bound to the generation actually served. It checks every
|
|
81
88
|
runs/held fact against the kept backend before querying. Stale facts fail;
|
|
82
89
|
device-answerable loading fails, while server-only offline
|
|
83
|
-
|
|
90
|
+
E_OFFLINE is recorded as expected unavailability. Server-only reads
|
|
84
91
|
must answer online in prime and restart. Conditional native-module queries
|
|
85
92
|
require an application witness; generic smoke refuses that unsupported shape. Client semantics are unchanged.
|
|
86
93
|
|
|
87
|
-
After JS
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
After JS edits, repeat `npm run export:ios`; simulator selection and fresh
|
|
95
|
+
artifact directories are automatic. Matching native inputs reuse the Release
|
|
96
|
+
app and install the exact exported bundle/assets. Native changes rebuild;
|
|
97
|
+
`npm run native:smoke` always runs Xcode. Both routes retain the same loaded-file
|
|
98
|
+
hash and prime/offline/restart proof. Measured on the Apartment M5 Pro mini,
|
|
99
|
+
18 cores/64 GiB, Xcode/iOS 27, with tools/dependencies installed first.
|
package/export-ios.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ref LLP 3000.030#15 — exact export, explicit environment skips, bounded evidence.
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
10
|
+
|
|
11
|
+
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
function exportFiles(root, relative = '') {
|
|
13
|
+
return fs.readdirSync(path.join(root, relative)).sort().flatMap(name => {
|
|
14
|
+
const file = path.join(relative, name), full = path.join(root, file), stat = fs.lstatSync(full);
|
|
15
|
+
const row = { path: file, mode: stat.mode & 0o777 };
|
|
16
|
+
if (stat.isDirectory()) return [{ ...row, type: 'directory' }, ...exportFiles(root, file)];
|
|
17
|
+
if (!stat.isFile()) throw Error('export contains an unsupported file type');
|
|
18
|
+
const bytes = fs.readFileSync(full);
|
|
19
|
+
return [{ ...row, type: 'file', bytes: bytes.length, sha256: sha256(bytes) }];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function publicationExpectation(artifacts, source, skipped, smoke) {
|
|
23
|
+
if (skipped) return { proofSha256: null, files: exportFiles(source) };
|
|
24
|
+
const bytes = fs.readFileSync(path.join(artifacts, 'proof.json'));
|
|
25
|
+
const proofSha256 = sha256(bytes), files = JSON.parse(bytes).exported?.inventory?.files;
|
|
26
|
+
if (smoke?.exitStatus !== 0 || smoke?.proof?.sha256 !== proofSha256 || !Array.isArray(files)) throw Error('export proof does not match successful smoke receipt');
|
|
27
|
+
return { proofSha256, files };
|
|
28
|
+
}
|
|
29
|
+
export function verifyPublication(artifacts, source, target, expected) {
|
|
30
|
+
let receipt;
|
|
31
|
+
try { receipt = JSON.parse(fs.readFileSync(path.join(artifacts, 'publication.json'), 'utf8')); }
|
|
32
|
+
catch { throw Error('missing or unreadable verified publication.json'); }
|
|
33
|
+
if (receipt.source !== source || receipt.target !== target || !Number.isFinite(receipt.verifiedAt) ||
|
|
34
|
+
receipt.proofSha256 !== expected.proofSha256 || !isDeepStrictEqual(receipt.files, expected.files)) {
|
|
35
|
+
throw Error('publication.json does not match the export proved by this run');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function skipReason(platform, env) {
|
|
40
|
+
if (env.SNAPBACK4_NATIVE_SMOKE === 'off') return 'SNAPBACK4_NATIVE_SMOKE=off';
|
|
41
|
+
if (platform !== 'darwin') return 'macOS is required';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const environmentConditions = new Set(['simulator-list', 'missing-runtime', 'missing-device-type', 'simulator-create', 'simulator-boot']);
|
|
45
|
+
export function smokeResult(status, receipt) {
|
|
46
|
+
if (status === 0) return { status: 0, message: 'native smoke passed' };
|
|
47
|
+
if (receipt?.environmentCondition === 'simulator-create' && /^E_WITNESS_TIMEOUT:/.test(receipt.error)) return { status: status || 1, message: 'native smoke failed' };
|
|
48
|
+
if (receipt?.phase === 'environment' && environmentConditions.has(receipt.environmentCondition) &&
|
|
49
|
+
/^(E_WITNESS_PLATFORM|E_WITNESS_SIMULATOR|E_WITNESS_TIMEOUT):/.test(receipt.error)) {
|
|
50
|
+
return { status: 0, message: `native smoke skipped: ${receipt.error}` };
|
|
51
|
+
}
|
|
52
|
+
return { status: status || 1, message: 'native smoke failed' };
|
|
53
|
+
}
|
|
54
|
+
// The Rust driver bounds its own cleanup and keeps its leases until it finishes.
|
|
55
|
+
// Wait for that exit even after a second signal; killing only its wrapper would
|
|
56
|
+
// orphan the driver's backend/app. This also forwards cancellation during export.
|
|
57
|
+
export function runChild(command, args, options = {}) {
|
|
58
|
+
return new Promise(resolve => {
|
|
59
|
+
const child = spawn(command, args, { stdio: 'inherit', env: process.env, ...options });
|
|
60
|
+
let received;
|
|
61
|
+
const handlers = Object.fromEntries(['SIGINT', 'SIGTERM'].map(signal => [signal, () => {
|
|
62
|
+
if (!received) { received = signal; child.kill(signal); }
|
|
63
|
+
}]));
|
|
64
|
+
for (const [signal, handler] of Object.entries(handlers)) process.on(signal, handler);
|
|
65
|
+
child.once('error', () => {}); // close also follows a failed spawn
|
|
66
|
+
child.once('close', (status, signal) => {
|
|
67
|
+
for (const [name, handler] of Object.entries(handlers)) process.off(name, handler);
|
|
68
|
+
const cancelled = received ?? signal;
|
|
69
|
+
resolve({ status: cancelled ? (cancelled === 'SIGINT' ? 130 : 143) : status ?? 1, cancelled });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// OS-locked pruning/recovery lives beside the driver's ownership records.
|
|
74
|
+
// Failure here is maintenance work, never a change to a completed smoke result.
|
|
75
|
+
export function retainEvidence(root, completed, binary = 'snapback4') {
|
|
76
|
+
const result = spawnSync(binary, ['witness', 'export-cleanup', root, ...(completed ? [completed] : [])], { encoding: 'utf8', timeout: 120_000 });
|
|
77
|
+
if (result.status !== 0) console.error(`native smoke evidence cleanup deferred: ${result.stderr || result.error?.message || result.status}`);
|
|
78
|
+
return result.status === 0;
|
|
79
|
+
}
|
|
80
|
+
// The helper locks a duplicate of this descriptor, so the kernel lease stays
|
|
81
|
+
// with this process after the helper exits. Children do not inherit it unless
|
|
82
|
+
// explicitly listed in stdio (the publisher must keep it if this wrapper dies).
|
|
83
|
+
export function acquireExportLease(artifacts, binary = 'snapback4') {
|
|
84
|
+
const fd = fs.openSync(path.join(artifacts, 'export.lock'), 'a+');
|
|
85
|
+
const result = spawnSync(binary, ['witness', 'export-lease', artifacts], { encoding: 'utf8', env: { ...process.env, SNAPBACK4_EXPORT_WRAPPER_PID: String(process.pid) }, stdio: ['ignore', 'pipe', 'pipe', fd] });
|
|
86
|
+
if (result.status !== 0) { fs.closeSync(fd); throw Error(result.stderr || result.error?.message || 'export lease failed'); }
|
|
87
|
+
return fd;
|
|
88
|
+
}
|
|
89
|
+
export async function main() {
|
|
90
|
+
const req = createRequire(path.resolve('package.json'));
|
|
91
|
+
const skip = skipReason(process.platform, process.env);
|
|
92
|
+
if (skip) {
|
|
93
|
+
const exported = await runChild(process.execPath, [req.resolve('expo/bin/cli'), 'export', '--platform', 'ios', '--dump-assetmap']);
|
|
94
|
+
if (exported.status !== 0) return exported.status;
|
|
95
|
+
console.log(`native smoke skipped: ${skip}`); return 0;
|
|
96
|
+
}
|
|
97
|
+
const root = path.resolve('artifacts/native-smoke');
|
|
98
|
+
const artifacts = path.join(root, `${Date.now()}-${process.pid}`);
|
|
99
|
+
const privateExport = path.join(artifacts, 'export');
|
|
100
|
+
const receiptPath = path.join(artifacts, 'smoke-ios-run.json');
|
|
101
|
+
fs.mkdirSync(artifacts, { recursive: true });
|
|
102
|
+
const lease = acquireExportLease(artifacts);
|
|
103
|
+
let publicationAttempted = false;
|
|
104
|
+
try {
|
|
105
|
+
const exported = await runChild(process.execPath, [req.resolve('expo/bin/cli'), 'export', '--platform', 'ios', '--dump-assetmap', '--output-dir', privateExport]);
|
|
106
|
+
if (exported.status !== 0) return exported.status;
|
|
107
|
+
const result = await runChild('snapback4', ['witness', 'ios', '--build', '.', '--smoke', '--export', privateExport, '--artifacts', artifacts], { env: { ...process.env, SNAPBACK4_EXPORT_WRAPPER_PID: String(process.pid) } });
|
|
108
|
+
let receipt;
|
|
109
|
+
try { receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); } catch {}
|
|
110
|
+
const outcome = result.cancelled ? { status: result.status, message: 'native smoke interrupted' } : smokeResult(result.status, receipt);
|
|
111
|
+
if (outcome.status === 0) {
|
|
112
|
+
const expected = publicationExpectation(artifacts, privateExport, result.status !== 0, receipt);
|
|
113
|
+
publicationAttempted = true;
|
|
114
|
+
const published = await runChild('snapback4', ['witness', 'export-publish', privateExport, path.resolve('dist'), ...(result.status === 0 ? [] : ['--environment-skip'])], { stdio: ['inherit', 'inherit', 'inherit', lease] });
|
|
115
|
+
try {
|
|
116
|
+
verifyPublication(artifacts, privateExport, path.resolve('dist'), expected);
|
|
117
|
+
if (published.status !== 0) throw Error(`publisher exited with status ${published.status}`);
|
|
118
|
+
} catch (error) { outcome.status = published.status || 1; outcome.message = `export output publication failed: ${error.message}`; }
|
|
119
|
+
}
|
|
120
|
+
console.log(`${outcome.message}; receipt: ${receiptPath}`);
|
|
121
|
+
return outcome.status;
|
|
122
|
+
} finally {
|
|
123
|
+
// After a publication attempt this path may be the displaced last good
|
|
124
|
+
// export. Only the publisher, under its destination lock, may remove it.
|
|
125
|
+
try { if (!publicationAttempted) fs.rmSync(privateExport, { recursive: true, force: true }); }
|
|
126
|
+
finally { fs.closeSync(lease); retainEvidence(root, artifacts); }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) {
|
|
130
|
+
try { process.exitCode = await main(); }
|
|
131
|
+
catch (error) { console.error(`export:ios failed: ${error.message}`); process.exitCode = 1; }
|
|
132
|
+
}
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapback4-expo",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Snapback 4's native module for Expo: the device in Rust (the partition in its own SQLite, the stream applied, the outbox kept, queries and predictions answered) behind one JSON call, and the opener `snapback4/native` takes.",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./witness": {
|
|
14
|
+
"types": "./src/witness.ts",
|
|
15
|
+
"default": "./src/witness.ts"
|
|
16
|
+
},
|
|
11
17
|
"./plugin": "./plugin.cjs",
|
|
12
18
|
"./package.json": "./package.json"
|
|
13
19
|
},
|
|
@@ -16,7 +22,8 @@
|
|
|
16
22
|
"src",
|
|
17
23
|
"ios",
|
|
18
24
|
"expo-module.config.json",
|
|
19
|
-
"README.md"
|
|
25
|
+
"README.md",
|
|
26
|
+
"export-ios.mjs"
|
|
20
27
|
],
|
|
21
28
|
"peerDependencies": {
|
|
22
29
|
"expo": "*",
|
|
@@ -25,5 +32,8 @@
|
|
|
25
32
|
"devDependencies": {
|
|
26
33
|
"expo-modules-core": "*",
|
|
27
34
|
"typescript": "5.9.3"
|
|
35
|
+
},
|
|
36
|
+
"bin": {
|
|
37
|
+
"snapback4-export-ios": "export-ios.mjs"
|
|
28
38
|
}
|
|
29
39
|
}
|
package/plugin.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ref LLP 3000.030#13 —
|
|
1
|
+
// @ref LLP 3000.030#13 — one published lifecycle for reference and generated Expo 57.0.22 apps; preserve existing plugin configs.
|
|
2
2
|
const { withAppDelegate, withInfoPlist } = require('expo/config-plugins');
|
|
3
3
|
const marker = '// Snapback4 starter scene lifecycle';
|
|
4
4
|
const legacy = `#if os(iOS) || os(tvOS)
|
package/src/witness.ts
CHANGED
|
@@ -21,3 +21,11 @@ export function smokeSegment(): "prime" | "offline" | "restart" | null {
|
|
|
21
21
|
const config = JSON.parse(container.witnessLaunch);
|
|
22
22
|
return config.smoke === true && ["prime", "offline", "restart"].includes(config.segment) ? config.segment : null;
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
/** Simulator smoke uses its owned loopback backend; ordinary launches keep the app URL. */
|
|
26
|
+
export function smokeOrigin(fallback: string): string {
|
|
27
|
+
const container = requireOptionalNativeModule<WitnessContainer>("Snapback4");
|
|
28
|
+
if (!container?.witnessLaunch) return fallback;
|
|
29
|
+
const config = JSON.parse(container.witnessLaunch);
|
|
30
|
+
return config.smoke === true && /^http:\/\/127\.0\.0\.1:[0-9]+$/.test(config.origin) ? config.origin : fallback;
|
|
31
|
+
}
|