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 `
+
+
+