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 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
@@ -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.16",
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
+ }