snapback4-expo 0.4.16 → 0.4.26
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 +4 -0
- package/ios/SavedAsset.swift +31 -0
- package/ios/Snapback4Device.xcframework/ios-arm64/Headers/snapback4.h +7 -0
- package/ios/Snapback4Device.xcframework/ios-arm64/libsnapback4_device.a +0 -0
- package/ios/Snapback4Device.xcframework/ios-arm64-simulator/Headers/snapback4.h +7 -0
- package/ios/Snapback4Device.xcframework/ios-arm64-simulator/libsnapback4_device.a +0 -0
- package/ios/Snapback4Module.swift +28 -0
- package/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/save-asset.ts +25 -0
- package/src/witness.ts +8 -0
package/README.md
CHANGED
|
@@ -102,3 +102,7 @@ app and install the exact exported bundle/assets. Native changes rebuild;
|
|
|
102
102
|
`npm run native:smoke` always runs Xcode. Both routes retain the same loaded-file
|
|
103
103
|
hash and prime/offline/restart proof. Automatic smoke creates and removes its own
|
|
104
104
|
recorded simulator, with project and device leases held through cleanup.
|
|
105
|
+
|
|
106
|
+
For durable offline media, use the local client's `uploadAndPlace` or `useUpload`
|
|
107
|
+
(re-exported here). `saveAsset(client, id, name?)` presents the iOS save/share sheet
|
|
108
|
+
from authorized or retained bytes. See [the shared upload contract](../snapback4/OFFLINE-UPLOADS.md).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// A task-owned save copy; no network fetch, token, or original cache file escapes to the sheet.
|
|
4
|
+
final class SavedAsset {
|
|
5
|
+
let directory: URL
|
|
6
|
+
let url: URL
|
|
7
|
+
init(hex: String, name: String) throws {
|
|
8
|
+
guard !name.isEmpty, name != ".", name != "..", !name.contains("/"), !name.contains("\\"),
|
|
9
|
+
!name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }),
|
|
10
|
+
hex.utf8.count <= 128 << 20, hex.utf8.count % 2 == 0 else {
|
|
11
|
+
throw NSError(domain: "Snapback4", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid save bytes or filename"])
|
|
12
|
+
}
|
|
13
|
+
let chars = Array(hex.utf8)
|
|
14
|
+
var data = Data(capacity: chars.count / 2)
|
|
15
|
+
for i in stride(from: 0, to: chars.count, by: 2) {
|
|
16
|
+
guard let byte = UInt8(String(bytes: chars[i...i+1], encoding: .utf8) ?? "", radix: 16) else {
|
|
17
|
+
throw NSError(domain: "Snapback4", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid save bytes"])
|
|
18
|
+
}
|
|
19
|
+
data.append(byte)
|
|
20
|
+
}
|
|
21
|
+
directory = FileManager.default.temporaryDirectory.appendingPathComponent("snapback4-save-" + UUID().uuidString, isDirectory: true)
|
|
22
|
+
url = directory.appendingPathComponent(name)
|
|
23
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
|
|
24
|
+
do {
|
|
25
|
+
try data.write(to: url, options: .atomic)
|
|
26
|
+
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
27
|
+
} catch { try? FileManager.default.removeItem(at: directory); throw error }
|
|
28
|
+
}
|
|
29
|
+
func remove() { try? FileManager.default.removeItem(at: directory) }
|
|
30
|
+
deinit { remove() }
|
|
31
|
+
}
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* panic=abort and terminates before unwinding. Every answer is JSON the
|
|
15
15
|
* caller frees. A device is not thread-safe: one caller at a time, as the
|
|
16
16
|
* store is one transaction at a time.
|
|
17
|
+
* A `query` answer is not always the device's to give: `unknown` without
|
|
18
|
+
* `retained` means ask the server (POST /q/<op>), and `speculative` data is a
|
|
19
|
+
* placeholder. The reply contract is "The device reply" in the snapback4
|
|
20
|
+
* package's CLIENT-AND-OPERATIONS.md.
|
|
17
21
|
* `apply.first` must be true on the first page of a snapshot. It defaults
|
|
18
22
|
* to false so an omitted marker cannot discard a snapshot already staged.
|
|
19
23
|
*/
|
|
@@ -24,6 +28,9 @@
|
|
|
24
28
|
extern "C" {
|
|
25
29
|
#endif
|
|
26
30
|
|
|
31
|
+
/* Engine identity without opening a store; free the JSON with sb4_free. */
|
|
32
|
+
char *sb4_engine_info(void);
|
|
33
|
+
|
|
27
34
|
typedef struct sb4_device sb4_device;
|
|
28
35
|
|
|
29
36
|
/* Backend may be GET /schema JSON, null for the kept backend, or
|
|
Binary file
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* panic=abort and terminates before unwinding. Every answer is JSON the
|
|
15
15
|
* caller frees. A device is not thread-safe: one caller at a time, as the
|
|
16
16
|
* store is one transaction at a time.
|
|
17
|
+
* A `query` answer is not always the device's to give: `unknown` without
|
|
18
|
+
* `retained` means ask the server (POST /q/<op>), and `speculative` data is a
|
|
19
|
+
* placeholder. The reply contract is "The device reply" in the snapback4
|
|
20
|
+
* package's CLIENT-AND-OPERATIONS.md.
|
|
17
21
|
* `apply.first` must be true on the first page of a snapshot. It defaults
|
|
18
22
|
* to false so an omitted marker cannot discard a snapshot already staged.
|
|
19
23
|
*/
|
|
@@ -24,6 +28,9 @@
|
|
|
24
28
|
extern "C" {
|
|
25
29
|
#endif
|
|
26
30
|
|
|
31
|
+
/* Engine identity without opening a store; free the JSON with sb4_free. */
|
|
32
|
+
char *sb4_engine_info(void);
|
|
33
|
+
|
|
27
34
|
typedef struct sb4_device sb4_device;
|
|
28
35
|
|
|
29
36
|
/* Backend may be GET /schema JSON, null for the kept backend, or
|
|
Binary file
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
2
3
|
import Snapback4Device
|
|
3
4
|
|
|
4
5
|
// The device in Rust, opened once per store and reached through one JSON
|
|
@@ -12,6 +13,11 @@ public final class Snapback4Module: Module {
|
|
|
12
13
|
|
|
13
14
|
public func definition() -> ModuleDefinition {
|
|
14
15
|
Name("Snapback4")
|
|
16
|
+
Function("engineInfo") { () -> String in
|
|
17
|
+
guard let stamp = sb4_engine_info() else { return "{}" }
|
|
18
|
+
defer { sb4_free(stamp) }
|
|
19
|
+
return String(cString: stamp)
|
|
20
|
+
}
|
|
15
21
|
|
|
16
22
|
Constant("witnessLaunch") { WitnessContainer.launch }
|
|
17
23
|
Function("witnessObservation") { (scriptURL: String?) -> [String: Any]? in try WitnessContainer.observation(scriptURL) }
|
|
@@ -70,6 +76,28 @@ public final class Snapback4Module: Module {
|
|
|
70
76
|
try self.withMedia(handle) { try $0.uri(id) }
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
// @ref LLP 3000.038.000#4 — saving is a user action over authorized bytes, with private temporary custody.
|
|
80
|
+
AsyncFunction("saveAsset") { (hex: String, name: String, promise: Promise) in
|
|
81
|
+
do {
|
|
82
|
+
let temporary = try SavedAsset(hex: hex, name: name)
|
|
83
|
+
guard let root = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene })
|
|
84
|
+
.flatMap({ $0.windows }).first(where: { $0.isKeyWindow })?.rootViewController else {
|
|
85
|
+
temporary.remove()
|
|
86
|
+
throw Snapback4OpenException("no view can present the save sheet")
|
|
87
|
+
}
|
|
88
|
+
var presenter = root
|
|
89
|
+
while let next = presenter.presentedViewController { presenter = next }
|
|
90
|
+
let sheet = UIActivityViewController(activityItems: [temporary.url], applicationActivities: nil)
|
|
91
|
+
sheet.popoverPresentationController?.sourceView = presenter.view
|
|
92
|
+
sheet.popoverPresentationController?.sourceRect = CGRect(x: presenter.view.bounds.midX, y: presenter.view.bounds.midY, width: 1, height: 1)
|
|
93
|
+
sheet.completionWithItemsHandler = { _, completed, _, error in
|
|
94
|
+
temporary.remove()
|
|
95
|
+
if let error { promise.reject(error) } else { promise.resolve(completed) }
|
|
96
|
+
}
|
|
97
|
+
presenter.present(sheet, animated: true)
|
|
98
|
+
} catch { promise.reject(error) }
|
|
99
|
+
}.runOnQueue(.main)
|
|
100
|
+
|
|
73
101
|
Function("close") { (handle: Int) in
|
|
74
102
|
self.lock.lock()
|
|
75
103
|
defer { self.lock.unlock() }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapback4-expo",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.26",
|
|
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
|
@@ -8,7 +8,9 @@ import { witnessRuntime } from "./witness-runtime";
|
|
|
8
8
|
import type { WitnessContainer } from "./witness-core";
|
|
9
9
|
|
|
10
10
|
interface Snapback4Native extends WitnessContainer {
|
|
11
|
+
saveAsset(hex: string, name: string): Promise<boolean>;
|
|
11
12
|
readonly documentDirectory: string;
|
|
13
|
+
engineInfo?(): string;
|
|
12
14
|
open(path: string, backend: string | null): Promise<number>;
|
|
13
15
|
call(handle: number, request: string): Promise<string>;
|
|
14
16
|
close(handle: number): void;
|
|
@@ -36,6 +38,7 @@ export function documentDirectory(): string {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export const nativeDevice: DeviceOpener = {
|
|
41
|
+
async engineInfo() { try { if (native?.engineInfo) return JSON.parse(native.engineInfo()); } catch {} return { format: 0, version: "unknown (unstamped)", capabilities: [] }; },
|
|
39
42
|
async open(name, backend): Promise<DeviceCore> {
|
|
40
43
|
if (!native) throw new Error("snapback4-expo: the native module is not in this build (Expo Go loads no custom native code; make a development build)");
|
|
41
44
|
const handle = await native.open(`${native.documentDirectory.replace(/\/$/, "")}/${name}.sqlite`, backend ? JSON.stringify(backend) : null);
|
|
@@ -60,3 +63,12 @@ export const nativeDevice: DeviceOpener = {
|
|
|
60
63
|
return core;
|
|
61
64
|
},
|
|
62
65
|
};
|
|
66
|
+
|
|
67
|
+
import { saveWithHost } from "./save-asset";
|
|
68
|
+
import type { Client } from "snapback4/contract";
|
|
69
|
+
/** Presents the native save/share sheet; cancellation is a typed refusal. */
|
|
70
|
+
export function saveAsset(client: Pick<Client, "asset">, id: string, name?: string) {
|
|
71
|
+
return saveWithHost(client, id, name, native ? (hex, filename) => native.saveAsset(hex, filename) : undefined);
|
|
72
|
+
}
|
|
73
|
+
export { uploadAndPlace, type UploadIntent, type UploadAndPlaceOptions } from "snapback4/local";
|
|
74
|
+
export { useUpload } from "snapback4/react";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Client } from "snapback4/contract";
|
|
2
|
+
import type { Saved } from "snapback4/react";
|
|
3
|
+
import { blobArrayBuffer } from "snapback4/local";
|
|
4
|
+
|
|
5
|
+
/** Save/share only bytes already authorized by client.asset, including retained offline files. */
|
|
6
|
+
export async function saveWithHost(client: Pick<Client, "asset">, id: string, name: string | undefined,
|
|
7
|
+
present: ((hex: string, name: string) => Promise<boolean>) | undefined, read: typeof fetch = fetch): Promise<Saved> {
|
|
8
|
+
if (!present) return { ok: false, why: { code: "E_CLIENT", family: "client", message: "saveAsset needs the Snapback native module in a development or production build" } };
|
|
9
|
+
let revoke: (() => void) | undefined;
|
|
10
|
+
try {
|
|
11
|
+
const asset = await client.asset(id);
|
|
12
|
+
if ("denied" in asset) return { ok: false, why: asset.denied };
|
|
13
|
+
revoke = () => asset.revoke();
|
|
14
|
+
const selected = name ?? asset.name ?? "file";
|
|
15
|
+
// Keep a single safe leaf. The native host independently validates it.
|
|
16
|
+
const filename = selected.split(/[\\/]/).pop()!.replace(/[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, "").trim().replace(/^\.+/, "").slice(0, 128) || "file";
|
|
17
|
+
if (!/^(file:|blob:|data:)/.test(asset.url)) throw new Error("asset handle is not a local byte source");
|
|
18
|
+
const response = await read(asset.url);
|
|
19
|
+
const bytes = new Uint8Array(await blobArrayBuffer(await response.blob()));
|
|
20
|
+
const completed = await present(Array.from(bytes, b => b.toString(16).padStart(2, "0")).join(""), filename);
|
|
21
|
+
return completed ? { ok: true, name: filename } : { ok: false, why: { code: "E_CANCELLED", family: "client", message: "the save sheet was cancelled" } };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return { ok: false, why: { code: "E_CLIENT", family: "client", message: `the asset could not be saved: ${String(error)}` } };
|
|
24
|
+
} finally { revoke?.(); }
|
|
25
|
+
}
|
package/src/witness.ts
CHANGED
|
@@ -29,3 +29,11 @@ export function smokeOrigin(fallback: string): string {
|
|
|
29
29
|
const config = JSON.parse(container.witnessLaunch);
|
|
30
30
|
return config.smoke === true && /^http:\/\/127\.0\.0\.1:[0-9]+$/.test(config.origin) ? config.origin : fallback;
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** Disposable account supplied only by the owned prime smoke launch. */
|
|
34
|
+
export function smokeAccount(): { email: string; password: string } | null {
|
|
35
|
+
const container = requireOptionalNativeModule<WitnessContainer>("Snapback4");
|
|
36
|
+
if (!container?.witnessLaunch) return null;
|
|
37
|
+
const config = JSON.parse(container.witnessLaunch);
|
|
38
|
+
return config.smoke === true && config.segment === "prime" && typeof config.smokeAccount?.email === "string" && typeof config.smokeAccount?.password === "string" ? config.smokeAccount : null;
|
|
39
|
+
}
|