diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e05be5..7958fd9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -165,8 +165,11 @@ fn initialize_core_logic(app_handle: &AppHandle) { show_main_window(app); } "check_updates" => { - show_main_window(app); - let _ = app.emit("check-for-updates", ()); + let settings = settings::get_settings(app); + if settings.update_checks_enabled { + show_main_window(app); + let _ = app.emit("check-for-updates", ()); + } } "cancel" => { use crate::utils::cancel_current_operation; @@ -205,6 +208,10 @@ fn initialize_core_logic(app_handle: &AppHandle) { #[tauri::command] #[specta::specta] fn trigger_update_check(app: AppHandle) -> Result<(), String> { + let settings = settings::get_settings(&app); + if !settings.update_checks_enabled { + return Ok(()); + } app.emit("check-for-updates", ()) .map_err(|e| e.to_string())?; Ok(()) @@ -246,6 +253,7 @@ pub fn run() { shortcut::suspend_binding, shortcut::resume_binding, shortcut::change_mute_while_recording_setting, + shortcut::change_update_checks_setting, trigger_update_check, commands::cancel_operation, commands::get_app_dir_path, @@ -300,30 +308,29 @@ pub fn run() { ) .expect("Failed to export typescript bindings"); - let mut builder = tauri::Builder::default() - .plugin( - LogBuilder::new() - .level(log::LevelFilter::Trace) // Set to most verbose level globally - .max_file_size(500_000) - .rotation_strategy(RotationStrategy::KeepOne) - .clear_targets() - .targets([ - // Console output respects RUST_LOG environment variable - Target::new(TargetKind::Stdout).filter({ - let console_filter = console_filter.clone(); - move |metadata| console_filter.enabled(metadata) - }), - // File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic) - Target::new(TargetKind::LogDir { - file_name: Some("handy".into()), - }) - .filter(|metadata| { - let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed); - metadata.level() <= level_filter_from_u8(file_level) - }), - ]) - .build(), - ); + let mut builder = tauri::Builder::default().plugin( + LogBuilder::new() + .level(log::LevelFilter::Trace) // Set to most verbose level globally + .max_file_size(500_000) + .rotation_strategy(RotationStrategy::KeepOne) + .clear_targets() + .targets([ + // Console output respects RUST_LOG environment variable + Target::new(TargetKind::Stdout).filter({ + let console_filter = console_filter.clone(); + move |metadata| console_filter.enabled(metadata) + }), + // File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic) + Target::new(TargetKind::LogDir { + file_name: Some("handy".into()), + }) + .filter(|metadata| { + let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed); + metadata.level() <= level_filter_from_u8(file_level) + }), + ]) + .build(), + ); #[cfg(target_os = "macos")] { diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 2fc226a..e5bc6a0 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -44,7 +44,7 @@ const OVERLAY_BOTTOM_OFFSET: f64 = 40.0; #[cfg(target_os = "windows")] fn force_overlay_topmost(overlay_window: &tauri::webview::WebviewWindow) { use windows::Win32::UI::WindowsAndMessaging::{ - SetWindowPos, HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, SWP_NOACTIVATE, SWP_SHOWWINDOW, + SetWindowPos, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_SHOWWINDOW, }; // Clone because run_on_main_thread takes 'static @@ -58,7 +58,10 @@ fn force_overlay_topmost(overlay_window: &tauri::webview::WebviewWindow) { let _ = SetWindowPos( hwnd, Some(HWND_TOPMOST), - 0, 0, 0, 0, + 0, + 0, + 0, + 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, ); } @@ -173,32 +176,26 @@ pub fn create_recording_overlay(app_handle: &AppHandle) { if let Some((x, y)) = calculate_overlay_position(app_handle) { // PanelBuilder creates a Tauri window then converts it to NSPanel. // The window remains registered, so get_webview_window() still works. - match PanelBuilder::<_, RecordingOverlayPanel>::new( - app_handle, - "recording_overlay" - ) - .url(WebviewUrl::App("src/overlay/index.html".into())) - .title("Recording") - .position(tauri::Position::Logical(tauri::LogicalPosition { x, y })) - .level(PanelLevel::Status) - .size(tauri::Size::Logical(tauri::LogicalSize { - width: OVERLAY_WIDTH, - height: OVERLAY_HEIGHT - })) - .has_shadow(false) - .transparent(true) - .no_activate(true) - .corner_radius(0.0) - .with_window(|w| { - w.decorations(false) - .transparent(true) - }) - .collection_behavior( - CollectionBehavior::new() - .can_join_all_spaces() - .full_screen_auxiliary() - ) - .build() + match PanelBuilder::<_, RecordingOverlayPanel>::new(app_handle, "recording_overlay") + .url(WebviewUrl::App("src/overlay/index.html".into())) + .title("Recording") + .position(tauri::Position::Logical(tauri::LogicalPosition { x, y })) + .level(PanelLevel::Status) + .size(tauri::Size::Logical(tauri::LogicalSize { + width: OVERLAY_WIDTH, + height: OVERLAY_HEIGHT, + })) + .has_shadow(false) + .transparent(true) + .no_activate(true) + .corner_radius(0.0) + .with_window(|w| w.decorations(false).transparent(true)) + .collection_behavior( + CollectionBehavior::new() + .can_join_all_spaces() + .full_screen_auxiliary(), + ) + .build() { Ok(panel) => { let _ = panel.hide(); @@ -221,7 +218,8 @@ pub fn show_recording_overlay(app_handle: &AppHandle) { if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { // Update position before showing to prevent flicker from position changes if let Some((x, y)) = calculate_overlay_position(app_handle) { - let _ = overlay_window.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + let _ = overlay_window + .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); } let _ = overlay_window.show(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index c0331ca..1981e4c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -233,6 +233,8 @@ pub struct AppSettings { pub start_hidden: bool, #[serde(default = "default_autostart_enabled")] pub autostart_enabled: bool, + #[serde(default = "default_update_checks_enabled")] + pub update_checks_enabled: bool, #[serde(default = "default_model")] pub selected_model: String, #[serde(default = "default_always_on_microphone")] @@ -305,6 +307,10 @@ fn default_autostart_enabled() -> bool { false } +fn default_update_checks_enabled() -> bool { + true +} + fn default_selected_language() -> String { "auto".to_string() } @@ -451,6 +457,7 @@ pub fn get_default_settings() -> AppSettings { sound_theme: default_sound_theme(), start_hidden: default_start_hidden(), autostart_enabled: default_autostart_enabled(), + update_checks_enabled: default_update_checks_enabled(), selected_model: "".to_string(), always_on_microphone: false, selected_microphone: None, diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index edfdb5c..d87438a 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -18,7 +18,6 @@ pub fn init_shortcuts(app: &AppHandle) { let default_bindings = settings::get_default_settings().bindings; let user_settings = settings::load_or_create_app_settings(app); - // Register all default shortcuts, applying user customizations for (id, default_binding) in default_bindings { if id == "cancel" { @@ -284,6 +283,24 @@ pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), Str Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_update_checks_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.update_checks_enabled = enabled; + settings::write_settings(&app, settings); + + let _ = app.emit( + "settings-changed", + serde_json::json!({ + "setting": "update_checks_enabled", + "value": enabled + }), + ); + + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn update_custom_words(app: AppHandle, words: Vec) -> Result<(), String> { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index fc43e52..ee2505e 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,3 +1,4 @@ +use crate::settings; use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIcon; @@ -74,6 +75,8 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { } pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { + let settings = settings::get_settings(app); + // Platform-specific accelerators #[cfg(target_os = "macos")] let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); @@ -90,7 +93,7 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { app, "check_updates", "Check for Updates...", - true, + settings.update_checks_enabled, None::<&str>, ) .expect("failed to create check updates item"); diff --git a/src/bindings.ts b/src/bindings.ts index ffd2ef5..ebde299 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -244,6 +244,14 @@ async changeMuteWhileRecordingSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async triggerUpdateCheck() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("trigger_update_check") }; @@ -594,7 +602,7 @@ async isLaptop() : Promise> { /** 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 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; update_checks_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" diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index eb27a6d..797f36b 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -84,17 +84,15 @@ export const HandyShortcut: React.FC = ({ if (editingShortcutId && originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); - await commands.resumeBinding(editingShortcutId).catch( - console.error, - ); + await commands + .resumeBinding(editingShortcutId) + .catch(console.error); } catch (error) { console.error("Failed to restore original binding:", error); toast.error("Failed to restore original shortcut"); } } else if (editingShortcutId) { - await commands.resumeBinding(editingShortcutId).catch( - console.error, - ); + await commands.resumeBinding(editingShortcutId).catch(console.error); } setEditingShortcutId(null); setKeyPressed([]); @@ -138,9 +136,9 @@ export const HandyShortcut: React.FC = ({ try { await updateBinding(editingShortcutId, newShortcut); // Re-register the shortcut now that recording is finished - await commands.resumeBinding(editingShortcutId).catch( - console.error, - ); + await commands + .resumeBinding(editingShortcutId) + .catch(console.error); } catch (error) { console.error("Failed to change binding:", error); toast.error(`Failed to set shortcut: ${error}`); @@ -149,9 +147,9 @@ export const HandyShortcut: React.FC = ({ if (originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); - await commands.resumeBinding(editingShortcutId).catch( - console.error, - ); + await commands + .resumeBinding(editingShortcutId) + .catch(console.error); } catch (resetError) { console.error("Failed to reset binding:", resetError); toast.error("Failed to reset shortcut to original value"); @@ -177,17 +175,15 @@ export const HandyShortcut: React.FC = ({ if (editingShortcutId && originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); - await commands.resumeBinding(editingShortcutId).catch( - console.error, - ); + await commands + .resumeBinding(editingShortcutId) + .catch(console.error); } catch (error) { console.error("Failed to restore original binding:", error); toast.error("Failed to restore original shortcut"); } } else if (editingShortcutId) { - commands.resumeBinding(editingShortcutId).catch( - console.error, - ); + commands.resumeBinding(editingShortcutId).catch(console.error); } setEditingShortcutId(null); setKeyPressed([]); diff --git a/src/components/settings/UpdateChecksToggle.tsx b/src/components/settings/UpdateChecksToggle.tsx new file mode 100644 index 0000000..cf8f8ec --- /dev/null +++ b/src/components/settings/UpdateChecksToggle.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { useSettings } from "../../hooks/useSettings"; + +interface UpdateChecksToggleProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const UpdateChecksToggle: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const { getSetting, updateSetting, isUpdating } = useSettings(); + const updateChecksEnabled = getSetting("update_checks_enabled") ?? true; + + return ( + updateSetting("update_checks_enabled", enabled)} + isUpdating={isUpdating("update_checks_enabled")} + label="Check for Updates" + description="Allow Handy to automatically check for updates and enable manual checks from the footer or tray menu." + descriptionMode={descriptionMode} + grouped={grouped} + /> + ); +}; diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index dbc9a9a..2a96c62 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -11,6 +11,7 @@ import { MuteWhileRecording } from "../MuteWhileRecording"; import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; import { HandyShortcut } from "../HandyShortcut"; +import { UpdateChecksToggle } from "../UpdateChecksToggle"; import { useSettings } from "../../../hooks/useSettings"; export const DebugSettings: React.FC = () => { @@ -22,6 +23,7 @@ export const DebugSettings: React.FC = () => { + { try { const result = await commands.addPostProcessPrompt( draftName.trim(), - draftText.trim() + draftText.trim(), ); if (result.status === "ok") { await refreshSettings(); @@ -199,7 +199,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { await commands.updatePostProcessPrompt( selectedPromptId, draftName.trim(), - draftText.trim() + draftText.trim(), ); await refreshSettings(); } catch (error) { diff --git a/src/components/update-checker/UpdateChecker.tsx b/src/components/update-checker/UpdateChecker.tsx index 7b495f8..fa22050 100644 --- a/src/components/update-checker/UpdateChecker.tsx +++ b/src/components/update-checker/UpdateChecker.tsx @@ -3,6 +3,7 @@ import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import { listen } from "@tauri-apps/api/event"; import { ProgressBar } from "../shared"; +import { useSettings } from "../../hooks/useSettings"; interface UpdateCheckerProps { className?: string; @@ -16,12 +17,29 @@ const UpdateChecker: React.FC = ({ className = "" }) => { const [downloadProgress, setDownloadProgress] = useState(0); const [showUpToDate, setShowUpToDate] = useState(false); + const { settings, isLoading } = useSettings(); + const settingsLoaded = !isLoading && settings !== null; + const updateChecksEnabled = settings?.update_checks_enabled ?? false; + const upToDateTimeoutRef = useRef>(); const isManualCheckRef = useRef(false); const downloadedBytesRef = useRef(0); const contentLengthRef = useRef(0); useEffect(() => { + // Wait for settings to load before doing anything + if (!settingsLoaded) return; + + if (!updateChecksEnabled) { + if (upToDateTimeoutRef.current) { + clearTimeout(upToDateTimeoutRef.current); + } + setIsChecking(false); + setUpdateAvailable(false); + setShowUpToDate(false); + return; + } + checkForUpdates(); // Listen for update check events @@ -35,11 +53,11 @@ const UpdateChecker: React.FC = ({ className = "" }) => { } updateUnlisten.then((fn) => fn()); }; - }, []); + }, [settingsLoaded, updateChecksEnabled]); // Update checking functions const checkForUpdates = async () => { - if (isChecking) return; + if (!updateChecksEnabled || isChecking) return; try { setIsChecking(true); @@ -70,11 +88,13 @@ const UpdateChecker: React.FC = ({ className = "" }) => { }; const handleManualUpdateCheck = () => { + if (!updateChecksEnabled) return; isManualCheckRef.current = true; checkForUpdates(); }; const installUpdate = async () => { + if (!updateChecksEnabled) return; try { setIsInstalling(true); setDownloadProgress(0); @@ -119,6 +139,9 @@ const UpdateChecker: React.FC = ({ className = "" }) => { // Update status functions const getUpdateStatusText = () => { + if (!updateChecksEnabled) { + return "Update Checking Disabled"; + } if (isInstalling) { return downloadProgress > 0 && downloadProgress < 100 ? `Downloading... ${downloadProgress.toString().padStart(3)}%` @@ -133,13 +156,14 @@ const UpdateChecker: React.FC = ({ className = "" }) => { }; const getUpdateStatusAction = () => { + if (!updateChecksEnabled) return undefined; if (updateAvailable && !isInstalling) return installUpdate; if (!isChecking && !isInstalling && !updateAvailable) return handleManualUpdateCheck; return undefined; }; - const isUpdateDisabled = isChecking || isInstalling; + const isUpdateDisabled = !updateChecksEnabled || isChecking || isInstalling; const isUpdateClickable = !isUpdateDisabled && (updateAvailable || (!isChecking && !showUpToDate)); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index ce9b7ba..ec28d8e 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -76,24 +76,28 @@ const settingUpdaters: { commands.changeAudioFeedbackSetting(value as boolean), audio_feedback_volume: (value) => commands.changeAudioFeedbackVolumeSetting(value as number), - sound_theme: (value) => - commands.changeSoundThemeSetting(value as string), - start_hidden: (value) => - commands.changeStartHiddenSetting(value as boolean), + sound_theme: (value) => commands.changeSoundThemeSetting(value as string), + start_hidden: (value) => commands.changeStartHiddenSetting(value as boolean), autostart_enabled: (value) => commands.changeAutostartSetting(value as boolean), + update_checks_enabled: (value) => + commands.changeUpdateChecksSetting(value as boolean), push_to_talk: (value) => commands.changePttSetting(value as boolean), selected_microphone: (value) => commands.setSelectedMicrophone( - (value as string) === "Default" || value === null ? "default" : (value as string) + (value as string) === "Default" || value === null + ? "default" + : (value as string), ), clamshell_microphone: (value) => commands.setClamshellMicrophone( - (value as string) === "Default" ? "default" : (value as string) + (value as string) === "Default" ? "default" : (value as string), ), selected_output_device: (value) => commands.setSelectedOutputDevice( - (value as string) === "Default" || value === null ? "default" : (value as string) + (value as string) === "Default" || value === null + ? "default" + : (value as string), ), recording_retention_period: (value) => commands.updateRecordingRetentionPeriod(value as string), @@ -103,13 +107,11 @@ const settingUpdaters: { commands.changeSelectedLanguageSetting(value as string), overlay_position: (value) => commands.changeOverlayPositionSetting(value as string), - debug_mode: (value) => - commands.changeDebugModeSetting(value as boolean), + debug_mode: (value) => commands.changeDebugModeSetting(value as boolean), custom_words: (value) => commands.updateCustomWords(value as string[]), word_correction_threshold: (value) => commands.changeWordCorrectionThresholdSetting(value as number), - paste_method: (value) => - commands.changePasteMethodSetting(value as string), + paste_method: (value) => commands.changePasteMethodSetting(value as string), clipboard_handling: (value) => commands.changeClipboardHandlingSetting(value as string), history_limit: (value) => commands.updateHistoryLimit(value as string), @@ -158,10 +160,8 @@ export const useSettingsStore = create()( 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_microphone: settings.selected_microphone ?? "Default", + clamshell_microphone: settings.clamshell_microphone ?? "Default", selected_output_device: settings.selected_output_device ?? "Default", };