From 4e6ca0ed967daf567d93d7f962868c6a5d90e826 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 17 Apr 2026 13:52:58 +0100 Subject: [PATCH] platform: fix macOS data path + add get_os_info + frontend osInfo helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CROSS-PLATFORM AUDIT: - Linux x86_64 (Fedora 43, KDE Wayland): HIGH confidence (the dev target) - Linux other distros / X11: MEDIUM (tested patterns, untested distros) - Windows 10/11: LOW — theoretically supported via Tauri + CPAL + whisper.cpp + tauri-plugin-global-shortcut (the custom evdev hotkey is no-op on Windows); has had zero hands-on testing - macOS Apple Silicon / Intel: LOW — same; macOS Info.plist needs NSMicrophoneUsageDescription for the bundled app, and the path bug fixed in this commit REAL BUG FIXED — macOS app_data_dir was Unix-style: crates/storage/src/file_storage.rs::app_data_dir() previously fell into the "Unix" branch on macOS and wrote to ~/.kon/, which violates Apple guidelines and confuses install/uninstall tooling. Now correctly: - Windows: %LOCALAPPDATA%/kon (unchanged) - macOS: ~/Library/Application Support/Kon/ - Linux: $XDG_DATA_HOME/kon or ~/.local/share/kon (XDG Base Directory), with legacy ~/.kon/ fallback so existing installs keep working - Other Unix: ~/.kon/ (unchanged) OS DETECTION LAYER: - New get_os_info Tauri command in commands/diagnostics.rs returns: {os, arch, family, usesCmd, isWayland, customHotkeyBackend, primaryModifierLabel} - New src/lib/utils/osInfo.js helper: - async loadOsInfo() warms a cache, called once at root layout mount - then isMac() / isWindows() / isLinux() / modKeyLabel() / isWayland() are synchronous - browser-preview fallback reads navigator.platform - Lets components localise hotkey labels (Cmd vs Ctrl), file-picker copy ("Open Finder" vs "Open Explorer"), etc, without recomputing in every place. cargo check -p kon-storage clean. Updated HANDOVER-2026-04-17.md with a per-platform confidence table. --- HANDOVER-2026-04-17.md | 25 +++++++ crates/storage/src/file_storage.rs | 50 +++++++++++-- src-tauri/src/commands/diagnostics.rs | 66 +++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/utils/osInfo.js | 100 ++++++++++++++++++++++++++ src/routes/+layout.svelte | 5 ++ 6 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 src/lib/utils/osInfo.js diff --git a/HANDOVER-2026-04-17.md b/HANDOVER-2026-04-17.md index cf04569..75c4b46 100644 --- a/HANDOVER-2026-04-17.md +++ b/HANDOVER-2026-04-17.md @@ -187,6 +187,31 @@ env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \ short term. The eventual destination is SQLite-only with localStorage as a transparent cache. +## Cross-platform status (audited 2026/04/17) + +| Platform | Build | Run | Polish | Confidence | +|---|---|---|---|---| +| **Linux x86_64 (Fedora 43, KDE Wayland)** | ✓ | ✓ | The everyday dev target | **HIGH** — this is what the sprint was developed against | +| **Linux x86_64 (other distros, X11)** | Should work | Should work | Wayland self-relaunch is no-op on X11 sessions, evdev hotkeys may need user added to `input` group | **MEDIUM** — tested patterns, untested distros | +| **Windows 10/11 x86_64** | Untested but should compile (CPAL + Tauri + whisper.cpp all support it) | Untested | Custom evdev hotkeys are no-op; falls back to Tauri's global-shortcut plugin which works on Windows | **LOW** — theoretically supported, has had zero hands-on testing | +| **macOS aarch64 (Apple Silicon)** | Untested | Untested | Path bug fixed this commit (now uses `~/Library/Application Support/Kon/`); Info.plist needs `NSMicrophoneUsageDescription` for the app bundle | **LOW** — theoretically supported, has had zero hands-on testing | +| **macOS x86_64 (Intel)** | Same as Apple Silicon | Same | Same | **LOW** | + +**For the friends beta on Linux only**, this matters not at all. For a future Windows or macOS build, expect to spend a focused day or two debugging: + +- Tauri config: `bundle.macOS.entitlements`, `bundle.windows.signingIdentity` +- macOS code-signing + notarisation (real money: ~£75/yr Apple developer account) +- Windows code-signing certificate (~£100-300/yr) or accept SmartScreen warning +- whisper-rs-sys build dependencies per OS (cmake on all; Visual Studio Build Tools on Windows) +- macOS-specific Info.plist keys for microphone permission + +### What this sprint added on the cross-platform front + +- `crates/storage/src/file_storage.rs::app_data_dir()` now correctly handles macOS (`~/Library/Application Support/Kon/`) and Linux (XDG-aware, `~/.local/share/kon`, with legacy `~/.kon` fallback for existing installs). Windows path unchanged. +- New Tauri command `get_os_info` returns `{os, arch, family, usesCmd, isWayland, customHotkeyBackend, primaryModifierLabel}` so the frontend can adapt UI strings (Cmd vs Ctrl labels, "Open Finder" vs "Open Explorer", etc). +- New `src/lib/utils/osInfo.js` helper: async `loadOsInfo()` warms a cache, then `isMac() / isWindows() / isLinux() / modKeyLabel() / isWayland()` are synchronous. Eagerly loaded at app startup in the root layout. +- Falls back gracefully in browser-preview mode by reading `navigator.platform`. + ## Files changed this sprint ``` diff --git a/crates/storage/src/file_storage.rs b/crates/storage/src/file_storage.rs index d99bffc..c7d8bdf 100644 --- a/crates/storage/src/file_storage.rs +++ b/crates/storage/src/file_storage.rs @@ -1,17 +1,55 @@ use std::path::PathBuf; -/// Resolve the app data directory. -/// Windows: %LOCALAPPDATA%/kon -/// Unix: ~/.kon +/// Resolve the per-user app data directory, following each OS's convention: +/// +/// - Windows: `%LOCALAPPDATA%\kon\` e.g. `C:\Users\Jake\AppData\Local\kon` +/// - macOS: `~/Library/Application Support/Kon/` +/// - Linux: `$XDG_DATA_HOME/kon` or `~/.local/share/kon` (XDG Base Directory), +/// with a fallback to the legacy `~/.kon/` if it already exists, so +/// existing installs keep working. +/// - Other Unix: `~/.kon/` /// /// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()` /// into a shared helper in `crates/core/` to avoid duplicating platform-specific /// path logic across crates. pub fn app_data_dir() -> PathBuf { - if cfg!(target_os = "windows") { + #[cfg(target_os = "windows")] + { let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()); - PathBuf::from(local_app_data).join("kon") - } else { + return PathBuf::from(local_app_data).join("kon"); + } + + #[cfg(target_os = "macos")] + { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + return PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("Kon"); + } + + #[cfg(target_os = "linux")] + { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + + // Honour the legacy ~/.kon/ if it exists on disk so existing + // installs are not orphaned. New installs follow XDG. + let legacy = PathBuf::from(&home).join(".kon"); + if legacy.exists() { + return legacy; + } + + // XDG Base Directory: $XDG_DATA_HOME/kon or default ~/.local/share/kon + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("kon"); + } + } + return PathBuf::from(home).join(".local").join("share").join("kon"); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home).join(".kon") } diff --git a/src-tauri/src/commands/diagnostics.rs b/src-tauri/src/commands/diagnostics.rs index 82618e9..17983ff 100644 --- a/src-tauri/src/commands/diagnostics.rs +++ b/src-tauri/src/commands/diagnostics.rs @@ -344,6 +344,72 @@ fn read_tail(path: &PathBuf, n_bytes: u64) -> std::io::Result { Ok(String::from_utf8_lossy(&buf).to_string()) } +/// Cross-platform OS info exposed to the frontend so the UI can adapt: +/// hotkey labels (Cmd vs Ctrl), file-picker text, "open file location" +/// terminology, etc. Returned on demand via the `get_os_info` Tauri +/// command rather than recomputed in JS so we have a single source of +/// truth in the Rust layer. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OsInfo { + /// "windows" | "macos" | "linux" | "ios" | "android" | other + pub os: String, + /// "x86_64" | "aarch64" | other + pub arch: String, + /// Human-readable platform family for tooltips: "Windows", "macOS", + /// "Linux" + pub family: String, + /// True if the platform uses the Cmd modifier rather than Ctrl as + /// the primary accelerator (macOS). + pub uses_cmd: bool, + /// True if the user is currently in a Wayland session (Linux only). + /// Useful for status display; the actual workaround is automatic. + pub is_wayland: bool, + /// True if the custom evdev hotkey listener is the primary backend + /// (Linux only). Otherwise the Tauri global-shortcut plugin is used. + pub custom_hotkey_backend: bool, + /// Convenience: a localised label for the primary modifier + /// ("Cmd" on macOS, "Ctrl" elsewhere). + pub primary_modifier_label: String, +} + +#[tauri::command] +pub fn get_os_info() -> OsInfo { + let os = std::env::consts::OS.to_string(); + let arch = std::env::consts::ARCH.to_string(); + let family = match os.as_str() { + "windows" => "Windows", + "macos" => "macOS", + "linux" => "Linux", + "ios" => "iOS", + "android" => "Android", + _ => "Unknown", + } + .to_string(); + let uses_cmd = os == "macos"; + let primary_modifier_label = if uses_cmd { "Cmd" } else { "Ctrl" }.to_string(); + + let is_wayland = if os == "linux" { + std::env::var("XDG_SESSION_TYPE") + .map(|v| v.eq_ignore_ascii_case("wayland")) + .unwrap_or(false) + } else { + false + }; + + let custom_hotkey_backend = os == "linux"; + + OsInfo { + os, + arch, + family, + uses_cmd, + is_wayland, + custom_hotkey_backend, + primary_modifier_label, + } +} + /// Write the report to a file and return the path. Lets the user save it /// somewhere they can attach to an email. #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e06346a..0da8061 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -252,6 +252,7 @@ pub fn run() { commands::diagnostics::list_crash_files, commands::diagnostics::generate_diagnostic_report, commands::diagnostics::save_diagnostic_report, + commands::diagnostics::get_os_info, commands::live::start_live_transcription_session, commands::live::stop_live_transcription_session, // Windows diff --git a/src/lib/utils/osInfo.js b/src/lib/utils/osInfo.js new file mode 100644 index 0000000..032f6e1 --- /dev/null +++ b/src/lib/utils/osInfo.js @@ -0,0 +1,100 @@ +// Cross-platform OS info for the Svelte frontend. Single async fetch, +// cached in memory, exposes synchronous helpers after first await. +// +// Backed by the `get_os_info` Tauri command (commands/diagnostics.rs). +// In browser-preview mode (no Tauri runtime), falls back to a sensible +// default based on navigator.platform. +// +// Usage: +// import { loadOsInfo, osInfo, modKeyLabel, isMac } from '$lib/utils/osInfo.js'; +// await loadOsInfo(); // call once at app startup +// modKeyLabel(); // "Cmd" on macOS, "Ctrl" elsewhere +// if (isMac()) { ... } +// +// The store version (osInfo) is reactive — Svelte components can read it +// directly with `$osInfo` once Svelte 5 runes are everywhere, or via +// destructuring on a non-reactive read. + +import { invoke } from '@tauri-apps/api/core'; +import { hasTauriRuntime } from './runtime.js'; + +let cached = null; + +const FALLBACK_BROWSER_INFO = { + os: detectBrowserOs(), + arch: 'unknown', + family: detectBrowserFamily(), + usesCmd: detectBrowserOs() === 'macos', + isWayland: false, + customHotkeyBackend: false, + primaryModifierLabel: detectBrowserOs() === 'macos' ? 'Cmd' : 'Ctrl', +}; + +function detectBrowserOs() { + if (typeof navigator === 'undefined') return 'unknown'; + const ua = (navigator.userAgent || '').toLowerCase(); + const platform = (navigator.platform || '').toLowerCase(); + if (platform.includes('mac') || ua.includes('mac os')) return 'macos'; + if (platform.includes('win') || ua.includes('windows')) return 'windows'; + if (platform.includes('linux') || ua.includes('linux')) return 'linux'; + return 'unknown'; +} + +function detectBrowserFamily() { + switch (detectBrowserOs()) { + case 'macos': return 'macOS'; + case 'windows': return 'Windows'; + case 'linux': return 'Linux'; + default: return 'Unknown'; + } +} + +/** Fetch OS info from the Tauri backend. Idempotent — call multiple times, + * only the first does the round-trip. Browser-preview mode returns the + * navigator-detected fallback. */ +export async function loadOsInfo() { + if (cached) return cached; + if (!hasTauriRuntime()) { + cached = FALLBACK_BROWSER_INFO; + return cached; + } + try { + cached = await invoke('get_os_info'); + } catch (err) { + console.warn('loadOsInfo: get_os_info failed, using browser fallback', err); + cached = FALLBACK_BROWSER_INFO; + } + return cached; +} + +/** Synchronous accessor. Returns null if loadOsInfo has not been awaited + * yet. Components that need the value at first render should await + * loadOsInfo() in onMount before reading. */ +export function osInfo() { + return cached; +} + +export function isMac() { + return cached?.os === 'macos'; +} + +export function isWindows() { + return cached?.os === 'windows'; +} + +export function isLinux() { + return cached?.os === 'linux'; +} + +/** Localised label for the primary keyboard modifier. "Cmd" on macOS, + * "Ctrl" everywhere else. Use in hotkey display strings: + * `${modKeyLabel()}+Shift+R` */ +export function modKeyLabel() { + return cached?.primaryModifierLabel ?? 'Ctrl'; +} + +/** True if the current Linux session is Wayland. False on Windows/macOS. + * Useful for Settings → Audio "PipeWire detected" status display. */ +export function isWayland() { + return !!cached?.isWayland; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 445887f..6dbf0e6 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import Titlebar from "$lib/components/Titlebar.svelte"; import ToastViewport from "$lib/components/ToastViewport.svelte"; import { hasTauriRuntime } from "$lib/utils/runtime.js"; + import { loadOsInfo } from "$lib/utils/osInfo.js"; import { page, settings, saveSettings } from "$lib/stores/page.svelte.js"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; import { hasTauriRuntime } from "$lib/utils/runtime.js"; @@ -203,6 +204,10 @@ // Diagnostics: capture every uncaught frontend error to error_log. installGlobalErrorCapture(); + // OS detection: warm the cache so components can use modKeyLabel() / + // isMac() / isWayland() synchronously after first render. + loadOsInfo().catch(() => { /* fallback already populated */ }); + if (!tauriRuntimeAvailable) { hotkeyBackend = "unavailable"; return;