diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..cc6e81c --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,9 @@ +# Android inference speed depends on these reaching llama-cpp-sys-2's build +# script: it hardcodes -march=armv8-a (2014 baseline, no dotprod/i8mm/fp16 +# SIMD), and env CFLAGS are appended after its own flags, so the last -march +# wins. Set here (not in shell wrappers) because the Gradle daemon invokes +# cargo with its own frozen environment — exports in scripts/android.sh never +# reach it. Target-scoped names make this a no-op for desktop builds. +[env] +CFLAGS_aarch64_linux_android = "-march=armv8.6-a+fp16" +CXXFLAGS_aarch64_linux_android = "-march=armv8.6-a+fp16" diff --git a/README.md b/README.md index aa01f3d..fdb5644 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ ## The Æther That Is +> **New here?** Read the plain-language introduction: [What is ÆTHER?](docs/WHAT-IS-AETHER.md) + ÆTHER is a Tauri-native desktop browser built for local knowledge work. It turns ordinary browsing into a private research loop: save useful pages, organize them into knowledge hubs, embed them on-device, and ask AiON for citation-backed answers grounded in what you actually captured. It is not a cloud wrapper and it is not an external chatbot sidebar. The local wisdom path runs in the app process through GGUF models and a bundled llama.cpp runtime. @@ -58,8 +60,8 @@ It is not a cloud wrapper and it is not an external chatbot sidebar. The local w - **iCE Maps**: generate layered concept atlases for research topics. Create a Cartography of Topics for new research hooks. - **Native Shell**: Tauri, Rust commands, Vite renderer, and no required local server sidecar. -| Grounded local answers | Complexity maps | -| --- | --- | +| Grounded local answers | Complexity maps | +| ------------------------------------------------------------------ | ------------------------------------------------------------ | | ![AiON grounded answer](docs/images/readme/aether-aion-answer.png) | ![iCE complexity map](docs/images/readme/aether-ice-map.png) | ## Privacy Ice Wall @@ -75,18 +77,18 @@ Local by design: - AiON answers generated by in-process local models - iCE topic maps -Normal browsing is still normal browsing. Websites can make their own network requests, track sessions, run JavaScript, and communicate with their servers. +Normal browsing is still normal browsing. Websites can make their own network requests, track sessions, run JavaScript, and communicate with their servers. The privacy boundary applies to ÆTHER's indexing and intelligence pipeline, not to third-party websites loaded in the browser. ## Local Wisdom Setup 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** | Smaller, faster chat model for everyday answers and summaries | `google/gemma-4-E2B-it-qat-q4_0-gguf` | ~3.35 GB | -| **AiON WiSE** | Larger 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** | Smaller, faster chat model for everyday answers and summaries | `google/gemma-4-E2B-it-qat-q4_0-gguf` | ~3.35 GB | +| **AiON WiSE** | Larger chat model for richer synthesis and iCE maps | `google/gemma-4-E4B-it-qat-q4_0-gguf` | ~5.15 GB | Install choices: @@ -444,7 +446,7 @@ Install Android Studio, then install these SDK pieces through Android Studio's S - Android NDK - Android Emulator, if you want emulator testing -Set the Android environment variables in your shell profile: +The `bun run android:*` scripts go through `scripts/android.sh`, which resolves `ANDROID_HOME`, picks the newest installed NDK, and exports the NDK variable spellings the whole toolchain needs (llama.cpp's build script reads `ANDROID_NDK_ROOT`, which the Tauri CLI does not set). If your SDK lives somewhere other than `~/Library/Android/sdk`, set the variables in your shell profile: ```bash export ANDROID_HOME="$HOME/Library/Android/sdk" @@ -507,7 +509,7 @@ Android outputs are generated under: src-tauri/gen/android/app/build/outputs/ ``` -Current mobile limitation: the React shell can be packaged for Android, but ÆTHER's current live browser tab surface uses Tauri desktop child webviews. That desktop-only browser surface must be replaced with an Android-compatible browser path before the Android app behaves like the macOS Tauri app. +Android browser behavior: Tauri's per-tab child webviews (`Window::add_child`) are desktop-only, so on Android each browser tab gets a real native `android.webkit.WebView` managed by the Kotlin `TabsPlugin` (`src-tauri/gen/android/.../TabsPlugin.kt`), layered above the app UI exactly where the renderer's tab surface sits. Rust stays the single source of truth for tab state: it drives the plugin through the same `*_native_webview` functions the desktop uses, and navigation, title, and find-in-page events flow back into Rust so the address bar, tab titles, and back/forward history stay correct. The hardware back button walks the in-app layers (find bar → page history → dashboard) before backgrounding the app. ## Linux Build diff --git a/build/icon.ico b/build/icon.ico index f848680..f718ffc 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/docs/WHAT-IS-AETHER.md b/docs/WHAT-IS-AETHER.md new file mode 100644 index 0000000..95acc9c --- /dev/null +++ b/docs/WHAT-IS-AETHER.md @@ -0,0 +1,106 @@ +# What is ÆTHER? + +**ÆTHER is a web browser with a memory.** + +A normal browser forgets. You read twenty pages while researching something, close the tabs, and a week later all that's left is a vague bookmark folder and the hope that you'll find the right page again. ÆTHER is built around a different idea: the pages you find valuable should become _your_ knowledge — saved, organized, searchable by meaning, and ready to answer questions — without any of it ever leaving your computer. + +Think of it as three things merged into one app: + +1. **A browser** - tabs, address bar, search. Nothing to relearn. +2. **A filing cabinet that organizes itself** — one click saves the page you're reading into a topic collection. +3. **A research assistant who has actually read your files** — ask a question, get an answer built from the pages you saved, with citations pointing back to them. + +And the key twist: the "assistant" is an AI that runs entirely **on your own machine**. No cloud, no account, no subscription, no telemetry. Once the models are downloaded, the intelligence works with the network cable unplugged. + +--- + +## The core loop + +Everything in ÆTHER revolves around one simple cycle: + +``` + Browse → Capture → Organize → Ask → (discover something new) → Browse … +``` + +**Browse.** Use ÆTHER like any browser. Open tabs, search, read. + +**Capture.** Found something worth keeping? Hit capture. ÆTHER extracts the readable text of the page (the article, not the ads), stores it locally, and quietly indexes it by _meaning_ — so later you can find it even if you don't remember any exact words from it. + +**Organize.** Captures live in **Knowledge Hubs** — collections you create per topic ("Thesis sources", "Kitchen renovation", "Rust learning"). ÆTHER can even suggest which hub a page belongs in, based on what it's about. + +**Ask.** This is where it pays off. Open the **AiON** panel and ask a question in plain language. Instead of guessing from generic internet knowledge, AiON answers _from the pages you captured_ and cites them, so every claim is one click away from its source. You can ask about a whole hub, or just about the page you're currently reading. + +**Discover.** Answers, related-capture trails, and topic maps surface connections you forgot you had — which usually kicks off the next round of browsing. + +--- + +## A tour of the rooms + +ÆTHER is organized as a few distinct "surfaces", each with one job: + +### 🏠 The Dashboard + +The calm home screen. Your saved **Portals** (favorite sites, one tap away), your Knowledge Hubs with everything captured in them, and your saved iCE maps. This is where you browse _your own_ collection instead of the web. + +### 🌐 The Browser + +Ordinary web browsing with tabs — plus a capture button and page-aware AI actions always within reach. A new tab opens on a start page with your portals and a search box, not a wall of noise. + +### 💬 AiON — the sidekick panel + +A sidebar that follows you everywhere. It answers questions grounded in your captures, shows a **semantic trail** of saved pages related to what you're currently reading, and helps route new captures to the right hub. Answers render as clean text with citations and can be copied out. + +### 🧊 iCE — the depth explorer + +The **Information Complexity Explorer** is ÆTHER's most unusual tool. Give it a topic, and the local AI generates an "iceberg" map of it: the widely-known basics at the surface, and progressively deeper, more specialized layers as you descend. It's a way to _see the shape of a subject_ — and to find research hooks you didn't know existed. Maps can be saved and revisited from the dashboard. + +_(For the curious, there are also experimental surfaces — Flow, which draws a graph of how your captures relate to a query, and AiR, an automatic document renderer — tucked behind a developer-mode switch.)_ + +--- + +## What does "local AI" actually mean here? + +Two small AI models live inside the app, running on your own processor: + +- an **embedding model** (the "librarian") that converts every captured page — and every question you ask — into a mathematical fingerprint of its meaning, so search works by _what things are about_, not just keywords; +- a **chat model** (the "writer") that reads the most relevant captured passages and composes the answer you see, with citations. + +On first launch, ÆTHER offers to download a recommended model pack (a couple of gigabytes, from official sources). That's the only big download; after that, everything runs offline. You can pick a lighter, faster writer or a heavier, smarter one depending on your hardware. + +**The honest privacy picture:** everything ÆTHER _itself_ produces — captured text, hubs, indexes, questions, answers, topic maps — stays on your machine, full stop. But normal browsing is still normal browsing: the websites you visit see your requests and run their scripts just like in any browser. ÆTHER's privacy promise is about its intelligence pipeline, not a cloak of invisibility for the web. + +--- + +## What it is — and isn't + +**ÆTHER is good at:** + +- Being your everyday browser while quietly building a private research library +- Answering "where did I read that…?" — by meaning, not keywords +- Grounded Q&A over your own sources, with citations you can verify +- Mapping how deep a topic goes before you dive in (iCE) +- Working offline, on your hardware, with zero recurring costs + +**ÆTHER is not:** + +- A cloud chatbot — it won't answer from the whole internet, and that's the point: answers come from _your_ captured sources +- A privacy shield for browsing itself — websites behave as they do in any browser +- A lightweight app for very old machines — the local AI wants a reasonably modern computer (8 GB of RAM is the practical floor; see the README for specifics) + +--- + +## Where it runs + +macOS (Apple Silicon and Intel), Windows, Linux (including ARM devices like the Raspberry Pi 5), and an Android build with native browser tabs. One codebase, no server component, nothing to host. + +--- + +## Try it in five minutes + +1. Install ÆTHER and let **AiON Launch** download the recommended models. +2. Browse to any article you find interesting and hit **capture**. +3. Make a hub for the topic and drop the capture in (or accept the suggested hub). +4. Capture two or three more related pages. +5. Open **AiON** and ask a question about the topic. + +The answer you get will be built from those pages — cited, private, and generated entirely on your machine. That's ÆTHER in a nutshell: **browse the web, keep the good parts, and let your own library talk back to you.** diff --git a/package.json b/package.json index 12236a0..80c8aca 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,16 @@ { "name": "aether-browser", - "version": "1.0.28", + "version": "1.0.29", "productName": "ÆTHER", "description": "Local AI-powered browser and knowledge hub", + "keywords": [ + "knowledge", + "ai", + "local", + "research", + "browser", + "rag" + ], "author": "canpixel.com", "homepage": "https://canpixel.com", "license": "PolyForm-Strict-1.0.0", @@ -17,16 +25,16 @@ "release": "bun scripts/release.ts", "start": "tauri dev", "dev": "tauri dev", - "dev:vite": "vite --host 127.0.0.1 --port 1420", + "dev:vite": "vite", "build:vite": "vite build", "prepare:dmg-background": "bash scripts/prepare-dmg-background.sh", "build": "bun run typecheck && tauri build --bundles app && bun run dmg", "dmg": "bash scripts/make-styled-dmg.sh", - "android:init": "tauri android init", - "android:dev": "tauri android dev", - "android:build": "tauri android build", - "android:build:apk": "tauri android build --apk", - "android:build:aab": "tauri android build --aab", + "android:init": "bash scripts/android.sh init", + "android:dev": "bash scripts/android.sh dev", + "android:build": "bash scripts/android.sh build", + "android:build:apk": "bash scripts/android.sh build --apk", + "android:build:aab": "bash scripts/android.sh build --aab", "linux:arm64:build": "bash scripts/build-linux.sh", "linux:arm64:export": "bash scripts/build-linux.sh", "linux:arm64:deb": "LINUX_BUNDLES=deb bash scripts/build-linux.sh", diff --git a/scripts/android.sh b/scripts/android.sh new file mode 100755 index 0000000..d4159bd --- /dev/null +++ b/scripts/android.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Wrapper for `tauri android `. +# +# The Tauri CLI needs ANDROID_HOME / NDK_HOME, and llama-cpp-sys-2's build +# script separately requires one of ANDROID_NDK / NDK_ROOT / ANDROID_NDK_ROOT — +# it does not read NDK_HOME, so a plain `tauri android build` fails while +# compiling llama.cpp. This resolves the SDK/NDK once and exports every +# spelling the toolchain wants. +set -euo pipefail + +ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +if [ ! -d "$ANDROID_HOME" ]; then + echo "Android SDK not found at $ANDROID_HOME — install it or set ANDROID_HOME." >&2 + exit 1 +fi + +if [ -z "${NDK_HOME:-}" ]; then + NDK_HOME="$(find "$ANDROID_HOME/ndk" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)" +fi +if [ -z "$NDK_HOME" ] || [ ! -d "$NDK_HOME" ]; then + echo "Android NDK not found under $ANDROID_HOME/ndk — install one via sdkmanager or set NDK_HOME." >&2 + exit 1 +fi + +export ANDROID_HOME NDK_HOME +export ANDROID_NDK_ROOT="${ANDROID_NDK_ROOT:-$NDK_HOME}" +export ANDROID_NDK="${ANDROID_NDK:-$NDK_HOME}" + +# llama-cpp-sys-2 hardcodes -march=armv8-a (2014 baseline) for Android, which +# leaves ggml's dotprod/i8mm/fp16 quantized-matmul kernels compiled out — CPU +# prefill runs several times slower than the silicon allows. Env CFLAGS are +# appended after the build script's own flags, and clang honors the last +# -march, so this override wins. armv8.6-a mandates dotprod+i8mm+bf16 (any +# Snapdragon 8 Gen 1+ / recent flagship); override AETHER_ANDROID_MARCH for +# older devices, e.g. armv8.2-a+dotprod+fp16. +: "${AETHER_ANDROID_MARCH:=armv8.6-a+fp16}" +export CFLAGS_aarch64_linux_android="-march=${AETHER_ANDROID_MARCH}${CFLAGS_aarch64_linux_android:+ ${CFLAGS_aarch64_linux_android}}" +export CXXFLAGS_aarch64_linux_android="-march=${AETHER_ANDROID_MARCH}${CXXFLAGS_aarch64_linux_android:+ ${CXXFLAGS_aarch64_linux_android}}" + +# llama.cpp does not compile for 32-bit ARM, and every device that can run +# ÆTHER's local AI stack is arm64 anyway — so builds default to aarch64 only +# unless the caller picks targets explicitly. +args=("$@") +if [ "${1:-}" = "build" ]; then + case " $* " in + *" --target "*) ;; + *) args+=(--target aarch64) ;; + esac +fi + +exec bun tauri android "${args[@]}" diff --git a/scripts/make-windows-icon.py b/scripts/make-windows-icon.py new file mode 100644 index 0000000..a2c750d --- /dev/null +++ b/scripts/make-windows-icon.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Generate build/icon.ico from resources/icon.png. + +The source artwork uses the macOS convention of a large transparent inset, +which makes the Windows taskbar icon render visibly smaller than other apps. +This script crops the artwork to its visible bounds (plus a small margin) so +the .ico fills its canvas the way Windows icons are expected to. + +Usage: python3 scripts/make-windows-icon.py +""" + +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ROOT / "resources" / "icon.png" +TARGET = ROOT / "build" / "icon.ico" + +# Fraction of the cropped artwork size kept as transparent margin on each side. +MARGIN = 0.02 +SIZES = [16, 24, 32, 48, 64, 128, 256] + + +def main() -> None: + image = Image.open(SOURCE).convert("RGBA") + bbox = image.getchannel("A").getbbox() + if bbox is None: + raise SystemExit(f"{SOURCE} is fully transparent") + + left, top, right, bottom = bbox + margin = round(max(right - left, bottom - top) * MARGIN) + left = max(left - margin, 0) + top = max(top - margin, 0) + right = min(right + margin, image.width) + bottom = min(bottom + margin, image.height) + + # Pad the crop to a square so the artwork is not stretched. + width = right - left + height = bottom - top + side = max(width, height) + square = Image.new("RGBA", (side, side), (0, 0, 0, 0)) + square.paste(image.crop((left, top, right, bottom)), ((side - width) // 2, (side - height) // 2)) + + frames = [square.resize((size, size), Image.LANCZOS) for size in SIZES] + frames[-1].save(TARGET, format="ICO", append_images=frames[:-1], sizes=[(s, s) for s in SIZES]) + print(f"Wrote {TARGET} with sizes {SIZES} (content fills {(1 - 2 * MARGIN) * 100:.0f}% of canvas)") + + +if __name__ == "__main__": + main() diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 99e2f4f..cecba8b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aether" -version = "1.0.28" +version = "1.0.29" dependencies = [ "chrono", "encoding_rs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a101ff7..e0508e4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aether" -version = "1.0.28" +version = "1.0.29" description = "Local AI-powered browser and knowledge hub" authors = ["canpixel.com"] edition = "2021" @@ -33,3 +33,13 @@ uuid = { version = "1", features = ["v4", "serde"] } [target.'cfg(target_os = "macos")'.dependencies] # Cargo unions features across target tables, so on macOS llama.cpp gets metal + sampler. llama-cpp-2 = { version = "0.1.146", default-features = false, features = ["metal"] } + +# llama.cpp compiled at -O0 (the dev-profile default for native deps) runs CPU +# inference 20-50x slower than release. Desktop dev hides this behind Metal; +# Android dev builds do real CPU prefill, so keep the inference stack optimized +# even in dev. +[profile.dev.package.llama-cpp-sys-2] +opt-level = 3 + +[profile.dev.package.llama-cpp-2] +opt-level = 3 diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index c901385..73d78c1 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -13,6 +13,15 @@ val tauriProperties = Properties().apply { } } +// Release signing: ../keystore.properties (gitignored) points at the local +// keystore so `tauri android build` emits installable APKs. +val keystoreProperties = Properties().apply { + val propFile = rootProject.file("keystore.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + android { compileSdk = 36 namespace = "com.canur.aether" @@ -24,6 +33,16 @@ android { versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") } + signingConfigs { + create("release") { + if (keystoreProperties.containsKey("storeFile")) { + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } buildTypes { getByName("debug") { manifestPlaceholders["usesCleartextTraffic"] = "true" @@ -37,7 +56,14 @@ android { } } getByName("release") { - isMinifyEnabled = true + if (keystoreProperties.containsKey("storeFile")) { + signingConfig = signingConfigs.getByName("release") + } + // Minification is off until the proguard rules are proven against + // the Kotlin TabsPlugin's JNI/reflection surface: a stripped + // symbol here means a crash at launch, and APK size is not a + // concern for sideloaded builds. + isMinifyEnabled = false proguardFiles( *fileTree(".") { include("**/*.pro") } .plus(getDefaultProguardFile("proguard-android-optimize.txt")) @@ -45,8 +71,12 @@ android { ) } } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } buildFeatures { buildConfig = true diff --git a/src-tauri/gen/android/app/proguard-rules.pro b/src-tauri/gen/android/app/proguard-rules.pro index 481bb43..b4d8f5a 100644 --- a/src-tauri/gen/android/app/proguard-rules.pro +++ b/src-tauri/gen/android/app/proguard-rules.pro @@ -18,4 +18,15 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# TabsPlugin is instantiated and its @Command methods invoked reflectively by +# Tauri's PluginManager (registered from Rust via register_android_plugin), +# and its @InvokeArg classes are populated by Jackson. +-keep class com.canur.aether.TabsPlugin { *; } +-keep class com.canur.aether.EnsureArgs { *; } +-keep class com.canur.aether.NavigateArgs { *; } +-keep class com.canur.aether.SyncArgs { *; } +-keep class com.canur.aether.TabArgs { *; } +-keep class com.canur.aether.EvalArgs { *; } +-keep class com.canur.aether.FindArgs { *; } \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index c79395f..5d8be36 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -7,12 +7,14 @@ diff --git a/src-tauri/gen/android/app/src/main/java/com/canur/aether/TabsPlugin.kt b/src-tauri/gen/android/app/src/main/java/com/canur/aether/TabsPlugin.kt new file mode 100644 index 0000000..ff1b3ac --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/canur/aether/TabsPlugin.kt @@ -0,0 +1,449 @@ +package com.canur.aether + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.SystemClock +import android.util.Base64 +import android.util.LruCache +import android.view.View +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import java.io.ByteArrayOutputStream + +// Per-tab browser WebViews for Android. +// +// Tauri's multi-webview API (`Window::add_child`) is desktop-only, so on +// Android each ÆTHER browser tab gets a plain android.webkit.WebView added to +// the activity's content view above the main (app UI) webview. Rust drives +// this plugin through `run_mobile_plugin` (see the `android_tabs` module in +// src-tauri/src/lib.rs) and stays the single source of truth for tab state; +// navigation/title/find events flow back by evaluating +// `window.__AETHER_TAB_EVENT__(...)` in the main webview, which forwards them +// to the `aether_tabs_report_native_event` command. +// +// Bounds arrive in CSS pixels measured by the renderer (MobileTabView) and are +// converted to device pixels with the display density. All WebView access +// happens on the UI thread. + +@InvokeArg +class EnsureArgs { + lateinit var tabId: String + lateinit var url: String +} + +@InvokeArg +class NavigateArgs { + lateinit var tabId: String + lateinit var url: String +} + +@InvokeArg +class SyncArgs { + var activeTabId: String? = null + var top: Double = 0.0 + var left: Double = 0.0 + var width: Double = 0.0 + var height: Double = 0.0 +} + +@InvokeArg +class TabArgs { + lateinit var tabId: String +} + +@InvokeArg +class EvalArgs { + lateinit var tabId: String + lateinit var script: String +} + +@InvokeArg +class FindArgs { + lateinit var tabId: String + var query: String? = null + var action: String = "find" +} + +@TauriPlugin +class TabsPlugin(private val activity: Activity) : Plugin(activity) { + private var mainWebView: WebView? = null + private val tabs = HashMap() + + // Downscaled page bitmaps for the mobile tab-grid switcher, captured whenever + // a visible tab gets hidden (sync) and on demand for the active tab. Sized in + // bytes so a couple dozen tabs stay well under typical heap budgets. + private val thumbnails = object : LruCache(24 * 1024 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount + } + + override fun load(webView: WebView) { + mainWebView = webView + // MainActivity runs edge-to-edge (enableEdgeToEdge, targetSdk 36), so the + // renderer must pad around the system bars itself. env(safe-area-inset-*) + // is unreliable in WebView, so push the insets as CSS variables instead; + // the renderer also pulls them once at startup via the `insets` command in + // case this listener fired before the app document loaded. + ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets -> + applyInsetVariables(view as WebView, insets) + insets + } + } + + private fun currentInsets(): WindowInsetsCompat? { + val root = activity.findViewById(android.R.id.content) + return ViewCompat.getRootWindowInsets(root) + } + + private fun insetValues(insets: WindowInsetsCompat): DoubleArray { + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + // The soft keyboard reports through the same inset system; folding it into + // the bottom inset lifts the chrome, sheets, and toasts above the IME so + // focused inputs stay visible while typing. + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + val density = activity.resources.displayMetrics.density.toDouble() + return doubleArrayOf( + bars.top / density, + maxOf(bars.bottom, ime.bottom) / density, + bars.left / density, + bars.right / density + ) + } + + private fun applyInsetVariables(webView: WebView, insets: WindowInsetsCompat) { + val values = insetValues(insets) + val js = + "document.documentElement.style.setProperty('--aether-inset-top','${values[0]}px');" + + "document.documentElement.style.setProperty('--aether-inset-bottom','${values[1]}px');" + + "document.documentElement.style.setProperty('--aether-inset-left','${values[2]}px');" + + "document.documentElement.style.setProperty('--aether-inset-right','${values[3]}px');" + webView.post { webView.evaluateJavascript(js, null) } + } + + // System-bar/cutout insets in CSS pixels for the renderer's mobile layout. + @Command + fun insets(invoke: Invoke) { + activity.runOnUiThread { + val result = JSObject() + val values = currentInsets()?.let { insetValues(it) } ?: doubleArrayOf(0.0, 0.0, 0.0, 0.0) + result.put("top", values[0]) + result.put("bottom", values[1]) + result.put("left", values[2]) + result.put("right", values[3]) + invoke.resolve(result) + } + } + + // A browser-tab WebView that reports scroll movement to the renderer so the + // mobile bottom chrome can auto-hide. Throttled: at most one event per + // ~80 ms once at least 8 px of movement accumulated. The renderer applies + // its own hysteresis, so precision here does not matter. + @SuppressLint("ViewConstructor") + private inner class TabWebView(context: Context, private val tabId: String) : WebView(context) { + private var lastEmit = 0L + private var pendingDelta = 0 + + override fun onScrollChanged(left: Int, top: Int, oldLeft: Int, oldTop: Int) { + super.onScrollChanged(left, top, oldLeft, oldTop) + pendingDelta += top - oldTop + val now = SystemClock.uptimeMillis() + if (kotlin.math.abs(pendingDelta) < 8 || now - lastEmit < 80) return + val payload = JSObject() + payload.put("tabId", tabId) + payload.put("kind", "scroll") + payload.put("scrollY", top) + payload.put("deltaY", pendingDelta) + lastEmit = now + pendingDelta = 0 + emitTabEvent(payload) + } + } + + // Draws the visible viewport into a downscaled bitmap. Software canvas draw + // is the supported capture path for WebView; failures (composited video, + // zero-sized view) just skip the thumbnail. + private fun captureThumbnail(tabId: String, view: WebView) { + if (view.width <= 0 || view.height <= 0) return + val scale = 0.34f + val width = (view.width * scale).toInt().coerceAtLeast(1) + val height = (view.height * scale).toInt().coerceAtLeast(1) + try { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + val canvas = Canvas(bitmap) + canvas.scale(scale, scale) + canvas.translate(-view.scrollX.toFloat(), -view.scrollY.toFloat()) + view.draw(canvas) + thumbnails.put(tabId, bitmap) + } catch (_: Throwable) { + // Never let a capture failure break tab switching. + } + } + + private fun emitTabEvent(payload: JSObject) { + val js = "window.__AETHER_TAB_EVENT__ && window.__AETHER_TAB_EVENT__($payload)" + activity.runOnUiThread { mainWebView?.evaluateJavascript(js, null) } + } + + private fun navigationEvent(tabId: String, view: WebView, url: String?, isLoading: Boolean) { + val payload = JSObject() + payload.put("tabId", tabId) + payload.put("kind", "navigation") + payload.put("url", url ?: view.url ?: "") + payload.put("isLoading", isLoading) + payload.put("canGoBack", view.canGoBack()) + payload.put("canGoForward", view.canGoForward()) + emitTabEvent(payload) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun createTab(tabId: String, url: String): WebView { + val view: WebView = TabWebView(activity, tabId) + view.settings.javaScriptEnabled = true + view.settings.domStorageEnabled = true + view.settings.setSupportZoom(true) + view.settings.builtInZoomControls = true + view.settings.displayZoomControls = false + view.settings.loadWithOverviewMode = true + view.settings.useWideViewPort = true + // target=_blank falls back to same-view navigation with multiple windows off. + view.settings.setSupportMultipleWindows(false) + + view.webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + navigationEvent(tabId, view, url, true) + } + + override fun onPageFinished(view: WebView, url: String?) { + navigationEvent(tabId, view, url, false) + } + + override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { + navigationEvent(tabId, view, url, false) + } + } + + view.webChromeClient = object : WebChromeClient() { + override fun onReceivedTitle(view: WebView, title: String?) { + if (title.isNullOrBlank()) return + val payload = JSObject() + payload.put("tabId", tabId) + payload.put("kind", "title") + payload.put("title", title) + emitTabEvent(payload) + } + } + + view.setFindListener { activeMatchOrdinal, numberOfMatches, isDoneCounting -> + if (isDoneCounting) { + val payload = JSObject() + payload.put("tabId", tabId) + payload.put("kind", "find") + payload.put("current", if (numberOfMatches > 0) activeMatchOrdinal + 1 else 0) + payload.put("total", numberOfMatches) + emitTabEvent(payload) + } + } + + view.visibility = View.GONE + val root = activity.findViewById(android.R.id.content) + // Added after the main webview, so tab views draw above the app UI; the + // renderer keeps chrome (address bar, tab strip) outside the tab bounds, + // mirroring the desktop child-webview layering. + root.addView(view, FrameLayout.LayoutParams(0, 0)) + view.loadUrl(url) + tabs[tabId] = view + return view + } + + @Command + fun ensure(invoke: Invoke) { + val args = invoke.parseArgs(EnsureArgs::class.java) + activity.runOnUiThread { + if (!tabs.containsKey(args.tabId)) { + createTab(args.tabId, args.url) + } + } + invoke.resolve() + } + + @Command + fun navigate(invoke: Invoke) { + val args = invoke.parseArgs(NavigateArgs::class.java) + activity.runOnUiThread { + val view = tabs[args.tabId] + if (view == null) { + // createTab already loads the URL. + createTab(args.tabId, args.url) + } else { + view.loadUrl(args.url) + } + } + invoke.resolve() + } + + @Command + fun sync(invoke: Invoke) { + val args = invoke.parseArgs(SyncArgs::class.java) + activity.runOnUiThread { + val density = activity.resources.displayMetrics.density + for ((tabId, view) in tabs) { + // Keep the switcher preview fresh: snapshot any tab that is about to + // be hidden while still laid out and visible. + if (view.visibility == View.VISIBLE && tabId != args.activeTabId) { + captureThumbnail(tabId, view) + } + if (tabId == args.activeTabId) { + val params = FrameLayout.LayoutParams( + (args.width * density).toInt().coerceAtLeast(0), + (args.height * density).toInt().coerceAtLeast(0) + ) + params.leftMargin = (args.left * density).toInt() + params.topMargin = (args.top * density).toInt() + view.layoutParams = params + view.visibility = View.VISIBLE + } else { + view.visibility = View.GONE + } + } + } + invoke.resolve() + } + + @Command + fun goBack(invoke: Invoke) { + val args = invoke.parseArgs(TabArgs::class.java) + activity.runOnUiThread { + val view = tabs[args.tabId] + if (view != null && view.canGoBack()) { + view.goBack() + } + } + invoke.resolve() + } + + @Command + fun goForward(invoke: Invoke) { + val args = invoke.parseArgs(TabArgs::class.java) + activity.runOnUiThread { + val view = tabs[args.tabId] + if (view != null && view.canGoForward()) { + view.goForward() + } + } + invoke.resolve() + } + + @Command + fun close(invoke: Invoke) { + val args = invoke.parseArgs(TabArgs::class.java) + activity.runOnUiThread { + thumbnails.remove(args.tabId) + tabs.remove(args.tabId)?.let { view -> + (view.parent as? ViewGroup)?.removeView(view) + view.destroy() + } + } + invoke.resolve() + } + + // Returns a data-URI JPEG preview for the tab-grid switcher, refreshing the + // capture first when the tab is currently visible. Resolves an empty object + // when no preview exists yet (e.g. a tab that never became visible). + @Command + fun thumbnail(invoke: Invoke) { + val args = invoke.parseArgs(TabArgs::class.java) + activity.runOnUiThread { + val view = tabs[args.tabId] + if (view != null && view.visibility == View.VISIBLE) { + captureThumbnail(args.tabId, view) + } + val result = JSObject() + val bitmap = thumbnails.get(args.tabId) + if (bitmap != null) { + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 68, stream) + val encoded = Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) + result.put("image", "data:image/jpeg;base64,$encoded") + } + invoke.resolve(result) + } + } + + // Live-DOM page snapshot for capture: unlike desktop, Android's + // evaluateJavascript has a value callback, so Rust can read the rendered + // page (logged-in / JS-built content) instead of re-fetching the URL. + // Mirrors the desktop extract_readable_page_from_webview script. + @Command + fun snapshot(invoke: Invoke) { + val args = invoke.parseArgs(TabArgs::class.java) + activity.runOnUiThread { + val view = tabs[args.tabId] + if (view == null) { + invoke.reject("Tab webview is not ready.") + return@runOnUiThread + } + val script = """(() => { + const clone = document.documentElement.cloneNode(true); + clone.querySelectorAll('script, style, noscript, iframe, form, nav, footer, svg').forEach((node) => node.remove()); + return { + html: '' + clone.outerHTML, + url: location.href, + title: document.title, + description: document.querySelector('meta[name="description"]')?.getAttribute('content') || '', + bodyText: document.body?.innerText || '' + }; + })()""" + view.evaluateJavascript(script) { value -> + val result = JSObject() + result.put("payload", value ?: "") + invoke.resolve(result) + } + } + } + + @Command + fun eval(invoke: Invoke) { + val args = invoke.parseArgs(EvalArgs::class.java) + activity.runOnUiThread { + tabs[args.tabId]?.evaluateJavascript(args.script, null) + } + invoke.resolve() + } + + @Command + fun find(invoke: Invoke) { + val args = invoke.parseArgs(FindArgs::class.java) + activity.runOnUiThread { + val view = tabs[args.tabId] ?: return@runOnUiThread + when (args.action) { + "next" -> view.findNext(true) + "prev" -> view.findNext(false) + "clear" -> view.clearMatches() + else -> { + val query = args.query + if (query.isNullOrBlank()) { + view.clearMatches() + } else { + view.findAllAsync(query) + } + } + } + } + invoke.resolve() + } +} diff --git a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d1..0000000 --- a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..299f2df 100644 --- a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3ba4e35 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3ba4e35 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 28f1aa1..b01b419 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 85d0c88..0ddc891 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 28f1aa1..66e1e16 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 73e48db..ea2495a 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 13dd214..2155b0c 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 73e48db..ee3daeb 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 1d98044..3773d06 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index a888b33..37fdf6e 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 1d98044..30acee2 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 0818324..ecabfd7 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index a2a838e..b8427bb 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 0818324..8dd4eb6 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b18bceb..ae98d14 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 3f8a57f..681d0f4 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index b18bceb..6035b58 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a220a0a..eb83dfa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,32 +26,44 @@ use std::{ use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}, webview::{NewWindowResponse, PageLoadEvent}, - Webview, WebviewBuilder, WebviewUrl, Window, -}; -use tauri::{ - AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Position, Rect, Size, State, - WindowEvent, + LogicalPosition, LogicalSize, Position, Rect, Size, Webview, WebviewBuilder, WebviewUrl, + Window, WindowEvent, }; +use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_opener::OpenerExt; use tokio::io::AsyncWriteExt; use tokio::task; use url::Url; const CHUNKS_TABLE: &str = "chunks"; +#[cfg(desktop)] const SIDEBAR_WIDTH: f64 = 76.0; +#[cfg(desktop)] const BROWSER_VIEW_TOP: f64 = 172.0; +#[cfg(desktop)] const PANEL_WIDTH: f64 = 404.0; +#[cfg(desktop)] const PANEL_COLLAPSED_WIDTH: f64 = 58.0; const LOCAL_RUNTIME_NAME: &str = "llama.cpp"; +#[cfg(desktop)] const AETHER_SHORTCUT_EVENT: &str = "aether:shortcut"; +#[cfg(desktop)] const AETHER_FIND_MENU_ID: &str = "aether-find-in-page"; +#[cfg(desktop)] const AETHER_FOCUS_ADDRESS_MENU_ID: &str = "aether-focus-address"; +#[cfg(desktop)] const AETHER_NEW_TAB_MENU_ID: &str = "aether-new-tab"; +#[cfg(desktop)] const AETHER_OPEN_DASHBOARD_MENU_ID: &str = "aether-open-dashboard"; +#[cfg(desktop)] const AETHER_OPEN_ICE_MENU_ID: &str = "aether-open-ice"; +#[cfg(desktop)] const AETHER_OPEN_BROWSER_MENU_ID: &str = "aether-open-browser"; +#[cfg(desktop)] const AETHER_TOGGLE_AION_MENU_ID: &str = "aether-toggle-aion"; +#[cfg(desktop)] const AETHER_CAPTURE_PAGE_MENU_ID: &str = "aether-capture-page"; +#[cfg(desktop)] const AETHER_FIND_REQUESTED_EVENT: &str = "aether:find-requested"; const AETHER_FIND_RESULT_EVENT: &str = "aether:find-result"; const AETHER_CHAT_STREAM_EVENT: &str = "aether:chat-stream"; @@ -104,8 +116,10 @@ const PREFERRED_CHAT_MODEL_HINTS: [&str; 8] = [ "gemma4", "gemma-4", "gemma3", "gemma-3", "gemma-2b", "2b", "gemma", "qwen", ]; const MIN_CAPTURE_TEXT_LENGTH: usize = 120; +#[cfg(desktop)] const DESKTOP_BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15"; +#[cfg(desktop)] const NATIVE_WEBVIEW_SCROLLBAR_SCRIPT: &str = r##" (() => { const styleId = 'aether-native-scrollbar-style'; @@ -182,6 +196,10 @@ struct Backend { tabs: Mutex, #[cfg(desktop)] webviews: Mutex, + // Where the renderer wants Android tab WebViews placed, in CSS pixels + // (reported by MobileTabView via aether_layout_set_mobile_tab_bounds). + #[cfg(not(desktop))] + mobile_tab_bounds: Mutex, client: Client, native_runtime: Arc>, vectors: tokio::sync::RwLock>, @@ -194,6 +212,15 @@ struct NativeBrowserViews { views: HashMap, } +#[cfg(not(desktop))] +#[derive(Clone, Copy, Default)] +struct MobileTabBounds { + top: f64, + left: f64, + width: f64, + height: f64, +} + #[derive(Default)] struct NativeModelRuntime { backend: Option, @@ -282,6 +309,12 @@ struct ManagedTab { theme_color: Option, history: Vec, history_index: usize, + // On Android the tab's real history lives in its native WebView, whose + // canGoBack/canGoForward are reported via aether_tabs_report_native_event. + // They extend (OR with) the Rust-side history, which still tracks entries + // the WebView never saw — most notably the aether://start page. + native_can_go_back: Option, + native_can_go_forward: Option, } #[derive(Clone, Serialize)] @@ -832,6 +865,7 @@ struct CreateShortcutInput { theme_color: Option, } +#[cfg(desktop)] #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PageMetadataSnapshot { @@ -839,12 +873,36 @@ struct PageMetadataSnapshot { favicon: Option, } +#[cfg(desktop)] #[derive(Deserialize)] struct FindMatchSnapshot { current: usize, total: usize, } +// Event payload forwarded by the renderer from the Kotlin TabsPlugin +// (window.__AETHER_TAB_EVENT__): per-tab navigation, title, and find updates. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NativeTabEventInput { + tab_id: String, + kind: String, + #[serde(default)] + url: Option, + #[serde(default)] + title: Option, + #[serde(default)] + is_loading: Option, + #[serde(default)] + can_go_back: Option, + #[serde(default)] + can_go_forward: Option, + #[serde(default)] + current: Option, + #[serde(default)] + total: Option, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct FindResultPayload { @@ -969,7 +1027,6 @@ struct UpdateCheckResult { } #[derive(Deserialize)] -#[serde(rename_all = "camelCase")] struct GithubRelease { tag_name: String, name: Option, @@ -1177,6 +1234,8 @@ impl Backend { tabs: Mutex::new(TabState::new()), #[cfg(desktop)] webviews: Mutex::new(NativeBrowserViews::default()), + #[cfg(not(desktop))] + mobile_tab_bounds: Mutex::new(MobileTabBounds::default()), client: Client::builder() .user_agent("Aether/1.0 Tauri") .build() @@ -1268,6 +1327,8 @@ impl ManagedTab { theme_color: None, history: vec![url], history_index: 0, + native_can_go_back: None, + native_can_go_forward: None, } } @@ -1281,24 +1342,9 @@ impl ManagedTab { self.history.truncate(self.history_index + 1); self.history.push(url); self.history_index = self.history.len().saturating_sub(1); - } - - #[cfg(not(desktop))] - fn go_back(&mut self) { - if self.can_go_back() { - self.history_index -= 1; - self.url = self.history[self.history_index].clone(); - self.title = title_from_url(&self.url); - } - } - - #[cfg(not(desktop))] - fn go_forward(&mut self) { - if self.can_go_forward() { - self.history_index += 1; - self.url = self.history[self.history_index].clone(); - self.title = title_from_url(&self.url); - } + // Unknown until the native webview reports in after the load. + self.native_can_go_back = None; + self.native_can_go_forward = None; } fn commit_history_url(&mut self, url: String) { @@ -1339,8 +1385,8 @@ impl ManagedTab { host: get_tab_host(&self.url), is_active, is_loading: self.is_loading, - can_go_back: self.can_go_back(), - can_go_forward: self.can_go_forward(), + can_go_back: self.can_go_back() || self.native_can_go_back.unwrap_or(false), + can_go_forward: self.can_go_forward() || self.native_can_go_forward.unwrap_or(false), favicon: self.favicon.clone(), theme_color: self.theme_color.clone(), } @@ -1384,8 +1430,35 @@ fn ensure_native_webview(app: &AppHandle, state: &State, tab_id: &str) } #[cfg(not(desktop))] -fn ensure_native_webview(_app: &AppHandle, _state: &State, _tab_id: &str) -> Cmd<()> { - Ok(()) +fn ensure_native_webview(app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let tab = { + let tabs = lock_tabs(state)?; + tabs.tabs + .iter() + .find(|tab| tab.id == tab_id) + .cloned() + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + // Like the desktop path: a start-page tab has nothing to load, only + // visibility to reconcile so the renderer's start page shows. + if tab.url != START_PAGE_URL { + app.state::().run( + "ensure", + android_tabs::TabUrlPayload { + tab_id: &tab.id, + url: &tab.url, + }, + )?; + } + return sync_native_webview_visibility(app, state); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id); + Ok(()) + } } #[cfg(desktop)] @@ -1491,16 +1564,27 @@ fn navigate_native_webview( #[cfg(not(desktop))] fn navigate_native_webview( - _app: &AppHandle, - _state: &State, - _tab_id: &str, - _url: &str, + app: &AppHandle, + state: &State, + tab_id: &str, + url: &str, ) -> Cmd<()> { - Ok(()) + #[cfg(target_os = "android")] + { + app.state::() + .run("navigate", android_tabs::TabUrlPayload { tab_id, url })?; + return sync_native_webview_visibility(app, state); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, url); + Ok(()) + } } #[cfg(desktop)] fn navigate_native_webview_history( + _app: &AppHandle, state: &State, tab_id: &str, direction: WebviewHistoryDirection, @@ -1522,15 +1606,36 @@ fn navigate_native_webview_history( #[cfg(not(desktop))] fn navigate_native_webview_history( - _state: &State, - _tab_id: &str, - _direction: WebviewHistoryDirection, + app: &AppHandle, + state: &State, + tab_id: &str, + direction: WebviewHistoryDirection, ) -> Cmd<()> { - Ok(()) + #[cfg(target_os = "android")] + { + let _ = state; + let command = match direction { + WebviewHistoryDirection::Back => "goBack", + WebviewHistoryDirection::Forward => "goForward", + }; + return app + .state::() + .run(command, android_tabs::TabPayload { tab_id }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, direction); + Ok(()) + } } #[cfg(desktop)] -fn scroll_native_webview_to_text(state: &State, tab_id: &str, text: &str) -> Cmd<()> { +fn scroll_native_webview_to_text( + _app: &AppHandle, + state: &State, + tab_id: &str, + text: &str, +) -> Cmd<()> { let source_text = text.trim(); if source_text.is_empty() { return Ok(()); @@ -1549,8 +1654,30 @@ fn scroll_native_webview_to_text(state: &State, tab_id: &str, text: &st } #[cfg(not(desktop))] -fn scroll_native_webview_to_text(_state: &State, _tab_id: &str, _text: &str) -> Cmd<()> { - Ok(()) +fn scroll_native_webview_to_text( + app: &AppHandle, + state: &State, + tab_id: &str, + text: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + let source_text = text.trim(); + if source_text.is_empty() { + return Ok(()); + } + let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; + let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); + return app + .state::() + .run("eval", android_tabs::EvalPayload { tab_id, script }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, text); + Ok(()) + } } #[cfg(desktop)] @@ -1598,17 +1725,36 @@ fn find_native_webview_text( #[cfg(not(desktop))] fn find_native_webview_text( - _app: &AppHandle, - _state: &State, - _tab_id: &str, - _query: Option<&str>, - _action: &str, + app: &AppHandle, + state: &State, + tab_id: &str, + query: Option<&str>, + action: &str, ) -> Cmd<()> { - Ok(()) + #[cfg(target_os = "android")] + { + let _ = state; + // Android WebView has native find support (findAllAsync/findNext); the + // match counts come back through the FindListener as a "find" event on + // aether_tabs_report_native_event. + return app.state::().run( + "find", + android_tabs::FindPayload { + tab_id, + query: query.map(str::trim).filter(|value| !value.is_empty()), + action, + }, + ); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, query, action); + Ok(()) + } } #[cfg(desktop)] -fn close_native_webview(state: &State, tab_id: &str) -> Cmd<()> { +fn close_native_webview(_app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { if let Some(webview) = state .webviews .lock() @@ -1622,10 +1768,22 @@ fn close_native_webview(state: &State, tab_id: &str) -> Cmd<()> { } #[cfg(not(desktop))] -fn close_native_webview(_state: &State, _tab_id: &str) -> Cmd<()> { - Ok(()) +fn close_native_webview(app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + return app + .state::() + .run("close", android_tabs::TabPayload { tab_id }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id); + Ok(()) + } } +#[cfg(desktop)] fn find_in_page_script() -> &'static str { r#" (() => { @@ -2037,11 +2195,6 @@ fn resize_native_webviews(app: &AppHandle, state: &State) -> Cmd<()> { sync_native_webview_visibility(app, state) } -#[cfg(not(desktop))] -fn resize_native_webviews(_app: &AppHandle, _state: &State) -> Cmd<()> { - Ok(()) -} - #[cfg(desktop)] fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { let (active_tab_id, show_active, panel_collapsed) = { @@ -2082,8 +2235,42 @@ fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cm } #[cfg(not(desktop))] -fn sync_native_webview_visibility(_app: &AppHandle, _state: &State) -> Cmd<()> { - Ok(()) +fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let (active_tab_id, show_active) = { + let tabs = lock_tabs(state)?; + // Same rules as desktop: keep webviews hidden behind the dashboard, + // modal overlays, and the renderer's start-page overlay. + let active_is_start = tabs + .active_tab() + .map(|tab| tab.url == START_PAGE_URL) + .unwrap_or(false); + ( + tabs.active_tab_id.clone(), + !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, + ) + }; + let bounds = *state + .mobile_tab_bounds + .lock() + .map_err(|_| "Æther layout bounds are unavailable.".to_string())?; + return app.state::().run( + "sync", + android_tabs::SyncPayload { + active_tab_id: show_active.then_some(active_tab_id.as_str()), + top: bounds.top, + left: bounds.left, + width: bounds.width, + height: bounds.height, + }, + ); + } + #[allow(unreachable_code)] + { + let _ = (app, state); + Ok(()) + } } #[cfg(desktop)] @@ -2213,6 +2400,7 @@ fn is_transient_webview_url(url: &str) -> bool { || normalized == "about:srcdoc" } +#[cfg(desktop)] fn update_tab_metadata( state: &State, tab_id: &str, @@ -2261,6 +2449,128 @@ fn force_exit() -> ! { unsafe { _exit(0) } } +// Bridge to the Kotlin TabsPlugin (gen/android/.../TabsPlugin.kt), which hosts +// one android.webkit.WebView per browser tab above the main app webview. This +// is the Android counterpart of the desktop `Window::add_child` path: the same +// `*_native_webview` functions drive it, keeping Rust the source of truth for +// tab state. Navigation events come back through the renderer via the +// `aether_tabs_report_native_event` command. +#[cfg(target_os = "android")] +mod android_tabs { + use serde::{de::DeserializeOwned, Deserialize, Serialize}; + use std::sync::OnceLock; + use tauri::{ + plugin::{Builder, PluginHandle, TauriPlugin}, + Manager, Wry, + }; + + pub struct AndroidTabs(PluginHandle); + + // The plugin handle, additionally kept in a module global so helpers that + // only receive `State` (page capture) can reach the Kotlin side + // without threading an AppHandle through every call site. + static HANDLE: OnceLock> = OnceLock::new(); + + impl AndroidTabs { + pub fn run(&self, command: &str, payload: impl Serialize) -> Result<(), String> { + self.0 + .run_mobile_plugin::<()>(command, payload) + .map_err(|error| error.to_string()) + } + + pub fn run_for( + &self, + command: &str, + payload: impl Serialize, + ) -> Result { + self.0 + .run_mobile_plugin::(command, payload) + .map_err(|error| error.to_string()) + } + } + + // Run a plugin command via the global handle. Blocks until Kotlin resolves, + // so only call from async commands (tokio workers), never the main thread — + // Kotlin resolves on the Android UI thread and would deadlock against it. + pub fn run_for_global( + command: &str, + payload: impl Serialize, + ) -> Result { + HANDLE + .get() + .ok_or_else(|| "Android tabs plugin is not ready.".to_string())? + .run_mobile_plugin::(command, payload) + .map_err(|error| error.to_string()) + } + + pub fn init() -> TauriPlugin { + Builder::new("aether-tabs") + .setup(|app, api| { + let handle = api.register_android_plugin("com.canur.aether", "TabsPlugin")?; + let _ = HANDLE.set(handle.clone()); + app.manage(AndroidTabs(handle)); + Ok(()) + }) + .build() + } + + #[derive(Deserialize)] + pub struct ThumbnailResponse { + pub image: Option, + } + + #[derive(Deserialize)] + pub struct SnapshotResponse { + pub payload: String, + } + + #[derive(Deserialize, Serialize, Default)] + pub struct InsetsResponse { + pub top: f64, + pub bottom: f64, + pub left: f64, + pub right: f64, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct TabUrlPayload<'a> { + pub tab_id: &'a str, + pub url: &'a str, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct SyncPayload<'a> { + pub active_tab_id: Option<&'a str>, + pub top: f64, + pub left: f64, + pub width: f64, + pub height: f64, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct TabPayload<'a> { + pub tab_id: &'a str, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct EvalPayload<'a> { + pub tab_id: &'a str, + pub script: String, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct FindPayload<'a> { + pub tab_id: &'a str, + pub query: Option<&'a str>, + pub action: &'a str, + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default(); @@ -2391,8 +2701,11 @@ pub fn run() { } }); + let builder = builder.plugin(tauri_plugin_opener::init()); + #[cfg(target_os = "android")] + let builder = builder.plugin(android_tabs::init()); + builder - .plugin(tauri_plugin_opener::init()) .setup(|app| { let app_data_dir = app.path().app_data_dir().expect("app data dir"); app.manage(Backend::new(app_data_dir)); @@ -2438,6 +2751,10 @@ pub fn run() { aether_tabs_find, aether_tabs_go_back, aether_tabs_go_forward, + aether_tabs_report_native_event, + aether_tabs_thumbnail, + aether_layout_window_insets, + aether_layout_set_mobile_tab_bounds, aether_dashboard_open, aether_hub_list, aether_hub_create, @@ -2482,9 +2799,9 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building Æther") - .run(|_app_handle, event| { + .run(|_app_handle, _event| { #[cfg(desktop)] - if let tauri::RunEvent::ExitRequested { .. } = event { + if let tauri::RunEvent::ExitRequested { .. } = _event { force_exit(); } }); @@ -2655,7 +2972,7 @@ fn aether_tabs_close(app: AppHandle, state: State, tab_id: String) -> C } } } - close_native_webview(&state, &tab_id)?; + close_native_webview(&app, &state, &tab_id)?; if let Some(active_tab_id) = next_active_tab_id { ensure_native_webview(&app, &state, &active_tab_id)?; } else { @@ -2690,14 +3007,19 @@ async fn aether_tabs_navigate( } #[tauri::command(rename_all = "camelCase")] -fn aether_tabs_scroll_to_text(state: State, tab_id: String, text: String) -> Cmd<()> { +fn aether_tabs_scroll_to_text( + app: AppHandle, + state: State, + tab_id: String, + text: String, +) -> Cmd<()> { { let tabs = lock_tabs(&state)?; if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { return Err(format!("Unknown tab: {tab_id}")); } } - scroll_native_webview_to_text(&state, &tab_id, &text) + scroll_native_webview_to_text(&app, &state, &tab_id, &text) } #[tauri::command(rename_all = "camelCase")] @@ -2748,17 +3070,17 @@ fn aether_tabs_go_back(app: AppHandle, state: State, tab_id: String) -> tab.is_loading = false; restore_start_page = true; } - let target_tab_id = tab.id.clone(); - #[cfg(not(desktop))] - if !restore_start_page { - tab.go_back(); - } - target_tab_id + tab.id.clone() }; if restore_start_page { sync_native_webview_visibility(&app, &state)?; } else { - navigate_native_webview_history(&state, &target_tab_id, WebviewHistoryDirection::Back)?; + navigate_native_webview_history( + &app, + &state, + &target_tab_id, + WebviewHistoryDirection::Back, + )?; } emit_state(&app, &state) } @@ -2786,21 +3108,157 @@ fn aether_tabs_go_forward(app: AppHandle, state: State, tab_id: String) tab.is_loading = false; leave_start_page = true; } - let target_tab_id = tab.id.clone(); - #[cfg(not(desktop))] - if !leave_start_page { - tab.go_forward(); - } - target_tab_id + tab.id.clone() }; if leave_start_page { ensure_native_webview(&app, &state, &target_tab_id)?; } else { - navigate_native_webview_history(&state, &target_tab_id, WebviewHistoryDirection::Forward)?; + navigate_native_webview_history( + &app, + &state, + &target_tab_id, + WebviewHistoryDirection::Forward, + )?; } emit_state(&app, &state) } +// Mobile-only feedback channel: the Kotlin TabsPlugin evaluates +// `window.__AETHER_TAB_EVENT__(...)` in the main webview and the renderer +// forwards the payload here, mirroring how desktop child-webview callbacks +// (on_navigation / on_page_load / on_document_title_changed) feed tab state. +#[tauri::command] +fn aether_tabs_report_native_event( + app: AppHandle, + state: State, + input: NativeTabEventInput, +) -> Cmd<()> { + match input.kind.as_str() { + "navigation" => { + let parked_on_start_page = { + let tabs = lock_tabs(&state)?; + tabs.tabs + .iter() + .find(|tab| tab.id == input.tab_id) + .map(|tab| tab.url == START_PAGE_URL) + .unwrap_or(true) + }; + // A tab parked on the start page keeps its (hidden) webview alive; + // ignore its stray events so the start-page sentinel survives. + if parked_on_start_page { + return Ok(()); + } + if let Some(url) = input.url.as_deref() { + update_tab_navigation_state( + &state, + &input.tab_id, + url, + input.is_loading.unwrap_or(false), + ); + } + { + let mut tabs = lock_tabs(&state)?; + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == input.tab_id) { + tab.native_can_go_back = input.can_go_back; + tab.native_can_go_forward = input.can_go_forward; + } + } + emit_state(&app, &state) + } + "title" => { + if let Some(title) = input.title.as_deref() { + update_tab_title(&state, &input.tab_id, title); + } + emit_state(&app, &state) + } + "find" => app + .emit( + AETHER_FIND_RESULT_EVENT, + FindResultPayload { + tab_id: input.tab_id, + current: input.current.unwrap_or(0), + total: input.total.unwrap_or(0), + }, + ) + .map_err(|error| error.to_string()), + _ => Ok(()), + } +} + +// Preview image (data-URI JPEG) for the mobile tab-grid switcher. Desktop +// renders live child webviews and never asks for one, so it returns None. +// Async on purpose: the Kotlin side resolves on the Android UI thread, so the +// blocking run_mobile_plugin call must sit on a tokio worker. +#[tauri::command(rename_all = "camelCase")] +async fn aether_tabs_thumbnail(app: AppHandle, tab_id: String) -> Cmd> { + #[cfg(target_os = "android")] + { + let response: android_tabs::ThumbnailResponse = app + .state::() + .run_for("thumbnail", android_tabs::TabPayload { tab_id: &tab_id })?; + return Ok(response.image); + } + #[allow(unreachable_code)] + { + let _ = (app, tab_id); + Ok(None) + } +} + +// System-bar/cutout insets (CSS px) for the edge-to-edge Android activity; +// zero on desktop, where the OS window frame handles this. Async for the same +// UI-thread reason as aether_tabs_thumbnail. +#[tauri::command] +async fn aether_layout_window_insets(app: AppHandle) -> Cmd { + #[cfg(target_os = "android")] + { + let response: android_tabs::InsetsResponse = app + .state::() + .run_for("insets", serde_json::json!({}))?; + return serde_json::to_value(response).map_err(|error| error.to_string()); + } + #[allow(unreachable_code)] + { + let _ = app; + serde_json::to_value(serde_json::json!({ + "top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0 + })) + .map_err(|error| error.to_string()) + } +} + +// The renderer measures where Android tab WebViews belong (MobileTabView's +// bounding rect, CSS px) and reports it here; desktop computes bounds natively +// from the window size instead, so this is a no-op there. +#[tauri::command(rename_all = "camelCase")] +fn aether_layout_set_mobile_tab_bounds( + app: AppHandle, + state: State, + top: f64, + left: f64, + width: f64, + height: f64, +) -> Cmd<()> { + #[cfg(not(desktop))] + { + *state + .mobile_tab_bounds + .lock() + .map_err(|_| "Æther layout bounds are unavailable.".to_string())? = MobileTabBounds { + top, + left, + width, + height, + }; + return sync_native_webview_visibility(&app, &state); + } + #[allow(unreachable_code)] + { + let _ = (app, state, top, left, width, height); + Ok(()) + } +} + #[tauri::command] fn aether_dashboard_open(app: AppHandle, state: State) -> Cmd<()> { { @@ -3347,7 +3805,11 @@ async fn aether_chat_ask( if let Some(captured) = captured { // Give the current page fewer slots when a hub is also in play so the // hub still contributes; let it use the full budget on its own. - let page_limit = if input.collection_id.is_some() { 3 } else { 8 }; + let page_limit = if input.collection_id.is_some() { + 3 + } else { + chat_citation_limit() + }; let page_citations = current_page_citations( &state, &settings, @@ -3364,7 +3826,7 @@ async fn aether_chat_ask( } let citations = dedupe_citations(citations) .into_iter() - .take(8) + .take(chat_citation_limit()) .collect::>(); local_chat(&state, &settings, &prompt, citations, Some(stream)).await } @@ -5681,8 +6143,11 @@ async fn local_chat( let mut runtime = runtime .lock() .map_err(|_| "Local model runtime is unavailable.".to_string())?; - let on_token: Option> = stream + let token_stream = stream.clone(); + let on_token: Option> = token_stream .map(|stream| Box::new(move |delta: &str| stream.delta(delta)) as Box); + let on_status: Option> = stream + .map(|stream| Box::new(move |status: String| stream.status(&status)) as Box); runtime.complete_chat( &model_path, messages, @@ -5690,6 +6155,7 @@ async fn local_chat( 0.2, &cancel, on_token, + on_status, ) }) .await @@ -5745,6 +6211,7 @@ async fn local_generate_iceberg( 0.35, &cancel, None, + None, ) }) .await @@ -5791,7 +6258,13 @@ impl NativeModelRuntime { .backend .as_ref() .ok_or_else(|| "Local model backend is not initialized.".to_string())?; - let mut params = LlamaModelParams::default().with_use_mmap(backend.supports_mmap()); + // Mobile: load weights into anonymous memory instead of mmapping the + // GGUF. Mmapped weight pages are ordinary page cache, and Android + // evicts them under the memory pressure the native tab WebViews + // create — after which every generated token faults back to flash and + // decode slows to a crawl. Malloc'd weights are app RSS and stay put. + let use_mmap = if cfg!(mobile) { false } else { backend.supports_mmap() }; + let mut params = LlamaModelParams::default().with_use_mmap(use_mmap); let use_gpu = match kind { NativeModelKind::Chat => local_gpu_enabled(), NativeModelKind::Embedding => embedding_gpu_enabled(), @@ -5963,6 +6436,7 @@ impl NativeModelRuntime { Ok(embeddings) } + #[cfg(desktop)] fn warm_embedding_model(&mut self, model_path: &Path) -> Cmd<()> { self.ensure_model(NativeModelKind::Embedding, model_path) } @@ -5975,7 +6449,23 @@ impl NativeModelRuntime { temperature: f32, cancel: &AtomicBool, on_token: Option>, + mut on_status: Option>, ) -> Cmd { + // The first ask pays for the multi-GB model load; on phone-class + // storage that is long enough to read as a hang without a status. + let needs_load = self + .chat + .as_ref() + .map(|loaded| loaded.path != canonical_model_path(model_path)) + .unwrap_or(true); + if needs_load { + if let Some(callback) = on_status.as_mut() { + callback(format!( + "Loading {} (first ask takes a moment)", + friendly_model_label(model_path) + )); + } + } self.ensure_model(NativeModelKind::Chat, model_path)?; let rendered = { let model = &self @@ -5992,9 +6482,11 @@ impl NativeModelRuntime { rendered.add_bos, cancel, on_token, + on_status, ) } + #[allow(clippy::too_many_arguments)] fn complete_loaded_prompt( &mut self, prompt: &str, @@ -6003,6 +6495,7 @@ impl NativeModelRuntime { add_bos: AddBos, cancel: &AtomicBool, mut on_token: Option>, + mut on_status: Option>, ) -> Cmd { let backend = self .backend @@ -6027,7 +6520,14 @@ impl NativeModelRuntime { tokens = tokens[tokens.len() - max_prompt_tokens..].to_vec(); } let n_batch = (chat_batch_token_limit() as u32).min(n_ctx).max(512); - let n_ubatch = n_batch.min(2048).max(512); + // Mobile: small micro-batches keep the compute buffer allocation + // phone-sized and make prefill progress tick in visible steps. + let n_ubatch = if cfg!(mobile) { + n_batch.min(512) + } else { + n_batch.min(2048) + } + .max(512); let threads = auto_thread_count(); let offload_ops = local_gpu_enabled(); let ctx_params = LlamaContextParams::default() @@ -6043,13 +6543,22 @@ impl NativeModelRuntime { .map_err(|error| error.to_string())?; let last_prompt_index = tokens.len().saturating_sub(1); - let prompt_batch_limit = n_batch as usize; + let prompt_batch_limit = if cfg!(mobile) { + (n_batch as usize).min(512) + } else { + n_batch as usize + }; + let total_prompt_tokens = tokens.len(); let mut prompt_cursor = 0usize; let mut sample_index = 0; while prompt_cursor < tokens.len() { if cancel.load(AtomicOrdering::Relaxed) { return Err("Generation stopped.".to_string()); } + if let Some(callback) = on_status.as_mut() { + let percent = prompt_cursor * 100 / total_prompt_tokens; + callback(format!("Reading context {percent}%")); + } let prompt_end = (prompt_cursor + prompt_batch_limit).min(tokens.len()); let mut prompt_batch = LlamaBatch::new(prompt_end - prompt_cursor, 1); for (offset, token) in tokens[prompt_cursor..prompt_end].iter().enumerate() { @@ -6065,6 +6574,9 @@ impl NativeModelRuntime { } prompt_cursor = prompt_end; } + if let Some(callback) = on_status.as_mut() { + callback("Generating answer".to_string()); + } let mut sampler = LlamaSampler::chain_simple([ LlamaSampler::top_k(DEFAULT_TOP_K), @@ -6599,7 +7111,9 @@ fn collect_gguf_models(root: &Path, models: &mut Vec) { } fn default_models_path(app_data_dir: &Path) -> PathBuf { - if cfg!(debug_assertions) { + // The repo-relative dev path is a compile-time string from the build + // machine; on a phone it would point at a nonexistent host filesystem. + if cfg!(all(debug_assertions, desktop)) { project_models_path() } else { app_data_dir.join("aether-models") @@ -6818,6 +7332,17 @@ fn model_label(path: &Path) -> String { .unwrap_or_else(|| path.display().to_string()) } +/// Product name for user-facing status text. Filenames must stay in sync with +/// `managed_model_spec`; anything unmanaged falls back to its cleaned filename. +fn friendly_model_label(path: &Path) -> String { + match path.file_name().and_then(|name| name.to_str()) { + Some("gemma-4-E2B_q4_0-it.gguf") => "AiON LiTE".to_string(), + Some("gemma-4-E4B_q4_0-it.gguf") => "AiON WiSE".to_string(), + Some("Qwen3-Embedding-0.6B-Q8_0.gguf") => "AiON MiST".to_string(), + _ => model_label(path), + } +} + fn strip_gguf_extension(value: &str) -> String { value .strip_suffix(".gguf") @@ -6827,10 +7352,18 @@ fn strip_gguf_extension(value: &str) -> String { } fn chat_context_tokens() -> u32 { + // Phones get half the desktop window: the KV cache plus compute buffers + // for 6k context put a multi-GB model into zram-thrashing territory, + // which reads as a silent hang during prefill. + let default = if cfg!(mobile) { + 3072 + } else { + DEFAULT_CHAT_CONTEXT_TOKENS + }; env::var(AETHER_LLM_CONTEXT_ENV) .ok() .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_CHAT_CONTEXT_TOKENS) + .unwrap_or(default) .clamp(1024, 65_536) } @@ -6878,8 +7411,12 @@ fn embedding_context_tokens(input_tokens: usize) -> u32 { } fn auto_thread_count() -> i32 { + // Mobile keeps one core free instead of two: recent flagships (like the + // all-big-core Snapdragon 8 Elite) have no little cores to avoid, prefill + // is compute-bound, and the UI sits idle while AiON works. + let reserve = if cfg!(mobile) { 1 } else { 2 }; std::thread::available_parallelism() - .map(|threads| threads.get().saturating_sub(2).clamp(2, 12) as i32) + .map(|threads| threads.get().saturating_sub(reserve).clamp(2, 12) as i32) .unwrap_or(6) } @@ -6898,12 +7435,37 @@ fn normalize_embedding(values: &[f32]) -> Vec { .collect() } +// Phones prefill on CPU, so prompt length is the ask latency. The mobile +// budget (5 sources x ~1100 chars + system + question) fits the 2048-token +// prompt window, where desktop's 8 full chunks would overflow it and get +// front-truncated — losing the system message and top-ranked sources while +// still paying full prefill cost. +fn chat_citation_limit() -> usize { + if cfg!(mobile) { + 5 + } else { + 8 + } +} + +fn chat_snippet_char_limit() -> usize { + if cfg!(mobile) { + 1100 + } else { + usize::MAX + } +} + fn build_chat_messages(prompt: &str, citations: &[SearchResult]) -> Vec { + let snippet_limit = chat_snippet_char_limit(); let context_block = citations .iter() .enumerate() .map(|(index, item)| { - let source_text = strip_numeric_bracket_markers(&item.text); + let mut source_text = strip_numeric_bracket_markers(&item.text); + if source_text.chars().count() > snippet_limit { + source_text = source_text.chars().take(snippet_limit).collect(); + } format!( "[{}] {}\nURL: {}\nCollection: {}\n{}", index + 1, @@ -6920,11 +7482,18 @@ fn build_chat_messages(prompt: &str, citations: &[SearchResult]) -> Vec return Ok(page), + Err(_) => {} + } + } + extract_readable_page(&state.client, &active_tab.url).await } +// Android counterpart of extract_readable_page_from_webview: the Kotlin +// TabsPlugin's `snapshot` command reads the live DOM through +// evaluateJavascript's value callback, so logged-in and JS-rendered pages +// capture correctly instead of falling back to an anonymous HTTP re-fetch. +#[cfg(target_os = "android")] +fn extract_readable_page_from_android(active_tab: &ManagedTab) -> Cmd { + let response: android_tabs::SnapshotResponse = android_tabs::run_for_global( + "snapshot", + android_tabs::TabPayload { + tab_id: &active_tab.id, + }, + )?; + let payload = response.payload.trim(); + if payload.is_empty() || payload == "null" { + return Err("Unable to read the active page.".to_string()); + } + let snapshot = parse_page_snapshot(payload)?; + snapshot_to_captured_page(snapshot, &active_tab.title) +} + #[cfg(desktop)] async fn extract_readable_page_from_webview( state: &State<'_, Backend>, @@ -7860,6 +8457,7 @@ fn active_tab_url(state: &State) -> Cmd { .ok_or_else(|| "No active browser tab.".to_string()) } +#[cfg(desktop)] fn active_tab_id(state: &State) -> Cmd { Ok(lock_tabs(state)?.active_tab_id.clone()) } @@ -8163,6 +8761,22 @@ mod tests { assert!(!version_is_newer("0.9.9", "1.0.0")); } + #[test] + fn github_release_parses_snake_case_api_payload() { + let payload = serde_json::json!({ + "tag_name": "v1.0.28", + "name": "v1.0.28", + "html_url": "https://github.com/CanPixel/aether/releases/tag/v1.0.28", + "body": "**Full Changelog**: ...", + "published_at": "2026-06-25T20:49:36Z", + "draft": false, + "prerelease": false + }); + let release: GithubRelease = serde_json::from_value(payload).expect("release parses"); + assert_eq!(release_version_from_tag(&release.tag_name), "1.0.28"); + assert_eq!(release.published_at.as_deref(), Some("2026-06-25T20:49:36Z")); + } + #[test] fn iceberg_normalizer_scores_layers_deterministically() { let response = serde_json::json!({ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3ca544b..db81e2b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "ÆTHER", "mainBinaryName": "aether", - "version": "1.0.28", + "version": "1.0.29", "identifier": "com.canur.aether", "build": { "beforeDevCommand": "bun run dev:vite", diff --git a/src/renderer/index.html b/src/renderer/index.html index 33d55b9..1f00400 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,6 +2,9 @@ + + ÆTHER diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 06068a0..c182129 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,5 @@ import { FormEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { addPluginListener, invoke, type PluginListener } from '@tauri-apps/api/core' import packageManifest from '../../../package.json' import { AetherState, @@ -36,6 +37,8 @@ import { UpdateCheckResult } from '../../shared/aether' import { BrowserChrome } from './components/BrowserChrome' +import { MobileShell } from './components/MobileShell' +import { MobileTabView } from './components/MobileTabView' import { StartPage } from './components/StartPage' import { CollectionDialog, CollectionDialogState } from './components/CollectionDialog' import { Crystallizer } from './components/Crystallizer' @@ -53,6 +56,7 @@ import { getTabTint, normalizeComparableUrl } from './utils/aether-ui' +import { HAS_NATIVE_TAB_WEBVIEWS, IS_ANDROID } from './utils/platform' import { ChevronDown, ChevronUp, @@ -1130,6 +1134,24 @@ function App(): React.JSX.Element { } } + // Mobile address bar: navigate the active tab (or open one) from a plain + // string, since MobileShell owns its own input instead of App's form. + async function mobileNavigateAddress(value: string): Promise { + const target = value.trim() + if (!target) return + try { + if (activeTab) { + await window.aether.tabs.navigate(activeTab.id, target) + setDashboardOpen(false) + setWorkspaceMode('dashboard') + } else { + await createTab({ url: target }) + } + } catch (error) { + setNotice(getErrorMessage(error)) + } + } + // Navigate the active (start-page) tab to a destination. The backend normalizes a // bare query into a search and lazily creates the tab's webview at the target. async function navigateActiveTab(input: string): Promise { @@ -1263,6 +1285,12 @@ function App(): React.JSX.Element { setAskCurrentPageOnly(false) setAskIncludeCurrentPage(false) setChatResult(null) + // On mobile the AiON surface is MobileShell's bottom sheet, not the + // desktop intelligence panel. + if (IS_ANDROID) { + mobileOpenAionRef.current?.() + return + } setAskPanelOpen(true) setPanelCollapsed(false) await window.aether.layout.setIntelligencePanelCollapsed(false) @@ -1843,6 +1871,54 @@ function App(): React.JSX.Element { await window.aether.layout.setIntelligencePanelCollapsed(next) } + // Android hardware back: peel UI layers before letting the system have it. + // Registering a `back-button` listener makes Tauri's AppPlugin route every + // back press here instead of applying its default (webview history) handling. + // The ref keeps the plugin subscription stable across renders while the + // handler always sees current state. + const androidBackRef = useRef<() => void>(() => undefined) + // MobileShell registers a handler here to close its own overlays (tab grid, + // sheets) before the app-level layers get peeled. + const mobileBackInterceptorRef = useRef<(() => boolean) | null>(null) + const mobileOpenAionRef = useRef<(() => void) | null>(null) + androidBackRef.current = () => { + if (mobileBackInterceptorRef.current?.()) return + if (findOpen) { + closeFindBar() + return + } + if (!dashboardOpen && activeTab?.canGoBack) { + void goBack() + return + } + if (!dashboardOpen) { + void openDashboard() + return + } + // Dashboard is the root of the app — hand back to Android, which + // backgrounds/finishes the activity like any other browser at its root. + void invoke('plugin:app|exit').catch(() => undefined) + } + + useEffect(() => { + if (!IS_ANDROID) return + let disposed = false + let listener: PluginListener | null = null + void addPluginListener('app', 'back-button', () => androidBackRef.current()) + .then((registered) => { + if (disposed) { + void registered.unregister() + } else { + listener = registered + } + }) + .catch(() => undefined) + return () => { + disposed = true + void listener?.unregister() + } + }, []) + async function runTask(label: string, task: () => Promise): Promise { setBusy(label) setNotice(null) @@ -1875,22 +1951,217 @@ function App(): React.JSX.Element { const showRailTooltips = dashboardOpen const startPageActive = !dashboardOpen && isStartPage + // Shared between the desktop and mobile shells so the two trees stay in sync + // without duplicating these prop lists. + const findBarNode = + findOpen && !dashboardOpen && !isStartPage && activeTab?.id ? ( + findHighlight(value, 'find')} + onNext={() => findHighlight(findQuery, 'next')} + onPrev={() => findHighlight(findQuery, 'prev')} + onClose={closeFindBar} + /> + ) : null + + const crystallizerNode = ( + + ) + + const dashboardNode = ( + { + void openCollectionDialog(state) + }} + askCollection={(collectionId) => { + void askCollectionHub(collectionId) + }} + reorderCollections={reorderCollections} + reorderSavedIcebergs={reorderSavedIcebergs} + reorderShortcuts={reorderShortcuts} + selectedCollectionId={selectedCollectionId} + savedIcebergs={savedIcebergs} + shortcuts={shortcuts} + selectCollection={selectCollection} + /> + ) + + const startPageNode = + + const overlayModals = ( + <> + {collectionDialog && ( + { + void closeCollectionDialog() + }} + onDelete={confirmDeleteCollection} + onSave={saveCollectionDialog} + /> + )} + + {modelSetupVisible && ( + { + void closeModelSetup() + }} + onStart={() => { + void startModelSetup() + }} + onToggleModel={toggleSetupModel} + /> + )} + + {settingsOpen && ( + checkForUpdates()} + onOpenUpdateRelease={openUpdateRelease} + onUpdateAutoCheck={updateAutoCheck} + onOpenModelSetup={openModelSetupFromSettings} + /> + )} + + ) + + if (IS_ANDROID) { + const mobileContent = crystallizerOpen ? ( + crystallizerNode + ) : dashboardOpen ? ( + dashboardNode + ) : startPageActive ? ( + startPageNode + ) : activeTab ? ( + + ) : ( +