diff --git a/Cargo.lock b/Cargo.lock index 647d586..add417f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -611,7 +611,9 @@ dependencies = [ "serde_json", "slint", "slint-build", + "toml 1.1.4+spec-1.1.0", "winit", + "winreg", ] [[package]] @@ -4129,7 +4131,7 @@ dependencies = [ "regex", "serde_json", "tar", - "toml", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -4662,6 +4664,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -4695,18 +4712,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.2", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -5757,6 +5774,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 8539610..42c1d8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,9 @@ i-slint-backend-winit = "1.16.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" slint = { version = "1.16.1", features = ["renderer-skia", "serde"] } +toml = "1.1.4" winit = "0.30" +winreg = "0.56.0" [build-dependencies] slint-build = "1.16.1" diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index 9a1b9ba..e70ceaa 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -101,13 +101,28 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он ## Этап E — конфиг и настройки -- [ ] `config.toml` рядом с exe / `%APPDATA%\bropicker` (крейс `toml`), serde-структура BrowserConfig -- [ ] Логика запуска с учётом галочек: remember-choice → записать выбор для домена; - always-ask → показывать пикер несмотря на запомненное -- [ ] Страница настроек: TabWidget + LineEdit/SpinBox/ComboBox/CheckBox std-widgets; - вход по кнопке cog из футера (колбэк settings-clicked уже есть) -- [ ] Автодетект установленных браузеров (реестр Windows) как fallback при отсутствии конфига +### E1 — конфиг, автодетект, логика запуска — ГОТОВО + +- [x] `src/config.rs`: `%APPDATA%\bropicker\config.toml` (крейсы `toml` + `winreg`); + env `BP_CONFIG` для тестов; секции `[[browsers]]`, `[remembered]` (домен→имя), + `[settings]` (remember_choice, always_ask) +- [x] Автодетект из реестра `HKLM/HKCU\SOFTWARE\Clients\StartMenuInternet`: + имя + exe из shell\open\command, дедуп по пути, проверка существования файла; + результат автосохраняется. Проверено: найдены Firefox, Zen, Chrome, Edge +- [x] Иконки по имени (firefox/chrome/opera/brave/yandex → `logos/*.png`, + остальное → globe.svg). Нет логотипов: Edge, Zen +- [x] Логика запуска: домен запомнен + always_ask=off → мгновенный запуск без окна + (протестировано); иначе пикер. Клик/«Открыть» + remember_choice → запись + домена в конфиг; переключение галочек персистится +- [x] URL-бар привязан к свойству (был захардкожен), метка чекбокса — домен + +### E2 — страница настроек (следующий шаг) + +- [ ] Переключение вид/настройки по кнопке cog (settings-clicked) +- [ ] Редактор списка браузеров: имя/путь/флаги (LineEdit), вкл/выкл +- [ ] Список запомненных доменов с удалением - [ ] Чтение флагов из .lnk ярлыков (крейс `lnk`) — по желанию +- [ ] Добавить логотипы Edge/Zen в logos/ (пересоздать набор из alrrr/browser-logos) ## Этап F — релиз diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..98c50d0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct BrowserEntry { + pub name: String, + pub path: String, + #[serde(default)] + pub flags: String, + #[serde(default)] + pub icon: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Settings { + #[serde(default = "default_true")] + pub remember_choice: bool, + #[serde(default = "default_true")] + pub always_ask: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for Settings { + fn default() -> Self { + Self { + remember_choice: true, + always_ask: true, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct Config { + #[serde(default)] + pub browsers: Vec, + #[serde(default)] + pub remembered: HashMap, + #[serde(default)] + pub settings: Settings, +} + +pub fn config_path() -> PathBuf { + if let Ok(p) = std::env::var("BP_CONFIG") { + return PathBuf::from(p); + } + std::env::var("APPDATA") + .map(|d| PathBuf::from(d).join("bropicker").join("config.toml")) + .unwrap_or_else(|_| PathBuf::from("config.toml")) +} + +pub fn load() -> Config { + let path = config_path(); + match std::fs::read_to_string(&path) { + Ok(s) => toml::from_str(&s).unwrap_or_else(|e| { + eprintln!("[bp] config parse error ({path:?}): {e}"); + Config::default() + }), + Err(_) => Config::default(), + } +} + +pub fn save(cfg: &Config) { + let path = config_path(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + match toml::to_string_pretty(cfg) { + Ok(s) => { + if let Err(e) = std::fs::write(&path, s) { + eprintln!("[bp] config save failed ({path:?}): {e}"); + } + } + Err(e) => eprintln!("[bp] config serialize failed: {e}"), + } +} + +pub fn ensure_browsers(cfg: &mut Config) { + if cfg.browsers.is_empty() { + cfg.browsers = detect_browsers(); + eprintln!("[bp] autodetected {} browsers", cfg.browsers.len()); + if !cfg.browsers.is_empty() { + save(cfg); + } + } +} + +pub fn domain_of(url: &str) -> String { + let rest = url.split("://").nth(1).unwrap_or(url); + let host = rest.split(['/', '?', '#']).next().unwrap_or(""); + let host = host.split('@').next().unwrap_or(host); + let host = host.split(':').next().unwrap_or(host); + host.trim_start_matches("www.").to_lowercase() +} + +fn extract_exe(cmd: &str) -> Option { + let t = cmd.trim(); + if t.starts_with('"') { + t.split('"').nth(1).map(String::from) + } else { + t.split_whitespace().next().map(String::from) + } +} + +pub fn detect_browsers() -> Vec { + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + use winreg::RegKey; + + let mut out: Vec = Vec::new(); + + for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] { + let Ok(clients) = + RegKey::predef(hive).open_subkey("SOFTWARE\\Clients\\StartMenuInternet") + else { + continue; + }; + for key_name in clients.enum_keys().flatten() { + let Ok(client) = clients.open_subkey(&key_name) else { + continue; + }; + let friendly: String = client + .get_value("") + .unwrap_or_else(|_| key_name.clone()); + let command: String = client + .open_subkey("shell\\open\\command") + .and_then(|k| k.get_value("")) + .unwrap_or_default(); + let Some(path) = extract_exe(&command) else { + continue; + }; + if !Path::new(&path).exists() { + continue; + } + if out + .iter() + .any(|b: &BrowserEntry| b.path.eq_ignore_ascii_case(&path)) + { + continue; + } + out.push(BrowserEntry { + name: friendly, + path, + flags: String::new(), + icon: String::new(), + }); + } + } + + out +} + +pub fn icon_for(name: &str) -> &'static str { + let n = name.to_lowercase(); + if n.contains("firefox") { + "logos/firefox_48x48.png" + } else if n.contains("chrome") { + "logos/chrome_48x48.png" + } else if n.contains("opera") { + "logos/opera_48x48.png" + } else if n.contains("brave") { + "logos/brave_48x48.png" + } else if n.contains("яндекс") || n.contains("yandex") { + "logos/yandex_48x48.png" + } else { + "icons/globe.svg" + } +} diff --git a/src/main.rs b/src/main.rs index eb4cdfd..8a25f32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,55 +1,53 @@ +use std::cell::RefCell; use std::rc::Rc; use i_slint_backend_winit::WinitWindowAccessor; use slint::Model; -use crate::winit::center_window; +mod config; mod winit; +use config::{BrowserEntry, Config}; + slint::include_modules!(); fn load_icon(rel: &str) -> slint::Image { slint::Image::load_from_path(std::path::Path::new(rel)).unwrap_or_default() } -fn init() -> State { - let browsers = vec![ - BrowserConfig { - icon: load_icon("logos/firefox_48x48.png"), - name: "Work browser".into(), - path: "firefox.exe".into(), - flags: "--profile work".into(), - is_default: true, - }, - BrowserConfig { - icon: load_icon("logos/chrome_48x48.png"), - name: "Ku6epXBOCTuK".into(), - path: "chrome.exe".into(), - flags: "--profile personal".into(), - is_default: false, - }, - BrowserConfig { - icon: load_icon("logos/opera_48x48.png"), - name: "Chrome".into(), - path: "chrome.exe".into(), - flags: "".into(), - is_default: false, - }, - BrowserConfig { - icon: load_icon("icons/globe.svg"), - name: "Zen Browser".into(), - path: "zen.exe".into(), - flags: "".into(), - is_default: false, - }, - ]; +fn to_ui_config(entry: &BrowserEntry) -> BrowserConfig { + let icon_rel = if entry.icon.is_empty() { + config::icon_for(&entry.name) + } else { + &entry.icon + }; + BrowserConfig { + icon: load_icon(icon_rel), + name: entry.name.clone().into(), + path: entry.path.clone().into(), + flags: entry.flags.clone().into(), + is_default: false, + } +} - let default_index = browsers +fn launch(entry: &BrowserEntry, url: &str) -> std::io::Result<()> { + let mut cmd = std::process::Command::new(&entry.path); + if !entry.flags.is_empty() { + cmd.args(entry.flags.split_whitespace()); + } + cmd.arg(url).spawn().map(|_| ()) +} + +fn init(cfg: Config, url: Option) -> State { + let cfg = Rc::new(RefCell::new(cfg)); + + let ui_browsers: Vec = cfg + .borrow() + .browsers .iter() - .position(|b| b.is_default) - .unwrap_or_default(); - - let browser_model = Rc::new(slint::VecModel::::from(browsers)); + .map(to_ui_config) + .collect(); + let browser_model = Rc::new(slint::VecModel::::from(ui_browsers)); let main_window = MainWindow::new().unwrap(); @@ -57,17 +55,19 @@ fn init() -> State { main_window.global::().set_is_dark(false); } - let url = std::env::args() - .nth(1) - .unwrap_or_else(|| "ku6epxboctuk.github.io".into()); - main_window.set_current_url(url.into()); - main_window.set_selected_index(default_index as i32); - main_window.set_remember_choice(true); - main_window.set_always_ask(true); + main_window.set_current_url( + url.clone().unwrap_or_else(|| "ku6epxboctuk.github.io".into()).into(), + ); + if let Some(u) = &url { + main_window.set_current_domain(config::domain_of(u).into()); + } + main_window.set_remember_choice(cfg.borrow().settings.remember_choice); + main_window.set_always_ask(cfg.borrow().settings.always_ask); main_window.on_launch_browser({ let main_window = main_window.clone_strong(); let browser_model = browser_model.clone(); + let cfg = cfg.clone(); move |browser: BrowserConfig| { let index = (0..browser_model.row_count()) .find(|&i| { @@ -78,19 +78,30 @@ fn init() -> State { .unwrap_or(0); main_window.set_selected_index(index as i32); + let entry = browser_model.row_data(index).expect("row exists"); let url = main_window.get_current_url().to_string(); + println!( "Launching [{}]: {:?} {:?} {}", - index, browser.path, browser.flags, url + index, entry.path, entry.flags, url ); - let mut cmd = std::process::Command::new(browser.path.to_string()); - if !browser.flags.is_empty() { - cmd.args(browser.flags.split_whitespace()); - } + let entry_ref = BrowserEntry { + name: entry.name.to_string(), + path: entry.path.to_string(), + flags: entry.flags.to_string(), + icon: String::new(), + }; - match cmd.arg(url).spawn() { + match launch(&entry_ref, &url) { Ok(_) => { + let domain = config::domain_of(&url); + if main_window.get_remember_choice() && !domain.is_empty() { + cfg.borrow_mut() + .remembered + .insert(domain, entry_ref.name.clone()); + config::save(&cfg.borrow()); + } let _ = main_window.window().hide(); } Err(e) => eprintln!("launch failed: {e}"), @@ -101,25 +112,34 @@ fn init() -> State { main_window.on_open_clicked({ let main_window = main_window.clone_strong(); let browser_model = browser_model.clone(); + let cfg = cfg.clone(); move || { let index = main_window.get_selected_index().max(0) as usize; - let Some(browser) = browser_model.row_data(index) else { + let Some(ui_entry) = browser_model.row_data(index) else { return; }; + let entry = BrowserEntry { + name: ui_entry.name.to_string(), + path: ui_entry.path.to_string(), + flags: ui_entry.flags.to_string(), + icon: String::new(), + }; let url = main_window.get_current_url().to_string(); println!( "Launching [{}]: {:?} {:?} {}", - index, browser.path, browser.flags, url + index, entry.path, entry.flags, url ); - let mut cmd = std::process::Command::new(browser.path.to_string()); - if !browser.flags.is_empty() { - cmd.args(browser.flags.split_whitespace()); - } - - match cmd.arg(url).spawn() { + match launch(&entry, &url) { Ok(_) => { + let domain = config::domain_of(&url); + if main_window.get_remember_choice() && !domain.is_empty() { + cfg.borrow_mut() + .remembered + .insert(domain, entry.name.clone()); + config::save(&cfg.borrow()); + } let _ = main_window.window().hide(); } Err(e) => eprintln!("launch failed: {e}"), @@ -127,6 +147,26 @@ fn init() -> State { } }); + main_window.on_toggle_remember({ + let cfg = cfg.clone(); + move |checked| { + cfg.borrow_mut().settings.remember_choice = checked; + config::save(&cfg.borrow()); + } + }); + + main_window.on_toggle_always_ask({ + let cfg = cfg.clone(); + move |checked| { + cfg.borrow_mut().settings.always_ask = checked; + config::save(&cfg.borrow()); + } + }); + + main_window.on_settings_clicked(move || { + println!("Settings clicked"); + }); + main_window.on_request_drag({ let main_window = main_window.clone_strong(); move || { @@ -147,29 +187,43 @@ fn init() -> State { State { main_window, browser_model, + config: cfg, } } pub struct State { pub main_window: MainWindow, pub browser_model: Rc>, + pub config: Rc>, } pub fn main() { - // TODO: how to enable skia? not panics - // let _selector = BackendSelector::new() - // .backend_name("renderer_skia".into()) - // .select() - // .unwrap(); + let mut cfg = config::load(); + config::ensure_browsers(&mut cfg); - let state = init(); + let url = std::env::args().nth(1); + + if let Some(url) = &url { + let domain = config::domain_of(url); + if let Some(name) = cfg.remembered.get(&domain).cloned() { + if cfg.settings.always_ask { + eprintln!("[bp] '{domain}' remembered -> {name}, but always_ask is on"); + } else if let Some(entry) = cfg.browsers.iter().find(|b| b.name == name) { + eprintln!("[bp] '{domain}' remembered -> launching {name}"); + let _ = launch(entry, url); + return; + } + } + } + + let state = init(cfg, url); let main_window = state.main_window.clone_strong(); main_window.show().unwrap(); let weak = main_window.as_weak(); let _ = slint::invoke_from_event_loop(move || { if let Some(mw) = weak.upgrade() { - center_window(mw.window()); + winit::center_window(mw.window()); } }); diff --git a/ui/header.slint b/ui/header.slint index 19a50f4..3034724 100644 --- a/ui/header.slint +++ b/ui/header.slint @@ -89,12 +89,12 @@ export component Header inherits Rectangle { VerticalLayout { padding-top: 2px; - Text { - text: "ku6epxboctuk.github.io"; - color: Theme.text-control; - font-size: 14px; - font-family: "Consolas"; - } + Text { + text: root.url; + color: Theme.text-control; + font-size: 14px; + font-family: "Consolas"; + } } } } diff --git a/ui/main.slint b/ui/main.slint index 0e96f6a..ca4a0af 100644 --- a/ui/main.slint +++ b/ui/main.slint @@ -58,6 +58,7 @@ export component MainWindow inherits Window { title: "bropicker"; in property <[BrowserConfig]> browser-model: []; in property current-url: ""; + in property current-domain: ""; in-out property remember-choice: false; in-out property always-ask: true; in property selected-index: 0; @@ -141,7 +142,7 @@ export component MainWindow inherits Window { padding-bottom: 8px; CheckRow { - label: "Запомнить выбор для " + root.current-url; + label: "Запомнить выбор для " + root.current-domain; checked <=> root.remember-choice; toggled => { root.toggle-remember(root.remember-choice);