feat: config, registry autodetect, remember domains

This commit is contained in:
2026-08-26 00:06:30 +05:00
parent 3f5923ad6d
commit c7557c163a
7 changed files with 353 additions and 83 deletions
Generated
+32 -5
View File
@@ -611,7 +611,9 @@ dependencies = [
"serde_json", "serde_json",
"slint", "slint",
"slint-build", "slint-build",
"toml 1.1.4+spec-1.1.0",
"winit", "winit",
"winreg",
] ]
[[package]] [[package]]
@@ -4129,7 +4131,7 @@ dependencies = [
"regex", "regex",
"serde_json", "serde_json",
"tar", "tar",
"toml", "toml 0.9.12+spec-1.1.0",
] ]
[[package]] [[package]]
@@ -4662,6 +4664,21 @@ dependencies = [
"winnow 0.7.15", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.7.5+spec-1.1.0" version = "0.7.5+spec-1.1.0"
@@ -4695,18 +4712,18 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ dependencies = [
"winnow 1.0.2", "winnow 1.0.2",
] ]
[[package]] [[package]]
name = "toml_writer" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]] [[package]]
name = "tracing" name = "tracing"
@@ -5757,6 +5774,16 @@ dependencies = [
"memchr", "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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
+2
View File
@@ -9,7 +9,9 @@ i-slint-backend-winit = "1.16.1"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
slint = { version = "1.16.1", features = ["renderer-skia", "serde"] } slint = { version = "1.16.1", features = ["renderer-skia", "serde"] }
toml = "1.1.4"
winit = "0.30" winit = "0.30"
winreg = "0.56.0"
[build-dependencies] [build-dependencies]
slint-build = "1.16.1" slint-build = "1.16.1"
+21 -6
View File
@@ -101,13 +101,28 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он
## Этап E — конфиг и настройки ## Этап E — конфиг и настройки
- [ ] `config.toml` рядом с exe / `%APPDATA%\bropicker` (крейс `toml`), serde-структура BrowserConfig ### E1 — конфиг, автодетект, логика запуска — ГОТОВО
- [ ] Логика запуска с учётом галочек: remember-choice → записать выбор для домена;
always-ask → показывать пикер несмотря на запомненное - [x] `src/config.rs`: `%APPDATA%\bropicker\config.toml` (крейсы `toml` + `winreg`);
- [ ] Страница настроек: TabWidget + LineEdit/SpinBox/ComboBox/CheckBox std-widgets; env `BP_CONFIG` для тестов; секции `[[browsers]]`, `[remembered]` (домен→имя),
вход по кнопке cog из футера (колбэк settings-clicked уже есть) `[settings]` (remember_choice, always_ask)
- [ ] Автодетект установленных браузеров (реестр Windows) как fallback при отсутствии конфига - [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`) — по желанию - [ ] Чтение флагов из .lnk ярлыков (крейс `lnk`) — по желанию
- [ ] Добавить логотипы Edge/Zen в logos/ (пересоздать набор из alrrr/browser-logos)
## Этап F — релиз ## Этап F — релиз
+171
View File
@@ -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<BrowserEntry>,
#[serde(default)]
pub remembered: HashMap<String, String>,
#[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<String> {
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<BrowserEntry> {
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey;
let mut out: Vec<BrowserEntry> = 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"
}
}
+117 -63
View File
@@ -1,55 +1,53 @@
use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use i_slint_backend_winit::WinitWindowAccessor; use i_slint_backend_winit::WinitWindowAccessor;
use slint::Model; use slint::Model;
use crate::winit::center_window; mod config;
mod winit; mod winit;
use config::{BrowserEntry, Config};
slint::include_modules!(); slint::include_modules!();
fn load_icon(rel: &str) -> slint::Image { fn load_icon(rel: &str) -> slint::Image {
slint::Image::load_from_path(std::path::Path::new(rel)).unwrap_or_default() slint::Image::load_from_path(std::path::Path::new(rel)).unwrap_or_default()
} }
fn init() -> State { fn to_ui_config(entry: &BrowserEntry) -> BrowserConfig {
let browsers = vec![ let icon_rel = if entry.icon.is_empty() {
config::icon_for(&entry.name)
} else {
&entry.icon
};
BrowserConfig { BrowserConfig {
icon: load_icon("logos/firefox_48x48.png"), icon: load_icon(icon_rel),
name: "Work browser".into(), name: entry.name.clone().into(),
path: "firefox.exe".into(), path: entry.path.clone().into(),
flags: "--profile work".into(), flags: entry.flags.clone().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, 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,
},
];
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<String>) -> State {
let cfg = Rc::new(RefCell::new(cfg));
let ui_browsers: Vec<BrowserConfig> = cfg
.borrow()
.browsers
.iter() .iter()
.position(|b| b.is_default) .map(to_ui_config)
.unwrap_or_default(); .collect();
let browser_model = Rc::new(slint::VecModel::<BrowserConfig>::from(ui_browsers));
let browser_model = Rc::new(slint::VecModel::<BrowserConfig>::from(browsers));
let main_window = MainWindow::new().unwrap(); let main_window = MainWindow::new().unwrap();
@@ -57,17 +55,19 @@ fn init() -> State {
main_window.global::<Theme>().set_is_dark(false); main_window.global::<Theme>().set_is_dark(false);
} }
let url = std::env::args() main_window.set_current_url(
.nth(1) url.clone().unwrap_or_else(|| "ku6epxboctuk.github.io".into()).into(),
.unwrap_or_else(|| "ku6epxboctuk.github.io".into()); );
main_window.set_current_url(url.into()); if let Some(u) = &url {
main_window.set_selected_index(default_index as i32); main_window.set_current_domain(config::domain_of(u).into());
main_window.set_remember_choice(true); }
main_window.set_always_ask(true); 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({ main_window.on_launch_browser({
let main_window = main_window.clone_strong(); let main_window = main_window.clone_strong();
let browser_model = browser_model.clone(); let browser_model = browser_model.clone();
let cfg = cfg.clone();
move |browser: BrowserConfig| { move |browser: BrowserConfig| {
let index = (0..browser_model.row_count()) let index = (0..browser_model.row_count())
.find(|&i| { .find(|&i| {
@@ -78,19 +78,30 @@ fn init() -> State {
.unwrap_or(0); .unwrap_or(0);
main_window.set_selected_index(index as i32); 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(); let url = main_window.get_current_url().to_string();
println!( println!(
"Launching [{}]: {:?} {:?} {}", "Launching [{}]: {:?} {:?} {}",
index, browser.path, browser.flags, url index, entry.path, entry.flags, url
); );
let mut cmd = std::process::Command::new(browser.path.to_string()); let entry_ref = BrowserEntry {
if !browser.flags.is_empty() { name: entry.name.to_string(),
cmd.args(browser.flags.split_whitespace()); path: entry.path.to_string(),
} flags: entry.flags.to_string(),
icon: String::new(),
};
match cmd.arg(url).spawn() { match launch(&entry_ref, &url) {
Ok(_) => { 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(); let _ = main_window.window().hide();
} }
Err(e) => eprintln!("launch failed: {e}"), Err(e) => eprintln!("launch failed: {e}"),
@@ -101,25 +112,34 @@ fn init() -> State {
main_window.on_open_clicked({ main_window.on_open_clicked({
let main_window = main_window.clone_strong(); let main_window = main_window.clone_strong();
let browser_model = browser_model.clone(); let browser_model = browser_model.clone();
let cfg = cfg.clone();
move || { move || {
let index = main_window.get_selected_index().max(0) as usize; 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; 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(); let url = main_window.get_current_url().to_string();
println!( println!(
"Launching [{}]: {:?} {:?} {}", "Launching [{}]: {:?} {:?} {}",
index, browser.path, browser.flags, url index, entry.path, entry.flags, url
); );
let mut cmd = std::process::Command::new(browser.path.to_string()); match launch(&entry, &url) {
if !browser.flags.is_empty() {
cmd.args(browser.flags.split_whitespace());
}
match cmd.arg(url).spawn() {
Ok(_) => { 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(); let _ = main_window.window().hide();
} }
Err(e) => eprintln!("launch failed: {e}"), 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({ main_window.on_request_drag({
let main_window = main_window.clone_strong(); let main_window = main_window.clone_strong();
move || { move || {
@@ -147,29 +187,43 @@ fn init() -> State {
State { State {
main_window, main_window,
browser_model, browser_model,
config: cfg,
} }
} }
pub struct State { pub struct State {
pub main_window: MainWindow, pub main_window: MainWindow,
pub browser_model: Rc<slint::VecModel<BrowserConfig>>, pub browser_model: Rc<slint::VecModel<BrowserConfig>>,
pub config: Rc<RefCell<Config>>,
} }
pub fn main() { pub fn main() {
// TODO: how to enable skia? not panics let mut cfg = config::load();
// let _selector = BackendSelector::new() config::ensure_browsers(&mut cfg);
// .backend_name("renderer_skia".into())
// .select()
// .unwrap();
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(); let main_window = state.main_window.clone_strong();
main_window.show().unwrap(); main_window.show().unwrap();
let weak = main_window.as_weak(); let weak = main_window.as_weak();
let _ = slint::invoke_from_event_loop(move || { let _ = slint::invoke_from_event_loop(move || {
if let Some(mw) = weak.upgrade() { if let Some(mw) = weak.upgrade() {
center_window(mw.window()); winit::center_window(mw.window());
} }
}); });
+1 -1
View File
@@ -90,7 +90,7 @@ export component Header inherits Rectangle {
padding-top: 2px; padding-top: 2px;
Text { Text {
text: "ku6epxboctuk.github.io"; text: root.url;
color: Theme.text-control; color: Theme.text-control;
font-size: 14px; font-size: 14px;
font-family: "Consolas"; font-family: "Consolas";
+2 -1
View File
@@ -58,6 +58,7 @@ export component MainWindow inherits Window {
title: "bropicker"; title: "bropicker";
in property <[BrowserConfig]> browser-model: []; in property <[BrowserConfig]> browser-model: [];
in property <string> current-url: ""; in property <string> current-url: "";
in property <string> current-domain: "";
in-out property <bool> remember-choice: false; in-out property <bool> remember-choice: false;
in-out property <bool> always-ask: true; in-out property <bool> always-ask: true;
in property <int> selected-index: 0; in property <int> selected-index: 0;
@@ -141,7 +142,7 @@ export component MainWindow inherits Window {
padding-bottom: 8px; padding-bottom: 8px;
CheckRow { CheckRow {
label: "Запомнить выбор для " + root.current-url; label: "Запомнить выбор для " + root.current-domain;
checked <=> root.remember-choice; checked <=> root.remember-choice;
toggled => { toggled => {
root.toggle-remember(root.remember-choice); root.toggle-remember(root.remember-choice);