Compare commits
4 Commits
150059e174
...
be5a7146ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5a7146ca | ||
|
|
17f4dff791 | ||
|
|
4abc2356c2 | ||
|
|
bd16c118cc |
@@ -3,11 +3,22 @@ name = "kon-llm"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
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]
|
[dependencies]
|
||||||
kon-core = { path = "../core" }
|
kon-core = { path = "../core" }
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
futures-util = "0.3"
|
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"
|
num_cpus = "1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -6,13 +6,19 @@ description = "Speech-to-text engine wrappers, model management, and inference c
|
|||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Whisper backend (direct whisper-rs, vulkan-accelerated). Default on —
|
# Whisper backend (direct whisper-rs). Default on — gating it exists so
|
||||||
# gating it exists so a future Windows non-AVX2 build, or a cloud-only
|
# a future Windows non-AVX2 build, or a cloud-only ASR configuration,
|
||||||
# ASR configuration, can drop whisper-rs-sys entirely per brief item
|
# can drop whisper-rs-sys entirely per brief item #13. Disabling this
|
||||||
# #13. Disabling this feature also drops the WhisperRsBackend module
|
# feature also drops the WhisperRsBackend module and the load_whisper
|
||||||
# and the load_whisper entry point.
|
# entry point.
|
||||||
default = ["whisper"]
|
#
|
||||||
|
# `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 = ["dep:whisper-rs", "dep:num_cpus"]
|
||||||
|
whisper-vulkan = ["whisper-rs?/vulkan"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kon-core = { path = "../core" }
|
kon-core = { path = "../core" }
|
||||||
@@ -30,8 +36,9 @@ futures-util = "0.3"
|
|||||||
# Download integrity verification
|
# Download integrity verification
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|
||||||
# Gated behind the `whisper` feature (see [features] above).
|
# Gated behind the `whisper` feature (see [features] above). Vulkan is
|
||||||
whisper-rs = { version = "0.16", default-features = false, features = ["vulkan"], optional = true }
|
# 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.
|
# Direct whisper-rs backend (WhisperRsBackend): thread pool sizing.
|
||||||
# Gated alongside whisper-rs since no other code in this crate needs it.
|
# Gated alongside whisper-rs since no other code in this crate needs it.
|
||||||
|
|||||||
@@ -37,16 +37,13 @@ kon-cloud-providers = { path = "../crates/cloud-providers" }
|
|||||||
kon-hotkey = { path = "../crates/hotkey" }
|
kon-hotkey = { path = "../crates/hotkey" }
|
||||||
kon-llm = { path = "../crates/llm" }
|
kon-llm = { path = "../crates/llm" }
|
||||||
|
|
||||||
# Tauri
|
# Tauri. The `tray-icon` feature, the global-shortcut/window-state/
|
||||||
tauri = { version = "2", features = ["tray-icon"] }
|
# 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-opener = "2"
|
||||||
tauri-plugin-dialog = "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
|
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge
|
||||||
# bus. Permission-request flow happens on the JS side before the first
|
# bus. Permission-request flow happens on the JS side before the first
|
||||||
# deliver_nudge call; Windows only delivers for installed apps.
|
# deliver_nudge call; Windows only delivers for installed apps.
|
||||||
@@ -79,6 +76,25 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
# pollute the workspace.
|
# pollute the workspace.
|
||||||
tempfile = "3"
|
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]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
webkit2gtk = "2.0"
|
webkit2gtk = "2.0"
|
||||||
# Needed for setting the preview overlay's WindowTypeHint to Utility via
|
# Needed for setting the preview overlay's WindowTypeHint to Utility via
|
||||||
|
|||||||
@@ -287,6 +287,16 @@ fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
|
|||||||
.collect())
|
.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<Vec<TtsVoice>, String> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Tauri commands ----------
|
// ---------- Tauri commands ----------
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -310,13 +320,26 @@ pub fn tts_speak(
|
|||||||
let spawned = spawn_macos(trimmed, rate, voice.as_deref())?;
|
let spawned = spawn_macos(trimmed, rate, voice.as_deref())?;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let spawned = spawn_windows(trimmed, rate, voice.as_deref())?;
|
let spawned = spawn_windows(trimmed, rate, voice.as_deref())?;
|
||||||
|
// No bundled TTS shim for non-desktop targets. Android has its own
|
||||||
if let Some(c) = spawned {
|
// TextToSpeech APIs that would have to be reached over JNI; out of
|
||||||
if let Ok(mut guard) = state.child.lock() {
|
// scope for v0.1-android. Mirrors the paste.rs not-implemented
|
||||||
*guard = Some(c);
|
// 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]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -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};
|
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
use crate::PreferencesScript;
|
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.
|
/// Open a floating always-on-top task window.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
if let Some(window) = app.get_webview_window("tasks-float") {
|
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`
|
/// The preview is passive: it subscribes to the `transcription-result`
|
||||||
/// event and the cross-window `preview-*` events that DictationPage fires
|
/// event and the cross-window `preview-*` events that DictationPage fires
|
||||||
/// as it moves through the phases of a dictation run.
|
/// as it moves through the phases of a dictation run.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
if let Some(window) = app.get_webview_window("transcription-preview") {
|
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
|
/// Hide the transcription preview window without destroying it so the next
|
||||||
/// open is instant. Returns Ok even when no preview window exists.
|
/// open is instant. Returns Ok even when no preview window exists.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
if let Some(window) = app.get_webview_window("transcription-preview") {
|
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.
|
/// Open the transcript viewer window.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_viewer_window(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn open_viewer_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
if let Some(window) = app.get_webview_window("transcript-viewer") {
|
if let Some(window) = app.get_webview_window("transcript-viewer") {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
mod commands;
|
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;
|
mod tray;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -132,9 +135,23 @@ pub fn run() {
|
|||||||
// Settings → About can attach them. Local only; nothing transmitted.
|
// Settings → About can attach them. Local only; nothing transmitted.
|
||||||
commands::diagnostics::install_panic_hook();
|
commands::diagnostics::install_panic_hook();
|
||||||
|
|
||||||
tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::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())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
// Phase 5 rituals: autostart. The plugin registers JS-facing
|
// Phase 5 rituals: autostart. The plugin registers JS-facing
|
||||||
// commands (isEnabled / enable / disable) that the Settings
|
// commands (isEnabled / enable / disable) that the Settings
|
||||||
@@ -145,18 +162,14 @@ pub fn run() {
|
|||||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||||
None,
|
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.
|
// Remember size + position of every window across app restarts.
|
||||||
// Without this, secondary windows (preview overlay, task float,
|
// Without this, secondary windows (preview overlay, task float,
|
||||||
// transcript viewer) open at whatever spot the compositor picks,
|
// transcript viewer) open at whatever spot the compositor picks,
|
||||||
// which feels random. State is persisted per-window-label to
|
// which feels random. State is persisted per-window-label to
|
||||||
// app-data/window-state.json.
|
// app-data/window-state.json.
|
||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||||
|
|
||||||
|
builder
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Initialise database (blocking in setup — runs once at startup)
|
// Initialise database (blocking in setup — runs once at startup)
|
||||||
let db_path = database_path();
|
let db_path = database_path();
|
||||||
@@ -256,14 +269,20 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close-to-tray: hide window instead of exiting
|
// Close-to-tray: hide the window instead of exiting so the
|
||||||
let win = main_window.clone();
|
// tray icon stays as the canonical entry point. Desktop-only;
|
||||||
main_window.on_window_event(move |event| {
|
// mobile has no tray and "hide on close" maps to the
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
// platform's own background-app behaviour.
|
||||||
api.prevent_close();
|
#[cfg(not(target_os = "android"))]
|
||||||
let _ = win.hide();
|
{
|
||||||
}
|
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)
|
// Store init script for secondary windows (float, viewer)
|
||||||
@@ -287,6 +306,7 @@ pub fn run() {
|
|||||||
// hint. No-ops on a fully-supported box.
|
// hint. No-ops on a fully-supported box.
|
||||||
crate::commands::models::emit_runtime_warnings(app.handle());
|
crate::commands::models::emit_runtime_warnings(app.handle());
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
if let Err(e) = tray::setup(app) {
|
if let Err(e) = tray::setup(app) {
|
||||||
eprintln!("Failed to setup tray: {e}");
|
eprintln!("Failed to setup tray: {e}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"android": {
|
||||||
|
"minSdkVersion": 24
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,30 @@ export function hasTauriRuntime() {
|
|||||||
if (window.isTauri === true) return true;
|
if (window.isTauri === true) return true;
|
||||||
return false;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user