From 8e74635086931480ac5878a60a4775e6a19e7e81 Mon Sep 17 00:00:00 2001 From: Can Ur Date: Tue, 28 Jul 2026 14:57:39 +0200 Subject: [PATCH 1/5] PRIVACY UPDATE --- .github/workflows/checks.yml | 93 +++ .gitignore | 2 + .../ice-interaction-crystallization.md | 2 +- README.md | 28 +- THIRD_PARTY_LICENSES.md | 18 +- docs/LICENSING.md | 10 +- docs/SECURITY.md | 259 +++++++- docs/SIGNING.md | 70 +-- package.json | 3 +- scripts/check-platforms.sh | 140 +++++ src-tauri/Cargo.lock | 38 ++ src-tauri/Cargo.toml | 65 +- src-tauri/examples/verify_content_rules.rs | 90 +++ .../main/java/com/canur/aether/TabsPlugin.kt | 12 + .../resources/content-blocking-rules.json | 497 +++++++++++++++ src-tauri/src/air.rs | 163 ++--- src-tauri/src/browsing_data.rs | 61 ++ src-tauri/src/browsing_data/gtk.rs | 46 ++ src-tauri/src/browsing_data/macos.rs | 27 + src-tauri/src/browsing_data/windows.rs | 67 +++ src-tauri/src/commands.rs | 480 +++++++++------ src-tauri/src/content_blocking.rs | 324 ++++++++++ src-tauri/src/content_blocking/gtk.rs | 152 +++++ src-tauri/src/content_blocking/macos.rs | 109 ++++ src-tauri/src/content_blocking/windows.rs | 194 ++++++ src-tauri/src/extract.rs | 82 ++- src-tauri/src/favicon.rs | 221 +++++++ src-tauri/src/flow.rs | 83 ++- src-tauri/src/iceberg.rs | 2 +- src-tauri/src/inference.rs | 147 +++-- src-tauri/src/lib.rs | 565 +++++++++++++++++- src-tauri/src/retrieval.rs | 170 +++--- src-tauri/src/store.rs | 9 - src-tauri/src/system.rs | 195 +++++- src-tauri/src/types.rs | 85 ++- src-tauri/src/util.rs | 115 +++- src-tauri/src/vectors.rs | 47 +- src-tauri/src/webview.rs | 26 +- src-tauri/tauri.conf.json | 18 +- src/renderer/src/App.tsx | 328 ++++++---- src/renderer/src/assets/styles/air-view.css | 12 +- .../src/assets/styles/browser-chrome.css | 94 ++- src/renderer/src/assets/styles/flow-map.css | 18 +- src/renderer/src/assets/styles/flow-view.css | 6 +- .../src/assets/styles/mobile-shell.css | 20 +- .../src/assets/styles/setup-settings.css | 97 ++- src/renderer/src/components/BrowserChrome.tsx | 135 ++++- src/renderer/src/components/Crystallizer.tsx | 29 +- src/renderer/src/components/Dashboard.tsx | 29 +- src/renderer/src/components/FlowView.tsx | 11 +- .../src/components/IntelligencePanel.tsx | 24 +- src/renderer/src/components/MobileShell.tsx | 418 +++++++------ .../src/components/ModelSetupModal.tsx | 9 +- src/renderer/src/components/icons.tsx | 26 + src/renderer/src/tauri-aether.ts | 10 +- src/renderer/src/utils/aether-ui.ts | 21 + src/renderer/src/utils/platform.ts | 4 + src/renderer/src/utils/site-favicon.ts | 61 ++ src/renderer/src/utils/stable-handler.ts | 34 ++ src/renderer/src/utils/web-content-bounds.ts | 9 +- src/shared/aether.ts | 37 +- 61 files changed, 5112 insertions(+), 1035 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100755 scripts/check-platforms.sh create mode 100644 src-tauri/examples/verify_content_rules.rs create mode 100644 src-tauri/resources/content-blocking-rules.json create mode 100644 src-tauri/src/browsing_data.rs create mode 100644 src-tauri/src/browsing_data/gtk.rs create mode 100644 src-tauri/src/browsing_data/macos.rs create mode 100644 src-tauri/src/browsing_data/windows.rs create mode 100644 src-tauri/src/content_blocking.rs create mode 100644 src-tauri/src/content_blocking/gtk.rs create mode 100644 src-tauri/src/content_blocking/macos.rs create mode 100644 src-tauri/src/content_blocking/windows.rs create mode 100644 src-tauri/src/favicon.rs create mode 100644 src/renderer/src/utils/site-favicon.ts create mode 100644 src/renderer/src/utils/stable-handler.ts 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..9ef9427 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ src-tauri/gen/* src-tauri/gen/schemas/ src-tauri/target src-tauri/target-linux-* +# scripts/check-platforms.sh +src-tauri/target-platform-check 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/SECURITY.md b/docs/SECURITY.md index ad61772..ea89405 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,218 @@ 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. +## 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 IP address, 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. + +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` | + +### 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. + +### 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 also: + +- **never written to the session file** (`persist_session_tabs`), and +- **barred from capture**, both directly and through AiON's "current page" + context — answers and citations are persisted to the conversation store, which + would otherwise be a second route onto disk. + +That second point is the one to keep in mind when adding any new feature that +reads the active tab: ÆTHER's entire purpose is a durable local index of what you +read, and a private tab is a promise not to build one. + +### 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]."#; 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..5ace821 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,8 @@ 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 +296,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..1a8289a 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -15,8 +15,19 @@ pub(crate) struct Backend { // 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>, + // 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, // Throttle for window geometry writes; resize/move fire continuously. #[cfg(desktop)] @@ -45,11 +56,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 +186,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 +234,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)] @@ -678,14 +730,35 @@ pub(crate) struct SystemStatus { pub(crate) db_path: String, pub(crate) library_path: String, pub(crate) collections: Vec, + pub(crate) content_blocking: ContentBlockingStatus, #[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, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CreateTabInput { pub(crate) url: Option, + #[serde(default)] + pub(crate) private: bool, + #[serde(default)] + pub(crate) container: Option, } #[derive(Deserialize)] @@ -1212,7 +1285,9 @@ 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(), } } } diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 5cc3f5c..3bd2b08 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -41,13 +41,86 @@ pub(crate) fn normalize_captured_text(text: &str) -> String { .to_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_engine: &str) -> String { let trimmed = raw_url.trim(); if trimmed.is_empty() { - return "https://www.google.com".to_string(); + return search_engine_home(search_engine).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!( @@ -65,20 +138,34 @@ pub(crate) fn normalize_url(raw_url: &str, search_engine: &str) -> String { format!("https://{trimmed}") } +// 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=", + } +} + +pub(crate) fn search_engine_home(id: &str) -> &'static str { + match id { + "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 +232,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..8935dcd 100644 --- a/src-tauri/src/webview.rs +++ b/src-tauri/src/webview.rs @@ -104,8 +104,28 @@ 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, + }; + + 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 barred from capture and from + // the session file rather than relying on the engine alone. + .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 +230,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) } 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..9e37aa3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -71,6 +71,7 @@ import { QuickAction } from './types/ui' import { cleanTitle, countLabel, + describeContentBlocking, formatByteSize, formatUpdateProgress, formatVisibleModelName, @@ -78,8 +79,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_MACOS } from './utils/platform' import { useDismissableOverlay } from './utils/dismissable-overlay' +import { useStableHandler } from './utils/stable-handler' import { ChevronDown, ChevronUp, @@ -88,8 +90,11 @@ import { RefreshCw, FileText, SearchIcon, + Shield, + ShieldAlert, Snowflake, SunMoon, + Trash2, Waves, Wind @@ -477,8 +482,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 +576,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 +652,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 +798,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 +875,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 +983,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 +991,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 +1061,11 @@ function App(): React.JSX.Element { }, [checkForUpdates, settings.updates.autoCheck]) const createTab = useCallback( - async (input?: { url?: string }): Promise => { + async (input?: { + url?: string + private?: boolean + container?: string + }): Promise => { setNotice(null) try { @@ -1472,9 +1487,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 +1688,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}.` + ) }) } @@ -2361,6 +2377,55 @@ 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 +2449,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 +2464,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 +2533,7 @@ function App(): React.JSX.Element { libraryExport={libraryExport} reindexing={reindexing} indexStatus={indexStatus} + systemStatus={status} onReindexLibrary={reindexLibrary} settings={settings} updateCheck={updateCheck} @@ -2488,13 +2548,14 @@ function App(): React.JSX.Element { onRelaunchForUpdate={relaunchForUpdate} onClose={closeSettings} onDefaultSearchEngineChange={updateDefaultSearchEngine} + onClearBrowsingData={clearBrowsingData} onDeveloperModeChange={updateDeveloperMode} onExportLibrary={exportLibrary} onCheckForUpdates={() => checkForUpdates()} onOpenUpdateRelease={openUpdateRelease} onAppearanceChange={updateAppearance} onUpdateAutoCheck={updateAutoCheck} - onOpenModelSetup={openModelSetup} + onOpenModelSetup={onOpenModelSetup} /> )} @@ -2518,7 +2579,7 @@ function App(): React.JSX.Element { {toast && }