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 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 { if (created && !publicationAttempted) fs.rmSync(artifacts, { recursive: true, force: true }); }
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-simulator</string>
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
@@ -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
@@ -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.15",
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
+ }