diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index d911db0..614f07d 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -14,16 +14,6 @@ on: - ".github/workflows/bundle.yml" - ".github/workflows/release.yml" pull_request: - paths: - - "app/src-tauri/nsis/**" - - "app/src-tauri/Info.plist" - - "app/src-tauri/tauri.conf.json" - - "app/src-tauri/Cargo.toml" - - "app/src-tauri/build.rs" - - "scripts/sidecar.sh" - - "Cargo.toml" - - ".github/workflows/bundle.yml" - - ".github/workflows/release.yml" workflow_dispatch: permissions: @@ -37,8 +27,38 @@ env: CARGO_TERM_COLOR: always jobs: + # `paths` on the trigger leaves a required check unreported for good; a skipped job counts as met. + changes: + name: what the branch touched + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + packaging: ${{ steps.look.outputs.packaging }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - id: look + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + if [ -z "$BASE" ]; then + echo "packaging=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + watched='^(app/src-tauri/(nsis/|Info\.plist|tauri\.conf\.json|Cargo\.toml|build\.rs)|scripts/sidecar\.sh|Cargo\.toml$|\.github/workflows/(bundle|release)\.yml$)' + from=$(git merge-base "$BASE" HEAD) + if git diff --name-only --diff-filter=d "$from" HEAD | grep -qE "$watched"; then + echo "packaging=true" >> "$GITHUB_OUTPUT" + else + echo "packaging=false" >> "$GITHUB_OUTPUT" + fi + nsis: name: the installer builds + needs: changes + if: needs.changes.outputs.packaging == 'true' runs-on: windows-latest timeout-minutes: 45 steps: @@ -99,6 +119,8 @@ jobs: macos: name: the bundle builds + needs: changes + if: needs.changes.outputs.packaging == 'true' runs-on: macos-latest timeout-minutes: 60 env: diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index 5c1064e..297c126 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -2,12 +2,6 @@ name: Mutants on: pull_request: - paths: - - crates/** - - app/src/** - - .cargo/mutants.toml - - app/stryker.config.json - - .github/workflows/mutants.yml permissions: contents: read @@ -20,10 +14,35 @@ env: CARGO_TERM_COLOR: always jobs: + # `paths` on the trigger leaves a required check unreported for good; a skipped job counts as met. + changes: + name: what the branch touched + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + mutable: ${{ steps.look.outputs.mutable }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - id: look + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + watched='^(crates/|app/src/|\.cargo/mutants\.toml$|app/stryker\.config\.json$|\.github/workflows/mutants\.yml$)' + from=$(git merge-base "$BASE" HEAD) + if git diff --name-only --diff-filter=d "$from" HEAD | grep -qE "$watched"; then + echo "mutable=true" >> "$GITHUB_OUTPUT" + else + echo "mutable=false" >> "$GITHUB_OUTPUT" + fi + crates-on-the-branch: name: crates / the lines this branch changed runs-on: ubuntu-latest - if: github.actor != 'dependabot[bot]' + needs: changes + if: github.actor != 'dependabot[bot]' && needs.changes.outputs.mutable == 'true' timeout-minutes: 90 steps: - uses: actions/checkout@v7 @@ -83,7 +102,8 @@ jobs: mac-on-the-branch: name: mac / the lines this branch changed runs-on: macos-latest - if: github.actor != 'dependabot[bot]' + needs: changes + if: github.actor != 'dependabot[bot]' && needs.changes.outputs.mutable == 'true' timeout-minutes: 90 steps: - uses: actions/checkout@v7 @@ -137,7 +157,8 @@ jobs: settings-on-the-branch: name: settings / the lines this branch changed runs-on: ubuntu-latest - if: github.actor != 'dependabot[bot]' + needs: changes + if: github.actor != 'dependabot[bot]' && needs.changes.outputs.mutable == 'true' timeout-minutes: 90 steps: - uses: actions/checkout@v7 @@ -201,7 +222,8 @@ jobs: window-on-the-branch: name: window / the lines this branch changed runs-on: ubuntu-latest - if: github.actor != 'dependabot[bot]' + needs: changes + if: github.actor != 'dependabot[bot]' && needs.changes.outputs.mutable == 'true' timeout-minutes: 60 steps: - uses: actions/checkout@v7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3742708..134db75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ We believe in: 1. **Fork** the repository. 2. **Choose the base branch:** - - `main` — active development. Version 2.0 lands here. + - `main` — active development. The 2.x line lands here. - `v1-stable` — the frozen 1.4.x line. Security and crash fixes only, no new features. 3. **Create a branch** from it (`git checkout -b feature/my-improvement`). 4. **Make your changes** following the style guide below. @@ -67,6 +67,19 @@ Found something confusing? Missing information? PRs welcome. --- +## Architecture + +Two binaries, one package: + +- `linkunbound-shell` → the resident: tray or menu bar, the picker, the notice; Windows and macOS run it for every link, and a second copy hands its link to the one already running and exits +- `linkunbound-settings` → the settings window, opened on demand, and the errands the resident sends it on with no window (`--look` asks the feed, `--update` installs) + +**Windows.** A named pipe of the user's own (`linkunbound-.sock`) links second instances to the resident, and holding its name is what keeps a second resident from starting. The installer writes the app's own ProgId, `RegisteredApplications` and `StartMenuInternet` keys; the settings window re-points them when the install moves and never recreates what the person took away; Windows itself owns the final choice through `UserChoice`, which no application may write. + +**macOS.** The bundle's `CFBundleExecutable` is the resident, so Launch Services starts it — or talks to the running copy — for every link. Nothing arrives on the command line: links, documents, launches and reopens come in as Apple Events (`GURL`, `odoc`, `oapp`, `rapp`), and the launch event says whether the session started the app as a login item, which is what keeps the settings window closed at sign-in. A Unix socket under `~/Library/Application Support/LinkUnbound/` carries links handed over from a terminal. Default-browser registration goes through `NSWorkspace.setDefaultApplication` for `http`, `https` and the web document types, which the system confirms with its own prompt; the browser that held the links before is remembered and gets them back on unregistering. Login items use `SMAppService`. A browser is started through `open` either way — plainly for a bare link, as an instance of its own when a private window or a profile rides along — so Launch Services starts it and it answers for its own permissions rather than for LinkUnbound's. The web document types are declared as an alternate opener: double-clicking an `.html` keeps opening wherever it did until the person chooses. The app runs as `LSUIElement`, so it lives in the menu bar instead of the Dock, and the picker floats above every Space, full-screen apps included. + +--- + ## Project Structure LinkUnbound is a Cargo workspace. Two binaries ship in one package: the diff --git a/PRIVACY.md b/PRIVACY.md index 3f1640d..2cad850 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -64,8 +64,10 @@ macOS does not report which application opened a link, so the foreground app sta What this means in practice: - **Only the application name is read.** Not its windows, not its title, not - its contents. `explorer`, `cmd` and `powershell` are discarded, since they - are the shell itself rather than an app worth writing a rule about. + its contents. The shell counts as an application like any other: a link + opened from a file on the desktop reports `explorer`, and whether that is + worth a rule is yours to decide, not ours. The picker names the rule that + decided every time one does. - **It is read at the moment a link arrives**, not continuously. LinkUnbound does not watch which applications you use. - **It reaches the disk only if you ask it to.** The name is used in memory to diff --git a/README.md b/README.md index 0940709..a045194 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ 
-LinkUnbound icon +LinkUnbound icon # LinkUnbound @@ -9,21 +9,6 @@ **Open source. Local-first. Privacy-first. Zero telemetry.**

