platform: fix macOS data path + add get_os_info + frontend osInfo helper
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.
This commit is contained in:
@@ -187,6 +187,31 @@ env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
|
|||||||
short term. The eventual destination is SQLite-only with localStorage
|
short term. The eventual destination is SQLite-only with localStorage
|
||||||
as a transparent cache.
|
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
|
## Files changed this sprint
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,17 +1,55 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Resolve the app data directory.
|
/// Resolve the per-user app data directory, following each OS's convention:
|
||||||
/// Windows: %LOCALAPPDATA%/kon
|
///
|
||||||
/// Unix: ~/.kon
|
/// - 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()`
|
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
|
||||||
/// into a shared helper in `crates/core/` to avoid duplicating platform-specific
|
/// into a shared helper in `crates/core/` to avoid duplicating platform-specific
|
||||||
/// path logic across crates.
|
/// path logic across crates.
|
||||||
pub fn app_data_dir() -> PathBuf {
|
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());
|
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
|
||||||
PathBuf::from(local_app_data).join("kon")
|
return PathBuf::from(local_app_data).join("kon");
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
#[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());
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
PathBuf::from(home).join(".kon")
|
PathBuf::from(home).join(".kon")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,6 +344,72 @@ fn read_tail(path: &PathBuf, n_bytes: u64) -> std::io::Result<String> {
|
|||||||
Ok(String::from_utf8_lossy(&buf).to_string())
|
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
|
/// Write the report to a file and return the path. Lets the user save it
|
||||||
/// somewhere they can attach to an email.
|
/// somewhere they can attach to an email.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ pub fn run() {
|
|||||||
commands::diagnostics::list_crash_files,
|
commands::diagnostics::list_crash_files,
|
||||||
commands::diagnostics::generate_diagnostic_report,
|
commands::diagnostics::generate_diagnostic_report,
|
||||||
commands::diagnostics::save_diagnostic_report,
|
commands::diagnostics::save_diagnostic_report,
|
||||||
|
commands::diagnostics::get_os_info,
|
||||||
commands::live::start_live_transcription_session,
|
commands::live::start_live_transcription_session,
|
||||||
commands::live::stop_live_transcription_session,
|
commands::live::stop_live_transcription_session,
|
||||||
// Windows
|
// Windows
|
||||||
|
|||||||
100
src/lib/utils/osInfo.js
Normal file
100
src/lib/utils/osInfo.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import Titlebar from "$lib/components/Titlebar.svelte";
|
import Titlebar from "$lib/components/Titlebar.svelte";
|
||||||
import ToastViewport from "$lib/components/ToastViewport.svelte";
|
import ToastViewport from "$lib/components/ToastViewport.svelte";
|
||||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
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 { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||||
@@ -203,6 +204,10 @@
|
|||||||
// Diagnostics: capture every uncaught frontend error to error_log.
|
// Diagnostics: capture every uncaught frontend error to error_log.
|
||||||
installGlobalErrorCapture();
|
installGlobalErrorCapture();
|
||||||
|
|
||||||
|
// OS detection: warm the cache so components can use modKeyLabel() /
|
||||||
|
// isMac() / isWayland() synchronously after first render.
|
||||||
|
loadOsInfo().catch(() => { /* fallback already populated */ });
|
||||||
|
|
||||||
if (!tauriRuntimeAvailable) {
|
if (!tauriRuntimeAvailable) {
|
||||||
hotkeyBackend = "unavailable";
|
hotkeyBackend = "unavailable";
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user