Skip to main content

rahti_native/
bridge.rs

1//! `pp.native` — the browser side of the capability bridge.
2//!
3//! ## Why this is a separate script
4//!
5//! PulsePoint ships as a prebuilt minified bundle, and that bundle is not
6//! edited: not by hand, not by a build step, not to add a namespace. So the
7//! native surface arrives beside it instead. This script attaches `native` to
8//! `pp` the moment the bundle publishes it, and PulsePoint itself is byte for
9//! byte the file a web project serves.
10//!
11//! It is framework-owned — generated from this crate rather than written into
12//! the application's `public/` — so it cannot drift from [`crate::commands`],
13//! and so a project that never opens a native package never serves it.
14//!
15//! ## The one rule for a page that uses it
16//!
17//! **The same page has to work as a website.** `pp.native` is undefined in a
18//! browser, and a page that assumed otherwise is a page the project's web
19//! deployment 500s on. Every use is guarded:
20//!
21//! ```javascript
22//! if (pp.native?.has("choose-file")) {
23//!   const file = await pp.native.invoke("choose_file", { extensions: ["png"] });
24//! } else {
25//!   fileInput.click();   // the web does this perfectly well already
26//! }
27//! ```
28//!
29//! ## What it does not do
30//!
31//! It does not wrap `window.__TAURI__` and re-export it. `invoke` refuses any
32//! name that is not in the Rust allowlist before it reaches Tauri, so a page
33//! cannot reach a Tauri plugin command that the application never meant to
34//! expose merely because the plugin was linked in.
35
36use axum::Router;
37use axum::http::{HeaderValue, header};
38use axum::response::{IntoResponse, Response};
39use axum::routing::get;
40
41/// Where the shell serves the bridge from, for a host that would rather link
42/// it than inject it.
43pub const BRIDGE_PATH: &str = "/__rahti/native.js";
44
45/// The script, with the current allowlist compiled into it.
46///
47/// `platform` is the value `pp.native.platform` reports; the generated shell
48/// passes [`crate::Platform::current`].
49pub fn bridge_script(platform: &str, version: &str) -> String {
50    let capabilities = json_array(&crate::capabilities::capability_names());
51    let commands: Vec<&str> = crate::capabilities::commands()
52        .iter()
53        .map(|c| c.name)
54        .collect();
55    let allowed = json_array(&commands);
56    let platform = json_string(platform);
57    let version = json_string(version);
58
59    format!(
60        r#"// Generated by rahti-native. Do not edit.
61//
62// Attaches `pp.native` to the PulsePoint runtime. The runtime bundle itself is
63// untouched: this listens for the moment it publishes `pp` and adds one
64// property to it.
65(function () {{
66  "use strict";
67
68  var ALLOWED = {allowed};
69  var CAPABILITIES = {capabilities};
70
71  function tauri() {{
72    var t = window.__TAURI__;
73    return t && t.core && typeof t.core.invoke === "function" ? t.core : null;
74  }}
75
76  var api = {{
77    // `true` inside a native package, and absent everywhere else — which is
78    // why a page checks `pp.native` before it checks anything on it.
79    available: true,
80    platform: {platform},
81    version: {version},
82
83    has: function (capability) {{
84      return CAPABILITIES.indexOf(capability) !== -1 && tauri() !== null;
85    }},
86
87    // Refused here as well as in Rust. This is not the security boundary — the
88    // shell's allowlist and Tauri's capability file are — but a name that was
89    // never meant to be callable should fail where the caller can see why.
90    invoke: function (name, payload) {{
91      if (ALLOWED.indexOf(name) === -1) {{
92        return Promise.reject(
93          new Error("pp.native: `" + name + "` is not a native command this application exposes.")
94        );
95      }}
96      var core = tauri();
97      if (!core) {{
98        return Promise.reject(new Error("pp.native: the native bridge is not available."));
99      }}
100      return core.invoke(name, payload || {{}});
101    }},
102  }};
103
104  Object.freeze(api);
105
106  // The access point a page uses, and the reason it is a global rather than a
107  // property of `pp`.
108  //
109  // PulsePoint compiles the expressions in a reactive block into a function of
110  // its own making, and the `pp` visible inside that function is a scoped
111  // object it supplies — not `window.pp`. So `pp.state(...)` works there and
112  // `pp.native` cannot, without editing the shipped runtime bundle, which is
113  // not something Rahti does.
114  //
115  // `window` *is* reachable from inside a compiled block, so this is.
116  window.rahtiNative = api;
117
118  // Best-effort, and documented nowhere as the way in: in a context where
119  // `pp` *is* the global object, `pp.native` also works and reads a little
120  // better. Nothing depends on it succeeding.
121  window.__rahtiNativeAttach = "not attempted";
122
123  function attach(pp) {{
124    if (!pp) {{
125      return false;
126    }}
127    if (pp.native) {{
128      return true;
129    }}
130    try {{
131      pp.native = api;
132    }} catch (e) {{
133      window.__rahtiNativeAttach = "assign threw: " + e.message;
134    }}
135    if (!pp.native) {{
136      try {{
137        Object.defineProperty(pp, "native", {{
138          value: api,
139          configurable: true,
140          enumerable: true,
141        }});
142      }} catch (e) {{
143        window.__rahtiNativeAttach =
144          "frozen=" + Object.isFrozen(pp) + " extensible=" + Object.isExtensible(pp);
145        return false;
146      }}
147    }}
148    if (pp.native) {{
149      window.__rahtiNativeAttach = "attached";
150    }}
151    return !!pp.native;
152  }}
153
154  // The bundle assigns `globalThis.pp` when it loads, which is after this
155  // script runs. Intercepting the assignment is what lets the namespace be
156  // added without the bundle knowing anything about it.
157  if (!attach(window.pp)) {{
158    var stored;
159    try {{
160      Object.defineProperty(window, "pp", {{
161        configurable: true,
162        get: function () {{
163          return stored;
164        }},
165        set: function (value) {{
166          stored = value;
167          attach(value);
168        }},
169      }});
170    }} catch (e) {{
171      window.__rahtiNativeAttach = "trap threw: " + e.message;
172    }}
173
174    // The trap is not enough on its own, and neither is attaching once.
175    //
176    // A runtime that publishes itself with `Object.defineProperty` replaces the
177    // accessor rather than calling it, so the set never happens. And a runtime
178    // that *replaces* `window.pp` later — a richer object after mount, say —
179    // silently drops a `native` that was attached to the object before it. Both
180    // were observed: the attach reported success and `pp.native` was undefined
181    // by the time a page read it.
182    //
183    // So the attach is not an event, it is a condition that is kept true. The
184    // check is a property lookup a few times a second and costs nothing
185    // measurable; being wrong costs the whole native surface, silently.
186    setInterval(function () {{
187      if (window.pp && !window.pp.native) {{
188        attach(window.pp);
189      }}
190    }}, 250);
191  }}
192}})();
193"#
194    )
195}
196
197/// A route serving the bridge, for a host that links it from the document
198/// rather than injecting it at WebView creation.
199///
200/// Injection is what the generated shell does — it runs before any page script
201/// and needs no route — and this exists for the case where that is not
202/// available.
203pub fn bridge_route(platform: &'static str, version: String) -> Router {
204    Router::new().route(
205        BRIDGE_PATH,
206        get(move || {
207            let body = bridge_script(platform, &version);
208            async move {
209                let mut response = body.into_response();
210                response.headers_mut().insert(
211                    header::CONTENT_TYPE,
212                    HeaderValue::from_static("text/javascript; charset=utf-8"),
213                );
214                response
215                    .headers_mut()
216                    .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
217                response as Response
218            }
219        }),
220    )
221}
222
223fn json_array(values: &[&str]) -> String {
224    let items: Vec<String> = values.iter().map(|v| json_string(v)).collect();
225    format!("[{}]", items.join(", "))
226}
227
228/// A JSON string literal, with `<` neutralized for the same reason
229/// `rahti::Json` does it: this text is written inside a script, and `</script>`
230/// in a value would end it.
231fn json_string(value: &str) -> String {
232    serde_json::to_string(value)
233        .unwrap_or_else(|_| "\"\"".to_string())
234        .replace('<', "\\u003c")
235}