- - Build Status - - - Quality Gate - - - Mutation score - - - Window mutation score - - - Coverage - Latest Release @@ -76,7 +61,7 @@ **LinkUnbound** is a free, open source **browser picker** for Windows and macOS — a small tool that makes itself the default browser and then asks *you* which real browser should open each link. Click a link in Teams, Outlook, Slack, Discord, a PDF, a terminal, anywhere: if you have a rule for it, the right browser opens at once; if not, a little **browser chooser** pops up next to your cursor and you pick with a click or a key. -It is the missing piece for anyone who lives with more than one browser: work in one, personal life in another, a client's tools in a third profile. Instead of changing the system default every week, you set a **rule per site, per subdomain, per exact address or per app** — "everything from Slack in Brave", "docs.google.com in the work profile of Chrome" — and the rest goes through the picker. A **default browser manager**, a **link router**, a **browser switcher**: call it what you like, it just routes links where you want them. +It is the missing piece for anyone who lives with more than one browser: work in one, personal life in another, a client's tools in a third profile. Instead of changing the system default every week, you set a **rule per site, per subdomain, per exact address or per app** — "everything from Slack in Brave", "docs.google.com in the work profile of Chrome" — and the rest goes through the picker. There is no company behind this. I am one developer who got tired of the operating system deciding which browser opens a link, built this for myself, and put it out for whoever has the same itch. That shapes everything about it: **no ads, no telemetry, no analytics, no accounts, no subscriptions, no cloud, no data collection** — the only thing it ever asks the network is whether a new version exists, and you can turn that off. Your rules live in a small JSON file on your own disk, and you can read every line of the code that touches them. @@ -85,7 +70,6 @@ There is no company behind this. I am one developer who got tired of the operati ## Table of Contents - [What It Does](#what-it-does) -- [What It Is / What It Isn't](#what-it-is--what-it-isnt) - [Privacy and Security](#privacy-and-security) - [Getting Started](#getting-started) - [How It Works](#how-it-works) @@ -96,6 +80,7 @@ There is no company behind this. I am one developer who got tired of the operati - [Want to Help?](#want-to-help) - [Support the Project](#support-the-project) - [Other Tools](#other-tools) +- [Alternatives](#alternatives) - [License](#license) --- @@ -118,23 +103,6 @@ There is no company behind this. I am one developer who got tired of the operati --- -## What It Is / What It Isn't - -**LinkUnbound is:** - -- A **local-first browser picker** and **default browser manager** for Windows and macOS -- A lightweight **browser switcher** and **browser selector** that works offline -- An **open source** tool you can trust — GPL v3, inspect every line, fork it, contribute - -**LinkUnbound is not:** - -- A browser, toolbar, or extension -- A telemetry or analytics tool -- A "platform" with accounts, subscriptions, or ads -- A corporate product — it's a personal project shared with the community - ---- - ## Privacy and Security **Everything stays local.** LinkUnbound is built on a single, non-negotiable principle: your data never leaves your computer. @@ -219,7 +187,7 @@ Since LinkUnbound is an independent open source project, the installer is signed **Settings (tray):** double-click the tray icon or right-click → Settings. Six pages: - **Links** — whether the system sends links here, what is wrong when it does not and the button that fixes it; on Windows, the associations held -- **Rules** — every rule, in the order that decides; change where each opens, add one, remove one +- **Rules** — every rule, the most precise one deciding; change where each opens, add one, remove one - **Browsers** — what was detected, with profiles; hide, duplicate, or add a custom one with its arguments - **Application** — theme, language, picker look, start at sign-in, the global shortcut, background update checks - **Maintenance** — export a diagnostic report, rescan browsers, reset the configuration, remove LinkUnbound from the system's list @@ -239,14 +207,7 @@ Rules are written from the picker — pick the reach, then the browser — or fr ## Architecture -Two binaries, one package: - -- `linkunbound-shell` → the resident: tray or menu bar, the picker, the notice; Windows and macOS run it for every link, and a second copy hands its link to the one already running and exits -- `linkunbound-settings` → the settings window, opened on demand, and the errands the resident sends it on with no window (`--look` asks the feed, `--update` installs) - -**Windows.** A named pipe of the user's own (`linkunbound-.sock`) links second instances to the resident, and holding its name is what keeps a second resident from starting. The installer writes the app's own ProgId, `RegisteredApplications` and `StartMenuInternet` keys; the settings window re-points them when the install moves and never recreates what the person took away; Windows itself owns the final choice through `UserChoice`, which no application may write. - -**macOS.** The bundle's `CFBundleExecutable` is the resident, so Launch Services starts it — or talks to the running copy — for every link. Nothing arrives on the command line: links, documents, launches and reopens come in as Apple Events (`GURL`, `odoc`, `oapp`, `rapp`), and the launch event says whether the session started the app as a login item, which is what keeps the settings window closed at sign-in. A Unix socket under `~/Library/Application Support/LinkUnbound/` carries links handed over from a terminal. Default-browser registration goes through `NSWorkspace.setDefaultApplication` for `http`, `https` and the web document types, which the system confirms with its own prompt; the browser that held the links before is remembered and gets them back on unregistering. Login items use `SMAppService`. A browser is started through `open` either way — plainly for a bare link, as an instance of its own when a private window or a profile rides along — so Launch Services starts it and it answers for its own permissions rather than for LinkUnbound's. The web document types are declared as an alternate opener: double-clicking an `.html` keeps opening wherever it did until the person chooses. The app runs as `LSUIElement`, so it lives in the menu bar instead of the Dock, and the picker floats above every Space, full-screen apps included. +Two binaries, one package: a resident that receives the links and draws the picker, and a settings window opened on demand. How they talk to each other on each system is in [CONTRIBUTING.md](CONTRIBUTING.md#architecture). **Coming from 1.x.** 2.0 is not compatible with the settings of the 1.x line, and does not promise to carry them over: it starts from what it can read and keeps the rest out of the way. On Windows it reads the rules and browsers of a standalone 1.x install where they are, and keeps a copy of the originals as `rules.1x.json` and `browsers.1x.json` before writing its own format; a copy installed from the Microsoft Store kept its files inside its package and they are not read. Going back to 1.x means restoring those copies over `rules.json` and `browsers.json`. Remove 1.x before installing 2.0 — the installer offers to — or the two fight over the same registration; a Store copy of 1.x has to be uninstalled by hand. On macOS 2.0 ships under the bundle identifier `dev.rgdevment.linkunbound`; 1.x was `com.rgdevment.linkunbound`, kept its files under that name, and nothing of it is read. macOS treats them as two applications: quit 1.x, remove it, then open Settings and set LinkUnbound as the default once more, and remove the old entry under System Settings → General → Login Items if one is left behind. @@ -333,6 +294,24 @@ I build free, open source tools focused on privacy and productivity. If you like --- +## Alternatives + +Other browser pickers, so you can pick the one that fits. Platform and licence checked against each project in September 2026; everything else changes, so go and look. + +| Project | Platform | Licence | +| :-- | :-- | :-- | +| [Finicky](https://github.com/johnste/finicky) | macOS | MIT | +| [Browserosaurus](https://github.com/will-stone/browserosaurus) | macOS | GPL-3.0, archived by its author | +| [BrowserPicker](https://github.com/mortenn/BrowserPicker) | Windows | MIT | +| [Browser Tamer](https://github.com/aloneguid/bt) | Windows, Linux | Apache-2.0 | +| [Junction](https://github.com/sonnyp/Junction) | Linux | GPL-3.0 | +| [Choosy](https://www.choosyosx.com/) | macOS, Windows | Paid | +| [Velja](https://sindresorhus.com/velja) | macOS | Free, closed source | + +What LinkUnbound does that most of these do not: **one build for Windows and macOS**, rules that match **the app a link came from** and not only its address, **browser profiles** detected on their own, and signed automatic updates. Finicky is configured in a file and is macOS only; Browserosaurus is no longer maintained; Junction is Linux only. + +--- + ## License **LinkUnbound** — A free, open source browser picker for Windows and macOS. @@ -358,4 +337,24 @@ from the lockfiles. I built LinkUnbound because I was tired of my OS not letting me choose which browser opens a link. This is a personal tool, built from a real need, shared because others might need it too. Free to use, free to inspect, free forever. -**Keywords:** browser picker, browser chooser, default browser manager, browser switcher, link router, URL router, link handler, multi-browser workflow, open source browser picker Windows, browser picker macOS, Microsoft Store browser picker, privacy-first link handler, local-first browser routing, no telemetry, work and personal browser separation, Teams links browser, Outlook links browser, Slack links browser, SafeLinks resolver, Browserosaurus alternative, Choosy alternative, BrowserPick alternative. +--- + +## Project health + +

