snapback4-expo 0.4.8 → 0.4.9
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 +24 -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/ios/Snapback4Module.swift +1 -0
- package/ios/WitnessContainer.swift +31 -0
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/smoke.ts +55 -19
- package/src/witness-core.ts +27 -1
- package/src/witness-runtime.ts +15 -0
- package/src/witness.ts +3 -1
package/README.md
CHANGED
|
@@ -58,3 +58,27 @@ runner, commands and artifact contract are in `snapback4 guide witness`
|
|
|
58
58
|
(aliases: `simulator`, `offline proof`) and the client package README's
|
|
59
59
|
“Proving it on an iOS simulator”. No Expo Go or Metro process supplies a
|
|
60
60
|
Release witness.
|
|
61
|
+
|
|
62
|
+
Own-build preparation (`witness ios --build … --smoke`) needs the updated native
|
|
63
|
+
witness helper in this source revision. Its synchronous native observation hashes
|
|
64
|
+
the executable and the file selected by React Native SourceCode.scriptURL.
|
|
65
|
+
Selection must resolve to the verified installed main.jsbundle; unknown, remote
|
|
66
|
+
or alternate URLs fail, as do runtime __DEV__ true/unknown and launcher/updates
|
|
67
|
+
manifest presence. The validator
|
|
68
|
+
checks Release/Hermes, the selected path and hashes against the external proof. Rebuild the native module
|
|
69
|
+
when adopting it. Ordinary launches remain inactive. Linked expo-updates is
|
|
70
|
+
outside this preparation adapter because an embedded hash cannot attest a cached
|
|
71
|
+
update selected at runtime. The JS smoke helper consumes compiler query facts
|
|
72
|
+
from the driver, bound to the generation actually served. It checks every
|
|
73
|
+
runs/held fact against the kept backend before querying. Stale facts fail;
|
|
74
|
+
device-answerable loading fails, while server-only offline
|
|
75
|
+
loading or E_OFFLINE is recorded as expected unavailability. Server-only reads
|
|
76
|
+
must answer online in prime and restart. Conditional native-module queries
|
|
77
|
+
require an application witness; generic smoke refuses that unsupported shape. Client semantics are unchanged.
|
|
78
|
+
|
|
79
|
+
After JS/assets edits, repeat `witness ios --build . --smoke` with the same
|
|
80
|
+
dedicated simulator and a fresh artifacts directory. Warm Xcode intermediates
|
|
81
|
+
are reused; each run builds the current app and seals a new receipt. Repack
|
|
82
|
+
is withdrawn. Five complete warm build-plus-smoke runs on the shared M5 Pro mini
|
|
83
|
+
measured 159.841 s median / 197.562 s nearest-rank p95, all passing; cold native preparation was about six minutes, with smoke and setup
|
|
84
|
+
additional. See `guide witness` for pins, scope and exact invocation.
|
|
Binary file
|
|
Binary file
|
|
@@ -14,6 +14,7 @@ public final class Snapback4Module: Module {
|
|
|
14
14
|
Name("Snapback4")
|
|
15
15
|
|
|
16
16
|
Constant("witnessLaunch") { WitnessContainer.launch }
|
|
17
|
+
Function("witnessObservation") { (scriptURL: String?) -> [String: Any]? in try WitnessContainer.observation(scriptURL) }
|
|
17
18
|
Function("witnessAppend") { (line: String) in try WitnessContainer.append(line) }
|
|
18
19
|
Function("witnessSignaled") { (label: String) -> Bool in try WitnessContainer.signaled(label) }
|
|
19
20
|
|
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import CryptoKit
|
|
2
3
|
|
|
3
4
|
// Runtime environment is not exposed to Hermes by Expo. This small bridge
|
|
4
5
|
// supplies it and durable file append; the scenario and protocol live in JS.
|
|
5
6
|
enum WitnessContainer {
|
|
7
|
+
// @ref LLP 3000.030#6 — native reads bind the selected payload on every cold launch.
|
|
8
|
+
static func observation(_ scriptURL: String?) throws -> [String: Any]? {
|
|
9
|
+
guard let launch, let bytes = launch.data(using: .utf8),
|
|
10
|
+
let config = try JSONSerialization.jsonObject(with: bytes) as? [String: Any],
|
|
11
|
+
let proof = config["proof"] as? [String: Any] else { return nil }
|
|
12
|
+
func digest(_ url: URL?) throws -> String {
|
|
13
|
+
guard let url else { throw Snapback4OpenException("E_WITNESS_PROOF: missing embedded payload") }
|
|
14
|
+
return SHA256.hash(data: try Data(contentsOf: url, options: .mappedIfSafe)).map { String(format: "%02x", $0) }.joined()
|
|
15
|
+
}
|
|
16
|
+
guard let scriptURL, let selected = URL(string: scriptURL), selected.isFileURL,
|
|
17
|
+
selected.host == nil || selected.host == "" || selected.host == "localhost",
|
|
18
|
+
selected.query == nil, selected.fragment == nil,
|
|
19
|
+
let embedded = Bundle.main.url(forResource: "main", withExtension: "jsbundle")
|
|
20
|
+
else { throw Snapback4OpenException("E_WITNESS_PROOF: unknown or remote selected JavaScript") }
|
|
21
|
+
let actual = selected.standardizedFileURL.resolvingSymlinksInPath()
|
|
22
|
+
guard actual == embedded.standardizedFileURL.resolvingSymlinksInPath() else {
|
|
23
|
+
throw Snapback4OpenException("E_WITNESS_PROOF: selected JavaScript differs from installed main.jsbundle: \(scriptURL)")
|
|
24
|
+
}
|
|
25
|
+
#if DEBUG
|
|
26
|
+
let release = false
|
|
27
|
+
#else
|
|
28
|
+
let release = true
|
|
29
|
+
#endif
|
|
30
|
+
return ["format": 2, "run": config["run"] ?? "", "segment": config["segment"] ?? "",
|
|
31
|
+
"proofSha256": proof["sha256"] ?? "", "nativeBase": (proof["nativeBase"] as? [String: Any])?["app"] ?? "",
|
|
32
|
+
"output": ((proof["executedApp"] as? [String: Any])?["inventory"] as? [String: Any])?["sha256"] ?? "",
|
|
33
|
+
"bundleId": Bundle.main.bundleIdentifier ?? "", "release": release, "helper": "native-proof-v2", "scriptURL": actual.absoluteString, "selectedScriptURL": scriptURL, "selectedBundlePath": actual.path,
|
|
34
|
+
"javascriptSha256": try digest(actual),
|
|
35
|
+
"executableSha256": try digest(Bundle.main.executableURL)]
|
|
36
|
+
}
|
|
6
37
|
static var launch: String? {
|
|
7
38
|
#if targetEnvironment(simulator)
|
|
8
39
|
return ProcessInfo.processInfo.environment["SNAPBACK4_WITNESS"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapback4-expo",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// online tier (`snapback4/client`) when it is not.
|
|
5
5
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
6
6
|
import type { DeviceCore, DeviceOpener } from "snapback4/local";
|
|
7
|
+
import { witnessRuntime } from "./witness-runtime";
|
|
7
8
|
import type { WitnessContainer } from "./witness-core";
|
|
8
9
|
|
|
9
10
|
interface Snapback4Native extends WitnessContainer {
|
|
@@ -22,7 +23,7 @@ const native = requireOptionalNativeModule<Snapback4Native>("Snapback4");
|
|
|
22
23
|
const trace = Boolean(process.env.EXPO_PUBLIC_SNAPBACK_TRACE);
|
|
23
24
|
// Metro includes the runner but does not evaluate it on an ordinary launch.
|
|
24
25
|
const smoke = native?.witnessLaunch && JSON.parse(native.witnessLaunch).smoke === true
|
|
25
|
-
? (require("./smoke") as typeof import("./smoke")).createSmoke(native) : null;
|
|
26
|
+
? (require("./smoke") as typeof import("./smoke")).createSmoke(native, { runtime: witnessRuntime() }) : null;
|
|
26
27
|
|
|
27
28
|
export function nativeAvailable(): boolean {
|
|
28
29
|
return native !== null;
|
package/src/smoke.ts
CHANGED
|
@@ -8,15 +8,18 @@ type State = {
|
|
|
8
8
|
storeId: string; acquired: boolean; backendRequired: boolean;
|
|
9
9
|
};
|
|
10
10
|
type Backend = {
|
|
11
|
-
|
|
11
|
+
generation: number;
|
|
12
|
+
programs: { name: string; kind: string; native?: boolean; args: Record<string, unknown> }[];
|
|
12
13
|
schema: { operations?: Record<string, { runs?: "device" | "server"; held?: boolean }> };
|
|
13
14
|
};
|
|
15
|
+
type Fact = { runs: "device" | "server"; held: boolean };
|
|
14
16
|
type Baseline = State & { origin: string; viewer: string };
|
|
15
17
|
type ErrorUtils = {
|
|
16
18
|
getGlobalHandler(): (error: Error, fatal?: boolean) => void;
|
|
17
19
|
setGlobalHandler(handler: (error: Error, fatal?: boolean) => void): void;
|
|
18
20
|
};
|
|
19
21
|
type Environment = {
|
|
22
|
+
runtime?: Parameters<typeof createWitness>[1];
|
|
20
23
|
errors?: ErrorUtils;
|
|
21
24
|
now?: () => number;
|
|
22
25
|
pause?: () => Promise<void>;
|
|
@@ -31,7 +34,7 @@ export function createSmoke(container: WitnessContainer, environment: Environmen
|
|
|
31
34
|
const config = JSON.parse(container.witnessLaunch);
|
|
32
35
|
if (config.smoke !== true) return null;
|
|
33
36
|
assert(["prime", "offline", "restart"].includes(config.segment), "unknown smoke segment");
|
|
34
|
-
const w = createWitness(container)!;
|
|
37
|
+
const w = createWitness(container, environment.runtime)!;
|
|
35
38
|
const now = environment.now ?? Date.now;
|
|
36
39
|
const pause = environment.pause ?? (() => new Promise<void>(resolve => setTimeout(resolve, 100)));
|
|
37
40
|
const startedAt = now();
|
|
@@ -108,7 +111,7 @@ export function createSmoke(container: WitnessContainer, environment: Environmen
|
|
|
108
111
|
};
|
|
109
112
|
const receipt = (pass: boolean, error?: unknown) => w.receipt({ smoke: {
|
|
110
113
|
segment: w.segment, pass, startedAt, endedAt: now(), durationMs: now() - startedAt,
|
|
111
|
-
origin, viewer: client.viewer(), link: client.link(), ...state, queries, errors: [...errors],
|
|
114
|
+
origin, viewer: client.viewer(), link: client.link(), ...state, queryFacts: config.queryFacts, queryGeneration: config.queryGeneration, queries, errors: [...errors],
|
|
112
115
|
...(w.segment === "restart" ? { barrierSeq: config.barrierSeq } : {}),
|
|
113
116
|
...(error ? { error: error instanceof Error ? error.message : String(error) } : {}),
|
|
114
117
|
} });
|
|
@@ -116,24 +119,57 @@ export function createSmoke(container: WitnessContainer, environment: Environmen
|
|
|
116
119
|
assert(errorUtils, "the native JavaScript error handler is unavailable");
|
|
117
120
|
await wait(async () => { await snapshot(); return ready(); });
|
|
118
121
|
identity();
|
|
119
|
-
if (w.segment === "
|
|
120
|
-
const backend = await read<Backend>("backend");
|
|
121
|
-
for (const program of backend.programs) {
|
|
122
|
-
if (program.kind !== "Query" || !Object.values(program.args).every(type => type !== null && typeof type === "object" && "Optional" in type)) continue;
|
|
123
|
-
const fact = backend.schema.operations?.[program.name] ?? {};
|
|
124
|
-
const op = { kind: "query", name: program.name, ...(fact.runs ? { runs: fact.runs } : {}), ...(fact.held ? { held: true } : {}) } as Query<{}, unknown>;
|
|
125
|
-
const result = await client.query(op, {});
|
|
126
|
-
current();
|
|
127
|
-
if ("data" in result) {
|
|
128
|
-
queries.push({ name: program.name, state: "ready", count: Array.isArray(result.data) ? result.data.length : result.data == null ? 0 : 1,
|
|
129
|
-
complete: result.complete, fresh: result.fresh, cut: result.cut });
|
|
130
|
-
} else if ("denied" in result && result.denied.code && result.denied.message) {
|
|
131
|
-
queries.push({ name: program.name, state: "refused", code: result.denied.code, message: result.denied.message });
|
|
132
|
-
} else { throw new Error(`query ${program.name} returned neither ready nor an explained refusal`); }
|
|
133
|
-
}
|
|
134
|
-
} else if (w.segment === "restart") {
|
|
122
|
+
if (w.segment === "restart") {
|
|
135
123
|
await wait(async () => { await snapshot(); identity(); return ready() && barrier(); });
|
|
136
124
|
}
|
|
125
|
+
// @ref LLP 3000.011.005.000#2 — residency comes from compiler facts, never a loading result.
|
|
126
|
+
const facts = config.queryFacts as Record<string, Fact> | undefined;
|
|
127
|
+
assert(facts && typeof facts === "object" && !Array.isArray(facts), "missing compiler query facts");
|
|
128
|
+
const backend = await read<Backend>("backend");
|
|
129
|
+
assert(Number.isSafeInteger(config.queryGeneration) && config.queryGeneration > 0
|
|
130
|
+
&& backend.generation === config.queryGeneration && state!.generation === config.queryGeneration,
|
|
131
|
+
"compiler query facts do not match the kept backend generation");
|
|
132
|
+
const eligible = backend.programs.filter(program => program.kind === "Query"
|
|
133
|
+
&& Object.values(program.args).every(type => type !== null && typeof type === "object" && "Optional" in type));
|
|
134
|
+
assert(JSON.stringify(eligible.map(p => p.name).sort()) === JSON.stringify(Object.keys(facts).sort()),
|
|
135
|
+
"compiler query facts do not match the kept program");
|
|
136
|
+
assert(eligible.every(program => program.native !== true),
|
|
137
|
+
"native queries require an application witness; generic smoke cannot classify conditional module availability");
|
|
138
|
+
const validated = eligible.map(program => {
|
|
139
|
+
const fact = facts[program.name];
|
|
140
|
+
assert(fact && ["device", "server"].includes(fact.runs) && typeof fact.held === "boolean",
|
|
141
|
+
`missing compiler residency for ${program.name}`);
|
|
142
|
+
const kept = backend.schema.operations?.[program.name];
|
|
143
|
+
assert(kept && kept.runs === fact.runs && (kept.held ?? false) === fact.held,
|
|
144
|
+
`compiler residency differs from kept backend for ${program.name}`);
|
|
145
|
+
return { program, fact };
|
|
146
|
+
});
|
|
147
|
+
// Validate the entire supplied set before issuing even the first query.
|
|
148
|
+
for (const { program, fact } of validated) {
|
|
149
|
+
const classification = fact.runs === "server" ? "online-only" : "device-answerable";
|
|
150
|
+
if (w.segment !== "offline" && fact.runs !== "server") continue;
|
|
151
|
+
const op = { kind: "query", name: program.name, runs: fact.runs, ...(fact.held ? { held: true } : {}) } as Query<{}, unknown>;
|
|
152
|
+
const result = await client.query(op, {});
|
|
153
|
+
current();
|
|
154
|
+
const query: Record<string, unknown> = { name: program.name, classification };
|
|
155
|
+
queries.push(query);
|
|
156
|
+
if ("data" in result) {
|
|
157
|
+
Object.assign(query, { state: "ready", count: Array.isArray(result.data) ? result.data.length : result.data == null ? 0 : 1,
|
|
158
|
+
complete: result.complete, fresh: result.fresh, cut: result.cut });
|
|
159
|
+
} else if ("denied" in result && result.denied.code && result.denied.message) {
|
|
160
|
+
Object.assign(query, { state: "refused", code: result.denied.code, message: result.denied.message });
|
|
161
|
+
} else if ("loading" in result) { query.state = "loading"; }
|
|
162
|
+
else { throw new Error(`query ${program.name} returned an unknown result`); }
|
|
163
|
+
if (w.segment === "offline" && fact.runs === "server") {
|
|
164
|
+
assert(query.state === "loading" || (query.state === "refused" && /^E_OFFLINE(?:_|$)/.test(String(query.code))),
|
|
165
|
+
`online-only query ${program.name} did not report offline unavailability`);
|
|
166
|
+
query.expectation = "online-only: unavailable offline (expected)";
|
|
167
|
+
} else if (fact.runs === "server") {
|
|
168
|
+
assert(query.state === "ready", `online-only query ${program.name} did not answer online`);
|
|
169
|
+
} else {
|
|
170
|
+
assert(query.state === "ready" || query.state === "refused", `query ${program.name} returned neither ready nor an explained refusal`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
137
173
|
// Give the mounted app a turn, then attest the state that exists now.
|
|
138
174
|
await pause();
|
|
139
175
|
await snapshot();
|
package/src/witness-core.ts
CHANGED
|
@@ -10,16 +10,42 @@ export interface WitnessContainer {
|
|
|
10
10
|
readonly witnessLaunch: string | null;
|
|
11
11
|
witnessAppend(line: string): void;
|
|
12
12
|
witnessSignaled(label: string): boolean;
|
|
13
|
+
witnessObservation?(scriptURL: string | null): Record<string, unknown> | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function manifestPresent(value: unknown): boolean {
|
|
17
|
+
if (value == null || value === "") return false;
|
|
18
|
+
if (typeof value === "string") {
|
|
19
|
+
try { value = JSON.parse(value); } catch { return true; }
|
|
20
|
+
}
|
|
21
|
+
return value === null || typeof value !== "object" || Object.keys(value).length > 0;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
// @ref LLP 3000.007#20.10 — app-owned receipts, completed steps and container-file signals
|
|
16
|
-
export function createWitness(container: WitnessContainer | null): Witness | null {
|
|
25
|
+
export function createWitness(container: WitnessContainer | null, runtime?: { scriptURL: string | null; dev: boolean; hermes: boolean; launcherManifestPresent: boolean; updatesPresent: boolean; updatesManifestPresent: boolean }): Witness | null {
|
|
17
26
|
if (!container?.witnessLaunch) return null;
|
|
18
27
|
const { segment, timeoutMs } = JSON.parse(container.witnessLaunch);
|
|
19
28
|
if (typeof segment !== "string" || !segment || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
20
29
|
throw new Error("E_WITNESS_INPUT: invalid witness launch configuration");
|
|
21
30
|
}
|
|
22
31
|
const native = container;
|
|
32
|
+
if (JSON.parse(container.witnessLaunch).proof) {
|
|
33
|
+
try {
|
|
34
|
+
if (!runtime || runtime.launcherManifestPresent !== false || runtime.updatesPresent !== false || runtime.updatesManifestPresent !== false || runtime.dev !== false || runtime.hermes !== true || !runtime.scriptURL?.startsWith("file://")) {
|
|
35
|
+
throw new Error("E_WITNESS_PROOF: prepared launch requires a selected local bundle in Release Hermes with no launcher/updates manifest");
|
|
36
|
+
}
|
|
37
|
+
const startup = native.witnessObservation?.(runtime.scriptURL);
|
|
38
|
+
if (!startup) throw new Error("E_WITNESS_PROOF: rebuild the native witness helper");
|
|
39
|
+
native.witnessAppend(JSON.stringify({ startup: { ...startup,
|
|
40
|
+
hermes: runtime.hermes, dev: runtime.dev, launcherManifestPresent: runtime.launcherManifestPresent,
|
|
41
|
+
updatesPresent: runtime.updatesPresent, updatesManifestPresent: runtime.updatesManifestPresent },
|
|
42
|
+
witness: { at: new Date().toISOString(), step: "launched" } }) + "\n");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
native.witnessAppend(JSON.stringify({ error: String(error), witness: {
|
|
45
|
+
at: new Date().toISOString(), step: "launched:failed" } }) + "\n");
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
23
49
|
let current = "launched";
|
|
24
50
|
let active = false;
|
|
25
51
|
const append = (object: Record<string, unknown>, step: string) => {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NativeModules } from "react-native";
|
|
2
|
+
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
3
|
+
import { manifestPresent } from "./witness-core";
|
|
4
|
+
|
|
5
|
+
/** SourceCode is React Native's native observation of the selected bundle. */
|
|
6
|
+
export function witnessRuntime() {
|
|
7
|
+
const source = NativeModules.SourceCode;
|
|
8
|
+
const updates = requireOptionalNativeModule<Record<string, unknown>>("ExpoUpdates");
|
|
9
|
+
return { scriptURL: source?.getConstants?.().scriptURL ?? source?.scriptURL ?? null,
|
|
10
|
+
dev: (globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__ !== false,
|
|
11
|
+
hermes: !!(globalThis as typeof globalThis & { HermesInternal?: unknown }).HermesInternal,
|
|
12
|
+
launcherManifestPresent: manifestPresent(NativeModules.EXDevLauncher?.manifestString),
|
|
13
|
+
updatesPresent: updates !== null,
|
|
14
|
+
updatesManifestPresent: manifestPresent(updates?.manifest) || manifestPresent(updates?.manifestString) };
|
|
15
|
+
}
|
package/src/witness.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { requireOptionalNativeModule } from "expo-modules-core";
|
|
|
2
2
|
import { createWitness, type Witness, type WitnessContainer } from "./witness-core";
|
|
3
3
|
export type { Witness } from "./witness-core";
|
|
4
4
|
|
|
5
|
+
import { witnessRuntime } from "./witness-runtime";
|
|
6
|
+
|
|
5
7
|
let instance: Witness | null | undefined;
|
|
6
8
|
/** Null on ordinary launches and outside an iOS simulator driven by `snapback4 witness ios`. */
|
|
7
9
|
export function witness(): Witness | null {
|
|
8
10
|
if (instance === undefined) {
|
|
9
11
|
const container = requireOptionalNativeModule<WitnessContainer>("Snapback4");
|
|
10
|
-
instance = container?.witnessLaunch && JSON.parse(container.witnessLaunch).smoke === true ? null : createWitness(container);
|
|
12
|
+
instance = container?.witnessLaunch && JSON.parse(container.witnessLaunch).smoke === true ? null : createWitness(container, witnessRuntime());
|
|
11
13
|
}
|
|
12
14
|
return instance;
|
|
13
15
|
}
|