diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..0fe1b59 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,93 @@ +name: Checks + +# Compile and test on every desktop platform, on every push and PR. +# +# build.yml already compiles all three, but only as part of a release-profile +# installer build with llama.cpp and bundling behind it — so a Windows-only +# compile error surfaces late and expensive. This job is dev-profile, no bundle, +# and exists purely to answer "does it build and pass everywhere" quickly. +# +# It earns its keep on the per-platform FFI in src/content_blocking/ and +# src/browsing_data/, where each platform is a separate implementation against a +# different native API and two of the three cannot be compiled on a Mac. +on: + workflow_dispatch: + pull_request: + push: + branches: + - '**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_HTTP_MULTIPLEXING: 'false' + CARGO_NET_RETRY: '10' + CARGO_TERM_COLOR: always + +jobs: + web: + name: Lint and typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun run typecheck:web + + rust: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + # Never cancel the other two on one platform's failure: when the platform + # implementations diverge, knowing which ones broke is the whole point. + fail-fast: false + matrix: + include: + - name: macOS + os: macos-latest + - name: Linux + os: ubuntu-latest + - name: Windows + os: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install Linux build dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ + librsvg2-dev libssl-dev pkg-config + + - name: Set libclang path + if: matrix.os == 'windows-latest' + shell: bash + run: echo 'LIBCLANG_PATH=C:\Program Files\LLVM\bin' >> "$GITHUB_ENV" + + # clippy compiles everything cargo check would, so it stands in for both. + - name: cargo clippy + working-directory: src-tauri + run: cargo clippy --all-targets -- -D warnings + + - name: cargo test + working-directory: src-tauri + run: cargo test --lib + + # Only WebKit can say whether the rule file compiles, and a rejected list + # disables blocking silently at runtime. The unit tests check the rules + # against what the documentation claims; this checks them against WebKit. + - name: Verify content blocking rules compile + if: matrix.os == 'macos-latest' + working-directory: src-tauri + run: cargo run --example verify_content_rules diff --git a/.gitignore b/.gitignore index 29c7485..17e8539 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ src-tauri/gen/* src-tauri/gen/schemas/ src-tauri/target src-tauri/target-linux-* +# scripts/check-platforms.sh +src-tauri/target-platform-check +src-tauri/target-platform-check-windows aether-models/ .DS_Store .eslintcache diff --git a/.planning/debug/resolved/ice-interaction-crystallization.md b/.planning/debug/resolved/ice-interaction-crystallization.md index dc4b5df..4c991ca 100644 --- a/.planning/debug/resolved/ice-interaction-crystallization.md +++ b/.planning/debug/resolved/ice-interaction-crystallization.md @@ -1,6 +1,6 @@ --- status: resolved -trigger: "iCE card clicks teleport cards to the top-left instead of centering with a slight zoom; Ordered Topics centers without zoom; crystallization intermittently fails for Quantum; percentage labels and Open in Library should be removed." +trigger: 'iCE card clicks teleport cards to the top-left instead of centering with a slight zoom; Ordered Topics centers without zoom; crystallization intermittently fails for Quantum; percentage labels and Open in Library should be removed.' created: 2026-07-25T20:55:39+0200 updated: 2026-07-25T21:38:00+0200 --- diff --git a/README.md b/README.md index f34a586..c436335 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,12 @@ Download the latest build for your platform from **[Releases](https://github.com/CanPixel/aether/releases/latest)**: -| Platform | File | -|---|---| -| macOS (Apple Silicon, 11+) | `AETHER_macOS.dmg` | -| Windows (x86_64) | `AETHER_x64-setup.exe` | -| Linux (x86_64) | `AETHER_amd64.deb` · `AETHER_amd64.AppImage` | -| Linux (ARM64) | `AETHER_arm64.deb` | +| Platform | File | +| -------------------------- | -------------------------------------------- | +| macOS (Apple Silicon, 11+) | `AETHER_macOS.dmg` | +| Windows (x86_64) | `AETHER_x64-setup.exe` | +| Linux (x86_64) | `AETHER_amd64.deb` · `AETHER_amd64.AppImage` | +| Linux (ARM64) | `AETHER_arm64.deb` | > [!NOTE] > **Intel Macs are not supported.** Releases are built `arm64` only, and Rosetta @@ -58,17 +58,17 @@ Download the latest build for your platform from > is in [docs/SIGNING.md](docs/SIGNING.md). **macOS.** The `.dmg` is unsigned and un-notarized, so macOS quarantines it and -reports *"ÆTHER is damaged and can't be opened"*. It is not damaged. Drag the app to +reports _"ÆTHER is damaged and can't be opened"_. It is not damaged. Drag the app to `/Applications`, then clear the quarantine flag: ```bash xattr -dr com.apple.quarantine /Applications/ÆTHER.app ``` -Then open it normally. (Right-click → *Open* alone does not work for un-notarized +Then open it normally. (Right-click → _Open_ alone does not work for un-notarized apps on current macOS.) -**Windows.** SmartScreen shows *"Windows protected your PC"*. Click **More info**, +**Windows.** SmartScreen shows _"Windows protected your PC"_. Click **More info**, then **Run anyway**. **Linux.** No workaround needed. @@ -134,11 +134,11 @@ The privacy boundary applies to ÆTHER's indexing and intelligence pipeline, not Fresh installs use **AiON Launch**, the in-app setup flow for downloading local models into the app-data model directory. The same setup flow is available later from Settings for repair or manual installation. -| Model | Role | Official source | Size | -| ------------- | ------------------------------------------------------------- | ------------------------------------- | -------: | -| **AiON MiST** | Required embedding model for search, capture, and retrieval | `Qwen/Qwen3-Embedding-0.6B-GGUF` | ~0.64 GB | -| **AiON LiTE** | Optional chat model for everyday answers and summaries | `google/gemma-4-E2B-it-qat-q4_0-gguf` | ~3.35 GB | -| **AiON WiSE** | Optional chat model for richer synthesis and iCE maps | `google/gemma-4-E4B-it-qat-q4_0-gguf` | ~5.15 GB | +| Model | Role | Official source | Size | +| ------------- | ----------------------------------------------------------- | ------------------------------------- | -------: | +| **AiON MiST** | Required embedding model for search, capture, and retrieval | `Qwen/Qwen3-Embedding-0.6B-GGUF` | ~0.64 GB | +| **AiON LiTE** | Optional chat model for everyday answers and summaries | `google/gemma-4-E2B-it-qat-q4_0-gguf` | ~3.35 GB | +| **AiON WiSE** | Optional chat model for richer synthesis and iCE maps | `google/gemma-4-E4B-it-qat-q4_0-gguf` | ~5.15 GB | Install choices: diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 458e069..7e04542 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -13,6 +13,7 @@ All three models Æther uses are licensed under the **Apache License, Version 2.0** (full text at the bottom of this file). ### Gemma 4 — E2B and E4B (chat / generation) + - Publisher: Google DeepMind - Project: https://ai.google.dev/gemma - License: Apache License 2.0 @@ -23,6 +24,7 @@ All three models Æther uses are licensed under the **Apache License, Version file here if one is present in the release you ship. ### Qwen3-Embedding-0.6B (text embeddings) + - Publisher: Qwen Team, Alibaba Group - Project: https://huggingface.co/Qwen/Qwen3-Embedding-0.6B - License: Apache License 2.0 @@ -38,15 +40,15 @@ All three models Æther uses are licensed under the **Apache License, Version The desktop application links or bundles open-source libraries, including (non-exhaustive): -| Component | License | -|---|---| -| llama.cpp / ggml | MIT | -| Tauri (and tauri-plugin-opener) | MIT OR Apache-2.0 | -| candle, candle-nn, candle-transformers | MIT OR Apache-2.0 | -| tokenizers | Apache-2.0 | +| Component | License | +| -------------------------------------------------------------------------- | ----------------- | +| llama.cpp / ggml | MIT | +| Tauri (and tauri-plugin-opener) | MIT OR Apache-2.0 | +| candle, candle-nn, candle-transformers | MIT OR Apache-2.0 | +| tokenizers | Apache-2.0 | | reqwest, serde, serde_json, tokio, url, uuid, scraper, chrono, encoding_rs | MIT OR Apache-2.0 | -| framer-motion | MIT | -| lucide-react | ISC | +| framer-motion | MIT | +| lucide-react | ISC | This table is a summary. Regenerate the authoritative, complete list with full license texts from the dependency tree: diff --git a/docs/LICENSING.md b/docs/LICENSING.md index 99b74f4..c702993 100644 --- a/docs/LICENSING.md +++ b/docs/LICENSING.md @@ -12,8 +12,8 @@ These are true today and worth fixing **whatever licence is chosen**. ### 1. Even local modification is not permitted -PolyForm Strict grants everything *"other than distributing the software **or making -changes or new works based on the software**."* +PolyForm Strict grants everything _"other than distributing the software **or making +changes or new works based on the software**."_ That second clause is stricter than it usually reads. It means: @@ -85,7 +85,7 @@ it ships. Real OSI open source, with a commercial licence sold to anyone who cannot comply. -- Weak here. The copyleft trigger is *conveying* or *network use*; a local desktop +- Weak here. The copyleft trigger is _conveying_ or _network use_; a local desktop app with no server rarely trips either, so the commercial pressure that makes dual-licensing work mostly is not there. - A competitor could fork commercially provided they publish source. @@ -108,8 +108,8 @@ contact. ## A correction to the audit that prompted this -The audit said the current setup has *"the costs of proprietary and the revenue of -open source."* That is unfair as written. PolyForm Strict **does** establish the +The audit said the current setup has _"the costs of proprietary and the revenue of +open source."_ That is unfair as written. PolyForm Strict **does** establish the legal basis for a commercial story — every commercial right is retained. What is missing is everything on the other side of it: no price, no tier, no contact. Gap 3 above is the real finding; the licence family is a secondary question. diff --git a/docs/PRINCIPLES.md b/docs/PRINCIPLES.md new file mode 100644 index 0000000..952cd75 --- /dev/null +++ b/docs/PRINCIPLES.md @@ -0,0 +1,161 @@ +# Principles + +ÆTHER is a research browser that runs its intelligence on your own machine. + +This document is the position behind that sentence. Every principle below names the +mechanism that makes it true, because a principle you cannot point at in the source +is a slogan. The last section names what ÆTHER does **not** do — that section is +load-bearing, not a disclaimer. + +--- + +## 1. Nothing happens until you ask + +Capture is a button. Answering is a button. Model downloads are a choice with the +size printed on it. There is no background indexing, no silent sync, no +"improving your experience". + +Opt-in is not a settings page here — it is the shape of the app. The only work that +happens is work you started. + +## 2. Your machine is the whole stack + +Embeddings and answers run locally through llama.cpp on weights sitting in your own +filesystem. Once the models are on disk, the intelligence works with the network +cable unplugged. + +Not "private cloud". Not "we don't train on your data". **There is no server.** + +## 3. The AI serves your reading — it does not replace it + +AiON answers from pages _you_ chose to keep, and cites them. Every claim is one +click from the source you already judged worth saving. + +An assistant that reads for you makes you dependent. An assistant that reads _with +you_ makes you faster. We build the second one. + +## 4. It only ever reads what you opened + +No crawler. No link-following. No frontier queue. No prefetching pages you might +want. ÆTHER fetches exactly the URLs you point it at, and stops. + +Your library grows by your judgement, not by a scraper's appetite. + +## 5. The web survives if people visit it + +AI summaries take an author's work and return nothing — no visit, no reader, no +reason to keep publishing. That trade ends with an empty web, and then with empty +summaries. + +So ÆTHER asks search engines for results without AI answers, on by default, using +each engine's own documented opt-out. It sends you **to** the source rather than +around it. Every principle here that helps a researcher also pays the person who +wrote the page. + +## 6. Depth is a feature, not a delay + +The iCE explorer maps a subject from its surface to its specialist layers — not to +answer your question, but to show you which questions exist. + +Instant answers flatten a topic into one paragraph. Research is the part where you +find out how much you didn't know. ÆTHER is built to make that part rewarding +instead of tedious. + +## 7. Memory, never surveillance + +The index exists to serve the person who built it. That is the whole test, and it +decides real design details: favicons are cached in memory only and thrown away when +you quit, because a favicon cache written to disk is a list of every site you +visited under another name. + +## 8. Your data is yours in the boring, literal sense + +Plain files in a folder you can open, back up, and export whole. No account, no +sign-in, no proprietary vault. Nothing is held hostage to a subscription, because +there is no subscription. + +You can leave and take everything. That is the only version of data ownership that +means anything. + +## 9. There is nothing pointed at you to degrade + +Enshittification needs a mechanism: ads to insert, engagement to farm, a free tier +to squeeze, telemetry to justify it. ÆTHER has none of them and never sends +analytics, crash reports, or usage data anywhere. + +**It cannot get worse for you to make it better for someone else. There is no +someone else.** + +## 10. Trackers die before the request leaves + +On WebKit, blocked requests are refused inside the network path — a tracker learns +nothing, not even that something was attempted. Third-party cookies are blocked +there too — on Windows they are not, and the app says so rather than implying +otherwise. Click identifiers are stripped from URLs on navigation _and_ on capture, so an ad +attribution never gets a permanent home in your library. + +## 11. We say what we don't do + +The app reports its own coverage per platform rather than claiming a uniform story: +where third-party cookie blocking is unavailable, the Settings screen says so; where +a search engine offers no AI opt-out, it says that too, naming the engine. + +A privacy claim that quietly stops being true is worse than one never made. Honesty +here is a feature with tests behind it. + +--- + +## Lines you can lift + +> There is no server. + +> No cloud. No account. No subscription. No telemetry. Nothing to enshittify. + +> The AI reads what you chose. Nothing else. + +> Every claim is one click from its source. + +> We send you to the source, not around it. + +> The web survives if people visit it. + +> Opt-in by architecture, not by checkbox. + +> A browser with a memory — and the memory is yours. + +> It cannot degrade. There's nothing pointed at you. + +> Research is the part where you find out how much you didn't know. + +--- + +## What ÆTHER does not claim + +Every item here is deliberate. A document about integrity that overstates its case +refutes itself. + +- **It is not anonymity.** Tabs are ordinary system webviews. Sites see your TLS + fingerprint and the usual canvas, WebGL, font and timezone fingerprinting + surface. ÆTHER does not defend against any of that and cannot without shipping + its own engine. You can route traffic through a proxy such as Tor, which hides + your IP address — but an unchanged fingerprint still links your sessions to each + other, and a proxy does not fix that. If you need anonymity, you want Tor + Browser. +- **Protection is not uniform across platforms.** Third-party cookie blocking is + unavailable on Windows. Storage partitioning is opt-in and macOS 14+ only. The app + tells you which you have. +- **The blocklist is curated and small**, not a full filter list. It covers the large + ad and analytics networks and will miss a long tail. +- **A local model is not a better model.** It is a model that cannot leak. Answers + can still be wrong — which is exactly why they are built from sources you can open + and check. +- **AI-free search depends on the engines.** These are documented opt-outs, not + guarantees; an engine can withdraw one without notice, and that failure is silent. +- **It is not open source.** The source is public and auditable, but ÆTHER ships + under PolyForm Strict 1.0.0, which permits neither redistribution nor + modification — including patching your own copy. See + [LICENSING.md](LICENSING.md), where this is an acknowledged open question. +- **Releases are unsigned.** See [SIGNING.md](SIGNING.md). + +For the full technical account, including the complete list of known gaps, see +[SECURITY.md](SECURITY.md). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index ad61772..b8ba251 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -7,11 +7,11 @@ Not a policy document — a record of the decisions that are easy to undo by acc ÆTHER runs visited pages in **child webviews**, separate from the window that hosts the app's own UI. That split is the main boundary: -| | Privileged window (`main`) | Child webviews (tabs) | -|---|---|---| -| Content | ÆTHER's own bundled UI | arbitrary web pages | -| IPC bridge | yes — all Tauri commands | no | -| CSP | `app.security.csp` (below) | the site's own | +| | Privileged window (`main`) | Child webviews (tabs) | +| ---------- | -------------------------- | --------------------- | +| Content | ÆTHER's own bundled UI | arbitrary web pages | +| IPC bridge | yes — all Tauri commands | no | +| CSP | `app.security.csp` (below) | the site's own | A page cannot reach the command bridge, because it is not in the context that has one. This is why an aggressive CSP on the privileged window costs page @@ -24,42 +24,376 @@ Lives in `src-tauri/tauri.conf.json` under `app.security.csp`, with a looser `127.0.0.1:1420`). Tauri injects it at load. **Deliberately not also a `` tag in `index.html`.** It used to be. Two -policies are *intersected* by the engine, so with both in place a tightening in +policies are _intersected_ by the engine, so with both in place a tightening in either silently overrides the other and the pair drifts apart. One source of truth. Why each directive is what it is: -| Directive | Value | Reason | -|---|---|---| -| `default-src` | `'self'` | Nothing loads from anywhere else unless listed below. | -| `script-src` | `'self'` | One bundled module script. No inline, no `eval`. | -| `style-src` | `'self' 'unsafe-inline'` | The UI uses React `style` attributes throughout. This permits inline *style*, not inline script. | -| `img-src` | `'self' data: blob: https: http:` | See favicons below. `data:`/`blob:` are tab thumbnails. | -| `connect-src` | `'self' ipc: http://ipc.localhost` | Tauri's IPC transport. Removing these breaks every command. | -| `object-src` | `'none'` | No plugins, ever. | -| `base-uri` | `'self'` | Stops injected markup repointing relative URLs. | -| `form-action` | `'none'` | The UI has no server to post to. | -| `frame-ancestors` | `'none'` | Nothing may embed the privileged window. | - -**`img-src` allows any host, and that is a real hole.** Tab favicons are fetched -straight from `https:///favicon.ico` by an `` in the privileged window -(`favicon_for_url` in `src-tauri/src/util.rs`). Narrowing this needs favicons -proxied through Rust and cached locally, which would also stop the privileged -window making any outbound request at all. Worth doing; not done. +| Directive | Value | Reason | +| ----------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ | +| `default-src` | `'self'` | Nothing loads from anywhere else unless listed below. | +| `script-src` | `'self'` | One bundled module script. No inline, no `eval`. | +| `style-src` | `'self' 'unsafe-inline'` | The UI uses React `style` attributes throughout. This permits inline _style_, not inline script. | +| `img-src` | `'self' data: blob:` | Favicons arrive as `data:` URIs from Rust; `blob:` is tab thumbnails. See below. | +| `connect-src` | `'self' ipc: http://ipc.localhost` | Tauri's IPC transport. Removing these breaks every command. | +| `object-src` | `'none'` | No plugins, ever. | +| `base-uri` | `'self'` | Stops injected markup repointing relative URLs. | +| `form-action` | `'none'` | The UI has no server to post to. | +| `frame-ancestors` | `'none'` | Nothing may embed the privileged window. | + +**`img-src` was open to any host; it is now `'self' data: blob:`.** Tab favicons +used to be fetched straight from `https:///favicon.ico` by an `` in the +privileged window, which is what forced `https:`/`http:` into the policy. They now +go through `aether_browser_favicon` (`src-tauri/src/favicon.rs`), which fetches on +the shared reqwest client and hands back a `data:` URI. + +The privileged window therefore makes **no outbound request at all**. The favicon +URL is still stored on tabs and hub shortcuts, but only as a cache key — never as +an ``. The cache is in memory for the session and deliberately not on +disk: a favicon cache is a list of visited hosts under another name. ## What the app sends anywhere -Outbound requests, all from Rust except where noted: +Outbound requests, all from Rust: - **Hugging Face** — only while downloading a model the user chose. - **GitHub Releases API** — the update check, if enabled in Settings. - **The update endpoint** — only when the user presses Install Update. - **Pages the user visits** — in child webviews, as any browser. -- **Favicons** — from the privileged window, per the above. +- **Favicons** — one request per host per session, from `favicon.rs`. No analytics, no crash reporting, no phone-home. Captured text, embeddings, answers, and iCE atlases never leave the machine. +When the proxy is on, **every one of those requests goes through it**, model +downloads included. See [Proxy](#proxy) for why nothing is exempted. + +## What visited sites can see + +The honest boundary, because "local AI" and "anonymous browsing" are different +claims and only the first is ours. + +Tabs are ordinary system webviews (WKWebView, WebView2, WebKitGTK). Sites see the +real TLS fingerprint, cookies, and the usual canvas, WebGL, font and timezone +fingerprinting surface. **ÆTHER does not defend against any of that, and cannot +without patching an engine it does not ship.** Anyone who needs anonymity wants +Tor Browser, not this. + +The IP address is the one exception, and only when the [proxy](#proxy) is +switched on. That changes _where_ a site thinks you are; it does not change how +recognisable you are once you get there. The two are independent, and a proxy +without fingerprint defences still leaves every visit joinable to every other. + +What is defended: + +| Defence | Where | +| ------------------------------------------------------- | -------------------------------------------------- | +| Tracker and ad requests blocked before they are sent | macOS, Linux, Windows — `src/content_blocking/` | +| Third-party cookies blocked | macOS, Linux, Android — **not Windows**, see below | +| Private tabs (ephemeral store, no capture, no session) | `.incognito()`, `src-tauri/src/webview.rs` | +| Container tabs (isolated persistent storage) | macOS 14+ only — `data_store_identifier` | +| Clear cookies, caches and site storage | macOS, Linux, Windows — `src/browsing_data/` | +| One User-Agent per platform, consistent with the engine | `BROWSER_USER_AGENT`, `src-tauri/src/lib.rs` | +| Click identifiers stripped on navigation and on capture | `strip_tracking_params`, `src-tauri/src/util.rs` | +| Favicons never fetched from the privileged window | `src-tauri/src/favicon.rs` | +| Default search engine that does not build a profile | `search_engine_prefix`, `src-tauri/src/util.rs` | +| AI-generated answers declined where the engine allows | `search_url`, `src-tauri/src/util.rs` | +| IP address hidden behind a SOCKS5/HTTP proxy, opt-in | macOS 14+, Linux, Windows — **not Android** | + +### AI-free search + +On by default, one toggle in Settings to turn off. Every search the app builds — +the address bar, a bare query typed into it, and an iCE card's "Explore in Web" — +goes through `search_url`, so none of them can disagree about it. + +This is a veracity and consent measure rather than a privacy one. AI answers are +inserted above the results the user asked for, by a mechanism they did not opt into, +and they are the part of a results page least likely to be checkable against a +source ÆTHER could capture. + +Four unrelated mechanisms, because the engines share nothing here: + +| Engine | Mechanism | Kind | +| ---------- | -------------------------------------------------------------- | -------------- | +| Google | `&udm=14` — the "Web" vertical, plain links, no AI Overview | URL parameter | +| Bing | `-ai` appended to the query — a real operator, added June 2026 | query operator | +| DuckDuckGo | `noai.duckduckgo.com`, DDG's own AI-free host | alternate host | +| Yahoo | none | — | +| Ecosia | none reachable from a URL | — | + +**Google does not get `-ai`, and this is the trap worth stating plainly.** `-ai` is +Microsoft's operator; on Google it is an ordinary negative keyword, so it would drop +every result containing "ai" — precisely the results an iCE concept like "neural +network" or "transformer" needs. Google's mechanism is `udm=14`, which changes the +result vertical and not the query's meaning. There is a unit test asserting that +`-ai` never reaches a Google URL. + +**Two engines can't honour the setting at all.** Yahoo serves Bing's results with no +control of its own, and Ecosia's opt-out is an account setting that is also gated by +region — neither can be asked for from a URL. Nothing is appended for them, because +an invented parameter can change how an engine parses the rest of the query. The +Settings screen says so for the selected engine rather than implying the toggle did +something: see `ai_free_search_status`, which derives its wording from the same +table `search_url` uses, so the two cannot drift apart. + +### Content blocking + +Three implementations, one rule file +(`src-tauri/resources/content-blocking-rules.json`): + +| Platform | Mechanism | Equivalent? | +| -------- | ------------------------------- | ------------------------------------ | +| macOS | `WKContentRuleList` | reference implementation | +| Linux | `WebKitUserContentFilterStore` | yes — **same JSON**, shared verbatim | +| Windows | `WebResourceRequested` callback | no — see below | + +On WebKit the rules are evaluated inside the network path, so a blocked request +is never made: a tracker learns nothing, not even that something was attempted. + +**Windows is not equivalent, and the gap is not cosmetic.** WebView2 has no +rule-list concept, so blocking there is a per-request callback matching the +request host against `blocked_hosts()`, derived from the same file so the domains +cannot drift. Two consequences: every request crosses the COM boundary, and +**third-party cookies are not blocked** — `block-cookies` has no WebView2 +equivalent, so a tracker not on the host list still sets them. Windows also only +approximates "third-party" by comparing against the top-level document's host. + +**Linux must go through `webkit2gtk`'s re-exports** (`webkit2gtk::glib`, `::gio`, +`::ffi`), never separate `glib`/`gio` dependencies. Declaring those directly +resolves a second copy of each into the graph, and a `GBytes` built from one then +fails to satisfy `ToGlibPtr` for the other — same name, different type. This cost +a build; the Cargo.toml comment is there to stop it happening twice. + +**Two traps, both of which cost a debugging session:** + +1. **`url-filter` does not support alternation.** `(com|net)` fails with + "Disjunctions are not supported yet" — and one bad filter rejects the _entire_ + list, so a single careless rule silently disables all blocking at runtime. A + unit test guards against `|`; split the domains into separate rules instead. +2. **The rule objects take exactly `trigger` and `action`.** An unknown key — + including a `_comment` — rejects the list. That is why the rules are + documented here rather than inline. + +Neither failure is visible without looking, so after touching the rules run: + + cargo run --example verify_content_rules + +which compiles them through WebKit itself and exits non-zero if WebKit disagrees. +The unit tests only check the shape against what the documentation claims. + +Every blocking rule is scoped to `third-party` loads. A first-party block would +break the site the user actually asked for. + +### Proxy + +Off by default. When on, tabs and the app's own fetches both route through one +SOCKS5 or HTTP CONNECT endpoint, prefilled with Tor's `socks5://127.0.0.1:9050`. + +**It covers everything, deliberately.** Sending multi-gigabyte model downloads +over Tor is slow and a poor use of the network, and exempting them was the +obvious alternative. It was rejected: a silent exemption is the same class of bug +as a leaking favicon fetch — traffic the user believes is proxied that quietly +is not. A slow or refused download is a failure the user can see and act on. + +Two details do real work: + +- **One source of routing.** `util::active_proxy_url` decides, `Backend::network` + holds the answer, and both the webview builder and the reqwest client read it + from there. Tabs and favicon fetches cannot diverge, and a divergence is exactly + the correlation leak the feature exists to close: one favicon request per + visited origin, from the real IP, would undo the whole thing. +- **`socks5` becomes `socks5h` for reqwest.** Under plain `socks5`, reqwest + resolves hostnames locally and the network operator still sees a DNS query for + every host — the address hidden, the destination not. `socks5h` hands the name + to the proxy. The rewrite is internal because Tauri's proxy parser accepts only + `socks5` and would reject `socks5h` when a tab is created. + +Accepted schemes are exactly `http` and `socks5`, matching Tauri's own parser. +`https` and `socks5h` are refused at the Settings screen rather than at the first +tab, which is where the failure would otherwise land. + +Limits worth stating: + +- **macOS 14+.** wry sets `proxyConfigurations` on the data store through KVC with + no version check of its own; the key does not exist on macOS 13, and + `setValue:forKey:` against a missing key raises rather than degrades. ÆTHER + supports back to 10.15, so `util::proxy_platform_support` gates it and Settings + reports the reason. **Not available on Android at all** — wry has no support. +- **Open tabs keep their old routing.** A webview's proxy is fixed when it is + built, so toggling this affects tabs opened afterwards. The UI says so. +- **It is not anonymity.** Same fingerprint, same TLS handshake, same cookies. A + proxy changes where a site thinks you are, not whether it recognises you — and + using Tor with a unique fingerprint can be worse than not using it, because the + fingerprint links sessions the exit node was supposed to separate. + +### Private tabs + +`WebviewBuilder::incognito(true)`, which wry maps to a non-persistent +`WKWebsiteDataStore` on macOS and an ephemeral `WebContext` on Linux. Windows +needs WebView2 runtime 101+ and silently does nothing on older ones. + +Because that last case fails open, the engine is not the only defence: a private +tab is **never written to the session file** (`persist_session_tabs`). + +**Capture is not gated, and the reasoning matters.** It used to be refused +outright, on the argument that a private tab promises to leave no trace and a +capture is the most durable trace the app makes. That conflates the two halves of +browser privacy. One is _outward_ — the IP, fingerprint, cookies and referrers a +site can read, which is where being recognised actually happens. The other is +_inward_ — what persists on your own disk. Capture is purely inward: on desktop it +reads the DOM already in memory (`extract_readable_page_from_webview`, with a +re-fetch only as fallback), so in the normal path it makes no network request at +all. Nothing is emitted, nothing is asserted, nobody is told anything. + +What remains is a local write, and a local index of what you read is what this +app is for. Pressing Capture is the decision — the same way saving a bookmark or +a download from a private window is the decision, neither of which any browser +prompts about. A confirmation step there was ceremony over a choice already made, +and confirmation dialogs people click through reflexively devalue the ones that +carry information. + +The stored record still carries `fromPrivateTab`, shown as a badge in the +library. That is **library hygiene, not protection**: it keeps a private session's +sources findable so they can be purged as a group, instead of blending into every +other source the moment they land. + +**AiON's "current page" context reads private tabs too**, for the same reason. +Answers and citations do land in the conversation store, but that is another +local write on the user's own disk, not something leaving the machine. It used to +be refused, which meant asking about the page in front of you and getting an +answer that silently pretended not to see it — a worse outcome than the one the +refusal was avoiding. + +The thing to keep in mind when adding any feature that reads the active tab is +the outward/inward split, not a blanket ban: a private tab is a promise about +what leaves the machine and what survives on it _by default_, not a prohibition +on the user deliberately keeping something. + +### Timezone and locale pinning + +Off by default. When on, every tab is built with a document-start script +(`TIMEZONE_PIN_SCRIPT`, injected via `initialization_script_for_all_frames`) that +reports UTC and `en-US` in place of the machine's own timezone and language. + +**Why this and not canvas noise.** Timezone is among the highest-entropy bits a +page reads for free, and pinning it is _uniformity_ rather than randomisation: +UTC is a large crowd that already exists, so the user becomes commoner. Randomised +canvas or audio fingerprints do the opposite — "the browser whose canvas hash +changes every read" is a very small set, and the shim is detectable besides. ÆTHER +does not have the user base to hide a randomiser in. It does not ship one. + +Both halves of the injection are load-bearing. On page load is too late: a +fingerprinting script has read the real values long before then, which is why the +existing `NATIVE_WEBVIEW_SCROLLBAR_SCRIPT` hook was not reusable. Main-frame-only +would leave any embedded tracker iframe reading the true values anyway. + +Coverage is `Date.prototype.getTimezoneOffset`, the `Date` string and +`toLocale*` methods — the engine formats those from its own internal zone, not +from `getTimezoneOffset`, so patching the offset alone leaves the real zone in +`String(new Date())` — `resolvedOptions().timeZone` on the `Intl` constructors, +which is where a modern script actually looks because it yields the IANA name +rather than an offset, and `navigator.language` / `languages`. + +Limits, and they are real: + +- **It is a JavaScript shim.** The overrides report `[native code]` from + `toString`, but a page that creates a blank same-origin iframe can read pristine + copies out of the fresh realm before the script runs there. +- **`Accept-Language` on the wire is not covered.** The engine sets it, below + where any injected script can reach. A UTC clock next to a `nl-NL` request + header is itself a signal. +- **Off by default on purpose.** Tracker blocking defaults on because it costs + nothing visible. This makes every web calendar, booking form and "posted 2 hours + ago" read wrong, in ordinary use, for a benefit the user cannot see. That is a + trade to offer, not one to make on someone's behalf. +- **Desktop only.** The Android shell drives its WebViews through `android_tabs`, + which has no document-start hook; `timezone_pin_platform_support` says so and + Settings reports it rather than showing a toggle that does nothing. + +It removes two easy bits from casual fingerprinting. It is not anonymity, and +pairing it with the proxy is where it earns its keep — a hidden IP next to a +precise local timezone gives most of the location back. + +### Verifying the platform code + +`src/content_blocking/` and `src/browsing_data/` are three implementations +against three unrelated native APIs, and only one of them compiles on whatever +machine you are sitting at. Two safety nets: + +- **`bun run check:platforms`** builds and tests all three locally. Linux goes + through the same Docker image as `scripts/build-linux.sh`. Windows is a cross + _type-check_: the whole crate cannot cross-compile (llama.cpp needs a C++ + toolchain) but `cargo check` never links, so the script assembles a scratch + crate containing only the Windows modules and their real dependencies. +- **`.github/workflows/checks.yml`** does the same on real runners for every push + and PR, dev-profile, without waiting on `build.yml`'s installer builds. + +This is not ceremony. The Windows cross-check caught `Uri()` and `Source()` being +**out-parameters** (`*mut PWSTR`, COM-allocated, caller frees) rather than +returning the string, and `ClearBrowsingDataAll` living on `ICoreWebView2Profile2` +rather than `ICoreWebView2Profile`. None of that compiles, and none of it was +visible from a Mac. The Linux check caught the duplicate-`glib` problem above. + +### Container tabs + +Opt-in storage partitioning: a tab opened in a container gets its own persistent +`WKWebsiteDataStore`, keyed by a UUIDv5 of the container name so it resolves to +the same store on every launch. **macOS 14+ only** — wry's availability check is +at runtime and falls back to the default store below that, and on every other +platform, where the tab shares the default jar and the isolation is nominal. + +**Why opt-in rather than always-on per-site isolation.** `navigate_native_webview` +reuses the webview, and the data store is fixed when the webview is built. A tab +created on `example.com` that follows a link to `other.com` would file the second +site's cookies under the first, while a fresh tab on `other.com` would get a +different store — same site, two jars, depending on how you arrived. Logins would +break unpredictably. True per-site isolation needs the webview torn down and +rebuilt on every cross-site navigation, which costs that tab's history. + +A private tab never keeps a container: it is already in a non-persistent store, +and a persistent partition on top would defeat the point. + +**The User-Agent must stay consistent with the engine it is compiled for.** A +single macOS Safari string on every desktop target — which is what this was — +contradicts `navigator.platform`, the WebGL renderer and the font list on Windows +and Linux, and a UA that disagrees with its own engine is a _stronger_ fingerprint +than an honest one. Linux is the deliberate exception: WebKitGTK has no crowd to +hide in, so it presents the Chrome/Linux string for site compatibility and accepts +that a probe can tell WebKit from Blink. + +**Tracking-parameter stripping is kept narrow on purpose.** An over-greedy prefix +breaks real navigation, and it breaks it invisibly — the user sees a broken page, +not a stripped parameter. Prefer leaking a campaign id to guessing. + +## What gets captured + +Not a privacy control, but it shares the same plumbing and the same failure mode: +something ends up in the local index that nobody meant to put there. + +`extract.rs` has two paths — a snapshot from the live webview, and an HTTP +re-fetch when there is no webview. Both now strip the same set of elements +(`NON_CONTENT_ELEMENTS`), so one URL yields the same text either way. Getting +that wrong means the same page produces different embeddings depending on how it +was captured. + +Two bugs worth not reintroducing: + +1. **The snapshot script's cleaning used to have no effect.** It strips nav, + footer, script and friends from a _clone_ and sends that as `html` — but + `body_text` was `document.body.innerText` from the untouched live DOM, and + `body_text` won. Every capture carried the site's navigation and footer into + the index. The cleaned clone is now preferred, with `innerText` as the + fallback for pages whose clone yields essentially nothing. +2. **Inline JavaScript was indexed as prose.** `scraper`'s `.text()` walks every + descendant text node and a ` + +
Copyright 2026. Privacy Policy.
+ "#, + ); + let text = select_body_text(&document); + assert_eq!(text, "The actual article body text."); + assert!(!text.contains("var tracker"), "script source leaked: {text}"); + assert!(!text.contains("display"), "stylesheet leaked: {text}"); + assert!(!text.contains("Home About"), "nav leaked: {text}"); + assert!(!text.contains("Copyright"), "footer leaked: {text}"); + } + + #[test] + fn body_text_keeps_nested_content_inside_ordinary_elements() { + let document = Html::parse_document( + r#"