+ + Build Status + + + Quality Gate + + + Mutation score + + + Window mutation score + + + Coverage + +

diff --git a/SECURITY.md b/SECURITY.md index 4392c53..badada5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,11 +35,11 @@ LinkUnbound makes exactly **one type of network request** on its own: a read-onl | Version | Supported | | :--------------- | :------------------------------------: | -| 2.0.x | Yes | +| 2.x | Yes | | 1.4.x | Security and crash fixes only | | Older than 1.4.x | No | -The 2.0 line is `main`. The 1.4.x line lives on the `v1-stable` branch and receives security and crash fixes only — no new features. Always use the [latest version](https://github.com/rgdevment/LinkUnbound/releases/latest). +The 2.x line is `main`. The 1.4.x line lives on the `v1-stable` branch and receives security and crash fixes only — no new features. Always use the [latest version](https://github.com/rgdevment/LinkUnbound/releases/latest). --- diff --git a/app/src-tauri/src/shell.rs b/app/src-tauri/src/shell.rs index 8b70250..b08e335 100644 --- a/app/src-tauri/src/shell.rs +++ b/app/src-tauri/src/shell.rs @@ -49,6 +49,16 @@ fn dress_natively(window: &tauri::WebviewWindow, theme: Theme) { #[cfg(not(target_os = "macos"))] fn dress_natively(_window: &tauri::WebviewWindow, _theme: Theme) {} +#[cfg(target_os = "windows")] +fn drop_the_title_icon(window: &tauri::WebviewWindow) { + if let Ok(hwnd) = window.hwnd() { + linkunbound_win::strip_title_icon(hwnd.0 as isize); + } +} + +#[cfg(not(target_os = "windows"))] +fn drop_the_title_icon(_window: &tauri::WebviewWindow) {} + /// Set while the webview is being built. A second instance's knock, or the global shortcut, /// arrives inside that build's own message pump — the WebView2 environment is created by /// pumping messages until it answers — and building again from in there leaves both sides @@ -98,6 +108,7 @@ fn build_settings(app: &AppHandle, theme: Theme) { let built = builder.build(); if let Ok(window) = built { dress_natively(&window, theme); + drop_the_title_icon(&window); let late = window.clone(); std::thread::spawn(move || { std::thread::sleep(LONG_ENOUGH_TO_PAINT); diff --git a/app/src/Settings.test.tsx b/app/src/Settings.test.tsx index 7d39224..3e1bfbb 100644 --- a/app/src/Settings.test.tsx +++ b/app/src/Settings.test.tsx @@ -65,16 +65,18 @@ describe("settings", () => { it("asks for the window once it has painted", async () => { answers(BASE); render(); - await screen.findByText("Versión 2.0.0"); + await screen.findByRole("heading", { name: "Enlaces" }); await waitFor(() => expect(invoke).toHaveBeenCalledWith("settings_painted")); }); /// The sidebar's corner said "Versión {}" for two different facts: the one you run and the one - /// waiting. With an offer in hand it showed the new number where the installed one had been. - it("shows the version it is running when there is nothing newer", async () => { + /// waiting, and with an offer in hand it showed the new number where the installed one had been. + /// The corner is gone and About carries the number, so the bar must stay quiet with nothing new. + it("says nothing in the bar when there is no newer version", async () => { answers(BASE); render(); - expect(await screen.findByText("Versión 2.0.0")).toBeInTheDocument(); + await go("Acerca de"); + expect(await screen.findByText("2.0.0")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /hay una versión nueva/ })).toBeNull(); }); @@ -453,7 +455,7 @@ describe("settings", () => { ["Navegadores", "Navegadores"], ["Aplicación", "Aplicación"], ["Mantenimiento", "Mantenimiento"], - ["Acerca de", "Acerca de"], + ["Acerca de", "LinkUnbound"], ["Enlaces", "Enlaces"], ]) { await go(name); diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index 62ece56..0509dc7 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -36,8 +36,6 @@ type Spoken = { language: string }; /// Only the one field this screen touches; the rest travels back untouched. type Noticed = { prefs: { edge_warning_dismissed: boolean } & Record }; -type Build = { version: string }; - const RELEASES = "https://github.com/rgdevment/LinkUnbound/releases/latest"; type Remedy = { label: Key; note?: Key } & { command: string; args?: Record }; @@ -88,7 +86,9 @@ function Wrappable({ path }: { path: string }) { type Page = "links" | "rules" | "browsers" | "app" | "care" | "about"; -const PAGES: { id: Page; label: Key; icon: React.ReactNode }[] = [ +type Entry = { id: Page; label: Key; icon: React.ReactNode }; + +const PAGES: Entry[] = [ { id: "links", label: "navLinks", @@ -130,18 +130,21 @@ const PAGES: { id: Page; label: Key; icon: React.ReactNode }[] = [ ), }, - { - id: "about", - label: "navAbout", - icon: ( - <> - - - - ), - }, ]; +/// Kept out of the list and pinned to the foot of the bar: it is where a person looks for it, +/// and it is the one page that is about the program rather than about what it does. +const ABOUT: Entry = { + id: "about", + label: "navAbout", + icon: ( + <> + + + + ), +}; + function Links({ state, change, @@ -310,7 +313,6 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { const { ready, step, look, settled } = useUpdate(); const [page, setPage] = useState("links"); const [state, setState] = useState(null); - const [here, setHere] = useState(null); const [problem, setProblem] = useState(null); const [starring, setStarring] = useState(false); @@ -326,12 +328,6 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { return () => window.removeEventListener("focus", ask); }, []); - useEffect(() => { - void invoke("about") - .then(({ version }) => setHere(version)) - .catch(noop); - }, []); - useEffect(() => { void invoke("star_due").then(setStarring).catch(noop); }, []); @@ -365,52 +361,30 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { className="flex w-44 shrink-0 flex-col gap-0.5 border-r border-black/[0.08] bg-black/[0.02] p-2 dark:border-white/[0.08] dark:bg-white/[0.02]" > {PAGES.map((p) => ( - + ))} - {ready ? ( - - ) : ( - - {here ? t("version", here) : ""} - - )} +
+ {ready && ( + + )} + +
-

- {t(PAGES.find((p) => p.id === page)?.label ?? "navLinks")} -

+ {page !== "about" && ( +

+ {t(PAGES.find((p) => p.id === page)?.label ?? "navLinks")} +

+ )} {problem && (

{problem} @@ -443,4 +417,34 @@ function Shell({ onLanguage }: { onLanguage: (next: Language) => void }) { ); } +function Tab({ tab, on, onPick }: { tab: Entry; on: Page; onPick: (next: Page) => void }) { + const t = useWords(); + const here = tab.id === on; + return ( + + ); +} + function noop() {} diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 6b06148..ed8ea1a 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -11,7 +11,6 @@ const ES = { navCare: "Mantenimiento", navAbout: "Acerca de", navLabel: "Secciones", - version: "Versión {}", healthTitle: "LinkUnbound no está recibiendo los enlaces", healthStale: @@ -74,8 +73,6 @@ const ES = { rulesFooter: "Gana siempre la regla más precisa: una URL concreta antes que un subdominio, y este antes que el sitio entero. Las marcadas «Desde» ganan sobre todas y responden a los enlaces de una aplicación, sea cual sea la dirección.", rulePrivate: "privada", - ruleUp: "Subir la regla de {}", - ruleDown: "Bajar la regla de {}", ruleRemove: "Eliminar la regla de {}", ruleBrowser: "Navegador para la regla de {}", ruleDeleteTitle: "Eliminar la regla de {}", @@ -142,11 +139,13 @@ const ES = { notifyTitle: "Avisar cuando una regla abre sin preguntar", notifyNote: "Un aviso breve, con la opción de deshacer", - careReport: "Informar de un problema", - reportTitle: "Guardar un informe de diagnóstico", - reportNote: "Un archivo con el estado de la app y tus reglas, sin las direcciones que visitas", + troubleSection: "Si algo va mal", + reportWhat: + "El informe reúne en un archivo la versión de LinkUnbound, tus reglas y qué sistema usas, sin las direcciones que visitas.", + reportStays: "No se envía a ninguna parte.", + reportPick: "Se guarda donde tú elijas y solo lo adjuntas si quieres.", + reportGo: "Guardar informe…", reportSaved: "Guardado en {}", - careBrowsers: "Navegadores", rescanTitle: "Volver a buscar navegadores", rescanNote: "Útil si instalaste uno y no aparece en el selector", rescanCounts: "{} nuevos, {} que ya no están", @@ -238,12 +237,13 @@ const ES = { aboutSponsorSection: "Apoyar a LinkUnbound", aboutRepo: "Abrir el repositorio", aboutTisty: "Tisty", - aboutTistyNote: - "Notas, documentos y tareas en tu propio equipo. Misma filosofía: sin anuncios, sin telemetría, todo local.", + aboutTistyNote: "Notas, documentos y tareas, en archivos que puedes leer sin él.", aboutProject: "Proyecto", aboutSource: "Código fuente", aboutSourceNote: "GPL-3.0 · las aportaciones son bienvenidas", aboutIssue: "Informar de un problema", + aboutPrivacyLink: "Privacidad", + aboutNotices: "Avisos de terceros", aboutIssueNote: "Errores, ideas y navegadores que no detecta", aboutSponsor: "Invítame un café", aboutSponsorNote: @@ -252,8 +252,7 @@ const ES = { aboutLicenseNote: "Software libre: puedes leerlo, cambiarlo y compartirlo", aboutOtherTools: "Otras herramientas", aboutCopyPaste: "CopyPaste", - aboutCopyPasteNote: - "Gestor de portapapeles gratuito y de código abierto para Windows, macOS y Linux. Misma filosofía: sin anuncios, sin telemetría, todo local.", + aboutCopyPasteNote: "Recupera lo que copiaste hace diez minutos, o ayer.", open: "Abrir", save: "Guardar", @@ -271,13 +270,12 @@ const EN: Record = { navCare: "Maintenance", navAbout: "About", navLabel: "Sections", - version: "Version {}", healthTitle: "LinkUnbound is not receiving links", healthStale: "The registration points at another copy of LinkUnbound. Links will go there, not here, until you repair it.", healthBuildTree: - "You are running a freshly built copy. Windows cannot trust links to a path that disappears when the project is cleaned.", + "You are running a freshly built copy. Windows cannot trust links to a path that disappears when the project is cleaned, so this copy does not register.", healthWrongBinary: "The registration points at the settings window, which cannot open a link. Repair it so it points at the process that can.", healthNoResident: @@ -334,8 +332,6 @@ const EN: Record = { rulesFooter: "The most precise rule always wins: one URL before a subdomain, and that before the whole site. The ones marked «From» win over all of them, and answer the links of one application whatever the address.", rulePrivate: "private", - ruleUp: "Move the rule for {} up", - ruleDown: "Move the rule for {} down", ruleRemove: "Delete the rule for {}", ruleBrowser: "Browser for the rule for {}", ruleDeleteTitle: "Delete the rule for {}", @@ -403,11 +399,13 @@ const EN: Record = { notifyTitle: "Warn when a rule opens without asking", notifyNote: "A brief notice, with the option to undo", - careReport: "Report a problem", - reportTitle: "Save a diagnostic report", - reportNote: "A file with the state of the app and your rules, without the addresses you visit", + troubleSection: "If something goes wrong", + reportWhat: + "The report gathers into one file LinkUnbound's version, your rules and which system you are on, without the addresses you visit.", + reportStays: "It is never sent anywhere.", + reportPick: "It is saved where you choose, and you attach it only if you want to.", + reportGo: "Save report…", reportSaved: "Saved to {}", - careBrowsers: "Browsers", rescanTitle: "Look for browsers again", rescanNote: "Useful if you installed one and it does not show up in the picker", rescanCounts: "{} new, {} no longer there", @@ -499,12 +497,13 @@ const EN: Record = { aboutSponsorSection: "Support LinkUnbound", aboutRepo: "Open the repository", aboutTisty: "Tisty", - aboutTistyNote: - "Notes, documents and tasks on your own machine. Same idea: no ads, no telemetry, all local.", + aboutTistyNote: "Notes, documents and tasks, in files you can read without it.", aboutProject: "Project", aboutSource: "Source code", aboutSourceNote: "GPL-3.0 · contributions are welcome", aboutIssue: "Report a problem", + aboutPrivacyLink: "Privacy", + aboutNotices: "Third-party notices", aboutIssueNote: "Bugs, ideas and browsers it fails to detect", aboutSponsor: "Buy me a coffee", aboutSponsorNote: @@ -513,8 +512,7 @@ const EN: Record = { aboutLicenseNote: "Free software: you can read it, change it and share it", aboutOtherTools: "Other tools", aboutCopyPaste: "CopyPaste", - aboutCopyPasteNote: - "A free, open source clipboard manager for Windows, macOS and Linux. Same idea: no ads, no telemetry, all local.", + aboutCopyPasteNote: "Brings back what you copied ten minutes ago, or yesterday.", open: "Open", save: "Save", diff --git a/app/src/index.css b/app/src/index.css index e9a4ae3..d6c4cf0 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -50,3 +50,393 @@ body { .dark .scroller:hover { scrollbar-color: rgb(255 255 255 / 0.2) transparent; } + +:root { + --panel: #ffffff; + --card: #fbfbfc; + --tile: #f1f2f6; + --hair: rgb(20 24 48 / 0.08); + --ink: #16181d; + --muted: #6b6d7a; + --accent: #2f62d8; + --accent-dim: #4f7ce4; + --accent-soft: rgb(47 98 216 / 0.09); + --danger: #c0362f; + --danger-soft: rgb(192 54 47 / 0.1); + --good: #1e7a52; +} + +.dark { + --panel: #191a1f; + --card: #1e2027; + --tile: #262a33; + --hair: rgb(255 255 255 / 0.08); + --ink: #f0f1f4; + --muted: #8b92a1; + --accent: #6e9bff; + --accent-dim: #4f7ce4; + --accent-soft: rgb(110 155 255 / 0.12); + --danger: #ff8a85; + --danger-soft: rgb(255 138 133 / 0.12); + --good: #4cc38a; +} + +.brow h1 { + margin: 0; + font-size: 20px; + font-weight: 640; + letter-spacing: -0.015em; +} + +.brow .line2 { + margin-top: 2px; + display: flex; + align-items: center; + gap: 8px; + font-size: 11.5px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.brow .line2 i { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--muted); +} + +.what-is { + margin-top: 15px; + border: 1px solid var(--hair); + border-radius: 11px; + background: var(--card); + padding: 12px 14px; +} + +.what-is p { + margin: 0; + font-size: 12.5px; + line-height: 1.55; + color: var(--ink); +} + +.what-is p + p { + margin-top: 4px; + color: var(--muted); +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + border: 1px solid var(--hair); + border-radius: 7px; + padding: 3px 9px; + font-size: 11px; + color: var(--muted); +} + +.badge svg { + width: 11px; + height: 11px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.rule { + margin: 24px 0 10px; + border-top: 1px solid var(--hair); + padding-top: 17px; + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); +} + +.quiet { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: var(--muted); +} + +.gives { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; + margin-top: 10px; +} + +.give { + display: flex; + align-items: center; + gap: 10px; + text-align: left; + border: 1px solid var(--hair); + border-radius: 11px; + background: var(--card); + padding: 10px 12px; + cursor: pointer; + font: inherit; + color: var(--ink); +} + +.give.wide { + grid-column: span 2; +} + +.give:hover { + background: var(--tile); +} + +.give svg { + width: 17px; + height: 17px; + flex: none; +} + +.give b { + display: block; + font-size: 12.5px; + font-weight: 560; +} + +.give span span { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11.5px; + color: var(--muted); +} + +.tool { + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + text-align: left; + border: 1px solid var(--hair); + border-radius: 11px; + background: transparent; + padding: 10px 12px; + margin-bottom: 7px; + cursor: pointer; + font: inherit; + color: var(--ink); +} + +.tool:hover { + background: var(--tile); +} + +.tool .ico { + width: 24px; + height: 24px; + flex: none; + display: grid; + place-items: center; + border-radius: 7px; + color: #ffffff; + font-size: 12px; + font-weight: 700; +} + +.tool b { + display: block; + font-size: 13px; + font-weight: 600; +} + +.tool span span { + display: block; + margin-top: 2px; + font-size: 11.5px; + line-height: 1.5; + color: var(--muted); +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 20px; + border-top: 1px solid var(--hair); + padding-top: 11px; +} + +.links button { + border: 0; + background: transparent; + border-radius: 6px; + padding: 6px 10px; + cursor: pointer; + font: inherit; + font-size: 11.5px; + color: var(--muted); +} + +.links button:hover { + background: var(--tile); + color: var(--ink); +} + +.newer { + margin-top: 17px; + display: flex; + align-items: center; + gap: 11px; + border: 1px solid var(--hair); + border-radius: 11px; + background: var(--card); + padding: 11px 13px; +} + +.newer.there { + border-color: var(--accent-soft); + background: var(--accent-soft); +} + +.newer .grow { + flex: 1; + min-width: 0; +} + +.newer b { + display: block; + font-size: 13px; + font-weight: 600; +} + +.newer .grow > span { + display: block; + margin-top: 1px; + font-size: 12.5px; + color: var(--muted); +} + +.pip { + width: 8px; + height: 8px; + flex: none; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.pip.ok { + background: var(--good); + box-shadow: 0 0 0 3px rgb(30 122 82 / 0.12); +} + +.dark .pip.ok { + box-shadow: 0 0 0 3px rgb(76 195 138 / 0.14); +} + +.mild { + flex: none; + border: 1px solid var(--hair); + border-radius: 7px; + background: transparent; + padding: 6px 12px; + cursor: pointer; + font: inherit; + font-size: 11.5px; + color: var(--ink); +} + +.mild:hover:not(:disabled) { + background: var(--tile); +} + +.mild:disabled { + opacity: 0.4; +} + +.strong { + flex: none; + border: 0; + border-radius: 7px; + background: var(--accent); + padding: 6px 12px; + cursor: pointer; + font: inherit; + font-size: 11.5px; + font-weight: 560; + color: var(--panel); +} + +.strong:disabled { + opacity: 0.5; +} + +.bar { + margin-top: 6px; + display: block; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: var(--hair); +} + +.bar span { + display: block; + height: 100%; + border-radius: 999px; + background: var(--accent); + transition: width 0.2s; +} + +.alarm { + margin: 0; + border-radius: 8px; + background: var(--danger-soft); + padding: 8px 12px; + font-size: 11.5px; + color: var(--danger); +} + +.trouble { + margin: 0 0 10px; + font-size: 12.5px; + line-height: 1.55; + color: var(--muted); +} + +.trouble b { + font-weight: 620; + color: var(--ink); +} + +.saved { + margin: 0; + border-radius: 8px; + background: rgb(30 122 82 / 0.1); + padding: 8px 12px; + font-size: 11.5px; + word-break: break-all; + color: var(--good); +} + +.dark .saved { + background: rgb(76 195 138 / 0.12); +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/app/src/settings/About.test.tsx b/app/src/settings/About.test.tsx index 3084d71..d30047d 100644 --- a/app/src/settings/About.test.tsx +++ b/app/src/settings/About.test.tsx @@ -353,4 +353,29 @@ describe("about", () => { cleanup(); expect(screen.queryByText(/GPL-3.0/)).toBeNull(); }); + + /// The report is meant to be pasted into a public issue, so the screen has to + /// promise what the core actually redacts. + it("saves a report and says where it landed", async () => { + answers({ + maintenance_report: () => Promise.resolve("C:/Users/x/Desktop/linkunbound-diagnostico.md"), + }); + show(); + await userEvent.click(await screen.findByRole("button", { name: "Guardar informe…" })); + expect(invoke).toHaveBeenCalledWith("maintenance_report"); + expect(await screen.findByText(/linkunbound-diagnostico\.md/)).toBeInTheDocument(); + }); + + it("saves the report without asking, since it destroys nothing", async () => { + show(); + await userEvent.click(await screen.findByRole("button", { name: "Guardar informe…" })); + expect(screen.queryByRole("alertdialog")).toBeNull(); + }); + + it("says a refusal instead of leaving the report looking saved", async () => { + answers({ maintenance_report: () => Promise.reject("the folder refused the write") }); + show(); + await userEvent.click(await screen.findByRole("button", { name: "Guardar informe…" })); + expect(await screen.findByText(/folder refused/)).toBeInTheDocument(); + }); }); diff --git a/app/src/settings/About.tsx b/app/src/settings/About.tsx index d778a4c..464dfaf 100644 --- a/app/src/settings/About.tsx +++ b/app/src/settings/About.tsx @@ -3,6 +3,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { useCallback, useEffect, useState } from "react"; import { type Key, useSpoken, useWords } from "../i18n"; import { offerMoved, saidPlainly } from "../refusal"; +import { CloudOff, Code, Gift, Key as KeyIcon } from "./Icons"; import { Card, Line, Switch } from "./parts"; import Star from "./Star"; import type { Ready, Underway } from "./update"; @@ -13,6 +14,7 @@ const COPYPASTE = "https://github.com/rgdevment/CopyPaste"; const TISTY = "https://github.com/rgdevment/Tisty"; const SPONSOR = "https://github.com/sponsors/rgdevment"; const RATING = "ms-windows-store://review/?ProductId=9N9F7C8Q43KC"; +const ALTERNATIVETO = "https://alternativeto.net/software/linkunbound/"; type Build = { version: string; @@ -33,28 +35,20 @@ function howItInstalls(newer: Ready): Key { function External({ href, children }: { href: string; children: string }) { return ( - ); } function Rule({ said }: { said: string }) { - return ( -

- {said} - -
- ); + return
{said}
; } -function Badge({ said }: { said: string }) { +function Badge({ said, children }: { said: string; children: React.ReactNode }) { return ( - + + {children} {said} ); @@ -74,21 +68,13 @@ function Gives({ children: React.ReactNode; }) { return ( - ); @@ -98,51 +84,32 @@ function Gives({ /// project, and a missing file here would leave a broken image in a row that is only a pointer. function Tool({ mark, - tone, + hue, said, note, onPick, }: { mark: string; - tone: string; + hue: string; said: string; note: string; onPick: () => void; }) { return ( - ); } function Pip({ ok }: { ok?: boolean }) { - return ( -