mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-10 21:27:21 +00:00
Fixed internal links opening in the system browser and limited the right-click menu (#1171)
This commit is contained in:
1 parent
c37393817b
commit
bbf4dc2231
8 files changed
+374
-31
No files matched your search
+225
-17
@@ -14,8 +14,8 @@ use pdfium_render::prelude::Pdfium;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent};
|
||||
use tauri::path::PathResolver;
|
||||
use tauri::webview::PageLoadEvent;
|
||||
use tauri::WebviewWindow;
|
||||
use tauri::webview::{NewWindowResponse, PageLoadEvent};
|
||||
use tauri::{WebviewWindow, WebviewWindowBuilder};
|
||||
use tauri::PhysicalPosition;
|
||||
use tauri_plugin_updater::{UpdaterExt, Update};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
@@ -121,26 +121,27 @@ pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
//
|
||||
// The link script of the opener plugin opens every link clicked while holding Shift or
|
||||
// Ctrl in the system browser, links of the app included. Our own script replaces it,
|
||||
// see link_guard.js:
|
||||
//
|
||||
.plugin(tauri_plugin_opener::Builder::new().open_js_links_on_click(false).build())
|
||||
.plugin(
|
||||
tauri::plugin::Builder::<tauri::Wry, ()>::new("external-link-handler")
|
||||
.js_init_script(include_str!("link_guard.js"))
|
||||
.on_navigation(|webview, url| {
|
||||
if !should_open_in_system_browser(webview, url) {
|
||||
let current_url = webview.url().ok();
|
||||
if !should_open_in_system_browser(url, approved_app_url().as_ref(), current_url.as_ref()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
match webview.app_handle().opener().open_url(url.as_str(), None::<&str>) {
|
||||
Ok(_) => {
|
||||
info!(Source = "Tauri"; "Opening external URL in system browser: {url}");
|
||||
},
|
||||
Err(error) => {
|
||||
error!(Source = "Tauri"; "Failed to open external URL '{url}' in system browser: {error}");
|
||||
},
|
||||
}
|
||||
open_in_system_browser(webview.app_handle(), url);
|
||||
false
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(context_menu_guard())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.on_page_load(|webview, payload| {
|
||||
@@ -160,12 +161,42 @@ pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
|
||||
})
|
||||
.setup(move |app| {
|
||||
|
||||
// Get the main window:
|
||||
let window = app.get_webview_window("main").expect("Failed to get main window.");
|
||||
//
|
||||
// We create the main window ourselves rather than letting Tauri create it from the
|
||||
// configuration, where "create" is false therefore. Only a window built here can get a
|
||||
// handler for new windows; neither the configuration nor a plugin can set one.
|
||||
//
|
||||
// AI Studio shows a single window. Whatever asks for another one opens nothing in the
|
||||
// app: a link with a target, a click with Shift or the middle mouse button, or
|
||||
// window.open. An address outside the app goes to the system browser instead, after the
|
||||
// same check as a navigation. The window shows the app as soon as a link can be clicked,
|
||||
// so the approved address of the app decides, and the current address is not needed:
|
||||
//
|
||||
let window_config = app.config().app.windows.iter()
|
||||
.find(|config| config.label == "main")
|
||||
.expect("The configuration lacks the main window.");
|
||||
|
||||
let new_window_app_handle = app.handle().clone();
|
||||
let window = WebviewWindowBuilder::from_config(app.handle(), window_config)
|
||||
.expect("Failed to read the configuration of the main window.")
|
||||
.on_new_window(move |url, _| {
|
||||
if should_open_in_system_browser(&url, approved_app_url().as_ref(), None) {
|
||||
open_in_system_browser(&new_window_app_handle, &url);
|
||||
} else {
|
||||
info!(Source = "Tauri"; "Denied a new window for '{url}'.");
|
||||
}
|
||||
|
||||
NewWindowResponse::Deny
|
||||
})
|
||||
.build()
|
||||
.expect("Failed to create the main window.");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
register_linux_permission_request_handler(&window);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
disable_link_previews(&window);
|
||||
|
||||
// Register a callback for window events, such as file drops. We have to use
|
||||
// this handler in addition to the app event handler, because file drop events
|
||||
// are only available in the window event handler (is a bug, cf. https://github.com/tauri-apps/tauri/issues/14338):
|
||||
@@ -338,6 +369,28 @@ fn should_allow_audio_capture(
|
||||
.is_some_and(|(approved, current)| same_origin(approved, current))
|
||||
}
|
||||
|
||||
/// Switches off the preview which macOS shows when somebody force-clicks a link or looks it up.
|
||||
/// wry switches it on, and Tauri offers no setting for it. The preview would show a page of the app
|
||||
/// outside the window, and a click on it may open the page in the system browser, which cannot
|
||||
/// show it; see link_guard.js.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn disable_link_previews(window: &WebviewWindow) {
|
||||
use objc2::msg_send;
|
||||
use objc2::runtime::{AnyObject, Bool};
|
||||
|
||||
if let Err(error) = window.with_webview(|platform_webview| {
|
||||
let webview = platform_webview.inner().cast::<AnyObject>();
|
||||
|
||||
// Safety: on macOS, the handle is the WKWebView of the window, and Tauri calls this closure
|
||||
// on the main thread, as AppKit requires:
|
||||
unsafe {
|
||||
let _: () = msg_send![webview, setAllowsLinkPreview: Bool::NO];
|
||||
}
|
||||
}) {
|
||||
error!(Source = "Tauri"; "Failed to switch off the link previews: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn register_linux_permission_request_handler(window: &WebviewWindow) {
|
||||
if let Err(error) = window.with_webview(|platform_webview| {
|
||||
@@ -386,7 +439,30 @@ fn register_linux_permission_request_handler(window: &WebviewWindow) {
|
||||
}
|
||||
}
|
||||
|
||||
fn should_open_in_system_browser<R: tauri::Runtime>(webview: &tauri::Webview<R>, url: &tauri::Url) -> bool {
|
||||
/// Limits the right-click menu to text fields and selected text in every frame, see
|
||||
/// context_menu_guard.js. Development builds keep the menu, since it opens the developer tools,
|
||||
/// which Tauri offers in these builds only.
|
||||
fn context_menu_guard() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
let plugin = tauri::plugin::Builder::<tauri::Wry, ()>::new("context-menu-guard");
|
||||
if cfg!(debug_assertions) {
|
||||
return plugin.build();
|
||||
}
|
||||
|
||||
plugin.js_init_script_on_all_frames(include_str!("context_menu_guard.js")).build()
|
||||
}
|
||||
|
||||
/// The address of the app, once the window shows it.
|
||||
fn approved_app_url() -> Option<tauri::Url> {
|
||||
APPROVED_APP_URL.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Decides whether an address leaves the window for the system: a website for the browser, or a
|
||||
/// `mailto:` or `tel:` link for the mail or phone app. Navigations and requests for new windows
|
||||
/// both ask here, so that one rule decides what leaves the window.
|
||||
///
|
||||
/// An address of the app never leaves: a browser outside the window has no session cookie, see
|
||||
/// app_access.rs. Another local server, such as a local LLM, leaves once the window shows the app.
|
||||
fn should_open_in_system_browser(url: &tauri::Url, approved_app_url: Option<&tauri::Url>, current_url: Option<&tauri::Url>) -> bool {
|
||||
match url.scheme() {
|
||||
"mailto" | "tel" => return true,
|
||||
"http" | "https" => {},
|
||||
@@ -397,7 +473,7 @@ fn should_open_in_system_browser<R: tauri::Runtime>(webview: &tauri::Webview<R>,
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(approved_app_url) = APPROVED_APP_URL.lock().unwrap().as_ref() {
|
||||
if let Some(approved_app_url) = approved_app_url {
|
||||
if same_origin(approved_app_url, url) {
|
||||
return false;
|
||||
}
|
||||
@@ -407,13 +483,76 @@ fn should_open_in_system_browser<R: tauri::Runtime>(webview: &tauri::Webview<R>,
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(current_url) = webview.url() && same_origin(¤t_url, url) {
|
||||
if current_url.is_some_and(|current_url| same_origin(current_url, url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!is_local_host(url.host_str())
|
||||
}
|
||||
|
||||
/// Hands an address outside the app to the system, see `should_open_in_system_browser`.
|
||||
///
|
||||
/// On Linux, the desktop portal opens it, as it does for the Flatpak anyway. The opener plugin
|
||||
/// starts the xdg-open of the system with the environment of AI Studio, which an AppImage points
|
||||
/// at its own GTK and GIO modules: the browser then stays closed, and no error tells why. The
|
||||
/// opener plugin remains for systems without a portal.
|
||||
fn open_in_system_browser<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>, url: &tauri::Url) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use crate::file_actions::LinuxPortalError;
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
let url = url.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match open_url_with_linux_portal(&url).await {
|
||||
Ok(()) => {
|
||||
info!(Source = "Tauri"; "Opening external URL through the desktop portal: {url}");
|
||||
},
|
||||
Err(LinuxPortalError::RequestFailed(error)) => {
|
||||
error!(Source = "Tauri"; "Failed to open external URL '{url}' through the desktop portal: {error}");
|
||||
},
|
||||
Err(LinuxPortalError::Unavailable(error)) => {
|
||||
warn!(Source = "Tauri"; "The desktop portal cannot open external URL '{url}', so the opener plugin opens it: {error}");
|
||||
open_with_opener(&app_handle, &url);
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
open_with_opener(app_handle, url);
|
||||
}
|
||||
|
||||
/// Opens an address outside the app through the desktop portal, see `open_in_system_browser`.
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn open_url_with_linux_portal(url: &tauri::Url) -> Result<(), crate::file_actions::LinuxPortalError> {
|
||||
use ashpd::desktop::open_uri::OpenFileRequest;
|
||||
use crate::file_actions::LinuxPortalError;
|
||||
|
||||
let uri = ashpd::Uri::parse(url.as_str())
|
||||
.map_err(|error| LinuxPortalError::RequestFailed(format!("The desktop portal cannot take the address: {error}")))?;
|
||||
|
||||
let request = OpenFileRequest::default()
|
||||
.send_uri(&uri)
|
||||
.await
|
||||
.map_err(|error| LinuxPortalError::Unavailable(format!("Desktop portal invocation failed: {error}")))?;
|
||||
|
||||
request.response()
|
||||
.map_err(|error| LinuxPortalError::RequestFailed(format!("Desktop portal request failed: {error}")))
|
||||
}
|
||||
|
||||
/// Opens an address outside the app with the opener plugin, see `open_in_system_browser`.
|
||||
fn open_with_opener<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>, url: &tauri::Url) {
|
||||
match app_handle.opener().open_url(url.as_str(), None::<&str>) {
|
||||
Ok(_) => {
|
||||
info!(Source = "Tauri"; "Opening external URL in system browser: {url}");
|
||||
},
|
||||
Err(error) => {
|
||||
error!(Source = "Tauri"; "Failed to open external URL '{url}' in system browser: {error}");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Our event API endpoint for Tauri events. We try to send an endless stream of events to the client.
|
||||
/// If no events are available for a certain time, we send a ping event to keep the connection alive.
|
||||
/// When the client disconnects, the stream is closed. But we try to not lose events in between.
|
||||
@@ -1365,6 +1504,75 @@ mod tests {
|
||||
assert!(!is_local_http_url(&url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_url_never_opens_in_system_browser() {
|
||||
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
||||
for address in [
|
||||
"http://127.0.0.1:12345/",
|
||||
"http://127.0.0.1:12345/chat",
|
||||
"http://127.0.0.1:12345/settings?section=providers",
|
||||
"http://127.0.0.1:12345/about#licenses",
|
||||
] {
|
||||
let url = tauri::Url::parse(address).unwrap();
|
||||
|
||||
assert!(!should_open_in_system_browser(&url, Some(&approved), None), "{address}");
|
||||
assert!(!should_open_in_system_browser(&url, Some(&approved), Some(&approved)), "{address}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_url_stays_in_window_before_it_is_approved() {
|
||||
let current = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
||||
let url = tauri::Url::parse("http://127.0.0.1:12345/chat").unwrap();
|
||||
|
||||
assert!(!should_open_in_system_browser(&url, None, None));
|
||||
assert!(!should_open_in_system_browser(&url, None, Some(¤t)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_website_opens_in_system_browser() {
|
||||
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
||||
let url = tauri::Url::parse("https://example.com/docs").unwrap();
|
||||
|
||||
assert!(should_open_in_system_browser(&url, Some(&approved), None));
|
||||
assert!(should_open_in_system_browser(&url, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mail_and_phone_links_open_in_system() {
|
||||
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
||||
for address in ["mailto:someone@example.com", "tel:+490000000"] {
|
||||
let url = tauri::Url::parse(address).unwrap();
|
||||
|
||||
assert!(should_open_in_system_browser(&url, Some(&approved), None), "{address}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_local_server_opens_in_system_browser_once_app_is_approved() {
|
||||
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
||||
let url = tauri::Url::parse("http://localhost:11434/").unwrap();
|
||||
|
||||
assert!(should_open_in_system_browser(&url, Some(&approved), None));
|
||||
assert!(!should_open_in_system_browser(&url, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_schemes_never_open_in_system_browser() {
|
||||
let approved = tauri::Url::parse("http://127.0.0.1:12345/").unwrap();
|
||||
for address in [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,hello",
|
||||
"tauri://localhost/index.html",
|
||||
"https://tauri.localhost/index.html",
|
||||
] {
|
||||
let url = tauri::Url::parse(address).unwrap();
|
||||
|
||||
assert!(!should_open_in_system_browser(&url, Some(&approved), None), "{address}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_capture_is_allowed_for_exact_approved_app_origin() {
|
||||
let approved = tauri::Url::parse("http://localhost:12345/").unwrap();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// Shows the right-click menu of the webview only where it helps: in text fields, where it offers
|
||||
// pasting and spelling suggestions, and on selected text, where it offers copying. Elsewhere, its
|
||||
// entries make no sense in the app, such as going back, reloading, printing, or opening a link in
|
||||
// a new window. On a link, the menu never shows, not even within a selection.
|
||||
//
|
||||
// The runtime adds this script to every frame of the window, but only in release builds:
|
||||
// development builds keep the menu, since it opens the developer tools. See runtime/AGENTS.md.
|
||||
//
|
||||
(() => {
|
||||
const TEXT_INPUT_TYPES = ['text', 'search', 'email', 'url', 'tel', 'password', 'number'];
|
||||
|
||||
const isTextField = element =>
|
||||
element instanceof HTMLTextAreaElement
|
||||
|| (element instanceof HTMLInputElement && TEXT_INPUT_TYPES.includes(element.type))
|
||||
|| (element instanceof HTMLElement && element.isContentEditable);
|
||||
|
||||
const isOnSelection = event => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.toString().trim() === '')
|
||||
return false;
|
||||
|
||||
for (let index = 0; index < selection.rangeCount; index++)
|
||||
for (const rect of selection.getRangeAt(index).getClientRects())
|
||||
if (event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
window.addEventListener('contextmenu', event => {
|
||||
const path = event.composedPath();
|
||||
if (path.some(node => node instanceof HTMLAnchorElement && node.hasAttribute('href'))) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = path.find(node => node instanceof Element);
|
||||
if (target && isTextField(target))
|
||||
return;
|
||||
|
||||
if (isOnSelection(event))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
}, true);
|
||||
})();
|
||||
@@ -1139,7 +1139,7 @@ fn xdg_open_fallback_path(target: &FileManagerTarget) -> &Path {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
enum LinuxPortalError {
|
||||
pub(crate) enum LinuxPortalError {
|
||||
Unavailable(String),
|
||||
RequestFailed(String),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// Keeps the links of the app inside its window and hands every other link to the runtime. The
|
||||
// runtime adds this script to the main frame of the window, where it runs before any script of the
|
||||
// page; see "Only the runtime lets links leave the window" in runtime/AGENTS.md.
|
||||
//
|
||||
// This script acts on a click which would open a link somewhere else: a click while holding Shift,
|
||||
// Ctrl, Cmd, or Alt, a click with another mouse button than the main one, or a click on a link with
|
||||
// a target such as _blank.
|
||||
//
|
||||
// On a link of the app, such a click does nothing. A browser outside the window cannot show the
|
||||
// app anyway, since it lacks the session cookie, and the webview would load the whole app anew.
|
||||
//
|
||||
// On any other link, the click goes on to the webview, which asks the runtime for a new window or
|
||||
// a navigation. The runtime opens the link in the system browser instead, on Linux through the
|
||||
// desktop portal. No script of the page sees such a click: the link script of the shell plugin
|
||||
// would open every link with a target itself, links of the app included, and through an xdg-open
|
||||
// which fails in an AppImage.
|
||||
//
|
||||
// Dragging a link of the app does nothing either, since dropping it onto a browser would open it
|
||||
// there.
|
||||
//
|
||||
(() => {
|
||||
const TARGETS_OF_THIS_WINDOW = ['', '_self', '_top', '_parent'];
|
||||
|
||||
const findLink = event =>
|
||||
event.composedPath().find(node => node instanceof HTMLAnchorElement && node.hasAttribute('href')) ?? null;
|
||||
|
||||
// Where a link leads: 'app', 'outside', or null for an address the webview cannot read.
|
||||
const destinationOf = link => {
|
||||
try {
|
||||
return new URL(link.href, location.href).origin === location.origin ? 'app' : 'outside';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const opensElsewhere = (event, link) =>
|
||||
event.button !== 0
|
||||
|| event.shiftKey
|
||||
|| event.ctrlKey
|
||||
|| event.metaKey
|
||||
|| event.altKey
|
||||
|| !TARGETS_OF_THIS_WINDOW.includes(link.target.toLowerCase());
|
||||
|
||||
const handleClick = event => {
|
||||
const link = findLink(event);
|
||||
if (!link || !opensElsewhere(event, link))
|
||||
return;
|
||||
|
||||
switch (destinationOf(link)) {
|
||||
case 'app':
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
break;
|
||||
|
||||
case 'outside':
|
||||
event.stopImmediatePropagation();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleClick, true);
|
||||
window.addEventListener('auxclick', handleClick, true);
|
||||
window.addEventListener('dragstart', event => {
|
||||
const link = findLink(event);
|
||||
if (link && destinationOf(link) === 'app')
|
||||
event.preventDefault();
|
||||
}, true);
|
||||
})();
|
||||
Reference in new issue
Block a user