snapback4-expo 0.4.15 → 0.4.25
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/export-ios.mjs +13 -2
- package/ios/SavedAsset.swift +31 -0
- package/ios/Snapback4Device.xcframework/Info.plist +4 -4
- 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/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).
|
package/export-ios.mjs
CHANGED
|
@@ -138,11 +138,22 @@ export async function main() {
|
|
|
138
138
|
} finally {
|
|
139
139
|
// After a publication attempt this path may be the displaced last good
|
|
140
140
|
// export. Only the publisher, under its destination lock, may remove it.
|
|
141
|
-
try {
|
|
141
|
+
try {
|
|
142
|
+
if (created && !publicationAttempted) {
|
|
143
|
+
// Keep custody paths and the live wrapper journal while deleting bulk
|
|
144
|
+
// output. Other exports never wait for that I/O on initialization.lock.
|
|
145
|
+
for (const name of fs.readdirSync(artifacts)) {
|
|
146
|
+
if (!['run.lock', 'export.lock', 'live.json'].includes(name)) fs.rmSync(path.join(artifacts, name), { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
// @ref LLP 3000.030#15 — final removal shares shell-repair custody.
|
|
149
|
+
// A worker cannot recreate a lease file after rm's final enumeration.
|
|
150
|
+
initialization ??= acquireExportLease(root, 'snapback4', true);
|
|
151
|
+
fs.rmSync(artifacts, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
142
154
|
finally {
|
|
143
155
|
try {
|
|
144
156
|
if (lease !== undefined) fs.closeSync(lease);
|
|
145
|
-
else if (created) fs.rmSync(artifacts, { recursive: true, force: true });
|
|
146
157
|
} finally { if (initialization !== undefined) fs.closeSync(initialization); }
|
|
147
158
|
}
|
|
148
159
|
}
|
|
@@ -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
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<key>HeadersPath</key>
|
|
11
11
|
<string>Headers</string>
|
|
12
12
|
<key>LibraryIdentifier</key>
|
|
13
|
-
<string>ios-arm64</string>
|
|
13
|
+
<string>ios-arm64-simulator</string>
|
|
14
14
|
<key>LibraryPath</key>
|
|
15
15
|
<string>libsnapback4_device.a</string>
|
|
16
16
|
<key>SupportedArchitectures</key>
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
</array>
|
|
20
20
|
<key>SupportedPlatform</key>
|
|
21
21
|
<string>ios</string>
|
|
22
|
+
<key>SupportedPlatformVariant</key>
|
|
23
|
+
<string>simulator</string>
|
|
22
24
|
</dict>
|
|
23
25
|
<dict>
|
|
24
26
|
<key>BinaryPath</key>
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
<key>HeadersPath</key>
|
|
27
29
|
<string>Headers</string>
|
|
28
30
|
<key>LibraryIdentifier</key>
|
|
29
|
-
<string>ios-arm64
|
|
31
|
+
<string>ios-arm64</string>
|
|
30
32
|
<key>LibraryPath</key>
|
|
31
33
|
<string>libsnapback4_device.a</string>
|
|
32
34
|
<key>SupportedArchitectures</key>
|
|
@@ -35,8 +37,6 @@
|
|
|
35
37
|
</array>
|
|
36
38
|
<key>SupportedPlatform</key>
|
|
37
39
|
<string>ios</string>
|
|
38
|
-
<key>SupportedPlatformVariant</key>
|
|
39
|
-
<string>simulator</string>
|
|
40
40
|
</dict>
|
|
41
41
|
</array>
|
|
42
42
|
<key>CFBundlePackageType</key>
|
|
@@ -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.25",
|
|
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
|
+
}
|