diff --git a/.github/oversized.txt b/.github/oversized.txt
index b3bda62..0ba4410 100644
--- a/.github/oversized.txt
+++ b/.github/oversized.txt
@@ -1,2 +1,2 @@
-2026 app/src-tauri/src/lib.rs
-1969 crates/linkunbound-shell/src/main.rs
+1893 app/src-tauri/src/lib.rs
+1965 crates/linkunbound-shell/src/main.rs
diff --git a/app/src-tauri/src/editing.rs b/app/src-tauri/src/editing.rs
new file mode 100644
index 0000000..d8abd7f
--- /dev/null
+++ b/app/src-tauri/src/editing.rs
@@ -0,0 +1,408 @@
+//! What the settings screen may change about a browser, checked before it is saved.
+
+use std::collections::BTreeMap;
+
+use linkunbound_core::{Browser, usable_key};
+
+#[derive(serde::Serialize)]
+pub struct BrowserView {
+ pub id: String,
+ pub name: String,
+ pub exe: String,
+ pub profiles: usize,
+ /// Named, not just counted: a rule can point at one profile of a browser,
+ /// so the screen has to be able to offer them apart.
+ pub profile_names: Vec<(String, String)>,
+ pub private: bool,
+ pub custom: bool,
+ pub hidden: bool,
+ pub icon: Option,
+ /// Quoted back the way it has to be typed, or a value with a space in it would be split
+ /// the next time the form was saved.
+ pub args: String,
+ pub private_flag: Option,
+ pub icon_path: Option,
+ /// The key chosen for each row, by profile id, or by "" for a browser with no profiles.
+ pub keys: BTreeMap,
+ /// The key each row shows in the picker, chosen or not, so an empty box can say which it got.
+ pub shown_keys: BTreeMap,
+}
+
+pub fn seen(browser: &Browser, shown_keys: BTreeMap) -> BrowserView {
+ BrowserView {
+ profiles: browser.profiles.len(),
+ profile_names: browser
+ .profiles
+ .iter()
+ .map(|p| (p.id.clone(), p.name.clone()))
+ .collect(),
+ private: browser.private_flag.is_some(),
+ icon: crate::icon_data(browser.icon_source(), &browser.id),
+ id: browser.id.clone(),
+ name: browser.name.clone(),
+ exe: browser.exe.clone(),
+ custom: browser.custom,
+ hidden: browser.hidden,
+ args: linkunbound_core::join_args(&browser.extra_args),
+ private_flag: browser.private_flag.clone(),
+ icon_path: browser.icon_path.clone(),
+ keys: browser.keys.clone(),
+ shown_keys,
+ }
+}
+
+pub fn views(all: &[Browser]) -> Vec {
+ let shown = linkunbound_core::picker_keys(all);
+ all.iter()
+ .zip(shown)
+ .map(|(b, keys)| seen(b, keys))
+ .collect()
+}
+
+/// What the settings screen can change about a browser it owns.
+#[derive(serde::Deserialize)]
+pub struct Edit {
+ pub name: String,
+ pub exe: String,
+ /// The line as it was typed, not the words in it: a value in quotes is one argument, and
+ /// deciding that in the form put the rule somewhere nothing could test it.
+ pub args: String,
+ pub private_flag: Option,
+ pub icon_path: Option,
+ /// What was typed for each row; an empty box leaves the row to the defaults.
+ #[serde(default)]
+ pub keys: BTreeMap,
+}
+
+pub fn readable(path: &str) -> bool {
+ let path = std::path::Path::new(path);
+ path.is_file() || is_bundle(path)
+}
+
+#[cfg(target_os = "macos")]
+fn is_bundle(path: &std::path::Path) -> bool {
+ path.extension()
+ .is_some_and(|e| e.eq_ignore_ascii_case("app"))
+ && path.join("Contents").join("Info.plist").is_file()
+}
+
+#[cfg(not(target_os = "macos"))]
+fn is_bundle(_path: &std::path::Path) -> bool {
+ false
+}
+
+pub fn hidden_in(mut all: Vec, id: &str, hidden: bool) -> Result, String> {
+ // Hidden, its keys were free to give away; back in the picker, it lets go of those now taken.
+ let taken: Vec = all
+ .iter()
+ .filter(|b| b.id != id && !b.hidden)
+ .flat_map(|b| {
+ b.keys
+ .values()
+ .filter_map(|k| linkunbound_core::usable_key(*k))
+ })
+ .collect();
+ let Some(found) = all.iter_mut().find(|b| b.id == id) else {
+ return Err("browserGone".to_owned());
+ };
+ if found.hidden && !hidden {
+ found
+ .keys
+ .retain(|_, k| linkunbound_core::usable_key(*k).is_none_or(|k| !taken.contains(&k)));
+ }
+ found.hidden = hidden;
+ Ok(all)
+}
+
+/// A detected browser is hidden, never removed: the next scan would bring it
+/// back and the deletion would look like it failed.
+pub fn without(mut all: Vec, id: &str) -> Result, String> {
+ if !all.iter().any(|b| b.id == id && b.custom) {
+ return Err("browserNotYours".to_owned());
+ }
+ all.retain(|b| b.id != id);
+ Ok(all)
+}
+
+/// Never reuses a number a live entry already holds, or editing one would
+/// rename the other.
+pub fn free_id(all: &[Browser]) -> String {
+ (1..)
+ .map(|n| format!("custom-{n}"))
+ .find(|id| !all.iter().any(|b| &b.id == id))
+ .unwrap_or_else(|| "custom-1".to_owned())
+}
+
+pub fn added(
+ mut all: Vec,
+ edit: Edit,
+ exists: impl Fn(&str) -> bool,
+) -> Result, String> {
+ if !exists(&edit.exe) {
+ return Err("browserNoProgram".to_owned());
+ }
+ let id = free_id(&all);
+ let keys = keyed(&all, &id, &edit.keys)?;
+ let private_flag = private_flag(edit.private_flag, &edit.exe);
+ all.push(Browser {
+ id,
+ name: edit.name,
+ exe: edit.exe,
+ profiles: Vec::new(),
+ extra_args: linkunbound_core::split_args(&edit.args),
+ private_flag,
+ icon_path: edit.icon_path.filter(|p| !p.is_empty()),
+ custom: true,
+ hidden: false,
+ keys,
+ });
+ Ok(all)
+}
+
+pub fn edited(
+ mut all: Vec,
+ id: &str,
+ edit: Edit,
+ exists: impl Fn(&str) -> bool,
+) -> Result, String> {
+ let keys = keyed(&all, id, &edit.keys)?;
+ let Some(found) = all.iter_mut().find(|b| b.id == id) else {
+ return Err("browserGone".to_owned());
+ };
+ found.keys = keys;
+ if found.custom {
+ if !exists(&edit.exe) {
+ return Err("browserNoProgram".to_owned());
+ }
+ found.exe = edit.exe;
+ }
+ found.name = edit.name;
+ found.extra_args = linkunbound_core::split_args(&edit.args);
+ // Detection knows how a browser it found opens privately, and keeps that; where it found one
+ // it does not know — a family off its list — the flag is the person's to say, as for a
+ // browser they added themselves.
+ if found.custom || linkunbound_core::private_flag_for(&found.exe).is_none() {
+ found.private_flag = private_flag(edit.private_flag, &found.exe);
+ }
+ found.icon_path = edit.icon_path.filter(|p| !p.is_empty());
+ Ok(all)
+}
+
+/// The keys typed for one browser's rows. One that another row already holds is refused rather
+/// than kept: the picker would give it to whichever row comes first, and the other would
+/// silently lose it.
+pub fn keyed(
+ all: &[Browser],
+ id: &str,
+ typed: &BTreeMap,
+) -> Result, String> {
+ let rows: Vec = all.iter().find(|b| b.id == id).map_or_else(
+ || vec![String::new()],
+ |b| {
+ b.rows()
+ .iter()
+ .map(|p| p.map(|p| p.id.clone()).unwrap_or_default())
+ .collect()
+ },
+ );
+ let mut keys = BTreeMap::new();
+ for (row, said) in typed {
+ let said = said.trim();
+ if said.is_empty() || !rows.contains(row) {
+ continue;
+ }
+ let mut chars = said.chars();
+ let key = chars
+ .next()
+ .and_then(usable_key)
+ .filter(|_| chars.next().is_none())
+ .ok_or_else(|| "keyUnusable".to_owned())?;
+ if keys.values().any(|k| *k == key) {
+ return Err("keyTwice".to_owned());
+ }
+ if let Some(holder) = holder_of(all, id, key) {
+ return Err(format!("keyTaken:{holder}"));
+ }
+ keys.insert(row.clone(), key);
+ }
+ Ok(keys)
+}
+
+/// The switch typed, or when the box was left empty the one the browser's family is known to use:
+/// few people know `--incognito` exists, let alone that Edge spells it `-inprivate`.
+fn private_flag(typed: Option, exe: &str) -> Option {
+ typed
+ .filter(|f| !f.trim().is_empty())
+ .or_else(|| linkunbound_core::private_flag_for(exe).map(str::to_owned))
+}
+
+/// Who else holds a key: the browser's name, with the profile's when it is one of several.
+fn holder_of(all: &[Browser], id: &str, key: char) -> Option {
+ all.iter()
+ .filter(|b| b.id != id && !b.hidden)
+ .find_map(|b| {
+ let held = b
+ .rows()
+ .into_iter()
+ .find(|p| b.key_for(p.map(|p| p.id.as_str())) == Some(key))?;
+ Some(held.map_or_else(|| b.name.clone(), |p| format!("{} · {}", b.name, p.name)))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Edit, added, hidden_in, keyed};
+ use linkunbound_core::{Browser, Profile};
+ use std::collections::BTreeMap;
+
+ fn browser(id: &str, profiles: &[(&str, &str)]) -> Browser {
+ Browser {
+ id: id.to_owned(),
+ name: "Google Chrome".to_owned(),
+ exe: "chrome.exe".to_owned(),
+ profiles: profiles
+ .iter()
+ .map(|(id, name)| Profile {
+ id: (*id).to_owned(),
+ name: (*name).to_owned(),
+ args: Vec::new(),
+ })
+ .collect(),
+ extra_args: Vec::new(),
+ private_flag: None,
+ icon_path: None,
+ custom: false,
+ hidden: false,
+ keys: BTreeMap::new(),
+ }
+ }
+
+ fn typed(pairs: &[(&str, &str)]) -> BTreeMap {
+ pairs
+ .iter()
+ .map(|(row, key)| ((*row).to_owned(), (*key).to_owned()))
+ .collect()
+ }
+
+ /// Most people have never heard of `--incognito`: a browser added by hand whose family is
+ /// known opens privately without anyone typing it, and a switch typed on purpose still wins.
+ #[test]
+ fn a_known_browser_brings_its_own_private_switch() {
+ let add = |exe: &str, flag: &str| {
+ let edit = Edit {
+ name: "Mío".to_owned(),
+ exe: exe.to_owned(),
+ args: String::new(),
+ private_flag: Some(flag.to_owned()),
+ icon_path: None,
+ keys: BTreeMap::new(),
+ };
+ added(Vec::new(), edit, |_| true).expect("there")[0]
+ .private_flag
+ .clone()
+ };
+ let chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe";
+ assert_eq!(add(chrome, "").as_deref(), Some("--incognito"));
+ assert_eq!(
+ add("C:/Edge/msedge.exe", " ").as_deref(),
+ Some("-inprivate")
+ );
+ assert_eq!(add(chrome, "--otra").as_deref(), Some("--otra"));
+ assert_eq!(add("C:/Apps/raro.exe", "").as_deref(), None);
+ }
+
+ /// A hidden browser holds no key, so another may take its key meanwhile; shown again, it lets
+ /// go of that one rather than share it, and keeps the rest.
+ #[test]
+ fn a_browser_shown_again_lets_go_of_a_key_given_away() {
+ let mut edge = browser("edge", &[]);
+ edge.hidden = true;
+ edge.keys.insert(String::new(), 'H');
+ let mut firefox = browser("firefox", &[]);
+ firefox.keys.insert(String::new(), 'H');
+ let mut kept = browser("brave", &[]);
+ kept.hidden = true;
+ kept.keys.insert(String::new(), 'B');
+
+ let shown = hidden_in(vec![edge, firefox, kept], "edge", false).expect("there");
+ assert!(shown[0].keys.is_empty(), "Firefox has H now");
+ assert_eq!(shown[1].key_for(None), Some('H'));
+ let back = hidden_in(shown, "brave", false).expect("there");
+ assert_eq!(back[2].key_for(None), Some('B'), "nobody took B");
+ }
+
+ /// A key is one digit from 1 or one letter, kept in capitals, and only while no other row holds
+ /// it: the second row would never be reached by it.
+ #[test]
+ fn a_key_is_kept_only_while_no_other_row_holds_it() {
+ let mut chrome = browser(
+ "chrome",
+ &[("Default", "Personal"), ("Profile 1", "Trabajo")],
+ );
+ chrome.keys.insert("Profile 1".to_owned(), 'W');
+ let all = vec![chrome, browser("custom-1", &[])];
+
+ let kept = keyed(&all, "custom-1", &typed(&[("", "p"), ("other", " ")])).expect("free");
+ assert_eq!(
+ kept,
+ BTreeMap::from([(String::new(), 'P')]),
+ "a blank box is no key"
+ );
+ assert_eq!(
+ keyed(&all, "custom-1", &typed(&[("", "w")])).unwrap_err(),
+ "keyTaken:Google Chrome · Trabajo"
+ );
+ assert_eq!(
+ keyed(&all, "custom-1", &typed(&[("", "0")])).unwrap_err(),
+ "keyUnusable"
+ );
+ assert_eq!(
+ keyed(&all, "custom-1", &typed(&[("", "ab")])).unwrap_err(),
+ "keyUnusable"
+ );
+ assert_eq!(
+ keyed(
+ &all,
+ "chrome",
+ &typed(&[("Default", "Q"), ("Profile 1", "q")])
+ )
+ .unwrap_err(),
+ "keyTwice",
+ "two rows of one browser cannot share one either"
+ );
+ assert_eq!(
+ keyed(&all, "chrome", &typed(&[("Profile 1", "w")])).expect("its own key"),
+ BTreeMap::from([("Profile 1".to_owned(), 'W')])
+ );
+
+ let mut hidden = browser("edge", &[]);
+ hidden.hidden = true;
+ hidden.keys.insert(String::new(), 'H');
+ let mut lower = browser("brave", &[]);
+ lower.keys.insert(String::new(), 'l');
+ let others = [hidden, lower];
+ assert!(
+ keyed(&others, "custom-1", &typed(&[("", "h")])).is_ok(),
+ "a hidden browser is not in the picker and holds nothing"
+ );
+ assert!(
+ keyed(&others, "custom-1", &typed(&[("", "L")])).is_err(),
+ "a key written in lowercase by hand still holds"
+ );
+
+ assert_eq!(
+ keyed(&all, "custom-1", &typed(&[("Profile 9", "z")])).expect("ignored"),
+ BTreeMap::new(),
+ "a row the browser does not have keeps no key"
+ );
+
+ let mut plain = browser("firefox", &[]);
+ plain.name = "Firefox".to_owned();
+ plain.keys.insert(String::new(), 'F');
+ assert_eq!(
+ keyed(&[plain], "custom-1", &typed(&[("", "F")])).unwrap_err(),
+ "keyTaken:Firefox",
+ "a browser with no profiles is named alone"
+ );
+ }
+}
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index d1c8544..aef3e3f 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -1,4 +1,5 @@
mod about;
+mod editing;
mod news;
mod shell;
mod shop;
@@ -6,6 +7,7 @@ mod star;
mod system;
mod update;
+use editing::{BrowserView, Edit, added, edited, free_id, hidden_in, readable, views, without};
use linkunbound_core::{
Browser, Language, Preferences, Rule, Scope, Store, Strings, Target, host_of, is_public_suffix,
merge, normalise, same_name, site_of,
@@ -327,79 +329,9 @@ fn rules_retarget(
rules_list()
}
-#[derive(Serialize)]
-struct BrowserView {
- id: String,
- name: String,
- exe: String,
- profiles: usize,
- /// Named, not just counted: a rule can point at one profile of a browser,
- /// so the screen has to be able to offer them apart.
- profile_names: Vec<(String, String)>,
- private: bool,
- custom: bool,
- hidden: bool,
- icon: Option,
- /// Quoted back the way it has to be typed, or a value with a space in it would be split
- /// the next time the form was saved.
- args: String,
- private_flag: Option,
- icon_path: Option,
-}
-
-fn seen(browser: &Browser) -> BrowserView {
- BrowserView {
- profiles: browser.profiles.len(),
- profile_names: browser
- .profiles
- .iter()
- .map(|p| (p.id.clone(), p.name.clone()))
- .collect(),
- private: browser.private_flag.is_some(),
- icon: icon_data(browser.icon_source(), &browser.id),
- id: browser.id.clone(),
- name: browser.name.clone(),
- exe: browser.exe.clone(),
- custom: browser.custom,
- hidden: browser.hidden,
- args: linkunbound_core::join_args(&browser.extra_args),
- private_flag: browser.private_flag.clone(),
- icon_path: browser.icon_path.clone(),
- }
-}
-
-/// What the settings screen can change about a browser it owns.
-#[derive(serde::Deserialize)]
-struct Edit {
- name: String,
- exe: String,
- /// The line as it was typed, not the words in it: a value in quotes is one argument, and
- /// deciding that in the form put the rule somewhere nothing could test it.
- args: String,
- private_flag: Option,
- icon_path: Option,
-}
-
-fn readable(path: &str) -> bool {
- let path = std::path::Path::new(path);
- path.is_file() || is_bundle(path)
-}
-
-#[cfg(target_os = "macos")]
-fn is_bundle(path: &std::path::Path) -> bool {
- path.extension()
- .is_some_and(|e| e.eq_ignore_ascii_case("app"))
- && path.join("Contents").join("Info.plist").is_file()
-}
-
-#[cfg(not(target_os = "macos"))]
-fn is_bundle(_path: &std::path::Path) -> bool {
- false
-}
-
#[tauri::command]
fn browsers_list() -> Vec {
- catalogue().iter().map(seen).collect()
+ views(&catalogue())
}
/// Saves the merged catalogue, which is what turns a detected browser into a
@@ -422,65 +354,16 @@ fn browsers_set_hidden(id: String, hidden: bool) -> Result, Str
keep(hidden_in(catalogue(), &id, hidden)?)
}
-fn hidden_in(mut all: Vec, id: &str, hidden: bool) -> Result, String> {
- let Some(found) = all.iter_mut().find(|b| b.id == id) else {
- return Err("browserGone".to_owned());
- };
- found.hidden = hidden;
- Ok(all)
-}
-
#[tauri::command]
fn browsers_remove(id: String) -> Result, String> {
keep(without(catalogue(), &id)?)
}
-/// A detected browser is hidden, never removed: the next scan would bring it
-/// back and the deletion would look like it failed.
-fn without(mut all: Vec, id: &str) -> Result, String> {
- if !all.iter().any(|b| b.id == id && b.custom) {
- return Err("browserNotYours".to_owned());
- }
- all.retain(|b| b.id != id);
- Ok(all)
-}
-
-/// Never reuses a number a live entry already holds, or editing one would
-/// rename the other.
-fn free_id(all: &[Browser]) -> String {
- (1..)
- .map(|n| format!("custom-{n}"))
- .find(|id| !all.iter().any(|b| &b.id == id))
- .unwrap_or_else(|| "custom-1".to_owned())
-}
-
#[tauri::command]
fn browsers_add(edit: Edit) -> Result, String> {
keep(added(catalogue(), edit, readable)?)
}
-fn added(
- mut all: Vec,
- edit: Edit,
- exists: impl Fn(&str) -> bool,
-) -> Result, String> {
- if !exists(&edit.exe) {
- return Err("browserNoProgram".to_owned());
- }
- all.push(Browser {
- id: free_id(&all),
- name: edit.name,
- exe: edit.exe,
- profiles: Vec::new(),
- extra_args: linkunbound_core::split_args(&edit.args),
- private_flag: edit.private_flag.filter(|f| !f.is_empty()),
- icon_path: edit.icon_path.filter(|p| !p.is_empty()),
- custom: true,
- hidden: false,
- });
- Ok(all)
-}
-
/// A detected browser can be renamed and given arguments or an icon, the way
/// 1.x allowed, but not moved: the path is what detection found, and a rescan
/// would overwrite anything typed over it.
@@ -489,33 +372,6 @@ fn browsers_update(id: String, edit: Edit) -> Result, String> {
keep(edited(catalogue(), &id, edit, readable)?)
}
-fn edited(
- mut all: Vec,
- id: &str,
- edit: Edit,
- exists: impl Fn(&str) -> bool,
-) -> Result, String> {
- let Some(found) = all.iter_mut().find(|b| b.id == id) else {
- return Err("browserGone".to_owned());
- };
- if found.custom {
- if !exists(&edit.exe) {
- return Err("browserNoProgram".to_owned());
- }
- found.exe = edit.exe;
- }
- found.name = edit.name;
- found.extra_args = linkunbound_core::split_args(&edit.args);
- // Detection knows how a browser it found opens privately, and keeps that; where it found one
- // it does not know — a family off its list — the flag is the person's to say, as for a
- // browser they added themselves.
- if found.custom || linkunbound_core::private_flag_for(&found.exe).is_none() {
- found.private_flag = edit.private_flag.filter(|f| !f.is_empty());
- }
- found.icon_path = edit.icon_path.filter(|p| !p.is_empty());
- Ok(all)
-}
-
/// What the bar shows. The plugin reports the size of each chunk rather than the total so far,
/// and a whole it does not know yet is nought rather than everything. Capped, because a server
/// that declares one size and sends another would otherwise put a number like 40000 on screen.
@@ -581,7 +437,7 @@ fn maintenance_rescan() -> Result {
// What was just detected and saved is the list; reading the catalogue again would detect twice.
keep_in(&store(), found.browsers.clone())?;
Ok(Rescanned {
- browsers: found.browsers.iter().map(seen).collect(),
+ browsers: views(&found.browsers),
added: found.added,
removed: found.removed,
})
@@ -1340,8 +1196,9 @@ pub fn run() {
mod tests {
use super::{
Edit, Errand, Store, added, describe, duplicated_in, edited, errand_in, far_along, free_id,
- hidden_in, keep_in, ordered, readable, rescanned, seen, spoken, without,
+ hidden_in, keep_in, ordered, readable, rescanned, spoken, without,
};
+ use crate::editing::seen;
use linkunbound_core::{Locale, Preferences, Profile, Rule, Scope, Target};
/// The resident's errands are told apart from a launch that wants the window, and from each
@@ -1620,6 +1477,7 @@ mod tests {
args: String::new(),
private_flag: Some(String::new()),
icon_path: Some(String::new()),
+ keys: [(String::new(), " ".to_owned())].into(),
},
|_| true,
)
@@ -1627,6 +1485,10 @@ mod tests {
assert_eq!(all[0].private_flag, None);
assert_eq!(all[0].icon_path, None);
+ assert!(
+ all[0].keys.is_empty(),
+ "an empty key box leaves the row to the defaults"
+ );
assert!(all[0].custom, "one somebody added is theirs to edit");
assert!(!all[0].hidden);
}
@@ -1643,6 +1505,7 @@ mod tests {
args: String::new(),
private_flag: None,
icon_path: None,
+ keys: Default::default(),
},
|_| false,
);
@@ -1661,6 +1524,7 @@ mod tests {
args: r#"--new-window --user-data-dir="C:\My Data""#.to_owned(),
private_flag: Some("--incognito".to_owned()),
icon_path: None,
+ keys: Default::default(),
},
|_| true,
)
@@ -1723,6 +1587,7 @@ mod tests {
icon_path: None,
custom: false,
hidden: false,
+ keys: Default::default(),
}
}
@@ -1765,7 +1630,7 @@ mod tests {
#[test]
fn a_detected_browser_is_not_reported_as_one_the_user_added() {
- let view = seen(&detected("chrome"));
+ let view = seen(&detected("chrome"), Default::default());
assert!(!view.custom);
assert_eq!(view.profiles, 1);
assert_eq!(
@@ -1775,7 +1640,7 @@ mod tests {
);
assert!(view.private, "it declares a private switch");
- let mine = seen(&custom("custom-1"));
+ let mine = seen(&custom("custom-1"), Default::default());
assert!(mine.custom);
assert!(!mine.private);
}
@@ -1896,6 +1761,7 @@ mod tests {
args: "--new-window".to_owned(),
private_flag: Some(String::new()),
icon_path: None,
+ keys: Default::default(),
}
}
@@ -2007,6 +1873,7 @@ mod tests {
icon_path: None,
custom: true,
hidden: false,
+ keys: Default::default(),
}
}
diff --git a/app/src/Settings.test.tsx b/app/src/Settings.test.tsx
index f7e4021..3732dee 100644
--- a/app/src/Settings.test.tsx
+++ b/app/src/Settings.test.tsx
@@ -513,6 +513,18 @@ describe("settings", () => {
expect(screen.queryByRole("button", { name: "Reparar" })).toBeNull();
});
+ /// Browsers come before the rules that point at them, and both before the default-browser page.
+ it("lists the sections in the order they are set up", async () => {
+ answers(BASE, { browsers_list: () => Promise.resolve([]) });
+ await opened();
+ const order = ["Aplicación", "Navegadores", "Reglas", "Enlaces", "Mantenimiento"];
+ const names = screen
+ .getAllByRole("button")
+ .map((b) => b.textContent?.trim() ?? "")
+ .filter((name) => order.includes(name));
+ expect(names).toEqual(order);
+ });
+
it("walks every section and marks the one it is on", async () => {
answers(BASE, { browsers_list: () => Promise.resolve([]) });
await opened();
diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx
index 80b2c06..7d6746d 100644
--- a/app/src/Settings.tsx
+++ b/app/src/Settings.tsx
@@ -103,23 +103,23 @@ const PAGES: Entry[] = [
),
},
{
- id: "links",
- label: "navLinks",
+ id: "browsers",
+ label: "navBrowsers",
icon: (
<>
-
-
+
+
>
),
},
{ id: "rules", label: "navRules", icon: },
{
- id: "browsers",
- label: "navBrowsers",
+ id: "links",
+ label: "navLinks",
icon: (
<>
-
-
+
+
>
),
},
diff --git a/app/src/i18n.ts b/app/src/i18n.ts
index 64332d0..7bb71c1 100644
--- a/app/src/i18n.ts
+++ b/app/src/i18n.ts
@@ -105,10 +105,26 @@ const ES = {
fieldArgsLabel: "Argumentos adicionales",
fieldPrivate: "Argumento de ventana privada (--incognito, -private-window…)",
fieldPrivateLabel: "Argumento de ventana privada",
- fieldPrivateHint: "Sin este argumento, el navegador no podrá abrir en privado desde el selector.",
+ fieldPrivateHint:
+ "Déjalo vacío si es Chrome, Edge, Brave, Vivaldi, Opera o Firefox: se pone solo. Sin él, otro navegador no podrá abrir en privado.",
+ fieldAdvanced: "Opciones avanzadas",
+ helpTitle: "¿Cómo creo un perfil o un navegador personalizado?",
+ helpProfile:
+ "Un perfil de Chrome, Edge o Brave (su carpeta aparece en chrome://version, «Ruta del perfil»):",
+ helpProfileCode: '--profile-directory="Profile 1"',
+ helpSeparate: "Un navegador aparte, con su propio historial y sesiones:",
+ helpSeparateCode: '--user-data-dir="C:/Navegadores/Trabajo"',
+ helpFirefox: "Un perfil de Firefox (créalo antes en about:profiles):",
+ helpFirefoxCode: '-P "Trabajo"',
+ helpWindow: "Abrir siempre en una ventana nueva (en Firefox: -new-window):",
+ helpWindowCode: "--new-window",
fieldIcon: "Ruta de icono personalizado (opcional)",
fieldIconLabel: "Ruta de icono personalizado",
fieldExeDetected: "La detección decide esta ruta; un nuevo escaneo la restauraría.",
+ fieldKey: "Tecla en el selector",
+ fieldKeyOf: "Tecla de {}",
+ fieldKeyHint:
+ "Si la dejas vacía usa la que le toca, en gris: 1–9, luego QWERTY, ASDFGH y ZXCVBN.",
browserUp: "Subir {}",
browserDown: "Bajar {}",
browserDuplicate: "Duplicar {}",
@@ -189,6 +205,9 @@ const ES = {
tryAgain: "Reintentar",
browserGone: "Ese navegador ya no está",
browserNoProgram: "No hay ningún programa en esa ruta",
+ keyTaken: "Esa tecla ya la usa {}",
+ keyTwice: "Dos perfiles no pueden compartir tecla",
+ keyUnusable: "Usa un número del 1 al 9 o una letra",
browserNotYours: "Solo puedes quitar un navegador que hayas añadido tú",
noStartupTask: "Esta versión no trae tarea de inicio",
notOnThisPlatform: "Todavía no está disponible en este sistema",
@@ -387,10 +406,25 @@ const EN: Record = {
fieldPrivate: "Private window argument (--incognito, -private-window…)",
fieldPrivateLabel: "Private window argument",
fieldPrivateHint:
- "Without this argument, the browser will not be able to open privately from the picker.",
+ "Leave it empty for Chrome, Edge, Brave, Vivaldi, Opera or Firefox: it fills itself in. Without it, any other browser cannot open privately.",
+ fieldAdvanced: "Advanced options",
+ helpTitle: "How do I make a profile or a custom browser?",
+ helpProfile:
+ "A Chrome, Edge or Brave profile (its folder shows in chrome://version, “Profile Path”):",
+ helpProfileCode: '--profile-directory="Profile 1"',
+ helpSeparate: "A separate browser, with its own history and sign-ins:",
+ helpSeparateCode: '--user-data-dir="C:/Browsers/Work"',
+ helpFirefox: "A Firefox profile (create it first in about:profiles):",
+ helpFirefoxCode: '-P "Work"',
+ helpWindow: "Always open in a new window (in Firefox: -new-window):",
+ helpWindowCode: "--new-window",
fieldIcon: "Custom icon path (optional)",
fieldIconLabel: "Custom icon path",
fieldExeDetected: "Detection owns this path; a rescan would put it back.",
+ fieldKey: "Key in the picker",
+ fieldKeyOf: "Key for {}",
+ fieldKeyHint:
+ "Left empty it uses the one in grey, next in line: 1–9, then QWERTY, ASDFGH and ZXCVBN.",
browserUp: "Move {} up",
browserDown: "Move {} down",
browserDuplicate: "Duplicate {}",
@@ -471,6 +505,9 @@ const EN: Record = {
tryAgain: "Try again",
browserGone: "That browser is no longer there",
browserNoProgram: "There is no program at that path",
+ keyTaken: "That key is already used by {}",
+ keyTwice: "Two profiles cannot share a key",
+ keyUnusable: "Use a number from 1 to 9 or a letter",
browserNotYours: "Only a browser you added can be removed",
noStartupTask: "This build has no startup task",
notOnThisPlatform: "Not supported on this platform yet",
@@ -566,6 +603,7 @@ const EN: Record = {
export const SPEECH: Record> = { es: ES, en: EN };
const MAC_ES: Partial> = {
+ helpSeparateCode: '--user-data-dir="/Users/Shared/Navegadores/Trabajo"',
fieldExe: "Ruta de la aplicación (.app)",
browserNoProgram: "No hay ninguna aplicación en esa ruta",
healthBuildTree:
@@ -586,6 +624,7 @@ const MAC_ES: Partial> = {
};
const MAC_EN: Partial> = {
+ helpSeparateCode: '--user-data-dir="/Users/Shared/Browsers/Work"',
fieldExe: "Application path (.app)",
browserNoProgram: "There is no application at that path",
healthBuildTree:
diff --git a/app/src/news.json b/app/src/news.json
index 9c70bd3..b5c1315 100644
--- a/app/src/news.json
+++ b/app/src/news.json
@@ -1,4 +1,55 @@
[
+ {
+ "version": "2.7.1",
+ "headline": {
+ "es": "Tu tecla para cada navegador, y un Mosaico renovado",
+ "en": "Your own key for every browser, and a renewed Tiles look"
+ },
+ "es": [
+ {
+ "title": "Una tecla para cada navegador",
+ "said": "en Navegadores, al editar uno, le das la tecla que quieras a él o a cada uno de sus perfiles. Si esa tecla ya la usa otro, te avisa antes de guardar. Si no eliges ninguna, se ve en gris la que le toca."
+ },
+ {
+ "title": "Más teclas",
+ "said": "después del 9 siguen las letras Q W E R T Y, A S D F G H y Z X C V B N, así que hasta 27 navegadores y perfiles se abren con una tecla. Con las flechas, la lista sigue al que eliges."
+ },
+ {
+ "title": "Mosaico renovado",
+ "said": "el estilo Mosaico ahora tiene las mismas tarjetas de LinkBear, en tres columnas, pero sin el oso."
+ },
+ {
+ "title": "Añadir un navegador es más fácil",
+ "said": "la ventana privada de Chrome, Edge, Brave, Vivaldi, Opera y Firefox se configura sola. Lo demás queda en Opciones avanzadas, con ejemplos para crear un perfil o un navegador aparte."
+ },
+ {
+ "title": "Ajustes en orden",
+ "said": "Navegadores va justo después de Aplicación, seguido de Reglas y Enlaces."
+ }
+ ],
+ "en": [
+ {
+ "title": "A key for every browser",
+ "said": "in Browsers, when you edit one, you give it, or each of its profiles, the key you want. If another one already uses that key, you are told before saving. If you choose none, the key it gets is shown in grey."
+ },
+ {
+ "title": "More keys",
+ "said": "after 9 come the letters Q W E R T Y, A S D F G H and Z X C V B N, so up to 27 browsers and profiles open with a single key. With the arrows, the list follows the one you choose."
+ },
+ {
+ "title": "Tiles, renewed",
+ "said": "the Tiles look now has the same cards as LinkBear, in three columns, without the bear."
+ },
+ {
+ "title": "Adding a browser is easier",
+ "said": "the private window of Chrome, Edge, Brave, Vivaldi, Opera and Firefox sets itself up. Everything else sits under Advanced options, with examples for making a profile or a separate browser."
+ },
+ {
+ "title": "Settings in order",
+ "said": "Browsers comes right after Application, followed by Rules and Links."
+ }
+ ]
+ },
{
"version": "2.7.0",
"headline": {
diff --git a/app/src/settings/Browsers.test.tsx b/app/src/settings/Browsers.test.tsx
index 932ec57..90cc781 100644
--- a/app/src/settings/Browsers.test.tsx
+++ b/app/src/settings/Browsers.test.tsx
@@ -18,6 +18,12 @@ const CHROME = {
args: "",
private_flag: "--incognito",
icon_path: null,
+ profile_names: [
+ ["Default", "Personal"],
+ ["Profile 1", "Trabajo"],
+ ] as [string, string][],
+ keys: { "Profile 1": "W" } as Record,
+ shown_keys: { Default: "1", "Profile 1": "W" } as Record,
};
const EDGE = {
@@ -26,6 +32,9 @@ const EDGE = {
name: "Microsoft Edge",
profiles: 1,
hidden: true,
+ profile_names: [["Default", "Personal"]] as [string, string][],
+ keys: {},
+ shown_keys: {},
};
const ODD = {
@@ -36,6 +45,9 @@ const ODD = {
private: false,
private_flag: null,
hidden: false,
+ profile_names: [],
+ keys: {},
+ shown_keys: { "": "2" },
};
const MINE = {
@@ -50,6 +62,9 @@ const MINE = {
args: "--disable-extensions",
private_flag: null,
icon_path: null,
+ profile_names: [] as [string, string][],
+ keys: {} as Record,
+ shown_keys: { "": "3" } as Record,
};
function answers(list: unknown[], overrides: Record Promise> = {}) {
@@ -179,6 +194,7 @@ describe("browsers", () => {
args: '--profile-directory="Profile 1"',
private_flag: null,
icon_path: null,
+ keys: { "": "" },
},
});
});
@@ -225,6 +241,71 @@ describe("browsers", () => {
});
});
+ /// A key another row holds is named before saving, and the form will not save it.
+ it("warns when the key is already another browser's", async () => {
+ render();
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Editar Chrome sin extensiones" }),
+ );
+ await userEvent.type(screen.getByLabelText("Tecla en el selector"), "w");
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Esa tecla ya la usa Google Chrome · Trabajo",
+ );
+ expect(screen.getByRole("button", { name: "Guardar" })).toBeDisabled();
+
+ await userEvent.type(screen.getByLabelText("Tecla en el selector"), "p");
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Guardar" }));
+ expect(invoke).toHaveBeenCalledWith("browsers_update", {
+ id: "custom-1",
+ edit: expect.objectContaining({ keys: { "": "P" } }),
+ });
+ });
+
+ /// Each profile is a row of its own in the picker, so each gets its own key.
+ it("offers a key for every profile and keeps the one already given", async () => {
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "Editar Google Chrome" }));
+ expect(screen.getByLabelText("Tecla de Trabajo")).toHaveValue("W");
+ expect(screen.getByLabelText("Tecla de Personal")).toHaveValue("");
+ expect(screen.getByLabelText("Tecla de Personal")).toHaveAttribute("placeholder", "1");
+ await userEvent.type(screen.getByLabelText("Tecla de Personal"), "w");
+ for (const said of screen.getAllByRole("alert")) {
+ expect(said).toHaveTextContent("Dos perfiles no pueden compartir tecla");
+ }
+ await userEvent.type(screen.getByLabelText("Tecla de Personal"), "1");
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ /// The advanced options carry examples, since few people know these switches exist.
+ it("shows how to make a profile or a separate browser", async () => {
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "Añadir un navegador" }));
+ await userEvent.click(
+ screen.getByRole("button", { name: "¿Cómo creo un perfil o un navegador personalizado?" }),
+ );
+ expect(screen.getByText('--profile-directory="Profile 1"')).toBeVisible();
+ expect(screen.getByText('--user-data-dir="C:/Navegadores/Trabajo"')).toBeVisible();
+ });
+
+ /// The box holds one key, so the new one is whatever was typed, wherever the caret was; and a
+ /// hidden browser is not in the picker, so its key is free.
+ it("takes the key just typed and ignores the ones hidden browsers kept", async () => {
+ answers([CHROME, { ...EDGE, keys: { Default: "E" } }, ODD, MINE]);
+ render();
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Editar Chrome sin extensiones" }),
+ );
+ const box = screen.getByLabelText("Tecla en el selector");
+ await userEvent.type(box, "e");
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ await userEvent.type(box, "k", { initialSelectionStart: 0, initialSelectionEnd: 0 });
+ expect(box).toHaveValue("K");
+ await userEvent.type(box, " ");
+ expect(box).toHaveValue("");
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
/// Duplicating a detected browser is how one binary becomes two entries with
/// different arguments, so it cannot be limited to the custom ones.
it("can duplicate a detected browser, not only a custom one", async () => {
diff --git a/app/src/settings/Browsers.tsx b/app/src/settings/Browsers.tsx
index a70f5cf..c541b4a 100644
--- a/app/src/settings/Browsers.tsx
+++ b/app/src/settings/Browsers.tsx
@@ -17,6 +17,9 @@ type BrowserView = {
args: string;
private_flag: string | null;
icon_path: string | null;
+ profile_names: [string, string][];
+ keys: Record;
+ shown_keys: Record;
};
type Edit = {
@@ -25,9 +28,63 @@ type Edit = {
args: string;
private_flag: string | null;
icon_path: string | null;
+ keys: Record;
};
-const BLANK: Edit = { name: "", exe: "", args: "", private_flag: null, icon_path: null };
+/// Examples for the advanced options: what to say, and what to type.
+const HELP: [Key, Key][] = [
+ ["helpProfile", "helpProfileCode"],
+ ["helpSeparate", "helpSeparateCode"],
+ ["helpFirefox", "helpFirefoxCode"],
+ ["helpWindow", "helpWindowCode"],
+];
+
+const BLANK: Edit = { name: "", exe: "", args: "", private_flag: null, icon_path: null, keys: {} };
+
+/// Each row of a browser in the picker, by the id its key is saved under: one per profile, or the
+/// browser itself under "" when it has none.
+function rowsOf(browser?: BrowserView): [string, string][] {
+ return browser?.profile_names.length ? browser.profile_names : [["", ""]];
+}
+
+/// Who else holds a key, named the way the picker shows them. A hidden browser is not in the
+/// picker and holds nothing.
+function holderOf(key: string, others: BrowserView[]): string | null {
+ for (const b of others.filter((o) => !o.hidden)) {
+ for (const [row, profile] of rowsOf(b)) {
+ if (b.keys[row]?.toUpperCase() !== key) continue;
+ return profile ? `${b.name} · ${profile}` : b.name;
+ }
+ }
+ return null;
+}
+
+/// The one key typed, wherever the caret was: the box already holds one, so the new one is
+/// whatever is not it.
+function typedKey(value: string, was: string): string {
+ const upper = value.toUpperCase();
+ const fresh = was && upper.length > 1 ? upper.replace(was, "") : upper;
+ return fresh.slice(-1).trim();
+}
+
+/// What is wrong with the key typed for one row, said before saving rather than after.
+function keyProblem(
+ key: string,
+ row: string,
+ typed: Record,
+ rows: string[],
+ others: BrowserView[],
+ t: (key: Key, ...values: string[]) => string,
+): string | null {
+ if (key === "") return null;
+ if (!/^[1-9A-Z]$/.test(key)) return t("keyUnusable");
+ if (
+ Object.entries(typed).some(([other, k]) => other !== row && k === key && rows.includes(other))
+ )
+ return t("keyTwice");
+ const holder = holderOf(key, others);
+ return holder ? t("keyTaken", holder) : null;
+}
function describe(browser: BrowserView, t: (key: Key, ...values: string[]) => string): string {
const parts = [
@@ -64,11 +121,18 @@ const FIELD =
function Form({
initial,
+ rows,
+ shown = {},
+ others,
detected,
onSave,
onCancel,
}: {
initial: Edit;
+ rows: [string, string][];
+ /// The key each row has in the picker today, chosen or next in line, shown in an empty box.
+ shown?: Record;
+ others: BrowserView[];
/// Detection owns the path and the private flag: the path is shown and locked, since it says
/// which program this is; the flag is not shown at all where detection knows it, and offered
/// only for a family it could not place — the one case the person knows better.
@@ -82,6 +146,11 @@ function Form({
const [args, setArgs] = useState(initial.args);
const [priv, setPriv] = useState(initial.private_flag ?? "");
const [icon, setIcon] = useState(initial.icon_path ?? "");
+ const [keys, setKeys] = useState>(initial.keys);
+ const advancedAtFirst = Boolean(initial.args || initial.icon_path);
+ const [helping, setHelping] = useState(false);
+ const ids = rows.map(([row]) => row);
+ const problems = ids.map((row) => keyProblem(keys[row] ?? "", row, keys, ids, others, t));
return (