First emphasised part.

+
  • One
  • Two
"#, + ); + assert_eq!( + select_body_text(&document), + "First emphasised part. One Two" + ); + } + + // The regression this guards: `body_text` came from the live DOM's innerText + // while the stripping only ever applied to the cloned `html`, so the cleaning + // had no effect on what was actually indexed. + #[test] + fn a_snapshot_prefers_the_cleaned_html_over_raw_inner_text() { + let snapshot = BrowserPageSnapshot { + url: Some("https://example.com/post".to_string()), + title: Some("Post".to_string()), + description: Some(String::new()), + html: Some( + "
Cleaned article \ + body, written at enough length that the capture comfortably clears \ + the minimum readable-text threshold on its own merits.
\ +
Copyright 2026.
" + .to_string(), + ), + body_text: Some( + "Home About Contact We use cookies to improve your experience. \ + Accept All. Cleaned article body. Copyright 2026." + .to_string(), + ), + }; + let page = snapshot_to_captured_page(snapshot, "fallback").unwrap(); + assert!(page.text.contains("Cleaned article body")); + assert!(!page.text.contains("We use cookies"), "{}", page.text); + assert!(!page.text.contains("Home About Contact"), "{}", page.text); + } + + // A page whose cleaned clone is too thin — a heavily scripted app, say — + // must still capture rather than fail, so innerText remains the fallback. + #[test] + fn a_snapshot_falls_back_to_inner_text_when_the_clone_is_empty() { + let long_text = "Readable text recovered from innerText. ".repeat(6); + let snapshot = BrowserPageSnapshot { + url: Some("https://example.com/app".to_string()), + title: Some("App".to_string()), + description: Some(String::new()), + html: Some("
".to_string()), + body_text: Some(long_text.clone()), + }; + let page = snapshot_to_captured_page(snapshot, "fallback").unwrap(); + assert!(page.text.contains("recovered from innerText")); + } + + // The old single constant claimed macOS Safari on every desktop target, which + // contradicted navigator.platform everywhere except macOS. + #[test] + fn user_agent_matches_the_platform_it_is_compiled_for() { + if cfg!(target_os = "macos") { + assert!(BROWSER_USER_AGENT.contains("Macintosh")); + assert!(BROWSER_USER_AGENT.contains("Safari")); + } else if cfg!(target_os = "windows") { + assert!(BROWSER_USER_AGENT.contains("Windows NT")); + } else if cfg!(target_os = "android") { + assert!(BROWSER_USER_AGENT.contains("Android")); + } else { + assert!(BROWSER_USER_AGENT.contains("X11; Linux")); + } + // The webview and the Rust client must not disagree; the capture fallback + // used to identify itself as "Aether/1.0 Tauri". + assert!(!BROWSER_USER_AGENT.contains("Aether")); + } + #[test] fn answer_citation_normalizer_removes_out_of_range_markers() { let answer = r#"The pelt was called "fitchet" [15]. It has another name [1, 16]."#; @@ -3132,3 +4402,4 @@ mod tests { } } } + diff --git a/src-tauri/src/model_downloads.rs b/src-tauri/src/model_downloads.rs index 6b11ffb..fbacee3 100644 --- a/src-tauri/src/model_downloads.rs +++ b/src-tauri/src/model_downloads.rs @@ -69,7 +69,7 @@ pub(crate) async fn download_managed_models( match download_model_file( app, - &state.client, + &state.http_client(), spec, overall_downloaded, overall_total, diff --git a/src-tauri/src/retrieval.rs b/src-tauri/src/retrieval.rs index 8010ed9..069b298 100644 --- a/src-tauri/src/retrieval.rs +++ b/src-tauri/src/retrieval.rs @@ -10,7 +10,7 @@ pub(crate) async fn search_collection( if query.is_empty() { return Ok(Vec::new()); } - get_collection(&state.paths.library_path, &input.collection_id).await?; + get_collection(state, &input.collection_id).await?; let settings = load_settings(&state.paths.settings_path).await?; let query_vector = local_embed_query(state, &settings, query).await?; with_vectors_read(state, |vectors| { @@ -59,15 +59,20 @@ pub(crate) async fn search_library( }); } - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - if let Some(collection_id) = input.collection_id.as_deref() { - get_collection(&state.paths.library_path, collection_id).await?; - } + // One pass over the library for both the labels and the scope check. These + // used to be two independent reads, each parsing the whole file, to answer + // questions about the same snapshot. + let collection_names = with_library_read(state, |library| -> Cmd> { + if let Some(collection_id) = input.collection_id.as_deref() { + find_collection(library, collection_id)?; + } + Ok(library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect()) + }) + .await??; let settings = load_settings(&state.paths.settings_path).await?; let limit = input.limit.unwrap_or(20).clamp(1, 60); @@ -285,26 +290,28 @@ pub(crate) async fn semantic_trail_generate( ) }; - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let root_collection_ids = root_url_key - .as_deref() - .map(|key| { - library - .captures - .iter() - .filter(|capture| normalize_capture_url_key(&capture.url) == key) - .map(|capture| capture.collection_id.clone()) - .collect::>() - }) - .unwrap_or_default(); - let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + let (collection_names, root_collection_ids) = with_library_read(state, |library| { + let names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let roots = root_url_key + .as_deref() + .map(|key| { + library + .captures + .iter() + .filter(|capture| normalize_capture_url_key(&capture.url) == key) + .map(|capture| capture.collection_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + (names, roots) + }) + .await?; - if chunks.is_empty() { + if with_vectors_read(state, |vectors| vectors.chunks.is_empty()).await? { return Ok(SemanticTrailResult { query: visible_query, generated_at: now(), @@ -317,31 +324,38 @@ pub(crate) async fn semantic_trail_generate( let settings = load_settings(&state.paths.settings_path).await?; let query_vector = local_embed_query(state, &settings, embedding_query).await?; - let mut candidates = chunks - .into_iter() - .filter_map(|chunk| { - let distance = cosine_distance(&query_vector, &chunk.vector); - if !distance.is_finite() { - return None; - } - let same_collection = root_collection_ids.contains(&chunk.collection_id); - let score = semantic_trail_score_breakdown(distance, &chunk.captured_at); - if score.semantic < SEMANTIC_TRAIL_MIN_SCORE { - return None; - } - let reasons = semantic_trail_reasons(&score, same_collection); - let collection_name = collection_names - .get(&chunk.collection_id) - .cloned() - .unwrap_or_else(|| "Knowledge Hub".to_string()); - Some(SemanticTrailChunkCandidate { - chunk, - collection_name, - score, - reasons, + // Scored inside the read lock so only the chunks that survive the score + // threshold are cloned. Cloning the store first and filtering after copied + // every chunk's text and vector to throw almost all of them away. + let mut candidates = with_vectors_read(state, |vectors| { + vectors + .chunks + .iter() + .filter_map(|chunk| { + let distance = cosine_distance(&query_vector, &chunk.vector); + if !distance.is_finite() { + return None; + } + let same_collection = root_collection_ids.contains(&chunk.collection_id); + let score = semantic_trail_score_breakdown(distance, &chunk.captured_at); + if score.semantic < SEMANTIC_TRAIL_MIN_SCORE { + return None; + } + let reasons = semantic_trail_reasons(&score, same_collection); + let collection_name = collection_names + .get(&chunk.collection_id) + .cloned() + .unwrap_or_else(|| "Knowledge Hub".to_string()); + Some(SemanticTrailChunkCandidate { + chunk: chunk.clone(), + collection_name, + score, + reasons, + }) }) - }) - .collect::>(); + .collect::>() + }) + .await?; candidates.sort_by(|left, right| { right @@ -392,12 +406,11 @@ pub(crate) async fn suggest_capture_hub( Err(_) => return Ok(None), }; - let library = load_library(&state.paths.library_path).await?; - if library.collections.is_empty() { + let names = collection_names(state).await?; + if names.is_empty() { return Ok(None); } - let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - if chunks.is_empty() { + if with_vectors_read(state, |vectors| vectors.chunks.is_empty()).await? { return Ok(None); } @@ -410,21 +423,28 @@ pub(crate) async fn suggest_capture_hub( // A hub is a strong home for this page if it already holds a source whose meaning is // close to it, so score each hub by its single closest chunk. - let mut best_by_collection: HashMap = HashMap::new(); - for chunk in &chunks { - let distance = cosine_distance(&query_vector, &chunk.vector); - if !distance.is_finite() { - continue; - } - let semantic = semantic_score_from_distance(distance); - let entry = best_by_collection - .entry(chunk.collection_id.clone()) - .or_insert((0.0, String::new())); - if semantic > entry.0 { - entry.0 = semantic; - entry.1 = chunk.title.clone(); + // + // Folded inside the read lock: the result is one entry per collection, so + // cloning the whole chunk store to produce it was pure waste. + let best_by_collection = with_vectors_read(state, |vectors| { + let mut best: HashMap = HashMap::new(); + for chunk in &vectors.chunks { + let distance = cosine_distance(&query_vector, &chunk.vector); + if !distance.is_finite() { + continue; + } + let semantic = semantic_score_from_distance(distance); + let entry = best + .entry(chunk.collection_id.clone()) + .or_insert((0.0, String::new())); + if semantic > entry.0 { + entry.0 = semantic; + entry.1 = chunk.title.clone(); + } } - } + best + }) + .await?; let Some((collection_id, (confidence, sample_title))) = best_by_collection.into_iter().max_by(|left, right| { @@ -441,11 +461,9 @@ pub(crate) async fn suggest_capture_hub( return Ok(None); } - let collection_name = library - .collections - .iter() - .find(|collection| collection.id == collection_id) - .map(|collection| collection.name.clone()) + let collection_name = names + .get(&collection_id) + .cloned() .unwrap_or_else(|| "Knowledge Hub".to_string()); Ok(Some(CaptureHubSuggestion { diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 23a650f..583ecf1 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -120,12 +120,3 @@ pub(crate) async fn save_json(path: &Path, data: &T) -> Cmd<()> { let raw = serde_json::to_string_pretty(data).map_err(|error| error.to_string())?; write_store_durably(path, format!("{raw}\n").as_bytes()).await } - -pub(crate) async fn get_collection(path: &Path, collection_id: &str) -> Cmd { - load_library(path) - .await? - .collections - .into_iter() - .find(|collection| collection.id == collection_id) - .ok_or_else(|| "Collection not found.".to_string()) -} diff --git a/src-tauri/src/system.rs b/src-tauri/src/system.rs index a454db6..c15be70 100644 --- a/src-tauri/src/system.rs +++ b/src-tauri/src/system.rs @@ -5,7 +5,7 @@ use super::*; pub(crate) async fn system_status(state: &State<'_, Backend>) -> Cmd { let settings = load_settings(&state.paths.settings_path).await?; - let library = load_library(&state.paths.library_path).await?; + let collections = with_library_read(state, |library| library.collections.clone()).await?; let catalog = model_catalog(&state.paths, &settings.local_model); Ok(SystemStatus { runtime_ready: catalog.chat_model.is_some() || catalog.embedding_model.is_some(), @@ -38,7 +38,11 @@ pub(crate) async fn system_status(state: &State<'_, Backend>) -> Cmd Cmd { read_json_or_default(path).await } +/// Drops captures whose collection no longer exists, returning how many went. +/// +/// A capture in this state is not reachable from the hub list, but it is still in +/// `captures`, so it keeps answering searches — under the "Knowledge Hub" fallback +/// name, because there is no collection left to name it. To the user that is a +/// source they deleted coming back. +pub(crate) fn drop_captures_without_collections(library: &mut LibraryData) -> usize { + let collections = library + .collections + .iter() + .map(|collection| collection.id.clone()) + .collect::>(); + let before = library.captures.len(); + library + .captures + .retain(|capture| collections.contains(&capture.collection_id)); + before - library.captures.len() +} + +/// Drops chunks whose capture is gone, returning how many went. +/// +/// Deliberately keyed on the capture rather than the collection: a chunk belongs +/// to a capture, and `drop_captures_without_collections` has already removed the +/// captures of dead collections, so one rule covers both kinds of orphan. +pub(crate) fn retain_chunks_with_live_captures( + chunks: &mut Vec, + live_captures: &HashSet, +) -> usize { + let before = chunks.len(); + chunks.retain(|chunk| live_captures.contains(&chunk.capture_id)); + before - chunks.len() +} + +/// Clears orphans left behind by a crash mid-delete, once, at startup. +/// +/// The delete paths now commit in the order that makes an interrupted delete leave +/// only the harmless orphan, so this is not needed for anything written after that +/// change. It is here for stores that predate it: the bad ordering was live, and a +/// store carrying its orphans has no other way to shed them. +/// +/// **On the lock nesting.** The library write lock is held across the vector +/// mutation, which is the only way this is safe against a capture running at the +/// same time: capture commits its library entry first and writes chunks second, so +/// a snapshot of live captures taken without that lock could miss an entry whose +/// chunks then land — and those brand-new chunks would look exactly like orphans. +/// Holding the library lock makes that interleaving impossible. It cannot deadlock +/// against capture, which never holds the library lock while waiting for the +/// vector one; the helpers each acquire and release in turn. +pub(crate) async fn reconcile_orphans(state: &State<'_, Backend>) -> Cmd<(usize, usize)> { + let mut library_guard = state.library.write().await; + if library_guard.is_none() { + *library_guard = Some(load_library(&state.paths.library_path).await?); + } + let library = library_guard.as_mut().expect("library cache"); + + let dropped_captures = drop_captures_without_collections(library); + let live_captures = library + .captures + .iter() + .map(|capture| capture.id.clone()) + .collect::>(); + + let mut vectors_guard = state.vectors.write().await; + if vectors_guard.is_none() { + *vectors_guard = Some(load_vectors(&state.paths.chunks_path).await?); + } + let vectors = vectors_guard.as_mut().expect("vector store cache"); + let dropped_chunks = retain_chunks_with_live_captures(&mut vectors.chunks, &live_captures); + + // Nothing to write in the common case, which is every launch after the first + // on a healthy store. Both saves rewrite whole files, so skipping them matters. + if dropped_chunks > 0 { + // Same reasoning as a user-initiated delete: the point is that the vectors + // of an unreachable source actually leave the sidecar. + compact_vectors(&state.paths.chunks_path, vectors).await?; + save_vector_metadata(&state.paths.chunks_path, vectors).await?; + } + if dropped_captures > 0 { + save_json(&state.paths.library_path, library).await?; + } + + if dropped_captures > 0 || dropped_chunks > 0 { + diag_info!( + "reconciled an interrupted delete: dropped {dropped_captures} orphaned capture(s) and {dropped_chunks} orphaned chunk(s)" + ); + } + Ok((dropped_captures, dropped_chunks)) +} + +/// Looks a collection up in an already-loaded library. Split from `get_collection` +/// so a caller that holds the library can check an id without a second read — the +/// double read this replaced was the whole cost of validating a search's scope. +pub(crate) fn find_collection( + library: &LibraryData, + collection_id: &str, +) -> Cmd { + library + .collections + .iter() + .find(|collection| collection.id == collection_id) + .cloned() + .ok_or_else(|| "Collection not found.".to_string()) +} + +pub(crate) async fn get_collection( + state: &State<'_, Backend>, + collection_id: &str, +) -> Cmd { + with_library_read(state, |library| find_collection(library, collection_id)).await? +} + +/// Collection id -> display name, for labelling search hits and graph nodes. +/// +/// Every retrieval path needs exactly this and nothing else from the library, so +/// it is worth a named helper: the alternative each site reached for was cloning +/// the whole collection list to build the same map. +pub(crate) async fn collection_names( + state: &State<'_, Backend>, +) -> Cmd> { + with_library_read(state, |library| { + library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect() + }) + .await +} + +/// Reads the cached library, loading it from disk on first use. +pub(crate) async fn with_library_read( + state: &State<'_, Backend>, + read: impl FnOnce(&LibraryData) -> T, +) -> Cmd { + { + let guard = state.library.read().await; + if let Some(library) = guard.as_ref() { + return Ok(read(library)); + } + } + let mut guard = state.library.write().await; + if guard.is_none() { + *guard = Some(load_library(&state.paths.library_path).await?); + } + Ok(read(guard.as_ref().expect("library cache"))) +} + +/// Mutates the cached library under the write lock and persists the result, so a +/// read-modify-write cannot interleave with another command's. +/// +/// The closure is fallible because most callers validate against the library they +/// are about to change ("Collection not found", "Page is already in X"), and doing +/// that outside the lock is the race this function exists to close. A closure that +/// returns `Err` may already have edited the library, so the cache is dropped +/// rather than saved: the next read reloads the last known-good file, and a +/// half-applied edit never becomes visible. +pub(crate) async fn with_library_mut( + state: &State<'_, Backend>, + mutate: impl FnOnce(&mut LibraryData) -> Cmd, +) -> Cmd { + let mut guard = state.library.write().await; + if guard.is_none() { + *guard = Some(load_library(&state.paths.library_path).await?); + } + let library = guard.as_mut().expect("library cache"); + + let result = match mutate(library) { + Ok(result) => result, + Err(error) => { + *guard = None; + return Err(error); + } + }; + + // Same reasoning as the error path: if the write fails, what is on disk and + // what is in memory have diverged, and memory is the wrong one to trust. + if let Err(error) = save_json(&state.paths.library_path, library).await { + *guard = None; + return Err(error); + } + Ok(result) +} + pub(crate) async fn load_settings(path: &Path) -> Cmd { read_json_or_default(path).await } @@ -112,8 +299,11 @@ pub(crate) async fn persist_session_tabs(state: &State<'_, Backend>) -> Cmd<()> let tabs = guard .tabs .iter() - // A tab parked on the internal start page has nothing to reopen. - .filter(|tab| tab.url != START_PAGE_URL && !tab.url.starts_with("aether://")) + // A tab parked on the internal start page has nothing to reopen, and a + // private tab must not survive the session that opened it. + .filter(|tab| { + !tab.private && tab.url != START_PAGE_URL && !tab.url.starts_with("aether://") + }) .map(|tab| SessionTab { id: tab.id.clone(), url: tab.url.clone(), diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index db868cd..3c9029e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -14,10 +14,31 @@ pub(crate) struct Backend { // aether_layout_set_web_content_bounds. Both shells use it; on desktop it takes // precedence over the SIDEBAR_WIDTH/BROWSER_VIEW_TOP/PANEL_WIDTH constants. pub(crate) web_content_bounds: Mutex, - pub(crate) client: Client, - pub(crate) native_runtime: Arc>, + // Every outbound request the *app* makes, as opposed to the ones a webview + // makes: favicons, the capture re-fetch, model downloads. Behind a lock + // because the proxy setting can change while the app is running and a + // reqwest client's proxy is fixed when it is built, so the client has to be + // replaced rather than adjusted. Read it through `Backend::http_client`. + pub(crate) network: Mutex, + // Session-scoped favicon cache, origin -> data URI, with None recording a host + // that has no usable icon so it is not refetched. Memory only on purpose: see + // the module comment in favicon.rs. + pub(crate) favicon_cache: Mutex>>, + pub(crate) native_runtime: Arc, pub(crate) vectors: tokio::sync::RwLock>, + // Collections, captures and shortcuts, cached the same way as the vectors. + // Two reasons, and the second is the load-bearing one: every command used to + // re-read and re-parse library.json from disk, and — because a mutation was a + // bare load/modify/save with no lock held across the pair — two commands in + // flight could interleave and silently drop one of the writes. The lock is + // what makes a read-modify-write atomic; the caching is the side benefit. + pub(crate) library: tokio::sync::RwLock>, pub(crate) generation_cancelled: Arc, + // Read when a tab's webview is built, which is a sync path, so it is cached + // here rather than re-read from settings.json. Kept beside the proxy and + // refreshed by the same `apply_browser_privacy` call for the same reason: + // one place decides, so tabs cannot disagree with what Settings reports. + pub(crate) pin_timezone: AtomicBool, // Throttle for window geometry writes; resize/move fire continuously. #[cfg(desktop)] pub(crate) window_geometry_saved_at: Mutex>, @@ -27,6 +48,79 @@ pub(crate) struct Backend { pub(crate) pending_downloads: Mutex>, } +/// Where the app's own HTTP requests go, and the client that takes them there. +/// +/// The pair is kept together so they cannot drift: `proxy` is what the webviews +/// were built with, and `client` is a reqwest client built with that same proxy. +/// If those two ever disagreed, tabs would go one way and favicon fetches the +/// other — which is precisely the correlation leak the proxy exists to close. +pub(crate) struct NetworkRouting { + pub(crate) proxy: Option, + pub(crate) client: Client, +} + +impl NetworkRouting { + /// Builds a client routed through `proxy`, or direct when it is `None`. + /// + /// Note that *everything* the app fetches goes through here when the proxy is + /// on, model downloads included. Sending multi-gigabyte pulls over Tor is + /// slow and a poor use of the network, and exempting them was the obvious + /// alternative — but a silent exemption is the same class of bug as the + /// favicon leak: traffic the user believes is proxied, quietly is not. A slow + /// or refused download is a visible failure the user can act on, so that is + /// the one we take. + pub(crate) fn new(proxy: Option) -> Self { + let mut builder = Client::builder().user_agent(BROWSER_USER_AGENT); + if let Some(url) = proxy.as_ref() { + // `all` rather than `http`/`https` separately: a proxy that covered + // only one scheme would leak the other. + match reqwest::Proxy::all(reqwest_proxy_scheme(url)) { + Ok(configured) => builder = builder.proxy(configured), + // Unreachable in practice — `parse_proxy_url` has already accepted + // this URL — but a client that silently fell back to direct here + // would be the leak. Refuse to resolve any host instead: requests + // fail, browsing still works, and nothing goes out unproxied. + Err(_) => builder = builder.no_proxy().dns_resolver(Arc::new(NoDnsResolver)), + } + } + Self { + proxy, + client: builder.build().expect("reqwest client"), + } + } +} + +/// Rewrites `socks5://` to `socks5h://` for reqwest, and only for reqwest. +/// +/// The two schemes differ in who resolves the hostname. Under plain `socks5` +/// reqwest resolves it locally and sends the proxy an IP, which means the +/// network operator still sees a DNS query for every host fetched — the IP is +/// hidden and the destination is not, which is most of the leak back again. +/// `socks5h` hands the name to the proxy instead. +/// +/// It stays out of the settings field because Tauri's proxy parser accepts only +/// `socks5` and would reject `socks5h` when a tab is created. So the user writes +/// the scheme the webview needs, and the app quietly asks for the stronger one +/// where it can — the two are the same endpoint either way. +pub(crate) fn reqwest_proxy_scheme(url: &Url) -> String { + match url.scheme() { + "socks5" => format!("socks5h://{}", &url.as_str()["socks5://".len()..]), + _ => url.to_string(), + } +} + +/// Fails every hostname lookup, so a misconfigured proxy cannot fall back to a +/// direct connection. See `NetworkRouting::new`. +pub(crate) struct NoDnsResolver; + +impl reqwest::dns::Resolve for NoDnsResolver { + fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving { + Box::pin(async { + Err("proxy is configured but unusable; refusing to connect directly".into()) + }) + } +} + #[cfg(desktop)] #[derive(Default)] pub(crate) struct NativeBrowserViews { @@ -45,11 +139,34 @@ pub(crate) struct WebContentBounds { pub(crate) height: f64, } +/// The loaded llama.cpp models, with chat and embedding locked separately. +/// +/// One lock over all of it used to be held for the entire duration of a chat +/// generation, so a search, a Flow graph or an AiR lens — all of which need to +/// embed a query — blocked until the answer finished streaming. That is tens of +/// seconds of a frozen library for something that shares no state with the chat +/// model: the two are already independent `LlamaModel`s. +/// +/// `LlamaBackend` is a zero-sized proof-of-initialization token that can only be +/// created once per process, and neither `load_from_file` nor `new_context` +/// retains the reference it is given, so it is shared rather than locked. The +/// `backend_init` mutex exists only to make the one-time init a single winner; +/// `LlamaBackend::init()` returns `BackendAlreadyInitialized` to the loser, which +/// on the second model load would be a spurious failure. +/// +/// The cost of the split, worth knowing before tuning anything here: a chat and an +/// embedding context can now be live at the same time, so peak memory is both KV +/// caches rather than the larger one, and both size their thread pools from +/// `auto_thread_count()` independently. On desktop that is the trade this is meant +/// to make. On mobile — where weights are already malloc'd rather than mmapped for +/// exactly these pressure reasons — it is the first thing to suspect if capture +/// during generation starts thrashing. #[derive(Default)] pub(crate) struct NativeModelRuntime { - pub(crate) backend: Option, - pub(crate) chat: Option, - pub(crate) embedding: Option, + pub(crate) backend: OnceLock, + pub(crate) backend_init: Mutex<()>, + pub(crate) chat: Mutex>, + pub(crate) embedding: Mutex>, } pub(crate) struct LoadedNativeModel { @@ -152,6 +269,21 @@ pub(crate) struct ManagedTab { // the WebView never saw — most notably the aether://start page. pub(crate) native_can_go_back: Option, pub(crate) native_can_go_forward: Option, + // A private tab gets a non-persistent webview data store, is never written to + // the session, and cannot be captured. The last of those is the one that is + // easy to forget: ÆTHER's whole point is a durable local index of what you + // read, and that is precisely what a private tab must not produce. + pub(crate) private: bool, + // Opt-in storage partition. `None` shares the default store with every other + // ordinary tab; `Some(name)` gets its own persistent cookie jar and local + // storage, isolated from the default and from every other container. + // + // Chosen over always-on per-site isolation because navigation reuses the + // webview (see navigate_native_webview): the data store is fixed when the + // webview is built, so a tab that started on one site and navigated to + // another would file the second site's cookies under the first. Same site, + // two jars, depending on how you arrived — worse than not partitioning. + pub(crate) container: Option, } #[derive(Clone, Serialize)] @@ -185,6 +317,9 @@ pub(crate) struct BrowserTabSummary { pub(crate) favicon: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) theme_color: Option, + pub(crate) is_private: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) container: Option, } #[derive(Clone, Serialize)] @@ -216,6 +351,60 @@ pub(crate) struct HubShortcutSummary { #[serde(rename_all = "camelCase")] pub(crate) struct BrowserSettings { pub(crate) default_search_engine: String, + /// Ask the search engine for results without AI-generated answers. + /// + /// `serde(default)` rather than plain `bool`, so an existing settings.json + /// written before this field existed reads as on rather than off — the same + /// direction as tracker blocking, which is also on without being asked for. + #[serde(default = "default_ai_free_search")] + pub(crate) ai_free_search: bool, + /// Route web traffic through a proxy, typically a local Tor daemon. + /// + /// Unlike `ai_free_search` this defaults to *off*, and the difference is not + /// stylistic: an AI-free search URL still works when the mechanism is wrong, + /// but a proxy pointing at nothing fails every request in the app. A default + /// that assumes a daemon the user never installed would present as "ÆTHER is + /// broken", so it stays off until someone asks for it. + #[serde(default)] + pub(crate) proxy: ProxySettings, + /// Report UTC and a fixed locale to pages instead of the machine's own. + /// + /// Default off, and this one is genuinely a judgement call rather than + /// caution. Timezone is among the highest-entropy bits a page reads for + /// free, and pinning it is *uniformity* rather than randomisation — UTC is a + /// large existing crowd, so unlike canvas noise it makes the user commoner + /// instead of rarer. Against that: every web calendar, booking form and + /// "posted 2 hours ago" then reads wrong, in ordinary use, for a benefit the + /// user cannot see. Tracker blocking defaults on because it costs nothing + /// visible; this costs something visible, so it waits to be asked for. + #[serde(default)] + pub(crate) pin_timezone: bool, +} + +/// Where web traffic goes, when the user has redirected it. +/// +/// One endpoint for the whole app rather than per tab. Per-tab proxying reads as +/// a stronger feature and is a weaker one: two tabs on different exits, sharing +/// one process and one clock, are trivially correlated, and the split invites the +/// belief that a "proxied tab" is isolated from an unproxied one when the only +/// thing separating them is which socket the bytes left by. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProxySettings { + pub(crate) enabled: bool, + /// `socks5://host:port`, or `http://host:port` for an HTTP CONNECT proxy. + pub(crate) url: String, +} + +impl Default for ProxySettings { + fn default() -> Self { + Self { + enabled: false, + // Tor's default SOCKS port. Prefilling it means the common case is a + // toggle rather than a lookup, and it is inert while `enabled` is false. + url: DEFAULT_PROXY_URL.to_string(), + } + } } #[derive(Clone, Serialize, Deserialize)] @@ -273,6 +462,16 @@ pub(crate) struct CaptureSummary { pub(crate) chunk_count: usize, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) metadata: Option, + /// Whether this source came out of a private tab. + /// + /// Library hygiene, not a privacy control — capture writes to your own disk + /// and sends nothing anywhere, so there is nothing here to protect against. + /// It exists so that research done in private tabs stays *findable* after the + /// fact: without it those sources are indistinguishable from any other, and + /// "delete everything I looked at that way" becomes impossible. Skipped when + /// false, so library.json is unchanged for every ordinary capture. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) from_private_tab: bool, } #[derive(Clone, Serialize)] @@ -678,14 +877,106 @@ pub(crate) struct SystemStatus { pub(crate) db_path: String, pub(crate) library_path: String, pub(crate) collections: Vec, + pub(crate) content_blocking: ContentBlockingStatus, + pub(crate) ai_free_search: AiFreeSearchStatus, + pub(crate) proxy: ProxyStatus, + pub(crate) timezone_pin: TimezonePinStatus, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) error: Option, } +/// What tracker blocking this build provides, reported rather than assumed. +/// +/// `blocks_third_party_cookies` is the one that matters: macOS and Linux get it +/// from the `block-cookies` rule the WebKit engine evaluates, and Windows has no +/// WebView2 equivalent — a request either happens or does not, so a tracker that +/// is not on the host list still sets cookies there. That is the largest +/// behavioural difference between the platforms and the user should be told. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ContentBlockingStatus { + pub(crate) engine: String, + pub(crate) blocked_host_count: usize, + pub(crate) blocks_third_party_cookies: bool, + pub(crate) available: bool, +} + +/// Whether the *currently selected* engine can be asked for AI-free results. +/// +/// Same reasoning as `ContentBlockingStatus`: the five engines have five unrelated +/// answers and two of them have none, so a fixed string in the renderer would keep +/// promising AI-free results on Yahoo long after anyone remembered that Yahoo has +/// no control to offer. `available == false` with the setting on is a real state, +/// and the screen should say so rather than imply the toggle did something. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiFreeSearchStatus { + pub(crate) enabled: bool, + /// What is actually being sent, for the UI to state plainly: "udm=14 Web + /// filter", "-ai operator", "noai.duckduckgo.com". Empty when unavailable. + pub(crate) mechanism: String, + /// False when the selected engine offers no URL-level opt-out at all. + pub(crate) available: bool, +} + +/// Whether this build can actually route traffic through the configured proxy. +/// +/// Same reasoning as the two above, and the stakes are higher: a search engine +/// that ignores an AI opt-out shows you an AI answer, while a proxy that silently +/// does nothing shows you the page you asked for over your own address. The +/// failure is invisible from inside the app, so it has to be stated. +/// +/// `unsupported_reason` carries the *why* rather than a bare false, because the +/// two causes have different remedies — an OS upgrade on macOS 13, and nothing at +/// all on Android. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProxyStatus { + pub(crate) enabled: bool, + pub(crate) url: String, + pub(crate) available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unsupported_reason: Option, + /// True once traffic is genuinely going through the proxy: enabled, supported, + /// and a valid endpoint. The renderer should key its "your IP is hidden" + /// affordance off this and nothing else. + pub(crate) active: bool, +} + +/// Whether pages are actually being told UTC, as opposed to asked to be. +/// +/// Reported for the same reason as the proxy: the pinning is an injected +/// document-start script, and the mobile shell drives its WebViews through a +/// different path that has nowhere to inject one. Claiming it uniformly would be +/// wrong on exactly the platform that cannot do it. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TimezonePinStatus { + pub(crate) enabled: bool, + pub(crate) available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unsupported_reason: Option, + pub(crate) active: bool, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CreateTabInput { pub(crate) url: Option, + /// Search terms, for callers that have a *concept* rather than a URL — the iCE + /// cards' "Explore in Web". + /// + /// Separate from `url` on purpose. `normalize_url` has to guess whether a bare + /// string is a query or a host, and it guesses by looking for a dot: a concept + /// named "Node.js" or "Web 2.0" would be sent to `https://Node.js` instead of + /// being searched for. A caller that already knows it holds search terms should + /// not have to route them through that guess. + #[serde(default)] + pub(crate) search: Option, + #[serde(default)] + pub(crate) private: bool, + #[serde(default)] + pub(crate) container: Option, } #[derive(Deserialize)] @@ -898,6 +1189,16 @@ pub(crate) struct UpdateSettingsInput { #[serde(rename_all = "camelCase")] pub(crate) struct PartialBrowserSettings { pub(crate) default_search_engine: Option, + pub(crate) ai_free_search: Option, + pub(crate) proxy: Option, + pub(crate) pin_timezone: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PartialProxySettings { + pub(crate) enabled: Option, + pub(crate) url: Option, } #[derive(Deserialize)] @@ -1212,7 +1513,12 @@ pub(crate) struct LocalModelSettings { impl Default for BrowserSettings { fn default() -> Self { Self { - default_search_engine: "google".to_string(), + // Existing installs keep whatever is already in settings.json; this + // only changes where a fresh profile starts. See search_engine_prefix. + default_search_engine: DEFAULT_SEARCH_ENGINE.to_string(), + ai_free_search: default_ai_free_search(), + proxy: ProxySettings::default(), + pin_timezone: false, } } } @@ -1247,6 +1553,13 @@ pub(crate) fn default_update_auto_check() -> bool { true } +/// On unless turned off. AI answers are inserted above the results the user asked +/// for, by a mechanism they did not opt into, so the default that respects the +/// user's intent is the one that declines them. +pub(crate) fn default_ai_free_search() -> bool { + true +} + #[derive(Serialize, Deserialize)] pub(crate) struct IcebergData { pub(crate) version: u8, diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 5cc3f5c..ef5e82c 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -41,20 +41,89 @@ pub(crate) fn normalize_captured_text(text: &str) -> String { .to_string() } -pub(crate) fn normalize_url(raw_url: &str, search_engine: &str) -> String { +/// Click identifiers, matched exactly. Each one exists to join a visit to an ad +/// impression or a mail send; none is load-bearing for rendering the page. +const TRACKING_PARAMS: [&str; 22] = [ + "fbclid", + "gclid", + "gclsrc", + "dclid", + "gbraid", + "wbraid", + "msclkid", + "twclid", + "ttclid", + "igshid", + "yclid", + "li_fat_id", + "mkt_tok", + "mc_cid", + "mc_eid", + "s_kwcid", + "ef_id", + "epik", + "irclickid", + "rb_clickid", + "vero_id", + "oly_enc_id", +]; + +/// Whole families of analytics parameters. Kept narrow on purpose: a prefix that +/// is too greedy silently breaks real navigation, which is a worse failure than +/// leaking a campaign id, because the user cannot see it happen. +const TRACKING_PARAM_PREFIXES: [&str; 5] = ["utm_", "pk_", "_hsenc", "_hsmi", "hsa_"]; + +fn is_tracking_param(key: &str) -> bool { + let lowered = key.to_ascii_lowercase(); + TRACKING_PARAMS.contains(&lowered.as_str()) + || TRACKING_PARAM_PREFIXES + .iter() + .any(|prefix| lowered.starts_with(prefix)) +} + +/// Removes click identifiers from an http(s) URL, leaving everything else byte +/// for byte. Returns the input unchanged when nothing matched, so ordinary +/// navigation never pays a URL-reserialisation round trip. +/// +/// This runs on navigation *and* on capture, which matters twice over: the site +/// never receives the identifier, and it never reaches the local index either — +/// otherwise a captured URL would keep the ad attribution forever. +pub(crate) fn strip_tracking_params(url: &str) -> String { + let Ok(parsed) = Url::parse(url) else { + return url.to_string(); + }; + if !matches!(parsed.scheme(), "http" | "https") || parsed.query().is_none() { + return url.to_string(); + } + + let kept = parsed + .query_pairs() + .filter(|(key, _)| !is_tracking_param(key)) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + if kept.len() == parsed.query_pairs().count() { + return url.to_string(); + } + + let mut cleaned = parsed.clone(); + if kept.is_empty() { + cleaned.set_query(None); + } else { + cleaned.query_pairs_mut().clear().extend_pairs(kept); + } + cleaned.to_string() +} + +pub(crate) fn normalize_url(raw_url: &str, search: SearchPrefs<'_>) -> String { let trimmed = raw_url.trim(); if trimmed.is_empty() { - return "https://www.google.com".to_string(); + return search_engine_home(search).to_string(); } if Url::parse(trimmed).is_ok() { - return trimmed.to_string(); + return strip_tracking_params(trimmed); } if trimmed.contains(char::is_whitespace) || !trimmed.contains(['.', ':']) { - return format!( - "{}{}", - search_engine_prefix(search_engine), - urlencoding(trimmed) - ); + return search_url(trimmed, search); } if trimmed.starts_with("localhost") || trimmed.starts_with("127.0.0.1") @@ -65,20 +134,310 @@ pub(crate) fn normalize_url(raw_url: &str, search_engine: &str) -> String { format!("https://{trimmed}") } +/// Which engine a typed query goes to, and whether to ask that engine for results +/// without AI-generated answers. +/// +/// The two travel together because they are one decision — "how does a query +/// become a URL" — and separating them is how a search path ends up honouring the +/// engine but quietly dropping the AI-free request, or the reverse. +#[derive(Clone, Copy)] +pub(crate) struct SearchPrefs<'a> { + pub(crate) engine: &'a str, + pub(crate) ai_free: bool, +} + +impl SearchPrefs<'_> { + /// For the paths that have no settings in hand: a link a page opened in a new + /// tab, or a shortcut whose input is already a URL. The engine is only ever a + /// fallback there — it applies when the input turns out to be a bare search + /// term, which for those callers is the rare case, not the normal one. + pub(crate) fn fallback() -> SearchPrefs<'static> { + SearchPrefs { + engine: DEFAULT_SEARCH_ENGINE, + ai_free: default_ai_free_search(), + } + } +} + +impl BrowserSettings { + pub(crate) fn search_prefs(&self) -> SearchPrefs<'_> { + SearchPrefs { + engine: &self.default_search_engine, + ai_free: self.ai_free_search, + } + } +} + +/// How an engine lets a user refuse AI-generated answers — if it lets them at all. +/// +/// Every variant below was checked against the engine's own documentation rather +/// than inferred, because the mechanisms are unrelated to each other and two of +/// the five engines have none. See docs/SECURITY.md for the sources. +pub(crate) enum AiFreeSearch { + /// No URL-level opt-out exists. Yahoo has no control of its own (it serves + /// Bing's results), and Ecosia's is an account setting that is also gated by + /// region — neither can be asked for from a URL, so nothing is claimed. + Unavailable, + /// A query operator appended to the search terms. Bing's `-ai` is a real, + /// documented operator, added in June 2026. + QueryOperator(&'static str), + /// A parameter on the search URL. Google's `udm=14` selects the "Web" vertical, + /// which returns plain links and no AI Overview. + /// + /// Deliberately *not* Google's `-ai`: that is a Bing operator, and on Google it + /// is an ordinary negative keyword that drops every result containing "ai" — + /// which would quietly gut a search for an iCE concept like "neural network". + UrlParam(&'static str), + /// A different host serving the same engine with AI features off, which is how + /// DuckDuckGo ships its opt-out. + AltHost { search: &'static str, home: &'static str }, +} + +/// What AI-free search does for the engine the user has actually selected. +/// +/// Derived from `ai_free_search` rather than written out again, so a mechanism +/// added or lost below cannot leave the Settings screen describing the old one. +pub(crate) fn ai_free_search_status(browser: &BrowserSettings) -> AiFreeSearchStatus { + let mechanism = match ai_free_search(&browser.default_search_engine) { + AiFreeSearch::UrlParam(param) => format!("{param} Web filter"), + AiFreeSearch::QueryOperator(operator) => format!("{operator} operator"), + AiFreeSearch::AltHost { home, .. } => home.trim_start_matches("https://").to_string(), + AiFreeSearch::Unavailable => String::new(), + }; + + AiFreeSearchStatus { + enabled: browser.ai_free_search, + available: !mechanism.is_empty(), + mechanism, + } +} + +/// Parse a proxy endpoint, rejecting anything the transports cannot carry. +/// +/// Deliberately strict, and the accepted set is not a judgement call: Tauri's +/// `parse_proxy_url` maps exactly `http` and `socks5` to a wry `ProxyConfig` and +/// returns `InvalidProxyUrl` for everything else. Accepting a scheme it will +/// refuse — `https`, `socks5h`, `socks4` — would push the failure to the moment +/// a tab is opened, long after the Settings screen said the address was fine. +/// +/// A bare `127.0.0.1:9050` is refused for the same reason: `Url::parse` reads it +/// as scheme `127.0.0.1` with no host, so it would sail past a looser check and +/// end up proxying nothing. +pub(crate) fn parse_proxy_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("A proxy address is required while the proxy is on.".to_string()); + } + + let parsed = Url::parse(trimmed) + .map_err(|_| format!("\"{trimmed}\" is not a valid proxy address."))?; + + if !matches!(parsed.scheme(), "socks5" | "http") { + return Err(format!( + "\"{}\" is not a supported proxy scheme — use socks5:// or http://.", + parsed.scheme() + )); + } + if parsed.host_str().unwrap_or_default().is_empty() { + return Err(format!("\"{trimmed}\" is missing a host.")); + } + // Neither transport has a default port worth guessing: Tor is 9050, a Tor + // Browser bundle is 9150, and an HTTP proxy is whatever it was configured as. + // Guessing wrong fails closed but confusingly, so require it. + if parsed.port().is_none() { + return Err(format!("\"{trimmed}\" is missing a port.")); + } + + Ok(parsed) +} + +/// Whether the running platform can route webview traffic through a proxy. +/// +/// `Err` carries the reason, which the Settings screen shows verbatim. +/// +/// The macOS gate is the load-bearing one. wry sets `proxyConfigurations` on the +/// data store through KVC *without* a version check of its own — unlike the +/// app-bound-domains call a few lines above it, which does test for 14. That key +/// only exists on macOS 14+, and `setValue:forKey:` against a missing key raises +/// NSUnknownKeyException, so on macOS 13 the choice is not "proxy or no proxy" +/// but "proxy or a crashed tab". ÆTHER supports back to 10.15, so this check +/// cannot be skipped. +pub(crate) fn proxy_platform_support() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + if macos_major_version() < 14 { + return Err( + "Proxy support needs macOS 14 or later; this Mac browses directly.".to_string(), + ); + } + Ok(()) + } + #[cfg(target_os = "android")] + { + Err("Proxy support is not available on Android; this device browses directly.".to_string()) + } + #[cfg(not(any(target_os = "macos", target_os = "android")))] + { + Ok(()) + } +} + +#[cfg(target_os = "macos")] +fn macos_major_version() -> isize { + objc2_foundation::NSProcessInfo::processInfo() + .operatingSystemVersion() + .majorVersion +} + +/// What the proxy is actually doing, as opposed to what the settings file says. +/// +/// `active` folds together the three things that all have to hold — switched on, +/// supported here, and a parseable endpoint — so no caller has to remember the +/// conjunction and get it subtly wrong. +pub(crate) fn proxy_status(browser: &BrowserSettings) -> ProxyStatus { + let support = proxy_platform_support(); + let available = support.is_ok(); + let usable = available && parse_proxy_url(&browser.proxy.url).is_ok(); + + ProxyStatus { + enabled: browser.proxy.enabled, + url: browser.proxy.url.clone(), + available, + unsupported_reason: support.err(), + active: browser.proxy.enabled && usable, + } +} + +/// Whether this shell can inject a document-start script into a visited page. +/// +/// Desktop builds each tab with `WebviewBuilder`, which takes one. The Android +/// shell drives its WebViews through `android_tabs` and has no equivalent hook, +/// so the setting is inert there and says so rather than implying otherwise. +pub(crate) fn timezone_pin_platform_support() -> Result<(), String> { + #[cfg(desktop)] + { + Ok(()) + } + #[cfg(not(desktop))] + { + Err("Timezone pinning is not available on this platform; pages see the device's own timezone.".to_string()) + } +} + +pub(crate) fn timezone_pin_status(browser: &BrowserSettings) -> TimezonePinStatus { + let support = timezone_pin_platform_support(); + let available = support.is_ok(); + + TimezonePinStatus { + enabled: browser.pin_timezone, + available, + unsupported_reason: support.err(), + active: browser.pin_timezone && available, + } +} + +/// The endpoint to hand to the webview and HTTP client, or `None` to go direct. +/// +/// Single source of truth for "is traffic proxied right now", so the tabs and the +/// favicon/capture client cannot disagree — a disagreement there is exactly the +/// leak this feature is meant to close. +pub(crate) fn active_proxy_url(browser: &BrowserSettings) -> Option { + if !browser.proxy.enabled || proxy_platform_support().is_err() { + return None; + } + parse_proxy_url(&browser.proxy.url).ok() +} + +pub(crate) fn ai_free_search(id: &str) -> AiFreeSearch { + match id { + "google" => AiFreeSearch::UrlParam("udm=14"), + "bing" => AiFreeSearch::QueryOperator("-ai"), + "duckduckgo" => AiFreeSearch::AltHost { + search: "https://noai.duckduckgo.com/?q=", + home: "https://noai.duckduckgo.com", + }, + // Yahoo and Ecosia, and anything unrecognised: nothing to append. + _ => AiFreeSearch::Unavailable, + } +} + +/// Turns search terms into a URL for the chosen engine. +/// +/// The AI-free step is applied here rather than at the call sites so that every +/// route to a search — the address bar, a bare query typed into it, and an iCE +/// concept sent to the web — cannot disagree about it. +pub(crate) fn search_url(terms: &str, search: SearchPrefs<'_>) -> String { + if !search.ai_free { + return format!( + "{}{}", + search_engine_prefix(search.engine), + urlencoding(terms) + ); + } + + match ai_free_search(search.engine) { + // Appended before encoding, so the space before the operator survives as + // `+` rather than being lost or double-escaped. + AiFreeSearch::QueryOperator(operator) => format!( + "{}{}", + search_engine_prefix(search.engine), + urlencoding(&format!("{terms} {operator}")) + ), + // The prefixes all end in `?q=` (or `?p=`), so the separator is `&`. + AiFreeSearch::UrlParam(param) => format!( + "{}{}&{param}", + search_engine_prefix(search.engine), + urlencoding(terms) + ), + AiFreeSearch::AltHost { search: prefix, .. } => { + format!("{prefix}{}", urlencoding(terms)) + } + AiFreeSearch::Unavailable => format!( + "{}{}", + search_engine_prefix(search.engine), + urlencoding(terms) + ), + } +} + +// DuckDuckGo is the fallback rather than Google in all three functions below. +// The default search engine is the single highest-traffic privacy decision the +// app makes — it sees every query typed into the address bar — so an unset or +// unrecognised value should land on the option that does not build a profile. pub(crate) fn search_engine_prefix(id: &str) -> &'static str { match id { + "google" => "https://www.google.com/search?q=", "bing" => "https://www.bing.com/search?q=", "yahoo" => "https://search.yahoo.com/search?p=", "ecosia" => "https://www.ecosia.org/search?q=", - "duckduckgo" => "https://duckduckgo.com/?q=", - _ => "https://www.google.com/search?q=", + _ => "https://duckduckgo.com/?q=", + } +} + +/// The engine's own landing page, which an empty address bar opens. +/// +/// Only DuckDuckGo differs when AI-free is on: its opt-out is a whole host, so +/// landing there keeps later searches typed into *that page* AI-free too. Google's +/// `udm=14` needs a query to apply to, so its home page is unchanged. +pub(crate) fn search_engine_home(search: SearchPrefs<'_>) -> &'static str { + if search.ai_free { + if let AiFreeSearch::AltHost { home, .. } = ai_free_search(search.engine) { + return home; + } + } + match search.engine { + "google" => "https://www.google.com", + "bing" => "https://www.bing.com", + "yahoo" => "https://search.yahoo.com", + "ecosia" => "https://www.ecosia.org", + _ => "https://duckduckgo.com", } } pub(crate) fn normalize_search_engine_id(value: &str) -> String { match value { "google" | "bing" | "yahoo" | "ecosia" | "duckduckgo" => value.to_string(), - _ => "google".to_string(), + _ => "duckduckgo".to_string(), } } @@ -145,6 +504,24 @@ pub(crate) fn title_from_url(url: &str) -> String { } } +/// Stable 16-byte data-store identifier for a container name. +/// +/// macOS-only because `data_store_identifier` is: Windows, Linux and Android +/// have no equivalent, so a container tab there shares the default store and the +/// isolation is nominal. See docs/SECURITY.md. +/// +/// UUIDv5 because it is a *deterministic* hash of the name: the same container +/// must resolve to the same WKWebsiteDataStore on every launch, or its cookies +/// are orphaned on disk and the user is silently logged out each restart. +#[cfg(any(target_os = "macos", test))] +pub(crate) fn container_data_store_id(container: &str) -> [u8; 16] { + const NAMESPACE: uuid::Uuid = uuid::Uuid::from_bytes([ + 0x41, 0x45, 0x54, 0x48, 0x45, 0x52, 0x43, 0x54, 0x52, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, + ]); + *uuid::Uuid::new_v5(&NAMESPACE, container.trim().to_lowercase().as_bytes()).as_bytes() +} + pub(crate) fn favicon_for_url(url: &str) -> Option { let parsed = Url::parse(url).ok()?; Some(format!( diff --git a/src-tauri/src/vectors.rs b/src-tauri/src/vectors.rs index a48fd25..b6a8341 100644 --- a/src-tauri/src/vectors.rs +++ b/src-tauri/src/vectors.rs @@ -195,6 +195,34 @@ pub(crate) async fn with_vectors_mut( Ok(result) } +/// `with_vectors_mut` for a deletion the user asked for, which rewrites the +/// sidecar instead of waiting for the usual compaction thresholds. +/// +/// Removing a chunk drops its text — the metadata file is rewritten whole on +/// every save — but the vector itself only leaves the sidecar when compaction +/// renumbers the live slots, and that needs 512 slots at ≥50% dead. Until then +/// the floats for a source the user deleted are still on disk. Embedding vectors +/// are not the text, but they are derived from it, and "delete" should not leave +/// a residue whose lifetime depends on how much else happens to be in the store. +/// +/// Kept separate from `with_vectors_mut` on purpose: this rewrites the whole +/// sidecar, which is the cost the ratio thresholds exist to avoid on the routine +/// save path. Only an explicit delete is worth paying it. +pub(crate) async fn with_vectors_deleted( + state: &State<'_, Backend>, + mutate: impl FnOnce(&mut VectorStoreData) -> T, +) -> Cmd { + let mut guard = state.vectors.write().await; + if guard.is_none() { + *guard = Some(load_vectors(&state.paths.chunks_path).await?); + } + let vectors = guard.as_mut().expect("vector store cache"); + let result = mutate(vectors); + compact_vectors(&state.paths.chunks_path, vectors).await?; + save_vector_metadata(&state.paths.chunks_path, vectors).await?; + Ok(result) +} + // Vector rows are large and machine-managed, so the metadata is persisted as compact // JSON instead of the pretty format used for small user-editable stores. pub(crate) async fn save_vector_metadata(path: &Path, data: &VectorStoreData) -> Cmd<()> { @@ -273,6 +301,18 @@ pub(crate) async fn compact_vectors_if_needed( return Ok(false); } + compact_vectors(path, data).await?; + Ok(true) +} + +// Renumbers the live chunks and rewrites the sidecar from scratch, unconditionally. +// Callers that only want this when it pays for itself go through +// compact_vectors_if_needed; a user-initiated delete calls it directly, because +// there the point is that the bytes actually leave the file. +pub(crate) async fn compact_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { + let live = data.embedded_count(); + let dead = data.next_slot.saturating_sub(live); + // Embedded chunks first in slot order, parked ones after, so renumbering walks // exactly the records that occupy the sidecar. data.chunks @@ -286,9 +326,10 @@ pub(crate) async fn compact_vectors_if_needed( next += 1; } data.next_slot = live; - diag_info!("compacted vector store, reclaimed {dead} dead slot(s)"); - write_vector_sidecar(path, data, 0).await?; - Ok(true) + if dead > 0 { + diag_info!("compacted vector store, reclaimed {dead} dead slot(s)"); + } + write_vector_sidecar(path, data, 0).await } pub(crate) async fn save_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { diff --git a/src-tauri/src/webview.rs b/src-tauri/src/webview.rs index 3eb1e2b..2321f15 100644 --- a/src-tauri/src/webview.rs +++ b/src-tauri/src/webview.rs @@ -104,8 +104,53 @@ pub(crate) fn create_native_webview( let app_for_download = app.clone(); let url = Url::parse(&tab.url).map_err(|error| error.to_string())?; - let builder = WebviewBuilder::new(label, WebviewUrl::External(url)) - .user_agent(DESKTOP_BROWSER_USER_AGENT) + let builder = WebviewBuilder::new(label, WebviewUrl::External(url)); + + // Container tabs get their own persistent store. wry's availability check is + // at *runtime* (macOS 14+) and falls back to the default store below that, so + // this costs nothing on older systems and needs no deployment-target bump — + // but it does mean a container silently shares the default jar on macOS 13 + // and earlier, and on every other platform, where the option is unsupported. + #[cfg(target_os = "macos")] + let builder = match tab.container.as_deref() { + Some(container) if !tab.private => { + builder.data_store_identifier(container_data_store_id(container)) + } + _ => builder, + }; + + // Routed through the proxy the app is currently configured for, if any. + // Read from `Backend` rather than from settings.json because this is a sync + // path and, more importantly, because it has to be the *same* value the HTTP + // client is using — one source, so tabs and favicon fetches cannot diverge. + // + // macOS: safe here only because `proxy()` returns None below macOS 14. wry + // sets `proxyConfigurations` through KVC with no version check, and that key + // does not exist on 13, so an ungated call would raise rather than degrade. + let builder = match state.proxy() { + Some(proxy) => builder.proxy_url(proxy), + None => builder, + }; + + // Document-start, every frame. Both halves matter: on load is too late to + // stop a page reading the real timezone, and main-frame-only would leave any + // embedded tracker iframe reading it anyway. + let builder = if state.pin_timezone.load(std::sync::atomic::Ordering::Relaxed) { + builder.initialization_script_for_all_frames(TIMEZONE_PIN_SCRIPT) + } else { + builder + }; + + let builder = builder + .user_agent(BROWSER_USER_AGENT) + // macOS/iOS: a nonPersistent WKWebsiteDataStore. Linux: an ephemeral + // WebContext. Windows: needs WebView2 runtime 101+, and does nothing on + // older ones — which is why the tab is also kept out of the session file + // rather than relying on the engine alone. Reading a private tab — + // capture, or AiON's current-page context — is deliberately not part of + // that defence: both write locally and emit nothing, so they are the + // user's call, not the engine's. See docs/SECURITY.md. + .incognito(tab.private) .on_navigation(move |url| { let state = app_for_navigation.state::(); update_tab_navigation_state(&state, &tab_id_for_navigation, url.as_str(), true); @@ -210,6 +255,8 @@ pub(crate) fn create_native_webview( let webview = window .add_child(builder, bounds.position, bounds.size) .map_err(|error| error.to_string())?; + // Before the first paint, so no tracker request escapes an unblocked tab. + content_blocking::apply_to_webview(&webview); webview.hide().map_err(|error| error.to_string())?; Ok(webview) } @@ -220,7 +267,8 @@ pub(crate) fn create_native_tab_from_url( state: &State, raw_url: &str, ) -> Cmd<()> { - let url = normalize_url(raw_url, "google"); + // A page opened this (target=_blank, window.open), so it is already a URL. + let url = normalize_url(raw_url, SearchPrefs::fallback()); let tab = ManagedTab::new("browser", &url); let tab_id = tab.id.clone(); { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c39bbf5..d50169e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ "default-src": "'self'", "script-src": "'self'", "style-src": ["'self'", "'unsafe-inline'"], - "img-src": ["'self'", "data:", "blob:", "https:", "http:"], + "img-src": ["'self'", "data:", "blob:"], "font-src": "'self'", "connect-src": ["'self'", "ipc:", "http://ipc.localhost"], "media-src": ["'self'", "data:", "blob:"], @@ -40,7 +40,7 @@ "default-src": "'self'", "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], "style-src": ["'self'", "'unsafe-inline'"], - "img-src": ["'self'", "data:", "blob:", "https:", "http:"], + "img-src": ["'self'", "data:", "blob:"], "font-src": "'self'", "connect-src": [ "'self'", @@ -59,9 +59,7 @@ }, "plugins": { "updater": { - "endpoints": [ - "https://github.com/CanPixel/aether/releases/latest/download/latest.json" - ], + "endpoints": ["https://github.com/CanPixel/aether/releases/latest/download/latest.json"], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5QzUwQTk2MkJFQjA4MEIKUldRTENPc3JsZ3JGU1FWamQrMkUrb0hmSDR2MktHZjltZVJyb0RCcHQzNFdEelIxNlYzdmlvOFgK", "windows": { "installMode": "passive" @@ -70,14 +68,8 @@ }, "bundle": { "active": true, - "targets": [ - "app" - ], - "icon": [ - "../resources/icon.png", - "../build/icon.icns", - "../build/icon.ico" - ], + "targets": ["app"], + "icon": ["../resources/icon.png", "../build/icon.icns", "../build/icon.ico"], "macOS": { "minimumSystemVersion": "10.15", "dmg": { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index da94e26..2859119 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -71,6 +71,12 @@ import { QuickAction } from './types/ui' import { cleanTitle, countLabel, + describeAiFreeSearch, + describeProxy, + describeTimezonePin, + proxyChangeNotice, + timezonePinChangeNotice, + describeContentBlocking, formatByteSize, formatUpdateProgress, formatVisibleModelName, @@ -78,8 +84,9 @@ import { getTabTint, normalizeComparableUrl } from './utils/aether-ui' -import { HAS_NATIVE_TAB_WEBVIEWS, IS_ANDROID } from './utils/platform' +import { HAS_NATIVE_TAB_WEBVIEWS, IS_ANDROID, IS_DESKTOP } from './utils/platform' import { useDismissableOverlay } from './utils/dismissable-overlay' +import { useStableHandler } from './utils/stable-handler' import { ChevronDown, ChevronUp, @@ -88,8 +95,11 @@ import { RefreshCw, FileText, SearchIcon, + Shield, + ShieldAlert, Snowflake, SunMoon, + Trash2, Waves, Wind @@ -448,7 +458,15 @@ function App(): React.JSX.Element { >({}) const [status, setStatus] = useState(null) const [settings, setSettings] = useState({ - browser: { defaultSearchEngine: 'google' }, + // Placeholder until the backend's settings arrive; it must match + // BrowserSettings::default() in src-tauri/src/types.rs, or Settings highlights + // an engine the backend is not actually using for the first frame. + browser: { + defaultSearchEngine: 'duckduckgo', + aiFreeSearch: true, + proxy: { enabled: false, url: 'socks5://127.0.0.1:9050' }, + pinTimezone: false + }, developerMode: false, updates: { autoCheck: true }, appearance: 'light' @@ -477,8 +495,6 @@ function App(): React.JSX.Element { ) const [activeTabId, setActiveTabId] = useState('') const [selectedCollectionId, setSelectedCollectionId] = useState('') - const [addressDraft, setAddressDraft] = useState('aether://dashboard') - const [addressFocused, setAddressFocused] = useState(false) const [chatPrompt, setChatPrompt] = useState('') const [askCollectionId, setAskCollectionId] = useState('') const [askIncludeCurrentPage, setAskIncludeCurrentPage] = useState(false) @@ -573,17 +589,11 @@ function App(): React.JSX.Element { [report] ) - const reportSuccess = useCallback( - (message: string): void => report(message, 'success'), - [report] - ) + const reportSuccess = useCallback((message: string): void => report(message, 'success'), [report]) // An action the user asked for that could not start yet — not a failure, but it must // be visible, otherwise the control simply appears to do nothing. - const reportBlocked = useCallback( - (message: string): void => report(message, 'info'), - [report] - ) + const reportBlocked = useCallback((message: string): void => report(message, 'info'), [report]) const activeTab = useMemo( () => tabs.find((tab) => tab.id === activeTabId) ?? tabs.find((tab) => tab.isActive) ?? tabs[0], @@ -655,19 +665,20 @@ function App(): React.JSX.Element { : (usableAskCollections[0]?.id ?? ''), [usableAskCollections, selectedCollectionId] ) - const addressValue = addressFocused - ? addressDraft - : dashboardOpen - ? workspaceMode === 'crystallizer' - ? 'ice://crystallizer' - : workspaceMode === 'flow' - ? 'flow://semantic-graph' - : workspaceMode === 'air' - ? 'air://renderer' - : 'æther://dashboard' - : isStartPage - ? '' - : activeTabUrl + // What the address bar shows when nobody is typing in it. The draft itself now + // lives in BrowserChrome (and MobileShell owns its own input), so this is purely + // derived and changes only when the tab or workspace does. + const displayAddress = dashboardOpen + ? workspaceMode === 'crystallizer' + ? 'ice://crystallizer' + : workspaceMode === 'flow' + ? 'flow://semantic-graph' + : workspaceMode === 'air' + ? 'air://renderer' + : 'æther://dashboard' + : isStartPage + ? '' + : activeTabUrl const activeTabHubShortcut = useMemo(() => { if (!activeTabUrl) return undefined const activeUrl = normalizeComparableUrl(activeTabUrl) @@ -800,31 +811,37 @@ function App(): React.JSX.Element { ]) }, [refreshAirRecent, refreshCollections, refreshSavedIcebergs, refreshShell, refreshShortcuts]) - const checkForUpdates = useCallback(async (options?: { quiet?: boolean }): Promise => { - if (!options?.quiet) setUpdateChecking(true) - try { - const result = await window.aether.system.checkForUpdate() - setUpdateCheck(result) - setSettings((current) => ({ - ...current, - updates: { - ...current.updates, - lastCheckedAt: result.checkedAt + const checkForUpdates = useCallback( + async (options?: { quiet?: boolean }): Promise => { + if (!options?.quiet) setUpdateChecking(true) + try { + const result = await window.aether.system.checkForUpdate() + setUpdateCheck(result) + setSettings((current) => ({ + ...current, + updates: { + ...current.updates, + lastCheckedAt: result.checkedAt + } + })) + if (result.updateAvailable) { + report( + `ÆTHER ${result.latestVersion ?? result.latestName ?? 'update'} is available.`, + 'info' + ) + } else if (!options?.quiet && result.error) { + report(result.error, 'error') + } else if (!options?.quiet) { + reportSuccess('ÆTHER is up to date.') } - })) - if (result.updateAvailable) { - report(`ÆTHER ${result.latestVersion ?? result.latestName ?? 'update'} is available.`, 'info') - } else if (!options?.quiet && result.error) { - report(result.error, 'error') - } else if (!options?.quiet) { - reportSuccess('ÆTHER is up to date.') + } catch (error) { + if (!options?.quiet) reportError(error) + } finally { + if (!options?.quiet) setUpdateChecking(false) } - } catch (error) { - if (!options?.quiet) reportError(error) - } finally { - if (!options?.quiet) setUpdateChecking(false) - } - }, [report, reportError, reportSuccess]) + }, + [report, reportError, reportSuccess] + ) const searchLibrary = useCallback( async (query: string, collectionId?: string): Promise => { @@ -871,8 +888,7 @@ function App(): React.JSX.Element { // Keyboard equivalent: a drag-only control is unusable without a pointer. const handlePanelResizeKey = useCallback((event: React.KeyboardEvent): void => { const step = event.shiftKey ? 40 : 12 - const delta = - event.key === 'ArrowLeft' ? step : event.key === 'ArrowRight' ? -step : 0 + const delta = event.key === 'ArrowLeft' ? step : event.key === 'ArrowRight' ? -step : 0 if (delta === 0) return event.preventDefault() const next = clampPanelWidth(panelWidthRef.current + delta) @@ -980,9 +996,7 @@ function App(): React.JSX.Element { setExportingDiagnostics(true) try { const result = await window.aether.system.exportDiagnostics() - reportSuccess( - `Diagnostics log (${formatByteSize(result.byteSize)}) saved to ${result.path}` - ) + reportSuccess(`Diagnostics log (${formatByteSize(result.byteSize)}) saved to ${result.path}`) } catch (error) { reportError(error) } finally { @@ -990,6 +1004,16 @@ function App(): React.JSX.Element { } }, [reportError, reportSuccess]) + const clearBrowsingData = useCallback(async (): Promise => { + setNotice(null) + try { + await window.aether.tabs.clearBrowsingData() + reportSuccess('Cleared cookies, caches and site storage. Your library is untouched.') + } catch (error) { + reportError(error) + } + }, [reportError, reportSuccess]) + const exportLibrary = useCallback(async (): Promise => { setExportingLibrary(true) setNotice(null) @@ -1050,7 +1074,12 @@ function App(): React.JSX.Element { }, [checkForUpdates, settings.updates.autoCheck]) const createTab = useCallback( - async (input?: { url?: string }): Promise => { + async (input?: { + url?: string + search?: string + private?: boolean + container?: string + }): Promise => { setNotice(null) try { @@ -1313,13 +1342,17 @@ function App(): React.JSX.Element { await window.aether.layout.setModalOverlayOpen(Boolean(settingsOpen || collectionDialog)) } - async function openModelSetup(): Promise { + // `preselect` comes from a greyed rung on the model slider: the user pointed at a + // specific missing model, so the modal opens with that one already chosen instead + // of making them find it again. + async function openModelSetup(preselect?: ModelDownloadChoice): Promise { setSettingsOpen(false) setModelSetupDismissed(false) setModelSetupRequested(true) setModelSetupError(null) setModelSetupComplete(false) setModelDownloadProgress([]) + if (preselect) setSelectedSetupModels([preselect]) await window.aether.layout.setModalOverlayOpen(true) } @@ -1472,9 +1505,8 @@ function App(): React.JSX.Element { void window.aether.layout.setModalOverlayOpen(false).catch(() => undefined) } - async function navigate(event: FormEvent): Promise { - event.preventDefault() - const target = addressDraft.trim() + async function navigate(value: string): Promise { + const target = value.trim() if (!target) return if (dashboardOpen && isDashboardAddress(target)) return @@ -1674,7 +1706,9 @@ function App(): React.JSX.Element { setAskCollectionId(result.collectionId) setSemanticTrailResult(null) setFlowGraphResult(null) - reportSuccess(`Saved ${countLabel(result.chunkCount, 'chunk')} into ${result.collectionName}.`) + reportSuccess( + `Saved ${countLabel(result.chunkCount, 'chunk')} into ${result.collectionName}.` + ) }) } @@ -2132,9 +2166,12 @@ function App(): React.JSX.Element { } } + // Handed to the backend as search *terms*, not as a pre-built URL. Two things + // used to be wrong with building it here: it hardcoded Google regardless of the + // user's chosen engine, and a URL assembled in the renderer skips the AI-free + // step that every other search in the app goes through. async function openCrystallizedTopic(_keyword: string, item: IcebergItem): Promise { - const url = `https://www.google.com/search?q=${encodeURIComponent(`${item.name}`)}` - await createTab({ url }) + await createTab({ search: item.name }) setWorkspaceMode('dashboard') setDashboardOpen(false) } @@ -2191,10 +2228,64 @@ function App(): React.JSX.Element { browser: { defaultSearchEngine } }) setSettings(nextSettings) + // status.aiFreeSearch describes the *selected* engine, so it goes stale the + // moment the engine changes — refetched here rather than left to the next + // shell refresh, which may be a while. + setStatus(await window.aether.system.status()) reportSuccess('Default search engine updated.') }) } + async function updateAiFreeSearch(aiFreeSearch: boolean): Promise { + await runTask('Updating settings', async () => { + const nextSettings = await window.aether.system.updateSettings({ + browser: { aiFreeSearch } + }) + setSettings(nextSettings) + setStatus(await window.aether.system.status()) + reportSuccess(aiFreeSearch ? 'AI-free search enabled.' : 'AI-free search disabled.') + }) + } + + async function updateProxyEnabled(enabled: boolean): Promise { + await runTask('Updating settings', async () => { + const nextSettings = await window.aether.system.updateSettings({ + browser: { proxy: { enabled } } + }) + setSettings(nextSettings) + setStatus(await window.aether.system.status()) + reportSuccess(proxyChangeNotice(enabled)) + }) + } + + // Committed on blur/Enter rather than per keystroke: the backend rejects an + // invalid endpoint, and validating half-typed input would error on every + // character of a perfectly good address. + async function updateProxyUrl(url: string): Promise { + if (url.trim() === settings.browser.proxy.url) { + return + } + await runTask('Updating settings', async () => { + const nextSettings = await window.aether.system.updateSettings({ + browser: { proxy: { url } } + }) + setSettings(nextSettings) + setStatus(await window.aether.system.status()) + reportSuccess('Proxy address updated.') + }) + } + + async function updatePinTimezone(pinTimezone: boolean): Promise { + await runTask('Updating settings', async () => { + const nextSettings = await window.aether.system.updateSettings({ + browser: { pinTimezone } + }) + setSettings(nextSettings) + setStatus(await window.aether.system.status()) + reportSuccess(timezonePinChangeNotice(pinTimezone)) + }) + } + async function updateDeveloperMode(developerMode: boolean): Promise { await runTask('Updating settings', async () => { const nextSettings = await window.aether.system.updateSettings({ developerMode }) @@ -2361,6 +2452,53 @@ function App(): React.JSX.Element { const showRailTooltips = dashboardOpen const startPageActive = !dashboardOpen && isStartPage + // Stable identities for the handlers the memoized panels receive. Most of the + // handlers above are plain `async function` declarations, so they are new + // objects on every render — passing them straight down would make the memo + // comparison fail every time and the memo pointless. + // + // Anything already wrapped in useCallback is deliberately absent: it is stable + // as it stands, and routing it through here would only add a layer. + const onOpenSearchHit = useStableHandler(openSearchHit) + const onDeleteCapture = useStableHandler(deleteCapture) + const onDeleteSavedIceberg = useStableHandler(deleteSavedIceberg) + const onDeleteShortcut = useStableHandler(deleteShortcut) + const onMoveCapture = useStableHandler(moveCapture) + const onOpenCapture = useStableHandler(openCapture) + const onOpenSavedIceberg = useStableHandler(openSavedIceberg) + const onOpenShortcut = useStableHandler(openShortcut) + const onReorderCollections = useStableHandler(reorderCollections) + const onReorderSavedIcebergs = useStableHandler(reorderSavedIcebergs) + const onReorderShortcuts = useStableHandler(reorderShortcuts) + const onSelectCollection = useStableHandler(selectCollection) + const onOpenCollectionDialog = useStableHandler((state: NonNullable) => { + void openCollectionDialog(state) + }) + const onAskCollection = useStableHandler((collectionId: string) => { + void askCollectionHub(collectionId) + }) + const onGenerateIceberg = useStableHandler(generateIceberg) + const onOpenCrystallizedTopic = useStableHandler(openCrystallizedTopic) + const onSaveIceberg = useStableHandler(saveIceberg) + const onBuildFlowGraph = useStableHandler(buildFlowGraph) + const onOpenFlowHub = useStableHandler(openFlowHub) + const onOpenFlowSource = useStableHandler(openFlowSource) + const onAsk = useStableHandler(ask) + const onCancelAsk = useStableHandler(cancelAsk) + const onTogglePanel = useStableHandler(togglePanel) + const onUpdateLocalModels = useStableHandler(updateLocalModels) + const onOpenModelSetup = useStableHandler(openModelSetup) + const onOpenCitation = useStableHandler(openCitation) + const onOpenSemanticTrailItem = useStableHandler(openSemanticTrailItem) + + // Recomputed inline in the JSX before, which handed the panel a new number + // (fine) from a new array scan on every render (wasteful, and the scan itself + // is over every tab). + const openTabCount = useMemo( + () => tabs.filter((tab) => tab.url && !tab.url.startsWith('aether://')).length, + [tabs] + ) + // Shared between the desktop and mobile shells so the two trees stay in sync // without duplicating these prop lists. const findBarNode = @@ -2384,11 +2522,11 @@ function App(): React.JSX.Element { key={activeSavedIceberg?.id ?? 'new-iceberg'} openedIceberg={activeSavedIceberg} savedIcebergs={savedIcebergs} - onDeleteSaved={deleteSavedIceberg} - onGenerate={generateIceberg} - onOpenSaved={openSavedIceberg} - onOpenTopic={openCrystallizedTopic} - onSave={saveIceberg} + onDeleteSaved={onDeleteSavedIceberg} + onGenerate={onGenerateIceberg} + onOpenSaved={onOpenSavedIceberg} + onOpenTopic={onOpenCrystallizedTopic} + onSave={onSaveIceberg} /> ) @@ -2399,35 +2537,29 @@ function App(): React.JSX.Element { searching={searching} searchLibrary={searchLibrary} clearSearch={clearSearch} - openSearchHit={openSearchHit} + openSearchHit={onOpenSearchHit} capturesByCollection={capturesByCollection} capturingLink={capturingLink} captureLink={captureLink} captureOpenTabs={captureOpenTabs} - openTabCount={ - tabs.filter((tab) => tab.url && !tab.url.startsWith('aether://')).length - } + openTabCount={openTabCount} collections={collections} - deleteCapture={deleteCapture} - deleteSavedIceberg={deleteSavedIceberg} - deleteShortcut={deleteShortcut} - moveCapture={moveCapture} - openCapture={openCapture} - openSavedIceberg={openSavedIceberg} - openShortcut={openShortcut} - openCollectionDialog={(state) => { - void openCollectionDialog(state) - }} - askCollection={(collectionId) => { - void askCollectionHub(collectionId) - }} - reorderCollections={reorderCollections} - reorderSavedIcebergs={reorderSavedIcebergs} - reorderShortcuts={reorderShortcuts} + deleteCapture={onDeleteCapture} + deleteSavedIceberg={onDeleteSavedIceberg} + deleteShortcut={onDeleteShortcut} + moveCapture={onMoveCapture} + openCapture={onOpenCapture} + openSavedIceberg={onOpenSavedIceberg} + openShortcut={onOpenShortcut} + openCollectionDialog={onOpenCollectionDialog} + askCollection={onAskCollection} + reorderCollections={onReorderCollections} + reorderSavedIcebergs={onReorderSavedIcebergs} + reorderShortcuts={onReorderShortcuts} selectedCollectionId={selectedCollectionId} savedIcebergs={savedIcebergs} shortcuts={shortcuts} - selectCollection={selectCollection} + selectCollection={onSelectCollection} /> ) @@ -2474,6 +2606,7 @@ function App(): React.JSX.Element { libraryExport={libraryExport} reindexing={reindexing} indexStatus={indexStatus} + systemStatus={status} onReindexLibrary={reindexLibrary} settings={settings} updateCheck={updateCheck} @@ -2488,13 +2621,18 @@ function App(): React.JSX.Element { onRelaunchForUpdate={relaunchForUpdate} onClose={closeSettings} onDefaultSearchEngineChange={updateDefaultSearchEngine} + onAiFreeSearchChange={updateAiFreeSearch} + onProxyEnabledChange={updateProxyEnabled} + onProxyUrlChange={updateProxyUrl} + onPinTimezoneChange={updatePinTimezone} + onClearBrowsingData={clearBrowsingData} onDeveloperModeChange={updateDeveloperMode} onExportLibrary={exportLibrary} onCheckForUpdates={() => checkForUpdates()} onOpenUpdateRelease={openUpdateRelease} onAppearanceChange={updateAppearance} onUpdateAutoCheck={updateAutoCheck} - onOpenModelSetup={openModelSetup} + onOpenModelSetup={onOpenModelSetup} /> )} @@ -2518,7 +2656,7 @@ function App(): React.JSX.Element { {toast && }