Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/wasm/cloud.rs

8.0 KiB, 1 run

created by r2519314175:975, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Cloud storage edge — the part of the workspace that is not on this device.
2//!
3//! The user's model is that the workspace is one set of files and the device holds as much of it
4//! as it can. What the device cannot hold lives in cloud storage, and the agent must be able to
5//! see that such a file exists, be told plainly when it asks for one, and fetch it deliberately.
6//!
7//! Three things on the JavaScript side make that possible, and this module is the only place that
8//! knows about them:
9//!
10//! - `localStorage["daimond-cloud-paths"]` — a JSON object mapping path to byte size, listing
11//! every path that is in cloud storage and **not** on this device right now.
12//! - `window.__daimondCloudFetch(path)` — brings one file down into OPFS.
13//! - `window.__daimondCloudForget(path)` — drops one path from the index, so its bytes are
14//! eventually reclaimed.
15//!
16//! A fetch can be large and will cost the user money, so nothing here is ever called on the
17//! agent's behalf: the index is *read* freely, and the bytes move only when `file_fetch` says so.
18
19use crate::tools::normalise;
20use crate::wasm::js_str;
21
22use oxedyne_fe2o3_core::prelude::*;
23
24use wasm_bindgen::JsCast;
25use wasm_bindgen::JsValue;
26use wasm_bindgen_futures::JsFuture;
27
28
29/// The `localStorage` key holding the paths that are in cloud storage and not on this device.
30pub const INDEX_KEY: &str = "daimond-cloud-paths";
31
32/// The JS global that brings one file down from cloud storage into OPFS.
33const FETCH_FN: &str = "__daimondCloudFetch";
34
35/// The JS global that drops one path from the cloud index.
36const FORGET_FN: &str = "__daimondCloudForget";
37
38/// Every path in cloud storage that is not on this device, with its size in bytes.
39///
40/// The key may be absent, empty or malformed, and each of those means the same thing here:
41/// nothing is in cloud storage. A workspace the user can still see must never fail to list
42/// merely because a cache entry was garbled, so this returns a list and never an error.
43pub fn index() -> Vec<(String, u64)> {
44 let raw = match raw_index() {
45 Some(s) => s,
46 None => return Vec::new(),
47 };
48 if raw.trim().is_empty() {
49 return Vec::new();
50 }
51 let parsed = match js_sys::JSON::parse(&raw) {
52 Ok(v) => v,
53 Err(_) => return Vec::new(), // malformed: treat as empty, never fail the listing
54 };
55 let obj: js_sys::Object = match parsed.dyn_into() {
56 Ok(o) => o,
57 Err(_) => return Vec::new(),
58 };
59 let mut out: Vec<(String, u64)> = Vec::new();
60 let entries = js_sys::Object::entries(&obj);
61 for i in 0..entries.length() {
62 let pair = js_sys::Array::from(&entries.get(i));
63 let path = match pair.get(0).as_string() {
64 Some(p) => p,
65 None => continue,
66 };
67 if path.trim().is_empty() {
68 continue;
69 }
70 // A size that is not a number is not a reason to hide the file; it is a reason to say
71 // nothing about how big it is.
72 let size = pair.get(1).as_f64().unwrap_or(0.0);
73 let size = if size.is_finite() && size > 0.0 { size as u64 } else { 0 };
74 out.push((path, size));
75 }
76 out
77}
78
79/// The raw index string from `localStorage`, or `None` when there is no storage to read.
80fn raw_index() -> Option<String> {
81 let win = match web_sys::window() {
82 Some(w) => w,
83 None => return None,
84 };
85 match win.local_storage() {
86 Ok(Some(store)) => store.get_item(INDEX_KEY).ok().flatten(),
87 _ => None, // no storage, or a browser that refuses it (Private Browsing)
88 }
89}
90
91/// The size in bytes of `path` when it is in cloud storage and not on this device, else `None`.
92///
93/// Both sides are normalised before comparison, so one path is not several ways past the lookup.
94///
95/// # Arguments
96/// * `path` - The workspace-relative path a tool is asking about.
97pub fn size_of(path: &str) -> Option<u64> {
98 let want = normalise(path);
99 if want.is_empty() {
100 return None;
101 }
102 index().into_iter()
103 .find(|(p, _)| normalise(p) == want)
104 .map(|(_, size)| size)
105}
106
107/// The cloud-only entries that are direct children of `dir`, as `(name, is_dir, size)`.
108///
109/// A cloud-only path nested deeper implies its intermediate directory exists: if `archive/old.md`
110/// is in cloud storage and `archive/` is nowhere on this device, `archive/` is still part of the
111/// workspace and must appear in its parent's listing. Such a directory reports no size, because
112/// the only honest size for it is the one the listing does not have.
113///
114/// # Arguments
115/// * `dir` - The workspace-relative directory being listed; empty or `.` is the root.
116pub fn children_of(dir: &str) -> Vec<(String, bool, u64)> {
117 let base = normalise(dir);
118 let mut out: Vec<(String, bool, u64)> = Vec::new();
119 for (path, size) in index() {
120 let norm = normalise(&path);
121 let rel = if base.is_empty() {
122 norm.clone()
123 } else {
124 match norm.strip_prefix(&fmt!("{}/", base)) {
125 Some(r) => r.to_string(),
126 None => continue,
127 }
128 };
129 if rel.is_empty() {
130 continue;
131 }
132 match rel.split_once('/') {
133 // Nested deeper: only the intermediate directory belongs in this listing.
134 Some((head, _)) => {
135 if !out.iter().any(|(n, is_dir, _)| n == head && *is_dir) {
136 out.push((head.to_string(), true, 0));
137 }
138 }
139 None => out.push((rel, false, size)),
140 }
141 }
142 out
143}
144
145/// Bring `path` down from cloud storage onto this device, returning what the JS side reports.
146///
147/// The result string starts with `OK` on success and `Error` on failure, and is passed back to
148/// the model as it stands.
149///
150/// # Arguments
151/// * `path` - The workspace-relative path to download.
152pub async fn fetch(path: &str) -> Outcome<String> {
153 call(FETCH_FN, path).await
154}
155
156/// Drop `path` from the cloud index, so its bytes are eventually reclaimed.
157///
158/// Absent from this device means "not here"; absent from the index means "gone". This is the
159/// second of those, and only an explicit delete ever reaches it. When the JS side has not loaded
160/// there is no index to drop anything from, so this answers with an empty string rather than an
161/// error: a delete that worked must not be reported as a failure.
162///
163/// # Arguments
164/// * `path` - The workspace-relative path to forget.
165pub async fn forget(path: &str) -> Outcome<String> {
166 if global(FORGET_FN).is_err() {
167 return Ok(String::new());
168 }
169 call(FORGET_FN, path).await
170}
171
172/// Reach a cloud global on `window`, or refuse in the model's language.
173fn global(name: &str) -> Outcome<(web_sys::Window, js_sys::Function)> {
174 let win = res!(web_sys::window()
175 .ok_or_else(|| err!("Cloud storage needs a browser window."; System, Missing)));
176 let val = res!(js_sys::Reflect::get(&win, &JsValue::from_str(name))
177 .map_err(|e| err!("Reading window.{} failed: {}.", name, js_str(&e); System, Missing)));
178 if !val.is_function() {
179 return Err(err!(
180 "Cloud storage is not loaded in this page, so there is nothing to fetch from. \
181 Tell the user, and carry on with the files that are on this device.";
182 System, Missing));
183 }
184 let f = res!(val.dyn_into::<js_sys::Function>()
185 .map_err(|_| err!("window.{} is not callable.", name; System, Invalid)));
186 Ok((win, f))
187}
188
189/// Call a one-argument cloud global and await the promise it returns.
190async fn call(name: &str, arg: &str) -> Outcome<String> {
191 let (win, f) = res!(global(name));
192 let ret = res!(f.call1(win.as_ref(), &JsValue::from_str(arg))
193 .map_err(|e| err!("{} failed: {}.", name, js_str(&e); IO, Network)));
194 let promise: js_sys::Promise = res!(ret.dyn_into()
195 .map_err(|_| err!("window.{} did not return a promise.", name; Invalid, Output)));
196 let val = res!(JsFuture::from(promise).await
197 .map_err(|e| err!("{} failed: {}.", name, js_str(&e); IO, Network)));
198 Ok(val.as_string().unwrap_or_else(|| js_str(&val)))
199}