From e28afa5b136eb1504cecd8f930e66c3ca0f2ee4d Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:00:42 +0400 Subject: [PATCH] feat: IndexedDB transfer --- _typos.toml | 2 + src-tauri/src/indexed_db.rs | 591 +++++++++++++++++++++++++++++++++ src-tauri/src/local_storage.rs | 119 ++++++- src-tauri/src/wayfern_cdp.rs | 10 + 4 files changed, 714 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/indexed_db.rs diff --git a/_typos.toml b/_typos.toml index cb55a987..fc71a39a 100644 --- a/_typos.toml +++ b/_typos.toml @@ -18,6 +18,8 @@ extend-exclude = [ caf = "caf" DBE = "DBE" nd = "nd" +# Playwright's code for a BigUint64Array in an encoded IndexedDB value. +bui = "bui" [default.extend-identifiers] # Chrome Web Store extension name in the known-VPN list. diff --git a/src-tauri/src/indexed_db.rs b/src-tauri/src/indexed_db.rs new file mode 100644 index 00000000..b76dd519 --- /dev/null +++ b/src-tauri/src/indexed_db.rs @@ -0,0 +1,591 @@ +//! IndexedDB in storage-state files, in the shape Playwright reads and writes +//! (`origins[].indexedDB`). +//! +//! IndexedDB on disk is a LevelDB of V8-serialized values that only the +//! browser can read back faithfully, so this module asks the browser: it opens +//! the closed profile headless for a moment, loads a blank page of each origin +//! that the browser itself answers (`Fetch.fulfillRequest`, so no request +//! leaves the computer and no site code runs), and reads or writes the +//! databases there with the page's own IndexedDB API. + +use std::path::Path; +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::profile::BrowserProfile; +use crate::wayfern_cdp::WayfernSession; + +/// The path of the blank page the browser answers itself. +const BLANK_PATH: &str = "/__donut_storage__"; +/// The most a whole export may hold, so the file it lands in can be imported. +const MAX_EXPORT_CHARS: usize = 24 * 1024 * 1024; +const STEP_TIMEOUT: Duration = Duration::from_secs(15); +const SCRIPT_TIMEOUT: Duration = Duration::from_secs(120); + +fn code(code: &str) -> String { + crate::backend_error(code) +} + +/// The origins with IndexedDB data in a profile directory: each origin's +/// folder is named `__`, with `.indexeddb.leveldb` after it +/// on older browsers, port 0 being the scheme's default. +pub fn origins_on_disk(profile_data: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(profile_data.join("Default").join("IndexedDB")) else { + return Vec::new(); + }; + let mut origins: Vec = entries + .flatten() + .filter_map(|entry| origin_of_folder(&entry.file_name().to_string_lossy())) + .collect(); + origins.sort(); + origins.dedup(); + origins +} + +fn origin_of_folder(name: &str) -> Option { + // Older browsers name the folder `.indexeddb.leveldb`; 152 names it + // after the origin alone and keeps one folder per database inside. + if name.contains(".indexeddb.") && !name.ends_with(".indexeddb.leveldb") { + return None; + } + let stem = name.strip_suffix(".indexeddb.leveldb").unwrap_or(name); + let (scheme, rest) = stem.split_once('_')?; + if scheme != "https" && scheme != "http" { + return None; + } + let (host, port) = rest.rsplit_once('_')?; + let port: u16 = port.parse().ok()?; + if host.is_empty() + || !host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') + { + return None; + } + Some(if port == 0 { + format!("{scheme}://{host}") + } else { + format!("{scheme}://{host}:{port}") + }) +} + +/// The serializer both scripts share, compatible with the encoded values in +/// Playwright's storage state (`keyEncoded`, `valueEncoded`). +const CODEC_JS: &str = r#" +const KINDS = { Int8Array: 'i8', Uint8Array: 'ui8', Uint8ClampedArray: 'ui8c', Int16Array: 'i16', + Uint16Array: 'ui16', Int32Array: 'i32', Uint32Array: 'ui32', Float32Array: 'f32', + Float64Array: 'f64', BigInt64Array: 'bi64', BigUint64Array: 'bui64' }; +const CTORS = Object.fromEntries(Object.entries(KINDS).map(([name, kind]) => [kind, globalThis[name]])); +class Unsupported extends Error {} +const plainProto = (v) => { const p = Object.getPrototypeOf(v); return p === Object.prototype || p === null; }; +const isPlain = (v, seen = new Set()) => { + if (v === null || typeof v === 'string' || typeof v === 'boolean') return true; + if (typeof v === 'number') return Number.isFinite(v) && !Object.is(v, -0); + if (typeof v !== 'object' || seen.has(v)) return false; + seen.add(v); + if (Array.isArray(v)) return v.every((item) => isPlain(item, seen)); + if (!plainProto(v)) return false; + return Object.keys(v).every((key) => v[key] !== undefined && isPlain(v[key], seen)); +}; +const toBase64 = (view) => { + const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + let text = ''; + for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(text); +}; +const encode = (value) => { + const ids = new Map(); + let last = 0; + const walk = (v) => { + if (v === undefined) return { v: 'undefined' }; + if (v === null) return { v: 'null' }; + if (typeof v === 'number') { + if (Number.isNaN(v)) return { v: 'NaN' }; + if (v === Infinity) return { v: 'Infinity' }; + if (v === -Infinity) return { v: '-Infinity' }; + if (Object.is(v, -0)) return { v: '-0' }; + return v; + } + if (typeof v === 'boolean' || typeof v === 'string') return v; + if (typeof v === 'bigint') return { bi: v.toString() }; + if (typeof v !== 'object') throw new Unsupported(); + if (v instanceof Date) return { d: v.toJSON() }; + if (v instanceof URL) return { u: v.toJSON() }; + if (v instanceof RegExp) return { r: { p: v.source, f: v.flags } }; + if (v instanceof Error) return { e: { n: v.name, m: v.message, s: v.stack } }; + if (ArrayBuffer.isView(v) && KINDS[v.constructor.name]) { + return { ta: { b: toBase64(v), k: KINDS[v.constructor.name] } }; + } + if (ids.has(v)) return { ref: ids.get(v) }; + if (!Array.isArray(v) && !plainProto(v)) throw new Unsupported(); + const id = ++last; + ids.set(v, id); + if (Array.isArray(v)) return { a: v.map(walk), id }; + return { o: Object.keys(v).map((k) => ({ k, v: walk(v[k]) })), id }; + }; + return walk(value); +}; +const decode = (value) => { + const refs = new Map(); + const walk = (x) => { + if (x === null || typeof x !== 'object') return x; + if ('v' in x) return { undefined: undefined, null: null, NaN: NaN, Infinity: Infinity, + '-Infinity': -Infinity, '-0': -0 }[x.v]; + if ('bi' in x) return BigInt(x.bi); + if ('d' in x) return new Date(x.d); + if ('u' in x) return new URL(x.u); + if ('r' in x) return new RegExp(x.r.p, x.r.f); + if ('e' in x) { const e = new Error(x.e.m); e.name = x.e.n; e.stack = x.e.s; return e; } + if ('ta' in x) { + const text = atob(x.ta.b); + const bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i); + const Ctor = CTORS[x.ta.k]; + if (!Ctor) throw new Error('unknown typed array ' + x.ta.k); + return new Ctor(bytes.buffer); + } + if ('ref' in x) return refs.get(x.ref); + if ('a' in x) { const out = []; refs.set(x.id, out); for (const item of x.a) out.push(walk(item)); return out; } + if ('o' in x) { const out = {}; refs.set(x.id, out); for (const { k, v } of x.o) out[k] = walk(v); return out; } + throw new Error('unknown encoded value'); + }; + return walk(value); +}; +const request = (req) => new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); +}); +const keyPathOf = (keyPath) => typeof keyPath === 'string' ? { keyPath } + : Array.isArray(keyPath) ? { keyPathArray: [...keyPath] } : {}; +"#; + +/// Every database of the page's origin, Playwright's shape. Records the +/// format cannot carry (a Blob, a Map) are counted in `skipped`, not lost +/// silently. +const EXPORT_JS: &str = r#" +(async () => { + const LIMIT = __LIMIT__; + let size = 0; + let skipped = 0; + const databases = []; + for (const info of await indexedDB.databases()) { + if (!info.name) continue; + const db = await request(indexedDB.open(info.name)); + const stores = []; + for (const name of Array.from(db.objectStoreNames)) { + const store = db.transaction(name, 'readonly').objectStore(name); + const records = []; + await new Promise((resolve, reject) => { + const cursor = store.openCursor(); + cursor.onerror = () => reject(cursor.error); + cursor.onsuccess = () => { + const at = cursor.result; + if (!at) return resolve(); + try { + const record = {}; + if (store.keyPath === null) { + if (isPlain(at.primaryKey)) record.key = at.primaryKey; + else record.keyEncoded = encode(at.primaryKey); + } + if (isPlain(at.value)) record.value = at.value; + else record.valueEncoded = encode(at.value); + size += JSON.stringify(record).length; + if (size > LIMIT) return reject(new Error('DONUT_TOO_LARGE')); + records.push(record); + } catch (error) { + if (!(error instanceof Unsupported)) return reject(error); + skipped += 1; + } + at.continue(); + }; + }); + stores.push({ + name, + autoIncrement: store.autoIncrement, + ...keyPathOf(store.keyPath), + records, + indexes: Array.from(store.indexNames).map((indexName) => { + const index = store.index(indexName); + return { name: indexName, ...keyPathOf(index.keyPath), multiEntry: index.multiEntry, unique: index.unique }; + }), + }); + } + db.close(); + databases.push({ name: info.name, version: info.version, stores }); + } + return { databases, skipped }; +})() +"#; + +/// Replace the databases the file names with its copies; other databases of +/// the origin stay as they are. +const IMPORT_JS: &str = r#" +(async () => { + const DATABASES = __DATA__; + let records = 0; + for (const source of DATABASES) { + await new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(source.name); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + const open = indexedDB.open(source.name, source.version); + open.onupgradeneeded = () => { + const db = open.result; + for (const store of source.stores) { + const options = { autoIncrement: !!store.autoIncrement }; + const keyPath = store.keyPathArray ?? store.keyPath; + if (keyPath !== undefined && keyPath !== null) options.keyPath = keyPath; + const created = db.createObjectStore(store.name, options); + for (const index of store.indexes ?? []) { + created.createIndex(index.name, index.keyPathArray ?? index.keyPath, + { multiEntry: !!index.multiEntry, unique: !!index.unique }); + } + } + }; + const db = await request(open); + for (const store of source.stores) { + if (!store.records?.length) continue; + await new Promise((resolve, reject) => { + const tx = db.transaction(store.name, 'readwrite'); + const target = tx.objectStore(store.name); + for (const record of store.records) { + const value = 'valueEncoded' in record ? decode(record.valueEncoded) : record.value; + if (target.keyPath === null) { + target.put(value, 'keyEncoded' in record ? decode(record.keyEncoded) : record.key); + } else { + target.put(value); + } + records += 1; + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } + db.close(); + } + return { records }; +})() +"#; + +/// The databases an import file names for one origin, checked for the shape +/// the import script needs before anything is deleted. +pub fn checked_databases(databases: &[Value]) -> Result<(), String> { + let valid = databases.iter().all(|db| { + db.get("name") + .and_then(Value::as_str) + .is_some_and(|n| !n.is_empty()) + && db + .get("version") + .and_then(Value::as_u64) + .is_some_and(|v| v >= 1) + && db + .get("stores") + .and_then(Value::as_array) + .is_some_and(|stores| { + stores.iter().all(|store| { + store.get("name").and_then(Value::as_str).is_some() + && store + .get("records") + .is_none_or(|records| records.is_array()) + && store + .get("indexes") + .is_none_or(|indexes| indexes.is_array()) + }) + }) + }); + if valid { + Ok(()) + } else { + Err(code("STORAGE_STATE_INVALID")) + } +} + +/// A headless browser on `profile`, closed again when the work is done +/// whatever it returned. +pub async fn with_headless_browser( + app_handle: &tauri::AppHandle, + profile: &BrowserProfile, + work: F, +) -> Result +where + F: for<'a> FnOnce( + &'a mut WayfernSession, + ) -> futures_util::future::BoxFuture<'a, Result>, +{ + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; + listener + .local_addr() + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? + .port() + }; + let started = std::time::Instant::now(); + let running = crate::browser_runner::launch_browser_profile_impl( + app_handle.clone(), + profile.clone(), + Some("about:blank".to_string()), + crate::browser_runner::LaunchOptions::automation(Some(port), true), + ) + .await?; + let outcome = async { + let target = crate::cdp_target::resolve(&running) + .await + .map_err(|e| crate::backend_error_with_detail("INDEXED_DB_UNAVAILABLE", e))?; + let mut session = WayfernSession::open(&target) + .await + .map_err(|e| crate::backend_error_with_detail("INDEXED_DB_UNAVAILABLE", e))?; + let result = prepare(&mut session).await; + let result = match result { + Ok(()) => work(&mut session).await, + Err(e) => Err(e), + }; + session.close().await; + result + } + .await; + if let Err(e) = crate::browser_runner::kill_browser_profile(app_handle.clone(), running).await { + log::warn!( + "Headless browser not closed after IndexedDB work profile={} err=\"{e}\"", + profile.id + ); + } + log::info!( + "IndexedDB browser closed profile={} ok={} elapsed_ms={}", + profile.id, + outcome.is_ok(), + started.elapsed().as_millis() + ); + outcome +} + +async fn prepare(session: &mut WayfernSession) -> Result<(), String> { + let unavailable = |e: crate::wayfern_cdp::WayfernError| { + crate::backend_error_with_detail("INDEXED_DB_UNAVAILABLE", e) + }; + session + .call("Page.enable", json!({})) + .await + .map_err(unavailable)?; + // A service worker of the origin would answer the navigation itself, with + // the site's own page and scripts. + session + .call("Network.enable", json!({})) + .await + .map_err(unavailable)?; + session + .call("Network.setBypassServiceWorker", json!({ "bypass": true })) + .await + .map_err(unavailable)?; + // Every request of the page pauses here. Only the blank page is answered; + // anything else it asks for (a favicon) waits until the browser closes, so + // nothing reaches the network. + session + .call( + "Fetch.enable", + json!({ "patterns": [{ "urlPattern": "*", "requestStage": "Request" }] }), + ) + .await + .map_err(unavailable)?; + Ok(()) +} + +/// Load the blank page of `origin` that the browser answers itself. +async fn open_origin(session: &mut WayfernSession, origin: &str) -> Result<(), String> { + let unavailable = |e: crate::wayfern_cdp::WayfernError| { + crate::backend_error_with_detail("INDEXED_DB_UNAVAILABLE", e) + }; + let url = format!("{origin}{BLANK_PATH}"); + session + .send("Page.navigate", json!({ "url": url })) + .await + .map_err(unavailable)?; + let request_id = loop { + let Some((_, paused)) = session + .await_any_event(&["Fetch.requestPaused"], STEP_TIMEOUT) + .await + .map_err(unavailable)? + else { + return Err(code("INDEXED_DB_UNAVAILABLE")); + }; + if paused.pointer("/request/url").and_then(Value::as_str) == Some(url.as_str()) { + break paused + .get("requestId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + } + }; + use base64::Engine as _; + let body = base64::engine::general_purpose::STANDARD.encode(""); + session + .call( + "Fetch.fulfillRequest", + json!({ + "requestId": request_id, + "responseCode": 200, + "responseHeaders": [{ "name": "Content-Type", "value": "text/html; charset=utf-8" }], + "body": body, + }), + ) + .await + .map_err(unavailable)?; + // The load event may already have passed while the reply above was read, so + // the page is asked where it is rather than waited on. + let deadline = tokio::time::Instant::now() + STEP_TIMEOUT; + loop { + let reply = session + .call( + "Runtime.evaluate", + json!({ + "expression": "location.origin + '|' + document.readyState", + "returnByValue": true, + }), + ) + .await; + let at = reply.ok().and_then(|r| { + r.pointer("/result/value") + .and_then(Value::as_str) + .map(str::to_string) + }); + if at.as_deref() == Some(&format!("{origin}|complete")) { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(code("INDEXED_DB_UNAVAILABLE")); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn run_script(session: &mut WayfernSession, script: String) -> Result { + let reply = session + .call_with_timeout( + "Runtime.evaluate", + json!({ "expression": script, "awaitPromise": true, "returnByValue": true }), + SCRIPT_TIMEOUT, + ) + .await + .map_err(|e| crate::backend_error_with_detail("INDEXED_DB_UNAVAILABLE", e))?; + if let Some(exception) = reply.get("exceptionDetails") { + let text = exception + .pointer("/exception/description") + .and_then(Value::as_str) + .unwrap_or_default(); + if text.contains("DONUT_TOO_LARGE") { + return Err(code("INDEXED_DB_TOO_LARGE")); + } + return Err(crate::backend_error_with_detail( + "INDEXED_DB_UNAVAILABLE", + text.lines().next().unwrap_or("script failed"), + )); + } + Ok( + reply + .pointer("/result/value") + .cloned() + .unwrap_or(Value::Null), + ) +} + +/// What an export found: each origin's databases, and the records skipped. +pub struct Exported { + pub origins: Vec<(String, Vec)>, + pub skipped: u64, +} + +pub async fn export(session: &mut WayfernSession, origins: &[String]) -> Result { + let mut exported = Exported { + origins: Vec::new(), + skipped: 0, + }; + let mut budget = MAX_EXPORT_CHARS; + for origin in origins { + open_origin(session, origin).await?; + let script = format!( + "{CODEC_JS}\n{}", + EXPORT_JS.replace("__LIMIT__", &budget.to_string()) + ); + let found = run_script(session, script).await?; + exported.skipped += found.get("skipped").and_then(Value::as_u64).unwrap_or(0); + let databases = found + .get("databases") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + budget = budget.saturating_sub( + serde_json::to_string(&databases) + .map(|s| s.len()) + .unwrap_or(0), + ); + if !databases.is_empty() { + exported.origins.push((origin.clone(), databases)); + } + } + Ok(exported) +} + +/// Write each origin's databases; answers how many records went in. +pub async fn import( + session: &mut WayfernSession, + origins: &[(String, Vec)], +) -> Result { + let mut records = 0; + for (origin, databases) in origins { + open_origin(session, origin).await?; + let data = serde_json::to_string(databases).map_err(|e| e.to_string())?; + let script = format!("{CODEC_JS}\n{}", IMPORT_JS.replace("__DATA__", &data)); + let written = run_script(session, script).await?; + records += written.get("records").and_then(Value::as_u64).unwrap_or(0); + } + Ok(records) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_database_folder_names_its_origin() { + assert_eq!( + origin_of_folder("https_example.com_0.indexeddb.leveldb").as_deref(), + Some("https://example.com") + ); + assert_eq!( + origin_of_folder("http_127.0.0.1_8080.indexeddb.leveldb").as_deref(), + Some("http://127.0.0.1:8080") + ); + assert_eq!( + origin_of_folder("http_127.0.0.1_64554").as_deref(), + Some("http://127.0.0.1:64554") + ); + assert_eq!(origin_of_folder("file__0.indexeddb.leveldb"), None); + assert_eq!(origin_of_folder("https_example.com_0.indexeddb.blob"), None); + assert_eq!(origin_of_folder("https_a b_0.indexeddb.leveldb"), None); + } + + #[test] + fn only_a_well_formed_database_list_is_imported() { + let good = json!([{ "name": "app", "version": 3, "stores": [ + { "name": "kv", "autoIncrement": false, "records": [{ "key": "a", "value": 1 }], "indexes": [] } + ]}]); + assert!(checked_databases(good.as_array().unwrap()).is_ok()); + for bad in [ + json!([{ "version": 1, "stores": [] }]), + json!([{ "name": "app", "version": 0, "stores": [] }]), + json!([{ "name": "app", "version": 1, "stores": [{ "name": "kv", "records": {} }] }]), + ] { + assert!(checked_databases(bad.as_array().unwrap()).is_err(), "{bad}"); + } + } + + #[test] + fn the_scripts_take_their_data_where_they_expect_it() { + assert!(EXPORT_JS.contains("__LIMIT__")); + assert!(IMPORT_JS.contains("__DATA__")); + assert!(CODEC_JS.contains("const encode") && CODEC_JS.contains("const decode")); + } +} diff --git a/src-tauri/src/local_storage.rs b/src-tauri/src/local_storage.rs index 653eb2c4..20c36b5d 100644 --- a/src-tauri/src/local_storage.rs +++ b/src-tauri/src/local_storage.rs @@ -34,12 +34,17 @@ pub struct StorageItem { pub value: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] pub struct OriginStorage { /// `https://example.com`, the way a page's `origin` reads. pub origin: String, #[serde(rename = "localStorage", default)] pub local_storage: Vec, + /// The origin's IndexedDB databases, in Playwright's shape. Only present when + /// an export asked for them or an import carries them. + #[serde(rename = "indexedDB", default, skip_serializing_if = "Vec::is_empty")] + #[schema(value_type = Vec)] + pub indexed_db: Vec, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] @@ -56,6 +61,8 @@ pub struct StorageStateImport { pub cookies_replaced: usize, /// Codes of the cookies that could not be imported. pub cookie_errors: Vec, + /// IndexedDB records written. + pub indexed_db_records: u64, } #[derive(Debug, Deserialize)] @@ -220,6 +227,7 @@ fn read_database(dir: &Path) -> Result, String> { _ => origins.push(OriginStorage { origin, local_storage: vec![StorageItem { name, value }], + indexed_db: Vec::new(), }), } } @@ -411,11 +419,16 @@ fn checked_origins(origins: Vec) -> Result, St .to_string(), ); } + crate::indexed_db::checked_databases(&origin.indexed_db)?; match merged.iter_mut().find(|o| o.origin == name) { - Some(existing) => existing.local_storage.extend(origin.local_storage), + Some(existing) => { + existing.local_storage.extend(origin.local_storage); + existing.indexed_db.extend(origin.indexed_db); + } None => merged.push(OriginStorage { origin: name, local_storage: origin.local_storage, + indexed_db: origin.indexed_db, }), } } @@ -457,9 +470,14 @@ pub async fn import_storage_state( cookie_errors = result.errors; } - if !origins.is_empty() { + let with_items: Vec = origins + .iter() + .filter(|o| !o.local_storage.is_empty()) + .cloned() + .collect(); + if !with_items.is_empty() { let dir = database_dir(&profile); - let to_write = origins.clone(); + let to_write = with_items; tauri::async_runtime::spawn_blocking(move || write_database(&dir, &to_write)) .await .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? @@ -471,8 +489,21 @@ pub async fn import_storage_state( } })?; } + let databases: Vec<(String, Vec)> = origins + .iter() + .filter(|o| !o.indexed_db.is_empty()) + .map(|o| (o.origin.clone(), o.indexed_db.clone())) + .collect(); + let indexed_db_records = if databases.is_empty() { + 0 + } else { + crate::indexed_db::with_headless_browser(app_handle, &profile, move |session| { + Box::pin(async move { crate::indexed_db::import(session, &databases).await }) + }) + .await? + }; log::info!( - "Storage state imported profile={profile_id} origins={} items={items} cookies={cookies_imported}", + "Storage state imported profile={profile_id} origins={} items={items} cookies={cookies_imported} indexed_db_records={indexed_db_records}", origins.len() ); Ok(StorageStateImport { @@ -481,6 +512,7 @@ pub async fn import_storage_state( cookies_imported, cookies_replaced, cookie_errors, + indexed_db_records, }) } @@ -493,14 +525,80 @@ pub async fn get_profile_local_storage_stats( .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? } +/// The storage state, with IndexedDB when asked for: that part needs the +/// browser, so the profile must be closed and is opened headless for a moment. +pub async fn export_storage_state_with( + app_handle: &tauri::AppHandle, + profile_id: &str, + include_cookies: bool, + include_indexed_db: bool, +) -> Result { + let id = profile_id.to_string(); + let content = + tauri::async_runtime::spawn_blocking(move || export_storage_state(&id, include_cookies)) + .await + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))??; + if !include_indexed_db { + return Ok(content); + } + let profile = readable_profile(profile_id)?; + let running = ProfileManager::instance() + .check_browser_status(app_handle.clone(), &profile) + .await + .unwrap_or(false); + if running { + return Err(code("LOCAL_STORAGE_BROWSER_RUNNING")); + } + if crate::remote_handoff::state_for(profile_id).is_some() { + return Err(code("LOCAL_STORAGE_REMOTE_SESSION")); + } + let origins = crate::indexed_db::origins_on_disk( + &profile.get_profile_data_path(&ProfileManager::instance().get_profiles_dir()), + ); + if origins.is_empty() { + return Ok(content); + } + let exported = crate::indexed_db::with_headless_browser(app_handle, &profile, move |session| { + Box::pin(async move { crate::indexed_db::export(session, &origins).await }) + }) + .await?; + let mut state: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; + let mut listed: Vec = + serde_json::from_value(state["origins"].take()).unwrap_or_default(); + let databases: usize = exported.origins.iter().map(|(_, dbs)| dbs.len()).sum(); + for (origin, dbs) in exported.origins { + match listed.iter_mut().find(|o| o.origin == origin) { + Some(existing) => existing.indexed_db = dbs, + None => listed.push(OriginStorage { + origin, + local_storage: Vec::new(), + indexed_db: dbs, + }), + } + } + state["origins"] = serde_json::to_value(&listed).map_err(|e| e.to_string())?; + log::info!( + "IndexedDB exported profile={profile_id} databases={databases} skipped={}", + exported.skipped + ); + serde_json::to_string_pretty(&state).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn export_profile_storage_state( + app_handle: tauri::AppHandle, profile_id: String, include_cookies: bool, + include_indexed_db: Option, ) -> Result { - tauri::async_runtime::spawn_blocking(move || export_storage_state(&profile_id, include_cookies)) - .await - .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? + export_storage_state_with( + &app_handle, + &profile_id, + include_cookies, + include_indexed_db.unwrap_or(false), + ) + .await } #[tauri::command] @@ -558,6 +656,7 @@ mod tests { &[OriginStorage { origin: "https://shop.example".to_string(), local_storage: vec![item("cart", "3 items"), item("lang", "日本語")], + indexed_db: Vec::new(), }], ) .unwrap(); @@ -567,10 +666,12 @@ mod tests { OriginStorage { origin: "https://shop.example".to_string(), local_storage: vec![item("cart", "empty")], + indexed_db: Vec::new(), }, OriginStorage { origin: "https://mail.example".to_string(), local_storage: vec![item("session", "abc")], + indexed_db: Vec::new(), }, ], ) @@ -599,6 +700,7 @@ mod tests { name: "k".to_string(), value: "v".to_string(), }], + indexed_db: Vec::new(), }; let tidy = checked_origins(vec![ origin("https://Example.com:443/path"), @@ -619,6 +721,7 @@ mod tests { name: "k".to_string(), value: "x".repeat(6_000_000), }], + indexed_db: Vec::new(), }; assert!(checked_origins(vec![huge]) .unwrap_err() diff --git a/src-tauri/src/wayfern_cdp.rs b/src-tauri/src/wayfern_cdp.rs index aa2880c2..f6e063dd 100644 --- a/src-tauri/src/wayfern_cdp.rs +++ b/src-tauri/src/wayfern_cdp.rs @@ -609,6 +609,16 @@ impl WayfernSession { id } + /// Send `method` without waiting for its reply: for a command that only + /// answers after events the caller must handle first (a navigation that a + /// paused request holds). Its reply, when it comes, is skipped like any + /// message that is not awaited. + pub async fn send(&mut self, method: &str, params: Value) -> Result { + let id = self.allocate_id(); + self.connection.send_command(id, method, params).await?; + Ok(id) + } + /// Send `method` and wait for its reply within the ordinary budget. pub async fn call(&mut self, method: &str, params: Value) -> Result { self