diff --git a/src/app/browser.rs b/src/app/browser.rs index 85f56bbe..59109ffc 100644 --- a/src/app/browser.rs +++ b/src/app/browser.rs @@ -1,16 +1,18 @@ mod about; mod action; mod database; +mod proxy; mod widget; pub mod window; use about::About; use action::Action; +use proxy::Proxy; use widget::Widget; use window::Window; use crate::Profile; -use adw::{AboutDialog, Application, prelude::AdwDialogExt}; +use adw::{AboutDialog, Application, PreferencesDialog, prelude::AdwDialogExt}; use anyhow::Result; use gtk::{ FileLauncher, @@ -91,6 +93,12 @@ impl Browser { } }); + action.proxy.connect_activate({ + let profile = profile.clone(); + let window = window.clone(); + move || PreferencesDialog::proxy(&profile).present(Some(&window.g_box)) + }); + // Return new activated `Self` Self { action, diff --git a/src/app/browser/action.rs b/src/app/browser/action.rs index 74cd2430..9a71a9bd 100644 --- a/src/app/browser/action.rs +++ b/src/app/browser/action.rs @@ -2,11 +2,13 @@ mod about; mod close; mod debug; mod profile; +mod proxy; use about::About; use close::Close; use debug::Debug; use profile::Profile; +use proxy::Proxy; use gtk::{ gio::SimpleActionGroup, @@ -22,6 +24,7 @@ pub struct Action { pub close: Rc, pub debug: Rc, pub profile: Rc, + pub proxy: Rc, // Group pub id: GString, pub simple_action_group: SimpleActionGroup, @@ -43,6 +46,7 @@ impl Action { let close = Rc::new(Close::new()); let debug = Rc::new(Debug::new()); let profile = Rc::new(Profile::new()); + let proxy = Rc::new(Proxy::new()); // Generate unique group ID let id = uuid_string_random(); @@ -55,6 +59,7 @@ impl Action { simple_action_group.add_action(&close.simple_action); simple_action_group.add_action(&debug.simple_action); simple_action_group.add_action(&profile.simple_action); + simple_action_group.add_action(&proxy.simple_action); // Done Self { @@ -62,6 +67,7 @@ impl Action { close, debug, profile, + proxy, id, simple_action_group, } diff --git a/src/app/browser/action/proxy.rs b/src/app/browser/action/proxy.rs new file mode 100644 index 00000000..a6945d5e --- /dev/null +++ b/src/app/browser/action/proxy.rs @@ -0,0 +1,31 @@ +use gtk::{gio::SimpleAction, glib::uuid_string_random}; + +/// [SimpleAction](https://docs.gtk.org/gio/class.SimpleAction.html) wrapper for `Profile` action of `Browser` group +pub struct Proxy { + pub simple_action: SimpleAction, +} + +impl Default for Proxy { + fn default() -> Self { + Self::new() + } +} + +impl Proxy { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + simple_action: SimpleAction::new(&uuid_string_random(), None), + } + } + + // Events + + /// Define callback function for + /// [SimpleAction::activate](https://docs.gtk.org/gio/signal.SimpleAction.activate.html) signal + pub fn connect_activate(&self, callback: impl Fn() + 'static) { + self.simple_action.connect_activate(move |_, _| callback()); + } +} diff --git a/src/app/browser/proxy.rs b/src/app/browser/proxy.rs new file mode 100644 index 00000000..0ed3d6af --- /dev/null +++ b/src/app/browser/proxy.rs @@ -0,0 +1,80 @@ +//! Proxy settings dialog + +mod rules; + +use super::Profile; +use adw::{ + PreferencesGroup, PreferencesPage, + prelude::{AdwDialogExt, PreferencesDialogExt, PreferencesGroupExt, PreferencesPageExt}, +}; +use rules::Rules; +use std::rc::Rc; + +pub trait Proxy { + fn proxy(profile: &Rc) -> Self; +} + +impl Proxy for adw::PreferencesDialog { + fn proxy(profile: &Rc) -> Self { + // Init components + let rules = Rules::build(profile); + + // Init widget + let d = adw::PreferencesDialog::builder() + .search_enabled(true) + .title("Proxy") + .build(); + + d.add(&{ + let p = PreferencesPage::builder() + .title("Rules") + .icon_name("system-run-symbolic") + .build(); + p.add(&{ + let g = PreferencesGroup::new(); + g.add(&rules.widget); + g + }); + /* @TODO URL entry p.add(&{ + let g = PreferencesGroup::builder().title("Test").build(); + //g.add(&Box::rules(profile)); + g + });*/ + p + }); + + d.add( + &PreferencesPage::builder() + .title("Exceptions") + .icon_name("action-unavailable-symbolic") + .build(), + ); + + d.add( + &PreferencesPage::builder() + .title("Interface") + .icon_name("preferences-desktop-display-symbolic") + .build(), + ); + + d.connect_closed({ + let profile = profile.clone(); + move |_| { + profile.proxy.clear(); + for rule in rules.take() { + if rule.validate() { + profile.proxy.add_rule( + rule.id, + rule.is_enabled(), + rule.priority(), + rule.request().to_string(), + rule.url().to_string(), + rule.time, + ) + } + } + } + }); + d + } +} diff --git a/src/app/browser/proxy/rules.rs b/src/app/browser/proxy/rules.rs new file mode 100644 index 00000000..52d0acd9 --- /dev/null +++ b/src/app/browser/proxy/rules.rs @@ -0,0 +1,93 @@ +mod rule; + +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use super::Profile; +use gtk::{ + Box, + glib::{GString, uuid_string_random}, + prelude::BoxExt, +}; +use rule::Rule; + +pub struct Rules { + pub widget: Box, + rules: Rc>>, +} + +impl Rules { + pub fn build(profile: &Rc) -> Self { + let config = profile.proxy.rules(); + + let rules: Rc>> = + Rc::new(RefCell::new(HashMap::with_capacity(config.len()))); + + let form = Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + + { + let mut r = rules.borrow_mut(); + + for proxy in config { + let key = uuid_string_random(); + let rule = Rule::build( + proxy.id, + Some(&proxy.time), + Some(&proxy.request), + Some(&proxy.url), + Some(proxy.priority), + proxy.is_enabled, + { + let rules = rules.clone(); + let key = key.clone(); + let form = form.clone(); + move || form.remove(&rules.borrow_mut().remove(&key).unwrap().widget) + }, + ); + rule.validate(); + form.append(&rule.widget); + assert!(r.insert(key, rule).is_none()) + } + } + + let add = { + let b = Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + + b.append(&rule::new({ + let rules = rules.clone(); + let form = form.clone(); + move || { + let key = uuid_string_random(); + let rule = Rule::build(None, None, None, None, None, false, { + let rules = rules.clone(); + let key = key.clone(); + let form = form.clone(); + move || form.remove(&rules.borrow_mut().remove(&key).unwrap().widget) + }); + form.append(&rule.widget); + assert!(rules.borrow_mut().insert(key, rule).is_none()) + } + })); + b + }; + + let widget = Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + + widget.append(&form); + widget.append(&add); + + Self { rules, widget } + } + + pub fn take(&self) -> Vec { + self.rules.take().into_values().collect() + } +} diff --git a/src/app/browser/proxy/rules/rule.rs b/src/app/browser/proxy/rules/rule.rs new file mode 100644 index 00000000..ffc14989 --- /dev/null +++ b/src/app/browser/proxy/rules/rule.rs @@ -0,0 +1,250 @@ +use gtk::{ + Align, Box, Button, Entry, Switch, + glib::{DateTime, GString}, + prelude::{BoxExt, ButtonExt, EditableExt, WidgetExt}, +}; + +pub struct Rule { + pub id: Option, + priority: Entry, + request: Entry, + status: Switch, + url: Entry, + pub time: DateTime, + pub widget: Box, +} + +impl Rule { + // Constructors + + pub fn build( + id: Option, + time: Option<&DateTime>, + request: Option<&str>, + url: Option<&str>, + priority: Option, + is_enabled: bool, + on_delete: impl Fn() + 'static, + ) -> Self { + // Init components + + let status = Switch::builder() + .active(is_enabled) + .valign(Align::Center) + .build(); + + let request = Entry::builder() + .max_width_chars(12) + .placeholder_text("Request") + .tooltip_text("Supports regex expressions") + .text(request.unwrap_or(".*")) + .build(); + + let url = Entry::builder() + .hexpand(true) + .placeholder_text("Proxy URL") + .text(url.unwrap_or_default()) + .tooltip_text("e.g. socks5://127.0.0.1:1080") + .build(); + + let priority = Entry::builder() + .max_width_chars(1) + .placeholder_text("Priority") + .text(priority.unwrap_or(0).to_string()) + .tooltip_text("Apply in priority") + .build(); + + let delete = Button::builder() + .css_classes(["error"]) + .icon_name("user-trash-symbolic") + .tooltip_text("Delete") + .build(); + + // Init widget + + let widget = Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + + widget.append(&status); + widget.append(&request); + widget.append(&url); + widget.append(&priority); + widget.append(&delete); + + // Activate + + delete.connect_clicked({ + let c = std::rc::Rc::new(on_delete); + move |this| { + use adw::{ + AlertDialog, ResponseAppearance, + prelude::{AdwDialogExt, AlertDialogExt, AlertDialogExtManual}, + }; + + const RESPONSE_CONFIRM: (&str, &str) = ("confirm", "Confirm"); + const RESPONSE_CANCEL: (&str, &str) = ("cancel", "Cancel"); + + let dialog = AlertDialog::builder() + .heading("Delete this rule?") + .close_response(RESPONSE_CANCEL.0) + .default_response(RESPONSE_CONFIRM.0) + .build(); + + dialog.add_responses(&[RESPONSE_CANCEL, RESPONSE_CONFIRM]); + dialog.set_response_appearance(RESPONSE_CONFIRM.0, ResponseAppearance::Destructive); + dialog.connect_response(None, { + let c = c.clone(); + move |dialog, response| { + dialog.set_response_enabled(response, false); // prevent double-click + if response == RESPONSE_CONFIRM.0 { + c() + } + } + }); + dialog.present(Some(this)) + } + }); + + priority.connect_changed({ + let url = url.clone(); + move |this| { + validate(this, &url); + } + }); + + url.connect_changed({ + let priority = priority.clone(); + move |this| { + validate(&priority, this); + } + }); + + status.connect_state_set({ + let priority = priority.clone(); + let request = request.clone(); + let url = url.clone(); + move |_, state| { + validate(&priority, &url); + + priority.set_sensitive(state); + request.set_sensitive(state); + url.set_sensitive(state); + + gtk::glib::Propagation::Proceed + } + }); + + Self { + id, + priority, + request, + status, + time: time.cloned().unwrap_or(DateTime::now_local().unwrap()), + url, + widget, + } + } + + // Actions + + pub fn validate(&self) -> bool { + validate(&self.priority, &self.url) + } + + // Getters + + pub fn priority(&self) -> i32 { + self.priority.text().parse::().unwrap_or_default() + } + + pub fn request(&self) -> GString { + self.request.text() + } + + pub fn url(&self) -> GString { + self.url.text() + } + + pub fn is_enabled(&self) -> bool { + self.status.is_active() + } +} + +pub fn new(on_add: impl Fn() + 'static) -> Box { + let b = Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + + b.append(&{ + let add = Button::builder() + .css_classes(["success"]) + .hexpand(true) + .icon_name("list-add-symbolic") + .tooltip_text("Add proxy") + .build(); + + add.connect_clicked(move |_| on_add()); + add + }); + b +} + +fn validate(priority: &Entry, url: &Entry) -> bool { + fn highlight(entry: &Entry, error: Result<(), String>) { + const E: &str = "error"; + match error { + Err(e) => { + entry.set_css_classes(&[E]); + entry.set_tooltip_text(Some(&e)) + } + Ok(()) => { + entry.remove_css_class(E); + entry.set_tooltip_text(Some("Value is valid")) + } + } + } + + fn validate_priority(value: &str) -> Result<(), String> { + if value.parse::().is_err() { + Err("Priority value is not valid integer".to_string()) + } else { + Ok(()) + } + } + + fn validate_url(value: &str) -> Result<(), String> { + match gtk::glib::Uri::parse(value, gtk::glib::UriFlags::NONE) { + Ok(uri) => { + if uri.scheme().is_empty() { + Err("Scheme is empty".to_string()) + } else if uri.host().is_none() { + Err("Host is required".to_string()) + } else if uri.port() == -1 { + Err("Port is required".to_string()) + } else if !uri.path().is_empty() { + Err("URL should not contain the path part".to_string()) + } else if uri.query().is_some() { + Err("URL should not contain the query part".to_string()) + } else if uri.fragment().is_some() { + Err("URL should not contain the fragment (anchor) part".to_string()) + } else { + Ok(()) + } + } + Err(e) => Err(e.to_string()), + } + } + + let v = validate_priority(&priority.text()); + let is_valid_priority = v.is_ok(); + highlight(priority, v); + + let v = validate_url(&url.text()); + let is_valid_url = v.is_ok(); + highlight(url, v); + + is_valid_priority && is_valid_url +} diff --git a/src/app/browser/window/header/bar/menu.rs b/src/app/browser/window/header/bar/menu.rs index 0dc63f3a..2e822298 100644 --- a/src/app/browser/window/header/bar/menu.rs +++ b/src/app/browser/window/header/bar/menu.rs @@ -160,6 +160,13 @@ impl Menu for MenuButton { main.append_submenu(Some("History"), &main_history); + // Main > Proxy + main.append(Some("Proxy settings"), Some(&format!( + "{}.{}", + browser_action.id, + browser_action.proxy.simple_action.name() + ))); // @TODO make the Settings submenu + // Main > Tool let main_tool = gio::Menu::new(); @@ -170,7 +177,7 @@ impl Menu for MenuButton { browser_action.debug.simple_action.name() ))); - main_tool.append(Some("Profile"), Some(&format!( + main_tool.append(Some("Open profile directory"), Some(&format!( "{}.{}", browser_action.id, browser_action.profile.simple_action.name() @@ -200,7 +207,7 @@ impl Menu for MenuButton { .build(); // Generate dynamical menu items - menu_button.set_create_popup_func({ + menu_button.set_create_popup_func({ let profile = profile.clone(); let main_bookmarks = main_bookmarks.clone(); let window_action = window_action.clone(); diff --git a/src/profile.rs b/src/profile.rs index 54eb6872..13022258 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -110,6 +110,7 @@ impl Profile { pub fn save(&self) -> Result<()> { self.history.save()?; + self.proxy.save()?; self.tofu.save()?; Ok(()) } diff --git a/src/profile/proxy.rs b/src/profile/proxy.rs index 061fdc4f..6ffac347 100644 --- a/src/profile/proxy.rs +++ b/src/profile/proxy.rs @@ -4,7 +4,10 @@ mod rule; use anyhow::Result; use database::Database; -use gtk::gio::{ProxyResolver, SimpleProxyResolver}; +use gtk::{ + gio::{ProxyResolver, SimpleProxyResolver}, + glib::DateTime, +}; use ignore::Ignore; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; @@ -12,6 +15,7 @@ use rule::Rule; use std::cell::RefCell; pub struct Proxy { + database: Database, ignore: RefCell>, rule: RefCell>, } @@ -44,18 +48,73 @@ impl Proxy { let mut b = rule.borrow_mut(); for r in rules { b.push(Rule { + id: Some(r.id), + time: r.time, is_enabled: r.is_enabled, + priority: r.priority, request: r.request, url: r.url, }); } } - Ok(Self { ignore, rule }) + Ok(Self { + database, + ignore, + rule, + }) } // Actions + pub fn add_rule( + &self, + id: Option, + is_enabled: bool, + priority: i32, + request: String, + url: String, + time: DateTime, + ) { + let mut rules = self.rule.borrow_mut(); + rules.push(Rule { + id, + time, + is_enabled, + priority, + request, + url, + }) // @TODO validate? + } + + pub fn clear(&self) { + self.ignore.borrow_mut().clear(); + self.rule.borrow_mut().clear(); + } + + pub fn save(&self) -> Result<()> { + let rules = self.rule.take(); + let mut keep_id = Vec::with_capacity(rules.len()); + for rule in rules { + keep_id.push(self.database.persist_rule( + rule.id, + rule.time, + rule.is_enabled, + rule.priority, + rule.request, + rule.url, + )?); + } + self.database.clean_rules(keep_id)?; + Ok(()) + } + + // Getters + + pub fn rules(&self) -> Vec { + self.rule.borrow().iter().cloned().collect() + } + pub fn matches(&self, request: &str) -> Option { for rule in self.rule.borrow().iter().filter(|r| r.is_enabled) { if gtk::glib::Regex::match_simple( diff --git a/src/profile/proxy/database.rs b/src/profile/proxy/database.rs index 11221186..bc3769ac 100644 --- a/src/profile/proxy/database.rs +++ b/src/profile/proxy/database.rs @@ -2,6 +2,7 @@ mod ignore; mod rule; use anyhow::Result; +use gtk::glib::DateTime; use ignore::Ignore; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; @@ -34,6 +35,44 @@ impl Database { } // Setters + + pub fn clean_rules(&self, keep_id: Vec) -> Result<()> { + let mut c = self.pool.get()?; + let tx = c.transaction()?; + clean_rules(&tx, keep_id)?; + tx.commit()?; + Ok(()) + } + + pub fn persist_rule( + &self, + id: Option, + time: DateTime, + is_enabled: bool, + priority: i32, + request: String, + url: String, + ) -> Result { + let mut c = self.pool.get()?; + let tx = c.transaction()?; + let id = match id { + Some(id) => { + update_rule(&tx, id, time, is_enabled, priority, request, url)?; + id + } + None => insert_rule( + &tx, + self.profile_id, + time, + is_enabled, + priority, + request, + url, + )?, + }; + tx.commit()?; + Ok(id) + } } // Low-level DB API @@ -50,8 +89,7 @@ pub fn init(tx: &Transaction) -> Result { `is_enabled` INTEGER NOT NULL, `host` VARCHAR(255) NOT NULL, - FOREIGN KEY (`profile_id`) REFERENCES `profile` (`id`), - UNIQUE (`host`) + FOREIGN KEY (`profile_id`) REFERENCES `profile` (`id`) )", [], )?; @@ -67,8 +105,7 @@ pub fn init(tx: &Transaction) -> Result { `request` VARCHAR(1024) NOT NULL, `url` VARCHAR(255) NOT NULL, - FOREIGN KEY (`profile_id`) REFERENCES `profile` (`id`), - UNIQUE (`request`) + FOREIGN KEY (`profile_id`) REFERENCES `profile` (`id`) )", [], )?; @@ -76,6 +113,75 @@ pub fn init(tx: &Transaction) -> Result { Ok(s) } +pub fn clean_rules(tx: &Transaction, keep_id: Vec) -> Result { + if keep_id.is_empty() { + return Ok(0); + } + Ok(tx.execute( + &format!( + "DELETE FROM `profile_proxy_rule` WHERE `id` NOT IN ({})", + keep_id + .into_iter() + .map(|id| id.to_string()) + .collect::>() + .join(",") + ), + [], + )?) +} + +pub fn insert_rule( + tx: &Transaction, + profile_id: i64, + time: DateTime, + is_enabled: bool, + priority: i32, + request: String, + url: String, +) -> Result { + tx.execute( + "INSERT INTO `profile_proxy_rule` ( + `profile_id`, + `time`, + `is_enabled`, + `priority`, + `request`, + `url` + ) VALUES (?, ?, ?, ?, ?, ?)", + ( + profile_id, + time.to_unix(), + is_enabled, + priority, + request, + url, + ), + )?; + Ok(tx.last_insert_rowid()) +} + +pub fn update_rule( + tx: &Transaction, + id: i64, + time: DateTime, + is_enabled: bool, + priority: i32, + request: String, + url: String, +) -> Result { + Ok(tx.execute( + "UPDATE `profile_proxy_rule` + SET `time` = ?, + `is_enabled` = ?, + `priority` = ?, + `request` = ?, + `url` = ? + + WHERE `id` = ?", + (time.to_unix(), is_enabled, priority, request, url, id), + )?) +} + pub fn ignores(tx: &Transaction, profile_id: i64) -> Result> { let mut stmt = tx.prepare( "SELECT `id`, @@ -125,11 +231,11 @@ pub fn rules(tx: &Transaction, profile_id: i64) -> Result> { let result = stmt.query_map([profile_id], |row| { Ok(Rule { - //id: row.get(0)?, + id: row.get(0)?, //profile_id: row.get(1)?, - //time: DateTime::from_unix_local(row.get(2)?).unwrap(), + time: DateTime::from_unix_local(row.get(2)?).unwrap(), is_enabled: row.get(3)?, - //priority: row.get(4)?, + priority: row.get(4)?, request: row.get(5)?, url: row.get(6)?, }) diff --git a/src/profile/proxy/database/rule.rs b/src/profile/proxy/database/rule.rs index 43c9c706..b786660b 100644 --- a/src/profile/proxy/database/rule.rs +++ b/src/profile/proxy/database/rule.rs @@ -1,5 +1,8 @@ pub struct Rule { + pub id: i64, pub is_enabled: bool, + pub priority: i32, pub request: String, + pub time: gtk::glib::DateTime, pub url: String, } diff --git a/src/profile/proxy/rule.rs b/src/profile/proxy/rule.rs index 43c9c706..78004b18 100644 --- a/src/profile/proxy/rule.rs +++ b/src/profile/proxy/rule.rs @@ -1,5 +1,9 @@ +#[derive(Clone)] pub struct Rule { + pub id: Option, pub is_enabled: bool, + pub priority: i32, pub request: String, + pub time: gtk::glib::DateTime, pub url: String, }