diff --git a/.github/oversized.txt b/.github/oversized.txt index b52b46b..6a931fb 100644 --- a/.github/oversized.txt +++ b/.github/oversized.txt @@ -1,2 +1,2 @@ -2134 app/src-tauri/src/lib.rs -2065 crates/linkunbound-shell/src/main.rs +2068 app/src-tauri/src/lib.rs +2043 crates/linkunbound-shell/src/main.rs diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 2b4be4d..a4371af 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -118,7 +118,7 @@ jobs: echo "touched=true" >> "$GITHUB_OUTPUT" exit 0 fi - watched='^(scripts/third-party\.mjs|THIRD-PARTY-(BUNDLED|LICENSES)\.md|scripts/licences/.*|Cargo\.(lock|toml)|.*/Cargo\.toml|app/package(-lock)?\.json|\.github/workflows/rules\.yml)$' + watched='^(scripts/third-party\.mjs|THIRD-PARTY-(BUNDLED|LICENSES)\.md|scripts/licences/.*|Cargo\.(lock|toml)|.*/Cargo\.toml|app/package(-lock)?\.json|app/src/.*|app/index\.html|app/vite\.config\.ts|\.github/workflows/rules\.yml)$' from=$(git merge-base "$BASE" HEAD) if git diff --name-only --diff-filter=d "$from" HEAD | grep -qE "$watched"; then echo "touched=true" >> "$GITHUB_OUTPUT" diff --git a/PRIVACY.md b/PRIVACY.md index a1dfa41..46036eb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -**Last updated:** October 2, 2026 +**Last updated:** October 4, 2026 --- @@ -93,11 +93,22 @@ browser. Such a link is treated exactly like one you clicked: - **Only web links are accepted.** A file, a script or anything else is dropped before it is shown or opened. -### No Log +### No History, an Error Log -LinkUnbound keeps no log of the links it handles. An address you open is held in memory while +LinkUnbound keeps no history of the links it handles. An address you open is held in memory while it is routed and is written to disk only as part of a rule you asked it to remember. +When something goes wrong — the rules file cannot be read, a browser will not start, a rule cannot +be saved — it writes one line to `errors.log` in its data folder: the time in UTC, what failed and +why. Before a line is written, an address with `://` is cut to its scheme, host and port; one +without, such as `mailto:` or `www.`, is dropped whole; your user folder is written as `~`; and +the account name in any other `Users` or `home` path is replaced by `…`. Only the latest 200 lines +are kept. + +The log is never sent anywhere. It leaves your computer only if you share it yourself: it is +included, already redacted, at the end of the diagnostic report (Settings → **About** → *Save +report*), and you can read or delete `errors.log` whenever you like. + ### Extracted Icons Browser icons are extracted locally from installed browser executables and stored as image files. These are visual assets only. @@ -122,6 +133,7 @@ All data is stored locally under your user profile. | Settings | `%LOCALAPPDATA%\LinkUnbound\preferences.json` | | Last update check | `%LOCALAPPDATA%\LinkUnbound\update.json` (and `updating.json` while one installs) | | Global shortcut held | `%LOCALAPPDATA%\LinkUnbound\shortcut` | +| Error log | `%LOCALAPPDATA%\LinkUnbound\errors.log` (latest 200 lines, redacted) | | Lock files | `rules.lock`, `browsers.lock`, `preferences.lock`, empty | | Kept aside | `rules.1x.json`, `browsers.1x.json` (the 1.x files, kept once when upgrading), `preferences.unread.json` (a preferences file that could not be read) | | Icons | `%LOCALAPPDATA%\LinkUnbound\icons\` | @@ -135,6 +147,7 @@ All data is stored locally under your user profile. | Settings | `~/Library/Application Support/LinkUnbound/preferences.json` | | Last update check | `~/Library/Application Support/LinkUnbound/update.json` (and `updating.json` while one installs) | | Global shortcut held | `~/Library/Application Support/LinkUnbound/shortcut` | +| Error log | `~/Library/Application Support/LinkUnbound/errors.log` (latest 200 lines, redacted) | | Resident's socket | `~/Library/Application Support/LinkUnbound/shell.sock` | | Lock files | `rules.lock`, `browsers.lock`, `preferences.lock`, empty | | Kept aside | `rules.1x.json`, `browsers.1x.json` (the 1.x files, kept once when upgrading), `preferences.unread.json` (a preferences file that could not be read) | @@ -260,6 +273,7 @@ It is a single Markdown file named `linkunbound-diagnostico.md`. On Windows it i | `Sistema` | Operating system, whether LinkUnbound is registered and default, the health check, how many associations it holds, whether it starts with the system, whether Edge is installed, and the names of the browsers it detected | | `Preferencias` | Theme, language, the global shortcut, and whether a rule announces itself when it decides | | `Reglas` | Every rule you have: what it matches, the application it is tied to when it has one, the browser it opens in, and whether it opens privately | +| `Errores recientes` | The lines of `errors.log`: when, what failed and why, with addresses cut to their host and your user folder written as `~` | ### What the report does not contain diff --git a/README.md b/README.md index 99728a3..23c939c 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The picker names the address and the application the link came from, and each br - **Shows a floating picker** near your cursor, in two looks: a classic list or a mosaic of tiles. Pick with the mouse or the keys `1`–`9`; hold **Shift** for a private window; `Ctrl+C` copies the address; `Esc` puts it away - **Remembers the choice at the reach you pick** — this URL, this subdomain, the whole site, or everything a given app sends - **Rules from Settings too** — a rule for a site you have not visited yet, or for an app, without waiting for the picker -- **Unwraps Microsoft SafeLinks** and the Edge-only scheme Teams and Outlook wrap links in, so rules see the real destination +- **Unwraps Microsoft SafeLinks**, so rules see the real destination - **Takes links from your other apps** — an application can send a link to `linkunbound://open?url=…` and it goes through your rules, even when LinkUnbound is not the default browser - **Tells you when a rule decided** — a small notice with an Undo, six seconds, no focus taken - **Opens local documents** when you choose it for them — `.html`, `.pdf` and the rest on Windows, `.html` and `.xhtml` on macOS @@ -109,7 +109,7 @@ The picker names the address and the application the link came from, and each br **Everything stays local.** LinkUnbound is built on a single, non-negotiable principle: your data never leaves your computer. - **Local-only storage** — browser list, rules and preferences stay on your machine -- **No link log** — links are routed in memory and never logged +- **No link history** — links are routed in memory and never kept; errors go to a local, redacted log you share only if you choose to - **No tracking** — no telemetry, no analytics, no hidden collection - **No accounts** — no sign-up, no login, no profiles - **Web links only from other apps** — `linkunbound://` accepts an `http` or `https` link and nothing else, and tells the sender nothing back diff --git a/SECURITY.md b/SECURITY.md index 78c36c1..63bda64 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,7 +14,7 @@ This is a personal open source project, not a company product. Security here is - **100% Local** — Your browser list, domain rules, and configuration never leave your machine. No cloud, no sync, no servers. - **No Tracking** — No telemetry, no analytics, no usage data collection of any kind. -- **No Data Collection** — LinkUnbound stores only what it needs to work: browser paths, rules and preferences. Nothing else, and no log of the links it handles. +- **No Data Collection** — LinkUnbound stores only what it needs to work: browser paths, rules and preferences. Nothing else, and no history of the links it handles; its error log keeps no address beyond the host and stays on the machine. ### Security Practices @@ -23,7 +23,7 @@ This is a personal open source project, not a company product. Security here is - **Open Source** — Every line of code is public under GPLv3. You can inspect, audit, and verify everything. - **Dependency Updates** — Dependencies are regularly updated to patch known vulnerabilities. - **Code Reviews** — All contributions go through review before merging. -- **No Link Log** — Links are routed in memory and never logged. The only addresses written to disk are the exact-address rules you ask it to remember, and the diagnostic report cuts those down to their host. +- **No Link History** — Links are routed in memory and never kept. The only addresses written to disk are the exact-address rules you ask it to remember, and the diagnostic report cuts those down to their host. The local error log cuts any address down to its host, or drops it when it has none, and never leaves the machine on its own. ### Links From Other Applications diff --git a/THIRD-PARTY-BUNDLED.md b/THIRD-PARTY-BUNDLED.md index 13d0f83..03b8109 100644 --- a/THIRD-PARTY-BUNDLED.md +++ b/THIRD-PARTY-BUNDLED.md @@ -7,13 +7,12 @@ licence. Nothing of it was copied into LinkUnbound's own source. The licence tex of every crate is in [THIRD-PARTY-LICENSES.md](https://github.com/rgdevment/LinkUnbound/blob/main/THIRD-PARTY-LICENSES.md), also under About → Licence texts. -## In the window (12 packages) +## In the window (11 packages) | Package | Version | Licence | | --- | --- | --- | | `@tauri-apps/api` | 2.11.1 | Apache-2.0 OR MIT | | `@tauri-apps/plugin-opener` | 2.5.5 | MIT OR Apache-2.0 | -| `argparse` | 3.0.2 | PSF-2.0 | | `entities` | 8.1.0 | BSD-2-Clause | | `linkify-it` | 6.1.0 | MIT | | `markdown-it` | 15.0.2 | MIT | @@ -503,58 +502,6 @@ PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git Creator: Person: Daniel Thompson-Yvetot ``` -### `argparse` — PSF-2.0 - -```text -PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 - -1. This LICENSE AGREEMENT is between the Python Software Foundation -("PSF"), and the Individual or Organization ("Licensee") accessing and -otherwise using this software ("Python") in source or binary form and -its associated documentation. - -2. Subject to the terms and conditions of this License Agreement, PSF hereby -grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, -analyze, test, perform and/or display publicly, prepare derivative works, -distribute, and otherwise use Python alone or in any derivative version, -provided, however, that PSF's License Agreement and PSF's notice of copyright, -i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019 Python Software Foundation; -All Rights Reserved" are retained in Python alone or in any derivative version -prepared by Licensee. - -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python. - -4. PSF is making Python available to Licensee on an "AS IS" -basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -7. Nothing in this License Agreement shall be deemed to create any -relationship of agency, partnership, or joint venture between PSF and -Licensee. This License Agreement does not grant permission to use PSF -trademarks or trade name in a trademark sense to endorse or promote -products or services of Licensee, or any third party. - -8. By copying, installing or otherwise using Python, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. -``` - ### `entities` — BSD-2-Clause ```text diff --git a/app/src-tauri/src/about.rs b/app/src-tauri/src/about.rs index 977451a..9671773 100644 --- a/app/src-tauri/src/about.rs +++ b/app/src-tauri/src/about.rs @@ -1,6 +1,8 @@ use serde::Serialize; -use crate::{HERE, store, update}; +use linkunbound_core::Language; + +use crate::{HERE, catalogue, same_name, shown_origin, store, system, update}; const NOTICES: &str = include_str!("../../../THIRD-PARTY-BUNDLED.md"); const LICENCES: &str = include_str!("../../../THIRD-PARTY-LICENSES.md"); @@ -34,3 +36,69 @@ pub fn about() -> Build { pub fn notices(licences: bool) -> &'static str { if licences { LICENCES } else { NOTICES } } + +#[tauri::command] +pub fn maintenance_report() -> Result { + let state = system::state(); + let facts = vec![ + ("versión".to_owned(), env!("CARGO_PKG_VERSION").to_owned()), + ("sistema".to_owned(), std::env::consts::OS.to_owned()), + ("registrado".to_owned(), state.registered.to_string()), + ("predeterminado".to_owned(), state.is_default.to_string()), + ("diagnóstico".to_owned(), format!("{:?}", state.health)), + ( + "asociaciones".to_owned(), + format!( + "{} de {}", + state.associations.iter().filter(|a| a.held).count(), + state.associations.len() + ), + ), + ( + "arranca con el sistema".to_owned(), + state.starts_with_system.to_string(), + ), + ( + "Edge instalado".to_owned(), + state.edge_installed.to_string(), + ), + ( + "navegadores".to_owned(), + catalogue() + .iter() + .map(|b| b.name.clone()) + .collect::>() + .join(", "), + ), + ]; + let store = store(); + let prefs = store.prefs(); + let words = Language::chosen(prefs.locale).strings(); + let mut rules = store.rules().unwrap_or_default(); + for rule in &mut rules.rules { + // The name a person reads, with the key the rule matches by when they differ. + rule.source_app = rule + .source_app + .as_deref() + .map(|saved| match shown_origin(saved) { + name if same_name(&name, saved) => name, + name => format!("{name} ({saved})"), + }); + } + let errors = linkunbound_core::journal(store.dir()); + let body = linkunbound_core::diagnostics(HERE, &facts, &rules, &prefs, &words, &errors); + + let named = format!("{}.md", words.report_file); + let target = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map_or_else(std::env::temp_dir, std::path::PathBuf::from) + .join("Desktop") + .join(&named); + let target = if target.parent().is_some_and(std::path::Path::is_dir) { + target + } else { + std::env::temp_dir().join(&named) + }; + std::fs::write(&target, body).map_err(|e| e.to_string())?; + Ok(target.to_string_lossy().into_owned()) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index ae0c6e8..30795c1 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -713,72 +713,6 @@ fn system_open_elsewhere(url: String) -> Result<(), String> { .map_err(|e| e.to_string()) } -#[tauri::command] -fn maintenance_report() -> Result { - let state = system::state(); - let facts = vec![ - ("versión".to_owned(), env!("CARGO_PKG_VERSION").to_owned()), - ("sistema".to_owned(), std::env::consts::OS.to_owned()), - ("registrado".to_owned(), state.registered.to_string()), - ("predeterminado".to_owned(), state.is_default.to_string()), - ("diagnóstico".to_owned(), format!("{:?}", state.health)), - ( - "asociaciones".to_owned(), - format!( - "{} de {}", - state.associations.iter().filter(|a| a.held).count(), - state.associations.len() - ), - ), - ( - "arranca con el sistema".to_owned(), - state.starts_with_system.to_string(), - ), - ( - "Edge instalado".to_owned(), - state.edge_installed.to_string(), - ), - ( - "navegadores".to_owned(), - catalogue() - .iter() - .map(|b| b.name.clone()) - .collect::>() - .join(", "), - ), - ]; - let store = store(); - let prefs = store.prefs(); - let words = Language::chosen(prefs.locale).strings(); - let mut rules = store.rules().unwrap_or_default(); - for rule in &mut rules.rules { - // The name a person reads, with the key the rule matches by when they differ. - rule.source_app = rule - .source_app - .as_deref() - .map(|saved| match shown_origin(saved) { - name if same_name(&name, saved) => name, - name => format!("{name} ({saved})"), - }); - } - let body = - linkunbound_core::diagnostics(env!("CARGO_PKG_VERSION"), &facts, &rules, &prefs, &words); - - let named = format!("{}.md", words.report_file); - let target = std::env::var_os("USERPROFILE") - .or_else(|| std::env::var_os("HOME")) - .map_or_else(std::env::temp_dir, std::path::PathBuf::from) - .join("Desktop") - .join(&named); - let target = if target.parent().is_some_and(std::path::Path::is_dir) { - target - } else { - std::env::temp_dir().join(&named) - }; - std::fs::write(&target, body).map_err(|e| e.to_string())?; - Ok(target.to_string_lossy().into_owned()) -} - #[tauri::command] fn system_state() -> system::SystemState { system::state() @@ -1344,7 +1278,7 @@ pub fn run() { browsers_reorder, maintenance_rescan, maintenance_reset, - maintenance_report, + about::maintenance_report, prefs_get, prefs_set, system_state, diff --git a/app/src-tauri/src/system.rs b/app/src-tauri/src/system.rs index 6d3ba1a..72e8bb0 100644 --- a/app/src-tauri/src/system.rs +++ b/app/src-tauri/src/system.rs @@ -108,6 +108,20 @@ mod platform { } } + fn left_behind( + command: Option<&str>, + handler: &std::path::Path, + scheme_agrees: bool, + exists: impl Fn(&std::path::Path) -> bool, + ) -> bool { + match what_is_registered(command, handler) { + Registered::Absent => false, + Registered::Correct => !scheme_agrees, + Registered::WrongBinary(_) => true, + Registered::Elsewhere(path) => !exists(std::path::Path::new(&path)), + } + } + /// The resident, never this process: settings cannot open a link, and /// registering it is the one mistake this whole check exists to catch. fn handler() -> Option { @@ -131,7 +145,10 @@ mod platform { return; } let registration = Registration::default(); - if registration.is_registered() && registration.register(&handler.to_string_lossy()).is_ok() + let command = registration.registered_command(); + let scheme_agrees = registration.own_scheme_command() == command; + if left_behind(command.as_deref(), &handler, scheme_agrees, |p| p.exists()) + && registration.register(&handler.to_string_lossy()).is_ok() { notify_associations_changed(); } @@ -191,12 +208,7 @@ mod platform { /// leaves them to find us. The name is the one under `RegisteredApplications`. pub fn open_default_apps() -> Result<(), String> { std::process::Command::new("cmd") - .args([ - "/C", - "start", - "", - "ms-settings:defaultapps?registeredAppUser=LinkUnbound", - ]) + .args(["/C", "start", "", &linkunbound_win::default_apps_page()]) .spawn() .map(|_| ()) .map_err(|e| e.to_string()) @@ -256,9 +268,41 @@ mod platform { #[cfg(test)] mod tests { use super::super::Health; - use super::verdict; + use super::{left_behind, verdict}; use std::path::Path; + #[test] + fn only_a_registration_nobody_answers_for_is_taken_over() { + let here = Path::new(r"C:\Program Files\LinkUnbound\linkunbound-shell.exe"); + let other = r#""D:\Portable\LinkUnbound\linkunbound-shell.exe" "%1""#; + let ours = r#""C:\Program Files\LinkUnbound\linkunbound-shell.exe" "%1""#; + let settings = r#""C:\Program Files\LinkUnbound\linkunbound-settings.exe" "%1""#; + let alive = |_: &Path| true; + let gone = |_: &Path| false; + + assert!( + !left_behind(None, here, true, gone), + "taken away stays away" + ); + assert!( + !left_behind(Some(other), here, true, alive), + "another copy keeps its own" + ); + assert!( + left_behind(Some(other), here, true, gone), + "a moved install is followed" + ); + assert!(!left_behind(Some(ours), here, true, gone), "ours and whole"); + assert!( + left_behind(Some(ours), here, false, gone), + "ours but the scheme strayed" + ); + assert!( + left_behind(Some(settings), here, true, alive), + "the wrong binary of ours" + ); + } + /// The screen's reaction to each verdict is tested with literals; this /// is the side that decides which verdict it gets. #[test] diff --git a/crates/linkunbound-core/src/i18n.rs b/crates/linkunbound-core/src/i18n.rs index 9de4d12..7763b4b 100644 --- a/crates/linkunbound-core/src/i18n.rs +++ b/crates/linkunbound-core/src/i18n.rs @@ -57,6 +57,9 @@ catalogue! { notice_opened: "Abierto en {}" | "Opened in {}", notice_by_rule: "Una regla decidió por {}" | "A rule decided for {}", notice_undo: "Deshacer" | "Undo", + notice_rule_failed: "La regla no pudo abrir {}" | "The rule could not open {}", + notice_rules_unreadable: "No se pudieron leer tus reglas" | "Your rules could not be read", + notice_pick_instead: "Elige un navegador esta vez" | "Pick a browser this time", fail_not_remembered: "No se pudo guardar la regla" | "The rule could not be saved", fail_unknown_browser: "Ese navegador ya no está configurado" | "That browser is no longer configured", fail_profile_gone: "El perfil {} ya no existe" | "The profile {} is no longer there", @@ -77,6 +80,7 @@ catalogue! { report_from: " desde {}" | " from {}", report_private: " en privado" | " privately", report_redacted: "[redactado]" | "[redacted]", + report_errors: "Errores recientes" | "Recent errors", report_file: "linkunbound-diagnostico" | "linkunbound-diagnostics", } diff --git a/crates/linkunbound-core/src/journal.rs b/crates/linkunbound-core/src/journal.rs new file mode 100644 index 0000000..7da5c84 --- /dev/null +++ b/crates/linkunbound-core/src/journal.rs @@ -0,0 +1,272 @@ +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::report::redact; + +pub const JOURNAL: &str = "errors.log"; +const KEPT: usize = 200; +const GONE: &str = "[…]"; +const BOUNDS: &str = "\\/\"':(),;"; + +pub fn note(dir: &Path, area: &'static str, what: &str) { + let homes: Vec = ["USERPROFILE", "HOME"] + .iter() + .filter_map(|key| std::env::var(key).ok()) + .collect(); + let line = format!( + "{} [{area}] {}", + stamp(SystemTime::now()), + scrubbed(what, &homes) + ); + let mut lines = journal(dir); + lines.push(line); + let from = lines.len().saturating_sub(KEPT); + let _ = std::fs::create_dir_all(dir); + let staged = dir.join(format!("{JOURNAL}.{}.tmp", std::process::id())); + if std::fs::write(&staged, lines[from..].join("\n") + "\n").is_ok() + && std::fs::rename(&staged, dir.join(JOURNAL)).is_err() + { + let _ = std::fs::remove_file(&staged); + } +} + +#[must_use] +pub fn journal(dir: &Path) -> Vec { + std::fs::read(dir.join(JOURNAL)) + .map(|bytes| { + String::from_utf8_lossy(&bytes) + .lines() + .filter(|line| !line.trim().is_empty()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() +} + +#[must_use] +pub fn scrubbed(text: &str, homes: &[String]) -> String { + let mut flat = text.replace(['\r', '\n'], " "); + let mut homes: Vec<&String> = homes.iter().filter(|home| home.len() > 3).collect(); + homes.sort_by_key(|home| std::cmp::Reverse(home.len())); + for home in homes { + flat = replaced_ignoring_case(&flat, home, "~"); + flat = replaced_ignoring_case(&flat, &home.replace('\\', "/"), "~"); + } + for marker in [r"\users\", "/users/", "/home/"] { + flat = owner_hidden(&flat, marker); + } + flat.split(' ') + .map(|word| { + let bare = word.trim_matches(|c: char| "\"'()<>[],;`".contains(c)); + if bare.contains("://") { + word.replace(bare, &redact(bare, GONE)) + } else if looks_like_an_address(bare) { + word.replace(bare, GONE) + } else { + word.to_owned() + } + }) + .collect::>() + .join(" ") +} + +fn looks_like_an_address(word: &str) -> bool { + if word.to_ascii_lowercase().starts_with("www.") { + return true; + } + let Some((scheme, rest)) = word.split_once(':') else { + return false; + }; + scheme.len() > 1 + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || "+.-".contains(c)) + && rest.chars().next().is_some_and(|c| !"\\/: ".contains(c)) +} + +fn owner_hidden(text: &str, marker: &str) -> String { + let lower = text.to_ascii_lowercase(); + let mut out = String::with_capacity(text.len()); + let mut at = 0; + while let Some(found) = lower[at..].find(marker) { + let after = at + found + marker.len(); + out.push_str(&text[at..after]); + let end = text[after..] + .find(|c: char| BOUNDS.contains(c)) + .map_or(text.len(), |end| after + end); + let owner = after + text[after..end].trim_end().len(); + if owner > after { + out.push('…'); + } + at = owner; + } + out.push_str(&text[at..]); + out +} + +fn replaced_ignoring_case(text: &str, what: &str, with: &str) -> String { + let lower = text.to_ascii_lowercase(); + let wanted = what.to_ascii_lowercase(); + let mut out = String::with_capacity(text.len()); + let mut at = 0; + while let Some(found) = lower[at..].find(&wanted) { + let end = at + found + wanted.len(); + let whole = text[end..] + .chars() + .next() + .is_none_or(|c| c.is_whitespace() || BOUNDS.contains(c)); + out.push_str(&text[at..at + found]); + out.push_str(if whole { with } else { &text[at + found..end] }); + at = end; + } + out.push_str(&text[at..]); + out +} + +fn stamp(when: SystemTime) -> String { + let seconds = when + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + let days = i64::try_from(seconds / 86_400).unwrap_or(0); + let rest = seconds % 86_400; + let (year, month, day) = civil(days); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + rest / 3600, + rest % 3600 / 60, + rest % 60 + ) +} + +fn civil(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1); + let month = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1); + let year = yoe + era * 400 + i64::from(month <= 2); + (year, month, day) +} + +#[cfg(test)] +mod tests { + use super::{KEPT, journal, note, scrubbed, stamp}; + use std::time::{Duration, UNIX_EPOCH}; + + fn ana() -> Vec { + vec![r"C:\Users\Ana".to_owned()] + } + + #[test] + fn a_link_keeps_only_its_host_and_the_home_folder_loses_its_owner() { + let said = scrubbed( + r#"launch of "C:\Users\Ana\AppData\Local\Chrome\chrome.exe" for https://intranet.corp/sueldos?id=4 refused"#, + &ana(), + ); + assert!(!said.contains("Ana"), "{said}"); + assert!(!said.contains("sueldos"), "{said}"); + assert!( + said.contains(r"~\AppData\Local\Chrome\chrome.exe"), + "{said}" + ); + assert!(said.contains("https://intranet.corp/…"), "{said}"); + } + + #[test] + fn the_home_folder_is_found_whatever_its_case_or_slashes() { + assert_eq!( + scrubbed(r"c:\users\ANA\x.json unreadable", &ana()), + r"~\x.json unreadable" + ); + assert_eq!( + scrubbed("C:/Users/Ana/x.json unreadable", &ana()), + "~/x.json unreadable" + ); + } + + #[test] + fn an_owner_the_home_does_not_name_is_hidden_all_the_same() { + for (path, kept) in [ + (r"D:\Users\Bea\rules.json", r"D:\Users\…\rules.json"), + (r"C:\Users\BEA~1\rules.json", r"C:\Users\…\rules.json"), + ("/Users/bea/Library/x", "/Users/…/Library/x"), + ("/home/bea/.config/x", "/home/…/.config/x"), + (r"D:\Users\Ana Maria\rules.json", r"D:\Users\…\rules.json"), + (r#""C:\Users\Ana Maria" missing"#, r#""C:\Users\…" missing"#), + ( + r"cannot create C:\Users\Bea (os error 5)", + r"cannot create C:\Users\… (os error 5)", + ), + (r"C:\Users\Bea: access denied", r"C:\Users\…: access denied"), + ] { + assert_eq!(scrubbed(path, &[]), kept); + } + } + + #[test] + fn an_address_without_slashes_is_cut_too() { + let said = scrubbed( + "open mailto:ana@corp.com and www.x.com/secret?a=1 and file:C:/x failed", + &[], + ); + assert!(!said.contains("ana@corp.com"), "{said}"); + assert!(!said.contains("secret"), "{said}"); + assert!(!said.contains("file:C"), "{said}"); + assert_eq!( + scrubbed(r"firefox: the browser would not start (C:\x)", &[]), + r"firefox: the browser would not start (C:\x)" + ); + } + + #[test] + fn another_account_that_starts_like_the_home_is_not_taken_for_it() { + assert_eq!( + scrubbed(r"C:\Users\Anabel\rules.json unreadable", &ana()), + r"C:\Users\…\rules.json unreadable" + ); + assert_eq!(scrubbed(r"C:\Users\Ana unreadable", &ana()), "~ unreadable"); + } + + #[test] + fn the_tilde_stands_for_the_home_folder_and_nothing_else() { + let said = scrubbed(r"C:\Users\Ana\AppData\Local\LinkUnbound\rules.json", &ana()); + assert_eq!(said, r"~\AppData\Local\LinkUnbound\rules.json"); + } + + #[test] + fn a_line_break_cannot_forge_another_entry() { + let said = scrubbed("first\n2026-01-01T00:00:00Z [rules] forged", &[]); + assert!(!said.contains('\n')); + } + + #[test] + fn dates_are_written_in_utc_the_way_people_read_them() { + assert_eq!(stamp(UNIX_EPOCH), "1970-01-01T00:00:00Z"); + assert_eq!( + stamp(UNIX_EPOCH + Duration::from_secs(1_791_158_400 + 3_661)), + "2026-10-05T01:01:01Z" + ); + assert_eq!( + stamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), + "2000-02-29T00:00:00Z" + ); + } + + #[test] + fn the_journal_keeps_only_its_latest_entries() { + let dir = std::env::temp_dir().join(format!("linkunbound-journal-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for at in 0..KEPT + 5 { + note(&dir, "test", &format!("entry {at}")); + } + let kept = journal(&dir); + assert_eq!(kept.len(), KEPT); + assert!(kept[0].ends_with("entry 5")); + assert!(kept[KEPT - 1].ends_with(&format!("entry {}", KEPT + 4))); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/linkunbound-core/src/lib.rs b/crates/linkunbound-core/src/lib.rs index 5df4f7f..ce2a643 100644 --- a/crates/linkunbound-core/src/lib.rs +++ b/crates/linkunbound-core/src/lib.rs @@ -6,6 +6,7 @@ mod detect; mod hostile; mod i18n; mod icons; +mod journal; mod launch; mod prefs; mod private; @@ -29,6 +30,7 @@ pub use detect::{chromium_home_on_mac, chromium_home_on_windows, id_for, profile pub use hostile::{as_file_name, disarm, is_remote}; pub use i18n::{Language, Strings}; pub use icons::{cached_icon, prune as prune_icons}; +pub use journal::{JOURNAL, journal, note, scrubbed}; pub use launch::{LaunchError, open as launch, spawn_and_forget}; pub use prefs::{Locale, PickerStyle, Preferences, Theme}; pub use private::private_flag_for; diff --git a/crates/linkunbound-core/src/report.rs b/crates/linkunbound-core/src/report.rs index 1a86323..eadb7c9 100644 --- a/crates/linkunbound-core/src/report.rs +++ b/crates/linkunbound-core/src/report.rs @@ -43,6 +43,7 @@ pub fn diagnostics( rules: &RuleSet, prefs: &Preferences, words: &Strings, + errors: &[String], ) -> String { let mut out = format!("LinkUnbound {version}\n\n## {}\n", words.report_system); for (key, value) in system { @@ -83,6 +84,20 @@ pub fn diagnostics( rule.target.browser_id, )); } + + out.push_str(&format!( + " +## {} ({}) +", + words.report_errors, + errors.len() + )); + for line in errors { + out.push_str(&format!( + "- {line} +" + )); + } out } @@ -149,6 +164,7 @@ mod tests { &rules, &Preferences::default(), &Language::Spanish.strings(), + &[], ); assert!(out.contains("mail.corp")); assert!(!out.contains("token=abc")); @@ -178,6 +194,7 @@ mod tests { &rules, &Preferences::default(), &Language::English.strings(), + &[], ); assert!(out.contains("## System"), "{out}"); assert!(out.contains("## Preferences")); @@ -197,9 +214,33 @@ mod tests { &RuleSet::default(), &Preferences::default(), &Language::Spanish.strings(), + &[], ); assert!(out.contains("LinkUnbound 2.0.0")); assert!(out.contains("- navegador predeterminado: no")); assert!(out.contains("## Reglas (0)")); + assert!(out.contains("## Errores recientes (0)")); + } + + #[test] + fn the_report_ends_with_the_errors_the_journal_kept() { + let errors = + vec!["2026-10-04T23:00:00Z [launch] firefox: the browser would not start".to_owned()]; + let out = diagnostics( + "2.0.0", + &[], + &RuleSet::default(), + &Preferences::default(), + &Language::English.strings(), + &errors, + ); + assert!( + out.ends_with( + "## Recent errors (1) +- 2026-10-04T23:00:00Z [launch] firefox: the browser would not start +" + ), + "{out}" + ); } } diff --git a/crates/linkunbound-shell/src/detected.rs b/crates/linkunbound-shell/src/detected.rs new file mode 100644 index 0000000..03c435d --- /dev/null +++ b/crates/linkunbound-shell/src/detected.rs @@ -0,0 +1,62 @@ +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use linkunbound_core::Browser; + +const FRESH: Duration = Duration::from_secs(30); + +static LAST: Mutex = Mutex::new(Remembered { at: None }); + +struct Remembered { + at: Option<(Instant, Vec)>, +} + +impl Remembered { + fn take(&mut self, now: Instant, look: impl FnOnce() -> Vec) -> Vec { + if let Some((when, found)) = &self.at + && now.saturating_duration_since(*when) < FRESH + { + return found.clone(); + } + let found = look(); + self.at = Some((now, found.clone())); + found + } +} + +pub fn catalogue() -> Vec { + let installed = LAST + .lock() + .map(|mut last| last.take(Instant::now(), crate::host::browsers)) + .unwrap_or_else(|_| crate::host::browsers()); + let saved = crate::store() + .browsers() + .map(|c| c.browsers) + .unwrap_or_default(); + linkunbound_core::merge(installed, &saved) +} + +#[cfg(test)] +mod tests { + use super::{FRESH, Remembered}; + use std::cell::Cell; + use std::time::{Duration, Instant}; + + #[test] + fn the_installed_browsers_are_looked_for_again_only_once_they_are_stale() { + let looked = Cell::new(0); + let look = || { + looked.set(looked.get() + 1); + Vec::new() + }; + let mut last = Remembered { at: None }; + let start = Instant::now(); + + last.take(start, look); + last.take(start + Duration::from_secs(1), look); + assert_eq!(looked.get(), 1); + + last.take(start + FRESH, look); + assert_eq!(looked.get(), 2); + } +} diff --git a/crates/linkunbound-shell/src/main.rs b/crates/linkunbound-shell/src/main.rs index 6bd0db4..1c787d1 100644 --- a/crates/linkunbound-shell/src/main.rs +++ b/crates/linkunbound-shell/src/main.rs @@ -15,6 +15,10 @@ use linkunbound_shell::{ }; use slint::{ComponentHandle, Model}; +mod detected; +mod notice; +use detected::catalogue; + fn store() -> Store { Store::at(data_dir()) } @@ -263,11 +267,6 @@ mod host { } } -fn catalogue() -> Vec { - let saved = store().browsers().map(|c| c.browsers).unwrap_or_default(); - linkunbound_core::merge(host::browsers(), &saved) -} - fn with_icons( mut listed: Vec, browsers: &[linkunbound_core::Browser], @@ -311,10 +310,24 @@ struct Fired { /// A rule that cannot be honoured falls through to the picker, never elsewhere. fn answered_by_rule(url: &str, source: Option<&Origin>) -> Option { - let rules = store().rules().ok()?; + let rules = store() + .rules() + .map_err(|why| { + notice::troubled("rules", &why.to_string(), |words| { + ( + words.notice_rules_unreadable.into(), + words.notice_pick_instead.into(), + ) + }); + }) + .ok()?; let host = host_of(url)?; let rule = rules.resolve_from(url, &host, source)?; let browsers = catalogue(); + let browser = browsers + .iter() + .find(|b| b.id == rule.target.browser_id) + .map_or_else(|| rule.target.browser_id.clone(), |b| b.name.clone()); host::let_whoever_opens_next_come_forward(); linkunbound_core::launch( &browsers, @@ -323,69 +336,24 @@ fn answered_by_rule(url: &str, source: Option<&Origin>) -> Option { rule.private, url, ) + .map_err(|why| { + notice::troubled("launch", &format!("{browser}: {why}"), |words| { + ( + Strings::fill(words.notice_rule_failed, &browser), + words.on_failure(&why), + ) + }); + }) .ok()?; Some(Fired { rule_id: rule.id.clone(), - browser: browsers - .iter() - .find(|b| b.id == rule.target.browser_id) - .map_or_else(|| rule.target.browser_id.clone(), |b| b.name.clone()), + browser, host, }) } const TICKS_BETWEEN_LOOKS: u32 = 8; -const NOTICE_SECONDS: i32 = 6; - -/// Never focused: it must not take the keyboard from whatever is being done. -fn flash(notice: &Notice, words: &Strings, fired: &Fired) { - notice.set_headline(Strings::fill(words.notice_opened, &fired.browser).into()); - notice.set_reason(Strings::fill(words.notice_by_rule, &fired.host).into()); - notice.set_undo_label(words.notice_undo.into()); - notice.set_left(NOTICE_SECONDS); - let _ = notice.show(); - if let Some(handle) = native_handle(notice.window()) { - host::keep_off_the_taskbar(handle, linkunbound_shell::CLASSIC_CORNER); - host::never_activates(handle); - } - in_the_corner(notice); - #[cfg(target_os = "macos")] - if !ui().is_some_and(|ui| ui.picker.window().is_visible()) { - host::let_whoever_opens_next_come_forward(); - } -} - -const NOTICE_MARGIN: f32 = 16.0; - -/// The corner of the screen the click happened on, where a notice is looked for; left to the -/// window manager it opened wherever the last one did, usually another screen, and the six -/// seconds passed unseen. -fn in_the_corner(notice: &Notice) { - let Some((cx, cy)) = host::cursor() else { - return; - }; - let Some((x, y, width, height)) = host::work_area_at(cx, cy) else { - return; - }; - let scale = if cfg!(target_os = "macos") { - 1.0 - } else { - f64::from(notice.window().scale_factor()) - }; - let size = |logical: f32| physical(logical, scale); - let at_x = x + width - size(notice.get_wanted_width()) - size(NOTICE_MARGIN); - let at_y = y + height - size(notice.get_wanted_height()) - size(NOTICE_MARGIN); - #[cfg(target_os = "macos")] - notice - .window() - .set_position(slint::LogicalPosition::new(at_x as f32, at_y as f32)); - #[cfg(not(target_os = "macos"))] - notice - .window() - .set_position(slint::PhysicalPosition::new(at_x, at_y)); -} - /// Settings runs in another process: the file is the only channel between them. fn prefs_touched_at() -> Option { std::fs::metadata(store().dir().join("preferences.json")) @@ -394,7 +362,9 @@ fn prefs_touched_at() -> Option { } fn forget(rule_id: &str) { - let _ = store().edit_rules(|rules| rules.remove(rule_id)); + if let Err(why) = store().edit_rules(|rules| rules.remove(rule_id)) { + notice::noted("rules", &why.to_string()); + } } /// Reports rather than swallows: the window says the choice was remembered, and @@ -416,6 +386,7 @@ fn remember( rules.upsert_from(rule.clone(), source.as_ref()); true }) + .map_err(|why| notice::noted("rules", &why.to_string())) .is_ok() } @@ -1031,7 +1002,13 @@ fn announce(ui: &Rc, fired: &Fired) { return; } ui.firing.replace(Some(fired.rule_id.clone())); - flash(&ui.notice, &ui.words.get(), fired); + let words = ui.words.get(); + notice::flash( + &ui.notice, + Strings::fill(words.notice_opened, &fired.browser), + Strings::fill(words.notice_by_rule, &fired.host), + Some(words.notice_undo), + ); ui.count_down(); // Shown over an open picker, the notice must not take the digits being typed. if ui.picker.window().is_visible() @@ -1348,6 +1325,7 @@ fn main() -> Result<(), slint::PlatformError> { } } Err(why) => { + notice::noted("launch", &format!("{}: {why}", chosen.browser_id)); if let Some(ui) = ui() { window.set_alarming(true); window.set_problem(ui.words.get().on_failure(&why).into()); diff --git a/crates/linkunbound-shell/src/notice.rs b/crates/linkunbound-shell/src/notice.rs new file mode 100644 index 0000000..2ae034a --- /dev/null +++ b/crates/linkunbound-shell/src/notice.rs @@ -0,0 +1,98 @@ +use linkunbound_core::Strings; +use linkunbound_shell::Notice; +use slint::ComponentHandle; + +use crate::{host, native_handle, physical, store, ui}; + +const NOTICE_SECONDS: i32 = 6; +const NOTICE_MARGIN: f32 = 16.0; + +/// Never focused: it must not take the keyboard from whatever is being done. +pub fn flash(notice: &Notice, headline: String, reason: String, undo: Option<&str>) { + notice.set_headline(headline.into()); + notice.set_reason(reason.into()); + notice.set_undo_label(undo.unwrap_or_default().into()); + notice.set_undoable(undo.is_some()); + notice.set_left(NOTICE_SECONDS); + let _ = notice.show(); + if let Some(handle) = native_handle(notice.window()) { + host::keep_off_the_taskbar(handle, linkunbound_shell::CLASSIC_CORNER); + host::never_activates(handle); + } + in_the_corner(notice); + #[cfg(target_os = "macos")] + if !ui().is_some_and(|ui| ui.picker.window().is_visible()) { + host::let_whoever_opens_next_come_forward(); + } +} + +pub fn noted(area: &'static str, what: &str) { + linkunbound_core::note(store().dir(), area, what); +} + +pub fn troubled(area: &'static str, what: &str, said: impl FnOnce(&Strings) -> (String, String)) { + noted(area, what); + let Some(ui) = ui() else { return }; + let (headline, reason) = said(&ui.words.get()); + ui.firing.replace(None); + flash(&ui.notice, headline, reason, None); + ui.count_down(); + if ui.picker.window().is_visible() + && let Some(handle) = native_handle(ui.picker.window()) + { + host::take_the_keyboard(handle); + } +} + +/// The corner of the screen the click happened on, where a notice is looked for; left to the +/// window manager it opened wherever the last one did, usually another screen, and the six +/// seconds passed unseen. +fn in_the_corner(notice: &Notice) { + let Some((cx, cy)) = host::cursor() else { + return; + }; + let Some((x, y, width, height)) = host::work_area_at(cx, cy) else { + return; + }; + let scale = if cfg!(target_os = "macos") { + 1.0 + } else { + f64::from(notice.window().scale_factor()) + }; + let size = |logical: f32| physical(logical, scale); + let at_x = x + width - size(notice.get_wanted_width()) - size(NOTICE_MARGIN); + let at_y = y + height - size(notice.get_wanted_height()) - size(NOTICE_MARGIN); + #[cfg(target_os = "macos")] + notice + .window() + .set_position(slint::LogicalPosition::new(at_x as f32, at_y as f32)); + #[cfg(not(target_os = "macos"))] + notice + .window() + .set_position(slint::PhysicalPosition::new(at_x, at_y)); +} + +#[cfg(test)] +mod tests { + use super::flash; + use linkunbound_shell::Notice; + + #[test] + fn a_notice_about_trouble_offers_nothing_to_undo() { + i_slint_backend_testing::init_no_event_loop(); + let notice = Notice::new().expect("a notice"); + + flash(¬ice, "Opened".into(), "by a rule".into(), Some("Undo")); + assert!(notice.get_undoable()); + assert_eq!(notice.get_undo_label(), "Undo"); + + flash( + ¬ice, + "The rule could not open Firefox".into(), + "why".into(), + None, + ); + assert!(!notice.get_undoable()); + assert_eq!(notice.get_headline(), "The rule could not open Firefox"); + } +} diff --git a/crates/linkunbound-shell/ui/shell.slint b/crates/linkunbound-shell/ui/shell.slint index 256ad73..f0b9b59 100644 --- a/crates/linkunbound-shell/ui/shell.slint +++ b/crates/linkunbound-shell/ui/shell.slint @@ -1100,6 +1100,7 @@ export component Notice inherits Window { in property headline; in property reason; in property undo-label; + in property undoable: true; in-out property left: 6; callback undo(); @@ -1144,7 +1145,7 @@ export component Notice inherits Window { } } - Rectangle { + if root.undoable: Rectangle { width: 76px; height: 28px; y: (parent.height - self.height) / 2; diff --git a/crates/linkunbound-win/src/lib.rs b/crates/linkunbound-win/src/lib.rs index d82503b..4e2afec 100644 --- a/crates/linkunbound-win/src/lib.rs +++ b/crates/linkunbound-win/src/lib.rs @@ -23,8 +23,8 @@ pub use native::{ }; #[cfg(windows)] pub use registration::{ - Registration, association_report, is_build_tree, is_default_browser, sweep_legacy_edge_capture, - taskbar_is_light, windows_are_light, + Registration, association_report, default_apps_page, is_build_tree, is_default_browser, + sweep_legacy_edge_capture, taskbar_is_light, windows_are_light, }; #[cfg(windows)] pub use startup::{ diff --git a/crates/linkunbound-win/src/registration.rs b/crates/linkunbound-win/src/registration.rs index 038ef2c..fd86041 100644 --- a/crates/linkunbound-win/src/registration.rs +++ b/crates/linkunbound-win/src/registration.rs @@ -7,6 +7,7 @@ use crate::RegistrationError; pub const PROG_ID: &str = "LinkUnboundURL"; const APP_NAME: &str = "LinkUnbound"; +const PACKAGED_APP_ID: &str = "LinkUnbound"; const APP_DESCRIPTION: &str = "Browser picker for Windows"; const URL_SCHEMES: [&str; 2] = ["http", "https"]; @@ -284,17 +285,36 @@ pub fn prog_id_is_ours(prog_id: &str) -> bool { prog_id.eq_ignore_ascii_case(PROG_ID) || names_this_package(prog_id) } +fn package_family() -> Option { + windows::ApplicationModel::Package::Current() + .and_then(|package| package.Id()) + .and_then(|id| id.FamilyName()) + .ok() + .map(|family| family.to_string()) +} + +#[must_use] +pub fn default_apps_page() -> String { + default_apps_page_for(package_family().as_deref()) +} + +fn default_apps_page_for(family: Option<&str>) -> String { + match family { + Some(family) => { + format!("ms-settings:defaultapps?registeredAUMID={family}!{PACKAGED_APP_ID}") + } + None => format!("ms-settings:defaultapps?registeredAppUser={APP_NAME}"), + } +} + /// Inside a package Windows writes a ProgId of its own, `AppX`, that carries nothing of /// the name; the class it registers names the package through its AppUserModelID, which starts /// with the package family. Only a packaged copy can answer for one. fn names_this_package(prog_id: &str) -> bool { - let Ok(family) = windows::ApplicationModel::Package::Current() - .and_then(|package| package.Id()) - .and_then(|id| id.FamilyName()) - else { + let Some(family) = package_family() else { return false; }; - let mine = format!("{}!", family.to_string().to_ascii_lowercase()); + let mine = format!("{}!", family.to_ascii_lowercase()); RegKey::predef(HKEY_CURRENT_USER) .open_subkey(format!(r"Software\Classes\{prog_id}\Application")) .and_then(|key| key.get_value::("AppUserModelID")) @@ -334,7 +354,28 @@ pub fn is_default_browser() -> bool { } fn still_held(classes: &RegKey, prog_id: &str) -> bool { - prog_id_is_ours(prog_id) && classes.open_subkey(prog_id).is_ok() + if !prog_id_is_ours(prog_id) { + return false; + } + let Ok(class) = classes.open_subkey(prog_id) else { + return false; + }; + class + .open_subkey(r"shell\open\command") + .and_then(|key| key.get_value::("")) + .map_or(true, |command| { + command_target(&command).is_some_and(|exe| std::path::Path::new(&exe).is_file()) + }) +} + +fn command_target(command: &str) -> Option { + let command = command.trim(); + match command.strip_prefix('"') { + Some(quoted) => quoted.split('"').next(), + None => command.split_whitespace().next(), + } + .filter(|exe| !exe.is_empty()) + .map(str::to_owned) } /// Which associations the app holds and which another application took, so the @@ -390,6 +431,24 @@ mod tests { ); } + #[test] + fn the_default_apps_page_names_whichever_copy_is_asking() { + assert_eq!( + default_apps_page_for(None), + "ms-settings:defaultapps?registeredAppUser=LinkUnbound" + ); + assert_eq!( + default_apps_page_for(Some("rgdevment.LinkUnbound-BrowserPicker_kdjgfdc2rb3gc")), + "ms-settings:defaultapps?registeredAUMID=rgdevment.LinkUnbound-BrowserPicker_kdjgfdc2rb3gc!LinkUnbound" + ); + let manifest = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../app/src-tauri/msix/AppxManifest.xml.in"), + ) + .expect("the package manifest"); + assert!(manifest.contains(&format!(r#" { + const away = mkdtempSync(join(tmpdir(), "linkunbound-notices-")); + try { + execFileSync( + process.execPath, + [ + join(root, "app", "node_modules", "vite", "bin", "vite.js"), + "build", + "--sourcemap", + "--emptyOutDir", + "--logLevel", + "error", + "--outDir", + away, + ], + { cwd: join(root, "app"), stdio: ["ignore", "ignore", "inherit"] }, + ); + const names = new Set(); + for (const one of readdirSync(join(away, "assets"))) { + if (!one.endsWith(".map")) continue; + const map = JSON.parse(readFileSync(join(away, "assets", one), "utf8")); + for (const source of map.sources ?? []) { + const where = source.replace(/\\/g, "/"); + const at = where.split("node_modules/").at(-1); + if (at === where) continue; + const parts = at.split("/"); + names.add(parts[0].startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0]); + } + } + if (names.size === 0) throw new Error("the window's bundle named no package"); + return names; + } finally { + rmSync(away, { recursive: true, force: true }); + } +}; + const shipped = () => { const lock = JSON.parse(readFileSync(join(root, "app", "package-lock.json"), "utf8")); + const inside = bundled(); const seen = new Map(); for (const [at, one] of Object.entries(lock.packages ?? {})) { if (!at || one.dev || one.devOptional || one.extraneous) continue; const name = one.name ?? at.slice(at.lastIndexOf("node_modules/") + 13); - if (!name || seen.has(name)) continue; + if (!name || seen.has(name) || !inside.has(name)) continue; seen.set(name, { version: one.version ?? "?", licence: one.license ?? "see the package",