Skip to main content

tauri_plugin_topcoat/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![forbid(unsafe_code)]
3
4//! Serve a topcoat application to a Tauri webview over a custom protocol.
5//!
6//! No port is bound and no socket is opened. topcoat's router is already a
7//! function from an HTTP request to an HTTP response - `Router::handle` needs
8//! none of its `serve` feature - so a Tauri custom protocol can call it
9//! directly, and the request never leaves the process.
10//!
11//! ```no_run
12//! use topcoat::router::Router;
13//!
14//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
15//! // With topcoat's `discover` feature this is `Router::builder().discover()`.
16//! let plugin = tauri_plugin_topcoat::Builder::new(Router::builder()).build()?;
17//!
18//! tauri::Builder::default().plugin(plugin);
19//! // ...then `.run(tauri::generate_context!())` as usual.
20//! # Ok(())
21//! # }
22//! ```
23//!
24//! Point the window at `topcoat://localhost/`; Tauri rewrites that to
25//! `http://topcoat.localhost/` on the platforms that need it.
26//!
27//! # What it adds
28//!
29//! Two things the webview gets wrong, both measured on macOS by the `probe`
30//! binary here; nobody has run it on the other two yet:
31//!
32//! * **One origin.** The server always sees `https://<scheme>.localhost`,
33//!   whatever URL shape the platform handed the webview.
34//! * **Redirects.** No webview follows a `Location` from a custom protocol, so
35//!   Post/Redirect/Get is followed here instead.
36//!
37//! # What you cannot do
38//!
39//! A custom protocol response is one buffered blob, so nothing streams:
40//! topcoat's `sse` feature, `datastar`, and any long-lived body have nowhere to
41//! go. WebSockets need an HTTP upgrade a protocol handler can't perform.
42//! Compression is dropped on the way in, and cookies don't survive in either
43//! direction.
44//!
45//! None of that fails quietly. Use one and you get a `502` naming it, because
46//! delivering half a response would have you debugging your application instead
47//! of this transport.
48//!
49//! Everything else - pages, shards, procedures, forms, assets - goes through
50//! untouched.
51//!
52//! # Sessions
53//!
54//! topcoat puts its session token in a cookie, and WebKit throws away every
55//! cookie a custom protocol sets. The `session` feature fixes that in topcoat's
56//! own `TokenStore` seam. See [`Builder::sessions`].
57//!
58//! # Tracing
59//!
60//! The `tracing` feature reports what this plugin decided, which is the part
61//! nothing else can see: a request served and how it ended, a navigation
62//! blocked, a response refused and which capability did it, a session handed
63//! over or withheld and which rule withheld it. Each request is a `serve` span
64//! naming its webview. Turning it on turns on the transport's events too.
65//!
66//! The token is never reported: no function that reports a session takes one,
67//! so it holds by signature.
68//!
69//! # Tauri commands
70//!
71//! They keep working, with nothing to configure: `invoke` needs the injected
72//! IPC script, and Tauri treats a page on a registered custom protocol as a
73//! local origin. An application that sets a strict `Content-Security-Policy`
74//! must allow `connect-src ipc: http://ipc.localhost` itself.
75
76mod protocol;
77#[cfg(feature = "session")]
78mod session;
79mod trace;
80
81use std::{
82    borrow::Cow,
83    sync::{Arc, OnceLock},
84};
85
86use custom_protocol_http::Origins;
87use protocol::Bridge;
88use tauri::{AppHandle, Runtime, Url, Webview, plugin::TauriPlugin};
89use topcoat::{context::BaseUrl, router::RouterBuilder};
90
91pub use custom_protocol_http::{Origin, OriginError, Platform};
92
93/// The protocol scheme used unless [`Builder::scheme`] says otherwise.
94pub const DEFAULT_SCHEME: &str = "topcoat";
95
96/// Why a plugin could not be built.
97///
98/// Every variant is a configuration mistake, caught before the application
99/// runs rather than as a puzzling failure once it does. More will be found, and
100/// finding one should not break an application that already handles the others.
101#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102#[non_exhaustive]
103pub enum Error {
104    /// The protocol scheme is not usable. See [`OriginError`].
105    #[error(transparent)]
106    Scheme(OriginError),
107    /// The application's base URL is the origin this protocol serves.
108    ///
109    /// topcoat resolves absolute URLs against its base URL, for links that
110    /// leave the application: mail, feeds, sitemaps. Pointed at our own origin
111    /// it would produce URLs the webview fetches for real, against a host that
112    /// cannot exist. A desktop application either leaves the base URL unset or
113    /// sets it to its public website.
114    #[error(
115        "the router's base URL `{}` is the origin this protocol serves (`{origin}`); leave it \
116         unset, or set it to the application's public website",
117        .base_url.as_str()
118    )]
119    BaseUrlCollision {
120        /// The base URL the router was given.
121        base_url: BaseUrl,
122        /// The origin this protocol serves.
123        origin: Origin,
124    },
125}
126
127/// Builds the plugin.
128///
129/// Takes a [`RouterBuilder`] rather than a finished `Router` so the base URL
130/// can be checked before the router is sealed, which is the only moment that
131/// mistake is still visible.
132pub struct Builder {
133    router: RouterBuilder,
134    scheme: String,
135    /// `None` reads the webview's own `useHttpsScheme` from the application
136    /// configuration, so the two cannot disagree.
137    https_scheme: Option<bool>,
138    allow_external_navigation: bool,
139    /// Shared with the token store the moment [`Builder::sessions`] installs
140    /// one, which is why it is made here rather than with the bridge: the store
141    /// goes onto the router long before the bridge exists.
142    #[cfg(feature = "session")]
143    webviews: Arc<session::Webviews>,
144}
145
146impl core::fmt::Debug for Builder {
147    /// The router is the bulk of the value and has no `Debug` of its own, so
148    /// what is shown is the configuration a reader would be checking.
149    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
150        f.debug_struct("Builder")
151            .field("scheme", &self.scheme)
152            .field("https_scheme", &self.https_scheme)
153            .field("allow_external_navigation", &self.allow_external_navigation)
154            .finish_non_exhaustive()
155    }
156}
157
158impl Builder {
159    /// Starts a plugin for `router`, on the [`DEFAULT_SCHEME`].
160    #[must_use]
161    pub fn new(router: RouterBuilder) -> Builder {
162        Builder {
163            router,
164            scheme: DEFAULT_SCHEME.to_owned(),
165            https_scheme: None,
166            allow_external_navigation: false,
167            #[cfg(feature = "session")]
168            webviews: Arc::new(session::Webviews::new()),
169        }
170    }
171
172    /// Installs topcoat sessions, carrying the token in this process.
173    ///
174    /// ```no_run
175    /// # use topcoat::{router::Router, session::SessionConfig};
176    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
177    /// let plugin = tauri_plugin_topcoat::Builder::new(Router::builder())
178    ///     .sessions(SessionConfig::builder())
179    ///     .build()?;
180    /// # let _: tauri::plugin::TauriPlugin<tauri::Wry> = plugin;
181    /// # Ok(())
182    /// # }
183    /// ```
184    ///
185    /// # Why you need this
186    ///
187    /// topcoat's session token rides a hardened cookie by default, and WebKit
188    /// throws away every cookie a custom protocol sets. Your login would look
189    /// like it worked and the next request would arrive anonymous. This swaps
190    /// the transport and nothing else - minting, hashing, expiry, `start` and
191    /// `stop` and `rotate`, and your own session storage all stay topcoat's.
192    ///
193    /// The token is held here, keyed by the webview that asked, and never
194    /// crosses into the webview at all. Not `document.cookie`, not a header a
195    /// script can read, not anything WebKit writes to disk. A browser has to
196    /// hand a client its token because the server is somewhere else. Here it
197    /// is the same process, and Tauri tells you which webview asked.
198    ///
199    /// # What it costs
200    ///
201    /// The token is ambient with respect to the webview, so whatever document
202    /// that webview is showing can use it. Confinement is what defends that, so
203    /// leaving [`allow_external_navigation`](Builder::allow_external_navigation)
204    /// off matters more once sessions are on. A webview seen on somebody else's
205    /// origin stops being handed the token either way.
206    ///
207    /// Confinement governs navigation, not sub-resources: a document of yours
208    /// embedding a foreign frame still shows your origin, so the token is still
209    /// handed out, and what stops that frame spending it is topcoat's origin
210    /// check. Serve a `Content-Security-Policy` if you would rather it could
211    /// not load.
212    ///
213    /// Reach for this rather than topcoat's own
214    /// [`sessions`](topcoat::session::RouterBuilderSessionExt::sessions), which
215    /// keeps whatever token store the configuration carries - the cookie one,
216    /// with topcoat's `cookie` feature on, whose every sign-in answers `502`.
217    ///
218    /// # Panics
219    ///
220    /// Never directly. Call this twice and the second store replaces the first,
221    /// taking the first one's tokens with it.
222    #[cfg(feature = "session")]
223    #[must_use]
224    pub fn sessions(mut self, config: topcoat::session::SessionConfigBuilder) -> Builder {
225        use topcoat::session::RouterBuilderSessionExt;
226
227        // Last, so an application's own token store is not quietly kept.
228        let config = config
229            .token_store(session::WebviewTokenStore::new(Arc::clone(&self.webviews)))
230            .build();
231        self.router = self.router.sessions(config);
232        self
233    }
234
235    /// Serves the application under a different scheme name.
236    ///
237    /// The window's URL must match: `<scheme>://localhost/`.
238    #[must_use]
239    pub fn scheme(mut self, scheme: impl Into<String>) -> Builder {
240        self.scheme = scheme.into();
241        self
242    }
243
244    /// Overrides [`WebviewWindowBuilder::use_https_scheme`], which changes the
245    /// URL shape on the platforms that rewrite custom schemes onto http.
246    ///
247    /// Left alone, the plugin reads the same `useHttpsScheme` the webview does
248    /// out of the application configuration, so there is no second place to
249    /// keep in step. Set this only for a webview built in code with a setting
250    /// the configuration does not carry.
251    ///
252    /// [`WebviewWindowBuilder::use_https_scheme`]: tauri::webview::WebviewWindowBuilder::use_https_scheme
253    #[must_use]
254    pub const fn use_https_scheme(mut self, https: bool) -> Builder {
255        self.https_scheme = Some(https);
256        self
257    }
258
259    /// Lets a webview showing this application navigate to another origin.
260    ///
261    /// Off by default. A desktop application usually wants an external link
262    /// opened in the user's browser rather than replacing its own UI, and a
263    /// webview that cannot reach another origin cannot host content that would
264    /// try to forge requests against this one.
265    #[must_use]
266    pub const fn allow_external_navigation(mut self, allow: bool) -> Builder {
267        self.allow_external_navigation = allow;
268        self
269    }
270
271    /// Builds the plugin.
272    ///
273    /// # Errors
274    ///
275    /// [`Error::Scheme`] if the scheme name is unusable, and
276    /// [`Error::BaseUrlCollision`] if the router's base URL is the origin this
277    /// protocol serves.
278    pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, Error> {
279        self.validate(Platform::current())?;
280
281        // Built in `setup`, the first moment the application configuration is
282        // readable, so the URL shape is discovered rather than restated.
283        let bridge: Arc<OnceLock<Bridge>> = Arc::new(OnceLock::new());
284        let building = Arc::clone(&bridge);
285        let serving = Arc::clone(&bridge);
286        let navigating = Arc::clone(&bridge);
287
288        let Builder {
289            router,
290            scheme,
291            https_scheme,
292            allow_external_navigation,
293            #[cfg(feature = "session")]
294            webviews,
295        } = self;
296        let confined = !allow_external_navigation;
297        let protocol_scheme = scheme.clone();
298
299        Ok(tauri::plugin::Builder::new("topcoat")
300            .setup(move |app, _api| {
301                let origins = Origins::new(&scheme, platform_for(app, https_scheme))?;
302                let _ = building.set(Bridge::new(
303                    origins,
304                    router.build(),
305                    #[cfg(feature = "session")]
306                    Arc::clone(&webviews),
307                ));
308                Ok(())
309            })
310            .register_asynchronous_uri_scheme_protocol(
311                protocol_scheme,
312                move |context, request, responder| {
313                    let bridge = Arc::clone(&serving);
314                    let label = context.webview_label().to_owned();
315                    tauri::async_runtime::spawn(async move {
316                        responder.respond(match bridge.get() {
317                            Some(bridge) => bridge.serve(&label, request).await,
318                            None => protocol::unavailable(),
319                        });
320                    });
321                },
322            )
323            .on_navigation(move |webview, url| observe(&navigating, webview, url, confined))
324            .build())
325    }
326
327    /// Drives the same application without a window, as `platform` would.
328    ///
329    /// Everything configured here applies, sessions included, so a test
330    /// exercises the transport the application actually runs on. Naming the
331    /// platform lets a test check a request the way Windows delivers it while
332    /// running on macOS.
333    ///
334    /// # Errors
335    ///
336    /// The same as [`Builder::build`].
337    pub fn session(self, platform: Platform) -> Result<Session, Error> {
338        let origins = self.validate(platform)?;
339        let bridge = Bridge::new(
340            origins,
341            self.router.build(),
342            #[cfg(feature = "session")]
343            self.webviews,
344        );
345        // Stands in for a window already showing one of your pages.
346        bridge.observe_navigation(SESSION_LABEL, true);
347        Ok(Session(bridge))
348    }
349
350    /// Checks what can be checked while the mistake is still attached to the
351    /// call that made it, and returns the origins it had to build to do so.
352    ///
353    /// The canonical origin does not depend on the platform, so a collision
354    /// found against one platform holds for all of them.
355    fn validate(&self, platform: Platform) -> Result<Origins, Error> {
356        let origins = Origins::new(&self.scheme, platform).map_err(Error::Scheme)?;
357        if let Some(base_url) = self.router.get_app_context::<BaseUrl>()
358            && origins.collides_with(base_url.as_str())
359        {
360            return Err(Error::BaseUrlCollision {
361                base_url: base_url.clone(),
362                origin: origins.canonical().clone(),
363            });
364        }
365        Ok(origins)
366    }
367}
368
369/// Drives a router exactly as a webview would, without one.
370///
371/// Testing a topcoat router with `Router::handle` skips everything this plugin
372/// adds: the origin rewrite, the redirect following, and the refusal of a
373/// response the transport cannot carry. A [`Session`] runs all three on a plain
374/// `async` call with no window, so a test fails where the application would.
375///
376/// Built by [`Builder::session`], so a test cannot be configured differently
377/// from the application it stands in for.
378///
379/// ```no_run
380/// # use tauri_plugin_topcoat::{Builder, Platform};
381/// # use topcoat::router::Router;
382/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
383/// let session = Builder::new(Router::builder()).session(Platform::Scheme)?;
384///
385/// let response = session
386///     .serve(http::Request::get("topcoat://localhost/").body(Vec::new())?)
387///     .await;
388///
389/// assert_eq!(response.status(), 200);
390/// # Ok(())
391/// # }
392/// ```
393pub struct Session(Bridge);
394
395impl core::fmt::Debug for Session {
396    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
397        f.debug_struct("Session").finish_non_exhaustive()
398    }
399}
400
401impl Session {
402    /// Serves one request, redirects and session included.
403    pub async fn serve(
404        &self,
405        request: http::Request<Vec<u8>>,
406    ) -> http::Response<Cow<'static, [u8]>> {
407        self.0.serve(SESSION_LABEL, request).await
408    }
409}
410
411/// The webview label a [`Session`] pretends to be.
412const SESSION_LABEL: &str = "session";
413
414/// The URL shape this build's webview uses, from the application configuration
415/// unless the caller insisted otherwise.
416fn platform_for<R: Runtime>(app: &AppHandle<R>, explicit: Option<bool>) -> Platform {
417    let https = explicit.unwrap_or_else(|| {
418        let windows = &app.config().app.windows;
419        let any = windows.iter().any(|window| window.use_https_scheme);
420        if any && !windows.iter().all(|window| window.use_https_scheme) {
421            eprintln!(
422                "tauri-plugin-topcoat: windows disagree about `useHttpsScheme`; \
423                 serving every window over https. Set `Builder::use_https_scheme` to choose."
424            );
425        }
426        any
427    });
428
429    match (Platform::current(), https) {
430        (Platform::HttpSubdomain | Platform::HttpsSubdomain, true) => Platform::HttpsSubdomain,
431        (Platform::HttpSubdomain | Platform::HttpsSubdomain, false) => Platform::HttpSubdomain,
432        // No http rewrite for the setting to apply to.
433        (platform, _) => platform,
434    }
435}
436
437/// Records where a webview is going, and decides whether it may.
438///
439/// The recording happens either way, and happens even when confinement is off,
440/// because a session token is held per webview and must not be handed to one
441/// that has wandered off to somebody else's document.
442///
443/// Confinement itself is narrow. Only a webview already showing one of our
444/// pages is held to it; every other webview in the application is none of this
445/// plugin's business, and neither is the first navigation into one.
446fn observe<R: Runtime>(
447    bridge: &OnceLock<Bridge>,
448    webview: &Webview<R>,
449    url: &Url,
450    confined: bool,
451) -> bool {
452    let Some(bridge) = bridge.get() else {
453        return true;
454    };
455    let ours = bridge.origins().platform();
456    let target_is_ours = ours.covers(url.as_str());
457    bridge.observe_navigation(webview.label(), target_is_ours);
458
459    let Ok(current) = webview.url() else {
460        return true;
461    };
462    if !confined || !ours.covers(current.as_str()) {
463        if !target_is_ours {
464            trace::left_our_origin(webview.label(), url.as_str());
465        }
466        return true;
467    }
468    if !target_is_ours {
469        trace::blocked_navigation(webview.label(), url.as_str());
470    }
471    target_is_ours
472}
473
474/// What installing the session transport does to a response, driven through the
475/// [`Builder`] an application uses rather than a parallel assembly of one.
476#[cfg(all(test, feature = "session"))]
477mod session_tests {
478    use topcoat::{
479        context::Cx,
480        router::{Body, IntoResponse, Path, RouteFn, RouteFuture, Router},
481        session::{SessionConfig, start, token_hash},
482    };
483
484    use super::*;
485
486    /// Mints a session the way an application's sign-in route does.
487    fn sign_in(cx: &Cx, _body: Body) -> RouteFuture<'_> {
488        Box::pin(async move {
489            start(cx).await?;
490            "signed in".into_response(cx)
491        })
492    }
493
494    /// Reports whether this request arrived carrying a live session.
495    fn whoami(cx: &Cx, _body: Body) -> RouteFuture<'_> {
496        Box::pin(async move {
497            let who = match token_hash(cx).await? {
498                Some(_) => "known",
499                None => "anonymous",
500            };
501            who.into_response(cx)
502        })
503    }
504
505    fn session() -> Session {
506        let router = Router::builder()
507            .route(RouteFn::new(
508                http::Method::POST,
509                Cow::Borrowed(Path::new("/sign-in")),
510                sign_in,
511            ))
512            .route(RouteFn::new(
513                http::Method::GET,
514                Cow::Borrowed(Path::new("/whoami")),
515                whoami,
516            ));
517        Builder::new(router)
518            .sessions(SessionConfig::builder())
519            .session(Platform::Scheme)
520            .expect("the plugin is configured correctly")
521    }
522
523    async fn serve(session: &Session, method: http::Method, path: &str) -> (u16, String) {
524        let request = http::Request::builder()
525            .method(method)
526            .uri(format!("topcoat://localhost{path}"))
527            .body(Vec::new())
528            .expect("a valid request");
529        let response = session.serve(request).await;
530        (
531            response.status().as_u16(),
532            String::from_utf8_lossy(response.body()).into_owned(),
533        )
534    }
535
536    #[tokio::test]
537    async fn signing_in_emits_no_cookie_and_is_not_refused() {
538        let session = session();
539
540        let request = http::Request::post("topcoat://localhost/sign-in")
541            .body(Vec::new())
542            .expect("a valid request");
543        let response = session.serve(request).await;
544
545        assert_eq!(
546            response.status(),
547            http::StatusCode::OK,
548            "the session transport tripped the transport's own cookie refusal: {}",
549            String::from_utf8_lossy(response.body())
550        );
551        assert!(
552            response.headers().get(http::header::SET_COOKIE).is_none(),
553            "the token was handed to a webview that would have discarded it"
554        );
555    }
556
557    #[tokio::test]
558    async fn the_token_survives_into_the_next_request() {
559        let session = session();
560
561        let (status, _) = serve(&session, http::Method::POST, "/sign-in").await;
562        assert_eq!(status, 200);
563
564        let (status, body) = serve(&session, http::Method::GET, "/whoami").await;
565
566        assert_eq!(status, 200);
567        assert_eq!(body, "known", "the session did not outlive the request");
568    }
569
570    #[tokio::test]
571    async fn a_webview_that_never_signed_in_is_anonymous() {
572        let (status, body) = serve(&session(), http::Method::GET, "/whoami").await;
573
574        assert_eq!(status, 200);
575        assert_eq!(body, "anonymous");
576    }
577
578    /// The third case is the blind spot, asserted rather than described: a
579    /// `fetch` here carries no `Origin` and can carry no `Sec-Fetch-Site`, and
580    /// topcoat passes anything sending neither. What keeps that safe is the
581    /// stripping this transport does; what could falsify it is a webview
582    /// omitting `Origin` cross-origin, which the probe measures.
583    #[tokio::test]
584    async fn topcoats_origin_check_sees_what_it_needs_through_the_rewrite() {
585        async fn post(session: &Session, origin: Option<&str>) -> u16 {
586            let mut request = http::Request::post("topcoat://localhost/sign-in");
587            if let Some(origin) = origin {
588                request = request.header(http::header::ORIGIN, origin);
589            }
590            let request = request.body(Vec::new()).expect("a valid request");
591            session.serve(request).await.status().as_u16()
592        }
593
594        let session = session();
595
596        assert_eq!(
597            post(&session, Some("topcoat://localhost")).await,
598            200,
599            "the application's own form post was refused; the rewrite has to \
600             move `Origin` and `Host` together for the check to match them"
601        );
602        assert_eq!(
603            post(&session, Some("https://evil.example")).await,
604            403,
605            "a foreign origin was laundered into a passing one"
606        );
607        assert_eq!(
608            post(&session, None).await,
609            200,
610            "a request carrying neither `Origin` nor `Sec-Fetch-Site` passes, \
611             which over a custom protocol is every `fetch`"
612        );
613    }
614
615    #[test]
616    fn our_own_sessions_builds() {
617        Builder::new(Router::builder())
618            .sessions(SessionConfig::builder())
619            .session(Platform::Scheme)
620            .expect("the plugin installs its own token store");
621    }
622
623    /// Why the plugin does not need to detect the mistake: without topcoat's
624    /// `cookie` feature there is no default store to fall back to, so it cannot
625    /// compile its way to runtime. Enabling `topcoat/cookie` is what to avoid.
626    #[test]
627    #[should_panic(expected = "no token store configured")]
628    fn a_session_config_without_a_token_store_refuses_to_build() {
629        let _ = SessionConfig::builder().build();
630    }
631}