WIP: Add tauri-specta for generating TS bindings (#322)

* initial specta commands

* refactor to use the bindings in the ui

* merge fixes

* remove unused commands

* add clamshell to lib

* Remove Invoke from everything.

* fix settings not being loaded properly

* fix settings being overwritten due to deserialization failure + remove
provider kind

* fix model loading text bug
This commit is contained in:
CJ Pais 2025-11-27 17:07:22 +07:00 committed by GitHub
parent 5085485e67
commit 9521ab9332
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1402 additions and 584 deletions

88
src-tauri/Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "adler2"
version = "2.0.1"
@ -2524,6 +2530,8 @@ dependencies = [
"serde",
"serde_json",
"signal-hook",
"specta",
"specta-typescript",
"strsim",
"tar",
"tauri",
@ -2542,6 +2550,7 @@ dependencies = [
"tauri-plugin-sql",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-specta",
"tokio",
"transcribe-rs",
"vad-rs",
@ -4306,6 +4315,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
@ -5907,6 +5922,50 @@ dependencies = [
"system-deps",
]
[[package]]
name = "specta"
version = "2.0.0-rc.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
dependencies = [
"paste",
"specta-macros",
"thiserror 1.0.69",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "specta-serde"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
dependencies = [
"specta",
"thiserror 1.0.69",
]
[[package]]
name = "specta-typescript"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
dependencies = [
"specta",
"specta-serde",
"thiserror 1.0.69",
]
[[package]]
name = "spin"
version = "0.9.8"
@ -6557,6 +6616,7 @@ dependencies = [
"serde_json",
"serde_repr",
"serialize-to-javascript",
"specta",
"swift-rs",
"tauri-build",
"tauri-macros",
@ -6952,6 +7012,34 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "tauri-utils"
version = "2.8.0"

View file

@ -68,6 +68,9 @@ tar = "0.4.44"
flate2 = "1.0"
transcribe-rs = "0.1.4"
ferrous-opencc = "0.2.3"
specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
[target.'cfg(unix)'.dependencies]
signal-hook = "0.3"

View file

@ -40,12 +40,11 @@ fn send_paste_ctrl_v() -> Result<(), String> {
/// Sends a Shift+Insert paste command (Windows and Linux only).
/// This is more universal for terminal applications and legacy software.
#[cfg(not(target_os = "macos"))]
fn send_paste_shift_insert() -> Result<(), String> {
#[cfg(target_os = "windows")]
let insert_key_code = Key::Other(0x2D); // VK_INSERT
#[cfg(target_os = "linux")]
let insert_key_code = Key::Other(0x76); // XK_Insert (keycode 118 / 0x76)
#[cfg(not(target_os = "windows"))]
let insert_key_code = Key::Other(0x76); // XK_Insert (keycode 118 / 0x76, also used as fallback)
let mut enigo = Enigo::new(&Settings::default())
.map_err(|e| format!("Failed to initialize Enigo: {}", e))?;
@ -109,7 +108,6 @@ fn paste_via_clipboard_ctrl_v(text: &str, app_handle: &AppHandle) -> Result<(),
/// Pastes text using the clipboard method with Shift+Insert (Windows/Linux only).
/// Saves the current clipboard, writes the text, sends paste command, then restores the clipboard.
#[cfg(not(target_os = "macos"))]
fn paste_via_clipboard_shift_insert(text: &str, app_handle: &AppHandle) -> Result<(), String> {
let clipboard = app_handle.clipboard();
@ -145,7 +143,6 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> {
match paste_method {
PasteMethod::CtrlV => paste_via_clipboard_ctrl_v(&text, &app_handle)?,
PasteMethod::Direct => paste_via_direct_input(&text)?,
#[cfg(not(target_os = "macos"))]
PasteMethod::ShiftInsert => paste_via_clipboard_shift_insert(&text, &app_handle)?,
}

View file

@ -4,10 +4,11 @@ use crate::managers::audio::{AudioRecordingManager, MicrophoneMode};
use crate::settings::{get_settings, write_settings};
use log::warn;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
#[derive(Serialize)]
#[derive(Serialize, Type)]
pub struct CustomSounds {
start: bool,
stop: bool,
@ -23,6 +24,7 @@ fn custom_sound_exists(app: &AppHandle, sound_type: &str) -> bool {
}
#[tauri::command]
#[specta::specta]
pub fn check_custom_sounds(app: AppHandle) -> CustomSounds {
CustomSounds {
start: custom_sound_exists(&app, "start"),
@ -30,7 +32,7 @@ pub fn check_custom_sounds(app: AppHandle) -> CustomSounds {
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
pub struct AudioDevice {
pub index: String,
pub name: String,
@ -38,6 +40,7 @@ pub struct AudioDevice {
}
#[tauri::command]
#[specta::specta]
pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> {
// Update settings
let mut settings = get_settings(&app);
@ -57,12 +60,14 @@ pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), Str
}
#[tauri::command]
#[specta::specta]
pub fn get_microphone_mode(app: AppHandle) -> Result<bool, String> {
let settings = get_settings(&app);
Ok(settings.always_on_microphone)
}
#[tauri::command]
#[specta::specta]
pub fn get_available_microphones() -> Result<Vec<AudioDevice>, String> {
let devices =
list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?;
@ -83,6 +88,7 @@ pub fn get_available_microphones() -> Result<Vec<AudioDevice>, String> {
}
#[tauri::command]
#[specta::specta]
pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> {
let mut settings = get_settings(&app);
settings.selected_microphone = if device_name == "default" {
@ -101,6 +107,7 @@ pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<()
}
#[tauri::command]
#[specta::specta]
pub fn get_selected_microphone(app: AppHandle) -> Result<String, String> {
let settings = get_settings(&app);
Ok(settings
@ -109,6 +116,7 @@ pub fn get_selected_microphone(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
#[specta::specta]
pub fn get_available_output_devices() -> Result<Vec<AudioDevice>, String> {
let devices =
list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?;
@ -129,6 +137,7 @@ pub fn get_available_output_devices() -> Result<Vec<AudioDevice>, String> {
}
#[tauri::command]
#[specta::specta]
pub fn set_selected_output_device(app: AppHandle, device_name: String) -> Result<(), String> {
let mut settings = get_settings(&app);
settings.selected_output_device = if device_name == "default" {
@ -141,6 +150,7 @@ pub fn set_selected_output_device(app: AppHandle, device_name: String) -> Result
}
#[tauri::command]
#[specta::specta]
pub fn get_selected_output_device(app: AppHandle) -> Result<String, String> {
let settings = get_settings(&app);
Ok(settings
@ -149,6 +159,7 @@ pub fn get_selected_output_device(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
#[specta::specta]
pub fn play_test_sound(app: AppHandle, sound_type: String) {
let sound = match sound_type.as_str() {
"start" => audio_feedback::SoundType::Start,
@ -162,6 +173,7 @@ pub fn play_test_sound(app: AppHandle, sound_type: String) {
}
#[tauri::command]
#[specta::specta]
pub fn set_clamshell_microphone(app: AppHandle, device_name: String) -> Result<(), String> {
let mut settings = get_settings(&app);
settings.clamshell_microphone = if device_name == "default" {
@ -174,6 +186,7 @@ pub fn set_clamshell_microphone(app: AppHandle, device_name: String) -> Result<(
}
#[tauri::command]
#[specta::specta]
pub fn get_clamshell_microphone(app: AppHandle) -> Result<String, String> {
let settings = get_settings(&app);
Ok(settings

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
#[specta::specta]
pub async fn get_history_entries(
_app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
@ -14,6 +15,7 @@ pub async fn get_history_entries(
}
#[tauri::command]
#[specta::specta]
pub async fn toggle_history_entry_saved(
_app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
@ -26,6 +28,7 @@ pub async fn toggle_history_entry_saved(
}
#[tauri::command]
#[specta::specta]
pub async fn get_audio_file_path(
_app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
@ -38,6 +41,7 @@ pub async fn get_audio_file_path(
}
#[tauri::command]
#[specta::specta]
pub async fn delete_history_entry(
_app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
@ -50,6 +54,7 @@ pub async fn delete_history_entry(
}
#[tauri::command]
#[specta::specta]
pub async fn update_history_limit(
app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
@ -67,6 +72,7 @@ pub async fn update_history_limit(
}
#[tauri::command]
#[specta::specta]
pub async fn update_recording_retention_period(
app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,

View file

@ -3,17 +3,19 @@ pub mod history;
pub mod models;
pub mod transcription;
use crate::{settings, utils::cancel_current_operation};
use crate::settings::{get_settings, write_settings, AppSettings, LogLevel};
use crate::utils::cancel_current_operation;
use tauri::{AppHandle, Manager};
use tauri_plugin_log::LogLevel;
use tauri_plugin_opener::OpenerExt;
#[tauri::command]
#[specta::specta]
pub fn cancel_operation(app: AppHandle) {
cancel_current_operation(&app);
}
#[tauri::command]
#[specta::specta]
pub fn get_app_dir_path(app: AppHandle) -> Result<String, String> {
let app_data_dir = app
.path()
@ -24,6 +26,19 @@ pub fn get_app_dir_path(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
#[specta::specta]
pub fn get_app_settings(app: AppHandle) -> Result<AppSettings, String> {
Ok(get_settings(&app))
}
#[tauri::command]
#[specta::specta]
pub fn get_default_settings() -> Result<AppSettings, String> {
Ok(crate::settings::get_default_settings())
}
#[tauri::command]
#[specta::specta]
pub fn get_log_dir_path(app: AppHandle) -> Result<String, String> {
let log_dir = app
.path()
@ -33,22 +48,25 @@ pub fn get_log_dir_path(app: AppHandle) -> Result<String, String> {
Ok(log_dir.to_string_lossy().to_string())
}
#[specta::specta]
#[tauri::command]
pub fn set_log_level(app: AppHandle, level: LogLevel) -> Result<(), String> {
let log_level: log::Level = level.clone().into();
let tauri_log_level: tauri_plugin_log::LogLevel = level.into();
let log_level: log::Level = tauri_log_level.into();
// Update the file log level atomic so the filter picks up the new level
crate::FILE_LOG_LEVEL.store(
log_level.to_level_filter() as u8,
std::sync::atomic::Ordering::Relaxed,
);
let mut settings = settings::get_settings(&app);
let mut settings = get_settings(&app);
settings.log_level = level;
settings::write_settings(&app, settings);
write_settings(&app, settings);
Ok(())
}
#[specta::specta]
#[tauri::command]
pub fn open_recordings_folder(app: AppHandle) -> Result<(), String> {
let app_data_dir = app
@ -66,6 +84,7 @@ pub fn open_recordings_folder(app: AppHandle) -> Result<(), String> {
Ok(())
}
#[specta::specta]
#[tauri::command]
pub fn open_log_dir(app: AppHandle) -> Result<(), String> {
let log_dir = app
@ -81,6 +100,7 @@ pub fn open_log_dir(app: AppHandle) -> Result<(), String> {
Ok(())
}
#[specta::specta]
#[tauri::command]
pub fn open_app_data_dir(app: AppHandle) -> Result<(), String> {
let app_data_dir = app

View file

@ -5,6 +5,7 @@ use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
#[specta::specta]
pub async fn get_available_models(
model_manager: State<'_, Arc<ModelManager>>,
) -> Result<Vec<ModelInfo>, String> {
@ -12,6 +13,7 @@ pub async fn get_available_models(
}
#[tauri::command]
#[specta::specta]
pub async fn get_model_info(
model_manager: State<'_, Arc<ModelManager>>,
model_id: String,
@ -20,6 +22,7 @@ pub async fn get_model_info(
}
#[tauri::command]
#[specta::specta]
pub async fn download_model(
model_manager: State<'_, Arc<ModelManager>>,
model_id: String,
@ -31,6 +34,7 @@ pub async fn download_model(
}
#[tauri::command]
#[specta::specta]
pub async fn delete_model(
model_manager: State<'_, Arc<ModelManager>>,
model_id: String,
@ -41,6 +45,7 @@ pub async fn delete_model(
}
#[tauri::command]
#[specta::specta]
pub async fn set_active_model(
app_handle: AppHandle,
model_manager: State<'_, Arc<ModelManager>>,
@ -70,12 +75,14 @@ pub async fn set_active_model(
}
#[tauri::command]
#[specta::specta]
pub async fn get_current_model(app_handle: AppHandle) -> Result<String, String> {
let settings = get_settings(&app_handle);
Ok(settings.selected_model)
}
#[tauri::command]
#[specta::specta]
pub async fn get_transcription_model_status(
transcription_manager: State<'_, Arc<TranscriptionManager>>,
) -> Result<Option<String>, String> {
@ -83,6 +90,7 @@ pub async fn get_transcription_model_status(
}
#[tauri::command]
#[specta::specta]
pub async fn is_model_loading(
transcription_manager: State<'_, Arc<TranscriptionManager>>,
) -> Result<bool, String> {
@ -92,6 +100,7 @@ pub async fn is_model_loading(
}
#[tauri::command]
#[specta::specta]
pub async fn has_any_models_available(
model_manager: State<'_, Arc<ModelManager>>,
) -> Result<bool, String> {
@ -100,6 +109,7 @@ pub async fn has_any_models_available(
}
#[tauri::command]
#[specta::specta]
pub async fn has_any_models_or_downloads(
model_manager: State<'_, Arc<ModelManager>>,
) -> Result<bool, String> {
@ -109,6 +119,7 @@ pub async fn has_any_models_or_downloads(
}
#[tauri::command]
#[specta::specta]
pub async fn cancel_download(
model_manager: State<'_, Arc<ModelManager>>,
model_id: String,
@ -119,6 +130,7 @@ pub async fn cancel_download(
}
#[tauri::command]
#[specta::specta]
pub async fn get_recommended_first_model() -> Result<String, String> {
// Recommend Parakeet V3 model for first-time users - fastest and most accurate
Ok("parakeet-tdt-0.6b-v3".to_string())

View file

@ -1,8 +1,17 @@
use crate::managers::transcription::TranscriptionManager;
use crate::settings::{get_settings, write_settings, ModelUnloadTimeout};
use serde::Serialize;
use specta::Type;
use tauri::{AppHandle, State};
#[derive(Serialize, Type)]
pub struct ModelLoadStatus {
is_loaded: bool,
current_model: Option<String>,
}
#[tauri::command]
#[specta::specta]
pub fn set_model_unload_timeout(app: AppHandle, timeout: ModelUnloadTimeout) {
let mut settings = get_settings(&app);
settings.model_unload_timeout = timeout;
@ -10,19 +19,18 @@ pub fn set_model_unload_timeout(app: AppHandle, timeout: ModelUnloadTimeout) {
}
#[tauri::command]
#[specta::specta]
pub fn get_model_load_status(
transcription_manager: State<TranscriptionManager>,
) -> Result<serde_json::Value, String> {
let is_loaded = transcription_manager.is_model_loaded();
let current_model = transcription_manager.get_current_model();
Ok(serde_json::json!({
"is_loaded": is_loaded,
"current_model": current_model
}))
) -> Result<ModelLoadStatus, String> {
Ok(ModelLoadStatus {
is_loaded: transcription_manager.is_model_loaded(),
current_model: transcription_manager.get_current_model(),
})
}
#[tauri::command]
#[specta::specta]
pub fn unload_model_manually(
transcription_manager: State<TranscriptionManager>,
) -> Result<(), String> {

View file

@ -6,7 +6,6 @@ use std::process::Command;
/// This queries the macOS IORegistry for the AppleClamshellState key.
/// Returns true if the lid is closed, false if open.
#[cfg(target_os = "macos")]
#[tauri::command]
pub fn is_clamshell() -> Result<bool, String> {
let output = Command::new("ioreg")
.args(["-r", "-k", "AppleClamshellState", "-d", "4"])
@ -32,6 +31,7 @@ pub fn is_clamshell() -> Result<bool, String> {
/// Returns true if a battery is detected (laptop), false otherwise (desktop)
#[cfg(target_os = "macos")]
#[tauri::command]
#[specta::specta]
pub fn is_laptop() -> Result<bool, String> {
let output = Command::new("pmset")
.arg("-g")
@ -48,7 +48,6 @@ pub fn is_laptop() -> Result<bool, String> {
/// Stub implementation for non-macOS platforms
/// Always returns false since clamshell mode is macOS-specific
#[cfg(not(target_os = "macos"))]
#[tauri::command]
pub fn is_clamshell() -> Result<bool, String> {
Ok(false)
}
@ -57,6 +56,7 @@ pub fn is_clamshell() -> Result<bool, String> {
/// Always returns false since laptop detection is macOS-specific
#[cfg(not(target_os = "macos"))]
#[tauri::command]
#[specta::specta]
pub fn is_laptop() -> Result<bool, String> {
Ok(false)
}

View file

@ -12,6 +12,8 @@ mod shortcut;
mod signal_handle;
mod tray;
mod utils;
use specta_typescript::{BigIntExportBehavior, Typescript};
use tauri_specta::{collect_commands, Builder};
use env_filter::Builder as EnvFilterBuilder;
use managers::audio::AudioRecordingManager;
@ -33,6 +35,8 @@ use tauri::{AppHandle, Manager};
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_log::{Builder as LogBuilder, RotationStrategy, Target, TargetKind};
use crate::settings::get_settings;
// Global atomic to store the file log level filter
// We use u8 to store the log::LevelFilter as a number
pub static FILE_LOG_LEVEL: AtomicU8 = AtomicU8::new(log::LevelFilter::Debug as u8);
@ -199,6 +203,7 @@ fn initialize_core_logic(app_handle: &AppHandle) {
}
#[tauri::command]
#[specta::specta]
fn trigger_update_check(app: AppHandle) -> Result<(), String> {
app.emit("check-for-updates", ())
.map_err(|e| e.to_string())?;
@ -211,6 +216,90 @@ pub fn run() {
// when the variable is unset
let console_filter = build_console_filter();
let specta_builder = Builder::<tauri::Wry>::new().commands(collect_commands![
shortcut::change_binding,
shortcut::reset_binding,
shortcut::change_ptt_setting,
shortcut::change_audio_feedback_setting,
shortcut::change_audio_feedback_volume_setting,
shortcut::change_sound_theme_setting,
shortcut::change_start_hidden_setting,
shortcut::change_autostart_setting,
shortcut::change_translate_to_english_setting,
shortcut::change_selected_language_setting,
shortcut::change_overlay_position_setting,
shortcut::change_debug_mode_setting,
shortcut::change_word_correction_threshold_setting,
shortcut::change_paste_method_setting,
shortcut::change_clipboard_handling_setting,
shortcut::change_post_process_enabled_setting,
shortcut::change_post_process_base_url_setting,
shortcut::change_post_process_api_key_setting,
shortcut::change_post_process_model_setting,
shortcut::set_post_process_provider,
shortcut::fetch_post_process_models,
shortcut::add_post_process_prompt,
shortcut::update_post_process_prompt,
shortcut::delete_post_process_prompt,
shortcut::set_post_process_selected_prompt,
shortcut::update_custom_words,
shortcut::suspend_binding,
shortcut::resume_binding,
shortcut::change_mute_while_recording_setting,
trigger_update_check,
commands::cancel_operation,
commands::get_app_dir_path,
commands::get_app_settings,
commands::get_default_settings,
commands::get_log_dir_path,
commands::set_log_level,
commands::open_recordings_folder,
commands::open_log_dir,
commands::open_app_data_dir,
commands::models::get_available_models,
commands::models::get_model_info,
commands::models::download_model,
commands::models::delete_model,
commands::models::cancel_download,
commands::models::set_active_model,
commands::models::get_current_model,
commands::models::get_transcription_model_status,
commands::models::is_model_loading,
commands::models::has_any_models_available,
commands::models::has_any_models_or_downloads,
commands::models::get_recommended_first_model,
commands::audio::update_microphone_mode,
commands::audio::get_microphone_mode,
commands::audio::get_available_microphones,
commands::audio::set_selected_microphone,
commands::audio::get_selected_microphone,
commands::audio::get_available_output_devices,
commands::audio::set_selected_output_device,
commands::audio::get_selected_output_device,
commands::audio::play_test_sound,
commands::audio::check_custom_sounds,
commands::audio::set_clamshell_microphone,
commands::audio::get_clamshell_microphone,
commands::transcription::set_model_unload_timeout,
commands::transcription::get_model_load_status,
commands::transcription::unload_model_manually,
commands::history::get_history_entries,
commands::history::toggle_history_entry_saved,
commands::history::get_audio_file_path,
commands::history::delete_history_entry,
commands::history::update_history_limit,
commands::history::update_recording_retention_period,
helpers::clamshell::is_laptop,
]);
#[cfg(debug_assertions)] // <- Only export on non-release builds
specta_builder
.export(
Typescript::default().bigint(BigIntExportBehavior::String),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
let mut builder = tauri::Builder::default()
.plugin(
LogBuilder::new()
@ -268,8 +357,9 @@ pub fn run() {
))
.manage(Mutex::new(ShortcutToggleStates::default()))
.setup(move |app| {
let settings = settings::get_settings(&app.handle());
let file_log_level: log::Level = settings.log_level.clone().into();
let settings = get_settings(&app.handle());
let tauri_log_level: tauri_plugin_log::LogLevel = settings.log_level.into();
let file_log_level: log::Level = tauri_log_level.into();
// Store the file log level in the atomic for the filter to use
FILE_LOG_LEVEL.store(file_log_level.to_level_filter() as u8, Ordering::Relaxed);
let app_handle = app.handle().clone();
@ -307,80 +397,7 @@ pub fn run() {
}
_ => {}
})
.invoke_handler(tauri::generate_handler![
shortcut::change_binding,
shortcut::reset_binding,
shortcut::change_ptt_setting,
shortcut::change_audio_feedback_setting,
shortcut::change_audio_feedback_volume_setting,
shortcut::change_sound_theme_setting,
shortcut::change_start_hidden_setting,
shortcut::change_autostart_setting,
shortcut::change_translate_to_english_setting,
shortcut::change_selected_language_setting,
shortcut::change_overlay_position_setting,
shortcut::change_debug_mode_setting,
shortcut::change_word_correction_threshold_setting,
shortcut::change_paste_method_setting,
shortcut::change_clipboard_handling_setting,
shortcut::change_post_process_enabled_setting,
shortcut::change_post_process_base_url_setting,
shortcut::change_post_process_api_key_setting,
shortcut::change_post_process_model_setting,
shortcut::set_post_process_provider,
shortcut::fetch_post_process_models,
shortcut::add_post_process_prompt,
shortcut::update_post_process_prompt,
shortcut::delete_post_process_prompt,
shortcut::set_post_process_selected_prompt,
shortcut::update_custom_words,
shortcut::suspend_binding,
shortcut::resume_binding,
shortcut::change_mute_while_recording_setting,
trigger_update_check,
commands::cancel_operation,
commands::get_app_dir_path,
commands::get_log_dir_path,
commands::set_log_level,
commands::open_recordings_folder,
commands::open_log_dir,
commands::open_app_data_dir,
commands::models::get_available_models,
commands::models::get_model_info,
commands::models::download_model,
commands::models::delete_model,
commands::models::cancel_download,
commands::models::set_active_model,
commands::models::get_current_model,
commands::models::get_transcription_model_status,
commands::models::is_model_loading,
commands::models::has_any_models_available,
commands::models::has_any_models_or_downloads,
commands::models::get_recommended_first_model,
commands::audio::update_microphone_mode,
commands::audio::get_microphone_mode,
commands::audio::get_available_microphones,
commands::audio::set_selected_microphone,
commands::audio::get_selected_microphone,
commands::audio::get_available_output_devices,
commands::audio::set_selected_output_device,
commands::audio::get_selected_output_device,
commands::audio::play_test_sound,
commands::audio::check_custom_sounds,
commands::audio::set_clamshell_microphone,
commands::audio::get_clamshell_microphone,
helpers::clamshell::is_clamshell,
helpers::clamshell::is_laptop,
commands::transcription::set_model_unload_timeout,
commands::transcription::get_model_load_status,
commands::transcription::unload_model_manually,
commands::history::get_history_entries,
commands::history::toggle_history_entry_saved,
commands::history::get_audio_file_path,
commands::history::delete_history_entry,
commands::history::update_history_limit,
commands::history::update_recording_retention_period
])
.invoke_handler(specta_builder.invoke_handler())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -3,6 +3,7 @@ use chrono::{DateTime, Local, Utc};
use log::{debug, error};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager};
@ -10,7 +11,7 @@ use tauri_plugin_sql::{Migration, MigrationKind};
use crate::audio_toolkit::save_wav_file;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
pub struct HistoryEntry {
pub id: i64,
pub file_name: String,

View file

@ -4,6 +4,7 @@ use flate2::read::GzDecoder;
use futures_util::StreamExt;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::fs;
use std::fs::File;
@ -13,13 +14,13 @@ use std::sync::Mutex;
use tar::Archive;
use tauri::{AppHandle, Emitter, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub enum EngineType {
Whisper,
Parakeet,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ModelInfo {
pub id: String,
pub name: String,
@ -36,7 +37,7 @@ pub struct ModelInfo {
pub speed_score: f32, // 0.0 to 1.0, higher is faster
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct DownloadProgress {
pub model_id: String,
pub downloaded: u64,

View file

@ -1,11 +1,79 @@
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use specta::Type;
use std::collections::HashMap;
use tauri::AppHandle;
use tauri_plugin_log::LogLevel;
use tauri_plugin_store::StoreExt;
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
// Custom deserializer to handle both old numeric format (1-5) and new string format ("trace", "debug", etc.)
impl<'de> Deserialize<'de> for LogLevel {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct LogLevelVisitor;
impl<'de> Visitor<'de> for LogLevelVisitor {
type Value = LogLevel;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or integer representing log level")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<LogLevel, E> {
match value.to_lowercase().as_str() {
"trace" => Ok(LogLevel::Trace),
"debug" => Ok(LogLevel::Debug),
"info" => Ok(LogLevel::Info),
"warn" => Ok(LogLevel::Warn),
"error" => Ok(LogLevel::Error),
_ => Err(E::unknown_variant(
value,
&["trace", "debug", "info", "warn", "error"],
)),
}
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<LogLevel, E> {
match value {
1 => Ok(LogLevel::Trace),
2 => Ok(LogLevel::Debug),
3 => Ok(LogLevel::Info),
4 => Ok(LogLevel::Warn),
5 => Ok(LogLevel::Error),
_ => Err(E::invalid_value(de::Unexpected::Unsigned(value), &"1-5")),
}
}
}
deserializer.deserialize_any(LogLevelVisitor)
}
}
impl From<LogLevel> for tauri_plugin_log::LogLevel {
fn from(level: LogLevel) -> Self {
match level {
LogLevel::Trace => tauri_plugin_log::LogLevel::Trace,
LogLevel::Debug => tauri_plugin_log::LogLevel::Debug,
LogLevel::Info => tauri_plugin_log::LogLevel::Info,
LogLevel::Warn => tauri_plugin_log::LogLevel::Warn,
LogLevel::Error => tauri_plugin_log::LogLevel::Error,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
pub struct ShortcutBinding {
pub id: String,
pub name: String,
@ -14,14 +82,14 @@ pub struct ShortcutBinding {
pub current_binding: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
pub struct LLMPrompt {
pub id: String,
pub name: String,
pub prompt: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
pub struct PostProcessProvider {
pub id: String,
pub label: String,
@ -32,7 +100,7 @@ pub struct PostProcessProvider {
pub models_endpoint: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "lowercase")]
pub enum OverlayPosition {
None,
@ -40,7 +108,7 @@ pub enum OverlayPosition {
Bottom,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum ModelUnloadTimeout {
Never,
@ -53,23 +121,22 @@ pub enum ModelUnloadTimeout {
Sec5, // Debug mode only
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum PasteMethod {
CtrlV,
Direct,
#[cfg(not(target_os = "macos"))]
ShiftInsert,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum ClipboardHandling {
DontModify,
CopyToClipboard,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum RecordingRetentionPeriod {
Never,
@ -125,7 +192,7 @@ impl ModelUnloadTimeout {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum SoundTheme {
Marimba,
@ -152,7 +219,7 @@ impl SoundTheme {
}
/* still handy for composing the initial JSON in the store ------------- */
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
pub struct AppSettings {
pub bindings: HashMap<String, ShortcutBinding>,
pub push_to_talk: bool,

View file

@ -1,5 +1,6 @@
use log::{error, warn};
use serde::Serialize;
use specta::Type;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_autostart::ManagerExt;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
@ -22,7 +23,7 @@ pub fn init_shortcuts(app: &AppHandle) {
}
}
#[derive(Serialize)]
#[derive(Serialize, Type)]
pub struct BindingResponse {
success: bool,
binding: Option<ShortcutBinding>,
@ -30,6 +31,7 @@ pub struct BindingResponse {
}
#[tauri::command]
#[specta::specta]
pub fn change_binding(
app: AppHandle,
id: String,
@ -93,6 +95,7 @@ pub fn change_binding(
}
#[tauri::command]
#[specta::specta]
pub fn reset_binding(app: AppHandle, id: String) -> Result<BindingResponse, String> {
let binding = settings::get_stored_binding(&app, &id);
@ -100,6 +103,7 @@ pub fn reset_binding(app: AppHandle, id: String) -> Result<BindingResponse, Stri
}
#[tauri::command]
#[specta::specta]
pub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
@ -113,6 +117,7 @@ pub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
}
#[tauri::command]
#[specta::specta]
pub fn change_audio_feedback_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.audio_feedback = enabled;
@ -121,6 +126,7 @@ pub fn change_audio_feedback_setting(app: AppHandle, enabled: bool) -> Result<()
}
#[tauri::command]
#[specta::specta]
pub fn change_audio_feedback_volume_setting(app: AppHandle, volume: f32) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.audio_feedback_volume = volume;
@ -129,6 +135,7 @@ pub fn change_audio_feedback_volume_setting(app: AppHandle, volume: f32) -> Resu
}
#[tauri::command]
#[specta::specta]
pub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match theme.as_str() {
@ -146,6 +153,7 @@ pub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), S
}
#[tauri::command]
#[specta::specta]
pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.translate_to_english = enabled;
@ -154,6 +162,7 @@ pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Res
}
#[tauri::command]
#[specta::specta]
pub fn change_selected_language_setting(app: AppHandle, language: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.selected_language = language;
@ -162,6 +171,7 @@ pub fn change_selected_language_setting(app: AppHandle, language: String) -> Res
}
#[tauri::command]
#[specta::specta]
pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match position.as_str() {
@ -183,6 +193,7 @@ pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Resu
}
#[tauri::command]
#[specta::specta]
pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.debug_mode = enabled;
@ -201,6 +212,7 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St
}
#[tauri::command]
#[specta::specta]
pub fn change_start_hidden_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.start_hidden = enabled;
@ -219,6 +231,7 @@ pub fn change_start_hidden_setting(app: AppHandle, enabled: bool) -> Result<(),
}
#[tauri::command]
#[specta::specta]
pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.autostart_enabled = enabled;
@ -245,6 +258,7 @@ pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), Str
}
#[tauri::command]
#[specta::specta]
pub fn update_custom_words(app: AppHandle, words: Vec<String>) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.custom_words = words;
@ -253,6 +267,7 @@ pub fn update_custom_words(app: AppHandle, words: Vec<String>) -> Result<(), Str
}
#[tauri::command]
#[specta::specta]
pub fn change_word_correction_threshold_setting(
app: AppHandle,
threshold: f64,
@ -264,12 +279,12 @@ pub fn change_word_correction_threshold_setting(
}
#[tauri::command]
#[specta::specta]
pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match method.as_str() {
"ctrl_v" => PasteMethod::CtrlV,
"direct" => PasteMethod::Direct,
#[cfg(not(target_os = "macos"))]
"shift_insert" => PasteMethod::ShiftInsert,
other => {
warn!("Invalid paste method '{}', defaulting to ctrl_v", other);
@ -282,6 +297,7 @@ pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(),
}
#[tauri::command]
#[specta::specta]
pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match handling.as_str() {
@ -301,6 +317,7 @@ pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Re
}
#[tauri::command]
#[specta::specta]
pub fn change_post_process_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.post_process_enabled = enabled;
@ -309,6 +326,7 @@ pub fn change_post_process_enabled_setting(app: AppHandle, enabled: bool) -> Res
}
#[tauri::command]
#[specta::specta]
pub fn change_post_process_base_url_setting(
app: AppHandle,
provider_id: String,
@ -352,6 +370,7 @@ fn validate_provider_exists(
}
#[tauri::command]
#[specta::specta]
pub fn change_post_process_api_key_setting(
app: AppHandle,
provider_id: String,
@ -365,6 +384,7 @@ pub fn change_post_process_api_key_setting(
}
#[tauri::command]
#[specta::specta]
pub fn change_post_process_model_setting(
app: AppHandle,
provider_id: String,
@ -378,6 +398,7 @@ pub fn change_post_process_model_setting(
}
#[tauri::command]
#[specta::specta]
pub fn set_post_process_provider(app: AppHandle, provider_id: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
validate_provider_exists(&settings, &provider_id)?;
@ -387,6 +408,7 @@ pub fn set_post_process_provider(app: AppHandle, provider_id: String) -> Result<
}
#[tauri::command]
#[specta::specta]
pub fn add_post_process_prompt(
app: AppHandle,
name: String,
@ -410,6 +432,7 @@ pub fn add_post_process_prompt(
}
#[tauri::command]
#[specta::specta]
pub fn update_post_process_prompt(
app: AppHandle,
id: String,
@ -433,6 +456,7 @@ pub fn update_post_process_prompt(
}
#[tauri::command]
#[specta::specta]
pub fn delete_post_process_prompt(app: AppHandle, id: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
@ -460,6 +484,7 @@ pub fn delete_post_process_prompt(app: AppHandle, id: String) -> Result<(), Stri
}
#[tauri::command]
#[specta::specta]
pub async fn fetch_post_process_models(
app: AppHandle,
provider_id: String,
@ -599,6 +624,7 @@ async fn fetch_models_manual(
}
#[tauri::command]
#[specta::specta]
pub fn set_post_process_selected_prompt(app: AppHandle, id: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
@ -613,6 +639,7 @@ pub fn set_post_process_selected_prompt(app: AppHandle, id: String) -> Result<()
}
#[tauri::command]
#[specta::specta]
pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.mute_while_recording = enabled;
@ -642,6 +669,7 @@ fn validate_shortcut_string(raw: &str) -> Result<(), String> {
/// Temporarily unregister a binding while the user is editing it in the UI.
/// This avoids firing the action while keys are being recorded.
#[tauri::command]
#[specta::specta]
pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {
if let Err(e) = _unregister_shortcut(&app, b) {
@ -654,6 +682,7 @@ pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> {
/// Re-register the binding after the user has finished editing.
#[tauri::command]
#[specta::specta]
pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {
if let Err(e) = _register_shortcut(&app, b) {

View file

@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { Toaster } from "sonner";
import "./App.css";
@ -7,6 +6,7 @@ import Footer from "./components/footer";
import Onboarding from "./components/onboarding";
import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar";
import { useSettings } from "./hooks/useSettings";
import { commands } from "@/bindings";
const renderSettingsContent = (section: SidebarSection) => {
const ActiveComponent =
@ -52,8 +52,12 @@ function App() {
const checkOnboardingStatus = async () => {
try {
// Always check if they have any models available
const modelsAvailable: boolean = await invoke("has_any_models_available");
setShowOnboarding(!modelsAvailable);
const result = await commands.hasAnyModelsAvailable();
if (result.status === "ok") {
setShowOnboarding(!result.data);
} else {
setShowOnboarding(true);
}
} catch (error) {
console.error("Failed to check onboarding status:", error);
setShowOnboarding(true);

674
src/bindings.ts Normal file
View file

@ -0,0 +1,674 @@
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
/** user-defined commands **/
export const commands = {
async changeBinding(id: string, binding: string) : Promise<Result<BindingResponse, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_binding", { id, binding }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async resetBinding(id: string) : Promise<Result<BindingResponse, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reset_binding", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changePttSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_ptt_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeAudioFeedbackSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_audio_feedback_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeAudioFeedbackVolumeSetting(volume: number) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_audio_feedback_volume_setting", { volume }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeSoundThemeSetting(theme: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_sound_theme_setting", { theme }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeStartHiddenSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_start_hidden_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeAutostartSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_autostart_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeTranslateToEnglishSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_translate_to_english_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeSelectedLanguageSetting(language: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_selected_language_setting", { language }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeOverlayPositionSetting(position: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_overlay_position_setting", { position }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeDebugModeSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_debug_mode_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeWordCorrectionThresholdSetting(threshold: number) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_word_correction_threshold_setting", { threshold }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changePasteMethodSetting(method: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_paste_method_setting", { method }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeClipboardHandlingSetting(handling: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_clipboard_handling_setting", { handling }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changePostProcessEnabledSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_post_process_enabled_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changePostProcessBaseUrlSetting(providerId: string, baseUrl: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_post_process_base_url_setting", { providerId, baseUrl }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changePostProcessApiKeySetting(providerId: string, apiKey: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_post_process_api_key_setting", { providerId, apiKey }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changePostProcessModelSetting(providerId: string, model: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_post_process_model_setting", { providerId, model }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setPostProcessProvider(providerId: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_post_process_provider", { providerId }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async fetchPostProcessModels(providerId: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("fetch_post_process_models", { providerId }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async addPostProcessPrompt(name: string, prompt: string) : Promise<Result<LLMPrompt, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("add_post_process_prompt", { name, prompt }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async updatePostProcessPrompt(id: string, name: string, prompt: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("update_post_process_prompt", { id, name, prompt }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async deletePostProcessPrompt(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("delete_post_process_prompt", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setPostProcessSelectedPrompt(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_post_process_selected_prompt", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async updateCustomWords(words: string[]) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("update_custom_words", { words }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Temporarily unregister a binding while the user is editing it in the UI.
* This avoids firing the action while keys are being recorded.
*/
async suspendBinding(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("suspend_binding", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Re-register the binding after the user has finished editing.
*/
async resumeBinding(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("resume_binding", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeMuteWhileRecordingSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_mute_while_recording_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async triggerUpdateCheck() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("trigger_update_check") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async cancelOperation() : Promise<void> {
await TAURI_INVOKE("cancel_operation");
},
async getAppDirPath() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_app_dir_path") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAppSettings() : Promise<Result<AppSettings, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_app_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getDefaultSettings() : Promise<Result<AppSettings, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_default_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLogDirPath() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_log_dir_path") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setLogLevel(level: LogLevel) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_log_level", { level }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async openRecordingsFolder() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_recordings_folder") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async openLogDir() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_log_dir") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async openAppDataDir() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_app_data_dir") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAvailableModels() : Promise<Result<ModelInfo[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_available_models") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getModelInfo(modelId: string) : Promise<Result<ModelInfo | null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_model_info", { modelId }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async downloadModel(modelId: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("download_model", { modelId }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async deleteModel(modelId: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("delete_model", { modelId }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async cancelDownload(modelId: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("cancel_download", { modelId }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setActiveModel(modelId: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_active_model", { modelId }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getCurrentModel() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_current_model") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getTranscriptionModelStatus() : Promise<Result<string | null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_transcription_model_status") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async isModelLoading() : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_model_loading") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async hasAnyModelsAvailable() : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("has_any_models_available") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async hasAnyModelsOrDownloads() : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("has_any_models_or_downloads") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getRecommendedFirstModel() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_recommended_first_model") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async updateMicrophoneMode(alwaysOn: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("update_microphone_mode", { alwaysOn }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getMicrophoneMode() : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_microphone_mode") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAvailableMicrophones() : Promise<Result<AudioDevice[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_available_microphones") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setSelectedMicrophone(deviceName: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_selected_microphone", { deviceName }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getSelectedMicrophone() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_selected_microphone") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAvailableOutputDevices() : Promise<Result<AudioDevice[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_available_output_devices") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setSelectedOutputDevice(deviceName: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_selected_output_device", { deviceName }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getSelectedOutputDevice() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_selected_output_device") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async playTestSound(soundType: string) : Promise<void> {
await TAURI_INVOKE("play_test_sound", { soundType });
},
async checkCustomSounds() : Promise<CustomSounds> {
return await TAURI_INVOKE("check_custom_sounds");
},
async setClamshellMicrophone(deviceName: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_clamshell_microphone", { deviceName }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getClamshellMicrophone() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_clamshell_microphone") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setModelUnloadTimeout(timeout: ModelUnloadTimeout) : Promise<void> {
await TAURI_INVOKE("set_model_unload_timeout", { timeout });
},
async getModelLoadStatus() : Promise<Result<ModelLoadStatus, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_model_load_status") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async unloadModelManually() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("unload_model_manually") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getHistoryEntries() : Promise<Result<HistoryEntry[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_history_entries") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async toggleHistoryEntrySaved(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("toggle_history_entry_saved", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAudioFilePath(fileName: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_audio_file_path", { fileName }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async deleteHistoryEntry(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("delete_history_entry", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async updateHistoryLimit(limit: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("update_history_limit", { limit }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async updateRecordingRetentionPeriod(period: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("update_recording_retention_period", { period }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Checks if the Mac is a laptop by detecting battery presence
*
* This uses pmset to check for battery information.
* Returns true if a battery is detected (laptop), false otherwise (desktop)
*/
async isLaptop() : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_laptop") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
/** user-defined events **/
/** user-defined constants **/
/** user-defined types **/
export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean }
export type AudioDevice = { index: string; name: string; is_default: boolean }
export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null }
export type ClipboardHandling = "dont_modify" | "copy_to_clipboard"
export type CustomSounds = { start: boolean; stop: boolean }
export type EngineType = "Whisper" | "Parakeet"
export type HistoryEntry = { id: string; file_name: string; timestamp: string; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null }
export type LLMPrompt = { id: string; name: string; prompt: string }
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"
export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: string; is_downloaded: boolean; is_downloading: boolean; partial_size: string; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number }
export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null }
export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5"
export type OverlayPosition = "none" | "top" | "bottom"
export type PasteMethod = "ctrl_v" | "direct" | "shift_insert"
export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null }
export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3"
export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string }
export type SoundTheme = "marimba" | "pop" | "custom"
/** tauri-specta globals **/
import {
invoke as TAURI_INVOKE,
Channel as TAURI_CHANNEL,
} from "@tauri-apps/api/core";
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
type __EventObj__<T> = {
listen: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
export type Result<T, E> =
| { status: "ok"; data: T }
| { status: "error"; error: E };
function __makeEvents__<T extends Record<string, any>>(
mappings: Record<keyof T, string>,
) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindow__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindow__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg),
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case "listen":
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case "once":
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case "emit":
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
},
});
},
},
);
}

View file

@ -1,5 +1,5 @@
import React from "react";
import { ModelInfo } from "../../lib/types";
import type { ModelInfo } from "@/bindings";
import { formatModelSize } from "../../lib/utils/format";
import { ProgressBar } from "../shared";
@ -180,7 +180,7 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
{model.description}
</div>
<div className="mt-1 text-xs text-text/50 tabular-nums">
Download size · {formatModelSize(model.size_mb)}
Download size · {formatModelSize(Number(model.size_mb))}
</div>
</div>
<div className="text-xs text-logo-primary tabular-nums">

View file

@ -1,7 +1,6 @@
import React, { useState, useRef, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { ModelInfo } from "../../lib/types";
import { commands, type ModelInfo } from "@/bindings";
import ModelStatusButton from "./ModelStatusButton";
import ModelDropdown from "./ModelDropdown";
import DownloadProgressDisplay from "./DownloadProgressDisplay";
@ -238,8 +237,10 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
const loadModels = async () => {
try {
const modelList = await invoke<ModelInfo[]>("get_available_models");
setModels(modelList);
const result = await commands.getAvailableModels();
if (result.status === "ok") {
setModels(result.data);
}
} catch (err) {
console.error("Failed to load models:", err);
}
@ -247,21 +248,25 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
const loadCurrentModel = async () => {
try {
const current = await invoke<string>("get_current_model");
setCurrentModelId(current);
const result = await commands.getCurrentModel();
if (result.status === "ok") {
const current = result.data;
setCurrentModelId(current);
if (current) {
// Check if model is actually loaded
const transcriptionStatus = await invoke<string | null>(
"get_transcription_model_status",
);
if (transcriptionStatus === current) {
setModelStatus("ready");
if (current) {
// Check if model is actually loaded
const statusResult = await commands.getTranscriptionModelStatus();
if (statusResult.status === "ok") {
const transcriptionStatus = statusResult.data;
if (transcriptionStatus === current) {
setModelStatus("ready");
} else {
setModelStatus("unloaded");
}
}
} else {
setModelStatus("unloaded");
setModelStatus("none");
}
} else {
setModelStatus("none");
}
} catch (err) {
console.error("Failed to load current model:", err);
@ -272,10 +277,16 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
const handleModelSelect = async (modelId: string) => {
try {
setCurrentModelId(modelId); // Set optimistically so loading text shows correct model
setModelError(null);
setShowModelDropdown(false);
await invoke("set_active_model", { modelId });
setCurrentModelId(modelId);
const result = await commands.setActiveModel(modelId);
if (result.status === "error") {
const errorMsg = result.error;
setModelError(errorMsg);
setModelStatus("error");
onError?.(errorMsg);
}
} catch (err) {
const errorMsg = `${err}`;
setModelError(errorMsg);
@ -287,7 +298,13 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
const handleModelDownload = async (modelId: string) => {
try {
setModelError(null);
await invoke("download_model", { modelId });
const result = await commands.downloadModel(modelId);
if (result.status === "error") {
const errorMsg = result.error;
setModelError(errorMsg);
setModelStatus("error");
onError?.(errorMsg);
}
} catch (err) {
const errorMsg = `${err}`;
setModelError(errorMsg);
@ -347,9 +364,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
};
const handleModelDelete = async (modelId: string) => {
await invoke("delete_model", { modelId });
await loadModels();
setModelError(null);
const result = await commands.deleteModel(modelId);
if (result.status === "ok") {
await loadModels();
setModelError(null);
}
};
return (

View file

@ -1,6 +1,6 @@
import React from "react";
import { Download } from "lucide-react";
import { ModelInfo } from "../../lib/types";
import type { ModelInfo } from "@/bindings";
import { formatModelSize } from "../../lib/utils/format";
import Badge from "../ui/Badge";
@ -42,7 +42,7 @@ const ModelCard: React.FC<ModelCardProps> = ({
<h3 className="text-lg font-semibold text-text group-hover:text-logo-primary transition-colors">
{model.name}
</h3>
<DownloadSize sizeMb={model.size_mb} />
<DownloadSize sizeMb={Number(model.size_mb)} />
{isFeatured && <Badge variant="primary">Recommended</Badge>}
</div>
<p className="text-text/60 text-sm leading-relaxed">

View file

@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ModelInfo } from "../../lib/types";
import { commands, type ModelInfo } from "@/bindings";
import ModelCard from "./ModelCard";
import HandyTextLogo from "../icons/HandyTextLogo";
@ -19,9 +18,13 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
const loadModels = async () => {
try {
const models: ModelInfo[] = await invoke("get_available_models");
// Only show downloadable models for onboarding
setAvailableModels(models.filter((m) => !m.is_downloaded));
const result = await commands.getAvailableModels();
if (result.status === "ok") {
// Only show downloadable models for onboarding
setAvailableModels(result.data.filter((m) => !m.is_downloaded));
} else {
setError("Failed to load available models");
}
} catch (err) {
console.error("Failed to load models:", err);
setError("Failed to load available models");
@ -36,7 +39,12 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
onModelSelected();
try {
await invoke("download_model", { modelId });
const result = await commands.downloadModel(modelId);
if (result.status === "error") {
console.error("Download failed:", result.error);
setError(`Failed to download model: ${result.error}`);
setDownloading(false);
}
} catch (err) {
console.error("Download failed:", err);
setError(`Failed to download model: ${err}`);
@ -80,7 +88,7 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
{availableModels
.filter((model) => !getRecommendedBadge(model.id))
.sort((a, b) => a.size_mb - b.size_mb)
.sort((a, b) => Number(a.size_mb) - Number(b.size_mb))
.map((model) => (
<ModelCard
key={model.id}

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { commands } from "@/bindings";
import { SettingContainer } from "../ui/SettingContainer";
import { Button } from "../ui/Button";
@ -19,8 +19,12 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
useEffect(() => {
const loadAppDirectory = async () => {
try {
const result = await invoke<string>("get_app_dir_path");
setAppDirPath(result);
const result = await commands.getAppDirPath();
if (result.status === "ok") {
setAppDirPath(result.data);
} else {
setError(result.error);
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load app directory",
@ -36,7 +40,7 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
const handleOpen = async () => {
if (!appDirPath) return;
try {
await invoke("open_app_data_dir");
await commands.openAppDataDir();
} catch (openError) {
console.error("Failed to open app data directory:", openError);
}

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { commands } from "@/bindings";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { ResetButton } from "../ui/ResetButton";
@ -27,8 +27,12 @@ export const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorPr
useEffect(() => {
const checkIsLaptop = async () => {
try {
const result = await invoke<boolean>("is_laptop");
setIsLaptop(result);
const result = await commands.isLaptop();
if (result.status === "ok") {
setIsLaptop(result.data);
} else {
setIsLaptop(false);
}
} catch (error) {
console.error("Failed to check if device is laptop:", error);
setIsLaptop(false);

View file

@ -2,7 +2,7 @@ import React from "react";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import type { ClipboardHandling } from "../../lib/types";
import type { ClipboardHandling } from "@/bindings";
interface ClipboardHandlingProps {
descriptionMode?: "inline" | "tooltip";

View file

@ -9,7 +9,7 @@ import {
import { ResetButton } from "../ui/ResetButton";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import { invoke } from "@tauri-apps/api/core";
import { commands } from "@/bindings";
import { toast } from "sonner";
interface HandyShortcutProps {
@ -80,7 +80,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
if (editingShortcutId && originalBinding) {
try {
await updateBinding(editingShortcutId, originalBinding);
await invoke("resume_binding", { id: editingShortcutId }).catch(
await commands.resumeBinding(editingShortcutId).catch(
console.error,
);
} catch (error) {
@ -88,7 +88,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
toast.error("Failed to restore original shortcut");
}
} else if (editingShortcutId) {
await invoke("resume_binding", { id: editingShortcutId }).catch(
await commands.resumeBinding(editingShortcutId).catch(
console.error,
);
}
@ -134,7 +134,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
try {
await updateBinding(editingShortcutId, newShortcut);
// Re-register the shortcut now that recording is finished
await invoke("resume_binding", { id: editingShortcutId }).catch(
await commands.resumeBinding(editingShortcutId).catch(
console.error,
);
} catch (error) {
@ -145,7 +145,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
if (originalBinding) {
try {
await updateBinding(editingShortcutId, originalBinding);
await invoke("resume_binding", { id: editingShortcutId }).catch(
await commands.resumeBinding(editingShortcutId).catch(
console.error,
);
} catch (resetError) {
@ -173,7 +173,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
if (editingShortcutId && originalBinding) {
try {
await updateBinding(editingShortcutId, originalBinding);
await invoke("resume_binding", { id: editingShortcutId }).catch(
await commands.resumeBinding(editingShortcutId).catch(
console.error,
);
} catch (error) {
@ -181,7 +181,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
toast.error("Failed to restore original shortcut");
}
} else if (editingShortcutId) {
invoke("resume_binding", { id: editingShortcutId }).catch(
commands.resumeBinding(editingShortcutId).catch(
console.error,
);
}
@ -217,7 +217,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
if (editingShortcutId === id) return; // Already editing this shortcut
// Suspend current binding to avoid firing while recording
await invoke("suspend_binding", { id }).catch(console.error);
await commands.suspendBinding(id).catch(console.error);
// Store the original binding to restore if canceled
setOriginalBinding(bindings[id]?.current_binding || "");

View file

@ -14,12 +14,12 @@ export const HistoryLimit: React.FC<HistoryLimitProps> = ({
}) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const historyLimit = getSetting("history_limit") ?? 5;
const historyLimit = Number(getSetting("history_limit") ?? "5");
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value, 10);
if (!isNaN(value) && value >= 0) {
updateSetting("history_limit", value);
updateSetting("history_limit", value.toString());
}
};

View file

@ -1,7 +1,6 @@
import React, { useMemo } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useSettings } from "../../hooks/useSettings";
import { ModelUnloadTimeout } from "../../lib/types";
import { commands, type ModelUnloadTimeout } from "@/bindings";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
@ -35,7 +34,7 @@ export const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({
const newTimeout = event.target.value as ModelUnloadTimeout;
try {
await invoke("set_model_unload_timeout", { timeout: newTimeout });
await commands.setModelUnloadTimeout(newTimeout);
updateSetting("model_unload_timeout", newTimeout);
} catch (error) {
console.error("Failed to update model unload timeout:", error);

View file

@ -3,7 +3,7 @@ import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { ResetButton } from "../ui/ResetButton";
import { useSettings } from "../../hooks/useSettings";
import { AudioDevice } from "../../lib/types";
import type { AudioDevice } from "@/bindings";
interface OutputDeviceSelectorProps {
descriptionMode?: "inline" | "tooltip";

View file

@ -3,7 +3,7 @@ import { type as getOsType } from "@tauri-apps/plugin-os";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import type { PasteMethod } from "../../lib/types";
import type { PasteMethod } from "@/bindings";
interface PasteMethodProps {
descriptionMode?: "inline" | "tooltip";

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo } from "react";
import { useSettings } from "../../../hooks/useSettings";
import { useSettingsStore } from "../../../stores/settingsStore";
import type { PostProcessProvider } from "../../../lib/types";
import type { PostProcessProvider } from "@/bindings";
import type { ModelOption } from "./types";
import type { DropdownOption } from "../../ui/Dropdown";

View file

@ -2,7 +2,7 @@ import React from "react";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import { RecordingRetentionPeriod } from "../../lib/types";
import { RecordingRetentionPeriod } from "@/bindings";
interface RecordingRetentionPeriodProps {
descriptionMode?: "inline" | "tooltip";

View file

@ -2,7 +2,7 @@ import React from "react";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import type { OverlayPosition } from "../../lib/types";
import type { OverlayPosition } from "@/bindings";
interface ShowOverlayProps {
descriptionMode?: "inline" | "tooltip";

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { commands } from "@/bindings";
import { SettingContainer } from "../../ui/SettingContainer";
import { Button } from "../../ui/Button";
@ -19,8 +19,12 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
useEffect(() => {
const loadLogDirectory = async () => {
try {
const result = await invoke<string>("get_log_dir_path");
setLogDir(result);
const result = await commands.getLogDirPath();
if (result.status === "ok") {
setLogDir(result.data);
} else {
setError(result.error);
}
} catch (err) {
const errorMessage =
err && typeof err === "object" && "message" in err
@ -38,7 +42,7 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
const handleOpen = async () => {
if (!logDir) return;
try {
await invoke("open_log_dir");
await commands.openLogDir();
} catch (openError) {
console.error("Failed to open log directory:", openError);
}

View file

@ -2,13 +2,14 @@ import React from "react";
import { SettingContainer } from "../../ui/SettingContainer";
import { Dropdown, type DropdownOption } from "../../ui/Dropdown";
import { useSettings } from "../../../hooks/useSettings";
import type { LogLevel } from "../../../bindings";
const LOG_LEVEL_OPTIONS: DropdownOption[] = [
{ value: "5", label: "Error" },
{ value: "4", label: "Warn" },
{ value: "3", label: "Info" },
{ value: "2", label: "Debug" },
{ value: "1", label: "Trace" },
{ value: "error", label: "Error" },
{ value: "warn", label: "Warn" },
{ value: "info", label: "Info" },
{ value: "debug", label: "Debug" },
{ value: "trace", label: "Trace" },
];
interface LogLevelSelectorProps {
@ -21,19 +22,13 @@ export const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({
grouped = false,
}) => {
const { settings, updateSetting, isUpdating } = useSettings();
const currentLevel = settings?.log_level ?? 2;
const isLevelUpdating = isUpdating("log_level");
const selectedValue = currentLevel.toString();
const currentLevel = settings?.log_level ?? "debug";
const handleSelect = async (value: string) => {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed === currentLevel) {
return;
}
if (value === currentLevel) return;
try {
await updateSetting("log_level", parsed as typeof currentLevel);
await updateSetting("log_level", value as LogLevel);
} catch (error) {
console.error("Failed to update log level:", error);
}
@ -47,14 +42,12 @@ export const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({
grouped={grouped}
layout="horizontal"
>
<div className="space-y-1">
<Dropdown
options={LOG_LEVEL_OPTIONS}
selectedValue={selectedValue}
onSelect={handleSelect}
disabled={!settings || isLevelUpdating}
/>
</div>
<Dropdown
options={LOG_LEVEL_OPTIONS}
selectedValue={currentLevel}
onSelect={handleSelect}
disabled={!settings || isUpdating("log_level")}
/>
</SettingContainer>
);
};

View file

@ -2,17 +2,9 @@ import React, { useState, useEffect, useCallback } from "react";
import { AudioPlayer } from "../../ui/AudioPlayer";
import { Button } from "../../ui/Button";
import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { convertFileSrc } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface HistoryEntry {
id: number;
file_name: string;
timestamp: number;
saved: boolean;
title: string;
transcription_text: string;
}
import { commands, type HistoryEntry } from "@/bindings";
interface OpenRecordingsButtonProps {
onClick: () => void;
@ -39,8 +31,10 @@ export const HistorySettings: React.FC = () => {
const loadHistoryEntries = useCallback(async () => {
try {
const entries = await invoke<HistoryEntry[]>("get_history_entries");
setHistoryEntries(entries);
const result = await commands.getHistoryEntries();
if (result.status === "ok") {
setHistoryEntries(result.data);
}
} catch (error) {
console.error("Failed to load history entries:", error);
} finally {
@ -73,9 +67,9 @@ export const HistorySettings: React.FC = () => {
};
}, [loadHistoryEntries]);
const toggleSaved = async (id: number) => {
const toggleSaved = async (id: string) => {
try {
await invoke("toggle_history_entry_saved", { id });
await commands.toggleHistoryEntrySaved(id);
// No need to reload here - the event listener will handle it
} catch (error) {
console.error("Failed to toggle saved status:", error);
@ -92,20 +86,20 @@ export const HistorySettings: React.FC = () => {
const getAudioUrl = async (fileName: string) => {
try {
const filePath = await invoke<string>("get_audio_file_path", {
fileName,
});
return convertFileSrc(`${filePath}`, "asset");
const result = await commands.getAudioFilePath(fileName);
if (result.status === "ok") {
return convertFileSrc(`${result.data}`, "asset");
}
return null;
} catch (error) {
console.error("Failed to get audio file path:", error);
return null;
}
};
const deleteAudioEntry = async (id: number) => {
const deleteAudioEntry = async (id: string) => {
try {
await invoke("delete_history_entry", { id });
await commands.deleteHistoryEntry(id);
} catch (error) {
console.error("Failed to delete audio entry:", error);
throw error;
@ -114,7 +108,7 @@ export const HistorySettings: React.FC = () => {
const openRecordingsFolder = async () => {
try {
await invoke("open_recordings_folder");
await commands.openRecordingsFolder();
} catch (error) {
console.error("Failed to open recordings folder:", error);
}
@ -199,7 +193,7 @@ interface HistoryEntryProps {
onToggleSaved: () => void;
onCopyText: () => void;
getAudioUrl: (fileName: string) => Promise<string | null>;
deleteAudio: (id: number) => Promise<void>;
deleteAudio: (id: string) => Promise<void>;
}
const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { RefreshCcw } from "lucide-react";
import { commands } from "@/bindings";
import { SettingsGroup } from "../../ui/SettingsGroup";
import { SettingContainer } from "../../ui/SettingContainer";
@ -16,7 +16,7 @@ import { ApiKeyField } from "../PostProcessingSettingsApi/ApiKeyField";
import { ModelSelect } from "../PostProcessingSettingsApi/ModelSelect";
import { usePostProcessProviderState } from "../PostProcessingSettingsApi/usePostProcessProviderState";
import { useSettings } from "../../../hooks/useSettings";
import type { LLMPrompt } from "../../../lib/types";
import type { LLMPrompt } from "@/bindings";
const DisabledNotice: React.FC<{ children: React.ReactNode }> = ({
children,
@ -178,13 +178,15 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
if (!draftName.trim() || !draftText.trim()) return;
try {
const newPrompt = await invoke<LLMPrompt>("add_post_process_prompt", {
name: draftName.trim(),
prompt: draftText.trim(),
});
await refreshSettings();
updateSetting("post_process_selected_prompt_id", newPrompt.id);
setIsCreating(false);
const result = await commands.addPostProcessPrompt(
draftName.trim(),
draftText.trim()
);
if (result.status === "ok") {
await refreshSettings();
updateSetting("post_process_selected_prompt_id", result.data.id);
setIsCreating(false);
}
} catch (error) {
console.error("Failed to create prompt:", error);
}
@ -194,11 +196,11 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
if (!selectedPromptId || !draftName.trim() || !draftText.trim()) return;
try {
await invoke("update_post_process_prompt", {
id: selectedPromptId,
name: draftName.trim(),
prompt: draftText.trim(),
});
await commands.updatePostProcessPrompt(
selectedPromptId,
draftName.trim(),
draftText.trim()
);
await refreshSettings();
} catch (error) {
console.error("Failed to update prompt:", error);
@ -209,7 +211,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
if (!promptId) return;
try {
await invoke("delete_post_process_prompt", { id: promptId });
await commands.deletePostProcessPrompt(promptId);
await refreshSettings();
setIsCreating(false);
} catch (error) {

View file

@ -1,21 +1,6 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface ModelInfo {
id: string;
name: string;
description: string;
filename: string;
url?: string;
size_mb: number;
is_downloaded: boolean;
is_downloading: boolean;
partial_size: number;
is_directory: boolean;
accuracy_score: number;
speed_score: number;
}
import { commands, type ModelInfo } from "@/bindings";
interface DownloadProgress {
model_id: string;
@ -43,9 +28,13 @@ export const useModels = () => {
const loadModels = async () => {
try {
const modelList = await invoke<ModelInfo[]>("get_available_models");
setModels(modelList);
setError(null);
const result = await commands.getAvailableModels();
if (result.status === "ok") {
setModels(result.data);
setError(null);
} else {
setError(`Failed to load models: ${result.error}`);
}
} catch (err) {
setError(`Failed to load models: ${err}`);
} finally {
@ -55,8 +44,10 @@ export const useModels = () => {
const loadCurrentModel = async () => {
try {
const current = await invoke<string>("get_current_model");
setCurrentModel(current);
const result = await commands.getCurrentModel();
if (result.status === "ok") {
setCurrentModel(result.data);
}
} catch (err) {
console.error("Failed to load current model:", err);
}
@ -64,10 +55,14 @@ export const useModels = () => {
const checkFirstRun = async () => {
try {
const hasModels = await invoke<boolean>("has_any_models_available");
setHasAnyModels(hasModels);
setIsFirstRun(!hasModels);
return !hasModels;
const result = await commands.hasAnyModelsAvailable();
if (result.status === "ok") {
const hasModels = result.data;
setHasAnyModels(hasModels);
setIsFirstRun(!hasModels);
return !hasModels;
}
return false;
} catch (err) {
console.error("Failed to check model availability:", err);
return false;
@ -77,11 +72,16 @@ export const useModels = () => {
const selectModel = async (modelId: string) => {
try {
setError(null);
await invoke("set_active_model", { modelId });
setCurrentModel(modelId);
setIsFirstRun(false);
setHasAnyModels(true);
return true;
const result = await commands.setActiveModel(modelId);
if (result.status === "ok") {
setCurrentModel(modelId);
setIsFirstRun(false);
setHasAnyModels(true);
return true;
} else {
setError(`Failed to switch to model: ${result.error}`);
return false;
}
} catch (err) {
setError(`Failed to switch to model: ${err}`);
return false;
@ -92,8 +92,18 @@ export const useModels = () => {
try {
setError(null);
setDownloadingModels((prev) => new Set(prev.add(modelId)));
await invoke("download_model", { modelId });
return true;
const result = await commands.downloadModel(modelId);
if (result.status === "ok") {
return true;
} else {
setError(`Failed to download model: ${result.error}`);
setDownloadingModels((prev) => {
const next = new Set(prev);
next.delete(modelId);
return next;
});
return false;
}
} catch (err) {
setError(`Failed to download model: ${err}`);
setDownloadingModels((prev) => {
@ -108,9 +118,14 @@ export const useModels = () => {
const deleteModel = async (modelId: string) => {
try {
setError(null);
await invoke("delete_model", { modelId });
await loadModels(); // Refresh the list
return true;
const result = await commands.deleteModel(modelId);
if (result.status === "ok") {
await loadModels(); // Refresh the list
return true;
} else {
setError(`Failed to delete model: ${result.error}`);
return false;
}
} catch (err) {
setError(`Failed to delete model: ${err}`);
return false;

View file

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useSettingsStore } from "../stores/settingsStore";
import { Settings, AudioDevice } from "../lib/types";
import type { AppSettings as Settings, AudioDevice } from "@/bindings";
interface UseSettingsReturn {
// State

View file

@ -1,151 +0,0 @@
import { z } from "zod";
export const ShortcutBindingSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
default_binding: z.string(),
current_binding: z.string(),
});
export const ShortcutBindingsMapSchema = z.record(
z.string(),
ShortcutBindingSchema,
);
export const AudioDeviceSchema = z.object({
index: z.string(),
name: z.string(),
is_default: z.boolean(),
});
export const OverlayPositionSchema = z.enum(["none", "top", "bottom"]);
export type OverlayPosition = z.infer<typeof OverlayPositionSchema>;
export const ModelUnloadTimeoutSchema = z.enum([
"never",
"immediately",
"min2",
"min5",
"min10",
"min15",
"hour1",
"sec5",
]);
export type ModelUnloadTimeout = z.infer<typeof ModelUnloadTimeoutSchema>;
export const PasteMethodSchema = z.enum(["ctrl_v", "direct", "shift_insert"]);
export type PasteMethod = z.infer<typeof PasteMethodSchema>;
export const ClipboardHandlingSchema = z.enum([
"dont_modify",
"copy_to_clipboard",
]);
export type ClipboardHandling = z.infer<typeof ClipboardHandlingSchema>;
export const LogLevelSchema = z.number().int().min(1).max(5).default(2);
export type LogLevelValue = z.infer<typeof LogLevelSchema>;
export const RecordingRetentionPeriodSchema = z.enum([
"never",
"preserve_limit",
"days3",
"weeks2",
"months3",
]);
export type RecordingRetentionPeriod = z.infer<
typeof RecordingRetentionPeriodSchema
>;
export const LLMPromptSchema = z.object({
id: z.string(),
name: z.string(),
prompt: z.string(),
});
export type LLMPrompt = z.infer<typeof LLMPromptSchema>;
export const PostProcessProviderSchema = z.object({
id: z.string(),
label: z.string(),
base_url: z.string(),
allow_base_url_edit: z.boolean().optional().default(false),
models_endpoint: z.string().nullable().optional(),
kind: z
.enum(["openai_compatible", "anthropic"])
.optional()
.default("openai_compatible"),
});
export type PostProcessProvider = z.infer<typeof PostProcessProviderSchema>;
export const SettingsSchema = z.object({
bindings: ShortcutBindingsMapSchema,
push_to_talk: z.boolean(),
audio_feedback: z.boolean(),
audio_feedback_volume: z.number().optional().default(1.0),
sound_theme: z
.enum(["marimba", "pop", "custom"])
.optional()
.default("marimba"),
start_hidden: z.boolean().optional().default(false),
autostart_enabled: z.boolean().optional().default(false),
selected_model: z.string(),
always_on_microphone: z.boolean(),
selected_microphone: z.string().nullable().optional(),
clamshell_microphone: z.string().nullable().optional(),
selected_output_device: z.string().nullable().optional(),
translate_to_english: z.boolean(),
selected_language: z.string(),
overlay_position: OverlayPositionSchema,
debug_mode: z.boolean(),
log_level: LogLevelSchema.optional().default(2),
custom_words: z.array(z.string()).optional().default([]),
model_unload_timeout: ModelUnloadTimeoutSchema.optional().default("never"),
word_correction_threshold: z.number().optional().default(0.18),
history_limit: z.number().optional().default(5),
recording_retention_period:
RecordingRetentionPeriodSchema.optional().default("preserve_limit"),
paste_method: PasteMethodSchema.optional().default("ctrl_v"),
clipboard_handling: ClipboardHandlingSchema.optional().default("dont_modify"),
post_process_enabled: z.boolean().optional().default(false),
post_process_provider_id: z.string().optional().default("openai"),
post_process_providers: z
.array(PostProcessProviderSchema)
.optional()
.default([]),
post_process_api_keys: z.record(z.string()).optional().default({}),
post_process_models: z.record(z.string()).optional().default({}),
post_process_prompts: z.array(LLMPromptSchema).optional().default([]),
post_process_selected_prompt_id: z.string().nullable().optional(),
mute_while_recording: z.boolean().optional().default(false),
});
export const BindingResponseSchema = z.object({
success: z.boolean(),
binding: ShortcutBindingSchema.nullable(),
error: z.string().nullable(),
});
export type AudioDevice = z.infer<typeof AudioDeviceSchema>;
export type BindingResponse = z.infer<typeof BindingResponseSchema>;
export type ShortcutBinding = z.infer<typeof ShortcutBindingSchema>;
export type ShortcutBindingsMap = z.infer<typeof ShortcutBindingsMapSchema>;
export type Settings = z.infer<typeof SettingsSchema>;
export const ModelInfoSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
filename: z.string(),
url: z.string().optional(),
size_mb: z.number(),
is_downloaded: z.boolean(),
is_downloading: z.boolean(),
partial_size: z.number(),
is_directory: z.boolean(),
accuracy_score: z.number(),
speed_score: z.number(),
});
export type ModelInfo = z.infer<typeof ModelInfoSchema>;

View file

@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import React, { useEffect, useRef, useState } from "react";
import {
@ -7,6 +6,7 @@ import {
CancelIcon,
} from "../components/icons";
import "./RecordingOverlay.css";
import { commands } from "@/bindings";
type OverlayState = "recording" | "transcribing";
@ -93,7 +93,7 @@ const RecordingOverlay: React.FC = () => {
<div
className="cancel-button"
onClick={() => {
invoke("cancel_operation");
commands.cancelOperation();
}}
>
<CancelIcon />

View file

@ -1,10 +1,11 @@
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import { invoke } from "@tauri-apps/api/core";
import { Settings, AudioDevice } from "../lib/types";
import type { AppSettings as Settings, AudioDevice } from "@/bindings";
import { commands } from "@/bindings";
interface SettingsStore {
settings: Settings | null;
defaultSettings: Settings | null;
isLoading: boolean;
isUpdating: Record<string, boolean>;
audioDevices: AudioDevice[];
@ -14,6 +15,7 @@ interface SettingsStore {
// Actions
initialize: () => Promise<void>;
loadDefaultSettings: () => Promise<void>;
updateSetting: <K extends keyof Settings>(
key: K,
value: Settings[K],
@ -48,6 +50,7 @@ interface SettingsStore {
// Internal state setters
setSettings: (settings: Settings | null) => void;
setDefaultSettings: (defaultSettings: Settings | null) => void;
setLoading: (loading: boolean) => void;
setUpdating: (key: string, updating: boolean) => void;
setAudioDevices: (devices: AudioDevice[]) => void;
@ -55,29 +58,8 @@ interface SettingsStore {
setCustomSounds: (sounds: { start: boolean; stop: boolean }) => void;
}
// Note: Default post-processing settings are now managed in Rust
// Settings will always have providers after migration
const DEFAULT_SETTINGS: Partial<Settings> = {
always_on_microphone: false,
audio_feedback: true,
audio_feedback_volume: 1.0,
sound_theme: "marimba",
start_hidden: false,
autostart_enabled: false,
push_to_talk: false,
selected_microphone: "Default",
clamshell_microphone: "Default",
selected_output_device: "Default",
translate_to_english: false,
selected_language: "auto",
overlay_position: "bottom",
debug_mode: false,
log_level: 2,
custom_words: [],
history_limit: 5,
recording_retention_period: "preserve_limit",
mute_while_recording: false,
};
// Note: Default settings are now fetched from Rust via commands.getDefaultSettings()
// This ensures platform-specific defaults (like overlay_position, shortcuts, paste_method) work correctly
const DEFAULT_AUDIO_DEVICE: AudioDevice = {
index: "default",
@ -89,60 +71,61 @@ const settingUpdaters: {
[K in keyof Settings]?: (value: Settings[K]) => Promise<unknown>;
} = {
always_on_microphone: (value) =>
invoke("update_microphone_mode", { alwaysOn: value }),
commands.updateMicrophoneMode(value as boolean),
audio_feedback: (value) =>
invoke("change_audio_feedback_setting", { enabled: value }),
commands.changeAudioFeedbackSetting(value as boolean),
audio_feedback_volume: (value) =>
invoke("change_audio_feedback_volume_setting", { volume: value }),
commands.changeAudioFeedbackVolumeSetting(value as number),
sound_theme: (value) =>
invoke("change_sound_theme_setting", { theme: value }),
commands.changeSoundThemeSetting(value as string),
start_hidden: (value) =>
invoke("change_start_hidden_setting", { enabled: value }),
commands.changeStartHiddenSetting(value as boolean),
autostart_enabled: (value) =>
invoke("change_autostart_setting", { enabled: value }),
push_to_talk: (value) => invoke("change_ptt_setting", { enabled: value }),
commands.changeAutostartSetting(value as boolean),
push_to_talk: (value) => commands.changePttSetting(value as boolean),
selected_microphone: (value) =>
invoke("set_selected_microphone", {
deviceName: value === "Default" ? "default" : value,
}),
commands.setSelectedMicrophone(
(value as string) === "Default" || value === null ? "default" : (value as string)
),
clamshell_microphone: (value) =>
invoke("set_clamshell_microphone", {
deviceName: value === "Default" ? "default" : value,
}),
commands.setClamshellMicrophone(
(value as string) === "Default" ? "default" : (value as string)
),
selected_output_device: (value) =>
invoke("set_selected_output_device", {
deviceName: value === "Default" ? "default" : value,
}),
commands.setSelectedOutputDevice(
(value as string) === "Default" || value === null ? "default" : (value as string)
),
recording_retention_period: (value) =>
invoke("update_recording_retention_period", { period: value }),
commands.updateRecordingRetentionPeriod(value as string),
translate_to_english: (value) =>
invoke("change_translate_to_english_setting", { enabled: value }),
commands.changeTranslateToEnglishSetting(value as boolean),
selected_language: (value) =>
invoke("change_selected_language_setting", { language: value }),
commands.changeSelectedLanguageSetting(value as string),
overlay_position: (value) =>
invoke("change_overlay_position_setting", { position: value }),
commands.changeOverlayPositionSetting(value as string),
debug_mode: (value) =>
invoke("change_debug_mode_setting", { enabled: value }),
custom_words: (value) => invoke("update_custom_words", { words: value }),
commands.changeDebugModeSetting(value as boolean),
custom_words: (value) => commands.updateCustomWords(value as string[]),
word_correction_threshold: (value) =>
invoke("change_word_correction_threshold_setting", { threshold: value }),
commands.changeWordCorrectionThresholdSetting(value as number),
paste_method: (value) =>
invoke("change_paste_method_setting", { method: value }),
commands.changePasteMethodSetting(value as string),
clipboard_handling: (value) =>
invoke("change_clipboard_handling_setting", { handling: value }),
history_limit: (value) => invoke("update_history_limit", { limit: value }),
commands.changeClipboardHandlingSetting(value as string),
history_limit: (value) => commands.updateHistoryLimit(value as string),
post_process_enabled: (value) =>
invoke("change_post_process_enabled_setting", { enabled: value }),
commands.changePostProcessEnabledSetting(value as boolean),
post_process_selected_prompt_id: (value) =>
invoke("set_post_process_selected_prompt", { id: value }),
commands.setPostProcessSelectedPrompt(value as string),
mute_while_recording: (value) =>
invoke("change_mute_while_recording_setting", { enabled: value }),
log_level: (value) => invoke("set_log_level", { level: value }),
commands.changeMuteWhileRecordingSetting(value as boolean),
log_level: (value) => commands.setLogLevel(value as any),
};
export const useSettingsStore = create<SettingsStore>()(
subscribeWithSelector((set, get) => ({
settings: null,
defaultSettings: null,
isLoading: true,
isUpdating: {},
audioDevices: [],
@ -152,6 +135,7 @@ export const useSettingsStore = create<SettingsStore>()(
// Internal setters
setSettings: (settings) => set({ settings }),
setDefaultSettings: (defaultSettings) => set({ defaultSettings }),
setLoading: (isLoading) => set({ isLoading }),
setUpdating: (key, updating) =>
set((state) => ({
@ -168,48 +152,24 @@ export const useSettingsStore = create<SettingsStore>()(
// Load settings from store
refreshSettings: async () => {
try {
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", {
defaults: DEFAULT_SETTINGS,
autoSave: false,
});
const settings = (await store.get("settings")) as Settings;
// Load additional settings that come from invoke calls
const [
microphoneMode,
selectedMicrophone,
clamshellMicrophone,
selectedOutputDevice,
] = await Promise.allSettled([
invoke("get_microphone_mode"),
invoke("get_selected_microphone"),
invoke("get_clamshell_microphone"),
invoke("get_selected_output_device"),
]);
// Merge all settings
const mergedSettings: Settings = {
...settings,
always_on_microphone:
microphoneMode.status === "fulfilled"
? (microphoneMode.value as boolean)
: false,
selected_microphone:
selectedMicrophone.status === "fulfilled"
? (selectedMicrophone.value as string)
: "Default",
clamshell_microphone:
clamshellMicrophone.status === "fulfilled"
? (clamshellMicrophone.value as string)
: "Default",
selected_output_device:
selectedOutputDevice.status === "fulfilled"
? (selectedOutputDevice.value as string)
: "Default",
};
set({ settings: mergedSettings, isLoading: false });
const result = await commands.getAppSettings();
if (result.status === "ok") {
const settings = result.data;
const normalizedSettings: Settings = {
...settings,
always_on_microphone: settings.always_on_microphone ?? false,
selected_microphone:
settings.selected_microphone ?? "Default",
clamshell_microphone:
settings.clamshell_microphone ?? "Default",
selected_output_device:
settings.selected_output_device ?? "Default",
};
set({ settings: normalizedSettings, isLoading: false });
} else {
console.error("Failed to load settings:", result.error);
set({ isLoading: false });
}
} catch (error) {
console.error("Failed to load settings:", error);
set({ isLoading: false });
@ -219,16 +179,18 @@ export const useSettingsStore = create<SettingsStore>()(
// Load audio devices
refreshAudioDevices: async () => {
try {
const devices: AudioDevice[] = await invoke(
"get_available_microphones",
);
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...devices.filter(
(d) => d.name !== "Default" && d.name !== "default",
),
];
set({ audioDevices: devicesWithDefault });
const result = await commands.getAvailableMicrophones();
if (result.status === "ok") {
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...result.data.filter(
(d) => d.name !== "Default" && d.name !== "default",
),
];
set({ audioDevices: devicesWithDefault });
} else {
set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });
}
} catch (error) {
console.error("Failed to load audio devices:", error);
set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });
@ -238,16 +200,18 @@ export const useSettingsStore = create<SettingsStore>()(
// Load output devices
refreshOutputDevices: async () => {
try {
const devices: AudioDevice[] = await invoke(
"get_available_output_devices",
);
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...devices.filter(
(d) => d.name !== "Default" && d.name !== "default",
),
];
set({ outputDevices: devicesWithDefault });
const result = await commands.getAvailableOutputDevices();
if (result.status === "ok") {
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...result.data.filter(
(d) => d.name !== "Default" && d.name !== "default",
),
];
set({ outputDevices: devicesWithDefault });
} else {
set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });
}
} catch (error) {
console.error("Failed to load output devices:", error);
set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });
@ -257,7 +221,7 @@ export const useSettingsStore = create<SettingsStore>()(
// Play a test sound
playTestSound: async (soundType: "start" | "stop") => {
try {
await invoke("play_test_sound", { soundType });
await commands.playTestSound(soundType);
} catch (error) {
console.error(`Failed to play test sound (${soundType}):`, error);
}
@ -265,8 +229,8 @@ export const useSettingsStore = create<SettingsStore>()(
checkCustomSounds: async () => {
try {
const sounds = await invoke("check_custom_sounds");
get().setCustomSounds(sounds as { start: boolean; stop: boolean });
const sounds = await commands.checkCustomSounds();
get().setCustomSounds(sounds);
} catch (error) {
console.error("Failed to check custom sounds:", error);
}
@ -306,9 +270,12 @@ export const useSettingsStore = create<SettingsStore>()(
// Reset a setting to its default value
resetSetting: async (key) => {
const defaultValue = DEFAULT_SETTINGS[key];
if (defaultValue !== undefined) {
await get().updateSetting(key, defaultValue as any);
const { defaultSettings } = get();
if (defaultSettings) {
const defaultValue = defaultSettings[key];
if (defaultValue !== undefined) {
await get().updateSetting(key, defaultValue as any);
}
}
},
@ -329,7 +296,7 @@ export const useSettingsStore = create<SettingsStore>()(
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
...state.settings.bindings[id]!,
current_binding: binding,
},
},
@ -337,7 +304,7 @@ export const useSettingsStore = create<SettingsStore>()(
: null,
}));
await invoke("change_binding", { id, binding });
await commands.changeBinding(id, binding);
} catch (error) {
console.error(`Failed to update binding ${id}:`, error);
@ -350,7 +317,7 @@ export const useSettingsStore = create<SettingsStore>()(
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
...state.settings.bindings[id]!,
current_binding: originalBinding,
},
},
@ -371,7 +338,7 @@ export const useSettingsStore = create<SettingsStore>()(
setUpdating(updateKey, true);
try {
await invoke("reset_binding", { id });
await commands.resetBinding(id);
await refreshSettings();
} catch (error) {
console.error(`Failed to reset binding ${id}:`, error);
@ -396,7 +363,7 @@ export const useSettingsStore = create<SettingsStore>()(
}
try {
await invoke("set_post_process_provider", { providerId });
await commands.setPostProcessProvider(providerId);
await refreshSettings();
} catch (error) {
console.error("Failed to set post-process provider:", error);
@ -421,27 +388,16 @@ export const useSettingsStore = create<SettingsStore>()(
const { setUpdating, refreshSettings } = get();
const updateKey = `post_process_${settingType}:${providerId}`;
// Map setting types to command names
const commandMap = {
base_url: "change_post_process_base_url_setting",
api_key: "change_post_process_api_key_setting",
model: "change_post_process_model_setting",
};
// Map setting types to param names
const paramMap = {
base_url: "baseUrl",
api_key: "apiKey",
model: "model",
};
setUpdating(updateKey, true);
try {
await invoke(commandMap[settingType], {
providerId,
[paramMap[settingType]]: value,
});
if (settingType === "base_url") {
await commands.changePostProcessBaseUrlSetting(providerId, value);
} else if (settingType === "api_key") {
await commands.changePostProcessApiKeySetting(providerId, value);
} else if (settingType === "model") {
await commands.changePostProcessModelSetting(providerId, value);
}
await refreshSettings();
} catch (error) {
console.error(
@ -480,12 +436,14 @@ export const useSettingsStore = create<SettingsStore>()(
try {
// Call Tauri backend command instead of fetch
const models: string[] = await invoke("fetch_post_process_models", {
providerId,
});
setPostProcessModelOptions(providerId, models);
return models;
const result = await commands.fetchPostProcessModels(providerId);
if (result.status === "ok") {
setPostProcessModelOptions(providerId, result.data);
return result.data;
} else {
console.error("Failed to fetch models:", result.error);
return [];
}
} catch (error) {
console.error("Failed to fetch models:", error);
// Don't cache empty array on error - let user retry
@ -503,6 +461,20 @@ export const useSettingsStore = create<SettingsStore>()(
},
})),
// Load default settings from Rust
loadDefaultSettings: async () => {
try {
const result = await commands.getDefaultSettings();
if (result.status === "ok") {
set({ defaultSettings: result.data });
} else {
console.error("Failed to load default settings:", result.error);
}
} catch (error) {
console.error("Failed to load default settings:", error);
}
},
// Initialize everything
initialize: async () => {
const {
@ -510,8 +482,10 @@ export const useSettingsStore = create<SettingsStore>()(
refreshAudioDevices,
refreshOutputDevices,
checkCustomSounds,
loadDefaultSettings,
} = get();
await Promise.all([
loadDefaultSettings(),
refreshSettings(),
refreshAudioDevices(),
refreshOutputDevices(),

View file

@ -15,6 +15,12 @@
"noEmit": true,
"jsx": "react-jsx",
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/bindings": ["./src/bindings.ts"]
},
/* Linting */
"strict": true,
"noUnusedLocals": false,

View file

@ -9,6 +9,13 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
// Path aliases
resolve: {
alias: {
"@/bindings": resolve(__dirname, "./src/bindings.ts"),
},
},
// Multiple entry points for main app and overlay
build: {
rollupOptions: {