From eb6817aa619d041b257a6b1be30482cd06cdc254 Mon Sep 17 00:00:00 2001 From: Emanschi Date: Sat, 29 Aug 2026 12:18:01 +0200 Subject: [PATCH 01/17] Add Linux build support: cross-platform build, Vosk linking, platform module Ports Flow to compile and run natively on Linux (Arch, verified) while keeping every Windows code path untouched: - Gate the `windows`/`winreg` crates to cfg(windows) only, so Cargo no longer tries to build Windows-only bindings on Linux. - Add Linux Vosk linking to build.rs: resolves libvosk.so via FLOW_VOSK_LIB_DIR override, a vendored copy, or the system library (ldconfig), with rpath set for portable/vendored builds. - Add packaging/linux/fetch-vosk.sh to fetch and checksum-verify a vendored libvosk.so for AppImage/deb/rpm builds that shouldn't depend on a system vosk-api install. - Add packaging/linux/flow-launcher.sh, forcing GDK_BACKEND=x11 (XWayland) by default since the overlay's always-on-top/positioning/global hotkeys don't work under native Wayland, plus the WebKitGTK DMABUF renderer workaround. - Add src-tauri/src/platform/ with a Linux session_type() helper. - Filter cpal's ALSA device enumeration on Linux down to the devices that make sense to pick as a microphone (raw enumeration mixes in ALSA plugin internals and output-only topology variants). - Add tauri-plugin-single-instance (Unix only) so a second `flow --toggle-clickthrough/--show/--hide` invocation controls the running instance instead of opening a second window - needed on native Wayland where the app can't register its own global hotkeys. - Use Tauri's cross-platform `.parent()` instead of the Windows-only `.owner()` for the remote-inbox window relationship. Verified on Arch Linux/KDE under XWayland: builds clean, Vosk model loads and decodes without ABI issues against the system vosk-api package, tray icon and its menu work end-to-end, global shortcuts register and fire correctly. See plan/notes/ for full verification detail. --- .gitignore | 1 + packaging/linux/fetch-vosk.sh | 67 +++++++++++++ packaging/linux/flow-launcher.sh | 50 ++++++++++ src-tauri/Cargo.lock | 17 ++++ src-tauri/Cargo.toml | 5 +- src-tauri/build.rs | 104 +++++++++++++++++++- src-tauri/resources/vosk/linux-x64/.gitkeep | 0 src-tauri/src/lib.rs | 46 ++++++++- src-tauri/src/platform/linux.rs | 41 ++++++++ src-tauri/src/platform/mod.rs | 20 ++++ src-tauri/src/voice_engine.rs | 68 ++++++++++++- 11 files changed, 412 insertions(+), 7 deletions(-) create mode 100755 packaging/linux/fetch-vosk.sh create mode 100755 packaging/linux/flow-launcher.sh create mode 100644 src-tauri/resources/vosk/linux-x64/.gitkeep create mode 100644 src-tauri/src/platform/linux.rs create mode 100644 src-tauri/src/platform/mod.rs diff --git a/.gitignore b/.gitignore index 97eef33..f140c20 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist-ssr *.local src-tauri/target/ src-tauri/gen/ +src-tauri/resources/vosk/linux-x64/*.so tools/ tmp/ src/pro diff --git a/packaging/linux/fetch-vosk.sh b/packaging/linux/fetch-vosk.sh new file mode 100755 index 0000000..f277e91 --- /dev/null +++ b/packaging/linux/fetch-vosk.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Flow - A high-performance teleprompter for Windows. +# Copyright (C) 2026 Waled Alturkmani (LumoRez07) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Fetches the official prebuilt Vosk API shared library for Linux x86_64 +# and stages it under src-tauri/resources/vosk/linux-x64/, so that +# src-tauri/build.rs can pick it up as a vendored fallback for packaged +# builds (AppImage/deb/rpm) that should not depend on a system-installed +# vosk-api package. +# +# The Vosk project stopped publishing prebuilt release assets after +# v0.3.45 (v0.3.50, the version Arch Linux's `vosk-api` package builds +# from source, has no binary release assets) - see +# https://github.com/alphacep/vosk-api/releases. v0.3.45 is therefore +# pinned deliberately, not because it is "latest". + +set -euo pipefail + +VOSK_VERSION="0.3.45" +ARCHIVE_NAME="vosk-linux-x86_64-${VOSK_VERSION}.zip" +DOWNLOAD_URL="https://github.com/alphacep/vosk-api/releases/download/v${VOSK_VERSION}/${ARCHIVE_NAME}" +# Verified by hand against the published GitHub release asset: +# curl -sL "$DOWNLOAD_URL" | sha256sum +EXPECTED_SHA256="bbdc8ed85c43979f6443142889770ea95cbfbc56cffb5c5dcd73afa875c5fbb2" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +DEST_DIR="${REPO_ROOT}/src-tauri/resources/vosk/linux-x64" +DEST_LIB="${DEST_DIR}/libvosk.so" + +if [[ -f "${DEST_LIB}" ]]; then + echo "libvosk.so already present at ${DEST_LIB}, skipping download." + echo "Delete it and re-run this script to force a re-fetch." + exit 0 +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "${WORK_DIR}"' EXIT + +ARCHIVE_PATH="${WORK_DIR}/${ARCHIVE_NAME}" + +echo "Downloading Vosk API ${VOSK_VERSION} for linux-x86_64..." +curl -fL --retry 3 -o "${ARCHIVE_PATH}" "${DOWNLOAD_URL}" + +echo "Verifying checksum..." +ACTUAL_SHA256="$(sha256sum "${ARCHIVE_PATH}" | cut -d' ' -f1)" +if [[ "${ACTUAL_SHA256}" != "${EXPECTED_SHA256}" ]]; then + echo "ERROR: checksum mismatch for ${ARCHIVE_NAME}" >&2 + echo " expected: ${EXPECTED_SHA256}" >&2 + echo " actual: ${ACTUAL_SHA256}" >&2 + exit 1 +fi + +echo "Extracting libvosk.so..." +unzip -q -j "${ARCHIVE_PATH}" "vosk-linux-x86_64-${VOSK_VERSION}/libvosk.so" -d "${WORK_DIR}" + +mkdir -p "${DEST_DIR}" +mv "${WORK_DIR}/libvosk.so" "${DEST_LIB}" +chmod 0644 "${DEST_LIB}" + +echo "Staged $(du -h "${DEST_LIB}" | cut -f1) libvosk.so at ${DEST_LIB}" diff --git a/packaging/linux/flow-launcher.sh b/packaging/linux/flow-launcher.sh new file mode 100755 index 0000000..befad58 --- /dev/null +++ b/packaging/linux/flow-launcher.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# Flow - A high-performance teleprompter for Windows. +# Copyright (C) 2026 Waled Alturkmani (LumoRez07) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Launcher wrapper for the Flow teleprompter on Linux. +# +# Flow is an always-on-top overlay: it needs absolute window positioning, +# always-on-top, and global hotkeys to work the way it does on Windows. +# Neither GTK3 nor the windowing toolkit Tauri uses on Linux (tao) support +# those under native Wayland, so this wrapper forces the app onto X11 via +# XWayland by default. Set FLOW_ALLOW_WAYLAND=1 to opt out and run under +# native Wayland instead (window positioning, always-on-top and global +# hotkeys will not work correctly in that mode). +# +# WEBKIT_DISABLE_DMABUF_RENDERER=1 works around the most common cause of a +# blank/black WebKitGTK window on Linux (DMA-BUF renderer issues, seen +# especially on some AMD/Mesa driver combinations). It is on by default; +# unset it explicitly if you have verified your system does not need it. + +set -euo pipefail + +if [[ -z "${FLOW_ALLOW_WAYLAND:-}" ]]; then + export GDK_BACKEND=x11 +fi + +export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Installed layout: binary lives at /usr/lib/flow/flow next to this wrapper's +# install location (/usr/bin/flow). Development layout: fall back to the +# debug binary built by `cargo build` inside the repository. +if [[ -x "${SCRIPT_DIR}/../lib/flow/flow" ]]; then + BINARY="${SCRIPT_DIR}/../lib/flow/flow" +elif [[ -x "${SCRIPT_DIR}/../../src-tauri/target/debug/flow" ]]; then + BINARY="${SCRIPT_DIR}/../../src-tauri/target/debug/flow" +elif [[ -x "${SCRIPT_DIR}/../../src-tauri/target/release/flow" ]]; then + BINARY="${SCRIPT_DIR}/../../src-tauri/target/release/flow" +else + echo "flow-launcher.sh: could not locate the flow binary" >&2 + exit 1 +fi + +exec "${BINARY}" "$@" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 847cf82..5b58a40 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1211,6 +1211,7 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-prevent-default", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", "uuid", @@ -4659,6 +4660,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e91dd89..643af0e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,11 +32,14 @@ tauri-plugin-prevent-default = "4" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } uuid = { version = "1", features = ["serde", "v4"] } vosk = "0.3.1" -windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Power", "Win32_UI_WindowsAndMessaging"] } futures-util = "0.3" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } zip = { version = "0.6", default-features = false, features = ["deflate"] } [target.'cfg(windows)'.dependencies] +windows = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Power", "Win32_UI_WindowsAndMessaging"] } winreg = "0.52" +[target.'cfg(unix)'.dependencies] +tauri-plugin-single-instance = "2" + diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 5901c1e..1e726a8 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -60,10 +60,14 @@ fn panic_message(payload: Box) -> String { } fn stage_vosk_runtime() -> Result<(), String> { - if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("windows") { - return Ok(()); + match env::var("CARGO_CFG_TARGET_OS").ok().as_deref() { + Some("windows") => stage_vosk_windows(), + Some("linux") => stage_vosk_linux(), + _ => Ok(()), } +} +fn stage_vosk_windows() -> Result<(), String> { let runtime_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|error| error.to_string())?) .join("resources") @@ -109,6 +113,102 @@ fn stage_vosk_runtime() -> Result<(), String> { Ok(()) } +fn stage_vosk_linux() -> Result<(), String> { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|error| error.to_string())?); + let vendored_dir = manifest_dir.join("resources").join("vosk").join("linux-x64"); + let vendored_lib = vendored_dir.join("libvosk.so"); + + println!("cargo:rerun-if-env-changed=FLOW_VOSK_LIB_DIR"); + println!("cargo:rerun-if-changed={}", vendored_dir.display()); + + // Resolution order: explicit override, then a vendored copy (AppImage/deb/rpm + // packaging), then whatever the system linker can already find (the Arch path, + // where `vosk-api` installs libvosk.so into /usr/lib). + if let Ok(override_dir) = env::var("FLOW_VOSK_LIB_DIR") { + let override_path = PathBuf::from(&override_dir); + let override_lib = override_path.join("libvosk.so"); + + if !override_lib.is_file() { + return Err(format!( + "FLOW_VOSK_LIB_DIR is set to '{}' but no libvosk.so was found there.\n\ + Install the Vosk API runtime (e.g. `sudo pacman -S vosk-api` on Arch \ + Linux) or point FLOW_VOSK_LIB_DIR at a directory containing libvosk.so.", + override_path.display() + )); + } + + emit_vosk_link_directives(&override_path); + return Ok(()); + } + + if vendored_lib.is_file() { + emit_vosk_link_directives(&vendored_dir); + + let profile_dir = resolve_profile_dir()?; + fs::create_dir_all(&profile_dir).map_err(|error| error.to_string())?; + let destination = profile_dir.join("libvosk.so"); + copy_if_changed(&vendored_lib, &destination)?; + + return Ok(()); + } + + if let Some(system_dir) = find_system_vosk_lib_dir() { + emit_vosk_link_directives(&system_dir); + return Ok(()); + } + + Err( + "Could not find libvosk.so for the Linux build.\n\ + Install the Vosk API runtime, e.g. `sudo pacman -S vosk-api` on Arch Linux,\n\ + or place a vendored copy at src-tauri/resources/vosk/linux-x64/libvosk.so\n\ + (see packaging/linux/fetch-vosk.sh), or set FLOW_VOSK_LIB_DIR to a directory\n\ + containing libvosk.so." + .to_string(), + ) +} + +fn emit_vosk_link_directives(dir: &Path) { + println!("cargo:rustc-link-search=native={}", dir.display()); + println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN"); + println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/../lib/flow"); +} + +fn find_system_vosk_lib_dir() -> Option { + let output = process::Command::new("ldconfig").arg("-p").output().ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().find_map(|line| { + let line = line.trim(); + if !line.starts_with("libvosk.so") { + return None; + } + + let resolved_path = line.rsplit("=> ").next()?.trim(); + PathBuf::from(resolved_path) + .parent() + .map(Path::to_path_buf) + }) +} + +fn copy_if_changed(source: &Path, destination: &Path) -> Result<(), String> { + if destination.is_file() { + let source_metadata = fs::metadata(source).map_err(|error| error.to_string())?; + let destination_metadata = fs::metadata(destination).map_err(|error| error.to_string())?; + + if source_metadata.len() == destination_metadata.len() { + return Ok(()); + } + } + + fs::copy(source, destination) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + fn stage_runtime_file(source: &Path, destination: &Path) -> Result<(), String> { if destination.is_file() { let source_metadata = fs::metadata(source).map_err(|error| error.to_string())?; diff --git a/src-tauri/resources/vosk/linux-x64/.gitkeep b/src-tauri/resources/vosk/linux-x64/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6d7508a..bfde1d9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,6 +43,7 @@ use uuid::Uuid; use vosk::{set_log_level, LogLevel}; use zip::ZipArchive; +mod platform; mod voice_engine; #[cfg(windows)] @@ -1374,7 +1375,16 @@ fn ensure_window( if label == "remote-inbox" { if let Some(main) = app.get_webview_window("main") { - builder = builder.owner(&main)?; + // `owner()` is Windows-only in Tauri; `parent()` is the cross-platform + // equivalent (GTK `set_transient_for` on Linux, child window on macOS). + #[cfg(windows)] + { + builder = builder.owner(&main)?; + } + #[cfg(not(windows))] + { + builder = builder.parent(&main)?; + } } } @@ -1694,6 +1704,29 @@ fn toggle_clickthrough_impl( set_clickthrough_impl(app, desktop, !current) } +/// Lets a user on native Wayland (where global hotkeys can't be grabbed by +/// the app itself, see `platform::session_type`) bind desktop-environment +/// shortcuts to launching `flow --toggle-clickthrough` / `--show` / `--hide`. +/// The single-instance plugin forwards the second invocation's argv here +/// instead of starting a second app instance. +#[cfg(unix)] +fn handle_secondary_instance_cli_args(app: &tauri::AppHandle, argv: &[String]) { + let Some(desktop) = app.try_state::() else { + return; + }; + + for arg in argv.iter().skip(1) { + match arg.as_str() { + "--toggle-clickthrough" => { + let _ = toggle_clickthrough_impl(app, &desktop); + } + "--show" => show_main_window(app), + "--hide" => hide_main_window_impl(app), + _ => {} + } + } +} + #[tauri::command] fn close_app(app: tauri::AppHandle) { exit_app(&app); @@ -2802,7 +2835,16 @@ pub fn run() { let voice_model_downloads = VoiceModelDownloads::default(); let voice_engine_state = voice_engine::VoiceEngineState::default(); - let result = tauri::Builder::default() + let mut builder = tauri::Builder::default(); + + #[cfg(unix)] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + handle_secondary_instance_cli_args(app, &argv); + })); + } + + let result = builder .manage(relay.clone()) .manage(desktop_state) .manage(voice_model_downloads) diff --git a/src-tauri/src/platform/linux.rs b/src-tauri/src/platform/linux.rs new file mode 100644 index 0000000..1b6e973 --- /dev/null +++ b/src-tauri/src/platform/linux.rs @@ -0,0 +1,41 @@ +/* + * Flow - A high-performance teleprompter for Windows. + * Copyright (C) 2026 Waled Alturkmani (LumoRez07) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +use std::env; + +/// Returns "x11", "wayland", or "unknown" for the current desktop session, +/// consulting the same environment signals GDK itself uses to pick a backend. +pub fn session_type() -> String { + if let Ok(value) = env::var("XDG_SESSION_TYPE") { + let normalized = value.to_lowercase(); + if normalized == "x11" || normalized == "wayland" { + return normalized; + } + } + + // GDK_BACKEND (set by our own launcher, or by the user) overrides the + // session compositor's own type when present. + if let Ok(value) = env::var("GDK_BACKEND") { + let normalized = value.to_lowercase(); + if normalized == "x11" || normalized == "wayland" { + return normalized; + } + } + + if env::var("WAYLAND_DISPLAY").is_ok() { + return "wayland".to_string(); + } + + if env::var("DISPLAY").is_ok() { + return "x11".to_string(); + } + + "unknown".to_string() +} diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs new file mode 100644 index 0000000..7541e74 --- /dev/null +++ b/src-tauri/src/platform/mod.rs @@ -0,0 +1,20 @@ +/* + * Flow - A high-performance teleprompter for Windows. + * Copyright (C) 2026 Waled Alturkmani (LumoRez07) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(target_os = "linux")] +pub use linux::session_type; + +#[cfg(not(target_os = "linux"))] +pub fn session_type() -> String { + "unknown".to_string() +} diff --git a/src-tauri/src/voice_engine.rs b/src-tauri/src/voice_engine.rs index 2d27305..af4c5c2 100644 --- a/src-tauri/src/voice_engine.rs +++ b/src-tauri/src/voice_engine.rs @@ -275,12 +275,75 @@ pub(crate) fn list_input_devices() -> Result, String> .input_devices() .map_err(|error| format!("Failed to enumerate microphones: {error}"))?; - Ok(devices + #[allow(unused_mut)] + let mut devices: Vec = devices .filter_map(|device| { let label = device.name().unwrap_or_default().trim().to_string(); (!label.is_empty()).then_some(VoiceInputDeviceInfo { label }) }) - .collect()) + .collect(); + + #[cfg(target_os = "linux")] + { + devices.retain(|device| is_selectable_linux_input_device(&device.label)); + devices.sort_by(|a, b| linux_input_device_sort_key(&a.label).cmp(&linux_input_device_sort_key(&b.label))); + } + + Ok(devices) +} + +/// ALSA enumerates every PCM name a card's plugin chain defines, not just the +/// ones that make sense for microphone capture. On a typical Linux desktop +/// (verified against a live PipeWire-routed ALSA stack) this includes +/// resampler/plugin building blocks (`speexrate`, `lavrate`, `jack`, `upmix`, +/// ...) and output-routing topology variants (`front:`, `surroundNN:`, +/// `iec958:`) that were never meant to be picked directly as an input device. +/// This filters those out so the Settings UI shows a short, meaningful list. +#[cfg(target_os = "linux")] +const ALSA_NON_DEVICE_PLUGIN_NAMES: &[&str] = &[ + "lavrate", + "samplerate", + "speexrate", + "speex", + "jack", + "upmix", + "vdownmix", +]; + +#[cfg(target_os = "linux")] +fn is_selectable_linux_input_device(label: &str) -> bool { + if ALSA_NON_DEVICE_PLUGIN_NAMES.contains(&label) { + return false; + } + + let is_output_topology_variant = label.starts_with("front:") + || label.starts_with("surround") + || label.starts_with("iec958:"); + + !is_output_topology_variant +} + +/// Ranks the common cross-desktop virtual devices first (in the order a user +/// is most likely to want them), then per-card `sysdefault:CARD=...` entries, +/// then anything else (e.g. raw `hw:`/`plughw:` devices on setups without a +/// sound server) last. +#[cfg(target_os = "linux")] +const ALSA_PREFERRED_DEVICE_ORDER: &[&str] = &["default", "pipewire", "pulse"]; + +#[cfg(target_os = "linux")] +fn linux_input_device_sort_key(label: &str) -> (u8, String) { + if let Some(position) = ALSA_PREFERRED_DEVICE_ORDER + .iter() + .position(|preferred| *preferred == label) + { + return (0, format!("{position}")); + } + + if label.starts_with("sysdefault:") { + return (1, label.to_lowercase()); + } + + (2, label.to_lowercase()) } impl VoiceEngine { @@ -1230,3 +1293,4 @@ fn emit_stream_error(app: &AppHandle, error: String) { fn emit_native_voice_event(app: &AppHandle, payload: NativeVoiceEvent) { let _ = app.emit(NATIVE_VOICE_EVENT, payload); } + From c3a1f9b2658fbd3e87c328f5e3aa47b1d48419fc Mon Sep 17 00:00:00 2001 From: Emanschi Date: Sat, 29 Aug 2026 14:20:31 +0200 Subject: [PATCH 02/17] Add Linux platform parity: sleep inhibit, error dialogs, capabilities, UI Phase 4 of the Linux port - brings feature parity where possible and honestly reports where it isn't, plus fixes a pre-existing (not Linux-specific) window dragging bug found during manual testing. Platform parity (src-tauri/src/platform/linux.rs): - Sleep/screensaver prevention via D-Bus: org.freedesktop.ScreenSaver Inhibit/UnInhibit and org.freedesktop.login1.Manager.Inhibit("idle"), used together since desktop environments vary in which one they honor. Verified against `systemd-inhibit --list`. - Startup error dialog fallback chain: kdialog -> zenity -> notify-send -> stderr, since nothing GTK-based can be assumed to work at the point this fires. Verified for real: kdialog isn't installed on this KDE system, so the fallback to zenity is exercised, not just theoretical. - New get_platform_capabilities command reports what's actually available on this session (os, X11/Wayland, capture protection, global shortcuts, in-app updater) instead of assuming Windows-level capability everywhere. Fixed a priority bug found while wiring this up: session_type() checked XDG_SESSION_TYPE before GDK_BACKEND, so it misreported "wayland" even when the launcher's GDK_BACKEND=x11 forces the app onto XWayland where those features do work. Frontend (src/core/platform.js + consumers): - New platform.js module mirrors distribution.js's cache/promise pattern, fails open (assumes full capability) if the query fails, so Windows behavior can't regress. - Settings UI hides capture protection when unavailable (with an explanatory line instead of just disappearing), adds a hint about the --toggle-clickthrough CLI flags when global shortcuts aren't available, and shows a package-manager hint instead of the update button when the in-app updater isn't. New i18n strings added in all 8 languages, not just English. - Both updater paths (services/updater.js's manual check and teleprompter/auto-updater.js's background timer) now gate on in-app-updater availability the same way they already gate on Microsoft Store builds, so non-AppImage Linux builds don't poll the update endpoint or run a dead interval. - Font fallback chains extended with Inter/Noto Sans/DejaVu Sans and Fira Code/DejaVu Sans Mono, Segoe entries kept first so Windows rendering is untouched. Window dragging fix (not Linux-specific): - Settings, About, and the text editor window had no drag region at all, unlike the main window - noticed only now because the main window has its own drag-handle overlay and a settings-based position picker, so nobody had tried grabbing the others by their header. Fixing this took two changes: `data-tauri-drag-region` has to be on the exact element the mousedown lands on (Tauri's drag script checks e.target directly, it doesn't walk up to parents), and the `core:window:allow-start-dragging` permission was missing from capabilities/default.json entirely - without it every drag attempt was silently rejected by the ACL, main window included. --- src-tauri/Cargo.lock | 4 + src-tauri/Cargo.toml | 3 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 99 +++++++++++++++- src-tauri/src/platform/linux.rs | 173 +++++++++++++++++++++++++++- src-tauri/src/platform/mod.rs | 2 +- src/about.html | 8 +- src/core/i18n.js | 24 ++++ src/core/platform.js | 76 ++++++++++++ src/input.html | 8 +- src/main.js | 2 +- src/services/updater.js | 35 +++++- src/settings.html | 18 +-- src/settings.js | 21 ++++ src/styles.css | 8 +- src/teleprompter/auto-updater.js | 32 ++++- 16 files changed, 482 insertions(+), 32 deletions(-) create mode 100644 src/core/platform.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5b58a40..4d4fd05 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1218,6 +1218,7 @@ dependencies = [ "vosk", "windows 0.61.3", "winreg 0.52.0", + "zbus", "zip 0.6.6", ] @@ -4939,8 +4940,10 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -6481,6 +6484,7 @@ dependencies = [ "rustix", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 643af0e..cf528c0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,3 +43,6 @@ winreg = "0.52" [target.'cfg(unix)'.dependencies] tauri-plugin-single-instance = "2" +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] } + diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bc37b97..c335fd3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "opener:default", "updater:default", "core:window:allow-close", + "core:window:allow-start-dragging", "core:window:allow-set-position", "core:window:allow-set-size", "core:window:allow-current-monitor", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bfde1d9..b1c8843 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1136,7 +1136,12 @@ fn set_sleep_prevention(enabled: bool) { let _ = unsafe { SetThreadExecutionState(flags) }; } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +fn set_sleep_prevention(enabled: bool) { + platform::set_sleep_prevention(enabled); +} + +#[cfg(all(not(windows), not(target_os = "linux")))] fn set_sleep_prevention(_enabled: bool) {} fn load_dev_tray_icon() -> tauri::Result> { @@ -1168,7 +1173,12 @@ fn show_startup_error_dialog(message: &str) { }; } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +fn show_startup_error_dialog(message: &str) { + platform::show_startup_error_dialog(message); +} + +#[cfg(all(not(windows), not(target_os = "linux")))] fn show_startup_error_dialog(_message: &str) {} #[cfg(windows)] @@ -1267,6 +1277,24 @@ fn ensure_webview2_runtime_available() -> bool { true } +#[cfg(target_os = "linux")] +fn format_startup_error_message(error: &tauri::Error) -> String { + format!( + concat!( + "Flow could not start the desktop runtime.\n\n", + "Common causes on Linux:\n", + "- The WebKitGTK runtime (webkit2gtk-4.1) is missing or too old.\n", + "- A GPU/driver DMA-BUF rendering issue is blocking the embedded webview \n", + " (try relaunching with WEBKIT_DISABLE_DMABUF_RENDERER=1).\n", + "- The user profile or app data location (~/.local/share) is unavailable.\n\n", + "Install webkit2gtk-4.1 via your distribution's package manager, then try again.\n\n", + "Technical details:\n{}" + ), + error + ) +} + +#[cfg(not(target_os = "linux"))] fn format_startup_error_message(error: &tauri::Error) -> String { format!( concat!( @@ -1952,6 +1980,72 @@ fn get_distribution_channel() -> String { .to_string() } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlatformCapabilities { + os: &'static str, + session: String, + capture_protection: bool, + global_shortcuts: bool, + always_on_top: bool, + window_positioning: bool, + in_app_updater: bool, + package_source: String, +} + +#[cfg(target_os = "linux")] +fn platform_session_type() -> String { + platform::session_type() +} + +#[cfg(not(target_os = "linux"))] +fn platform_session_type() -> String { + "unknown".to_string() +} + +#[cfg(target_os = "linux")] +fn platform_package_source() -> String { + option_env!("FLOW_LINUX_PACKAGE") + .unwrap_or("unknown") + .to_string() +} + +#[cfg(not(target_os = "linux"))] +fn platform_package_source() -> String { + "unknown".to_string() +} + +#[tauri::command] +fn get_platform_capabilities() -> PlatformCapabilities { + let session = platform_session_type(); + let package_source = platform_package_source(); + + // On Linux, always-on-top / absolute positioning / global shortcuts all + // rely on the same thing: an X11 server tao/global-hotkey can talk to. + // That's present under X11 and under XWayland, but not under native + // Wayland (see plan.md E2 and Task 3.6's verification). + let has_x11_window_management = cfg!(target_os = "linux").then(|| session == "x11").unwrap_or(true); + + PlatformCapabilities { + os: if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "unknown" + }, + session, + capture_protection: cfg!(windows), + global_shortcuts: has_x11_window_management, + always_on_top: has_x11_window_management, + window_positioning: has_x11_window_management, + in_app_updater: cfg!(windows) || package_source == "appimage", + package_source, + } +} + #[tauri::command] async fn download_voice_model( app: tauri::AppHandle, @@ -2875,6 +2969,7 @@ pub fn run() { save_persisted_app_state, save_persisted_voice_model_registry, get_distribution_channel, + get_platform_capabilities, fetch_updater_feed_metadata, list_voice_models, get_voice_model_status, diff --git a/src-tauri/src/platform/linux.rs b/src-tauri/src/platform/linux.rs index 1b6e973..52b2977 100644 --- a/src-tauri/src/platform/linux.rs +++ b/src-tauri/src/platform/linux.rs @@ -9,20 +9,181 @@ */ use std::env; +use std::process::Command; +use std::sync::Mutex; -/// Returns "x11", "wayland", or "unknown" for the current desktop session, -/// consulting the same environment signals GDK itself uses to pick a backend. +use zbus::blocking::Connection; +use zbus::zvariant::OwnedFd; + +const STARTUP_ERROR_TITLE: &str = "Flow startup error"; + +/// Shows a startup error dialog on Linux, mirroring Windows' `MessageBoxW` +/// use in lib.rs. Tries native dialog tools in order of how commonly they're +/// preinstalled, since at this point in startup nothing GTK-based can be +/// assumed to work yet (that's exactly what failed) - `kdialog`/`zenity`/ +/// `notify-send` are separate processes, so they don't depend on our own +/// (broken) webview or GTK context. +pub fn show_startup_error_dialog(message: &str) { + if try_command_dialog("kdialog", &["--error", message, "--title", STARTUP_ERROR_TITLE]) { + return; + } + + if try_command_dialog( + "zenity", + &[ + "--error", + &format!("--title={STARTUP_ERROR_TITLE}"), + &format!("--text={message}"), + ], + ) { + return; + } + + if try_command_dialog( + "notify-send", + &["--urgency=critical", STARTUP_ERROR_TITLE, message], + ) { + return; + } + + eprintln!("[flow] {STARTUP_ERROR_TITLE}: {message}"); +} + +fn try_command_dialog(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +const INHIBIT_APP_NAME: &str = "Flow"; +const INHIBIT_REASON: &str = "Teleprompter is actively being used"; + +/// Holds whatever handles are needed to release an active sleep/screensaver +/// inhibitor later. Dropping the logind file descriptor releases that +/// inhibitor automatically; the ScreenSaver one needs an explicit UnInhibit +/// call on the same connection that requested it. +struct SleepInhibitor { + screensaver: Option<(Connection, u32)>, + // Only ever dropped, never read: closing the fd is what releases the + // logind inhibitor lock. + #[allow(dead_code)] + logind_fd: Option, +} + +static SLEEP_INHIBITOR: Mutex> = Mutex::new(None); + +/// Prevents (or re-allows) the system from sleeping/screen-locking while +/// Flow is actively being used, mirroring Windows' `SetThreadExecutionState` +/// use in lib.rs. Two independent D-Bus mechanisms are used together since +/// desktop environments vary in which one they honor: +/// - `org.freedesktop.ScreenSaver.Inhibit` blocks the screensaver/DPMS +/// (supported by GNOME, KDE/kscreenlocker, and most other DEs). +/// - `org.freedesktop.login1.Manager.Inhibit("idle", ...)` blocks systemd- +/// logind from suspending the system due to idle timeout. +/// Failures are logged but never fatal - a teleprompter that occasionally +/// lets the screen sleep is a much smaller problem than one that crashes. +pub fn set_sleep_prevention(enabled: bool) { + let mut guard = SLEEP_INHIBITOR + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if enabled { + if guard.is_some() { + return; + } + *guard = Some(acquire_sleep_inhibitor()); + } else if let Some(inhibitor) = guard.take() { + release_sleep_inhibitor(inhibitor); + } +} + +fn acquire_sleep_inhibitor() -> SleepInhibitor { + let screensaver = match inhibit_screensaver() { + Ok(inhibitor) => Some(inhibitor), + Err(error) => { + crate::log_backend_error("org.freedesktop.ScreenSaver.Inhibit", &error); + None + } + }; + + let logind_fd = match inhibit_logind() { + Ok(fd) => Some(fd), + Err(error) => { + crate::log_backend_error("org.freedesktop.login1.Manager.Inhibit", &error); + None + } + }; + + SleepInhibitor { + screensaver, + logind_fd, + } +} + +fn release_sleep_inhibitor(inhibitor: SleepInhibitor) { + if let Some((connection, cookie)) = inhibitor.screensaver { + let result = connection.call_method( + Some("org.freedesktop.ScreenSaver"), + "/org/freedesktop/ScreenSaver", + Some("org.freedesktop.ScreenSaver"), + "UnInhibit", + &(cookie,), + ); + if let Err(error) = result { + crate::log_backend_error("org.freedesktop.ScreenSaver.UnInhibit", &error); + } + } + // `inhibitor.logind_fd` (if any) is dropped here, closing the file + // descriptor and releasing the logind idle inhibitor lock. +} + +fn inhibit_screensaver() -> zbus::Result<(Connection, u32)> { + let connection = Connection::session()?; + let reply = connection.call_method( + Some("org.freedesktop.ScreenSaver"), + "/org/freedesktop/ScreenSaver", + Some("org.freedesktop.ScreenSaver"), + "Inhibit", + &(INHIBIT_APP_NAME, INHIBIT_REASON), + )?; + let cookie: u32 = reply.body().deserialize()?; + Ok((connection, cookie)) +} + +fn inhibit_logind() -> zbus::Result { + let connection = Connection::system()?; + let reply = connection.call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.login1.Manager"), + "Inhibit", + &("idle", INHIBIT_APP_NAME, INHIBIT_REASON, "block"), + )?; + let fd: OwnedFd = reply.body().deserialize()?; + Ok(fd) +} + +/// Returns "x11", "wayland", or "unknown" for the windowing backend GTK/tao +/// actually uses - not necessarily the compositor's own session type. +/// +/// `GDK_BACKEND` is checked first and wins whenever set: our own launcher +/// (packaging/linux/flow-launcher.sh) sets `GDK_BACKEND=x11` by default so +/// the app runs under XWayland even in a `XDG_SESSION_TYPE=wayland` session, +/// since always-on-top/window positioning/global shortcuts only work that +/// way (see plan.md E2). Reporting the compositor's session type instead +/// would make `get_platform_capabilities` claim those features are +/// unavailable on a system where they actually work fine under XWayland. pub fn session_type() -> String { - if let Ok(value) = env::var("XDG_SESSION_TYPE") { + if let Ok(value) = env::var("GDK_BACKEND") { let normalized = value.to_lowercase(); if normalized == "x11" || normalized == "wayland" { return normalized; } } - // GDK_BACKEND (set by our own launcher, or by the user) overrides the - // session compositor's own type when present. - if let Ok(value) = env::var("GDK_BACKEND") { + if let Ok(value) = env::var("XDG_SESSION_TYPE") { let normalized = value.to_lowercase(); if normalized == "x11" || normalized == "wayland" { return normalized; diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs index 7541e74..46a1aa6 100644 --- a/src-tauri/src/platform/mod.rs +++ b/src-tauri/src/platform/mod.rs @@ -12,7 +12,7 @@ mod linux; #[cfg(target_os = "linux")] -pub use linux::session_type; +pub use linux::{session_type, set_sleep_prevention, show_startup_error_dialog}; #[cfg(not(target_os = "linux"))] pub fn session_type() -> String { diff --git a/src/about.html b/src/about.html index 993f836..8dc2dcb 100644 --- a/src/about.html +++ b/src/about.html @@ -19,10 +19,10 @@
-