openpencil/crates/op-chrome-extension-core/src/wasm_api.rs
Kayshen-X bc1f35ef9b feat(web): chrome extension offline download emits a ready-to-open .op file
The download fallback used to save the raw capture snapshot JSON, which
OpenPencil cannot open directly. Route the snapshot through op-html's
import_snapshot_document in the wasm core so the extension hands back a
canonical .op document (with node count reported and empty captures
surfaced as an actionable error instead of a broken file).
2026-08-05 22:15:09 +08:00

429 lines
15 KiB
Rust

//! The JavaScript boundary. Type conversion only — no decisions.
//!
//! Kept deliberately thin so the logic underneath stays host-agnostic and
//! natively testable. Every export is either a plain scalar/string or a small
//! JSON document the popup's glue parses with `JSON.parse`; nothing here
//! hands JavaScript a structure it has to know the layout of by hand.
//!
//! Numbers cross as `f64` because that is what a JavaScript number is. The
//! conversions to the internal `u32` counters clamp rather than wrap, so a
//! nonsensical value from the JS side degrades into a refused transfer
//! instead of undefined behaviour.
use wasm_bindgen::prelude::*;
use crate::account::{self, Region};
use crate::delivery;
use crate::endpoint;
use crate::filename;
use crate::hub;
use crate::hub_reply::{self, CreateReply};
use crate::ingress::{self, Reply};
use crate::op_export::{self, OpExport};
use crate::transfer;
/// Endpoint the popup pre-fills when the user has none stored.
#[wasm_bindgen(js_name = defaultEndpoint)]
pub fn default_endpoint() -> String {
endpoint::DEFAULT_ENDPOINT.to_owned()
}
/// Normalize a user-typed endpoint to `host:port`, or `undefined` when it is
/// unparseable or names anything but loopback.
#[wasm_bindgen(js_name = normalizeEndpoint)]
pub fn normalize_endpoint(raw: &str) -> Option<String> {
endpoint::normalize_endpoint(raw)
}
/// Absolute URL of the desktop app's snapshot ingest route.
#[wasm_bindgen(js_name = ingestUrl)]
pub fn ingest_url(endpoint: &str) -> String {
endpoint::ingest_url(endpoint)
}
/// Absolute URL of the `/mcp` fallback.
#[wasm_bindgen(js_name = mcpUrl)]
pub fn mcp_url(endpoint: &str) -> String {
endpoint::mcp_url(endpoint)
}
/// Request body for the `/mcp` fallback, minus the snapshot.
///
/// The caller splits this on [`snapshot_placeholder`] and joins the two
/// halves around `JSON.stringify(snapshot)`. The snapshot deliberately never
/// crosses this boundary — see [`ingress::mcp_envelope_template`].
#[wasm_bindgen(js_name = mcpEnvelopeTemplate)]
pub fn mcp_envelope_template() -> String {
ingress::mcp_envelope_template()
}
/// The marker inside [`mcp_envelope_template`] to split on.
#[wasm_bindgen(js_name = snapshotPlaceholder)]
pub fn snapshot_placeholder() -> String {
ingress::SNAPSHOT_PLACEHOLDER.to_owned()
}
/// Download file name for the `.op` document of a page titled `title`.
#[wasm_bindgen(js_name = opFilename)]
pub fn op_filename(title: &str) -> String {
filename::op_filename(title)
}
/// Convert an extractor snapshot into a ready-to-open `.op` document.
///
/// Returns a small JSON document the popup glue parses with `JSON.parse`:
///
/// * `{"ok":true,"op":"<PenDocument JSON>","nodeCount":N,"warnings":[…]}` —
/// `op` is the exact text to write to a `.op` file.
/// * `{"ok":false,"error":"…"}` — the capture produced no importable content
/// (empty page, unsupported snapshot, malformed JSON); the caller must show
/// an actionable error instead of downloading a broken file.
///
/// `title`, when present and non-blank, names the produced document.
#[wasm_bindgen(js_name = snapshotToOpDocument)]
pub fn snapshot_to_op_document(snapshot_json: &str, title: Option<String>) -> String {
let value = match op_export::snapshot_to_op(snapshot_json, title.as_deref()) {
OpExport::Ready {
op,
node_count,
warnings,
} => serde_json::json!({
"ok": true,
"op": op,
"nodeCount": node_count,
"warnings": warnings,
}),
OpExport::Failed { error } => serde_json::json!({
"ok": false,
"error": error,
}),
};
value.to_string()
}
/// Milliseconds before an in-flight request is aborted.
#[wasm_bindgen(js_name = requestTimeoutMs)]
pub fn request_timeout_ms() -> u32 {
transfer::REQUEST_TIMEOUT_MS
}
/// Ingest-route body cap, in megabytes (used in the over-size message).
#[wasm_bindgen(js_name = maxSnapshotMb)]
pub fn max_snapshot_mb() -> u32 {
transfer::MAX_SNAPSHOT_MB
}
/// Whether a snapshot of `chars` UTF-16 code units exceeds that cap.
#[wasm_bindgen(js_name = snapshotTooLarge)]
pub fn snapshot_too_large(chars: f64) -> bool {
transfer::snapshot_too_large(chars)
}
/// Classify a reply from `POST /api/import/web-snapshot`. See
/// [`reply_to_json`] for the shape returned.
#[wasm_bindgen(js_name = classifyIngestReply)]
pub fn classify_ingest_reply(status: u16, text: &str) -> String {
reply_to_json(&ingress::classify_ingest_reply(status, text))
}
/// Classify a reply from the `POST /mcp` fallback.
#[wasm_bindgen(js_name = classifyMcpReply)]
pub fn classify_mcp_reply(status: u16, text: &str) -> String {
reply_to_json(&ingress::classify_mcp_reply(status, text))
}
/* ------------------------------------------------------- the account row */
/// Normalize a stored region value to `"cn"` or `"global"`.
#[wasm_bindgen(js_name = accountRegion)]
pub fn account_region(stored: &str) -> String {
Region::from_stored(stored).as_str().to_owned()
}
/// The hub origin for `region`, unless `override_raw` names a loopback
/// development hub. See [`account::hub_origin`] for why that is the only
/// override shape accepted.
#[wasm_bindgen(js_name = hubOrigin)]
pub fn hub_origin(region: &str, override_raw: &str) -> String {
account::hub_origin(Region::from_stored(region), override_raw)
}
/// The sign-in URL to OPEN IN A TAB. Never fetched — the SSO handshake is
/// the browser's, not the extension's.
#[wasm_bindgen(js_name = hubLoginUrl)]
pub fn hub_login_url(origin: &str) -> String {
account::login_url(origin)
}
/// `GET` this with `credentials: 'include'` to learn who is signed in.
#[wasm_bindgen(js_name = hubSessionUrl)]
pub fn hub_session_url(origin: &str) -> String {
account::session_url(origin)
}
/// `POST` this with the session's CSRF header to sign out.
#[wasm_bindgen(js_name = hubLogoutUrl)]
pub fn hub_logout_url(origin: &str) -> String {
account::logout_url(origin)
}
/// The account page, opened in a tab when the extension cannot sign out
/// itself.
#[wasm_bindgen(js_name = hubAccountUrl)]
pub fn hub_account_url(origin: &str) -> String {
account::account_url(origin)
}
/// The `host_permissions` match pattern `origin` needs.
///
/// The popup checks this with `chrome.permissions.contains` before its first
/// probe, so a manifest that does not grant the origin fails with a sentence
/// naming the origin rather than with an unexplained network error.
#[wasm_bindgen(js_name = hubHostPermission)]
pub fn hub_host_permission(origin: &str) -> String {
account::host_permission(origin)
}
/// Classify a reply from `GET /api/v1/session`.
///
/// * `{"state":"signedIn","userId":…,"displayName":…,"avatarUrl":…|null,"csrfToken":…}`
/// * `{"state":"signedOut"}`
/// * `{"state":"error","detail":…}`
#[wasm_bindgen(js_name = parseSession)]
pub fn parse_session(status: u16, text: &str) -> String {
account::session_to_json(&account::parse_session(status, text))
}
/// Re-validate an avatar URL read back out of `chrome.storage`, returning
/// `undefined` for anything that must not reach an `<img src>`.
#[wasm_bindgen(js_name = sanitizeAvatarUrl)]
pub fn sanitize_avatar_url(raw: &str) -> Option<String> {
account::avatar_url(Some(raw))
}
/// Milliseconds before a hub request is abandoned.
#[wasm_bindgen(js_name = accountRequestTimeoutMs)]
pub fn account_request_timeout_ms() -> u32 {
account::REQUEST_TIMEOUT_MS
}
/// Whether delivery to the signed-in account is implemented end to end.
#[wasm_bindgen(js_name = accountDeliveryAvailable)]
pub fn account_delivery_available() -> bool {
delivery::ACCOUNT_AVAILABLE
}
/// The delivery target actually used for the next capture, given what the
/// user last chose and whether a session was observed.
#[wasm_bindgen(js_name = deliveryTarget)]
pub fn delivery_target(stored: &str, signed_in: bool) -> String {
delivery::resolve(stored, signed_in).as_str().to_owned()
}
/// Whether the popup should render the delivery row at all.
#[wasm_bindgen(js_name = deliveryRowVisible)]
pub fn delivery_row_visible(signed_in: bool) -> bool {
delivery::row_visible(signed_in)
}
/* ----------------------------------------------- the account snapshot inbox */
/// Absolute URL of the Hub's inbox create route on `origin`.
#[wasm_bindgen(js_name = hubSnapshotsUrl)]
pub fn hub_snapshots_url(origin: &str) -> String {
hub::snapshots_url(origin)
}
/// Request body for `POST /api/v1/snapshots`, minus the snapshot document.
///
/// The caller splits this on [`hub_snapshot_placeholder`] and joins the two
/// halves around the extractor's raw JSON text. Unlike the `/mcp` envelope the
/// document goes in as a JSON **object**, so there is no `JSON.stringify`
/// around it; see [`hub::create_envelope_template`] for why splicing it
/// verbatim cannot forge a field.
///
/// `captured_at_ms` is `Date.now()` and `tz_offset_minutes` is
/// `-new Date().getTimezoneOffset()` — the latter only affects the local-time
/// stamp inside the display name, never the wire instant, which is always UTC.
#[wasm_bindgen(js_name = hubEnvelopeTemplate)]
pub fn hub_envelope_template(
title: &str,
source_url: &str,
captured_at_ms: f64,
tz_offset_minutes: f64,
) -> String {
hub::create_envelope_template(title, source_url, captured_at_ms, tz_offset_minutes)
}
/// The marker inside [`hub_envelope_template`] to split on.
#[wasm_bindgen(js_name = hubSnapshotPlaceholder)]
pub fn hub_snapshot_placeholder() -> String {
hub::SNAPSHOT_PLACEHOLDER.to_owned()
}
/// Whether a snapshot of `chars` UTF-16 code units cannot fit in the Hub's
/// 32 MiB request body once the envelope is wrapped around it.
#[wasm_bindgen(js_name = hubSnapshotTooLarge)]
pub fn hub_snapshot_too_large(chars: f64) -> bool {
hub::snapshot_too_large(chars)
}
/// Milliseconds before an upload to the Hub is abandoned.
#[wasm_bindgen(js_name = hubUploadTimeoutMs)]
pub fn hub_upload_timeout_ms() -> u32 {
hub::UPLOAD_TIMEOUT_MS
}
/// Per-user ceiling on stored snapshots, for the "inbox is full" message.
#[wasm_bindgen(js_name = hubQuotaItems)]
pub fn hub_quota_items() -> u32 {
hub::QUOTA_ITEMS
}
/// Per-user ceiling on stored bytes, in MiB, for the same message.
#[wasm_bindgen(js_name = hubQuotaTotalMb)]
pub fn hub_quota_total_mb() -> u32 {
hub::QUOTA_TOTAL_MB
}
/// Classify a reply from `POST /api/v1/snapshots`.
///
/// * `{"outcome":"ok","id":…,"name":…,"bytes":N,"expiresAt":…}`
/// * `{"outcome":"error","code":"signedOut"|"forbidden"|"quota"|"tooLarge"|
/// "rateLimited"|"rejected"|"unavailable","detail":…,"retryAfterSeconds":N|null}`
#[wasm_bindgen(js_name = classifyHubReply)]
pub fn classify_hub_reply(status: u16, text: &str, retry_after: &str) -> String {
let value = match hub_reply::classify_create_reply(status, text, retry_after) {
CreateReply::Created {
id,
name,
bytes,
expires_at,
} => serde_json::json!({
"outcome": "ok",
"id": id,
"name": name,
"bytes": bytes,
"expiresAt": expires_at,
}),
CreateReply::Failed {
code,
detail,
retry_after_seconds,
} => serde_json::json!({
"outcome": "error",
"code": code.as_str(),
"detail": detail,
"retryAfterSeconds": retry_after_seconds,
}),
};
value.to_string()
}
/// Plan and verify one chunked snapshot readback.
///
/// JavaScript drives this: while `!done`, it asks the tab for `nextLength`
/// code units at `nextOffset` and passes the answer to `accept`; when `done`,
/// it calls `verifyTotal` with the length of the joined string. A `false`
/// from either means `chunkLost`.
#[wasm_bindgen(js_name = ChunkPlan)]
pub struct JsChunkPlan {
inner: transfer::ChunkPlan,
}
#[wasm_bindgen(js_class = ChunkPlan)]
impl JsChunkPlan {
/// Plan a readback of `expected_chars` UTF-16 code units.
#[wasm_bindgen(constructor)]
pub fn new(expected_chars: f64) -> JsChunkPlan {
JsChunkPlan {
inner: transfer::ChunkPlan::new(clamp_u32(expected_chars)),
}
}
/// Offset of the next slice to request.
#[wasm_bindgen(getter, js_name = nextOffset)]
pub fn next_offset(&self) -> u32 {
self.inner.next_offset()
}
/// Maximum length of the next slice to request.
#[wasm_bindgen(getter, js_name = nextLength)]
pub fn next_length(&self) -> u32 {
self.inner.next_length()
}
/// Code units accepted so far.
#[wasm_bindgen(getter)]
pub fn transferred(&self) -> u32 {
self.inner.transferred()
}
/// Whether every expected code unit has arrived.
#[wasm_bindgen(getter)]
pub fn done(&self) -> bool {
self.inner.done()
}
/// Whether the payload carries the extractor's `truncated` flag.
#[wasm_bindgen(getter)]
pub fn truncated(&self) -> bool {
self.inner.truncated()
}
/// Record one slice. `chunk_chars` is the `String.prototype.length` the
/// caller measured on the intact slice, and `-1` when the tab answered
/// with something that was not a string.
#[wasm_bindgen(js_name = accept)]
pub fn accept(&mut self, chunk: &str, chunk_chars: f64) -> bool {
self.inner.accept(chunk, chunk_chars)
}
/// Final integrity check against the length of the joined text.
#[wasm_bindgen(js_name = verifyTotal)]
pub fn verify_total(&self, total_chars: f64) -> bool {
total_chars.is_finite()
&& total_chars >= 0.0
&& total_chars <= f64::from(u32::MAX)
&& self.inner.verify_total(total_chars as u32)
}
}
/// Serialize a [`Reply`] for the popup.
///
/// * `{"outcome":"fallback"}` — try the next ingress.
/// * `{"outcome":"ok","nodeCount":N,"warnings":[…]}`
/// * `{"outcome":"error","code":"forbidden"|"import","detail":"…"}`
fn reply_to_json(reply: &Reply) -> String {
let value = match reply {
Reply::Fallback => serde_json::json!({ "outcome": "fallback" }),
Reply::Imported {
node_count,
warnings,
} => serde_json::json!({
"outcome": "ok",
"nodeCount": node_count,
"warnings": warnings,
}),
Reply::Failed { code, detail } => serde_json::json!({
"outcome": "error",
"code": code.as_str(),
"detail": detail,
}),
};
value.to_string()
}
/// Clamp a JavaScript number into the `u32` counter space. NaN and negatives
/// become 0, which makes a nonsensical plan finish immediately and fail its
/// final length check rather than looping.
fn clamp_u32(value: f64) -> u32 {
if !value.is_finite() || value <= 0.0 {
0
} else if value >= f64::from(u32::MAX) {
u32::MAX
} else {
value as u32
}
}