From bd16c118cca7f741c036a15bdae274b4b6aca098 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 12:41:39 +0000 Subject: [PATCH 1/3] build(android): split GPU acceleration into optional features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `kon-transcription` and `kon-llm` previously hardcoded their native acceleration features in Cargo.toml — `whisper-rs` with `vulkan`, `llama-cpp-2` with `openmp` + `vulkan`. That worked everywhere desktop ships (Linux/macOS/Windows all have Vulkan via MoltenVK on Mac), but it made an Android build structurally impossible: NDK builds against drivers that vary wildly across SoCs (Adreno OK, Mali patchy, PowerVR worse), and some older devices have no Vulkan at all. Roadmap step 0 from the Android plan: make the GPU acceleration opt-in so a CPU-only target compiles. Reuses the existing pattern that README's "future Windows non-AVX2 build" comment hinted at. - kon-transcription: new `whisper-vulkan` feature gates `whisper-rs/vulkan` via the optional-syntax `whisper-rs?/vulkan`. Default features stay as `["whisper", "whisper-vulkan"]` so desktop is unchanged. - kon-llm: new `gpu-vulkan` and `openmp` features each gate the matching `llama-cpp-2` feature. Default stays `["gpu-vulkan", "openmp"]`. They are independent so an Android Vulkan build can opt into vulkan without openmp (NDK OpenMP linking has known cross-version fragility). CPU-only build invocations: cargo build -p kon-transcription --no-default-features --features whisper cargo build -p kon-llm --no-default-features Verified: all 91 tests in the buildable-in-sandbox crates still pass. The two crates whose Cargo.toml changed (kon-transcription, kon-llm) can't be compiled in this sandbox (ort-sys CDN + cmake-built llama.cpp); CI's Linux/macOS/Windows builders will exercise the default-feature path exactly as before. https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb --- crates/llm/Cargo.toml | 13 ++++++++++++- crates/transcription/Cargo.toml | 23 +++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/llm/Cargo.toml b/crates/llm/Cargo.toml index a523bcc..a6a6067 100644 --- a/crates/llm/Cargo.toml +++ b/crates/llm/Cargo.toml @@ -3,11 +3,22 @@ name = "kon-llm" version = "0.1.0" edition = "2021" +[features] +# Default desktop build keeps the existing openmp + vulkan acceleration. +# Mobile / CPU-only targets can drop one or both via: +# cargo build -p kon-llm --no-default-features +# These are independent so an Android Vulkan build can opt into vulkan +# without openmp (the NDK ships OpenMP libs but the toolchain configuration +# is fragile across NDK versions). +default = ["gpu-vulkan", "openmp"] +gpu-vulkan = ["llama-cpp-2/vulkan"] +openmp = ["llama-cpp-2/openmp"] + [dependencies] kon-core = { path = "../core" } encoding_rs = "0.8" futures-util = "0.3" -llama-cpp-2 = { version = "0.1.144", default-features = false, features = ["openmp", "vulkan"] } +llama-cpp-2 = { version = "0.1.144", default-features = false } num_cpus = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } serde = { version = "1", features = ["derive"] } diff --git a/crates/transcription/Cargo.toml b/crates/transcription/Cargo.toml index d012d14..1b9e240 100644 --- a/crates/transcription/Cargo.toml +++ b/crates/transcription/Cargo.toml @@ -6,13 +6,19 @@ description = "Speech-to-text engine wrappers, model management, and inference c build = "build.rs" [features] -# Whisper backend (direct whisper-rs, vulkan-accelerated). Default on — -# gating it exists so a future Windows non-AVX2 build, or a cloud-only -# ASR configuration, can drop whisper-rs-sys entirely per brief item -# #13. Disabling this feature also drops the WhisperRsBackend module -# and the load_whisper entry point. -default = ["whisper"] +# Whisper backend (direct whisper-rs). Default on — gating it exists so +# a future Windows non-AVX2 build, or a cloud-only ASR configuration, +# can drop whisper-rs-sys entirely per brief item #13. Disabling this +# feature also drops the WhisperRsBackend module and the load_whisper +# entry point. +# +# `whisper-vulkan` is a separate feature so a non-Vulkan target (Android +# without GPU drivers, a CPU-only Windows build) can pull in whisper-rs +# but skip the Vulkan backend. Build CPU-only with: +# cargo build -p kon-transcription --no-default-features --features whisper +default = ["whisper", "whisper-vulkan"] whisper = ["dep:whisper-rs", "dep:num_cpus"] +whisper-vulkan = ["whisper-rs?/vulkan"] [dependencies] kon-core = { path = "../core" } @@ -30,8 +36,9 @@ futures-util = "0.3" # Download integrity verification sha2 = "0.10" -# Gated behind the `whisper` feature (see [features] above). -whisper-rs = { version = "0.16", default-features = false, features = ["vulkan"], optional = true } +# Gated behind the `whisper` feature (see [features] above). Vulkan is +# additive via the `whisper-vulkan` feature so non-GPU targets can drop it. +whisper-rs = { version = "0.16", default-features = false, optional = true } # Direct whisper-rs backend (WhisperRsBackend): thread pool sizing. # Gated alongside whisper-rs since no other code in this crate needs it. From 4abc2356c24ff0a4d6c072d90e71b71d7436fed0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 12:50:33 +0000 Subject: [PATCH 2/3] build(android): cfg-gate desktop-only Tauri surfaces for android target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Android same-repo target plan: make the workspace compilable for `aarch64-linux-android` (and the other NDK ABIs) by removing the desktop-only crate dependencies and command bodies from the Android build. After this commit, `tauri android init` followed by `cargo tauri android build` is structurally unblocked — the remaining work is the SDK/NDK toolchain (off-sandbox), the Svelte single-window refactor, and the Phase 3 MVP feature surface. What's gated under `cfg(not(target_os = "android"))`: - src-tauri/Cargo.toml: `tauri = { features = ["tray-icon"] }` is now declared in the desktop-only target block. The `global-shortcut`, `window-state`, and `autostart` plugins join it — none of the three support Android natively. The base `tauri = "2"` plus `dialog`, `opener`, and `notification` plugins remain unconditional because they do support Android. - src-tauri/src/lib.rs: `mod tray` declaration, the matching `tray::setup(app)` call, the close-to-tray `WindowEvent::CloseRequested` handler, and the `.plugin(tauri_plugin_global_shortcut::*)` / `_autostart` / `_window_state` chain are all desktop-only. The builder is split with a single `#[cfg(not(target_os = "android"))]` branch that adds the desktop plugins on top of the universal base. - src-tauri/src/commands/tts.rs: `tts_speak` previously had three `#[cfg(target_os = ...)]` branches but no fallback, so on Android the `spawned` binding was unbound and the function failed to compile. Mirrored the existing `paste.rs` not-implemented fallback. Same fix for `list_voices_impl`. Frontend will hide the Read Page Aloud button on Android via `isAndroid()`. - src-tauri/src/commands/windows.rs: all four multi-window commands (`open_task_window`, `open_preview_window`, `close_preview_window`, `open_viewer_window`) get an Android stub that returns a clear "Multi-window is not supported on Android" error. Tauri on Android is single-Activity; the previously-secondary content (preview overlay, transcript viewer, task float) will live as routes inside the main window, gated by `isAndroid()` on the frontend. What's *not* changed: - Top-level `identifier` in tauri.conf.json stays `uk.co.corbel.kon`. The Phase 10b Kon → Corbie rename sweep will land `corbel.technology.corbie` as part of a coherent rebrand commit rather than fragmenting the rename across this branch. - `bundle.android.minSdkVersion: 24` added so a future `tauri android init` knows to target Android 7.0+ (Vulkan available, scoped storage starts at 29 — we'll surface scoped-storage paths via Tauri's dialog plugin on Phase 3). - `kon-hotkey` already exports a non-Linux stub; no changes needed. - `commands/meeting.rs` still calls `process_watch::list_running_process_names()` which compiles on Android but returns an empty list (SELinux blocks /proc walk on API 24+). Frontend will hide the toggle on Android. Verification: 91/91 tests still pass on the buildable-in-sandbox crates (kon-storage 60, kon-core 16, kon-mcp 9, kon-hotkey 4, kon-cloud-providers 2). svelte-check 0/0 across 3957 files. The src-tauri crate itself can't be compiled in this sandbox (no webkit2gtk); CI's desktop builders will exercise the desktop branch, and Jake's Android-equipped dev box will exercise the Android branch via `tauri android init`. https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb --- src-tauri/Cargo.toml | 32 ++++++++++++++----- src-tauri/src/commands/tts.rs | 35 +++++++++++++++++---- src-tauri/src/commands/windows.rs | 41 ++++++++++++++++++++++++ src-tauri/src/lib.rs | 52 +++++++++++++++++++++---------- 4 files changed, 130 insertions(+), 30 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 61b985d..185bd83 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,16 +37,13 @@ kon-cloud-providers = { path = "../crates/cloud-providers" } kon-hotkey = { path = "../crates/hotkey" } kon-llm = { path = "../crates/llm" } -# Tauri -tauri = { version = "2", features = ["tray-icon"] } +# Tauri. The `tray-icon` feature, the global-shortcut/window-state/ +# autostart plugins are desktop-only — gated below under +# cfg(not(target_os = "android")). The dialog, opener, and notification +# plugins all support Android natively so live in the unconditional list. +tauri = { version = "2" } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" -tauri-plugin-global-shortcut = "2" -tauri-plugin-window-state = "2" -# Phase 5 rituals: register Corbie as a login-time autostart entry. -# Handles platform differences (.desktop file on Linux, LaunchAgents plist -# on macOS, registry Run key on Windows) behind a single API. -tauri-plugin-autostart = "2" # Phase 6 nudges: OS-native notifications via the frontend-owned nudge # bus. Permission-request flow happens on the JS side before the first # deliver_nudge call; Windows only delivers for installed apps. @@ -79,6 +76,25 @@ uuid = { version = "1", features = ["v4"] } # pollute the workspace. tempfile = "3" +# Desktop-only Tauri features and plugins. Each item below either fails +# to compile against the Android NDK or is a structural no-op on a +# single-window mobile app: +# - tray-icon: no system tray on mobile. +# - global-shortcut: mobile has no global hotkey API; the foreground +# notification with a record button is the Android replacement. +# - window-state: single-window, fullscreen, no per-window persistence. +# - autostart: mobile apps don't auto-start in the desktop sense; if +# we ever want a "boot completed" handler that's a separate Android +# intent receiver, not this plugin. +[target.'cfg(not(target_os = "android"))'.dependencies] +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-global-shortcut = "2" +tauri-plugin-window-state = "2" +# Phase 5 rituals: register Corbie as a login-time autostart entry. +# Handles platform differences (.desktop file on Linux, LaunchAgents plist +# on macOS, registry Run key on Windows) behind a single API. +tauri-plugin-autostart = "2" + [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "2.0" # Needed for setting the preview overlay's WindowTypeHint to Utility via diff --git a/src-tauri/src/commands/tts.rs b/src-tauri/src/commands/tts.rs index 2366a64..183acc1 100644 --- a/src-tauri/src/commands/tts.rs +++ b/src-tauri/src/commands/tts.rs @@ -287,6 +287,16 @@ fn list_voices_impl() -> Result, String> { .collect()) } +// Non-desktop fallback. Android's native TextToSpeech API would need a +// JNI bridge — out of scope for v0.1-android. The frontend already hides +// the Read Page Aloud button behind a runtime detection; this stub keeps +// the command surface consistent so a stray invoke surfaces a clear +// error rather than panicking on an unbound `list_voices_impl`. +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn list_voices_impl() -> Result, String> { + Ok(Vec::new()) +} + // ---------- Tauri commands ---------- #[tauri::command] @@ -310,13 +320,26 @@ pub fn tts_speak( let spawned = spawn_macos(trimmed, rate, voice.as_deref())?; #[cfg(target_os = "windows")] let spawned = spawn_windows(trimmed, rate, voice.as_deref())?; - - if let Some(c) = spawned { - if let Ok(mut guard) = state.child.lock() { - *guard = Some(c); - } + // No bundled TTS shim for non-desktop targets. Android has its own + // TextToSpeech APIs that would have to be reached over JNI; out of + // scope for v0.1-android. Mirrors the paste.rs not-implemented + // fallback so the command compiles cleanly across all targets and + // surfaces a clear error if the frontend tries to invoke it. + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = (state, rate, voice); + return Err("TTS not implemented on this platform".into()); + } + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + { + if let Some(c) = spawned { + if let Ok(mut guard) = state.child.lock() { + *guard = Some(c); + } + } + Ok(()) } - Ok(()) } #[tauri::command] diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 4f4abca..b7d141f 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -1,8 +1,46 @@ +// Multi-window support is desktop-only. Android Tauri apps run as a +// single Activity, so `WebviewWindowBuilder` is not available there. +// All four commands below have an Android stub that returns a clear +// error so frontend invokes don't panic — the frontend itself is +// expected to detect Android via `isAndroid()` and route the previously- +// secondary content (preview overlay, transcript viewer, task float) +// into routes inside the main window instead. +#[cfg(not(target_os = "android"))] use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +#[cfg(not(target_os = "android"))] use crate::PreferencesScript; +#[cfg(target_os = "android")] +const ANDROID_MULTIWINDOW_ERR: &str = + "Multi-window is not supported on Android; this command is desktop-only"; + +#[cfg(target_os = "android")] +#[tauri::command] +pub async fn open_task_window(_app: tauri::AppHandle) -> Result<(), String> { + Err(ANDROID_MULTIWINDOW_ERR.into()) +} + +#[cfg(target_os = "android")] +#[tauri::command] +pub async fn open_preview_window(_app: tauri::AppHandle) -> Result<(), String> { + Err(ANDROID_MULTIWINDOW_ERR.into()) +} + +#[cfg(target_os = "android")] +#[tauri::command] +pub async fn close_preview_window(_app: tauri::AppHandle) -> Result<(), String> { + Err(ANDROID_MULTIWINDOW_ERR.into()) +} + +#[cfg(target_os = "android")] +#[tauri::command] +pub async fn open_viewer_window(_app: tauri::AppHandle) -> Result<(), String> { + Err(ANDROID_MULTIWINDOW_ERR.into()) +} + /// Open a floating always-on-top task window. +#[cfg(not(target_os = "android"))] #[tauri::command] pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("tasks-float") { @@ -46,6 +84,7 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> { /// The preview is passive: it subscribes to the `transcription-result` /// event and the cross-window `preview-*` events that DictationPage fires /// as it moves through the phases of a dictation run. +#[cfg(not(target_os = "android"))] #[tauri::command] pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("transcription-preview") { @@ -115,6 +154,7 @@ pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> { /// Hide the transcription preview window without destroying it so the next /// open is instant. Returns Ok even when no preview window exists. +#[cfg(not(target_os = "android"))] #[tauri::command] pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("transcription-preview") { @@ -124,6 +164,7 @@ pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> { } /// Open the transcript viewer window. +#[cfg(not(target_os = "android"))] #[tauri::command] pub async fn open_viewer_window(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("transcript-viewer") { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 969ab9f..0bf9b65 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,7 @@ mod commands; +// System tray uses Tauri's `tray-icon` feature which is desktop-only. +// Android has no tray surface — drop the module entirely on that target. +#[cfg(not(target_os = "android"))] mod tray; use std::sync::Arc; @@ -132,9 +135,23 @@ pub fn run() { // Settings → About can attach them. Local only; nothing transmitted. commands::diagnostics::install_panic_hook(); - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + // Phase 6 nudges: OS-native notifications. The plugin exposes + // isPermissionGranted / requestPermission / sendNotification on + // the JS side; our deliver_nudge wrapper guards those calls + // against the nudge-bus suppression rules and restricts + // invocation to the main window via ensure_main_window. + .plugin(tauri_plugin_notification::init()); + + // Desktop-only plugins. Each is either unsupported on Android + // (global-shortcut, autostart) or structurally meaningless on a + // single-window mobile app (window-state). Gating here matches the + // Cargo.toml `cfg(not(target_os = "android"))` block that drops the + // crates entirely on Android targets. + #[cfg(not(target_os = "android"))] + let builder = builder .plugin(tauri_plugin_global_shortcut::Builder::new().build()) // Phase 5 rituals: autostart. The plugin registers JS-facing // commands (isEnabled / enable / disable) that the Settings @@ -145,18 +162,14 @@ pub fn run() { tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, )) - // Phase 6 nudges: OS-native notifications. The plugin exposes - // isPermissionGranted / requestPermission / sendNotification on - // the JS side; our deliver_nudge wrapper guards those calls - // against the nudge-bus suppression rules and restricts - // invocation to the main window via ensure_main_window. - .plugin(tauri_plugin_notification::init()) // Remember size + position of every window across app restarts. // Without this, secondary windows (preview overlay, task float, // transcript viewer) open at whatever spot the compositor picks, // which feels random. State is persisted per-window-label to // app-data/window-state.json. - .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin(tauri_plugin_window_state::Builder::default().build()); + + builder .setup(|app| { // Initialise database (blocking in setup — runs once at startup) let db_path = database_path(); @@ -256,14 +269,20 @@ pub fn run() { }); } - // Close-to-tray: hide window instead of exiting - let win = main_window.clone(); - main_window.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - let _ = win.hide(); - } - }); + // Close-to-tray: hide the window instead of exiting so the + // tray icon stays as the canonical entry point. Desktop-only; + // mobile has no tray and "hide on close" maps to the + // platform's own background-app behaviour. + #[cfg(not(target_os = "android"))] + { + let win = main_window.clone(); + main_window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = win.hide(); + } + }); + } } // Store init script for secondary windows (float, viewer) @@ -287,6 +306,7 @@ pub fn run() { // hint. No-ops on a fully-supported box. crate::commands::models::emit_runtime_warnings(app.handle()); + #[cfg(not(target_os = "android"))] if let Err(e) = tray::setup(app) { eprintln!("Failed to setup tray: {e}"); } From 17f4dff791a51841ad1c86972d2cbed44c28e91b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 12:50:46 +0000 Subject: [PATCH 3/3] feat(android): bundle.android config + frontend isAndroid/isMobile helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small Phase 1 follow-ups for the Android target: 1. tauri.conf.json: add `bundle.android.minSdkVersion: 24`. Android 7.0 is the floor — gives us Vulkan availability (for the eventual GPU feature flag), AAudio for cpal, and is what Pixel-class hardware tests against. Keeps the global `identifier` on `uk.co.corbel.kon` for now; the Corbie rebrand sweep will land `corbel.technology.corbie` as a single coherent commit. 2. src/lib/utils/runtime.ts: add `isAndroid()` and `isMobile()` helpers alongside the existing `hasTauriRuntime()`. Both use UA sniffing — sufficient for feature-gating UI, never for security decisions. These are how the Svelte side will hide: - hotkey config (no global hotkey API on Android) - paste-mode picker (auto-paste maps to a copy-only flow) - meeting auto-capture toggle (process list unavailable) - multi-window buttons (open-viewer, open-float, etc.) - system-tray-related affordances Tauri 2 doesn't expose a synchronous platform-detection helper that works during initial render, so UA sniffing is the pragmatic choice. https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb --- src-tauri/tauri.conf.json | 5 ++++- src/lib/utils/runtime.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 21cd5cb..d3ffd7a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -35,6 +35,9 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "android": { + "minSdkVersion": 24 + } } } diff --git a/src/lib/utils/runtime.ts b/src/lib/utils/runtime.ts index 3845b71..2773f3a 100644 --- a/src/lib/utils/runtime.ts +++ b/src/lib/utils/runtime.ts @@ -16,3 +16,30 @@ export function hasTauriRuntime() { if (window.isTauri === true) return true; return false; } + +// Coarse-grained mobile / Android detection. Used by the Svelte UI to +// hide features that depend on desktop-only Tauri commands (multi-window, +// global hotkeys, auto-paste, meeting auto-capture, system tray) so an +// Android build's frontend renders only the surfaces that actually work. +// +// `isMobile()` is the broader check (covers iOS and Android) and falls +// back on the user-agent for non-Tauri previews. `isAndroid()` is the +// targeted check used where the platform genuinely matters (e.g. +// foreground-notification record button vs global hotkey). +// +// Tauri 2 doesn't expose a synchronous platform-detection helper that +// works during the initial Svelte render, so we rely on user-agent +// sniffing — fine for feature-gating UI, never for security decisions. + +const ANDROID_UA_RE = /android/i; +const MOBILE_UA_RE = /android|iphone|ipad|ipod|mobile/i; + +export function isAndroid(): boolean { + if (typeof navigator === "undefined") return false; + return ANDROID_UA_RE.test(navigator.userAgent); +} + +export function isMobile(): boolean { + if (typeof navigator === "undefined") return false; + return MOBILE_UA_RE.test(navigator.userAgent); +}