diff --git a/docs/config.example.toml b/docs/config.example.toml new file mode 100644 index 0000000..9343ceb --- /dev/null +++ b/docs/config.example.toml @@ -0,0 +1,40 @@ +# bropicker config +# Расположение: %APPDATA%\bropicker\config.toml +# Открывается из пикера кнопкой шестерёнки (subl/notepad) + +# [[browsers]] — один блок на запись пикера. +# path — полный путь к exe (или имя из PATH) +# flags — аргументы запуска; пробелы внутри значения — в кавычках +# icon — необязательно: путь к png/svg, иначе подбирается по имени + +[[browsers]] +name = "Firefox" +path = 'C:\Program Files\Mozilla Firefox\firefox.exe' +flags = "" + +# Профили Chrome: имена каталогов профилей смотри на chrome://version +# (Profile Path: ...\User Data\Default → "Default", +# ...\User Data\Profile 1 → "Profile 1") + +[[browsers]] +name = "Chrome — Work" +path = 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe' +flags = '--profile-directory="Default"' + +[[browsers]] +name = "Chrome — Personal" +path = 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe' +flags = '--profile-directory="Profile 1"' + +[[browsers]] +name = "Firefox — Work" +path = 'C:\Program Files\Mozilla Firefox\firefox.exe' +flags = '-P "Work"' + +# Запомненные домены: домен = имя записи из [[browsers]] +[remembered] +"github.com" = "Chrome — Work" + +[settings] +remember_choice = true +always_ask = true diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index a3b239f..9ea488b 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -149,6 +149,13 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он cwd = system32 и иконки бы отвалились); проверено запуском из LOCALAPPDATA с cwd=System32 - [x] Release-профиль: lto + codegen-units=1 + strip +- [x] Release без консоли: `windows_subsystem="windows"` только для не-debug + (в debug консоль остаётся); весь вывод → `%APPDATA%\bropicker\bropicker.log` + (config::log с unix-timestamp) +- [x] Парсер флагов понимает кавычки (`split_flags`) — профили браузеров: + `flags = '--profile-directory="Profile 1"'` (Chrome), `-P "Work"` (Firefox); + пример: docs/config.example.toml +- [x] Cold start release: 131 ms (после добавления файлового лога) ## Этап F — релиз diff --git a/src/config.rs b/src/config.rs index 98c50d0..465778a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,171 +1,190 @@ -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" - } -} +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub fn log(msg: &str) { + use std::io::Write; + let Ok(appdata) = std::env::var("APPDATA") else { + return; + }; + let dir = Path::new(&appdata).join("bropicker"); + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let path = dir.join("bropicker.log"); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + let _ = writeln!(f, "[{ts}] {msg}"); + } +} + +#[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| { + crate::config::log(&format!("[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) { + log(&format!("[bp] config save failed ({path:?}): {e}")); + } + } + Err(e) => log(&format!("[bp] config serialize failed: {e}")), + } +} + +pub fn ensure_browsers(cfg: &mut Config) { + if cfg.browsers.is_empty() { + cfg.browsers = detect_browsers(); + log(&format!("[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 90d1f08..39809df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")] + use std::cell::RefCell; use std::rc::Rc; @@ -11,6 +13,27 @@ use config::{BrowserEntry, Config}; slint::include_modules!(); +fn split_flags(flags: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_quotes = false; + for c in flags.chars() { + match c { + '"' => in_quotes = !in_quotes, + ' ' if !in_quotes => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + _ => cur.push(c), + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + fn load_icon(rel: &str) -> slint::Image { if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { @@ -41,7 +64,7 @@ fn to_ui_config(entry: &BrowserEntry) -> BrowserConfig { 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.args(split_flags(&entry.flags)); } cmd.arg(url).spawn().map(|_| ()) } @@ -114,17 +137,18 @@ fn init(cfg: Config, url: Option) -> State { let entry = browser_model.row_data(index).expect("row exists"); let url = main_window.get_current_url().to_string(); - println!( - "Launching [{}]: {:?} {:?} {}", - index, entry.path, entry.flags, url - ); - let entry_ref = BrowserEntry { name: entry.name.to_string(), path: entry.path.to_string(), flags: entry.flags.to_string(), icon: String::new(), }; + let url = main_window.get_current_url().to_string(); + + config::log(&format!( + "[bp] launching [{}]: {:?} {:?} {}", + index, entry_ref.path, entry_ref.flags, url + )); match launch(&entry_ref, &url) { Ok(_) => { @@ -137,7 +161,7 @@ fn init(cfg: Config, url: Option) -> State { } let _ = main_window.window().hide(); } - Err(e) => eprintln!("launch failed: {e}"), + Err(e) => config::log(&format!("[bp] launch failed: {e}")), } } }); @@ -159,10 +183,10 @@ fn init(cfg: Config, url: Option) -> State { }; let url = main_window.get_current_url().to_string(); - println!( - "Launching [{}]: {:?} {:?} {}", + config::log(&format!( + "[bp] launching [{}]: {:?} {:?} {}", index, entry.path, entry.flags, url - ); + )); match launch(&entry, &url) { Ok(_) => { @@ -175,7 +199,7 @@ fn init(cfg: Config, url: Option) -> State { } let _ = main_window.window().hide(); } - Err(e) => eprintln!("launch failed: {e}"), + Err(e) => config::log(&format!("[bp] launch failed: {e}")), } } }); @@ -221,7 +245,7 @@ fn init(cfg: Config, url: Option) -> State { .spawn() .or_else(|_| std::process::Command::new("notepad").arg(&path).spawn()); if let Err(e) = editor { - eprintln!("[bp] cannot open editor: {e}"); + config::log(&format!("[bp] cannot open editor: {e}")); } } }); @@ -329,9 +353,9 @@ pub fn main() { 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"); + config::log(&format!("[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}"); + config::log(&format!("[bp] '{domain}' remembered -> launching {name}")); let _ = launch(entry, url); return; } @@ -360,7 +384,10 @@ pub fn main() { winit::center_window(mw.window()); mw.invoke_restore_focus(); } - eprintln!("[bp] cold start -> event loop: {:?}", start.elapsed()); + config::log(&format!( + "[bp] cold start -> event loop: {:?}", + start.elapsed() + )); }); state.main_window.run().unwrap();