feat: console only in debug, update config parsing

This commit is contained in:
2026-08-26 11:59:05 +05:00
parent 9dfb72f0ea
commit 55e30ecfe6
4 changed files with 279 additions and 186 deletions
+40
View File
@@ -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
+7
View File
@@ -149,6 +149,13 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он
cwd = system32 и иконки бы отвалились); проверено запуском из cwd = system32 и иконки бы отвалились); проверено запуском из
LOCALAPPDATA с cwd=System32 LOCALAPPDATA с cwd=System32
- [x] Release-профиль: lto + codegen-units=1 + strip - [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 — релиз ## Этап F — релиз
+190 -171
View File
@@ -1,171 +1,190 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] pub fn log(msg: &str) {
pub struct BrowserEntry { use std::io::Write;
pub name: String, let Ok(appdata) = std::env::var("APPDATA") else {
pub path: String, return;
#[serde(default)] };
pub flags: String, let dir = Path::new(&appdata).join("bropicker");
#[serde(default)] if std::fs::create_dir_all(&dir).is_err() {
pub icon: String, return;
} }
let path = dir.join("bropicker.log");
#[derive(Serialize, Deserialize, Clone, Debug)] let ts = std::time::SystemTime::now()
pub struct Settings { .duration_since(std::time::UNIX_EPOCH)
#[serde(default = "default_true")] .map(|d| d.as_secs())
pub remember_choice: bool, .unwrap_or(0);
#[serde(default = "default_true")] if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
pub always_ask: bool, let _ = writeln!(f, "[{ts}] {msg}");
} }
}
fn default_true() -> bool {
true #[derive(Serialize, Deserialize, Clone, Debug)]
} pub struct BrowserEntry {
pub name: String,
impl Default for Settings { pub path: String,
fn default() -> Self { #[serde(default)]
Self { pub flags: String,
remember_choice: true, #[serde(default)]
always_ask: true, pub icon: String,
} }
}
} #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Settings {
#[derive(Serialize, Deserialize, Clone, Debug, Default)] #[serde(default = "default_true")]
pub struct Config { pub remember_choice: bool,
#[serde(default)] #[serde(default = "default_true")]
pub browsers: Vec<BrowserEntry>, pub always_ask: bool,
#[serde(default)] }
pub remembered: HashMap<String, String>,
#[serde(default)] fn default_true() -> bool {
pub settings: Settings, true
} }
pub fn config_path() -> PathBuf { impl Default for Settings {
if let Ok(p) = std::env::var("BP_CONFIG") { fn default() -> Self {
return PathBuf::from(p); Self {
} remember_choice: true,
std::env::var("APPDATA") always_ask: true,
.map(|d| PathBuf::from(d).join("bropicker").join("config.toml")) }
.unwrap_or_else(|_| PathBuf::from("config.toml")) }
} }
pub fn load() -> Config { #[derive(Serialize, Deserialize, Clone, Debug, Default)]
let path = config_path(); pub struct Config {
match std::fs::read_to_string(&path) { #[serde(default)]
Ok(s) => toml::from_str(&s).unwrap_or_else(|e| { pub browsers: Vec<BrowserEntry>,
eprintln!("[bp] config parse error ({path:?}): {e}"); #[serde(default)]
Config::default() pub remembered: HashMap<String, String>,
}), #[serde(default)]
Err(_) => Config::default(), pub settings: Settings,
} }
}
pub fn config_path() -> PathBuf {
pub fn save(cfg: &Config) { if let Ok(p) = std::env::var("BP_CONFIG") {
let path = config_path(); return PathBuf::from(p);
if let Some(dir) = path.parent() { }
let _ = std::fs::create_dir_all(dir); std::env::var("APPDATA")
} .map(|d| PathBuf::from(d).join("bropicker").join("config.toml"))
match toml::to_string_pretty(cfg) { .unwrap_or_else(|_| PathBuf::from("config.toml"))
Ok(s) => { }
if let Err(e) = std::fs::write(&path, s) {
eprintln!("[bp] config save failed ({path:?}): {e}"); pub fn load() -> Config {
} let path = config_path();
} match std::fs::read_to_string(&path) {
Err(e) => eprintln!("[bp] config serialize failed: {e}"), Ok(s) => toml::from_str(&s).unwrap_or_else(|e| {
} crate::config::log(&format!("[bp] config parse error ({path:?}): {e}"));
} Config::default()
}),
pub fn ensure_browsers(cfg: &mut Config) { Err(_) => Config::default(),
if cfg.browsers.is_empty() { }
cfg.browsers = detect_browsers(); }
eprintln!("[bp] autodetected {} browsers", cfg.browsers.len());
if !cfg.browsers.is_empty() { pub fn save(cfg: &Config) {
save(cfg); let path = config_path();
} if let Some(dir) = path.parent() {
} let _ = std::fs::create_dir_all(dir);
} }
match toml::to_string_pretty(cfg) {
pub fn domain_of(url: &str) -> String { Ok(s) => {
let rest = url.split("://").nth(1).unwrap_or(url); if let Err(e) = std::fs::write(&path, s) {
let host = rest.split(['/', '?', '#']).next().unwrap_or(""); log(&format!("[bp] config save failed ({path:?}): {e}"));
let host = host.split('@').next().unwrap_or(host); }
let host = host.split(':').next().unwrap_or(host); }
host.trim_start_matches("www.").to_lowercase() Err(e) => log(&format!("[bp] config serialize failed: {e}")),
} }
}
fn extract_exe(cmd: &str) -> Option<String> {
let t = cmd.trim(); pub fn ensure_browsers(cfg: &mut Config) {
if t.starts_with('"') { if cfg.browsers.is_empty() {
t.split('"').nth(1).map(String::from) cfg.browsers = detect_browsers();
} else { log(&format!("[bp] autodetected {} browsers", cfg.browsers.len()));
t.split_whitespace().next().map(String::from) if !cfg.browsers.is_empty() {
} save(cfg);
} }
}
pub fn detect_browsers() -> Vec<BrowserEntry> { }
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey; pub fn domain_of(url: &str) -> String {
let rest = url.split("://").nth(1).unwrap_or(url);
let mut out: Vec<BrowserEntry> = Vec::new(); let host = rest.split(['/', '?', '#']).next().unwrap_or("");
let host = host.split('@').next().unwrap_or(host);
for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] { let host = host.split(':').next().unwrap_or(host);
let Ok(clients) = host.trim_start_matches("www.").to_lowercase()
RegKey::predef(hive).open_subkey("SOFTWARE\\Clients\\StartMenuInternet") }
else {
continue; fn extract_exe(cmd: &str) -> Option<String> {
}; let t = cmd.trim();
for key_name in clients.enum_keys().flatten() { if t.starts_with('"') {
let Ok(client) = clients.open_subkey(&key_name) else { t.split('"').nth(1).map(String::from)
continue; } else {
}; t.split_whitespace().next().map(String::from)
let friendly: String = client }
.get_value("") }
.unwrap_or_else(|_| key_name.clone());
let command: String = client pub fn detect_browsers() -> Vec<BrowserEntry> {
.open_subkey("shell\\open\\command") use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
.and_then(|k| k.get_value("")) use winreg::RegKey;
.unwrap_or_default();
let Some(path) = extract_exe(&command) else { let mut out: Vec<BrowserEntry> = Vec::new();
continue;
}; for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] {
if !Path::new(&path).exists() { let Ok(clients) =
continue; RegKey::predef(hive).open_subkey("SOFTWARE\\Clients\\StartMenuInternet")
} else {
if out continue;
.iter() };
.any(|b: &BrowserEntry| b.path.eq_ignore_ascii_case(&path)) for key_name in clients.enum_keys().flatten() {
{ let Ok(client) = clients.open_subkey(&key_name) else {
continue; continue;
} };
out.push(BrowserEntry { let friendly: String = client
name: friendly, .get_value("")
path, .unwrap_or_else(|_| key_name.clone());
flags: String::new(), let command: String = client
icon: String::new(), .open_subkey("shell\\open\\command")
}); .and_then(|k| k.get_value(""))
} .unwrap_or_default();
} let Some(path) = extract_exe(&command) else {
continue;
out };
} if !Path::new(&path).exists() {
continue;
pub fn icon_for(name: &str) -> &'static str { }
let n = name.to_lowercase(); if out
if n.contains("firefox") { .iter()
"logos/firefox_48x48.png" .any(|b: &BrowserEntry| b.path.eq_ignore_ascii_case(&path))
} else if n.contains("chrome") { {
"logos/chrome_48x48.png" continue;
} else if n.contains("opera") { }
"logos/opera_48x48.png" out.push(BrowserEntry {
} else if n.contains("brave") { name: friendly,
"logos/brave_48x48.png" path,
} else if n.contains("яндекс") || n.contains("yandex") { flags: String::new(),
"logos/yandex_48x48.png" icon: String::new(),
} else { });
"icons/globe.svg" }
} }
}
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"
}
}
+42 -15
View File
@@ -1,3 +1,5 @@
#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")]
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -11,6 +13,27 @@ use config::{BrowserEntry, Config};
slint::include_modules!(); slint::include_modules!();
fn split_flags(flags: &str) -> Vec<String> {
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 { fn load_icon(rel: &str) -> slint::Image {
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() { 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<()> { fn launch(entry: &BrowserEntry, url: &str) -> std::io::Result<()> {
let mut cmd = std::process::Command::new(&entry.path); let mut cmd = std::process::Command::new(&entry.path);
if !entry.flags.is_empty() { if !entry.flags.is_empty() {
cmd.args(entry.flags.split_whitespace()); cmd.args(split_flags(&entry.flags));
} }
cmd.arg(url).spawn().map(|_| ()) cmd.arg(url).spawn().map(|_| ())
} }
@@ -114,17 +137,18 @@ fn init(cfg: Config, url: Option<String>) -> State {
let entry = browser_model.row_data(index).expect("row exists"); 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!(
"Launching [{}]: {:?} {:?} {}",
index, entry.path, entry.flags, url
);
let entry_ref = BrowserEntry { let entry_ref = BrowserEntry {
name: entry.name.to_string(), name: entry.name.to_string(),
path: entry.path.to_string(), path: entry.path.to_string(),
flags: entry.flags.to_string(), flags: entry.flags.to_string(),
icon: String::new(), 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) { match launch(&entry_ref, &url) {
Ok(_) => { Ok(_) => {
@@ -137,7 +161,7 @@ fn init(cfg: Config, url: Option<String>) -> State {
} }
let _ = main_window.window().hide(); 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<String>) -> State {
}; };
let url = main_window.get_current_url().to_string(); let url = main_window.get_current_url().to_string();
println!( config::log(&format!(
"Launching [{}]: {:?} {:?} {}", "[bp] launching [{}]: {:?} {:?} {}",
index, entry.path, entry.flags, url index, entry.path, entry.flags, url
); ));
match launch(&entry, &url) { match launch(&entry, &url) {
Ok(_) => { Ok(_) => {
@@ -175,7 +199,7 @@ fn init(cfg: Config, url: Option<String>) -> State {
} }
let _ = main_window.window().hide(); 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<String>) -> State {
.spawn() .spawn()
.or_else(|_| std::process::Command::new("notepad").arg(&path).spawn()); .or_else(|_| std::process::Command::new("notepad").arg(&path).spawn());
if let Err(e) = editor { 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); let domain = config::domain_of(url);
if let Some(name) = cfg.remembered.get(&domain).cloned() { if let Some(name) = cfg.remembered.get(&domain).cloned() {
if cfg.settings.always_ask { 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) { } 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); let _ = launch(entry, url);
return; return;
} }
@@ -360,7 +384,10 @@ pub fn main() {
winit::center_window(mw.window()); winit::center_window(mw.window());
mw.invoke_restore_focus(); 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(); state.main_window.run().unwrap();