From cdf5028b5a8f2e66bf41d2fc5401c313d6900d16 Mon Sep 17 00:00:00 2001 From: Mika Krul <167889775+MikaKrul@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:52:54 +0200 Subject: [PATCH 01/49] Refactor Sidebar settings structure (#1720) * Refactor Sidebar settings structure * re arrange to my liking --------- Co-authored-by: CJ Pais --- src/components/Sidebar.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d70c8dafd1..95ec0c6190 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -38,6 +38,12 @@ export const SECTIONS_CONFIG = { component: GeneralSettings, enabled: () => true, }, + history: { + labelKey: "sidebar.history", + icon: History, + component: HistorySettings, + enabled: () => true, + }, models: { labelKey: "sidebar.models", icon: Cpu, @@ -50,12 +56,6 @@ export const SECTIONS_CONFIG = { component: AdvancedSettings, enabled: () => true, }, - history: { - labelKey: "sidebar.history", - icon: History, - component: HistorySettings, - enabled: () => true, - }, postprocessing: { labelKey: "sidebar.postProcessing", icon: Sparkles, From b462aa390f3c7af7b9ed2d3a935ed0f429c30286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20Jakob=20Schj=C3=B8th?= Date: Tue, 21 Jul 2026 12:34:27 +0200 Subject: [PATCH 02/49] feat: windows single left click on tray icon opens handy window (#369) * feat: add double-click support for tray icon * windows left click open window --------- Co-authored-by: CJ Pais --- src-tauri/src/lib.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d931216b9..cf5bdc25f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -208,7 +208,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { // Choose the appropriate initial icon based on theme let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle); - let tray = TrayIconBuilder::new() + let mut tray_builder = TrayIconBuilder::new() .icon( Image::from_path( app_handle @@ -219,8 +219,38 @@ fn initialize_core_logic(app_handle: &AppHandle) { .unwrap(), ) .tooltip(tray::tray_tooltip()) - .show_menu_on_left_click(true) - .icon_as_template(true) + .icon_as_template(true); + + // Windows notification-area convention: left click opens the app, right click + // shows the menu. Elsewhere (macOS menu bar, Linux) the menu stays on left click. + #[cfg(target_os = "windows")] + { + tray_builder = tray_builder + .show_menu_on_left_click(false) + .on_tray_icon_event(|tray, event| { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; + let opens_window = matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } | TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } + ); + if opens_window { + show_main_window(tray.app_handle()); + } + }); + } + #[cfg(not(target_os = "windows"))] + { + tray_builder = tray_builder.show_menu_on_left_click(true); + } + + let tray = tray_builder .on_menu_event(|app, event| match event.id.as_ref() { "settings" => { show_main_window(app); From f4e3587bac44eead5896bb8809076e4ce59caf00 Mon Sep 17 00:00:00 2001 From: Michael Jacobsen Date: Tue, 21 Jul 2026 13:22:58 +0200 Subject: [PATCH 03/49] Add Danish translations (#1747) --- src/i18n/languages.ts | 1 + src/i18n/locales/da/translation.json | 637 +++++++++++++++++++++++++++ 2 files changed, 638 insertions(+) create mode 100644 src/i18n/locales/da/translation.json diff --git a/src/i18n/languages.ts b/src/i18n/languages.ts index 2b6a197351..d16bf1612b 100644 --- a/src/i18n/languages.ts +++ b/src/i18n/languages.ts @@ -39,4 +39,5 @@ export const LANGUAGE_METADATA: Record< nl: { name: "Dutch", nativeName: "Nederlands", priority: 21 }, ne: { name: "Nepali", nativeName: "नेपाली", priority: 22 }, hi: { name: "Hindi", nativeName: "हिन्दी", priority: 23 }, + da: { name: "Danish", nativeName: "Dansk", priority: 24 }, }; diff --git a/src/i18n/locales/da/translation.json b/src/i18n/locales/da/translation.json new file mode 100644 index 0000000000..5feb4a9bfd --- /dev/null +++ b/src/i18n/locales/da/translation.json @@ -0,0 +1,637 @@ +{ + "tray": { + "settings": "Indstillinger...", + "checkUpdates": "Tjek for opdateringer...", + "copyLastTranscript": "Kopiér seneste transskription", + "unloadModel": "Frigør model", + "model": "Model", + "quit": "Afslut", + "cancel": "Annuller" + }, + "sidebar": { + "general": "Generelt", + "models": "Modeller", + "advanced": "Avanceret", + "postProcessing": "Efterbehandling", + "history": "Historik", + "debug": "Fejlfinding", + "about": "Om" + }, + "onboarding": { + "subtitle": "For at komme i gang skal du vælge en transskriptionsmodel", + "existingModelsTitle": "Kompatible modeller", + "downloadModelsTitle": "Tilgængelig til download", + "showAllModels": "Vis alle {{total}} modeller", + "showFewerModels": "Vis færre modeller", + "recommended": "Anbefalet", + "customModelDescription": "Ikke officielt understøttet", + "modelCard": { + "accuracy": "nøjagtighed", + "speed": "hastighed" + }, + "models": { + "small": { + "name": "Whisper Small", + "description": "Hurtig og ret nøjagtig." + }, + "medium": { + "name": "Whisper Medium", + "description": "God nøjagtighed, middel hastighed" + }, + "turbo": { + "name": "Whisper Turbo", + "description": "Afbalanceret nøjagtighed og hastighed." + }, + "large": { + "name": "Whisper Large", + "description": "God nøjagtighed, men langsom." + }, + "parakeet-tdt-0.6b-v2": { + "name": "Parakeet V2", + "description": "Kun engelsk. Den bedste model for engelsktalende." + }, + "parakeet-tdt-0.6b-v3": { + "name": "Parakeet V3", + "description": "Hurtig og nøjagtig" + }, + "moonshine-base": { + "name": "Moonshine Base", + "description": "Meget hurtig, kun engelsk. Håndterer accenter godt." + }, + "moonshine-tiny-streaming-en": { + "name": "Moonshine V2 Tiny", + "description": "Ultrahurtig, kun engelsk" + }, + "moonshine-small-streaming-en": { + "name": "Moonshine V2 Small", + "description": "Hurtig, kun engelsk. God balance mellem hastighed og nøjagtighed." + }, + "moonshine-medium-streaming-en": { + "name": "Moonshine V2 Medium", + "description": "Kun engelsk. Høj kvalitet." + }, + "breeze-asr": { + "name": "Breeze ASR", + "description": "Optimeret til taiwansk mandarin. Understøtter kodeskift." + }, + "sense-voice-int8": { + "name": "SenseVoice", + "description": "Meget hurtig. Kinesisk, engelsk, japansk, koreansk, kantonesisk." + }, + "gigaam-v3-e2e-ctc": { + "name": "GigaAM v3", + "description": "Russisk talegenkendelse. Hurtig og nøjagtig." + }, + "canary-180m-flash": { + "name": "Canary 180M Flash", + "description": "Meget hurtig. Engelsk, tysk, spansk, fransk. Understøtter oversættelse." + }, + "canary-1b-v2": { + "name": "Canary 1B v2", + "description": "Nøjagtig, flersproget. 25 europæiske sprog. Understøtter oversættelse." + }, + "cohere-int8": { + "name": "Cohere", + "description": "En stor, langsommere, men meget nøjagtig flersproget model." + } + }, + "errors": { + "selectModel": "Kunne ikke vælge model" + }, + "permissions": { + "title": "Tilladelser påkrævet", + "description": "Handy har brug for et par tilladelser for at fungere korrekt.", + "microphone": { + "title": "Mikrofonadgang", + "description": "Påkrævet for at høre din stemme til transskription." + }, + "accessibility": { + "title": "Tilgængelighedsadgang", + "description": "Påkrævet for at skrive den transskriberede tekst ind i dine programmer." + }, + "grant": "Giv tilladelse", + "granted": "Givet", + "waiting": "Venter...", + "allGranted": "Alt er klar!", + "errors": { + "checkFailed": "Kunne ikke tjekke tilladelser. Prøv igen.", + "requestFailed": "Kunne ikke anmode om tilladelse. Prøv igen." + } + } + }, + "modelSelector": { + "custom": "Brugerdefineret", + "legacy": "Forældet", + "streaming": "Streaming", + "active": "Aktiv", + "switching": "Skifter...", + "noModelsAvailable": "Ingen modeller tilgængelige", + "verifying": "Verificerer {{modelName}}...", + "verifyingGeneric": "Verificerer...", + "extracting": "Udpakker {{modelName}}...", + "extractingMultiple": "Udpakker {{count}} modeller...", + "extractingGeneric": "Udpakker...", + "downloading": "Downloader {{percentage}}%", + "downloadingMultiple": "Downloader {{count}} modeller...", + "modelReady": "Model klar", + "loading": "Indlæser {{modelName}}...", + "loadingGeneric": "Indlæser...", + "modelError": "Modelfejl", + "modelUnloaded": "Model frigjort", + "noModelDownloadRequired": "Ingen model - download påkrævet", + "deleteModel": "Slet {{modelName}}", + "downloadSpeed": "{{speed}} MB/s", + "cancel": "Annuller", + "cancelDownload": "Annuller download", + "capabilities": { + "languageSelection": "Understøtter flere inputsprog", + "singleLanguage": "Understøtter kun dette sprog", + "languageCount": "{{total}} sprog", + "languageOnly": "Kun {{language}}", + "translation": "Kan oversætte til engelsk", + "translate": "Oversæt", + "streaming": "Viser live transskription, mens du taler" + } + }, + "settings": { + "modelSettings": { + "title": "{{model}}-indstillinger" + }, + "general": { + "title": "Generelt", + "shortcut": { + "title": "Handy-genveje", + "description": "Konfigurer tastaturgenveje til at udløse tale-til-tekst-optagelse", + "loading": "Indlæser genveje...", + "none": "Ingen genveje konfigureret", + "notFound": "Genvej ikke fundet", + "pressKeys": "Tryk på ønskede taster...", + "bindings": { + "transcribe": { + "name": "Transskriptionsgenvej", + "description": "Tastaturgenvejen til at optage og transskribere din stemme." + }, + "cancel": { + "name": "Annulleringsgenvej", + "description": "Tastaturgenvejen til at annullere den aktuelle optagelse." + }, + "transcribe_with_post_process": { + "name": "Genvej til efterbehandling", + "description": "Valgfrit: En dedikeret genvej, der altid anvender AI-efterbehandling på din transskription." + } + }, + "errors": { + "restore": "Kunne ikke gendanne den oprindelige genvej", + "set": "Kunne ikke indstille genvej: {{error}}", + "reset": "Kunne ikke nulstille genvej til oprindelig indstilling" + } + }, + "language": { + "title": "Sprog", + "description": "Vælg sproget til talegenkendelse. Auto tyder automatisk sproget, mens valg af et specifikt sprog kan forbedre nøjagtigheden for det pågældende sprog.", + "searchPlaceholder": "Søg efter sprog...", + "noResults": "Ingen sprog fundet", + "auto": "Auto" + }, + "pushToTalk": { + "label": "Tryk-for-at-tale", + "description": "Hold nede for at optage, slip for at stoppe" + } + }, + "models": { + "title": "Transskriptionsmodeller", + "description": "Vælg en transskriptionsmodel, eller download flere modeller. Forskellige modeller tilbyder varierende niveauer af nøjagtighed og hastighed.", + "searchPlaceholder": "Søg efter modeller ved navn…", + "yourModels": "Downloadede modeller", + "availableModels": "Tilgængelig til download", + "deleteConfirm": "Er du sikker på, at du vil slette {{modelName}}? Du skal downloade den igen for at bruge den.", + "deleteActiveConfirm": "{{modelName}} er din aktive model. Sletning af den vil stoppe transskriptioner, indtil du vælger en ny model. Er du sikker?", + "deleteTitle": "Slet model", + "filters": { + "allLanguages": "Alle sprog" + }, + "rescan": { + "label": "Søg igen", + "tooltip": "Søg igen efter modeller, der er tilføjet til modelmappen eller Hugging Face-cachen uden for Handy" + }, + "noModelsMatch": "Ingen modeller matcher dette filter." + }, + "sound": { + "title": "Lyd", + "microphone": { + "title": "Mikrofon", + "description": "Vælg din foretrukne mikrofonenhed", + "placeholder": "Vælg mikrofon...", + "loading": "Indlæser..." + }, + "audioFeedback": { + "label": "Lydfeedback", + "description": "Afspil lyd, når optagelse starter og stopper" + }, + "outputDevice": { + "title": "Outputenhed", + "description": "Vælg din foretrukne lydoutputenhed til feedbacklyde", + "placeholder": "Vælg outputenhed...", + "loading": "Indlæser..." + }, + "volume": { + "title": "Lydstyrke", + "description": "Juster lydstyrken for lydfeedback" + } + }, + "advanced": { + "groups": { + "app": "App", + "output": "Output", + "transcription": "Transskription", + "history": "Historik", + "experimental": "Eksperimentel" + }, + "experimentalToggle": { + "label": "Eksperimentelle funktioner", + "description": "Aktivér eksperimentelle funktioner, der stadig er under udvikling." + }, + "lazyStreamClose": { + "label": "Hold mikrofon åben mellem transskriptioner", + "description": "Holder mikrofonstrømmen åben i 30 sekunder, efter at optagelsen stopper, hvilket reducerer ventetid ved transskriptioner lige efter hinanden. Kan forringe Bluetooth-lydkvaliteten, mens den er aktiv." + }, + "acceleration": { + "transcribe": { + "title": "transcribe.cpp-acceleration", + "description": "Hardwareacceleration til transcribe.cpp-modeller (Whisper-familien). Auto bruger GPU, hvis tilgængelig (Metal på macOS, Vulkan på Windows/Linux)." + }, + "ort": { + "title": "ONNX-acceleration", + "description": "Hardwareacceleration til ONNX-modeller (Parakeet, Canary, Moonshine osv.). DirectML på Windows er eksperimentelt. Modeller kan fejle under transskription." + }, + "gpuDevice": { + "auto": "Auto" + } + }, + "startHidden": { + "label": "Start skjult", + "description": "Vis på meddelelsesområdet uden at åbne vinduet." + }, + "autostart": { + "label": "Start ved opstart", + "description": "Start Handy automatisk, når du logger ind på din computer." + }, + "showTrayIcon": { + "label": "Vis proceslinjeikon", + "description": "Vis Handy-ikonet i meddelelsesområdet." + }, + "overlay": { + "style": { + "title": "Overlay", + "description": "Vælg optagelses-overlay: Ingen skjuler det, Minimal viser en kompakt pille, Live viser transskription i realtid, mens du taler (kun modeller med streaming-understøttelse — se efter Streaming-mærket i modelvælgeren). På Linux anbefales 'Ingen'.", + "options": { + "none": "Ingen", + "minimal": "Minimal", + "live": "Live" + } + }, + "position": { + "title": "Overlay-placering", + "description": "Hvor overlayet vises på skærmen under optagelse og transskription.", + "options": { + "bottom": "Bund", + "top": "Top" + } + } + }, + "pasteMethod": { + "title": "Indsætningsmetode", + "description": "Vælg, hvordan tekst indsættes. Direkte: simulerer tastetryk via systeminput. Ingen: springer indsætning over, opdaterer kun historik/udklipsholder.", + "options": { + "clipboard": "Udklipsholder ({{modifier}}+V)", + "clipboardCtrlShiftV": "Udklipsholder (Ctrl+Shift+V)", + "clipboardShiftInsert": "Udklipsholder (Shift+Insert)", + "direct": "Direkte", + "none": "Ingen", + "externalScript": "Eksternt script" + }, + "externalScriptPlaceholder": "/sti/til/dit/script.sh" + }, + "typingTool": { + "title": "Skriveværktøj", + "description": "Vælg hvilket Linux-skriveværktøj der skal bruges til Direkte indsætningsmetode. Auto registrerer og bruger automatisk det bedste tilgængelige værktøj til dit system.", + "options": { + "auto": "Auto (anbefalet)" + } + }, + "clipboardHandling": { + "title": "Håndtering af udklipsholder", + "description": "Rør ikke ved udklipsholder bevarer dit nuværende udklipsholderindhold efter transskription. Kopiér til udklipsholder efterlader transskriptionsresultatet i din udklipsholder efter indsætning.", + "options": { + "dontModify": "Rør ikke ved udklipsholder", + "copyToClipboard": "Kopiér til udklipsholder" + } + }, + "autoSubmit": { + "title": "Automatisk indsendelse", + "description": "Send automatisk den valgte tastekombination efter tekstindsættelse. Cmd+Enter gælder på macOS, mens Windows/Linux bruger Super+Enter.", + "options": { + "off": "Fra", + "enter": "Enter", + "cmdEnter": "Cmd+Enter", + "superEnter": "Super+Enter", + "ctrlEnter": "Ctrl+Enter" + } + }, + "translateToEnglish": { + "label": "Oversæt til engelsk", + "description": "Oversæt automatisk tale fra andre sprog til engelsk under transskription." + }, + "modelUnload": { + "title": "Frigør model", + "description": "Frigør automatisk GPU-/CPU-hukommelse, når modellen ikke har været brugt i den angivne tid", + "options": { + "never": "Aldrig", + "immediately": "Med det samme", + "min2": "Efter 2 minutter", + "min5": "Efter 5 minutter", + "min10": "Efter 10 minutter", + "min15": "Efter 15 minutter", + "hour1": "Efter 1 time", + "sec15": "Efter 15 sekunder (fejlfinding)" + } + }, + "customWords": { + "title": "Brugerdefinerede ord", + "description": "Hjælp understøttede modeller med at genkende navne og specialiserede udtryk. Fuzzy-korrektion er i øjeblikket begrænset til ord, der bruger A-Z og tal.", + "placeholder": "Tilføj et ord", + "add": "Tilføj", + "remove": "Fjern {{word}}", + "duplicate": "\"{{word}}\" findes allerede" + }, + "voiceActivityDetection": { + "title": "Stemmeaktivitetsdetektion", + "description": "Filtrer stilhed fra optagelser. Streaming-modeller bruger en længere VAD-hale; deaktivering af VAD optager rå lyd." + } + }, + "postProcessing": { + "hotkey": { + "title": "Genvejstast" + }, + "api": { + "title": "API (OpenAI-kompatibel)", + "provider": { + "title": "Udbyder", + "description": "Vælg en OpenAI-kompatibel udbyder." + }, + "appleIntelligence": { + "unavailable": "Apple Intelligence er ikke tilgængelig på denne enhed. Kræver en Apple Silicon Mac med macOS Tahoe (26.0) eller nyere med Apple Intelligence aktiveret i Systemindstillinger." + }, + "baseUrl": { + "title": "Basis-URL", + "description": "API-basis-URL for den valgte udbyder. Kun den brugerdefinerede udbyder kan redigeres.", + "placeholder": "https://api.openai.com/v1" + }, + "apiKey": { + "title": "API-nøgle", + "description": "API-nøgle til den valgte udbyder.", + "placeholder": "sk-..." + }, + "model": { + "title": "Model", + "descriptionCustom": "Angiv den modelidentifikator, som det brugerdefinerede endpoint forventer.", + "descriptionDefault": "Vælg en model, der udbydes af den valgte udbyder.", + "placeholderWithOptions": "Søg eller vælg en model", + "placeholderNoOptions": "Skriv et modelnavn", + "refreshModels": "Opdater modeller" + } + }, + "prompts": { + "title": "Prompt", + "selectedPrompt": { + "title": "Valgt prompt", + "description": "Vælg en skabelon til at forfine transskriptioner, eller opret en ny. Brug ${output} i promptteksten for at referere til den fangede transskription." + }, + "noPrompts": "Ingen prompts tilgængelige", + "selectPrompt": "Vælg en prompt", + "createNew": "Opret ny prompt", + "promptLabel": "Promptnavn", + "promptLabelPlaceholder": "Indtast promptnavn", + "promptInstructions": "Promptinstruktioner", + "promptInstructionsPlaceholder": "Skriv de instruktioner, der skal køres efter transskription. Eksempel: Forbedr grammatik og klarhed for følgende tekst: ${output}", + "promptTip": "Tip: Brug ${output} til at indsætte den transskriberede tekst i din prompt.", + "updatePrompt": "Opdater prompt", + "deletePrompt": "Slet prompt", + "createPrompt": "Opret prompt", + "cancel": "Annuller", + "selectToEdit": "Vælg en prompt ovenfor for at se og redigere dens detaljer.", + "createFirst": "Klik på 'Opret ny prompt' ovenfor for at oprette din første efterbehandlingsprompt." + } + }, + "history": { + "title": "Historik", + "openFolder": "Åbn optagelsesmappe", + "loading": "Indlæser historik...", + "empty": "Ingen transskriptioner endnu. Start optagelse for at opbygge din historik!", + "copyToClipboard": "Kopiér transskription til udklipsholder", + "save": "Gem transskription", + "unsave": "Fjern fra gemte", + "delete": "Slet post", + "deleteError": "Kunne ikke slette posten. Prøv igen.", + "retranscribe": "Transskribér igen", + "retranscribeError": "Kunne ikke transskribere igen. Prøv igen.", + "transcribing": "Transskriberer...", + "transcriptionFailed": "Transskription mislykkedes. Du kan transskribere igen ved hjælp af genforsøgsikonet." + }, + "debug": { + "title": "Fejlfinding", + "logDirectory": { + "title": "Logmappe", + "description": "Placering af logfiler" + }, + "logLevel": { + "title": "Logniveau", + "description": "Detaljeringsgrad for logning" + }, + "liveLogs": { + "title": "Live logs", + "description": "Stream applikationslogs i realtid for at diagnosticere problemer uden at åbne logfilen. Kun logs udsendt, mens dette panel er åbent, vises; respekterer indstillingen Logniveau ovenfor.", + "live": "Live", + "paused": "På pause", + "pause": "Pause", + "resume": "Genoptag", + "copied": "Kopieret", + "lineCount": "{{count}} linjer", + "empty": "Venter på logs… Poster vises her, når appen udsender dem." + }, + "updateChecks": { + "label": "Tjek for opdateringer", + "description": "Tjek automatisk for nye versioner af Handy" + }, + "whatsNewPreview": { + "title": "Forhåndsvisning af nyheder", + "description": "Åbn de seneste medfølgende udgivelsesnoter uden at markere dem som set", + "button": "Forhåndsvisning", + "noNotes": "Ingen medfølgende udgivelsesnoter fundet", + "error": "Kunne ikke forhåndsvise udgivelsesnoter" + }, + "soundTheme": { + "label": "Lydtema", + "description": "Vælg et lydtema til feedback ved start og stop af optagelse" + }, + "wordCorrectionThreshold": { + "title": "Tærskel for ordkorrektion", + "description": "Følsomhed for brugerdefinerede ordkorrektioner" + }, + "historyLimit": { + "title": "Historikgrænse", + "description": "Maksimalt antal historikposter, der skal beholdes", + "entries": "poster" + }, + "recordingRetention": { + "title": "Automatisk sletning af optagelser", + "description": "Slet automatisk gamle optagelser for at spare plads", + "never": "Aldrig", + "preserveLimit": "Behold de seneste {{count}}", + "days3": "Efter 3 dage", + "weeks2": "Efter 2 uger", + "months3": "Efter 3 måneder", + "placeholder": "Vælg opbevaringsperiode..." + }, + "alwaysOnMicrophone": { + "label": "Altid aktiv mikrofon", + "description": "Hold mikrofonen aktiv for hurtigere reaktion" + }, + "clamshellMicrophone": { + "title": "Clamshell-mikrofon", + "description": "Mikrofon, der skal bruges, når den bærbares låg er lukket" + }, + "postProcessingToggle": { + "label": "Efterbehandling", + "description": "Aktivér AI-drevet tekstforbedring efter transskription" + }, + "muteWhileRecording": { + "label": "Mute under optagelse", + "description": "Sluk for systemlyd under optagelse" + }, + "appendTrailingSpace": { + "label": "Tilføj efterfølgende mellemrum", + "description": "Tilføj et mellemrum efter indsat transskription" + }, + "keyboardImplementation": { + "title": "Tastaturimplementering", + "description": "Vælg backend til tastaturgenveje.", + "bindingsReset": "Tastaturgenveje var inkompatible og blev nulstillet til standard" + }, + "paths": { + "appData": "Appdata:", + "models": "Modeller:", + "settings": "Indstillinger:" + }, + "pasteDelay": { + "title": "Forsinkelse af indsættelse (før)", + "description": "Forsinkelse (i millisekunder) efter kopiering af tekst, før tastetrykket for indsætning sendes. Øg, hvis intet bliver indsat." + }, + "pasteDelayAfter": { + "title": "Forsinkelse af indsættelse (efter)", + "description": "Forsinkelse (i millisekunder) efter tastetrykket for indsætning, før din tidligere udklipsholder gendannes. Øg, hvis dit gamle udklipsholderindhold bliver indsat i stedet for transskriptionen." + }, + "recordingBuffer": { + "title": "Ekstra optagelsesbuffer", + "description": "Ekstra tid (i millisekunder) til at fortsætte optagelsen, efter du slipper tasten, for at fange efterfølgende lyd. 0 = ingen ekstra buffer." + } + }, + "about": { + "title": "Om", + "version": { + "title": "Version", + "description": "Nuværende version af Handy" + }, + "whatsNewUpdates": { + "label": "Vis nyheder", + "description": "Vis udgivelsesnoter, efter Handy opdateres" + }, + "appDataDirectory": { + "title": "Appdatamappe", + "description": "Placering, hvor Handy gemmer sine data" + }, + "sourceCode": { + "title": "Kildekode", + "description": "Se kildekode og bidrag", + "button": "Vis på GitHub" + }, + "supportDevelopment": { + "title": "Støt udviklingen", + "description": "Hjælp os med at fortsætte udviklingen af Handy", + "button": "Donér" + }, + "acknowledgments": { + "title": "Anerkendelser", + "ggml": { + "title": "ggml", + "description": "Højtydende tensor-bibliotek til maskinlæringsinferens på enheden", + "details": "Handys lokale tale-til-tekst funktionalitet er bygget på transcribe.cpp og ggml. Tak for det fantastiske arbejde af Georgi Gerganov og bidragyderne." + } + } + } + }, + "footer": { + "checkingUpdates": "Tjekker for opdateringer...", + "updateAvailableShort": "Opdatering tilgængelig", + "upToDate": "Opdateret", + "updateCheckingDisabled": "Tjek for opdateringer deaktiveret", + "downloading": "Downloader... {{progress}}%", + "installing": "Installerer...", + "preparing": "Forbereder...", + "checkForUpdates": "Tjek for opdateringer", + "portableUpdateTitle": "Manuel opdatering påkrævet", + "portableUpdateMessage": "Flytbare installationer kan ikke opdateres automatisk. For at opdatere: download den nyeste NSIS-installer fra GitHub Releases, installer den i den samme mappe, og kopiér derefter din Data/-mappe (indstillinger, modeller, optagelser) fra den gamle version til den nye.", + "portableUpdateButton": "Åbn GitHub Releases" + }, + "whatsNew": { + "title": "Nyt i Handy v{{version}}" + }, + "common": { + "loading": "Indlæser...", + "delete": "Slet", + "close": "Luk", + "open": "Åbn", + "copy": "Kopiér", + "clear": "Ryd", + "noOptionsFound": "Ingen muligheder fundet" + }, + "accessibility": { + "permissionsDescription": "Handy har brug for tilgængelighedstilladelser for at skrive den transskriberede tekst.", + "openSettings": "Åbn systemindstillinger" + }, + "errors": { + "loadDirectory": "Fejl ved indlæsning af mappe: {{error}}", + "micPermissionDeniedTitle": "Mikrofonadgang nægtet", + "micPermissionDenied": { + "generic": "Mikrofonadgang blev nægtet af operativsystemet. Giv venligst mikrofontilladelse i dine systemindstillinger.", + "windows": "Aktivér mikrofonadgang under Indstillinger → Privatliv og sikkerhed → Mikrofon (inklusive adgang for skrivebordsapps).", + "macos": "Giv mikrofonadgang under Systemindstillinger → Privatliv og sikkerhed → Mikrofon.", + "linux": "Giv mikrofonadgang i dit systems lyd- eller privatlivsindstillinger." + }, + "noInputDeviceTitle": "Ingen mikrofon fundet", + "noInputDevice": "Ingen lydinputenhed blev registreret. Tilslut en mikrofon eller et headset, og prøv igen.", + "recordingFailed": "Kunne ikke starte optagelse: {{error}}", + "modelLoadFailed": "Kunne ikke indlæse model: {{model}}", + "modelLoadFailedUnknown": "ukendt model", + "pasteFailedTitle": "Kunne ikke indsætte tekst", + "pasteFailed": "Teksten kunne ikke indsættes i det aktive program.", + "transcriptionFailedTitle": "Transskription mislykkedes" + }, + "appLanguage": { + "title": "Applikationssprog", + "description": "Skift sproget for Handy-grænsefladen" + }, + "theme": { + "title": "Applikationstema", + "description": "Vælg om Handy skal følge dit systemtema eller forblive lyst eller mørkt", + "options": { + "system": "System", + "light": "Lys", + "dark": "Mørk" + } + }, + "overlay": { + "transcribing": "Transskriberer...", + "processing": "Behandler..." + } +} From 3ed2b2190fc56a998a1a3c2c95b133c2531660e7 Mon Sep 17 00:00:00 2001 From: Egor Sokolov Date: Tue, 21 Jul 2026 14:39:46 +0300 Subject: [PATCH 04/49] fix: restore clipboard images after paste (#1231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste-and-restore only ever saved plain text, so a clipboard holding a screenshot came back as an empty string — the copied image was silently destroyed by dictating. Save the image too, but only probe for one when there is no text to restore, so the common text path costs exactly what it did before and behaves identically. When the clipboard held nothing at all, clear it rather than leaving the transcription behind. Deliberately scoped to text and images via the clipboard plugin already in the tree: no new dependency, no new setting. HTML formatting and file lists still aren't preserved — they need arboard directly, and there are no reports of anyone hitting those. Fixes #921 Co-authored-by: CJ Pais Co-authored-by: Egor Sokolov <236158718+egsok@users.noreply.github.com> Co-authored-by: Nazmus Sayad <87106526+NazmusSayad@users.noreply.github.com> --- src-tauri/src/clipboard.rs | 39 ++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 5e425745b8..c822bc2f56 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -22,7 +22,15 @@ fn paste_via_clipboard( paste_delay_after_ms: u64, ) -> Result<(), String> { let clipboard = app_handle.clipboard(); - let clipboard_content = clipboard.read_text().unwrap_or_default(); + let saved_text = clipboard.read_text().ok().filter(|t| !t.is_empty()); + // Only probe for an image when there is no text to restore. Text is by far the + // common case, and reading an image decodes the full bitmap, so this keeps the + // text path exactly as cheap as it was before. + let saved_image = if saved_text.is_none() { + clipboard.read_image().ok().map(|image| image.to_owned()) + } else { + None + }; // Write text to clipboard first // On Wayland, prefer wl-copy for better compatibility (especially with umlauts) @@ -64,18 +72,29 @@ fn paste_via_clipboard( std::thread::sleep(Duration::from_millis(paste_delay_after_ms)); - // Restore original clipboard content - // On Wayland, prefer wl-copy for better compatibility - #[cfg(target_os = "linux")] - if is_wayland() && is_wl_copy_available() { - let _ = write_clipboard_via_wl_copy(&clipboard_content); - } else { + // Restore original clipboard content. + // Text takes priority so this path stays identical to the previous behavior; + // an image is only restored when the clipboard held no text at all, which is + // the case that used to silently wipe screenshots. + if let Some(clipboard_content) = saved_text { + // On Wayland, prefer wl-copy for better compatibility + #[cfg(target_os = "linux")] + if is_wayland() && is_wl_copy_available() { + let _ = write_clipboard_via_wl_copy(&clipboard_content); + } else { + let _ = clipboard.write_text(&clipboard_content); + } + + #[cfg(not(target_os = "linux"))] let _ = clipboard.write_text(&clipboard_content); + } else if let Some(image) = saved_image { + info!("Restoring image to clipboard"); + let _ = clipboard.write_image(&image); + } else { + // Nothing was there to begin with — don't leave the transcription behind. + let _ = clipboard.clear(); } - #[cfg(not(target_os = "linux"))] - let _ = clipboard.write_text(&clipboard_content); - Ok(()) } From e8c73ba700378c7275b9bbf951f3be2e1257c309 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 21 Jul 2026 20:32:23 +0800 Subject: [PATCH 05/49] update catalog --- scripts/gen_catalog.py | 15 +- src-tauri/src/catalog/catalog.json | 496 +++++++++++++++++++---------- 2 files changed, 343 insertions(+), 168 deletions(-) diff --git a/scripts/gen_catalog.py b/scripts/gen_catalog.py index f6466d4fae..9df81c97e8 100644 --- a/scripts/gen_catalog.py +++ b/scripts/gen_catalog.py @@ -90,13 +90,20 @@ def headline_wer(d): for q in ("q8_0","f16","q5_k_m","q6_k","q4_k_m","f32","bf16"): if isinstance(d, dict) and q in d: return d[q], q return None, None -def auto_desc(langn, caps): +LANG_NAMES = {"en":"English","ar":"Arabic","ja":"Japanese","ko":"Korean","ru":"Russian", + "uk":"Ukrainian","vi":"Vietnamese","zh":"Chinese"} +def auto_desc(langs, caps): feats = [] if caps["translate"]: feats.append("translation") if caps["lang_detect"]: feats.append("auto language detection") if caps["streaming"]: feats.append("streaming") if caps["timestamps"] != "none": feats.append(f"{caps['timestamps']}-level timestamps") - base = f"{langn}-language speech-to-text" if langn > 1 else "English speech-to-text" + if len(langs) > 1: + base = f"{len(langs)}-language speech-to-text" + else: + # unknown code falls back to the raw code so it's caught in diff review + lang = LANG_NAMES.get(langs[0], langs[0]) if langs else "English" + base = f"{lang} speech-to-text" return base + (" with " + ", ".join(feats) + "." if feats else ".") api = HfApi(token=os.environ.get("HF_TOKEN")) @@ -209,7 +216,7 @@ def build(repo): "architecture": gg.get("general.architecture"), "family": family(s, info.tags), "parameters": gg.get("general.size_label"), # "0.6B" / "1.7B" / "62M" - "description": cur.get("desc") or auto_desc(len(langs), caps), + "description": cur.get("desc") or auto_desc(langs, caps), "base_model": cd.get("base_model"), "license": cd.get("license"), "language_count": len(langs), @@ -240,7 +247,7 @@ def main(): print(f"catalog generation failed for {len(failures)} repo(s)", file=sys.stderr) raise SystemExit(1) models.sort(key=lambda m: (not m["recommended"], m["recommended_rank"] or 1e9, - m["family"], -(m["speed_score"] or 0))) + m["family"], -(m["speed_score"] or 0), m["slug"])) catalog = { "catalog_version": CATALOG_VERSION, "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), diff --git a/src-tauri/src/catalog/catalog.json b/src-tauri/src/catalog/catalog.json index 08b1a126c7..4d24bcaf5b 100644 --- a/src-tauri/src/catalog/catalog.json +++ b/src-tauri/src/catalog/catalog.json @@ -1,6 +1,6 @@ { "catalog_version": 1, - "generated_at": "2026-07-01T13:30:59+00:00", + "generated_at": "2026-07-21T12:23:22+00:00", "models": [ { "id": "handy-computer/parakeet-unified-en-0.6b-gguf", @@ -786,6 +786,62 @@ "recommended": false, "recommended_rank": null }, + { + "id": "handy-computer/cohere-transcribe-arabic-07-2026-gguf", + "slug": "cohere-transcribe-arabic-07-2026", + "name": "Cohere Transcribe", + "architecture": "cohere_asr", + "family": "cohere", + "parameters": "2.0B", + "description": "2-language speech-to-text.", + "base_model": "CohereLabs/cohere-transcribe-arabic-07-2026", + "license": "apache-2.0", + "language_count": 2, + "languages": ["ar", "en"], + "capabilities": { + "streaming": false, + "translate": false, + "lang_detect": false, + "timestamps": "none" + }, + "speed_score": 63, + "accuracy_score": 48, + "files": [ + { + "filename": "cohere-transcribe-arabic-07-2026-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_bytes": 1558162848 + }, + { + "filename": "cohere-transcribe-arabic-07-2026-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_bytes": 1770270112 + }, + { + "filename": "cohere-transcribe-arabic-07-2026-Q6_K.gguf", + "quant": "Q6_K", + "size_bytes": 1972524448 + }, + { + "filename": "cohere-transcribe-arabic-07-2026-Q8_0.gguf", + "quant": "Q8_0", + "size_bytes": 2410655136 + }, + { + "filename": "cohere-transcribe-arabic-07-2026-BF16.gguf", + "quant": "BF16", + "size_bytes": 4105263008 + }, + { + "filename": "cohere-transcribe-arabic-07-2026-F16.gguf", + "quant": "F16", + "size_bytes": 4106644896 + } + ], + "default_quant": "Q5_K_M", + "recommended": false, + "recommended_rank": null + }, { "id": "handy-computer/Fun-ASR-Nano-2512-gguf", "slug": "Fun-ASR-Nano-2512", @@ -1347,19 +1403,19 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-gguf", - "slug": "moonshine-tiny", - "name": "Moonshine Tiny", - "architecture": "moonshine", + "id": "handy-computer/moonshine-streaming-tiny-gguf", + "slug": "moonshine-streaming-tiny", + "name": "Moonshine Streaming Tiny", + "architecture": "moonshine_streaming", "family": "moonshine", - "parameters": "27M", - "description": "English speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny", + "parameters": "44M", + "description": "English speech-to-text with streaming.", + "base_model": "UsefulSensors/moonshine-streaming-tiny", "license": "mit", "language_count": 1, "languages": ["en"], "capabilities": { - "streaming": false, + "streaming": true, "translate": false, "lang_detect": false, "timestamps": "none" @@ -1368,19 +1424,19 @@ "accuracy_score": 74, "files": [ { - "filename": "moonshine-tiny-Q8_0.gguf", + "filename": "moonshine-streaming-tiny-Q8_0.gguf", "quant": "Q8_0", - "size_bytes": 35466912 + "size_bytes": 50462816 }, { - "filename": "moonshine-tiny-F16.gguf", + "filename": "moonshine-streaming-tiny-F16.gguf", "quant": "F16", - "size_bytes": 59244192 + "size_bytes": 89784416 }, { - "filename": "moonshine-tiny-F32.gguf", + "filename": "moonshine-streaming-tiny-F32.gguf", "quant": "F32", - "size_bytes": 109969056 + "size_bytes": 177817696 } ], "default_quant": "Q8_0", @@ -1388,19 +1444,19 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-streaming-tiny-gguf", - "slug": "moonshine-streaming-tiny", - "name": "Moonshine Streaming Tiny", - "architecture": "moonshine_streaming", + "id": "handy-computer/moonshine-tiny-gguf", + "slug": "moonshine-tiny", + "name": "Moonshine Tiny", + "architecture": "moonshine", "family": "moonshine", - "parameters": "44M", - "description": "English speech-to-text with streaming.", - "base_model": "UsefulSensors/moonshine-streaming-tiny", + "parameters": "27M", + "description": "English speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny", "license": "mit", "language_count": 1, "languages": ["en"], "capabilities": { - "streaming": true, + "streaming": false, "translate": false, "lang_detect": false, "timestamps": "none" @@ -1409,19 +1465,19 @@ "accuracy_score": 74, "files": [ { - "filename": "moonshine-streaming-tiny-Q8_0.gguf", + "filename": "moonshine-tiny-Q8_0.gguf", "quant": "Q8_0", - "size_bytes": 50462816 + "size_bytes": 35466912 }, { - "filename": "moonshine-streaming-tiny-F16.gguf", + "filename": "moonshine-tiny-F16.gguf", "quant": "F16", - "size_bytes": 89784416 + "size_bytes": 59244192 }, { - "filename": "moonshine-streaming-tiny-F32.gguf", + "filename": "moonshine-tiny-F32.gguf", "quant": "F32", - "size_bytes": 177817696 + "size_bytes": 109969056 } ], "default_quant": "Q8_0", @@ -1429,17 +1485,17 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-vi-gguf", - "slug": "moonshine-tiny-vi", - "name": "Moonshine Tiny (Vietnamese)", + "id": "handy-computer/moonshine-tiny-ar-gguf", + "slug": "moonshine-tiny-ar", + "name": "Moonshine Tiny (Arabic)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Vietnamese speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-vi", + "description": "Arabic speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-ar", "license": "mit", "language_count": 1, - "languages": ["vi"], + "languages": ["ar"], "capabilities": { "streaming": false, "translate": false, @@ -1447,20 +1503,20 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 42, + "accuracy_score": 17, "files": [ { - "filename": "moonshine-tiny-vi-Q8_0.gguf", + "filename": "moonshine-tiny-ar-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944 }, { - "filename": "moonshine-tiny-vi-F16.gguf", + "filename": "moonshine-tiny-ar-F16.gguf", "quant": "F16", "size_bytes": 59244224 }, { - "filename": "moonshine-tiny-vi-F32.gguf", + "filename": "moonshine-tiny-ar-F32.gguf", "quant": "F32", "size_bytes": 109969088 } @@ -1470,17 +1526,17 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-uk-gguf", - "slug": "moonshine-tiny-uk", - "name": "Moonshine Tiny (Ukrainian)", + "id": "handy-computer/moonshine-tiny-ja-gguf", + "slug": "moonshine-tiny-ja", + "name": "Moonshine Tiny (Japanese)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Ukrainian speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-uk", + "description": "Japanese speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-ja", "license": "mit", "language_count": 1, - "languages": ["uk"], + "languages": ["ja"], "capabilities": { "streaming": false, "translate": false, @@ -1488,20 +1544,20 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 28, + "accuracy_score": 41, "files": [ { - "filename": "moonshine-tiny-uk-Q8_0.gguf", + "filename": "moonshine-tiny-ja-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944 }, { - "filename": "moonshine-tiny-uk-F16.gguf", + "filename": "moonshine-tiny-ja-F16.gguf", "quant": "F16", "size_bytes": 59244224 }, { - "filename": "moonshine-tiny-uk-F32.gguf", + "filename": "moonshine-tiny-ja-F32.gguf", "quant": "F32", "size_bytes": 109969088 } @@ -1552,17 +1608,17 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-zh-gguf", - "slug": "moonshine-tiny-zh", - "name": "Moonshine Tiny (Chinese)", + "id": "handy-computer/moonshine-tiny-uk-gguf", + "slug": "moonshine-tiny-uk", + "name": "Moonshine Tiny (Ukrainian)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Chinese speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-zh", + "description": "Ukrainian speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-uk", "license": "mit", "language_count": 1, - "languages": ["zh"], + "languages": ["uk"], "capabilities": { "streaming": false, "translate": false, @@ -1570,20 +1626,20 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 40, + "accuracy_score": 28, "files": [ { - "filename": "moonshine-tiny-zh-Q8_0.gguf", + "filename": "moonshine-tiny-uk-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944 }, { - "filename": "moonshine-tiny-zh-F16.gguf", + "filename": "moonshine-tiny-uk-F16.gguf", "quant": "F16", "size_bytes": 59244224 }, { - "filename": "moonshine-tiny-zh-F32.gguf", + "filename": "moonshine-tiny-uk-F32.gguf", "quant": "F32", "size_bytes": 109969088 } @@ -1593,17 +1649,17 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-ar-gguf", - "slug": "moonshine-tiny-ar", - "name": "Moonshine Tiny (Arabic)", + "id": "handy-computer/moonshine-tiny-vi-gguf", + "slug": "moonshine-tiny-vi", + "name": "Moonshine Tiny (Vietnamese)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Arabic speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-ar", + "description": "Vietnamese speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-vi", "license": "mit", "language_count": 1, - "languages": ["ar"], + "languages": ["vi"], "capabilities": { "streaming": false, "translate": false, @@ -1611,20 +1667,20 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 17, + "accuracy_score": 42, "files": [ { - "filename": "moonshine-tiny-ar-Q8_0.gguf", + "filename": "moonshine-tiny-vi-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944 }, { - "filename": "moonshine-tiny-ar-F16.gguf", + "filename": "moonshine-tiny-vi-F16.gguf", "quant": "F16", "size_bytes": 59244224 }, { - "filename": "moonshine-tiny-ar-F32.gguf", + "filename": "moonshine-tiny-vi-F32.gguf", "quant": "F32", "size_bytes": 109969088 } @@ -1634,17 +1690,17 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-tiny-ja-gguf", - "slug": "moonshine-tiny-ja", - "name": "Moonshine Tiny (Japanese)", + "id": "handy-computer/moonshine-tiny-zh-gguf", + "slug": "moonshine-tiny-zh", + "name": "Moonshine Tiny (Chinese)", "architecture": "moonshine", "family": "moonshine", "parameters": "27M", - "description": "Japanese speech-to-text.", - "base_model": "UsefulSensors/moonshine-tiny-ja", + "description": "Chinese speech-to-text.", + "base_model": "UsefulSensors/moonshine-tiny-zh", "license": "mit", "language_count": 1, - "languages": ["ja"], + "languages": ["zh"], "capabilities": { "streaming": false, "translate": false, @@ -1652,20 +1708,20 @@ "timestamps": "none" }, "speed_score": 100, - "accuracy_score": 41, + "accuracy_score": 40, "files": [ { - "filename": "moonshine-tiny-ja-Q8_0.gguf", + "filename": "moonshine-tiny-zh-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944 }, { - "filename": "moonshine-tiny-ja-F16.gguf", + "filename": "moonshine-tiny-zh-F16.gguf", "quant": "F16", "size_bytes": 59244224 }, { - "filename": "moonshine-tiny-ja-F32.gguf", + "filename": "moonshine-tiny-zh-F32.gguf", "quant": "F32", "size_bytes": 109969088 } @@ -1757,17 +1813,17 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-base-ko-gguf", - "slug": "moonshine-base-ko", - "name": "Moonshine Base (Korean)", + "id": "handy-computer/moonshine-base-ja-gguf", + "slug": "moonshine-base-ja", + "name": "Moonshine Base (Japanese)", "architecture": "moonshine", "family": "moonshine", "parameters": "62M", - "description": "Korean speech-to-text.", - "base_model": "UsefulSensors/moonshine-base-ko", + "description": "Japanese speech-to-text.", + "base_model": "UsefulSensors/moonshine-base-ja", "license": "mit", "language_count": 1, - "languages": ["ko"], + "languages": ["ja"], "capabilities": { "streaming": false, "translate": false, @@ -1775,20 +1831,20 @@ "timestamps": "none" }, "speed_score": 99, - "accuracy_score": 58, + "accuracy_score": 50, "files": [ { - "filename": "moonshine-base-ko-Q8_0.gguf", + "filename": "moonshine-base-ja-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480 }, { - "filename": "moonshine-base-ko-F16.gguf", + "filename": "moonshine-base-ja-F16.gguf", "quant": "F16", "size_bytes": 131789440 }, { - "filename": "moonshine-base-ko-F32.gguf", + "filename": "moonshine-base-ja-F32.gguf", "quant": "F32", "size_bytes": 247657088 } @@ -1798,17 +1854,17 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-base-uk-gguf", - "slug": "moonshine-base-uk", - "name": "Moonshine Base (Ukrainian)", + "id": "handy-computer/moonshine-base-ko-gguf", + "slug": "moonshine-base-ko", + "name": "Moonshine Base (Korean)", "architecture": "moonshine", "family": "moonshine", "parameters": "62M", - "description": "Ukrainian speech-to-text.", - "base_model": "UsefulSensors/moonshine-base-uk", + "description": "Korean speech-to-text.", + "base_model": "UsefulSensors/moonshine-base-ko", "license": "mit", "language_count": 1, - "languages": ["uk"], + "languages": ["ko"], "capabilities": { "streaming": false, "translate": false, @@ -1816,22 +1872,22 @@ "timestamps": "none" }, "speed_score": 99, - "accuracy_score": 38, + "accuracy_score": 58, "files": [ { - "filename": "moonshine-base-uk-Q8_0.gguf", + "filename": "moonshine-base-ko-Q8_0.gguf", "quant": "Q8_0", - "size_bytes": 77476512 + "size_bytes": 77476480 }, { - "filename": "moonshine-base-uk-F16.gguf", + "filename": "moonshine-base-ko-F16.gguf", "quant": "F16", - "size_bytes": 131789472 + "size_bytes": 131789440 }, { - "filename": "moonshine-base-uk-F32.gguf", + "filename": "moonshine-base-ko-F32.gguf", "quant": "F32", - "size_bytes": 247657120 + "size_bytes": 247657088 } ], "default_quant": "Q8_0", @@ -1839,17 +1895,17 @@ "recommended_rank": null }, { - "id": "handy-computer/moonshine-base-ja-gguf", - "slug": "moonshine-base-ja", - "name": "Moonshine Base (Japanese)", + "id": "handy-computer/moonshine-base-uk-gguf", + "slug": "moonshine-base-uk", + "name": "Moonshine Base (Ukrainian)", "architecture": "moonshine", "family": "moonshine", "parameters": "62M", - "description": "Japanese speech-to-text.", - "base_model": "UsefulSensors/moonshine-base-ja", + "description": "Ukrainian speech-to-text.", + "base_model": "UsefulSensors/moonshine-base-uk", "license": "mit", "language_count": 1, - "languages": ["ja"], + "languages": ["uk"], "capabilities": { "streaming": false, "translate": false, @@ -1857,22 +1913,22 @@ "timestamps": "none" }, "speed_score": 99, - "accuracy_score": 50, + "accuracy_score": 38, "files": [ { - "filename": "moonshine-base-ja-Q8_0.gguf", + "filename": "moonshine-base-uk-Q8_0.gguf", "quant": "Q8_0", - "size_bytes": 77476480 + "size_bytes": 77476512 }, { - "filename": "moonshine-base-ja-F16.gguf", + "filename": "moonshine-base-uk-F16.gguf", "quant": "F16", - "size_bytes": 131789440 + "size_bytes": 131789472 }, { - "filename": "moonshine-base-ja-F32.gguf", + "filename": "moonshine-base-uk-F32.gguf", "quant": "F32", - "size_bytes": 247657088 + "size_bytes": 247657120 } ], "default_quant": "Q8_0", @@ -2155,6 +2211,62 @@ "recommended": false, "recommended_rank": null }, + { + "id": "handy-computer/multitalker-parakeet-streaming-0.6b-v1-gguf", + "slug": "multitalker-parakeet-streaming-0.6b-v1", + "name": "Multitalker Parakeet Streaming EN", + "architecture": "parakeet", + "family": "parakeet", + "parameters": "0.6B", + "description": "English speech-to-text with streaming, token-level timestamps.", + "base_model": "nvidia/multitalker-parakeet-streaming-0.6b-v1", + "license": "other", + "language_count": 1, + "languages": ["en"], + "capabilities": { + "streaming": true, + "translate": false, + "lang_detect": false, + "timestamps": "token" + }, + "speed_score": 96, + "accuracy_score": 86, + "files": [ + { + "filename": "multitalker-parakeet-streaming-0.6b-v1-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_bytes": 477812416 + }, + { + "filename": "multitalker-parakeet-streaming-0.6b-v1-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_bytes": 541890240 + }, + { + "filename": "multitalker-parakeet-streaming-0.6b-v1-Q6_K.gguf", + "quant": "Q6_K", + "size_bytes": 603878080 + }, + { + "filename": "multitalker-parakeet-streaming-0.6b-v1-Q8_0.gguf", + "quant": "Q8_0", + "size_bytes": 734123712 + }, + { + "filename": "multitalker-parakeet-streaming-0.6b-v1-F16.gguf", + "quant": "F16", + "size_bytes": 1246058304 + }, + { + "filename": "multitalker-parakeet-streaming-0.6b-v1-F32.gguf", + "quant": "F32", + "size_bytes": 2489180480 + } + ], + "default_quant": "Q8_0", + "recommended": false, + "recommended_rank": null + }, { "id": "handy-computer/parakeet-ctc-0.6b-gguf", "slug": "parakeet-ctc-0.6b", @@ -2547,6 +2659,62 @@ "recommended": false, "recommended_rank": null }, + { + "id": "handy-computer/moss-transcribe-diarize-gguf", + "slug": "moss-transcribe-diarize", + "name": "MOSS-Transcribe-Diarize 0.9B", + "architecture": "moss", + "family": "qwen3", + "parameters": "909M", + "description": "2-language speech-to-text with segment-level timestamps.", + "base_model": "OpenMOSS-Team/MOSS-Transcribe-Diarize", + "license": "apache-2.0", + "language_count": 2, + "languages": ["en", "zh"], + "capabilities": { + "streaming": false, + "translate": false, + "lang_detect": false, + "timestamps": "segment" + }, + "speed_score": 31, + "accuracy_score": 88, + "files": [ + { + "filename": "MOSS-Transcribe-Diarize-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_bytes": 617345184 + }, + { + "filename": "MOSS-Transcribe-Diarize-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_bytes": 700313760 + }, + { + "filename": "MOSS-Transcribe-Diarize-Q6_K.gguf", + "quant": "Q6_K", + "size_bytes": 768151712 + }, + { + "filename": "MOSS-Transcribe-Diarize-Q8_0.gguf", + "quant": "Q8_0", + "size_bytes": 986899616 + }, + { + "filename": "MOSS-Transcribe-Diarize-BF16.gguf", + "quant": "BF16", + "size_bytes": 1826882720 + }, + { + "filename": "MOSS-Transcribe-Diarize-F16.gguf", + "quant": "F16", + "size_bytes": 1833665696 + } + ], + "default_quant": "Q8_0", + "recommended": false, + "recommended_rank": null + }, { "id": "handy-computer/SenseVoiceSmall-gguf", "slug": "SenseVoiceSmall", @@ -3114,14 +3282,14 @@ "architecture": "whisper", "family": "whisper", "parameters": "809M", - "description": "100-language speech-to-text with translation, auto language detection, segment-level timestamps.", + "description": "100-language speech-to-text with auto language detection, segment-level timestamps.", "base_model": "openai/whisper-large-v3-turbo", "license": "apache-2.0", "language_count": 100, "languages": ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", "ro", "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", "is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", "sn", "yo", "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", "as", "tt", "haw", "ln", "ha", "ba", "jw", "su", "yue"], "capabilities": { "streaming": false, - "translate": true, + "translate": false, "lang_detect": true, "timestamps": "segment" }, @@ -3131,27 +3299,27 @@ { "filename": "whisper-large-v3-turbo-Q4_K_M.gguf", "quant": "Q4_K_M", - "size_bytes": 536069792 + "size_bytes": 536069728 }, { "filename": "whisper-large-v3-turbo-Q5_K_M.gguf", "quant": "Q5_K_M", - "size_bytes": 619628192 + "size_bytes": 619628128 }, { "filename": "whisper-large-v3-turbo-Q6_K.gguf", "quant": "Q6_K", - "size_bytes": 692536992 + "size_bytes": 692536928 }, { "filename": "whisper-large-v3-turbo-Q8_0.gguf", "quant": "Q8_0", - "size_bytes": 886381824 + "size_bytes": 886381760 }, { "filename": "whisper-large-v3-turbo-F16.gguf", "quant": "F16", - "size_bytes": 1636749024 + "size_bytes": 1625935520 } ], "default_quant": "Q8_0", @@ -3159,17 +3327,17 @@ "recommended_rank": null }, { - "id": "handy-computer/whisper-large-v3-gguf", - "slug": "whisper-large-v3", - "name": "Whisper Large v3", + "id": "handy-computer/Breeze-ASR-25-gguf", + "slug": "Breeze-ASR-25", + "name": "Breeze-ASR-25", "architecture": "whisper", "family": "whisper", "parameters": "1.5B", - "description": "100-language speech-to-text with translation, auto language detection, segment-level timestamps.", - "base_model": "openai/whisper-large-v3", + "description": "Optimized for Taiwanese Mandarin. Code-switching support.", + "base_model": "MediaTek-Research/Breeze-ASR-25", "license": "apache-2.0", - "language_count": 100, - "languages": ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", "ro", "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", "is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", "sn", "yo", "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", "as", "tt", "haw", "ln", "ha", "ba", "jw", "su", "yue"], + "language_count": 2, + "languages": ["zh", "en"], "capabilities": { "streaming": false, "translate": true, @@ -3177,32 +3345,37 @@ "timestamps": "segment" }, "speed_score": 23, - "accuracy_score": 89, + "accuracy_score": 86, "files": [ { - "filename": "whisper-large-v3-Q4_K_M.gguf", + "filename": "Breeze-ASR-25-Q4_K_M.gguf", "quant": "Q4_K_M", - "size_bytes": 997303008 + "size_bytes": 996526080 }, { - "filename": "whisper-large-v3-Q5_K_M.gguf", + "filename": "Breeze-ASR-25-Q5_K_M.gguf", "quant": "Q5_K_M", - "size_bytes": 1161143008 + "size_bytes": 1160366080 }, { - "filename": "whisper-large-v3-Q6_K.gguf", + "filename": "Breeze-ASR-25-Q6_K.gguf", "quant": "Q6_K", - "size_bytes": 1297130208 + "size_bytes": 1296353280 }, { - "filename": "whisper-large-v3-Q8_0.gguf", + "filename": "Breeze-ASR-25-Q8_0.gguf", "quant": "Q8_0", - "size_bytes": 1668741440 + "size_bytes": 1667964224 }, { - "filename": "whisper-large-v3-F16.gguf", + "filename": "Breeze-ASR-25-BF16.gguf", + "quant": "BF16", + "size_bytes": 3096013408 + }, + { + "filename": "Breeze-ASR-25-F16.gguf", "quant": "F16", - "size_bytes": 3107236640 + "size_bytes": 3106458208 } ], "default_quant": "Q5_K_M", @@ -3322,17 +3495,17 @@ "recommended_rank": null }, { - "id": "handy-computer/Breeze-ASR-25-gguf", - "slug": "Breeze-ASR-25", - "name": "Breeze-ASR-25", + "id": "handy-computer/whisper-large-v3-gguf", + "slug": "whisper-large-v3", + "name": "Whisper Large v3", "architecture": "whisper", "family": "whisper", "parameters": "1.5B", - "description": "Optimized for Taiwanese Mandarin. Code-switching support.", - "base_model": "MediaTek-Research/Breeze-ASR-25", + "description": "100-language speech-to-text with translation, auto language detection, segment-level timestamps.", + "base_model": "openai/whisper-large-v3", "license": "apache-2.0", - "language_count": 2, - "languages": ["zh", "en"], + "language_count": 100, + "languages": ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca", "nl", "ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", "ro", "da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy", "sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu", "is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km", "sn", "yo", "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo", "uz", "fo", "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg", "as", "tt", "haw", "ln", "ha", "ba", "jw", "su", "yue"], "capabilities": { "streaming": false, "translate": true, @@ -3340,37 +3513,32 @@ "timestamps": "segment" }, "speed_score": 23, - "accuracy_score": 86, + "accuracy_score": 89, "files": [ { - "filename": "Breeze-ASR-25-Q4_K_M.gguf", + "filename": "whisper-large-v3-Q4_K_M.gguf", "quant": "Q4_K_M", - "size_bytes": 996526080 + "size_bytes": 997303008 }, { - "filename": "Breeze-ASR-25-Q5_K_M.gguf", + "filename": "whisper-large-v3-Q5_K_M.gguf", "quant": "Q5_K_M", - "size_bytes": 1160366080 + "size_bytes": 1161143008 }, { - "filename": "Breeze-ASR-25-Q6_K.gguf", + "filename": "whisper-large-v3-Q6_K.gguf", "quant": "Q6_K", - "size_bytes": 1296353280 + "size_bytes": 1297130208 }, { - "filename": "Breeze-ASR-25-Q8_0.gguf", + "filename": "whisper-large-v3-Q8_0.gguf", "quant": "Q8_0", - "size_bytes": 1667964224 - }, - { - "filename": "Breeze-ASR-25-BF16.gguf", - "quant": "BF16", - "size_bytes": 3096013408 + "size_bytes": 1668741440 }, { - "filename": "Breeze-ASR-25-F16.gguf", + "filename": "whisper-large-v3-F16.gguf", "quant": "F16", - "size_bytes": 3106458208 + "size_bytes": 3107236640 } ], "default_quant": "Q5_K_M", @@ -3378,4 +3546,4 @@ "recommended_rank": null } ] -} +} \ No newline at end of file From e1152d86935562264d7c5637d77ce2e29aeef57e Mon Sep 17 00:00:00 2001 From: Deepak Thapa <137135921+Curious-Ray@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:37:15 +0545 Subject: [PATCH 06/49] fix: place Windows overlay on secondary monitors (#1733) * fix: place Windows overlay on secondary monitors * fixes which work on my machine --------- Co-authored-by: CJ Pais --- src-tauri/src/overlay.rs | 274 ++++++++++++++++++++++++++++++++++----- 1 file changed, 243 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index c4e4be1f8e..d157ebe70f 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -171,25 +171,29 @@ fn get_monitor_with_cursor(app_handle: &AppHandle) -> Option { if let Some(mouse_location) = input::get_cursor_position(app_handle) { if let Ok(monitors) = app_handle.available_monitors() { for monitor in monitors { - // Tauri's monitor position/size are physical pixels, but enigo - // may return logical coordinates (confirmed on macOS via - // NSEvent::mouseLocation; on Windows, GetCursorPos behavior - // depends on the process DPI-awareness context). Dividing by - // scale_factor normalizes to logical, which is safe regardless: - // if enigo returns logical it matches directly, and if it returns - // physical on a scale=1 monitor the division is a no-op. - let scale = monitor.scale_factor(); - let pos = PhysicalPosition::new( - (monitor.position().x as f64 / scale) as i32, - (monitor.position().y as f64 / scale) as i32, - ); - let size = PhysicalSize::new( - (monitor.size().width as f64 / scale) as u32, - (monitor.size().height as f64 / scale) as u32, - ); - if is_mouse_within_monitor(mouse_location, &pos, &size) { + // On Windows both the cursor (enigo -> GetCursorPos) and the + // monitor bounds are physical pixels, so compare them directly. + #[cfg(target_os = "windows")] + if is_mouse_within_monitor(mouse_location, monitor.position(), monitor.size()) { return Some(monitor); } + + // macOS/Linux: enigo returns logical coords, so scale the bounds down. + #[cfg(not(target_os = "windows"))] + { + let scale = monitor.scale_factor(); + let pos = PhysicalPosition::new( + (monitor.position().x as f64 / scale) as i32, + (monitor.position().y as f64 / scale) as i32, + ); + let size = PhysicalSize::new( + (monitor.size().width as f64 / scale) as u32, + (monitor.size().height as f64 / scale) as u32, + ); + if is_mouse_within_monitor(mouse_location, &pos, &size) { + return Some(monitor); + } + } } } } @@ -229,7 +233,8 @@ fn is_mouse_within_monitor( /// /// We must use LogicalPosition (not PhysicalPosition) because Tauri/tao /// converts PhysicalPosition using the scale factor of the monitor the window -/// is *currently* on, which is wrong when moving cross-monitor. +/// is *currently* on, which is wrong when moving cross-monitor. Windows uses +/// `place_windows_overlay` instead (no single logical space across mixed DPI). fn calculate_overlay_position( app_handle: &AppHandle, width: f64, @@ -266,12 +271,93 @@ fn calculate_overlay_position( /// Current overlay window size in logical units (points), for repositioning /// without assuming a fixed size (compact vs. streaming). +#[cfg(not(target_os = "windows"))] fn current_overlay_logical_size(window: &tauri::webview::WebviewWindow) -> Option<(f64, f64)> { let size = window.inner_size().ok()?; let scale = window.scale_factor().ok()?; Some((size.width as f64 / scale, size.height as f64 / scale)) } +#[cfg(target_os = "windows")] +static WINDOWS_OVERLAY_IS_STREAMING: AtomicBool = AtomicBool::new(false); + +/// Overlay rectangle in the destination monitor's physical pixels, so nothing +/// is converted through the window's previous-monitor DPI. +#[cfg(target_os = "windows")] +fn windows_overlay_bounds( + monitor_position: PhysicalPosition, + monitor_size: PhysicalSize, + scale: f64, + logical_width: f64, + logical_height: f64, + overlay_position: OverlayPosition, +) -> (i32, i32, i32, i32) { + let width = (logical_width * scale).round().max(1.0) as i32; + let height = (logical_height * scale).round().max(1.0) as i32; + let x = (monitor_position.x as f64 + (monitor_size.width as f64 - width as f64) / 2.0).round() + as i32; + let y = match overlay_position { + OverlayPosition::Top => { + (monitor_position.y as f64 + OVERLAY_TOP_OFFSET * scale).round() as i32 + } + OverlayPosition::Bottom => (monitor_position.y as f64 + monitor_size.height as f64 + - height as f64 + - OVERLAY_BOTTOM_OFFSET * scale) + .round() as i32, + }; + + (x, y, width, height) +} + +/// Moves and sizes the overlay in one native SetWindowPos, bypassing tao's +/// current-DPI logical conversion that mislands cross-monitor moves. +#[cfg(target_os = "windows")] +fn place_windows_overlay( + app_handle: &AppHandle, + overlay_window: &tauri::webview::WebviewWindow, + logical_width: f64, + logical_height: f64, +) -> Result<(), String> { + use windows::Win32::UI::WindowsAndMessaging::{SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER}; + + let monitor = get_monitor_with_cursor(app_handle) + .ok_or_else(|| "failed to determine the monitor containing the cursor".to_string())?; + let (x, y, width, height) = windows_overlay_bounds( + *monitor.position(), + *monitor.size(), + monitor.scale_factor(), + logical_width, + logical_height, + settings::get_settings(app_handle).overlay_position, + ); + let hwnd = overlay_window + .hwnd() + .map_err(|error| format!("failed to get overlay window handle: {error}"))?; + + unsafe { + SetWindowPos( + hwnd, + None, + x, + y, + width, + height, + SWP_NOACTIVATE | SWP_NOZORDER, + ) + .map_err(|error| format!("failed to set overlay bounds: {error}"))?; + } + + log::debug!( + "windows overlay bounds: x={} y={} width={} height={} scale={}", + x, + y, + width, + height, + monitor.scale_factor() + ); + Ok(()) +} + /// Creates the recording overlay window and keeps it hidden by default #[cfg(not(target_os = "macos"))] pub fn create_recording_overlay(app_handle: &AppHandle) { @@ -387,17 +473,31 @@ fn show_overlay_state(app_handle: &AppHandle, state: &str) { update_gtk_layer_shell_anchors(&overlay_window); let size_started = std::time::Instant::now(); + #[cfg(not(target_os = "windows"))] let _ = overlay_window.set_size(tauri::Size::Logical(tauri::LogicalSize { width, height })); + #[cfg(target_os = "windows")] + WINDOWS_OVERLAY_IS_STREAMING.store(state == "streaming", Ordering::Relaxed); let size_elapsed = size_started.elapsed(); let pos_started = std::time::Instant::now(); - let mut set_pos_elapsed = std::time::Duration::ZERO; - if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) { + #[cfg(not(target_os = "windows"))] + let set_pos_elapsed = + if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) { + let set_pos_started = std::time::Instant::now(); + let _ = overlay_window + .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + set_pos_started.elapsed() + } else { + std::time::Duration::ZERO + }; + #[cfg(target_os = "windows")] + let set_pos_elapsed = { let set_pos_started = std::time::Instant::now(); - let _ = overlay_window - .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); - set_pos_elapsed = set_pos_started.elapsed(); - } + if let Err(error) = place_windows_overlay(app_handle, &overlay_window, width, height) { + log::error!("Failed to place recording overlay: {error}"); + } + set_pos_started.elapsed() + }; let pos_calc_elapsed = pos_started.elapsed() - set_pos_elapsed; let show_started = std::time::Instant::now(); @@ -408,6 +508,13 @@ fn show_overlay_state(app_handle: &AppHandle, state: &str) { #[cfg(target_os = "windows")] force_overlay_topmost(&overlay_window); + // Re-assert bounds after show(): the pre-show move crosses the DPI + // boundary, and tao's WM_DPICHANGED reflow clobbers the first placement. + #[cfg(target_os = "windows")] + if let Err(error) = place_windows_overlay(app_handle, &overlay_window, width, height) { + log::error!("Failed to re-assert recording overlay position: {error}"); + } + let _ = overlay_window.emit("show-overlay", state); log::debug!( "overlay '{}': set_size={:?} pos_calc={:?} set_pos={:?} show={:?}", @@ -448,13 +555,29 @@ pub fn update_overlay_position(app_handle: &AppHandle) { update_gtk_layer_shell_anchors(&overlay_window); } - // Use the window's current size so centering stays correct whether the - // overlay is in compact or streaming layout. - let (width, height) = current_overlay_logical_size(&overlay_window) - .unwrap_or((OVERLAY_WIDTH, OVERLAY_HEIGHT)); - if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) { - let _ = overlay_window - .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + #[cfg(target_os = "windows")] + { + let state = if WINDOWS_OVERLAY_IS_STREAMING.load(Ordering::Relaxed) { + "streaming" + } else { + "recording" + }; + let (width, height) = overlay_dimensions(state); + if let Err(error) = place_windows_overlay(app_handle, &overlay_window, width, height) { + log::error!("Failed to update recording overlay position: {error}"); + } + } + + #[cfg(not(target_os = "windows"))] + { + // Use the window's current size so centering stays correct whether the + // overlay is in compact or streaming layout. + let (width, height) = current_overlay_logical_size(&overlay_window) + .unwrap_or((OVERLAY_WIDTH, OVERLAY_HEIGHT)); + if let Some((x, y)) = calculate_overlay_position(app_handle, width, height) { + let _ = overlay_window + .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + } } } } @@ -526,3 +649,92 @@ pub fn emit_levels(app_handle: &AppHandle, levels: &[f32]) { // dispatch work in half. let _ = app_handle.emit_to("recording_overlay", "mic-level", levels); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monitor_hit_test_uses_half_open_physical_bounds() { + let position = PhysicalPosition::new(-2560, -200); + let size = PhysicalSize::new(2560, 1440); + + assert!(is_mouse_within_monitor((-2560, -200), &position, &size)); + assert!(is_mouse_within_monitor((-1, 1239), &position, &size)); + assert!(!is_mouse_within_monitor((0, 0), &position, &size)); + assert!(!is_mouse_within_monitor((-1, 1240), &position, &size)); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_cursor_hit_test_does_not_scale_physical_monitor_bounds() { + let position = PhysicalPosition::new(1920, 0); + let size = PhysicalSize::new(3840, 2160); + let cursor = (5000, 1000); + + assert!(is_mouse_within_monitor(cursor, &position, &size)); + + // This is the old mixed-coordinate comparison. It excludes a cursor + // that is visibly inside a secondary display running at 150%. + let scale = 1.5; + let logical_position = PhysicalPosition::new( + (position.x as f64 / scale) as i32, + (position.y as f64 / scale) as i32, + ); + let logical_size = PhysicalSize::new( + (size.width as f64 / scale) as u32, + (size.height as f64 / scale) as u32, + ); + assert!(!is_mouse_within_monitor( + cursor, + &logical_position, + &logical_size + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_overlay_bounds_use_destination_monitor_scale() { + let monitor_position = PhysicalPosition::new(1920, 0); + let monitor_size = PhysicalSize::new(3840, 2160); + + assert_eq!( + windows_overlay_bounds( + monitor_position, + monitor_size, + 1.5, + OVERLAY_WIDTH, + OVERLAY_HEIGHT, + OverlayPosition::Bottom, + ), + (3648, 2031, 384, 69) + ); + assert_eq!( + windows_overlay_bounds( + monitor_position, + monitor_size, + 1.5, + OVERLAY_WIDTH, + OVERLAY_HEIGHT, + OverlayPosition::Top, + ), + (3648, 6, 384, 69) + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_overlay_bounds_support_negative_monitor_origins() { + assert_eq!( + windows_overlay_bounds( + PhysicalPosition::new(-2560, -200), + PhysicalSize::new(2560, 1440), + 1.25, + OVERLAY_STREAM_WIDTH, + OVERLAY_STREAM_HEIGHT, + OverlayPosition::Bottom, + ), + (-1530, 1040, 500, 150) + ); + } +} From 8a362e9eba59d4057fda79b7f38f5b0d5cbabf65 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Wed, 22 Jul 2026 17:03:01 +0800 Subject: [PATCH 07/49] fix mute bug (#1760) --- src-tauri/src/managers/audio.rs | 199 ++++++++++++++++++++++++++++---- 1 file changed, 179 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index ddcd6c0bc5..dc8ef9f189 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -109,6 +109,127 @@ fn set_mute(mute: bool) { } } +/// Reads the current system output mute state, mirroring `set_mute`'s backends. +/// +/// Returns `Some(true)`/`Some(false)` when the state could be determined, or +/// `None` when it couldn't (unsupported platform, missing CLI tools, or an +/// error). Callers treat `None` as "unknown" and fall back to unmuting on stop, +/// so we never strand the user's audio muted. +#[cfg(target_os = "windows")] +fn get_mute() -> Option { + unsafe { + use windows::Win32::{ + Media::Audio::{ + eMultimedia, eRender, Endpoints::IAudioEndpointVolume, IMMDeviceEnumerator, + MMDeviceEnumerator, + }, + System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED}, + }; + + // Matches set_mute: no-op if COM is already initialized on this thread. + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + + let all_devices: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL).ok()?; + let default_device = all_devices + .GetDefaultAudioEndpoint(eRender, eMultimedia) + .ok()?; + let volume_interface = default_device + .Activate::(CLSCTX_ALL, None) + .ok()?; + + Some(volume_interface.GetMute().ok()?.as_bool()) + } +} + +#[cfg(target_os = "linux")] +fn get_mute() -> Option { + use std::process::Command; + + // 1. PipeWire (wpctl): prints "[MUTED]" in the volume line when muted. + if let Ok(out) = Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + { + if out.status.success() { + return Some(String::from_utf8_lossy(&out.stdout).contains("[MUTED]")); + } + } + + // 2. PulseAudio (pactl): prints "Mute: yes" / "Mute: no". + // Force LC_ALL=C so a localized system still emits the parseable English + // "yes"/"no" instead of e.g. "ja"/"nein". + if let Ok(out) = Command::new("pactl") + .env("LC_ALL", "C") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).to_lowercase(); + if s.contains("yes") { + return Some(true); + } + if s.contains("no") { + return Some(false); + } + } + } + + // 3. ALSA (amixer): prints "[off]" for muted channels, "[on]" otherwise. + // LC_ALL=C keeps the "[on]"/"[off]" tokens stable across locales. + if let Ok(out) = Command::new("amixer") + .env("LC_ALL", "C") + .args(["get", "Master"]) + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + if s.contains("[off]") { + return Some(true); + } + if s.contains("[on]") { + return Some(false); + } + } + } + + None +} + +#[cfg(target_os = "macos")] +fn get_mute() -> Option { + use std::process::Command; + + let out = Command::new("osascript") + .args(["-e", "output muted of (get volume settings)"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + match String::from_utf8_lossy(&out.stdout).trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] +fn get_mute() -> Option { + None +} + +/// Restores the system mute state after our forced mute, given the state +/// captured just before we muted. We only ever need to unmute — and only when +/// the system was NOT already muted beforehand. If the prior state was muted, +/// we leave it muted (the user's own state). If it's unknown (`None`), we +/// default to unmuting so audio is never left stranded muted by us. +fn restore_mute(prev_muted: Option) { + if prev_muted != Some(true) { + set_mute(false); + } +} + const WHISPER_SAMPLE_RATE: usize = 16000; /* ──────────────────────────────────────────────────────────────── */ @@ -126,6 +247,16 @@ pub enum MicrophoneMode { OnDemand, } +/// Tracks our forced "mute while recording" so we can restore the user's audio +/// exactly as it was. `did_mute` is true while our mute is active; `prev_muted` +/// is the system mute state captured just before we muted, used to decide +/// whether to unmute on stop (so a system that was already muted stays muted). +#[derive(Debug, Default, Clone, Copy)] +struct MuteState { + did_mute: bool, + prev_muted: Option, +} + /* ──────────────────────────────────────────────────────────────── */ fn create_audio_recorder( @@ -182,7 +313,7 @@ pub struct AudioRecordingManager { recorder: Arc>>, is_open: Arc>, is_recording: Arc>, - did_mute: Arc>, + mute_state: Arc>, close_generation: Arc, cancel_generation: Arc, stream_router: Arc, @@ -217,7 +348,7 @@ impl AudioRecordingManager { recorder: Arc::new(Mutex::new(None)), is_open: Arc::new(Mutex::new(false)), is_recording: Arc::new(Mutex::new(false)), - did_mute: Arc::new(Mutex::new(false)), + mute_state: Arc::new(Mutex::new(MuteState::default())), close_generation: Arc::new(AtomicU64::new(0)), cancel_generation: Arc::new(AtomicU64::new(0)), stream_router, @@ -324,25 +455,43 @@ impl AudioRecordingManager { /* ---------- microphone life-cycle -------------------------------------- */ - /// Applies mute if mute_while_recording is enabled and stream is open + /// Applies mute if mute_while_recording is enabled and stream is open. + /// Snapshots the system's prior mute state first so `remove_mute` can + /// restore it instead of unconditionally unmuting. pub fn apply_mute(&self) { let settings = get_settings(&self.app_handle); - let mut did_mute_guard = self.did_mute.lock().unwrap(); + if !settings.mute_while_recording { + return; + } - if settings.mute_while_recording && *self.is_open.lock().unwrap() { + // Lock order: is_open before mute_state (matches stop_microphone_stream). + let is_open = self.is_open.lock().unwrap(); + let mut mute_guard = self.mute_state.lock().unwrap(); + // Already muted this session — don't re-snapshot, or a duplicate/late + // apply would overwrite prev_muted with our own forced-muted state and + // strand audio muted on stop. + if mute_guard.did_mute { + return; + } + if *is_open { + mute_guard.prev_muted = get_mute(); set_mute(true); - *did_mute_guard = true; - debug!("Mute applied"); + mute_guard.did_mute = true; + debug!("Mute applied (prev_muted={:?})", mute_guard.prev_muted); } } - /// Removes mute if it was applied + /// Removes mute if it was applied, restoring the system's prior mute state + /// (a system already muted before recording stays muted). pub fn remove_mute(&self) { - let mut did_mute_guard = self.did_mute.lock().unwrap(); - if *did_mute_guard { - set_mute(false); - *did_mute_guard = false; - debug!("Mute removed"); + let mut mute_guard = self.mute_state.lock().unwrap(); + if mute_guard.did_mute { + restore_mute(mute_guard.prev_muted); + mute_guard.did_mute = false; + debug!( + "Mute removed (restored prev_muted={:?})", + mute_guard.prev_muted + ); } } @@ -375,9 +524,17 @@ impl AudioRecordingManager { let start_time = Instant::now(); - // Don't mute immediately - caller will handle muting after audio feedback - let mut did_mute_guard = self.did_mute.lock().unwrap(); - *did_mute_guard = false; + // Don't mute immediately - caller will handle muting after audio feedback. + // The previous stream restored audio on close, so did_mute should already + // be false here; if it somehow isn't, restore rather than just clearing the + // flag, which would strand system audio muted. + { + let mut mute_guard = self.mute_state.lock().unwrap(); + if mute_guard.did_mute { + restore_mute(mute_guard.prev_muted); + mute_guard.did_mute = false; + } + } // Get the selected device from settings, considering clamshell mode. // No pre-flight enumeration here: when nothing is configured the @@ -434,11 +591,13 @@ impl AudioRecordingManager { return; } - let mut did_mute_guard = self.did_mute.lock().unwrap(); - if *did_mute_guard { - set_mute(false); + { + let mut mute_guard = self.mute_state.lock().unwrap(); + if mute_guard.did_mute { + restore_mute(mute_guard.prev_muted); + } + mute_guard.did_mute = false; } - *did_mute_guard = false; if let Some(rec) = self.recorder.lock().unwrap().as_mut() { // If still recording, stop first. From 390729a8007a9c09be38416bc7755e4fa04165c3 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 23 Jul 2026 21:00:15 +0800 Subject: [PATCH 08/49] bump handy keys 0.3.2 --- src-tauri/Cargo.lock | 8 ++++---- src-tauri/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8d122e3c90..4e19d72dfc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2510,9 +2510,9 @@ dependencies = [ [[package]] name = "handy-keys" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31719b86c449fc86156217bcdd2991a9d29432466540ed88f717774e9b391647" +checksum = "1a8b8e4be98a7cade231df09a8f5625c1044febdf39eb6205325b896034ccc7f" dependencies = [ "bitflags 2.11.0", "block2", @@ -4152,7 +4152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7961,7 +7961,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ea17e8e6a6..93127b251a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,7 +74,7 @@ sha2 = "0.10" # Canary, Cohere). Per-platform backend features are added in the target tables. transcribe-rs = { version = "0.3.8", features = ["onnx"] } transcribe-cpp = { version = "0.1.3", default-features = false } -handy-keys = "0.3.1" +handy-keys = "0.3.2" ferrous-opencc = "0.2.3" clap = { version = "4", features = ["derive"] } specta = "=2.0.0-rc.22" From 6cad594cdba3aaa99555183fcb1e7b5a3967168e Mon Sep 17 00:00:00 2001 From: Cxkies <69440959+Cxkies@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:35:53 -0500 Subject: [PATCH 09/49] maximize-window: enable maximize and snap support (#1778) --- src-tauri/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cf5bdc25f9..b4e8398167 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -874,7 +874,7 @@ pub fn run(cli_args: CliArgs) { .inner_size(680.0, 570.0) .min_inner_size(680.0, 570.0) .resizable(true) - .maximizable(false) + .maximizable(true) .visible(false); if let Some(data_dir) = portable::data_dir() { From 09a6a71015db794b47dd4916333be1a8b3ad2dac Mon Sep 17 00:00:00 2001 From: Fred Chu Date: Tue, 28 Jul 2026 07:43:50 +0800 Subject: [PATCH 10/49] fix(i18n): complete zh-TW translations for newer UI strings (#1795) --- src/i18n/locales/zh-TW/translation.json | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 4ab651b97a..7ad33db7c3 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -19,10 +19,10 @@ }, "onboarding": { "subtitle": "首先,請選擇一種模型", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "相容的模型", + "downloadModelsTitle": "可供下載", + "showAllModels": "顯示全部 {{total}} 個模型", + "showFewerModels": "顯示較少模型", "recommended": "推薦", "customModelDescription": "官方不支援", "modelCard": { @@ -96,7 +96,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "選擇模型失敗" }, "permissions": { "title": "需要權限", @@ -145,7 +145,7 @@ "capabilities": { "languageSelection": "支援多種輸入語言", "singleLanguage": "僅支援此語言", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} 種語言", "languageOnly": "僅 {{language}}", "translation": "可翻譯為英語", "translate": "翻譯為英語", @@ -188,7 +188,7 @@ }, "language": { "title": "語言", - "description": "選擇語音識別的語言。選擇自動將自動判定語言,選擇特定語言可以提高該語言的準確度", + "description": "選擇語音辨識的語言。選擇自動將自動判定語言,選擇特定語言可以提高該語言的準確度", "searchPlaceholder": "搜尋語言...", "noResults": "未找到語言", "auto": "自動" @@ -282,12 +282,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "懸浮窗", + "description": "選擇錄音懸浮窗的樣式:「無」會隱藏懸浮窗,「精簡」顯示小巧的膠囊狀指示,「即時」在您說話時即時顯示轉錄內容(僅限支援串流的模型——請在模型選擇器中尋找「串流」標籤)。在 Linux 上建議選擇「無」", "options": { "none": "無", - "minimal": "Minimal", - "live": "Live" + "minimal": "精簡", + "live": "即時" } }, "position": { @@ -365,8 +365,8 @@ "duplicate": "「{{word}}」已存在" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "語音活動偵測", + "description": "過濾錄音中的靜音。支援串流的模型會使用較長的 VAD 尾段;停用 VAD 則會錄製原始音訊" } }, "postProcessing": { @@ -560,7 +560,7 @@ "title": "致謝", "ggml": { "title": "ggml", - "description": "針對 OpenAI Whisper 自動語音識別模型的高效能推理引擎", + "description": "針對 OpenAI Whisper 自動語音辨識模型的高效能推理引擎", "details": "Handy 使用 ggml 進行快速的本機語音轉文字處理。感謝 Georgi Gerganov 和貢獻者們的傑出工作" } }, From 292db6470e7463487aabb7aad83f18c5831b574c Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Tue, 28 Jul 2026 15:33:46 +0800 Subject: [PATCH 11/49] Harden Hugging Face downloads and use mirror fallback (#1773) * logging and retry for hf * mirror backup for dl * back to parallel dl but backoff * reorg some of the changes --- .gitignore | 3 + scripts/gen_catalog.py | 43 +- scripts/mirror_models.py | 281 +++ src-tauri/Cargo.toml | 3 + src-tauri/src/catalog/catalog.json | 2226 ++++------------- src-tauri/src/catalog/mod.rs | 141 +- src-tauri/src/commands/models.rs | 4 + src-tauri/src/managers/model.rs | 910 ++++--- src-tauri/src/managers/model/download.rs | 384 +++ .../src/managers/model/download/tests.rs | 579 +++++ 10 files changed, 2421 insertions(+), 2153 deletions(-) create mode 100644 scripts/mirror_models.py create mode 100644 src-tauri/src/managers/model/download.rs create mode 100644 src-tauri/src/managers/model/download/tests.rs diff --git a/.gitignore b/.gitignore index c6020fce0b..c505900839 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ blob-report/ # Nix build output result + +# Python bytecode (scripts/) +__pycache__/ diff --git a/scripts/gen_catalog.py b/scripts/gen_catalog.py index 9df81c97e8..0d34dfcf22 100644 --- a/scripts/gen_catalog.py +++ b/scripts/gen_catalog.py @@ -18,7 +18,14 @@ from huggingface_hub import HfApi, HfFileSystem ORG = "handy-computer" -CATALOG_VERSION = 1 +CATALOG_VERSION = 2 + +# Download sources tried in order after Hugging Face itself. Each entry is a +# base URL; the full file URL is `{mirror}/{repo_id}/{revision}/{filename}` +# (the same three values that form the HF resolve URL, so a mirror is a plain +# static file host). Mirrors are untrusted: every download is verified against +# the per-file `sha256` below, so listing one only affects availability. +MIRRORS = ["https://blob.handy.computer"] # ───────────────────────── scoring (one constant each) ────────────────────── SPEED_SCALE = 8.0 # speed = 100·(1 − e^(−rtf/8)) grows toward 100 @@ -49,6 +56,10 @@ def acc_from_wer(wer): "Fun-ASR-MLT-Nano-2512": {"rank": 10, "desc": "A tiny multilingual model"}, # description-only (unranked, not recommended) — carried over from the legacy .bin entry "Breeze-ASR-25": {"desc": "Optimized for Taiwanese Mandarin. Code-switching support."}, + # hidden until the pinned transcribe-cpp ships their arch (has no `moss`/`sortformer` + # under src/arch/) — the app would offer a download it cannot load + "moss-transcribe-diarize": {"hidden": True}, + "diar_streaming_sortformer_4spk-v2.1": {"hidden": True}, } # temporary capability corrections pending a card re-push (remove once cards fixed) OVERRIDES = { @@ -153,32 +164,46 @@ def skip(o, vt): pass # ran past the buffer (hit tokenizer) — keep what we got return out +def lfs_sha256(x): + """Content sha256 from the sibling's LFS/Xet info (dataclass or dict by hub version).""" + lfs = getattr(x, "lfs", None) + if lfs is None: return None + return getattr(lfs, "sha256", None) or (lfs.get("sha256") if isinstance(lfs, dict) else None) + def gguf_files(repo, siblings): - """Return GGUF files with mandatory size metadata. + """Return GGUF files with mandatory size + sha256 metadata. `QuantFile.size_bytes` is a non-null `u64` in Rust. Failing generation here keeps a transient/malformed HF listing from producing a catalog that panics at app startup when deserialized by `include_str!("catalog.json")`. + + `sha256` is the trust anchor for downloads (HF or mirror alike), so it is + equally mandatory. Every GGUF is LFS/Xet-tracked; a missing hash means the + listing is broken, not that the file is small. """ files = [] invalid = [] for x in siblings: if not x.rfilename.endswith(".gguf"): continue - if type(x.size) is not int or x.size <= 0: + sha = lfs_sha256(x) + if type(x.size) is not int or x.size <= 0 or not sha: invalid.append(x.rfilename) continue files.append({ "filename": x.rfilename, "quant": quant_of(x.rfilename), "size_bytes": x.size, + "sha256": sha, }) if invalid: - raise ValueError(f"{repo}: missing/invalid size metadata for {', '.join(invalid)}") + raise ValueError(f"{repo}: missing/invalid size or sha256 metadata for {', '.join(invalid)}") return sorted(files, key=lambda f: f["size_bytes"]) def build(repo): info = api.model_info(repo, files_metadata=True) + if not info.sha: + raise ValueError(f"{repo}: listing has no commit sha to pin") cd = info.card_data.to_dict() if info.card_data else {} s = slug(repo) cur = CURATION.get(s, {}) @@ -211,6 +236,10 @@ def build(repo): return { "id": repo, + # Pinned commit: downloads fetch `resolve/{revision}/{file}` so the bytes + # provably match the hashes below even if the repo moves on. Identity + # stays repo+filename; the pin only scopes acquisition. + "revision": info.sha, "slug": s, "name": gg.get("general.name") or pretty(s), # friendly name (from GGUF) "architecture": gg.get("general.architecture"), @@ -251,6 +280,7 @@ def main(): catalog = { "catalog_version": CATALOG_VERSION, "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), + "mirrors": MIRRORS, "models": models, } text = json.dumps(catalog, indent=2, ensure_ascii=False) @@ -258,6 +288,11 @@ def main(): text = re.sub(r'"languages": \[(.*?)\]', lambda m: '"languages": [' + ", ".join(re.findall(r'"[^"]*"', m.group(1))) + ']', text, flags=re.S) + # one line per `files[]` entry: a file is one diff line, so schema additions + # and hash changes don't cascade into per-key comma churn + text = re.sub(r'\{\s+("filename":.*?"sha256": "[0-9a-f]{64}")\s+\}', + lambda m: "{" + re.sub(r",\s+", ", ", m.group(1)) + "}", + text, flags=re.S) out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(__file__), "catalog.json") open(out, "w").write(text) print(f"wrote {out}: {len(models)} models, {os.path.getsize(out)/1024:.1f} KB", file=sys.stderr) diff --git a/scripts/mirror_models.py b/scripts/mirror_models.py new file mode 100644 index 0000000000..f0a192d1f6 --- /dev/null +++ b/scripts/mirror_models.py @@ -0,0 +1,281 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["boto3", "huggingface_hub[hf_xet]"] +# /// +""" +Mirror the catalog's GGUF files to S3-compatible blob storage (Cloudflare R2). + +Object keys are `{repo_id}/{revision}/{filename}` — the same three values that +form the HF resolve URL, so the app's mirror template is plain substitution. +A revision pins content, making keys immutable: objects are served with an +immutable cache policy, and existence implies correctness. + +The bucket is the only state. Each run HEADs every expected key and uploads +what's missing, so runs are idempotent and machine-independent; concurrent +runs from different machines at worst duplicate work with identical bytes. +Every file is hash-verified against the catalog's `sha256` between download +and upload — that verification is what lets everything downstream trust bare +key existence. When a repo's revision moves but a file's bytes didn't change, +the object is server-side copied from the old revision's key instead of +re-transferred. + +Modes: + (default) dry run — print the plan; read-only HEAD/LIST when credentials + are present, offline otherwise. Writes nothing. + --execute perform the plan. Downloads (hf_xet, parallel chunks) and + uploads (one worker thread) are pipelined, so wall time is + roughly max(total download, total upload). Ctrl-C is safe at any + point: a key only appears once its multipart upload completes + (an interrupted upload is invisible and auto-aborted by R2's + lifecycle rule), the in-flight upload finishes before exit, and + a rerun skips everything already mirrored. + --verify audit the mirror: stream every expected object back from the + bucket and hash it against the catalog. No disk writes; R2 + egress is free, so a full audit costs only time. + +Only each model's default quant is mirrored — the one quant the app offers +for download; --all-quants widens to every listed quant. + +Env: R2_ENDPOINT (https://.r2.cloudflarestorage.com) + R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET +Run: uv run scripts/mirror_models.py [--execute | --verify] [--only SUBSTR] + [--all-quants] [--tmpdir DIR] [catalog_path] +""" +import argparse +import hashlib +import json +import os +import shutil +import sys +import tempfile +import time +from collections import deque +from concurrent.futures import ThreadPoolExecutor + +import boto3 +from boto3.s3.transfer import TransferConfig +from huggingface_hub import hf_hub_download +from tqdm import tqdm + +CACHE_CONTROL = "public, max-age=31536000, immutable" +DOWNLOAD_ATTEMPTS = 3 +CHUNK = 8 * 1024 * 1024 +TRANSFER = TransferConfig(max_concurrency=16, multipart_chunksize=32 * 1024 * 1024) + + +def say(msg): + """All script output funnels through tqdm.write so lines land cleanly + above the active download bar instead of tearing through it.""" + tqdm.write(msg) + + +def make_s3(): + """R2 client and bucket from env, or (None, None) → offline dry-run.""" + endpoint, key, secret, bucket = (os.environ.get(v) for v in + ("R2_ENDPOINT", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET")) + if not all((endpoint, key, secret, bucket)): + return None, None + return boto3.client("s3", endpoint_url=endpoint, + aws_access_key_id=key, aws_secret_access_key=secret), bucket + + +def head_meta(s3, bucket, key): + """Object's user metadata, or None if the key doesn't exist.""" + try: + return s3.head_object(Bucket=bucket, Key=key).get("Metadata", {}) + except s3.exceptions.ClientError as e: + if e.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"): + return None + raise + + +def find_copy_source(s3, bucket, repo_id, filename, sha): + """Key of the same file under an older revision with matching sha256, if any.""" + paginator = s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket, Prefix=repo_id + "/"): + for obj in page.get("Contents", []): + key = obj["Key"] + if key.rsplit("/", 1)[-1] != filename: + continue + meta = head_meta(s3, bucket, key) + if meta is not None and meta.get("sha256") == sha: + return key + return None + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(CHUNK), b""): + h.update(chunk) + return h.hexdigest() + + +def download_verified(repo_id, revision, f, tmpdir): + """Fetch at the pinned revision (hf_xet parallel transfer, own progress + bar), then hash-verify against the catalog. + + Returns (path, scratch_dir); the caller removes scratch_dir after upload. + Retries transfer errors AND verification failures — a hash mismatch here + means a corrupted transfer, and uploading it would poison an immutable key. + """ + last_err = None + for attempt in range(1, DOWNLOAD_ATTEMPTS + 1): + scratch = tempfile.mkdtemp(dir=tmpdir, prefix="mirror-") + try: + path = hf_hub_download(repo_id, f["filename"], revision=revision, + cache_dir=scratch) + if (sha256_file(path) == f["sha256"] + and os.path.getsize(path) == f["size_bytes"]): + return path, scratch + last_err = "verification failed" + except Exception as e: + last_err = str(e) + except BaseException: # Ctrl-C mid-download: don't leak the scratch dir + shutil.rmtree(scratch, ignore_errors=True) + raise + shutil.rmtree(scratch, ignore_errors=True) + say(f" attempt {attempt}/{DOWNLOAD_ATTEMPTS} failed: {last_err}") + raise RuntimeError(f"download failed for {repo_id}/{f['filename']}@{revision}: {last_err}") + + +def upload_one(s3, bucket, key, path, scratch, sha, size): + """Runs on the uploader thread: push to R2, report once, drop the scratch.""" + extra = {"Metadata": {"sha256": sha}, + "CacheControl": CACHE_CONTROL, + "ContentType": "application/octet-stream"} + try: + t0 = time.monotonic() + s3.upload_file(path, bucket, key, ExtraArgs=extra, Config=TRANSFER) + rate = size / max(time.monotonic() - t0, 1e-9) / 1e6 + say(f"↑ done {key} ({rate:.0f} MB/s)") + finally: + shutil.rmtree(scratch, ignore_errors=True) + + +def verify_bucket(s3, bucket, jobs): + """Stream every expected object and hash it against the catalog.""" + bad = 0 + for key, f in jobs: + try: + body = s3.get_object(Bucket=bucket, Key=key)["Body"] + except s3.exceptions.ClientError as e: + if e.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"): + say(f"MISSING {key}") + bad += 1 + continue + raise + h, t0 = hashlib.sha256(), time.monotonic() + for chunk in iter(lambda: body.read(CHUNK), b""): + h.update(chunk) + rate = f["size_bytes"] / max(time.monotonic() - t0, 1e-9) / 1e6 + if h.hexdigest() == f["sha256"]: + say(f"ok {key} ({rate:.0f} MB/s)") + else: + say(f"BAD {key} object does not match catalog sha256") + bad += 1 + return bad + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("catalog", nargs="?", default=os.path.join( + os.path.dirname(__file__), "..", "src-tauri", "src", "catalog", "catalog.json")) + ap.add_argument("--execute", action="store_true", help="perform the plan (default: dry run)") + ap.add_argument("--verify", action="store_true", help="stream-hash every mirrored object against the catalog") + ap.add_argument("--only", help="restrict to models whose id contains this substring") + ap.add_argument("--all-quants", action="store_true", + help="mirror every quant instead of just each model's default") + ap.add_argument("--tmpdir", default=None, + help="scratch dir for downloads (needs room for two files)") + args = ap.parse_args() + + catalog = json.load(open(args.catalog)) + jobs = [] # (model, file entry, object key) for every file this run covers + for m in catalog["models"]: + if args.only and args.only not in m["id"]: + continue + for f in m["files"]: + if args.all_quants or f["quant"] == m["default_quant"]: + jobs.append((m, f, f'{m["id"]}/{m["revision"]}/{f["filename"]}')) + + s3, bucket = make_s3() + if args.verify: + if s3 is None: + sys.exit("--verify requires R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET") + bad = verify_bucket(s3, bucket, [(key, f) for _, f, key in jobs]) + print(f"verified {len(jobs)} object(s), {bad} problem(s)") + sys.exit(1 if bad else 0) + if args.execute and s3 is None: + sys.exit("--execute requires R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET") + if s3 is None: + say("no R2 credentials: offline plan (every file counts as 'upload')") + + uploader = ThreadPoolExecutor(max_workers=1) + in_flight = deque() + + def drain(limit): + while len(in_flight) > limit: + in_flight.popleft().result() # re-raises upload errors here + + tally = {"skip": 0, "copy": 0, "upload": 0, "mismatch": 0} + upload_bytes = 0 + for m, f, key in jobs: + gb = f["size_bytes"] / 1e9 + copy_src = None + if s3 is not None: + meta = head_meta(s3, bucket, key) + if meta is not None: + # Immutable key already populated. "Existence implies + # correctness" only covers objects this script wrote (verified, + # hash recorded) — a disagreeing OR missing hash means some + # other writer, the one state that must never pass silently. + recorded = meta.get("sha256") + if recorded != f["sha256"]: + got = f"object sha {recorded[:12]}…" if recorded else "no sha256 metadata" + say(f"!! MISMATCH {key}: {got} != catalog {f['sha256'][:12]}…") + tally["mismatch"] += 1 + else: + tally["skip"] += 1 + continue + copy_src = find_copy_source(s3, bucket, m["id"], f["filename"], f["sha256"]) + + if not args.execute: + action = "copy" if copy_src else "upload" + say(f"DRY {action:6} {key} ({gb:.2f} GB" + (f", from {copy_src}" if copy_src else "") + ")") + tally[action] += 1 + upload_bytes += 0 if copy_src else f["size_bytes"] + continue + + if copy_src: + say(f"copy {key} (from {copy_src})") + extra = {"Metadata": {"sha256": f["sha256"]}, + "CacheControl": CACHE_CONTROL, + "ContentType": "application/octet-stream"} + # Managed copy: server-side, multipart above the 5 GB single-copy limit. + s3.copy({"Bucket": bucket, "Key": copy_src}, bucket, key, + ExtraArgs={**extra, "MetadataDirective": "REPLACE"}) + tally["copy"] += 1 + continue + + # Pipeline: cap queued uploads at one so at most two scratch dirs + # exist, then download the next file while the previous one uploads. + drain(1) + say(f"↓ {key} ({gb:.2f} GB)") + path, scratch = download_verified(m["id"], m["revision"], f, args.tmpdir) + in_flight.append(uploader.submit(upload_one, s3, bucket, key, path, scratch, + f["sha256"], f["size_bytes"])) + tally["upload"] += 1 + upload_bytes += f["size_bytes"] + + drain(0) + uploader.shutdown() + print(f'{"DRY RUN — nothing written. " if not args.execute else ""}' + f'skip {tally["skip"]}, copy {tally["copy"]}, upload {tally["upload"]}' + f' ({upload_bytes/1e9:.1f} GB), mismatch {tally["mismatch"]}') + if tally["mismatch"]: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 93127b251a..d70fdc734a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -170,6 +170,9 @@ tao-macros = { git = "https://github.com/cjpais/tao", rev = "c3bee28c1d446d95f08 [dev-dependencies] tempfile = "3" +# Test-only tokio features for the local socket servers exercising the +# resumable HTTP downloader; normal builds keep the leaner feature set. +tokio = { version = "1.43.0", features = ["macros", "net", "io-util", "sync", "time", "rt-multi-thread"] } [profile.release] lto = true diff --git a/src-tauri/src/catalog/catalog.json b/src-tauri/src/catalog/catalog.json index 4d24bcaf5b..978b1837bd 100644 --- a/src-tauri/src/catalog/catalog.json +++ b/src-tauri/src/catalog/catalog.json @@ -1,9 +1,13 @@ { - "catalog_version": 1, - "generated_at": "2026-07-21T12:23:22+00:00", + "catalog_version": 2, + "generated_at": "2026-07-23T10:15:59+00:00", + "mirrors": [ + "https://blob.handy.computer" + ], "models": [ { "id": "handy-computer/parakeet-unified-en-0.6b-gguf", + "revision": "7e948f21b7bdbac698d3318db9d350f1096f3b6c", "slug": "parakeet-unified-en-0.6b", "name": "Parakeet Unified EN 0.6B", "architecture": "parakeet", @@ -23,36 +27,12 @@ "speed_score": 79, "accuracy_score": 90, "files": [ - { - "filename": "parakeet-unified-en-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 477274496 - }, - { - "filename": "parakeet-unified-en-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 540795264 - }, - { - "filename": "parakeet-unified-en-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 602191232 - }, - { - "filename": "parakeet-unified-en-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 731357568 - }, - { - "filename": "parakeet-unified-en-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1239114240 - }, - { - "filename": "parakeet-unified-en-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2473323520 - } + {"filename": "parakeet-unified-en-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 477274496, "sha256": "a8bf3de2b393bd14ead5a858c3748d5e3b07a20fdeabdd3b498fba4f463fa929"}, + {"filename": "parakeet-unified-en-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 540795264, "sha256": "f9def6f9b4e83ab7d006df3e1b676dfa1f973a3b6da232a9c99fcaa66bcd2836"}, + {"filename": "parakeet-unified-en-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 602191232, "sha256": "912c42ec3fd1afa31b7eda45684577feadf413249ca767fa912a89c4810e38d0"}, + {"filename": "parakeet-unified-en-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 731357568, "sha256": "4b50b6dd862bf6e346929aaf4f5eaacec003bfa3f56462d6c874b41ef2f38795"}, + {"filename": "parakeet-unified-en-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1239114240, "sha256": "4a284b229bff9dc66aa00666fdcc2419c0f82a5adb5a36f7a48a4e0713c35492"}, + {"filename": "parakeet-unified-en-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2473323520, "sha256": "81d3a8c7676324a4dae977dac0f5900dc1468755fdf3b5369261bb759f17fdf3"} ], "default_quant": "Q8_0", "recommended": true, @@ -60,6 +40,7 @@ }, { "id": "handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf", + "revision": "6d44e540bc31b0de1dbe174a3cea87f53a7f22fb", "slug": "nemotron-3.5-asr-streaming-0.6b", "name": "Nemotron Streaming 3.5", "architecture": "parakeet", @@ -79,36 +60,12 @@ "speed_score": 84, "accuracy_score": 82, "files": [ - { - "filename": "nemotron-3.5-asr-streaming-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 495831520 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 559647200 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 621356512 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 751094240 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1277750240 - }, - { - "filename": "nemotron-3.5-asr-streaming-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2552277984 - } + {"filename": "nemotron-3.5-asr-streaming-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 495831520, "sha256": "41c99fa5fb6f3d35f68e79adc3e755eca2232a8d921178bd647b71194792b8fd"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 559647200, "sha256": "86429e8c4f7fdcf9b3312269ad1ca6669478ba7805331c4aea7a2e33e9910d65"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 621356512, "sha256": "4ff802c6207c4a7df23242003fd2aa849a1ab02bba6bc80c3db02e7e82606c28"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 751094240, "sha256": "b94545b313b3223fda7b2857a52681da813935c2127643d1e9ff0c23d988089c"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1277750240, "sha256": "f21a0cea64d232981def7f8f2b7ab322459703a2e89359962c042c57159755b1"}, + {"filename": "nemotron-3.5-asr-streaming-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2552277984, "sha256": "fbbc82e8e1084301a670fbc4d79c69c0c8980da506352fd83e74a182f38f5b78"} ], "default_quant": "Q8_0", "recommended": true, @@ -116,6 +73,7 @@ }, { "id": "handy-computer/canary-180m-flash-gguf", + "revision": "b147f9dc52b59f0998e410540a84727bd86457fd", "slug": "canary-180m-flash", "name": "Canary 180M Flash", "architecture": "canary", @@ -135,36 +93,12 @@ "speed_score": 98, "accuracy_score": 88, "files": [ - { - "filename": "canary-180m-flash-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 139223744 - }, - { - "filename": "canary-180m-flash-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 158704320 - }, - { - "filename": "canary-180m-flash-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 176291520 - }, - { - "filename": "canary-180m-flash-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 218447552 - }, - { - "filename": "canary-180m-flash-F16.gguf", - "quant": "F16", - "size_bytes": 381632192 - }, - { - "filename": "canary-180m-flash-F32.gguf", - "quant": "F32", - "size_bytes": 756498112 - } + {"filename": "canary-180m-flash-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 139223744, "sha256": "c8ae5758d7d4dc59c48d816474a33544e362ca52c635f96ee0b4b95e1b48f90c"}, + {"filename": "canary-180m-flash-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 158704320, "sha256": "a87992d84aea5329fa5d70f2eb440d3ae4fe47bd774875374ec381472d348299"}, + {"filename": "canary-180m-flash-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 176291520, "sha256": "be28948a5f6d93d59d0edc1f9580936ea30b23296130da02b719aee9f3a59e96"}, + {"filename": "canary-180m-flash-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 218447552, "sha256": "e13c7f5d0952b056a027cfffec13e3a3a134d1608babed24f983568f141e297c"}, + {"filename": "canary-180m-flash-F16.gguf", "quant": "F16", "size_bytes": 381632192, "sha256": "8042593b341836ac711a617cd483bb2ab7fe05eae14ace27b2488d8ce90ab017"}, + {"filename": "canary-180m-flash-F32.gguf", "quant": "F32", "size_bytes": 756498112, "sha256": "f42fce02dc5279545cd540f0e5d51e36b9335b69e11fd98de6e08cc1250fd7ad"} ], "default_quant": "Q8_0", "recommended": true, @@ -172,6 +106,7 @@ }, { "id": "handy-computer/cohere-transcribe-03-2026-gguf", + "revision": "dfa4adebb64f3076b7b6b90b721275cc069cb421", "slug": "cohere-transcribe-03-2026", "name": "Cohere Transcribe", "architecture": "cohere_asr", @@ -191,36 +126,12 @@ "speed_score": 63, "accuracy_score": 92, "files": [ - { - "filename": "cohere-transcribe-03-2026-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1558162944 - }, - { - "filename": "cohere-transcribe-03-2026-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1770270208 - }, - { - "filename": "cohere-transcribe-03-2026-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1972524544 - }, - { - "filename": "cohere-transcribe-03-2026-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2410655232 - }, - { - "filename": "cohere-transcribe-03-2026-BF16.gguf", - "quant": "BF16", - "size_bytes": 4105263104 - }, - { - "filename": "cohere-transcribe-03-2026-F16.gguf", - "quant": "F16", - "size_bytes": 4106644992 - } + {"filename": "cohere-transcribe-03-2026-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1558162944, "sha256": "0ea56826d8bd5d74b7143a4a04e022dc1bb75452cfae49d98b6acb0c1d16a1fb"}, + {"filename": "cohere-transcribe-03-2026-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1770270208, "sha256": "14d02f1ad6dd77b3a60f82639879012c3adb4fe25c50a5a47a2c4c661daf1558"}, + {"filename": "cohere-transcribe-03-2026-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1972524544, "sha256": "094fb65b9d82d4a52db1d944152e4d7b6629f356bb3beaffdd0891eb527f24f3"}, + {"filename": "cohere-transcribe-03-2026-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2410655232, "sha256": "931916663432fd895423a4291a8400221802b288967ca2d435fc5e3141c9e71e"}, + {"filename": "cohere-transcribe-03-2026-BF16.gguf", "quant": "BF16", "size_bytes": 4105263104, "sha256": "72e7625abc358da7b1a27348b18269b4a026eceb792e6ade53c6bb2280d0f3a7"}, + {"filename": "cohere-transcribe-03-2026-F16.gguf", "quant": "F16", "size_bytes": 4106644992, "sha256": "65ea095ba78ed938a613cc950da0599b29e16bb5a22a968d7b1fd99d71f13784"} ], "default_quant": "Q5_K_M", "recommended": true, @@ -228,6 +139,7 @@ }, { "id": "handy-computer/whisper-medium-gguf", + "revision": "ec78f06fded51aa82cde751678b78f76f78c8b7f", "slug": "whisper-medium", "name": "Whisper Medium", "architecture": "whisper", @@ -247,36 +159,12 @@ "speed_score": 42, "accuracy_score": 84, "files": [ - { - "filename": "whisper-medium-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 504102848 - }, - { - "filename": "whisper-medium-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 582746048 - }, - { - "filename": "whisper-medium-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 648019904 - }, - { - "filename": "whisper-medium-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 831538144 - }, - { - "filename": "whisper-medium-F16.gguf", - "quant": "F16", - "size_bytes": 1541931424 - }, - { - "filename": "whisper-medium-F32.gguf", - "quant": "F32", - "size_bytes": 3057437088 - } + {"filename": "whisper-medium-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 504102848, "sha256": "6c9cad9c41d1fa0fcc624898f7ee3485b66127627878081a5655c6f6f3ce20ab"}, + {"filename": "whisper-medium-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 582746048, "sha256": "4e2a8904a866b3aa7ef70d7640ec6abc5f0a05524cd950ea4b66ace12122bf53"}, + {"filename": "whisper-medium-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 648019904, "sha256": "8718bafef4f595b5cacd9d9409fb7e19ce99b1afe4c6a450198d692fa58bd380"}, + {"filename": "whisper-medium-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 831538144, "sha256": "09e6a65e7de377aa5b10bae24608bc6f8ca2ed04b3993ef10d4a02bcd9a82adf"}, + {"filename": "whisper-medium-F16.gguf", "quant": "F16", "size_bytes": 1541931424, "sha256": "62338e5194cb9ccc6734adf6f42694805a98a158028b2022e39ec060559bd517"}, + {"filename": "whisper-medium-F32.gguf", "quant": "F32", "size_bytes": 3057437088, "sha256": "d716aa436e8d1d3164898d1fd6f4bde0f7cbbb7059b8c276b18f78cbca2faf6b"} ], "default_quant": "Q8_0", "recommended": true, @@ -284,6 +172,7 @@ }, { "id": "handy-computer/Voxtral-Mini-4B-Realtime-2602-gguf", + "revision": "b3e1c979e3775cbd0a49a65878a0ec7f06789ed7", "slug": "Voxtral-Mini-4B-Realtime-2602", "name": "Voxtral Mini 4B Realtime", "architecture": "voxtral_realtime", @@ -303,36 +192,12 @@ "speed_score": 11, "accuracy_score": 87, "files": [ - { - "filename": "Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 2830493984 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 3281439008 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 3661018912 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 4731791648 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-BF16.gguf", - "quant": "BF16", - "size_bytes": 8868301088 - }, - { - "filename": "Voxtral-Mini-4B-Realtime-2602-F16.gguf", - "quant": "F16", - "size_bytes": 8879114528 - } + {"filename": "Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 2830493984, "sha256": "39dc1f65539373a406edea7490505822d77c12edff521744678717eef4da4723"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 3281439008, "sha256": "e20a7582c5cf8159454c909b7f57b184b287cb3d7c3ec85744727a2cec51b08f"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 3661018912, "sha256": "92038784cffa20ea8ee925f10fd28c110285124d94730eacce48bab6c214fa48"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 4731791648, "sha256": "6fb22249463c1e7a600d920ad732e2dd0a231510e9db59fb8061d3d1d48d0c67"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-BF16.gguf", "quant": "BF16", "size_bytes": 8868301088, "sha256": "dca6a7bece55858472e1b2df1f928642bdc1e964b1a91f3378c3a4e72fc5bc04"}, + {"filename": "Voxtral-Mini-4B-Realtime-2602-F16.gguf", "quant": "F16", "size_bytes": 8879114528, "sha256": "243258ae0ad80f748b18cfd9c8141998157d74eb32fbb131ba8f69e0c01564d7"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -340,6 +205,7 @@ }, { "id": "handy-computer/parakeet-tdt-0.6b-v3-gguf", + "revision": "85ac09ea12fc4b1112fa76810059364bc6adc9de", "slug": "parakeet-tdt-0.6b-v3", "name": "Parakeet TDT 0.6B v3", "architecture": "parakeet", @@ -359,36 +225,12 @@ "speed_score": 79, "accuracy_score": 88, "files": [ - { - "filename": "parakeet-tdt-0.6b-v3-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 485425504 - }, - { - "filename": "parakeet-tdt-0.6b-v3-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 548946272 - }, - { - "filename": "parakeet-tdt-0.6b-v3-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 610342240 - }, - { - "filename": "parakeet-tdt-0.6b-v3-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 739508576 - }, - { - "filename": "parakeet-tdt-0.6b-v3-F16.gguf", - "quant": "F16", - "size_bytes": 1255869856 - }, - { - "filename": "parakeet-tdt-0.6b-v3-F32.gguf", - "quant": "F32", - "size_bytes": 2508435616 - } + {"filename": "parakeet-tdt-0.6b-v3-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 485425504, "sha256": "b68557be1e3c40207fd7c4bd9d63f1d3316b963f15325bfb0cc16a8bb0ffd181"}, + {"filename": "parakeet-tdt-0.6b-v3-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 548946272, "sha256": "cc722e76adc1a629fc0b2535de879d99b8160d07ad4c0215e2ca7d7ea0ae4b8f"}, + {"filename": "parakeet-tdt-0.6b-v3-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 610342240, "sha256": "ab1bbcbe3e1c4c2ec12ca1df1aeafd22e9043ce7262e4b5f5c5d7d76418a9ae8"}, + {"filename": "parakeet-tdt-0.6b-v3-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 739508576, "sha256": "5859f77944efcd8eafa23a6350731960b2b55b2203df51f319665c807d802cc7"}, + {"filename": "parakeet-tdt-0.6b-v3-F16.gguf", "quant": "F16", "size_bytes": 1255869856, "sha256": "d9ec7e2c39da7b4fec3e01e71e82c7f7bbe97741e08c0c054091bf3d520a41d3"}, + {"filename": "parakeet-tdt-0.6b-v3-F32.gguf", "quant": "F32", "size_bytes": 2508435616, "sha256": "bbd36acd8c6fbb817e658e3acc9f75002cb6a74932c687e74fefccfcc7b9f47e"} ], "default_quant": "Q8_0", "recommended": false, @@ -396,6 +238,7 @@ }, { "id": "handy-computer/parakeet-tdt-0.6b-v2-gguf", + "revision": "07cee0616125a08ef619729bb47f40ef747e4bc4", "slug": "parakeet-tdt-0.6b-v2", "name": "Parakeet TDT 0.6B v2", "architecture": "parakeet", @@ -415,36 +258,12 @@ "speed_score": 85, "accuracy_score": 89, "files": [ - { - "filename": "parakeet-tdt-0.6b-v2-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 475491840 - }, - { - "filename": "parakeet-tdt-0.6b-v2-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 539012608 - }, - { - "filename": "parakeet-tdt-0.6b-v2-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 600408576 - }, - { - "filename": "parakeet-tdt-0.6b-v2-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 729574912 - }, - { - "filename": "parakeet-tdt-0.6b-v2-F16.gguf", - "quant": "F16", - "size_bytes": 1237334592 - }, - { - "filename": "parakeet-tdt-0.6b-v2-F32.gguf", - "quant": "F32", - "size_bytes": 2471550272 - } + {"filename": "parakeet-tdt-0.6b-v2-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 475491840, "sha256": "4853f9653f641d376e6f7de65d73c7a34a73677704a606727bf51acc83f999f3"}, + {"filename": "parakeet-tdt-0.6b-v2-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 539012608, "sha256": "dfa904520b95451599683613fea47766c378c5fdf5d8cddf48151226b6eaec85"}, + {"filename": "parakeet-tdt-0.6b-v2-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 600408576, "sha256": "44c7c363438e03b613cf8ced8b7e6fd1beb2b3805008136fe5e922ab3c79eea9"}, + {"filename": "parakeet-tdt-0.6b-v2-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 729574912, "sha256": "f0d0e99cebb6d3b83f1f7069b82b5d3c2e39a54545b0da039cb4bafd9c4e5caa"}, + {"filename": "parakeet-tdt-0.6b-v2-F16.gguf", "quant": "F16", "size_bytes": 1237334592, "sha256": "8440125213cb92625e3993ec751c3919dd329da9ae9149951cd6f95dd8bafe81"}, + {"filename": "parakeet-tdt-0.6b-v2-F32.gguf", "quant": "F32", "size_bytes": 2471550272, "sha256": "501ba1e61965cdb28689614c648d2c560d75895b47a2948b9010b4e8c9cf1325"} ], "default_quant": "Q8_0", "recommended": false, @@ -452,6 +271,7 @@ }, { "id": "handy-computer/Qwen3-ASR-0.6B-gguf", + "revision": "e4e16599b900eb0cb36e524514756bb92eb092b7", "slug": "Qwen3-ASR-0.6B", "name": "Qwen3-ASR 0.6B", "architecture": "qwen3_asr", @@ -471,36 +291,12 @@ "speed_score": 63, "accuracy_score": 87, "files": [ - { - "filename": "Qwen3-ASR-0.6B-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 589560480 - }, - { - "filename": "Qwen3-ASR-0.6B-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 645356192 - }, - { - "filename": "Qwen3-ASR-0.6B-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 690417824 - }, - { - "filename": "Qwen3-ASR-0.6B-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 850423456 - }, - { - "filename": "Qwen3-ASR-0.6B-BF16.gguf", - "quant": "BF16", - "size_bytes": 1571490016 - }, - { - "filename": "Qwen3-ASR-0.6B-F16.gguf", - "quant": "F16", - "size_bytes": 1579793056 - } + {"filename": "Qwen3-ASR-0.6B-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 589560480, "sha256": "5b58f32a58ffa2c8783e0b0963485623e286e6272d953dfc9e28bc3447dee0c0"}, + {"filename": "Qwen3-ASR-0.6B-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 645356192, "sha256": "062a7bcb18675c2fe5ffd0e8b354eac6a1ceada9b2af9ef7168746ae16b54358"}, + {"filename": "Qwen3-ASR-0.6B-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 690417824, "sha256": "3b051f108f03c0c91bbe1a3b2c1ee15e3ed51e4caec2a48751b01f2a21441cc3"}, + {"filename": "Qwen3-ASR-0.6B-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 850423456, "sha256": "f081b2d5e23bd669d92cc331d722a8a0681943b8e6f34b48996fd5c319b5acd8"}, + {"filename": "Qwen3-ASR-0.6B-BF16.gguf", "quant": "BF16", "size_bytes": 1571490016, "sha256": "dadb8196937aa6b998a94124a043fce694e0ab6c3c6eb0ae1669fa49d04aab84"}, + {"filename": "Qwen3-ASR-0.6B-F16.gguf", "quant": "F16", "size_bytes": 1579793056, "sha256": "5c90e4b1a72a4c59cd12afa5ebb0cc8628848148f2b337d025cc5121ae4d2eea"} ], "default_quant": "Q8_0", "recommended": false, @@ -508,6 +304,7 @@ }, { "id": "handy-computer/Fun-ASR-MLT-Nano-2512-gguf", + "revision": "0b8f9c7bc545a219658aeb1dd4eeaa55d1cf89f3", "slug": "Fun-ASR-MLT-Nano-2512", "name": "Fun-ASR Nano Multilingual", "architecture": "funasr_nano", @@ -527,36 +324,12 @@ "speed_score": 68, "accuracy_score": 89, "files": [ - { - "filename": "Fun-ASR-MLT-Nano-2512-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 556975168 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 631129152 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 690744384 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 891271232 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-BF16.gguf", - "quant": "BF16", - "size_bytes": 1667504192 - }, - { - "filename": "Fun-ASR-MLT-Nano-2512-F16.gguf", - "quant": "F16", - "size_bytes": 1667504192 - } + {"filename": "Fun-ASR-MLT-Nano-2512-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 556975168, "sha256": "9232584a27a7dcbcaf13640de8f9c2e7375178ea11e587f90a5328ecf06d58a1"}, + {"filename": "Fun-ASR-MLT-Nano-2512-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 631129152, "sha256": "5906267c6067d254a2681f40d9eac70c4291ae14fb58ffea4c4cd22a307517ec"}, + {"filename": "Fun-ASR-MLT-Nano-2512-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 690744384, "sha256": "e4ca67cbcb9b12c828b2afbadc871a8128ea3e73890d2747c687b1fac0ac6934"}, + {"filename": "Fun-ASR-MLT-Nano-2512-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 891271232, "sha256": "d12476d8d9f2baa0ebf738fa955fa05ed33a654f1567289033a810c45d9d9002"}, + {"filename": "Fun-ASR-MLT-Nano-2512-BF16.gguf", "quant": "BF16", "size_bytes": 1667504192, "sha256": "dcacaf5eb69651e4e3f1f79e9bdd16be518cf5f9ecaf023dada9c5aa891bde4b"}, + {"filename": "Fun-ASR-MLT-Nano-2512-F16.gguf", "quant": "F16", "size_bytes": 1667504192, "sha256": "ffd1e5258f2cfbc05dc3e467b570a32d5671a6511bcd070cd72f08657108213d"} ], "default_quant": "Q8_0", "recommended": false, @@ -564,6 +337,7 @@ }, { "id": "handy-computer/canary-1b-flash-gguf", + "revision": "b427664769b93c021df108a2fa8bfb858ae236c1", "slug": "canary-1b-flash", "name": "Canary 1B Flash", "architecture": "canary", @@ -583,36 +357,12 @@ "speed_score": 83, "accuracy_score": 90, "files": [ - { - "filename": "canary-1b-flash-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 677141280 - }, - { - "filename": "canary-1b-flash-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 769563424 - }, - { - "filename": "canary-1b-flash-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 857603872 - }, - { - "filename": "canary-1b-flash-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1048131360 - }, - { - "filename": "canary-1b-flash-F16.gguf", - "quant": "F16", - "size_bytes": 1785657120 - }, - { - "filename": "canary-1b-flash-F32.gguf", - "quant": "F32", - "size_bytes": 3560372000 - } + {"filename": "canary-1b-flash-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 677141280, "sha256": "2521a615a2b04ab11900d894e1f5c5a70405d85aa74be0806b569b9cc311a707"}, + {"filename": "canary-1b-flash-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 769563424, "sha256": "7eed3cac92f255a4adbd518c58663d3fbf65984d2619189e593f2d374b05c601"}, + {"filename": "canary-1b-flash-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 857603872, "sha256": "3faeb99e16c719047df92fbdcc501ab5cd00edfac83a5e6e7cd69b470d650730"}, + {"filename": "canary-1b-flash-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1048131360, "sha256": "9b99e0881d883467e0a03ceb0968dba888c9f8921b73355143ad8b67931e08ce"}, + {"filename": "canary-1b-flash-F16.gguf", "quant": "F16", "size_bytes": 1785657120, "sha256": "e87e172694fe6d808fa8844961fb7bc77a4a22063936227c62ac76b6fdbf3dcd"}, + {"filename": "canary-1b-flash-F32.gguf", "quant": "F32", "size_bytes": 3560372000, "sha256": "888c24f7d67c3c60d00003bf6a1e3123822afbc47927b7ca931d0b4aaf45305b"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -620,6 +370,7 @@ }, { "id": "handy-computer/canary-1b-v2-gguf", + "revision": "58d13c2c0102229aad45f7e19a77ddc42b41dd9a", "slug": "canary-1b-v2", "name": "Canary 1B v2", "architecture": "canary", @@ -639,36 +390,12 @@ "speed_score": 81, "accuracy_score": 88, "files": [ - { - "filename": "canary-1b-v2-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 735476448 - }, - { - "filename": "canary-1b-v2-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 836664032 - }, - { - "filename": "canary-1b-v2-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 931986144 - }, - { - "filename": "canary-1b-v2-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1144290016 - }, - { - "filename": "canary-1b-v2-F16.gguf", - "quant": "F16", - "size_bytes": 1966111456 - }, - { - "filename": "canary-1b-v2-F32.gguf", - "quant": "F32", - "size_bytes": 3920657120 - } + {"filename": "canary-1b-v2-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 735476448, "sha256": "49e0a67e219bec95a254c2348460b6350e75a7ac6f93a131e48244b4c7cb53b9"}, + {"filename": "canary-1b-v2-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 836664032, "sha256": "9c3a893c93795438baf9b4b1c853c39b60316c3a0d259a3ba6e284712f5ddb71"}, + {"filename": "canary-1b-v2-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 931986144, "sha256": "6567c2d601fccacf9809fc230edefde05728340f65860bbe91cfd72d0a5eea9d"}, + {"filename": "canary-1b-v2-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1144290016, "sha256": "224f83d1bc487b3303b495a7d6874912fdece93de19d1a04b550829c30a5d289"}, + {"filename": "canary-1b-v2-F16.gguf", "quant": "F16", "size_bytes": 1966111456, "sha256": "eadda53cd1652d65cd12ff7ac4b7dc64cba1ce9837aae0c86e4222e8db89e320"}, + {"filename": "canary-1b-v2-F32.gguf", "quant": "F32", "size_bytes": 3920657120, "sha256": "b0b48b62b2c68f2779c49481de102715c5eaadbe084bca7fdddc7912f9c20e8b"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -676,6 +403,7 @@ }, { "id": "handy-computer/canary-1b-gguf", + "revision": "f2eaa93b2221b6a28e5a00111a3e93f62964dff3", "slug": "canary-1b", "name": "Canary 1B", "architecture": "canary", @@ -695,36 +423,12 @@ "speed_score": 67, "accuracy_score": 90, "files": [ - { - "filename": "canary-1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 729686848 - }, - { - "filename": "canary-1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 837694272 - }, - { - "filename": "canary-1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 934167616 - }, - { - "filename": "canary-1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1162740288 - }, - { - "filename": "canary-1b-F16.gguf", - "quant": "F16", - "size_bytes": 2047537728 - }, - { - "filename": "canary-1b-F32.gguf", - "quant": "F32", - "size_bytes": 4086100544 - } + {"filename": "canary-1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 729686848, "sha256": "ded40db7f9d25e992bf35a0c3c3743e1a7c85d0c30bdbbabd46bca932a47353d"}, + {"filename": "canary-1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 837694272, "sha256": "1139f01aad4ed4208d2c0532cfc25939cbc26cea1e160084c83f40d5b6eaed35"}, + {"filename": "canary-1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 934167616, "sha256": "ac3a38b09f78e7cfc5ecfcf75b6dd5ca7c77c17669c9c5d6a5f1fb3917b75c73"}, + {"filename": "canary-1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1162740288, "sha256": "0b5d28516bcde9402d5eba34fc4a79c95b84b27954f05e5f8993d67df2857b62"}, + {"filename": "canary-1b-F16.gguf", "quant": "F16", "size_bytes": 2047537728, "sha256": "f40216c503fe14d98b384c31ab18e705fb9c004cb8e6c9976962aae2a369e5ea"}, + {"filename": "canary-1b-F32.gguf", "quant": "F32", "size_bytes": 4086100544, "sha256": "b3ec64ca239c1266f83dc8d15ebf6d4b09b2b5aa72a972d3dea1a4c2f3e1fa37"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -732,6 +436,7 @@ }, { "id": "handy-computer/canary-qwen-2.5b-gguf", + "revision": "3370d4e2f28cc70eea79dfc9f2f43fb91eef3163", "slug": "canary-qwen-2.5b", "name": "Canary-Qwen 2.5B", "architecture": "canary_qwen", @@ -751,36 +456,12 @@ "speed_score": 40, "accuracy_score": 90, "files": [ - { - "filename": "canary-qwen-2.5b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1737575808 - }, - { - "filename": "canary-qwen-2.5b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1983729024 - }, - { - "filename": "canary-qwen-2.5b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 2208697728 - }, - { - "filename": "canary-qwen-2.5b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2797548928 - }, - { - "filename": "canary-qwen-2.5b-BF16.gguf", - "quant": "BF16", - "size_bytes": 5076107136 - }, - { - "filename": "canary-qwen-2.5b-F16.gguf", - "quant": "F16", - "size_bytes": 5076972928 - } + {"filename": "canary-qwen-2.5b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1737575808, "sha256": "db5162229d6fa22597d06a613bd9b543eddb3ee02e6afc5e759120fde02bebf7"}, + {"filename": "canary-qwen-2.5b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1983729024, "sha256": "d71b1d9ae442dc538665ed4df63a69520eb9be769ec0bc78c7d2f40040928306"}, + {"filename": "canary-qwen-2.5b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 2208697728, "sha256": "25dba2a425113e73d626cc3db6148d925cbd7ead6872b631b7ef4d927788be31"}, + {"filename": "canary-qwen-2.5b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2797548928, "sha256": "d89aad1285d5bd5aa441c464d3a4cf37bd5474f70705408e71558d8627415b34"}, + {"filename": "canary-qwen-2.5b-BF16.gguf", "quant": "BF16", "size_bytes": 5076107136, "sha256": "5b29334e1d257503b95436608519c39436f0e2056251d17b7c5c001f862c00b8"}, + {"filename": "canary-qwen-2.5b-F16.gguf", "quant": "F16", "size_bytes": 5076972928, "sha256": "ae310ac25c1f190b8df4b29a301e936440e1d5306c23d6e5352e29400338ad87"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -788,6 +469,7 @@ }, { "id": "handy-computer/cohere-transcribe-arabic-07-2026-gguf", + "revision": "715cbe09ca9b60fc1e497db1e4478fa9fcf4ac21", "slug": "cohere-transcribe-arabic-07-2026", "name": "Cohere Transcribe", "architecture": "cohere_asr", @@ -807,36 +489,12 @@ "speed_score": 63, "accuracy_score": 48, "files": [ - { - "filename": "cohere-transcribe-arabic-07-2026-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1558162848 - }, - { - "filename": "cohere-transcribe-arabic-07-2026-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1770270112 - }, - { - "filename": "cohere-transcribe-arabic-07-2026-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1972524448 - }, - { - "filename": "cohere-transcribe-arabic-07-2026-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2410655136 - }, - { - "filename": "cohere-transcribe-arabic-07-2026-BF16.gguf", - "quant": "BF16", - "size_bytes": 4105263008 - }, - { - "filename": "cohere-transcribe-arabic-07-2026-F16.gguf", - "quant": "F16", - "size_bytes": 4106644896 - } + {"filename": "cohere-transcribe-arabic-07-2026-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1558162848, "sha256": "4671080cd10ffc6166e1e1b36216f9b51e1455f170d55c59cff7b81332ad4621"}, + {"filename": "cohere-transcribe-arabic-07-2026-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1770270112, "sha256": "55e61c9b047e36f0e084d367f6b0bfecc71a6a0527da6eea4f0c687f3584775f"}, + {"filename": "cohere-transcribe-arabic-07-2026-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1972524448, "sha256": "f0fc615d27da3236166cd68cf9968c3be7d257b953bbba3b2e10e5d9f3fd1ff4"}, + {"filename": "cohere-transcribe-arabic-07-2026-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2410655136, "sha256": "910de5c9c57f9fd8a280e1701f9cd96f63768878c1ca9e4ecf23638a5e0fef16"}, + {"filename": "cohere-transcribe-arabic-07-2026-BF16.gguf", "quant": "BF16", "size_bytes": 4105263008, "sha256": "4eade55ddc6ef7d9d1caeee0fa412d161212910c30ee2aa82d95725e0dfdf0a2"}, + {"filename": "cohere-transcribe-arabic-07-2026-F16.gguf", "quant": "F16", "size_bytes": 4106644896, "sha256": "c8a37345c10d1cb3b85d7d1779905e63051f39694bd6fdd5002d9434c09226fc"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -844,6 +502,7 @@ }, { "id": "handy-computer/Fun-ASR-Nano-2512-gguf", + "revision": "30360f003f99929c1e25c3f6222d02eccb04663c", "slug": "Fun-ASR-Nano-2512", "name": "Fun-ASR Nano", "architecture": "funasr_nano", @@ -863,36 +522,12 @@ "speed_score": 73, "accuracy_score": 89, "files": [ - { - "filename": "Fun-ASR-Nano-2512-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 556974848 - }, - { - "filename": "Fun-ASR-Nano-2512-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 631128832 - }, - { - "filename": "Fun-ASR-Nano-2512-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 690744064 - }, - { - "filename": "Fun-ASR-Nano-2512-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 891270912 - }, - { - "filename": "Fun-ASR-Nano-2512-BF16.gguf", - "quant": "BF16", - "size_bytes": 1667503872 - }, - { - "filename": "Fun-ASR-Nano-2512-F16.gguf", - "quant": "F16", - "size_bytes": 1667503872 - } + {"filename": "Fun-ASR-Nano-2512-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 556974848, "sha256": "444885fb7a16211a721e265dfe08c31b31e81fb9e2c4f21ab1079210cd038408"}, + {"filename": "Fun-ASR-Nano-2512-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 631128832, "sha256": "68de7b48ba46b739254fb57486edf7023943cb0af63767ab795d58d6e482096c"}, + {"filename": "Fun-ASR-Nano-2512-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 690744064, "sha256": "894153c04fee18f982823e67b9be6ffd83a9e245f92419b3e8170e26c818273b"}, + {"filename": "Fun-ASR-Nano-2512-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 891270912, "sha256": "681caef6df15a2c0e153b40ca7fe4087fdf65751fa5e6fe605d8a75dff969e61"}, + {"filename": "Fun-ASR-Nano-2512-BF16.gguf", "quant": "BF16", "size_bytes": 1667503872, "sha256": "ee183ddfeb19847e3ee7b80ffcbbae48173330dc167b07955b898a7705c68159"}, + {"filename": "Fun-ASR-Nano-2512-F16.gguf", "quant": "F16", "size_bytes": 1667503872, "sha256": "d754b627ebe8386e565ca9aabc87906d7530d73d2981ec7106fbcc3932febc16"} ], "default_quant": "Q8_0", "recommended": false, @@ -900,6 +535,7 @@ }, { "id": "handy-computer/gigaam-v3-ctc-gguf", + "revision": "c3c611444004820c21c3b68312a41c83c1e4813b", "slug": "gigaam-v3-ctc", "name": "GigaAM v3 CTC", "architecture": "gigaam", @@ -919,36 +555,12 @@ "speed_score": 98, "accuracy_score": 57, "files": [ - { - "filename": "gigaam-v3-ctc-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 182150080 - }, - { - "filename": "gigaam-v3-ctc-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 204563392 - }, - { - "filename": "gigaam-v3-ctc-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 226091968 - }, - { - "filename": "gigaam-v3-ctc-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 271803328 - }, - { - "filename": "gigaam-v3-ctc-F16.gguf", - "quant": "F16", - "size_bytes": 448750528 - }, - { - "filename": "gigaam-v3-ctc-F32.gguf", - "quant": "F32", - "size_bytes": 882913216 - } + {"filename": "gigaam-v3-ctc-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 182150080, "sha256": "16b329fed3eaa197f2191506ac3cad72f46502297ed6a0f915c79d2ac08c404e"}, + {"filename": "gigaam-v3-ctc-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 204563392, "sha256": "2002cd7603b99c318b52a88047d6676e69218083014f19b11c84da0223fb2023"}, + {"filename": "gigaam-v3-ctc-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 226091968, "sha256": "7556f1c8b9ed0f93b3e224d14a954b51cc331c5a3d2be0f5988bd0523b962c7f"}, + {"filename": "gigaam-v3-ctc-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 271803328, "sha256": "71e5c82890e9e243a6bd7575f129f5d1bd2c3ca3ae79aab75cfb1a6934c6a62b"}, + {"filename": "gigaam-v3-ctc-F16.gguf", "quant": "F16", "size_bytes": 448750528, "sha256": "1d958ddf73abf722e320ee36212190e9bdfc6bfdc89778d2e4deeaaeb5bce443"}, + {"filename": "gigaam-v3-ctc-F32.gguf", "quant": "F32", "size_bytes": 882913216, "sha256": "0feb1fdebf57d6b16769391e23d87e8bdba69549b6cf8864cbcbaa3b0f9caab6"} ], "default_quant": "Q8_0", "recommended": false, @@ -956,6 +568,7 @@ }, { "id": "handy-computer/gigaam-v3-e2e-ctc-gguf", + "revision": "075dff81f843cf23d22b4ce943ffdc4dd8650cd7", "slug": "gigaam-v3-e2e-ctc", "name": "GigaAM v3 E2E-CTC", "architecture": "gigaam", @@ -975,36 +588,12 @@ "speed_score": 98, "accuracy_score": 69, "files": [ - { - "filename": "gigaam-v3-e2e-ctc-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 182497888 - }, - { - "filename": "gigaam-v3-e2e-ctc-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 204911200 - }, - { - "filename": "gigaam-v3-e2e-ctc-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 226439776 - }, - { - "filename": "gigaam-v3-e2e-ctc-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 272151136 - }, - { - "filename": "gigaam-v3-e2e-ctc-F16.gguf", - "quant": "F16", - "size_bytes": 449098336 - }, - { - "filename": "gigaam-v3-e2e-ctc-F32.gguf", - "quant": "F32", - "size_bytes": 883603552 - } + {"filename": "gigaam-v3-e2e-ctc-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 182497888, "sha256": "35581e11e048eef785657cff07e5fced794bba6d9c75143257f6452c8aeea655"}, + {"filename": "gigaam-v3-e2e-ctc-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 204911200, "sha256": "d7c6a729f9bae8714d05cb7e66149e662c5098fe10b66206b70f4917f4f84004"}, + {"filename": "gigaam-v3-e2e-ctc-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 226439776, "sha256": "cb517247ae37589c5b281d6d9331ffb32124c77b789affe46a272be1428a5260"}, + {"filename": "gigaam-v3-e2e-ctc-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 272151136, "sha256": "9ccce4750dc813a493d96ca15ee251712bedec15ac9a02fa3d2bd732f08ae5eb"}, + {"filename": "gigaam-v3-e2e-ctc-F16.gguf", "quant": "F16", "size_bytes": 449098336, "sha256": "25a2fd66a50912645307bca1489b8c07e9060ef079b61a7cce0041248cc89b4a"}, + {"filename": "gigaam-v3-e2e-ctc-F32.gguf", "quant": "F32", "size_bytes": 883603552, "sha256": "bf300323d1bc67bee5e9f177a5fae748d78077528c27de73ecc6d7a113e09ec5"} ], "default_quant": "Q8_0", "recommended": false, @@ -1012,6 +601,7 @@ }, { "id": "handy-computer/gigaam-v3-rnnt-gguf", + "revision": "8f30356b4607ae2d79353ab7dc9e5eea6dc46b48", "slug": "gigaam-v3-rnnt", "name": "GigaAM v3 RNN-T", "architecture": "gigaam", @@ -1031,36 +621,12 @@ "speed_score": 96, "accuracy_score": 58, "files": [ - { - "filename": "gigaam-v3-rnnt-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 183246752 - }, - { - "filename": "gigaam-v3-rnnt-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 205690784 - }, - { - "filename": "gigaam-v3-rnnt-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 227252000 - }, - { - "filename": "gigaam-v3-rnnt-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 273022880 - }, - { - "filename": "gigaam-v3-rnnt-F16.gguf", - "quant": "F16", - "size_bytes": 451084832 - }, - { - "filename": "gigaam-v3-rnnt-F32.gguf", - "quant": "F32", - "size_bytes": 887573536 - } + {"filename": "gigaam-v3-rnnt-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 183246752, "sha256": "33b09558330ccfdc5c156e6d6b57c89759a0ac3b653bcdfcd5f1e13f20fc1e76"}, + {"filename": "gigaam-v3-rnnt-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 205690784, "sha256": "3541b4e6b2c3909e99254bcb7c61565137038f24db5651a779885d0c08257348"}, + {"filename": "gigaam-v3-rnnt-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 227252000, "sha256": "ee329ee6ef93b5da62e1915927278a9930b04ac95b2735e7c80e64b725f3f82d"}, + {"filename": "gigaam-v3-rnnt-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 273022880, "sha256": "130d54450be2922144b265d90d453b781fc2b5adaee52a16954b00f3686201f7"}, + {"filename": "gigaam-v3-rnnt-F16.gguf", "quant": "F16", "size_bytes": 451084832, "sha256": "b1fe43be121c64934e464c2320c576d74de1466cb7e91f2d8c2aa1149ebe8572"}, + {"filename": "gigaam-v3-rnnt-F32.gguf", "quant": "F32", "size_bytes": 887573536, "sha256": "6e02744a354fd44e50af9a3cb555de417d7be7163ab79561ad7ff42796990df9"} ], "default_quant": "Q8_0", "recommended": false, @@ -1068,6 +634,7 @@ }, { "id": "handy-computer/gigaam-v3-e2e-rnnt-gguf", + "revision": "f719d70812344f4d0fb8c11c0887b190501a7465", "slug": "gigaam-v3-e2e-rnnt", "name": "GigaAM v3 E2E-RNN-T", "architecture": "gigaam", @@ -1087,36 +654,12 @@ "speed_score": 94, "accuracy_score": 70, "files": [ - { - "filename": "gigaam-v3-e2e-rnnt-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 183948704 - }, - { - "filename": "gigaam-v3-e2e-rnnt-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 206392736 - }, - { - "filename": "gigaam-v3-e2e-rnnt-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 227953952 - }, - { - "filename": "gigaam-v3-e2e-rnnt-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 273724832 - }, - { - "filename": "gigaam-v3-e2e-rnnt-F16.gguf", - "quant": "F16", - "size_bytes": 452381408 - }, - { - "filename": "gigaam-v3-e2e-rnnt-F32.gguf", - "quant": "F32", - "size_bytes": 890138592 - } + {"filename": "gigaam-v3-e2e-rnnt-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 183948704, "sha256": "7d69952fb431a8d7800ed9910dc61fea37d8406bfe96d10bf24c8bd4b7c68623"}, + {"filename": "gigaam-v3-e2e-rnnt-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 206392736, "sha256": "0f5969b05f7e196e8922e243c64963132025a5ae7ec2f83d028ae120d201594f"}, + {"filename": "gigaam-v3-e2e-rnnt-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 227953952, "sha256": "1ef0f771d9ab0b0675c45ce67a3e5b79ac7497fe35ebb08a51704c4ee423edce"}, + {"filename": "gigaam-v3-e2e-rnnt-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 273724832, "sha256": "78d63b47723b7f8d78c6113a6ef983b5a86e2a86f6c273e1f5cb6967b1c4467a"}, + {"filename": "gigaam-v3-e2e-rnnt-F16.gguf", "quant": "F16", "size_bytes": 452381408, "sha256": "87b12fa3523939d3c47c4b427f587e415dda41dcafa9e4ba17f2a1cab2f0d5ad"}, + {"filename": "gigaam-v3-e2e-rnnt-F32.gguf", "quant": "F32", "size_bytes": 890138592, "sha256": "0de6fa626b8a3429dc80e4b34c4b03c0e85b02e2eca4c654efbeb27a8a0a4c5b"} ], "default_quant": "Q8_0", "recommended": false, @@ -1124,6 +667,7 @@ }, { "id": "handy-computer/granite-speech-4.1-2b-nar-gguf", + "revision": "ca53e8273416eb7e888f19bcebbcb9b6ab3edc17", "slug": "granite-speech-4.1-2b-nar", "name": "Granite Speech 4.1 2B NAR", "architecture": "granite_speech_nar", @@ -1143,36 +687,12 @@ "speed_score": 37, "accuracy_score": 92, "files": [ - { - "filename": "granite-speech-4.1-2b-nar-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1560008832 - }, - { - "filename": "granite-speech-4.1-2b-nar-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1782089344 - }, - { - "filename": "granite-speech-4.1-2b-nar-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1977417568 - }, - { - "filename": "granite-speech-4.1-2b-nar-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2498105472 - }, - { - "filename": "granite-speech-4.1-2b-nar-BF16.gguf", - "quant": "BF16", - "size_bytes": 4514736032 - }, - { - "filename": "granite-speech-4.1-2b-nar-F16.gguf", - "quant": "F16", - "size_bytes": 4515792768 - } + {"filename": "granite-speech-4.1-2b-nar-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1560008832, "sha256": "68537a51d4d6d5a6be6e50f735a08af16646afba70fe8ab80dfe60ba7623325f"}, + {"filename": "granite-speech-4.1-2b-nar-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1782089344, "sha256": "88d7c7b5b8b59c95bb6580a1e7d5d81cae63943cef71405ff477527b0bb69fca"}, + {"filename": "granite-speech-4.1-2b-nar-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1977417568, "sha256": "cfdcdf223a8132ab58bd983df43e277d84056f2a4bf2115fdbc1d40d387f42e1"}, + {"filename": "granite-speech-4.1-2b-nar-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2498105472, "sha256": "4762df797b9efa1ce0d73d2f5128517c86fdfdba2e9d00cd5cca97e624760b21"}, + {"filename": "granite-speech-4.1-2b-nar-BF16.gguf", "quant": "BF16", "size_bytes": 4514736032, "sha256": "742575c3cea9ff717ad39b18cf635da7c0453f6238fc01baa687beae223ad7e5"}, + {"filename": "granite-speech-4.1-2b-nar-F16.gguf", "quant": "F16", "size_bytes": 4515792768, "sha256": "433c4925b3c7fdfea037a343eed60e4c9655acc25d293131ec6f71fd42dfe93f"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -1180,6 +700,7 @@ }, { "id": "handy-computer/granite-4.0-1b-speech-gguf", + "revision": "5899f364fb5bc4bae48c54dec8489aff40b70253", "slug": "granite-4.0-1b-speech", "name": "Granite Speech 4.0 1B", "architecture": "granite_speech", @@ -1199,36 +720,12 @@ "speed_score": 31, "accuracy_score": 91, "files": [ - { - "filename": "granite-4.0-1b-speech-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1602904800 - }, - { - "filename": "granite-4.0-1b-speech-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1829704544 - }, - { - "filename": "granite-4.0-1b-speech-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 2024967936 - }, - { - "filename": "granite-4.0-1b-speech-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2559878848 - }, - { - "filename": "granite-4.0-1b-speech-BF16.gguf", - "quant": "BF16", - "size_bytes": 4631640064 - }, - { - "filename": "granite-4.0-1b-speech-F16.gguf", - "quant": "F16", - "size_bytes": 4632623104 - } + {"filename": "granite-4.0-1b-speech-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1602904800, "sha256": "eacd9bc06063a3fdb6bf904a2a82c2646d504922c6e4e5eb1988d61e780884d1"}, + {"filename": "granite-4.0-1b-speech-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1829704544, "sha256": "f5e371d2a894e5b30dce793d573df2a965d7705488750b4ef5a6cad1069e2572"}, + {"filename": "granite-4.0-1b-speech-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 2024967936, "sha256": "b50af8f3c99767f87ccae800589688096678d086daba5e482f6da14e59e2f99c"}, + {"filename": "granite-4.0-1b-speech-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2559878848, "sha256": "1a48716efce9468bc99f33619e15942ae6a262f7ef51883c3c39b519302a533f"}, + {"filename": "granite-4.0-1b-speech-BF16.gguf", "quant": "BF16", "size_bytes": 4631640064, "sha256": "c10ceec82a87949a6192917aab6b75ca2df5a07faed1d05140a06787cae12a76"}, + {"filename": "granite-4.0-1b-speech-F16.gguf", "quant": "F16", "size_bytes": 4632623104, "sha256": "443e5f2cb8ab47dbef8ddaba20823ff5d928662565eb16b2164215f281ec4053"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -1236,6 +733,7 @@ }, { "id": "handy-computer/granite-speech-4.1-2b-gguf", + "revision": "58e7710fd7039ded5a185668eef5f71ca5d9d919", "slug": "granite-speech-4.1-2b", "name": "Granite Speech 4.1 2B", "architecture": "granite_speech", @@ -1255,36 +753,12 @@ "speed_score": 30, "accuracy_score": 92, "files": [ - { - "filename": "granite-speech-4.1-2b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1602904800 - }, - { - "filename": "granite-speech-4.1-2b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1829704544 - }, - { - "filename": "granite-speech-4.1-2b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 2024967936 - }, - { - "filename": "granite-speech-4.1-2b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2559878848 - }, - { - "filename": "granite-speech-4.1-2b-BF16.gguf", - "quant": "BF16", - "size_bytes": 4631640064 - }, - { - "filename": "granite-speech-4.1-2b-F16.gguf", - "quant": "F16", - "size_bytes": 4632623104 - } + {"filename": "granite-speech-4.1-2b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1602904800, "sha256": "3171de08a99315481f0eac1979cbc0684911631d59907b2fe1e189dbcdce135f"}, + {"filename": "granite-speech-4.1-2b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1829704544, "sha256": "63e0d3a82fa6f0f4688af0b7d7ee784864d271b7be820f4ea43c8298c59b0ac5"}, + {"filename": "granite-speech-4.1-2b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 2024967936, "sha256": "f2de8601facb83a5be83aa61198366b2e9cae739ded451c70abcd710f43d8877"}, + {"filename": "granite-speech-4.1-2b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2559878848, "sha256": "8c0b2dce2861feb1dba91e9d59f2ab4a9c23ad808bff86465151576e096ec7a6"}, + {"filename": "granite-speech-4.1-2b-BF16.gguf", "quant": "BF16", "size_bytes": 4631640064, "sha256": "6a0489017b2410b411e62460c20019573f1fb2163f44b2f92a892846d6b0c844"}, + {"filename": "granite-speech-4.1-2b-F16.gguf", "quant": "F16", "size_bytes": 4632623104, "sha256": "28285a4aafc3979a6587084fa0166556e97aaeea55204546036b95f6f0dbb48e"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -1292,6 +766,7 @@ }, { "id": "handy-computer/granite-speech-4.1-2b-plus-gguf", + "revision": "f73a59df77fef89ac0a34bc539d09d756793b065", "slug": "granite-speech-4.1-2b-plus", "name": "Granite Speech 4.1 2B Plus", "architecture": "granite_speech", @@ -1311,36 +786,12 @@ "speed_score": 29, "accuracy_score": 90, "files": [ - { - "filename": "granite-speech-4.1-2b-plus-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1489663424 - }, - { - "filename": "granite-speech-4.1-2b-plus-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1691297088 - }, - { - "filename": "granite-speech-4.1-2b-plus-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1859821504 - }, - { - "filename": "granite-speech-4.1-2b-plus-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2345973152 - }, - { - "filename": "granite-speech-4.1-2b-plus-BF16.gguf", - "quant": "BF16", - "size_bytes": 4228988768 - }, - { - "filename": "granite-speech-4.1-2b-plus-F16.gguf", - "quant": "F16", - "size_bytes": 4229971808 - } + {"filename": "granite-speech-4.1-2b-plus-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1489663424, "sha256": "5d52b6bb9bc2b504120448b4da61aeb383da3c1bcfe88b22c6ed667f2520206f"}, + {"filename": "granite-speech-4.1-2b-plus-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1691297088, "sha256": "3415f2c2d5979ded038dc012e5a875d77602fa5a0699cc75ee7fa47991f42530"}, + {"filename": "granite-speech-4.1-2b-plus-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1859821504, "sha256": "fe281f479ea53ad41d5d1e4d29f1c7174177803717c9b63771416312a3838817"}, + {"filename": "granite-speech-4.1-2b-plus-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2345973152, "sha256": "a2ffe6c8db730f3eb3719ad0ac27016cad30c87b18cd33e214c7b2083e38ce10"}, + {"filename": "granite-speech-4.1-2b-plus-BF16.gguf", "quant": "BF16", "size_bytes": 4228988768, "sha256": "fcf1499c5bcaf71f587337501d198b52c0cf0307c73732b619b2071ed0d2360c"}, + {"filename": "granite-speech-4.1-2b-plus-F16.gguf", "quant": "F16", "size_bytes": 4229971808, "sha256": "a9eba6a2df0d446c8c5020404abe6c9e3da0d077f010f772481ef1f221957c02"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -1348,6 +799,7 @@ }, { "id": "handy-computer/medasr-gguf", + "revision": "6f481df085bb50ae922cea918fb578e664237126", "slug": "medasr", "name": "MedASR", "architecture": "medasr", @@ -1367,36 +819,12 @@ "speed_score": 100, "accuracy_score": 30, "files": [ - { - "filename": "medasr-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 83082432 - }, - { - "filename": "medasr-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 94239936 - }, - { - "filename": "medasr-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 106094784 - }, - { - "filename": "medasr-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 127712448 - }, - { - "filename": "medasr-F16.gguf", - "quant": "F16", - "size_bytes": 211455168 - }, - { - "filename": "medasr-F32.gguf", - "quant": "F32", - "size_bytes": 421301440 - } + {"filename": "medasr-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 83082432, "sha256": "b23b52db7e1e17a5a21c24f3b78add04e1055bbe026b7c7196324c34ab6f3e2f"}, + {"filename": "medasr-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 94239936, "sha256": "873c90a9a78d98446b5e02506073108496df6ff2d062d26f5f7b90f6f1368f1f"}, + {"filename": "medasr-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 106094784, "sha256": "59be9d324cde3a64c813383e7090b3009713e261bf7fdce90b48e2f7bcbc28fa"}, + {"filename": "medasr-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 127712448, "sha256": "5a391a2154416b96241b829ac2c7eed8b64d197f00372512c9bbf63f4849c978"}, + {"filename": "medasr-F16.gguf", "quant": "F16", "size_bytes": 211455168, "sha256": "70e51cf5da1d3ea0838cf6d94a19f3dc1fdf28b24d8665589f43c50eaf429580"}, + {"filename": "medasr-F32.gguf", "quant": "F32", "size_bytes": 421301440, "sha256": "4b1b5b3a3092b96299624788b4eb28d04f7c66c447f0953c0216285c518f2ed5"} ], "default_quant": "Q8_0", "recommended": false, @@ -1404,6 +832,7 @@ }, { "id": "handy-computer/moonshine-streaming-tiny-gguf", + "revision": "85ddff612fa3a2cf40b2f745abcfa90ef82f293b", "slug": "moonshine-streaming-tiny", "name": "Moonshine Streaming Tiny", "architecture": "moonshine_streaming", @@ -1423,21 +852,9 @@ "speed_score": 100, "accuracy_score": 74, "files": [ - { - "filename": "moonshine-streaming-tiny-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 50462816 - }, - { - "filename": "moonshine-streaming-tiny-F16.gguf", - "quant": "F16", - "size_bytes": 89784416 - }, - { - "filename": "moonshine-streaming-tiny-F32.gguf", - "quant": "F32", - "size_bytes": 177817696 - } + {"filename": "moonshine-streaming-tiny-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 50462816, "sha256": "930e4622ad3a24158b91406c30c977fa6a26b34cb32d6ac3e57cfb23383a869e"}, + {"filename": "moonshine-streaming-tiny-F16.gguf", "quant": "F16", "size_bytes": 89784416, "sha256": "cfe64aacd68b0c4244369c04dab9828e324f005e4799af5ec33bbc1d37ebd681"}, + {"filename": "moonshine-streaming-tiny-F32.gguf", "quant": "F32", "size_bytes": 177817696, "sha256": "38ad83ec7a9db0b23be02f71fea1615ef8403eb28e79a915668d26026415d5f6"} ], "default_quant": "Q8_0", "recommended": false, @@ -1445,6 +862,7 @@ }, { "id": "handy-computer/moonshine-tiny-gguf", + "revision": "f5c11906eba3f44cf305eed30feb9cbfb0b4b9d0", "slug": "moonshine-tiny", "name": "Moonshine Tiny", "architecture": "moonshine", @@ -1464,21 +882,9 @@ "speed_score": 100, "accuracy_score": 74, "files": [ - { - "filename": "moonshine-tiny-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466912 - }, - { - "filename": "moonshine-tiny-F16.gguf", - "quant": "F16", - "size_bytes": 59244192 - }, - { - "filename": "moonshine-tiny-F32.gguf", - "quant": "F32", - "size_bytes": 109969056 - } + {"filename": "moonshine-tiny-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466912, "sha256": "2fd348d7b38f97d309cc3ec6848f3f57f537b80244950f07d2637e463f95a3a1"}, + {"filename": "moonshine-tiny-F16.gguf", "quant": "F16", "size_bytes": 59244192, "sha256": "d115694627d146c347ac5209db69db48b3125b07971026a6119b1888db7563d3"}, + {"filename": "moonshine-tiny-F32.gguf", "quant": "F32", "size_bytes": 109969056, "sha256": "67e50023e13f295d6efb8500b69a538019ff7c2dd4b577e425260048e8035418"} ], "default_quant": "Q8_0", "recommended": false, @@ -1486,6 +892,7 @@ }, { "id": "handy-computer/moonshine-tiny-ar-gguf", + "revision": "2360cb6893cb523da51875eaffc8050d2ca7b56d", "slug": "moonshine-tiny-ar", "name": "Moonshine Tiny (Arabic)", "architecture": "moonshine", @@ -1505,21 +912,9 @@ "speed_score": 100, "accuracy_score": 17, "files": [ - { - "filename": "moonshine-tiny-ar-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-ar-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-ar-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-ar-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "e0bfa7c3aad2fe7acce022566daa1bb6140b6b263ba79655241acec7f7e0000b"}, + {"filename": "moonshine-tiny-ar-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "209dd912f51f04a67977d0d93ea7363b0190786c3d7e1a2fbbbd0e94c4001aad"}, + {"filename": "moonshine-tiny-ar-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "39cacdf5c240930cca65c06eb176eb49c25aa59dd2a425cc8c04a0a14bc9cc64"} ], "default_quant": "Q8_0", "recommended": false, @@ -1527,6 +922,7 @@ }, { "id": "handy-computer/moonshine-tiny-ja-gguf", + "revision": "627aae63193d6c47cbdf316346119dcdc65d9b0d", "slug": "moonshine-tiny-ja", "name": "Moonshine Tiny (Japanese)", "architecture": "moonshine", @@ -1546,21 +942,9 @@ "speed_score": 100, "accuracy_score": 41, "files": [ - { - "filename": "moonshine-tiny-ja-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-ja-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-ja-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-ja-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "b47da12b2238cd176d9deebf31d49803c9eb4e99de5fd3abf672b4c0e2773c26"}, + {"filename": "moonshine-tiny-ja-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "5a8b7960b119d1d2a947509ca9b4be6e71f6759973d5c200283335bfddd8cb43"}, + {"filename": "moonshine-tiny-ja-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "4c87575b72de88ba148ffd0fa87c8cde20e0761974a6fed458822131a71e239b"} ], "default_quant": "Q8_0", "recommended": false, @@ -1568,6 +952,7 @@ }, { "id": "handy-computer/moonshine-tiny-ko-gguf", + "revision": "b858a303948b69c8b2442f700e936a07ba5f64da", "slug": "moonshine-tiny-ko", "name": "Moonshine Tiny (Korean)", "architecture": "moonshine", @@ -1587,21 +972,9 @@ "speed_score": 100, "accuracy_score": 55, "files": [ - { - "filename": "moonshine-tiny-ko-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-ko-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-ko-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-ko-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "3ffe3340ceed10843789e2f7bf580097213c97a3537cae7031dbfe2a409b36b7"}, + {"filename": "moonshine-tiny-ko-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "4228c8e92d6308166f96708de35af09318d624c3c1495e80eb8f6180790edd5d"}, + {"filename": "moonshine-tiny-ko-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "1a13e4a0b7a97c9974ca1f88b275f8e43bf6453225cf549712efe4ec7aa1ad40"} ], "default_quant": "Q8_0", "recommended": false, @@ -1609,6 +982,7 @@ }, { "id": "handy-computer/moonshine-tiny-uk-gguf", + "revision": "b4c991eb4223caa4de770843185476b0c86056b4", "slug": "moonshine-tiny-uk", "name": "Moonshine Tiny (Ukrainian)", "architecture": "moonshine", @@ -1628,21 +1002,9 @@ "speed_score": 100, "accuracy_score": 28, "files": [ - { - "filename": "moonshine-tiny-uk-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-uk-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-uk-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-uk-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "61c372f6d99cce6c7b7c45626a51a6c935217368e715d9c4e036ce85f4496338"}, + {"filename": "moonshine-tiny-uk-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "08521642651a398a6c7afac28395ce5cc6938d30f9563bde4c13fe898438ff69"}, + {"filename": "moonshine-tiny-uk-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "7c2bf9f4562f9c094f12fe73d490d03dc2971bc7f31bf0b2e5b3b10b92330610"} ], "default_quant": "Q8_0", "recommended": false, @@ -1650,6 +1012,7 @@ }, { "id": "handy-computer/moonshine-tiny-vi-gguf", + "revision": "d97be0111a74a9058689beacc05b38969d2b68aa", "slug": "moonshine-tiny-vi", "name": "Moonshine Tiny (Vietnamese)", "architecture": "moonshine", @@ -1669,21 +1032,9 @@ "speed_score": 100, "accuracy_score": 42, "files": [ - { - "filename": "moonshine-tiny-vi-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-vi-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-vi-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-vi-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "a42a80ef2e3636104aa2aff87cdde3e251e319e7d0d1acb3fb594cc601d8b95f"}, + {"filename": "moonshine-tiny-vi-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "94464f474fc4a574951e335a731fabaf97a2e517a96bf4fcfd82791a67fa40ce"}, + {"filename": "moonshine-tiny-vi-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "7423501769416a83eb4763ea85be58e4c20ca629d9181f522a2a2da398b9c194"} ], "default_quant": "Q8_0", "recommended": false, @@ -1691,6 +1042,7 @@ }, { "id": "handy-computer/moonshine-tiny-zh-gguf", + "revision": "2aca379ba0dc978d878b84b76231d5ba5550e738", "slug": "moonshine-tiny-zh", "name": "Moonshine Tiny (Chinese)", "architecture": "moonshine", @@ -1710,21 +1062,9 @@ "speed_score": 100, "accuracy_score": 40, "files": [ - { - "filename": "moonshine-tiny-zh-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 35466944 - }, - { - "filename": "moonshine-tiny-zh-F16.gguf", - "quant": "F16", - "size_bytes": 59244224 - }, - { - "filename": "moonshine-tiny-zh-F32.gguf", - "quant": "F32", - "size_bytes": 109969088 - } + {"filename": "moonshine-tiny-zh-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 35466944, "sha256": "95387abe90625410f8e2bcec43faaaadc0d92ef1e721117eb8a69cd31a7ef692"}, + {"filename": "moonshine-tiny-zh-F16.gguf", "quant": "F16", "size_bytes": 59244224, "sha256": "33c59e565336f1b8d6da86cb6d6321e8797bb65e9bf11947258e7340d3dcd968"}, + {"filename": "moonshine-tiny-zh-F32.gguf", "quant": "F32", "size_bytes": 109969088, "sha256": "43cb182cedd45d7117846bb540bb2b33ee200ec35095b9f7a697ea3ad3c9602f"} ], "default_quant": "Q8_0", "recommended": false, @@ -1732,6 +1072,7 @@ }, { "id": "handy-computer/moonshine-base-gguf", + "revision": "3ef112378a8cf46ac8b278d9bfa2d15c846704b8", "slug": "moonshine-base", "name": "Moonshine Base", "architecture": "moonshine", @@ -1751,21 +1092,9 @@ "speed_score": 99, "accuracy_score": 80, "files": [ - { - "filename": "moonshine-base-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "7f0027dfd857d310b63a85ef57cadf183da712cc374f85a648f8bc18aaa2efc8"}, + {"filename": "moonshine-base-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "c7a158ae0ad47c887041372e97c874e98c11eb0d8eff01279723dde65b358702"}, + {"filename": "moonshine-base-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "b19a36db5a91e4f25cc527c5ed87f774fd037aee8ceec774397c19b7198199fb"} ], "default_quant": "Q8_0", "recommended": false, @@ -1773,6 +1102,7 @@ }, { "id": "handy-computer/moonshine-base-ar-gguf", + "revision": "1ae85af52b16eac8bcebda0287e6195ed1956e86", "slug": "moonshine-base-ar", "name": "Moonshine Base (Arabic)", "architecture": "moonshine", @@ -1792,21 +1122,9 @@ "speed_score": 99, "accuracy_score": 20, "files": [ - { - "filename": "moonshine-base-ar-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-ar-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-ar-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-ar-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "1a5b34653dfff0917d5bc7de1aeafca674162a5efa674472358d1b7f1644cf73"}, + {"filename": "moonshine-base-ar-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "9a70242ad42fb130a417c11127f5e7ad227c03b0c4a287fa60757efa3cccd714"}, + {"filename": "moonshine-base-ar-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "5c0f54279e41c195c5b5c288cdee94b0960bdc8be518dfd22aac11850254fb2b"} ], "default_quant": "Q8_0", "recommended": false, @@ -1814,6 +1132,7 @@ }, { "id": "handy-computer/moonshine-base-ja-gguf", + "revision": "aac5cfff17ae28b6f17ae790a3e309ae0ca5911c", "slug": "moonshine-base-ja", "name": "Moonshine Base (Japanese)", "architecture": "moonshine", @@ -1833,21 +1152,9 @@ "speed_score": 99, "accuracy_score": 50, "files": [ - { - "filename": "moonshine-base-ja-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-ja-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-ja-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-ja-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "8ee6e9d7c9df69a3ea654af77659f1c0c08bf90998c2e2a269826096ffafc4f0"}, + {"filename": "moonshine-base-ja-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "a3fb02bb2f6d6b5082818fb60cbc816a20e3865502adca2e1fd94068b29e5d2b"}, + {"filename": "moonshine-base-ja-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "649f65dbc795d9fd4cf23f2123a09cdcdffba7e071891caa39e60d6a84c19082"} ], "default_quant": "Q8_0", "recommended": false, @@ -1855,6 +1162,7 @@ }, { "id": "handy-computer/moonshine-base-ko-gguf", + "revision": "03813c71abe85b40cd0671d3cfa420e831d7e333", "slug": "moonshine-base-ko", "name": "Moonshine Base (Korean)", "architecture": "moonshine", @@ -1874,21 +1182,9 @@ "speed_score": 99, "accuracy_score": 58, "files": [ - { - "filename": "moonshine-base-ko-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-ko-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-ko-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-ko-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "b430672cfdde98b894bd98adf1a8b3470308fea39ad1e6bb0f54d8fc9e114471"}, + {"filename": "moonshine-base-ko-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "79b29fd6803723b7884824710aebef743f068e2db0fc3ad0488bc16b05b69c2c"}, + {"filename": "moonshine-base-ko-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "7b4a358a883537ce5584f867c683f1850027ddb842c7cdb526b6b4704402eeb1"} ], "default_quant": "Q8_0", "recommended": false, @@ -1896,6 +1192,7 @@ }, { "id": "handy-computer/moonshine-base-uk-gguf", + "revision": "d6ec586909c5209c0c17243dc4f5e55164f85dfb", "slug": "moonshine-base-uk", "name": "Moonshine Base (Ukrainian)", "architecture": "moonshine", @@ -1915,21 +1212,9 @@ "speed_score": 99, "accuracy_score": 38, "files": [ - { - "filename": "moonshine-base-uk-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476512 - }, - { - "filename": "moonshine-base-uk-F16.gguf", - "quant": "F16", - "size_bytes": 131789472 - }, - { - "filename": "moonshine-base-uk-F32.gguf", - "quant": "F32", - "size_bytes": 247657120 - } + {"filename": "moonshine-base-uk-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476512, "sha256": "6dcac8c3905706e8c8416f531a479eccaa38cdaf1eae439f776fda4d4faa88d3"}, + {"filename": "moonshine-base-uk-F16.gguf", "quant": "F16", "size_bytes": 131789472, "sha256": "e603b1e70da31060b0094f34732996156ec8e89cb1068e2f2cf64a91c3ea6cff"}, + {"filename": "moonshine-base-uk-F32.gguf", "quant": "F32", "size_bytes": 247657120, "sha256": "2d230e76f32e4e9e818b53c0abe70cfb65c15e1ee28170bcc324bce0470e7c4b"} ], "default_quant": "Q8_0", "recommended": false, @@ -1937,6 +1222,7 @@ }, { "id": "handy-computer/moonshine-base-vi-gguf", + "revision": "76ccec93f5854ae16d2dab72d5b762fe10c0df81", "slug": "moonshine-base-vi", "name": "Moonshine Base (Vietnamese)", "architecture": "moonshine", @@ -1956,21 +1242,9 @@ "speed_score": 99, "accuracy_score": 52, "files": [ - { - "filename": "moonshine-base-vi-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476512 - }, - { - "filename": "moonshine-base-vi-F16.gguf", - "quant": "F16", - "size_bytes": 131789472 - }, - { - "filename": "moonshine-base-vi-F32.gguf", - "quant": "F32", - "size_bytes": 247657120 - } + {"filename": "moonshine-base-vi-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476512, "sha256": "d382ec7ac6aa821d25b921ab982bf1bf29da591cad73372b65209c6a74decb36"}, + {"filename": "moonshine-base-vi-F16.gguf", "quant": "F16", "size_bytes": 131789472, "sha256": "1406f7c80cad755f93d4915d21fbcf5e65f19d4c93e8a3966f970a6a4d6f7068"}, + {"filename": "moonshine-base-vi-F32.gguf", "quant": "F32", "size_bytes": 247657120, "sha256": "b04a66041ac50f0274668e17ec62a9ec93bdae23af39b98cc0923fbca2ff2c72"} ], "default_quant": "Q8_0", "recommended": false, @@ -1978,6 +1252,7 @@ }, { "id": "handy-computer/moonshine-base-zh-gguf", + "revision": "64385c681d79767be73ce8591b4609854ac750c9", "slug": "moonshine-base-zh", "name": "Moonshine Base (Chinese)", "architecture": "moonshine", @@ -1997,21 +1272,9 @@ "speed_score": 99, "accuracy_score": 32, "files": [ - { - "filename": "moonshine-base-zh-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 77476480 - }, - { - "filename": "moonshine-base-zh-F16.gguf", - "quant": "F16", - "size_bytes": 131789440 - }, - { - "filename": "moonshine-base-zh-F32.gguf", - "quant": "F32", - "size_bytes": 247657088 - } + {"filename": "moonshine-base-zh-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 77476480, "sha256": "1bae1d753b29b0c34038585b576d28e4f262ed10c6c7217e023fa5d624dc15ad"}, + {"filename": "moonshine-base-zh-F16.gguf", "quant": "F16", "size_bytes": 131789440, "sha256": "bead0492ab45cee75411d5e59e95a5e796b86ab6cde3bfee4613c02cba9f2e35"}, + {"filename": "moonshine-base-zh-F32.gguf", "quant": "F32", "size_bytes": 247657088, "sha256": "1a54db0bec02f81730e6378b185f807cdf9065a477c7171f01914ca917408673"} ], "default_quant": "Q8_0", "recommended": false, @@ -2019,6 +1282,7 @@ }, { "id": "handy-computer/moonshine-streaming-small-gguf", + "revision": "41444173ed8210852a883e046fadcfba3e7bfbae", "slug": "moonshine-streaming-small", "name": "Moonshine Streaming Small", "architecture": "moonshine_streaming", @@ -2038,21 +1302,9 @@ "speed_score": 95, "accuracy_score": 84, "files": [ - { - "filename": "moonshine-streaming-small-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 198506848 - }, - { - "filename": "moonshine-streaming-small-F16.gguf", - "quant": "F16", - "size_bytes": 282092128 - }, - { - "filename": "moonshine-streaming-small-F32.gguf", - "quant": "F32", - "size_bytes": 562146912 - } + {"filename": "moonshine-streaming-small-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 198506848, "sha256": "d03670f69629b649085d0f44a63d97668b4119117cc9611a4e4ad94341713dfc"}, + {"filename": "moonshine-streaming-small-F16.gguf", "quant": "F16", "size_bytes": 282092128, "sha256": "4e0b872dd11072c3b6ae06a25e832d4eb29281ae0d6931992d1c58889c65eb2f"}, + {"filename": "moonshine-streaming-small-F32.gguf", "quant": "F32", "size_bytes": 562146912, "sha256": "cb231274a2c402d42376e5f25378d17f8b229f530cb06cef6e6cf812d39a5dc3"} ], "default_quant": "Q8_0", "recommended": false, @@ -2060,6 +1312,7 @@ }, { "id": "handy-computer/moonshine-streaming-medium-gguf", + "revision": "c722a9455a40a1844c3d25267dc84eff61d8dd84", "slug": "moonshine-streaming-medium", "name": "Moonshine Streaming Medium", "architecture": "moonshine_streaming", @@ -2079,21 +1332,9 @@ "speed_score": 83, "accuracy_score": 87, "files": [ - { - "filename": "moonshine-streaming-medium-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 295793568 - }, - { - "filename": "moonshine-streaming-medium-F16.gguf", - "quant": "F16", - "size_bytes": 533781408 - }, - { - "filename": "moonshine-streaming-medium-F32.gguf", - "quant": "F32", - "size_bytes": 1065204640 - } + {"filename": "moonshine-streaming-medium-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 295793568, "sha256": "f7c9564249b508f6012927ec4f9e536087da53a7047f858ca9975bea5f75299e"}, + {"filename": "moonshine-streaming-medium-F16.gguf", "quant": "F16", "size_bytes": 533781408, "sha256": "cf2ab2c366df44e8885e1c127c23275dc9a0aa0d10557c2fec325506058b9de8"}, + {"filename": "moonshine-streaming-medium-F32.gguf", "quant": "F32", "size_bytes": 1065204640, "sha256": "70b4fad683358feba54631e440b4ee9d813a416e239c4cf163f422189dfef03f"} ], "default_quant": "Q8_0", "recommended": false, @@ -2101,6 +1342,7 @@ }, { "id": "handy-computer/nemotron-speech-streaming-en-0.6b-gguf", + "revision": "7d9b719206789e4068d87c6398262ab4dfd4e45d", "slug": "nemotron-speech-streaming-en-0.6b", "name": "Nemotron Speech Streaming EN", "architecture": "parakeet", @@ -2120,36 +1362,12 @@ "speed_score": 80, "accuracy_score": 86, "files": [ - { - "filename": "nemotron-speech-streaming-en-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 475436032 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 538989568 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 600420352 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 729650176 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1237652608 - }, - { - "filename": "nemotron-speech-streaming-en-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2472386176 - } + {"filename": "nemotron-speech-streaming-en-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 475436032, "sha256": "dc959ca31499b114e395c44eb4f0778968f20e5cfb03305a08a39925b2da8e1e"}, + {"filename": "nemotron-speech-streaming-en-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 538989568, "sha256": "d6b8b36f9aca6a751779d064a29f6583f3e82c0c15646904383c8436a25c1489"}, + {"filename": "nemotron-speech-streaming-en-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 600420352, "sha256": "b31fcc5e9f3b00cb33c10ff0f05d7e71265f160eeed6b497a65cd296dddd21f3"}, + {"filename": "nemotron-speech-streaming-en-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 729650176, "sha256": "90d8c89714cd31efc88be62a40c6b2bea57e0cc2063af1ffe2c28f1a228ca110"}, + {"filename": "nemotron-speech-streaming-en-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1237652608, "sha256": "dc8c4bd7dce6e1805b4e00bef391c3870aca4fa99a563ec02a6f5e0f15d2f489"}, + {"filename": "nemotron-speech-streaming-en-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2472386176, "sha256": "53bdc1d4d21e419d4da513d2df31ce88784e25cc908841386490658854b13b41"} ], "default_quant": "Q8_0", "recommended": false, @@ -2157,6 +1375,7 @@ }, { "id": "handy-computer/parakeet-tdt_ctc-110m-gguf", + "revision": "9d66d34f9e1594075c5dd72c90c0f4c321b29f21", "slug": "parakeet-tdt_ctc-110m", "name": "Parakeet TDT-CTC 110M", "architecture": "parakeet", @@ -2176,36 +1395,12 @@ "speed_score": 98, "accuracy_score": 85, "files": [ - { - "filename": "parakeet-tdt_ctc-110m-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 89989600 - }, - { - "filename": "parakeet-tdt_ctc-110m-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 101335520 - }, - { - "filename": "parakeet-tdt_ctc-110m-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 112311264 - }, - { - "filename": "parakeet-tdt_ctc-110m-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 135373280 - }, - { - "filename": "parakeet-tdt_ctc-110m-F16.gguf", - "quant": "F16", - "size_bytes": 229334560 - }, - { - "filename": "parakeet-tdt_ctc-110m-F32.gguf", - "quant": "F32", - "size_bytes": 456524064 - } + {"filename": "parakeet-tdt_ctc-110m-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 89989600, "sha256": "486414fd90185a8c8a4ced7c123cfb133ff4f7958426c6b8bd9049946b56b448"}, + {"filename": "parakeet-tdt_ctc-110m-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 101335520, "sha256": "1552e707fddb59e3741b66ea917fc91a9381e336a4730739b1d2d448cb013a2e"}, + {"filename": "parakeet-tdt_ctc-110m-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 112311264, "sha256": "c20520c245adf82e5166005f599cb3b95e7cf5192117e845be4bbcd39226d483"}, + {"filename": "parakeet-tdt_ctc-110m-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 135373280, "sha256": "7dd44c74a331d788a4e5f8b16913b3feb29ced22cf5613aad0e0f6cd30516296"}, + {"filename": "parakeet-tdt_ctc-110m-F16.gguf", "quant": "F16", "size_bytes": 229334560, "sha256": "965cc3f8e171286ecb4d4e6646b407a1782a97da9aed22f88d1e0ec6c96b2358"}, + {"filename": "parakeet-tdt_ctc-110m-F32.gguf", "quant": "F32", "size_bytes": 456524064, "sha256": "99701a7de4b2435e63efdf43db060f3dce723294fe028afc6f0852f406060d93"} ], "default_quant": "Q8_0", "recommended": false, @@ -2213,6 +1408,7 @@ }, { "id": "handy-computer/multitalker-parakeet-streaming-0.6b-v1-gguf", + "revision": "0314bc0d2f4be9a0d79fa5eeb36ad1f3962cfcbb", "slug": "multitalker-parakeet-streaming-0.6b-v1", "name": "Multitalker Parakeet Streaming EN", "architecture": "parakeet", @@ -2232,36 +1428,12 @@ "speed_score": 96, "accuracy_score": 86, "files": [ - { - "filename": "multitalker-parakeet-streaming-0.6b-v1-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 477812416 - }, - { - "filename": "multitalker-parakeet-streaming-0.6b-v1-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 541890240 - }, - { - "filename": "multitalker-parakeet-streaming-0.6b-v1-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 603878080 - }, - { - "filename": "multitalker-parakeet-streaming-0.6b-v1-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 734123712 - }, - { - "filename": "multitalker-parakeet-streaming-0.6b-v1-F16.gguf", - "quant": "F16", - "size_bytes": 1246058304 - }, - { - "filename": "multitalker-parakeet-streaming-0.6b-v1-F32.gguf", - "quant": "F32", - "size_bytes": 2489180480 - } + {"filename": "multitalker-parakeet-streaming-0.6b-v1-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 477812416, "sha256": "d9f05f95ab70054f37af71aae7bc64d37eabfc2d266935224a827cb0d838bb4f"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 541890240, "sha256": "247a14c84dc6d0e7d4f5604b6f2b89b927af2e49eb0ed9fe350b447e849acf01"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 603878080, "sha256": "c5ad23c5e59e3f73d5adebc963992818109d1ecb272f5c862b2355bc41950dac"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 734123712, "sha256": "4748deea0222eb9057ebdac1679eebb970bbe882ee10a96ae34596ffe83615c7"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-F16.gguf", "quant": "F16", "size_bytes": 1246058304, "sha256": "d2b3cbd7d202f97dd7d91da228a70cc47d5f60ba57c8431a37462b2c2d035ba7"}, + {"filename": "multitalker-parakeet-streaming-0.6b-v1-F32.gguf", "quant": "F32", "size_bytes": 2489180480, "sha256": "a73ea5c34cffd714e97ef6de22db8e9437b6c0265f86f8bd31825fd2b0f49e6c"} ], "default_quant": "Q8_0", "recommended": false, @@ -2269,6 +1441,7 @@ }, { "id": "handy-computer/parakeet-ctc-0.6b-gguf", + "revision": "cdc56f0467ec675a9509a46ef2e83f4c9e49af94", "slug": "parakeet-ctc-0.6b", "name": "Parakeet CTC 0.6B", "architecture": "parakeet", @@ -2288,36 +1461,12 @@ "speed_score": 94, "accuracy_score": 88, "files": [ - { - "filename": "parakeet-ctc-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 469302464 - }, - { - "filename": "parakeet-ctc-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 532544704 - }, - { - "filename": "parakeet-ctc-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 593644736 - }, - { - "filename": "parakeet-ctc-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 722271424 - }, - { - "filename": "parakeet-ctc-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1220181184 - }, - { - "filename": "parakeet-ctc-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2435482816 - } + {"filename": "parakeet-ctc-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 469302464, "sha256": "2a0dc545d06184b30cd3441a38811f09867a1d6b7b89f008f16bd75c1030ccdb"}, + {"filename": "parakeet-ctc-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 532544704, "sha256": "8837b9489682f870dae15e126940956d4c0a03fbe884d82edcd8b8c8b1dc0f22"}, + {"filename": "parakeet-ctc-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 593644736, "sha256": "3ecb616005ea4bffa67fb5e94a02f155a48b52cb40a466af7982a4a4173692f6"}, + {"filename": "parakeet-ctc-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 722271424, "sha256": "e47ae86c1b34dbaf054334592575c29aee7de3546e75d1f0ccee05904c5c49ae"}, + {"filename": "parakeet-ctc-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1220181184, "sha256": "9529e692dce3cd2c84dd63c576095d02cbed245fb4ccbe264783fdf59677f1b5"}, + {"filename": "parakeet-ctc-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2435482816, "sha256": "2956c41281192c0657da23ab24ab20a961278e3a4e8e038eab2cdb33145acd75"} ], "default_quant": "Q8_0", "recommended": false, @@ -2325,6 +1474,7 @@ }, { "id": "handy-computer/parakeet-rnnt-0.6b-gguf", + "revision": "6001ebcc1c64dd821cf70b7b4ffdd4d18097760a", "slug": "parakeet-rnnt-0.6b", "name": "Parakeet RNN-T 0.6B", "architecture": "parakeet", @@ -2344,36 +1494,12 @@ "speed_score": 84, "accuracy_score": 90, "files": [ - { - "filename": "parakeet-rnnt-0.6b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 476390816 - }, - { - "filename": "parakeet-rnnt-0.6b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 539714976 - }, - { - "filename": "parakeet-rnnt-0.6b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 600902048 - }, - { - "filename": "parakeet-rnnt-0.6b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 729687456 - }, - { - "filename": "parakeet-rnnt-0.6b-F16.gguf", - "quant": "F16", - "size_bytes": 1235969568 - }, - { - "filename": "parakeet-rnnt-0.6b-F32.gguf", - "quant": "F32", - "size_bytes": 2467033120 - } + {"filename": "parakeet-rnnt-0.6b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 476390816, "sha256": "70b32229e2b576a3be66176ebc484476f3d715d911c11a4e17167790ce378cf7"}, + {"filename": "parakeet-rnnt-0.6b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 539714976, "sha256": "b2910e4f81f35cc99ce830847040ac469c07aa27d289e9e517d6b73b47a4d7d5"}, + {"filename": "parakeet-rnnt-0.6b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 600902048, "sha256": "fcc8fd8e1b21311eefcc6ebabc2cc059df30cbbd296dc3013aa7727f070a3530"}, + {"filename": "parakeet-rnnt-0.6b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 729687456, "sha256": "06da5ba773736984e0513037b27ff9300e95c55eaa2b79dbc688efc34b25df51"}, + {"filename": "parakeet-rnnt-0.6b-F16.gguf", "quant": "F16", "size_bytes": 1235969568, "sha256": "5adef2718a57b02745db5717c245b90aae8b450139ccbfb2cf5ed2fb955192c0"}, + {"filename": "parakeet-rnnt-0.6b-F32.gguf", "quant": "F32", "size_bytes": 2467033120, "sha256": "9312f95f6a2271eae0f04d4e499647bfb8dcf1447e41d77709435f8d4df61f52"} ], "default_quant": "Q8_0", "recommended": false, @@ -2381,6 +1507,7 @@ }, { "id": "handy-computer/parakeet-ctc-1.1b-gguf", + "revision": "a9607fbeb480b4a66a3e8bd77efc490e8410cdd4", "slug": "parakeet-ctc-1.1b", "name": "Parakeet CTC 1.1B", "architecture": "parakeet", @@ -2400,36 +1527,12 @@ "speed_score": 83, "accuracy_score": 88, "files": [ - { - "filename": "parakeet-ctc-1.1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 818156576 - }, - { - "filename": "parakeet-ctc-1.1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 928584736 - }, - { - "filename": "parakeet-ctc-1.1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1035248672 - }, - { - "filename": "parakeet-ctc-1.1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1259869216 - }, - { - "filename": "parakeet-ctc-1.1b-F16.gguf", - "quant": "F16", - "size_bytes": 2129368096 - }, - { - "filename": "parakeet-ctc-1.1b-F32.gguf", - "quant": "F32", - "size_bytes": 4250639392 - } + {"filename": "parakeet-ctc-1.1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 818156576, "sha256": "9e4c104086067ee3afefbf7d41929024261682926cf0a57cc5a1f557fc5b69e5"}, + {"filename": "parakeet-ctc-1.1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 928584736, "sha256": "8b38f60bb1c56b518953a39703068adc09991993b5aab52782e038a86a6c04cc"}, + {"filename": "parakeet-ctc-1.1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1035248672, "sha256": "23a6fd82f39cba5ef97873cb1228dbe7acc2ffd6425276ad668c2d48b020b6eb"}, + {"filename": "parakeet-ctc-1.1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1259869216, "sha256": "4f66c66bdef2c6901384a218a01d8a614698010e880bbdfe44f3bcf0b11ae0f2"}, + {"filename": "parakeet-ctc-1.1b-F16.gguf", "quant": "F16", "size_bytes": 2129368096, "sha256": "1f4e4e6301d341655cf35f329852c7211a3fbcfa7564afd1dc9384b5142d3e32"}, + {"filename": "parakeet-ctc-1.1b-F32.gguf", "quant": "F32", "size_bytes": 4250639392, "sha256": "dbbc3ed2ccf7a0633b4aaa18e930113973f79dd98a2f2f79b5d4f1ef0db4ff05"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2437,6 +1540,7 @@ }, { "id": "handy-computer/parakeet-tdt-1.1b-gguf", + "revision": "8c21810615694c53a4f4745996190fcca880f8e5", "slug": "parakeet-tdt-1.1b", "name": "Parakeet TDT 1.1B", "architecture": "parakeet", @@ -2456,36 +1560,12 @@ "speed_score": 76, "accuracy_score": 91, "files": [ - { - "filename": "parakeet-tdt-1.1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 825248416 - }, - { - "filename": "parakeet-tdt-1.1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 935758496 - }, - { - "filename": "parakeet-tdt-1.1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1042509472 - }, - { - "filename": "parakeet-tdt-1.1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1267288736 - }, - { - "filename": "parakeet-tdt-1.1b-F16.gguf", - "quant": "F16", - "size_bytes": 2145162976 - }, - { - "filename": "parakeet-tdt-1.1b-F32.gguf", - "quant": "F32", - "size_bytes": 4282202592 - } + {"filename": "parakeet-tdt-1.1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 825248416, "sha256": "38e85773763afe7b79c8538fbd091367b4644123358ea209eeb665879e3fcb13"}, + {"filename": "parakeet-tdt-1.1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 935758496, "sha256": "18c22888fed10676fde72fdd5b833fab9175a6abf4e0cb390eb0a59b0b107cd3"}, + {"filename": "parakeet-tdt-1.1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1042509472, "sha256": "7481b215629957031205893c99e9b6472278bc713354f723c682562c39ef52f4"}, + {"filename": "parakeet-tdt-1.1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1267288736, "sha256": "8479e1ed0b7244e293ed81f547c69074a38c00e17511d8ecae2d273bc7b2ceda"}, + {"filename": "parakeet-tdt-1.1b-F16.gguf", "quant": "F16", "size_bytes": 2145162976, "sha256": "5d5e36992fa5102a1eac29c4356b51e6de8577c5d8e55e250cfd792a018dc8f2"}, + {"filename": "parakeet-tdt-1.1b-F32.gguf", "quant": "F32", "size_bytes": 4282202592, "sha256": "627ba3def22f056fa48d62bcdb70455b75c81d5fb7f30028f63682fb9ffe1684"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2493,6 +1573,7 @@ }, { "id": "handy-computer/parakeet-rnnt-1.1b-gguf", + "revision": "58c3f5108699a6aba6d6dbb21f8fa153fe7d3f62", "slug": "parakeet-rnnt-1.1b", "name": "Parakeet RNN-T 1.1B", "architecture": "parakeet", @@ -2512,36 +1593,12 @@ "speed_score": 75, "accuracy_score": 91, "files": [ - { - "filename": "parakeet-rnnt-1.1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 825244928 - }, - { - "filename": "parakeet-rnnt-1.1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 935755008 - }, - { - "filename": "parakeet-rnnt-1.1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1042505984 - }, - { - "filename": "parakeet-rnnt-1.1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1267285248 - }, - { - "filename": "parakeet-rnnt-1.1b-F16.gguf", - "quant": "F16", - "size_bytes": 2145156480 - }, - { - "filename": "parakeet-rnnt-1.1b-F32.gguf", - "quant": "F32", - "size_bytes": 4282189696 - } + {"filename": "parakeet-rnnt-1.1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 825244928, "sha256": "9e8636f491867751ca5b5c18ca91adf52014b1ae406b46516a9a8680ba1049a1"}, + {"filename": "parakeet-rnnt-1.1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 935755008, "sha256": "8ee2ead0c30773440743591cd8a9f8ac180c27f450c9b053805b2bab6bf352cb"}, + {"filename": "parakeet-rnnt-1.1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1042505984, "sha256": "14378bc2ccd71be446d7c869fb369ec856dcd02ca5a895194da6855b1d529665"}, + {"filename": "parakeet-rnnt-1.1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1267285248, "sha256": "e1918556aa0e2b857c12cb365cff4f33b0f1cbcc0e0769499ec38948c8363775"}, + {"filename": "parakeet-rnnt-1.1b-F16.gguf", "quant": "F16", "size_bytes": 2145156480, "sha256": "d4608d90b26a3ff3f741b091d74ee0d0d9f9d30a7888914f251cb8821c2e1358"}, + {"filename": "parakeet-rnnt-1.1b-F32.gguf", "quant": "F32", "size_bytes": 4282189696, "sha256": "875406c56378dcc355c7fb3ac1d9ba2e7a0ab76432b7840839f02cdf2ef60901"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2549,6 +1606,7 @@ }, { "id": "handy-computer/parakeet-tdt_ctc-1.1b-gguf", + "revision": "f1ea20171bfb7a1741e8b695d02e0a9bb2855996", "slug": "parakeet-tdt_ctc-1.1b", "name": "Parakeet TDT-CTC 1.1B", "architecture": "parakeet", @@ -2568,36 +1626,12 @@ "speed_score": 75, "accuracy_score": 88, "files": [ - { - "filename": "parakeet-tdt_ctc-1.1b-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 825248000 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 935758080 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1042509056 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1267288320 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-F16.gguf", - "quant": "F16", - "size_bytes": 2145162560 - }, - { - "filename": "parakeet-tdt_ctc-1.1b-F32.gguf", - "quant": "F32", - "size_bytes": 4282202176 - } + {"filename": "parakeet-tdt_ctc-1.1b-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 825248000, "sha256": "5c27d9289e23019c459b1cef87baa5b672343733ccd3762796e68aec447169cd"}, + {"filename": "parakeet-tdt_ctc-1.1b-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 935758080, "sha256": "da23eb8c78fe3813b88f616db85e8191cfbfffdbc2b8bb8bd74a94eb8987caee"}, + {"filename": "parakeet-tdt_ctc-1.1b-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1042509056, "sha256": "961ac90755f0d0bff3515a1b09eaf1fee6049af46c03b733f94ffff6b857b917"}, + {"filename": "parakeet-tdt_ctc-1.1b-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1267288320, "sha256": "56e826b96d0a625eb41f18d8f51d8600e8ec5959bf5278fe3929e5efefe787d6"}, + {"filename": "parakeet-tdt_ctc-1.1b-F16.gguf", "quant": "F16", "size_bytes": 2145162560, "sha256": "185565daaf0352c33464d421ae3cab8e6057890f7d6ead9d5e2b8333cc7ecff7"}, + {"filename": "parakeet-tdt_ctc-1.1b-F32.gguf", "quant": "F32", "size_bytes": 4282202176, "sha256": "16bdd9ecd6ae993e2b89edfa103ddc7ffc6e63a63d07e4f858038b8190a5972c"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2605,6 +1639,7 @@ }, { "id": "handy-computer/Qwen3-ASR-1.7B-gguf", + "revision": "92282af1610a2db19d66f2bef1e260f5deca782d", "slug": "Qwen3-ASR-1.7B", "name": "Qwen3-ASR 1.7B", "architecture": "qwen3_asr", @@ -2624,99 +1659,20 @@ "speed_score": 38, "accuracy_score": 90, "files": [ - { - "filename": "Qwen3-ASR-1.7B-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 1319830496 - }, - { - "filename": "Qwen3-ASR-1.7B-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1517290464 - }, - { - "filename": "Qwen3-ASR-1.7B-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1692554208 - }, - { - "filename": "Qwen3-ASR-1.7B-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 2185030624 - }, - { - "filename": "Qwen3-ASR-1.7B-BF16.gguf", - "quant": "BF16", - "size_bytes": 4083087904 - }, - { - "filename": "Qwen3-ASR-1.7B-F16.gguf", - "quant": "F16", - "size_bytes": 4091390944 - } + {"filename": "Qwen3-ASR-1.7B-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 1319830496, "sha256": "b7afe3674f653fa84f712ed2440353c6e7cf7f93697fef76b05a26538b24844e"}, + {"filename": "Qwen3-ASR-1.7B-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1517290464, "sha256": "034c557fe92ff8fcd9a9c041cbdaad347be0a86a58d3a348f63cf3f0180879d0"}, + {"filename": "Qwen3-ASR-1.7B-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1692554208, "sha256": "c75a961b7134a6c952d89797865cb0d0376876185aee04ef6d12c31c2952e4e1"}, + {"filename": "Qwen3-ASR-1.7B-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 2185030624, "sha256": "9a0d81792dfea2d5f278b8a63deb3ea6e02139ce42c2301f32ea19c4f77526b7"}, + {"filename": "Qwen3-ASR-1.7B-BF16.gguf", "quant": "BF16", "size_bytes": 4083087904, "sha256": "57db745f8ec3ad2ea391b0205661e7d74f76158877a33f151e4d4624ed2b7cc9"}, + {"filename": "Qwen3-ASR-1.7B-F16.gguf", "quant": "F16", "size_bytes": 4091390944, "sha256": "edb09c29b8f73822c639168d5ef72aa2dccdf8b4e48fc4b8518885352ff62c71"} ], "default_quant": "Q5_K_M", "recommended": false, "recommended_rank": null }, - { - "id": "handy-computer/moss-transcribe-diarize-gguf", - "slug": "moss-transcribe-diarize", - "name": "MOSS-Transcribe-Diarize 0.9B", - "architecture": "moss", - "family": "qwen3", - "parameters": "909M", - "description": "2-language speech-to-text with segment-level timestamps.", - "base_model": "OpenMOSS-Team/MOSS-Transcribe-Diarize", - "license": "apache-2.0", - "language_count": 2, - "languages": ["en", "zh"], - "capabilities": { - "streaming": false, - "translate": false, - "lang_detect": false, - "timestamps": "segment" - }, - "speed_score": 31, - "accuracy_score": 88, - "files": [ - { - "filename": "MOSS-Transcribe-Diarize-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 617345184 - }, - { - "filename": "MOSS-Transcribe-Diarize-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 700313760 - }, - { - "filename": "MOSS-Transcribe-Diarize-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 768151712 - }, - { - "filename": "MOSS-Transcribe-Diarize-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 986899616 - }, - { - "filename": "MOSS-Transcribe-Diarize-BF16.gguf", - "quant": "BF16", - "size_bytes": 1826882720 - }, - { - "filename": "MOSS-Transcribe-Diarize-F16.gguf", - "quant": "F16", - "size_bytes": 1833665696 - } - ], - "default_quant": "Q8_0", - "recommended": false, - "recommended_rank": null - }, { "id": "handy-computer/SenseVoiceSmall-gguf", + "revision": "4a08b8e900b38a977e32eb08d5d0697d6e72ba04", "slug": "SenseVoiceSmall", "name": "SenseVoice Small", "architecture": "sensevoice", @@ -2736,36 +1692,12 @@ "speed_score": 98, "accuracy_score": 81, "files": [ - { - "filename": "SenseVoiceSmall-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 145738304 - }, - { - "filename": "SenseVoiceSmall-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 172474880 - }, - { - "filename": "SenseVoiceSmall-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 196438336 - }, - { - "filename": "SenseVoiceSmall-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 252684608 - }, - { - "filename": "SenseVoiceSmall-F16.gguf", - "quant": "F16", - "size_bytes": 470412128 - }, - { - "filename": "SenseVoiceSmall-F32.gguf", - "quant": "F32", - "size_bytes": 936617824 - } + {"filename": "SenseVoiceSmall-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 145738304, "sha256": "9d775303e6b4e793d049ef226214c400b2b1746c734ed017146a1a085b13d02b"}, + {"filename": "SenseVoiceSmall-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 172474880, "sha256": "622a819ef9b37dc5506f0079ddedca6ec22e272bb9b4fe950d8f13422d1ccd0b"}, + {"filename": "SenseVoiceSmall-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 196438336, "sha256": "5724d2ff6329f4355a29cee6a2432ecd1f60547a0a4ef7726cfceda7dafb394f"}, + {"filename": "SenseVoiceSmall-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 252684608, "sha256": "6c759ee4c9748c9b3f7a5a60ca74f0f7e685fb9d45d1378fce7cfd62f59adf29"}, + {"filename": "SenseVoiceSmall-F16.gguf", "quant": "F16", "size_bytes": 470412128, "sha256": "dc172fd5ffaff0a396393cd4edd82e18403c53f8f5873bcc544ba012b70598f6"}, + {"filename": "SenseVoiceSmall-F32.gguf", "quant": "F32", "size_bytes": 936617824, "sha256": "dda511c3eac009568643b5c64541468d62be2e2a59700113f49c72675464d13d"} ], "default_quant": "Q8_0", "recommended": false, @@ -2773,6 +1705,7 @@ }, { "id": "handy-computer/Voxtral-Mini-3B-2507-gguf", + "revision": "5690205813042c07cbaa86d2a9dcc585fcd31304", "slug": "Voxtral-Mini-3B-2507", "name": "Voxtral Mini 3B", "architecture": "voxtral", @@ -2792,36 +1725,12 @@ "speed_score": 14, "accuracy_score": 88, "files": [ - { - "filename": "Voxtral-Mini-3B-2507-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 2984721056 - }, - { - "filename": "Voxtral-Mini-3B-2507-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 3464182432 - }, - { - "filename": "Voxtral-Mini-3B-2507-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 3869489824 - }, - { - "filename": "Voxtral-Mini-3B-2507-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 5000084128 - }, - { - "filename": "Voxtral-Mini-3B-2507-BF16.gguf", - "quant": "BF16", - "size_bytes": 9365764768 - }, - { - "filename": "Voxtral-Mini-3B-2507-F16.gguf", - "quant": "F16", - "size_bytes": 9376578208 - } + {"filename": "Voxtral-Mini-3B-2507-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 2984721056, "sha256": "3a6717aa8f8989108d260cbd237584289eec43cc987e10133c33643515936205"}, + {"filename": "Voxtral-Mini-3B-2507-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 3464182432, "sha256": "9ea2eb85359f7771270715849dc2b0e80381a59dab09d9c5d85352ab9c53f675"}, + {"filename": "Voxtral-Mini-3B-2507-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 3869489824, "sha256": "0be733fd436c2a89483b96ed8d398abbc3ce5bcaa7aac64e886f5eb9d9066a43"}, + {"filename": "Voxtral-Mini-3B-2507-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 5000084128, "sha256": "06b4dd04e32953ccab0d417125bdd728ec031431596ed5e80123c3c66f29ddc2"}, + {"filename": "Voxtral-Mini-3B-2507-BF16.gguf", "quant": "BF16", "size_bytes": 9365764768, "sha256": "ddfa5eb2e5e56343ee9b3262d5581f9b48085bdecefba71c0092901cf0183ccb"}, + {"filename": "Voxtral-Mini-3B-2507-F16.gguf", "quant": "F16", "size_bytes": 9376578208, "sha256": "a7f0bebce988a32ce7e736acffe19a48d9bc739789dfab7d13ca6b622d23dd6e"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2829,6 +1738,7 @@ }, { "id": "handy-computer/Voxtral-Small-24B-2507-gguf", + "revision": "3b85044238d7d73b0063c54ee6d0754b5f061795", "slug": "Voxtral-Small-24B-2507", "name": "Voxtral Small 24B", "architecture": "voxtral", @@ -2848,36 +1758,12 @@ "speed_score": 10, "accuracy_score": 90, "files": [ - { - "filename": "Voxtral-Small-24B-2507-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 14302261728 - }, - { - "filename": "Voxtral-Small-24B-2507-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 17138659808 - }, - { - "filename": "Voxtral-Small-24B-2507-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 19936473568 - }, - { - "filename": "Voxtral-Small-24B-2507-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 25810383328 - }, - { - "filename": "Voxtral-Small-24B-2507-BF16.gguf", - "quant": "BF16", - "size_bytes": 48537285088 - }, - { - "filename": "Voxtral-Small-24B-2507-F16.gguf", - "quant": "F16", - "size_bytes": 48548098528 - } + {"filename": "Voxtral-Small-24B-2507-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 14302261728, "sha256": "0da1b866c0eb2ce805678c18a7fb1e413320e4ad3ccbef7160707b65bae937a3"}, + {"filename": "Voxtral-Small-24B-2507-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 17138659808, "sha256": "a53f73a5f63b7663fe155977616a636acac567dac898f6ef82ca519017e8b6e7"}, + {"filename": "Voxtral-Small-24B-2507-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 19936473568, "sha256": "e85950b9576d3913d2664abaac1cdcb3a967dbbc8a60febacaf137a7f4d216f0"}, + {"filename": "Voxtral-Small-24B-2507-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 25810383328, "sha256": "ab0964350131990a364dca53e3cac5f4d9cc176dce2f273066be3ef71e252fb2"}, + {"filename": "Voxtral-Small-24B-2507-BF16.gguf", "quant": "BF16", "size_bytes": 48537285088, "sha256": "5c38ff2d6dbb413d70dec37430825ff129862536f74149e015b3bc42c11c5745"}, + {"filename": "Voxtral-Small-24B-2507-F16.gguf", "quant": "F16", "size_bytes": 48548098528, "sha256": "d5cc73553d9422dada176dd02c1920dc72a888a01eed34e20816b0bab8db207e"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -2885,6 +1771,7 @@ }, { "id": "handy-computer/whisper-tiny-gguf", + "revision": "6687f30c99641ee265df421e582354adbc8848fc", "slug": "whisper-tiny", "name": "Whisper Tiny", "architecture": "whisper", @@ -2904,36 +1791,12 @@ "speed_score": 100, "accuracy_score": 61, "files": [ - { - "filename": "whisper-tiny-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 43621792 - }, - { - "filename": "whisper-tiny-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 44211616 - }, - { - "filename": "whisper-tiny-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 44838304 - }, - { - "filename": "whisper-tiny-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 45981088 - }, - { - "filename": "whisper-tiny-F16.gguf", - "quant": "F16", - "size_bytes": 80135360 - }, - { - "filename": "whisper-tiny-F32.gguf", - "quant": "F32", - "size_bytes": 152997824 - } + {"filename": "whisper-tiny-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 43621792, "sha256": "86e1100470df173fa9079f941e33e4d52d11c03923407dd1ec51fd1937d8e609"}, + {"filename": "whisper-tiny-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 44211616, "sha256": "72cfa8ee436a635a5b6fb373cc056a828b9efe96d32d6eb8769ed3cc5b429719"}, + {"filename": "whisper-tiny-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 44838304, "sha256": "e23e79dbc42e90171e050b0e7da00f07e41343547ef09d86588324b3c5672413"}, + {"filename": "whisper-tiny-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 45981088, "sha256": "325b9c7997cd1eff81ef709d55766565e71be696130cc3a3d444713798706834"}, + {"filename": "whisper-tiny-F16.gguf", "quant": "F16", "size_bytes": 80135360, "sha256": "5b44043278b47d3b6e56fb16c6bc5bb0aa16f2e69086f4d67175ed0a30d6a987"}, + {"filename": "whisper-tiny-F32.gguf", "quant": "F32", "size_bytes": 152997824, "sha256": "f5e0ecdd9967173fdede25ceb7e5159b3294f05b8dc0c87b0876fccd36f18982"} ], "default_quant": "Q8_0", "recommended": false, @@ -2941,6 +1804,7 @@ }, { "id": "handy-computer/whisper-tiny.en-gguf", + "revision": "becb8bcb804405dc97b380a523d9975888820986", "slug": "whisper-tiny.en", "name": "Whisper Tiny (English)", "architecture": "whisper", @@ -2960,36 +1824,12 @@ "speed_score": 100, "accuracy_score": 68, "files": [ - { - "filename": "whisper-tiny.en-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 43545248 - }, - { - "filename": "whisper-tiny.en-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 44135072 - }, - { - "filename": "whisper-tiny.en-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 44761760 - }, - { - "filename": "whisper-tiny.en-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 45904544 - }, - { - "filename": "whisper-tiny.en-F16.gguf", - "quant": "F16", - "size_bytes": 80058464 - }, - { - "filename": "whisper-tiny.en-F32.gguf", - "quant": "F32", - "size_bytes": 152920160 - } + {"filename": "whisper-tiny.en-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 43545248, "sha256": "3bfa6200aa12a21409445401f7871b5c733546dc45a29eb4871fcb3c7954e08b"}, + {"filename": "whisper-tiny.en-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 44135072, "sha256": "610a75700023ffd74df1ab538353525d0a82a3e35702b923e955e69422519740"}, + {"filename": "whisper-tiny.en-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 44761760, "sha256": "33b349eabeb0808562060b44dd539264f94365f09ed5317337207b955355abe4"}, + {"filename": "whisper-tiny.en-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 45904544, "sha256": "e8c9b73c06344307d8b346e07fbe93dd88d894627854bcff31523f1ce44394fa"}, + {"filename": "whisper-tiny.en-F16.gguf", "quant": "F16", "size_bytes": 80058464, "sha256": "7ae6eae54d625472e52b6af03b09dec586f0e948ffa5872815bb1988e973ad2b"}, + {"filename": "whisper-tiny.en-F32.gguf", "quant": "F32", "size_bytes": 152920160, "sha256": "4aaf936f214a08f199ff7daecf421eb28be273e4e4b3bba0486b98efed140045"} ], "default_quant": "Q8_0", "recommended": false, @@ -2997,6 +1837,7 @@ }, { "id": "handy-computer/whisper-base-gguf", + "revision": "e0f69524f648720eca44c024d1d0dbb7027d1fa0", "slug": "whisper-base", "name": "Whisper Base", "architecture": "whisper", @@ -3016,36 +1857,12 @@ "speed_score": 99, "accuracy_score": 71, "files": [ - { - "filename": "whisper-base-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 58870848 - }, - { - "filename": "whisper-base-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 63786048 - }, - { - "filename": "whisper-base-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 67865664 - }, - { - "filename": "whisper-base-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 84962880 - }, - { - "filename": "whisper-base-F16.gguf", - "quant": "F16", - "size_bytes": 151145760 - }, - { - "filename": "whisper-base-F32.gguf", - "quant": "F32", - "size_bytes": 292335904 - } + {"filename": "whisper-base-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 58870848, "sha256": "78bc9a8cb7e16a3354b262a4c71c0ed4892f6a66b681068f474dfada0729c0ae"}, + {"filename": "whisper-base-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 63786048, "sha256": "8e0feb7bc35780353cf31821018e601bb7b7cff6c9a0e17ada5a5db23f4db867"}, + {"filename": "whisper-base-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 67865664, "sha256": "6e4ac95aef48f28b5dd11b0be26a65e785ffbf3682cb076560a27e72206ea740"}, + {"filename": "whisper-base-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 84962880, "sha256": "81c069428bc8a24551a8169cf31cf09bcfd9d4cf50389ae281323c9aa9648c81"}, + {"filename": "whisper-base-F16.gguf", "quant": "F16", "size_bytes": 151145760, "sha256": "38ab6b0ed742e9eded4d5a2ba7fc34d44fc28cdb71d6360f6321d4b306ef8039"}, + {"filename": "whisper-base-F32.gguf", "quant": "F32", "size_bytes": 292335904, "sha256": "ab30724d251b16383fdbcf88698bcca6649eb98c4e102d6a7271a44d608bb4c6"} ], "default_quant": "Q8_0", "recommended": false, @@ -3053,6 +1870,7 @@ }, { "id": "handy-computer/whisper-base.en-gguf", + "revision": "cf0804db15fb341d00c9274b90da9cbb4fe2e5c6", "slug": "whisper-base.en", "name": "Whisper Base (English)", "architecture": "whisper", @@ -3072,36 +1890,12 @@ "speed_score": 99, "accuracy_score": 76, "files": [ - { - "filename": "whisper-base.en-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 58794272 - }, - { - "filename": "whisper-base.en-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 63709472 - }, - { - "filename": "whisper-base.en-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 67789088 - }, - { - "filename": "whisper-base.en-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 84886208 - }, - { - "filename": "whisper-base.en-F16.gguf", - "quant": "F16", - "size_bytes": 151068608 - }, - { - "filename": "whisper-base.en-F32.gguf", - "quant": "F32", - "size_bytes": 292257728 - } + {"filename": "whisper-base.en-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 58794272, "sha256": "fd19959ff81f96c2b66f26fad97aada090ed6b3af681cf5b569bef3d7a073da5"}, + {"filename": "whisper-base.en-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 63709472, "sha256": "549e6e991588cd748dc6bf53b8fe47629dc08ed9038b8955270ee98fbc5e4c54"}, + {"filename": "whisper-base.en-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 67789088, "sha256": "5de8fc00f70f538af2811c9992de502513d6d60336f32ed975b4b6eb91b69e73"}, + {"filename": "whisper-base.en-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 84886208, "sha256": "3b46ca40bccbf7609c68d88a36d96077a04ca7c87f2060ede06f129fac3e7652"}, + {"filename": "whisper-base.en-F16.gguf", "quant": "F16", "size_bytes": 151068608, "sha256": "e6c447b0cc761e8ac3fc2f5e0e6918f604e415a41cff97825f1503ccde376e1e"}, + {"filename": "whisper-base.en-F32.gguf", "quant": "F32", "size_bytes": 292257728, "sha256": "f694f59682d44bafe4e74b8b9cc395dfdc3737a4d5e1910e399d45683729ad2d"} ], "default_quant": "Q8_0", "recommended": false, @@ -3109,6 +1903,7 @@ }, { "id": "handy-computer/whisper-small.en-gguf", + "revision": "41b0f75fd44415ba127a5356c5ba9ed450c1debd", "slug": "whisper-small.en", "name": "Whisper Small (English)", "architecture": "whisper", @@ -3128,36 +1923,12 @@ "speed_score": 80, "accuracy_score": 81, "files": [ - { - "filename": "whisper-small.en-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 171553856 - }, - { - "filename": "whisper-small.en-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 193672256 - }, - { - "filename": "whisper-small.en-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 212030528 - }, - { - "filename": "whisper-small.en-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 269674144 - }, - { - "filename": "whisper-small.en-F16.gguf", - "quant": "F16", - "size_bytes": 492810784 - }, - { - "filename": "whisper-small.en-F32.gguf", - "quant": "F32", - "size_bytes": 968835616 - } + {"filename": "whisper-small.en-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 171553856, "sha256": "7bf9ebce1e6332b3d18d4c27032e3097148fc124befed3e70022e0d70539a365"}, + {"filename": "whisper-small.en-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 193672256, "sha256": "e96a679e57d36f80bb26512f3893185d219ddd8880cdac4c433e5af13a001b47"}, + {"filename": "whisper-small.en-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 212030528, "sha256": "3703d6f9e4d78b5b2f196f9c3cfab5c1b6b268fe42fff311cb89137506f92019"}, + {"filename": "whisper-small.en-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 269674144, "sha256": "9614e6b7fda2d26018e4f268aece8ca25a83296ea0b534169a585b740bfd71ef"}, + {"filename": "whisper-small.en-F16.gguf", "quant": "F16", "size_bytes": 492810784, "sha256": "59eade60e072bd289c9ff39313ef14b565691bfe373275ea904ecea497e47c8a"}, + {"filename": "whisper-small.en-F32.gguf", "quant": "F32", "size_bytes": 968835616, "sha256": "fce06c84aa930d0652d292781b4470fb4d5631527e211fd4f402703f63ee7291"} ], "default_quant": "Q8_0", "recommended": false, @@ -3165,6 +1936,7 @@ }, { "id": "handy-computer/whisper-small-gguf", + "revision": "c0214bd34be9296695486f838e0142f900803159", "slug": "whisper-small", "name": "Whisper Small", "architecture": "whisper", @@ -3184,36 +1956,12 @@ "speed_score": 78, "accuracy_score": 80, "files": [ - { - "filename": "whisper-small-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 171630656 - }, - { - "filename": "whisper-small-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 193749056 - }, - { - "filename": "whisper-small-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 212107328 - }, - { - "filename": "whisper-small-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 269751136 - }, - { - "filename": "whisper-small-F16.gguf", - "quant": "F16", - "size_bytes": 492888480 - }, - { - "filename": "whisper-small-F32.gguf", - "quant": "F32", - "size_bytes": 968914848 - } + {"filename": "whisper-small-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 171630656, "sha256": "b204d2005a3e5d4fe6153bd61e5e8b32e757ff7b017ac8f61c6f051c2f80e939"}, + {"filename": "whisper-small-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 193749056, "sha256": "326cd00c3e7217c751667c7c1600eaf7e0de174e186ca2c16b4bf590251c3c3b"}, + {"filename": "whisper-small-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 212107328, "sha256": "4d14215a972101fff787fc59e30b908ee511c725600317e02c9032cb8799f32d"}, + {"filename": "whisper-small-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 269751136, "sha256": "9b9c8811bbcc82a7766f0fb0925614bdacb0923b2cc630daeac17108b655b860"}, + {"filename": "whisper-small-F16.gguf", "quant": "F16", "size_bytes": 492888480, "sha256": "bef65e1ac9d012269453243243aac0d0f67792693ba6c99b584a124e0ee326fc"}, + {"filename": "whisper-small-F32.gguf", "quant": "F32", "size_bytes": 968914848, "sha256": "6bc797078fdddcc890999130dfc33dfc30d82de5a9115a840ec73853262a9d21"} ], "default_quant": "Q8_0", "recommended": false, @@ -3221,6 +1969,7 @@ }, { "id": "handy-computer/whisper-medium.en-gguf", + "revision": "f25c70d9095dcfdad187ebb3b113d157b414aee8", "slug": "whisper-medium.en", "name": "Whisper Medium (English)", "architecture": "whisper", @@ -3240,36 +1989,12 @@ "speed_score": 44, "accuracy_score": 83, "files": [ - { - "filename": "whisper-medium.en-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 504025856 - }, - { - "filename": "whisper-medium.en-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 582669056 - }, - { - "filename": "whisper-medium.en-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 647942912 - }, - { - "filename": "whisper-medium.en-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 831460928 - }, - { - "filename": "whisper-medium.en-F16.gguf", - "quant": "F16", - "size_bytes": 1541853248 - }, - { - "filename": "whisper-medium.en-F32.gguf", - "quant": "F32", - "size_bytes": 3057356864 - } + {"filename": "whisper-medium.en-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 504025856, "sha256": "8a0eb0bb20019af88fc0ccda06d97dc949a6a0cdf35d3f2c316b967e76cd4077"}, + {"filename": "whisper-medium.en-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 582669056, "sha256": "8c90b55725036f1362fc5a583432a4e63370304dbf0f96b6245685f8a588174f"}, + {"filename": "whisper-medium.en-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 647942912, "sha256": "9abdf673a4096ccb8bd210a3c68d73665c3ac8450bdeb54fd35c255a28b63d55"}, + {"filename": "whisper-medium.en-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 831460928, "sha256": "03d7257fef498750ce272631bc6a34de322fc2b438aab5c268ff49dfd1b64c49"}, + {"filename": "whisper-medium.en-F16.gguf", "quant": "F16", "size_bytes": 1541853248, "sha256": "c8be41b68306ad35382af749d4681b33da403b7d82eb3b6d3225a01a02dc1196"}, + {"filename": "whisper-medium.en-F32.gguf", "quant": "F32", "size_bytes": 3057356864, "sha256": "6a0bcb8c3360dbc8101a1ebcf2776d1e3463538c4b9486da97b8f21e462e6039"} ], "default_quant": "Q8_0", "recommended": false, @@ -3277,6 +2002,7 @@ }, { "id": "handy-computer/whisper-large-v3-turbo-gguf", + "revision": "5eaf945c7978e564bae5b28a5b1639dd93c2bfb1", "slug": "whisper-large-v3-turbo", "name": "Whisper Large v3 Turbo", "architecture": "whisper", @@ -3294,33 +2020,13 @@ "timestamps": "segment" }, "speed_score": 35, - "accuracy_score": 87, + "accuracy_score": 88, "files": [ - { - "filename": "whisper-large-v3-turbo-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 536069728 - }, - { - "filename": "whisper-large-v3-turbo-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 619628128 - }, - { - "filename": "whisper-large-v3-turbo-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 692536928 - }, - { - "filename": "whisper-large-v3-turbo-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 886381760 - }, - { - "filename": "whisper-large-v3-turbo-F16.gguf", - "quant": "F16", - "size_bytes": 1625935520 - } + {"filename": "whisper-large-v3-turbo-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 536069728, "sha256": "ecfe9b6beb4ab18fef49187cc968cc74b5168b94629c8830e2ca6b794c6e25ed"}, + {"filename": "whisper-large-v3-turbo-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 619628128, "sha256": "977b5db4e004349dffd1ab9caa10ba5aaba3fc3edd3ba72cadb84328a3203e36"}, + {"filename": "whisper-large-v3-turbo-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 692536928, "sha256": "3881dc17bf345f7ce4a3c48327074951e1550c8a1e89fa58d554d9f26a1b6274"}, + {"filename": "whisper-large-v3-turbo-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 886381760, "sha256": "b2e30cc286bc9f3aba4db9099fc7403543497c05ce7100d0d83091ddfd25a183"}, + {"filename": "whisper-large-v3-turbo-F16.gguf", "quant": "F16", "size_bytes": 1625935520, "sha256": "e1d0144e9afc9f479d9e51fc92c7dea9dc36059655eeb3819f16ad2de779046a"} ], "default_quant": "Q8_0", "recommended": false, @@ -3328,6 +2034,7 @@ }, { "id": "handy-computer/Breeze-ASR-25-gguf", + "revision": "1e5e8d7295110e1e14e305e9cf7411d82711beaa", "slug": "Breeze-ASR-25", "name": "Breeze-ASR-25", "architecture": "whisper", @@ -3347,36 +2054,12 @@ "speed_score": 23, "accuracy_score": 86, "files": [ - { - "filename": "Breeze-ASR-25-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 996526080 - }, - { - "filename": "Breeze-ASR-25-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1160366080 - }, - { - "filename": "Breeze-ASR-25-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1296353280 - }, - { - "filename": "Breeze-ASR-25-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1667964224 - }, - { - "filename": "Breeze-ASR-25-BF16.gguf", - "quant": "BF16", - "size_bytes": 3096013408 - }, - { - "filename": "Breeze-ASR-25-F16.gguf", - "quant": "F16", - "size_bytes": 3106458208 - } + {"filename": "Breeze-ASR-25-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 996526080, "sha256": "cb056e63e5606284637c8a9c6d35e3c544f190dd5d26335466c7e584e61815c4"}, + {"filename": "Breeze-ASR-25-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1160366080, "sha256": "c871fc811b33a16a5607c4d4166cfa2c0a1d359f7796e3da255e3e922f59139b"}, + {"filename": "Breeze-ASR-25-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1296353280, "sha256": "4c7fe8eed7b36a00dc345e9825fb0f4ac717c38cc5a9292cd552c2f141e3b258"}, + {"filename": "Breeze-ASR-25-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1667964224, "sha256": "1650c163cd1623d13b585368ffd87d7e669c5ff23014511e0fa91bb4ce994756"}, + {"filename": "Breeze-ASR-25-BF16.gguf", "quant": "BF16", "size_bytes": 3096013408, "sha256": "b2ae64afd2bea002c0188bba34e868f208eca8ab2b5d1359bd5d4b052209851e"}, + {"filename": "Breeze-ASR-25-F16.gguf", "quant": "F16", "size_bytes": 3106458208, "sha256": "1ed33e23e1dd7183c7d60e8713d159a61bfa1852019e7a65fd04ec6042a44f98"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -3384,6 +2067,7 @@ }, { "id": "handy-computer/whisper-large-gguf", + "revision": "1b99acc257d1a605153c9e4d818a065569a3fdcd", "slug": "whisper-large", "name": "Whisper Large", "architecture": "whisper", @@ -3403,36 +2087,12 @@ "speed_score": 23, "accuracy_score": 83, "files": [ - { - "filename": "whisper-large-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 996526048 - }, - { - "filename": "whisper-large-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1160366048 - }, - { - "filename": "whisper-large-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1296353248 - }, - { - "filename": "whisper-large-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1667964192 - }, - { - "filename": "whisper-large-F16.gguf", - "quant": "F16", - "size_bytes": 3106458176 - }, - { - "filename": "whisper-large-F32.gguf", - "quant": "F32", - "size_bytes": 6175245376 - } + {"filename": "whisper-large-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 996526048, "sha256": "7d7693afc06096de46f2a41309ac479909e131824e78dae4e205f35463aaec09"}, + {"filename": "whisper-large-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1160366048, "sha256": "b1dfb9fa0e9b0574d8c822f51662b8e59915831e1a3ca0d191f18ff8ba742639"}, + {"filename": "whisper-large-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1296353248, "sha256": "04756b664385d0ec26af1ef43b94bccdaf2d6dce83b52f482d2232b67642b6bc"}, + {"filename": "whisper-large-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1667964192, "sha256": "0b9c63f73c68327d6ba2386e40c4a4dc46a9e7114a78c93a464b48b72c001547"}, + {"filename": "whisper-large-F16.gguf", "quant": "F16", "size_bytes": 3106458176, "sha256": "2c6198aeb009e01bf03cce9a30374b86ecd3484cd53b245e4a9f5bf7a670c498"}, + {"filename": "whisper-large-F32.gguf", "quant": "F32", "size_bytes": 6175245376, "sha256": "fafb2d4c6f865fc6cd745be8781eaf87d8d7a289cf3556283849fb1cc6741717"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -3440,6 +2100,7 @@ }, { "id": "handy-computer/whisper-large-v2-gguf", + "revision": "b64d145562182428ef04ca992b182aec8c61e578", "slug": "whisper-large-v2", "name": "Whisper Large v2", "architecture": "whisper", @@ -3459,36 +2120,12 @@ "speed_score": 23, "accuracy_score": 84, "files": [ - { - "filename": "whisper-large-v2-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 996526080 - }, - { - "filename": "whisper-large-v2-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1160366080 - }, - { - "filename": "whisper-large-v2-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1296353280 - }, - { - "filename": "whisper-large-v2-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1667964224 - }, - { - "filename": "whisper-large-v2-F16.gguf", - "quant": "F16", - "size_bytes": 3106458208 - }, - { - "filename": "whisper-large-v2-F32.gguf", - "quant": "F32", - "size_bytes": 6175245408 - } + {"filename": "whisper-large-v2-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 996526080, "sha256": "76aa37b205abc1fb7a9e7aaf0655b8747995b81e6bb72c18f4b1acf59e222f79"}, + {"filename": "whisper-large-v2-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1160366080, "sha256": "94fab1289a98d26bf375174a8daa00f44c7b16a9944726dde05361e5a8bd07ff"}, + {"filename": "whisper-large-v2-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1296353280, "sha256": "d216cb645a4a0b158a465f55c18a8317872788c38b3dbc0895c00d5c3bba72c8"}, + {"filename": "whisper-large-v2-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1667964224, "sha256": "704603cff0f82ddd7f7f51045e12e5c8f85a37a9edec72ab10fd8ede32220702"}, + {"filename": "whisper-large-v2-F16.gguf", "quant": "F16", "size_bytes": 3106458208, "sha256": "c8ffa39202367b896cdf642cd28c28fb9ac4d90f7f12795a219952c70bc63f04"}, + {"filename": "whisper-large-v2-F32.gguf", "quant": "F32", "size_bytes": 6175245408, "sha256": "eed42074ca931551b6bb9c80e658bb0d646c80da06acb03a49cb6326d2a57ced"} ], "default_quant": "Q5_K_M", "recommended": false, @@ -3496,6 +2133,7 @@ }, { "id": "handy-computer/whisper-large-v3-gguf", + "revision": "e3e29bee6389c7da4a141406f07bb80ddac5337c", "slug": "whisper-large-v3", "name": "Whisper Large v3", "architecture": "whisper", @@ -3515,31 +2153,11 @@ "speed_score": 23, "accuracy_score": 89, "files": [ - { - "filename": "whisper-large-v3-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_bytes": 997303008 - }, - { - "filename": "whisper-large-v3-Q5_K_M.gguf", - "quant": "Q5_K_M", - "size_bytes": 1161143008 - }, - { - "filename": "whisper-large-v3-Q6_K.gguf", - "quant": "Q6_K", - "size_bytes": 1297130208 - }, - { - "filename": "whisper-large-v3-Q8_0.gguf", - "quant": "Q8_0", - "size_bytes": 1668741440 - }, - { - "filename": "whisper-large-v3-F16.gguf", - "quant": "F16", - "size_bytes": 3107236640 - } + {"filename": "whisper-large-v3-Q4_K_M.gguf", "quant": "Q4_K_M", "size_bytes": 997303008, "sha256": "6fe933811cec4cd3159debc46520ecd3aac6c7e322ece2ac61fcfdab184e1fe0"}, + {"filename": "whisper-large-v3-Q5_K_M.gguf", "quant": "Q5_K_M", "size_bytes": 1161143008, "sha256": "6053d0fd69a0fd48b8fea5ea7a52b9e0cde389343566fa30e453a1b2b258dc38"}, + {"filename": "whisper-large-v3-Q6_K.gguf", "quant": "Q6_K", "size_bytes": 1297130208, "sha256": "88e6dc60bd0538da145a50c19f4778e4a106fe28713adeeca43163215ee02805"}, + {"filename": "whisper-large-v3-Q8_0.gguf", "quant": "Q8_0", "size_bytes": 1668741440, "sha256": "2fa1a5f179f8a511a53e2108db270aa4af3ce08cd976af4180e2854666bb4ba3"}, + {"filename": "whisper-large-v3-F16.gguf", "quant": "F16", "size_bytes": 3107236640, "sha256": "e633ab1d74b0e98f4f57daedaee34291297dbbf01389bda3d890766600c1c584"} ], "default_quant": "Q5_K_M", "recommended": false, diff --git a/src-tauri/src/catalog/mod.rs b/src-tauri/src/catalog/mod.rs index 3472995f87..64d0a0ceaa 100644 --- a/src-tauri/src/catalog/mod.rs +++ b/src-tauri/src/catalog/mod.rs @@ -25,6 +25,11 @@ use crate::managers::model_capabilities::{CapabilityProbe, Compatibility}; #[derive(Deserialize)] struct CatalogRoot { + /// Base URLs tried in order when the Hugging Face download fails. The full + /// file URL is `{mirror}/{repo_id}/{revision}/{filename}` — the same three + /// values that form the HF resolve URL, so a mirror is a plain static host. + #[serde(default)] + mirrors: Vec, models: Vec, } @@ -34,6 +39,12 @@ struct CatalogRoot { struct CatalogModel { /// HF repo id, e.g. `handy-computer/whisper-small-gguf`. id: String, + /// Commit sha the catalog's sizes/hashes were generated from. Both HF + /// acquisition and mirror keys use it, so downloaded bytes provably match + /// the hashes regardless of source. Cache *lookup* additionally falls back + /// to `main` (see `hf_cached_path`) so downloads that predate pinning keep + /// resolving. + revision: Option, name: String, description: String, architecture: Option, @@ -59,8 +70,8 @@ struct CatalogCaps { // `CapabilityProbe` field yet — wire it through when the probe gains one. } -impl From for ModelDescriptor { - fn from(m: CatalogModel) -> Self { +impl From<&CatalogModel> for ModelDescriptor { + fn from(m: &CatalogModel) -> Self { // The default download file. Its name is folded into the id so a catalog // entry collides (dedups) with the very same file later discovered in // the HF cache — both compute `"{repo_id}/{filename}"`. @@ -71,24 +82,27 @@ impl From for ModelDescriptor { ModelDescriptor { id: format!("{}/{}", m.id, default_filename), source: ModelSource::HuggingFace { - repo_id: m.id, - revision: "main".to_string(), + repo_id: m.id.clone(), + // Acquire at the pin: `resolve/` is immutable (CDN-friendly) + // and guarantees the bytes match the catalog's hashes. `main` + // only remains as a lookup fallback for pre-pinning caches. + revision: m.revision.clone().unwrap_or_else(|| "main".to_string()), }, - name: m.name, - description: m.description, + name: m.name.clone(), + description: m.description.clone(), engine_type: EngineType::TranscribeCpp, caps: CapabilityProbe { verdict: Compatibility::Compatible, // curated org models we ship support for display_name: None, - architecture: m.architecture, + architecture: m.architecture.clone(), variant: None, - languages: Some(m.languages), + languages: Some(m.languages.clone()), supports_streaming: Some(m.capabilities.streaming), supports_translation: Some(m.capabilities.translate), supports_language_detect: Some(m.capabilities.lang_detect), }, - files: m.files, - default_quant: m.default_quant, + files: m.files.clone(), + default_quant: m.default_quant.clone(), // catalog scores are 0–100; ModelInfo / the UI bars use 0.0–1.0. speed_score: m.speed_score.unwrap_or(0.0) / 100.0, accuracy_score: m.accuracy_score.unwrap_or(0.0) / 100.0, @@ -98,13 +112,90 @@ impl From for ModelDescriptor { } } -/// The bundled catalog, parsed once and normalised into descriptors. -pub static CATALOG: Lazy> = Lazy::new(|| { - let root: CatalogRoot = serde_json::from_str(include_str!("catalog.json")) - .expect("bundled catalog.json is valid JSON matching the catalog schema"); - root.models.into_iter().map(ModelDescriptor::from).collect() +/// The raw parsed catalog. Kept alive (not consumed) so mirror metadata that +/// deliberately stays out of [`ModelDescriptor`] can be looked up separately. +static ROOT: Lazy = Lazy::new(|| { + serde_json::from_str(include_str!("catalog.json")) + .expect("bundled catalog.json is valid JSON matching the catalog schema") }); +/// The bundled catalog, parsed once and normalised into descriptors. +pub static CATALOG: Lazy> = + Lazy::new(|| ROOT.models.iter().map(ModelDescriptor::from).collect()); + +/// A mirror copy of a catalog model's default file, with the expected content +/// hash for end-to-end verification. Mirrors are untrusted bit-pipes: the +/// sha256 here (from the catalog compiled into the binary) is the trust anchor, +/// which is why it is mandatory — a file without one is never offered from a +/// mirror at all. +pub struct MirrorFile { + pub url: String, + pub sha256: String, + /// Catalog size — drives progress totals and resume sanity checks. + pub size_bytes: u64, +} + +/// Ordered mirror URLs for a catalog model's file — any listed quant, not just +/// the default — or empty when the model isn't from the catalog / no mirrors +/// are configured. `model_id` is the registry id (`"{repo_id}/{filename}"`). +/// (The mirror may only host default quants; a miss there just 404s and the +/// caller reports it, so listing every quant here costs nothing.) +pub fn mirror_fallbacks(model_id: &str) -> Vec { + let Some((m, file)) = ROOT.models.iter().find_map(|m| { + m.files + .iter() + .find(|f| format!("{}/{}", m.id, f.filename) == model_id) + .map(|f| (m, f)) + }) else { + return Vec::new(); + }; + let Some(revision) = m.revision.as_deref() else { + return Vec::new(); + }; + // No hash means no verification means no mirror: never fetch from an + // untrusted host without the catalog trust anchor. + let Some(sha256) = file.sha256.as_deref() else { + return Vec::new(); + }; + ROOT.mirrors + .iter() + .map(|base| MirrorFile { + url: format!( + "{}/{}/{}/{}", + base.trim_end_matches('/'), + m.id, + revision, + file.filename + ), + sha256: sha256.to_string(), + size_bytes: file.size_bytes, + }) + .collect() +} + +/// The catalog descriptor + specific `files[]` entry owning `filename`, +/// matched across every listed quant (not just the default). `repo_id`, when +/// given, must also match — the HF-cache scan uses it to keep a foreign repo +/// that happens to reuse a catalog filename from masquerading as ours. +pub fn file_in_catalog( + filename: &str, + repo_id: Option<&str>, +) -> Option<(&'static ModelDescriptor, &'static QuantFile)> { + let catalog: &'static Vec = Lazy::force(&CATALOG); + catalog.iter().find_map(|d| { + if let Some(repo) = repo_id { + match &d.source { + ModelSource::HuggingFace { repo_id: r, .. } if r == repo => {} + _ => return None, + } + } + d.files + .iter() + .find(|f| f.filename == filename) + .map(|f| (d, f)) + }) +} + /// Editorial recommended rank keyed by descriptor id (the same id the model /// registry uses). Built once from the catalog. static RANK_BY_ID: Lazy> = Lazy::new(|| { @@ -148,6 +239,26 @@ mod tests { } } + #[test] + fn every_catalog_model_has_mirror_fallbacks_with_hashes() { + // The mirror fallback is the safety net for HF outages and blocked + // networks; a catalog entry without one (missing revision, missing + // sha256, empty mirrors) silently loses that net. + for d in CATALOG.iter() { + let mirrors = mirror_fallbacks(&d.id); + assert!(!mirrors.is_empty(), "{}: no mirror fallbacks", d.id); + for m in &mirrors { + assert!( + m.sha256.len() == 64, + "{}: mirror entry lacks a sha256", + d.id + ); + assert!(m.size_bytes > 0, "{}: mirror entry lacks a size", d.id); + assert!(m.url.starts_with("https://"), "{}: bad url {}", d.id, m.url); + } + } + } + #[test] fn catalog_architectures_are_known_to_capability_probe() { let missing: BTreeSet<&str> = CATALOG diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index b8647e45e3..b1bb8949ad 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -1,6 +1,7 @@ use crate::managers::model::{ModelInfo, ModelManager}; use crate::managers::transcription::{ModelStateEvent, TranscriptionManager}; use crate::settings::{get_settings, write_settings, ModelUnloadTimeout}; +use log::error; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, State}; @@ -48,6 +49,9 @@ pub async fn download_model( .map_err(|e| e.to_string()); if let Err(ref error) = result { + // Log as well as emit: the toast is transient, and failed downloads have + // historically been undiagnosable because logs showed nothing (#1579). + error!("Model download failed for {}: {}", model_id, error); let _ = app_handle.emit( "model-download-failed", serde_json::json!({ "model_id": &model_id, "error": error }), diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 8ab72c7619..bd75b5fadf 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -4,17 +4,14 @@ use super::model_capabilities::{ use crate::settings::{get_settings, write_settings}; use anyhow::Result; use flate2::read::GzDecoder; -use futures_util::StreamExt; use hf_hub::api::tokio::{ApiBuilder, CancellationToken, Progress}; use hf_hub::{Cache, Repo, RepoType}; -use log::{debug, info, warn}; +use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use specta::Type; use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; -use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -22,6 +19,10 @@ use std::time::{Duration, Instant}; use tar::Archive; use tauri::{AppHandle, Emitter, Manager}; +mod download; + +use download::{HttpDownloadOutcome, DOWNLOAD_STALL_TIMEOUT}; + #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub enum EngineType { /// Any GGML/GGUF model loaded through transcribe-cpp (Whisper, Parakeet, @@ -122,6 +123,10 @@ pub struct QuantFile { pub filename: String, pub quant: String, pub size_bytes: u64, + /// Content sha256 — the trust anchor for downloads from any source (HF or + /// mirror). `None` only for catalogs predating the field. + #[serde(default)] + pub sha256: Option, } /// Pick the default quant among `files`: the one whose `quant` matches @@ -184,12 +189,45 @@ impl ModelDescriptor { /// Render the frontend-facing [`ModelInfo`] by combining this spec with live /// disk `status`. pub fn to_model_info(&self, status: &DiskStatus) -> ModelInfo { - let file = self.default_file(); + self.render_model_info(self.default_file(), status) + } + + /// [`ModelInfo`] for one specific quant `file` of this catalog model — how + /// alternate-quant files found on disk surface with full catalog metadata + /// instead of as anonymous customs. The default quant keeps the plain + /// catalog name; any other quant appends it ("Name (Q4_K_M)") so two + /// quants of one model stay tellable apart, and only the default carries + /// the Recommended badge. Identity stays `"{repo_id}/{filename}"`, so the + /// default quant renders identically to the seeded entry. + pub fn to_model_info_for_file(&self, file: &QuantFile, status: &DiskStatus) -> ModelInfo { + self.render_model_info(Some(file), status) + } + + fn render_model_info(&self, file: Option<&QuantFile>, status: &DiskStatus) -> ModelInfo { + let is_default = match (file, self.default_file()) { + (Some(f), Some(d)) => f.filename == d.filename, + _ => true, + }; + let id = match (&self.source, file) { + (ModelSource::HuggingFace { repo_id, .. }, Some(f)) => { + format!("{}/{}", repo_id, f.filename) + } + _ => self.id.clone(), + }; + let name = if is_default { + self.name.clone() + } else { + format!( + "{} ({})", + self.name, + file.map(|f| f.quant.as_str()).unwrap_or("") + ) + }; let languages = canonicalize_supported_languages(self.caps.languages.clone().unwrap_or_default()); ModelInfo { - id: self.id.clone(), - name: self.name.clone(), + id, + name, description: self.description.clone(), filename: file.map(|f| f.filename.clone()).unwrap_or_default(), source: self.source.clone(), @@ -202,7 +240,7 @@ impl ModelDescriptor { accuracy_score: self.accuracy_score, speed_score: self.speed_score, supports_translation: self.caps.supports_translation.unwrap_or(false), - is_recommended: self.recommended, + is_recommended: self.recommended && is_default, supports_language_selection: languages.len() > 1, supported_languages: languages, // Catalog models are always HF-sourced downloads, never user-dropped @@ -275,14 +313,23 @@ pub struct DownloadProgress { /// Resolve a Hugging Face model file in the shared HF cache, if already present. /// Uses hf-hub's stock location (HF_HOME or ~/.cache/huggingface/hub) so /// downloads are shared with other tools. +/// +/// hf-hub resolves purely through `refs/`. Pinned downloads write +/// `refs/`, but caches populated before pinning — or by other +/// tools, which download via `main` — only have `refs/main`, so lookup falls +/// back to it. Grandfathered `main` copies may predate the pin; per policy a +/// working local model is never invalidated by routine catalog regeneration. fn hf_cached_path(repo_id: &str, revision: &str, filename: &str) -> Option { - Cache::from_env() - .repo(Repo::with_revision( - repo_id.to_string(), - RepoType::Model, - revision.to_string(), - )) - .get(filename) + let get = |rev: &str| { + Cache::from_env() + .repo(Repo::with_revision( + repo_id.to_string(), + RepoType::Model, + rev.to_string(), + )) + .get(filename) + }; + get(revision).or_else(|| (revision != "main").then(|| get("main")).flatten()) } /// Friendly name advertised by GGUF metadata, if present. Empty strings are not @@ -335,6 +382,10 @@ struct HfProgressState { total: u64, downloaded: u64, last_emit: Instant, + /// Every callback (even throttled-out ones) bumps this; the stall watchdog + /// reads it. Starts at construction so a hang before the first byte — + /// e.g. a wedged metadata/resolve request — also counts as a stall. + last_activity: Instant, } impl HfDownloadProgress { @@ -346,10 +397,16 @@ impl HfDownloadProgress { total: 0, downloaded: 0, last_emit: Instant::now(), + last_activity: Instant::now(), })), } } + /// Instant of the most recent sign of life from the transfer. + fn last_activity(&self) -> Instant { + self.state.lock().unwrap().last_activity + } + fn emit(&self, downloaded: u64, total: u64) { let percentage = if total > 0 { (downloaded as f64 / total as f64) * 100.0 @@ -375,6 +432,7 @@ impl Progress for HfDownloadProgress { st.total = size as u64; st.downloaded = 0; st.last_emit = Instant::now(); + st.last_activity = Instant::now(); } self.emit(0, size as u64); } @@ -384,6 +442,7 @@ impl Progress for HfDownloadProgress { let mut st = self.state.lock().unwrap(); st.downloaded = st.downloaded.saturating_add(size as u64); let now = Instant::now(); + st.last_activity = now; // Throttle to ~10 updates/sec, but always emit the final byte. let emit = now.duration_since(st.last_emit) >= Duration::from_millis(100) || (st.total > 0 && st.downloaded >= st.total); @@ -1303,13 +1362,33 @@ impl ModelManager { } fn update_download_status(&self) -> Result<()> { + // Snapshot in-flight download ids before taking the registry lock (the + // two locks are never nested) so a mid-download entry is never dropped. + let downloading_ids: HashSet = + self.cancel_flags.lock().unwrap().keys().cloned().collect(); let mut models = self.available_models.lock().unwrap(); + let mut vanished_alternates: Vec = Vec::new(); for model in models.values_mut() { if let ModelSource::HuggingFace { repo_id, revision } = &model.source { - model.is_downloaded = hf_cached_path(repo_id, revision, &model.filename).is_some(); + // A models-dir copy counts too: mirror-fallback downloads land + // there, and it makes manual drop-ins of catalog files work. + let local_path = self.models_dir.join(&model.filename); + let partial_path = self.models_dir.join(format!("{}.partial", &model.filename)); + model.is_downloaded = hf_cached_path(repo_id, revision, &model.filename).is_some() + || local_path.exists(); model.is_downloading = false; - model.partial_size = 0; + model.partial_size = partial_path.metadata().map(|m| m.len()).unwrap_or(0); + // Alternate-quant entries exist only because their file was + // discovered on disk — the catalog offers just the default + // quant, so they are never presented for download. When the + // file is gone, the entry goes with it. + if !model.is_downloaded + && !downloading_ids.contains(&model.id) + && Self::is_catalog_alternate_quant(repo_id, &model.filename) + { + vanished_alternates.push(model.id.clone()); + } continue; } if model.is_directory { @@ -1357,9 +1436,46 @@ impl ModelManager { } } + for id in vanished_alternates { + models.remove(&id); + } + Ok(()) } + /// Whether `filename` is a catalog-listed quant of `repo_id` other than + /// the default — the only quant the catalog seeds and offers for download. + fn is_catalog_alternate_quant(repo_id: &str, filename: &str) -> bool { + crate::catalog::file_in_catalog(filename, Some(repo_id)).is_some_and(|(desc, file)| { + desc.default_file() + .is_some_and(|d| d.filename != file.filename) + }) + } + + /// Remove a single file from the shared HF cache: the snapshot pointer for + /// the resolved revision and, when the pointer is a symlink, the blob it + /// points to. Everything else in the repo (other quants, refs) is left + /// untouched. Returns whether anything was removed. + fn delete_hf_cache_file(repo_id: &str, revision: &str, filename: &str) -> bool { + let Some(pointer) = hf_cached_path(repo_id, revision, filename) else { + return false; + }; + // Resolve the blob before the pointer goes away. On Windows the + // pointer may be a plain file (hf-hub's symlink fallback renames the + // blob into the snapshot), in which case there is no separate blob. + let is_symlink = fs::symlink_metadata(&pointer) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if is_symlink { + if let Ok(blob) = fs::canonicalize(&pointer) { + info!("Deleting HF cache blob at: {:?}", blob); + let _ = fs::remove_file(&blob); + } + } + info!("Deleting HF cache file at: {:?}", pointer); + fs::remove_file(&pointer).is_ok() + } + fn auto_select_model_if_needed(&self) -> Result<()> { let mut settings = get_settings(&self.app_handle); @@ -1476,6 +1592,28 @@ impl ModelManager { continue; } + // A file matching ANY catalog-listed quant surfaces as that catalog + // model — full name/description/scores, quant-suffixed name for + // non-defaults — instead of as an anonymous custom entry. (Default + // quants never reach here: they're in `predefined_filenames`.) + if let Some((desc, quant_file)) = crate::catalog::file_in_catalog(&filename, None) { + let info = desc.to_model_info_for_file( + quant_file, + &DiskStatus { + is_downloaded: true, + ..Default::default() + }, + ); + if !available_models.contains_key(&info.id) { + info!( + "Discovered catalog quant in models dir: {} ({})", + info.id, filename + ); + available_models.insert(info.id.clone(), info); + } + continue; + } + // Skip if model ID already exists (shouldn't happen, but be safe) if available_models.contains_key(&model_id) { continue; @@ -1624,6 +1762,28 @@ impl ModelManager { continue; } + // Catalog-listed quants (same repo) surface with full catalog + // metadata — quant-suffixed name for non-defaults — and skip + // the header probe (the catalog is authoritative for its own + // models). Everything else keeps the generic probed path. + if let Some((desc, quant_file)) = + crate::catalog::file_in_catalog(&fname, Some(&repo_id)) + { + let info = desc.to_model_info_for_file( + quant_file, + &DiskStatus { + is_downloaded: true, + ..Default::default() + }, + ); + info!( + "Discovered catalog quant in HF cache: {} ({})", + info.id, repo_id + ); + available_models.insert(info.id.clone(), info); + continue; + } + let path = snapshot.join(&fname); let probe = prober.probe_file(&path); // Only surface models transcribe-cpp recognises. @@ -1686,56 +1846,6 @@ impl ModelManager { }) } - /// Verifies the SHA256 of `path` against `expected_sha256` (if provided). - /// On mismatch or read error the partial file is deleted and an error is returned, - /// so the next download attempt always starts from a clean state. - /// When `expected_sha256` is `None` (custom user models) verification is skipped. - fn verify_sha256(path: &Path, expected_sha256: Option<&str>, model_id: &str) -> Result<()> { - let Some(expected) = expected_sha256 else { - return Ok(()); - }; - match Self::compute_sha256(path) { - Ok(actual) if actual == expected => { - info!("SHA256 verified for model {}", model_id); - Ok(()) - } - Ok(actual) => { - warn!( - "SHA256 mismatch for model {}: expected {}, got {}", - model_id, expected, actual - ); - let _ = fs::remove_file(path); - Err(anyhow::anyhow!( - "Download verification failed for model {}: file is corrupt. Please retry.", - model_id - )) - } - Err(e) => { - let _ = fs::remove_file(path); - Err(anyhow::anyhow!( - "Failed to verify download for model {}: {}. Please retry.", - model_id, - e - )) - } - } - } - - /// Computes the SHA256 hex digest of a file, reading in 64KB chunks to handle large models. - fn compute_sha256(path: &Path) -> Result { - let mut file = File::open(path)?; - let mut hasher = Sha256::new(); - let mut buffer = [0u8; 65536]; - loop { - let n = file.read(&mut buffer)?; - if n == 0 { - break; - } - hasher.update(&buffer[..n]); - } - Ok(format!("{:x}", hasher.finalize())) - } - /// Download a Hugging Face-sourced model into the shared HF cache via /// hf-hub, reporting progress through the same `model-download-progress` /// event the URL path uses. Uses hf-hub's stock cache, but deliberately @@ -1749,8 +1859,11 @@ impl ModelManager { let model_id = model_info.id.clone(); let filename = model_info.filename.clone(); - // Already in the shared cache (possibly from another tool)? Done. - if hf_cached_path(&repo_id, &revision, &filename).is_some() { + // Already in the shared cache (possibly from another tool), or dropped + // into the models dir (mirror fallback / manual install)? Done. + if hf_cached_path(&repo_id, &revision, &filename).is_some() + || self.models_dir.join(&filename).exists() + { self.update_download_status()?; let _ = self.app_handle.emit("model-download-complete", &model_id); return Ok(()); @@ -1784,37 +1897,207 @@ impl ModelManager { model_id, repo_id, revision, filename ); - // Download chunks in parallel (default is 1 = sequential). Throughput - // scales near-linearly with this count because each connection is capped - // (~8 MB/s observed per stream), so we stack several to approach the - // link's real bandwidth. 8 stays light on CPU/RAM (~80 MB peak buffers) - // even on older machines and is browser-like in connection count. - let api = ApiBuilder::from_env() - // Ignore cached and environment-provided credentials. A stale token - // can make otherwise-public downloads fail authentication. - .with_token(None) - .with_progress(false) - .with_max_files(8) - .build() - .map_err(|e| anyhow::anyhow!("Failed to init Hugging Face API: {}", e))?; - let repo = api.repo(Repo::with_revision(repo_id, RepoType::Model, revision)); - let progress = HfDownloadProgress::new(self.app_handle.clone(), model_id.clone()); - match repo - .download_with_progress_cancellable(&filename, progress, cancel_token) - .await - { - Ok(_) => {} - Err(hf_hub::api::tokio::ApiError::Cancelled) => { - // User cancelled. hf-hub leaves the partially downloaded - // `.sync.part` in the shared cache, so a later attempt resumes - // instead of restarting. The guard resets is_downloading and - // drops the token; `cancel_download` already emitted - // `model-download-cancelled`. - info!("HF download cancelled for: {}", model_id); - return Ok(()); + // hf-hub has no working internal retry (its retry knobs are hardcoded + // to zero), so a single transient fault — dropped connection, a 429 + // from the resolve endpoint, a CDN blip — would otherwise fail the + // whole download. Each attempt resumes from the `.sync.part` + // committed-offset marker, so a retry only re-fetches what the failed + // attempt hadn't finished. + // Start moderately parallel for normal-network throughput, then stay + // sequential after the first failure. Eight simultaneous connections + // were all reset on an affected network in #1579, while one stream + // succeeded; four is a less aggressive fast path, and every retry uses + // the known-compatible request pattern. + const ATTEMPT_STREAMS: [usize; 4] = [4, 1, 1, 1]; + let mut attempt: usize = 1; + let hf_error = loop { + let stream_count = ATTEMPT_STREAMS[attempt - 1]; + info!( + "HF download attempt {}/{} for {} using {} concurrent stream(s)", + attempt, + ATTEMPT_STREAMS.len(), + model_id, + stream_count + ); + + // Fresh client per attempt so a wedged connection from the previous + // try can't poison the retry. + let api = ApiBuilder::from_env() + // Ignore cached and environment-provided credentials. A stale token + // can make otherwise-public downloads fail authentication. + .with_token(None) + .with_progress(false) + .with_max_files(stream_count) + .build() + .map_err(|e| anyhow::anyhow!("Failed to init Hugging Face API: {}", e))?; + let repo = api.repo(Repo::with_revision( + repo_id.clone(), + RepoType::Model, + revision.clone(), + )); + let progress = HfDownloadProgress::new(self.app_handle.clone(), model_id.clone()); + + // hf-hub has no internal timeouts, so a wedged connection would + // otherwise hang this attempt forever and neither the retry loop + // nor the mirror fallback would ever fire. The watchdog cancels a + // per-attempt child token when progress goes stale; a user cancel + // on the parent propagates through the same child. + let attempt_token = cancel_token.child_token(); + let watchdog = tokio::spawn({ + let probe = progress.clone(); + let attempt_token = attempt_token.clone(); + async move { + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + if attempt_token.is_cancelled() { + break; + } + if probe.last_activity().elapsed() > DOWNLOAD_STALL_TIMEOUT { + attempt_token.cancel(); + break; + } + } + } + }); + // hf-hub only observes its token inside the chunk loop — the + // metadata/resolve request and cache lock run before it, so a hang + // there would ignore the cancel entirely. Race the whole future + // against the token: on cancel, grant a short grace so an attempt + // that IS in the chunk loop can unwind gracefully (committing the + // `.sync.part` resume offset), then drop the future outright, + // which aborts whatever request it was wedged in. + let mut download = std::pin::pin!(repo.download_with_progress_cancellable( + &filename, + progress, + attempt_token.clone() + )); + let result = tokio::select! { + r = &mut download => r, + _ = attempt_token.cancelled() => { + match tokio::time::timeout(Duration::from_secs(5), &mut download).await { + Ok(r) => r, + Err(_) => Err(hf_hub::api::tokio::ApiError::Cancelled), + } + } + }; + watchdog.abort(); + + match result { + Ok(_) => break None, + Err(hf_hub::api::tokio::ApiError::Cancelled) if cancel_token.is_cancelled() => { + // User cancelled. hf-hub leaves the partially downloaded + // `.sync.part` in the shared cache, so a later attempt resumes + // instead of restarting. The guard resets is_downloading and + // drops the token; `cancel_download` already emitted + // `model-download-cancelled`. + info!("HF download cancelled for: {}", model_id); + return Ok(()); + } + Err(hf_hub::api::tokio::ApiError::Cancelled) => { + let err = anyhow::anyhow!( + "transfer stalled: no progress for {}s", + DOWNLOAD_STALL_TIMEOUT.as_secs() + ); + // A parallel attempt may be what wedged the network. Give + // the connection pool a brief pause, then retry once using + // the known-compatible single-stream path. A sequential + // stall already cost DOWNLOAD_STALL_TIMEOUT, so further + // retries would likely just repeat it — use the mirror. + if stream_count == 1 || attempt >= ATTEMPT_STREAMS.len() { + break Some(err); + } + let delay = Duration::from_secs(1_u64 << attempt); + warn!( + "HF download attempt {}/{} stalled for {} using {} concurrent stream(s); retrying with {} stream(s) in {}s", + attempt, + ATTEMPT_STREAMS.len(), + model_id, + stream_count, + ATTEMPT_STREAMS[attempt], + delay.as_secs() + ); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = cancel_token.cancelled() => { + info!("HF download cancelled for: {}", model_id); + return Ok(()); + } + } + attempt += 1; + } + Err(e) => { + // {:?} keeps the error source chain (reset vs TLS vs timeout); + // Display truncates it to "error sending request". + let err = anyhow::anyhow!("{:?}", e); + if attempt >= ATTEMPT_STREAMS.len() { + break Some(err); + } + let delay = Duration::from_secs(1_u64 << attempt); + warn!( + "HF download attempt {}/{} failed for {} using {} concurrent stream(s): {}; retrying with {} stream(s) in {}s", + attempt, + ATTEMPT_STREAMS.len(), + model_id, + stream_count, + err, + ATTEMPT_STREAMS[attempt], + delay.as_secs() + ); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = cancel_token.cancelled() => { + info!("HF download cancelled for: {}", model_id); + return Ok(()); + } + } + attempt += 1; + } + } + }; + + if let Some(hf_error) = hf_error { + // `attempt`, not the schedule length: a sequential stall breaks out early. + error!( + "HF download failed for {} after {} attempt(s): {:?}", + model_id, attempt, hf_error + ); + let mirrors = crate::catalog::mirror_fallbacks(&model_id); + if mirrors.is_empty() { + return Err(anyhow::anyhow!( + "Hugging Face download failed after {} attempt(s): {}", + attempt, + hf_error + )); + } + let mut completed = false; + for mirror in &mirrors { + info!("Falling back to mirror for {}: {}", model_id, mirror.url); + match self + .download_from_mirror(&model_id, &filename, mirror, cancel_token.clone()) + .await + { + Ok(true) => { + completed = true; + break; + } + Ok(false) => { + info!("Mirror download cancelled for: {}", model_id); + return Ok(()); + } + Err(e) => { + warn!( + "Mirror download failed for {} from {}: {:?}", + model_id, mirror.url, e + ); + } + } } - Err(e) => { - return Err(anyhow::anyhow!("Hugging Face download failed: {}", e)); + if !completed { + return Err(anyhow::anyhow!( + "Download failed from Hugging Face ({}) and {} mirror(s)", + hf_error, + mirrors.len() + )); } } @@ -1826,6 +2109,47 @@ impl ModelManager { Ok(()) } + /// Direct-HTTP download of a catalog model's file from a mirror into the + /// models dir (HF-model resolution accepts that location too). Returns + /// `Ok(true)` on completion, `Ok(false)` if cancelled (partial kept). + async fn download_from_mirror( + &self, + model_id: &str, + filename: &str, + mirror: &crate::catalog::MirrorFile, + cancel_token: CancellationToken, + ) -> Result { + fs::create_dir_all(&self.models_dir)?; + let model_path = self.models_dir.join(filename); + let partial_path = self.models_dir.join(format!("{}.partial", filename)); + + if model_path.exists() { + return Ok(true); + } + + match self + .download_http_resumable( + model_id, + &mirror.url, + &partial_path, + Some(mirror.size_bytes), + Some(&mirror.sha256), + &cancel_token, + ) + .await? + { + HttpDownloadOutcome::Cancelled => Ok(false), + HttpDownloadOutcome::Completed => { + fs::rename(&partial_path, &model_path)?; + info!( + "Mirror download of {} completed and verified ({:?})", + model_id, model_path + ); + Ok(true) + } + } + } + pub async fn download_model(&self, model_id: &str) -> Result<()> { let model_info = { let models = self.available_models.lock().unwrap(); @@ -1861,16 +2185,6 @@ impl ModelManager { return Ok(()); } - // Check if we have a partial download to resume - let mut resume_from = if partial_path.exists() { - let size = partial_path.metadata()?.len(); - info!("Resuming download of model {} from byte {}", model_id, size); - size - } else { - info!("Starting fresh download of model {} from {}", model_id, url); - 0 - }; - // Mark as downloading { let mut models = self.available_models.lock().unwrap(); @@ -1895,168 +2209,28 @@ impl ModelManager { disarmed: false, }; - // Create HTTP client with range request for resuming - let client = reqwest::Client::new(); - let mut request = client.get(&url); - - if resume_from > 0 { - request = request.header("Range", format!("bytes={}-", resume_from)); - } - - let mut response = request.send().await?; - - // If we tried to resume but server returned 200 (not 206 Partial Content), - // the server doesn't support range requests. Delete partial file and restart - // fresh to avoid file corruption (appending full file to partial). - if resume_from > 0 && response.status() == reqwest::StatusCode::OK { - warn!( - "Server doesn't support range requests for model {}, restarting download", - model_id - ); - drop(response); - let _ = fs::remove_file(&partial_path); - - // Reset resume_from since we're starting fresh - resume_from = 0; - - // Restart download without range header - response = client.get(&url).send().await?; - } - - // Check for success or partial content status - if !response.status().is_success() - && response.status() != reqwest::StatusCode::PARTIAL_CONTENT + // URL sources carry no authoritative size, so the helper falls back to + // the server's content-length for progress and completeness checks. + match self + .download_http_resumable( + model_id, + &url, + &partial_path, + None, + expected_sha256.as_deref(), + &cancel_token, + ) + .await? { - return Err(anyhow::anyhow!( - "Failed to download model: HTTP {}", - response.status() - )); - } - - let total_size = if resume_from > 0 { - // For resumed downloads, add the resume point to content length - resume_from + response.content_length().unwrap_or(0) - } else { - response.content_length().unwrap_or(0) - }; - - let mut downloaded = resume_from; - let mut stream = response.bytes_stream(); - - // Open file for appending if resuming, or create new if starting fresh - let mut file = if resume_from > 0 { - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&partial_path)? - } else { - std::fs::File::create(&partial_path)? - }; - - // Emit initial progress - let initial_progress = DownloadProgress { - model_id: model_id.to_string(), - downloaded, - total: total_size, - percentage: if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 0.0 - }, - }; - let _ = self - .app_handle - .emit("model-download-progress", &initial_progress); - - // Throttle progress events to max 10/sec (100ms intervals) - let mut last_emit = Instant::now(); - let throttle_duration = Duration::from_millis(100); - - // Download with progress - while let Some(chunk) = stream.next().await { - // Check if download was cancelled - if cancel_token.is_cancelled() { - drop(file); + HttpDownloadOutcome::Cancelled => { info!("Download cancelled for: {}", model_id); // Keep partial file for resume functionality. // Guard handles is_downloading + cancel_flags cleanup on drop. return Ok(()); } - - let chunk = chunk?; - - file.write_all(&chunk)?; - downloaded += chunk.len() as u64; - - let percentage = if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 0.0 - }; - - // Emit progress event (throttled to avoid UI freeze) - if last_emit.elapsed() >= throttle_duration { - let progress = DownloadProgress { - model_id: model_id.to_string(), - downloaded, - total: total_size, - percentage, - }; - let _ = self.app_handle.emit("model-download-progress", &progress); - last_emit = Instant::now(); - } + HttpDownloadOutcome::Completed => {} } - // Emit final progress to ensure 100% is shown - let final_progress = DownloadProgress { - model_id: model_id.to_string(), - downloaded, - total: total_size, - percentage: if total_size > 0 { - (downloaded as f64 / total_size as f64) * 100.0 - } else { - 100.0 - }, - }; - let _ = self - .app_handle - .emit("model-download-progress", &final_progress); - - file.flush()?; - drop(file); // Ensure file is closed before moving - - // Verify downloaded file size matches expected size - if total_size > 0 { - let actual_size = partial_path.metadata()?.len(); - if actual_size != total_size { - // Download is incomplete/corrupted - delete partial and return error - let _ = fs::remove_file(&partial_path); - return Err(anyhow::anyhow!( - "Download incomplete: expected {} bytes, got {} bytes", - total_size, - actual_size - )); - } - } - - // Verify SHA256 checksum. Runs in a blocking thread so the async executor is not - // stalled while hashing large model files (up to 1.6 GB). On failure the partial - // is deleted inside verify_sha256 so the next attempt always starts fresh. - let _ = self.app_handle.emit("model-verification-started", model_id); - info!("Verifying SHA256 for model {}...", model_id); - let verify_path = partial_path.clone(); - let verify_expected = expected_sha256.clone(); - let verify_model_id = model_id.to_string(); - let verify_result = tokio::task::spawn_blocking(move || { - Self::verify_sha256(&verify_path, verify_expected.as_deref(), &verify_model_id) - }) - .await - .map_err(|e| anyhow::anyhow!("SHA256 task panicked: {}", e))?; - verify_result?; - let _ = self - .app_handle - .emit("model-verification-completed", model_id); - // Handle directory-based models (extract tar.gz) vs file-based models if model_info.is_directory { // Track that this model is being extracted @@ -2188,11 +2362,18 @@ impl ModelManager { debug!("ModelManager: Found model info: {:?}", model_info); if let ModelSource::HuggingFace { repo_id, revision } = &model_info.source { - // Cached at /models--org--name/snapshots//; remove - // the whole repo dir (blobs + refs + snapshots). Per product decision, - // delete hard-removes from the shared HF cache. + let is_alternate_quant = + Self::is_catalog_alternate_quant(repo_id, &model_info.filename); let mut deleted = false; - if let Some(file) = hf_cached_path(repo_id, revision, &model_info.filename) { + if is_alternate_quant { + // Only this quant's own file: the snapshot pointer and its + // blob. The default (and any other quants) survive in the + // cache — the entry never owned more than its one file. + deleted |= Self::delete_hf_cache_file(repo_id, revision, &model_info.filename); + } else if let Some(file) = hf_cached_path(repo_id, revision, &model_info.filename) { + // Cached at /models--org--name/snapshots//; remove + // the whole repo dir (blobs + refs + snapshots). Per product decision, + // delete hard-removes from the shared HF cache. if let Some(repo_dir) = file.ancestors().nth(3) { if repo_dir .file_name() @@ -2205,9 +2386,28 @@ impl ModelManager { } } } + // Also remove a models-dir copy (mirror fallback / manual drop-in) + // and any resumable partial next to it. + for path in [ + self.models_dir.join(&model_info.filename), + self.models_dir + .join(format!("{}.partial", &model_info.filename)), + ] { + if path.exists() { + info!("Deleting model file at: {:?}", path); + fs::remove_file(&path)?; + deleted = true; + } + } if !deleted { return Err(anyhow::anyhow!("No model files found to delete")); } + // Alternate-quant entries are discovery-created (the catalog only + // seeds defaults), so deleting one un-discovers it rather than + // leaving a permanent "(Q4_K_M)" row in the list. + if is_alternate_quant { + self.available_models.lock().unwrap().remove(model_id); + } self.update_download_status()?; let _ = self.app_handle.emit("model-deleted", model_id); return Ok(()); @@ -2288,9 +2488,27 @@ impl ModelManager { } if let ModelSource::HuggingFace { repo_id, revision } = &model_info.source { - return hf_cached_path(repo_id, revision, &model_info.filename).ok_or_else(|| { - anyhow::anyhow!("Complete model file not found in HF cache: {}", model_id) - }); + if let Some(path) = hf_cached_path(repo_id, revision, &model_info.filename) { + return Ok(path); + } + // Mirror-fallback download or manual drop-in in the models dir. + // The complete file only ever appears after verification, so a + // stale `.partial` alongside it is leftover noise, not a veto — + // clear it rather than declaring the model missing. + let local_path = self.models_dir.join(&model_info.filename); + if local_path.exists() { + let partial_path = self + .models_dir + .join(format!("{}.partial", &model_info.filename)); + if partial_path.exists() { + let _ = fs::remove_file(&partial_path); + } + return Ok(local_path); + } + return Err(anyhow::anyhow!( + "Complete model file not found in HF cache or models dir: {}", + model_id + )); } let model_path = self.models_dir.join(&model_info.filename); @@ -2560,73 +2778,105 @@ mod tests { assert_eq!(models.len(), count_before); } - // ── SHA256 verification tests ───────────────────────────────────────────── - - /// Helper: write `data` to a temp file and return (TempDir, path). - /// TempDir must be kept alive for the duration of the test. - fn write_temp_file(data: &[u8]) -> (TempDir, std::path::PathBuf) { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("model.partial"); - let mut f = File::create(&path).unwrap(); - f.write_all(data).unwrap(); - (dir, path) - } - - #[test] - fn test_verify_sha256_skipped_when_none() { - // Custom models have no expected hash — verification must be a no-op. - let (_dir, path) = write_temp_file(b"anything"); - assert!(ModelManager::verify_sha256(&path, None, "custom").is_ok()); - assert!( - path.exists(), - "file must be untouched when verification is skipped" - ); - } - #[test] - fn test_verify_sha256_passes_on_correct_hash() { - // Compute the real hash so the test is self-consistent. - let (_dir, path) = write_temp_file(b"hello world"); - let actual = ModelManager::compute_sha256(&path).unwrap(); - assert!( - ModelManager::verify_sha256(&path, Some(&actual), "test_model").is_ok(), - "should pass when hash matches" - ); - assert!( - path.exists(), - "file must be kept on successful verification" - ); + fn test_catalog_quant_rendering() { + let desc = ModelDescriptor { + id: "org/repo/model-Q8_0.gguf".to_string(), + source: ModelSource::HuggingFace { + repo_id: "org/repo".to_string(), + revision: "main".to_string(), + }, + name: "Model".to_string(), + description: "desc".to_string(), + engine_type: EngineType::TranscribeCpp, + caps: CapabilityProbe::default(), + files: vec![ + QuantFile { + filename: "model-Q4_K_M.gguf".to_string(), + quant: "Q4_K_M".to_string(), + size_bytes: 1, + sha256: None, + }, + QuantFile { + filename: "model-Q8_0.gguf".to_string(), + quant: "Q8_0".to_string(), + size_bytes: 2, + sha256: None, + }, + ], + default_quant: Some("Q8_0".to_string()), + speed_score: 0.5, + accuracy_score: 0.5, + recommended_rank: None, + recommended: true, + }; + let status = DiskStatus::default(); + + // Default quant: plain name, badge intact. + let default_info = desc.to_model_info(&status); + assert_eq!(default_info.name, "Model"); + assert!(default_info.is_recommended); + + // Alternate quant: suffixed name, own id, no badge. + let alt_info = desc.to_model_info_for_file(&desc.files[0], &status); + assert_eq!(alt_info.id, "org/repo/model-Q4_K_M.gguf"); + assert_eq!(alt_info.name, "Model (Q4_K_M)"); + assert_eq!(alt_info.filename, "model-Q4_K_M.gguf"); + assert!(!alt_info.is_recommended); + assert!(!alt_info.is_custom); + + // The default rendered through the per-file path matches to_model_info, + // so seeded entries and discovered defaults can never diverge. + let same = desc.to_model_info_for_file(&desc.files[1], &status); + assert_eq!(same.id, default_info.id); + assert_eq!(same.name, default_info.name); + assert_eq!(same.is_recommended, default_info.is_recommended); } #[test] - fn test_verify_sha256_fails_and_deletes_partial_on_mismatch() { - let (_dir, path) = write_temp_file(b"this is not the real model"); - let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000"; - - let result = ModelManager::verify_sha256(&path, Some(wrong_hash), "bad_model"); - - assert!(result.is_err(), "mismatch must return an error"); - assert!( - result.unwrap_err().to_string().contains("corrupt"), - "error message should mention corruption" - ); - assert!( - !path.exists(), - "partial file must be deleted after hash mismatch" - ); - } + fn test_discover_catalog_alternate_quant_in_models_dir() { + // A real catalog model with more than one quant. + let desc = crate::catalog::CATALOG + .iter() + .find(|d| d.files.len() > 1) + .expect("catalog has multi-quant models"); + let default_filename = default_quant_file(&desc.files, desc.default_quant.as_deref()) + .unwrap() + .filename + .clone(); + let alt = desc + .files + .iter() + .find(|f| f.filename != default_filename) + .unwrap(); - #[test] - fn test_verify_sha256_fails_and_deletes_partial_when_file_missing() { - // Simulate a partial file that was already removed (e.g. disk full mid-download). - let dir = TempDir::new().unwrap(); - let missing_path = dir.path().join("gone.partial"); - // Don't create the file — it should not exist. + let temp_dir = TempDir::new().unwrap(); + // Content is never probed for catalog-matched files, so empty files do. + fs::write(temp_dir.path().join(&alt.filename), b"").unwrap(); + fs::write(temp_dir.path().join(&default_filename), b"").unwrap(); - let result = - ModelManager::verify_sha256(&missing_path, Some("anyexpectedhash"), "missing_model"); + let mut models = HashMap::new(); + ModelManager::seed_catalog_models(&mut models); + let seeded = models.len(); + ModelManager::discover_custom_transcribe_models(temp_dir.path(), &mut models).unwrap(); - assert!(result.is_err(), "missing file must return an error"); + // The alternate quant surfaces as a catalog-grade HF entry… + let ModelSource::HuggingFace { repo_id, .. } = &desc.source else { + panic!("catalog descriptors are HF-sourced"); + }; + let alt_id = format!("{}/{}", repo_id, alt.filename); + let info = models.get(&alt_id).expect("alternate quant discovered"); + assert_eq!(info.name, format!("{} ({})", desc.name, alt.quant)); + assert_eq!(info.description, desc.description); + assert!(info.is_downloaded); + assert!(!info.is_custom); + assert!(matches!(info.source, ModelSource::HuggingFace { .. })); + + // …while the default-quant file dedups onto its seeded entry: exactly + // one new id, and no filename-stem custom entries for either file. + assert_eq!(models.len(), seeded + 1); + assert!(!models.contains_key(alt.filename.trim_end_matches(".gguf"))); + assert!(!models.contains_key(default_filename.trim_end_matches(".gguf"))); } fn push_gguf_str(out: &mut Vec, val: &str) { diff --git a/src-tauri/src/managers/model/download.rs b/src-tauri/src/managers/model/download.rs new file mode 100644 index 0000000000..288d67f63c --- /dev/null +++ b/src-tauri/src/managers/model/download.rs @@ -0,0 +1,384 @@ +//! Shared direct-HTTP model download transport. +//! +//! Both legacy URL models and Hugging Face mirror fallbacks use this module; +//! source-specific orchestration and finalization remain in the parent module. + +use super::{DownloadProgress, ModelManager}; +use anyhow::Result; +use futures_util::StreamExt; +use hf_hub::api::tokio::CancellationToken; +use log::{info, warn}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; +use tauri::Emitter; + +/// Bound on connection setup for direct HTTP downloads (mirror + URL models). +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// No headers, body bytes, or hf-hub progress for this long means the transfer +/// is wedged, not slow: direct downloads error out (keeping the partial for +/// resume) and HF attempts are cancelled by a watchdog — either way the retry +/// loop and mirror fallback take over instead of hanging forever. +pub(super) const DOWNLOAD_STALL_TIMEOUT: Duration = Duration::from_secs(60); + +/// Start offset of a `Content-Range: bytes -/` header. +fn content_range_start(value: &str) -> Option { + let range = value.trim().strip_prefix("bytes")?.trim_start(); + range.split('-').next()?.trim().parse().ok() +} + +/// How a [`ModelManager::download_http_resumable`] call ended, cancellation +/// being an outcome (partial kept, no error surfaced) rather than a failure. +#[derive(Debug)] +pub(super) enum HttpDownloadOutcome { + Completed, + Cancelled, +} + +/// Side-channel notifications from the resumable HTTP downloader, decoupled +/// from Tauri (the production wrapper maps them onto app events) so the +/// transport logic is testable without an `AppHandle`. +enum HttpDownloadEvent<'a> { + Progress(&'a DownloadProgress), + VerificationStarted, + VerificationCompleted, +} + +impl ModelManager { + /// Verifies the SHA256 of `path` against `expected_sha256` (if provided). + /// On mismatch or read error the partial file is deleted and an error is returned, + /// so the next download attempt always starts from a clean state. + /// When `expected_sha256` is `None` (custom user models) verification is skipped. + fn verify_sha256(path: &Path, expected_sha256: Option<&str>, model_id: &str) -> Result<()> { + let Some(expected) = expected_sha256 else { + return Ok(()); + }; + match Self::compute_sha256(path) { + Ok(actual) if actual == expected => { + info!("SHA256 verified for model {}", model_id); + Ok(()) + } + Ok(actual) => { + warn!( + "SHA256 mismatch for model {}: expected {}, got {}", + model_id, expected, actual + ); + let _ = fs::remove_file(path); + Err(anyhow::anyhow!( + "Download verification failed for model {}: file is corrupt. Please retry.", + model_id + )) + } + Err(e) => { + let _ = fs::remove_file(path); + Err(anyhow::anyhow!( + "Failed to verify download for model {}: {}. Please retry.", + model_id, + e + )) + } + } + } + + /// Computes the SHA256 hex digest of a file, reading in 64KB chunks to handle large models. + fn compute_sha256(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(format!("{:x}", hasher.finalize())) + } + + /// Emit verification events around a blocking sha256 check of `path`. + /// On mismatch `verify_sha256` deletes the file, so the next attempt (or + /// next source) starts clean. A `None` hash skips checking (custom models). + async fn verify_file_with_events( + model_id: &str, + path: &Path, + expected_sha256: Option<&str>, + emit: &(dyn Fn(HttpDownloadEvent<'_>) + Send + Sync), + ) -> Result<()> { + emit(HttpDownloadEvent::VerificationStarted); + let path = path.to_path_buf(); + let expected = expected_sha256.map(str::to_string); + let id = model_id.to_string(); + tokio::task::spawn_blocking(move || Self::verify_sha256(&path, expected.as_deref(), &id)) + .await + .map_err(|e| anyhow::anyhow!("SHA256 task panicked: {}", e))??; + emit(HttpDownloadEvent::VerificationCompleted); + Ok(()) + } + + /// [`Self::download_http_resumable_with_events`] wired to the Tauri event + /// bus — the production entry point. + pub(super) async fn download_http_resumable( + &self, + model_id: &str, + url: &str, + partial_path: &Path, + expected_size: Option, + expected_sha256: Option<&str>, + cancel_token: &CancellationToken, + ) -> Result { + let app_handle = self.app_handle.clone(); + let id = model_id.to_string(); + Self::download_http_resumable_with_events( + model_id, + url, + partial_path, + expected_size, + expected_sha256, + cancel_token, + &move |event| { + let _ = match event { + HttpDownloadEvent::Progress(progress) => { + app_handle.emit("model-download-progress", progress) + } + HttpDownloadEvent::VerificationStarted => { + app_handle.emit("model-verification-started", &id) + } + HttpDownloadEvent::VerificationCompleted => { + app_handle.emit("model-verification-completed", &id) + } + }; + }, + ) + .await + } + + /// The one resumable HTTP downloader, shared by the mirror fallback and + /// URL-sourced models: fetch `url` into `partial_path`, resuming what's + /// already there, and leave verified bytes in `partial_path` on success — + /// finalizing (rename / extract) is the caller's job. Takes progress and + /// verification notifications as a callback instead of touching Tauri, so + /// the failure-mode behavior below is exercised by tests against a local + /// socket server. + /// + /// Robustness properties, in the order the failure modes appear: + /// - a partial already at the expected size (crash between completion and + /// finalize) is verified and accepted instead of asking the server for + /// `Range: bytes=-` and looping on 416 forever; an oversized one is + /// deleted; a live 416 finishes the partial only when a hash can prove + /// it, and otherwise clears it + /// - connection setup and every body chunk are bounded by + /// [`HTTP_CONNECT_TIMEOUT`] / [`DOWNLOAD_STALL_TIMEOUT`] and race the + /// cancel token, so a wedged transfer can neither hang the download + /// forever nor ignore a cancel + /// - a 200 to a Range request (server ignored it) restarts from zero + /// rather than appending the whole file to the partial; a 206 must start + /// exactly at our offset or the partial is discarded + /// - a server claiming or sending more than the expected size is cut off + /// at the first excess byte, not trusted until it closes the stream + /// - the final bytes are checked against `expected_size` (catalog, or + /// content-length when unknown) and `expected_sha256` before returning + async fn download_http_resumable_with_events( + model_id: &str, + url: &str, + partial_path: &Path, + expected_size: Option, + expected_sha256: Option<&str>, + cancel_token: &CancellationToken, + emit: &(dyn Fn(HttpDownloadEvent<'_>) + Send + Sync), + ) -> Result { + let mut resume_from = partial_path.metadata().map(|m| m.len()).unwrap_or(0); + + if let Some(expected) = expected_size { + if resume_from > expected { + let _ = fs::remove_file(partial_path); + resume_from = 0; + } else if resume_from == expected && expected > 0 { + info!( + "Partial download of {} is already full-size; verifying", + model_id + ); + Self::verify_file_with_events(model_id, partial_path, expected_sha256, emit) + .await?; + return Ok(HttpDownloadOutcome::Completed); + } + } + + if resume_from > 0 { + info!( + "Resuming download of {} from byte {}", + model_id, resume_from + ); + } else { + info!("Starting fresh download of {} from {}", model_id, url); + } + + let client = reqwest::Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .build()?; + let mut request = client.get(url); + if resume_from > 0 { + request = request.header("Range", format!("bytes={}-", resume_from)); + } + let response = tokio::select! { + r = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, request.send()) => r + .map_err(|_| anyhow::anyhow!( + "no response within {}s from {}", + DOWNLOAD_STALL_TIMEOUT.as_secs(), url + ))??, + _ = cancel_token.cancelled() => return Ok(HttpDownloadOutcome::Cancelled), + }; + + // 416 to our Range request means its start is at or past the object's + // end. With a catalog size in hand that can only mean the server's + // object is *smaller* than expected (a full-size partial never issues + // a request — handled above), and with no hash there is no trusted + // signal to bless the partial: both restart clean. Only a hash can + // genuinely finish a partial here. Without a Range in flight a 416 is + // just a broken server, which the generic status check below rejects. + if resume_from > 0 && response.status() == reqwest::StatusCode::RANGE_NOT_SATISFIABLE { + if expected_size.is_some() || expected_sha256.is_none() { + let _ = fs::remove_file(partial_path); + return Err(anyhow::anyhow!( + "server object ends before the expected size (HTTP 416)" + )); + } + Self::verify_file_with_events(model_id, partial_path, expected_sha256, emit).await?; + return Ok(HttpDownloadOutcome::Completed); + } + // A 200 to a Range request means the server ignored it and is sending + // the whole file; appending it to the partial would corrupt the model. + if resume_from > 0 && response.status() == reqwest::StatusCode::OK { + let _ = fs::remove_file(partial_path); + resume_from = 0; + } + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "server returned HTTP {}", + response.status() + )); + } + // On a 206, trust but verify the offset: a reply starting anywhere but + // exactly our partial's end would silently corrupt the file on append. + if resume_from > 0 && response.status() == reqwest::StatusCode::PARTIAL_CONTENT { + let starts_at = response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(content_range_start); + if starts_at != Some(resume_from) { + let _ = fs::remove_file(partial_path); + return Err(anyhow::anyhow!( + "server returned Content-Range starting at {:?}, expected {}", + starts_at, + resume_from + )); + } + } + // When the catalog pins the size, a server advertising a different + // total is already misbehaving — reject before writing anything. + if let (Some(expected), Some(len)) = (expected_size, response.content_length()) { + if resume_from + len != expected { + return Err(anyhow::anyhow!( + "server advertises {} bytes, expected {}", + resume_from + len, + expected + )); + } + } + + let known_total = + expected_size.or_else(|| response.content_length().map(|l| resume_from + l)); + let total_size = known_total.unwrap_or(0); + let mut downloaded = resume_from; + let mut file = if resume_from > 0 { + std::fs::OpenOptions::new() + .append(true) + .open(partial_path)? + } else { + std::fs::File::create(partial_path)? + }; + + let emit_progress = |downloaded: u64| { + emit(HttpDownloadEvent::Progress(&DownloadProgress { + model_id: model_id.to_string(), + downloaded, + total: total_size, + percentage: if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 0.0 + }, + })); + }; + emit_progress(downloaded); + + // Throttle progress events to max 10/sec (100ms intervals) + let mut last_emit = Instant::now(); + let throttle = Duration::from_millis(100); + let mut stream = response.bytes_stream(); + loop { + let chunk = tokio::select! { + c = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next()) => match c { + // Stalled mid-body: keep the partial for resume. + Err(_) => return Err(anyhow::anyhow!( + "transfer stalled: no data for {}s", + DOWNLOAD_STALL_TIMEOUT.as_secs() + )), + Ok(None) => break, + Ok(Some(chunk)) => chunk?, + }, + _ = cancel_token.cancelled() => { + // Keep the partial for resume; caller handles state cleanup. + return Ok(HttpDownloadOutcome::Cancelled); + } + }; + // An untrusted server must not be able to fill the disk: cut the + // transfer at the first byte past the known total instead of + // trusting it to eventually close the stream. Everything written + // so far is tainted by a provably-misbehaving server — clear it. + if let Some(cap) = known_total { + if downloaded + chunk.len() as u64 > cap { + drop(file); + let _ = fs::remove_file(partial_path); + return Err(anyhow::anyhow!( + "server sent more than the expected {} bytes", + cap + )); + } + } + file.write_all(&chunk)?; + downloaded += chunk.len() as u64; + if last_emit.elapsed() >= throttle { + emit_progress(downloaded); + last_emit = Instant::now(); + } + } + file.flush()?; + drop(file); + emit_progress(downloaded); + + if let Some(expected) = known_total { + let actual = partial_path.metadata()?.len(); + if actual != expected { + let _ = fs::remove_file(partial_path); + return Err(anyhow::anyhow!( + "download incomplete: expected {} bytes, got {}", + expected, + actual + )); + } + } + + // The catalog hash is the trust anchor: for a mirror (an untrusted + // host) this verification is what makes the fallback safe at all. + Self::verify_file_with_events(model_id, partial_path, expected_sha256, emit).await?; + Ok(HttpDownloadOutcome::Completed) + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/managers/model/download/tests.rs b/src-tauri/src/managers/model/download/tests.rs new file mode 100644 index 0000000000..dfe3356d91 --- /dev/null +++ b/src-tauri/src/managers/model/download/tests.rs @@ -0,0 +1,579 @@ +use super::*; +use sha2::{Digest, Sha256}; +use std::fs; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use std::time::{Duration, Instant}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +// ── SHA256 verification tests ───────────────────────────────────────────── + +/// Helper: write `data` to a temp file and return (TempDir, path). +/// TempDir must be kept alive for the duration of the test. +fn write_temp_file(data: &[u8]) -> (TempDir, std::path::PathBuf) { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("model.partial"); + let mut f = File::create(&path).unwrap(); + f.write_all(data).unwrap(); + (dir, path) +} + +#[test] +fn test_verify_sha256_skipped_when_none() { + // Custom models have no expected hash — verification must be a no-op. + let (_dir, path) = write_temp_file(b"anything"); + assert!(ModelManager::verify_sha256(&path, None, "custom").is_ok()); + assert!( + path.exists(), + "file must be untouched when verification is skipped" + ); +} + +#[test] +fn test_verify_sha256_passes_on_correct_hash() { + // Compute the real hash so the test is self-consistent. + let (_dir, path) = write_temp_file(b"hello world"); + let actual = ModelManager::compute_sha256(&path).unwrap(); + assert!( + ModelManager::verify_sha256(&path, Some(&actual), "test_model").is_ok(), + "should pass when hash matches" + ); + assert!( + path.exists(), + "file must be kept on successful verification" + ); +} + +#[test] +fn test_verify_sha256_fails_and_deletes_partial_on_mismatch() { + let (_dir, path) = write_temp_file(b"this is not the real model"); + let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + + let result = ModelManager::verify_sha256(&path, Some(wrong_hash), "bad_model"); + + assert!(result.is_err(), "mismatch must return an error"); + assert!( + result.unwrap_err().to_string().contains("corrupt"), + "error message should mention corruption" + ); + assert!( + !path.exists(), + "partial file must be deleted after hash mismatch" + ); +} + +#[test] +fn test_verify_sha256_fails_and_deletes_partial_when_file_missing() { + // Simulate a partial file that was already removed (e.g. disk full mid-download). + let dir = TempDir::new().unwrap(); + let missing_path = dir.path().join("gone.partial"); + // Don't create the file — it should not exist. + + let result = + ModelManager::verify_sha256(&missing_path, Some("anyexpectedhash"), "missing_model"); + + assert!(result.is_err(), "missing file must return an error"); +} + +// ── Resumable HTTP downloader tests ─────────────────────────────────────── +// +// Each test drives `download_http_resumable_with_events` against a scripted +// single-connection server on a local socket — no Tauri, no real network. +// Success means verified bytes are left in the partial (finalizing is the +// caller's job), so "no Completed on a bad hash" is the rename gate too. + +fn sha_hex(data: &[u8]) -> String { + format!("{:x}", Sha256::digest(data)) +} + +fn http_response(status_line: &str, headers: &[String], body: &[u8]) -> Vec { + let mut head = format!("HTTP/1.1 {}\r\nConnection: close\r\n", status_line); + for h in headers { + head.push_str(h); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + let mut bytes = head.into_bytes(); + bytes.extend_from_slice(body); + bytes +} + +async fn read_request_head(sock: &mut TcpStream) -> String { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + while !buf.ends_with(b"\r\n\r\n") { + if sock.read_exact(&mut byte).await.is_err() { + break; + } + buf.push(byte[0]); + } + String::from_utf8_lossy(&buf).into_owned() +} + +/// Serve exactly one connection: capture the request head, write +/// `response` verbatim, close. Returns the URL to fetch and a handle +/// yielding the captured request head (lowercased for header asserts). +async fn serve_once(response: Vec) -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let head = read_request_head(&mut sock).await; + // The client may hang up early (error tests); that's not a + // server-side failure. + let _ = sock.write_all(&response).await; + let _ = sock.shutdown().await; + head.to_lowercase() + }); + (format!("http://{}/file", addr), handle) +} + +async fn run_download( + url: &str, + partial: &Path, + expected_size: Option, + expected_sha256: Option<&str>, + cancel: &CancellationToken, +) -> Result { + ModelManager::download_http_resumable_with_events( + "test/model", + url, + partial, + expected_size, + expected_sha256, + cancel, + &|_| {}, + ) + .await +} + +#[tokio::test] +async fn http_fresh_download_completes_and_verifies() { + let body = b"hello world"; + let (url, server) = serve_once(http_response( + "200 OK", + &[format!("Content-Length: {}", body.len())], + body, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let out = run_download( + &url, + &partial, + Some(body.len() as u64), + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); + let head = server.await.unwrap(); + assert!( + !head.contains("range:"), + "fresh download must not send a Range header" + ); +} + +#[tokio::test] +async fn http_resume_appends_with_valid_content_range() { + let full = b"helloworld"; + let (url, server) = serve_once(http_response( + "206 Partial Content", + &[ + "Content-Range: bytes 5-9/10".to_string(), + "Content-Length: 5".to_string(), + ], + &full[5..], + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, &full[..5]).unwrap(); + + let out = run_download( + &url, + &partial, + Some(10), + Some(&sha_hex(full)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), full); + let head = server.await.unwrap(); + assert!(head.contains("range: bytes=5-"), "got request: {head}"); +} + +#[tokio::test] +async fn http_wrong_content_range_discards_partial() { + // Server answers 206 but restarts the range at 0: appending would + // corrupt, so the partial must be dropped and the call must fail. + let full = b"helloworld"; + let (url, _server) = serve_once(http_response( + "206 Partial Content", + &[ + "Content-Range: bytes 0-9/10".to_string(), + "Content-Length: 10".to_string(), + ], + full, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, &full[..5]).unwrap(); + + let err = run_download( + &url, + &partial, + Some(10), + Some(&sha_hex(full)), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("Content-Range"), "{err}"); + assert!(!partial.exists(), "corrupt-prone partial must be deleted"); +} + +#[tokio::test] +async fn http_range_ignored_200_restarts_from_zero() { + let body = b"helloworld!"; + let (url, _server) = serve_once(http_response( + "200 OK", + &[format!("Content-Length: {}", body.len())], + body, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"XXXXX").unwrap(); // stale bytes that must not survive + + let out = run_download( + &url, + &partial, + Some(body.len() as u64), + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); +} + +#[tokio::test] +async fn http_416_with_hash_finalizes_complete_partial() { + // URL-model shape: no catalog size, but a hash — the one case where a + // 416 may bless the partial, because verification proves it. + let body = b"the whole file"; + let (url, _server) = serve_once(http_response("416 Range Not Satisfiable", &[], b"")).await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, body).unwrap(); + + let out = run_download( + &url, + &partial, + None, + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); +} + +#[tokio::test] +async fn http_416_with_wrong_hash_clears_partial() { + let (url, _server) = serve_once(http_response("416 Range Not Satisfiable", &[], b"")).await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"corrupted bytes").unwrap(); + + let err = run_download( + &url, + &partial, + None, + Some(&sha_hex(b"the real file")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("corrupt"), "{err}"); + assert!( + !partial.exists(), + "failed verification must delete the partial" + ); +} + +#[tokio::test] +async fn http_416_short_of_expected_size_clears_partial() { + // Mirror shape: catalog size known, partial shorter, yet the server + // says our offset is past EOF — its object is smaller than the catalog + // expects. Never blessed, even with a hash on hand. + let (url, _server) = serve_once(http_response("416 Range Not Satisfiable", &[], b"")).await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"12345").unwrap(); + + let err = run_download( + &url, + &partial, + Some(10), + Some(&sha_hex(b"1234567890")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("416"), "{err}"); + assert!(!partial.exists()); +} + +#[tokio::test] +async fn http_416_without_trust_signals_clears_partial() { + // No size, no hash: nothing can prove the partial complete, so a 416 + // must restart clean instead of accepting unverified bytes. + let (url, _server) = serve_once(http_response("416 Range Not Satisfiable", &[], b"")).await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"whatever").unwrap(); + + let err = run_download(&url, &partial, None, None, &CancellationToken::new()) + .await + .unwrap_err(); + + assert!(err.to_string().contains("416"), "{err}"); + assert!(!partial.exists()); +} + +#[tokio::test] +async fn http_full_size_partial_verified_without_network() { + // Crash-between-completion-and-finalize recovery: the partial already + // has every byte, so no request is issued at all (the URL points at a + // dead port to prove it). + let body = b"complete content"; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, body).unwrap(); + + let out = run_download( + "http://127.0.0.1:9/unreachable", + &partial, + Some(body.len() as u64), + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); +} + +#[tokio::test] +async fn http_oversized_partial_restarts_clean() { + let body = b"12345"; + let (url, server) = serve_once(http_response( + "200 OK", + &[format!("Content-Length: {}", body.len())], + body, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + fs::write(&partial, b"garbage-beyond-expected").unwrap(); + + let out = run_download( + &url, + &partial, + Some(body.len() as u64), + Some(&sha_hex(body)), + &CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Completed)); + assert_eq!(fs::read(&partial).unwrap(), body); + let head = server.await.unwrap(); + assert!( + !head.contains("range:"), + "oversized partial must restart, not resume: {head}" + ); +} + +#[tokio::test] +async fn http_conflicting_content_length_rejected_before_writing() { + let (url, _server) = serve_once(http_response( + "200 OK", + &["Content-Length: 10".to_string()], + b"0123456789", + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let err = run_download( + &url, + &partial, + Some(5), + Some(&sha_hex(b"01234")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("advertises"), "{err}"); + assert!( + !partial.exists(), + "nothing may be written on an up-front size conflict" + ); +} + +#[tokio::test] +async fn http_body_exceeding_expected_size_aborts_and_clears() { + // Chunked response (no Content-Length to reject up-front) that keeps + // sending past the expected size: the disk-fill guard must cut the + // transfer at the first excess byte and clear the tainted partial. + let (url, _server) = serve_once(http_response( + "200 OK", + &["Transfer-Encoding: chunked".to_string()], + b"3\r\nabc\r\n5\r\ndefgh\r\n0\r\n\r\n", + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let err = run_download( + &url, + &partial, + Some(5), + Some(&sha_hex(b"abcde")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("more than"), "{err}"); + assert!(!partial.exists(), "tainted partial must be deleted"); +} + +#[tokio::test] +async fn http_wrong_hash_after_download_clears_partial() { + let body = b"tampered bytes"; + let (url, _server) = serve_once(http_response( + "200 OK", + &[format!("Content-Length: {}", body.len())], + body, + )) + .await; + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let err = run_download( + &url, + &partial, + Some(body.len() as u64), + Some(&sha_hex(b"the expected bytes")), + &CancellationToken::new(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("corrupt"), "{err}"); + assert!(!partial.exists()); +} + +#[tokio::test] +async fn http_cancel_while_awaiting_headers() { + // Server accepts and goes silent. Cancellation must win immediately — + // not after the stall timeout, and certainly not never. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let _ = read_request_head(&mut sock).await; + tokio::time::sleep(Duration::from_secs(600)).await; + }); + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let cancel = CancellationToken::new(); + let trigger = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + trigger.cancel(); + }); + + let started = Instant::now(); + let out = run_download( + &format!("http://{}/file", addr), + &partial, + Some(10), + Some("00"), + &cancel, + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Cancelled)); + assert!( + started.elapsed() < Duration::from_secs(30), + "cancel must not wait out the stall timeout" + ); +} + +#[tokio::test] +async fn http_cancel_during_stalled_body_keeps_partial() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let _ = read_request_head(&mut sock).await; + let _ = sock + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nabc") + .await; + tokio::time::sleep(Duration::from_secs(600)).await; + }); + let dir = TempDir::new().unwrap(); + let partial = dir.path().join("m.partial"); + + let cancel = CancellationToken::new(); + let trigger = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + trigger.cancel(); + }); + + let out = run_download( + &format!("http://{}/file", addr), + &partial, + Some(100), + Some("00"), + &cancel, + ) + .await + .unwrap(); + + assert!(matches!(out, HttpDownloadOutcome::Cancelled)); + assert_eq!( + fs::read(&partial).unwrap(), + b"abc", + "bytes received before the cancel must be kept for resume" + ); +} From ea3c20a3a67c7401d8b19198723760da9d40ac45 Mon Sep 17 00:00:00 2001 From: Fred Chu Date: Tue, 28 Jul 2026 18:06:22 +0800 Subject: [PATCH 12/49] fix(i18n): resolve zh-Hant-* system locales to Traditional Chinese (zh-TW) (#1798) * fix(i18n): resolve zh-Hant-* system locales to Traditional Chinese (zh-TW) On macOS with system language set to Traditional Chinese (Taiwan), the OS reports the locale with a script subtag (zh-Hant-TW). Both language resolution paths missed it and fell back to the bare "zh" prefix, i.e. Simplified Chinese: - frontend: getSupportedLanguage() in src/i18n/index.ts (webview UI) - backend: get_tray_translations() in src-tauri/src/tray_i18n.rs (tray menu) Add a script-aware fallback to both: a zh locale whose second subtag is "hant" resolves to zh-TW before the language-only prefix match. zh-Hans-* still resolves to zh via the existing prefix path; saved zh-TW preferences still exact-match first; other locales are unchanged. Fixes #1794 * fix pre-existing bug and add tests * improve Chinese locale fallbacks * simplify --------- Co-authored-by: CJ Pais --- src-tauri/src/tray_i18n.rs | 60 +++++++++++++++++-- .../settings/AppLanguageSelector.tsx | 8 ++- src/i18n/index.ts | 24 ++++++-- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/tray_i18n.rs b/src-tauri/src/tray_i18n.rs index 1f896dbe34..2bdc84ca82 100644 --- a/src-tauri/src/tray_i18n.rs +++ b/src-tauri/src/tray_i18n.rs @@ -20,15 +20,63 @@ include!(concat!(env!("OUT_DIR"), "/tray_translations.rs")); /// Get localized tray menu strings based on the system locale. /// -/// Lookup order: full locale (e.g. "zh-TW") → language code ("zh") → English. +/// Lookup order: exact locale → Chinese script/region fallback → language code → English. pub fn get_tray_translations(locale: Option) -> TrayStrings { - let locale_str = locale.as_deref().unwrap_or("en"); - let lang_code = locale_str.split(['-', '_']).next().unwrap_or("en"); + let normalized = locale + .as_deref() + .unwrap_or("en") + .to_lowercase() + .replace('_', "-"); + let subtags: Vec<_> = normalized.split('-').collect(); + let language = subtags.first().copied().unwrap_or("en"); + let is_hant = subtags.contains(&"hant"); + let is_hans = subtags.contains(&"hans"); + let is_traditional_region = ["tw", "hk", "mo"] + .iter() + .any(|region| subtags.contains(region)); - TRANSLATIONS - .get(locale_str) - .or_else(|| TRANSLATIONS.get(lang_code)) + let exact_match = TRANSLATIONS + .iter() + .find_map(|(code, strings)| code.eq_ignore_ascii_case(&normalized).then_some(strings)); + let fallback = match language { + "zh" if is_hant || (!is_hans && is_traditional_region) => "zh-TW", + // Cantonese uses Traditional Chinese unless explicitly tagged as Hans. + "yue" if is_hans => "zh", + "yue" => "zh-TW", + _ => language, + }; + + exact_match + .or_else(|| TRANSLATIONS.get(fallback)) .or_else(|| TRANSLATIONS.get("en")) .cloned() .expect("English translations must exist") } + +#[cfg(test)] +mod tests { + use super::{get_tray_translations, TRANSLATIONS}; + + #[test] + fn resolves_locale_fallbacks() { + for (locale, expected) in [ + ("zh-Hant-TW", "zh-TW"), + ("zh-Hant-HK", "zh-TW"), + ("zh-HK", "zh-TW"), + ("zh-MO", "zh-TW"), + ("ZH-TW", "zh-TW"), + ("zh_Hant_TW", "zh-TW"), + ("zh-Hans-CN", "zh"), + ("yue-Hant-HK", "zh-TW"), + ("yue-Hans-CN", "zh"), + ("fr-FR", "fr"), + ("xx-YY", "en"), + ] { + assert_eq!( + format!("{:?}", get_tray_translations(Some(locale.into()))), + format!("{:?}", TRANSLATIONS[expected]), + "{locale} should resolve to {expected}" + ); + } + } +} diff --git a/src/components/settings/AppLanguageSelector.tsx b/src/components/settings/AppLanguageSelector.tsx index 3c41aa1a08..69be863159 100644 --- a/src/components/settings/AppLanguageSelector.tsx +++ b/src/components/settings/AppLanguageSelector.tsx @@ -2,7 +2,11 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; -import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from "../../i18n"; +import { + SUPPORTED_LANGUAGES, + getSupportedLanguage, + type SupportedLanguageCode, +} from "../../i18n"; import { useSettings } from "@/hooks/useSettings"; interface AppLanguageSelectorProps { @@ -15,7 +19,7 @@ export const AppLanguageSelector: React.FC = const { t, i18n } = useTranslation(); const { settings, updateSetting } = useSettings(); - const currentLanguage = (settings?.app_language || + const currentLanguage = (getSupportedLanguage(settings?.app_language) || i18n.language) as SupportedLanguageCode; const languageOptions = SUPPORTED_LANGUAGES.map((lang) => ({ diff --git a/src/i18n/index.ts b/src/i18n/index.ts index cf5fd3f2bc..107e7ce001 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -52,20 +52,34 @@ export const SUPPORTED_LANGUAGES = Object.keys(resources) export type SupportedLanguageCode = string; // Check if a language code is supported -const getSupportedLanguage = ( +export const getSupportedLanguage = ( langCode: string | null | undefined, ): SupportedLanguageCode | null => { if (!langCode) return null; - const normalized = langCode.toLowerCase(); + + const normalized = langCode.toLowerCase().replace(/_/g, "-"); + const subtags = normalized.split("-"); + const language = subtags[0]; + const isHant = subtags.includes("hant"); + const isHans = subtags.includes("hans"); + const isTraditionalRegion = ["tw", "hk", "mo"].some((region) => + subtags.includes(region), + ); + // Try exact match first let supported = SUPPORTED_LANGUAGES.find( (lang) => lang.code.toLowerCase() === normalized, ); if (!supported) { - // Fall back to prefix match (language only, without region) - const prefix = normalized.split("-")[0]; + let fallback = language; + if (language === "zh" && (isHant || (!isHans && isTraditionalRegion))) { + fallback = "zh-tw"; + } else if (language === "yue") { + // Cantonese uses Traditional Chinese unless explicitly tagged as Hans. + fallback = isHans ? "zh" : "zh-tw"; + } supported = SUPPORTED_LANGUAGES.find( - (lang) => lang.code.toLowerCase() === prefix, + (lang) => lang.code.toLowerCase() === fallback, ); } return supported ? supported.code : null; From d001fcd938aa6c0159873ae650a9351670fc446f Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 30 Jul 2026 15:34:27 +0800 Subject: [PATCH 13/49] secure input warning and fallback (#1785) * secure input warning and fallback * translations * change icon to be better * better secure input dialog * harden secure input fallback lifecycle --- src-tauri/resources/handy_warning.png | Bin 0 -> 2261 bytes src-tauri/resources/tray_idle_warning.png | Bin 0 -> 1464 bytes .../resources/tray_idle_warning_dark.png | Bin 0 -> 1237 bytes src-tauri/src/commands/mod.rs | 3 +- src-tauri/src/lib.rs | 14 +- src-tauri/src/secure_input.rs | 660 ++++++++++++++++++ src-tauri/src/shortcut/handy_keys.rs | 10 + src-tauri/src/shortcut/mod.rs | 24 +- src-tauri/src/shortcut/tauri_impl.rs | 9 +- src-tauri/src/tray.rs | 45 +- src/App.css | 6 + src/App.tsx | 4 + src/bindings.ts | 48 ++ src/components/SecureInputWarning.tsx | 121 ++++ .../settings/HandyKeysShortcutInput.tsx | 134 +++- .../settings/debug/DebugSettings.tsx | 2 + .../settings/debug/KeyboardDiagnostic.tsx | 120 ++++ src/components/ui/Button.tsx | 6 + src/i18n/locales/ar/translation.json | 34 +- src/i18n/locales/bg/translation.json | 34 +- src/i18n/locales/cs/translation.json | 34 +- src/i18n/locales/da/translation.json | 34 +- src/i18n/locales/de/translation.json | 34 +- src/i18n/locales/en/translation.json | 34 +- src/i18n/locales/es/translation.json | 34 +- src/i18n/locales/fr/translation.json | 34 +- src/i18n/locales/he/translation.json | 34 +- src/i18n/locales/hi/translation.json | 34 +- src/i18n/locales/it/translation.json | 34 +- src/i18n/locales/ja/translation.json | 34 +- src/i18n/locales/ko/translation.json | 34 +- src/i18n/locales/ne/translation.json | 34 +- src/i18n/locales/nl/translation.json | 34 +- src/i18n/locales/pl/translation.json | 34 +- src/i18n/locales/pt/translation.json | 34 +- src/i18n/locales/ru/translation.json | 34 +- src/i18n/locales/sv/translation.json | 34 +- src/i18n/locales/tr/translation.json | 34 +- src/i18n/locales/uk/translation.json | 34 +- src/i18n/locales/vi/translation.json | 34 +- src/i18n/locales/zh-TW/translation.json | 34 +- src/i18n/locales/zh/translation.json | 34 +- src/styles/theme.css | 12 + 43 files changed, 1963 insertions(+), 71 deletions(-) create mode 100644 src-tauri/resources/handy_warning.png create mode 100644 src-tauri/resources/tray_idle_warning.png create mode 100644 src-tauri/resources/tray_idle_warning_dark.png create mode 100644 src-tauri/src/secure_input.rs create mode 100644 src/components/SecureInputWarning.tsx create mode 100644 src/components/settings/debug/KeyboardDiagnostic.tsx diff --git a/src-tauri/resources/handy_warning.png b/src-tauri/resources/handy_warning.png new file mode 100644 index 0000000000000000000000000000000000000000..55e84e83eb83966d74498045052a8ef4c2151866 GIT binary patch literal 2261 zcmV;`2rBo9P)1 zU5F%C75>h>T|JB0(Fqs@1=A~vF%Ytwh>8zp1|JkelGzu1PKnN;e!g>VHT9qTEeJ`dn8@?r1=25(F|SA>?irf2>|j;=5dkNq{KoI>2%pZi94qdkB6TMFuC?xhuoEn}rB)V70oQRoSP+3mPi!OvV>>_Q*xi>N zFoLJa%s<0EL+$kfhj1tmn89ODh!mZThEDkLTf>Q=y9|e#ed3KPz-V(V_S3{BK|o~Y zi__)^H0f*O?iKoc>u@V@iNy$l6#8jj^*5%@(f!r<>AHan6fkHF;()hfdM2GCaU>;O z%%=$IzFApg3k^VgH%M)4&m2hn}AfF-vS5E)5rWBHzRkk<)x4-u{8vq$!HOVcZHM<{X$=MaUQ z7Himd1sI6~5pi+sgdB}9uqO81z`P069i!HfpfP7Cf`i?v;|{9o#Hkk|^;o>vB-ssg zk`D8MFot#Zufg?@StM4_WBERBj#}%|qG!o11!M_?J4j@Oh9t*ZWNKpCzV;tef>ydbqECRg&dkqh03!510Q?xh7XW+!z%4ehV*7%(O7{rB z{S1E6&y|OA`&<;j>_Q?#cp%UA(xG9ni}^qug&YF#4FG@iLcb~c-BO<}0eDJS`0i5D ze7xj)4Oant2f*P*70`C=GcoNn#+;uv&#`-=;w>bO{2IVRa+!kPUjy)AUub>IKmC{E zSm3kUSG>!-zcB?EZKU59aQ?KcRlAMOY@T!Om;^}%pRAYx?f~$cxJZidZ2-UXPaED! zzTUP)x=L{yfL{UlP%~-JgPG40QBZag(V8$l%!VUg__s}&x6X>U$?f(K7`#d#A!d^x zmh%w+&oxpDv|T%3bLm2uYn7pq&;PW7@F^6}v}CCbgdHxAT@N!Lbp@h$o2t`)y&v=d zDneYUa<5-FLGay$-zVU`0KU~&HORw8XV?R9v>A7TDx|v2dN&TgW^J*G_ziYbDWs!cD`kOvM#r2}It9%K#~stfc;0 zkizNhjVXW&Iza$V#{{VWpDZ9b6QKuTjW78>UmsM2aD??nTVl?-lSKq+N>+&X3@3sW zm-R>s;DXKVTZ<%?_iDq`x@zUCHI(72m_F z#bufKIuZR+48iXM+*Id$pH$xk;BRxY011R89pYoY0)!7jKdEzF$r5eXmj1!Lp3~PA zUhwlw__@9;owdnj`#SpR{@n35eMFv!H0!fVvxMjVLJ)=|p&lfCJic#li)*dB*`|r? z4*8<#i{AXHGlQh`-HAj2N^AL%zUt z*ug%dNJ#?|t-)}+vZTrrWqmms`!0YlRk-bo-Ua`8!~ZW}hOgEQ86Vt#2y|SkN%<~< zPbhJpsyG(ozdmLJ!Y|SvCY}fI3E#b)gm+gQi-rFbfZrGWsIWMA+PAL%JcS3OuHtc< zADd+MADv_+3X25^ig)`x!J=~S7SH0g{pYeDD~k3%^gdtsGe5WMk5HER4xa2ojMkJ>SXn`#NQ_5% zO^94Y5Xb-Xh$ff>M-c-GW>!{{_yI!{Jvuolh!8ys*~PPD1w9B##*?TAHy%V!yL$7I z-NlO@bS789OcWvLWhRKABJKzpV$7zbhMJ|PQvF`{+xKQ>nIAO!rnmd`>v~ny)zvc< zBEl?_V8%%><0P1I63jRWW}E~wPJ$U{52lYQfSUZh2%x{4G@uG#3BVeFu9Oylg@C?w zX}bxa<6L(G`{#p*iYhfC>XM)LQ~I8wY*q?<*6o5ZO+IZB+&7fwvwXfwPQR6DID z92`wuAaqfEUI=K@2rwUO@_na22VG4usp=#{;SG79Ax{}?zEf@Lh8FZZrs4{K@3G?P z%ciy*_2g8HH`ObF<*gTtvI);aZUqUnjD?k4%$)Mvyv?{CDH_HqH0zYmxfkvIKj)h$2qP_9Pk5x zF93W4;Clc+s&)?mcm%)`0GDA)*I!^xGfP9+5Zi zq^M(BRw&pe$6YFsCeT&tuT|S;BE2ZJ9OWQ@XT_@@mmTC?yn+Gv1HjJ!E&wzNR@u9Lzps5D*dGKbyGUqG%i0B<6`qwkb>qOKh zqOXYPny1ZwMDzg>9Vl7=Rpae;0@{T-!MTj}zfVMuJMAAPqTf>5Um~JoMG63(mB7mR z*CaU_x+XuGF&XAfqu3!L~F)CJ4sf~1x((z)eQ;E`aZvqS5C|UqLBPm*!Z+iGNfj2gj;&>TJ(Phcd zy3|AnXAD8;bHQofNl7aD<`-&=>o%4luPp;Qjn08H``X z_XN`t5(B9VoXnUM*N4xUlr(_P``_zab0%j}Y>%CkG=TL*2j+-#&1tMyjs(_aaiMGh zYfV~z={Dn&Kk^Zeg&`>aa;xPC4enC2iPCk z{|12j0Q^$KZZNI*(&Y0_o2>JMG8jqGCn%4eEQvZeddazdS3Gho*QCV4>i+g<4V9(= zS{s(+oKSy(fwQ-va7Oqv5$%<7TIxdiw5XgQc34~i9FXj72p<~v;<^wn6VaWze}c*2 z21|d-9zEnl=j*^a%@rBr&uRU|$Rz;J0Qf7T-Ti@edh*W!_}2U{O~wR#g;Uo*rKs`m zM0C_?e^lzy)c&!wF);;`*5x1(eIyA?iz~8}t;@B=4RLrk+5B!00HkpC_A@!>3uQ|L z<2?z|r81DhbaO~@c<%8y@8=;ggnK-GZacyYq_PVH;M-PLiugrYWtR$|F#ZQ%(A0S# Sx|O2<0000Nklbb@mnzK_P+{ar^@$bMUA*o+Nn4CWl}^m?bw4 zNj86gY&?rQ0S|(gNe~Z4VOG#+baz{7c`Z#*)m1f9{bQ5)!eg46`t@GDAN5{0EU>@= z3oNj}0*@*V&^{zUD!;dZE%p%-U;x|zCPEuP-(zf7`eqtUJjXYVR5qJg=t9ZZ_e#d) zG-4k!pOr9?dW_3yNJ}vaV4|muHL3mB(mti7m}nba6`SrF{!`55=o*+*KBTA}Q|*5> zrge@lD>I7{;WNtm4;{j+1c}(&u;7XN4s|qX ziA`HJ0+OnphZBy9UWq4movbbS(J>AX)~HSm=mk0@lQW!XT9O6`VCdm2`%UNlnd8JL zn2gP1=lFO}=8Z%ls4!HlAh4dXs=j8O;i8j*3su+Tik%3~hP+frSh zi`iCezmOQb{bWWje9)oxF$AFKM~NuW(DO6}7tRNG`FRAO=nEoaIHxHbFY)$O2w3sMoU15Z*v9+T{G z53$}0ysy>t6A6`NQDKGVuo7DUcYsf|wyuOXn zvxVoMXb%6c-Kl5+(}xadRb5re3dIS}HWxOl7QjtQ`>TcLZ)TYHL#@D}(&&$7m}i!? z=T4V1?bH*g;e~qdzog214ftb*@jIF+Rt8&zSY?1YT<;UApju1xsewuL1}Dd|BqyOv zJP1sqbK0$45;vR4tPT7oV59lze-Dx6rK-B_11@h%Z9lwdyc&e zJWWe+%JKC`PG=2c&w7kgb%$3>3wLM-lm?sues}bH&a Result<(), String> { // Initialize shortcuts crate::shortcut::init_shortcuts(&app); - // Mark as initialized + // Mark as initialized before reconciling the macOS Secure Input fallback. app.manage(ShortcutsInitialized); + crate::secure_input::reconcile_fallback(&app); log::info!("Shortcuts initialized successfully"); Ok(()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b4e8398167..0faef85ecf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod llm_client; mod managers; mod overlay; pub mod portable; +mod secure_input; mod settings; mod shortcut; mod signal_handle; @@ -206,7 +207,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { let initial_theme = tray::get_current_theme(app_handle); // Choose the appropriate initial icon based on theme - let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle); + let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle, false); let mut tray_builder = TrayIconBuilder::new() .icon( @@ -255,6 +256,10 @@ fn initialize_core_logic(app_handle: &AppHandle) { "settings" => { show_main_window(app); } + "secure_input_warning" => { + // Full explanation lives in the settings-window banner + show_main_window(app); + } "check_updates" => { let settings = settings::get_settings(app); if settings.update_checks_enabled { @@ -657,6 +662,8 @@ pub fn run(cli_args: CliArgs) { shortcut::get_available_accelerators, shortcut::handy_keys::start_handy_keys_recording, shortcut::handy_keys::stop_handy_keys_recording, + secure_input::get_secure_input_status, + secure_input::run_keyboard_diagnostic, trigger_update_check, show_main_window_command, commands::cancel_operation, @@ -911,6 +918,11 @@ pub fn run(cli_args: CliArgs) { initialize_core_logic(&app_handle); + // Secure Input monitor (macOS): detects stuck secure input that + // silently blocks keyed shortcuts, warns the user, and activates + // the Carbon fallback. See secure_input.rs and issue #1578. + secure_input::init(&app_handle); + // Populate the overlay-enabled cache from initial settings so the // audio path (overlay::emit_levels, called ~24 Hz during recording) // can do a single atomic load instead of reading the Tauri store. diff --git a/src-tauri/src/secure_input.rs b/src-tauri/src/secure_input.rs new file mode 100644 index 0000000000..9512c5cd45 --- /dev/null +++ b/src-tauri/src/secure_input.rs @@ -0,0 +1,660 @@ +//! macOS Secure Event Input detection, monitoring, and fallback. +//! +//! When any process enables secure event input (password fields, Terminal's +//! "Secure Keyboard Entry", a stuck `loginwindow`), CGEventTaps stop receiving +//! KeyDown/KeyUp events while FlagsChanged still flows. The handy-keys +//! implementation is tap-based, so keyed shortcuts (e.g. Option+Space) die +//! silently while modifier-only shortcuts keep working. See issue #1578. +//! +//! This module: +//! - polls `IsSecureEventInputEnabled()` and tracks state transitions +//! - looks up the holding process (best effort — Apple documents no reliable +//! API; the IORegistry PID is frequently wrong or absent) +//! - while secure input is sustained, shadow-registers vulnerable *keyed* +//! bindings through the Carbon-backed Tauri global-shortcut path, which is +//! not affected by secure input (modifier-only bindings need no fallback) +//! - dynamically shadows the Cancel binding while recording, so Escape and +//! other keyed cancellation shortcuts remain available under secure input +//! - exposes a count-only keyboard diagnostic for the debug window. Only +//! event *kinds* are counted — key identity is never logged or returned. + +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, Emitter, Manager}; + +#[derive(Debug, Clone, Serialize, Type)] +pub struct SecureInputStatus { + /// Secure input is currently enabled (live check) + pub enabled: bool, + /// Enabled continuously long enough to be considered stuck (not just a + /// password field gaining momentary focus) + pub sustained: bool, + pub culprit_pid: Option, + pub culprit_name: Option, + /// Carbon fallback registrations are currently active + pub fallback_active: bool, + /// Binding ids shadow-registered with identical semantics + pub covered_bindings: Vec, + /// Side-specific binding ids widened to match either side while shadowed + pub degraded_bindings: Vec, + /// Binding ids that cannot fire at all (e.g. fn+key, registration failure) + pub uncovered_bindings: Vec, + /// The user tried to record a shortcut while secure input was active. + /// Treated as user impact even when every binding is covered, so the + /// warning banner appears and explains why recording refused. + pub recorder_blocked: bool, +} + +#[derive(Debug, Clone, Serialize, Type)] +pub struct KeyboardDiagnosticReport { + pub secure_input_enabled: bool, + pub culprit_pid: Option, + pub culprit_name: Option, + /// Counts only — key identity is deliberately never captured. + pub key_down: u32, + pub key_up: u32, + pub flags_changed: u32, + pub mouse: u32, + pub duration_ms: u32, +} + +#[tauri::command] +#[specta::specta] +pub fn get_secure_input_status(app: AppHandle) -> SecureInputStatus { + imp::status(&app) +} + +#[tauri::command] +#[specta::specta] +pub async fn run_keyboard_diagnostic( + duration_secs: Option, +) -> Result { + imp::run_diagnostic(duration_secs.unwrap_or(10).clamp(3, 30)).await +} + +/// True if secure input is enabled right now (live check, macOS only). +pub fn is_enabled_now() -> bool { + imp::is_enabled() +} + +/// Record that a shortcut-recording attempt was refused because secure input +/// is active. Flips the warning state so the banner/tray explain the refusal +/// even when every registered binding is covered by the fallback. +pub fn note_recorder_blocked(app: &AppHandle) { + imp::note_recorder_blocked(app) +} + +/// Register/unregister the dynamic Cancel binding through the Carbon fallback +/// while a recording and sustained Secure Input overlap. +pub fn register_cancel_fallback(app: &AppHandle) { + imp::register_cancel_fallback(app) +} + +pub fn unregister_cancel_fallback(app: &AppHandle) { + imp::unregister_cancel_fallback(app) +} + +/// Rebuild Carbon fallback registrations from the current settings and +/// lifecycle state. This is called after shortcut-related settings change. +pub fn reconcile_fallback(app: &AppHandle) { + imp::reconcile_fallback(app) +} + +/// Managed state + monitor startup. On non-macOS platforms the state exists +/// but the monitor never runs and everything reports disabled. +pub fn init(app: &AppHandle) { + app.manage(imp::SecureInputState::new()); + imp::start_monitor(app); +} + +/// Whether the tray should show the warning badge / menu entry. +/// +/// Only when the user is actually impacted: a binding is degraded or dead. +/// When every affected binding is covered transparently by the fallback (or +/// none are affected), the experience is seamless and nothing is shown. +pub fn tray_warning_active(app: &AppHandle) -> bool { + app.try_state::() + .map(|s| s.warning_active()) + .unwrap_or(false) +} + +#[cfg(target_os = "macos")] +mod imp { + use super::*; + use crate::settings::{self, KeyboardImplementation, ShortcutBinding}; + use log::{debug, error, info, warn}; + use std::process::Command; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + /// How often the monitor thread polls. + const POLL_INTERVAL: Duration = Duration::from_secs(1); + /// Secure input must be held this long before we treat it as stuck. + /// Momentary activation (a password field gaining focus) is normal. + const SUSTAIN_THRESHOLD: Duration = Duration::from_secs(3); + + #[link(name = "Carbon", kind = "framework")] + extern "C" { + // Carbon HIToolbox; Boolean is an unsigned char + fn IsSecureEventInputEnabled() -> u8; + } + + pub fn is_enabled() -> bool { + unsafe { IsSecureEventInputEnabled() != 0 } + } + + #[derive(Debug, Clone)] + struct Culprit { + pid: i32, + name: String, + } + + #[derive(Default)] + struct FallbackState { + /// Bindings shadow-registered through the Tauri/Carbon path (possibly + /// with widened modifiers), kept so deactivation unregisters the + /// exact strings we registered. + registered: Vec, + /// Shadowed with identical semantics + covered: Vec, + /// Shadowed, but side-specific modifiers widened to either side + degraded: Vec, + /// Cannot fire at all while secure input is held + uncovered: Vec, + } + + pub struct SecureInputState { + enabled: AtomicBool, + sustained: AtomicBool, + enabled_since: Mutex>, + culprit: Mutex>, + fallback: Mutex, + /// Serializes fallback registration changes without requiring the + /// fallback state lock to be held across global-shortcut plugin calls. + fallback_operation: Mutex<()>, + recorder_blocked: AtomicBool, + cancel_requested: AtomicBool, + monitor_started: AtomicBool, + } + + impl SecureInputState { + pub fn new() -> Self { + Self { + enabled: AtomicBool::new(false), + sustained: AtomicBool::new(false), + enabled_since: Mutex::new(None), + culprit: Mutex::new(None), + fallback: Mutex::new(FallbackState::default()), + fallback_operation: Mutex::new(()), + recorder_blocked: AtomicBool::new(false), + cancel_requested: AtomicBool::new(false), + monitor_started: AtomicBool::new(false), + } + } + + pub fn is_sustained(&self) -> bool { + self.sustained.load(Ordering::SeqCst) + } + + /// User-visible impact exists: some binding is degraded or dead, or + /// the user ran into the blocked shortcut recorder. + pub fn warning_active(&self) -> bool { + if self.recorder_blocked.load(Ordering::SeqCst) { + return true; + } + if !self.is_sustained() { + return false; + } + let fallback = self.fallback.lock().unwrap(); + !fallback.degraded.is_empty() || !fallback.uncovered.is_empty() + } + } + + /// Best-effort culprit lookup via the IORegistry session property. + /// Apple documents no reliable API for this; the PID may be missing + /// (an app quit while holding secure input) or point at the wrong + /// process (often the responsible parent, or `loginwindow`). + fn lookup_culprit() -> Option { + let out = Command::new("ioreg") + .args(["-l", "-w", "0"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let pid: i32 = text + .lines() + .find_map(|l| l.split("\"kCGSSessionSecureInputPID\"=").nth(1))? + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .ok()?; + + // `ps -o comm=` returns the full executable path; show just the + // binary name ("Terminal", not ".../Terminal.app/Contents/MacOS/Terminal") + let name = Command::new("ps") + .args(["-o", "comm=", "-p", &pid.to_string()]) + .output() + .ok() + .and_then(|o| { + let raw = String::from_utf8_lossy(&o.stdout); + let trimmed = raw.trim(); + (!trimmed.is_empty()) + .then(|| trimmed.rsplit('/').next().unwrap_or(trimmed).to_string()) + }) + .unwrap_or_else(|| "(process no longer running)".to_string()); + + Some(Culprit { pid, name }) + } + + pub fn status(app: &AppHandle) -> SecureInputStatus { + let enabled = is_enabled(); + let state = app.state::(); + + // Culprit discovery shells out to ioreg and is intentionally performed + // only by the monitor (or the blocking diagnostic), never by this + // synchronous Tauri command. + let culprit = state.culprit.lock().unwrap().clone(); + let fallback = state.fallback.lock().unwrap(); + SecureInputStatus { + enabled, + sustained: state.sustained.load(Ordering::SeqCst), + culprit_pid: culprit.as_ref().map(|c| c.pid), + culprit_name: culprit.map(|c| c.name), + fallback_active: !fallback.registered.is_empty(), + covered_bindings: fallback.covered.clone(), + degraded_bindings: fallback.degraded.clone(), + uncovered_bindings: fallback.uncovered.clone(), + recorder_blocked: state.recorder_blocked.load(Ordering::SeqCst), + } + } + + pub fn note_recorder_blocked(app: &AppHandle) { + let state = app.state::(); + if !state.recorder_blocked.swap(true, Ordering::SeqCst) { + warn!("SecureInput: shortcut recording attempt blocked — surfacing warning"); + refresh_tray(app); + emit_status(app); + } + } + + fn emit_status(app: &AppHandle) { + let payload = status(app); + if let Err(e) = app.emit("secure-input-changed", &payload) { + error!("Failed to emit secure-input-changed: {e}"); + } + } + + fn refresh_tray(app: &AppHandle) { + // Tray may be absent (--no-tray) + if app.try_state::().is_some() { + crate::tray::refresh_tray_icon(app); + } + } + + pub fn start_monitor(app: &AppHandle) { + let state = app.state::(); + if state.monitor_started.swap(true, Ordering::SeqCst) { + return; + } + + let app = app.clone(); + std::thread::spawn(move || { + info!("secure-input monitor started"); + loop { + std::thread::sleep(POLL_INTERVAL); + let state = app.state::(); + let now_enabled = is_enabled(); + let was_enabled = state.enabled.swap(now_enabled, Ordering::SeqCst); + + if now_enabled && !was_enabled { + let culprit = lookup_culprit(); + match &culprit { + Some(c) => { + info!("SecureInput ENABLED (held by pid {} '{}')", c.pid, c.name) + } + None => info!("SecureInput ENABLED (no visible holder)"), + } + *state.enabled_since.lock().unwrap() = Some(Instant::now()); + *state.culprit.lock().unwrap() = culprit; + } + + if !now_enabled { + // Clear recorder impact on every disabled sample. A short + // Secure Input episode can otherwise occur entirely + // between polls and leave this flag latched indefinitely. + let was_blocked = state.recorder_blocked.swap(false, Ordering::SeqCst); + if was_enabled { + info!("SecureInput DISABLED"); + *state.enabled_since.lock().unwrap() = None; + *state.culprit.lock().unwrap() = None; + } + + if state.sustained.swap(false, Ordering::SeqCst) { + reconcile_fallback(&app); + } else if was_enabled || was_blocked { + refresh_tray(&app); + emit_status(&app); + } + continue; + } + + // Promote to "sustained" after the threshold. + if !state.sustained.load(Ordering::SeqCst) { + let held_long_enough = state + .enabled_since + .lock() + .unwrap() + .map(|t| t.elapsed() >= SUSTAIN_THRESHOLD) + .unwrap_or(false); + if held_long_enough { + warn!( + "SecureInput held for {}s — keyed shortcuts are blocked; activating fallback", + SUSTAIN_THRESHOLD.as_secs() + ); + state.sustained.store(true, Ordering::SeqCst); + reconcile_fallback(&app); + } + } + } + }); + } + + fn is_mouse_key(key: &handy_keys::Key) -> bool { + key.to_string().to_lowercase().starts_with("mouse") + } + + /// Build the Carbon-registrable equivalent of a keyed hotkey. + /// + /// Carbon has no concept of left/right modifiers, so side-specific + /// modifiers widen to the whole group — returned as `degraded: true` so + /// the UI can call out the changed matching. The fn key cannot be + /// expressed at all (`None`). + fn carbon_equivalent(hotkey: &handy_keys::Hotkey) -> Option<(String, bool)> { + use handy_keys::Modifiers as M; + + if hotkey.modifiers.contains(M::FN) { + return None; + } + + let mut widened = M::empty(); + let mut degraded = false; + for group in [M::CTRL, M::OPT, M::SHIFT, M::CMD] { + if hotkey.modifiers.intersects(group) { + widened |= group; + if !hotkey.modifiers.contains(group) { + // Only one side was specified — matching gets wider + degraded = true; + } + } + } + + let carbon_hotkey = handy_keys::Hotkey::new(widened, hotkey.key).ok()?; + Some((carbon_hotkey.to_handy_string(), degraded)) + } + + /// Register one vulnerable binding through Carbon. `fallback` is local + /// reconciliation state, never the mutex-protected shared state. + fn register_fallback_binding( + app: &AppHandle, + id: &str, + binding: &ShortcutBinding, + fallback: &mut FallbackState, + ) -> bool { + let Ok(hotkey) = binding.current_binding.parse::() else { + warn!( + "SecureInput fallback: '{}' has unparseable binding '{}', skipping", + id, binding.current_binding + ); + fallback.uncovered.push(id.to_string()); + return false; + }; + + match &hotkey.key { + None => { + debug!( + "SecureInput fallback: '{}' ('{}') is modifier-only — immune, no shadow needed", + id, binding.current_binding + ); + return true; + } + Some(k) if is_mouse_key(k) => { + debug!( + "SecureInput fallback: '{}' ('{}') is mouse-based — immune, no shadow needed", + id, binding.current_binding + ); + return true; + } + Some(_) => {} + } + + let Some((carbon_binding, degraded)) = carbon_equivalent(&hotkey) else { + warn!( + "SecureInput fallback: '{}' ('{}') cannot be expressed via Carbon", + id, binding.current_binding + ); + fallback.uncovered.push(id.to_string()); + return false; + }; + + let mut shadow = binding.clone(); + shadow.current_binding = carbon_binding; + + match crate::shortcut::tauri_impl::register_shortcut(app, shadow.clone()) { + Ok(()) => { + info!( + "SecureInput fallback: '{}' registered via Carbon as '{}'{}", + id, + shadow.current_binding, + if degraded { + " (widened to either side)" + } else { + "" + } + ); + fallback.registered.push(shadow); + if degraded { + fallback.degraded.push(id.to_string()); + } else { + fallback.covered.push(id.to_string()); + } + } + Err(e) => { + warn!( + "SecureInput fallback: could not cover '{}' ('{}'): {}", + id, shadow.current_binding, e + ); + fallback.uncovered.push(id.to_string()); + } + } + + false + } + + /// Rebuild the fallback from current state. The operation mutex serializes + /// reconciliations, while the fallback mutex is released before every + /// global-shortcut plugin call to avoid lock-order inversion with callbacks. + pub fn reconcile_fallback(app: &AppHandle) { + let state = app.state::(); + let _operation = state.fallback_operation.lock().unwrap(); + + let previous = { + let mut fallback = state.fallback.lock().unwrap(); + std::mem::take(&mut *fallback) + }; + + if !previous.registered.is_empty() { + info!( + "SecureInput fallback reconciling: removing {} Carbon shadow(s)", + previous.registered.len() + ); + } + for binding in previous.registered { + if let Err(e) = crate::shortcut::tauri_impl::unregister_shortcut(app, binding.clone()) { + warn!( + "SecureInput fallback: failed to unregister '{}': {}", + binding.current_binding, e + ); + } + } + + let settings = settings::get_settings(app); + let eligible = state.is_sustained() + && app + .try_state::() + .is_some() + && settings.keyboard_implementation == KeyboardImplementation::HandyKeys; + + let mut next = FallbackState::default(); + let mut immune = 0usize; + if eligible { + for (id, binding) in &settings.bindings { + if id == "cancel" && !state.cancel_requested.load(Ordering::SeqCst) { + continue; + } + if id == "transcribe_with_post_process" && !settings.post_process_enabled { + continue; + } + + if register_fallback_binding(app, id, binding, &mut next) { + immune += 1; + } + } + + info!( + "SecureInput fallback active: {} covered, {} degraded, {} uncovered, {} immune (user impact: {})", + next.covered.len(), + next.degraded.len(), + next.uncovered.len(), + immune, + !next.degraded.is_empty() || !next.uncovered.is_empty() + ); + } else if state.is_sustained() + && app + .try_state::() + .is_none() + { + debug!("SecureInput fallback deferred until shortcuts are initialized"); + } + + *state.fallback.lock().unwrap() = next; + drop(_operation); + refresh_tray(app); + emit_status(app); + } + + fn schedule_reconcile(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + reconcile_fallback(&app); + }); + } + + pub fn register_cancel_fallback(app: &AppHandle) { + let state = app.state::(); + state.cancel_requested.store(true, Ordering::SeqCst); + schedule_reconcile(app); + } + + pub fn unregister_cancel_fallback(app: &AppHandle) { + let state = app.state::(); + state.cancel_requested.store(false, Ordering::SeqCst); + schedule_reconcile(app); + } + + /// Count-only capture test for the debug window. Opens a short-lived + /// keyboard listener and tallies event kinds; key identity is never + /// inspected beyond the mouse/keyboard distinction, and nothing about + /// individual events is logged or returned. + pub async fn run_diagnostic(duration_secs: u32) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let listener = handy_keys::KeyboardListener::new() + .map_err(|e| format!("Failed to create keyboard listener: {e}"))?; + + let enabled_at_start = is_enabled(); + let start = Instant::now(); + let deadline = start + Duration::from_secs(duration_secs as u64); + let (mut key_down, mut key_up, mut flags_changed, mut mouse) = (0u32, 0u32, 0u32, 0u32); + + while Instant::now() < deadline { + match listener.try_recv() { + Some(event) => match &event.key { + Some(k) if is_mouse_key(k) => mouse += 1, + Some(_) if event.is_key_down => key_down += 1, + Some(_) => key_up += 1, + None => flags_changed += 1, + }, + None => std::thread::sleep(Duration::from_millis(10)), + } + } + + let enabled = enabled_at_start || is_enabled(); + let culprit = if enabled { lookup_culprit() } else { None }; + info!( + "keyboard diagnostic: secure_input={} key_down={} key_up={} flags_changed={} mouse={}", + enabled, key_down, key_up, flags_changed, mouse + ); + + Ok(KeyboardDiagnosticReport { + secure_input_enabled: enabled, + culprit_pid: culprit.as_ref().map(|c| c.pid), + culprit_name: culprit.map(|c| c.name), + key_down, + key_up, + flags_changed, + mouse, + duration_ms: start.elapsed().as_millis() as u32, + }) + }) + .await + .map_err(|e| format!("Diagnostic task failed: {e}"))? + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + use super::*; + + pub struct SecureInputState; + + impl SecureInputState { + pub fn new() -> Self { + Self + } + pub fn warning_active(&self) -> bool { + false + } + } + + pub fn is_enabled() -> bool { + false + } + + pub fn start_monitor(_app: &AppHandle) {} + + pub fn status(_app: &AppHandle) -> SecureInputStatus { + SecureInputStatus { + enabled: false, + sustained: false, + culprit_pid: None, + culprit_name: None, + fallback_active: false, + covered_bindings: Vec::new(), + degraded_bindings: Vec::new(), + uncovered_bindings: Vec::new(), + recorder_blocked: false, + } + } + + pub fn note_recorder_blocked(_app: &AppHandle) {} + + pub fn register_cancel_fallback(_app: &AppHandle) {} + + pub fn unregister_cancel_fallback(_app: &AppHandle) {} + + pub fn reconcile_fallback(_app: &AppHandle) {} + + pub async fn run_diagnostic(_duration_secs: u32) -> Result { + Err("The keyboard diagnostic is only supported on macOS".to_string()) + } +} diff --git a/src-tauri/src/shortcut/handy_keys.rs b/src-tauri/src/shortcut/handy_keys.rs index 3153d5e994..4c1d0c1ed7 100644 --- a/src-tauri/src/shortcut/handy_keys.rs +++ b/src-tauri/src/shortcut/handy_keys.rs @@ -527,6 +527,16 @@ pub fn start_handy_keys_recording(app: AppHandle, binding_id: String) -> Result< return Err("handy-keys is not the active keyboard implementation".into()); } + // While Secure Input is active the tap receives no KeyDown/KeyUp, so the + // recorder would silently capture just the modifier and overwrite the + // binding with it (issue #1578). Refuse instead; the frontend maps this + // marker to a localized explanation, and the noted impact makes the + // warning banner appear with the full story. + if crate::secure_input::is_enabled_now() { + crate::secure_input::note_recorder_blocked(&app); + return Err("secure-input-active".into()); + } + let state = app .try_state::() .ok_or("HandyKeysState not initialized")?; diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index e20edfc26f..20817a2275 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -11,7 +11,7 @@ mod handler; pub mod handy_keys; -mod tauri_impl; +pub mod tauri_impl; use log::{error, info, warn}; use serde::Serialize; @@ -58,6 +58,10 @@ pub fn init_shortcuts(app: &AppHandle) { /// Register the cancel shortcut (called when recording starts) pub fn register_cancel_shortcut(app: &AppHandle) { + // Track recording lifecycle independently of the current implementation so + // switching implementations mid-recording cannot leave stale fallback state. + crate::secure_input::register_cancel_fallback(app); + let settings = get_settings(app); match settings.keyboard_implementation { KeyboardImplementation::Tauri => tauri_impl::register_cancel_shortcut(app), @@ -67,6 +71,8 @@ pub fn register_cancel_shortcut(app: &AppHandle) { /// Unregister the cancel shortcut (called when recording stops) pub fn unregister_cancel_shortcut(app: &AppHandle) { + crate::secure_input::unregister_cancel_fallback(app); + let settings = get_settings(app); match settings.keyboard_implementation { KeyboardImplementation::Tauri => tauri_impl::unregister_cancel_shortcut(app), @@ -151,6 +157,7 @@ pub fn change_binding( b.current_binding = binding; settings.bindings.insert(id.clone(), b.clone()); settings::write_settings(&app, settings); + crate::secure_input::reconcile_fallback(&app); return Ok(BindingResponse { success: true, binding: Some(b.clone()), @@ -190,8 +197,9 @@ pub fn change_binding( // Update the binding in the settings settings.bindings.insert(id, updated_binding.clone()); - // Save the settings + // Save the settings and synchronize any active Secure Input shadows. settings::write_settings(&app, settings); + crate::secure_input::reconcile_fallback(&app); // Return the updated binding Ok(BindingResponse { @@ -282,9 +290,16 @@ pub fn change_keyboard_implementation_setting( settings.keyboard_implementation = new_impl; settings::write_settings(&app, settings); + // Carbon fallback registrations use the Tauri plugin. Remove them before + // registering the full Tauri implementation to avoid duplicate conflicts. + if new_impl == KeyboardImplementation::Tauri { + crate::secure_input::reconcile_fallback(&app); + } + // Initialize new implementation if needed (HandyKeys needs state) if new_impl == KeyboardImplementation::HandyKeys && initialize_handy_keys_with_rollback(&app)? { - // Shortcuts already registered during init + // Shortcuts already registered during init. + crate::secure_input::reconcile_fallback(&app); return Ok(ImplementationChangeResult { success: true, reset_bindings: vec![], @@ -293,6 +308,7 @@ pub fn change_keyboard_implementation_setting( // Register all shortcuts with new implementation, resetting invalid ones let reset_bindings = register_all_shortcuts_for_implementation(&app, new_impl); + crate::secure_input::reconcile_fallback(&app); // Emit event to notify frontend of the change let _ = app.emit( @@ -454,6 +470,7 @@ fn initialize_handy_keys_with_rollback(app: &AppHandle) -> Result let mut settings = settings::get_settings(app); settings.keyboard_implementation = KeyboardImplementation::Tauri; settings::write_settings(app, settings); + crate::secure_input::reconcile_fallback(app); tauri_impl::init_shortcuts(app); return Err(format!( "Failed to initialize HandyKeys: {}. Reverted to Tauri.", @@ -933,6 +950,7 @@ pub fn change_post_process_enabled_setting(app: AppHandle, enabled: bool) -> Res } } + crate::secure_input::reconcile_fallback(&app); Ok(()) } diff --git a/src-tauri/src/shortcut/tauri_impl.rs b/src-tauri/src/shortcut/tauri_impl.rs index c12da7221d..6e19c03c1e 100644 --- a/src-tauri/src/shortcut/tauri_impl.rs +++ b/src-tauri/src/shortcut/tauri_impl.rs @@ -3,7 +3,7 @@ //! This module provides shortcut functionality using Tauri's built-in //! global-shortcut plugin. -use log::{error, warn}; +use log::{debug, error, warn}; use tauri::AppHandle; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; @@ -108,6 +108,13 @@ pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<() if scut == &shortcut { let shortcut_string = scut.into_string(); let is_pressed = event.state == ShortcutState::Pressed; + // Mirrors the handy-keys event log line; the distinct prefix + // makes it possible to tell which backend fired a shortcut + // (e.g. when diagnosing the Secure Input fallback) + debug!( + "tauri global-shortcut event: binding={}, shortcut={}, state={:?}", + binding_id_for_closure, shortcut_string, event.state + ); handle_shortcut_event( app_handle, &binding_id_for_closure, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 4751c35c06..e4e2c5642e 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -93,8 +93,21 @@ fn windows_taskbar_theme() -> Option { }) } -/// Gets the appropriate icon path for the given theme and state -pub fn get_icon_path(theme: AppTheme, state: TrayIconState) -> &'static str { +/// Gets the appropriate icon path for the given theme and state. +/// +/// `warning` overlays a badge on the idle icon while keyboard shortcuts are +/// blocked (macOS Secure Input); recording/transcribing states keep their +/// normal icons so in-flight activity stays recognizable. +pub fn get_icon_path(theme: AppTheme, state: TrayIconState, warning: bool) -> &'static str { + if warning && state == TrayIconState::Idle { + return match theme { + AppTheme::Dark => "resources/tray_idle_warning.png", + AppTheme::Light => "resources/tray_idle_warning_dark.png", + // Linux never sets the warning flag (Secure Input is macOS-only), + // but fall back to the normal icon just in case. + AppTheme::Colored => "resources/handy.png", + }; + } match (theme, state) { // Dark theme uses light icons (AppTheme::Dark, TrayIconState::Idle) => "resources/tray_idle.png", @@ -118,7 +131,8 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { // Store current state app.state::().set(icon); - let icon_path = get_icon_path(theme, icon); + let warning = crate::secure_input::tray_warning_active(app); + let icon_path = get_icon_path(theme, icon, warning); let icon_started = std::time::Instant::now(); if let Err(err) = load_tray_icon( @@ -174,6 +188,20 @@ pub fn update_tray_menu(app: &AppHandle, locale: Option<&str>) { let locale = locale.unwrap_or(&settings.app_language); let strings = get_tray_translations(Some(locale.to_string())); + // Secure Input warning entry (macOS): clicking opens the settings window + // where the full warning banner explains the situation. Locales that + // haven't translated the key yet get the English string rather than a + // blank menu item (build.rs emits "" for missing keys). + let secure_input_warning = crate::secure_input::tray_warning_active(app).then(|| { + let label = if strings.secure_input_warning.is_empty() { + get_tray_translations(Some("en".to_string())).secure_input_warning + } else { + strings.secure_input_warning.clone() + }; + MenuItem::with_id(app, "secure_input_warning", &label, true, None::<&str>) + .expect("failed to create secure input warning item") + }); + // Platform-specific accelerators #[cfg(target_os = "macos")] let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); @@ -292,10 +320,19 @@ pub fn update_tray_menu(app: &AppHandle, locale: Option<&str>) { .expect("failed to create menu"), }; + // Both layouts start with [version, separator, ...]; slot the warning in + // right below the version line so it's the first actionable thing seen. + let mut tooltip = version_label; + if let Some(warning_item) = secure_input_warning { + let _ = menu.insert(&warning_item, 2); + let _ = menu.insert(&separator(), 3); + tooltip = format!("{} — {}", tooltip, warning_item.text().unwrap_or_default()); + } + let tray = app.state::(); let _ = tray.set_menu(Some(menu)); let _ = tray.set_icon_as_template(true); - let _ = tray.set_tooltip(Some(version_label)); + let _ = tray.set_tooltip(Some(tooltip)); } fn last_transcript_text(entry: &HistoryEntry) -> &str { diff --git a/src/App.css b/src/App.css index 650b809bd9..de315044f5 100644 --- a/src/App.css +++ b/src/App.css @@ -11,6 +11,8 @@ --color-logo-stroke: var(--color-logo-stroke); --color-text-stroke: var(--color-text-stroke); --color-mid-gray: var(--color-mid-gray); + --color-warning: var(--color-warning); + --color-error: var(--color-error); } :root { @@ -71,6 +73,8 @@ --color-background: var(--light-color-background); --color-logo-primary: var(--light-color-logo-primary); --color-logo-stroke: var(--light-color-logo-stroke); + --color-warning: var(--light-color-warning); + --color-error: var(--light-color-error); } :root[data-theme="dark"] { @@ -78,6 +82,8 @@ --color-background: var(--dark-color-background); --color-logo-primary: var(--dark-color-logo-primary); --color-logo-stroke: var(--dark-color-logo-stroke); + --color-warning: var(--dark-color-warning); + --color-error: var(--dark-color-error); } /* macOS - tint native overlay scrollbar thumb */ diff --git a/src/App.tsx b/src/App.tsx index d3b05771cd..449245a420 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { import { ModelStateEvent, RecordingErrorEvent } from "./lib/types/events"; import "./App.css"; import AccessibilityPermissions from "./components/AccessibilityPermissions"; +import SecureInputWarning from "./components/SecureInputWarning"; import Footer from "./components/footer"; import Onboarding, { AccessibilityOnboarding } from "./components/onboarding"; import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar"; @@ -264,6 +265,8 @@ function App() { "bg-background border border-mid-gray/20 rounded-lg shadow-lg px-4 py-3 flex items-center gap-3 text-sm", title: "font-medium", description: "text-mid-gray", + actionButton: + "px-2 py-1 text-xs font-medium rounded-lg border bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary cursor-pointer whitespace-nowrap", }, }} /> @@ -302,6 +305,7 @@ function App() {
+ {renderSettingsContent(currentSection)}
diff --git a/src/bindings.ts b/src/bindings.ts index f31c730a3b..3447eea7b8 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -468,6 +468,17 @@ async stopHandyKeysRecording() : Promise> { else return { status: "error", error: e as any }; } }, +async getSecureInputStatus() : Promise { + return await TAURI_INVOKE("get_secure_input_status"); +}, +async runKeyboardDiagnostic(durationSecs: number | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("run_keyboard_diagnostic", { durationSecs }) }; +} 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") }; @@ -936,6 +947,11 @@ export type ImplementationChangeResult = { success: boolean; * List of binding IDs that were reset to defaults due to incompatibility */ reset_bindings: string[] } +export type KeyboardDiagnosticReport = { secure_input_enabled: boolean; culprit_pid: number | null; culprit_name: string | null; +/** + * Counts only — key identity is deliberately never captured. + */ +key_down: number; key_up: number; flags_changed: number; mouse: number; duration_ms: number } export type KeyboardImplementation = "tauri" | "handy_keys" export type LLMPrompt = { id: string; name: string; prompt: string } export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" @@ -981,6 +997,38 @@ export type PermissionAccess = "allowed" | "denied" | "unknown" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type SecretMap = Partial<{ [key in string]: string }> +export type SecureInputStatus = { +/** + * Secure input is currently enabled (live check) + */ +enabled: boolean; +/** + * Enabled continuously long enough to be considered stuck (not just a + * password field gaining momentary focus) + */ +sustained: boolean; culprit_pid: number | null; culprit_name: string | null; +/** + * Carbon fallback registrations are currently active + */ +fallback_active: boolean; +/** + * Binding ids shadow-registered with identical semantics + */ +covered_bindings: string[]; +/** + * Side-specific binding ids widened to match either side while shadowed + */ +degraded_bindings: string[]; +/** + * Binding ids that cannot fire at all (e.g. fn+key, registration failure) + */ +uncovered_bindings: string[]; +/** + * The user tried to record a shortcut while secure input was active. + * Treated as user impact even when every binding is covered, so the + * warning banner appears and explains why recording refused. + */ +recorder_blocked: boolean } export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } export type SoundTheme = "marimba" | "pop" | "custom" /** diff --git a/src/components/SecureInputWarning.tsx b/src/components/SecureInputWarning.tsx new file mode 100644 index 0000000000..e064f7c196 --- /dev/null +++ b/src/components/SecureInputWarning.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { listen } from "@tauri-apps/api/event"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { ExternalLink, TriangleAlert, X } from "lucide-react"; +import { commands, type SecureInputStatus } from "@/bindings"; + +// Detailed remediation steps live in the docs rather than in the banner +export const SECURE_INPUT_HELP_URL = + "https://handy.computer/docs/troubleshooting#shortcuts-stopped-working-on-macos-secure-input"; + +/** + * Compact warning banner shown while macOS Secure Input is stuck on. + * + * Secure Input (password fields, Terminal's "Secure Keyboard Entry", a stuck + * loginwindow) blocks key events from reaching Handy's keyboard listener, so + * keyed shortcuts silently stop firing (issue #1578). The backend monitor + * emits `secure-input-changed` on state transitions; `sustained` filters out + * the normal momentary activation from focusing a password field. + */ +const SecureInputWarning: React.FC = () => { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + const [dismissed, setDismissed] = useState(false); + + const refresh = useCallback(async () => { + try { + setStatus(await commands.getSecureInputStatus()); + } catch (e) { + console.warn("Failed to fetch secure input status:", e); + } + }, []); + + useEffect(() => { + refresh(); + const unlisten = listen( + "secure-input-changed", + (event) => setStatus(event.payload), + ); + return () => { + unlisten.then((fn) => fn()); + }; + }, [refresh]); + + // Only warn when the user is actually impacted: a binding is degraded + // (side-specific matching widened) or dead (e.g. fn+key), or they ran into + // the blocked shortcut recorder. When the fallback covers everything + // transparently — and nothing else surfaced — stay silent; the backend + // still logs. + const impacted = + status !== null && + ((status.sustained && + (status.degraded_bindings.length > 0 || + status.uncovered_bindings.length > 0)) || + status.recorder_blocked); + + // A dismissal lasts for the current episode only: once the condition + // clears, the next occurrence warns again. The tray badge is the + // persistent indicator and is not dismissible. + useEffect(() => { + if (!impacted) { + setDismissed(false); + } + }, [impacted]); + + if (!impacted || dismissed) { + return null; + } + + const affectedCount = new Set([ + ...status.uncovered_bindings, + ...status.degraded_bindings, + ]).size; + const countSuffix = affectedCount === 1 ? "one" : "other"; + const message = + affectedCount > 0 + ? status.culprit_name !== null + ? t(`secureInput.blockedWithCulprit_${countSuffix}`, { + name: status.culprit_name, + count: affectedCount, + }) + : t(`secureInput.blockedNoCulprit_${countSuffix}`, { + count: affectedCount, + }) + : status.culprit_name !== null + ? t("secureInput.recorderBlockedWithCulprit", { + name: status.culprit_name, + }) + : t("secureInput.recorderBlockedNoCulprit"); + + return ( +
+
+ +

+ {message} +

+
+ + +
+
+
+ ); +}; + +export default SecureInputWarning; diff --git a/src/components/settings/HandyKeysShortcutInput.tsx b/src/components/settings/HandyKeysShortcutInput.tsx index d5f5ed0350..5a1b4b58a1 100644 --- a/src/components/settings/HandyKeysShortcutInput.tsx +++ b/src/components/settings/HandyKeysShortcutInput.tsx @@ -8,6 +8,8 @@ import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; import { commands } from "@/bindings"; import { toast } from "sonner"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { SECURE_INPUT_HELP_URL } from "../SecureInputWarning"; interface HandyKeysShortcutInputProps { descriptionMode?: "inline" | "tooltip"; @@ -39,6 +41,13 @@ export const HandyKeysShortcutInput: React.FC = ({ const unlistenRef = useRef<(() => void) | null>(null); // Use a ref to track currentKeys for the event handler (avoids stale closure) const currentKeysRef = useRef(""); + // Track keyed vs modifier-only captures separately so a combo commits only + // on its key's release and a modifier-only shortcut only once every + // modifier is released. Committing on the *first* release (the old + // behavior) silently saved just the modifier whenever the key event never + // arrived — e.g. while macOS Secure Input is active (issue #1578). + const keyedShortcutRef = useRef(""); + const modifierOnlyShortcutRef = useRef(""); const osType = useOsType(); const bindings = getSetting("bindings") || {}; @@ -69,6 +78,8 @@ export const HandyKeysShortcutInput: React.FC = ({ setIsRecording(false); setCurrentKeys(""); currentKeysRef.current = ""; + keyedShortcutRef.current = ""; + modifierOnlyShortcutRef.current = ""; setOriginalBinding(""); }, [isRecording, originalBinding, shortcutId, updateBinding, t]); @@ -80,51 +91,78 @@ export const HandyKeysShortcutInput: React.FC = ({ const setupListener = async () => { // Listen for key events from backend + const commitAndStop = async (keysToCommit: string) => { + try { + await updateBinding(shortcutId, keysToCommit); + } catch (error) { + console.error("Failed to change binding:", error); + toast.error( + t("settings.general.shortcut.errors.set", { + error: String(error), + }), + ); + + // Reset to original binding on error + if (originalBinding) { + try { + await updateBinding(shortcutId, originalBinding); + } catch (resetError) { + console.error("Failed to reset binding:", resetError); + toast.error(t("settings.general.shortcut.errors.reset")); + } + } + } + + // Stop recording + if (unlistenRef.current) { + unlistenRef.current(); + unlistenRef.current = null; + } + await commands.stopHandyKeysRecording().catch(console.error); + setIsRecording(false); + setCurrentKeys(""); + currentKeysRef.current = ""; + keyedShortcutRef.current = ""; + modifierOnlyShortcutRef.current = ""; + setOriginalBinding(""); + }; + const unlisten = await listen( "handy-keys-event", async (event) => { if (cleanup) return; - const { hotkey_string, is_key_down } = event.payload; + const { hotkey_string, is_key_down, key, modifiers } = event.payload; if (is_key_down && hotkey_string) { - // Update both state (for display) and ref (for release handler) + // Update both state (for display) and refs (for release handler) + if (key) { + keyedShortcutRef.current = hotkey_string; + } else { + modifierOnlyShortcutRef.current = hotkey_string; + } currentKeysRef.current = hotkey_string; setCurrentKeys(hotkey_string); - } else if (!is_key_down && currentKeysRef.current) { - // Key released - commit the shortcut using the ref value - const keysToCommit = currentKeysRef.current; - try { - await updateBinding(shortcutId, keysToCommit); - } catch (error) { - console.error("Failed to change binding:", error); - toast.error( - t("settings.general.shortcut.errors.set", { - error: String(error), - }), - ); - - // Reset to original binding on error - if (originalBinding) { - try { - await updateBinding(shortcutId, originalBinding); - } catch (resetError) { - console.error("Failed to reset binding:", resetError); - toast.error(t("settings.general.shortcut.errors.reset")); - } - } + } else if (!is_key_down && key) { + // The main key was released — commit the keyed combo. The release + // event's hotkey_string still contains the key, so it works even + // if the key-down was somehow missed. Never fall back to a + // modifier-only capture here: that's how bindings used to get + // silently overwritten with just the modifier (issue #1578). + const keysToCommit = keyedShortcutRef.current || hotkey_string; + if (keysToCommit) { + await commitAndStop(keysToCommit); } - - // Stop recording - if (unlistenRef.current) { - unlistenRef.current(); - unlistenRef.current = null; - } - await commands.stopHandyKeysRecording().catch(console.error); - setIsRecording(false); - setCurrentKeys(""); - currentKeysRef.current = ""; - setOriginalBinding(""); + } else if ( + !is_key_down && + !key && + modifiers.length === 0 && + !keyedShortcutRef.current && + modifierOnlyShortcutRef.current + ) { + // Every modifier released without a main key ever going down — + // commit as a modifier-only shortcut + await commitAndStop(modifierOnlyShortcutRef.current); } }, ); @@ -176,12 +214,34 @@ export const HandyKeysShortcutInput: React.FC = ({ // Store the original binding to restore if canceled setOriginalBinding(bindings[shortcutId]?.current_binding || ""); - // Start backend recording + // Start backend recording. The backend refuses while macOS Secure Input + // is active (the recorder's listener would receive no key events and + // capture just the modifier) — it also flips the warning banner on, so + // the toast points at a visible explanation. try { - await commands.startHandyKeysRecording(shortcutId); + const result = await commands.startHandyKeysRecording(shortcutId); + if (result.status === "error") { + if (String(result.error).includes("secure-input-active")) { + toast.error(t("secureInput.recorderBlocked"), { + action: { + label: t("secureInput.learnMore"), + onClick: () => openUrl(SECURE_INPUT_HELP_URL), + }, + }); + } else { + toast.error( + t("settings.general.shortcut.errors.set", { + error: String(result.error), + }), + ); + } + return; + } setIsRecording(true); setCurrentKeys(""); currentKeysRef.current = ""; + keyedShortcutRef.current = ""; + modifierOnlyShortcutRef.current = ""; } catch (error) { console.error("Failed to start recording:", error); toast.error( diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index de6d6297e0..c3e1b339d0 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -11,6 +11,7 @@ import { SoundPicker } from "../SoundPicker"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; import { UpdateChecksToggle } from "../UpdateChecksToggle"; import { WhatsNewPreview } from "./WhatsNewPreview"; +import { KeyboardDiagnostic } from "./KeyboardDiagnostic"; export const DebugSettings: React.FC = () => { const { t } = useTranslation(); @@ -37,6 +38,7 @@ export const DebugSettings: React.FC = () => { + diff --git a/src/components/settings/debug/KeyboardDiagnostic.tsx b/src/components/settings/debug/KeyboardDiagnostic.tsx new file mode 100644 index 0000000000..a333359a17 --- /dev/null +++ b/src/components/settings/debug/KeyboardDiagnostic.tsx @@ -0,0 +1,120 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { commands, type KeyboardDiagnosticReport } from "@/bindings"; +import { useOsType } from "../../../hooks/useOsType"; + +/** + * Count-only keyboard capture test (macOS). + * + * Opens a short-lived listener and tallies how many key-down / key-up / + * modifier / mouse events reach Handy — never *which* keys were pressed. + * The signature of stuck Secure Input (issue #1578) is modifier events + * flowing while key-down stays at zero. + */ +export const KeyboardDiagnostic: React.FC = () => { + const { t } = useTranslation(); + const osType = useOsType(); + const [running, setRunning] = useState(false); + const [report, setReport] = useState(null); + const [error, setError] = useState(null); + + if (osType !== "macos") { + return null; + } + + const runDiagnostic = async () => { + setRunning(true); + setReport(null); + setError(null); + try { + const result = await commands.runKeyboardDiagnostic(10); + if (result.status === "ok") { + setReport(result.data); + } else { + setError(result.error); + } + } catch (e) { + setError(String(e)); + } finally { + setRunning(false); + } + }; + + const verdict = (r: KeyboardDiagnosticReport): string => { + if (r.secure_input_enabled && r.key_down === 0) { + return t("settings.debug.keyboardDiagnostic.verdictBlocked"); + } + if (!r.secure_input_enabled && r.key_down === 0 && r.flags_changed > 0) { + return t("settings.debug.keyboardDiagnostic.verdictSuspicious"); + } + if (r.key_down === 0 && r.flags_changed === 0 && r.mouse === 0) { + return t("settings.debug.keyboardDiagnostic.verdictNoEvents"); + } + return t("settings.debug.keyboardDiagnostic.verdictOk"); + }; + + const secureInputLine = (r: KeyboardDiagnosticReport): string => { + const state = r.secure_input_enabled + ? t("settings.debug.keyboardDiagnostic.enabled") + : t("settings.debug.keyboardDiagnostic.disabled"); + if (!r.secure_input_enabled) { + return state; + } + const holder = + r.culprit_name !== null + ? t("settings.debug.keyboardDiagnostic.holder", { + name: r.culprit_name, + pid: r.culprit_pid, + }) + : t("settings.debug.keyboardDiagnostic.holderUnknown"); + return `${state} — ${holder}`; + }; + + return ( +
+
+
+

+ {t("settings.debug.keyboardDiagnostic.title")} +

+

+ {t("settings.debug.keyboardDiagnostic.description")} +

+
+ +
+ {running && ( +

+ {t("settings.debug.keyboardDiagnostic.running")} +

+ )} + {error !== null && ( +

+ {t("settings.debug.keyboardDiagnostic.failed", { error })} +

+ )} + {report !== null && ( +
+

+ {t("settings.debug.keyboardDiagnostic.secureInputLabel")}:{" "} + {secureInputLine(report)} +

+

+ {t("settings.debug.keyboardDiagnostic.keyDown")}: {report.key_down}{" "} + · {t("settings.debug.keyboardDiagnostic.keyUp")}: {report.key_up} ·{" "} + {t("settings.debug.keyboardDiagnostic.flagsChanged")}:{" "} + {report.flags_changed} ·{" "} + {t("settings.debug.keyboardDiagnostic.mouse")}: {report.mouse} +

+

{verdict(report)}

+
+ )} +
+ ); +}; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index df92fdc907..056cbb4962 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -5,6 +5,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { | "primary" | "primary-soft" | "secondary" + | "warning" | "danger" | "danger-ghost" | "ghost"; @@ -28,6 +29,11 @@ export const Button: React.FC = ({ "text-text bg-logo-primary/20 border-transparent hover:bg-logo-primary/30 focus:ring-1 focus:ring-logo-primary", secondary: "bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary focus:outline-none", + // Secondary's neutral resting look, but hover/focus use the semantic + // --color-warning token (theme.css) instead of the pink accent — for + // buttons sitting on warning surfaces like SecureInputWarning + warning: + "text-text bg-mid-gray/10 border-mid-gray/20 hover:bg-warning/15 hover:border-warning focus:ring-1 focus:ring-warning", danger: "text-white bg-red-600 border-mid-gray/20 hover:bg-red-700 hover:border-red-700 focus:ring-1 focus:ring-red-500", "danger-ghost": diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 801229cfc5..8c26b0ff4d 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -6,7 +6,8 @@ "unloadModel": "تفريغ النموذج", "model": "النموذج", "quit": "إنهاء", - "cancel": "إلغاء" + "cancel": "إلغاء", + "secureInputWarning": "⚠ الاختصارات محجوبة بواسطة Secure Input" }, "sidebar": { "general": "عام", @@ -507,6 +508,26 @@ "title": "مخزن التسجيل الإضافي", "description": "وقت إضافي (بالمللي ثانية) للاستمرار في التسجيل بعد تحرير المفتاح، لالتقاط الصوت المتبقي. 0 = لا مخزن إضافي." }, + "keyboardDiagnostic": { + "title": "تشخيص لوحة المفاتيح", + "description": "يتحقق مما إذا كانت أحداث لوحة المفاتيح تصل إلى Handy. يُسجَّل عدد الأحداث فقط — ولا تُسجَّل أبدًا المفاتيح التي تضغط عليها.", + "run": "تشغيل تشخيص لمدة 10 ثوانٍ", + "running": "جارٍ الاستماع… اضغط اختصارك عدة مرات (مثل Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "مُفعَّل", + "disabled": "مُعطَّل", + "holder": "يحتجزه {{name}} (pid {{pid}})", + "holderUnknown": "لا يوجد محتجِز ظاهر", + "keyDown": "ضغط مفتاح", + "keyUp": "تحرير مفتاح", + "flagsChanged": "مفاتيح التعديل", + "mouse": "الماوس", + "verdictBlocked": "يحجب Secure Input أحداث المفاتيح — لا يمكن للاختصارات التي تتضمن مفتاحًا عاديًا أن تعمل حتى تُحلّ المشكلة.", + "verdictSuspicious": "وصلت أحداث مفاتيح التعديل لكن لم يصل أي حدث مفتاح — هناك شيء يمنع المفاتيح رغم أن Secure Input يظهر معطلاً. يُرجى الإبلاغ عن ذلك على GitHub.", + "verdictOk": "أحداث المفاتيح تصل إلى Handy بشكل طبيعي.", + "verdictNoEvents": "لم يُسجَّل أي حدث — هل ضغطت أي مفاتيح أثناء الاختبار؟", + "failed": "فشل التشخيص: {{error}}" + }, "whatsNewPreview": { "title": "معاينة الجديد", "description": "افتح أحدث ملاحظات إصدار مضمنة دون وضع علامة عليها كمقروءة", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "الجديد في Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "قد يحظر {{name}} اختصارًا واحدًا", + "blockedWithCulprit_other": "قد يحظر {{name}} {{count}} اختصارات", + "blockedNoCulprit_one": "يحظر macOS مؤقتًا اختصارًا واحدًا", + "blockedNoCulprit_other": "يحظر macOS مؤقتًا {{count}} اختصارات", + "recorderBlockedWithCulprit": "قد يمنع {{name}} تغيير الاختصارات", + "recorderBlockedNoCulprit": "يمنع macOS مؤقتًا تغيير الاختصارات", + "learnMore": "كيفية الإصلاح", + "recorderBlocked": "لا يمكن تسجيل الاختصارات الآن — يحجب Secure Input في macOS أحداث المفاتيح. عالج تحذير Secure Input أولاً.", + "dismiss": "تجاهل" } } diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 937680ce1a..d18d5a5b3a 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Разтоварване на модела", "model": "Модел", "quit": "Изход", - "cancel": "Отказ" + "cancel": "Отказ", + "secureInputWarning": "⚠ Бързите клавиши са блокирани от Secure Input" }, "sidebar": { "general": "Общи", @@ -528,6 +529,26 @@ "title": "Допълнителен буфер на записа", "description": "Допълнително време (в милисекунди) за запис след пускане на клавиша, за да се улови краят на аудиото. 0 = без допълнителен буфер." }, + "keyboardDiagnostic": { + "title": "Диагностика на клавиатурата", + "description": "Проверява дали клавиатурните събития достигат до Handy. Записва се само броят на събитията — никога кои клавиши натискате.", + "run": "Стартиране на 10-секундна диагностика", + "running": "Слушане… натиснете бързия си клавиш няколко пъти (напр. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "включен", + "disabled": "изключен", + "holder": "задържан от {{name}} (pid {{pid}})", + "holderUnknown": "няма видим държател", + "keyDown": "Натиснат клавиш", + "keyUp": "Отпуснат клавиш", + "flagsChanged": "Модификатори", + "mouse": "Мишка", + "verdictBlocked": "Secure Input блокира клавишните събития — бързите клавиши с обикновен клавиш не могат да работят, докато това не бъде решено.", + "verdictSuspicious": "Пристигнаха събития от модификатори, но нито едно клавишно събитие — нещо потиска клавишите, въпреки че Secure Input се отчита като изключен. Моля, съобщете за това в GitHub.", + "verdictOk": "Клавишните събития достигат до Handy нормално.", + "verdictNoEvents": "Не са уловени събития — натиснахте ли някакви клавиши по време на теста?", + "failed": "Диагностиката е неуспешна: {{error}}" + }, "whatsNewPreview": { "title": "Преглед на новостите", "description": "Отваря последните включени бележки към изданието, без да ги маркира като видени", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Ново в Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} може да блокира 1 бърз клавиш", + "blockedWithCulprit_other": "{{name}} може да блокира {{count}} бързи клавиша", + "blockedNoCulprit_one": "macOS временно блокира 1 бърз клавиш", + "blockedNoCulprit_other": "macOS временно блокира {{count}} бързи клавиша", + "recorderBlockedWithCulprit": "{{name}} може да блокира промените на бързите клавиши", + "recorderBlockedNoCulprit": "macOS временно блокира промените на бързите клавиши", + "learnMore": "Как да поправите това", + "recorderBlocked": "В момента не могат да се записват бързи клавиши — Secure Input на macOS блокира клавишните събития. Първо отстранете предупреждението за Secure Input.", + "dismiss": "Отхвърляне" } } diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index ec7017c72b..b98738680b 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Uvolnit model", "model": "Model", "quit": "Ukončit", - "cancel": "Zrušit" + "cancel": "Zrušit", + "secureInputWarning": "⚠ Zkratky blokuje Secure Input" }, "sidebar": { "general": "Obecné", @@ -528,6 +529,26 @@ "title": "Extra vyrovnávací paměť nahrávání", "description": "Extra čas (v milisekundách) pro pokračování nahrávání po uvolnění klávesy, pro zachycení zbývajícího zvuku. 0 = žádná extra vyrovnávací paměť." }, + "keyboardDiagnostic": { + "title": "Diagnostika klávesnice", + "description": "Ověří, zda události klávesnice dorazí do Handy. Zaznamenává se pouze počet událostí — nikdy to, které klávesy stisknete.", + "run": "Spustit 10s diagnostiku", + "running": "Naslouchání… několikrát stiskněte svou zkratku (např. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "zapnuto", + "disabled": "vypnuto", + "holder": "drží ji {{name}} (pid {{pid}})", + "holderUnknown": "žádný viditelný držitel", + "keyDown": "Stisk klávesy", + "keyUp": "Uvolnění klávesy", + "flagsChanged": "Modifikátory", + "mouse": "Myš", + "verdictBlocked": "Secure Input blokuje události kláves — zkratky obsahující běžnou klávesu nemohou fungovat, dokud se to nevyřeší.", + "verdictSuspicious": "Dorazily události modifikátorů, ale žádné události kláves — něco klávesy potlačuje, přestože Secure Input hlásí, že je vypnutý. Nahlaste to prosím na GitHubu.", + "verdictOk": "Události kláves dorazí do Handy normálně.", + "verdictNoEvents": "Nebyly zachyceny žádné události — stiskli jste během testu nějaké klávesy?", + "failed": "Diagnostika selhala: {{error}}" + }, "whatsNewPreview": { "title": "Náhled novinek", "description": "Otevřít nejnovější přibalené poznámky k vydání bez označení jako zobrazené", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Novinky v Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} může blokovat 1 zkratku", + "blockedWithCulprit_other": "{{name}} může blokovat {{count}} zkratek", + "blockedNoCulprit_one": "macOS dočasně blokuje 1 zkratku", + "blockedNoCulprit_other": "macOS dočasně blokuje {{count}} zkratek", + "recorderBlockedWithCulprit": "{{name}} může blokovat změny zkratek", + "recorderBlockedNoCulprit": "macOS dočasně blokuje změny zkratek", + "learnMore": "Jak to vyřešit", + "recorderBlocked": "Zkratky teď nelze nahrát — Secure Input v macOS blokuje události kláves. Nejprve vyřešte upozornění na Secure Input.", + "dismiss": "Zavřít" } } diff --git a/src/i18n/locales/da/translation.json b/src/i18n/locales/da/translation.json index 5feb4a9bfd..3b8da3fbee 100644 --- a/src/i18n/locales/da/translation.json +++ b/src/i18n/locales/da/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Frigør model", "model": "Model", "quit": "Afslut", - "cancel": "Annuller" + "cancel": "Annuller", + "secureInputWarning": "⚠ Genveje blokeret af Secure Input" }, "sidebar": { "general": "Generelt", @@ -534,6 +535,26 @@ "recordingBuffer": { "title": "Ekstra optagelsesbuffer", "description": "Ekstra tid (i millisekunder) til at fortsætte optagelsen, efter du slipper tasten, for at fange efterfølgende lyd. 0 = ingen ekstra buffer." + }, + "keyboardDiagnostic": { + "title": "Tastaturdiagnostik", + "description": "Kontrollerer, om tastaturhændelser når frem til Handy. Kun antallet af hændelser registreres — aldrig hvilke taster du trykker på.", + "run": "Kør 10 s diagnostik", + "running": "Lytter… tryk på din genvej et par gange (f.eks. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "aktiveret", + "disabled": "deaktiveret", + "holder": "holdes af {{name}} (pid {{pid}})", + "holderUnknown": "ingen synlig indehaver", + "keyDown": "Tast ned", + "keyUp": "Tast op", + "flagsChanged": "Modifikatorer", + "mouse": "Mus", + "verdictBlocked": "Secure Input blokerer tastehændelser — genveje med en almindelig tast kan ikke fungere, før det er løst.", + "verdictSuspicious": "Der kom modifikatorhændelser, men ingen tastehændelser — noget undertrykker taster, selvom Secure Input rapporteres som deaktiveret. Rapportér det gerne på GitHub.", + "verdictOk": "Tastehændelser når frem til Handy som normalt.", + "verdictNoEvents": "Ingen hændelser registreret — trykkede du på nogen taster under testen?", + "failed": "Diagnostik mislykkedes: {{error}}" } }, "about": { @@ -633,5 +654,16 @@ "overlay": { "transcribing": "Transskriberer...", "processing": "Behandler..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} blokerer muligvis 1 genvej", + "blockedWithCulprit_other": "{{name}} blokerer muligvis {{count}} genveje", + "blockedNoCulprit_one": "macOS blokerer midlertidigt 1 genvej", + "blockedNoCulprit_other": "macOS blokerer midlertidigt {{count}} genveje", + "recorderBlockedWithCulprit": "{{name}} blokerer muligvis ændringer af genveje", + "recorderBlockedNoCulprit": "macOS blokerer midlertidigt ændringer af genveje", + "learnMore": "Sådan løser du det", + "recorderBlocked": "Genveje kan ikke optages lige nu — macOS Secure Input blokerer tastehændelser. Løs advarslen om Secure Input først.", + "dismiss": "Afvis" } } diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 094737eb70..5e1fcc83e3 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Modell entladen", "model": "Modell", "quit": "Beenden", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "secureInputWarning": "⚠ Tastenkürzel durch Secure Input blockiert" }, "sidebar": { "general": "Allgemein", @@ -528,6 +529,26 @@ "title": "Zusätzlicher Aufnahmepuffer", "description": "Zusätzliche Zeit (in Millisekunden), um nach dem Loslassen der Taste weiter aufzunehmen, um nachlaufendes Audio zu erfassen. 0 = kein zusätzlicher Puffer." }, + "keyboardDiagnostic": { + "title": "Tastatur-Diagnose", + "description": "Prüft, ob Tastaturereignisse Handy erreichen. Es wird nur die Anzahl der Ereignisse erfasst — niemals, welche Tasten du drückst.", + "run": "10-Sekunden-Diagnose starten", + "running": "Lauscht… drücke dein Tastenkürzel ein paar Mal (z. B. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "aktiviert", + "disabled": "deaktiviert", + "holder": "gehalten von {{name}} (pid {{pid}})", + "holderUnknown": "kein sichtbarer Verursacher", + "keyDown": "Taste gedrückt", + "keyUp": "Taste losgelassen", + "flagsChanged": "Modifikatoren", + "mouse": "Maus", + "verdictBlocked": "Secure Input blockiert Tastenereignisse — Tastenkürzel mit einer normalen Taste können erst wieder funktionieren, wenn das behoben ist.", + "verdictSuspicious": "Es kamen Modifikator-Ereignisse an, aber keine Tastenereignisse — irgendetwas unterdrückt Tasten, obwohl Secure Input als deaktiviert gemeldet wird. Bitte melde das auf GitHub.", + "verdictOk": "Tastenereignisse erreichen Handy normal.", + "verdictNoEvents": "Keine Ereignisse erfasst — hast du während des Tests Tasten gedrückt?", + "failed": "Diagnose fehlgeschlagen: {{error}}" + }, "whatsNewPreview": { "title": "Vorschau der Neuerungen", "description": "Die neuesten gebündelten Versionshinweise öffnen, ohne sie als gesehen zu markieren", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Neu in Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} blockiert möglicherweise 1 Tastenkürzel", + "blockedWithCulprit_other": "{{name}} blockiert möglicherweise {{count}} Tastenkürzel", + "blockedNoCulprit_one": "macOS blockiert vorübergehend 1 Tastenkürzel", + "blockedNoCulprit_other": "macOS blockiert vorübergehend {{count}} Tastenkürzel", + "recorderBlockedWithCulprit": "{{name}} blockiert möglicherweise Änderungen an Tastenkürzeln", + "recorderBlockedNoCulprit": "macOS blockiert vorübergehend Änderungen an Tastenkürzeln", + "learnMore": "So behebst du das", + "recorderBlocked": "Tastenkürzel können gerade nicht aufgezeichnet werden — macOS Secure Input blockiert Tastenereignisse. Behebe zuerst die Secure-Input-Warnung.", + "dismiss": "Ausblenden" } } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 33c7321e11..1c23046bfb 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Unload Model", "model": "Model", "quit": "Quit", - "cancel": "Cancel" + "cancel": "Cancel", + "secureInputWarning": "⚠ Shortcuts blocked by Secure Input" }, "sidebar": { "general": "General", @@ -534,6 +535,26 @@ "recordingBuffer": { "title": "Extra Recording Buffer", "description": "Extra time (in milliseconds) to keep recording after you release the key, to capture trailing audio. 0 = no extra buffer." + }, + "keyboardDiagnostic": { + "title": "Keyboard Diagnostic", + "description": "Checks whether keyboard events reach Handy. Only event counts are recorded — never which keys you press.", + "run": "Run 10s diagnostic", + "running": "Listening… press your shortcut a few times (e.g. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "enabled", + "disabled": "disabled", + "holder": "held by {{name}} (pid {{pid}})", + "holderUnknown": "no visible holder", + "keyDown": "Key down", + "keyUp": "Key up", + "flagsChanged": "Modifiers", + "mouse": "Mouse", + "verdictBlocked": "Secure Input is blocking key events — keyed shortcuts cannot work until it is resolved.", + "verdictSuspicious": "Modifier events arrived but no key events — something is suppressing keys even though Secure Input reports disabled. Please report this on GitHub.", + "verdictOk": "Key events are reaching Handy normally.", + "verdictNoEvents": "No events captured — did you press any keys during the test?", + "failed": "Diagnostic failed: {{error}}" } }, "about": { @@ -633,5 +654,16 @@ "overlay": { "transcribing": "Transcribing...", "processing": "Processing..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} may be blocking 1 shortcut", + "blockedWithCulprit_other": "{{name}} may be blocking {{count}} shortcuts", + "blockedNoCulprit_one": "macOS is temporarily blocking 1 shortcut", + "blockedNoCulprit_other": "macOS is temporarily blocking {{count}} shortcuts", + "recorderBlockedWithCulprit": "{{name}} may be blocking shortcut changes", + "recorderBlockedNoCulprit": "macOS is temporarily blocking shortcut changes", + "learnMore": "How to fix", + "recorderBlocked": "Can't record shortcuts right now — macOS Secure Input is blocking key events. Resolve the Secure Input warning first.", + "dismiss": "Dismiss" } } diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 3efacbdd0f..a14f33fc89 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Descargar modelo", "model": "Modelo", "quit": "Salir", - "cancel": "Cancelar" + "cancel": "Cancelar", + "secureInputWarning": "⚠ Atajos bloqueados por Secure Input" }, "sidebar": { "general": "General", @@ -528,6 +529,26 @@ "title": "Búfer de grabación adicional", "description": "Tiempo adicional (en milisegundos) para seguir grabando después de soltar la tecla, para capturar el audio restante. 0 = sin búfer adicional." }, + "keyboardDiagnostic": { + "title": "Diagnóstico del teclado", + "description": "Comprueba si los eventos de teclado llegan a Handy. Solo se registra el número de eventos, nunca qué teclas pulsas.", + "run": "Ejecutar diagnóstico de 10 s", + "running": "Escuchando… pulsa tu atajo varias veces (p. ej. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "activado", + "disabled": "desactivado", + "holder": "retenido por {{name}} (pid {{pid}})", + "holderUnknown": "sin responsable visible", + "keyDown": "Tecla pulsada", + "keyUp": "Tecla soltada", + "flagsChanged": "Modificadores", + "mouse": "Ratón", + "verdictBlocked": "Secure Input está bloqueando los eventos de teclado: los atajos que incluyen una tecla normal no pueden funcionar hasta que se resuelva.", + "verdictSuspicious": "Llegaron eventos de modificadores, pero ningún evento de tecla: algo está suprimiendo las teclas aunque Secure Input aparezca como desactivado. Por favor, repórtalo en GitHub.", + "verdictOk": "Los eventos de teclado llegan a Handy con normalidad.", + "verdictNoEvents": "No se capturó ningún evento: ¿pulsaste alguna tecla durante la prueba?", + "failed": "El diagnóstico falló: {{error}}" + }, "whatsNewPreview": { "title": "Vista previa de novedades", "description": "Abrir la nota de la versión incluida más reciente sin marcarla como vista", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Nuevo en Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} puede estar bloqueando 1 atajo", + "blockedWithCulprit_other": "{{name}} puede estar bloqueando {{count}} atajos", + "blockedNoCulprit_one": "macOS está bloqueando temporalmente 1 atajo", + "blockedNoCulprit_other": "macOS está bloqueando temporalmente {{count}} atajos", + "recorderBlockedWithCulprit": "{{name}} puede estar bloqueando los cambios de atajos", + "recorderBlockedNoCulprit": "macOS está bloqueando temporalmente los cambios de atajos", + "learnMore": "Cómo solucionarlo", + "recorderBlocked": "Ahora mismo no se pueden grabar atajos: Secure Input de macOS está bloqueando los eventos de teclado. Resuelve primero el aviso de Secure Input.", + "dismiss": "Descartar" } } diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 9cce8c47b3..842b076c33 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Décharger le modèle", "model": "Modèle", "quit": "Quitter", - "cancel": "Annuler" + "cancel": "Annuler", + "secureInputWarning": "⚠ Raccourcis bloqués par Secure Input" }, "sidebar": { "general": "Général", @@ -528,6 +529,26 @@ "title": "Tampon d'enregistrement supplémentaire", "description": "Temps supplémentaire (en millisecondes) pour continuer l'enregistrement après avoir relâché la touche, pour capturer l'audio restant. 0 = pas de tampon supplémentaire." }, + "keyboardDiagnostic": { + "title": "Diagnostic du clavier", + "description": "Vérifie si les événements clavier atteignent Handy. Seul le nombre d'événements est enregistré, jamais les touches sur lesquelles vous appuyez.", + "run": "Lancer un diagnostic de 10 s", + "running": "Écoute en cours… appuyez plusieurs fois sur votre raccourci (par ex. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "activé", + "disabled": "désactivé", + "holder": "détenu par {{name}} (pid {{pid}})", + "holderUnknown": "aucun détenteur visible", + "keyDown": "Touche enfoncée", + "keyUp": "Touche relâchée", + "flagsChanged": "Modificateurs", + "mouse": "Souris", + "verdictBlocked": "Secure Input bloque les événements clavier : les raccourcis contenant une touche normale ne peuvent pas fonctionner tant que ce n'est pas résolu.", + "verdictSuspicious": "Des événements de modificateurs sont arrivés, mais aucun événement de touche : quelque chose supprime les touches alors que Secure Input est signalé comme désactivé. Merci de le signaler sur GitHub.", + "verdictOk": "Les événements clavier atteignent Handy normalement.", + "verdictNoEvents": "Aucun événement capturé : avez-vous appuyé sur des touches pendant le test ?", + "failed": "Échec du diagnostic : {{error}}" + }, "whatsNewPreview": { "title": "Aperçu des nouveautés", "description": "Ouvrir la dernière note de version intégrée sans la marquer comme vue", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Nouveautés de Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} bloque peut-être 1 raccourci", + "blockedWithCulprit_other": "{{name}} bloque peut-être {{count}} raccourcis", + "blockedNoCulprit_one": "macOS bloque temporairement 1 raccourci", + "blockedNoCulprit_other": "macOS bloque temporairement {{count}} raccourcis", + "recorderBlockedWithCulprit": "{{name}} bloque peut-être la modification des raccourcis", + "recorderBlockedNoCulprit": "macOS bloque temporairement la modification des raccourcis", + "learnMore": "Comment résoudre ce problème", + "recorderBlocked": "Impossible d'enregistrer un raccourci pour le moment : Secure Input de macOS bloque les événements clavier. Résolvez d'abord l'avertissement Secure Input.", + "dismiss": "Ignorer" } } diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 677ee35f88..2d2e368cf7 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -6,7 +6,8 @@ "unloadModel": "פרוק מודל", "model": "מודל", "quit": "יציאה", - "cancel": "ביטול" + "cancel": "ביטול", + "secureInputWarning": "⚠ קיצורי המקשים חסומים על ידי Secure Input" }, "sidebar": { "general": "כללי", @@ -528,6 +529,26 @@ "title": "באפר הקלטה נוסף", "description": "זמן נוסף (במילישניות) להמשך הקלטה אחרי שחרור המקש, כדי ללכוד סוף דיבור. 0 = בלי באפר נוסף." }, + "keyboardDiagnostic": { + "title": "אבחון מקלדת", + "description": "בודק אם אירועי מקלדת מגיעים ל-Handy. נרשם רק מספר האירועים — לעולם לא אילו מקשים הקשת.", + "run": "הרצת אבחון של 10 שניות", + "running": "מאזין… הקש על קיצור המקשים שלך כמה פעמים (למשל Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "מופעל", + "disabled": "מושבת", + "holder": "מוחזק על ידי {{name}} (pid {{pid}})", + "holderUnknown": "אין מחזיק גלוי", + "keyDown": "לחיצת מקש", + "keyUp": "שחרור מקש", + "flagsChanged": "מקשי החלפה", + "mouse": "עכבר", + "verdictBlocked": "Secure Input חוסם אירועי מקשים — קיצורים הכוללים מקש רגיל לא יוכלו לפעול עד שהבעיה תיפתר.", + "verdictSuspicious": "הגיעו אירועים של מקשי החלפה אך לא אירועי מקשים — משהו מדכא מקשים למרות ש-Secure Input מדווח כמושבת. אנא דווח על כך ב-GitHub.", + "verdictOk": "אירועי מקשים מגיעים ל-Handy כרגיל.", + "verdictNoEvents": "לא נקלטו אירועים — האם הקשת על מקשים במהלך הבדיקה?", + "failed": "האבחון נכשל: {{error}}" + }, "whatsNewPreview": { "title": "תצוגה מקדימה של מה חדש", "description": "פתח את הערת הגרסה המצורפת האחרונה בלי לסמן אותה כנצפתה", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "חדש ב-Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "ייתכן ש-{{name}} חוסם קיצור דרך אחד", + "blockedWithCulprit_other": "ייתכן ש-{{name}} חוסם {{count}} קיצורי דרך", + "blockedNoCulprit_one": "macOS חוסם זמנית קיצור דרך אחד", + "blockedNoCulprit_other": "macOS חוסם זמנית {{count}} קיצורי דרך", + "recorderBlockedWithCulprit": "ייתכן ש-{{name}} חוסם שינויים בקיצורי דרך", + "recorderBlockedNoCulprit": "macOS חוסם זמנית שינויים בקיצורי דרך", + "learnMore": "איך לתקן את זה", + "recorderBlocked": "לא ניתן להקליט קיצורים כרגע — Secure Input של macOS חוסם אירועי מקשים. פתור תחילה את אזהרת Secure Input.", + "dismiss": "התעלם" } } diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index 775392b964..b63c058e60 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -6,7 +6,8 @@ "unloadModel": "मॉडल अनलोड करें", "model": "मॉडल", "quit": "बाहर निकलें", - "cancel": "रद्द करें" + "cancel": "रद्द करें", + "secureInputWarning": "⚠ Secure Input ने शॉर्टकट ब्लॉक किए हैं" }, "sidebar": { "general": "सामान्य", @@ -534,6 +535,26 @@ "recordingBuffer": { "title": "अतिरिक्त रिकॉर्डिंग बफ़र", "description": "बटन छोड़ने के बाद भी रिकॉर्डिंग जारी रखने का अतिरिक्त समय (मिलीसेकंड में), ताकि आखिरी हिस्से का ऑडियो कैप्चर हो सके. 0 = कोई अतिरिक्त बफ़र नहीं." + }, + "keyboardDiagnostic": { + "title": "कीबोर्ड डायग्नोस्टिक", + "description": "जाँचता है कि कीबोर्ड इवेंट Handy तक पहुँच रहे हैं या नहीं. सिर्फ़ इवेंट की गिनती रिकॉर्ड होती है — कभी नहीं कि आपने कौन-सी कुंजियाँ दबाईं.", + "run": "10 सेकंड का डायग्नोस्टिक चलाएँ", + "running": "सुन रहे हैं… अपना शॉर्टकट कुछ बार दबाएँ (जैसे Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "चालू", + "disabled": "बंद", + "holder": "{{name}} (pid {{pid}}) ने रोका हुआ है", + "holderUnknown": "कोई दिखने वाला ऐप नहीं", + "keyDown": "कुंजी दबाई", + "keyUp": "कुंजी छोड़ी", + "flagsChanged": "मॉडिफ़ायर", + "mouse": "माउस", + "verdictBlocked": "Secure Input कुंजी इवेंट ब्लॉक कर रहा है — जब तक यह हल नहीं होता, सामान्य कुंजी वाले शॉर्टकट काम नहीं कर सकते.", + "verdictSuspicious": "मॉडिफ़ायर इवेंट आए, लेकिन कोई कुंजी इवेंट नहीं — Secure Input के बंद दिखने के बावजूद कुछ कुंजियों को दबा रहा है. कृपया इसकी रिपोर्ट GitHub पर करें.", + "verdictOk": "कुंजी इवेंट Handy तक सामान्य रूप से पहुँच रहे हैं.", + "verdictNoEvents": "कोई इवेंट कैप्चर नहीं हुआ — क्या आपने टेस्ट के दौरान कोई कुंजी दबाई थी?", + "failed": "डायग्नोस्टिक विफल: {{error}}" } }, "about": { @@ -633,5 +654,16 @@ "overlay": { "transcribing": "ट्रांसक्राइब हो रहा है...", "processing": "प्रोसेस हो रहा है..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} शायद 1 शॉर्टकट को ब्लॉक कर रहा है", + "blockedWithCulprit_other": "{{name}} शायद {{count}} शॉर्टकट को ब्लॉक कर रहा है", + "blockedNoCulprit_one": "macOS अस्थायी रूप से 1 शॉर्टकट को ब्लॉक कर रहा है", + "blockedNoCulprit_other": "macOS अस्थायी रूप से {{count}} शॉर्टकट को ब्लॉक कर रहा है", + "recorderBlockedWithCulprit": "{{name}} शायद शॉर्टकट में बदलाव को ब्लॉक कर रहा है", + "recorderBlockedNoCulprit": "macOS अस्थायी रूप से शॉर्टकट में बदलाव को ब्लॉक कर रहा है", + "learnMore": "इसे कैसे ठीक करें", + "recorderBlocked": "अभी शॉर्टकट रिकॉर्ड नहीं किए जा सकते — macOS Secure Input कुंजी इवेंट ब्लॉक कर रहा है. पहले Secure Input चेतावनी हल करें.", + "dismiss": "खारिज करें" } } diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 5c0b9aee7b..d614d721e1 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Rilascia modello", "model": "Modello", "quit": "Esci", - "cancel": "Annulla" + "cancel": "Annulla", + "secureInputWarning": "⚠ Scorciatoie bloccate da Secure Input" }, "sidebar": { "general": "Generale", @@ -528,6 +529,26 @@ "title": "Buffer di registrazione extra", "description": "Tempo extra (in millisecondi) per continuare a registrare dopo aver rilasciato il tasto, per catturare l'audio finale. 0 = nessun buffer extra." }, + "keyboardDiagnostic": { + "title": "Diagnostica tastiera", + "description": "Verifica se gli eventi della tastiera arrivano a Handy. Viene registrato solo il numero di eventi, mai quali tasti premi.", + "run": "Esegui diagnostica di 10 s", + "running": "In ascolto… premi la tua scorciatoia alcune volte (ad es. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "abilitato", + "disabled": "disabilitato", + "holder": "trattenuto da {{name}} (pid {{pid}})", + "holderUnknown": "nessun responsabile visibile", + "keyDown": "Tasto premuto", + "keyUp": "Tasto rilasciato", + "flagsChanged": "Modificatori", + "mouse": "Mouse", + "verdictBlocked": "Secure Input sta bloccando gli eventi dei tasti: le scorciatoie che includono un tasto normale non possono funzionare finché non viene risolto.", + "verdictSuspicious": "Sono arrivati eventi dei modificatori ma nessun evento dei tasti: qualcosa sta sopprimendo i tasti anche se Secure Input risulta disabilitato. Segnalalo su GitHub.", + "verdictOk": "Gli eventi dei tasti arrivano a Handy normalmente.", + "verdictNoEvents": "Nessun evento rilevato: hai premuto qualche tasto durante il test?", + "failed": "Diagnostica non riuscita: {{error}}" + }, "whatsNewPreview": { "title": "Anteprima delle novità", "description": "Apri le note di rilascio incluse più recenti senza segnarle come viste", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Novità in Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} potrebbe bloccare 1 scorciatoia", + "blockedWithCulprit_other": "{{name}} potrebbe bloccare {{count}} scorciatoie", + "blockedNoCulprit_one": "macOS sta bloccando temporaneamente 1 scorciatoia", + "blockedNoCulprit_other": "macOS sta bloccando temporaneamente {{count}} scorciatoie", + "recorderBlockedWithCulprit": "{{name}} potrebbe bloccare le modifiche alle scorciatoie", + "recorderBlockedNoCulprit": "macOS sta bloccando temporaneamente le modifiche alle scorciatoie", + "learnMore": "Come risolvere", + "recorderBlocked": "Non è possibile registrare scorciatoie in questo momento: Secure Input di macOS sta bloccando gli eventi dei tasti. Risolvi prima l'avviso di Secure Input.", + "dismiss": "Ignora" } } diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index cac4c1d3ee..b43b07f1a8 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -6,7 +6,8 @@ "unloadModel": "モデルをアンロード", "model": "モデル", "quit": "終了", - "cancel": "キャンセル" + "cancel": "キャンセル", + "secureInputWarning": "⚠ Secure Input でショートカットがブロック中" }, "sidebar": { "general": "一般", @@ -528,6 +529,26 @@ "title": "追加録音バッファ", "description": "キーを離した後に録音を続ける追加時間(ミリ秒)。末尾の音声を捕捉するため。0 = 追加バッファなし。" }, + "keyboardDiagnostic": { + "title": "キーボード診断", + "description": "キーボードイベントが Handy に届いているかを確認します。記録されるのはイベント数のみで、押したキーの内容は記録されません。", + "run": "10 秒間の診断を実行", + "running": "待機中… ショートカットを数回押してください (例: Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "有効", + "disabled": "無効", + "holder": "{{name}} (pid {{pid}}) が保持中", + "holderUnknown": "保持元は不明", + "keyDown": "キー押下", + "keyUp": "キー解放", + "flagsChanged": "修飾キー", + "mouse": "マウス", + "verdictBlocked": "Secure Input がキーイベントをブロックしています。解消されるまで、通常キーを含むショートカットは動作しません。", + "verdictSuspicious": "修飾キーのイベントは届いていますが、キーイベントは届いていません。Secure Input は無効と報告されているのに、何かがキーを抑制しています。GitHub で報告してください。", + "verdictOk": "キーイベントは Handy に正常に届いています。", + "verdictNoEvents": "イベントを検出できませんでした。テスト中にキーを押しましたか?", + "failed": "診断に失敗しました: {{error}}" + }, "whatsNewPreview": { "title": "新機能をプレビュー", "description": "最新の同梱リリースノートを既読にせずに開く", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Handy v{{version}} の新機能" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} が1個のショートカットをブロックしている可能性があります", + "blockedWithCulprit_other": "{{name}} が{{count}}個のショートカットをブロックしている可能性があります", + "blockedNoCulprit_one": "macOS が一時的に1個のショートカットをブロックしています", + "blockedNoCulprit_other": "macOS が一時的に{{count}}個のショートカットをブロックしています", + "recorderBlockedWithCulprit": "{{name}} がショートカットの変更をブロックしている可能性があります", + "recorderBlockedNoCulprit": "macOS が一時的にショートカットの変更をブロックしています", + "learnMore": "解決方法", + "recorderBlocked": "現在ショートカットを記録できません。macOS の Secure Input がキーイベントをブロックしています。先に Secure Input の警告を解消してください。", + "dismiss": "閉じる" } } diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index a843dc53a7..4ab6f759df 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -6,7 +6,8 @@ "unloadModel": "모델 언로드", "model": "모델", "quit": "종료", - "cancel": "취소" + "cancel": "취소", + "secureInputWarning": "⚠ Secure Input으로 단축키가 차단됨" }, "sidebar": { "general": "일반", @@ -523,6 +524,26 @@ "title": "추가 녹음 버퍼", "description": "키를 놓은 후 추가로 녹음을 계속하는 시간(밀리초). 후행 오디오를 캡처하기 위함. 0 = 추가 버퍼 없음." }, + "keyboardDiagnostic": { + "title": "키보드 진단", + "description": "키보드 이벤트가 Handy에 도달하는지 확인합니다. 이벤트 개수만 기록되며, 어떤 키를 눌렀는지는 절대 기록되지 않습니다.", + "run": "10초 진단 실행", + "running": "수신 중… 단축키를 몇 번 눌러 보세요 (예: Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "사용 중", + "disabled": "사용 안 함", + "holder": "{{name}}(pid {{pid}})이(가) 점유 중", + "holderUnknown": "점유 중인 앱을 확인할 수 없음", + "keyDown": "키 누름", + "keyUp": "키 뗌", + "flagsChanged": "조합 키", + "mouse": "마우스", + "verdictBlocked": "Secure Input이 키 이벤트를 차단하고 있습니다. 해결될 때까지 일반 키가 포함된 단축키는 작동할 수 없습니다.", + "verdictSuspicious": "조합 키 이벤트는 도착했지만 키 이벤트는 없습니다. Secure Input은 꺼져 있다고 보고되지만 무언가가 키를 가로막고 있습니다. GitHub에 신고해 주세요.", + "verdictOk": "키 이벤트가 Handy에 정상적으로 도달하고 있습니다.", + "verdictNoEvents": "이벤트가 감지되지 않았습니다. 테스트 중에 키를 누르셨나요?", + "failed": "진단 실패: {{error}}" + }, "paths": { "appData": "앱 데이터:", "models": "모델:", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Handy v{{version}}의 새 소식" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}}이(가) 단축키 1개를 차단하고 있을 수 있습니다", + "blockedWithCulprit_other": "{{name}}이(가) 단축키 {{count}}개를 차단하고 있을 수 있습니다", + "blockedNoCulprit_one": "macOS가 단축키 1개를 일시적으로 차단하고 있습니다", + "blockedNoCulprit_other": "macOS가 단축키 {{count}}개를 일시적으로 차단하고 있습니다", + "recorderBlockedWithCulprit": "{{name}}이(가) 단축키 변경을 차단하고 있을 수 있습니다", + "recorderBlockedNoCulprit": "macOS가 단축키 변경을 일시적으로 차단하고 있습니다", + "learnMore": "해결 방법", + "recorderBlocked": "지금은 단축키를 등록할 수 없습니다. macOS Secure Input이 키 이벤트를 차단하고 있습니다. Secure Input 경고를 먼저 해결하세요.", + "dismiss": "닫기" } } diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index 7ec013ebe4..be89c29a2c 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -6,7 +6,8 @@ "unloadModel": "मोडेल अनलोड गर्नुहोस्", "model": "मोडेल", "quit": "बाहिर निस्कनुहोस्", - "cancel": "रद्द गर्नुहोस्" + "cancel": "रद्द गर्नुहोस्", + "secureInputWarning": "⚠ Secure Input ले सर्टकटहरू रोकेको छ" }, "sidebar": { "general": "सामान्य", @@ -534,6 +535,26 @@ "recordingBuffer": { "title": "अतिरिक्त रेकर्डिङ बफर", "description": "कि छोडेपछि अन्तिम अडियो समात्न रेकर्डिङ जारी राख्ने अतिरिक्त समय (मिलिसेकेन्डमा)। 0 = कुनै अतिरिक्त बफर छैन।" + }, + "keyboardDiagnostic": { + "title": "किबोर्ड डायग्नोस्टिक", + "description": "किबोर्ड इभेन्टहरू Handy सम्म पुग्छन् कि पुग्दैनन् भनी जाँच्छ। इभेन्टको संख्या मात्र रेकर्ड हुन्छ — तपाईंले कुन कुञ्जी थिच्नुभयो भन्ने कहिल्यै होइन।", + "run": "१० सेकेन्डको डायग्नोस्टिक चलाउनुहोस्", + "running": "सुनिरहेको छ… आफ्नो सर्टकट केही पटक थिच्नुहोस् (जस्तै Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "सक्षम", + "disabled": "अक्षम", + "holder": "{{name}} (pid {{pid}}) ले समातेको छ", + "holderUnknown": "देखिने कुनै धारक छैन", + "keyDown": "कुञ्जी थिचिएको", + "keyUp": "कुञ्जी छाडिएको", + "flagsChanged": "मोडिफायर", + "mouse": "माउस", + "verdictBlocked": "Secure Input ले कुञ्जी इभेन्टहरू रोकिरहेको छ — यो समाधान नभएसम्म सामान्य कुञ्जी भएका सर्टकटहरूले काम गर्न सक्दैनन्।", + "verdictSuspicious": "मोडिफायर इभेन्टहरू आए तर कुञ्जी इभेन्ट आएनन् — Secure Input अक्षम देखिए पनि केहीले कुञ्जीहरू दबाइरहेको छ। कृपया यसको रिपोर्ट GitHub मा गर्नुहोस्।", + "verdictOk": "कुञ्जी इभेन्टहरू Handy सम्म सामान्य रूपमा पुगिरहेका छन्।", + "verdictNoEvents": "कुनै इभेन्ट कैद भएन — के तपाईंले परीक्षणको क्रममा कुनै कुञ्जी थिच्नुभयो?", + "failed": "डायग्नोस्टिक असफल: {{error}}" } }, "about": { @@ -633,5 +654,16 @@ "overlay": { "transcribing": "ट्रान्सक्राइब गरिँदैछ...", "processing": "प्रशोधन गरिँदैछ..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} ले 1 सर्टकट रोकिरहेको हुन सक्छ", + "blockedWithCulprit_other": "{{name}} ले {{count}} सर्टकटहरू रोकिरहेको हुन सक्छ", + "blockedNoCulprit_one": "macOS ले अस्थायी रूपमा 1 सर्टकट रोकिरहेको छ", + "blockedNoCulprit_other": "macOS ले अस्थायी रूपमा {{count}} सर्टकटहरू रोकिरहेको छ", + "recorderBlockedWithCulprit": "{{name}} ले सर्टकट परिवर्तन रोकिरहेको हुन सक्छ", + "recorderBlockedNoCulprit": "macOS ले अस्थायी रूपमा सर्टकट परिवर्तन रोकिरहेको छ", + "learnMore": "यसलाई कसरी ठिक गर्ने", + "recorderBlocked": "अहिले सर्टकट रेकर्ड गर्न सकिँदैन — macOS Secure Input ले कुञ्जी इभेन्टहरू रोकिरहेको छ। पहिले Secure Input चेतावनी समाधान गर्नुहोस्।", + "dismiss": "हटाउनुहोस्" } } diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 9d9c4946c7..68f89f1261 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Model ontladen", "model": "Model", "quit": "Afsluiten", - "cancel": "Annuleren" + "cancel": "Annuleren", + "secureInputWarning": "⚠ Sneltoetsen geblokkeerd door Secure Input" }, "sidebar": { "general": "Algemeen", @@ -534,6 +535,26 @@ "recordingBuffer": { "title": "Extra opnamebuffer", "description": "Extra tijd (in milliseconden) om te blijven opnemen nadat je de toets loslaat, om naloopaudio op te vangen. 0 = geen extra buffer." + }, + "keyboardDiagnostic": { + "title": "Toetsenborddiagnose", + "description": "Controleert of toetsenbordgebeurtenissen Handy bereiken. Alleen het aantal gebeurtenissen wordt vastgelegd — nooit welke toetsen je indrukt.", + "run": "Diagnose van 10 s uitvoeren", + "running": "Luisteren… druk je sneltoets een paar keer in (bijv. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "ingeschakeld", + "disabled": "uitgeschakeld", + "holder": "vastgehouden door {{name}} (pid {{pid}})", + "holderUnknown": "geen zichtbare veroorzaker", + "keyDown": "Toets ingedrukt", + "keyUp": "Toets losgelaten", + "flagsChanged": "Modificatietoetsen", + "mouse": "Muis", + "verdictBlocked": "Secure Input blokkeert toetsgebeurtenissen — sneltoetsen met een gewone toets kunnen pas weer werken als dit is opgelost.", + "verdictSuspicious": "Er kwamen wel modificatiegebeurtenissen binnen, maar geen toetsgebeurtenissen — iets onderdrukt toetsen terwijl Secure Input als uitgeschakeld wordt gemeld. Meld dit op GitHub.", + "verdictOk": "Toetsgebeurtenissen bereiken Handy normaal.", + "verdictNoEvents": "Geen gebeurtenissen vastgelegd — heb je tijdens de test toetsen ingedrukt?", + "failed": "Diagnose mislukt: {{error}}" } }, "about": { @@ -633,5 +654,16 @@ "overlay": { "transcribing": "Transcriberen...", "processing": "Verwerken..." + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} blokkeert mogelijk 1 sneltoets", + "blockedWithCulprit_other": "{{name}} blokkeert mogelijk {{count}} sneltoetsen", + "blockedNoCulprit_one": "macOS blokkeert tijdelijk 1 sneltoets", + "blockedNoCulprit_other": "macOS blokkeert tijdelijk {{count}} sneltoetsen", + "recorderBlockedWithCulprit": "{{name}} blokkeert mogelijk wijzigingen aan sneltoetsen", + "recorderBlockedNoCulprit": "macOS blokkeert tijdelijk wijzigingen aan sneltoetsen", + "learnMore": "Hoe je dit oplost", + "recorderBlocked": "Sneltoetsen kunnen nu niet worden opgenomen — Secure Input van macOS blokkeert toetsgebeurtenissen. Los eerst de Secure Input-waarschuwing op.", + "dismiss": "Sluiten" } } diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index ff2570f9fc..c1f88fd730 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Zwolnij model", "model": "Model", "quit": "Zamknij", - "cancel": "Anuluj" + "cancel": "Anuluj", + "secureInputWarning": "⚠ Skróty zablokowane przez Secure Input" }, "sidebar": { "general": "Ogólne", @@ -528,6 +529,26 @@ "title": "Dodatkowy bufor nagrywania", "description": "Dodatkowy czas (w milisekundach) na kontynuowanie nagrywania po zwolnieniu klawisza, aby przechwycić końcowy dźwięk. 0 = brak dodatkowego bufora." }, + "keyboardDiagnostic": { + "title": "Diagnostyka klawiatury", + "description": "Sprawdza, czy zdarzenia klawiatury docierają do Handy. Zapisywana jest tylko liczba zdarzeń — nigdy to, które klawisze naciskasz.", + "run": "Uruchom 10-sekundową diagnostykę", + "running": "Nasłuchiwanie… naciśnij swój skrót kilka razy (np. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "włączony", + "disabled": "wyłączony", + "holder": "trzymany przez {{name}} (pid {{pid}})", + "holderUnknown": "brak widocznego posiadacza", + "keyDown": "Klawisz wciśnięty", + "keyUp": "Klawisz zwolniony", + "flagsChanged": "Modyfikatory", + "mouse": "Mysz", + "verdictBlocked": "Secure Input blokuje zdarzenia klawiszy — skróty zawierające zwykły klawisz nie zadziałają, dopóki nie zostanie to rozwiązane.", + "verdictSuspicious": "Dotarły zdarzenia modyfikatorów, ale żadnych zdarzeń klawiszy — coś tłumi klawisze, mimo że Secure Input jest zgłaszany jako wyłączony. Zgłoś to na GitHubie.", + "verdictOk": "Zdarzenia klawiszy docierają do Handy normalnie.", + "verdictNoEvents": "Nie przechwycono żadnych zdarzeń — czy w trakcie testu naciśnięto jakieś klawisze?", + "failed": "Diagnostyka nie powiodła się: {{error}}" + }, "whatsNewPreview": { "title": "Podgląd nowości", "description": "Otwórz najnowsze dołączone informacje o wydaniu bez oznaczania ich jako wyświetlone", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Nowości w Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} może blokować 1 skrót", + "blockedWithCulprit_other": "{{name}} może blokować {{count}} skrótów", + "blockedNoCulprit_one": "macOS tymczasowo blokuje 1 skrót", + "blockedNoCulprit_other": "macOS tymczasowo blokuje {{count}} skrótów", + "recorderBlockedWithCulprit": "{{name}} może blokować zmiany skrótów", + "recorderBlockedNoCulprit": "macOS tymczasowo blokuje zmiany skrótów", + "learnMore": "Jak to naprawić", + "recorderBlocked": "Nie można teraz nagrać skrótu — Secure Input w macOS blokuje zdarzenia klawiszy. Najpierw rozwiąż ostrzeżenie o Secure Input.", + "dismiss": "Odrzuć" } } diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 1bf553adf0..989a3930f4 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Descarregar modelo", "model": "Modelo", "quit": "Sair", - "cancel": "Cancelar" + "cancel": "Cancelar", + "secureInputWarning": "⚠ Atalhos bloqueados pelo Secure Input" }, "sidebar": { "general": "Geral", @@ -528,6 +529,26 @@ "title": "Buffer de gravação extra", "description": "Tempo extra (em milissegundos) para continuar gravando após soltar a tecla, para capturar áudio restante. 0 = sem buffer extra." }, + "keyboardDiagnostic": { + "title": "Diagnóstico do teclado", + "description": "Verifica se os eventos de teclado chegam ao Handy. Apenas a contagem de eventos é registrada — nunca quais teclas você pressiona.", + "run": "Executar diagnóstico de 10 s", + "running": "Ouvindo… pressione seu atalho algumas vezes (ex.: Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "ativado", + "disabled": "desativado", + "holder": "retido por {{name}} (pid {{pid}})", + "holderUnknown": "sem responsável visível", + "keyDown": "Tecla pressionada", + "keyUp": "Tecla liberada", + "flagsChanged": "Modificadores", + "mouse": "Mouse", + "verdictBlocked": "O Secure Input está bloqueando os eventos de tecla — atalhos que incluem uma tecla normal não funcionam até que isso seja resolvido.", + "verdictSuspicious": "Chegaram eventos de modificadores, mas nenhum evento de tecla — algo está suprimindo as teclas mesmo com o Secure Input indicando desativado. Por favor, relate isso no GitHub.", + "verdictOk": "Os eventos de tecla estão chegando normalmente ao Handy.", + "verdictNoEvents": "Nenhum evento capturado — você pressionou alguma tecla durante o teste?", + "failed": "Falha no diagnóstico: {{error}}" + }, "whatsNewPreview": { "title": "Pré-visualizar novidades", "description": "Abrir a nota de versão incorporada mais recente sem marcá-la como vista", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Novidades no Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} pode estar bloqueando 1 atalho", + "blockedWithCulprit_other": "{{name}} pode estar bloqueando {{count}} atalhos", + "blockedNoCulprit_one": "O macOS está bloqueando temporariamente 1 atalho", + "blockedNoCulprit_other": "O macOS está bloqueando temporariamente {{count}} atalhos", + "recorderBlockedWithCulprit": "{{name}} pode estar bloqueando alterações nos atalhos", + "recorderBlockedNoCulprit": "O macOS está bloqueando temporariamente alterações nos atalhos", + "learnMore": "Como resolver isso", + "recorderBlocked": "Não é possível gravar atalhos agora — o Secure Input do macOS está bloqueando os eventos de tecla. Resolva primeiro o aviso do Secure Input.", + "dismiss": "Dispensar" } } diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 53db4a3d45..c2debd274c 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Выгрузить модель", "model": "Модель", "quit": "Выход", - "cancel": "Отмена" + "cancel": "Отмена", + "secureInputWarning": "⚠ Сочетания клавиш заблокированы Secure Input" }, "sidebar": { "general": "Общие", @@ -528,6 +529,26 @@ "title": "Дополнительный буфер записи", "description": "Дополнительное время (в миллисекундах) для продолжения записи после отпускания клавиши, чтобы захватить завершающий звук. 0 = без дополнительного буфера." }, + "keyboardDiagnostic": { + "title": "Диагностика клавиатуры", + "description": "Проверяет, доходят ли события клавиатуры до Handy. Записывается только количество событий — никогда то, какие клавиши вы нажимаете.", + "run": "Запустить 10-секундную диагностику", + "running": "Прослушивание… нажмите своё сочетание клавиш несколько раз (например, Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "включён", + "disabled": "выключен", + "holder": "удерживает {{name}} (pid {{pid}})", + "holderUnknown": "видимый владелец не найден", + "keyDown": "Нажатие клавиши", + "keyUp": "Отпускание клавиши", + "flagsChanged": "Модификаторы", + "mouse": "Мышь", + "verdictBlocked": "Secure Input блокирует события клавиш — сочетания с обычной клавишей не смогут работать, пока это не будет устранено.", + "verdictSuspicious": "События модификаторов приходят, а события клавиш — нет: что-то подавляет клавиши, хотя Secure Input отмечен как выключенный. Пожалуйста, сообщите об этом на GitHub.", + "verdictOk": "События клавиш доходят до Handy нормально.", + "verdictNoEvents": "События не зафиксированы — вы нажимали клавиши во время теста?", + "failed": "Диагностика не удалась: {{error}}" + }, "whatsNewPreview": { "title": "Предпросмотр новинок", "description": "Открыть последние встроенные примечания к выпуску, не отмечая их как просмотренные", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Новое в Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} может блокировать 1 сочетание клавиш", + "blockedWithCulprit_other": "{{name}} может блокировать {{count}} сочетаний клавиш", + "blockedNoCulprit_one": "macOS временно блокирует 1 сочетание клавиш", + "blockedNoCulprit_other": "macOS временно блокирует {{count}} сочетаний клавиш", + "recorderBlockedWithCulprit": "{{name}} может блокировать изменение сочетаний клавиш", + "recorderBlockedNoCulprit": "macOS временно блокирует изменение сочетаний клавиш", + "learnMore": "Как это исправить", + "recorderBlocked": "Сейчас нельзя записать сочетание клавиш — Secure Input в macOS блокирует события клавиш. Сначала устраните предупреждение о Secure Input.", + "dismiss": "Скрыть" } } diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index dc7294c8b4..e76be7489e 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Avlasta modell", "model": "Modell", "quit": "Avsluta", - "cancel": "Avbryt" + "cancel": "Avbryt", + "secureInputWarning": "⚠ Kortkommandon blockeras av Secure Input" }, "sidebar": { "general": "Allmänt", @@ -528,6 +529,26 @@ "title": "Extra inspelningsbuffert", "description": "Extra tid (i millisekunder) för att fortsätta spela in efter att du släppt tangenten, för att fånga upp avslutande ljud. 0 = ingen extra buffert." }, + "keyboardDiagnostic": { + "title": "Tangentbordsdiagnostik", + "description": "Kontrollerar om tangentbordshändelser når Handy. Endast antalet händelser registreras — aldrig vilka tangenter du trycker på.", + "run": "Kör 10 s diagnostik", + "running": "Lyssnar… tryck på ditt kortkommando några gånger (t.ex. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "aktiverat", + "disabled": "inaktiverat", + "holder": "hålls av {{name}} (pid {{pid}})", + "holderUnknown": "ingen synlig innehavare", + "keyDown": "Tangent ned", + "keyUp": "Tangent upp", + "flagsChanged": "Modifierare", + "mouse": "Mus", + "verdictBlocked": "Secure Input blockerar tangenthändelser — kortkommandon med en vanlig tangent kan inte fungera förrän det är löst.", + "verdictSuspicious": "Modifierarhändelser kom fram men inga tangenthändelser — något undertrycker tangenter trots att Secure Input rapporteras som inaktiverat. Rapportera gärna detta på GitHub.", + "verdictOk": "Tangenthändelser når Handy som vanligt.", + "verdictNoEvents": "Inga händelser fångades — tryckte du på några tangenter under testet?", + "failed": "Diagnostiken misslyckades: {{error}}" + }, "whatsNewPreview": { "title": "Förhandsvisa nyheter", "description": "Öppna den senaste medföljande versionsinformationen utan att markera den som visad", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Nytt i Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} kanske blockerar 1 kortkommando", + "blockedWithCulprit_other": "{{name}} kanske blockerar {{count}} kortkommandon", + "blockedNoCulprit_one": "macOS blockerar tillfälligt 1 kortkommando", + "blockedNoCulprit_other": "macOS blockerar tillfälligt {{count}} kortkommandon", + "recorderBlockedWithCulprit": "{{name}} kanske blockerar ändringar av kortkommandon", + "recorderBlockedNoCulprit": "macOS blockerar tillfälligt ändringar av kortkommandon", + "learnMore": "Så här löser du det", + "recorderBlocked": "Det går inte att spela in kortkommandon just nu — macOS Secure Input blockerar tangenthändelser. Lös varningen om Secure Input först.", + "dismiss": "Avfärda" } } diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 7bf0526601..369690b826 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Modeli boşalt", "model": "Model", "quit": "Çıkış", - "cancel": "İptal" + "cancel": "İptal", + "secureInputWarning": "⚠ Kısayollar Secure Input tarafından engelleniyor" }, "sidebar": { "general": "Genel", @@ -528,6 +529,26 @@ "title": "Ekstra kayıt tamponu", "description": "Tuşu bıraktıktan sonra arka plandaki sesi yakalamak için kaydı sürdürme süresi (milisaniye). 0 = ekstra tampon yok." }, + "keyboardDiagnostic": { + "title": "Klavye Tanılaması", + "description": "Klavye olaylarının Handy'ye ulaşıp ulaşmadığını denetler. Yalnızca olay sayıları kaydedilir — hangi tuşlara bastığınız asla kaydedilmez.", + "run": "10 sn'lik tanılamayı çalıştır", + "running": "Dinleniyor… kısayolunuza birkaç kez basın (ör. Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "etkin", + "disabled": "devre dışı", + "holder": "{{name}} tarafından tutuluyor (pid {{pid}})", + "holderUnknown": "görünür bir sahip yok", + "keyDown": "Tuş basma", + "keyUp": "Tuş bırakma", + "flagsChanged": "Değiştiriciler", + "mouse": "Fare", + "verdictBlocked": "Secure Input tuş olaylarını engelliyor — normal tuş içeren kısayollar bu sorun çözülene kadar çalışamaz.", + "verdictSuspicious": "Değiştirici olayları geldi ancak hiç tuş olayı gelmedi — Secure Input devre dışı görünmesine rağmen bir şey tuşları bastırıyor. Lütfen bunu GitHub'da bildirin.", + "verdictOk": "Tuş olayları Handy'ye normal şekilde ulaşıyor.", + "verdictNoEvents": "Hiç olay yakalanmadı — test sırasında herhangi bir tuşa bastınız mı?", + "failed": "Tanılama başarısız: {{error}}" + }, "whatsNewPreview": { "title": "Yenilikleri Önizle", "description": "En son paketlenmiş sürüm notunu görüldü olarak işaretlemeden aç", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Handy v{{version}} yenilikleri" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} 1 kısayolu engelliyor olabilir", + "blockedWithCulprit_other": "{{name}} {{count}} kısayolu engelliyor olabilir", + "blockedNoCulprit_one": "macOS geçici olarak 1 kısayolu engelliyor", + "blockedNoCulprit_other": "macOS geçici olarak {{count}} kısayolu engelliyor", + "recorderBlockedWithCulprit": "{{name}} kısayol değişikliklerini engelliyor olabilir", + "recorderBlockedNoCulprit": "macOS geçici olarak kısayol değişikliklerini engelliyor", + "learnMore": "Bu nasıl düzeltilir", + "recorderBlocked": "Şu anda kısayol kaydedilemiyor — macOS Secure Input tuş olaylarını engelliyor. Önce Secure Input uyarısını giderin.", + "dismiss": "Kapat" } } diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 90e302fa2f..b0a06c7faf 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Вивантажити модель", "model": "Модель", "quit": "Вийти", - "cancel": "Скасувати" + "cancel": "Скасувати", + "secureInputWarning": "⚠ Комбінації клавіш заблоковано Secure Input" }, "sidebar": { "general": "Загальні", @@ -528,6 +529,26 @@ "title": "Додатковий буфер запису", "description": "Додатковий час (у мілісекундах) для продовження запису після відпускання клавіші, щоб захопити завершальний звук. 0 = без додаткового буфера." }, + "keyboardDiagnostic": { + "title": "Діагностика клавіатури", + "description": "Перевіряє, чи доходять події клавіатури до Handy. Записується лише кількість подій — ніколи те, які клавіші ви натискаєте.", + "run": "Запустити 10-секундну діагностику", + "running": "Прослуховування… натисніть свою комбінацію кілька разів (наприклад, Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "увімкнено", + "disabled": "вимкнено", + "holder": "утримує {{name}} (pid {{pid}})", + "holderUnknown": "видимого власника не знайдено", + "keyDown": "Натискання клавіші", + "keyUp": "Відпускання клавіші", + "flagsChanged": "Модифікатори", + "mouse": "Миша", + "verdictBlocked": "Secure Input блокує події клавіш — комбінації зі звичайною клавішею не працюватимуть, доки це не буде усунуто.", + "verdictSuspicious": "Події модифікаторів надходять, а події клавіш — ні: щось пригнічує клавіші, хоча Secure Input позначено як вимкнений. Будь ласка, повідомте про це на GitHub.", + "verdictOk": "Події клавіш надходять до Handy нормально.", + "verdictNoEvents": "Подій не зафіксовано — ви натискали клавіші під час тесту?", + "failed": "Не вдалося виконати діагностику: {{error}}" + }, "whatsNewPreview": { "title": "Попередній перегляд новин", "description": "Відкрити останні вбудовані примітки до випуску, не позначаючи їх як переглянуті", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Нове в Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} може блокувати 1 комбінацію клавіш", + "blockedWithCulprit_other": "{{name}} може блокувати {{count}} комбінацій клавіш", + "blockedNoCulprit_one": "macOS тимчасово блокує 1 комбінацію клавіш", + "blockedNoCulprit_other": "macOS тимчасово блокує {{count}} комбінацій клавіш", + "recorderBlockedWithCulprit": "{{name}} може блокувати зміну комбінацій клавіш", + "recorderBlockedNoCulprit": "macOS тимчасово блокує зміну комбінацій клавіш", + "learnMore": "Як це виправити", + "recorderBlocked": "Зараз неможливо записати комбінацію — Secure Input у macOS блокує події клавіш. Спершу усуньте попередження про Secure Input.", + "dismiss": "Приховати" } } diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 2a636c67e1..045c81b711 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -6,7 +6,8 @@ "unloadModel": "Dỡ mô hình", "model": "Mô hình", "quit": "Thoát", - "cancel": "Hủy" + "cancel": "Hủy", + "secureInputWarning": "⚠ Phím tắt bị Secure Input chặn" }, "sidebar": { "general": "Chung", @@ -528,6 +529,26 @@ "title": "Bộ đệm ghi âm thêm", "description": "Thời gian thêm (tính bằng mili giây) để tiếp tục ghi âm sau khi nhả phím, để thu âm thanh cuối. 0 = không có bộ đệm thêm." }, + "keyboardDiagnostic": { + "title": "Chẩn đoán bàn phím", + "description": "Kiểm tra xem các sự kiện bàn phím có đến được Handy hay không. Chỉ số lượng sự kiện được ghi lại — không bao giờ ghi lại bạn bấm phím nào.", + "run": "Chạy chẩn đoán 10 giây", + "running": "Đang lắng nghe… hãy bấm phím tắt của bạn vài lần (ví dụ: Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "đang bật", + "disabled": "đang tắt", + "holder": "bị {{name}} (pid {{pid}}) giữ", + "holderUnknown": "không thấy tiến trình nào giữ", + "keyDown": "Nhấn phím", + "keyUp": "Nhả phím", + "flagsChanged": "Phím bổ trợ", + "mouse": "Chuột", + "verdictBlocked": "Secure Input đang chặn các sự kiện phím — phím tắt có chứa phím thường không thể hoạt động cho đến khi vấn đề này được xử lý.", + "verdictSuspicious": "Có sự kiện phím bổ trợ nhưng không có sự kiện phím nào — thứ gì đó đang chặn phím dù Secure Input báo là đang tắt. Vui lòng báo cáo việc này trên GitHub.", + "verdictOk": "Các sự kiện phím đang đến Handy bình thường.", + "verdictNoEvents": "Không ghi nhận sự kiện nào — bạn có bấm phím nào trong lúc kiểm tra không?", + "failed": "Chẩn đoán thất bại: {{error}}" + }, "whatsNewPreview": { "title": "Xem trước điểm mới", "description": "Mở ghi chú phát hành mới nhất được đóng gói mà không đánh dấu là đã xem", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Điểm mới trong Handy v{{version}}" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} có thể đang chặn 1 phím tắt", + "blockedWithCulprit_other": "{{name}} có thể đang chặn {{count}} phím tắt", + "blockedNoCulprit_one": "macOS đang tạm thời chặn 1 phím tắt", + "blockedNoCulprit_other": "macOS đang tạm thời chặn {{count}} phím tắt", + "recorderBlockedWithCulprit": "{{name}} có thể đang chặn việc thay đổi phím tắt", + "recorderBlockedNoCulprit": "macOS đang tạm thời chặn việc thay đổi phím tắt", + "learnMore": "Cách khắc phục", + "recorderBlocked": "Hiện không thể ghi phím tắt — Secure Input của macOS đang chặn các sự kiện phím. Hãy xử lý cảnh báo Secure Input trước.", + "dismiss": "Bỏ qua" } } diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 7ad33db7c3..1dcf679924 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -6,7 +6,8 @@ "unloadModel": "卸載模型", "model": "模型", "quit": "結束", - "cancel": "取消" + "cancel": "取消", + "secureInputWarning": "⚠ 快捷鍵被 Secure Input 阻擋" }, "sidebar": { "general": "一般", @@ -528,6 +529,26 @@ "title": "額外錄音緩衝", "description": "放開按鍵後繼續錄音的額外時間(毫秒),以捕捉尾音。0 = 無額外緩衝。" }, + "keyboardDiagnostic": { + "title": "鍵盤診斷", + "description": "檢查鍵盤事件是否能送達 Handy。只會記錄事件數量,絕不記錄您按了哪些按鍵。", + "run": "執行 10 秒診斷", + "running": "正在監聽… 請按幾次您的快捷鍵(例如 Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "已啟用", + "disabled": "已停用", + "holder": "被 {{name}}(pid {{pid}})占用", + "holderUnknown": "找不到占用的程式", + "keyDown": "按鍵按下", + "keyUp": "按鍵放開", + "flagsChanged": "修飾鍵", + "mouse": "滑鼠", + "verdictBlocked": "Secure Input 正在阻擋按鍵事件——在問題解決前,包含一般按鍵的快捷鍵無法運作。", + "verdictSuspicious": "收到了修飾鍵事件,但沒有按鍵事件——即使 Secure Input 回報為已停用,仍有程式在抑制按鍵。請在 GitHub 上回報此問題。", + "verdictOk": "按鍵事件正常送達 Handy。", + "verdictNoEvents": "未擷取到任何事件——測試期間您有按鍵嗎?", + "failed": "診斷失敗:{{error}}" + }, "whatsNewPreview": { "title": "預覽新功能", "description": "開啟最新內建版本資訊,且不標記為已看過", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Handy v{{version}} 新功能" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} 可能正在阻擋 1 個快捷鍵", + "blockedWithCulprit_other": "{{name}} 可能正在阻擋 {{count}} 個快捷鍵", + "blockedNoCulprit_one": "macOS 正在暫時阻擋 1 個快捷鍵", + "blockedNoCulprit_other": "macOS 正在暫時阻擋 {{count}} 個快捷鍵", + "recorderBlockedWithCulprit": "{{name}} 可能正在阻擋快捷鍵變更", + "recorderBlockedNoCulprit": "macOS 正在暫時阻擋快捷鍵變更", + "learnMore": "如何解決", + "recorderBlocked": "目前無法錄製快捷鍵——macOS 的 Secure Input 正在阻擋按鍵事件。請先解決 Secure Input 警告。", + "dismiss": "關閉" } } diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index eff414234d..b6a78e3cad 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -6,7 +6,8 @@ "unloadModel": "卸载模型", "model": "模型", "quit": "退出", - "cancel": "取消" + "cancel": "取消", + "secureInputWarning": "⚠ 快捷键被 Secure Input 阻止" }, "sidebar": { "general": "通用", @@ -528,6 +529,26 @@ "title": "额外录音缓冲", "description": "放开按键后继续录音的额外时间(毫秒),以捕捉尾音。0 = 无额外缓冲。" }, + "keyboardDiagnostic": { + "title": "键盘诊断", + "description": "检查键盘事件是否能到达 Handy。仅记录事件数量,绝不记录您按了哪些键。", + "run": "运行 10 秒诊断", + "running": "正在监听… 请按几次您的快捷键(例如 Option+Space)", + "secureInputLabel": "Secure Input", + "enabled": "已启用", + "disabled": "已禁用", + "holder": "被 {{name}}(pid {{pid}})占用", + "holderUnknown": "未发现占用方", + "keyDown": "按键按下", + "keyUp": "按键释放", + "flagsChanged": "修饰键", + "mouse": "鼠标", + "verdictBlocked": "Secure Input 正在阻止按键事件——在问题解决前,包含普通按键的快捷键无法工作。", + "verdictSuspicious": "收到了修饰键事件,但没有按键事件——尽管 Secure Input 报告为已禁用,仍有程序在抑制按键。请在 GitHub 上反馈此问题。", + "verdictOk": "按键事件正常到达 Handy。", + "verdictNoEvents": "未捕获到任何事件——测试期间您按过键吗?", + "failed": "诊断失败:{{error}}" + }, "whatsNewPreview": { "title": "预览新功能", "description": "打开最新内置发行说明,且不标记为已查看", @@ -633,5 +654,16 @@ }, "whatsNew": { "title": "Handy v{{version}} 新功能" + }, + "secureInput": { + "blockedWithCulprit_one": "{{name}} 可能正在阻止 1 个快捷键", + "blockedWithCulprit_other": "{{name}} 可能正在阻止 {{count}} 个快捷键", + "blockedNoCulprit_one": "macOS 正在暂时阻止 1 个快捷键", + "blockedNoCulprit_other": "macOS 正在暂时阻止 {{count}} 个快捷键", + "recorderBlockedWithCulprit": "{{name}} 可能正在阻止快捷键更改", + "recorderBlockedNoCulprit": "macOS 正在暂时阻止快捷键更改", + "learnMore": "如何解决", + "recorderBlocked": "当前无法录制快捷键——macOS 的 Secure Input 正在阻止按键事件。请先解决 Secure Input 警告。", + "dismiss": "关闭" } } diff --git a/src/styles/theme.css b/src/styles/theme.css index 2494b5b024..d562c2817f 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -14,6 +14,14 @@ --dark-color-logo-primary: #f28cbb; --dark-color-logo-stroke: #fad1ed; + /* Semantic status colors. First used by SecureInputWarning; intended to + replace the ad-hoc yellow-500/red-500 usages (e.g. ui/Alert.tsx) over + time so status styling is themeable from one place. */ + --light-color-warning: #d97706; + --light-color-error: #dc2626; + --dark-color-warning: #fbbf24; + --dark-color-error: #f87171; + /* Tokens that do not change between light and dark */ --color-background-ui: #da5893; --color-text-stroke: #f6f6f6; @@ -25,6 +33,8 @@ --color-background: var(--light-color-background); --color-logo-primary: var(--light-color-logo-primary); --color-logo-stroke: var(--light-color-logo-stroke); + --color-warning: var(--light-color-warning); + --color-error: var(--light-color-error); } @media (prefers-color-scheme: dark) { @@ -33,5 +43,7 @@ --color-background: var(--dark-color-background); --color-logo-primary: var(--dark-color-logo-primary); --color-logo-stroke: var(--dark-color-logo-stroke); + --color-warning: var(--dark-color-warning); + --color-error: var(--dark-color-error); } } From 16e5d48e01fc2e9fbaf6c4764f09517bf091f132 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 30 Jul 2026 16:24:24 +0800 Subject: [PATCH 14/49] dont fire bindings when recording (#1811) * dont fire bindings when recording * regen bindings + format --- src-tauri/src/lib.rs | 4 +- src-tauri/src/shortcut/handy_keys.rs | 19 ++++- src-tauri/src/shortcut/mod.rs | 77 ++++++++++++++----- src/bindings.ts | 14 ++-- .../settings/GlobalShortcutInput.tsx | 12 ++- 5 files changed, 92 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0faef85ecf..3dec643a9e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -643,8 +643,8 @@ pub fn run(cli_args: CliArgs) { shortcut::delete_post_process_prompt, shortcut::set_post_process_selected_prompt, shortcut::update_custom_words, - shortcut::suspend_binding, - shortcut::resume_binding, + shortcut::suspend_all_bindings, + shortcut::resume_all_bindings, shortcut::change_mute_while_recording_setting, shortcut::change_append_trailing_space_setting, shortcut::change_lazy_stream_close_setting, diff --git a/src-tauri/src/shortcut/handy_keys.rs b/src-tauri/src/shortcut/handy_keys.rs index 4c1d0c1ed7..bd8e562e50 100644 --- a/src-tauri/src/shortcut/handy_keys.rs +++ b/src-tauri/src/shortcut/handy_keys.rs @@ -540,7 +540,16 @@ pub fn start_handy_keys_recording(app: AppHandle, binding_id: String) -> Result< let state = app .try_state::() .ok_or("HandyKeysState not initialized")?; - state.start_recording(&app, binding_id) + + // Suspend every registered shortcut so a combo that overlaps an existing + // binding can't fire it (or have its keys swallowed) mid-capture. + super::suspend_all_shortcuts(&app); + + let result = state.start_recording(&app, binding_id); + if result.is_err() { + super::resume_all_shortcuts(&app); + } + result } /// Stop key recording mode @@ -555,5 +564,11 @@ pub fn stop_handy_keys_recording(app: AppHandle) -> Result<(), String> { let state = app .try_state::() .ok_or("HandyKeysState not initialized")?; - state.stop_recording() + + // Restore shortcuts from settings regardless of how recording ended. + // A commit has already registered the new binding via change_binding; + // re-registering it here fails cleanly and is ignored. + let result = state.stop_recording(); + super::resume_all_shortcuts(&app); + result } diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 20817a2275..8fb2bb40d7 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -13,7 +13,7 @@ mod handler; pub mod handy_keys; pub mod tauri_impl; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; use serde::Serialize; use specta::Type; use tauri::{AppHandle, Emitter, Manager}; @@ -176,17 +176,19 @@ pub fn change_binding( if let Err(e) = validate_shortcut_for_implementation(&binding, settings.keyboard_implementation) { warn!("change_binding validation error: {}", e); + restore_registration(&app, &binding_to_modify); return Err(e); } // Create an updated binding - let mut updated_binding = binding_to_modify; + let mut updated_binding = binding_to_modify.clone(); updated_binding.current_binding = binding; // Register the new binding if let Err(e) = register_shortcut(&app, updated_binding.clone()) { let error_msg = format!("Failed to register shortcut: {}", e); error!("change_binding error: {}", error_msg); + restore_registration(&app, &binding_to_modify); return Ok(BindingResponse { success: false, binding: None, @@ -209,6 +211,17 @@ pub fn change_binding( }) } +/// Best-effort re-register of the previous binding after a failed change, +/// so a failure leaves the user's shortcut working exactly as before. +fn restore_registration(app: &AppHandle, binding: &ShortcutBinding) { + if let Err(e) = register_shortcut(app, binding.clone()) { + error!( + "Failed to restore previous binding '{}' ({}): {}", + binding.id, binding.current_binding, e + ); + } +} + #[tauri::command] #[specta::specta] pub fn reset_binding(app: AppHandle, id: String) -> Result { @@ -216,30 +229,56 @@ pub fn reset_binding(app: AppHandle, id: String) -> Result Result<(), String> { - if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { - if let Err(e) = unregister_shortcut(&app, b) { - error!("suspend_binding error for id '{}': {}", id, e); - return Err(e); +/// Unregister every binding while the user is recording a new shortcut in +/// the UI, so no existing shortcut can fire — or swallow the keystrokes — +/// mid-capture. The "cancel" binding is untouched: it is managed dynamically +/// by the recording lifecycle. +pub fn suspend_all_shortcuts(app: &AppHandle) { + for (id, binding) in settings::get_bindings(app) { + if id == "cancel" { + continue; + } + if let Err(e) = unregister_shortcut(app, binding) { + debug!( + "suspend_all_shortcuts: could not unregister '{}': {}", + id, e + ); + } + } +} + +/// Re-register every binding from settings after shortcut recording ends. +/// Registering an already-registered shortcut fails cleanly in both +/// implementations, so this is idempotent and safe on every exit path. +pub fn resume_all_shortcuts(app: &AppHandle) { + let settings = get_settings(app); + for (id, binding) in &settings.bindings { + if id == "cancel" { + continue; + } + if id == "transcribe_with_post_process" && !settings.post_process_enabled { + continue; + } + if let Err(e) = register_shortcut(app, binding.clone()) { + debug!("resume_all_shortcuts: could not register '{}': {}", id, e); } } +} + +/// Temporarily unregister all bindings while the user is recording a +/// shortcut in the UI. This avoids firing actions while keys are recorded. +#[tauri::command] +#[specta::specta] +pub fn suspend_all_bindings(app: AppHandle) -> Result<(), String> { + suspend_all_shortcuts(&app); Ok(()) } -/// Re-register the binding after the user has finished editing. +/// Re-register all bindings after the user has finished recording. #[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) { - error!("resume_binding error for id '{}': {}", id, e); - return Err(e); - } - } +pub fn resume_all_bindings(app: AppHandle) -> Result<(), String> { + resume_all_shortcuts(&app); Ok(()) } diff --git a/src/bindings.ts b/src/bindings.ts index 3447eea7b8..e7ef242efa 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -297,23 +297,23 @@ async updateCustomWords(words: string[]) : Promise> { } }, /** - * Temporarily unregister a binding while the user is editing it in the UI. - * This avoids firing the action while keys are being recorded. + * Temporarily unregister all bindings while the user is recording a + * shortcut in the UI. This avoids firing actions while keys are recorded. */ -async suspendBinding(id: string) : Promise> { +async suspendAllBindings() : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("suspend_binding", { id }) }; + return { status: "ok", data: await TAURI_INVOKE("suspend_all_bindings") }; } 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. + * Re-register all bindings after the user has finished recording. */ -async resumeBinding(id: string) : Promise> { +async resumeAllBindings() : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("resume_binding", { id }) }; + return { status: "ok", data: await TAURI_INVOKE("resume_all_bindings") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; diff --git a/src/components/settings/GlobalShortcutInput.tsx b/src/components/settings/GlobalShortcutInput.tsx index 537a782a55..71997fc219 100644 --- a/src/components/settings/GlobalShortcutInput.tsx +++ b/src/components/settings/GlobalShortcutInput.tsx @@ -124,6 +124,10 @@ export const GlobalShortcutInput: React.FC = ({ } } + // Re-register all bindings (the one just committed is already + // registered; re-registering it fails cleanly and is ignored) + await commands.resumeAllBindings().catch(console.error); + // Exit editing mode and reset states setEditingShortcutId(null); setKeyPressed([]); @@ -146,9 +150,8 @@ export const GlobalShortcutInput: React.FC = ({ console.error("Failed to restore original binding:", error); toast.error(t("settings.general.shortcut.errors.restore")); } - } else if (editingShortcutId) { - commands.resumeBinding(editingShortcutId).catch(console.error); } + await commands.resumeAllBindings().catch(console.error); setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); @@ -180,8 +183,9 @@ export const GlobalShortcutInput: React.FC = ({ const startRecording = async (id: string) => { if (editingShortcutId === id) return; // Already editing this shortcut - // Suspend current binding to avoid firing while recording - await commands.suspendBinding(id).catch(console.error); + // Suspend all bindings so no shortcut fires (or swallows the + // keystrokes) while keys are being recorded + await commands.suspendAllBindings().catch(console.error); // Store the original binding to restore if canceled setOriginalBinding(bindings[id]?.current_binding || ""); From 9aae694d395da2a5a4db039dc0a6182b4f962593 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 30 Jul 2026 17:45:29 +0800 Subject: [PATCH 15/49] update translations --- src/i18n/locales/ar/translation.json | 26 +++++++++++----------- src/i18n/locales/bg/translation.json | 24 ++++++++++----------- src/i18n/locales/cs/translation.json | 26 +++++++++++----------- src/i18n/locales/da/translation.json | 4 ++-- src/i18n/locales/de/translation.json | 4 ++-- src/i18n/locales/es/translation.json | 12 +++++------ src/i18n/locales/fr/translation.json | 10 ++++----- src/i18n/locales/he/translation.json | 24 ++++++++++----------- src/i18n/locales/it/translation.json | 12 +++++------ src/i18n/locales/ko/translation.json | 24 ++++++++++----------- src/i18n/locales/pl/translation.json | 30 +++++++++++++------------- src/i18n/locales/pt/translation.json | 28 ++++++++++++------------ src/i18n/locales/ru/translation.json | 32 ++++++++++++++-------------- src/i18n/locales/sv/translation.json | 22 +++++++++---------- src/i18n/locales/tr/translation.json | 26 +++++++++++----------- src/i18n/locales/uk/translation.json | 24 ++++++++++----------- src/i18n/locales/vi/translation.json | 32 ++++++++++++++-------------- src/i18n/locales/zh/translation.json | 32 ++++++++++++++-------------- 18 files changed, 196 insertions(+), 196 deletions(-) diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 8c26b0ff4d..955971120b 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "للبدء، اختر نموذج التفريغ الصوتي", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "النماذج المتوافقة", + "downloadModelsTitle": "متاح للتنزيل", + "showAllModels": "عرض كل النماذج ({{total}})", + "showFewerModels": "عرض عدد أقل من النماذج", "recommended": "موصى به", "customModelDescription": "غير مدعوم رسميًا", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "فشل تحديد النموذج" }, "permissions": { "title": "الأذونات المطلوبة", @@ -146,7 +146,7 @@ "capabilities": { "languageSelection": "يدعم اختيار اللغة", "singleLanguage": "يدعم هذه اللغة فقط", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} لغة", "languageOnly": "{{language}} فقط", "translation": "يدعم الترجمة", "translate": "ترجمة", @@ -262,12 +262,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "التراكب", + "description": "اختر تراكب التسجيل: «بلا» يخفيه، و«مبسط» يعرض شارة صغيرة، و«مباشر» يعرض النص المكتوب فوريًا أثناء التحدث (النماذج التي تدعم البث فقط — ابحث عن شارة «البث» في منتقي النماذج). على Linux يُنصح باختيار «بلا».", "options": { "none": "بلا", - "minimal": "Minimal", - "live": "Live" + "minimal": "مبسط", + "live": "مباشر" } }, "position": { @@ -345,8 +345,8 @@ "duplicate": "\"{{word}}\" موجود بالفعل" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "كشف النشاط الصوتي", + "description": "تصفية الصمت من التسجيلات. النماذج التي تدعم البث تستخدم فترة VAD أطول؛ وتعطيل VAD يسجّل الصوت الخام." } }, "postProcessing": { @@ -570,7 +570,7 @@ } }, "modelSettings": { - "title": "إعدادات النموذج" + "title": "إعدادات {{model}}" }, "models": { "title": "نماذج النسخ", diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index d18d5a5b3a..5b73ae8131 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "За да започнете, изберете модел за транскрипция", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Съвместими модели", + "downloadModelsTitle": "Достъпни за изтегляне", + "showAllModels": "Показване на всички {{total}} модела", + "showFewerModels": "Показване на по-малко модели", "recommended": "Препоръчан", "customModelDescription": "Не се поддържа официално", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Неуспешно избиране на модел" }, "permissions": { "title": "Необходими разрешения", @@ -146,7 +146,7 @@ "capabilities": { "languageSelection": "Поддържа няколко входни езика", "singleLanguage": "Поддържа само този език", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} езика", "languageOnly": "Само {{language}}", "translation": "Може да превежда на английски", "translate": "Превод на английски", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Наслагване", + "description": "Изберете наслагването при запис: «Няма» го скрива, «Минимално» показва компактен индикатор, «На живо» показва транскрипцията в реално време, докато говорите (само модели с поддръжка на стрийминг — потърсете етикета «Стрийминг» в избора на модел). На Linux се препоръчва «Няма».", "options": { "none": "Няма", - "minimal": "Minimal", - "live": "Live" + "minimal": "Минимално", + "live": "На живо" } }, "position": { @@ -366,8 +366,8 @@ "duplicate": "„{{word}}“ вече съществува" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Откриване на гласова активност", + "description": "Филтрира тишината от записите. Моделите с поддръжка на стрийминг използват по-дълга опашка на VAD; изключването на VAD записва необработен звук." } }, "postProcessing": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index b98738680b..0b7771fa80 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "Pro začátek vyberte model pro přepis", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Kompatibilní modely", + "downloadModelsTitle": "Dostupné ke stažení", + "showAllModels": "Zobrazit všech {{total}} modelů", + "showFewerModels": "Zobrazit méně modelů", "recommended": "Doporučeno", "customModelDescription": "Oficiálně nepodporováno", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Nepodařilo se vybrat model" }, "permissions": { "title": "Vyžadována oprávnění", @@ -143,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Podporuje více vstupních jazyků", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} jazyků", "translation": "Umí překládat do angličtiny", "translate": "Přeložit do angličtiny", "singleLanguage": "Podporuje pouze tento jazyk", @@ -210,7 +210,7 @@ "description": "Vyberte jazyk rozpoznávání řeči. Auto jazyk určí automaticky, zatímco výběr konkrétního jazyka může zlepšit přesnost.", "searchPlaceholder": "Hledat jazyky...", "noResults": "Žádné jazyky nenalezeny", - "auto": "Auto" + "auto": "Automaticky" }, "pushToTalk": { "label": "Stisk a mluv", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Překryv", + "description": "Vyberte překryv při nahrávání: „Žádné“ jej skryje, „Minimální“ zobrazí kompaktní indikátor, „Živě“ zobrazuje přepis v reálném čase, když mluvíte (pouze modely podporující streaming — hledejte odznak „Streaming“ ve výběru modelů). Na Linuxu se doporučuje „Žádné“.", "options": { "none": "Žádné", - "minimal": "Minimal", - "live": "Live" + "minimal": "Minimální", + "live": "Živě" } }, "position": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" již existuje" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Detekce hlasové aktivity", + "description": "Odfiltruje ticho z nahrávek. Modely podporující streaming používají delší doběh VAD; vypnutí VAD nahrává surový zvuk." } }, "postProcessing": { diff --git a/src/i18n/locales/da/translation.json b/src/i18n/locales/da/translation.json index 3b8da3fbee..0f62ca4c49 100644 --- a/src/i18n/locales/da/translation.json +++ b/src/i18n/locales/da/translation.json @@ -192,7 +192,7 @@ "description": "Vælg sproget til talegenkendelse. Auto tyder automatisk sproget, mens valg af et specifikt sprog kan forbedre nøjagtigheden for det pågældende sprog.", "searchPlaceholder": "Søg efter sprog...", "noResults": "Ingen sprog fundet", - "auto": "Auto" + "auto": "Automatisk" }, "pushToTalk": { "label": "Tryk-for-at-tale", @@ -266,7 +266,7 @@ "description": "Hardwareacceleration til ONNX-modeller (Parakeet, Canary, Moonshine osv.). DirectML på Windows er eksperimentelt. Modeller kan fejle under transskription." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatisk" } }, "startHidden": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 5e1fcc83e3..3b5c59fb98 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -210,7 +210,7 @@ "description": "Wähle die Sprache für die Spracherkennung. Auto erkennt die Sprache automatisch, die Auswahl einer bestimmten Sprache kann die Genauigkeit verbessern.", "searchPlaceholder": "Sprachen suchen...", "noResults": "Keine Sprachen gefunden", - "auto": "Auto" + "auto": "Automatisch" }, "pushToTalk": { "label": "Push-to-Talk", @@ -266,7 +266,7 @@ "description": "Hardwarebeschleunigung für ONNX-Modelle (Parakeet, Canary, Moonshine usw.). DirectML unter Windows ist experimentell. Modelle können bei der Transkription fehlschlagen." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatisch" } }, "startHidden": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index a14f33fc89..58f215b6f6 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -210,7 +210,7 @@ "description": "Selecciona el idioma para el reconocimiento de voz. Auto detectará automáticamente el idioma, mientras que seleccionar un idioma específico puede mejorar la precisión para ese idioma.", "searchPlaceholder": "Buscar idiomas...", "noResults": "No se encontraron idiomas", - "auto": "Auto" + "auto": "Automático" }, "pushToTalk": { "label": "Presionar para hablar", @@ -266,7 +266,7 @@ "description": "Aceleración por hardware para modelos ONNX (Parakeet, Canary, Moonshine, etc.). DirectML en Windows es experimental. Los modelos pueden fallar al transcribir." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automático" } }, "startHidden": { @@ -333,10 +333,10 @@ "description": "Envía automáticamente la combinación de teclas seleccionada después de insertar el texto. Cmd+Enter se aplica en macOS, mientras que Windows/Linux usan Super+Enter.", "options": { "off": "Desactivado", - "enter": "Enter", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "enter": "Intro", + "cmdEnter": "Cmd+Intro", + "superEnter": "Super+Intro", + "ctrlEnter": "Ctrl+Intro" } }, "translateToEnglish": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 842b076c33..55aa46722e 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -210,7 +210,7 @@ "description": "Sélectionnez la langue pour la reconnaissance vocale. Auto déterminera automatiquement la langue, tandis que sélectionner une langue spécifique peut améliorer la précision pour cette langue.", "searchPlaceholder": "Rechercher des langues...", "noResults": "Aucune langue trouvée", - "auto": "Auto" + "auto": "Automatique" }, "pushToTalk": { "label": "Appuyer pour parler", @@ -266,7 +266,7 @@ "description": "Accélération matérielle pour les modèles ONNX (Parakeet, Canary, Moonshine, etc.). DirectML sur Windows est expérimental. Les modèles peuvent échouer à transcrire." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatique" } }, "startHidden": { @@ -334,9 +334,9 @@ "options": { "off": "Désactivé", "enter": "Entrée", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "cmdEnter": "Cmd+Entrée", + "superEnter": "Super+Entrée", + "ctrlEnter": "Ctrl+Entrée" } }, "translateToEnglish": { diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 2d2e368cf7..335b4e3786 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "כדי להתחיל, בחר מודל תמלול", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "מודלים תואמים", + "downloadModelsTitle": "זמינים להורדה", + "showAllModels": "הצג את כל {{total}} המודלים", + "showFewerModels": "הצג פחות מודלים", "recommended": "מומלץ", "customModelDescription": "לא נתמך באופן רשמי", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "בחירת המודל נכשלה" }, "permissions": { "title": "נדרשות הרשאות", @@ -146,7 +146,7 @@ "capabilities": { "languageSelection": "תומך בכמה שפות קלט", "singleLanguage": "תומך רק בשפה הזו", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} שפות", "languageOnly": "{{language}} בלבד", "translation": "יכול לתרגם לאנגלית", "translate": "תרגם לאנגלית", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "שכבת-על", + "description": "בחר את שכבת-העל בהקלטה: 'ללא' מסתירה אותה, 'מינימלי' מציג סמן קומפקטי, 'בזמן אמת' מציג תמלול בזמן אמת בזמן הדיבור (מודלים התומכים בסטרימינג בלבד — חפש את התווית 'סטרימינג' בבורר המודלים). ב-Linux מומלץ לבחור 'ללא'.", "options": { "none": "ללא", - "minimal": "Minimal", - "live": "Live" + "minimal": "מינימלי", + "live": "בזמן אמת" } }, "position": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" כבר קיימת" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "זיהוי פעילות קולית", + "description": "סינון שקט מההקלטות. מודלים התומכים בסטרימינג משתמשים בזנב VAD ארוך יותר; ביטול VAD מקליט אודיו גולמי." } }, "postProcessing": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index d614d721e1..ba26136835 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -210,7 +210,7 @@ "description": "Scegli la lingua per il riconoscimento vocale. Auto la determinerà automaticamente, mentre scegliere una lingua specifica può migliorare l'accuratezza per quella lingua.", "searchPlaceholder": "Cerca lingue...", "noResults": "Nessuna lingua trovata", - "auto": "Auto" + "auto": "Automatico" }, "pushToTalk": { "label": "Premi per Parlare", @@ -266,7 +266,7 @@ "description": "Accelerazione hardware per i modelli ONNX (Parakeet, Canary, Moonshine, ecc.). DirectML su Windows è sperimentale. I modelli potrebbero non riuscire a trascrivere." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatico" } }, "startHidden": { @@ -333,10 +333,10 @@ "description": "Invia automaticamente la combinazione di tasti selezionata dopo l'inserimento del testo. Cmd+Enter si applica su macOS, mentre Windows/Linux usano Super+Enter.", "options": { "off": "Disattivato", - "enter": "Enter", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "enter": "Invio", + "cmdEnter": "Cmd+Invio", + "superEnter": "Super+Invio", + "ctrlEnter": "Ctrl+Invio" } }, "translateToEnglish": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 4ab6f759df..a41fa08841 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "시작하려면 음성 인식 모델을 선택하세요", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "호환 가능한 모델", + "downloadModelsTitle": "다운로드 가능", + "showAllModels": "전체 {{total}}개 모델 표시", + "showFewerModels": "간략히 표시", "recommended": "추천", "customModelDescription": "공식 지원되지 않음", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "모델을 선택하지 못했습니다" }, "permissions": { "title": "권한 필요", @@ -146,7 +146,7 @@ "capabilities": { "languageSelection": "여러 입력 언어를 지원합니다", "singleLanguage": "이 언어만 지원합니다", - "languageCount": "{{total}} languages", + "languageCount": "{{total}}개 언어", "languageOnly": "{{language}} 전용", "translation": "영어로 번역 가능", "translate": "영어로 번역", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "오버레이", + "description": "녹음 오버레이를 선택하세요. '없음'은 오버레이를 숨기고, '간단'은 작은 알약 모양 표시를 보여주며, '실시간'은 말하는 동안 전사 결과를 실시간으로 표시합니다(스트리밍 지원 모델만 해당 — 모델 선택기에서 '스트리밍' 배지를 확인하세요). Linux에서는 '없음'을 권장합니다.", "options": { "none": "없음", - "minimal": "Minimal", - "live": "Live" + "minimal": "간단", + "live": "실시간" } }, "position": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\"이(가) 이미 존재합니다" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "음성 활동 감지", + "description": "녹음에서 무음 구간을 걸러냅니다. 스트리밍 지원 모델은 더 긴 VAD 테일을 사용하며, VAD를 끄면 원본 오디오가 그대로 녹음됩니다." } }, "postProcessing": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index c1f88fd730..a3e0f9e680 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "Aby rozpocząć, wybierz model transkrypcji", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Zgodne modele", + "downloadModelsTitle": "Dostępne do pobrania", + "showAllModels": "Pokaż wszystkie modele ({{total}})", + "showFewerModels": "Pokaż mniej modeli", "recommended": "Polecane", "customModelDescription": "Nieoficjalnie wspierane", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Nie udało się wybrać modelu" }, "permissions": { "title": "Wymagane uprawnienia", @@ -143,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Obsługuje wiele języków wejściowych", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} języków", "translation": "Może tłumaczyć na angielski", "translate": "Tłumacz na angielski", "singleLanguage": "Obsługuje tylko ten język", @@ -210,10 +210,10 @@ "description": "Wybierz język rozpoznawania mowy. Opcja Auto automatycznie określi język, a wybór konkretnego języka może poprawić dokładność.", "searchPlaceholder": "Szukaj języka...", "noResults": "Nie znaleziono języków", - "auto": "Auto" + "auto": "Automatycznie" }, "pushToTalk": { - "label": "Push To Talk", + "label": "Naciśnij i mów", "description": "Przytrzymaj, aby nagrywać, puść, aby zatrzymać" } }, @@ -266,7 +266,7 @@ "description": "Akceleracja sprzętowa dla modeli ONNX (Parakeet, Canary, Moonshine itp.). DirectML na Windows jest eksperymentalne. Modele mogą nie transkrybować poprawnie." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatycznie" } }, "startHidden": { @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Nakładka", + "description": "Wybierz nakładkę nagrywania: „Brak“ ją ukrywa, „Minimalna“ pokazuje kompaktowy wskaźnik, „Na żywo“ wyświetla transkrypcję w czasie rzeczywistym podczas mówienia (tylko modele obsługujące streaming — poszukaj etykiety „Streaming“ w selektorze modeli). W systemie Linux zalecana jest opcja „Brak“.", "options": { "none": "Brak", - "minimal": "Minimal", - "live": "Live" + "minimal": "Minimalna", + "live": "Na żywo" } }, "position": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" już istnieje" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Wykrywanie aktywności głosowej", + "description": "Odfiltrowuje ciszę z nagrań. Modele obsługujące streaming używają dłuższego marginesu VAD; wyłączenie VAD nagrywa surowy dźwięk." } }, "postProcessing": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 989a3930f4..1f2aca38bb 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "Para começar, escolha um modelo de transcrição", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Modelos compatíveis", + "downloadModelsTitle": "Disponíveis para download", + "showAllModels": "Mostrar todos os {{total}} modelos", + "showFewerModels": "Mostrar menos modelos", "recommended": "Recomendado", "customModelDescription": "Não suportado oficialmente", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Falha ao selecionar o modelo" }, "permissions": { "title": "Permissões Necessárias", @@ -143,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Suporta vários idiomas de entrada", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} idiomas", "translation": "Pode traduzir para inglês", "translate": "Traduzir para inglês", "singleLanguage": "Suporta apenas este idioma", @@ -192,7 +192,7 @@ "description": "Selecione o idioma para reconhecimento de fala. Auto detectará automaticamente o idioma, enquanto selecionar um idioma específico pode melhorar a precisão para esse idioma.", "searchPlaceholder": "Buscar idiomas...", "noResults": "Nenhum idioma encontrado", - "auto": "Auto" + "auto": "Automático" }, "pushToTalk": { "label": "Pressionar para Falar", @@ -266,7 +266,7 @@ "description": "Aceleração por hardware para modelos ONNX (Parakeet, Canary, Moonshine, etc.). DirectML no Windows é experimental. Os modelos podem falhar na transcrição." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automático" } }, "startHidden": { @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Sobreposição", + "description": "Escolha a sobreposição de gravação: 'Nenhum' a oculta, 'Minimalista' mostra um indicador compacto, 'Ao vivo' mostra a transcrição em tempo real enquanto você fala (apenas modelos com suporte a streaming — procure o selo 'Streaming' no seletor de modelos). No Linux, recomenda-se 'Nenhum'.", "options": { "none": "Nenhum", - "minimal": "Minimal", - "live": "Live" + "minimal": "Minimalista", + "live": "Ao vivo" } }, "position": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" já existe" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Detecção de atividade de voz", + "description": "Filtra o silêncio das gravações. Modelos com suporte a streaming usam uma cauda de VAD mais longa; desativar o VAD grava o áudio bruto." } }, "postProcessing": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index c2debd274c..e5f5a3e918 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "Для начала выберите модель транскрипции", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Совместимые модели", + "downloadModelsTitle": "Доступны для загрузки", + "showAllModels": "Показать все модели ({{total}})", + "showFewerModels": "Показать меньше моделей", "recommended": "Рекомендуется", "customModelDescription": "Официально не поддерживается", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Не удалось выбрать модель" }, "permissions": { "title": "Требуются разрешения", @@ -143,7 +143,7 @@ "downloadSpeed": "{{speed}} МБ/с", "capabilities": { "languageSelection": "Поддерживает несколько языков ввода", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} языков", "translation": "Может переводить на английский", "translate": "Перевод на английский", "singleLanguage": "Поддерживает только этот язык", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Наложение", + "description": "Выберите наложение при записи: «Нет» скрывает его, «Минимальное» показывает компактный индикатор, «В реальном времени» показывает транскрипцию по мере речи (только модели с поддержкой потоковой обработки — ищите значок «Потоковый» в списке моделей). В Linux рекомендуется «Нет».", "options": { "none": "Нет", - "minimal": "Minimal", - "live": "Live" + "minimal": "Минимальное", + "live": "В реальном времени" } }, "position": { @@ -333,10 +333,10 @@ "description": "Автоматически отправляет выбранную комбинацию клавиш после вставки текста. Cmd+Enter применяется на macOS, а Windows/Linux используют Super+Enter.", "options": { "off": "Выкл.", - "enter": "Enter", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "enter": "Ввод", + "cmdEnter": "Cmd+Ввод", + "superEnter": "Super+Ввод", + "ctrlEnter": "Ctrl+Ввод" } }, "translateToEnglish": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" уже существует" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Определение голосовой активности", + "description": "Отфильтровывает тишину из записей. Модели с поддержкой потоковой обработки используют более длинный «хвост» VAD; отключение VAD записывает необработанный звук." } }, "postProcessing": { diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index e76be7489e..0774ef2c56 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "För att komma igång, välj en transkriptionsmodell", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Kompatibla modeller", + "downloadModelsTitle": "Tillgängliga för nedladdning", + "showAllModels": "Visa alla {{total}} modeller", + "showFewerModels": "Visa färre modeller", "recommended": "Rekommenderat", "customModelDescription": "Stöds inte officiellt", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Det gick inte att välja modell" }, "permissions": { "title": "Behörigheter krävs", @@ -146,7 +146,7 @@ "capabilities": { "languageSelection": "Stöder flera indataspråk", "singleLanguage": "Stöder endast detta språk", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} språk", "languageOnly": "Endast {{language}}", "translation": "Kan översätta till engelska", "translate": "Översätt till engelska", @@ -192,7 +192,7 @@ "description": "Välj språk för taligenkänning. Auto kommer automatiskt att bestämma språket, medan val av ett specifikt språk kan förbättra noggrannheten för det språket.", "searchPlaceholder": "Sök språk...", "noResults": "Inga språk hittades", - "auto": "Auto" + "auto": "Automatiskt" }, "pushToTalk": { "label": "Tryck för att tala", @@ -266,7 +266,7 @@ "description": "Hårdvaruacceleration för ONNX-modeller (Parakeet, Canary, Moonshine, etc.). DirectML på Windows är experimentellt. Modeller kan misslyckas med att transkribera." }, "gpuDevice": { - "auto": "Auto" + "auto": "Automatiskt" } }, "startHidden": { @@ -284,7 +284,7 @@ "overlay": { "style": { "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "description": "Välj inspelningsöverlägget: ”Ingen” döljer det, ”Minimal” visar en kompakt indikator, ”Live” visar transkriberingen i realtid medan du talar (endast modeller med streamingstöd — leta efter Streaming-märket i modellväljaren). På Linux rekommenderas ”Ingen”.", "options": { "none": "Ingen", "minimal": "Minimal", @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" finns redan" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Röstaktivitetsdetektering", + "description": "Filtrerar bort tystnad från inspelningar. Modeller med streamingstöd använder en längre VAD-svans; om VAD inaktiveras spelas rått ljud in." } }, "postProcessing": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 369690b826..cbf3eae680 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "Başlamak için bir transkripsiyon modeli seçin", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Uyumlu Modeller", + "downloadModelsTitle": "İndirilebilir", + "showAllModels": "{{total}} modelin tümünü göster", + "showFewerModels": "Daha az model göster", "recommended": "Önerilen", "customModelDescription": "Resmi olarak desteklenmiyor", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Model seçilemedi" }, "permissions": { "title": "İzinler Gerekli", @@ -143,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Birden fazla giriş dilini destekler", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} dil", "translation": "İngilizce'ye çevirebilir", "translate": "İngilizce'ye çevir", "singleLanguage": "Yalnızca bu dili destekler", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Yer Paylaşımı", + "description": "Kayıt sırasındaki yer paylaşımını seçin: 'Yok' gizler, 'Sade' küçük bir gösterge gösterir, 'Canlı' siz konuşurken transkripsiyonu gerçek zamanlı gösterir (yalnızca akış destekli modeller — model seçicide 'Akış' rozetini arayın). Linux'ta 'Yok' önerilir.", "options": { "none": "Yok", - "minimal": "Minimal", - "live": "Live" + "minimal": "Sade", + "live": "Canlı" } }, "position": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" zaten mevcut" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Ses Etkinliği Algılama", + "description": "Kayıtlardaki sessizliği filtreler. Akış destekli modeller daha uzun bir VAD kuyruğu kullanır; VAD devre dışı bırakıldığında ham ses kaydedilir." } }, "postProcessing": { @@ -403,7 +403,7 @@ } }, "prompts": { - "title": "Prompt", + "title": "İstem", "selectedPrompt": { "title": "Seçili Prompt", "description": "Transkripsiyonları iyileştirmek için bir şablon seçin veya yeni bir tane oluşturun. Yakalanan transkripte referans vermek için prompt metni içinde ${output} kullanın." diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index b0a06c7faf..e33e73f122 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "Для початку оберіть модель транскрипції", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Сумісні моделі", + "downloadModelsTitle": "Доступні для завантаження", + "showAllModels": "Показати всі моделі ({{total}})", + "showFewerModels": "Показати менше моделей", "recommended": "Рекомендовано", "customModelDescription": "Офіційно не підтримується", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Не вдалося вибрати модель" }, "permissions": { "title": "Потрібні дозволи", @@ -143,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Підтримує кілька мов введення", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} мов", "translation": "Може перекладати на англійську", "translate": "Перекласти на англійську", "singleLanguage": "Підтримує лише цю мову", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Накладка", + "description": "Виберіть накладку під час запису: «Немає» приховує її, «Мінімальна» показує компактний індикатор, «У реальному часі» показує транскрипцію під час мовлення (лише моделі з підтримкою потокової обробки — шукайте позначку «Потокове» у списку моделей). У Linux рекомендовано «Немає».", "options": { "none": "Немає", - "minimal": "Minimal", - "live": "Live" + "minimal": "Мінімальна", + "live": "У реальному часі" } }, "position": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" вже існує" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Виявлення голосової активності", + "description": "Відфільтровує тишу із записів. Моделі з підтримкою потокової обробки використовують довший «хвіст» VAD; вимкнення VAD записує необроблений звук." } }, "postProcessing": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 045c81b711..a394366442 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "Để bắt đầu, hãy chọn một mô hình chuyển đổi giọng nói", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "Mô hình tương thích", + "downloadModelsTitle": "Có sẵn để tải xuống", + "showAllModels": "Hiển thị tất cả {{total}} mô hình", + "showFewerModels": "Hiển thị ít mô hình hơn", "recommended": "Đề xuất", "customModelDescription": "Không được hỗ trợ chính thức", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "Không thể chọn mô hình" }, "permissions": { "title": "Cần cấp quyền", @@ -143,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "Hỗ trợ nhiều ngôn ngữ đầu vào", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} ngôn ngữ", "translation": "Có thể dịch sang tiếng Anh", "translate": "Dịch sang tiếng Anh", "singleLanguage": "Chỉ hỗ trợ ngôn ngữ này", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "Lớp phủ", + "description": "Chọn lớp phủ khi ghi âm: 'Không có' sẽ ẩn nó, 'Tối giản' hiển thị một chỉ báo nhỏ gọn, 'Trực tiếp' hiển thị bản chép lời theo thời gian thực khi bạn nói (chỉ với các mô hình hỗ trợ truyền trực tiếp — hãy tìm huy hiệu 'Truyền trực tiếp' trong bộ chọn mô hình). Trên Linux, khuyến nghị chọn 'Không có'.", "options": { "none": "Không có", - "minimal": "Minimal", - "live": "Live" + "minimal": "Tối giản", + "live": "Trực tiếp" } }, "position": { @@ -304,9 +304,9 @@ "title": "Phương thức dán", "description": "Chọn cách chèn văn bản. Trực tiếp: mô phỏng gõ phím qua đầu vào hệ thống. Không có: bỏ qua dán, chỉ cập nhật lịch sử/clipboard.", "options": { - "clipboard": "Clipboard ({{modifier}}+V)", - "clipboardCtrlShiftV": "Clipboard (Ctrl+Shift+V)", - "clipboardShiftInsert": "Clipboard (Shift+Insert)", + "clipboard": "Bảng nhớ tạm ({{modifier}}+V)", + "clipboardCtrlShiftV": "Bảng nhớ tạm (Ctrl+Shift+V)", + "clipboardShiftInsert": "Bảng nhớ tạm (Shift+Insert)", "direct": "Trực tiếp", "none": "Không có", "externalScript": "Script bên ngoài" @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" đã tồn tại" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "Phát hiện hoạt động giọng nói", + "description": "Lọc bỏ khoảng lặng khỏi bản ghi. Các mô hình hỗ trợ truyền trực tiếp dùng đuôi VAD dài hơn; tắt VAD sẽ ghi âm thanh thô." } }, "postProcessing": { @@ -403,7 +403,7 @@ } }, "prompts": { - "title": "Prompt", + "title": "Câu lệnh", "selectedPrompt": { "title": "Prompt đã chọn", "description": "Chọn một mẫu để tinh chỉnh bản ghi hoặc tạo mới. Sử dụng ${output} trong văn bản prompt để tham chiếu bản ghi đã chụp." diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index b6a78e3cad..34976ba8c0 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -20,10 +20,10 @@ }, "onboarding": { "subtitle": "请选择一个转录模型以开始使用", - "existingModelsTitle": "Compatible Models", - "downloadModelsTitle": "Available to Download", - "showAllModels": "Show all {{total}} models", - "showFewerModels": "Show fewer models", + "existingModelsTitle": "兼容的模型", + "downloadModelsTitle": "可供下载", + "showAllModels": "显示全部 {{total}} 个模型", + "showFewerModels": "显示较少模型", "recommended": "推荐", "customModelDescription": "非官方支持", "modelCard": { @@ -97,7 +97,7 @@ } }, "errors": { - "selectModel": "Failed to select model" + "selectModel": "选择模型失败" }, "permissions": { "title": "需要权限", @@ -143,7 +143,7 @@ "downloadSpeed": "{{speed}} MB/s", "capabilities": { "languageSelection": "支持多种输入语言", - "languageCount": "{{total}} languages", + "languageCount": "{{total}} 种语言", "translation": "可翻译为英语", "translate": "翻译为英语", "singleLanguage": "仅支持此语言", @@ -283,12 +283,12 @@ }, "overlay": { "style": { - "title": "Overlay", - "description": "Choose the recording overlay: None hides it, Minimal shows a compact pill, Live shows transcription in real time as you speak (streaming-capable models only — look for the Streaming badge in the model picker). On Linux 'None' is recommended.", + "title": "悬浮窗", + "description": "选择录音悬浮窗的样式:「无」会隐藏悬浮窗,「精简」显示小巧的胶囊状指示,「实时」在您说话时实时显示转录内容(仅限支持流式的模型——请在模型选择器中查找「流式」标签)。在 Linux 上建议选择「无」。", "options": { "none": "无", - "minimal": "Minimal", - "live": "Live" + "minimal": "精简", + "live": "实时" } }, "position": { @@ -333,10 +333,10 @@ "description": "在文本插入后自动发送所选的按键组合。macOS 上使用 Cmd+Enter,Windows/Linux 上使用 Super+Enter。", "options": { "off": "关闭", - "enter": "Enter", - "cmdEnter": "Cmd+Enter", - "superEnter": "Super+Enter", - "ctrlEnter": "Ctrl+Enter" + "enter": "回车", + "cmdEnter": "Cmd+回车", + "superEnter": "Super+回车", + "ctrlEnter": "Ctrl+回车" } }, "translateToEnglish": { @@ -366,8 +366,8 @@ "duplicate": "\"{{word}}\" 已存在" }, "voiceActivityDetection": { - "title": "Voice Activity Detection", - "description": "Filter silence from recordings. Streaming-capable models use a longer VAD tail; disabling VAD records raw audio." + "title": "语音活动检测", + "description": "过滤录音中的静音。支持流式的模型会使用更长的 VAD 尾段;停用 VAD 则会录制原始音频。" } }, "postProcessing": { From cf49ab3543a3e4d1fdcc318014393975b4a3306a Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 31 Jul 2026 10:03:31 +0800 Subject: [PATCH 16/49] run overlay on main thread (#1810) --- src-tauri/src/overlay.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index d157ebe70f..3b9ca7fa27 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -460,12 +460,27 @@ pub fn create_recording_overlay(app_handle: &AppHandle) { fn show_overlay_state(app_handle: &AppHandle, state: &str) { // Whether the overlay shows at all is governed by overlay_style; position - // only chooses Top vs Bottom placement. + // only chooses Top vs Bottom placement. Checked here (off the main thread) + // so the common overlay-disabled case never pays for a main-thread hop. let settings = settings::get_settings(app_handle); if settings.overlay_style == OverlayStyle::None { return; } + // The rest queries monitors and the cursor and mutates window geometry. On + // Linux the monitor/cursor lookups hit GDK/Xlib on the process's shared X11 + // connection, which is only safe from the GTK main thread — running them on + // a background thread corrupts the connection and hard-crashes the app + // (issue #227). Hop to the main thread on every platform to keep the + // geometry path uniform (a no-op cost on Windows, and it also keeps macOS's + // NSScreen access main-thread-correct). run_on_main_thread runs the closure + // inline when already on the main thread, so this never deadlocks. + let handle = app_handle.clone(); + let state = state.to_string(); + let _ = app_handle.run_on_main_thread(move || show_overlay_state_on_main(&handle, &state)); +} + +fn show_overlay_state_on_main(app_handle: &AppHandle, state: &str) { // Size the overlay for this state (compact vs. streaming), then position it. let (width, height) = overlay_dimensions(state); if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { @@ -549,6 +564,13 @@ pub fn show_processing_overlay(app_handle: &AppHandle) { /// Updates the overlay window position based on current settings pub fn update_overlay_position(app_handle: &AppHandle) { + // Positioning queries monitors/cursor (GDK/Xlib on Linux) and moves the + // window, so it must run on the main thread — see show_overlay_state. + let handle = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || update_overlay_position_on_main(&handle)); +} + +fn update_overlay_position_on_main(app_handle: &AppHandle) { if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { #[cfg(target_os = "linux")] { From 148e5492f43dd0b4a80680ee92bebe6459a4ef3a Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 31 Jul 2026 14:55:40 +0800 Subject: [PATCH 17/49] retry without reasoning (#1809) --- src-tauri/src/actions.rs | 78 +++++++---- src-tauri/src/llm_client.rs | 257 ++++++++++++++++++++++++++++++++---- 2 files changed, 286 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 0a428b5cbf..247eda4224 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -62,6 +62,19 @@ fn strip_invisible_chars(s: &str) -> String { s.replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") } +/// Strip a leading `...` block. Some endpoints can't disable +/// reasoning, and some local servers put the reasoning text into `content` +/// instead of a separate field — without this the user would get the model's +/// chain of thought pasted along with the cleaned transcription. +fn strip_think_block(s: &str) -> &str { + if let Some(rest) = s.trim_start().strip_prefix("") { + if let Some(end) = rest.find("") { + return rest[end + "".len()..].trim_start(); + } + } + s +} + /// Build a system prompt from the user's prompt template. /// Removes `${output}` placeholder since the transcription is sent as the user message. fn build_system_prompt(prompt_template: &str) -> String { @@ -168,21 +181,10 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) .cloned() .unwrap_or_default(); - // Disable reasoning for providers where post-processing rarely benefits from it. - // - custom: top-level reasoning_effort (works for local OpenAI-compat servers) - // - openrouter: nested reasoning object; exclude:true also keeps reasoning text - // out of the response so it can't pollute structured-output JSON parsing - let (reasoning_effort, reasoning) = match provider.id.as_str() { - "custom" => (Some("none".to_string()), None), - "openrouter" => ( - None, - Some(crate::llm_client::ReasoningConfig { - effort: Some("none".to_string()), - exclude: Some(true), - }), - ), - _ => (None, None), - }; + // Ask these providers to skip reasoning/thinking — post-processing rarely + // benefits from it and it adds seconds of latency. llm_client picks the + // field the endpoint understands and retries without it if rejected. + let disable_reasoning = matches!(provider.id.as_str(), "custom" | "openrouter"); if provider.supports_structured_output { debug!("Using structured outputs for provider '{}'", provider.id); @@ -254,14 +256,14 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) user_content, Some(system_prompt), Some(json_schema), - reasoning_effort.clone(), - reasoning.clone(), + disable_reasoning, ) .await { Ok(Some(content)) => { // Parse the JSON response to extract the transcription field - match serde_json::from_str::(&content) { + let content = strip_think_block(&content); + match serde_json::from_str::(content) { Ok(json) => { if let Some(transcription_value) = json.get(TRANSCRIPTION_FIELD).and_then(|t| t.as_str()) @@ -275,7 +277,7 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) return Some(result); } else { error!("Structured output response missing 'transcription' field"); - return Some(strip_invisible_chars(&content)); + return Some(strip_invisible_chars(content)); } } Err(e) => { @@ -283,7 +285,7 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) "Failed to parse structured output JSON: {}. Returning raw content.", e ); - return Some(strip_invisible_chars(&content)); + return Some(strip_invisible_chars(content)); } } } @@ -310,13 +312,12 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) api_key, &model, processed_prompt, - reasoning_effort, - reasoning, + disable_reasoning, ) .await { Ok(Some(content)) => { - let content = strip_invisible_chars(&content); + let content = strip_invisible_chars(strip_think_block(&content)); debug!( "LLM post-processing succeeded for provider '{}'. Output length: {} chars", provider.id, @@ -927,7 +928,10 @@ pub static ACTION_MAP: Lazy>> = Lazy::ne #[cfg(test)] mod tests { - use super::{complete_unless_cancelled, is_blank_transcription, should_use_streaming_overlay}; + use super::{ + complete_unless_cancelled, is_blank_transcription, should_use_streaming_overlay, + strip_think_block, + }; use crate::settings::OverlayStyle; use std::future; use std::sync::atomic::{AtomicBool, Ordering}; @@ -976,6 +980,32 @@ mod tests { assert_eq!(result, None); } + #[test] + fn leading_think_block_is_stripped() { + assert_eq!( + strip_think_block("pondering...Cleaned text."), + "Cleaned text." + ); + assert_eq!( + strip_think_block(" \nmulti\nline\n Cleaned text."), + "Cleaned text." + ); + } + + #[test] + fn content_without_think_block_is_unchanged() { + assert_eq!(strip_think_block("Cleaned text."), "Cleaned text."); + assert_eq!( + strip_think_block("Mentions mid-sentence."), + "Mentions mid-sentence." + ); + // Unclosed block: leave untouched rather than guess + assert_eq!( + strip_think_block("never closed"), + "never closed" + ); + } + #[test] fn live_overlay_uses_streaming_states_only_for_streaming_models() { assert!(should_use_streaming_overlay(OverlayStyle::Live, true)); diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 2a06e1736e..6ed64c1285 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -1,8 +1,10 @@ use crate::settings::PostProcessProvider; -use log::debug; +use log::{debug, info}; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, REFERER, USER_AGENT}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; #[derive(Debug, Serialize)] struct ChatMessage { @@ -24,12 +26,86 @@ struct ResponseFormat { json_schema: JsonSchema, } -#[derive(Debug, Serialize, Clone, Default)] -pub struct ReasoningConfig { +#[derive(Debug, Serialize, Clone, Default, PartialEq)] +struct ReasoningConfig { #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, + effort: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub exclude: Option, + exclude: Option, +} + +/// Request fields used to ask an endpoint to skip reasoning/thinking. +/// Providers disagree on the field name and accepted values, so at most one of +/// these is set per request (see `reasoning_disable_params`). +#[derive(Debug, Serialize, Clone, Default, PartialEq)] +struct ReasoningParams { + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thinking: Option, +} + +impl ReasoningParams { + fn is_empty(&self) -> bool { + self.reasoning_effort.is_none() && self.reasoning.is_none() && self.thinking.is_none() + } +} + +/// Pick the reasoning-disable request fields an endpoint understands. +/// Unknown endpoints get the common OpenAI-style field; if they reject it, +/// the request is retried without it (see `send_chat_completion_with_schema`). +fn reasoning_disable_params(provider: &PostProcessProvider) -> ReasoningParams { + let base_url = provider.base_url.to_lowercase(); + if base_url.contains("api.deepseek.com") { + // DeepSeek rejects reasoning_effort "none" and uses its own field: + // https://api-docs.deepseek.com/guides/thinking_mode + ReasoningParams { + thinking: Some(serde_json::json!({ "type": "disabled" })), + ..Default::default() + } + } else if provider.id == "openrouter" { + // OpenRouter nested object; exclude:true also keeps reasoning text out + // of the response so it can't pollute structured-output JSON parsing + ReasoningParams { + reasoning: Some(ReasoningConfig { + effort: Some("none".to_string()), + exclude: Some(true), + }), + ..Default::default() + } + } else { + ReasoningParams { + reasoning_effort: Some("none".to_string()), + ..Default::default() + } + } +} + +/// Endpoints (base_url|model) that rejected the reasoning-disable fields with a +/// 4xx. Remembered for the lifetime of the process so every dictation after the +/// first skips the doomed attempt and goes straight to a plain request. +fn reasoning_rejections() -> &'static Mutex> { + static REJECTED: OnceLock>> = OnceLock::new(); + REJECTED.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn endpoint_key(provider: &PostProcessProvider, model: &str) -> String { + format!("{}|{}", provider.base_url.trim_end_matches('/'), model) +} + +fn is_known_rejected(key: &str) -> bool { + reasoning_rejections() + .lock() + .map(|set| set.contains(key)) + .unwrap_or(false) +} + +fn remember_rejection(key: String) { + if let Ok(mut set) = reasoning_rejections().lock() { + set.insert(key); + } } #[derive(Debug, Serialize)] @@ -38,10 +114,8 @@ struct ChatCompletionRequest { messages: Vec, #[serde(skip_serializing_if = "Option::is_none")] response_format: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reasoning_effort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reasoning: Option, + #[serde(flatten)] + reasoning: ReasoningParams, } #[derive(Debug, Deserialize)] @@ -113,8 +187,7 @@ pub async fn send_chat_completion( api_key: String, model: &str, prompt: String, - reasoning_effort: Option, - reasoning: Option, + disable_reasoning: bool, ) -> Result, String> { send_chat_completion_with_schema( provider, @@ -123,18 +196,21 @@ pub async fn send_chat_completion( prompt, None, None, - reasoning_effort, - reasoning, + disable_reasoning, ) .await } -/// Send a chat completion request with structured output support -/// When json_schema is provided, uses structured outputs mode -/// system_prompt is used as the system message when provided -/// reasoning_effort sets the OpenAI-style top-level field (e.g., "none", "low", "medium", "high") -/// reasoning sets the OpenRouter-style nested object (effort + exclude) -#[allow(clippy::too_many_arguments)] +/// Send a chat completion request with structured output support. +/// When json_schema is provided, uses structured outputs mode. +/// system_prompt is used as the system message when provided. +/// +/// When disable_reasoning is set, the request carries the reasoning-disable +/// fields the endpoint is expected to understand. Not every OpenAI-compatible +/// endpoint accepts them (DeepSeek, Gemini's compat layer, and some OpenRouter +/// upstreams reject with 400), so a 400/422 answer to such a request triggers +/// one retry without the fields, and the rejection is remembered per +/// (base_url, model) so later requests skip the failing attempt entirely. pub async fn send_chat_completion_with_schema( provider: &PostProcessProvider, api_key: String, @@ -142,8 +218,7 @@ pub async fn send_chat_completion_with_schema( user_content: String, system_prompt: Option, json_schema: Option, - reasoning_effort: Option, - reasoning: Option, + disable_reasoning: bool, ) -> Result, String> { let base_url = provider.base_url.trim_end_matches('/'); let url = format!("{}/chat/completions", base_url); @@ -179,22 +254,61 @@ pub async fn send_chat_completion_with_schema( }, }); - let request_body = ChatCompletionRequest { + let key = endpoint_key(provider, model); + let reasoning = if disable_reasoning && !is_known_rejected(&key) { + reasoning_disable_params(provider) + } else { + ReasoningParams::default() + }; + + let mut request_body = ChatCompletionRequest { model: model.to_string(), messages, response_format, - reasoning_effort, reasoning, }; - let response = client + let mut response = client .post(&url) .json(&request_body) .send() .await .map_err(|e| format!("HTTP request failed: {}", e))?; + let mut status = response.status(); + + // A 400/422 on a request carrying reasoning-disable fields is almost always + // the endpoint rejecting those fields — retry once without them. + if !status.is_success() + && matches!(status.as_u16(), 400 | 422) + && !request_body.reasoning.is_empty() + { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Failed to read error response".to_string()); + info!( + "Endpoint rejected request with reasoning disabled (status {}): {}. Retrying without reasoning fields", + status, error_text + ); + + request_body.reasoning = ReasoningParams::default(); + response = client + .post(&url) + .json(&request_body) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + status = response.status(); + + if status.is_success() { + info!( + "Retry without reasoning fields succeeded; '{}' (model '{}') will skip them from now on", + base_url, model + ); + remember_rejection(key); + } + } - let status = response.status(); if !status.is_success() { let error_text = response .text() @@ -276,3 +390,96 @@ pub async fn fetch_models( Ok(models) } + +#[cfg(test)] +mod tests { + use super::*; + + fn provider(id: &str, base_url: &str) -> PostProcessProvider { + PostProcessProvider { + id: id.to_string(), + label: id.to_string(), + base_url: base_url.to_string(), + allow_base_url_edit: true, + models_endpoint: None, + supports_structured_output: false, + } + } + + fn request_json(reasoning: ReasoningParams) -> Value { + let request = ChatCompletionRequest { + model: "test-model".to_string(), + messages: vec![ChatMessage { + role: "user".to_string(), + content: "hi".to_string(), + }], + response_format: None, + reasoning, + }; + serde_json::to_value(&request).unwrap() + } + + #[test] + fn default_reasoning_params_serialize_to_no_fields() { + let json = request_json(ReasoningParams::default()); + assert!(json.get("reasoning_effort").is_none()); + assert!(json.get("reasoning").is_none()); + assert!(json.get("thinking").is_none()); + } + + #[test] + fn custom_provider_uses_top_level_reasoning_effort() { + let params = reasoning_disable_params(&provider("custom", "http://localhost:11434/v1")); + let json = request_json(params); + assert_eq!(json["reasoning_effort"], "none"); + assert!(json.get("reasoning").is_none()); + assert!(json.get("thinking").is_none()); + } + + #[test] + fn openrouter_uses_nested_reasoning_object() { + let params = + reasoning_disable_params(&provider("openrouter", "https://openrouter.ai/api/v1")); + let json = request_json(params); + assert!(json.get("reasoning_effort").is_none()); + assert_eq!(json["reasoning"]["effort"], "none"); + assert_eq!(json["reasoning"]["exclude"], true); + assert!(json.get("thinking").is_none()); + } + + #[test] + fn deepseek_base_url_uses_thinking_disabled() { + let params = reasoning_disable_params(&provider("custom", "https://api.deepseek.com")); + let json = request_json(params); + assert!(json.get("reasoning_effort").is_none()); + assert!(json.get("reasoning").is_none()); + assert_eq!(json["thinking"]["type"], "disabled"); + } + + #[test] + fn reasoning_params_is_empty_tracks_all_fields() { + assert!(ReasoningParams::default().is_empty()); + assert!(!ReasoningParams { + reasoning_effort: Some("none".to_string()), + ..Default::default() + } + .is_empty()); + assert!(!ReasoningParams { + thinking: Some(serde_json::json!({ "type": "disabled" })), + ..Default::default() + } + .is_empty()); + } + + #[test] + fn rejection_memo_is_keyed_by_base_url_and_model() { + let deepseek = provider("custom", "https://api.deepseek.com/"); + let key = endpoint_key(&deepseek, "deepseek-chat"); + assert_eq!(key, "https://api.deepseek.com|deepseek-chat"); + assert!(!is_known_rejected(&key)); + remember_rejection(key.clone()); + assert!(is_known_rejected(&key)); + // A different model on the same endpoint is tracked separately + assert!(!is_known_rejected(&endpoint_key(&deepseek, "other-model"))); + } +} From 76b44d83fd2d0cb7b9f4714b56abe068e499c4b5 Mon Sep 17 00:00:00 2001 From: Gaurav Singh Date: Fri, 31 Jul 2026 12:41:59 +0530 Subject: [PATCH 18/49] fix(audio): recalibrate mic level meter so speech moves the bars (#1813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `db` value computed in AudioVisualiser::feed() is not true dBFS: it is a per-bin average magnitude divided by the FFT window size, which for speech-shaped input lands roughly 20 dB below the band's actual dBFS. DB_MIN/DB_MAX were calibrated as if it were dBFS, so the window sat about 20 dB too high: - src-tauri/src/audio_toolkit/audio/visualizer.rs (level buckets) - src/overlay/RecordingOverlay.tsx (draws 3 + v^0.7 * 15 px bars) Normal dictation therefore mapped to ~0.1, which the overlay draws as a 4 px bar against a 3 px floor and an 18 px maximum — roughly one pixel of travel, which reads as a frozen waveform. Recalibrate to -68/-30, measured against captured audio on a built-in mic at 48 kHz (dictation ~-32 dBFS median, room tone ~-48 dBFS) and scored on rendered pixels: speech gets ~5 px of travel while room tone stays pinned at the floor. Not lowered to -70, where a noisy room starts making the idle waveform twitch. Fixes #1694 Co-authored-by: Claude Opus 5 (1M context) --- src-tauri/src/audio_toolkit/audio/visualizer.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/audio_toolkit/audio/visualizer.rs b/src-tauri/src/audio_toolkit/audio/visualizer.rs index b0ef038e39..08b1e47d4f 100644 --- a/src-tauri/src/audio_toolkit/audio/visualizer.rs +++ b/src-tauri/src/audio_toolkit/audio/visualizer.rs @@ -1,8 +1,14 @@ use rustfft::{num_complex::Complex32, Fft, FftPlanner}; use std::sync::Arc; -const DB_MIN: f32 = -55.0; -const DB_MAX: f32 = -8.0; +// `db` below is not true dBFS: it's a per-bin average divided by the FFT +// window size, which lands ~20 dB low for speech. So this window is calibrated +// against measured mic audio (dictation ~-32 dBFS, room tone ~-48 dBFS) rather +// than absolute dBFS. The old -55/-8 left speech ~1 px above the overlay's +// floor, which reads as a frozen waveform (#1694). Not lowered past -68: at +// -70 a noisy room starts making the idle waveform twitch. +const DB_MIN: f32 = -68.0; +const DB_MAX: f32 = -30.0; const GAIN: f32 = 1.3; const CURVE_POWER: f32 = 0.7; From 2f7c2fcadeabd445f7c6445cd9df5bff84963ef2 Mon Sep 17 00:00:00 2001 From: andreimek <63675133+andreimek@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:25:41 -0400 Subject: [PATCH 19/49] Update "Manual Model Installation" documentation with Parakeet Unified (#1668) * docs: replace Parakeet section with Parakeet Unified EN (huggingface-cli install) * corrections --------- Co-authored-by: andrzej.nescior Co-authored-by: CJ Pais --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 19483d6351..f1890809ea 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,10 @@ Download the models you want from below - Turbo (1600 MB): `https://blob.handy.computer/ggml-large-v3-turbo.bin` - Large (1100 MB): `https://blob.handy.computer/ggml-large-v3-q5_0.bin` +**Parakeet Unified EN 0.6B (single `.gguf` file, recommended):** + +- Q8_0 (731 MB): `https://huggingface.co/handy-computer/parakeet-unified-en-0.6b-gguf/resolve/main/parakeet-unified-en-0.6b-Q8_0.gguf` + **Parakeet Models (compressed archives):** - V2 (473 MB): `https://blob.handy.computer/parakeet-v2-int8.tar.gz` @@ -368,6 +372,10 @@ Simply place the `.bin` file directly into the `models` directory: └── ggml-large-v3-q5_0.bin ``` +**For GGUF Models (.gguf files):** + +Place the `.gguf` file directly into the `models` directory, exactly like the Whisper `.bin` files above. Handy also picks up models already present in the shared Hugging Face cache (`~/.cache/huggingface/hub`), so a copy downloaded by another tool works without being moved. + **For Parakeet Models (.tar.gz archives):** 1. Extract the `.tar.gz` file @@ -391,7 +399,7 @@ Final structure should look like: **Important Notes:** - For Parakeet models, the extracted directory name **must** match exactly as shown above -- Do not rename the `.bin` files for Whisper models—use the exact filenames from the download URLs +- Do not rename the `.bin` or `.gguf` files—use the exact filenames from the download URLs - After placing the files, restart Handy to detect the new models #### Step 5: Verify Installation From 099df58a591bb8ab07ee16dc8e4e8d69d1270791 Mon Sep 17 00:00:00 2001 From: Cxkies <69440959+Cxkies@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:39:40 -0500 Subject: [PATCH 20/49] slider-reset: add reset icon following existing UI pattern (#1779) --- src/components/settings/debug/PasteDelay.tsx | 4 +++- src/components/settings/debug/RecordingBuffer.tsx | 4 +++- .../settings/debug/WordCorrectionThreshold.tsx | 4 +++- src/components/ui/Slider.tsx | 11 +++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/settings/debug/PasteDelay.tsx b/src/components/settings/debug/PasteDelay.tsx index 8bc33a5728..28e24479f7 100644 --- a/src/components/settings/debug/PasteDelay.tsx +++ b/src/components/settings/debug/PasteDelay.tsx @@ -21,7 +21,7 @@ export const PasteDelay: React.FC = ({ descriptionKey = "settings.debug.pasteDelay.description", }) => { const { t } = useTranslation(); - const { settings, updateSetting } = useSettings(); + const { settings, updateSetting, resetSetting, isUpdating } = useSettings(); const handleDelayChange = (value: number) => { updateSetting(settingKey, value); @@ -31,6 +31,8 @@ export const PasteDelay: React.FC = ({ resetSetting(settingKey)} + isResetting={isUpdating(settingKey)} min={10} max={500} step={10} diff --git a/src/components/settings/debug/RecordingBuffer.tsx b/src/components/settings/debug/RecordingBuffer.tsx index 4bfc0f0d6b..c5cc1389ad 100644 --- a/src/components/settings/debug/RecordingBuffer.tsx +++ b/src/components/settings/debug/RecordingBuffer.tsx @@ -13,7 +13,7 @@ export const RecordingBuffer: React.FC = ({ grouped = false, }) => { const { t } = useTranslation(); - const { settings, updateSetting } = useSettings(); + const { settings, updateSetting, resetSetting, isUpdating } = useSettings(); const handleBufferChange = (value: number) => { updateSetting("extra_recording_buffer_ms", value); @@ -23,6 +23,8 @@ export const RecordingBuffer: React.FC = ({ resetSetting("extra_recording_buffer_ms")} + isResetting={isUpdating("extra_recording_buffer_ms")} min={0} max={1500} step={50} diff --git a/src/components/settings/debug/WordCorrectionThreshold.tsx b/src/components/settings/debug/WordCorrectionThreshold.tsx index 5945d93727..5de9fdab99 100644 --- a/src/components/settings/debug/WordCorrectionThreshold.tsx +++ b/src/components/settings/debug/WordCorrectionThreshold.tsx @@ -12,7 +12,7 @@ export const WordCorrectionThreshold: React.FC< WordCorrectionThresholdProps > = ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); - const { settings, updateSetting } = useSettings(); + const { settings, updateSetting, resetSetting, isUpdating } = useSettings(); const handleThresholdChange = (value: number) => { updateSetting("word_correction_threshold", value); @@ -22,6 +22,8 @@ export const WordCorrectionThreshold: React.FC< resetSetting("word_correction_threshold")} + isResetting={isUpdating("word_correction_threshold")} min={0.0} max={1.0} label={t("settings.debug.wordCorrectionThreshold.title")} diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index aaa50bc476..c5af56b83b 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -1,5 +1,6 @@ import React from "react"; import { SettingContainer } from "./SettingContainer"; +import { ResetButton } from "./ResetButton"; interface SliderProps { value: number; @@ -14,6 +15,8 @@ interface SliderProps { grouped?: boolean; showValue?: boolean; formatValue?: (value: number) => string; + onReset?: () => void; + isResetting?: boolean; } export const Slider: React.FC = ({ @@ -29,6 +32,8 @@ export const Slider: React.FC = ({ grouped = false, showValue = true, formatValue = (v) => v.toFixed(2), + onReset, + isResetting = false, }) => { const handleChange = (e: React.ChangeEvent) => { onChange(parseFloat(e.target.value)); @@ -67,6 +72,12 @@ export const Slider: React.FC = ({ {formatValue(value)} )} + {onReset && ( + + )} From 347986bc4b78dfa066de1fa5ebbacd6cf27d63d2 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 31 Jul 2026 19:10:21 +0800 Subject: [PATCH 21/49] format --- src/components/ui/Slider.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index c5af56b83b..b3fdc8eba3 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -73,10 +73,7 @@ export const Slider: React.FC = ({ )} {onReset && ( - + )} From a70ac84fd66819d171a0bce156e4f729aa46527a Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 31 Jul 2026 20:11:32 +0800 Subject: [PATCH 22/49] add more reliable paste (#1812) * add more reliable paste * translations * minor fixes --- src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 9 + src-tauri/src/clipboard.rs | 32 +- src-tauri/src/input.rs | 19 +- src-tauri/src/lib.rs | 2 + src-tauri/src/paste_tx/macos.rs | 336 ++++++++++ src-tauri/src/paste_tx/mod.rs | 283 ++++++++ src-tauri/src/paste_tx/windows.rs | 613 ++++++++++++++++++ src-tauri/src/settings.rs | 6 + src-tauri/src/shortcut/mod.rs | 9 + src/bindings.ts | 16 +- .../settings/debug/DebugSettings.tsx | 2 + .../settings/debug/ReliablePaste.tsx | 36 + src/i18n/locales/ar/translation.json | 4 + src/i18n/locales/bg/translation.json | 4 + src/i18n/locales/cs/translation.json | 4 + src/i18n/locales/da/translation.json | 4 + src/i18n/locales/de/translation.json | 4 + src/i18n/locales/en/translation.json | 4 + src/i18n/locales/es/translation.json | 4 + src/i18n/locales/fr/translation.json | 4 + src/i18n/locales/he/translation.json | 4 + src/i18n/locales/hi/translation.json | 4 + src/i18n/locales/it/translation.json | 4 + src/i18n/locales/ja/translation.json | 4 + src/i18n/locales/ko/translation.json | 4 + src/i18n/locales/ne/translation.json | 4 + src/i18n/locales/nl/translation.json | 4 + src/i18n/locales/pl/translation.json | 4 + src/i18n/locales/pt/translation.json | 4 + src/i18n/locales/ru/translation.json | 4 + src/i18n/locales/sv/translation.json | 4 + src/i18n/locales/tr/translation.json | 4 + src/i18n/locales/uk/translation.json | 4 + src/i18n/locales/vi/translation.json | 4 + src/i18n/locales/zh-TW/translation.json | 4 + src/i18n/locales/zh/translation.json | 4 + src/stores/settingsStore.ts | 2 + 38 files changed, 1453 insertions(+), 11 deletions(-) create mode 100644 src-tauri/src/paste_tx/macos.rs create mode 100644 src-tauri/src/paste_tx/mod.rs create mode 100644 src-tauri/src/paste_tx/windows.rs create mode 100644 src/components/settings/debug/ReliablePaste.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4e19d72dfc..fae1ad2096 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2465,6 +2465,9 @@ dependencies = [ "hound", "log", "natural", + "objc2", + "objc2-app-kit", + "objc2-foundation", "once_cell", "rdev", "regex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d70fdc734a..77051b487a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -106,10 +106,14 @@ tauri-plugin-updater = "2.10.0" windows = { version = "0.61.3", features = [ "Win32_Media_Audio_Endpoints", "Win32_System_Com_StructuredStorage", + "Win32_System_DataExchange", "Win32_System_LibraryLoader", + "Win32_System_Ole", + "Win32_System_Memory", "Win32_System_Threading", "Win32_System_Variant", "Win32_Foundation", + "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", ] } winreg = "0.55" @@ -139,6 +143,11 @@ transcribe-cpp = { version = "0.1.3", default-features = false } [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } transcribe-cpp = { version = "0.1.3", default-features = false, features = ["metal"] } +# Used by the receipt-sequenced ("reliable") paste path for lazy NSPasteboard +# promises. Versions track what Tauri already pulls in. +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-app-kit = "0.3" [target.'cfg(target_os = "linux")'.dependencies] gtk-layer-shell = { version = "0.8", features = ["v0_6"] } diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index c822bc2f56..5a181345e1 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -63,9 +63,11 @@ fn paste_via_clipboard( // Fall back to enigo if no native tool handled it if !key_combo_sent { match paste_method { - PasteMethod::CtrlV => input::send_paste_ctrl_v(enigo)?, - PasteMethod::CtrlShiftV => input::send_paste_ctrl_shift_v(enigo)?, - PasteMethod::ShiftInsert => input::send_paste_shift_insert(enigo)?, + // The legacy path cannot detect a mistimed chord, so it keeps the + // conservative 100ms modifier hold. + PasteMethod::CtrlV => input::send_paste_ctrl_v(enigo, 100)?, + PasteMethod::CtrlShiftV => input::send_paste_ctrl_shift_v(enigo, 100)?, + PasteMethod::ShiftInsert => input::send_paste_shift_insert(enigo, 100)?, _ => return Err("Invalid paste method for clipboard paste".into()), } } @@ -561,7 +563,7 @@ fn paste_direct( input::paste_text_direct(enigo, text) } -fn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), String> { +pub(crate) fn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), String> { match key_type { AutoSubmitKey::Enter => { enigo @@ -649,6 +651,28 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { )?; } PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert => { + // Debug-gated receipt-sequenced paste (#502): restore the clipboard + // after the target actually reads the transcript, not on a timer. + // On success it fully handles the paste (including auto-submit and + // clipboard handling) asynchronously; on failure fall through to + // the legacy path untouched. + #[cfg(any(target_os = "macos", target_os = "windows"))] + if settings.reliable_paste { + match crate::paste_tx::try_reliable_paste( + &text, + &app_handle, + &paste_method, + &mut enigo, + settings.auto_submit, + settings.auto_submit_key, + settings.clipboard_handling, + ) { + Ok(()) => return Ok(()), + Err(e) => { + log::warn!("Reliable paste unavailable ({e}); falling back to legacy paste") + } + } + } paste_via_clipboard( &mut enigo, &text, diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index ee33317b48..ef915035b4 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -25,7 +25,14 @@ pub fn get_cursor_position(app_handle: &AppHandle) -> Option<(i32, i32)> { /// Sends a Ctrl+V or Cmd+V paste command using platform-specific virtual key codes. /// This ensures the paste works regardless of keyboard layout (e.g., Russian, AZERTY, DVORAK). /// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods. -pub fn send_paste_ctrl_v(enigo: &mut Enigo) -> Result<(), String> { +/// +/// `hold_ms` is how long the modifier stays held after the V click before being +/// released. Most applications read the modifier from the V event's flags and +/// need no hold at all, but applications that poll global keyboard state when +/// handling the key need the modifier to still be down — the hold insures +/// against those. Callers that can detect a failed chord (e.g. the +/// receipt-sequenced paste path) may use a much shorter hold. +pub fn send_paste_ctrl_v(enigo: &mut Enigo, hold_ms: u64) -> Result<(), String> { // Platform-specific key definitions #[cfg(target_os = "macos")] let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); @@ -42,7 +49,7 @@ pub fn send_paste_ctrl_v(enigo: &mut Enigo) -> Result<(), String> { .key(v_key_code, enigo::Direction::Click) .map_err(|e| format!("Failed to click V key: {}", e))?; - std::thread::sleep(std::time::Duration::from_millis(100)); + std::thread::sleep(std::time::Duration::from_millis(hold_ms)); enigo .key(modifier_key, enigo::Direction::Release) @@ -54,7 +61,7 @@ pub fn send_paste_ctrl_v(enigo: &mut Enigo) -> Result<(), String> { /// Sends a Ctrl+Shift+V paste command. /// This is commonly used in terminal applications on Linux to paste without formatting. /// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods. -pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo) -> Result<(), String> { +pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo, hold_ms: u64) -> Result<(), String> { // Platform-specific key definitions #[cfg(target_os = "macos")] let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); // Cmd+Shift+V on macOS @@ -74,7 +81,7 @@ pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo) -> Result<(), String> { .key(v_key_code, enigo::Direction::Click) .map_err(|e| format!("Failed to click V key: {}", e))?; - std::thread::sleep(std::time::Duration::from_millis(100)); + std::thread::sleep(std::time::Duration::from_millis(hold_ms)); enigo .key(Key::Shift, enigo::Direction::Release) @@ -89,7 +96,7 @@ pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo) -> Result<(), String> { /// Sends a Shift+Insert paste command (Windows and Linux only). /// This is more universal for terminal applications and legacy software. /// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods. -pub fn send_paste_shift_insert(enigo: &mut Enigo) -> Result<(), String> { +pub fn send_paste_shift_insert(enigo: &mut Enigo, hold_ms: u64) -> Result<(), String> { #[cfg(target_os = "windows")] let insert_key_code = Key::Other(0x2D); // VK_INSERT #[cfg(not(target_os = "windows"))] @@ -103,7 +110,7 @@ pub fn send_paste_shift_insert(enigo: &mut Enigo) -> Result<(), String> { .key(insert_key_code, enigo::Direction::Click) .map_err(|e| format!("Failed to click Insert key: {}", e))?; - std::thread::sleep(std::time::Duration::from_millis(100)); + std::thread::sleep(std::time::Duration::from_millis(hold_ms)); enigo .key(Key::Shift, enigo::Direction::Release) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3dec643a9e..621b6a0271 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod input; mod llm_client; mod managers; mod overlay; +mod paste_tx; pub mod portable; mod secure_input; mod settings; @@ -624,6 +625,7 @@ pub fn run(cli_args: CliArgs) { shortcut::change_extra_recording_buffer_setting, shortcut::change_paste_delay_ms_setting, shortcut::change_paste_delay_after_ms_setting, + shortcut::change_reliable_paste_setting, shortcut::change_paste_method_setting, shortcut::get_available_typing_tools, shortcut::change_typing_tool_setting, diff --git a/src-tauri/src/paste_tx/macos.rs b/src-tauri/src/paste_tx/macos.rs new file mode 100644 index 0000000000..0c4113b676 --- /dev/null +++ b/src-tauri/src/paste_tx/macos.rs @@ -0,0 +1,336 @@ +//! macOS reliable paste. +//! +//! Publishes the transcript with `declareTypes:owner:`, which puts a *promise* +//! on the general pasteboard instead of data. When any consumer actually +//! requests the text, AppKit calls `pasteboard:provideDataForType:` on our +//! owner object — that callback is the read receipt. The previous clipboard is +//! restored once receipts go quiet (see `paste_tx::evaluate`), guarded by the +//! pasteboard `changeCount` so we never clobber a newer user copy. +//! +//! Threading: publishing and chord injection happen on the calling (main) +//! thread, because promised pasteboard data is serviced by AppKit on the main +//! run loop. The (potentially seconds-long) wait runs on a worker thread; the +//! guarded restore is dispatched back to the main thread. + +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use log::{error, info}; +use objc2::rc::Retained; +use objc2::{define_class, msg_send, AnyThread, DefinedClass}; +use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString}; +use objc2_foundation::{NSArray, NSInteger, NSObject, NSString}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_clipboard_manager::ClipboardExt; + +use super::{evaluate, send_chord, TxState, WaitDecision}; +use crate::clipboard::send_return_key; +use crate::input::EnigoState; +use crate::settings::{AutoSubmitKey, ClipboardHandling, PasteMethod}; + +/// Concealment marker types declared alongside the text so that well-behaved +/// third-party clipboard managers (Maccy, Paste, ...) skip the transcript. +/// Fewer eager readers means a receipt is more likely to be the actual target. +/// These are conventions; nothing enforces them. +const CONCEALMENT_TYPES: [&str; 3] = [ + "org.nspasteboard.TransientType", + "org.nspasteboard.ConcealedType", + "org.nspasteboard.AutoGeneratedType", +]; + +pub struct ProviderIvars { + state: Arc>, + text: String, +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements and the ivars are + // plain Rust values guarded by a Mutex. + #[unsafe(super(NSObject))] + #[name = "HandyPasteProvider"] + #[ivars = ProviderIvars] + pub struct HandyPasteProvider; + + impl HandyPasteProvider { + // NSPasteboardOwner informal protocol: the pasteboard is asking for the + // promised data — our receipt that a consumer read the clipboard. + #[unsafe(method(pasteboard:provideDataForType:))] + fn pasteboard_provide_data_for_type(&self, pasteboard: &NSPasteboard, data_type: &NSString) { + let ivars = self.ivars(); + // SAFETY: NSPasteboardTypeString is a read-only framework static. + let is_text = unsafe { data_type.isEqualToString(NSPasteboardTypeString) }; + // Only a text read counts as a receipt: a request for one of the + // concealment marker types is a clipboard manager inspecting the + // markers, not the paste target reading the transcript. + if is_text { + if let Ok(mut state) = ivars.state.lock() { + state.record_receipt(Instant::now()); + } + } + let payload = if is_text { + NSString::from_str(&ivars.text) + } else { + // Concealment marker types are never handed out. + NSString::from_str("") + }; + let _ = pasteboard.setString_forType(&payload, data_type); + } + + #[unsafe(method(pasteboardChangedOwner:))] + fn pasteboard_changed_owner(&self, _pasteboard: &NSPasteboard) { + if let Ok(mut state) = self.ivars().state.lock() { + state.ownership_lost = true; + } + } + } +); + +impl HandyPasteProvider { + fn new(state: Arc>, text: String) -> Retained { + let this = Self::alloc().set_ivars(ProviderIvars { state, text }); + unsafe { msg_send![super(this), init] } + } +} + +struct MacPending { + state: Arc>, + saved_text: Option, + saved_image: Option>, + change_count: NSInteger, + provider: Option>, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, + /// ClipboardHandling::CopyToClipboard — instead of restoring, settle by + /// re-writing the transcript as plain text (without the concealment + /// markers), so clipboard managers record it and it outlives the promise. + preserve_transcript: bool, + /// The transcript, for the `preserve_transcript` re-write at settle time. + transcript: String, + settled: bool, +} + +/// The transaction currently holding the clipboard, if any. A new paste +/// settles it before snapshotting (see `flush_pending`). +static PENDING: Mutex>>> = Mutex::new(None); + +/// Settles a transaction exactly once: sends the owed auto-submit Enter and +/// restores the previous clipboard, guarded so we never clobber a newer copy. +/// Must run on the main thread. Uses the already-locked enigo when called +/// from within `paste` (which holds the lock), otherwise locks it itself. +fn settle( + pending: &Arc>, + app_handle: &AppHandle, + enigo: Option<&mut enigo::Enigo>, +) { + let mut p = match pending.lock() { + Ok(p) => p, + Err(_) => return, + }; + if p.settled { + return; + } + p.settled = true; + + let (receipt_seen, ownership_lost) = match p.state.lock() { + Ok(st) => (st.any_receipt_after_injection(), st.ownership_lost), + Err(_) => (false, true), + }; + + // Auto-submit only once the target demonstrably read the transcript; + // pressing Enter after an unconfirmed paste could submit stale content. + if p.auto_submit && receipt_seen { + match enigo { + Some(e) => { + let _ = send_return_key(e, p.auto_submit_key); + } + None => { + if let Some(enigo_state) = app_handle.try_state::() { + if let Ok(mut e) = enigo_state.0.lock() { + let _ = send_return_key(&mut e, p.auto_submit_key); + } + } + } + } + } + + let still_ours = + !ownership_lost && NSPasteboard::generalPasteboard().changeCount() == p.change_count; + if !still_ours { + info!("[reliable-paste] clipboard changed externally; leaving it untouched"); + } else if p.preserve_transcript { + // The user asked for the transcript to stay on the clipboard: replace + // the concealed promise with plain text so clipboard managers record + // it and it survives this app exiting. + let _ = app_handle.clipboard().write_text(&p.transcript); + info!("[reliable-paste] left transcript on clipboard as plain text"); + } else { + let clipboard = app_handle.clipboard(); + if let Some(text) = &p.saved_text { + let _ = clipboard.write_text(text); + } else if let Some(image) = &p.saved_image { + let _ = clipboard.write_image(image); + } else { + let _ = clipboard.clear(); + } + info!("[reliable-paste] restored previous clipboard"); + } + + // Release the owner; any outstanding promise dies with the pasteboard + // contents we just replaced (or with the external change). + p.provider = None; +} + +/// If a previous transaction is still holding the clipboard, settle it now so +/// the caller's snapshot captures the user's original clipboard content. +fn flush_pending(app_handle: &AppHandle, enigo: &mut enigo::Enigo) { + let previous = match PENDING.lock() { + Ok(mut slot) => slot.take(), + Err(_) => None, + }; + if let Some(previous) = previous { + settle(&previous, app_handle, Some(enigo)); + } +} + +fn spawn_waiter(pending: Arc>, app_handle: AppHandle) { + thread::spawn(move || { + let outcome = loop { + thread::sleep(Duration::from_millis(15)); + let (decision, state_snapshot) = { + let p = match pending.lock() { + Ok(p) => p, + Err(_) => return, + }; + if p.settled { + // A newer paste settled this transaction already. + return; + } + let result = match p.state.lock() { + Ok(st) => { + let now = Instant::now(); + let snapshot = ( + st.any_receipt_after_injection(), + st.ownership_lost, + st.injection_failed, + ); + (evaluate(&st, now), snapshot) + } + Err(_) => return, + }; + result + }; + if let WaitDecision::Finish = decision { + break state_snapshot; + } + }; + + let (receipt_seen, ownership_lost, injection_failed) = outcome; + if ownership_lost { + info!("[reliable-paste] settling: clipboard ownership lost"); + } else if receipt_seen { + info!("[reliable-paste] settling: reads went quiet"); + } else if injection_failed { + info!("[reliable-paste] settling: chord injection failed, restoring quickly"); + } else { + info!("[reliable-paste] settling: no read within timeout, restoring anyway"); + } + + let pending_for_finish = pending.clone(); + let app_for_finish = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || { + settle(&pending_for_finish, &app_for_finish, None); + if let Ok(mut slot) = PENDING.lock() { + let is_us = slot + .as_ref() + .map(|current| Arc::ptr_eq(current, &pending)) + .unwrap_or(false); + if is_us { + *slot = None; + } + } + }); + }); +} + +pub(super) fn run( + text: &str, + app_handle: &AppHandle, + paste_method: &PasteMethod, + enigo: &mut enigo::Enigo, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, + clipboard_handling: ClipboardHandling, +) -> Result<(), String> { + // Settle any previous transaction first so the snapshot below captures the + // user's original clipboard, not the previous transcript. + flush_pending(app_handle, enigo); + + let clipboard = app_handle.clipboard(); + let saved_text = clipboard.read_text().ok().filter(|t| !t.is_empty()); + // Only probe for an image when there is no text; reading an image decodes + // the full bitmap (mirrors the legacy path). + let saved_image = if saved_text.is_none() { + clipboard.read_image().ok().map(|image| image.to_owned()) + } else { + None + }; + + let state = Arc::new(Mutex::new(TxState::new())); + let provider = HandyPasteProvider::new(state.clone(), text.to_string()); + let pasteboard = NSPasteboard::generalPasteboard(); + + let mut types: Vec> = Vec::with_capacity(1 + CONCEALMENT_TYPES.len()); + types.push(NSString::from_str("public.utf8-plain-text")); + for t in CONCEALMENT_TYPES { + types.push(NSString::from_str(t)); + } + let types = NSArray::from_retained_slice(&types); + + // declareTypes:owner: clears the pasteboard and puts our promise on it; + // the return value is the new changeCount. + let change_count: NSInteger = + unsafe { msg_send![&*pasteboard, declareTypes: &*types, owner: &*provider] }; + if change_count <= 0 { + return Err("declareTypes:owner: failed".to_string()); + } + info!("[reliable-paste] published transcript as lazy promise (changeCount {change_count})"); + + // Mark injection *before* sending: enigo holds the chord for ~100ms and a + // fast target may legitimately read while the chord is still held. + if let Ok(mut st) = state.lock() { + st.injected_at = Some(Instant::now()); + } + match send_chord(enigo, paste_method) { + Ok(()) => { + info!("[reliable-paste] paste chord sent ({paste_method:?})"); + } + Err(e) => { + // Keep the transaction alive: the waiter restores the clipboard + // after the short failed-injection timeout. + if let Ok(mut st) = state.lock() { + st.injection_failed = true; + } + error!("[reliable-paste] failed to send paste chord: {e}"); + } + } + + let pending = Arc::new(Mutex::new(MacPending { + state, + saved_text, + saved_image, + change_count, + provider: Some(provider), + auto_submit, + auto_submit_key, + preserve_transcript: clipboard_handling == ClipboardHandling::CopyToClipboard, + transcript: text.to_string(), + settled: false, + })); + if let Ok(mut slot) = PENDING.lock() { + *slot = Some(pending.clone()); + } + spawn_waiter(pending, app_handle.clone()); + + Ok(()) +} diff --git a/src-tauri/src/paste_tx/mod.rs b/src-tauri/src/paste_tx/mod.rs new file mode 100644 index 0000000000..71fdc5524e --- /dev/null +++ b/src-tauri/src/paste_tx/mod.rs @@ -0,0 +1,283 @@ +//! Receipt-sequenced clipboard paste ("reliable paste", debug-gated). +//! +//! The legacy clipboard paste (`clipboard::paste_via_clipboard`) restores the +//! previous clipboard after a fixed delay. The paste keystroke is only +//! *enqueued* at that point — the target application reads the clipboard +//! whenever its event loop gets to it, so any fixed delay can lose the race +//! and the user gets their old clipboard pasted back (#502). +//! +//! This module instead publishes the transcript as a *lazy promise* and waits +//! for the operating system to tell us that a consumer actually read the +//! clipboard — a "receipt" — before restoring: +//! +//! - Windows: delayed rendering (`SetClipboardData(CF_UNICODETEXT, NULL)`), +//! the owner window receives `WM_RENDERFORMAT` on read. +//! - macOS: `declareTypes:owner:` with an owner object, the pasteboard calls +//! `pasteboard:provideDataForType:` on read. +//! +//! Two rules make the receipt trustworthy: +//! +//! 1. Only receipts observed *after* the paste chord was injected count. A +//! read before that is an eager third party (clipboard manager, antivirus) +//! reacting to the clipboard change itself. +//! 2. Restoration only happens while we still own the clipboard +//! (sequence number / changeCount unchanged, no ownership-lost event). If +//! the user copied something else in the meantime, their action wins. +//! +//! The restore is additionally gated on a short quiet period after the *last* +//! receipt, because some applications read the clipboard several times per +//! paste (Chromium probes, then reads). A bounded timeout caps how long the +//! transcript may occupy the clipboard; the failure mode is always "the +//! transcript stays on the clipboard a bit longer", never "stale content gets +//! pasted". + +// The shared transaction state is compiled on all platforms (for the unit +// tests), but only the macOS/Windows platform modules consume all of it. +#![cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] + +use std::time::{Duration, Instant}; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "macos")] +use macos as platform; +#[cfg(target_os = "windows")] +use windows as platform; + +/// How long after the *last* observed read the transcript stays on the +/// clipboard before restoring. Covers applications that read the clipboard +/// several times per paste (e.g. Chromium probe-then-read). +pub(crate) const QUIET_PERIOD: Duration = Duration::from_millis(200); + +/// Upper bound on how long the transcript may occupy the clipboard before we +/// restore regardless of receipts. Long enough that a realistically loaded +/// target always gets to read first; short enough that a lost keystroke does +/// not strand the transcript on the clipboard for long. +pub(crate) const RESTORE_TIMEOUT: Duration = Duration::from_secs(8); + +/// When the chord could not be injected at all, no legitimate receipt can +/// arrive, so restore quickly instead of waiting out the full timeout. +pub(crate) const FAILED_INJECTION_TIMEOUT: Duration = Duration::from_millis(500); + +/// Shared, cross-thread record of one paste transaction. +#[derive(Debug)] +pub(crate) struct TxState { + /// When the transcript was published to the clipboard. + pub published_at: Instant, + /// When the paste chord was injected. Only receipts *after* this count as + /// evidence the target read the transcript — earlier reads are eager third + /// parties reacting to the clipboard change itself. + pub injected_at: Option, + /// The chord could not be sent; short-circuit the wait. + pub injection_failed: bool, + /// Times at which a consumer requested the clipboard data. + pub receipts: Vec, + /// Someone else took clipboard ownership (user copied elsewhere, ...). + pub ownership_lost: bool, + /// A newer paste transaction settled this one early (see flush logic in + /// the platform modules). + pub cancelled: bool, + /// The post-paste Enter (auto-submit) has been sent for this transaction. + /// (Read on Windows; the macOS path settles via `MacPending::settled`.) + #[allow(dead_code)] + pub auto_submit_sent: bool, + /// First post-injection receipt has been logged. + pub logged_receipt: bool, +} + +impl TxState { + pub fn new() -> Self { + Self { + published_at: Instant::now(), + injected_at: None, + injection_failed: false, + receipts: Vec::new(), + ownership_lost: false, + cancelled: false, + auto_submit_sent: false, + logged_receipt: false, + } + } + + /// Records a read receipt, logging the first one that counts as evidence. + pub fn record_receipt(&mut self, at: Instant) { + self.receipts.push(at); + if !self.logged_receipt { + if let Some(injected) = self.injected_at { + if at >= injected { + self.logged_receipt = true; + log::info!( + "[reliable-paste] clipboard read {}ms after chord", + at.duration_since(injected).as_millis() + ); + } + } + } + } + + pub fn last_receipt_after_injection(&self) -> Option { + let injected = self.injected_at?; + self.receipts.iter().copied().rev().find(|t| *t >= injected) + } + + pub fn any_receipt_after_injection(&self) -> bool { + self.last_receipt_after_injection().is_some() + } +} + +pub(crate) enum WaitDecision { + KeepWaiting, + /// Stop waiting; settle the transaction (auto-submit + guarded restore). + Finish, +} + +/// Pure decision: given the current transaction state, keep waiting for the +/// target to read, or finish now. Both platform event loops call this. +pub(crate) fn evaluate(state: &TxState, now: Instant) -> WaitDecision { + if state.ownership_lost || state.cancelled { + return WaitDecision::Finish; + } + if let Some(last) = state.last_receipt_after_injection() { + if now.duration_since(last) >= QUIET_PERIOD { + return WaitDecision::Finish; + } + } + let deadline = if state.injection_failed { + FAILED_INJECTION_TIMEOUT + } else { + RESTORE_TIMEOUT + }; + if now.duration_since(state.published_at) >= deadline { + return WaitDecision::Finish; + } + WaitDecision::KeepWaiting +} + +/// Modifier hold for the receipt-sequenced chord, kept at parity with the +/// legacy path (100ms) for the beta: that hold was added in #165 because real +/// users' systems dropped chords released too quickly, and the beta should +/// validate the receipt mechanism without changing a second variable. +/// +/// Once receipts are proven in the field this becomes a safe tuning knob — a +/// chord the target never recognizes produces no receipt and is logged ("no +/// read within timeout") rather than failing silently, so a shorter hold +/// (measured working at 10ms on a fast machine, cutting visible latency from +/// ~110ms to ~20ms) can be tried as its own experiment later. +const CHORD_HOLD_MS: u64 = 100; + +/// Sends the platform paste chord for the configured method. +pub(crate) fn send_chord( + enigo: &mut enigo::Enigo, + paste_method: &crate::settings::PasteMethod, +) -> Result<(), String> { + use crate::settings::PasteMethod; + match paste_method { + PasteMethod::CtrlV => crate::input::send_paste_ctrl_v(enigo, CHORD_HOLD_MS), + PasteMethod::CtrlShiftV => crate::input::send_paste_ctrl_shift_v(enigo, CHORD_HOLD_MS), + PasteMethod::ShiftInsert => crate::input::send_paste_shift_insert(enigo, CHORD_HOLD_MS), + other => Err(format!( + "Invalid paste method for clipboard paste: {:?}", + other + )), + } +} + +/// Attempts the receipt-sequenced paste. Returns `Err` before anything has +/// been published when the platform transaction cannot start, in which case +/// the caller should fall back to the legacy paste path. On `Ok`, publishing +/// and chord injection have completed and the guarded restore (plus +/// auto-submit) finishes asynchronously. +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(crate) fn try_reliable_paste( + text: &str, + app_handle: &tauri::AppHandle, + paste_method: &crate::settings::PasteMethod, + enigo: &mut enigo::Enigo, + auto_submit: bool, + auto_submit_key: crate::settings::AutoSubmitKey, + clipboard_handling: crate::settings::ClipboardHandling, +) -> Result<(), String> { + platform::run( + text, + app_handle, + paste_method, + enigo, + auto_submit, + auto_submit_key, + clipboard_handling, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state_after_publish(published_ago: Duration) -> TxState { + let mut s = TxState::new(); + s.published_at = Instant::now() - published_ago; + s + } + + #[test] + fn keeps_waiting_without_receipt_within_timeout() { + let s = state_after_publish(Duration::from_millis(100)); + assert!(matches!( + evaluate(&s, Instant::now()), + WaitDecision::KeepWaiting + )); + } + + #[test] + fn finishes_after_quiet_period_once_read() { + let mut s = state_after_publish(Duration::from_millis(300)); + s.injected_at = Some(Instant::now() - Duration::from_millis(250)); + s.receipts.push(Instant::now() - QUIET_PERIOD); + assert!(matches!(evaluate(&s, Instant::now()), WaitDecision::Finish)); + } + + #[test] + fn waits_through_quiet_period_after_recent_read() { + let mut s = state_after_publish(Duration::from_millis(300)); + s.injected_at = Some(Instant::now() - Duration::from_millis(100)); + s.receipts.push(Instant::now() - Duration::from_millis(50)); + assert!(matches!( + evaluate(&s, Instant::now()), + WaitDecision::KeepWaiting + )); + } + + #[test] + fn pre_injection_receipt_does_not_count() { + let mut s = state_after_publish(Duration::from_millis(300)); + s.receipts.push(Instant::now() - Duration::from_millis(200)); + s.injected_at = Some(Instant::now() - Duration::from_millis(100)); + assert!(!s.any_receipt_after_injection()); + assert!(matches!( + evaluate(&s, Instant::now()), + WaitDecision::KeepWaiting + )); + } + + #[test] + fn finishes_on_timeout_without_receipt() { + let s = state_after_publish(RESTORE_TIMEOUT); + assert!(matches!(evaluate(&s, Instant::now()), WaitDecision::Finish)); + } + + #[test] + fn failed_injection_uses_short_timeout() { + let mut s = state_after_publish(FAILED_INJECTION_TIMEOUT); + s.injection_failed = true; + assert!(matches!(evaluate(&s, Instant::now()), WaitDecision::Finish)); + } + + #[test] + fn ownership_loss_finishes_immediately() { + let mut s = state_after_publish(Duration::from_millis(10)); + s.ownership_lost = true; + assert!(matches!(evaluate(&s, Instant::now()), WaitDecision::Finish)); + } +} diff --git a/src-tauri/src/paste_tx/windows.rs b/src-tauri/src/paste_tx/windows.rs new file mode 100644 index 0000000000..4c09091924 --- /dev/null +++ b/src-tauri/src/paste_tx/windows.rs @@ -0,0 +1,613 @@ +//! Windows reliable paste. +//! +//! Publishes the transcript as a *delayed-render* clipboard format +//! (`SetClipboardData(CF_UNICODETEXT, NULL)`) owned by a hidden message-only +//! window. Windows sends the owner `WM_RENDERFORMAT` when a consumer actually +//! requests the data — that message is the read receipt. The previous +//! clipboard contents (snapshotted with full format fidelity) are restored +//! once receipts go quiet (see `paste_tx::evaluate`), guarded by the clipboard +//! sequence number so we never clobber a newer user copy. +//! +//! Threading: clipboard ownership and delayed rendering are per-thread and +//! need a message pump, so the whole transaction lives on a dedicated worker +//! thread. The calling thread only sends the paste chord once the worker +//! signals the transcript is published, then returns; the wait, guarded +//! restore and auto-submit all finish on the worker. + +use std::sync::{mpsc::Sender, Arc, Mutex, Once}; +use std::thread; +use std::time::Instant; + +use log::{error, info, warn}; +use tauri::Manager; +use windows::core::{w, PCWSTR}; +use windows::Win32::Foundation::{HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM}; + +use super::{evaluate, send_chord, TxState, WaitDecision}; +use crate::clipboard::send_return_key; +use crate::input::EnigoState; +use crate::settings::{AutoSubmitKey, ClipboardHandling, PasteMethod}; +use windows::Win32::Foundation::GlobalFree; +use windows::Win32::System::DataExchange::{ + CloseClipboard, EmptyClipboard, EnumClipboardFormats, GetClipboardData, GetClipboardOwner, + GetClipboardSequenceNumber, OpenClipboard, RegisterClipboardFormatW, SetClipboardData, +}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Memory::{ + GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, +}; +use windows::Win32::System::Ole::{ + CF_BITMAP, CF_DSPBITMAP, CF_DSPENHMETAFILE, CF_DSPMETAFILEPICT, CF_DSPTEXT, CF_ENHMETAFILE, + CF_OWNERDISPLAY, CF_PALETTE, CF_UNICODETEXT, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + CopyImage, CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW, + GetWindowLongPtrW, KillTimer, PostQuitMessage, RegisterClassW, SetTimer, SetWindowLongPtrW, + GDI_IMAGE_TYPE, GWLP_USERDATA, HWND_MESSAGE, IMAGE_FLAGS, MSG, WINDOW_EX_STYLE, WINDOW_STYLE, + WM_DESTROYCLIPBOARD, WM_RENDERALLFORMATS, WM_RENDERFORMAT, WM_TIMER, WNDCLASSW, +}; + +const CLASS_NAME: PCWSTR = w!("HandyPasteTxWindow"); +const TIMER_ID: usize = 1; +const TIMER_INTERVAL_MS: u32 = 25; +/// Skip clipboard formats larger than this when snapshotting. +const MAX_FORMAT_BYTES: usize = 64 * 1024 * 1024; + +const IMAGE_BITMAP_TYPE: GDI_IMAGE_TYPE = GDI_IMAGE_TYPE(0); +const LR_CREATEDIBSECTION_FLAG: IMAGE_FLAGS = IMAGE_FLAGS(0x2000); + +struct SavedFormat { + format: u32, + data: Vec, +} + +pub(super) struct WinTxShared { + state: Mutex, + text: String, + snapshot: Mutex>, + /// Copied HBITMAP (as raw usize), restored via SetClipboardData. + saved_bitmap: Mutex>, + sequence: Mutex, + app_handle: tauri::AppHandle, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, + /// ClipboardHandling::CopyToClipboard — settle by leaving the transcript + /// on the clipboard as plain text instead of restoring the snapshot. + preserve_transcript: bool, +} + +/// The transaction currently holding the clipboard, if any. A new +/// transaction settles it before snapshotting (see `flush_pending`). +static PENDING: Mutex>> = Mutex::new(None); + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +unsafe fn shared_ptr(hwnd: HWND) -> *const WinTxShared { + GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *const WinTxShared +} + +/// Sends the auto-submit Enter. Uses `try_lock` because the paste caller may +/// currently hold the enigo lock while waiting for this worker. +fn send_auto_submit(shared: &WinTxShared) { + { + let mut st = match shared.state.lock() { + Ok(st) => st, + Err(_) => return, + }; + if st.auto_submit_sent { + return; + } + st.auto_submit_sent = true; + } + if let Some(enigo_state) = shared.app_handle.try_state::() { + match enigo_state.0.try_lock() { + Ok(mut enigo) => { + let _ = send_return_key(&mut enigo, shared.auto_submit_key); + } + Err(_) => warn!("[reliable-paste] skipping auto-submit: input state busy"), + } + } +} + +/// Renders the promised transcript into the clipboard, which must already be +/// open: the system opens it on our behalf for WM_RENDERFORMAT; every other +/// caller has to wrap this in OpenClipboard/CloseClipboard itself. +unsafe fn render_text(shared: &WinTxShared) { + let wide_text: Vec = shared + .text + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let Ok(hg) = GlobalAlloc(GMEM_MOVEABLE, wide_text.len() * 2) else { + return; + }; + let ptr = GlobalLock(hg) as *mut u16; + if ptr.is_null() { + let _ = GlobalFree(Some(hg)); + return; + } + std::ptr::copy_nonoverlapping(wide_text.as_ptr(), ptr, wide_text.len()); + let _ = GlobalUnlock(hg); + if SetClipboardData(CF_UNICODETEXT.0 as u32, Some(HANDLE(hg.0))).is_err() { + let _ = GlobalFree(Some(hg)); + } +} + +unsafe extern "system" fn paste_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + let shared = shared_ptr(hwnd); + match msg { + WM_RENDERFORMAT => { + if !shared.is_null() { + let shared = &*shared; + if let Ok(mut st) = shared.state.lock() { + st.record_receipt(Instant::now()); + } + if wparam.0 as u32 == CF_UNICODETEXT.0 as u32 { + render_text(shared); + } + } + LRESULT(0) + } + WM_RENDERALLFORMATS => { + // Sent when the window is destroyed while an unrendered promise is + // still on the clipboard — not a consumer read, so no receipt. + // Unlike WM_RENDERFORMAT the system does not open the clipboard on + // our behalf here: open it and confirm we still own it first. + if !shared.is_null() { + let shared = &*shared; + if OpenClipboard(Some(hwnd)).is_ok() { + if GetClipboardOwner() + .map(|owner| owner == hwnd) + .unwrap_or(false) + { + render_text(shared); + } + let _ = CloseClipboard(); + } + } + LRESULT(0) + } + WM_DESTROYCLIPBOARD => { + if !shared.is_null() { + if let Ok(mut st) = (&*shared).state.lock() { + st.ownership_lost = true; + } + } + LRESULT(0) + } + WM_TIMER => { + if !shared.is_null() { + on_timer(hwnd, &*shared); + } + LRESULT(0) + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +fn ensure_window_class(hinstance: HINSTANCE) { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let wc = WNDCLASSW { + lpfnWndProc: Some(paste_wnd_proc), + hInstance: hinstance, + lpszClassName: CLASS_NAME, + ..Default::default() + }; + unsafe { + RegisterClassW(&wc); + } + }); +} + +/// If a previous transaction is still holding the clipboard, settle it now so +/// the snapshot below captures the user's original clipboard content. The +/// previous worker observes `cancelled` on its next timer tick and tears down +/// without restoring. +fn flush_pending() { + let previous = match PENDING.lock() { + Ok(mut slot) => slot.take(), + Err(_) => None, + }; + let Some(previous) = previous else { + return; + }; + let receipt = { + let mut st = match previous.state.lock() { + Ok(st) => st, + Err(_) => return, + }; + st.cancelled = true; + st.any_receipt_after_injection() + }; + if previous.auto_submit && receipt { + send_auto_submit(&previous); + } + let sequence = *previous.sequence.lock().unwrap(); + let still_ours = unsafe { GetClipboardSequenceNumber() } == sequence; + if still_ours { + unsafe { settle_clipboard(&previous) }; + } +} + +/// Settle-time clipboard handling once we know we still own the clipboard: +/// restore the snapshot, or — for ClipboardHandling::CopyToClipboard — replace +/// the concealed promise with plain transcript text, so clipboard history and +/// managers record it and it survives this transaction's window going away. +unsafe fn settle_clipboard(shared: &WinTxShared) { + if !shared.preserve_transcript { + restore_snapshot(shared); + return; + } + if OpenClipboard(None).is_err() { + warn!("[reliable-paste] could not open clipboard to leave transcript"); + return; + } + let _ = EmptyClipboard(); + render_text(shared); + let _ = CloseClipboard(); + info!("[reliable-paste] left transcript on clipboard as plain text"); +} + +/// Restores the snapshotted clipboard contents. Safe to call from any thread. +unsafe fn restore_snapshot(shared: &WinTxShared) { + if OpenClipboard(None).is_err() { + warn!("[reliable-paste] could not open clipboard to restore"); + return; + } + let _ = EmptyClipboard(); + if let Ok(formats) = shared.snapshot.lock() { + for saved in formats.iter() { + if saved.data.is_empty() { + continue; + } + let Ok(hg) = GlobalAlloc(GMEM_MOVEABLE, saved.data.len()) else { + continue; + }; + let ptr = GlobalLock(hg) as *mut u8; + if ptr.is_null() { + let _ = GlobalFree(Some(hg)); + continue; + } + std::ptr::copy_nonoverlapping(saved.data.as_ptr(), ptr, saved.data.len()); + let _ = GlobalUnlock(hg); + // SetClipboardData takes ownership of the handle on success. + if SetClipboardData(saved.format, Some(HANDLE(hg.0))).is_err() { + let _ = GlobalFree(Some(hg)); + } + } + } + if let Ok(mut bitmap) = shared.saved_bitmap.lock() { + if let Some(raw) = bitmap.take() { + let _ = SetClipboardData(CF_BITMAP.0 as u32, Some(HANDLE(raw as *mut _))); + } + } + let _ = CloseClipboard(); + info!("[reliable-paste] restored previous clipboard"); +} + +unsafe fn snapshot_clipboard(hwnd: HWND, shared: &WinTxShared) -> Result<(), String> { + OpenClipboard(Some(hwnd)).map_err(|e| format!("OpenClipboard failed: {e}"))?; + let mut formats = Vec::new(); + let mut format = 0u32; + loop { + format = EnumClipboardFormats(format); + if format == 0 { + break; + } + if format == CF_BITMAP.0 as u32 { + // GDI object, not global memory: duplicate the handle instead. + if let Ok(handle) = GetClipboardData(CF_BITMAP.0 as u32) { + if let Ok(copy) = + CopyImage(handle, IMAGE_BITMAP_TYPE, 0, 0, LR_CREATEDIBSECTION_FLAG) + { + if let Ok(mut slot) = shared.saved_bitmap.lock() { + *slot = Some(copy.0 as usize); + } + } + } + continue; + } + // Formats whose handles are not plain global memory cannot be + // byte-copied; skipping them matches what the legacy path restored. + if format == CF_ENHMETAFILE.0 as u32 + || format == CF_DSPENHMETAFILE.0 as u32 + || format == CF_DSPBITMAP.0 as u32 + || format == CF_DSPMETAFILEPICT.0 as u32 + || format == CF_DSPTEXT.0 as u32 + || format == CF_OWNERDISPLAY.0 as u32 + || format == CF_PALETTE.0 as u32 + { + continue; + } + if let Ok(handle) = GetClipboardData(format) { + let hg = HGLOBAL(handle.0); + let size = GlobalSize(hg); + if size == 0 || size > MAX_FORMAT_BYTES { + continue; + } + let ptr = GlobalLock(hg) as *const u8; + if ptr.is_null() { + continue; + } + let data = std::slice::from_raw_parts(ptr, size).to_vec(); + let _ = GlobalUnlock(hg); + formats.push(SavedFormat { format, data }); + } + } + let _ = CloseClipboard(); + if let Ok(mut slot) = shared.snapshot.lock() { + *slot = formats; + } + Ok(()) +} + +/// Publishes the transcript as a delayed-render promise plus clipboard +/// history / cloud / monitoring opt-out markers (the same formats Chrome uses +/// for Incognito copies). Returns the new clipboard sequence number. +unsafe fn publish(hwnd: HWND) -> Result { + OpenClipboard(Some(hwnd)).map_err(|e| format!("OpenClipboard failed: {e}"))?; + let published = publish_formats(); + let closed = CloseClipboard(); + published?; + closed.map_err(|e| format!("CloseClipboard failed: {e}"))?; + Ok(GetClipboardSequenceNumber()) +} + +/// Everything `publish` does while the clipboard is open, split out so +/// `publish` closes the clipboard on every path — bailing out while holding it +/// open (and possibly already emptied) would strand the clipboard and leave +/// the legacy fallback snapshotting nothing. +unsafe fn publish_formats() -> Result<(), String> { + EmptyClipboard().map_err(|e| format!("EmptyClipboard failed: {e}"))?; + + for (name, value) in [ + ("ExcludeClipboardContentFromMonitorProcessing", 1u32), + ("CanIncludeInClipboardHistory", 0u32), + ("CanUploadToCloudClipboard", 0u32), + ] { + let name_wide = wide(name); + let format = RegisterClipboardFormatW(PCWSTR(name_wide.as_ptr())); + if format == 0 { + continue; + } + if let Ok(hg) = GlobalAlloc(GMEM_MOVEABLE, std::mem::size_of::()) { + let ptr = GlobalLock(hg) as *mut u32; + if !ptr.is_null() { + *ptr = value; + let _ = GlobalUnlock(hg); + if SetClipboardData(format, Some(HANDLE(hg.0))).is_err() { + let _ = GlobalFree(Some(hg)); + } + } else { + let _ = GlobalFree(Some(hg)); + } + } + } + + // NULL handle = delayed rendering: we are only asked for the data (via + // WM_RENDERFORMAT) when a consumer actually reads it. + SetClipboardData(CF_UNICODETEXT.0 as u32, None) + .map_err(|e| format!("SetClipboardData failed: {e}"))?; + Ok(()) +} + +fn on_timer(_hwnd: HWND, shared: &WinTxShared) { + let now = Instant::now(); + let finish = { + let mut st = match shared.state.lock() { + Ok(st) => st, + Err(_) => return, + }; + if st.cancelled { + true + } else { + match evaluate(&st, now) { + WaitDecision::KeepWaiting => false, + WaitDecision::Finish => { + st.cancelled = true; + true + } + } + } + }; + if !finish { + return; + } + + let (receipt, ownership_lost, injection_failed) = { + let st = match shared.state.lock() { + Ok(st) => st, + Err(_) => return, + }; + ( + st.any_receipt_after_injection(), + st.ownership_lost, + st.injection_failed, + ) + }; + if ownership_lost { + info!("[reliable-paste] settling: clipboard ownership lost"); + } else if receipt { + info!("[reliable-paste] settling: reads went quiet"); + } else if injection_failed { + info!("[reliable-paste] settling: chord injection failed, restoring quickly"); + } else { + info!("[reliable-paste] settling: no read within timeout, restoring anyway"); + } + + // Auto-submit only once the target demonstrably read the transcript; + // pressing Enter after an unconfirmed paste could submit stale content. + if shared.auto_submit && receipt { + send_auto_submit(shared); + } + + let sequence = *shared.sequence.lock().unwrap(); + let still_ours = !ownership_lost && unsafe { GetClipboardSequenceNumber() } == sequence; + if still_ours { + unsafe { settle_clipboard(shared) }; + } else { + info!("[reliable-paste] clipboard changed externally; leaving it untouched"); + } + + if let Ok(mut slot) = PENDING.lock() { + let is_us = slot + .as_ref() + .map(|pending| Arc::as_ptr(pending) as *const WinTxShared == shared as *const _) + .unwrap_or(false); + if is_us { + *slot = None; + } + } + + unsafe { + PostQuitMessage(0); + } +} + +unsafe fn destroy_window_and_shared(hwnd: HWND) { + let ptr = shared_ptr(hwnd); + let _ = DestroyWindow(hwnd); + if !ptr.is_null() { + drop(Arc::from_raw(ptr)); + } +} + +fn pump_thread(shared: Arc, ready: Sender>) { + unsafe { + // Settle any previous transaction first so the snapshot captures the + // user's original clipboard, not the previous transcript. + flush_pending(); + + let hinstance = match GetModuleHandleW(PCWSTR::null()) { + Ok(hmodule) => HINSTANCE(hmodule.0), + Err(e) => { + let _ = ready.send(Err(format!("GetModuleHandle failed: {e}"))); + return; + } + }; + ensure_window_class(hinstance); + + let hwnd = match CreateWindowExW( + WINDOW_EX_STYLE::default(), + CLASS_NAME, + w!("HandyPasteTx"), + WINDOW_STYLE::default(), + 0, + 0, + 0, + 0, + Some(HWND_MESSAGE), + None, + Some(hinstance), + None, + ) { + Ok(hwnd) => hwnd, + Err(e) => { + let _ = ready.send(Err(format!("CreateWindowEx failed: {e}"))); + return; + } + }; + SetWindowLongPtrW( + hwnd, + GWLP_USERDATA, + Arc::into_raw(shared.clone()) as *const _ as isize, + ); + + let published = match snapshot_clipboard(hwnd, &shared) { + Ok(()) => match publish(hwnd) { + Ok(sequence) => Ok(sequence), + Err(e) => { + // publish may have emptied the clipboard before failing; + // put the snapshot back so the legacy fallback's own + // snapshot captures the user's clipboard, not an empty one. + restore_snapshot(&shared); + Err(e) + } + }, + Err(e) => Err(e), + }; + let sequence = match published { + Ok(sequence) => sequence, + Err(e) => { + destroy_window_and_shared(hwnd); + let _ = ready.send(Err(e)); + return; + } + }; + *shared.sequence.lock().unwrap() = sequence; + shared.state.lock().unwrap().published_at = Instant::now(); + if let Ok(mut slot) = PENDING.lock() { + *slot = Some(shared.clone()); + } + let _ = SetTimer(Some(hwnd), TIMER_ID, TIMER_INTERVAL_MS, None); + let _ = ready.send(Ok(())); + + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + let _ = DispatchMessageW(&msg); + } + + let _ = KillTimer(Some(hwnd), TIMER_ID); + destroy_window_and_shared(hwnd); + } +} + +pub(super) fn run( + text: &str, + app_handle: &tauri::AppHandle, + paste_method: &PasteMethod, + enigo: &mut enigo::Enigo, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, + clipboard_handling: ClipboardHandling, +) -> Result<(), String> { + let shared = Arc::new(WinTxShared { + state: Mutex::new(TxState::new()), + text: text.to_string(), + snapshot: Mutex::new(Vec::new()), + saved_bitmap: Mutex::new(None), + sequence: Mutex::new(0), + app_handle: app_handle.clone(), + auto_submit, + auto_submit_key, + preserve_transcript: clipboard_handling == ClipboardHandling::CopyToClipboard, + }); + + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let shared_for_pump = shared.clone(); + thread::spawn(move || pump_thread(shared_for_pump, ready_tx)); + + // Wait until the transcript is actually published (or the worker reports + // why it could not) before injecting the chord. + match ready_rx.recv() { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e), + Err(_) => return Err("reliable paste worker died before publishing".to_string()), + } + info!("[reliable-paste] published transcript (delayed render)"); + + // Mark injection *before* sending: enigo holds the chord for ~100ms and a + // fast target may legitimately read while the chord is still held. + shared.state.lock().unwrap().injected_at = Some(Instant::now()); + match send_chord(enigo, paste_method) { + Ok(()) => { + info!("[reliable-paste] paste chord sent ({paste_method:?})"); + } + Err(e) => { + // Keep the transaction alive: the worker restores the clipboard + // after the short failed-injection timeout. + shared.state.lock().unwrap().injection_failed = true; + error!("[reliable-paste] failed to send paste chord: {e}"); + } + } + + Ok(()) +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 5fa6279f0e..7cf32fec60 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -443,6 +443,11 @@ pub struct AppSettings { pub paste_delay_ms: u64, #[serde(default = "default_paste_delay_after_ms")] pub paste_delay_after_ms: u64, + /// Debug-gated ("beta") receipt-sequenced paste: restore the clipboard only + /// after the target app actually reads the transcript, instead of after a + /// fixed delay. See `paste_tx`. macOS and Windows only. + #[serde(default)] + pub reliable_paste: bool, #[serde(default = "default_typing_tool")] pub typing_tool: TypingTool, #[serde(default)] @@ -885,6 +890,7 @@ pub fn get_default_settings() -> AppSettings { show_tray_icon: default_show_tray_icon(), paste_delay_ms: default_paste_delay_ms(), paste_delay_after_ms: default_paste_delay_after_ms(), + reliable_paste: false, typing_tool: default_typing_tool(), external_script_path: None, custom_filler_words: None, diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 8fb2bb40d7..d69bc0ea75 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -855,6 +855,15 @@ pub fn change_paste_delay_after_ms_setting(app: AppHandle, ms: u64) -> Result<() Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_reliable_paste_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.reliable_paste = enabled; + settings::write_settings(&app, settings); + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), String> { diff --git a/src/bindings.ts b/src/bindings.ts index e7ef242efa..e850f73335 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -149,6 +149,14 @@ async changePasteDelayAfterMsSetting(ms: number) : Promise> else return { status: "error", error: e as any }; } }, +async changeReliablePasteSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_reliable_paste_setting", { enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changePasteMethodSetting(method: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_paste_method_setting", { method }) }; @@ -916,7 +924,13 @@ bindings?: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk?: boolean * upgrading from before this key existed are blanked by the migration so they * see the current release's notes — see `apply_settings_migrations`. */ -whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; 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; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; 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; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; typing_tool?: TypingTool; external_script_path?: string | null; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; +whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; 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; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; 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; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; +/** + * Debug-gated ("beta") receipt-sequenced paste: restore the clipboard only + * after the target app actually reads the transcript, instead of after a + * fixed delay. See `paste_tx`. macOS and Windows only. + */ +reliable_paste?: boolean; typing_tool?: TypingTool; external_script_path?: string | null; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; /** * Which recording overlay to show: None / Minimal / Live. Streaming mode is * not gated on this — that follows model capability. Migrated from the old diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index c3e1b339d0..dd65ae5740 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -4,6 +4,7 @@ import { WordCorrectionThreshold } from "./WordCorrectionThreshold"; import { LogLevelSelector } from "./LogLevelSelector"; import { LiveLogViewer } from "./LiveLogViewer"; import { PasteDelay } from "./PasteDelay"; +import { ReliablePasteToggle } from "./ReliablePaste"; import { RecordingBuffer } from "./RecordingBuffer"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone"; @@ -35,6 +36,7 @@ export const DebugSettings: React.FC = () => { labelKey="settings.debug.pasteDelayAfter.title" descriptionKey="settings.debug.pasteDelayAfter.description" /> + diff --git a/src/components/settings/debug/ReliablePaste.tsx b/src/components/settings/debug/ReliablePaste.tsx new file mode 100644 index 0000000000..7dc9b0d8ee --- /dev/null +++ b/src/components/settings/debug/ReliablePaste.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ToggleSwitch } from "../../ui/ToggleSwitch"; +import { useSettings } from "../../../hooks/useSettings"; +import { useOsType } from "../../../hooks/useOsType"; + +interface ReliablePasteToggleProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const ReliablePasteToggle: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating } = useSettings(); + const osType = useOsType(); + + // The receipt-sequenced paste path is implemented for macOS and Windows. + if (osType !== "macos" && osType !== "windows") { + return null; + } + + return ( + updateSetting("reliable_paste", enabled)} + isUpdating={isUpdating("reliable_paste")} + label={t("settings.debug.reliablePaste.title")} + description={t("settings.debug.reliablePaste.description")} + descriptionMode={descriptionMode} + grouped={grouped} + /> + ); +}; diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 955971120b..30c95c2ed9 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -504,6 +504,10 @@ "title": "تأخير اللصق (بعد)", "description": "التأخير (بالمللي ثانية) بعد ضغطة مفتاح اللصق وقبل استعادة محتوى الحافظة السابق. قم بزيادته إذا تم لصق محتوى الحافظة القديم بدلاً من النص المُفرَّغ." }, + "reliablePaste": { + "title": "لصق موثوق (Beta)", + "description": "استعادة الحافظة فقط بعد أن يقرأ التطبيق المستهدف النص المُفرَّغ فعلياً، بدلاً من الاعتماد على تأخير ثابت. يهدف إلى إصلاح لصق محتوى الحافظة القديم عند وجود حمل على النظام. يتطلب طريقة لصق تعتمد على الحافظة؛ على macOS وWindows فقط." + }, "recordingBuffer": { "title": "مخزن التسجيل الإضافي", "description": "وقت إضافي (بالمللي ثانية) للاستمرار في التسجيل بعد تحرير المفتاح، لالتقاط الصوت المتبقي. 0 = لا مخزن إضافي." diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 5b73ae8131..c676b3d9ca 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -525,6 +525,10 @@ "title": "Забавяне при поставяне (след)", "description": "Забавяне (в милисекунди) след клавиша за поставяне, преди възстановяване на предишния клипборд. Увеличете, ако вместо транскрипцията се поставя старото съдържание на клипборда." }, + "reliablePaste": { + "title": "Надеждно поставяне (Beta)", + "description": "Възстановява клипборда само след като целевото приложение действително прочете транскрипцията, вместо след фиксирано забавяне. Предназначено да отстрани поставянето на старото съдържание на клипборда при натоварване на системата. Изисква начин на поставяне през клипборда; само за macOS и Windows." + }, "recordingBuffer": { "title": "Допълнителен буфер на записа", "description": "Допълнително време (в милисекунди) за запис след пускане на клавиша, за да се улови краят на аудиото. 0 = без допълнителен буфер." diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 0b7771fa80..34205dea01 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -525,6 +525,10 @@ "title": "Zpoždění vložení (po)", "description": "Zpoždění (v milisekundách) po klávese pro vložení, před obnovením předchozí schránky. Zvyšte, pokud se místo přepisu vkládá starý obsah schránky." }, + "reliablePaste": { + "title": "Spolehlivé vložení (Beta)", + "description": "Obnoví schránku teprve poté, co cílová aplikace skutečně přečte přepis, místo po pevně daném zpoždění. Má odstranit vkládání starého obsahu schránky při zatížení systému. Vyžaduje způsob vložení přes schránku; pouze macOS a Windows." + }, "recordingBuffer": { "title": "Extra vyrovnávací paměť nahrávání", "description": "Extra čas (v milisekundách) pro pokračování nahrávání po uvolnění klávesy, pro zachycení zbývajícího zvuku. 0 = žádná extra vyrovnávací paměť." diff --git a/src/i18n/locales/da/translation.json b/src/i18n/locales/da/translation.json index 0f62ca4c49..e32fc566eb 100644 --- a/src/i18n/locales/da/translation.json +++ b/src/i18n/locales/da/translation.json @@ -532,6 +532,10 @@ "title": "Forsinkelse af indsættelse (efter)", "description": "Forsinkelse (i millisekunder) efter tastetrykket for indsætning, før din tidligere udklipsholder gendannes. Øg, hvis dit gamle udklipsholderindhold bliver indsat i stedet for transskriptionen." }, + "reliablePaste": { + "title": "Pålidelig indsættelse (Beta)", + "description": "Gendan først din udklipsholder, når målappen faktisk har læst transskriptionen, i stedet for efter en fast forsinkelse. Skal afhjælpe, at gammelt udklipsholderindhold indsættes, når systemet er belastet. Kræver en indsætningsmetode via udklipsholderen; kun macOS og Windows." + }, "recordingBuffer": { "title": "Ekstra optagelsesbuffer", "description": "Ekstra tid (i millisekunder) til at fortsætte optagelsen, efter du slipper tasten, for at fange efterfølgende lyd. 0 = ingen ekstra buffer." diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 3b5c59fb98..b5d4e56b60 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -525,6 +525,10 @@ "title": "Einfügeverzögerung (nachher)", "description": "Verzögerung (in Millisekunden) nach dem Einfüge-Tastendruck, bevor deine vorherige Zwischenablage wiederhergestellt wird. Erhöhe den Wert, wenn der alte Inhalt der Zwischenablage statt der Transkription eingefügt wird." }, + "reliablePaste": { + "title": "Zuverlässiges Einfügen (Beta)", + "description": "Die Zwischenablage erst wiederherstellen, wenn die Ziel-App die Transkription tatsächlich gelesen hat, statt nach einer festen Verzögerung. Soll verhindern, dass bei Systemlast der alte Inhalt der Zwischenablage eingefügt wird. Erfordert eine Einfügemethode über die Zwischenablage; nur macOS und Windows." + }, "recordingBuffer": { "title": "Zusätzlicher Aufnahmepuffer", "description": "Zusätzliche Zeit (in Millisekunden), um nach dem Loslassen der Taste weiter aufzunehmen, um nachlaufendes Audio zu erfassen. 0 = kein zusätzlicher Puffer." diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 1c23046bfb..0b12d86944 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -532,6 +532,10 @@ "title": "Paste Delay (After)", "description": "Delay (in milliseconds) after the paste keystroke, before restoring your previous clipboard. Increase if your old clipboard content is being pasted instead of the transcription." }, + "reliablePaste": { + "title": "Reliable Paste (Beta)", + "description": "Restore the clipboard only after the target app actually reads the transcription, instead of after a fixed delay. Intended to fix old clipboard content being pasted under system load. Requires a clipboard paste method; macOS and Windows only." + }, "recordingBuffer": { "title": "Extra Recording Buffer", "description": "Extra time (in milliseconds) to keep recording after you release the key, to capture trailing audio. 0 = no extra buffer." diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 58f215b6f6..dc132b412d 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -525,6 +525,10 @@ "title": "Retraso de pegado (después)", "description": "Retraso (en milisegundos) después de la pulsación de tecla de pegar, antes de restaurar el portapapeles anterior. Auméntelo si se pega el contenido antiguo del portapapeles en lugar de la transcripción." }, + "reliablePaste": { + "title": "Pegado fiable (Beta)", + "description": "Restaura el portapapeles solo después de que la aplicación de destino lea realmente la transcripción, en lugar de tras un retraso fijo. Pensado para corregir que se pegue el contenido antiguo del portapapeles cuando el sistema está cargado. Requiere un método de pegado por portapapeles; solo macOS y Windows." + }, "recordingBuffer": { "title": "Búfer de grabación adicional", "description": "Tiempo adicional (en milisegundos) para seguir grabando después de soltar la tecla, para capturar el audio restante. 0 = sin búfer adicional." diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 55aa46722e..cc16c5a4bd 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -525,6 +525,10 @@ "title": "Délai de collage (après)", "description": "Délai (en millisecondes) après la touche de collage, avant la restauration de votre presse-papiers précédent. Augmentez si l'ancien contenu du presse-papiers est collé à la place de la transcription." }, + "reliablePaste": { + "title": "Collage fiable (Beta)", + "description": "Restaure le presse-papiers uniquement après que l'application cible a réellement lu la transcription, au lieu d'attendre un délai fixe. Vise à corriger le collage de l'ancien contenu du presse-papiers lorsque le système est chargé. Nécessite une méthode de collage par presse-papiers ; macOS et Windows uniquement." + }, "recordingBuffer": { "title": "Tampon d'enregistrement supplémentaire", "description": "Temps supplémentaire (en millisecondes) pour continuer l'enregistrement après avoir relâché la touche, pour capturer l'audio restant. 0 = pas de tampon supplémentaire." diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 335b4e3786..3acaee7a03 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -525,6 +525,10 @@ "title": "השהיית הדבקה (אחרי)", "description": "השהיה (במילישניות) לאחר צירוף ההדבקה, לפני שחזור הלוח הקודם. הגדל אם התוכן הישן של הלוח מודבק במקום התמלול." }, + "reliablePaste": { + "title": "הדבקה אמינה (Beta)", + "description": "שחזור הלוח רק אחרי שאפליקציית היעד קוראת בפועל את התמלול, במקום אחרי השהיה קבועה. נועד לתקן הדבקה של תוכן לוח ישן כשהמערכת עמוסה. דורש שיטת הדבקה דרך הלוח; ב-macOS וב-Windows בלבד." + }, "recordingBuffer": { "title": "באפר הקלטה נוסף", "description": "זמן נוסף (במילישניות) להמשך הקלטה אחרי שחרור המקש, כדי ללכוד סוף דיבור. 0 = בלי באפר נוסף." diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index b63c058e60..23b871e94d 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -532,6 +532,10 @@ "title": "पेस्ट में देरी (बाद में)", "description": "पेस्ट कीस्ट्रोक के बाद और आपका पिछला क्लिपबोर्ड वापस लाने से पहले की देरी (मिलीसेकंड में). अगर ट्रांसक्रिप्शन की जगह आपके क्लिपबोर्ड का पुराना कॉन्टेंट पेस्ट हो रहा हो, तो इसे बढ़ाएं." }, + "reliablePaste": { + "title": "भरोसेमंद पेस्ट (Beta)", + "description": "क्लिपबोर्ड को तय देरी के बाद नहीं, बल्कि टारगेट ऐप असल में ट्रांसक्रिप्शन पढ़ लेने के बाद ही वापस लाएं. यह सिस्टम पर लोड होने के दौरान पुराना क्लिपबोर्ड कॉन्टेंट पेस्ट होने की समस्या ठीक करने के लिए है. इसके लिए क्लिपबोर्ड वाला पेस्ट तरीका ज़रूरी है; सिर्फ़ macOS और Windows पर." + }, "recordingBuffer": { "title": "अतिरिक्त रिकॉर्डिंग बफ़र", "description": "बटन छोड़ने के बाद भी रिकॉर्डिंग जारी रखने का अतिरिक्त समय (मिलीसेकंड में), ताकि आखिरी हिस्से का ऑडियो कैप्चर हो सके. 0 = कोई अतिरिक्त बफ़र नहीं." diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index ba26136835..bf03bf63fb 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -525,6 +525,10 @@ "title": "Ritardo incolla (dopo)", "description": "Ritardo (in millisecondi) dopo il tasto incolla, prima di ripristinare gli appunti precedenti. Aumentare se viene incollato il vecchio contenuto degli appunti invece della trascrizione." }, + "reliablePaste": { + "title": "Incolla affidabile (Beta)", + "description": "Ripristina gli appunti solo dopo che l'app di destinazione ha effettivamente letto la trascrizione, anziché dopo un ritardo fisso. Serve a risolvere l'incollaggio del vecchio contenuto degli appunti quando il sistema è sotto carico. Richiede un metodo di incollaggio tramite appunti; solo macOS e Windows." + }, "recordingBuffer": { "title": "Buffer di registrazione extra", "description": "Tempo extra (in millisecondi) per continuare a registrare dopo aver rilasciato il tasto, per catturare l'audio finale. 0 = nessun buffer extra." diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index b43b07f1a8..869681e8e5 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -525,6 +525,10 @@ "title": "貼り付け遅延(後)", "description": "貼り付けキーを送信した後、以前のクリップボードを復元する前の遅延(ミリ秒)。文字起こしではなく古いクリップボードの内容が貼り付けられる場合は増やしてください。" }, + "reliablePaste": { + "title": "確実な貼り付け(Beta)", + "description": "固定の遅延ではなく、対象アプリが実際に文字起こしを読み取った後にのみクリップボードを復元します。システム負荷が高いときに古いクリップボードの内容が貼り付けられる問題を解決するための設定です。クリップボードを使う貼り付け方法が必要です。macOS と Windows のみ。" + }, "recordingBuffer": { "title": "追加録音バッファ", "description": "キーを離した後に録音を続ける追加時間(ミリ秒)。末尾の音声を捕捉するため。0 = 追加バッファなし。" diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index a41fa08841..1851283b15 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -520,6 +520,10 @@ "title": "붙여넣기 지연 (이후)", "description": "붙여넣기 키 입력을 보낸 후 이전 클립보드를 복원하기 전의 지연 시간(밀리초). 전사된 텍스트 대신 이전 클립보드 내용이 붙여넣어지면 늘리세요." }, + "reliablePaste": { + "title": "안정적인 붙여넣기 (Beta)", + "description": "고정된 지연 시간이 아니라 대상 앱이 실제로 전사된 텍스트를 읽은 뒤에만 클립보드를 복원합니다. 시스템 부하가 높을 때 이전 클립보드 내용이 붙여넣어지는 문제를 해결하기 위한 옵션입니다. 클립보드를 사용하는 붙여넣기 방법이 필요하며 macOS와 Windows에서만 지원됩니다." + }, "recordingBuffer": { "title": "추가 녹음 버퍼", "description": "키를 놓은 후 추가로 녹음을 계속하는 시간(밀리초). 후행 오디오를 캡처하기 위함. 0 = 추가 버퍼 없음." diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index be89c29a2c..02711d8db9 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -532,6 +532,10 @@ "title": "पेस्ट ढिलाइ (पछि)", "description": "पेस्ट किस्ट्रोकपछि, तपाईंको अघिल्लो क्लिपबोर्ड पुनर्स्थापना गर्नुअघिको ढिलाइ (मिलिसेकेन्डमा)। ट्रान्सक्रिप्सनको सट्टा पुरानो क्लिपबोर्ड सामग्री पेस्ट भइरहेको छ भने बढाउनुहोस्।" }, + "reliablePaste": { + "title": "भरपर्दो पेस्ट (Beta)", + "description": "निश्चित ढिलाइपछि नभई, लक्षित एपले ट्रान्सक्रिप्सन साँच्चै पढेपछि मात्र क्लिपबोर्ड पुनर्स्थापना गर्नुहोस्। प्रणालीमा भार बढ्दा पुरानो क्लिपबोर्ड सामग्री पेस्ट हुने समस्या समाधान गर्न बनाइएको। क्लिपबोर्ड प्रयोग गर्ने पेस्ट विधि आवश्यक; macOS र Windows मा मात्र।" + }, "recordingBuffer": { "title": "अतिरिक्त रेकर्डिङ बफर", "description": "कि छोडेपछि अन्तिम अडियो समात्न रेकर्डिङ जारी राख्ने अतिरिक्त समय (मिलिसेकेन्डमा)। 0 = कुनै अतिरिक्त बफर छैन।" diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 68f89f1261..0f644a3112 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -532,6 +532,10 @@ "title": "Plakvertraging (na)", "description": "Vertraging (in milliseconden) na de plak-toetsaanslag, voordat je vorige klembord wordt hersteld. Verhoog dit als de oude klembordinhoud wordt geplakt in plaats van de transcriptie." }, + "reliablePaste": { + "title": "Betrouwbaar plakken (Beta)", + "description": "Herstel het klembord pas nadat de doelapp de transcriptie daadwerkelijk heeft gelezen, in plaats van na een vaste vertraging. Bedoeld om te verhelpen dat bij systeembelasting de oude klembordinhoud wordt geplakt. Vereist een plakmethode via het klembord; alleen macOS en Windows." + }, "recordingBuffer": { "title": "Extra opnamebuffer", "description": "Extra tijd (in milliseconden) om te blijven opnemen nadat je de toets loslaat, om naloopaudio op te vangen. 0 = geen extra buffer." diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index a3e0f9e680..4bcd765512 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -525,6 +525,10 @@ "title": "Opóźnienie wklejania (po)", "description": "Opóźnienie (w milisekundach) po klawiszu wklejania, przed przywróceniem poprzedniego schowka. Zwiększ, jeśli zamiast transkrypcji wklejana jest stara zawartość schowka." }, + "reliablePaste": { + "title": "Niezawodne wklejanie (Beta)", + "description": "Przywracaj schowek dopiero po tym, jak docelowa aplikacja faktycznie odczyta transkrypcję, a nie po ustalonym opóźnieniu. Ma naprawić wklejanie starej zawartości schowka przy obciążeniu systemu. Wymaga metody wklejania przez schowek; tylko macOS i Windows." + }, "recordingBuffer": { "title": "Dodatkowy bufor nagrywania", "description": "Dodatkowy czas (w milisekundach) na kontynuowanie nagrywania po zwolnieniu klawisza, aby przechwycić końcowy dźwięk. 0 = brak dodatkowego bufora." diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 1f2aca38bb..68de502883 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -525,6 +525,10 @@ "title": "Atraso de colagem (depois)", "description": "Atraso (em milissegundos) após a tecla de colar, antes de restaurar a área de transferência anterior. Aumente se o conteúdo antigo da área de transferência estiver sendo colado em vez da transcrição." }, + "reliablePaste": { + "title": "Colagem confiável (Beta)", + "description": "Restaura a área de transferência somente depois que o aplicativo de destino realmente lê a transcrição, em vez de após um atraso fixo. Serve para corrigir a colagem do conteúdo antigo da área de transferência quando o sistema está sobrecarregado. Requer um método de colar via área de transferência; apenas macOS e Windows." + }, "recordingBuffer": { "title": "Buffer de gravação extra", "description": "Tempo extra (em milissegundos) para continuar gravando após soltar a tecla, para capturar áudio restante. 0 = sem buffer extra." diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index e5f5a3e918..6a05b781fa 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -525,6 +525,10 @@ "title": "Задержка вставки (после)", "description": "Задержка (в миллисекундах) после нажатия клавиши вставки, перед восстановлением предыдущего буфера обмена. Увеличьте, если вместо транскрипции вставляется старое содержимое буфера обмена." }, + "reliablePaste": { + "title": "Надёжная вставка (Beta)", + "description": "Восстанавливать буфер обмена только после того, как целевое приложение действительно прочитает транскрипцию, а не по фиксированной задержке. Предназначено для устранения вставки старого содержимого буфера обмена при нагрузке на систему. Требуется метод вставки через буфер обмена; только macOS и Windows." + }, "recordingBuffer": { "title": "Дополнительный буфер записи", "description": "Дополнительное время (в миллисекундах) для продолжения записи после отпускания клавиши, чтобы захватить завершающий звук. 0 = без дополнительного буфера." diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 0774ef2c56..83fb39b18a 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -525,6 +525,10 @@ "title": "Fördröjning vid klistra in (efter)", "description": "Fördröjning (i millisekunder) efter tangenttryckningen för att klistra in, innan ditt tidigare urklipp återställs. Öka om det gamla urklippsinnehållet klistras in i stället för transkriptionen." }, + "reliablePaste": { + "title": "Tillförlitlig inklistring (Beta)", + "description": "Återställ urklipp först när målappen faktiskt har läst transkriptionen, i stället för efter en fast fördröjning. Avsett att åtgärda att det gamla urklippsinnehållet klistras in när systemet är belastat. Kräver en klistra-in-metod via urklipp; endast macOS och Windows." + }, "recordingBuffer": { "title": "Extra inspelningsbuffert", "description": "Extra tid (i millisekunder) för att fortsätta spela in efter att du släppt tangenten, för att fånga upp avslutande ljud. 0 = ingen extra buffert." diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index cbf3eae680..5865cd28cc 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -525,6 +525,10 @@ "title": "Yapıştırma gecikmesi (sonra)", "description": "Yapıştırma tuşundan sonra, önceki panonuz geri yüklenmeden önceki gecikme (milisaniye cinsinden). Yazıya dökülen metin yerine eski pano içeriği yapıştırılıyorsa artırın." }, + "reliablePaste": { + "title": "Güvenilir yapıştırma (Beta)", + "description": "Panoyu sabit bir gecikmenin ardından değil, hedef uygulama yazıya dökülen metni gerçekten okuduktan sonra geri yükler. Sistem yükü altında eski pano içeriğinin yapıştırılması sorununu gidermeyi amaçlar. Pano üzerinden çalışan bir yapıştırma yöntemi gerektirir; yalnızca macOS ve Windows." + }, "recordingBuffer": { "title": "Ekstra kayıt tamponu", "description": "Tuşu bıraktıktan sonra arka plandaki sesi yakalamak için kaydı sürdürme süresi (milisaniye). 0 = ekstra tampon yok." diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index e33e73f122..da057f126c 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -525,6 +525,10 @@ "title": "Затримка вставки (після)", "description": "Затримка (у мілісекундах) після натискання клавіші вставки, перед відновленням попереднього буфера обміну. Збільшіть, якщо замість транскрипції вставляється старий вміст буфера обміну." }, + "reliablePaste": { + "title": "Надійна вставка (Beta)", + "description": "Відновлювати буфер обміну лише після того, як цільова програма справді прочитає транскрипцію, а не через фіксовану затримку. Призначено для усунення вставки старого вмісту буфера обміну під навантаженням на систему. Потрібен метод вставки через буфер обміну; лише macOS і Windows." + }, "recordingBuffer": { "title": "Додатковий буфер запису", "description": "Додатковий час (у мілісекундах) для продовження запису після відпускання клавіші, щоб захопити завершальний звук. 0 = без додаткового буфера." diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index a394366442..d1d2ce2fa2 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -525,6 +525,10 @@ "title": "Độ trễ dán (sau)", "description": "Độ trễ (tính bằng mili giây) sau khi gửi phím dán, trước khi khôi phục bảng nhớ tạm trước đó. Tăng nếu nội dung bảng nhớ tạm cũ được dán thay vì bản chép lời." }, + "reliablePaste": { + "title": "Dán đáng tin cậy (Beta)", + "description": "Chỉ khôi phục bảng nhớ tạm sau khi ứng dụng đích thực sự đọc bản chép lời, thay vì sau một độ trễ cố định. Nhằm khắc phục việc nội dung bảng nhớ tạm cũ bị dán khi hệ thống bị tải nặng. Cần một phương thức dán qua bảng nhớ tạm; chỉ dành cho macOS và Windows." + }, "recordingBuffer": { "title": "Bộ đệm ghi âm thêm", "description": "Thời gian thêm (tính bằng mili giây) để tiếp tục ghi âm sau khi nhả phím, để thu âm thanh cuối. 0 = không có bộ đệm thêm." diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 1dcf679924..092844cdbd 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -525,6 +525,10 @@ "title": "貼上延遲(後)", "description": "發送貼上按鍵後、還原先前剪貼簿前的延遲(毫秒)。如果貼上的是舊的剪貼簿內容而非轉錄文字,請增加此值。" }, + "reliablePaste": { + "title": "可靠貼上(Beta)", + "description": "在目標應用程式真正讀取轉錄文字之後才還原剪貼簿,而不是等待固定延遲。用於解決系統負載較高時貼上舊剪貼簿內容的問題。需要使用剪貼簿類的貼上方式;僅支援 macOS 與 Windows。" + }, "recordingBuffer": { "title": "額外錄音緩衝", "description": "放開按鍵後繼續錄音的額外時間(毫秒),以捕捉尾音。0 = 無額外緩衝。" diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 34976ba8c0..5f170e0caa 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -525,6 +525,10 @@ "title": "粘贴延迟(后)", "description": "发送粘贴按键后、恢复先前剪贴板前的延迟(毫秒)。如果粘贴的是旧的剪贴板内容而非转录文本,请增加此值。" }, + "reliablePaste": { + "title": "可靠粘贴(Beta)", + "description": "在目标应用程序真正读取转录文本之后再恢复剪贴板,而不是等待固定延迟。用于解决系统负载较高时粘贴旧剪贴板内容的问题。需要使用剪贴板类的粘贴方式;仅支持 macOS 和 Windows。" + }, "recordingBuffer": { "title": "额外录音缓冲", "description": "放开按键后继续录音的额外时间(毫秒),以捕捉尾音。0 = 无额外缓冲。" diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index b4e042829d..50f9b2c590 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -125,6 +125,8 @@ const settingUpdaters: { commands.changePasteDelayMsSetting(value as number), paste_delay_after_ms: (value) => commands.changePasteDelayAfterMsSetting(value as number), + reliable_paste: (value) => + commands.changeReliablePasteSetting(value as boolean), paste_method: (value) => commands.changePasteMethodSetting(value as string), typing_tool: (value) => commands.changeTypingToolSetting(value as string), external_script_path: (value) => From 7032758283edc09c120ae56d485ed7e4e89b216a Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sat, 1 Aug 2026 16:00:13 +0800 Subject: [PATCH 23/49] bump handy-keys to 0.3.3 --- src-tauri/Cargo.lock | 4 ++-- src-tauri/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fae1ad2096..e34f379100 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2513,9 +2513,9 @@ dependencies = [ [[package]] name = "handy-keys" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a8b8e4be98a7cade231df09a8f5625c1044febdf39eb6205325b896034ccc7f" +checksum = "71f78470c4a23919ba16e6d621fa2c429451faa98b073f4cba679c496b3e8fa2" dependencies = [ "bitflags 2.11.0", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 77051b487a..69342233dd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,7 +74,7 @@ sha2 = "0.10" # Canary, Cohere). Per-platform backend features are added in the target tables. transcribe-rs = { version = "0.3.8", features = ["onnx"] } transcribe-cpp = { version = "0.1.3", default-features = false } -handy-keys = "0.3.2" +handy-keys = "0.3.3" ferrous-opencc = "0.2.3" clap = { version = "4", features = ["derive"] } specta = "=2.0.0-rc.22" From 2266b29b798b60b74c98989c53876551238d6450 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sat, 1 Aug 2026 18:52:59 +0800 Subject: [PATCH 24/49] remove sigusr1 on linux (#1824) * drop sigusr1 on linux Co-authored-by: Murilo Vasconcelos * consolidate signal setup into signal_handle.rs --------- Co-authored-by: Murilo Vasconcelos --- README.md | 20 ++++++++++++++------ src-tauri/src/lib.rs | 12 ++++-------- src-tauri/src/signal_handle.rs | 24 ++++++++++++++++++++++-- src/content/release-notes/0.9.5.md | 18 ++++++++++++++++++ 4 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 src/content/release-notes/0.9.5.md diff --git a/README.md b/README.md index f1890809ea..cf22a0c541 100644 --- a/README.md +++ b/README.md @@ -194,22 +194,24 @@ Without these tools, Handy falls back to enigo which may have limited compatibil bind = $mainMod, O, exec, handy --toggle-transcription ``` -- You can also manage global shortcuts outside of Handy via Unix signals, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings: +- You can also trigger Handy externally via Unix signals or the CLI flags, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings: - | Signal | Action | Example | - | --------- | ----------------------------------------- | ---------------------- | - | `SIGUSR2` | Toggle transcription | `pkill -USR2 -n handy` | - | `SIGUSR1` | Toggle transcription with post-processing | `pkill -USR1 -n handy` | + | Action | Trigger | + | ----------------------------------------- | -------------------------------------------------------- | + | Toggle transcription | `pkill -USR2 -n handy` or `handy --toggle-transcription` | + | Toggle transcription with post-processing | `handy --toggle-post-process` | Example Sway config: ```ini bindsym $mod+o exec pkill -USR2 -n handy - bindsym $mod+p exec pkill -USR1 -n handy + bindsym $mod+p exec handy --toggle-post-process ``` `pkill` here simply delivers the signal—it does not terminate the process. + > **Behavior change:** older releases also accepted `SIGUSR1` for toggling transcription with post-processing. WebKitGTK — the webview engine embedded in Handy on Linux — uses SIGUSR1 internally to coordinate JavaScript garbage collection, so listening for it caused phantom recordings and interrupted dictations every few minutes ([#1660](https://github.com/cjpais/Handy/issues/1660)). Handy no longer listens for SIGUSR1 on Linux; the post-processing toggle is still available via `handy --toggle-post-process`. **Remove any `pkill -USR1` bindings**: the signal is now delivered straight to WebKit's internal handler and can crash the app. + **Overlay & Pasting Issues (Linux):** - The recording overlay window can interfere with pasting transcribed text into target applications on Linux (X11) @@ -468,6 +470,12 @@ Exec=env HANDY_NO_GTK_LAYER_SHELL=1 handy If a workaround helps you, please [open an issue](https://github.com/cjpais/Handy/issues) describing your distro, desktop environment, and session type — that information helps us narrow down the underlying bug. +### Handy Starts or Stops Recording on Its Own (Linux) + +Handy 0.9.4 and earlier listened for `SIGUSR1` as a remote-control trigger. WebKitGTK — the webview engine embedded in Handy on Linux — uses that same signal internally to coordinate JavaScript garbage collection, so GC cycles were misread as hotkey presses: recordings started on their own, or real dictations were cut off mid-sentence (typically ~2 minutes in). See [#1660](https://github.com/cjpais/Handy/issues/1660). + +Update to a newer release, and replace any `pkill -USR1 -n handy` keybindings with `handy --toggle-post-process`. + ### How to Contribute 1. **Check existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 621b6a0271..5bfcfbebae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,10 +33,6 @@ use managers::audio::AudioRecordingManager; use managers::history::HistoryManager; use managers::model::ModelManager; use managers::transcription::TranscriptionManager; -#[cfg(unix)] -use signal_hook::consts::{SIGUSR1, SIGUSR2}; -#[cfg(unix)] -use signal_hook::iterator::Signals; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::Arc; use tauri::image::Image; @@ -189,11 +185,11 @@ fn initialize_core_logic(app_handle: &AppHandle) { // after permissions are confirmed (on macOS) or after onboarding completes. // This matches the pattern used for Enigo initialization. + // Set up signal handlers for toggling transcription. On Linux, SIGUSR1 is + // deliberately not handled — it belongs to WebKitGTK's garbage collector + // (#1660) — see signal_handle.rs. #[cfg(unix)] - let signals = Signals::new([SIGUSR1, SIGUSR2]).unwrap(); - // Set up signal handlers for toggling transcription - #[cfg(unix)] - signal_handle::setup_signal_handler(app_handle.clone(), signals); + signal_handle::setup_signal_handler(app_handle.clone()); // Apply macOS Accessory policy if starting hidden and tray is available. // If the tray icon is disabled, keep the dock icon so the user can reopen. diff --git a/src-tauri/src/signal_handle.rs b/src-tauri/src/signal_handle.rs index 1f54c0d96e..efe01e8a55 100644 --- a/src-tauri/src/signal_handle.rs +++ b/src-tauri/src/signal_handle.rs @@ -4,8 +4,10 @@ use log::debug; use log::warn; use tauri::{AppHandle, Manager}; +#[cfg(target_os = "macos")] +use signal_hook::consts::SIGUSR1; #[cfg(unix)] -use signal_hook::consts::{SIGUSR1, SIGUSR2}; +use signal_hook::consts::SIGUSR2; #[cfg(unix)] use signal_hook::iterator::Signals; #[cfg(unix)] @@ -21,12 +23,30 @@ pub fn send_transcription_input(app: &AppHandle, binding_id: &str, source: &str) } } +/// Listen for Unix signals that remotely toggle transcription. +/// +/// SIGUSR2 toggles plain transcription on all Unix platforms. SIGUSR1 +/// (transcription with post-processing) is only handled on macOS: on Linux, +/// WebKitGTK's JavaScriptCore garbage collector sends SIGUSR1 to its own +/// threads to suspend them, so handling it caused phantom recordings on every +/// GC cycle (#1660). Linux users should use `handy --toggle-post-process` +/// instead. #[cfg(unix)] -pub fn setup_signal_handler(app_handle: AppHandle, mut signals: Signals) { +pub fn setup_signal_handler(app_handle: AppHandle) { + #[cfg(target_os = "macos")] + let mut signals = + Signals::new([SIGUSR1, SIGUSR2]).expect("failed to register transcription signal handlers"); + #[cfg(not(target_os = "macos"))] + let mut signals = + Signals::new([SIGUSR2]).expect("failed to register transcription signal handlers"); + #[cfg(target_os = "macos")] debug!("Signal handlers registered (SIGUSR1, SIGUSR2)"); + #[cfg(not(target_os = "macos"))] + debug!("Signal handler registered (SIGUSR2; SIGUSR1 is left to WebKitGTK)"); thread::spawn(move || { for sig in signals.forever() { let (binding_id, signal_name) = match sig { + #[cfg(target_os = "macos")] SIGUSR1 => ("transcribe_with_post_process", "SIGUSR1"), SIGUSR2 => ("transcribe", "SIGUSR2"), _ => continue, diff --git a/src/content/release-notes/0.9.5.md b/src/content/release-notes/0.9.5.md new file mode 100644 index 0000000000..8e38d23630 --- /dev/null +++ b/src/content/release-notes/0.9.5.md @@ -0,0 +1,18 @@ +## Linux: Phantom Recording Fix + +If Handy on Linux sometimes started recording on its own, or cut off a real +dictation mid-sentence (typically about 2 minutes in), that was a signal +conflict: Handy listened for `SIGUSR1` as a remote-control trigger, but +WebKitGTK — the webview engine embedded in Handy — uses the same signal +internally for JavaScript garbage collection. Every GC cycle looked like a +hotkey press ([#1660](https://github.com/cjpais/Handy/issues/1660)). + +Handy no longer listens for `SIGUSR1` on Linux. + +**If you bound `pkill -USR1 -n handy` to a hotkey, replace it with:** + +```bash +handy --toggle-post-process +``` + +`SIGUSR2` (plain transcription toggle) is unchanged and keeps working. From 0902937bb1799853bc60d23dad403d812cbb1e05 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sat, 1 Aug 2026 20:52:25 +0800 Subject: [PATCH 25/49] use SMAppService so Login Items shows Handy (#1825) --- src-tauri/Cargo.lock | 25 ++++++ src-tauri/Cargo.toml | 5 +- src-tauri/src/autostart.rs | 155 ++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 17 ++-- src-tauri/src/shortcut/mod.rs | 8 +- 5 files changed, 189 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/autostart.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e34f379100..3b482a39d2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2468,6 +2468,7 @@ dependencies = [ "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-service-management", "once_cell", "rdev", "regex", @@ -3973,6 +3974,30 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 69342233dd..fb506dc076 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -141,11 +141,12 @@ transcribe-cpp = { version = "0.1.3", default-features = false, features = [ transcribe-cpp = { version = "0.1.3", default-features = false } [target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-service-management = "0.3.2" tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } transcribe-cpp = { version = "0.1.3", default-features = false, features = ["metal"] } # Used by the receipt-sequenced ("reliable") paste path for lazy NSPasteboard -# promises. Versions track what Tauri already pulls in. -objc2 = "0.6" +# promises (objc2 above is shared). Versions track what Tauri already pulls in. objc2-foundation = "0.3" objc2-app-kit = "0.3" diff --git a/src-tauri/src/autostart.rs b/src-tauri/src/autostart.rs new file mode 100644 index 0000000000..bd20b5cf14 --- /dev/null +++ b/src-tauri/src/autostart.rs @@ -0,0 +1,155 @@ +//! Launch-at-login (autostart) handling. +//! +//! All platforms apply the setting through tauri-plugin-autostart, except +//! macOS 13+ where the app registers itself as a login item via +//! `SMAppService`. The plugin's launch agent plist carries no app +//! association, so the System Settings Login Items pane attributes it to the +//! code-signing certificate's developer name instead of the app (#337). +//! `SMAppService` login items are attributed to the app bundle itself and +//! appear under "Open at Login" with the app's name and icon. + +use tauri::AppHandle; +use tauri_plugin_autostart::ManagerExt; + +/// Apply the user's autostart preference using the best mechanism for the +/// current platform. +/// +/// Errors are logged rather than returned: the preference is re-applied on +/// every launch, so a transient failure self-heals and must not block +/// startup. This mirrors the pre-existing behavior of ignoring +/// enable()/disable() results. +pub fn apply_autostart(app: &AppHandle, enabled: bool) { + #[cfg(target_os = "macos")] + if macos::login_item_api_available() { + macos::remove_plugin_launch_agent(app); + macos::set_login_item(enabled); + return; + } + + let manager = app.autolaunch(); + let result = if enabled { + manager.enable() + } else { + manager.disable() + }; + if let Err(e) = result { + log::warn!( + "Failed to apply autostart setting (enabled={}): {}", + enabled, + e + ); + } +} + +#[cfg(target_os = "macos")] +mod macos { + use std::path::{Path, PathBuf}; + + use objc2::runtime::AnyClass; + use objc2_service_management::{SMAppService, SMAppServiceStatus}; + use tauri::{AppHandle, Manager}; + + /// `SMAppService` requires macOS 13. The ServiceManagement framework is + /// linked unconditionally (it has existed since 10.6), so looking up the + /// class doubles as the OS version check: present exactly when the API is + /// usable. + pub fn login_item_api_available() -> bool { + AnyClass::get(c"SMAppService").is_some() + } + + /// Register or unregister the app as a login item, skipping the call when + /// the service is already in the requested state (unregistering a + /// never-registered service returns an error on every launch otherwise). + pub fn set_login_item(enabled: bool) { + let service = unsafe { SMAppService::mainAppService() }; + let status = unsafe { service.status() }; + + if enabled { + if status == SMAppServiceStatus::Enabled { + return; + } + match unsafe { service.registerAndReturnError() } { + Ok(()) => log::info!("Registered login item via SMAppService"), + // Fails in dev (no signed app bundle) and when the user has + // switched the item off in System Settings, which apps are + // not allowed to override. + Err(e) => log::warn!("Failed to register login item: {}", e), + } + } else { + if status == SMAppServiceStatus::NotRegistered || status == SMAppServiceStatus::NotFound + { + return; + } + match unsafe { service.unregisterAndReturnError() } { + Ok(()) => log::info!("Unregistered login item via SMAppService"), + Err(e) => log::warn!("Failed to unregister login item: {}", e), + } + } + } + + /// Remove the launch agent plist that tauri-plugin-autostart (via the + /// auto-launch crate) wrote on older versions, so login doesn't start the + /// app twice after migrating to `SMAppService`. Runs on every launch; + /// missing file is the normal case. + pub fn remove_plugin_launch_agent(app: &AppHandle) { + let Ok(home) = app.path().home_dir() else { + return; + }; + remove_launch_agent_file(&plugin_launch_agent_path(&home, &app.package_info().name)); + } + + /// Path of the plist the auto-launch crate writes: + /// `~/Library/LaunchAgents/{app name}.plist`. + fn plugin_launch_agent_path(home: &Path, app_name: &str) -> PathBuf { + home.join("Library") + .join("LaunchAgents") + .join(format!("{}.plist", app_name)) + } + + fn remove_launch_agent_file(path: &Path) { + match std::fs::remove_file(path) { + Ok(()) => log::info!("Removed legacy autostart launch agent {:?}", path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => log::warn!("Failed to remove legacy launch agent {:?}: {}", path, e), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + /// Validates the assumption `login_item_api_available` rests on: the + /// ServiceManagement framework is linked into the binary, so the + /// class lookup finds `SMAppService` whenever the host is macOS 13+ + /// (which anything able to build this crate is). + #[test] + fn sm_app_service_class_resolves() { + assert!(login_item_api_available()); + } + + #[test] + fn launch_agent_path_matches_auto_launch_crate() { + let path = plugin_launch_agent_path(Path::new("/Users/someone"), "Handy"); + assert_eq!( + path, + Path::new("/Users/someone/Library/LaunchAgents/Handy.plist") + ); + } + + #[test] + fn removes_existing_launch_agent() { + let dir = tempfile::tempdir().unwrap(); + let plist = dir.path().join("Handy.plist"); + std::fs::write(&plist, "").unwrap(); + + remove_launch_agent_file(&plist); + assert!(!plist.exists()); + } + + #[test] + fn missing_launch_agent_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + remove_launch_agent_file(&dir.path().join("Handy.plist")); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5bfcfbebae..c4bdb7e944 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod actions; mod apple_intelligence; mod audio_feedback; pub mod audio_toolkit; +mod autostart; mod catalog; pub mod cli; mod clipboard; @@ -40,7 +41,7 @@ pub use transcription_coordinator::TranscriptionCoordinator; use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Emitter, Listener, Manager}; -use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; +use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_log::{Builder as LogBuilder, RotationStrategy, Target, TargetKind}; use crate::settings::get_settings; @@ -327,17 +328,9 @@ fn initialize_core_logic(app_handle: &AppHandle) { tray::update_tray_menu(&app_handle_for_listener, None); }); - // Get the autostart manager and configure based on user setting - let autostart_manager = app_handle.autolaunch(); - let settings = settings::get_settings(app_handle); - - if settings.autostart_enabled { - // Enable autostart if user has opted in - let _ = autostart_manager.enable(); - } else { - // Disable autostart if user has opted out - let _ = autostart_manager.disable(); - } + // Apply the autostart preference (SMAppService login item on macOS 13+, + // tauri-plugin-autostart elsewhere) + autostart::apply_autostart(app_handle, settings.autostart_enabled); // Create the recording overlay window (hidden by default) utils::create_recording_overlay(app_handle); diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index d69bc0ea75..ff3bc01e11 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -17,7 +17,6 @@ use log::{debug, error, info, warn}; use serde::Serialize; use specta::Type; use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_autostart::ManagerExt; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] use crate::settings::APPLE_INTELLIGENCE_DEFAULT_MODEL_ID; @@ -727,12 +726,7 @@ pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), Str settings::write_settings(&app, settings); // Apply the autostart setting immediately - let autostart_manager = app.autolaunch(); - if enabled { - let _ = autostart_manager.enable(); - } else { - let _ = autostart_manager.disable(); - } + crate::autostart::apply_autostart(&app, enabled); // Notify frontend let _ = app.emit( From f602ef4f6c39c1834f693ada3244a027712c98ee Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sat, 1 Aug 2026 20:53:31 +0800 Subject: [PATCH 26/49] fix cargo.toml up a bit --- src-tauri/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fb506dc076..9d6c33d21a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -141,14 +141,14 @@ transcribe-cpp = { version = "0.1.3", default-features = false, features = [ transcribe-cpp = { version = "0.1.3", default-features = false } [target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.6" -objc2-service-management = "0.3.2" tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } transcribe-cpp = { version = "0.1.3", default-features = false, features = ["metal"] } # Used by the receipt-sequenced ("reliable") paste path for lazy NSPasteboard -# promises (objc2 above is shared). Versions track what Tauri already pulls in. +# promises. Versions track what Tauri already pulls in. +objc2 = "0.6" objc2-foundation = "0.3" objc2-app-kit = "0.3" +objc2-service-management = "0.3.2" [target.'cfg(target_os = "linux")'.dependencies] gtk-layer-shell = { version = "0.8", features = ["v0_6"] } From 05ca5f48b519aa3afdb42d3c635ab86a6a9e25a7 Mon Sep 17 00:00:00 2001 From: Kaushik Samadder Date: Sat, 1 Aug 2026 18:39:08 +0530 Subject: [PATCH 27/49] docs: add missing Linux build dependencies to BUILD.md (#1561) The Linux dependency lists were missing libraries that several bundled crates require, so a clean Fedora build failed in three separate rounds: - evdev-sys needs libevdev (libevdev-devel / libevdev-dev / libevdev) - whisper-rs-sys (bindgen) needs libclang (clang + *-devel) - whisper-rs-sys (ggml-vulkan) needs the glslc shader compiler Fedora was missing all three; Ubuntu was missing libevdev + libclang; Arch was missing all three (shaderc provides glslc). Add them so a fresh Linux checkout builds without hunting down each failure. Co-authored-by: CJ Pais --- BUILD.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/BUILD.md b/BUILD.md index e3698dcc63..5b7fe16fcd 100644 --- a/BUILD.md +++ b/BUILD.md @@ -70,19 +70,19 @@ ORT_LIB_LOCATION=$(brew --prefix onnxruntime)/lib ORT_PREFER_DYNAMIC_LINK=1 bun ```bash # Ubuntu/Debian sudo apt update - sudo apt install build-essential libasound2-dev pkg-config libssl-dev libvulkan-dev vulkan-tools glslc spirv-headers glslang-tools libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-layer-shell0 libgtk-layer-shell-dev patchelf cmake + sudo apt install build-essential clang libclang-dev libevdev-dev libasound2-dev pkg-config libssl-dev libvulkan-dev vulkan-tools glslc spirv-headers glslang-tools libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-layer-shell0 libgtk-layer-shell-dev patchelf cmake # Fedora/RHEL sudo dnf groupinstall "Development Tools" - sudo dnf install alsa-lib-devel pkgconf openssl-devel vulkan-devel \ - spirv-headers-devel spirv-tools-devel glslang glslc \ + sudo dnf install alsa-lib-devel pkgconf openssl-devel vulkan-devel glslc \ + clang clang-devel libevdev-devel \ + spirv-headers-devel spirv-tools-devel glslang \ gtk3-devel webkit2gtk4.1-devel libappindicator-gtk3-devel librsvg2-devel \ gtk-layer-shell gtk-layer-shell-devel \ cmake # Arch Linux - sudo pacman -S base-devel alsa-lib pkgconf openssl vulkan-devel \ - spirv-headers glslang shaderc \ + sudo pacman -S base-devel clang libevdev shaderc spirv-headers glslang alsa-lib pkgconf openssl vulkan-devel \ gtk3 webkit2gtk-4.1 libappindicator-gtk3 librsvg gtk-layer-shell \ cmake ``` From 76736d5ac7bd6d4a6328a6ab392748aa35f5a12b Mon Sep 17 00:00:00 2001 From: Shehab Tarek <45536170+back1ply@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:27:08 +0300 Subject: [PATCH 28/49] fix: link portable update dialog to the correct installer (#1750) (#1753) * fix: link portable update dialog straight to the correct installer Portable installs can't self-update in place (no installer, and Windows won't let a running exe replace itself), so the update dialog directs users to download the NSIS setup.exe manually. It previously opened the generic releases page, forcing the user to hand-pick one of ~27 assets (issue #1750: "you need to find it"). Resolve the exact installer for the running architecture from the update version + `plugin-os` arch() and deep-link that asset directly. Falls back to the releases page when the version can't be determined. Also reword the dialog to note the Data/ folder is preserved on reinstall. URL construction is a pure helper with a standalone assert check (no unit-test runner in this repo; runnable via bun). Co-Authored-By: Claude Opus 4.8 * add proper translations and clean up the pr --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: CJ Pais --- .../update-checker/UpdateChecker.tsx | 27 +++++++- .../update-checker/portableInstaller.test.ts | 65 +++++++++++++++++++ .../update-checker/portableInstaller.ts | 41 ++++++++++++ src/i18n/locales/ar/translation.json | 6 +- src/i18n/locales/bg/translation.json | 6 +- src/i18n/locales/cs/translation.json | 6 +- src/i18n/locales/da/translation.json | 6 +- src/i18n/locales/de/translation.json | 6 +- src/i18n/locales/en/translation.json | 6 +- src/i18n/locales/es/translation.json | 6 +- src/i18n/locales/fr/translation.json | 6 +- src/i18n/locales/he/translation.json | 6 +- src/i18n/locales/hi/translation.json | 6 +- src/i18n/locales/it/translation.json | 6 +- src/i18n/locales/ja/translation.json | 6 +- src/i18n/locales/ko/translation.json | 6 +- src/i18n/locales/ne/translation.json | 6 +- src/i18n/locales/nl/translation.json | 6 +- src/i18n/locales/pl/translation.json | 6 +- src/i18n/locales/pt/translation.json | 6 +- src/i18n/locales/ru/translation.json | 6 +- src/i18n/locales/sv/translation.json | 6 +- src/i18n/locales/tr/translation.json | 6 +- src/i18n/locales/uk/translation.json | 6 +- src/i18n/locales/vi/translation.json | 6 +- src/i18n/locales/zh-TW/translation.json | 6 +- src/i18n/locales/zh/translation.json | 6 +- 27 files changed, 226 insertions(+), 51 deletions(-) create mode 100644 src/components/update-checker/portableInstaller.test.ts create mode 100644 src/components/update-checker/portableInstaller.ts diff --git a/src/components/update-checker/UpdateChecker.tsx b/src/components/update-checker/UpdateChecker.tsx index a104ebf4d5..aea87bf6d9 100644 --- a/src/components/update-checker/UpdateChecker.tsx +++ b/src/components/update-checker/UpdateChecker.tsx @@ -4,9 +4,14 @@ import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import { listen } from "@tauri-apps/api/event"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { arch, platform } from "@tauri-apps/plugin-os"; import { ProgressBar } from "../shared"; import { useSettings } from "../../hooks/useSettings"; import { commands } from "../../bindings"; +import { + resolvePortableInstallerUrl, + PORTABLE_RELEASES_URL, +} from "./portableInstaller"; interface UpdateCheckerProps { className?: string; @@ -22,6 +27,9 @@ const UpdateChecker: React.FC = ({ className = "" }) => { const [showUpToDate, setShowUpToDate] = useState(false); const [showPortableUpdateDialog, setShowPortableUpdateDialog] = useState(false); + const [portableInstallerUrl, setPortableInstallerUrl] = useState( + PORTABLE_RELEASES_URL, + ); const { settings, isLoading } = useSettings(); const settingsLoaded = !isLoading && settings !== null; @@ -72,6 +80,11 @@ const UpdateChecker: React.FC = ({ className = "" }) => { if (update) { setUpdateAvailable(true); setShowUpToDate(false); + // Portable installs can't self-update in place — the manual dialog links + // straight at the matching installer from this manifest instead. + setPortableInstallerUrl( + resolvePortableInstallerUrl(update.rawJson, platform(), arch()), + ); } else { setUpdateAvailable(false); @@ -182,6 +195,10 @@ const UpdateChecker: React.FC = ({ className = "" }) => { const isUpdateClickable = !isUpdateDisabled && (updateAvailable || (!isChecking && !showUpToDate)); + // When no installer could be resolved for this target the button falls back to + // the releases index, so the dialog has to say "browse" rather than "download". + const hasDirectInstaller = portableInstallerUrl !== PORTABLE_RELEASES_URL; + return ( <> {showPortableUpdateDialog && ( @@ -191,7 +208,9 @@ const UpdateChecker: React.FC = ({ className = "" }) => { {t("footer.portableUpdateTitle")}

- {t("footer.portableUpdateMessage")} + {hasDirectInstaller + ? t("footer.portableUpdateMessage") + : t("footer.portableUpdateBrowseMessage")}

diff --git a/src/components/update-checker/portableInstaller.test.ts b/src/components/update-checker/portableInstaller.test.ts new file mode 100644 index 0000000000..18b15603f9 --- /dev/null +++ b/src/components/update-checker/portableInstaller.test.ts @@ -0,0 +1,65 @@ +// Standalone assert check (no JS unit-test runner in this repo). Run with: +// bun src/components/update-checker/portableInstaller.test.ts +import assert from "node:assert"; +import { + resolvePortableInstallerUrl, + PORTABLE_RELEASES_URL, +} from "./portableInstaller"; + +const X64_SETUP = + "https://github.com/cjpais/Handy/releases/download/v0.9.5/Handy_0.9.5_x64-setup.exe"; +const ARM64_SETUP = + "https://github.com/cjpais/Handy/releases/download/v0.9.5/Handy_0.9.5_arm64-setup.exe"; + +// Trimmed copy of the real latest.json served from the updater endpoint. +const manifest = { + version: "0.9.5", + platforms: { + "windows-x86_64-nsis": { url: X64_SETUP, signature: "…" }, + "windows-x86_64-msi": { + url: "https://github.com/cjpais/Handy/releases/download/v0.9.5/Handy_0.9.5_x64_en-US.msi", + }, + "windows-aarch64-nsis": { url: ARM64_SETUP, signature: "…" }, + "darwin-aarch64": { + url: "https://github.com/cjpais/Handy/releases/download/v0.9.5/Handy_aarch64.app.tar.gz", + }, + }, +}; + +// x64 Windows -> the x64 NSIS asset, pinned to the release tag +assert.equal( + resolvePortableInstallerUrl(manifest, "windows", "x86_64"), + X64_SETUP, +); + +// arm64 Windows -> the arm64 NSIS asset +assert.equal( + resolvePortableInstallerUrl(manifest, "windows", "aarch64"), + ARM64_SETUP, +); + +// no manifest (check() failed or returned no update) -> releases page fallback +assert.equal( + resolvePortableInstallerUrl(undefined, "windows", "x86_64"), + PORTABLE_RELEASES_URL, +); + +// no NSIS bundle for this arch -> releases page fallback +assert.equal( + resolvePortableInstallerUrl(manifest, "windows", "x86"), + PORTABLE_RELEASES_URL, +); + +// non-Windows portable install -> releases page, never a Windows .exe +assert.equal( + resolvePortableInstallerUrl(manifest, "macos", "aarch64"), + PORTABLE_RELEASES_URL, +); + +// malformed manifest -> releases page fallback +assert.equal( + resolvePortableInstallerUrl({ platforms: "nope" }, "windows", "x86_64"), + PORTABLE_RELEASES_URL, +); + +console.log("portableInstaller: all assertions passed"); diff --git a/src/components/update-checker/portableInstaller.ts b/src/components/update-checker/portableInstaller.ts new file mode 100644 index 0000000000..0cce528a48 --- /dev/null +++ b/src/components/update-checker/portableInstaller.ts @@ -0,0 +1,41 @@ +// Portable installs can't self-update in place (no installer, and Windows won't +// let a running exe replace itself). Instead of dumping the user on the releases +// page to hand-pick one of ~27 assets, deep-link the NSIS setup.exe for their +// platform and architecture. +// +// The URL comes straight out of the updater manifest (`Update.rawJson`) rather +// than being rebuilt from the bundler's file-naming convention: it stays correct +// if asset names or the repo slug change, and it points at the immutable +// `releases/download/v/…` tag URL instead of a moving `latest` link. + +export const PORTABLE_RELEASES_URL = + "https://github.com/cjpais/Handy/releases/latest"; + +/** + * Pick the NSIS installer URL for the running target out of the update manifest. + * Falls back to the generic releases page whenever there is no matching entry — + * e.g. a portable install on a platform Handy ships no NSIS bundle for. + * + * @param rawJson `Update.rawJson`, the deserialized `latest.json` manifest + * @param platformName value from `@tauri-apps/plugin-os` `platform()` + * @param archName value from `@tauri-apps/plugin-os` `arch()` ("x86_64", "aarch64") + */ +export function resolvePortableInstallerUrl( + rawJson: Record | undefined, + platformName: string, + archName: string, +): string { + // NSIS is a Windows-only bundle; nothing else has an installer to link to. + if (platformName !== "windows") return PORTABLE_RELEASES_URL; + + const platforms = rawJson?.platforms; + if (!platforms || typeof platforms !== "object") return PORTABLE_RELEASES_URL; + + const entry = (platforms as Record)[ + `windows-${archName}-nsis` + ]; + if (!entry || typeof entry !== "object") return PORTABLE_RELEASES_URL; + + const url = (entry as Record).url; + return typeof url === "string" ? url : PORTABLE_RELEASES_URL; +} diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 30c95c2ed9..9c7a8c0e97 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -605,8 +605,10 @@ "preparing": "...جاري التحضير", "checkForUpdates": "التحقق من وجود تحديثات", "portableUpdateTitle": "التحديث اليدوي مطلوب", - "portableUpdateMessage": "لا يمكن تحديث التثبيتات المحمولة تلقائيًا. للتحديث: نزّل أحدث مثبّت NSIS من GitHub Releases، وثبّته في المجلد نفسه، ثم انسخ مجلد Data/ (الإعدادات والنماذج والتسجيلات) من الإصدار القديم إلى الجديد.", - "portableUpdateButton": "فتح GitHub Releases" + "portableUpdateMessage": "لا يمكن تحديث التثبيتات المحمولة تلقائيًا. نزّل المثبّت المناسب لنظامك من الزر أدناه، شغّله، وثبّته في المجلد نفسه — يبقى مجلد Data/ (الإعدادات والنماذج والتسجيلات) في مكانه.", + "portableUpdateButton": "تنزيل المثبّت", + "portableUpdateBrowseMessage": "لا يمكن تحديث التثبيتات المحمولة تلقائيًا. نزّل أحدث إصدار مناسب لنظامك من GitHub Releases وثبّته في المجلد نفسه — يبقى مجلد Data/ (الإعدادات والنماذج والتسجيلات) في مكانه.", + "portableUpdateBrowseButton": "فتح GitHub Releases" }, "common": { "loading": "...جاري التحميل", diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index c676b3d9ca..80db4a265c 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -605,8 +605,10 @@ "preparing": "Подготовка...", "checkForUpdates": "Проверка за актуализации", "portableUpdateTitle": "Необходима е ръчна актуализация", - "portableUpdateMessage": "Преносимите инсталации не могат да бъдат актуализирани автоматично. За актуализация: изтеглете най-новия NSIS инсталатор от GitHub Releases, инсталирайте го в същата папка, след което копирайте папката Data/ (настройки, модели, записи) от старата версия в новата.", - "portableUpdateButton": "Отвори GitHub Releases" + "portableUpdateMessage": "Преносимите инсталации не могат да бъдат актуализирани автоматично. Изтеглете инсталатора за вашата система от бутона по-долу, стартирайте го и инсталирайте в същата папка — папката Data/ (настройки, модели, записи) остава на мястото си.", + "portableUpdateButton": "Изтегляне на инсталатора", + "portableUpdateBrowseMessage": "Преносимите инсталации не могат да бъдат актуализирани автоматично. Изтеглете най-новата версия за вашата система от GitHub Releases и я инсталирайте в същата папка — папката Data/ (настройки, модели, записи) остава на мястото си.", + "portableUpdateBrowseButton": "Отвори GitHub Releases" }, "common": { "loading": "Зареждане...", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 34205dea01..6e42e63ae5 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -605,8 +605,10 @@ "preparing": "Příprava...", "checkForUpdates": "Zkontrolovat aktualizace", "portableUpdateTitle": "Vyžadována ruční aktualizace", - "portableUpdateMessage": "Přenosné instalace nelze aktualizovat automaticky. Postup aktualizace: stáhněte nejnovější instalátor NSIS z GitHub Releases, nainstalujte jej do stejné složky a poté zkopírujte svou složku Data/ (nastavení, modely, nahrávky) ze staré verze do nové.", - "portableUpdateButton": "Otevřít GitHub Releases" + "portableUpdateMessage": "Přenosné instalace nelze aktualizovat automaticky. Níže stáhněte instalátor pro svůj systém, spusťte jej a nainstalujte do stejné složky — vaše složka Data/ (nastavení, modely, nahrávky) zůstane na místě.", + "portableUpdateButton": "Stáhnout instalátor", + "portableUpdateBrowseMessage": "Přenosné instalace nelze aktualizovat automaticky. Stáhněte nejnovější verzi pro svůj systém z GitHub Releases a nainstalujte ji do stejné složky — vaše složka Data/ (nastavení, modely, nahrávky) zůstane na místě.", + "portableUpdateBrowseButton": "Otevřít GitHub Releases" }, "common": { "loading": "Načítání...", diff --git a/src/i18n/locales/da/translation.json b/src/i18n/locales/da/translation.json index e32fc566eb..6121d53690 100644 --- a/src/i18n/locales/da/translation.json +++ b/src/i18n/locales/da/translation.json @@ -605,8 +605,10 @@ "preparing": "Forbereder...", "checkForUpdates": "Tjek for opdateringer", "portableUpdateTitle": "Manuel opdatering påkrævet", - "portableUpdateMessage": "Flytbare installationer kan ikke opdateres automatisk. For at opdatere: download den nyeste NSIS-installer fra GitHub Releases, installer den i den samme mappe, og kopiér derefter din Data/-mappe (indstillinger, modeller, optagelser) fra den gamle version til den nye.", - "portableUpdateButton": "Åbn GitHub Releases" + "portableUpdateMessage": "Flytbare installationer kan ikke opdateres automatisk. Download installeren til dit system nedenfor, kør den, og installer i den samme mappe — din Data/-mappe (indstillinger, modeller, optagelser) bliver bevaret.", + "portableUpdateButton": "Download installer", + "portableUpdateBrowseMessage": "Flytbare installationer kan ikke opdateres automatisk. Download den nyeste version til dit system fra GitHub Releases, og installer den i den samme mappe — din Data/-mappe (indstillinger, modeller, optagelser) bliver bevaret.", + "portableUpdateBrowseButton": "Åbn GitHub Releases" }, "whatsNew": { "title": "Nyt i Handy v{{version}}" diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index b5d4e56b60..204e171bda 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -605,8 +605,10 @@ "preparing": "Wird vorbereitet...", "checkForUpdates": "Nach Updates suchen", "portableUpdateTitle": "Manuelles Update erforderlich", - "portableUpdateMessage": "Portable Installationen können nicht automatisch aktualisiert werden. Zum Aktualisieren: Lade den neuesten NSIS-Installer von GitHub Releases herunter, installiere ihn im selben Ordner und kopiere dann deinen Data/-Ordner (Einstellungen, Modelle, Aufnahmen) von der alten Version in die neue.", - "portableUpdateButton": "GitHub Releases öffnen" + "portableUpdateMessage": "Portable Installationen können nicht automatisch aktualisiert werden. Lade unten den Installer für dein System herunter, führe ihn aus und installiere in denselben Ordner — dein Data/-Ordner (Einstellungen, Modelle, Aufnahmen) bleibt erhalten.", + "portableUpdateButton": "Installer herunterladen", + "portableUpdateBrowseMessage": "Portable Installationen können nicht automatisch aktualisiert werden. Lade die neueste Version für dein System von GitHub Releases herunter und installiere sie in denselben Ordner — dein Data/-Ordner (Einstellungen, Modelle, Aufnahmen) bleibt erhalten.", + "portableUpdateBrowseButton": "GitHub Releases öffnen" }, "common": { "loading": "Wird geladen...", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 0b12d86944..20488920bd 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -605,8 +605,10 @@ "preparing": "Preparing...", "checkForUpdates": "Check for updates", "portableUpdateTitle": "Manual update required", - "portableUpdateMessage": "Portable installs cannot be updated automatically. To update: download the latest NSIS installer from GitHub Releases, install it to the same folder, then copy your Data/ folder (settings, models, recordings) from the old version to the new one.", - "portableUpdateButton": "Open GitHub Releases" + "portableUpdateMessage": "Portable installs cannot be updated automatically. Download the installer for your system below, run it, and install to the same folder — your Data/ folder (settings, models, recordings) is kept in place.", + "portableUpdateButton": "Download installer", + "portableUpdateBrowseMessage": "Portable installs cannot be updated automatically. Download the latest version for your system from GitHub Releases and install it to the same folder — your Data/ folder (settings, models, recordings) is kept in place.", + "portableUpdateBrowseButton": "Open GitHub Releases" }, "whatsNew": { "title": "New in Handy v{{version}}" diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index dc132b412d..7043824152 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -605,8 +605,10 @@ "preparing": "Preparando...", "checkForUpdates": "Buscar actualizaciones", "portableUpdateTitle": "Actualización manual necesaria", - "portableUpdateMessage": "Las instalaciones portátiles no se pueden actualizar automáticamente. Para actualizar: descarga el instalador NSIS más reciente desde GitHub Releases, instálalo en la misma carpeta y luego copia tu carpeta Data/ (ajustes, modelos, grabaciones) de la versión anterior a la nueva.", - "portableUpdateButton": "Abrir GitHub Releases" + "portableUpdateMessage": "Las instalaciones portátiles no se pueden actualizar automáticamente. Descarga abajo el instalador para tu sistema, ejecútalo e instálalo en la misma carpeta: tu carpeta Data/ (ajustes, modelos, grabaciones) se conserva.", + "portableUpdateButton": "Descargar instalador", + "portableUpdateBrowseMessage": "Las instalaciones portátiles no se pueden actualizar automáticamente. Descarga la versión más reciente para tu sistema desde GitHub Releases e instálala en la misma carpeta: tu carpeta Data/ (ajustes, modelos, grabaciones) se conserva.", + "portableUpdateBrowseButton": "Abrir GitHub Releases" }, "common": { "loading": "Cargando...", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index cc16c5a4bd..17fc4a13bc 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -605,8 +605,10 @@ "preparing": "Préparation...", "checkForUpdates": "Rechercher des mises à jour", "portableUpdateTitle": "Mise à jour manuelle requise", - "portableUpdateMessage": "Les installations portables ne peuvent pas être mises à jour automatiquement. Pour mettre à jour : téléchargez le dernier installateur NSIS depuis GitHub Releases, installez-le dans le même dossier, puis copiez votre dossier Data/ (paramètres, modèles, enregistrements) de l’ancienne version vers la nouvelle.", - "portableUpdateButton": "Ouvrir GitHub Releases" + "portableUpdateMessage": "Les installations portables ne peuvent pas être mises à jour automatiquement. Téléchargez ci-dessous l’installateur correspondant à votre système, exécutez-le et installez-le dans le même dossier : votre dossier Data/ (paramètres, modèles, enregistrements) est conservé.", + "portableUpdateButton": "Télécharger l’installateur", + "portableUpdateBrowseMessage": "Les installations portables ne peuvent pas être mises à jour automatiquement. Téléchargez la dernière version correspondant à votre système depuis GitHub Releases et installez-la dans le même dossier : votre dossier Data/ (paramètres, modèles, enregistrements) est conservé.", + "portableUpdateBrowseButton": "Ouvrir GitHub Releases" }, "common": { "loading": "Chargement...", diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 3acaee7a03..70cbd689c3 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -605,8 +605,10 @@ "preparing": "מכין...", "checkForUpdates": "בדוק עדכונים", "portableUpdateTitle": "נדרש עדכון ידני", - "portableUpdateMessage": "התקנות ניידות אינן מתעדכנות אוטומטית. כדי לעדכן, הורד את מתקין ה-NSIS האחרון מ-GitHub Releases, התקן לאותה תיקייה, ואז העתק את תיקיית Data/ (הגדרות, מודלים, הקלטות) מהגרסה הישנה לחדשה.", - "portableUpdateButton": "פתח את GitHub Releases" + "portableUpdateMessage": "התקנות ניידות אינן מתעדכנות אוטומטית. הורד למטה את המתקין המתאים למערכת שלך, הפעל אותו והתקן לאותה תיקייה — תיקיית Data/ (הגדרות, מודלים, הקלטות) נשמרת במקומה.", + "portableUpdateButton": "הורדת המתקין", + "portableUpdateBrowseMessage": "התקנות ניידות אינן מתעדכנות אוטומטית. הורד את הגרסה האחרונה המתאימה למערכת שלך מ-GitHub Releases והתקן אותה לאותה תיקייה — תיקיית Data/ (הגדרות, מודלים, הקלטות) נשמרת במקומה.", + "portableUpdateBrowseButton": "פתח את GitHub Releases" }, "common": { "loading": "טוען...", diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index 23b871e94d..4fb9a20afc 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -605,8 +605,10 @@ "preparing": "तैयारी हो रही है...", "checkForUpdates": "अपडेट जांचें", "portableUpdateTitle": "मैन्युअल अपडेट ज़रूरी है", - "portableUpdateMessage": "पोर्टेबल इंस्टॉल अपने आप अपडेट नहीं हो सकते. अपडेट करने के लिए: GitHub Releases से सबसे नया NSIS इंस्टॉलर डाउनलोड करें, उसे उसी फ़ोल्डर में इंस्टॉल करें, फिर पुराने वर्शन से अपना Data/ फ़ोल्डर (सेटिंग, मॉडल, रिकॉर्डिंग) नए वर्शन में कॉपी करें.", - "portableUpdateButton": "GitHub Releases खोलें" + "portableUpdateMessage": "पोर्टेबल इंस्टॉल अपने आप अपडेट नहीं हो सकते. नीचे से अपने सिस्टम के लिए इंस्टॉलर डाउनलोड करें, उसे चलाएँ और उसी फ़ोल्डर में इंस्टॉल करें — आपका Data/ फ़ोल्डर (सेटिंग, मॉडल, रिकॉर्डिंग) जहाँ है वहीं बना रहता है.", + "portableUpdateButton": "इंस्टॉलर डाउनलोड करें", + "portableUpdateBrowseMessage": "पोर्टेबल इंस्टॉल अपने आप अपडेट नहीं हो सकते. GitHub Releases से अपने सिस्टम के लिए नया वर्शन डाउनलोड करें और उसे उसी फ़ोल्डर में इंस्टॉल करें — आपका Data/ फ़ोल्डर (सेटिंग, मॉडल, रिकॉर्डिंग) जहाँ है वहीं बना रहता है.", + "portableUpdateBrowseButton": "GitHub Releases खोलें" }, "whatsNew": { "title": "Handy v{{version}} में नया क्या है" diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index bf03bf63fb..c66c0001b0 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -605,8 +605,10 @@ "preparing": "Preparazione...", "checkForUpdates": "Controlla aggiornamenti", "portableUpdateTitle": "Aggiornamento manuale necessario", - "portableUpdateMessage": "Le installazioni portatili non possono essere aggiornate automaticamente. Per aggiornare: scarica l'ultima versione del programma di installazione NSIS da GitHub Releases, installala nella stessa cartella, quindi copia la cartella Data/ (impostazioni, modelli, registrazioni) dalla vecchia versione a quella nuova..", - "portableUpdateButton": "Apri i rilasci su GitHub" + "portableUpdateMessage": "Le installazioni portatili non possono essere aggiornate automaticamente. Scarica qui sotto il programma di installazione per il tuo sistema, eseguilo e installalo nella stessa cartella: la cartella Data/ (impostazioni, modelli, registrazioni) viene mantenuta.", + "portableUpdateButton": "Scarica il programma di installazione", + "portableUpdateBrowseMessage": "Le installazioni portatili non possono essere aggiornate automaticamente. Scarica da GitHub Releases la versione più recente per il tuo sistema e installala nella stessa cartella: la cartella Data/ (impostazioni, modelli, registrazioni) viene mantenuta.", + "portableUpdateBrowseButton": "Apri i rilasci su GitHub" }, "common": { "loading": "Caricamento...", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 869681e8e5..5c25fba94b 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -605,8 +605,10 @@ "preparing": "準備中…", "checkForUpdates": "アップデートを確認", "portableUpdateTitle": "手動での更新が必要です", - "portableUpdateMessage": "ポータブル版は自動的に更新できません。更新するには、GitHub Releases から最新の NSIS インストーラーをダウンロードし、同じフォルダーにインストールしてから、古いバージョンの Data フォルダー(設定、モデル、録音)を新しいバージョンにコピーしてください。", - "portableUpdateButton": "GitHub Releases を開く" + "portableUpdateMessage": "ポータブル版は自動的に更新できません。下のボタンからお使いのシステム向けのインストーラーをダウンロードし、実行して同じフォルダーにインストールしてください。Data フォルダー(設定、モデル、録音)はそのまま残ります。", + "portableUpdateButton": "インストーラーをダウンロード", + "portableUpdateBrowseMessage": "ポータブル版は自動的に更新できません。GitHub Releases からお使いのシステム向けの最新版をダウンロードし、同じフォルダーにインストールしてください。Data フォルダー(設定、モデル、録音)はそのまま残ります。", + "portableUpdateBrowseButton": "GitHub Releases を開く" }, "common": { "loading": "読み込み中…", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 1851283b15..8a2fe59c4b 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -605,8 +605,10 @@ "preparing": "준비 중...", "checkForUpdates": "업데이트 확인", "portableUpdateTitle": "수동 업데이트 필요", - "portableUpdateMessage": "포터블 설치는 자동으로 업데이트할 수 없습니다. 업데이트하려면 GitHub Releases에서 최신 NSIS 설치 프로그램을 다운로드하여 같은 폴더에 설치한 다음, 이전 버전의 Data/ 폴더(설정, 모델, 녹음)를 새 버전으로 복사하세요.", - "portableUpdateButton": "GitHub Releases 열기" + "portableUpdateMessage": "포터블 설치는 자동으로 업데이트할 수 없습니다. 아래에서 사용 중인 시스템에 맞는 설치 프로그램을 다운로드해 실행하고 같은 폴더에 설치하세요. Data/ 폴더(설정, 모델, 녹음)는 그대로 유지됩니다.", + "portableUpdateButton": "설치 프로그램 다운로드", + "portableUpdateBrowseMessage": "포터블 설치는 자동으로 업데이트할 수 없습니다. GitHub Releases에서 사용 중인 시스템에 맞는 최신 버전을 다운로드해 같은 폴더에 설치하세요. Data/ 폴더(설정, 모델, 녹음)는 그대로 유지됩니다.", + "portableUpdateBrowseButton": "GitHub Releases 열기" }, "common": { "loading": "로딩 중...", diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index 02711d8db9..7df5264e92 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -605,8 +605,10 @@ "preparing": "तयारी गरिँदैछ...", "checkForUpdates": "अपडेटहरू जाँच गर्नुहोस्", "portableUpdateTitle": "म्यानुअल अपडेट आवश्यक", - "portableUpdateMessage": "पोर्टेबल इन्स्टलहरू स्वतः अपडेट गर्न सकिँदैन। अपडेट गर्न: GitHub Releases बाट पछिल्लो NSIS इन्स्टलर डाउनलोड गर्नुहोस्, उही फोल्डरमा इन्स्टल गर्नुहोस्, अनि पुरानो संस्करणबाट आफ्नो Data/ फोल्डर (सेटिङ, मोडेल, रेकर्डिङ) नयाँमा कपी गर्नुहोस्।", - "portableUpdateButton": "GitHub Releases खोल्नुहोस्" + "portableUpdateMessage": "पोर्टेबल इन्स्टलहरू स्वतः अपडेट गर्न सकिँदैन। तलबाट आफ्नो प्रणालीका लागि इन्स्टलर डाउनलोड गर्नुहोस्, चलाउनुहोस् र उही फोल्डरमा इन्स्टल गर्नुहोस् — तपाईंको Data/ फोल्डर (सेटिङ, मोडेल, रेकर्डिङ) जस्ताको तस्तै रहन्छ।", + "portableUpdateButton": "इन्स्टलर डाउनलोड गर्नुहोस्", + "portableUpdateBrowseMessage": "पोर्टेबल इन्स्टलहरू स्वतः अपडेट गर्न सकिँदैन। GitHub Releases बाट आफ्नो प्रणालीका लागि पछिल्लो संस्करण डाउनलोड गर्नुहोस् र उही फोल्डरमा इन्स्टल गर्नुहोस् — तपाईंको Data/ फोल्डर (सेटिङ, मोडेल, रेकर्डिङ) जस्ताको तस्तै रहन्छ।", + "portableUpdateBrowseButton": "GitHub Releases खोल्नुहोस्" }, "whatsNew": { "title": "Handy v{{version}} मा नयाँ" diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 0f644a3112..67b71934d9 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -605,8 +605,10 @@ "preparing": "Voorbereiden...", "checkForUpdates": "Controleren op updates", "portableUpdateTitle": "Handmatige update vereist", - "portableUpdateMessage": "Draagbare (portable) installaties kunnen niet automatisch worden bijgewerkt. Om bij te werken: download het nieuwste NSIS-installatiebestand van GitHub Releases, installeer het in dezelfde map en kopieer vervolgens je Data/-map (instellingen, modellen, opnames) van de oude versie naar de nieuwe.", - "portableUpdateButton": "GitHub Releases openen" + "portableUpdateMessage": "Draagbare (portable) installaties kunnen niet automatisch worden bijgewerkt. Download hieronder het installatiebestand voor je systeem, voer het uit en installeer in dezelfde map — je Data/-map (instellingen, modellen, opnames) blijft behouden.", + "portableUpdateButton": "Installatiebestand downloaden", + "portableUpdateBrowseMessage": "Draagbare (portable) installaties kunnen niet automatisch worden bijgewerkt. Download de nieuwste versie voor je systeem van GitHub Releases en installeer die in dezelfde map — je Data/-map (instellingen, modellen, opnames) blijft behouden.", + "portableUpdateBrowseButton": "GitHub Releases openen" }, "whatsNew": { "title": "Nieuw in Handy v{{version}}" diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 4bcd765512..a80b915a9e 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -605,8 +605,10 @@ "preparing": "Przygotowywanie...", "checkForUpdates": "Sprawdź aktualizacje", "portableUpdateTitle": "Wymagana ręczna aktualizacja", - "portableUpdateMessage": "Instalacji przenośnych nie można aktualizować automatycznie. Aby zaktualizować: pobierz najnowszy instalator NSIS z GitHub Releases, zainstaluj go w tym samym folderze, a następnie skopiuj swój folder Data/ (ustawienia, modele, nagrania) ze starej wersji do nowej.", - "portableUpdateButton": "Otwórz GitHub Releases" + "portableUpdateMessage": "Instalacji przenośnych nie można aktualizować automatycznie. Pobierz poniżej instalator dla swojego systemu, uruchom go i zainstaluj w tym samym folderze — Twój folder Data/ (ustawienia, modele, nagrania) pozostaje na miejscu.", + "portableUpdateButton": "Pobierz instalator", + "portableUpdateBrowseMessage": "Instalacji przenośnych nie można aktualizować automatycznie. Pobierz najnowszą wersję dla swojego systemu z GitHub Releases i zainstaluj ją w tym samym folderze — Twój folder Data/ (ustawienia, modele, nagrania) pozostaje na miejscu.", + "portableUpdateBrowseButton": "Otwórz GitHub Releases" }, "common": { "loading": "Wczytywanie...", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 68de502883..0d35620414 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -605,8 +605,10 @@ "preparing": "Preparando...", "checkForUpdates": "Verificar atualizações", "portableUpdateTitle": "Atualização manual necessária", - "portableUpdateMessage": "Instalações portáteis não podem ser atualizadas automaticamente. Para atualizar: baixe o instalador NSIS mais recente do GitHub Releases, instale-o na mesma pasta e, em seguida, copie sua pasta Data/ (configurações, modelos, gravações) da versão antiga para a nova.", - "portableUpdateButton": "Abrir GitHub Releases" + "portableUpdateMessage": "Instalações portáteis não podem ser atualizadas automaticamente. Baixe abaixo o instalador para o seu sistema, execute-o e instale na mesma pasta — a sua pasta Data/ (configurações, modelos, gravações) é mantida.", + "portableUpdateButton": "Baixar instalador", + "portableUpdateBrowseMessage": "Instalações portáteis não podem ser atualizadas automaticamente. Baixe a versão mais recente para o seu sistema no GitHub Releases e instale-a na mesma pasta — a sua pasta Data/ (configurações, modelos, gravações) é mantida.", + "portableUpdateBrowseButton": "Abrir GitHub Releases" }, "common": { "loading": "Carregando...", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 6a05b781fa..743b9c3ca7 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -605,8 +605,10 @@ "preparing": "Подготовка...", "checkForUpdates": "Проверить наличие обновлений", "portableUpdateTitle": "Требуется ручное обновление", - "portableUpdateMessage": "Портативные установки нельзя обновить автоматически. Чтобы обновить: скачайте последний установщик NSIS со страницы GitHub Releases, установите его в ту же папку, затем скопируйте папку Data/ (настройки, модели, записи) из старой версии в новую.", - "portableUpdateButton": "Открыть GitHub Releases" + "portableUpdateMessage": "Портативные установки нельзя обновить автоматически. Скачайте ниже установщик для вашей системы, запустите его и установите в ту же папку — папка Data/ (настройки, модели, записи) останется на месте.", + "portableUpdateButton": "Скачать установщик", + "portableUpdateBrowseMessage": "Портативные установки нельзя обновить автоматически. Скачайте последнюю версию для вашей системы со страницы GitHub Releases и установите её в ту же папку — папка Data/ (настройки, модели, записи) останется на месте.", + "portableUpdateBrowseButton": "Открыть GitHub Releases" }, "common": { "loading": "Загрузка...", diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 83fb39b18a..d26c2410ef 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -605,8 +605,10 @@ "preparing": "Förbereder...", "checkForUpdates": "Sök efter uppdateringar", "portableUpdateTitle": "Manuell uppdatering krävs", - "portableUpdateMessage": "Portabla installationer kan inte uppdateras automatiskt. Så här uppdaterar du: ladda ner den senaste NSIS-installeraren från GitHub Releases, installera den i samma mapp och kopiera sedan din Data/-mapp (inställningar, modeller, inspelningar) från den gamla versionen till den nya.", - "portableUpdateButton": "Öppna GitHub Releases" + "portableUpdateMessage": "Portabla installationer kan inte uppdateras automatiskt. Ladda ner installeraren för ditt system nedan, kör den och installera i samma mapp — din Data/-mapp (inställningar, modeller, inspelningar) behålls.", + "portableUpdateButton": "Ladda ner installeraren", + "portableUpdateBrowseMessage": "Portabla installationer kan inte uppdateras automatiskt. Ladda ner den senaste versionen för ditt system från GitHub Releases och installera den i samma mapp — din Data/-mapp (inställningar, modeller, inspelningar) behålls.", + "portableUpdateBrowseButton": "Öppna GitHub Releases" }, "common": { "loading": "Laddar...", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 5865cd28cc..7031ddd4da 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -605,8 +605,10 @@ "preparing": "Hazırlanıyor...", "checkForUpdates": "Güncellemeleri kontrol et", "portableUpdateTitle": "Manuel güncelleme gerekli", - "portableUpdateMessage": "Taşınabilir kurulumlar otomatik olarak güncellenemez. Güncellemek için: GitHub Releases’ten en son NSIS yükleyicisini indirin, aynı klasöre kurun ve ardından Data/ klasörünüzü (ayarlar, modeller, kayıtlar) eski sürümden yenisine kopyalayın.", - "portableUpdateButton": "GitHub Releases’i aç" + "portableUpdateMessage": "Taşınabilir kurulumlar otomatik olarak güncellenemez. Aşağıdan sisteminize uygun yükleyiciyi indirin, çalıştırın ve aynı klasöre kurun — Data/ klasörünüz (ayarlar, modeller, kayıtlar) yerinde kalır.", + "portableUpdateButton": "Yükleyiciyi indir", + "portableUpdateBrowseMessage": "Taşınabilir kurulumlar otomatik olarak güncellenemez. Sisteminize uygun en son sürümü GitHub Releases’ten indirin ve aynı klasöre kurun — Data/ klasörünüz (ayarlar, modeller, kayıtlar) yerinde kalır.", + "portableUpdateBrowseButton": "GitHub Releases’i aç" }, "common": { "loading": "Yükleniyor...", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index da057f126c..ed88557d69 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -605,8 +605,10 @@ "preparing": "Підготовка...", "checkForUpdates": "Перевірити оновлення", "portableUpdateTitle": "Потрібне ручне оновлення", - "portableUpdateMessage": "Портативні встановлення не можна оновити автоматично. Щоб оновити: завантажте найновіший інсталятор NSIS зі сторінки GitHub Releases, установіть його в ту саму теку, а потім скопіюйте теку Data/ (налаштування, моделі, записи) зі старої версії до нової.", - "portableUpdateButton": "Відкрити GitHub Releases" + "portableUpdateMessage": "Портативні встановлення не можна оновити автоматично. Завантажте нижче інсталятор для вашої системи, запустіть його та встановіть у ту саму теку — ваша тека Data/ (налаштування, моделі, записи) залишиться на місці.", + "portableUpdateButton": "Завантажити інсталятор", + "portableUpdateBrowseMessage": "Портативні встановлення не можна оновити автоматично. Завантажте найновішу версію для вашої системи зі сторінки GitHub Releases та встановіть її в ту саму теку — ваша тека Data/ (налаштування, моделі, записи) залишиться на місці.", + "portableUpdateBrowseButton": "Відкрити GitHub Releases" }, "common": { "loading": "Завантаження...", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index d1d2ce2fa2..b350704ca5 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -605,8 +605,10 @@ "preparing": "Đang chuẩn bị...", "checkForUpdates": "Kiểm tra cập nhật", "portableUpdateTitle": "Cần cập nhật thủ công", - "portableUpdateMessage": "Bản cài đặt di động không thể cập nhật tự động. Để cập nhật: tải trình cài đặt NSIS mới nhất từ GitHub Releases, cài đặt vào cùng thư mục, sau đó sao chép thư mục Data/ (cài đặt, mô hình, bản ghi) từ phiên bản cũ sang phiên bản mới.", - "portableUpdateButton": "Mở GitHub Releases" + "portableUpdateMessage": "Bản cài đặt di động không thể cập nhật tự động. Hãy tải trình cài đặt phù hợp với hệ thống của bạn bên dưới, chạy nó và cài vào cùng thư mục — thư mục Data/ (cài đặt, mô hình, bản ghi) của bạn được giữ nguyên.", + "portableUpdateButton": "Tải trình cài đặt", + "portableUpdateBrowseMessage": "Bản cài đặt di động không thể cập nhật tự động. Hãy tải phiên bản mới nhất phù hợp với hệ thống của bạn từ GitHub Releases và cài vào cùng thư mục — thư mục Data/ (cài đặt, mô hình, bản ghi) của bạn được giữ nguyên.", + "portableUpdateBrowseButton": "Mở GitHub Releases" }, "common": { "loading": "Đang tải...", diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 092844cdbd..771afaa235 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -605,8 +605,10 @@ "preparing": "準備中...", "checkForUpdates": "檢查更新", "portableUpdateTitle": "需要手動更新", - "portableUpdateMessage": "可攜式安裝無法自動更新。更新方法:從 GitHub Releases 下載最新的 NSIS 安裝程式,安裝到相同資料夾,然後將舊版本的 Data/ 資料夾(設定、模型、錄音)複製到新版本。", - "portableUpdateButton": "開啟 GitHub Releases" + "portableUpdateMessage": "可攜式安裝無法自動更新。請透過下方按鈕下載適用於你的系統的安裝程式,執行後安裝到相同資料夾,你的 Data/ 資料夾(設定、模型、錄音)會保留在原處。", + "portableUpdateButton": "下載安裝程式", + "portableUpdateBrowseMessage": "可攜式安裝無法自動更新。請從 GitHub Releases 下載適用於你的系統的最新版本,並安裝到相同資料夾,你的 Data/ 資料夾(設定、模型、錄音)會保留在原處。", + "portableUpdateBrowseButton": "開啟 GitHub Releases" }, "common": { "loading": "載入中...", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 5f170e0caa..7392cbefc8 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -605,8 +605,10 @@ "preparing": "准备中...", "checkForUpdates": "检查更新", "portableUpdateTitle": "需要手动更新", - "portableUpdateMessage": "便携版无法自动更新。更新方法:从 GitHub Releases 下载最新的 NSIS 安装程序,安装到同一文件夹,然后将旧版本的 Data/ 文件夹(设置、模型、录音)复制到新版本。", - "portableUpdateButton": "打开 GitHub Releases" + "portableUpdateMessage": "便携版无法自动更新。请通过下方按钮下载适用于你的系统的安装程序,运行后安装到同一文件夹,你的 Data/ 文件夹(设置、模型、录音)会保留在原处。", + "portableUpdateButton": "下载安装程序", + "portableUpdateBrowseMessage": "便携版无法自动更新。请从 GitHub Releases 下载适用于你的系统的最新版本,并安装到同一文件夹,你的 Data/ 文件夹(设置、模型、录音)会保留在原处。", + "portableUpdateBrowseButton": "打开 GitHub Releases" }, "common": { "loading": "加载中...", From a4348beb18abdb255b230b659f4adf4774de8270 Mon Sep 17 00:00:00 2001 From: asjad-vyro Date: Mon, 3 Aug 2026 14:13:35 +0500 Subject: [PATCH 29/49] fix: recover microphone stream after the capture worker dies (#1838) * fix: recover microphone stream after the capture worker dies run_consumer is driven entirely by the sample channel, so when cpal tears the stream down mid-session (device unplugged, USB/Bluetooth dropout) sample_rx.recv() returns Err and the worker thread exits. cmd_tx and worker_handle stay populated, so both AudioRecorder::open() and AudioRecordingManager::start_microphone_stream() still report the stream as open: recording captures nothing, stop() fails on the closed channel, and the app stays wedged until the on-demand close timeout resets it. Detect the exited worker and rebuild the stream instead of handing back a dead recorder. Fixes #1743 * fix for always on as well --------- Co-authored-by: CJ Pais --- src-tauri/src/audio_toolkit/audio/recorder.rs | 34 +++++++++- src-tauri/src/managers/audio.rs | 65 +++++++++++++++---- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 82297cb672..9077bf1ca1 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -138,7 +138,14 @@ impl AudioRecorder { pub fn open(&mut self, device: Option) -> Result<(), Box> { if self.worker_handle.is_some() { - return Ok(()); // already open + if !self.is_capture_worker_dead() { + return Ok(()); // already open + } + // The worker exited on its own (see `is_capture_worker_dead`). Reap + // it so we rebuild the stream below instead of handing the caller + // back a recorder whose channels are already closed. + log::warn!("Capture worker exited; rebuilding microphone stream"); + let _ = self.close(); } let (sample_tx, sample_rx) = mpsc::channel::(); @@ -331,6 +338,20 @@ impl AudioRecorder { Ok(resp_rx.recv()?) // wait for the samples } + /// True once the capture worker has exited without anyone calling `close`. + /// + /// `run_consumer` is driven entirely by the sample channel, so when cpal + /// tears the stream down mid-session (device unplugged, USB/Bluetooth + /// dropout) `sample_rx.recv()` returns `Err`, the loop ends and the worker + /// thread finishes. `cmd_tx` and `worker_handle` are still populated at + /// that point, so the recorder looks open from the outside while every + /// command sent to it fails on a closed channel. + pub fn is_capture_worker_dead(&self) -> bool { + self.worker_handle + .as_ref() + .is_some_and(|handle| handle.is_finished()) + } + pub fn close(&mut self) -> Result<(), Box> { if let Some(tx) = self.cmd_tx.take() { let _ = tx.send(Cmd::Shutdown); @@ -472,7 +493,16 @@ pub fn is_no_input_device_error(error_message: &str) -> bool { #[cfg(test)] mod tests { - use super::{is_microphone_access_denied, is_no_input_device_error}; + use super::{is_microphone_access_denied, is_no_input_device_error, AudioRecorder}; + + #[test] + fn unopened_recorder_is_not_reported_dead() { + // No worker has been spawned yet, so there is nothing to reap. Guards + // against inverting the "no worker" case, which would make every first + // open() take the rebuild path. + let recorder = AudioRecorder::new().expect("recorder"); + assert!(!recorder.is_capture_worker_dead()); + } #[test] fn detects_access_is_denied() { diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index dc8ef9f189..ca599be0a2 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -10,7 +10,7 @@ use crate::helpers::clamshell; use crate::managers::transcription::StreamRouter; use crate::settings::{get_settings, AppSettings}; use crate::utils; -use log::{debug, error, info, warn}; +use log::{debug, error, info, trace, warn}; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -518,8 +518,46 @@ impl AudioRecordingManager { pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> { let mut open_flag = self.is_open.lock().unwrap(); if *open_flag { - debug!("Microphone stream already active"); - return Ok(()); + // `is_open` only records that we opened a stream at some point, not + // that one is still running. If the capture worker has since exited + // (mic unplugged mid-session, USB dropout), returning Ok here hands + // the caller a dead recorder: it captures nothing, then fails in + // stop() on the closed channel, and stays wedged until the + // on-demand close timeout eventually resets the manager. + let worker_dead = self + .recorder + .lock() + .unwrap() + .as_ref() + .is_some_and(|rec| rec.is_capture_worker_dead()); + + if !worker_dead { + // trace, not debug: with the aliveness check in + // try_start_recording this now fires on every keypress in + // always-on mode. + trace!("Microphone stream already active"); + return Ok(()); + } + + warn!("Microphone stream is no longer running (device disconnected?); reopening"); + + // Torn down inline rather than via stop_microphone_stream(), which + // takes the `is_open` lock we are already holding. + { + let mut mute_guard = self.mute_state.lock().unwrap(); + if mute_guard.did_mute { + restore_mute(mute_guard.prev_muted); + mute_guard.did_mute = false; + } + } + if let Some(rec) = self.recorder.lock().unwrap().as_mut() { + // Skipping rec.stop() here: the worker is gone, so the command + // would only fail on the closed channel. + let _ = rec.close(); + } + *self.is_recording.lock().unwrap() = false; + *open_flag = false; + // Fall through and open a fresh stream. } let start_time = Instant::now(); @@ -645,15 +683,18 @@ impl AudioRecordingManager { let mut state = self.state.lock().unwrap(); if let RecordingState::Idle = *state { - // Ensure microphone is open in on-demand mode - if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { - // Cancel any pending lazy close - self.close_generation.fetch_add(1, Ordering::SeqCst); - if let Err(e) = self.start_microphone_stream() { - let msg = format!("{e}"); - error!("Failed to open microphone stream: {msg}"); - return Err(msg); - } + // Cancel any pending lazy close (no-op in always-on mode, where + // closes are never scheduled). + self.close_generation.fetch_add(1, Ordering::SeqCst); + // Opens the stream in on-demand mode. In always-on mode the stream + // is normally already open and this is a cheap aliveness check — + // but if the capture worker died (device disconnect), it rebuilds + // the stream instead of leaving every subsequent start wedged on + // "Recorder not available". + if let Err(e) = self.start_microphone_stream() { + let msg = format!("{e}"); + error!("Failed to open microphone stream: {msg}"); + return Err(msg); } if let Some(rec) = self.recorder.lock().unwrap().as_ref() { From b1b2d9f9072e55902a46ca6cc655e5893ed7a91d Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Mon, 3 Aug 2026 21:24:29 +0800 Subject: [PATCH 30/49] fix reliable paste (#1847) --- src-tauri/src/paste_tx/windows.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/paste_tx/windows.rs b/src-tauri/src/paste_tx/windows.rs index 4c09091924..55f13bf520 100644 --- a/src-tauri/src/paste_tx/windows.rs +++ b/src-tauri/src/paste_tx/windows.rs @@ -21,7 +21,9 @@ use std::time::Instant; use log::{error, info, warn}; use tauri::Manager; use windows::core::{w, PCWSTR}; -use windows::Win32::Foundation::{HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM}; +use windows::Win32::Foundation::{ + SetLastError, ERROR_SUCCESS, HANDLE, HGLOBAL, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM, +}; use super::{evaluate, send_chord, TxState, WaitDecision}; use crate::clipboard::send_return_key; @@ -393,9 +395,18 @@ unsafe fn publish_formats() -> Result<(), String> { } // NULL handle = delayed rendering: we are only asked for the data (via - // WM_RENDERFORMAT) when a consumer actually reads it. - SetClipboardData(CF_UNICODETEXT.0 as u32, None) - .map_err(|e| format!("SetClipboardData failed: {e}"))?; + // WM_RENDERFORMAT) when a consumer actually reads it. SetClipboardData + // returns the handle it was given, so for delayed rendering success is + // also NULL and the windows crate reports it as an Err carrying + // GetLastError(). Only a nonzero thread error is a real failure, and the + // thread error must be cleared first so a stale value from an earlier + // call can't masquerade as one. + SetLastError(ERROR_SUCCESS); + if let Err(e) = SetClipboardData(CF_UNICODETEXT.0 as u32, None) { + if e.code().is_err() { + return Err(format!("SetClipboardData failed: {e}")); + } + } Ok(()) } From 2211da652ea408191c6ab74e850d1155360d7304 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Wed, 5 Aug 2026 13:53:28 +0800 Subject: [PATCH 31/49] request stream: false, for post processing --- src-tauri/src/llm_client.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 6ed64c1285..381f3a757f 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -112,6 +112,7 @@ fn remember_rejection(key: String) { struct ChatCompletionRequest { model: String, messages: Vec, + stream: bool, #[serde(skip_serializing_if = "Option::is_none")] response_format: Option, #[serde(flatten)] @@ -264,6 +265,7 @@ pub async fn send_chat_completion_with_schema( let mut request_body = ChatCompletionRequest { model: model.to_string(), messages, + stream: false, response_format, reasoning, }; @@ -413,12 +415,19 @@ mod tests { role: "user".to_string(), content: "hi".to_string(), }], + stream: false, response_format: None, reasoning, }; serde_json::to_value(&request).unwrap() } + #[test] + fn requests_explicitly_disable_streaming() { + let json = request_json(ReasoningParams::default()); + assert_eq!(json["stream"], false); + } + #[test] fn default_reasoning_params_serialize_to_no_fields() { let json = request_json(ReasoningParams::default()); From 96afb0cf335d0c6b5d6d92c9af9e14f4c3f4ef0b Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Wed, 5 Aug 2026 14:08:07 +0800 Subject: [PATCH 32/49] return freed transcription buffers to the OS on Linux (#1846) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/actions.rs | 4 +++ src-tauri/src/lib.rs | 6 +++++ src-tauri/src/memory.rs | 55 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 src-tauri/src/memory.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3b482a39d2..ced343f233 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2463,6 +2463,7 @@ dependencies = [ "handy-keys", "hf-hub", "hound", + "libc", "log", "natural", "objc2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9d6c33d21a..8f67641a55 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -153,6 +153,7 @@ objc2-service-management = "0.3.2" [target.'cfg(target_os = "linux")'.dependencies] gtk-layer-shell = { version = "0.8", features = ["v0_6"] } gtk = "0.18" +libc = "0.2" transcribe-cpp = { version = "0.1.3", default-features = false, features = [ "dynamic-backends", "vulkan", diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 247eda4224..0aa3b50e9e 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -40,6 +40,10 @@ impl Drop for FinishGuard { if let Some(c) = self.0.try_state::() { c.notify_processing_finished(); } + // The pipeline just freed its large transient buffers (captured PCM, + // WAV copy, engine scratch); hand the cached pages back to the OS so + // they don't sit in malloc arenas until they get swapped out (#1792). + crate::memory::trim_freed_memory(); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4bdb7e944..9ccc1f620e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod helpers; mod input; mod llm_client; mod managers; +mod memory; mod overlay; mod paste_tx; pub mod portable; @@ -587,6 +588,11 @@ fn run_headless_transcription(app: &AppHandle, args: &CliArgs) -> i32 { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run(cli_args: CliArgs) { + // Pin glibc's dynamic mmap threshold before the first large allocation, + // so per-dictation transient buffers are returned to the OS on free + // instead of accumulating in malloc arenas (#1792). No-op off Linux/glibc. + memory::init_allocator(); + // Detect portable mode before anything else portable::init(); diff --git a/src-tauri/src/memory.rs b/src-tauri/src/memory.rs new file mode 100644 index 0000000000..2c4f940c15 --- /dev/null +++ b/src-tauri/src/memory.rs @@ -0,0 +1,55 @@ +//! glibc allocator tuning (Linux only). +//! +//! Each dictation allocates multi-megabyte transient buffers — the captured +//! 16 kHz PCM (~64 KB per second, held twice: once for transcription, once +//! for the history WAV) plus the transcription engine's per-run mel/FFT +//! scratch (~80 KB per second of audio) — all freed within seconds. +//! +//! glibc's malloc serves allocations above its "mmap threshold" with a +//! private mmap that is returned to the OS on free. But the threshold is +//! *dynamic*: freeing an mmapped block raises it to that block's size (up to +//! 32 MB), so after the first dictation every later large buffer is served +//! from malloc arenas instead. Arena memory freed by the app is cached for +//! reuse, and interleaved small live allocations pin those pages, so the OS +//! never gets them back: RSS grows by roughly the transient-buffer volume of +//! each dictation, is never touched again, and slowly migrates to swap +//! (issue #1792 — measured at ~15 MB retained per 2-minute dictation; +//! pinning the threshold reduced that to ~0.5 MB). +//! +//! Both entry points are no-ops on non-glibc targets (Windows, macOS, musl): +//! this failure mode is specific to glibc's dynamic-threshold heuristic. + +/// Pin glibc's mmap threshold so large transient buffers keep taking the +/// mmap path and are returned to the OS as soon as they are freed. +/// +/// Must run before the workload allocates (called at the top of `run()`); +/// the cost is an mmap/munmap round-trip per multi-MB buffer, which is +/// negligible at dictation frequency. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub fn init_allocator() { + // SAFETY: FFI call with no memory arguments; mallopt only updates + // malloc's internal parameters. + unsafe { + libc::mallopt(libc::M_MMAP_THRESHOLD, 128 * 1024); + } +} + +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub fn init_allocator() {} + +/// Return freed-but-cached malloc arena memory to the OS. +/// +/// Called once per finished transcription pipeline (see `FinishGuard`); it +/// sweeps whatever smaller-than-threshold churn still accumulates in the +/// arenas. Takes on the order of a millisecond, off the main thread. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub fn trim_freed_memory() { + // SAFETY: FFI call with no memory arguments; malloc_trim releases whole + // free pages back to the OS via madvise and is thread-safe. + unsafe { + libc::malloc_trim(0); + } +} + +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub fn trim_freed_memory() {} From 838f8a242012f1576bf9059f21c1582a0febfc0b Mon Sep 17 00:00:00 2001 From: Max Miller Date: Wed, 5 Aug 2026 02:19:46 -0400 Subject: [PATCH 33/49] feat(nix): add Cachix binary cache to CI (#1237) * feat(nix): add Cachix binary cache to CI Replace magic-nix-cache (GitHub-local only) with cachix-action so build artifacts are pushed to a public binary cache. Nix users can then pull pre-built binaries instead of compiling from source (~25 min). Requires repo owner to: 1. Create the cache: cachix create handy 2. Add CACHIX_AUTH_TOKEN to GitHub repo secrets * style: fix prettier formatting in nix-check.yml * fix(nix): use correct cache name 'handy-computer' * chore(ci): pin Cachix action v17 --------- Co-authored-by: CJ Pais --- .github/workflows/nix-check.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nix-check.yml b/.github/workflows/nix-check.yml index 1d802e0284..e78aff88f7 100644 --- a/.github/workflows/nix-check.yml +++ b/.github/workflows/nix-check.yml @@ -4,7 +4,7 @@ # so compilation-breaking edits are caught by flake eval. # 2. Full nix build (~25 min) only runs when nix packaging files change. # -# Setting up a Cachix binary cache would further reduce full-build times. +# Build artifacts are pushed to Cachix so Nix users can skip local compilation. name: "nix build check" on: @@ -48,7 +48,10 @@ jobs: with: nix_path: nixpkgs=channel:nixos-unstable - - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: handy-computer + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" # Regenerate .nix/bun.nix from bun.lock and check if it matches # what's committed. A diff means the developer forgot to run @@ -121,7 +124,7 @@ jobs: # sandbox issues, compilation failures) that flake eval alone misses. # On PRs: only runs when nix packaging files change (~25 min with cold cache). # On push to main and workflow_dispatch: always runs so every commit on - # main has a verified nix build before release. + # main has a verified nix build. cachix-action auto-pushes artifacts. - name: Build handy if: steps.bun-check.outputs.outdated != 'true' && steps.eval.outputs.failed != 'true' && (steps.nix-files.outputs.changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push') run: nix build .#handy -L --show-trace From 7a5f397d07ddf1eef157319b4a4ed2ef852e4534 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Wed, 5 Aug 2026 14:30:56 +0800 Subject: [PATCH 34/49] pr template: dont submit more than one fix per pr --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ee57f00dcc..1bffa1536e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,8 @@ HANDY IS UNDERGOING A FEATURE FREEZE. IF YOU ARE SUBMITTING A PR WHICH IS A NEW BUG FIXES ARE THE TOP PRIORITY. THERE ARE 60+ ISSUES TO FIX. --> +**Please submit only one fix or feature per pull request. Pull requests containing multiple fixes or features will likely be closed.** + **Please confirm you have done the following:** - [ ] I have searched [existing issues](https://github.com/cjpais/Handy/issues) and [pull requests](https://github.com/cjpais/Handy/pulls) (including closed ones) to ensure this isn't a duplicate From b4453a297f60d2688f9663908c0e92071b0f7641 Mon Sep 17 00:00:00 2001 From: Mike Evdokimov Date: Wed, 5 Aug 2026 12:15:19 +0500 Subject: [PATCH 35/49] fix(audio): move blocking cpal work off the main thread + lock-free is_recording (#1716) * fix(audio): move blocking cpal work off the main thread + lock-free is_recording Synchronous #[tauri::command] handlers run inline on the webview/main run loop, and the audio manager guards its state with a std Mutex held across blocking CoreAudio syscalls (cpal stream start/stop, device enumeration). A worker holding that mutex across a slow device open/close (Bluetooth/USB mic) serializes the main thread, freezing the UI (spinning beachball). Fix A: is_recording() reads a lock-free Arc mirror of the "state in {Recording, Stopping}" membership, flipped at the state transitions, instead of locking `state`. The hot-path UI poll can no longer deadlock against a worker holding `state`. Fix B: the four cpal-running commands (update_microphone_mode, get_available_microphones, set_selected_microphone, get_available_output_devices) become async and run their blocking cpal work via tokio::task::spawn_blocking. Tauri's invoke is identical for sync/async commands, so there is no frontend/binding change. Live verification (Bluetooth/USB mic recording + device change mid-use) is left to manual testing; concurrency/hardware behavior is not unit-testable. * add helper function --------- Co-authored-by: CJ Pais --- src-tauri/src/commands/audio.rs | 109 ++++++++++++++++++-------------- src-tauri/src/managers/audio.rs | 51 +++++++++++---- 2 files changed, 103 insertions(+), 57 deletions(-) diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index c06d28ecae..e303627b2b 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -151,21 +151,26 @@ pub fn open_microphone_privacy_settings() -> Result<(), String> { #[tauri::command] #[specta::specta] -pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { - // Update settings +pub async fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { + // Update settings (fast, stays inline) let mut settings = get_settings(&app); settings.always_on_microphone = always_on; write_settings(&app, settings); - // Update the audio manager mode - let rm = app.state::>(); + // Update the audio manager mode. update_mode can stop/start the cpal stream + // (blocking CoreAudio) and takes the manager std mutexes — run it on a + // blocking thread, NOT inline on the webview/main run loop (a slow device + // open/close would freeze the UI). + let rm = app.state::>().inner().clone(); let new_mode = if always_on { MicrophoneMode::AlwaysOn } else { MicrophoneMode::OnDemand }; - rm.update_mode(new_mode) + tokio::task::spawn_blocking(move || rm.update_mode(new_mode)) + .await + .map_err(|e| format!("audio task join failed: {}", e))? .map_err(|e| format!("Failed to update microphone mode: {}", e)) } @@ -178,28 +183,33 @@ pub fn get_microphone_mode(app: AppHandle) -> Result { #[tauri::command] #[specta::specta] -pub fn get_available_microphones() -> Result, String> { - let devices = - list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?; - - let mut result = vec![AudioDevice { - index: "default".to_string(), - name: "Default".to_string(), - is_default: true, - }]; - - result.extend(devices.into_iter().map(|d| AudioDevice { - index: d.index, - name: d.name, - is_default: false, // The explicit default is handled separately - })); - - Ok(result) +pub async fn get_available_microphones() -> Result, String> { + // cpal device enumeration can stall — run it off the webview/main run loop. + tokio::task::spawn_blocking(|| { + let devices = + list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?; + + let mut result = vec![AudioDevice { + index: "default".to_string(), + name: "Default".to_string(), + is_default: true, + }]; + + result.extend(devices.into_iter().map(|d| AudioDevice { + index: d.index, + name: d.name, + is_default: false, // The explicit default is handled separately + })); + + Ok::<_, String>(result) + }) + .await + .map_err(|e| format!("audio task join failed: {}", e))? } #[tauri::command] #[specta::specta] -pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> { +pub async fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> { let mut settings = get_settings(&app); settings.selected_microphone = if device_name == "default" { None @@ -208,12 +218,14 @@ pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<() }; write_settings(&app, settings); - // Update the audio manager to use the new device - let rm = app.state::>(); - rm.update_selected_device() - .map_err(|e| format!("Failed to update selected device: {}", e))?; - - Ok(()) + // Update the audio manager to use the new device. update_selected_device + // can restart the cpal stream (blocking CoreAudio) — run it on a blocking + // thread, not inline on the webview/main run loop. + let rm = app.state::>().inner().clone(); + tokio::task::spawn_blocking(move || rm.update_selected_device()) + .await + .map_err(|e| format!("audio task join failed: {}", e))? + .map_err(|e| format!("Failed to update selected device: {}", e)) } #[tauri::command] @@ -227,23 +239,28 @@ pub fn get_selected_microphone(app: AppHandle) -> Result { #[tauri::command] #[specta::specta] -pub fn get_available_output_devices() -> Result, String> { - let devices = - list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?; - - let mut result = vec![AudioDevice { - index: "default".to_string(), - name: "Default".to_string(), - is_default: true, - }]; - - result.extend(devices.into_iter().map(|d| AudioDevice { - index: d.index, - name: d.name, - is_default: false, // The explicit default is handled separately - })); - - Ok(result) +pub async fn get_available_output_devices() -> Result, String> { + // cpal device enumeration can stall — run it off the webview/main run loop. + tokio::task::spawn_blocking(|| { + let devices = + list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?; + + let mut result = vec![AudioDevice { + index: "default".to_string(), + name: "Default".to_string(), + is_default: true, + }]; + + result.extend(devices.into_iter().map(|d| AudioDevice { + index: d.index, + name: d.name, + is_default: false, // The explicit default is handled separately + })); + + Ok::<_, String>(result) + }) + .await + .map_err(|e| format!("audio task join failed: {}", e))? } #[tauri::command] diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index ca599be0a2..5543c6b071 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -12,7 +12,7 @@ use crate::settings::{get_settings, AppSettings}; use crate::utils; use log::{debug, error, info, trace, warn}; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::Manager; @@ -306,6 +306,8 @@ fn create_audio_recorder( #[derive(Clone)] pub struct AudioRecordingManager { + /// Never assign through this directly — route every write through + /// `set_state()`, which keeps `recording_active` in sync. state: Arc>, mode: Arc>, app_handle: tauri::AppHandle, @@ -317,6 +319,12 @@ pub struct AudioRecordingManager { close_generation: Arc, cancel_generation: Arc, stream_router: Arc, + /// Lock-free mirror of "is the state in {Recording, Stopping}", + /// maintained by `set_state()`. The hot-path `is_recording()` reads THIS + /// instead of the std `state` mutex, so a UI poll can no longer deadlock + /// the main/webview thread when a worker holds `state` across a slow + /// CoreAudio open/close. + recording_active: Arc, /// Resolution of a *named* microphone (selected or clamshell) to its cpal /// device, cached so on-demand recording starts skip the full device /// enumeration (~40-110ms). Keyed by the resolved name, so a settings @@ -352,6 +360,7 @@ impl AudioRecordingManager { close_generation: Arc::new(AtomicU64::new(0)), cancel_generation: Arc::new(AtomicU64::new(0)), stream_router, + recording_active: Arc::new(AtomicBool::new(false)), cached_device: Arc::new(Mutex::new(None)), }; @@ -675,6 +684,21 @@ impl AudioRecordingManager { /* ---------- recording --------------------------------------------------- */ + /// The one place `state` is written. Derives `recording_active` (the + /// lock-free mirror read by `is_recording()`) from the new value itself, + /// so the two can never drift: a new `RecordingState` variant only needs + /// its active-set membership decided here, once. + fn set_state(&self, guard: &mut RecordingState, new_state: RecordingState) { + *guard = new_state; + self.recording_active.store( + matches!( + *guard, + RecordingState::Recording { .. } | RecordingState::Stopping + ), + Ordering::SeqCst, + ); + } + pub fn try_start_recording( &self, binding_id: &str, @@ -700,9 +724,12 @@ impl AudioRecordingManager { if let Some(rec) = self.recorder.lock().unwrap().as_ref() { if rec.start(vad_policy).is_ok() { *self.is_recording.lock().unwrap() = true; - *state = RecordingState::Recording { - binding_id: binding_id.to_string(), - }; + self.set_state( + &mut state, + RecordingState::Recording { + binding_id: binding_id.to_string(), + }, + ); debug!("Recording started for binding {binding_id}"); return Ok(()); } @@ -742,7 +769,7 @@ impl AudioRecordingManager { RecordingState::Recording { binding_id: ref active, } if active == binding_id => { - *state = RecordingState::Stopping; + self.set_state(&mut state, RecordingState::Stopping); drop(state); // Optionally keep recording for a bit longer to capture trailing audio. @@ -781,7 +808,7 @@ impl AudioRecordingManager { }; *self.is_recording.lock().unwrap() = false; - *self.state.lock().unwrap() = RecordingState::Idle; + self.set_state(&mut self.state.lock().unwrap(), RecordingState::Idle); // In on-demand mode, close the mic (lazily if the setting is enabled) if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { @@ -812,10 +839,12 @@ impl AudioRecordingManager { } } pub fn is_recording(&self) -> bool { - matches!( - *self.state.lock().unwrap(), - RecordingState::Recording { .. } | RecordingState::Stopping - ) + // Lock-free: mirrors the `state` {Recording, Stopping} membership via + // an atomic maintained by `set_state()`. Polled from the webview/main + // thread, so it MUST NOT take the `state` mutex (a worker can hold it + // across a slow CoreAudio open/close → main-thread deadlock / UI + // freeze). + self.recording_active.load(Ordering::SeqCst) } /// Cancel any ongoing recording without returning audio samples @@ -825,7 +854,7 @@ impl AudioRecordingManager { match *state { RecordingState::Recording { .. } => { - *state = RecordingState::Idle; + self.set_state(&mut state, RecordingState::Idle); drop(state); if let Some(rec) = self.recorder.lock().unwrap().as_ref() { From 4223e7ac6741298654d98003db38e4164ee98294 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Wed, 5 Aug 2026 15:38:06 +0800 Subject: [PATCH 36/49] fix: preserve HTTP transport error causes (#1823) * fix: preserve HTTP transport error causes * fix: keep HTTP diagnostics free of sensitive data --- src-tauri/src/llm_client.rs | 272 +++++++++++++++++++++++++++++++++--- 1 file changed, 256 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 381f3a757f..f5695b517a 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -1,9 +1,10 @@ use crate::settings::PostProcessProvider; -use log::{debug, info}; +use log::{debug, error, info}; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, REFERER, USER_AGENT}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashSet; +use std::error::Error as StdError; use std::sync::{Mutex, OnceLock}; #[derive(Debug, Serialize)] @@ -177,7 +178,120 @@ fn create_client(provider: &PostProcessProvider, api_key: &str) -> Result Vec { + let mut causes = Vec::new(); + let mut source = error.source(); + + // Defensive cap in case a third-party error exposes a cyclic source chain. + for _ in 0..16 { + let Some(cause) = source else { + break; + }; + causes.push(cause.to_string()); + source = cause.source(); + } + + causes +} + +fn reqwest_error_kinds(error: &reqwest::Error) -> String { + let mut kinds = Vec::new(); + + if error.is_builder() { + kinds.push("builder"); + } + if error.is_connect() { + kinds.push("connect"); + } + if error.is_request() { + kinds.push("request"); + } + if error.is_redirect() { + kinds.push("redirect"); + } + if error.is_timeout() { + kinds.push("timeout"); + } + if error.is_status() { + kinds.push("status"); + } + if error.is_body() { + kinds.push("body"); + } + if error.is_decode() { + kinds.push("decode"); + } + if error.is_upgrade() { + kinds.push("upgrade"); + } + + if kinds.is_empty() { + "unknown".to_string() + } else { + kinds.join(", ") + } +} + +fn sanitized_url(url: &reqwest::Url) -> String { + let mut url = url.clone(); + + // Custom endpoints should not contain credentials or query-string tokens, + // but omit them from diagnostics in case one does. + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + + url.to_string() +} + +fn sanitized_url_for_log(url: &str) -> String { + reqwest::Url::parse(url) + .map(|url| sanitized_url(&url)) + // Do not echo an invalid URL: the parse failure might have been caused + // by sensitive data entered in the custom endpoint field. + .unwrap_or_else(|_| "".to_string()) +} + +fn report_reqwest_error(context: &str, error: &reqwest::Error) -> String { + let kinds = reqwest_error_kinds(error); + let url = error + .url() + .map(sanitized_url) + .map(|url| format!(", url: {url}")) + .unwrap_or_default(); + + // serde_json's error text can quote values from a malformed response. That + // response may contain transcription content, so retain the useful decode + // classification but never put its nested source in logs or UI errors. + let causes = if error.is_decode() { + Vec::new() + } else { + error_source_chain(error) + }; + let cause_details = if !causes.is_empty() { + format!(": caused by: {}", causes.join(" -> ")) + } else if error.url().is_none() { + // Reqwest's short Display text is safe when it cannot append a raw URL. + format!(": {error}") + } else { + // The sanitized URL is already included above. Avoid formatting the + // original error because its Display implementation includes the raw URL. + String::new() + }; + + let details = format!("{context} (kind: {kinds}{url}){cause_details}"); + error!("{details}"); + details } /// Send a chat completion request to an OpenAI-compatible API @@ -224,7 +338,10 @@ pub async fn send_chat_completion_with_schema( let base_url = provider.base_url.trim_end_matches('/'); let url = format!("{}/chat/completions", base_url); - debug!("Sending chat completion request to: {}", url); + debug!( + "Sending chat completion request to: {}", + sanitized_url_for_log(&url) + ); let client = create_client(provider, &api_key)?; @@ -275,8 +392,14 @@ pub async fn send_chat_completion_with_schema( .json(&request_body) .send() .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + .map_err(|e| report_reqwest_error("HTTP request failed", &e))?; let mut status = response.status(); + debug!( + "Chat completion response received with status {} over {:?} from {}", + status, + response.version(), + sanitized_url(response.url()) + ); // A 400/422 on a request carrying reasoning-disable fields is almost always // the endpoint rejecting those fields — retry once without them. @@ -284,10 +407,9 @@ pub async fn send_chat_completion_with_schema( && matches!(status.as_u16(), 400 | 422) && !request_body.reasoning.is_empty() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Failed to read error response".to_string()); + let error_text = response.text().await.unwrap_or_else(|e| { + report_reqwest_error("Failed to read reasoning rejection response", &e) + }); info!( "Endpoint rejected request with reasoning disabled (status {}): {}. Retrying without reasoning fields", status, error_text @@ -299,13 +421,19 @@ pub async fn send_chat_completion_with_schema( .json(&request_body) .send() .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + .map_err(|e| report_reqwest_error("HTTP retry failed", &e))?; status = response.status(); + debug!( + "Chat completion retry response received with status {} over {:?} from {}", + status, + response.version(), + sanitized_url(response.url()) + ); if status.is_success() { info!( "Retry without reasoning fields succeeded; '{}' (model '{}') will skip them from now on", - base_url, model + sanitized_url_for_log(base_url), model ); remember_rejection(key); } @@ -315,7 +443,7 @@ pub async fn send_chat_completion_with_schema( let error_text = response .text() .await - .unwrap_or_else(|_| "Failed to read error response".to_string()); + .unwrap_or_else(|e| report_reqwest_error("Failed to read API error response", &e)); return Err(format!( "API request failed with status {}: {}", status, error_text @@ -325,7 +453,7 @@ pub async fn send_chat_completion_with_schema( let completion: ChatCompletionResponse = response .json() .await - .map_err(|e| format!("Failed to parse API response: {}", e))?; + .map_err(|e| report_reqwest_error("Failed to parse API response", &e))?; Ok(completion .choices @@ -342,7 +470,7 @@ pub async fn fetch_models( let base_url = provider.base_url.trim_end_matches('/'); let url = format!("{}/models", base_url); - debug!("Fetching models from: {}", url); + debug!("Fetching models from: {}", sanitized_url_for_log(&url)); let client = create_client(provider, &api_key)?; @@ -350,14 +478,20 @@ pub async fn fetch_models( .get(&url) .send() .await - .map_err(|e| format!("Failed to fetch models: {}", e))?; + .map_err(|e| report_reqwest_error("Failed to fetch models", &e))?; let status = response.status(); + debug!( + "Model list response received with status {} over {:?} from {}", + status, + response.version(), + sanitized_url(response.url()) + ); if !status.is_success() { let error_text = response .text() .await - .unwrap_or_else(|_| "Unknown error".to_string()); + .unwrap_or_else(|e| report_reqwest_error("Failed to read model list error", &e)); return Err(format!( "Model list request failed ({}): {}", status, error_text @@ -367,7 +501,7 @@ pub async fn fetch_models( let parsed: serde_json::Value = response .json() .await - .map_err(|e| format!("Failed to parse response: {}", e))?; + .map_err(|e| report_reqwest_error("Failed to parse model list response", &e))?; let mut models = Vec::new(); @@ -396,6 +530,28 @@ pub async fn fetch_models( #[cfg(test)] mod tests { use super::*; + use std::fmt; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[derive(Debug)] + struct TestError { + message: &'static str, + source: Option>, + } + + impl fmt::Display for TestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message) + } + } + + impl StdError for TestError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn StdError + 'static)) + } + } fn provider(id: &str, base_url: &str) -> PostProcessProvider { PostProcessProvider { @@ -422,6 +578,90 @@ mod tests { serde_json::to_value(&request).unwrap() } + async fn serve_one_response(status: &str, body: &str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request).await.unwrap(); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + + format!("http://{address}") + } + + #[test] + fn error_source_chain_includes_all_nested_causes() { + let error = TestError { + message: "request failed", + source: Some(Box::new(TestError { + message: "TLS handshake failed", + source: Some(Box::new(TestError { + message: "unknown certificate authority", + source: None, + })), + })), + }; + + assert_eq!( + error_source_chain(&error), + vec!["TLS handshake failed", "unknown certificate authority"] + ); + } + + #[test] + fn log_url_sanitization_removes_credentials_and_tokens() { + let url = "https://user:password@example.com/v1/models?api_key=secret#private"; + assert_eq!(sanitized_url_for_log(url), "https://example.com/v1/models"); + } + + #[test] + fn invalid_log_urls_are_not_echoed() { + assert_eq!( + sanitized_url_for_log("not a URL containing secret"), + "" + ); + } + + #[tokio::test] + async fn decode_error_does_not_echo_response_values() { + let base_url = + serve_one_response("200 OK", r#"{"choices":"PRIVATE TRANSCRIPTION CONTENT"}"#).await; + let error = reqwest::get(base_url) + .await + .unwrap() + .json::() + .await + .unwrap_err(); + + let details = report_reqwest_error("Failed to parse API response", &error); + assert!(details.contains("kind: decode")); + assert!(!details.contains("PRIVATE TRANSCRIPTION CONTENT")); + } + + #[tokio::test] + async fn raw_error_url_is_not_reintroduced_without_a_source() { + let base_url = serve_one_response("400 Bad Request", "bad request").await; + let error = reqwest::get(format!( + "{base_url}/private?api_key=SECRET_QUERY_TOKEN#private" + )) + .await + .unwrap() + .error_for_status() + .unwrap_err(); + + let details = report_reqwest_error("Request failed", &error); + assert!(details.contains(&format!("url: {base_url}/private"))); + assert!(!details.contains("SECRET_QUERY_TOKEN")); + assert!(!details.contains("#private")); + } + #[test] fn requests_explicitly_disable_streaming() { let json = request_json(ReasoningParams::default()); From 16caad7a4cf760c38f77ca4b8aa5605ed5f76849 Mon Sep 17 00:00:00 2001 From: koloved Date: Wed, 5 Aug 2026 09:59:41 +0200 Subject: [PATCH 37/49] fix(overlay): stop rendering animations while hidden (#1445) * This fixes rendering corruption (cropping) after monitor reconfiguration This prevents rendering corruption/cropping after monitor reconfiguration (display off/on, disconnect/reconnect, or DPI changes while hidden) by reapplying the overlay's logical size (OVERLAY_WIDTH/OVERLAY_HEIGHT). Refactor overlay listeners and visibility handling Move listener setup out of an inner async function and track unlisten handles so cleanup runs from the effect. Add an isVisibleRef (kept in sync via an effect) and early-return in the mic-level handler to avoid processing level updates when the overlay is hidden. Reset levels and overlay state when receiving hide-overlay. These changes prevent stale-closure issues, avoid unnecessary mic processing while hidden, and ensure listeners are properly torn down. * fix for 0.9 version I didn't have any problems with that. Fix now. * fix(overlay): render nothing while hidden --------- Co-authored-by: CJ Pais --- src/overlay/RecordingOverlay.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index 1fcfc0ff0d..c32f617dd7 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -139,6 +139,8 @@ const RecordingOverlay: React.FC = () => { setOverflowing(false); }, [session]); + if (!isVisible) return null; + // Re-pin when the user is within ~a line of the bottom; unpin otherwise. const handleStreamScroll = () => { const el = capRef.current; From 3f24f4b26a16b123f1bf7a080ce92d0561061261 Mon Sep 17 00:00:00 2001 From: Christoph Noetel <88427028+ChristophNoetel@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:25:34 +0200 Subject: [PATCH 38/49] fix: prioritize NonPackaged key in Windows mic permission check (#1284) (#1308) On systems where debloaters or privacy tools (O&O ShutUp10, etc.) set the UWP master key (HKCU\...\ConsentStore\microphone) to "deny", the app would get stuck on the mic permission onboarding page even though desktop app access (NonPackaged key) was correctly set to "allow". Change the priority logic so that desktop_app_access (NonPackaged) takes precedence over app_access (UWP master) when determining overall mic permission status, since Handy is a desktop app and only needs the NonPackaged scope. Co-authored-by: CJ Pais --- src-tauri/src/commands/audio.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index e303627b2b..97cb52f12e 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -87,14 +87,19 @@ fn get_windows_microphone_permission_status_impl() -> WindowsMicrophonePermissio let app_access = read_registry_permission_access(HKEY_CURRENT_USER, MICROPHONE_PATH); let desktop_app_access = read_registry_permission_access(HKEY_CURRENT_USER, DESKTOP_APPS_PATH); - let overall_access = if [device_access, app_access, desktop_app_access] - .into_iter() - .any(|access| access == PermissionAccess::Denied) - { + // Handy is a desktop app, so the NonPackaged key (desktop_app_access) is + // the relevant permission scope. The UWP master key (app_access) can be + // "deny" on systems with debloaters (e.g. O&O ShutUp10) without actually + // blocking desktop app microphone access. + let overall_access = if device_access == PermissionAccess::Denied { + PermissionAccess::Denied + } else if desktop_app_access == PermissionAccess::Denied { + PermissionAccess::Denied + } else if desktop_app_access == PermissionAccess::Allowed { + PermissionAccess::Allowed + } else if app_access == PermissionAccess::Denied { PermissionAccess::Denied - } else if [device_access, app_access, desktop_app_access] - .into_iter() - .all(|access| access == PermissionAccess::Allowed) + } else if device_access == PermissionAccess::Allowed && app_access == PermissionAccess::Allowed { PermissionAccess::Allowed } else { From 12f02e2a9645295c95a156b83bbb2f054df2aea4 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Thu, 6 Aug 2026 03:45:08 -0500 Subject: [PATCH 39/49] fix: add input channel selection for multi-channel audio interfaces (#1254) * fix: add input channel selection for multi-channel audio interfaces Multi-channel audio interfaces (e.g. Focusrite Scarlett, MOTU M4) expose loopback/output channels alongside mic inputs. Handy averaged all channels to mono, causing music and other audio to bleed into transcription. Add an "Input Channel" dropdown to settings that only appears when the selected microphone has more than one channel. Default is "Average all channels" which preserves the original behavior. Users with multi-channel interfaces can select their mic's specific channel. Fixes #591 * fix: preserve recorder when changing input channel * add translations --------- Co-authored-by: CJ Pais --- src-tauri/src/audio_toolkit/audio/recorder.rs | 67 ++++++++++++-- src-tauri/src/commands/audio.rs | 48 +++++++++- src-tauri/src/lib.rs | 2 + src-tauri/src/managers/audio.rs | 47 ++++++++-- src-tauri/src/settings.rs | 5 ++ src/bindings.ts | 23 ++++- src/components/settings/ChannelSelector.tsx | 88 +++++++++++++++++++ .../settings/general/GeneralSettings.tsx | 2 + src/components/settings/index.ts | 1 + src/i18n/locales/ar/translation.json | 6 ++ src/i18n/locales/bg/translation.json | 6 ++ src/i18n/locales/cs/translation.json | 6 ++ src/i18n/locales/da/translation.json | 6 ++ src/i18n/locales/de/translation.json | 6 ++ src/i18n/locales/en/translation.json | 6 ++ src/i18n/locales/es/translation.json | 6 ++ src/i18n/locales/fr/translation.json | 6 ++ src/i18n/locales/he/translation.json | 6 ++ src/i18n/locales/hi/translation.json | 6 ++ src/i18n/locales/it/translation.json | 6 ++ src/i18n/locales/ja/translation.json | 6 ++ src/i18n/locales/ko/translation.json | 6 ++ src/i18n/locales/ne/translation.json | 6 ++ src/i18n/locales/nl/translation.json | 6 ++ src/i18n/locales/pl/translation.json | 6 ++ src/i18n/locales/pt/translation.json | 6 ++ src/i18n/locales/ru/translation.json | 6 ++ src/i18n/locales/sv/translation.json | 6 ++ src/i18n/locales/tr/translation.json | 6 ++ src/i18n/locales/uk/translation.json | 6 ++ src/i18n/locales/vi/translation.json | 6 ++ src/i18n/locales/zh-TW/translation.json | 6 ++ src/i18n/locales/zh/translation.json | 6 ++ src/stores/settingsStore.ts | 8 ++ 34 files changed, 421 insertions(+), 14 deletions(-) create mode 100644 src/components/settings/ChannelSelector.tsx diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 9077bf1ca1..9bc77d05f0 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -77,6 +77,8 @@ pub struct AudioRecorder { vad: Option, level_cb: Option) + Send + Sync + 'static>>, audio_cb: Option, + /// Which input channel to use. None = average all (original behavior). + selected_channel: Option, /// Preferred stream config cached per device name. The two HAL property /// queries in `get_preferred_config` cost ~40-85ms per open (worse on /// USB/Bluetooth), which lands on the keypress->capture path in on-demand @@ -95,6 +97,7 @@ impl AudioRecorder { vad: None, level_cb: None, audio_cb: None, + selected_channel: None, config_cache: Arc::new(Mutex::new(None)), }) } @@ -136,6 +139,15 @@ impl AudioRecorder { self } + pub fn with_selected_channel(mut self, channel: Option) -> Self { + self.set_selected_channel(channel); + self + } + + pub fn set_selected_channel(&mut self, channel: Option) { + self.selected_channel = channel.map(usize::from); + } + pub fn open(&mut self, device: Option) -> Result<(), Box> { if self.worker_handle.is_some() { if !self.is_capture_worker_dead() { @@ -166,6 +178,7 @@ impl AudioRecorder { let level_cb = self.level_cb.clone(); // Move the optional real-time audio frame callback into the worker thread let audio_cb = self.audio_cb.clone(); + let selected_channel = self.selected_channel; let config_cache = Arc::clone(&self.config_cache); let worker = std::thread::spawn(move || { @@ -199,6 +212,20 @@ impl AudioRecorder { config.sample_format() ); + if let Some(channel) = selected_channel { + if channel < channels { + log::info!("Using selected input channel: {}", channel + 1); + } else { + log::warn!( + "Selected input channel {} is out of range for a {}-channel device; averaging all channels instead", + channel + 1, + channels + ); + } + } else { + log::info!("Averaging all {} input channels", channels); + } + let build_started = Instant::now(); let stream = match config.sample_format() { cpal::SampleFormat::U8 => AudioRecorder::build_stream::( @@ -206,6 +233,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -214,6 +242,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -222,6 +251,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -230,6 +260,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -238,6 +269,7 @@ impl AudioRecorder { &config, sample_tx, channels, + selected_channel, stop_flag_for_stream, ) .map_err(|e| format!("Failed to build input stream: {e}"))?, @@ -368,6 +400,7 @@ impl AudioRecorder { config: &cpal::SupportedStreamConfig, sample_tx: mpsc::Sender, channels: usize, + selected_channel: Option, stop_flag: Arc, ) -> Result where @@ -376,6 +409,13 @@ impl AudioRecorder { { let mut output_buffer = Vec::new(); let mut eos_sent = false; + // Resolve the effective channel to use. If the selected channel is + // out of range for this device, fall back to averaging all channels. + let use_channel: Option = match selected_channel { + Some(ch) if ch < channels => Some(ch), + Some(_) => None, // out of range, fall back to average + None => None, // user chose "average all" + }; let stream_cb = move |data: &[T], _: &cpal::InputCallbackInfo| { if stop_flag.load(Ordering::Relaxed) { @@ -395,13 +435,20 @@ impl AudioRecorder { let frame_count = data.len() / channels; output_buffer.reserve(frame_count); - for frame in data.chunks_exact(channels) { - let mono_sample = frame - .iter() - .map(|&sample| sample.to_sample::()) - .sum::() - / channels as f32; - output_buffer.push(mono_sample); + if let Some(ch) = use_channel { + for frame in data.chunks_exact(channels) { + let mono_sample = frame[ch].to_sample::(); + output_buffer.push(mono_sample); + } + } else { + for frame in data.chunks_exact(channels) { + let mono_sample = frame + .iter() + .map(|&sample| sample.to_sample::()) + .sum::() + / channels as f32; + output_buffer.push(mono_sample); + } } } @@ -421,6 +468,12 @@ impl AudioRecorder { ) } + pub fn preferred_input_channel_count( + device: &cpal::Device, + ) -> Result> { + Ok(Self::get_preferred_config(device)?.channels()) + } + fn get_preferred_config( device: &cpal::Device, ) -> Result> { diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 97cb52f12e..07920e2527 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -1,5 +1,5 @@ use crate::audio_feedback; -use crate::audio_toolkit::audio::{list_input_devices, list_output_devices}; +use crate::audio_toolkit::audio::{list_input_devices, list_output_devices, AudioRecorder}; use crate::managers::audio::{AudioRecordingManager, MicrophoneMode}; use crate::settings::{get_settings, write_settings}; use log::warn; @@ -332,3 +332,49 @@ pub fn is_recording(app: AppHandle) -> bool { let audio_manager = app.state::>(); audio_manager.is_recording() } + +#[tauri::command] +#[specta::specta] +pub async fn get_microphone_channels(device_name: String) -> Result { + // cpal device enumeration and config queries can stall, so keep them off + // the webview/main run loop. + tokio::task::spawn_blocking(move || { + use cpal::traits::HostTrait; + + let device = if device_name.eq_ignore_ascii_case("default") { + crate::audio_toolkit::get_cpal_host().default_input_device() + } else { + list_input_devices() + .map_err(|e| format!("Failed to list audio devices: {e}"))? + .into_iter() + .find(|device| device.name == device_name) + .map(|device| device.device) + }; + + match device { + Some(device) => AudioRecorder::preferred_input_channel_count(&device) + .map_err(|e| format!("Failed to get microphone config: {e}")), + None => Ok(1), + } + }) + .await + .map_err(|e| format!("audio task join failed: {e}"))? +} + +#[tauri::command] +#[specta::specta] +pub async fn set_selected_channel(app: AppHandle, channel: Option) -> Result<(), String> { + // Restarting cpal can block, so keep it off the webview/main run loop. Apply + // the runtime change before persisting it so a rejected active-recording + // change does not become effective on the next launch. + let manager = app.state::>().inner().clone(); + tokio::task::spawn_blocking(move || manager.update_selected_channel(channel)) + .await + .map_err(|e| format!("audio task join failed: {e}"))? + .map_err(|e| format!("Failed to update channel selection: {e}"))?; + + let mut settings = get_settings(&app); + settings.selected_channel = channel; + write_settings(&app, settings); + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9ccc1f620e..6b3e2fa042 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -701,6 +701,8 @@ pub fn run(cli_args: CliArgs) { commands::audio::set_clamshell_microphone, commands::audio::get_clamshell_microphone, commands::audio::is_recording, + commands::audio::get_microphone_channels, + commands::audio::set_selected_channel, commands::transcription::set_model_unload_timeout, commands::transcription::get_model_load_status, commands::transcription::unload_model_manually, diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 5543c6b071..8cf50a2e5a 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -262,6 +262,7 @@ struct MuteState { fn create_audio_recorder( vad_path: &Path, app_handle: &tauri::AppHandle, + selected_channel: Option, stream_router: Arc, ) -> Result { // A single Silero engine covers both the offline and streaming policies (never @@ -286,6 +287,7 @@ fn create_audio_recorder( VAD_OFFLINE_HANGOVER_FRAMES, VAD_STREAMING_HANGOVER_FRAMES, ) + .with_selected_channel(selected_channel) .with_level_callback({ let app_handle = app_handle.clone(); move |levels| { @@ -515,9 +517,11 @@ impl AudioRecordingManager { tauri::path::BaseDirectory::Resource, ) .map_err(|e| anyhow::anyhow!("Failed to resolve VAD path: {}", e))?; + let settings = get_settings(&self.app_handle); *recorder_opt = Some(create_audio_recorder( &vad_path, &self.app_handle, + settings.selected_channel, Arc::clone(&self.stream_router), )?); } @@ -741,12 +745,10 @@ impl AudioRecordingManager { } pub fn update_selected_device(&self) -> Result<(), anyhow::Error> { - // Device settings changed; drop the cached resolution so the next - // open re-enumerates. (The name-keyed cache would miss anyway; this - // just avoids holding a stale cpal::Device alive.) + // Device settings changed; re-enumerate the device and restart capture. self.invalidate_device_cache(); - // If currently open, restart the microphone stream to use the new device - if *self.is_open.lock().unwrap() { + let was_open = *self.is_open.lock().unwrap(); + if was_open { self.close_generation.fetch_add(1, Ordering::SeqCst); self.stop_microphone_stream(); self.start_microphone_stream()?; @@ -754,6 +756,41 @@ impl AudioRecordingManager { Ok(()) } + pub fn update_selected_channel( + &self, + selected_channel: Option, + ) -> Result<(), anyhow::Error> { + // Serialize against recording start/stop. Restarting an active capture + // would discard its samples and leave the manager's recording state out + // of sync with the new recorder. + let state = self.state.lock().unwrap(); + if !matches!(*state, RecordingState::Idle) { + return Err(anyhow::anyhow!( + "Cannot change the input channel while recording" + )); + } + + let previous_channel = get_settings(&self.app_handle).selected_channel; + let was_open = *self.is_open.lock().unwrap(); + if was_open { + self.close_generation.fetch_add(1, Ordering::SeqCst); + self.stop_microphone_stream(); + } + if let Some(recorder) = self.recorder.lock().unwrap().as_mut() { + recorder.set_selected_channel(selected_channel); + } + if was_open { + if let Err(error) = self.start_microphone_stream() { + if let Some(recorder) = self.recorder.lock().unwrap().as_mut() { + recorder.set_selected_channel(previous_channel); + } + return Err(error); + } + } + drop(state); + Ok(()) + } + pub fn cancel_generation(&self) -> u64 { self.cancel_generation.load(Ordering::Acquire) } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 7cf32fec60..7b73ba0e3c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -377,6 +377,10 @@ pub struct AppSettings { pub always_on_microphone: bool, #[serde(default)] pub selected_microphone: Option, + /// Which input channel to use on the selected microphone device. + /// None means "average all channels" (original behavior). + #[serde(default)] + pub selected_channel: Option, #[serde(default)] pub clamshell_microphone: Option, #[serde(default)] @@ -857,6 +861,7 @@ pub fn get_default_settings() -> AppSettings { onboarding_completed: false, always_on_microphone: false, selected_microphone: None, + selected_channel: None, clamshell_microphone: None, selected_output_device: None, translate_to_english: false, diff --git a/src/bindings.ts b/src/bindings.ts index e850f73335..98127f979c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -789,6 +789,22 @@ async getClamshellMicrophone() : Promise> { async isRecording() : Promise { return await TAURI_INVOKE("is_recording"); }, +async getMicrophoneChannels(deviceName: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_microphone_channels", { deviceName }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async setSelectedChannel(channel: number | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("set_selected_channel", { channel }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async setModelUnloadTimeout(timeout: ModelUnloadTimeout) : Promise { await TAURI_INVOKE("set_model_unload_timeout", { timeout }); }, @@ -924,7 +940,12 @@ bindings?: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk?: boolean * upgrading from before this key existed are blanked by the migration so they * see the current release's notes — see `apply_settings_migrations`. */ -whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; 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; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; 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; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; +whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; +/** + * Which input channel to use on the selected microphone device. + * None means "average all channels" (original behavior). + */ +selected_channel?: number | 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; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; 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; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; /** * Debug-gated ("beta") receipt-sequenced paste: restore the clipboard only * after the target app actually reads the transcript, instead of after a diff --git a/src/components/settings/ChannelSelector.tsx b/src/components/settings/ChannelSelector.tsx new file mode 100644 index 0000000000..2f64afedd2 --- /dev/null +++ b/src/components/settings/ChannelSelector.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Dropdown } from "../ui/Dropdown"; +import { SettingContainer } from "../ui/SettingContainer"; +import { commands } from "@/bindings"; +import { useSettings } from "../../hooks/useSettings"; + +interface ChannelSelectorProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const ChannelSelector: React.FC = React.memo( + ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating, isLoading } = useSettings(); + const [channelCount, setChannelCount] = useState(1); + + const selectedMicrophone = getSetting("selected_microphone") || "default"; + const selectedChannel = getSetting("selected_channel"); + + useEffect(() => { + let cancelled = false; + setChannelCount(1); + + const fetchChannels = async () => { + try { + const deviceName = + selectedMicrophone === "Default" ? "default" : selectedMicrophone; + const result = await commands.getMicrophoneChannels(deviceName); + if (!cancelled && result.status === "ok") { + setChannelCount(result.data); + } + } catch (error) { + console.error("Failed to get microphone channel count:", error); + } + }; + + void fetchChannels(); + return () => { + cancelled = true; + }; + }, [selectedMicrophone]); + + // Don't render if the device only has one channel. + if (channelCount <= 1) { + return null; + } + + const handleChannelSelect = async (value: string) => { + const channel = value === "average" ? null : parseInt(value, 10); + await updateSetting("selected_channel", channel); + }; + + const options = [ + { value: "average", label: t("settings.sound.channel.average") }, + ...Array.from({ length: channelCount }, (_, index) => ({ + value: index.toString(), + label: t("settings.sound.channel.channel", { n: index + 1 }), + })), + ]; + + // An old selection may not exist on a newly selected device. The recorder + // also falls back to averaging in that case, so reflect that effective value. + const currentValue = + selectedChannel == null || selectedChannel >= channelCount + ? "average" + : selectedChannel.toString(); + + return ( + + + + ); + }, +); + +ChannelSelector.displayName = "ChannelSelector"; diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index adbb6787d6..314c00faee 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { type } from "@tauri-apps/plugin-os"; import { MicrophoneSelector } from "../MicrophoneSelector"; +import { ChannelSelector } from "../ChannelSelector"; import { ShortcutInput } from "../ShortcutInput"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { OutputDeviceSelector } from "../OutputDeviceSelector"; @@ -30,6 +31,7 @@ export const GeneralSettings: React.FC = () => { + { + const result = await commands.setSelectedChannel( + (value as number | null | undefined) ?? null, + ); + if (result.status === "error") { + throw new Error(result.error); + } + }, clamshell_microphone: (value) => commands.setClamshellMicrophone( (value as string) === "Default" ? "default" : (value as string), From d7fc6a07a7c6e64189f6d9851ad96ad65971b56d Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 6 Aug 2026 21:11:10 +0800 Subject: [PATCH 40/49] add appimage version tag to release --- .github/workflows/build.yml | 48 +++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37275dbbf2..79b5cd1799 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -506,6 +506,9 @@ jobs: AZURE_TENANT_ID: ${{ inputs.sign-binaries && secrets.AZURE_TENANT_ID || '' }} TAURI_SIGNING_PRIVATE_KEY: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }} + # linuxdeploy passes this to appimagetool, which writes it as + # X-AppImage-Version for desktop integration tools such as Gear Lever. + LINUXDEPLOY_OUTPUT_VERSION: ${{ contains(inputs.platform, 'ubuntu') && steps.get-version.outputs.version || '' }} with: tagName: ${{ inputs.release-id && format('v{0}', steps.get-version.outputs.version) || '' }} releaseName: ${{ inputs.release-id && format('v{0}', steps.get-version.outputs.version) || '' }} @@ -513,6 +516,38 @@ jobs: assetNamePattern: ${{ steps.patch-release-name.outputs.platform }} args: ${{ inputs.build-args }} + # tauri-action uploads release assets before returning, so verify the + # metadata produced by Tauri itself rather than relying only on the later + # post-processing audit. + - name: Verify AppImage version metadata before post-processing + if: contains(inputs.platform, 'ubuntu') + shell: bash + run: | + set -euo pipefail + PROFILE="${{ steps.build-profile.outputs.profile }}" + APPIMAGE_PATH="$(find "src-tauri/target/${PROFILE}/bundle/appimage" -name '*.AppImage' -print -quit 2>/dev/null || true)" + if [ -z "$APPIMAGE_PATH" ]; then + echo "No AppImage produced by this Linux build; skipping metadata verification" + exit 0 + fi + APPIMAGE_PATH="$(readlink -f "$APPIMAGE_PATH")" + WORKDIR="$(mktemp -d)" + trap 'rm -rf "$WORKDIR"' EXIT + ( + cd "$WORKDIR" + "$APPIMAGE_PATH" --appimage-extract 'usr/share/applications/*.desktop' >/dev/null + DESKTOP_FILE="$(find squashfs-root/usr/share/applications -name '*.desktop' -print -quit)" + if [ -z "$DESKTOP_FILE" ]; then + echo "ERROR: AppImage desktop metadata is missing" >&2 + exit 1 + fi + if ! grep -Fqx 'X-AppImage-Version=${{ steps.get-version.outputs.version }}' "$DESKTOP_FILE"; then + echo "ERROR: AppImage version metadata is missing or incorrect:" >&2 + grep -E '^X-AppImage-Version=' "$DESKTOP_FILE" >&2 || true + exit 1 + fi + ) + - name: Verify macOS dylib bundling if: inputs.target == 'x86_64-apple-darwin' shell: bash @@ -604,8 +639,10 @@ jobs: wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage" chmod +x "appimagetool-${APPIMAGETOOL_ARCH}.AppImage" - # Repackage AppImage with no-appstream to avoid warnings - ARCH="${APPIMAGETOOL_ARCH}" "./appimagetool-${APPIMAGETOOL_ARCH}.AppImage" --no-appstream squashfs-root "$APPIMAGE_NAME" + # Repackage AppImage with no-appstream to avoid warnings. VERSION + # preserves X-AppImage-Version when appimagetool rewrites metadata. + ARCH="${APPIMAGETOOL_ARCH}" VERSION="${{ steps.get-version.outputs.version }}" \ + "./appimagetool-${APPIMAGETOOL_ARCH}.AppImage" --no-appstream squashfs-root "$APPIMAGE_NAME" # Clean up rm -rf squashfs-root "appimagetool-${APPIMAGETOOL_ARCH}.AppImage" @@ -723,6 +760,13 @@ jobs: cd "$workdir" ./Handy.AppImage --appimage-extract >/dev/null require_path "squashfs-root/usr/bin/handy" "AppImage handy binary" + desktop_file="$(find squashfs-root/usr/share/applications -name '*.desktop' -print -quit)" + require_path "$desktop_file" "AppImage desktop metadata" + if ! grep -Fqx 'X-AppImage-Version=${{ steps.get-version.outputs.version }}' "$desktop_file"; then + echo "ERROR: AppImage version metadata is missing or incorrect:" >&2 + grep -E '^X-AppImage-Version=' "$desktop_file" >&2 || true + exit 1 + fi # The SONAME (libtranscribe.so.N) is the name the loader # resolves; the bare .so alias may or may not be present. if ! find "squashfs-root/usr/lib" -name 'libtranscribe.so*' | grep -q .; then From eba5d9e28676ca4903dbf0d680f50719651711cf Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 7 Aug 2026 12:51:36 +0800 Subject: [PATCH 41/49] fix appimage check --- .github/workflows/build.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 79b5cd1799..391ebeb676 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -535,8 +535,10 @@ jobs: trap 'rm -rf "$WORKDIR"' EXIT ( cd "$WORKDIR" - "$APPIMAGE_PATH" --appimage-extract 'usr/share/applications/*.desktop' >/dev/null - DESKTOP_FILE="$(find squashfs-root/usr/share/applications -name '*.desktop' -print -quit)" + # AppImage integration tools read the root desktop entry. appimagetool + # adds X-AppImage-Version there, not to the source copy under usr/share. + "$APPIMAGE_PATH" --appimage-extract '*.desktop' >/dev/null + DESKTOP_FILE="$(find squashfs-root -maxdepth 1 -type f -name '*.desktop' -print -quit)" if [ -z "$DESKTOP_FILE" ]; then echo "ERROR: AppImage desktop metadata is missing" >&2 exit 1 @@ -760,7 +762,9 @@ jobs: cd "$workdir" ./Handy.AppImage --appimage-extract >/dev/null require_path "squashfs-root/usr/bin/handy" "AppImage handy binary" - desktop_file="$(find squashfs-root/usr/share/applications -name '*.desktop' -print -quit)" + # Gear Lever and other integration tools consume the root entry; + # appimagetool does not update the copy under usr/share/applications. + desktop_file="$(find squashfs-root -maxdepth 1 -type f -name '*.desktop' -print -quit)" require_path "$desktop_file" "AppImage desktop metadata" if ! grep -Fqx 'X-AppImage-Version=${{ steps.get-version.outputs.version }}' "$desktop_file"; then echo "ERROR: AppImage version metadata is missing or incorrect:" >&2 From 6d3239e0faa0178b9a0e12090464b54c6ca03e98 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 7 Aug 2026 13:37:32 +0800 Subject: [PATCH 42/49] fix: prevent What's New from blanking settings (#1822) --- .nix/bun-lock-hash | 2 +- .nix/bun.nix | 72 ------------------- bun.lock | 37 ---------- package.json | 1 - src/App.tsx | 5 +- src/components/ErrorBoundary.tsx | 35 +++++++++ src/components/whats-new/MarkdownContent.tsx | 76 +++----------------- src/content/release-notes/README.md | 6 +- src/lib/compat.ts | 16 +++++ src/main.tsx | 3 + 10 files changed, 70 insertions(+), 183 deletions(-) create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/lib/compat.ts diff --git a/.nix/bun-lock-hash b/.nix/bun-lock-hash index 6a1a8b1956..446a982c06 100644 --- a/.nix/bun-lock-hash +++ b/.nix/bun-lock-hash @@ -1 +1 @@ -2b3f6a3dd6298cd35494ee52c6f5fdd40fd6391df54d48cd0063b0e154f5e1a8 +9017a47022ca92d4a176df42b617f04be0991a50d41b286dd2d0c8b926a9ea1a diff --git a/.nix/bun.nix b/.nix/bun.nix index 6289a09d31..b9acd6eb73 100644 --- a/.nix/bun.nix +++ b/.nix/bun.nix @@ -901,10 +901,6 @@ url = "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"; hash = "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="; }; - "escape-string-regexp@5.0.0" = fetchurl { - url = "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz"; - hash = "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="; - }; "eslint-plugin-i18next@6.1.3" = fetchurl { url = "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz"; hash = "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw=="; @@ -1241,42 +1237,10 @@ url = "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"; hash = "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="; }; - "markdown-table@3.0.4" = fetchurl { - url = "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz"; - hash = "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="; - }; - "mdast-util-find-and-replace@3.0.2" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz"; - hash = "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="; - }; "mdast-util-from-markdown@2.0.3" = fetchurl { url = "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz"; hash = "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="; }; - "mdast-util-gfm-autolink-literal@2.0.1" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz"; - hash = "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="; - }; - "mdast-util-gfm-footnote@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz"; - hash = "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="; - }; - "mdast-util-gfm-strikethrough@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz"; - hash = "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="; - }; - "mdast-util-gfm-table@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz"; - hash = "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="; - }; - "mdast-util-gfm-task-list-item@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz"; - hash = "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="; - }; - "mdast-util-gfm@3.1.0" = fetchurl { - url = "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz"; - hash = "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="; - }; "mdast-util-mdx-expression@2.0.1" = fetchurl { url = "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz"; hash = "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="; @@ -1313,34 +1277,6 @@ url = "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz"; hash = "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="; }; - "micromark-extension-gfm-autolink-literal@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz"; - hash = "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="; - }; - "micromark-extension-gfm-footnote@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz"; - hash = "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="; - }; - "micromark-extension-gfm-strikethrough@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz"; - hash = "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="; - }; - "micromark-extension-gfm-table@2.1.1" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz"; - hash = "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="; - }; - "micromark-extension-gfm-tagfilter@2.0.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz"; - hash = "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="; - }; - "micromark-extension-gfm-task-list-item@2.1.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz"; - hash = "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="; - }; - "micromark-extension-gfm@3.0.0" = fetchurl { - url = "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz"; - hash = "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="; - }; "micromark-factory-destination@2.0.1" = fetchurl { url = "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz"; hash = "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="; @@ -1561,10 +1497,6 @@ url = "https://registry.npmjs.org/react/-/react-18.3.1.tgz"; hash = "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="; }; - "remark-gfm@4.0.1" = fetchurl { - url = "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz"; - hash = "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="; - }; "remark-parse@11.0.0" = fetchurl { url = "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz"; hash = "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="; @@ -1573,10 +1505,6 @@ url = "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz"; hash = "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="; }; - "remark-stringify@11.0.0" = fetchurl { - url = "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz"; - hash = "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="; - }; "requireindex@1.1.0" = fetchurl { url = "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz"; hash = "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg=="; diff --git a/bun.lock b/bun.lock index 7f6b711e44..1a52da125a 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,6 @@ "react-i18next": "^16.4.1", "react-markdown": "^10.1.0", "react-select": "^5.8.0", - "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", "tauri-plugin-macos-permissions-api": "2.3.0", @@ -638,24 +637,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], - "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], - - "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], - - "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], - - "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], - - "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], - - "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], @@ -676,20 +659,6 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], - "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], - - "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], - - "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], - - "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], - - "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], - - "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], - - "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], @@ -796,14 +765,10 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], - "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "requireindex": ["requireindex@1.1.0", "", {}, "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], @@ -952,8 +917,6 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], diff --git a/package.json b/package.json index bb319004e3..7cf421718f 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "react-i18next": "^16.4.1", "react-markdown": "^10.1.0", "react-select": "^5.8.0", - "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", "tauri-plugin-macos-permissions-api": "2.3.0", diff --git a/src/App.tsx b/src/App.tsx index 449245a420..fde2fbe96d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import AccessibilityPermissions from "./components/AccessibilityPermissions"; import SecureInputWarning from "./components/SecureInputWarning"; import Footer from "./components/footer"; import Onboarding, { AccessibilityOnboarding } from "./components/onboarding"; +import { ErrorBoundary } from "./components/ErrorBoundary"; import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar"; import { WhatsNewGate } from "./components/whats-new"; import { useSettings } from "./hooks/useSettings"; @@ -293,7 +294,9 @@ function App() { dir={direction} className="h-screen flex flex-col select-none cursor-default" > - + + + {/* Main content area that takes remaining space */}
{ + state: ErrorBoundaryState = { failed: false }; + + static getDerivedStateFromError(): ErrorBoundaryState { + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error( + `Error rendering ${this.props.context}:`, + error, + info.componentStack, + ); + } + + render(): ReactNode { + if (this.state.failed) return null; + return this.props.children; + } +} diff --git a/src/components/whats-new/MarkdownContent.tsx b/src/components/whats-new/MarkdownContent.tsx index bb1f055e3c..7ece8c3ee3 100644 --- a/src/components/whats-new/MarkdownContent.tsx +++ b/src/components/whats-new/MarkdownContent.tsx @@ -1,6 +1,5 @@ import React from "react"; import ReactMarkdown, { type Components } from "react-markdown"; -import remarkGfm from "remark-gfm"; import { openUrl } from "@tauri-apps/plugin-opener"; interface MarkdownContentProps { @@ -12,25 +11,17 @@ const allowedElements = [ "blockquote", "br", "code", - "del", "em", "h1", "h2", "h3", "hr", "img", - "input", "li", "ol", "p", "pre", "strong", - "table", - "tbody", - "td", - "th", - "thead", - "tr", "ul", ]; @@ -77,51 +68,19 @@ const components: Components = { p: ({ children }) => (

{children}

), - ul: ({ children, className }) => { - const isTaskList = className?.includes("contains-task-list"); - - return ( -
    - {children} -
- ); - }, - li: ({ children, className }) => { - const isTaskListItem = className?.includes("task-list-item"); - - return ( -
  • - {children} -
  • - ); - }, - input: ({ checked, type }) => { - if (type !== "checkbox") return null; - - return ( - - ); - }, + ul: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • {children}
  • + ), ol: ({ children }) => (
      {children}
    ), - del: ({ children }) => ( - {children} - ), br: () =>
    , hr: () =>
    , img: ({ alt, src }) => { @@ -137,24 +96,6 @@ const components: Components = { /> ); }, - table: ({ children }) => ( -
    - - {children} -
    -
    - ), - thead: ({ children }) => ( - {children} - ), - tbody: ({ children }) => ( - {children} - ), - tr: ({ children }) => {children}, - th: ({ children }) => ( - {children} - ), - td: ({ children }) => {children}, blockquote: ({ children }) => (
    {children} @@ -211,7 +152,6 @@ export const MarkdownContent: React.FC = ({ {markdown} diff --git a/src/content/release-notes/README.md b/src/content/release-notes/README.md index 4abd0f9547..3aeb543868 100644 --- a/src/content/release-notes/README.md +++ b/src/content/release-notes/README.md @@ -11,9 +11,9 @@ persisted `whats_new_last_seen_version` and not newer than the running app version. Keep these files focused on headline user-facing changes. Release notes support -paragraphs, headings, lists, links, code, quotes, strikethrough, tables, task -lists, separators, hard line breaks, and local images under -`/release-notes/...`. Raw HTML is ignored before rendering. +paragraphs, headings, lists, links, code, quotes, separators, hard line breaks, +and local images under `/release-notes/...`. Raw HTML is ignored before +rendering. Place image assets in `public/release-notes/{version}/` and reference them from Markdown with absolute paths: diff --git a/src/lib/compat.ts b/src/lib/compat.ts new file mode 100644 index 0000000000..b88c2137f7 --- /dev/null +++ b/src/lib/compat.ts @@ -0,0 +1,16 @@ +const objectConstructor = Object as typeof Object & { + hasOwn?: (target: object, key: PropertyKey) => boolean; +}; + +/** Install compatibility shims required by frontend dependencies. */ +export const installCompatShims = (): void => { + // react-markdown uses Object.hasOwn, which is unavailable before Safari 15.4. + if (typeof objectConstructor.hasOwn !== "function") { + Object.defineProperty(Object, "hasOwn", { + value: (target: object, key: PropertyKey): boolean => + Object.prototype.hasOwnProperty.call(target, key), + configurable: true, + writable: true, + }); + } +}; diff --git a/src/main.tsx b/src/main.tsx index 3cd2ed60a5..55c7d5a9e4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,12 +2,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { platform } from "@tauri-apps/plugin-os"; import App from "./App"; +import { installCompatShims } from "./lib/compat"; import { applyTheme, getStoredTheme, syncThemeFromSettings, } from "./lib/utils/theme"; +installCompatShims(); + // Set platform before render so CSS can scope per-platform (e.g. scrollbar styles) document.documentElement.dataset.platform = platform(); From d961593721b6643d8fe94b6ae28f5dc3fa63edd8 Mon Sep 17 00:00:00 2001 From: gwendall Date: Fri, 7 Aug 2026 08:10:18 +0200 Subject: [PATCH 43/49] fix: update js-yaml to address quadratic parsing (#1865) --- .nix/bun-lock-hash | 2 +- .nix/bun.nix | 6 +++--- bun.lock | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.nix/bun-lock-hash b/.nix/bun-lock-hash index 446a982c06..fec2300a8b 100644 --- a/.nix/bun-lock-hash +++ b/.nix/bun-lock-hash @@ -1 +1 @@ -9017a47022ca92d4a176df42b617f04be0991a50d41b286dd2d0c8b926a9ea1a +a7fd0739d7f757d16b24723d230d25203292c76eafb53f3daddcf43b8ec94872 diff --git a/.nix/bun.nix b/.nix/bun.nix index b9acd6eb73..0b6e51d9e2 100644 --- a/.nix/bun.nix +++ b/.nix/bun.nix @@ -1117,9 +1117,9 @@ url = "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"; hash = "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="; }; - "js-yaml@4.1.1" = fetchurl { - url = "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz"; - hash = "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="; + "js-yaml@4.3.1" = fetchurl { + url = "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz"; + hash = "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="; }; "jsesc@3.1.0" = fetchurl { url = "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"; diff --git a/bun.lock b/bun.lock index 1a52da125a..6ac7785818 100644 --- a/bun.lock +++ b/bun.lock @@ -577,7 +577,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], From 0f32df744532e7eb0a241a0938b8ff351626a5cf Mon Sep 17 00:00:00 2001 From: Yan Li Date: Fri, 7 Aug 2026 08:11:00 +0200 Subject: [PATCH 44/49] fix(paste): release the modifier keys after pasting with wtype (#1487) * fix(paste): release the modifier keys after pasting with wtype * format --------- Co-authored-by: CJ Pais --- src-tauri/src/clipboard.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 5a181345e1..01b60f66c4 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -427,9 +427,11 @@ fn write_clipboard_via_wl_copy(text: &str) -> Result<(), String> { #[cfg(target_os = "linux")] fn send_key_combo_via_wtype(paste_method: &PasteMethod) -> Result<(), String> { let args: Vec<&str> = match paste_method { - PasteMethod::CtrlV => vec!["-M", "ctrl", "-k", "v"], - PasteMethod::ShiftInsert => vec!["-M", "shift", "-k", "Insert"], - PasteMethod::CtrlShiftV => vec!["-M", "ctrl", "-M", "shift", "-k", "v"], + PasteMethod::CtrlV => vec!["-M", "ctrl", "-k", "v", "-m", "ctrl"], + PasteMethod::ShiftInsert => vec!["-M", "shift", "-k", "Insert", "-m", "shift"], + PasteMethod::CtrlShiftV => vec![ + "-M", "ctrl", "-M", "shift", "-k", "v", "-m", "shift", "-m", "ctrl", + ], _ => return Err("Unsupported paste method".into()), }; From 4b3a969daeafb8c6403228149fdb20eec7edf3a9 Mon Sep 17 00:00:00 2001 From: vscg <39382021+vscg@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:10:58 +0530 Subject: [PATCH 45/49] feat: include streaming and translation filter buttons for easy access of models (#1815) * feat: include streaming and translation filter buttons for easy access of models * style: standardize model button sizes and add divider * format + translations --------- Co-authored-by: vscg Co-authored-by: CJ Pais --- .../settings/models/ModelsSettings.tsx | 302 ++++++++++-------- src/i18n/locales/ar/translation.json | 4 +- src/i18n/locales/bg/translation.json | 4 +- src/i18n/locales/cs/translation.json | 4 +- src/i18n/locales/da/translation.json | 4 +- src/i18n/locales/de/translation.json | 4 +- src/i18n/locales/en/translation.json | 4 +- src/i18n/locales/es/translation.json | 4 +- src/i18n/locales/fr/translation.json | 4 +- src/i18n/locales/he/translation.json | 4 +- src/i18n/locales/hi/translation.json | 4 +- src/i18n/locales/it/translation.json | 4 +- src/i18n/locales/ja/translation.json | 4 +- src/i18n/locales/ko/translation.json | 4 +- src/i18n/locales/ne/translation.json | 4 +- src/i18n/locales/nl/translation.json | 4 +- src/i18n/locales/pl/translation.json | 4 +- src/i18n/locales/pt/translation.json | 4 +- src/i18n/locales/ru/translation.json | 4 +- src/i18n/locales/sv/translation.json | 4 +- src/i18n/locales/tr/translation.json | 4 +- src/i18n/locales/uk/translation.json | 4 +- src/i18n/locales/vi/translation.json | 4 +- src/i18n/locales/zh-TW/translation.json | 4 +- src/i18n/locales/zh/translation.json | 4 +- 25 files changed, 243 insertions(+), 155 deletions(-) diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 41d7a0e8bf..9554594400 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -1,7 +1,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ask } from "@tauri-apps/plugin-dialog"; -import { ChevronDown, Globe, RefreshCw, Search } from "lucide-react"; +import { + AudioLines, + ChevronDown, + Globe, + Languages, + RefreshCw, + Search, +} from "lucide-react"; import type { ModelCardStatus } from "@/components/onboarding"; import { ModelCard } from "@/components/onboarding"; import { useModelStore } from "@/stores/modelStore"; @@ -27,6 +34,8 @@ export const ModelsSettings: React.FC = () => { const { t } = useTranslation(); const [switchingModelId, setSwitchingModelId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + const [filterStreaming, setFilterStreaming] = useState(false); + const [filterTranslation, setFilterTranslation] = useState(false); const [languageFilter, setLanguageFilter] = useState("all"); const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false); const [languageSearch, setLanguageSearch] = useState(""); @@ -164,7 +173,7 @@ export const ModelsSettings: React.FC = () => { } }; - // Filter models by search query (name + description) and language filter + // Filter models by search query (name + description), language filter, and toggles const filteredModels = useMemo(() => { const q = searchQuery.trim().toLowerCase(); return models.filter((model: ModelInfo) => { @@ -173,13 +182,16 @@ export const ModelsSettings: React.FC = () => { if (languageFilter !== "all") { if (!modelSupportsLanguage(model, languageFilter)) return false; } + if (filterStreaming && !model.supports_streaming) return false; + if (filterTranslation && !model.supports_translation) return false; + if (q) { const haystack = `${model.name} ${model.description}`.toLowerCase(); if (!haystack.includes(q)) return false; } return true; }); - }, [models, languageFilter, searchQuery]); + }, [models, languageFilter, filterStreaming, filterTranslation, searchQuery]); // Split filtered models into downloaded (including custom) and available sections const { downloadedModels, availableModels } = useMemo(() => { @@ -246,125 +258,175 @@ export const ModelsSettings: React.FC = () => { />
    - {filteredModels.length > 0 ? ( -
    - {/* Downloaded Models Section — header always visible so filter stays accessible */} -
    -
    -

    - {t("settings.models.yourModels")} -

    -
    - {/* Rescan local sources for models added outside Handy */} +
    + {/* Downloaded Models Section — header always visible so filter stays accessible */} +
    +
    +

    + {t("settings.models.yourModels")} +

    +
    + {/* Rescan local sources for models added outside Handy */} + + + {/* Vertical divider separating action from filters */} +
    + + + {/* Language filter dropdown */} +
    - {/* Language filter dropdown */} -
    - - {languageDropdownOpen && ( -
    -
    - setLanguageSearch(e.target.value)} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - filteredLanguages.length > 0 - ) { - setLanguageFilter(filteredLanguages[0].value); - setLanguageDropdownOpen(false); - setLanguageSearch(""); - } else if (e.key === "Escape") { - setLanguageDropdownOpen(false); - setLanguageSearch(""); - } - }} - placeholder={t( - "settings.general.language.searchPlaceholder", - )} - className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary" - /> -
    -
    + {languageDropdownOpen && ( +
    +
    + setLanguageSearch(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + filteredLanguages.length > 0 + ) { + setLanguageFilter(filteredLanguages[0].value); + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } else if (e.key === "Escape") { + setLanguageDropdownOpen(false); + setLanguageSearch(""); + } + }} + placeholder={t( + "settings.general.language.searchPlaceholder", + )} + className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary" + /> +
    +
    + + {filteredLanguages.map((lang) => ( - {filteredLanguages.map((lang) => ( - - ))} - {filteredLanguages.length === 0 && ( -
    - {t("settings.general.language.noResults")} -
    - )} -
    + ))} + {filteredLanguages.length === 0 && ( +
    + {t("settings.general.language.noResults")} +
    + )}
    - )} -
    +
    + )}
    - {downloadedModels.map((model: ModelInfo) => ( +
    + {downloadedModels.map((model: ModelInfo) => ( + + ))} +
    + + {/* Available Models Section */} + {availableModels.length > 0 && ( +
    +

    + {t("settings.models.availableModels")} +

    + {availableModels.map((model: ModelInfo) => ( { onCancel={handleModelCancel} downloadProgress={getDownloadProgress(model.id)} downloadSpeed={getDownloadSpeed(model.id)} - showRecommended={false} + showRecommended={true} /> ))}
    - - {/* Available Models Section */} - {availableModels.length > 0 && ( -
    -

    - {t("settings.models.availableModels")} -

    - {availableModels.map((model: ModelInfo) => ( - - ))} -
    - )} -
    - ) : ( -
    - {t("settings.models.noModelsMatch")} -
    - )} + )} + {filteredModels.length === 0 && ( +
    + {t("settings.models.noModelsMatch")} +
    + )} +
    ); }; diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 12598ead7c..ebef21ec53 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -591,7 +591,9 @@ "deleteActiveConfirm": "{{modelName}} هو النموذج النشط حاليًا. حذفه سيوقف النسخ حتى تختار نموذجًا جديدًا. هل أنت متأكد؟", "deleteTitle": "حذف النموذج", "filters": { - "allLanguages": "جميع اللغات" + "allLanguages": "جميع اللغات", + "streaming": "تصفية النماذج التي تدعم النسخ المباشر", + "translation": "تصفية النماذج التي تدعم الترجمة إلى الإنجليزية" }, "noModelsMatch": "لا توجد نماذج مطابقة لهذا الفلتر.", "searchPlaceholder": "ابحث عن النماذج بالاسم…", diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index a09af10dd6..79f498f708 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -208,7 +208,9 @@ "deleteActiveConfirm": "{{modelName}} е вашият активен модел. Изтриването му ще спре транскрипциите, докато не изберете нов модел. Сигурни ли сте?", "deleteTitle": "Изтриване на модел", "filters": { - "allLanguages": "Всички езици" + "allLanguages": "Всички езици", + "streaming": "Филтриране на моделите, които поддържат транскрипция на живо", + "translation": "Филтриране на моделите, които поддържат превод на английски" }, "noModelsMatch": "Няма модели, отговарящи на този филтър.", "searchPlaceholder": "Търсене на модели по име…", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 783d70675c..cbd6fb17c6 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} je váš aktivní model. Jeho smazáním se zastaví přepisy, dokud nevyberete nový model. Opravdu chcete pokračovat?", "deleteTitle": "Smazat model", "filters": { - "allLanguages": "Všechny jazyky" + "allLanguages": "Všechny jazyky", + "streaming": "Filtrovat modely podporující živý přepis", + "translation": "Filtrovat modely podporující překlad do angličtiny" }, "noModelsMatch": "Tomuto filtru neodpovídají žádné modely.", "yourModels": "Stažené modely", diff --git a/src/i18n/locales/da/translation.json b/src/i18n/locales/da/translation.json index 051d6ae303..16b2375823 100644 --- a/src/i18n/locales/da/translation.json +++ b/src/i18n/locales/da/translation.json @@ -209,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} er din aktive model. Sletning af den vil stoppe transskriptioner, indtil du vælger en ny model. Er du sikker?", "deleteTitle": "Slet model", "filters": { - "allLanguages": "Alle sprog" + "allLanguages": "Alle sprog", + "streaming": "Filtrer modeller, der understøtter live transskription", + "translation": "Filtrer modeller, der understøtter oversættelse til engelsk" }, "rescan": { "label": "Søg igen", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 550aa87d9c..4d8aa8238a 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} ist dein aktives Modell. Das Löschen stoppt Transkriptionen, bis du ein neues Modell auswählst. Bist du sicher?", "deleteTitle": "Modell löschen", "filters": { - "allLanguages": "Alle Sprachen" + "allLanguages": "Alle Sprachen", + "streaming": "Modelle filtern, die Live-Transkription unterstützen", + "translation": "Modelle filtern, die Übersetzungen ins Englische unterstützen" }, "noModelsMatch": "Keine Modelle entsprechen diesem Filter.", "yourModels": "Heruntergeladene Modelle", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 9b42f7b6ce..d4d32a5dab 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -209,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} is your active model. Deleting it will stop transcriptions until you select a new model. Are you sure?", "deleteTitle": "Delete Model", "filters": { - "allLanguages": "All Languages" + "allLanguages": "All Languages", + "streaming": "Filter models that support live streaming transcription", + "translation": "Filter models that support translation to English" }, "rescan": { "label": "Rescan", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index cdf9e183da..108388c8c4 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} es tu modelo activo. Eliminarlo detendrá las transcripciones hasta que selecciones un nuevo modelo. ¿Estás seguro?", "deleteTitle": "Eliminar modelo", "filters": { - "allLanguages": "Todos los idiomas" + "allLanguages": "Todos los idiomas", + "streaming": "Filtrar modelos compatibles con la transcripción en vivo", + "translation": "Filtrar modelos compatibles con la traducción al inglés" }, "noModelsMatch": "Ningún modelo coincide con este filtro.", "yourModels": "Modelos descargados", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 0805981b5a..6bdf9da07c 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} est votre modèle actif. Le supprimer arrêtera les transcriptions jusqu'à ce que vous sélectionniez un nouveau modèle. Êtes-vous sûr ?", "deleteTitle": "Supprimer le modèle", "filters": { - "allLanguages": "Toutes les langues" + "allLanguages": "Toutes les langues", + "streaming": "Filtrer les modèles prenant en charge la transcription en direct", + "translation": "Filtrer les modèles prenant en charge la traduction vers l'anglais" }, "noModelsMatch": "Aucun modèle ne correspond à ce filtre.", "yourModels": "Modèles téléchargés", diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 025f06d70b..415cb8226c 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -208,7 +208,9 @@ "deleteActiveConfirm": "{{modelName}} הוא המודל הפעיל שלך כרגע. מחיקה שלו תעצור את התמלול עד שתבחר מודל חדש. להמשיך?", "deleteTitle": "מחיקת מודל", "filters": { - "allLanguages": "כל השפות" + "allLanguages": "כל השפות", + "streaming": "סינון דגמים התומכים בתמלול חי", + "translation": "סינון דגמים התומכים בתרגום לאנגלית" }, "noModelsMatch": "אין מודלים שמתאימים לסינון הזה.", "searchPlaceholder": "חיפוש מודלים לפי שם…", diff --git a/src/i18n/locales/hi/translation.json b/src/i18n/locales/hi/translation.json index 2089103a41..ee6f0d6c5a 100644 --- a/src/i18n/locales/hi/translation.json +++ b/src/i18n/locales/hi/translation.json @@ -209,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} अभी इस्तेमाल में है. इसे हटाने पर ट्रांसक्रिप्शन तब तक रुका रहेगा, जब तक आप कोई नया मॉडल नहीं चुन लेते. क्या आप वाकई हटाना चाहते हैं?", "deleteTitle": "मॉडल हटाएं", "filters": { - "allLanguages": "सभी भाषाएं" + "allLanguages": "सभी भाषाएं", + "streaming": "लाइव ट्रांसक्रिप्शन का समर्थन करने वाले मॉडल फ़िल्टर करें", + "translation": "अंग्रेज़ी में अनुवाद का समर्थन करने वाले मॉडल फ़िल्टर करें" }, "rescan": { "label": "फिर से स्कैन करें", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 7206f0180e..80aefa3be9 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} è il tuo modello attivo. Eliminarlo interromperà le trascrizioni fino a quando non selezionerai un nuovo modello. Sei sicuro?", "deleteTitle": "Elimina modello", "filters": { - "allLanguages": "Tutte le lingue" + "allLanguages": "Tutte le lingue", + "streaming": "Filtra i modelli che supportano la trascrizione in tempo reale", + "translation": "Filtra i modelli che supportano la traduzione in inglese" }, "noModelsMatch": "Nessun modello corrisponde a questo filtro.", "yourModels": "Modelli scaricati", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 41d0e4ad78..636892af4b 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}}は現在使用中のモデルです。削除すると、新しいモデルを選択するまで文字起こしが停止します。本当に削除しますか?", "deleteTitle": "モデルを削除", "filters": { - "allLanguages": "すべての言語" + "allLanguages": "すべての言語", + "streaming": "リアルタイム文字起こしに対応するモデルを絞り込む", + "translation": "英語への翻訳に対応するモデルを絞り込む" }, "noModelsMatch": "このフィルターに一致するモデルがありません。", "yourModels": "ダウンロード済みモデル", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 74b2ea94c9..557686ce5f 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -237,7 +237,9 @@ "deleteActiveConfirm": "{{modelName}}은(는) 현재 활성 모델입니다. 삭제하면 새 모델을 선택할 때까지 전사가 중지됩니다. 정말 삭제하시겠습니까?", "deleteTitle": "모델 삭제", "filters": { - "allLanguages": "모든 언어" + "allLanguages": "모든 언어", + "streaming": "실시간 음성 인식을 지원하는 모델 필터링", + "translation": "영어 번역을 지원하는 모델 필터링" }, "noModelsMatch": "이 필터에 맞는 모델이 없습니다.", "searchPlaceholder": "이름으로 모델 검색…", diff --git a/src/i18n/locales/ne/translation.json b/src/i18n/locales/ne/translation.json index 43dc81f052..e9572e7cf9 100644 --- a/src/i18n/locales/ne/translation.json +++ b/src/i18n/locales/ne/translation.json @@ -209,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} तपाईंको सक्रिय मोडेल हो। यसलाई मेटाउँदा नयाँ मोडेल चयन नगरेसम्म ट्रान्सक्रिप्सन रोकिनेछ। के तपाईं साँच्चै मेटाउन चाहनुहुन्छ?", "deleteTitle": "मोडेल मेटाउनुहोस्", "filters": { - "allLanguages": "सबै भाषाहरू" + "allLanguages": "सबै भाषाहरू", + "streaming": "लाइभ ट्रान्सक्रिप्सन समर्थन गर्ने मोडेलहरू फिल्टर गर्नुहोस्", + "translation": "अङ्ग्रेजीमा अनुवाद समर्थन गर्ने मोडेलहरू फिल्टर गर्नुहोस्" }, "rescan": { "label": "फेरि स्क्यान गर्नुहोस्", diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 13ed451f5d..af549bd3f5 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -209,7 +209,9 @@ "deleteActiveConfirm": "{{modelName}} is je actieve model. Als je dit verwijdert, stoppen de transcripties totdat je een nieuw model selecteert. Weet je het zeker?", "deleteTitle": "Model verwijderen", "filters": { - "allLanguages": "Alle talen" + "allLanguages": "Alle talen", + "streaming": "Filter modellen die live transcriptie ondersteunen", + "translation": "Filter modellen die vertaling naar het Engels ondersteunen" }, "rescan": { "label": "Opnieuw scannen", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 09440847e5..5e3daed778 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} jest Twoim aktywnym modelem. Usunięcie go zatrzyma transkrypcje, dopóki nie wybierzesz nowego modelu. Czy na pewno chcesz kontynuować?", "deleteTitle": "Usuń model", "filters": { - "allLanguages": "Wszystkie języki" + "allLanguages": "Wszystkie języki", + "streaming": "Filtruj modele obsługujące transkrypcję na żywo", + "translation": "Filtruj modele obsługujące tłumaczenie na język angielski" }, "noModelsMatch": "Żadne modele nie pasują do tego filtra.", "yourModels": "Pobrane modele", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 52cd84b3fb..3f4dd94a00 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -206,7 +206,9 @@ "deleteActiveConfirm": "{{modelName}} é o seu modelo ativo. Excluí-lo interromperá as transcrições até que você selecione um novo modelo. Tem certeza?", "deleteTitle": "Excluir Modelo", "filters": { - "allLanguages": "Todos os Idiomas" + "allLanguages": "Todos os Idiomas", + "streaming": "Filtrar modelos compatíveis com transcrição ao vivo", + "translation": "Filtrar modelos compatíveis com tradução para inglês" }, "noModelsMatch": "Nenhum modelo corresponde a este filtro.", "yourModels": "Modelos baixados", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 5d1eaf7546..5b85e04880 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} — ваша активная модель. Удаление остановит транскрипцию, пока вы не выберете новую модель. Вы уверены?", "deleteTitle": "Удалить модель", "filters": { - "allLanguages": "Все языки" + "allLanguages": "Все языки", + "streaming": "Фильтровать модели с поддержкой транскрипции в реальном времени", + "translation": "Фильтровать модели с поддержкой перевода на английский" }, "noModelsMatch": "Нет моделей, соответствующих этому фильтру.", "yourModels": "Загруженные модели", diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index ee627c73d2..e92f075efa 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -208,7 +208,9 @@ "deleteActiveConfirm": "{{modelName}} är din aktiva modell. Om du tar bort den avbryts transkriptioner tills du väljer en ny modell. Är du säker?", "deleteTitle": "Ta bort modell", "filters": { - "allLanguages": "Alla språk" + "allLanguages": "Alla språk", + "streaming": "Filtrera modeller som stöder transkription i realtid", + "translation": "Filtrera modeller som stöder översättning till engelska" }, "noModelsMatch": "Inga modeller matchar detta filter.", "searchPlaceholder": "Sök modeller efter namn…", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 9b32dc0fc2..34cf6c5858 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} aktif modelinizdir. Silmek, yeni bir model seçene kadar transkripsiyonları durduracaktır. Emin misiniz?", "deleteTitle": "Modeli Sil", "filters": { - "allLanguages": "Tüm Diller" + "allLanguages": "Tüm Diller", + "streaming": "Canlı transkripsiyonu destekleyen modelleri filtrele", + "translation": "İngilizce'ye çeviriyi destekleyen modelleri filtrele" }, "noModelsMatch": "Bu filtreyle eşleşen model yok.", "yourModels": "İndirilen modeller", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index eb7d757e90..8f8149c189 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -206,7 +206,9 @@ "deleteActiveConfirm": "{{modelName}} — ваша активна модель. Видалення зупинить транскрипцію, доки ви не оберете нову модель. Ви впевнені?", "deleteTitle": "Видалити модель", "filters": { - "allLanguages": "Усі мови" + "allLanguages": "Усі мови", + "streaming": "Фільтрувати моделі з підтримкою транскрипції в реальному часі", + "translation": "Фільтрувати моделі з підтримкою перекладу англійською" }, "noModelsMatch": "Жодна модель не відповідає цьому фільтру.", "yourModels": "Завантажені моделі", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 5497fd9a07..b94dee384d 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} là mô hình đang hoạt động của bạn. Xóa nó sẽ dừng phiên âm cho đến khi bạn chọn một mô hình mới. Bạn có chắc không?", "deleteTitle": "Xóa mô hình", "filters": { - "allLanguages": "Tất cả ngôn ngữ" + "allLanguages": "Tất cả ngôn ngữ", + "streaming": "Lọc các mô hình hỗ trợ phiên âm trực tiếp", + "translation": "Lọc các mô hình hỗ trợ dịch sang tiếng Anh" }, "noModelsMatch": "Không có mô hình nào khớp với bộ lọc này.", "yourModels": "Mô hình đã tải", diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index bc985b8026..4d694ed8f9 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -208,7 +208,9 @@ "deleteActiveConfirm": "{{modelName}} 是您目前使用的模型。刪除後將停止轉錄,直到您選擇新的模型。確定要刪除嗎?", "deleteTitle": "刪除模型", "filters": { - "allLanguages": "所有語言" + "allLanguages": "所有語言", + "streaming": "篩選支援即時轉錄的模型", + "translation": "篩選支援翻譯為英語的模型" }, "noModelsMatch": "沒有符合此篩選條件的模型", "searchPlaceholder": "依名稱搜尋模型…", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index b57e1fa6f2..d816fc7db9 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -165,7 +165,9 @@ "deleteActiveConfirm": "{{modelName}} 是您当前使用的模型。删除后将停止转录,直到您选择新的模型。确定要删除吗?", "deleteTitle": "删除模型", "filters": { - "allLanguages": "所有语言" + "allLanguages": "所有语言", + "streaming": "筛选支持实时转录的模型", + "translation": "筛选支持翻译为英语的模型" }, "noModelsMatch": "没有符合此筛选条件的模型。", "yourModels": "已下载的模型", From 09aaf4d303334ea80b616c46e860f6bf51b4c0ba Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 7 Aug 2026 17:12:28 +0800 Subject: [PATCH 46/49] fix moonshine streaming giving the wrong results --- src-tauri/src/managers/transcription.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 466c950da5..6c546d9509 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -983,7 +983,7 @@ impl TranscriptionManager { update.audio_committed_ms, update.buffered_ms, ); - Some(stream.text().display()) + Some(stream.text().full) } Err(e) => { perf.record_compute(finalize_start.elapsed()); From b428ae4c9c8aecba00d0e44e14d6466d9687795f Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 7 Aug 2026 20:22:17 +0800 Subject: [PATCH 47/49] appimage packaging fixes (#1867) * remove .so from appimage * narrow exclusions --- .github/workflows/build.yml | 154 +++++++++++------------- src-tauri/src/managers/transcription.rs | 14 ++- 2 files changed, 85 insertions(+), 83 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 391ebeb676..5bfb684fef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -451,8 +451,8 @@ jobs: # ships them. Pre-build the app so that install dir exists, then expose it # via LD_LIBRARY_PATH so the AppImage's linuxdeploy can resolve # libtranscribe.so (a NEEDED of `handy`) while bundling. tauri-action - # reuses this compile incrementally. TRANSCRIBE_LIBDIR is consumed by the - # AppImage post-processing step to co-locate the dlopen'd ggml modules. + # reuses this compile incrementally. build.rs stages the runtime and + # dlopen'd modules into transcribe-libs/, which tauri.conf.json includes. - name: Pre-build and locate transcribe-cpp backend libs (Linux) if: contains(inputs.platform, 'ubuntu') shell: bash @@ -478,7 +478,6 @@ jobs: LIBDIR="$(cd "$LIBDIR" && pwd)" echo "Located transcribe-cpp libs in: $LIBDIR" ls -la "$LIBDIR" - echo "TRANSCRIBE_LIBDIR=$LIBDIR" >> "$GITHUB_ENV" echo "LD_LIBRARY_PATH=$LIBDIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV" # linuxdeploy resolves `handy`'s NEEDED libs via `ldd` and HARD-FAILS # on an unresolved one (it does not warn-and-skip). libtranscribe.so @@ -490,6 +489,28 @@ jobs: sudo cp -vL "$LIBDIR"/libtranscribe.so* "$LIBDIR"/libggml*.so* /usr/local/lib/ 2>/dev/null || true sudo ldconfig + # Tauri's mirrored 2024 linuxdeploy predates support for the + # LINUXDEPLOY_EXCLUDED_LIBRARIES environment variable. Put a current + # upstream build in Tauri's tool cache so the exclusions on the build step + # are applied while constructing the original, signed AppImage. + - name: Install linuxdeploy with library exclusion support + if: contains(inputs.platform, 'ubuntu') && contains(inputs.build-args, 'appimage') + shell: bash + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) echo "Unsupported linuxdeploy architecture: $(uname -m)" >&2; exit 1 ;; + esac + tools_dir="${XDG_CACHE_HOME:-$HOME/.cache}/tauri" + mkdir -p "$tools_dir" + curl -L --fail --show-error --retry 3 \ + -o "$tools_dir/linuxdeploy-${arch}.AppImage" \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${arch}.AppImage" + chmod +x "$tools_dir/linuxdeploy-${arch}.AppImage" + "$tools_dir/linuxdeploy-${arch}.AppImage" --appimage-extract-and-run --version + - name: Build with Tauri uses: tauri-apps/tauri-action@v0 env: @@ -506,6 +527,13 @@ jobs: AZURE_TENANT_ID: ${{ inputs.sign-binaries && secrets.AZURE_TENANT_ID || '' }} TAURI_SIGNING_PRIVATE_KEY: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }} + # The Vulkan loader and Wayland client library must come from the host + # graphics stack. Bundling Ubuntu's copies ahead of the system libraries can + # prevent a newer distro's Vulkan ICD or Mesa stack from loading. + # linuxdeploy reads this semicolon-separated glob list before it creates + # (and Tauri signs and uploads) the AppImage, so the published artifact + # is already clean. + LINUXDEPLOY_EXCLUDED_LIBRARIES: ${{ contains(inputs.platform, 'ubuntu') && 'libvulkan.so*;libwayland-client.so*' || '' }} # linuxdeploy passes this to appimagetool, which writes it as # X-AppImage-Version for desktop integration tools such as Gear Lever. LINUXDEPLOY_OUTPUT_VERSION: ${{ contains(inputs.platform, 'ubuntu') && steps.get-version.outputs.version || '' }} @@ -516,10 +544,10 @@ jobs: assetNamePattern: ${{ steps.patch-release-name.outputs.platform }} args: ${{ inputs.build-args }} - # tauri-action uploads release assets before returning, so verify the - # metadata produced by Tauri itself rather than relying only on the later - # post-processing audit. - - name: Verify AppImage version metadata before post-processing + # tauri-action uploads release assets before returning. All AppImage + # filtering therefore happens inside linuxdeploy above; this verifies the + # exact artifact Tauri signed and uploaded. + - name: Verify AppImage version metadata if: contains(inputs.platform, 'ubuntu') shell: bash run: | @@ -581,79 +609,6 @@ jobs: src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle/macos/*.app retention-days: 30 - - name: Install FUSE for AppImage processing - if: contains(inputs.platform, 'ubuntu') - run: | - sudo apt-get update - sudo apt-get install -y fuse libfuse2 - - - name: Remove libwayland-client.so from AppImage - if: contains(inputs.platform, 'ubuntu') - run: | - # Find the AppImage file - APPIMAGE_PATH=$(find src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/appimage -name "*.AppImage" | head -1) - - if [ -n "$APPIMAGE_PATH" ]; then - echo "Processing AppImage: $APPIMAGE_PATH" - - # Make AppImage executable - chmod +x "$APPIMAGE_PATH" - - # Extract AppImage - cd "$(dirname "$APPIMAGE_PATH")" - APPIMAGE_NAME=$(basename "$APPIMAGE_PATH") - - # Extract using the AppImage itself - "./$APPIMAGE_NAME" --appimage-extract - - # Remove libwayland-client.so files - echo "Removing libwayland-client.so files..." - find squashfs-root -name "libwayland-client.so*" -type f -delete - - # List what was removed for verification - echo "Files remaining in lib directories:" - find squashfs-root -name "lib*" -type d | head -5 | while read dir; do - echo "Contents of $dir:" - ls "$dir" | grep -E "(wayland|fuse)" || echo " No wayland/fuse libraries found" - done - - # Co-locate transcribe-cpp's dynamic backends in the AppImage. - # linuxdeploy bundles the NEEDED libtranscribe.so (resolved at build - # time via LD_LIBRARY_PATH) but NOT the dlopen'd ggml backend modules - # that init_backends_default() loads at runtime from libtranscribe's - # own directory — stage those (and backfill the core libs) into - # usr/lib, next to libtranscribe. Fails loudly if libs are missing. - if [ -n "${TRANSCRIBE_LIBDIR:-}" ]; then - bash "$GITHUB_WORKSPACE/scripts/ci/stage-transcribe-libs.sh" \ - "$TRANSCRIBE_LIBDIR" "squashfs-root/usr/lib" - else - echo "ERROR: TRANSCRIBE_LIBDIR not set; transcribe-cpp backends would be missing from the AppImage" >&2 - exit 1 - fi - - # Detect architecture and get appropriate appimagetool - if [[ "$(uname -m)" == "aarch64" ]]; then - APPIMAGETOOL_ARCH="aarch64" - else - APPIMAGETOOL_ARCH="x86_64" - fi - - wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage" - chmod +x "appimagetool-${APPIMAGETOOL_ARCH}.AppImage" - - # Repackage AppImage with no-appstream to avoid warnings. VERSION - # preserves X-AppImage-Version when appimagetool rewrites metadata. - ARCH="${APPIMAGETOOL_ARCH}" VERSION="${{ steps.get-version.outputs.version }}" \ - "./appimagetool-${APPIMAGETOOL_ARCH}.AppImage" --no-appstream squashfs-root "$APPIMAGE_NAME" - - # Clean up - rm -rf squashfs-root "appimagetool-${APPIMAGETOOL_ARCH}.AppImage" - - echo "libwayland-client.so removed from AppImage successfully" - else - echo "No AppImage found to process" - fi - - name: Audit Linux package runtime contents if: contains(inputs.platform, 'ubuntu') shell: bash @@ -696,11 +651,18 @@ jobs: smoke_binary() { local root="$1" local label="$2" + local vulkan_icd="${3:-}" # Run under a virtual X server: handy initializes GTK on startup even # on the headless --list-devices path, which fails without a display. + # An AppImage audit supplies Mesa's software ICD so GPU registration + # is deterministic even on a runner without physical graphics. + if [ -n "$vulkan_icd" ]; then + export VK_ICD_FILENAMES="$vulkan_icd" + fi HANDY_NO_GTK_LAYER_SHELL=1 \ LD_LIBRARY_PATH="${root}/usr/lib/Handy:${root}/usr/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \ - xvfb-run -a "${root}/usr/bin/handy" --list-devices >/tmp/handy-package-smoke.log + xvfb-run -a "${root}/usr/bin/handy" --list-devices \ + >/tmp/handy-package-smoke.log 2>&1 echo "${label} --list-devices smoke passed" } @@ -783,7 +745,35 @@ jobs: find "squashfs-root/usr/lib" -maxdepth 1 -type f >&2 exit 1 fi - smoke_binary "$(pwd)/squashfs-root" "AppImage" + require_path "squashfs-root/usr/lib/libggml-vulkan.so" "AppImage ggml Vulkan backend module" + + # These libraries mediate access to the host graphics stack. + # Shipping build-host copies ahead of the system versions can + # break Vulkan ICD or Mesa loading on newer distributions. + for pattern in \ + 'libvulkan.so*' \ + 'libwayland-client.so*'; do + forbidden="$(find squashfs-root/usr -name "$pattern" -print -quit)" + if [ -n "$forbidden" ]; then + echo "ERROR: AppImage bundles host graphics loader: $forbidden" >&2 + exit 1 + fi + done + + vulkan_icd="$(find /usr/share/vulkan/icd.d -type f -name 'lvp_icd*.json' -print -quit)" + if [ -z "$vulkan_icd" ]; then + echo "ERROR: Mesa software Vulkan ICD is unavailable for the AppImage audit" >&2 + exit 1 + fi + smoke_binary "$(pwd)/squashfs-root" "AppImage" "$vulkan_icd" + # llvmpipe is intentionally not exposed as a compute device by + # ggml, but reaching this log proves the module loaded against a + # usable system Vulkan loader/ICD instead of failing silently. + if ! grep -q 'load_backend: loaded Vulkan backend' /tmp/handy-package-smoke.log; then + echo "ERROR: AppImage could not initialize its Vulkan backend with a usable system ICD" >&2 + cat /tmp/handy-package-smoke.log >&2 + exit 1 + fi ) rm -rf "$workdir" done diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 6c546d9509..bf169c05cd 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1801,7 +1801,19 @@ fn select_transcribe_backend(setting: TranscribeAcceleratorSetting) -> Backend { { Some(b) => b, None => { - warn!("No GPU backend available for transcribe.cpp; falling back to Auto"); + #[cfg(target_os = "linux")] + warn!( + "GPU acceleration was requested, but no transcribe.cpp GPU backend is \ + registered; falling back to Auto (usually CPU). Run with \ + --list-devices to inspect detected devices; VK_LOADER_DEBUG=error can \ + reveal Vulkan loader or driver failures" + ); + #[cfg(not(target_os = "linux"))] + warn!( + "GPU acceleration was requested, but no transcribe.cpp GPU backend is \ + registered; falling back to Auto (usually CPU). Run with \ + --list-devices to inspect detected devices" + ); Backend::Auto } } From 3388983e9a81ae06ba58d135f347167007017bdc Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sat, 8 Aug 2026 07:53:45 +0800 Subject: [PATCH 48/49] release v0.9.5 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/content/release-notes/0.9.5.md | 18 ------------------ 5 files changed, 4 insertions(+), 22 deletions(-) delete mode 100644 src/content/release-notes/0.9.5.md diff --git a/package.json b/package.json index 7cf421718f..9d1e108676 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "handy-app", "private": true, - "version": "0.9.4", + "version": "0.9.5", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ced343f233..81e72d8244 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2447,7 +2447,7 @@ dependencies = [ [[package]] name = "handy" -version = "0.9.4" +version = "0.9.5" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8f67641a55..3265568920 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "handy" -version = "0.9.4" +version = "0.9.5" description = "Handy" authors = ["cjpais"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 981362b927..4cce76913a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Handy", - "version": "0.9.4", + "version": "0.9.5", "identifier": "com.pais.handy", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/content/release-notes/0.9.5.md b/src/content/release-notes/0.9.5.md deleted file mode 100644 index 8e38d23630..0000000000 --- a/src/content/release-notes/0.9.5.md +++ /dev/null @@ -1,18 +0,0 @@ -## Linux: Phantom Recording Fix - -If Handy on Linux sometimes started recording on its own, or cut off a real -dictation mid-sentence (typically about 2 minutes in), that was a signal -conflict: Handy listened for `SIGUSR1` as a remote-control trigger, but -WebKitGTK — the webview engine embedded in Handy — uses the same signal -internally for JavaScript garbage collection. Every GC cycle looked like a -hotkey press ([#1660](https://github.com/cjpais/Handy/issues/1660)). - -Handy no longer listens for `SIGUSR1` on Linux. - -**If you bound `pkill -USR1 -n handy` to a hotkey, replace it with:** - -```bash -handy --toggle-post-process -``` - -`SIGUSR2` (plain transcription toggle) is unchanged and keeps working. From db003f38b1aef4eb967ac3419bebc851d680f71c Mon Sep 17 00:00:00 2001 From: Mike Evdokimov Date: Sat, 8 Aug 2026 05:03:08 +0500 Subject: [PATCH 49/49] fix(audio): skip level meter and resampler while idle in always-on mode (#1873) In always-on mode the capture stream stays open continuously so recording can start with no latency. While idle (not recording) each incoming chunk still entered the level-meter path and the resampler, but handle_frame returns early when not recording, so the resampled output was discarded and the level meter has no idle consumer. Skip both while idle to stop doing unnecessary work whose output is thrown away. Cmd::Start resets the visualizer and the resampler, so both resume cleanly when recording begins. --- src-tauri/src/audio_toolkit/audio/recorder.rs | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 9bc77d05f0..08a4430441 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -797,24 +797,33 @@ fn run_consumer( ); } - // ---------- spectrum processing ---------------------------------- // - if let Some(buckets) = visualizer.feed(&raw) { - if let Some(cb) = &level_cb { - cb(buckets); + // ---------- recording-time processing ---------------------------- // + // In always-on mode the capture stream stays open continuously for + // zero-latency start, so while idle (not recording) there is nothing to + // do with a chunk: handle_frame returns early when not recording, which + // means the resampled output would be discarded, and the level meter has + // no idle consumer. Skip both the level-meter FFT and the resampler while + // idle to avoid doing unnecessary work whose output is thrown away. Both + // are reset on Cmd::Start (visualizer.reset() / frame_resampler.reset()), + // so they resume cleanly the moment recording begins. + if recording { + if let Some(buckets) = visualizer.feed(&raw) { + if let Some(cb) = &level_cb { + cb(buckets); + } } - } - // ---------- existing pipeline ------------------------------------ // - frame_resampler.push(&raw, &mut |frame: &[f32]| { - handle_frame( - frame, - recording, - vad_policy, - &vad, - &audio_cb, - &mut processed_samples, - ) - }); + frame_resampler.push(&raw, &mut |frame: &[f32]| { + handle_frame( + frame, + recording, + vad_policy, + &vad, + &audio_cb, + &mut processed_samples, + ) + }); + } if recording { if let Some(started) = awaiting_first_captured_chunk.take() {