tauri/manager/
webview.rs

1// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use std::{
6  borrow::Cow,
7  collections::{HashMap, HashSet},
8  fmt,
9  fs::create_dir_all,
10  sync::{Arc, Mutex, MutexGuard},
11};
12
13use serde::Serialize;
14use serialize_to_javascript::{default_template, DefaultTemplate, Template};
15use tauri_runtime::{
16  webview::{DetachedWebview, InitializationScript, PendingWebview},
17  window::DragDropEvent,
18};
19use tauri_utils::config::WebviewUrl;
20use url::Url;
21
22use crate::{
23  app::{GlobalWebviewEventListener, OnPageLoad, UriSchemeResponder, WebviewEvent},
24  ipc::InvokeHandler,
25  pattern::PatternJavascript,
26  sealed::ManagerBase,
27  webview::PageLoadPayload,
28  EventLoopMessage, EventTarget, Manager, Runtime, Scopes, UriSchemeContext, Webview, Window,
29};
30
31use super::{
32  window::{DragDropPayload, DRAG_DROP_EVENT, DRAG_ENTER_EVENT, DRAG_LEAVE_EVENT, DRAG_OVER_EVENT},
33  {AppManager, EmitPayload},
34};
35
36// we need to proxy the dev server on mobile because we can't use `localhost`, so we use the local IP address
37// and we do not get a secure context without the custom protocol that proxies to the dev server
38// additionally, we need the custom protocol to inject the initialization scripts on Android
39// must also keep in sync with the `let mut response` assignment in prepare_uri_scheme_protocol
40pub(crate) const PROXY_DEV_SERVER: bool = cfg!(all(dev, mobile));
41
42pub(crate) const PROCESS_IPC_MESSAGE_FN: &str =
43  include_str!("../../scripts/process-ipc-message-fn.js");
44
45#[cfg(feature = "isolation")]
46#[derive(Template)]
47#[default_template("../../scripts/isolation.js")]
48pub(crate) struct IsolationJavascript<'a> {
49  pub(crate) isolation_src: &'a str,
50  pub(crate) style: &'a str,
51}
52
53#[derive(Template)]
54#[default_template("../../scripts/ipc.js")]
55pub(crate) struct IpcJavascript<'a> {
56  pub(crate) isolation_origin: &'a str,
57}
58
59/// Uses a custom URI scheme handler to resolve file requests
60pub struct UriSchemeProtocol<R: Runtime> {
61  /// Handler for protocol
62  #[allow(clippy::type_complexity)]
63  pub protocol:
64    Box<dyn Fn(UriSchemeContext<'_, R>, http::Request<Vec<u8>>, UriSchemeResponder) + Send + Sync>,
65}
66
67pub struct WebviewManager<R: Runtime> {
68  pub webviews: Mutex<HashMap<String, Webview<R>>>,
69  /// The JS message handler.
70  pub invoke_handler: Box<InvokeHandler<R>>,
71  /// The page load hook, invoked when the webview performs a navigation.
72  pub on_page_load: Option<Arc<OnPageLoad<R>>>,
73  /// The webview protocols available to all webviews.
74  pub uri_scheme_protocols: Mutex<HashMap<String, Arc<UriSchemeProtocol<R>>>>,
75  /// Webview event listeners to all webviews.
76  pub event_listeners: Arc<Vec<GlobalWebviewEventListener<R>>>,
77
78  /// The script that initializes the invoke system.
79  pub invoke_initialization_script: String,
80
81  /// A runtime generated invoke key.
82  pub(crate) invoke_key: String,
83}
84
85impl<R: Runtime> fmt::Debug for WebviewManager<R> {
86  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87    f.debug_struct("WebviewManager")
88      .field(
89        "invoke_initialization_script",
90        &self.invoke_initialization_script,
91      )
92      .field("invoke_key", &self.invoke_key)
93      .finish()
94  }
95}
96
97impl<R: Runtime> WebviewManager<R> {
98  pub(crate) fn register_uri_scheme_protocol<N: Into<String>>(
99    &self,
100    uri_scheme: N,
101    protocol: Arc<UriSchemeProtocol<R>>,
102  ) {
103    let uri_scheme = uri_scheme.into();
104    self
105      .uri_scheme_protocols
106      .lock()
107      .unwrap()
108      .insert(uri_scheme, protocol);
109  }
110
111  /// Get a locked handle to the webviews.
112  pub(crate) fn webviews_lock(&self) -> MutexGuard<'_, HashMap<String, Webview<R>>> {
113    self.webviews.lock().expect("poisoned webview manager")
114  }
115
116  fn prepare_pending_webview<M: Manager<R>>(
117    &self,
118    mut pending: PendingWebview<EventLoopMessage, R>,
119    label: &str,
120    window_label: &str,
121    manager: &M,
122  ) -> crate::Result<PendingWebview<EventLoopMessage, R>> {
123    let app_manager = manager.manager();
124
125    let plugin_init_scripts = app_manager
126      .plugins
127      .lock()
128      .expect("poisoned plugin store")
129      .initialization_script();
130
131    let pattern_init = PatternJavascript {
132      pattern: (&*app_manager.pattern).into(),
133    }
134    .render_default(&Default::default())?;
135
136    let mut webview_attributes = pending.webview_attributes;
137
138    let use_https_scheme = webview_attributes.use_https_scheme;
139
140    let ipc_init = IpcJavascript {
141      isolation_origin: &match &*app_manager.pattern {
142        #[cfg(feature = "isolation")]
143        crate::Pattern::Isolation { schema, .. } => {
144          crate::pattern::format_real_schema(schema, use_https_scheme)
145        }
146        _ => "".to_owned(),
147      },
148    }
149    .render_default(&Default::default())?;
150
151    let mut all_initialization_scripts: Vec<InitializationScript> = vec![];
152
153    fn main_frame_script(script: String) -> InitializationScript {
154      InitializationScript {
155        script,
156        for_main_frame_only: true,
157      }
158    }
159
160    all_initialization_scripts.push(main_frame_script(
161      r"
162        Object.defineProperty(window, 'isTauri', {
163          value: true,
164        });
165
166        if (!window.__TAURI_INTERNALS__) {
167          Object.defineProperty(window, '__TAURI_INTERNALS__', {
168            value: {
169              plugins: {}
170            }
171          })
172        }
173      "
174      .to_owned(),
175    ));
176    all_initialization_scripts.push(main_frame_script(self.invoke_initialization_script.clone()));
177    all_initialization_scripts.push(main_frame_script(format!(
178      r#"
179          Object.defineProperty(window.__TAURI_INTERNALS__, 'metadata', {{
180            value: {{
181              currentWindow: {{ label: {current_window_label} }},
182              currentWebview: {{ label: {current_webview_label} }}
183            }}
184          }})
185        "#,
186      current_window_label = serde_json::to_string(window_label)?,
187      current_webview_label = serde_json::to_string(&label)?,
188    )));
189    all_initialization_scripts.push(main_frame_script(self.initialization_script(
190      app_manager,
191      &ipc_init.into_string(),
192      &pattern_init.into_string(),
193      use_https_scheme,
194    )?));
195
196    all_initialization_scripts.extend(plugin_init_scripts);
197
198    #[cfg(feature = "isolation")]
199    if let crate::Pattern::Isolation { schema, .. } = &*app_manager.pattern {
200      all_initialization_scripts.push(main_frame_script(
201        IsolationJavascript {
202          isolation_src: &crate::pattern::format_real_schema(schema, use_https_scheme),
203          style: tauri_utils::pattern::isolation::IFRAME_STYLE,
204        }
205        .render_default(&Default::default())?
206        .into_string(),
207      ));
208    }
209
210    if let Some(plugin_global_api_scripts) = &*app_manager.plugin_global_api_scripts {
211      for &script in plugin_global_api_scripts.iter() {
212        all_initialization_scripts.push(main_frame_script(script.to_owned()));
213      }
214    }
215
216    // Prepend `all_initialization_scripts` to `webview_attributes.initialization_scripts`
217    all_initialization_scripts.extend(webview_attributes.initialization_scripts);
218    webview_attributes.initialization_scripts = all_initialization_scripts;
219
220    pending.webview_attributes = webview_attributes;
221
222    let mut registered_scheme_protocols = Vec::new();
223
224    for (uri_scheme, protocol) in &*self.uri_scheme_protocols.lock().unwrap() {
225      registered_scheme_protocols.push(uri_scheme.clone());
226      let protocol = protocol.clone();
227      let app_handle = manager.app_handle().clone();
228
229      pending.register_uri_scheme_protocol(uri_scheme, move |webview_id, request, responder| {
230        let context = UriSchemeContext {
231          app_handle: &app_handle,
232          webview_label: webview_id,
233        };
234        (protocol.protocol)(context, request, UriSchemeResponder(responder))
235      });
236    }
237
238    let window_url = Url::parse(&pending.url).unwrap();
239    let window_origin = if window_url.scheme() == "data" {
240      "null".into()
241    } else if (cfg!(windows) || cfg!(target_os = "android"))
242      && window_url.scheme() != "http"
243      && window_url.scheme() != "https"
244    {
245      let https = if use_https_scheme { "https" } else { "http" };
246      format!("{https}://{}.localhost", window_url.scheme())
247    } else if let Some(host) = window_url.host() {
248      format!(
249        "{}://{}{}",
250        window_url.scheme(),
251        host,
252        window_url
253          .port()
254          .map(|p| format!(":{p}"))
255          .unwrap_or_default()
256      )
257    } else {
258      "null".into()
259    };
260
261    if !registered_scheme_protocols.contains(&"tauri".into()) {
262      let web_resource_request_handler = pending.web_resource_request_handler.take();
263      let protocol = crate::protocol::tauri::get(
264        manager.manager_owned(),
265        &window_origin,
266        web_resource_request_handler,
267      );
268      pending.register_uri_scheme_protocol("tauri", move |webview_id, request, responder| {
269        protocol(webview_id, request, UriSchemeResponder(responder))
270      });
271      registered_scheme_protocols.push("tauri".into());
272    }
273
274    if !registered_scheme_protocols.contains(&"ipc".into()) {
275      let protocol = crate::ipc::protocol::get(manager.manager_owned());
276      pending.register_uri_scheme_protocol("ipc", move |webview_id, request, responder| {
277        protocol(webview_id, request, UriSchemeResponder(responder))
278      });
279      registered_scheme_protocols.push("ipc".into());
280    }
281
282    let label = pending.label.clone();
283    let app_manager_ = manager.manager_owned();
284    let on_page_load_handler = pending.on_page_load_handler.take();
285    pending
286      .on_page_load_handler
287      .replace(Box::new(move |url, event| {
288        let payload = PageLoadPayload { url: &url, event };
289
290        if let Some(w) = app_manager_.get_webview(&label) {
291          if let Some(on_page_load) = &app_manager_.webview.on_page_load {
292            on_page_load(&w, &payload);
293          }
294
295          app_manager_
296            .plugins
297            .lock()
298            .unwrap()
299            .on_page_load(&w, &payload);
300        }
301
302        if let Some(handler) = &on_page_load_handler {
303          handler(url, event);
304        }
305      }));
306
307    #[cfg(feature = "protocol-asset")]
308    if !registered_scheme_protocols.contains(&"asset".into()) {
309      let asset_scope = app_manager
310        .state()
311        .get::<crate::Scopes>()
312        .asset_protocol
313        .clone();
314      let protocol = crate::protocol::asset::get(asset_scope, window_origin.clone());
315      pending.register_uri_scheme_protocol("asset", move |webview_id, request, responder| {
316        protocol(webview_id, request, UriSchemeResponder(responder))
317      });
318    }
319
320    #[cfg(feature = "isolation")]
321    if let crate::Pattern::Isolation {
322      assets,
323      schema,
324      key: _,
325      crypto_keys,
326    } = &*app_manager.pattern
327    {
328      let protocol = crate::protocol::isolation::get(
329        manager.manager_owned(),
330        schema,
331        assets.clone(),
332        *crypto_keys.aes_gcm().raw(),
333        window_origin,
334        use_https_scheme,
335      );
336      pending.register_uri_scheme_protocol(schema, move |webview_id, request, responder| {
337        protocol(webview_id, request, UriSchemeResponder(responder))
338      });
339    }
340
341    Ok(pending)
342  }
343
344  fn initialization_script(
345    &self,
346    app_manager: &AppManager<R>,
347    ipc_script: &str,
348    pattern_script: &str,
349    use_https_scheme: bool,
350  ) -> crate::Result<String> {
351    #[derive(Template)]
352    #[default_template("../../scripts/init.js")]
353    struct InitJavascript<'a> {
354      #[raw]
355      pattern_script: &'a str,
356      #[raw]
357      ipc_script: &'a str,
358      #[raw]
359      core_script: &'a str,
360      #[raw]
361      event_initialization_script: &'a str,
362      #[raw]
363      freeze_prototype: &'a str,
364    }
365
366    #[derive(Template)]
367    #[default_template("../../scripts/core.js")]
368    struct CoreJavascript<'a> {
369      os_name: &'a str,
370      protocol_scheme: &'a str,
371      invoke_key: &'a str,
372    }
373
374    let freeze_prototype = if app_manager.config.app.security.freeze_prototype {
375      include_str!("../../scripts/freeze_prototype.js")
376    } else {
377      ""
378    };
379
380    InitJavascript {
381      pattern_script,
382      ipc_script,
383      core_script: &CoreJavascript {
384        os_name: std::env::consts::OS,
385        protocol_scheme: if use_https_scheme { "https" } else { "http" },
386        invoke_key: self.invoke_key(),
387      }
388      .render_default(&Default::default())?
389      .into_string(),
390      event_initialization_script: &crate::event::event_initialization_script(
391        app_manager.listeners().function_name(),
392        app_manager.listeners().listeners_object_name(),
393      ),
394      freeze_prototype,
395    }
396    .render_default(&Default::default())
397    .map(|s| s.into_string())
398    .map_err(Into::into)
399  }
400
401  pub fn prepare_webview<M: Manager<R>>(
402    &self,
403    manager: &M,
404    mut pending: PendingWebview<EventLoopMessage, R>,
405    window_label: &str,
406  ) -> crate::Result<PendingWebview<EventLoopMessage, R>> {
407    if self.webviews_lock().contains_key(&pending.label) {
408      return Err(crate::Error::WebviewLabelAlreadyExists(pending.label));
409    }
410
411    let app_manager = manager.manager();
412
413    #[allow(unused_mut)] // mut url only for the data-url parsing
414    let mut url = match &pending.webview_attributes.url {
415      WebviewUrl::App(path) => {
416        let app_url = app_manager.get_url(pending.webview_attributes.use_https_scheme);
417        let url = if PROXY_DEV_SERVER && is_local_network_url(&app_url) {
418          Cow::Owned(Url::parse("tauri://localhost").unwrap())
419        } else {
420          app_url
421        };
422        // ignore "index.html" just to simplify the url
423        if path.to_str() != Some("index.html") {
424          url
425            .join(&path.to_string_lossy())
426            .map_err(crate::Error::InvalidUrl)
427            // this will never fail
428            .unwrap()
429        } else {
430          url.into_owned()
431        }
432      }
433      WebviewUrl::External(url) => {
434        let config_url = app_manager.get_url(pending.webview_attributes.use_https_scheme);
435        let is_app_url = config_url.make_relative(url).is_some();
436        let mut url = url.clone();
437        if is_app_url && PROXY_DEV_SERVER && is_local_network_url(&url) {
438          Url::parse("tauri://localhost").unwrap()
439        } else {
440          url
441        }
442      }
443
444      WebviewUrl::CustomProtocol(url) => url.clone(),
445      _ => unimplemented!(),
446    };
447
448    #[cfg(not(feature = "webview-data-url"))]
449    if url.scheme() == "data" {
450      return Err(crate::Error::InvalidWebviewUrl(
451        "data URLs are not supported without the `webview-data-url` feature.",
452      ));
453    }
454
455    #[cfg(feature = "webview-data-url")]
456    if let Some(csp) = app_manager.csp() {
457      if url.scheme() == "data" {
458        if let Ok(data_url) = data_url::DataUrl::process(url.as_str()) {
459          let (body, _) = data_url.decode_to_vec().unwrap();
460          let html = String::from_utf8_lossy(&body).into_owned();
461          // naive way to check if it's an html
462          if html.contains('<') && html.contains('>') {
463            let document = tauri_utils::html::parse(html);
464            tauri_utils::html::inject_csp(&document, &csp.to_string());
465            url.set_path(&format!("{},{document}", mime::TEXT_HTML));
466          }
467        }
468      }
469    }
470
471    pending.url = url.to_string();
472
473    #[cfg(target_os = "android")]
474    {
475      pending = pending.on_webview_created(move |ctx| {
476        let plugin_manager = ctx
477          .env
478          .call_method(
479            ctx.activity,
480            "getPluginManager",
481            "()Lapp/tauri/plugin/PluginManager;",
482            &[],
483          )?
484          .l()?;
485
486        // tell the manager the webview is ready
487        ctx.env.call_method(
488          plugin_manager,
489          "onWebViewCreated",
490          "(Landroid/webkit/WebView;)V",
491          &[ctx.webview.into()],
492        )?;
493
494        Ok(())
495      });
496    }
497
498    let label = pending.label.clone();
499    pending = self.prepare_pending_webview(pending, &label, window_label, manager)?;
500
501    pending.ipc_handler = Some(crate::ipc::protocol::message_handler(
502      manager.manager_owned(),
503    ));
504
505    // in `windows`, we need to force a data_directory
506    // but we do respect user-specification
507    #[cfg(any(target_os = "linux", target_os = "windows"))]
508    if pending.webview_attributes.data_directory.is_none() {
509      let local_app_data = manager.path().resolve(
510        &app_manager.config.identifier,
511        crate::path::BaseDirectory::LocalData,
512      );
513      if let Ok(user_data_dir) = local_app_data {
514        pending.webview_attributes.data_directory = Some(user_data_dir);
515      }
516    }
517
518    // make sure the directory is created and available to prevent a panic
519    if let Some(user_data_dir) = &pending.webview_attributes.data_directory {
520      if !user_data_dir.exists() {
521        create_dir_all(user_data_dir)?;
522      }
523    }
524
525    #[cfg(all(desktop, not(target_os = "windows")))]
526    if pending.webview_attributes.zoom_hotkeys_enabled {
527      #[derive(Template)]
528      #[default_template("../webview/scripts/zoom-hotkey.js")]
529      struct HotkeyZoom<'a> {
530        os_name: &'a str,
531      }
532
533      pending
534        .webview_attributes
535        .initialization_scripts
536        .push(InitializationScript {
537          script: HotkeyZoom {
538            os_name: std::env::consts::OS,
539          }
540          .render_default(&Default::default())?
541          .into_string(),
542          for_main_frame_only: true,
543        })
544    }
545
546    #[cfg(feature = "isolation")]
547    let pattern = app_manager.pattern.clone();
548    let navigation_handler = pending.navigation_handler.take();
549    let app_manager = manager.manager_owned();
550    let label = pending.label.clone();
551    pending.navigation_handler = Some(Box::new(move |url| {
552      // always allow navigation events for the isolation iframe and do not emit them for consumers
553      #[cfg(feature = "isolation")]
554      if let crate::Pattern::Isolation { schema, .. } = &*pattern {
555        if url.scheme() == schema
556          && url.domain() == Some(crate::pattern::ISOLATION_IFRAME_SRC_DOMAIN)
557        {
558          return true;
559        }
560      }
561      if let Some(handler) = &navigation_handler {
562        if !handler(url) {
563          return false;
564        }
565      }
566      let webview = app_manager.webview.webviews_lock().get(&label).cloned();
567      if let Some(w) = webview {
568        app_manager
569          .plugins
570          .lock()
571          .expect("poisoned plugin store")
572          .on_navigation(&w, url)
573      } else {
574        true
575      }
576    }));
577
578    Ok(pending)
579  }
580
581  pub(crate) fn attach_webview(
582    &self,
583    window: Window<R>,
584    webview: DetachedWebview<EventLoopMessage, R>,
585    use_https_scheme: bool,
586  ) -> Webview<R> {
587    let webview = Webview::new(window, webview, use_https_scheme);
588
589    let webview_event_listeners = self.event_listeners.clone();
590    let webview_ = webview.clone();
591    webview.on_webview_event(move |event| {
592      let _ = on_webview_event(&webview_, event);
593      for handler in webview_event_listeners.iter() {
594        handler(&webview_, event);
595      }
596    });
597
598    // insert the webview into our manager
599    {
600      self
601        .webviews_lock()
602        .insert(webview.label().to_string(), webview.clone());
603    }
604
605    // let plugins know that a new webview has been added to the manager
606    let manager = webview.manager_owned();
607    let webview_ = webview.clone();
608    // run on main thread so the plugin store doesn't dead lock with the event loop handler in App
609    let _ = webview.run_on_main_thread(move || {
610      manager
611        .plugins
612        .lock()
613        .expect("poisoned plugin store")
614        .webview_created(webview_);
615    });
616
617    #[cfg(all(target_os = "ios", feature = "wry"))]
618    {
619      webview
620        .with_webview(|w| {
621          unsafe { crate::ios::on_webview_created(w.inner() as _, w.view_controller() as _) };
622        })
623        .expect("failed to run on_webview_created hook");
624    }
625
626    let event = crate::EventName::from_str("tauri://webview-created");
627    let payload = Some(crate::webview::CreatedEvent {
628      label: webview.label().into(),
629    });
630
631    let _ = webview
632      .manager
633      .emit(event, EmitPayload::Serialize(&payload));
634
635    webview
636  }
637
638  pub fn eval_script_all<S: Into<String>>(&self, script: S) -> crate::Result<()> {
639    let script = script.into();
640    let webviews = self.webviews_lock().values().cloned().collect::<Vec<_>>();
641    webviews
642      .iter()
643      .try_for_each(|webview| webview.eval(&script))
644  }
645
646  pub fn labels(&self) -> HashSet<String> {
647    self.webviews_lock().keys().cloned().collect()
648  }
649
650  pub(crate) fn invoke_key(&self) -> &str {
651    &self.invoke_key
652  }
653}
654
655impl<R: Runtime> Webview<R> {
656  /// Emits event to [`EventTarget::Window`] and [`EventTarget::WebviewWindow`]
657  fn emit_to_webview<S: Serialize>(
658    &self,
659    event: crate::EventName<&str>,
660    payload: &S,
661  ) -> crate::Result<()> {
662    let window_label = self.label();
663    let payload = EmitPayload::Serialize(payload);
664    self
665      .manager()
666      .emit_filter(event, payload, |target| match target {
667        EventTarget::Webview { label } | EventTarget::WebviewWindow { label } => {
668          label == window_label
669        }
670        _ => false,
671      })
672  }
673}
674
675fn on_webview_event<R: Runtime>(webview: &Webview<R>, event: &WebviewEvent) -> crate::Result<()> {
676  match event {
677    WebviewEvent::DragDrop(event) => match event {
678      DragDropEvent::Enter { paths, position } => {
679        let payload = DragDropPayload {
680          paths: Some(paths),
681          position,
682        };
683        webview.emit_to_webview(DRAG_ENTER_EVENT, &payload)?
684      }
685      DragDropEvent::Over { position } => {
686        let payload = DragDropPayload {
687          position,
688          paths: None,
689        };
690        webview.emit_to_webview(DRAG_OVER_EVENT, &payload)?
691      }
692      DragDropEvent::Drop { paths, position } => {
693        let scopes = webview.state::<Scopes>();
694        for path in paths {
695          if path.is_file() {
696            let _ = scopes.allow_file(path);
697          } else {
698            let _ = scopes.allow_directory(path, false);
699          }
700        }
701        let payload = DragDropPayload {
702          paths: Some(paths),
703          position,
704        };
705        webview.emit_to_webview(DRAG_DROP_EVENT, &payload)?
706      }
707      DragDropEvent::Leave => webview.emit_to_webview(DRAG_LEAVE_EVENT, &())?,
708      _ => unimplemented!(),
709    },
710  }
711
712  Ok(())
713}
714
715fn is_local_network_url(url: &url::Url) -> bool {
716  match url.host() {
717    Some(url::Host::Domain(s)) => s == "localhost",
718    Some(url::Host::Ipv4(_)) | Some(url::Host::Ipv6(_)) => true,
719    None => false,
720  }
721}
722
723#[cfg(test)]
724mod tests {
725  use super::*;
726
727  #[test]
728  fn local_network_url() {
729    assert!(is_local_network_url(&"http://localhost".parse().unwrap()));
730    assert!(is_local_network_url(
731      &"http://127.0.0.1:8080".parse().unwrap()
732    ));
733    assert!(is_local_network_url(
734      &"https://192.168.3.17".parse().unwrap()
735    ));
736
737    assert!(!is_local_network_url(&"https://tauri.app".parse().unwrap()));
738  }
739}