Upload files to "src"

This commit is contained in:
Martin Dvořák 2026-04-30 14:05:31 +02:00
commit d51a9eabe9
2 changed files with 165 additions and 159 deletions

View file

@ -16,7 +16,10 @@ pub enum LinuxBackend {
version version
)] )]
pub struct Cli { pub struct Cli {
#[arg(long, help = "Optional startup URL (defaults to https://duckduckgo.com)")] #[arg(
long,
help = "Optional startup URL (defaults to https://duckduckgo.com)"
)]
pub url: Option<String>, pub url: Option<String>,
#[arg(long, help = "Set a custom window title")] #[arg(long, help = "Set a custom window title")]
@ -32,7 +35,7 @@ pub struct Cli {
long, long,
value_enum, value_enum,
default_value_t = LinuxBackend::Auto, default_value_t = LinuxBackend::Auto,
help = "Linux only: prefer a windowing backend (auto, x11, wayland)" help = "Linux only: select a GTK windowing backend (auto, x11, wayland)"
)] )]
pub linux_backend: LinuxBackend, pub linux_backend: LinuxBackend,

View file

@ -2,6 +2,8 @@
use crate::cli::Cli; use crate::cli::Cli;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use base64::Engine; use base64::Engine;
#[cfg(target_os = "linux")]
use gtk::prelude::*;
use serde_json::json; use serde_json::json;
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
@ -9,13 +11,11 @@ use std::path::{Path, PathBuf};
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(target_os = "linux")]
use gtk::prelude::*;
#[cfg(target_os = "linux")]
use tao::platform::unix::WindowExtUnix;
use tao::dpi::LogicalSize; use tao::dpi::LogicalSize;
use tao::event::{Event, WindowEvent}; use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy}; use tao::event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy};
#[cfg(target_os = "linux")]
use tao::platform::unix::WindowExtUnix;
use tao::window::{Icon, Window, WindowBuilder}; use tao::window::{Icon, Window, WindowBuilder};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use wry::WebViewBuilderExtUnix; use wry::WebViewBuilderExtUnix;
@ -38,13 +38,31 @@ const MAX_TAB_NAV_ENTRIES: usize = 100;
#[derive(Debug)] #[derive(Debug)]
enum UserEvent { enum UserEvent {
ChromeIpc(String), ChromeIpc(String),
TitleChanged { tab_id: u32, title: String }, TitleChanged {
PageLoaded { tab_id: u32, url: String }, tab_id: u32,
DownloadStarted { url: String, file_path: String }, title: String,
DownloadTotal { file_path: String, total_bytes: Option<u64> }, },
PageLoaded {
tab_id: u32,
url: String,
},
DownloadStarted {
url: String,
file_path: String,
},
DownloadTotal {
file_path: String,
total_bytes: Option<u64>,
},
DownloadTick, DownloadTick,
DownloadCompleted { url: String, file_path: Option<String>, success: bool }, DownloadCompleted {
OpenInNewTab { url: String }, url: String,
file_path: Option<String>,
success: bool,
},
OpenInNewTab {
url: String,
},
Quit, Quit,
} }
@ -713,7 +731,10 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
.as_deref() .as_deref()
.map(normalize_url) .map(normalize_url)
.unwrap_or_else(|| DEFAULT_HOME.to_string()); .unwrap_or_else(|| DEFAULT_HOME.to_string());
let title = args.title.clone().unwrap_or_else(|| "RustyWolf".to_string()); let title = args
.title
.clone()
.unwrap_or_else(|| "RustyWolf".to_string());
if args.dry_run { if args.dry_run {
println!( println!(
@ -773,10 +794,12 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
open_tab(&mut state, &window, &proxy, start_url)?; open_tab(&mut state, &window, &proxy, start_url)?;
let tick_proxy = proxy.clone(); let tick_proxy = proxy.clone();
thread::spawn(move || loop { thread::spawn(move || {
thread::sleep(Duration::from_millis(300)); loop {
if tick_proxy.send_event(UserEvent::DownloadTick).is_err() { thread::sleep(Duration::from_millis(300));
break; if tick_proxy.send_event(UserEvent::DownloadTick).is_err() {
break;
}
} }
}); });
@ -790,10 +813,16 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
eprintln!("event error: {e:#}"); eprintln!("event error: {e:#}");
} }
} }
Event::WindowEvent { event: WindowEvent::Resized(_size), .. } => { Event::WindowEvent {
event: WindowEvent::Resized(_size),
..
} => {
reflow_layout(&window, &state); reflow_layout(&window, &state);
} }
Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => { Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
*control_flow = ControlFlow::Exit; *control_flow = ControlFlow::Exit;
} }
_ => {} _ => {}
@ -808,18 +837,14 @@ fn apply_linux_runtime_overrides(_args: &Cli) {
match _args.linux_backend { match _args.linux_backend {
crate::cli::LinuxBackend::Auto => {} crate::cli::LinuxBackend::Auto => {}
crate::cli::LinuxBackend::X11 => { crate::cli::LinuxBackend::X11 => unsafe {
unsafe { std::env::set_var("WINIT_UNIX_BACKEND", "x11");
std::env::set_var("WINIT_UNIX_BACKEND", "x11"); std::env::set_var("GDK_BACKEND", "x11");
std::env::set_var("GDK_BACKEND", "x11"); },
} crate::cli::LinuxBackend::Wayland => unsafe {
} std::env::set_var("WINIT_UNIX_BACKEND", "wayland");
crate::cli::LinuxBackend::Wayland => { std::env::set_var("GDK_BACKEND", "wayland");
unsafe { },
std::env::set_var("WINIT_UNIX_BACKEND", "wayland");
std::env::set_var("GDK_BACKEND", "wayland");
}
}
} }
if _args.linux_disable_dmabuf { if _args.linux_disable_dmabuf {
@ -844,7 +869,9 @@ fn sanitize_gtk_modules_env() {
match std::env::var(name) { match std::env::var(name) {
Ok(raw) => { Ok(raw) => {
let mut kept = Vec::new(); let mut kept = Vec::new();
for token in raw.split(|c: char| c == ':' || c == ';' || c == ',' || c.is_ascii_whitespace()) { for token in
raw.split(|c: char| c == ':' || c == ';' || c == ',' || c.is_ascii_whitespace())
{
let trimmed = token.trim(); let trimmed = token.trim();
if trimmed.is_empty() || is_appmenu_module(trimmed) { if trimmed.is_empty() || is_appmenu_module(trimmed) {
continue; continue;
@ -882,27 +909,8 @@ fn sanitize_gtk_modules_env() {
fn resolve_webview_host_mode(args: &Cli) -> WebViewHostMode { fn resolve_webview_host_mode(args: &Cli) -> WebViewHostMode {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
return match args.linux_backend { let _ = args;
crate::cli::LinuxBackend::X11 => WebViewHostMode::Child, WebViewHostMode::Window
crate::cli::LinuxBackend::Wayland => WebViewHostMode::Window,
crate::cli::LinuxBackend::Auto => {
let forced = std::env::var("WINIT_UNIX_BACKEND").ok();
if forced.as_deref() == Some("x11") {
return WebViewHostMode::Child;
}
if forced.as_deref() == Some("wayland") {
return WebViewHostMode::Window;
}
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
let has_wayland_display = std::env::var_os("WAYLAND_DISPLAY").is_some();
if session_type.eq_ignore_ascii_case("wayland") || has_wayland_display {
WebViewHostMode::Window
} else {
WebViewHostMode::Child
}
}
};
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
@ -956,7 +964,11 @@ fn handle_event(
state.tabs[i].url.clone(), state.tabs[i].url.clone(),
); );
} }
let enabled = if state.adblock_enabled { "true" } else { "false" }; let enabled = if state.adblock_enabled {
"true"
} else {
"false"
};
if let Some(webview) = state.tabs[i].webview.as_ref() { if let Some(webview) = state.tabs[i].webview.as_ref() {
let _ = webview.evaluate_script(&format!( let _ = webview.evaluate_script(&format!(
"window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});" "window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});"
@ -979,7 +991,10 @@ fn handle_event(
trim_downloads(state); trim_downloads(state);
push_downloads(state); push_downloads(state);
} }
UserEvent::DownloadTotal { file_path, total_bytes } => { UserEvent::DownloadTotal {
file_path,
total_bytes,
} => {
if let Some(entry) = state if let Some(entry) = state
.downloads .downloads
.iter_mut() .iter_mut()
@ -1005,7 +1020,11 @@ fn handle_event(
push_downloads(state); push_downloads(state);
} }
} }
UserEvent::DownloadCompleted { url, file_path, success } => { UserEvent::DownloadCompleted {
url,
file_path,
success,
} => {
let path = file_path.unwrap_or_else(|| "Unknown destination".to_string()); let path = file_path.unwrap_or_else(|| "Unknown destination".to_string());
if let Some(entry) = state if let Some(entry) = state
.downloads .downloads
@ -1262,25 +1281,15 @@ fn build_chrome(
WebViewHostMode::Window => { WebViewHostMode::Window => {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if let Some(container) = gtk_chrome_container { let container = gtk_chrome_container.ok_or_else(|| {
match builder().build_gtk(container) { anyhow!("Failed to create chrome webview: missing Linux GTK chrome container")
Ok(webview) => return Ok((webview, WebViewHostMode::Window)), })?;
Err(gtk_err) => { builder()
return builder() .build_gtk(container)
.build(window)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|fallback_err| {
anyhow!(
"Failed to create chrome webview: gtk hosting failed ({gtk_err}); window fallback failed ({fallback_err})"
)
});
}
}
}
return builder()
.build(window)
.map(|webview| (webview, WebViewHostMode::Window)) .map(|webview| (webview, WebViewHostMode::Window))
.map_err(|err| anyhow!("Failed to create chrome webview: {err}")); .map_err(|err| {
anyhow!("Failed to create chrome webview with GTK hosting: {err}")
})
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
@ -1302,28 +1311,18 @@ fn build_chrome(
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if let Some(container) = gtk_chrome_container { if let Some(container) = gtk_chrome_container {
match builder().build_gtk(container) { return builder()
Ok(webview) => return Ok((webview, WebViewHostMode::Window)), .build_gtk(container)
Err(gtk_err) => { .map(|webview| (webview, WebViewHostMode::Window))
return builder() .map_err(|gtk_err| {
.build(window) anyhow!(
.map(|webview| (webview, WebViewHostMode::Window)) "Failed to create chrome webview: child failed ({err_text}); GTK fallback failed ({gtk_err})"
.map_err(|fallback_err| { )
anyhow!( });
"Failed to create chrome webview: child failed ({err_text}); gtk fallback failed ({gtk_err}); window fallback failed ({fallback_err})"
)
});
}
}
} }
return builder() return Err(anyhow!(
.build(window) "Failed to create chrome webview: child failed ({err_text}); missing Linux GTK chrome container"
.map(|webview| (webview, WebViewHostMode::Window)) ));
.map_err(|fallback_err| {
anyhow!(
"Failed to create chrome webview: child failed ({err_text}); window fallback failed ({fallback_err})"
)
});
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
@ -1405,7 +1404,11 @@ fn build_tab_webview(
} }
*destination = path.clone(); *destination = path.clone();
let path_string = path_to_string(&path); let path_string = path_to_string(&path);
fetch_download_total(url.clone(), path_string.clone(), download_total_proxy.clone()); fetch_download_total(
url.clone(),
path_string.clone(),
download_total_proxy.clone(),
);
let _ = download_started_proxy.send_event(UserEvent::DownloadStarted { let _ = download_started_proxy.send_event(UserEvent::DownloadStarted {
url, url,
file_path: path_string, file_path: path_string,
@ -1425,25 +1428,13 @@ fn build_tab_webview(
WebViewHostMode::Window => { WebViewHostMode::Window => {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if let Some(container) = gtk_content_container { let container = gtk_content_container.ok_or_else(|| {
match builder().build_gtk(container) { anyhow!("Failed to create tab webview: missing Linux GTK content container")
Ok(webview) => return Ok((webview, WebViewHostMode::Window)), })?;
Err(gtk_err) => { builder()
return builder() .build_gtk(container)
.build(window)
.map(|webview| (webview, WebViewHostMode::Window))
.map_err(|fallback_err| {
anyhow!(
"Failed to create tab webview: gtk hosting failed ({gtk_err}); window fallback failed ({fallback_err})"
)
});
}
}
}
return builder()
.build(window)
.map(|webview| (webview, WebViewHostMode::Window)) .map(|webview| (webview, WebViewHostMode::Window))
.map_err(|err| anyhow!("Failed to create tab webview: {err}")); .map_err(|err| anyhow!("Failed to create tab webview with GTK hosting: {err}"))
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
@ -1465,28 +1456,18 @@ fn build_tab_webview(
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
if let Some(container) = gtk_content_container { if let Some(container) = gtk_content_container {
match builder().build_gtk(container) { return builder()
Ok(webview) => return Ok((webview, WebViewHostMode::Window)), .build_gtk(container)
Err(gtk_err) => { .map(|webview| (webview, WebViewHostMode::Window))
return builder() .map_err(|gtk_err| {
.build(window) anyhow!(
.map(|webview| (webview, WebViewHostMode::Window)) "Failed to create tab webview: child failed ({err_text}); GTK fallback failed ({gtk_err})"
.map_err(|fallback_err| { )
anyhow!( });
"Failed to create tab webview: child failed ({err_text}); gtk fallback failed ({gtk_err}); window fallback failed ({fallback_err})"
)
});
}
}
} }
return builder() return Err(anyhow!(
.build(window) "Failed to create tab webview: child failed ({err_text}); missing Linux GTK content container"
.map(|webview| (webview, WebViewHostMode::Window)) ));
.map_err(|fallback_err| {
anyhow!(
"Failed to create tab webview: child failed ({err_text}); window fallback failed ({fallback_err})"
)
});
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
@ -1580,7 +1561,9 @@ fn close_tab(
proxy: &EventLoopProxy<UserEvent>, proxy: &EventLoopProxy<UserEvent>,
tab_id: u32, tab_id: u32,
) -> Result<bool> { ) -> Result<bool> {
let Some(index) = state.find_index(tab_id) else { return Ok(false); }; let Some(index) = state.find_index(tab_id) else {
return Ok(false);
};
state.tabs.remove(index); state.tabs.remove(index);
if state.tabs.is_empty() { if state.tabs.is_empty() {
@ -1669,7 +1652,9 @@ fn apply_visibility(state: &BrowserState) {
} }
fn push_tabs(state: &BrowserState) { fn push_tabs(state: &BrowserState) {
if state.tabs.is_empty() { return; } if state.tabs.is_empty() {
return;
}
let payload = json!({ let payload = json!({
"tabs": state.tabs.iter().map(|t| json!({ "tabs": state.tabs.iter().map(|t| json!({
"id": t.id, "id": t.id,
@ -1705,15 +1690,19 @@ fn push_downloads(state: &BrowserState) {
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
}) })
.to_string(); .to_string();
let script = format!( let script =
"window.__rustywolfSetDownloads && window.__rustywolfSetDownloads({payload});" format!("window.__rustywolfSetDownloads && window.__rustywolfSetDownloads({payload});");
);
let _ = state.chrome.evaluate_script(&script); let _ = state.chrome.evaluate_script(&script);
} }
fn push_adblock(state: &BrowserState) { fn push_adblock(state: &BrowserState) {
let enabled = if state.adblock_enabled { "true" } else { "false" }; let enabled = if state.adblock_enabled {
let script = format!("window.__rustywolfSetAdblock && window.__rustywolfSetAdblock({enabled});"); "true"
} else {
"false"
};
let script =
format!("window.__rustywolfSetAdblock && window.__rustywolfSetAdblock({enabled});");
let _ = state.chrome.evaluate_script(&script); let _ = state.chrome.evaluate_script(&script);
} }
@ -1726,14 +1715,17 @@ fn push_popup_prompt(state: &BrowserState) {
None => json!({ "visible": false }), None => json!({ "visible": false }),
} }
.to_string(); .to_string();
let script = format!( let script =
"window.__rustywolfSetPopupPrompt && window.__rustywolfSetPopupPrompt({payload});" format!("window.__rustywolfSetPopupPrompt && window.__rustywolfSetPopupPrompt({payload});");
);
let _ = state.chrome.evaluate_script(&script); let _ = state.chrome.evaluate_script(&script);
} }
fn apply_adblock_setting_to_tabs(state: &BrowserState) { fn apply_adblock_setting_to_tabs(state: &BrowserState) {
let enabled = if state.adblock_enabled { "true" } else { "false" }; let enabled = if state.adblock_enabled {
"true"
} else {
"false"
};
let script = format!( let script = format!(
"window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});" "window.__rustywolfSetAdblockEnabled && window.__rustywolfSetAdblockEnabled({enabled});"
); );
@ -1860,7 +1852,9 @@ fn chrome_height(state: &BrowserState) -> f64 {
fn normalize_url(input: &str) -> String { fn normalize_url(input: &str) -> String {
let trimmed = input.trim(); let trimmed = input.trim();
if trimmed == "about:blank" { return trimmed.to_string(); } if trimmed == "about:blank" {
return trimmed.to_string();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") { if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return trimmed.to_string(); return trimmed.to_string();
} }
@ -1963,8 +1957,7 @@ fn render_properties_html(state: &BrowserState) -> String {
<body><h1>Browser Properties</h1><p class=\"muted\">Session history and download activity.</p>\ <body><h1>Browser Properties</h1><p class=\"muted\">Session history and download activity.</p>\
<h2>Browsing History</h2><p class=\"muted\">{}</p><table><thead><tr><th>Visited</th><th>Title</th><th>URL</th></tr></thead><tbody>{history_rows}</tbody></table>\ <h2>Browsing History</h2><p class=\"muted\">{}</p><table><thead><tr><th>Visited</th><th>Title</th><th>URL</th></tr></thead><tbody>{history_rows}</tbody></table>\
<h2>Download History</h2><p class=\"muted\">{}</p><table><thead><tr><th>Updated</th><th>Status</th><th>Saved To</th><th>Source URL</th></tr></thead><tbody>{download_rows}</tbody></table>\ <h2>Download History</h2><p class=\"muted\">{}</p><table><thead><tr><th>Updated</th><th>Status</th><th>Saved To</th><th>Source URL</th></tr></thead><tbody>{download_rows}</tbody></table>\
</body></html>" </body></html>",
,
if history_is_truncated { if history_is_truncated {
format!("Showing latest {PROPERTIES_MAX_ROWS} entries.") format!("Showing latest {PROPERTIES_MAX_ROWS} entries.")
} else { } else {
@ -2105,8 +2098,8 @@ fn path_string_to_file_url(path: &str) -> String {
fn sanitize_filename(raw: &str) -> String { fn sanitize_filename(raw: &str) -> String {
let mut out = String::with_capacity(raw.len()); let mut out = String::with_capacity(raw.len());
for ch in raw.chars() { for ch in raw.chars() {
let invalid = matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') let invalid =
|| ch.is_control(); matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || ch.is_control();
out.push(if invalid { '_' } else { ch }); out.push(if invalid { '_' } else { ch });
} }
let trimmed = out.trim_matches('.').trim(); let trimmed = out.trim_matches('.').trim();
@ -2119,10 +2112,10 @@ fn sanitize_filename(raw: &str) -> String {
fn fetch_download_total(url: String, file_path: String, proxy: EventLoopProxy<UserEvent>) { fn fetch_download_total(url: String, file_path: String, proxy: EventLoopProxy<UserEvent>) {
thread::spawn(move || { thread::spawn(move || {
let total_bytes = ureq::head(&url) let total_bytes = ureq::head(&url).call().ok().and_then(|resp| {
.call() resp.header("Content-Length")
.ok() .and_then(|v| v.parse::<u64>().ok())
.and_then(|resp| resp.header("Content-Length").and_then(|v| v.parse::<u64>().ok())); });
let _ = proxy.send_event(UserEvent::DownloadTotal { let _ = proxy.send_event(UserEvent::DownloadTotal {
file_path, file_path,
total_bytes, total_bytes,
@ -2162,7 +2155,10 @@ mod tests {
#[test] #[test]
fn converts_search_terms_to_duckduckgo_query() { fn converts_search_terms_to_duckduckgo_query() {
assert_eq!(normalize_url("privacy browser"), "https://duckduckgo.com/?q=privacy+browser"); assert_eq!(
normalize_url("privacy browser"),
"https://duckduckgo.com/?q=privacy+browser"
);
} }
#[test] #[test]
@ -2172,7 +2168,10 @@ mod tests {
#[test] #[test]
fn trims_before_search() { fn trims_before_search() {
assert_eq!(normalize_url(" rust webview "), "https://duckduckgo.com/?q=rust+webview"); assert_eq!(
normalize_url(" rust webview "),
"https://duckduckgo.com/?q=rust+webview"
);
} }
#[test] #[test]
@ -2183,9 +2182,13 @@ mod tests {
#[test] #[test]
fn blocks_duckduckgo_internal_popup_paths() { fn blocks_duckduckgo_internal_popup_paths() {
assert!(!should_open_new_tab_url("https://duckduckgo.com/post3.html")); assert!(!should_open_new_tab_url(
"https://duckduckgo.com/post3.html"
));
assert!(!should_open_new_tab_url("https://duckduckgo.com/page3")); assert!(!should_open_new_tab_url("https://duckduckgo.com/page3"));
assert!(!should_open_new_tab_url("https://duckduckgo.com/page3.html")); assert!(!should_open_new_tab_url(
"https://duckduckgo.com/page3.html"
));
} }
#[test] #[test]