Add show tray icon setting (#667)

* feat: add show tray icon setting

Allow hiding system tray icon via Settings > Advanced.
When hidden, closing window quits app instead of hiding.

* i8n

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
Victor Navarro 2026-02-10 03:39:35 +01:00 committed by GitHub
parent ea4b270f85
commit f2c306eab6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 150 additions and 1 deletions

View file

@ -213,6 +213,12 @@ fn initialize_core_logic(app_handle: &AppHandle) {
// Initialize tray menu with idle state
utils::update_tray_menu(app_handle, &utils::TrayIconState::Idle, None);
// Apply show_tray_icon setting
let settings = settings::get_settings(app_handle);
if !settings.show_tray_icon {
tray::set_tray_visibility(app_handle, false);
}
// Refresh tray menu when model state changes
let app_handle_for_listener = app_handle.clone();
app_handle.listen("model-state-changed", move |_| {
@ -289,6 +295,7 @@ pub fn run() {
shortcut::change_update_checks_setting,
shortcut::change_keyboard_implementation_setting,
shortcut::get_keyboard_implementation,
shortcut::change_show_tray_icon_setting,
shortcut::handy_keys::start_handy_keys_recording,
shortcut::handy_keys::stop_handy_keys_recording,
trigger_update_check,
@ -419,6 +426,12 @@ pub fn run() {
})
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
let settings = get_settings(&window.app_handle());
// If tray icon is hidden, quit the app
if !settings.show_tray_icon {
window.app_handle().exit(0);
return;
}
api.prevent_close();
let _res = window.hide();
#[cfg(target_os = "macos")]

View file

@ -315,6 +315,8 @@ pub struct AppSettings {
pub experimental_enabled: bool,
#[serde(default)]
pub keyboard_implementation: KeyboardImplementation,
#[serde(default = "default_show_tray_icon")]
pub show_tray_icon: bool,
#[serde(default = "default_paste_delay_ms")]
pub paste_delay_ms: u64,
}
@ -396,6 +398,10 @@ fn default_app_language() -> String {
.unwrap_or_else(|| "en".to_string())
}
fn default_show_tray_icon() -> bool {
true
}
fn default_post_process_provider_id() -> String {
"openai".to_string()
}
@ -631,6 +637,7 @@ pub fn get_default_settings() -> AppSettings {
app_language: default_app_language(),
experimental_enabled: false,
keyboard_implementation: KeyboardImplementation::default(),
show_tray_icon: default_show_tray_icon(),
paste_delay_ms: default_paste_delay_ms(),
}
}

View file

@ -978,3 +978,16 @@ pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn change_show_tray_icon_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.show_tray_icon = enabled;
settings::write_settings(&app, settings);
// Apply change immediately
tray::set_tray_visibility(&app, enabled);
Ok(())
}

View file

@ -187,6 +187,15 @@ fn last_transcript_text(entry: &HistoryEntry) -> &str {
.unwrap_or(&entry.transcription_text)
}
pub fn set_tray_visibility(app: &AppHandle, visible: bool) {
let tray = app.state::<TrayIcon>();
if let Err(e) = tray.set_visible(visible) {
error!("Failed to set tray visibility: {}", e);
} else {
info!("Tray visibility set to: {}", visible);
}
}
pub fn copy_last_transcript(app: &AppHandle) {
let history_manager = app.state::<Arc<HistoryManager>>();
let entry = match history_manager.get_latest_entry() {

View file

@ -296,6 +296,14 @@ async changeKeyboardImplementationSetting(implementation: string) : Promise<Resu
async getKeyboardImplementation() : Promise<string> {
return await TAURI_INVOKE("get_keyboard_implementation");
},
async changeShowTrayIconSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_show_tray_icon_setting", { enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Start key recording mode
*/
@ -695,7 +703,7 @@ async isLaptop() : Promise<Result<boolean, string>> {
/** 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; 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?: number; 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; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; paste_delay_ms?: number }
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?: number; 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; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number }
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"

View file

@ -0,0 +1,31 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
interface ShowTrayIconProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const ShowTrayIcon: React.FC<ShowTrayIconProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const showTrayIcon = getSetting("show_tray_icon") ?? true;
return (
<ToggleSwitch
checked={showTrayIcon}
onChange={(enabled) => updateSetting("show_tray_icon", enabled)}
isUpdating={isUpdating("show_tray_icon")}
label={t("settings.advanced.showTrayIcon.label")}
description={t("settings.advanced.showTrayIcon.description")}
descriptionMode={descriptionMode}
grouped={grouped}
tooltipPosition="bottom"
/>
);
},
);

View file

@ -6,6 +6,7 @@ import { CustomWords } from "../CustomWords";
import { SettingsGroup } from "../../ui/SettingsGroup";
import { StartHidden } from "../StartHidden";
import { AutostartToggle } from "../AutostartToggle";
import { ShowTrayIcon } from "../ShowTrayIcon";
import { PasteMethodSetting } from "../PasteMethod";
import { ClipboardHandlingSetting } from "../ClipboardHandling";
import { PostProcessingToggle } from "../PostProcessingToggle";
@ -26,6 +27,7 @@ export const AdvancedSettings: React.FC = () => {
<SettingsGroup title={t("settings.advanced.groups.app")}>
<StartHidden descriptionMode="tooltip" grouped={true} />
<AutostartToggle descriptionMode="tooltip" grouped={true} />
<ShowTrayIcon descriptionMode="tooltip" grouped={true} />
<ShowOverlay descriptionMode="tooltip" grouped={true} />
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
<ExperimentalToggle descriptionMode="tooltip" grouped={true} />

View file

@ -198,6 +198,10 @@
"label": "التشغيل عند بدء التشغيل",
"description": ".بدء تشغيل Handy تلقائياً عند تسجيل الدخول إلى جهاز الكمبيوتر الخاص بك"
},
"showTrayIcon": {
"label": "إظهار أيقونة شريط النظام",
"description": ".عرض أيقونة Handy في شريط النظام"
},
"overlay": {
"title": "موقع التراكب",
"description": "عرض تراكب الملاحظات المرئية أثناء التسجيل والتفريغ. على نظام Linux يوصى بـ 'بلا'.",

View file

@ -220,6 +220,10 @@
"label": "Spouštět při startu",
"description": "Automaticky spustit Handy po přihlášení do počítače."
},
"showTrayIcon": {
"label": "Zobrazit ikonu v systémové liště",
"description": "Zobrazit ikonu Handy v systémové liště."
},
"overlay": {
"title": "Pozice překryvu",
"description": "Zobrazovat vizuální překryv během nahrávání a přepisu. Na Linuxu je doporučeno 'Žádné'.",

View file

@ -220,6 +220,10 @@
"label": "Beim Start ausführen",
"description": "Handy automatisch beim Anmelden starten."
},
"showTrayIcon": {
"label": "Taskleistensymbol anzeigen",
"description": "Das Handy-Symbol in der Taskleiste anzeigen."
},
"overlay": {
"title": "Overlay-Position",
"description": "Visuelles Feedback-Overlay während Aufnahme und Transkription anzeigen. Unter Linux wird 'Keine' empfohlen.",

View file

@ -220,6 +220,10 @@
"label": "Launch on Startup",
"description": "Automatically start Handy when you log in to your computer."
},
"showTrayIcon": {
"label": "Show Tray Icon",
"description": "Display the Handy icon in the system tray."
},
"overlay": {
"title": "Overlay Position",
"description": "Display visual feedback overlay during recording and transcription. On Linux 'None' is recommended.",

View file

@ -220,6 +220,10 @@
"label": "Iniciar al Arranque",
"description": "Iniciar Handy automáticamente cuando inicies sesión en tu computadora."
},
"showTrayIcon": {
"label": "Mostrar Icono de Bandeja",
"description": "Mostrar el icono de Handy en la bandeja del sistema."
},
"overlay": {
"title": "Posición de Superposición",
"description": "Mostrar superposición de retroalimentación visual durante la grabación y transcripción. En Linux se recomienda 'Ninguna'.",

View file

@ -220,6 +220,10 @@
"label": "Lancer au démarrage",
"description": "Démarrer automatiquement Handy lorsque vous vous connectez à votre ordinateur."
},
"showTrayIcon": {
"label": "Afficher l'icône de la barre",
"description": "Afficher l'icône de Handy dans la barre système."
},
"overlay": {
"title": "Position de la fenêtre d'enregistrement",
"description": "Afficher un retour visuel pendant l'enregistrement et la transcription. Sur Linux, 'Aucune' est recommandé.",

View file

@ -220,6 +220,10 @@
"label": "Avvia all'Accensione",
"description": "Avvia Handy automaticamente quando accedi al computer."
},
"showTrayIcon": {
"label": "Mostra icona nella barra di sistema",
"description": "Mostra l'icona di Handy nella barra di sistema."
},
"overlay": {
"title": "Posizione della Sovrimpressione",
"description": "Mostra un feedback visivo in sovrimpressione durante la registrazione e la trascrizione. Su Linux si raccomanda 'Nessuna'.",

View file

@ -220,6 +220,10 @@
"label": "起動時に実行",
"description": "コンピューターにログインしたときにHandyを自動的に起動。"
},
"showTrayIcon": {
"label": "トレイアイコンを表示",
"description": "システムトレイにHandyのアイコンを表示します。"
},
"overlay": {
"title": "オーバーレイ位置",
"description": "録音と文字起こし中に視覚的なフィードバックオーバーレイを表示。Linuxでは「なし」を推奨。",

View file

@ -220,6 +220,10 @@
"label": "시작 시 실행",
"description": "컴퓨터 로그인 시 Handy를 자동으로 시작합니다."
},
"showTrayIcon": {
"label": "트레이 아이콘 표시",
"description": "시스템 트레이에 Handy 아이콘을 표시합니다."
},
"overlay": {
"title": "오버레이 위치",
"description": "녹음 및 전사 중 시각적 피드백 오버레이를 표시합니다. Linux에서는 '없음'을 권장합니다.",

View file

@ -220,6 +220,10 @@
"label": "Uruchamiaj przy starcie",
"description": "Automatycznie uruchamiaj Handy po zalogowaniu."
},
"showTrayIcon": {
"label": "Pokaż ikonę w zasobniku",
"description": "Wyświetlaj ikonę Handy w zasobniku systemowym."
},
"overlay": {
"title": "Pozycja nakładki",
"description": "Wyświetlaj wizualną nakładkę podczas nagrywania i transkrypcji. Na Linuxie zalecane 'Brak'.",

View file

@ -220,6 +220,10 @@
"label": "Iniciar na Inicialização",
"description": "Iniciar automaticamente o Handy quando você fizer login no seu computador."
},
"showTrayIcon": {
"label": "Mostrar ícone na bandeja",
"description": "Exibir o ícone do Handy na bandeja do sistema."
},
"overlay": {
"title": "Posição da Sobreposição",
"description": "Exibir sobreposição de feedback visual durante gravação e transcrição. No Linux, 'Nenhum' é recomendado.",

View file

@ -220,6 +220,10 @@
"label": "Запуск при запуске",
"description": "Автоматически запускать Handy при входе в систему."
},
"showTrayIcon": {
"label": "Показать значок в трее",
"description": "Отображать значок Handy в системном трее."
},
"overlay": {
"title": "Позиция наложения",
"description": "Отображение наложения визуальной обратной связи во время записи и транскрипции. В Linux рекомендуется выбрать «Нет».",

View file

@ -220,6 +220,10 @@
"label": "Başlangıçta Çalıştır",
"description": "Bilgisayara giriş yaptığınızda Handy otomatik olarak başlatılır."
},
"showTrayIcon": {
"label": "Tepsi simgesini göster",
"description": "Handy simgesini sistem tepsisinde göster."
},
"overlay": {
"title": "Overlay Konumu",
"description": "Kayıt ve transkripsiyon sırasında görsel geri bildirim kaplamasını gösterir. Linux'ta 'Yok' önerilir.",

View file

@ -220,6 +220,10 @@
"label": "Запуск при старті системи",
"description": "Автоматично запускати Handy при вході в систему"
},
"showTrayIcon": {
"label": "Показати значок у треї",
"description": "Відображати значок Handy у системному треї."
},
"overlay": {
"title": "Позиція оверлею",
"description": "Показувати візуальний оверлей під час запису та транскрипції. На Linux рекомендовано «Немає»",

View file

@ -220,6 +220,10 @@
"label": "Khởi động cùng hệ thống",
"description": "Tự động khởi động Handy khi bạn đăng nhập vào máy tính."
},
"showTrayIcon": {
"label": "Hiển thị biểu tượng khay",
"description": "Hiển thị biểu tượng Handy trong khay hệ thống."
},
"overlay": {
"title": "Vị trí lớp phủ",
"description": "Hiển thị lớp phủ phản hồi trực quan trong quá trình ghi âm và chuyển đổi. Trên Linux, 'Không có' được khuyến nghị.",

View file

@ -220,6 +220,10 @@
"label": "开机启动",
"description": "登录计算机时自动启动 Handy。"
},
"showTrayIcon": {
"label": "显示托盘图标",
"description": "在系统托盘中显示 Handy 图标。"
},
"overlay": {
"title": "悬浮窗位置",
"description": "在录制和转录期间显示可视反馈悬浮窗。在 Linux 上建议选择「无」。",

View file

@ -127,6 +127,8 @@ const settingUpdaters: {
app_language: (value) => commands.changeAppLanguageSetting(value as string),
experimental_enabled: (value) =>
commands.changeExperimentalEnabledSetting(value as boolean),
show_tray_icon: (value) =>
commands.changeShowTrayIconSetting(value as boolean),
};
export const useSettingsStore = create<SettingsStore>()(