Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/oversized.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/rules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 17 additions & 3 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Privacy Policy

**Last updated:** October 2, 2026
**Last updated:** October 4, 2026

---

Expand Down Expand Up @@ -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.
Expand All @@ -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\` |
Expand All @@ -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) |
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
55 changes: 1 addition & 54 deletions THIRD-PARTY-BUNDLED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
70 changes: 69 additions & 1 deletion app/src-tauri/src/about.rs
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -34,3 +36,69 @@
pub fn notices(licences: bool) -> &'static str {
if licences { LICENCES } else { NOTICES }
}

#[tauri::command]
pub fn maintenance_report() -> Result<String, String> {
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::<Vec<_>>()
.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,

Check warning on line 84 in app/src-tauri/src/about.rs

View workflow job for this annotation

GitHub Actions / settings / the lines this branch changed

Missed mutant

replace match guard same_name(&name, saved) with false in maintenance_report

Check warning on line 84 in app/src-tauri/src/about.rs

View workflow job for this annotation

GitHub Actions / settings / the lines this branch changed

Missed mutant

replace match guard same_name(&name, saved) with true in maintenance_report
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())

Check warning on line 103 in app/src-tauri/src/about.rs

View workflow job for this annotation

GitHub Actions / settings / the lines this branch changed

Missed mutant

replace maintenance_report -> Result<String, String> with Ok("xyzzy".into())

Check warning on line 103 in app/src-tauri/src/about.rs

View workflow job for this annotation

GitHub Actions / settings / the lines this branch changed

Missed mutant

replace maintenance_report -> Result<String, String> with Ok(String::new())
}
68 changes: 1 addition & 67 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -713,72 +713,6 @@ fn system_open_elsewhere(url: String) -> Result<(), String> {
.map_err(|e| e.to_string())
}

#[tauri::command]
fn maintenance_report() -> Result<String, String> {
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::<Vec<_>>()
.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()
Expand Down Expand Up @@ -1344,7 +1278,7 @@ pub fn run() {
browsers_reorder,
maintenance_rescan,
maintenance_reset,
maintenance_report,
about::maintenance_report,
prefs_get,
prefs_set,
system_state,
Expand Down
Loading
Loading