diff --git a/.codex/skills/dev-pr/SKILL.md b/.codex/skills/dev-pr/SKILL.md
index 3bb3159..986129e 100644
--- a/.codex/skills/dev-pr/SKILL.md
+++ b/.codex/skills/dev-pr/SKILL.md
@@ -18,7 +18,7 @@ draft pull request. Never merge without explicit user permission after completin
4. Fetch and prune `origin`. Confirm the branch contains commits not in `origin/main` and inspect the
complete proposed diff.
5. Require an active Plasma graphical session and run `make screenshots`. Confirm that
- `screenshots/` contains exactly the four expected numbered PNG files. Inspect the regenerated
+ `screenshots/` contains exactly the five expected numbered PNG files. Inspect the regenerated
images and repository diff for accidental or private content. If tracked screenshots changed,
stage only `screenshots/` and commit them with `Refresh application screenshots`; never create an
empty commit. Confirm the working tree is clean afterward. Stop if capture or inspection fails.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 38c54e2..2f80315 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,7 @@ jobs:
base-devel cmake ninja git clang gcovr reuse appstream desktop-file-utils
extra-cmake-modules qt6-base qt6-declarative qt6-multimedia
kirigami kconfig kcoreaddons kdbusaddons kdeclarative kglobalaccel ki18n
- knotifications kstatusnotifieritem
+ knotifications kstatusnotifieritem kwallet libsodium
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -92,7 +92,7 @@ jobs:
extra-cmake-modules qt6-base qt6-declarative qt6-multimedia
qt6-multimedia-gstreamer
kirigami kconfig kcoreaddons kdbusaddons kdeclarative kglobalaccel ki18n
- knotifications kstatusnotifieritem
+ knotifications kstatusnotifieritem kwallet libsodium
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/AGENTS.md b/AGENTS.md
index f5e2f0a..ccbe66b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,6 +6,9 @@
- Keep the UI minimal. Prefer native KDE and Qt patterns, preserve behavior already supplied by the
platform, and follow the KDE Human Interface Guidelines instead of introducing custom UI
conventions.
+- Reuse established Qt and Kirigami components and styling across equivalent UI. Use
+ `Kirigami.PromptDialog` for confirmation dialogs with an explicit action verb and Cancel; reserve
+ specialized dialogs for tasks such as file selection or content viewing.
## Git workflow
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 894307f..be88cf8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -15,8 +15,10 @@ include(FetchContent)
include(GNUInstallDirs)
find_package(ECM 6.0 REQUIRED NO_MODULE)
list(APPEND CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(Sodium REQUIRED IMPORTED_TARGET libsodium)
find_package(Qt6 6.6 REQUIRED COMPONENTS Concurrent Core DBus Gui Multimedia Network Quick QuickControls2 Widgets)
-find_package(KF6 6.20 REQUIRED COMPONENTS Config CoreAddons DBusAddons Declarative GlobalAccel I18n Kirigami Notifications StatusNotifierItem)
+find_package(KF6 6.20 REQUIRED COMPONENTS Config CoreAddons DBusAddons Declarative GlobalAccel I18n Kirigami Notifications StatusNotifierItem Wallet)
option(KASTWORD_FETCH_WHISPER "Download the pinned whisper.cpp dependency" ON)
option(KASTWORD_FETCH_DEFAULT_MODEL "Download the legacy default English Whisper model" OFF)
@@ -56,6 +58,8 @@ qt_add_executable(kastword
src/main.cpp
src/AppController.cpp
src/AppController.h
+ src/DictationHistory.cpp
+ src/DictationHistory.h
src/AudioConversion.cpp
src/AudioConversion.h
src/AudioCapture.cpp
@@ -79,12 +83,14 @@ qt_add_executable(kastword
)
set_source_files_properties(src/qml/Main.qml PROPERTIES QT_RESOURCE_ALIAS Main.qml)
+set_source_files_properties(src/qml/HistoryEntryCard.qml PROPERTIES QT_RESOURCE_ALIAS HistoryEntryCard.qml)
+set_source_files_properties(src/qml/FooterContainer.qml PROPERTIES QT_RESOURCE_ALIAS FooterContainer.qml)
qt_add_qml_module(kastword
URI io.github.shape_machine.Kastword
VERSION 1.0
RESOURCE_PREFIX /qt/qml
NO_IMPORT_SCAN
- QML_FILES src/qml/Main.qml
+ QML_FILES src/qml/Main.qml src/qml/HistoryEntryCard.qml src/qml/FooterContainer.qml
)
target_link_libraries(kastword PRIVATE
@@ -92,6 +98,7 @@ target_link_libraries(kastword PRIVATE
Qt6::Multimedia Qt6::Network
KF6::ConfigCore KF6::CoreAddons KF6::DBusAddons KF6::GlobalAccel KF6::I18n
KF6::I18nQml KF6::Kirigami KF6::Notifications KF6::StatusNotifierItem whisper)
+target_link_libraries(kastword PRIVATE KF6::Wallet PkgConfig::Sodium)
function(kastword_enable_coverage target)
if(KASTWORD_ENABLE_COVERAGE AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
@@ -118,7 +125,7 @@ target_link_libraries(kastword_screenshot_generator PRIVATE
KF6::I18n KF6::I18nQml KF6::Kirigami)
qt_add_resources(kastword_screenshot_generator "screenshot-qml-resources"
PREFIX "/"
- FILES src/qml/Main.qml)
+ FILES src/qml/Main.qml src/qml/HistoryEntryCard.qml src/qml/FooterContainer.qml)
kastword_enable_sanitizers(kastword_screenshot_generator)
if(KASTWORD_FETCH_DEFAULT_MODEL)
@@ -133,13 +140,14 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/io.github.shape_machine.Kastword.deskt
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications COMPONENT Kastword)
install(FILES data/io.github.shape_machine.Kastword.metainfo.xml DESTINATION share/metainfo COMPONENT Kastword)
install(FILES README.md DESTINATION share/doc/kastword COMPONENT Kastword)
-install(FILES docs/DEVELOPMENT.md docs/AUTOMATIC_PASTE.md
+install(FILES docs/DEVELOPMENT.md docs/AUTOMATIC_PASTE.md docs/DICTATION_HISTORY.md
DESTINATION share/doc/kastword/docs COMPONENT Kastword)
install(FILES
screenshots/01-offline-dictation.png
- screenshots/02-speech-models.png
- screenshots/03-audio-input.png
- screenshots/04-settings.png
+ screenshots/02-history.png
+ screenshots/03-speech-models.png
+ screenshots/04-audio-input.png
+ screenshots/05-settings.png
DESTINATION share/doc/kastword/screenshots COMPONENT Kastword)
install(FILES LICENSES/GPL-3.0-or-later.txt
DESTINATION share/doc/kastword/LICENSES COMPONENT Kastword)
@@ -198,10 +206,23 @@ if(BUILD_TESTING)
kastword_enable_sanitizers(kastword_model_manager_test)
add_test(NAME model-manager COMMAND kastword_model_manager_test)
+ add_executable(kastword_history_test
+ tests/DictationHistoryTest.cpp
+ src/DictationHistory.cpp
+ src/DictationHistory.h)
+ target_include_directories(kastword_history_test PRIVATE src)
+ target_link_libraries(kastword_history_test PRIVATE
+ Qt6::Concurrent Qt6::Core Qt6::Test KF6::I18n KF6::Wallet PkgConfig::Sodium)
+ kastword_enable_coverage(kastword_history_test)
+ kastword_enable_sanitizers(kastword_history_test)
+ add_test(NAME history COMMAND kastword_history_test)
+
add_executable(kastword_controller_test
tests/AppControllerTest.cpp
src/AppController.cpp
src/AppController.h
+ src/DictationHistory.cpp
+ src/DictationHistory.h
src/AudioCapture.cpp
src/AudioCapture.h
src/AudioConversion.cpp
@@ -223,7 +244,8 @@ if(BUILD_TESTING)
target_include_directories(kastword_controller_test PRIVATE src)
target_link_libraries(kastword_controller_test PRIVATE
Qt6::Concurrent Qt6::Core Qt6::DBus Qt6::Gui Qt6::Multimedia Qt6::Network Qt6::Test Qt6::Widgets
- KF6::ConfigCore KF6::GlobalAccel KF6::I18n KF6::Notifications whisper)
+ KF6::ConfigCore KF6::GlobalAccel KF6::I18n KF6::Notifications KF6::Wallet
+ PkgConfig::Sodium whisper)
kastword_enable_coverage(kastword_controller_test)
kastword_enable_sanitizers(kastword_controller_test)
add_test(NAME controller COMMAND kastword_controller_test)
@@ -236,7 +258,7 @@ if(BUILD_TESTING)
Qt6::Qml Qt6::Quick Qt6::QuickTest KF6::I18n KF6::I18nQml KF6::Kirigami)
qt_add_resources(kastword_qml_test "qml-test-resources"
PREFIX "/"
- FILES src/qml/Main.qml)
+ FILES src/qml/Main.qml src/qml/HistoryEntryCard.qml src/qml/FooterContainer.qml)
kastword_enable_sanitizers(kastword_qml_test)
add_dependencies(kastword_qml_test kastword_screenshot_generator)
add_test(NAME qml COMMAND kastword_qml_test)
diff --git a/Makefile b/Makefile
index de5b019..380ba26 100644
--- a/Makefile
+++ b/Makefile
@@ -84,10 +84,12 @@ install-smoke: ensure-configured
test -f "$$smoke_prefix/share/doc/kastword/README.md"; \
test -f "$$smoke_prefix/share/doc/kastword/docs/DEVELOPMENT.md"; \
test -f "$$smoke_prefix/share/doc/kastword/docs/AUTOMATIC_PASTE.md"; \
+ test -f "$$smoke_prefix/share/doc/kastword/docs/DICTATION_HISTORY.md"; \
test -f "$$smoke_prefix/share/doc/kastword/screenshots/01-offline-dictation.png"; \
- test -f "$$smoke_prefix/share/doc/kastword/screenshots/02-speech-models.png"; \
- test -f "$$smoke_prefix/share/doc/kastword/screenshots/03-audio-input.png"; \
- test -f "$$smoke_prefix/share/doc/kastword/screenshots/04-settings.png"; \
+ test -f "$$smoke_prefix/share/doc/kastword/screenshots/02-history.png"; \
+ test -f "$$smoke_prefix/share/doc/kastword/screenshots/03-speech-models.png"; \
+ test -f "$$smoke_prefix/share/doc/kastword/screenshots/04-audio-input.png"; \
+ test -f "$$smoke_prefix/share/doc/kastword/screenshots/05-settings.png"; \
test -f "$$smoke_prefix/share/doc/kastword/LICENSES/GPL-3.0-or-later.txt"; \
./tools/check-documentation-links.sh "$$smoke_prefix/share/doc/kastword"; \
legacy_license="$$smoke_prefix/share/doc/kastword/GPL-3.0-or-later.txt"; \
@@ -110,10 +112,12 @@ uninstall:
"$(PREFIX)/share/doc/kastword/README.md" \
"$(PREFIX)/share/doc/kastword/docs/DEVELOPMENT.md" \
"$(PREFIX)/share/doc/kastword/docs/AUTOMATIC_PASTE.md" \
+ "$(PREFIX)/share/doc/kastword/docs/DICTATION_HISTORY.md" \
"$(PREFIX)/share/doc/kastword/screenshots/01-offline-dictation.png" \
- "$(PREFIX)/share/doc/kastword/screenshots/02-speech-models.png" \
- "$(PREFIX)/share/doc/kastword/screenshots/03-audio-input.png" \
- "$(PREFIX)/share/doc/kastword/screenshots/04-settings.png" \
+ "$(PREFIX)/share/doc/kastword/screenshots/02-history.png" \
+ "$(PREFIX)/share/doc/kastword/screenshots/03-speech-models.png" \
+ "$(PREFIX)/share/doc/kastword/screenshots/04-audio-input.png" \
+ "$(PREFIX)/share/doc/kastword/screenshots/05-settings.png" \
"$(PREFIX)/share/doc/kastword/GPL-3.0-or-later.txt" \
"$(PREFIX)/share/doc/kastword/LICENSES/GPL-3.0-or-later.txt"
@if command -v update-desktop-database >/dev/null 2>&1; then \
diff --git a/README.md b/README.md
index 132e1f0..7a2bbee 100644
--- a/README.md
+++ b/README.md
@@ -20,11 +20,15 @@ text are never sent to a cloud transcription service.
@@ -42,8 +46,9 @@ text are never sent to a cloud transcription service.
paste on Plasma Wayland or X11.
- **Configurable limits and output:** choose the transcription language, recording limit, paste
shortcuts, and global keyboard shortcut.
-- **No history or telemetry:** Kastword does not save recordings, retain a transcription history,
- or include usage tracking.
+- **Private by default:** Kastword never saves recordings. Optional dictation history is off by
+ default and, when enabled, is encrypted locally with its key protected by KDE Wallet. Kastword
+ includes no usage tracking.
## How dictation works
@@ -67,7 +72,9 @@ another input instead of silently switching devices.
- Microphone samples remain in process memory only until transcription completes. Recordings stop
at the configured duration limit—five minutes by default—or a 256 MiB raw-audio ceiling.
-- Raw audio and transcription history are not written to disk.
+- Raw audio is never written to disk. Transcription history is not written until you explicitly
+ enable it; enabled history is authenticated and encrypted locally. See [Encrypted dictation
+ history](docs/DICTATION_HISTORY.md).
- Transcribed text is placed on the desktop clipboard and primary selection. Clipboard managers,
target applications, desktop services, crash dumps, and the operating system may retain it
independently of Kastword.
@@ -83,6 +90,7 @@ another input instead of silently switching devices.
The clear-transcription action removes Kastword's retained text and clears matching current
clipboard selections. It cannot erase entries already retained by clipboard-manager history.
+Deleting a saved history entry affects Kastword's encrypted store only.
## Platform status
@@ -122,6 +130,7 @@ contributor instructions are in the [development guide](docs/DEVELOPMENT.md).
## Help and project information
- [Automatic paste setup and troubleshooting](docs/AUTOMATIC_PASTE.md)
+- [Encrypted dictation history and its security boundaries](docs/DICTATION_HISTORY.md)
- [Source installation and development](docs/DEVELOPMENT.md)
- [Report a bug or request a feature](https://github.com/Shape-Machine/Kastword/issues)
- [License](LICENSES/GPL-3.0-or-later.txt)
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 3012d88..519f69b 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -19,7 +19,7 @@ sudo pacman -S --needed \
qt6-base qt6-declarative qt6-multimedia \
extra-cmake-modules kirigami kdeclarative \
kconfig kcoreaddons kdbusaddons kglobalaccel ki18n \
- knotifications kstatusnotifieritem
+ knotifications kstatusnotifieritem kwallet libsodium
```
Automatic paste requires a separate session helper. See [Automatic paste setup and
@@ -111,7 +111,7 @@ reports under `build-coverage/coverage/`. The default gates require at least 68%
coverage and can be raised with `COVERAGE_MIN_LINE` and `COVERAGE_MIN_BRANCH`. Coverage uses an
installed `gcovr`, or runs it through `uvx` when available.
-`make screenshots` renders all four top-level views in a 760×520 logical window with deterministic
+`make screenshots` renders all five top-level views in a 760×700 logical window with deterministic
fake data and safely replaces the permanent PNG assets in `screenshots/`. It requires an active
graphical session so output inherits the current Plasma theme, icons, fonts, and display scale. It
does not access the microphone or network and preserves the previous complete set on failure.
diff --git a/docs/DICTATION_HISTORY.md b/docs/DICTATION_HISTORY.md
new file mode 100644
index 0000000..f92139a
--- /dev/null
+++ b/docs/DICTATION_HISTORY.md
@@ -0,0 +1,50 @@
+
+
+# Encrypted dictation history
+
+Kastword can keep completed transcriptions available across restarts. History is disabled by
+default: no history file or encryption key is created until you explicitly enable it from the
+History view.
+
+## Storage and encryption
+
+Enabled history is stored at `~/.local/share/kastword/history.enc` by default. Kastword encrypts and
+authenticates the complete history before writing it. A separate random encryption key is stored as
+a binary secret in KDE Wallet; it is never written beside the history file. The history directory
+and file are restricted to the current user, and updates use atomic replacement.
+
+If KDE Wallet is unavailable, remains locked, or rejects access, Kastword does not load or save
+history and never falls back to plaintext. A damaged file or incorrect key also fails closed. You
+can restore wallet access and retry, or disable history and request deletion of the unreadable file
+and wallet key.
+
+Only successful, non-empty transcription text and its timestamp are retained. Recorded audio,
+model data, microphone information, target applications, and usage analytics are never included.
+
+## Retention and deletion
+
+The History view lets you set both a maximum age and maximum entry count. The defaults are 30 days
+and 100 entries. Both limits are applied after each insertion and whenever history is loaded;
+age-based expiry also continues automatically while Kastword remains running.
+
+You can delete one entry or clear all entries. When disabling history, choose whether to keep the
+encrypted data for later or delete both the file and its KDE Wallet key. Deletion prevents Kastword
+from reading those records, but cannot guarantee forensic erasure from solid-state storage,
+filesystem snapshots, swap, crash dumps, or backups.
+
+If Kastword closes while KDE Wallet key deletion is still pending, it records that intent in the
+private history directory and retries the deletion the next time it starts.
+
+## Security boundaries
+
+Encryption at rest protects the history file when the wallet key is unavailable. It does not
+protect text from software controlling your unlocked desktop session. Copying or automatically
+pasting text can expose it to clipboard managers, target applications, desktop services, the
+operating system, and their own history or backup mechanisms. Review those systems before using
+dictation for sensitive material.
+
+Return to the [project overview](../README.md) or read the [automatic paste security
+guide](AUTOMATIC_PASTE.md).
diff --git a/po/x-test/kastword.po b/po/x-test/kastword.po
index 0fc8a1a..6baa966 100644
--- a/po/x-test/kastword.po
+++ b/po/x-test/kastword.po
@@ -5,7 +5,7 @@ msgid ""
msgstr ""
"Project-Id-Version: kastword\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-08-07 20:28+0200\n"
+"POT-Creation-Date: 2026-08-08 14:34+0200\n"
"PO-Revision-Date: 2026-08-02 00:00+0000\n"
"Last-Translator: KDE Test Language \n"
"Language-Team: KDE Test Language \n"
@@ -15,99 +15,99 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
-#: src/AppController.cpp:58
+#: src/AppController.cpp:60
msgid "Toggle dictation"
msgstr "xxToggle dictationxx"
-#: src/AppController.cpp:113 src/AppController.cpp:438
+#: src/AppController.cpp:123 src/AppController.cpp:452
msgid "Recording stopped because the speech model is no longer available."
msgstr "xxRecording stopped because the speech model is no longer available.xx"
-#: src/AppController.cpp:117 src/AppController.cpp:197
-#: src/AppController.cpp:206
+#: src/AppController.cpp:127 src/AppController.cpp:207
+#: src/AppController.cpp:216
msgid "Ready"
msgstr "xxReadyxx"
-#: src/AppController.cpp:119 src/AppController.cpp:125
-#: src/AppController.cpp:194 src/AppController.cpp:208
+#: src/AppController.cpp:129 src/AppController.cpp:135
+#: src/AppController.cpp:204 src/AppController.cpp:218
msgid "Verifying the speech model…"
msgstr "xxVerifying the speech model…xx"
-#: src/AppController.cpp:133 src/AppController.cpp:195
-#: src/AppController.cpp:210
+#: src/AppController.cpp:143 src/AppController.cpp:205
+#: src/AppController.cpp:220
msgid "Choose a speech model to enable dictation."
msgstr "xxChoose a speech model to enable dictation.xx"
-#: src/AppController.cpp:203
+#: src/AppController.cpp:213
msgid "Automatic paste failed"
msgstr "xxAutomatic paste failedxx"
-#: src/AppController.cpp:216 src/AppController.cpp:234
+#: src/AppController.cpp:226 src/AppController.cpp:244
msgid "None"
msgstr "xxNonexx"
-#: src/AppController.cpp:241
+#: src/AppController.cpp:251
msgid "System default"
msgstr "xxSystem defaultxx"
-#: src/AppController.cpp:242
+#: src/AppController.cpp:252
msgid "System default (%1)"
msgstr "xxSystem default (%1)xx"
-#: src/AppController.cpp:259
+#: src/AppController.cpp:269
msgid "Unavailable selected microphone"
msgstr "xxUnavailable selected microphonexx"
-#: src/AppController.cpp:270
+#: src/AppController.cpp:280
msgid "No audio input is selected. Choose an input to enable dictation."
msgstr "xxNo audio input is selected. Choose an input to enable dictation.xx"
-#: src/AppController.cpp:272 src/AudioCapture.cpp:179
+#: src/AppController.cpp:282 src/AudioCapture.cpp:179
msgid "No microphone is available."
msgstr "xxNo microphone is available.xx"
-#: src/AppController.cpp:273 src/AudioCapture.cpp:180
+#: src/AppController.cpp:283 src/AudioCapture.cpp:180
msgid "The selected microphone is unavailable. Choose another audio input."
msgstr ""
"xxThe selected microphone is unavailable. Choose another audio input.xx"
-#: src/AppController.cpp:275
+#: src/AppController.cpp:285
msgid "Using %1"
msgstr "xxUsing %1xx"
-#: src/AppController.cpp:316
+#: src/AppController.cpp:326
msgid "English"
msgstr "xxEnglishxx"
-#: src/AppController.cpp:318
+#: src/AppController.cpp:328
msgid "Automatic"
msgstr "xxAutomaticxx"
-#: src/AppController.cpp:320
+#: src/AppController.cpp:330
msgid "German"
msgstr "xxGermanxx"
-#: src/AppController.cpp:322
+#: src/AppController.cpp:332
msgid "French"
msgstr "xxFrenchxx"
-#: src/AppController.cpp:324
+#: src/AppController.cpp:334
msgid "Spanish"
msgstr "xxSpanishxx"
-#: src/AppController.cpp:326
+#: src/AppController.cpp:336
msgid "Dutch"
msgstr "xxDutchxx"
-#: src/AppController.cpp:328
+#: src/AppController.cpp:338
msgid "Italian"
msgstr "xxItalianxx"
-#: src/AppController.cpp:330
+#: src/AppController.cpp:340
msgid "Portuguese"
msgstr "xxPortuguesexx"
-#: src/AppController.cpp:418
+#: src/AppController.cpp:428
msgid ""
"Cleared from Kastword and matching current clipboards. Clipboard history may "
"retain it."
@@ -115,65 +115,69 @@ msgstr ""
"xxCleared from Kastword and matching current clipboards. Clipboard history "
"may retain it.xx"
-#: src/AppController.cpp:443 src/AppController.cpp:444
+#: src/AppController.cpp:457 src/AppController.cpp:458
msgid "Transcribing locally…"
msgstr "xxTranscribing locally…xx"
-#: src/AppController.cpp:444 src/AppController.cpp:475 src/main.cpp:45
+#: src/AppController.cpp:458 src/AppController.cpp:489 src/main.cpp:45
#: src/main.cpp:62 src/main.cpp:63 src/main.cpp:96 src/main.cpp:104
#: src/qml/Main.qml:21
msgid "Kastword"
msgstr "xxKastwordxx"
-#: src/AppController.cpp:450
+#: src/AppController.cpp:464
msgid "Choose a speech model before starting dictation."
msgstr "xxChoose a speech model before starting dictation.xx"
-#: src/AppController.cpp:457
+#: src/AppController.cpp:471
msgid "Audio input disabled"
msgstr "xxAudio input disabledxx"
-#: src/AppController.cpp:458
+#: src/AppController.cpp:472
msgid "Microphone unavailable"
msgstr "xxMicrophone unavailablexx"
-#: src/AppController.cpp:467
+#: src/AppController.cpp:481
msgid "Transcription is already in progress."
msgstr "xxTranscription is already in progress.xx"
-#: src/AppController.cpp:480
+#: src/AppController.cpp:494
msgid "Listening — use Stop Dictation to finish."
msgstr "xxListening — use Stop Dictation to finish.xx"
-#: src/AppController.cpp:481 src/AppController.cpp:486
+#: src/AppController.cpp:495 src/AppController.cpp:500
msgid "Dictation started"
msgstr "xxDictation startedxx"
-#: src/AppController.cpp:482
+#: src/AppController.cpp:496
msgid "Use the Kastword window or tray action to stop."
msgstr "xxUse the Kastword window or tray action to stop.xx"
-#: src/AppController.cpp:485
+#: src/AppController.cpp:499
msgid "Listening — press %1 to finish."
msgstr "xxListening — press %1 to finish.xx"
-#: src/AppController.cpp:487
+#: src/AppController.cpp:501
msgid "Press %1 again to stop."
msgstr "xxPress %1 again to stop.xx"
-#: src/AppController.cpp:511
+#: src/AppController.cpp:525
msgid "Transcription failed"
msgstr "xxTranscription failedxx"
-#: src/AppController.cpp:517
+#: src/AppController.cpp:531
msgid "No speech detected."
msgstr "xxNo speech detected.xx"
-#: src/AppController.cpp:527
+#: src/AppController.cpp:541
+msgid "%1 History was not saved: %2"
+msgstr "xx%1 History was not saved: %2xx"
+
+#: src/AppController.cpp:545
msgid "Dictation complete"
msgstr "xxDictation completexx"
-#: src/AppController.cpp:548
+#: src/AppController.cpp:566
msgid "Recording failed"
msgstr "xxRecording failedxx"
@@ -201,10 +205,138 @@ msgstr "xxThe microphone reported an invalid audio format.xx"
msgid "Could not start microphone capture."
msgstr "xxCould not start microphone capture.xx"
-#: src/AudioCapture.cpp:244
+#: src/AudioCapture.cpp:247
msgid "Recording stopped after reaching the configured duration limit."
msgstr "xxRecording stopped after reaching the configured duration limit.xx"
+#: src/DictationHistory.cpp:36 src/DictationHistory.cpp:45
+msgid "Secure history requires an available, unlocked KDE Wallet."
+msgstr "xxSecure history requires an available, unlocked KDE Wallet.xx"
+
+#: src/DictationHistory.cpp:59 src/DictationHistory.cpp:69
+#: src/DictationHistory.cpp:123
+msgid "The secure history key could not be removed from KDE Wallet."
+msgstr "xxThe secure history key could not be removed from KDE Wallet.xx"
+
+#: src/DictationHistory.cpp:91
+msgid "Kastword could not create its secure wallet folder."
+msgstr "xxKastword could not create its secure wallet folder.xx"
+
+#: src/DictationHistory.cpp:95
+msgid "Kastword could not open its secure wallet folder."
+msgstr "xxKastword could not open its secure wallet folder.xx"
+
+#: src/DictationHistory.cpp:102
+msgid "The secure history key could not be read."
+msgstr "xxThe secure history key could not be read.xx"
+
+#: src/DictationHistory.cpp:111
+msgid "The secure history key could not be stored."
+msgstr "xxThe secure history key could not be stored.xx"
+
+#: src/DictationHistory.cpp:151
+msgid "Secure history encryption is unavailable."
+msgstr "xxSecure history encryption is unavailable.xx"
+
+#: src/DictationHistory.cpp:195
+msgid "Opening KDE Wallet…"
+msgstr "xxOpening KDE Wallet…xx"
+
+#: src/DictationHistory.cpp:200
+msgid "The secure history key is unavailable."
+msgstr "xxThe secure history key is unavailable.xx"
+
+#: src/DictationHistory.cpp:214 src/DictationHistory.cpp:521
+msgid "History is encrypted locally. The key is stored in KDE Wallet."
+msgstr "xxHistory is encrypted locally. The key is stored in KDE Wallet.xx"
+
+msgid "Saving encrypted history…"
+msgstr "xxSaving encrypted history…xx"
+
+#: src/DictationHistory.cpp:240
+msgid "History is disabled. Existing encrypted history was kept."
+msgstr "xxHistory is disabled. Existing encrypted history was kept.xx"
+
+#: src/DictationHistory.cpp:250
+msgid "The encrypted history file could not be deleted."
+msgstr "xxThe encrypted history file could not be deleted.xx"
+
+#: src/DictationHistory.cpp:259
+msgid "Removing the secure history key…"
+msgstr "xxRemoving the secure history key…xx"
+
+#: src/DictationHistory.cpp:269
+msgid "The encrypted history could not be deleted completely."
+msgstr "xxThe encrypted history could not be deleted completely.xx"
+
+#: src/DictationHistory.cpp:274
+msgid "The secure history deletion marker could not be removed."
+msgstr "xxThe secure history deletion marker could not be removed.xx"
+
+#: src/DictationHistory.cpp:278
+msgid "Encrypted history was deleted."
+msgstr "xxEncrypted history was deleted.xx"
+
+#: src/AppController.cpp:453
+msgid "The history storage location could not be opened."
+msgstr "xxThe history storage location could not be opened.xx"
+
+#: src/AppController.cpp:463
+msgid "The model storage folder could not be opened."
+msgstr "xxThe model storage folder could not be opened.xx"
+
+#: src/AppController.cpp:458 src/AppController.cpp:472
+msgid "Could not open storage location"
+msgstr "xxCould not open storage locationxx"
+
+#: src/DictationHistory.cpp:411
+msgid "The encrypted history file could not be opened."
+msgstr "xxThe encrypted history file could not be opened.xx"
+
+#: src/DictationHistory.cpp:415 src/DictationHistory.cpp:420
+msgid "The encrypted history file is too large to open safely."
+msgstr "xxThe encrypted history file is too large to open safely.xx"
+
+#: src/DictationHistory.cpp:730
+msgid "The encrypted history is too large to save safely."
+msgstr "xxThe encrypted history is too large to save safely.xx"
+
+#: src/DictationHistory.cpp:426
+msgid "The encrypted history file is damaged or unsupported."
+msgstr "xxThe encrypted history file is damaged or unsupported.xx"
+
+#: src/DictationHistory.cpp:441
+msgid "The encrypted history could not be authenticated or decrypted."
+msgstr "xxThe encrypted history could not be authenticated or decrypted.xx"
+
+#: src/DictationHistory.cpp:451
+msgid "The decrypted history format is damaged or unsupported."
+msgstr "xxThe decrypted history format is damaged or unsupported.xx"
+
+#: src/DictationHistory.cpp:456
+msgid "The decrypted history contains too many entries."
+msgstr "xxThe decrypted history contains too many entries.xx"
+
+#: src/DictationHistory.cpp:467
+msgid "The decrypted history contains an invalid entry."
+msgstr "xxThe decrypted history contains an invalid entry.xx"
+
+#: src/DictationHistory.cpp:511
+msgid "The history could not be encrypted."
+msgstr "xxThe history could not be encrypted.xx"
+
+#: src/DictationHistory.cpp:517
+msgid "The encrypted history could not be saved."
+msgstr "xxThe encrypted history could not be saved.xx"
+
+#: src/DictationHistory.cpp:531
+msgid "The private history directory could not be secured."
+msgstr "xxThe private history directory could not be secured.xx"
+
+#: src/DictationHistory.cpp:540
+msgid "The encrypted history could not be written atomically."
+msgstr "xxThe encrypted history could not be written atomically.xx"
+
#: src/ModelManager.cpp:20
msgid "Tiny English"
msgstr "xxTiny Englishxx"
@@ -409,7 +541,7 @@ msgstr "xxThe active speech model is no longer valid.xx"
msgid "Stop and Transcribe"
msgstr "xxStop and Transcribexx"
-#: src/PlatformIntegration.cpp:73 src/qml/Main.qml:653
+#: src/PlatformIntegration.cpp:73 src/qml/Main.qml:891
msgid "Transcribing…"
msgstr "xxTranscribing…xx"
@@ -525,131 +657,249 @@ msgstr "xxKastword is readyxx"
msgid "Download URL copied to clipboard."
msgstr "xxDownload URL copied to clipboard.xx"
-#: src/qml/Main.qml:83
+#: src/qml/Main.qml:84
msgid "Select a Whisper model"
msgstr "xxSelect a Whisper modelxx"
-#: src/qml/Main.qml:84
+#: src/qml/Main.qml:85
msgid "Whisper models (*.bin)"
msgstr "xxWhisper models (*.bin)xx"
-#: src/qml/Main.qml:84
+#: src/qml/Main.qml:85
msgid "All files (*)"
msgstr "xxAll files (*)xx"
-#: src/qml/Main.qml:90
+#: src/qml/Main.qml:91
msgid "Remove speech model?"
msgstr "xxRemove speech model?xx"
-#: src/qml/Main.qml:91
+#: src/qml/Main.qml:92
msgid "The model file will be deleted from this user account."
msgstr "xxThe model file will be deleted from this user account.xx"
-#: src/qml/Main.qml:103 src/qml/Main.qml:714 src/qml/Main.qml:724
+#: src/qml/Main.qml:95 src/qml/Main.qml:595
+msgid "Remove"
+msgstr "xxRemovexx"
+
+#: src/qml/Main.qml:109 src/qml/Main.qml:952 src/qml/Main.qml:962
msgid "Last transcription"
msgstr "xxLast transcriptionxx"
-#: src/qml/Main.qml:120
+#: src/qml/Main.qml:126
msgid "Full transcription"
msgstr "xxFull transcriptionxx"
-#: src/qml/Main.qml:130 src/qml/Main.qml:775 src/qml/Main.qml:823
-msgid "Speech models"
-msgstr "xxSpeech modelsxx"
+#: src/qml/Main.qml:134
+msgid "Disable dictation history?"
+msgstr "xxDisable dictation history?xx"
-#: src/qml/Main.qml:142
-msgid "Choose and download a model before using dictation."
-msgstr "xxChoose and download a model before using dictation.xx"
+#: src/qml/Main.qml:135
+msgid ""
+"You can keep the encrypted history for later, or delete it together with its "
+"KDE Wallet key."
+msgstr ""
+"xxYou can keep the encrypted history for later, or delete it together with "
+"its KDE Wallet key.xx"
+
+#: src/qml/Main.qml:140
+msgid "Disable and keep"
+msgstr "xxDisable and keepxx"
+
+#: src/qml/Main.qml:149
+msgid "Disable and delete"
+msgstr "xxDisable and deletexx"
-#: src/qml/Main.qml:148
+#: src/qml/Main.qml:161
+msgid "Clear all dictation history?"
+msgstr "xxClear all dictation history?xx"
+
+#: src/qml/Main.qml:162
msgid ""
-"Models are downloaded only when you request them. Transcription remains "
-"offline after the download."
+"All entries in Kastword's encrypted history will be deleted. This cannot "
+"remove copies retained by other applications or backups."
msgstr ""
-"xxModels are downloaded only when you request them. Transcription remains "
-"offline after the download.xx"
+"xxAll entries in Kastword's encrypted history will be deleted. This cannot "
+"remove copies retained by other applications or backups.xx"
-#: src/qml/Main.qml:158 src/qml/Main.qml:220 src/qml/Main.qml:227
-msgid "Recommended"
-msgstr "xxRecommendedxx"
+#: src/qml/Main.qml:165
+msgid "Clear all"
+msgstr "xxClear allxx"
-#: src/qml/Main.qml:159
-msgid "English-only models"
-msgstr "xxEnglish-only modelsxx"
+#: src/qml/Main.qml:176
+msgid "Reset encrypted history?"
+msgstr "xxReset encrypted history?xx"
-#: src/qml/Main.qml:160
-msgid "Multilingual models"
-msgstr "xxMultilingual modelsxx"
+#: src/qml/Main.qml:177
+msgid ""
+"The unreadable encrypted file and its KDE Wallet key will be deleted. "
+"Existing entries cannot be recovered afterward."
+msgstr ""
+"xxThe unreadable encrypted file and its KDE Wallet key will be deleted. "
+"Existing entries cannot be recovered afterward.xx"
-#: src/qml/Main.qml:161
-msgid "All models"
-msgstr "xxAll modelsxx"
+#: src/qml/Main.qml:180
+msgid "Reset and delete"
+msgstr "xxReset and deletexx"
+
+#: src/qml/Main.qml:191
+msgid "Delete this history entry?"
+msgstr "xxDelete this history entry?xx"
+
+#: src/qml/Main.qml:192
+msgid "The selected entry will be removed from Kastword's encrypted history."
+msgstr ""
+"xxThe selected entry will be removed from Kastword's encrypted history.xx"
+
+#: src/qml/Main.qml:195 src/qml/Main.qml:346
+msgid "Delete"
+msgstr "xxDeletexx"
+
+#: src/qml/Main.qml:211 src/qml/Main.qml:1048 src/qml/Main.qml:1097
+msgid "History"
+msgstr "xxHistoryxx"
+
+#: src/qml/Main.qml:220
+msgid "Clear all history"
+msgstr "xxClear all historyxx"
+
+#: src/qml/Main.qml:242
+msgid "Enable history"
+msgstr "xxEnable historyxx"
+
+#: src/qml/Main.qml:251
+msgid ""
+"Store transcription text locally using authenticated encryption and a key "
+"protected by KDE Wallet"
+msgstr ""
+"xxStore transcription text locally using authenticated encryption and a key "
+"protected by KDE Walletxx"
+
+#: src/qml/Main.qml:266
+msgid "Reset encrypted history"
+msgstr "xxReset encrypted historyxx"
+
+#: src/qml/Main.qml:278
+msgid "Maximum entries:"
+msgstr "xxMaximum entries:xx"
+
+#: src/qml/Main.qml:283
+msgid "Maximum history entries"
+msgstr "xxMaximum history entriesxx"
+
+#: src/qml/Main.qml:288
+msgid "Maximum age (days):"
+msgstr "xxMaximum age (days):xx"
+
+#: src/qml/Main.qml:295
+msgid "Maximum history age in days"
+msgstr "xxMaximum history age in daysxx"
+
+#: src/qml/Main.qml:313
+msgid "History storage:"
+msgstr "xxHistory storage:xx"
+
+#: src/qml/Main.qml:304
+msgid "Encrypted history storage path"
+msgstr "xxEncrypted history storage pathxx"
+
+#: src/qml/Main.qml:323
+msgid "Show history file in file manager"
+msgstr "xxShow history file in file managerxx"
+
+#: src/qml/Main.qml:431
+msgid "Open model storage folder"
+msgstr "xxOpen model storage folderxx"
+
+#: src/qml/Main.qml:311
+msgid "No saved dictations yet"
+msgstr "xxNo saved dictations yetxx"
+
+#: src/qml/Main.qml:335
+msgid "Dictation from %1"
+msgstr "xxDictation from %1xx"
+
+#: src/qml/Main.qml:340 src/qml/Main.qml:973
+msgid "Copy"
+msgstr "xxCopyxx"
-#: src/qml/Main.qml:163
-msgid "Filter speech models"
-msgstr "xxFilter speech modelsxx"
+#: src/qml/Main.qml:353
+msgid "Delete the dictation from encrypted history"
+msgstr "xxDelete the dictation from encrypted historyxx"
-#: src/qml/Main.qml:234
+#: src/qml/Main.qml:368 src/qml/Main.qml:1049 src/qml/Main.qml:1114
+msgid "Models"
+msgstr "xxModelsxx"
+
+#: src/qml/Main.qml:380
+msgid "Choose and download a model before using dictation."
+msgstr "xxChoose and download a model before using dictation.xx"
+
+#: src/qml/Main.qml:386
+#: src/qml/Main.qml:396 src/qml/Main.qml:458 src/qml/Main.qml:465
+msgid "Recommended"
+msgstr "xxRecommendedxx"
+
+#: src/qml/Main.qml:397
+msgid "All"
+msgstr "xxAllxx"
+
+#: src/qml/Main.qml:472
msgid "%1 · %2 · %3"
msgstr "xx%1 · %2 · %3xx"
-#: src/qml/Main.qml:241
+#: src/qml/Main.qml:479
msgid "Partial download: %1"
msgstr "xxPartial download: %1xx"
-#: src/qml/Main.qml:250
+#: src/qml/Main.qml:488
msgid "Model download progress"
msgstr "xxModel download progressxx"
-#: src/qml/Main.qml:257
+#: src/qml/Main.qml:495
msgid "Model verification progress"
msgstr "xxModel verification progressxx"
-#: src/qml/Main.qml:258
+#: src/qml/Main.qml:496
msgid "The model is being verified"
msgstr "xxThe model is being verifiedxx"
-#: src/qml/Main.qml:293
+#: src/qml/Main.qml:531
msgid "In use"
msgstr "xxIn usexx"
-#: src/qml/Main.qml:293
+#: src/qml/Main.qml:531
msgid "Downloaded"
msgstr "xxDownloadedxx"
-#: src/qml/Main.qml:302
+#: src/qml/Main.qml:540
msgid "Available for download"
msgstr "xxAvailable for downloadxx"
-#: src/qml/Main.qml:329
+#: src/qml/Main.qml:567
msgid "Cancel"
msgstr "xxCancelxx"
-#: src/qml/Main.qml:338
+#: src/qml/Main.qml:576
msgid "Download"
msgstr "xxDownloadxx"
-#: src/qml/Main.qml:340
+#: src/qml/Main.qml:578
msgid "Download %1"
msgstr "xxDownload %1xx"
-#: src/qml/Main.qml:348
+#: src/qml/Main.qml:586
msgid "Use"
msgstr "xxUsexx"
-#: src/qml/Main.qml:350
+#: src/qml/Main.qml:588
msgid "Use %1"
msgstr "xxUse %1xx"
-#: src/qml/Main.qml:357
-msgid "Remove"
-msgstr "xxRemovexx"
-
-#: src/qml/Main.qml:362
+#: src/qml/Main.qml:600
msgid "Remove %1"
msgstr "xxRemove %1xx"
-#: src/qml/Main.qml:389
+#: src/qml/Main.qml:627
msgid ""
"Only open Whisper models from sources you trust. Models are parsed inside "
"Kastword."
@@ -657,83 +907,87 @@ msgstr ""
"xxOnly open Whisper models from sources you trust. Models are parsed inside "
"Kastword.xx"
-#: src/qml/Main.qml:393
+#: src/qml/Main.qml:631
msgid "Use an existing model…"
msgstr "xxUse an existing model…xx"
-#: src/qml/Main.qml:396
+#: src/qml/Main.qml:634
msgid "Select an existing compatible Whisper model file"
msgstr "xxSelect an existing compatible Whisper model filexx"
-#: src/qml/Main.qml:403
-msgid "Model storage: %1"
-msgstr "xxModel storage: %1xx"
+#: src/qml/Main.qml:641
+msgid "Model storage:"
+msgstr "xxModel storage:xx"
+
+#: src/qml/Main.qml:641
+msgid "Model storage path"
+msgstr "xxModel storage pathxx"
-#: src/qml/Main.qml:414 src/qml/Main.qml:776 src/qml/Main.qml:840
-msgid "Audio input"
-msgstr "xxAudio inputxx"
+#: src/qml/Main.qml:652 src/qml/Main.qml:1050 src/qml/Main.qml:1131
+msgid "Audio"
+msgstr "xxAudioxx"
-#: src/qml/Main.qml:421
+#: src/qml/Main.qml:659
msgid "Microphone:"
msgstr "xxMicrophone:xx"
-#: src/qml/Main.qml:429
+#: src/qml/Main.qml:667
msgid "Audio input device"
msgstr "xxAudio input devicexx"
-#: src/qml/Main.qml:430
+#: src/qml/Main.qml:668
msgid "Choose a microphone or follow the system default"
msgstr "xxChoose a microphone or follow the system defaultxx"
-#: src/qml/Main.qml:451
+#: src/qml/Main.qml:689
msgid "Disable audio input"
msgstr "xxDisable audio inputxx"
-#: src/qml/Main.qml:453
+#: src/qml/Main.qml:691
msgid "Use %1 for dictation"
msgstr "xxUse %1 for dictationxx"
-#: src/qml/Main.qml:454
+#: src/qml/Main.qml:692
msgid "This microphone is unavailable"
msgstr "xxThis microphone is unavailablexx"
-#: src/qml/Main.qml:466
+#: src/qml/Main.qml:704
msgid "Audio input status"
msgstr "xxAudio input statusxx"
-#: src/qml/Main.qml:475
+#: src/qml/Main.qml:713
msgid "Input level monitoring failed: %1"
msgstr "xxInput level monitoring failed: %1xx"
-#: src/qml/Main.qml:477
+#: src/qml/Main.qml:715
msgid "Input level monitoring failed"
msgstr "xxInput level monitoring failedxx"
-#: src/qml/Main.qml:481
+#: src/qml/Main.qml:719
msgid "Retry"
msgstr "xxRetryxx"
-#: src/qml/Main.qml:489
+#: src/qml/Main.qml:727
msgid "Input level:"
msgstr "xxInput level:xx"
-#: src/qml/Main.qml:498 src/qml/Main.qml:690
+#: src/qml/Main.qml:736 src/qml/Main.qml:928
msgid "Microphone level"
msgstr "xxMicrophone levelxx"
-#: src/qml/Main.qml:499
+#: src/qml/Main.qml:737
msgid "Current level from the selected microphone"
msgstr "xxCurrent level from the selected microphonexx"
-#: src/qml/Main.qml:508
+#: src/qml/Main.qml:746
msgid "Audio input is disabled."
msgstr "xxAudio input is disabled.xx"
-#: src/qml/Main.qml:510
+#: src/qml/Main.qml:748
msgid "Dictation is recording audio for local transcription."
msgstr "xxDictation is recording audio for local transcription.xx"
-#: src/qml/Main.qml:511
+#: src/qml/Main.qml:749
msgid ""
"Level monitoring only; audio is not saved. A specific microphone is never "
"replaced automatically if it becomes unavailable."
@@ -741,57 +995,57 @@ msgstr ""
"xxLevel monitoring only; audio is not saved. A specific microphone is never "
"replaced automatically if it becomes unavailable.xx"
-#: src/qml/Main.qml:522 src/qml/Main.qml:777 src/qml/Main.qml:857
+#: src/qml/Main.qml:760 src/qml/Main.qml:1051 src/qml/Main.qml:1148
msgid "Settings"
msgstr "xxSettingsxx"
-#: src/qml/Main.qml:530
+#: src/qml/Main.qml:768
msgid "Language:"
msgstr "xxLanguage:xx"
-#: src/qml/Main.qml:537
+#: src/qml/Main.qml:775
msgid "Dictation language"
msgstr "xxDictation languagexx"
-#: src/qml/Main.qml:538
+#: src/qml/Main.qml:776
msgid "Language supported by the selected Whisper model"
msgstr "xxLanguage supported by the selected Whisper modelxx"
-#: src/qml/Main.qml:542
+#: src/qml/Main.qml:780
msgid "Maximum recording:"
msgstr "xxMaximum recording:xx"
-#: src/qml/Main.qml:546
+#: src/qml/Main.qml:784
msgid "%1 minute"
msgid_plural "%1 minutes"
msgstr[0] "xx%1 minutexx"
msgstr[1] "xx%1 minutesxx"
-#: src/qml/Main.qml:553
+#: src/qml/Main.qml:791
msgid "Output:"
msgstr "xxOutput:xx"
-#: src/qml/Main.qml:554
+#: src/qml/Main.qml:792
msgid "Paste automatically"
msgstr "xxPaste automaticallyxx"
-#: src/qml/Main.qml:561
+#: src/qml/Main.qml:799
msgid "Paste shortcuts:"
msgstr "xxPaste shortcuts:xx"
-#: src/qml/Main.qml:562
+#: src/qml/Main.qml:800
msgid "Ctrl+V"
msgstr "xxCtrl+Vxx"
-#: src/qml/Main.qml:570
+#: src/qml/Main.qml:808
msgid "Ctrl+Shift+V"
msgstr "xxCtrl+Shift+Vxx"
-#: src/qml/Main.qml:578
+#: src/qml/Main.qml:816
msgid "Shift+Insert"
msgstr "xxShift+Insertxx"
-#: src/qml/Main.qml:589
+#: src/qml/Main.qml:827
msgid ""
"Automatic paste sends each selected shortcut to the focused application "
"after a short delay. On Wayland, Kastword cannot verify that focus stayed "
@@ -801,95 +1055,103 @@ msgstr ""
"after a short delay. On Wayland, Kastword cannot verify that focus stayed "
"unchanged.xx"
-#: src/qml/Main.qml:595
+#: src/qml/Main.qml:833
msgid "Shortcut:"
msgstr "xxShortcut:xx"
-#: src/qml/Main.qml:602
+#: src/qml/Main.qml:840
msgid "Global dictation shortcut"
msgstr "xxGlobal dictation shortcutxx"
-#: src/qml/Main.qml:617
+#: src/qml/Main.qml:855
msgid "The global shortcut could not be changed. Choose another shortcut."
msgstr "xxThe global shortcut could not be changed. Choose another shortcut.xx"
-#: src/qml/Main.qml:627 src/qml/Main.qml:774 src/qml/Main.qml:806
-msgid "Offline dictation"
-msgstr "xxOffline dictationxx"
+#: src/qml/Main.qml:865 src/qml/Main.qml:1047 src/qml/Main.qml:1080
+msgid "Dictation"
+msgstr "xxDictationxx"
-#: src/qml/Main.qml:643
+#: src/qml/Main.qml:881
msgid "Dictation status"
msgstr "xxDictation statusxx"
-#: src/qml/Main.qml:652
+#: src/qml/Main.qml:890
msgid "Stop dictation"
msgstr "xxStop dictationxx"
-#: src/qml/Main.qml:654
+#: src/qml/Main.qml:892
msgid "Start dictation"
msgstr "xxStart dictationxx"
-#: src/qml/Main.qml:660
+#: src/qml/Main.qml:898
msgid "Stop recording and begin local transcription"
msgstr "xxStop recording and begin local transcriptionxx"
-#: src/qml/Main.qml:662 src/qml/Main.qml:692
+#: src/qml/Main.qml:900 src/qml/Main.qml:930
msgid "Local transcription is in progress"
msgstr "xxLocal transcription is in progressxx"
-#: src/qml/Main.qml:664
+#: src/qml/Main.qml:902
msgid "Choose a speech model before starting dictation"
msgstr "xxChoose a speech model before starting dictationxx"
-#: src/qml/Main.qml:666
+#: src/qml/Main.qml:904
msgid "Choose an available microphone before starting dictation"
msgstr "xxChoose an available microphone before starting dictationxx"
-#: src/qml/Main.qml:667
+#: src/qml/Main.qml:905
msgid "Begin recording audio for local transcription"
msgstr "xxBegin recording audio for local transcriptionxx"
-#: src/qml/Main.qml:689
+#: src/qml/Main.qml:927
msgid "Transcription progress"
msgstr "xxTranscription progressxx"
-#: src/qml/Main.qml:693
+#: src/qml/Main.qml:931
msgid "Current microphone input level"
msgstr "xxCurrent microphone input levelxx"
-#: src/qml/Main.qml:700
+#: src/qml/Main.qml:938
msgid "Global shortcut: %1"
msgstr "xxGlobal shortcut: %1xx"
-#: src/qml/Main.qml:725
+#: src/qml/Main.qml:963
msgid "Preview of the most recent dictation"
msgstr "xxPreview of the most recent dictationxx"
-#: src/qml/Main.qml:735
-msgid "Copy"
-msgstr "xxCopyxx"
-
-#: src/qml/Main.qml:746
+#: src/qml/Main.qml:984
msgid "View"
msgstr "xxViewxx"
-#: src/qml/Main.qml:749
+#: src/qml/Main.qml:987
msgid "Show full transcription"
msgstr "xxShow full transcriptionxx"
-#: src/qml/Main.qml:757
+#: src/qml/Main.qml:995
msgid "Clear"
msgstr "xxClearxx"
-#: src/qml/Main.qml:760
+#: src/qml/Main.qml:998
msgid "Clear transcription"
msgstr "xxClear transcriptionxx"
-#: src/qml/Main.qml:761
+#: src/qml/Main.qml:999
msgid "Remove the transcription from Kastword and matching current clipboards"
msgstr ""
"xxRemove the transcription from Kastword and matching current clipboardsxx"
+#: src/qml/Main.qml:1019
+msgid "Recent history"
+msgstr "xxRecent historyxx"
+
+#: src/qml/Main.qml:1022
+msgid "View all"
+msgstr "xxView allxx"
+
+#: src/qml/Main.qml:1024
+msgid "View all dictation history"
+msgstr "xxView all dictation historyxx"
+
#~ msgid "Ready — press %1 to dictate."
#~ msgstr "xxReady — press %1 to dictate.xx"
diff --git a/screenshots/01-offline-dictation.png b/screenshots/01-offline-dictation.png
index f0667bf..8e68b4c 100644
Binary files a/screenshots/01-offline-dictation.png and b/screenshots/01-offline-dictation.png differ
diff --git a/screenshots/02-history.png b/screenshots/02-history.png
new file mode 100644
index 0000000..81ace2a
Binary files /dev/null and b/screenshots/02-history.png differ
diff --git a/screenshots/02-speech-models.png b/screenshots/02-speech-models.png
deleted file mode 100644
index 0508807..0000000
Binary files a/screenshots/02-speech-models.png and /dev/null differ
diff --git a/screenshots/03-audio-input.png b/screenshots/03-audio-input.png
deleted file mode 100644
index b4f1dcb..0000000
Binary files a/screenshots/03-audio-input.png and /dev/null differ
diff --git a/screenshots/03-speech-models.png b/screenshots/03-speech-models.png
new file mode 100644
index 0000000..cd318fb
Binary files /dev/null and b/screenshots/03-speech-models.png differ
diff --git a/screenshots/04-audio-input.png b/screenshots/04-audio-input.png
new file mode 100644
index 0000000..f7868d3
Binary files /dev/null and b/screenshots/04-audio-input.png differ
diff --git a/screenshots/04-settings.png b/screenshots/04-settings.png
deleted file mode 100644
index 46ec503..0000000
Binary files a/screenshots/04-settings.png and /dev/null differ
diff --git a/screenshots/05-settings.png b/screenshots/05-settings.png
new file mode 100644
index 0000000..3b42a4f
Binary files /dev/null and b/screenshots/05-settings.png differ
diff --git a/src/AppController.cpp b/src/AppController.cpp
index d5611ac..bff7647 100644
--- a/src/AppController.cpp
+++ b/src/AppController.cpp
@@ -47,12 +47,14 @@ AppController::AppController(std::unique_ptr audio,
std::unique_ptr output, TranscribeFunction transcribe,
bool desktopIntegration, bool requireModel, QObject *parent,
std::unique_ptr modelManager,
- std::unique_ptr desktopServices)
+ std::unique_ptr desktopServices,
+ std::unique_ptr history)
: QObject(parent), m_audio(std::move(audio)), m_output(std::move(output)),
m_modelManager(modelManager ? std::move(modelManager) : std::make_unique()),
m_desktopServices(desktopServices
? std::move(desktopServices)
: (desktopIntegration ? createDesktopIntegration() : nullptr)),
+ m_history(history ? std::move(history) : std::make_unique()),
m_transcriptionWorker(new TranscriptionWorker(std::move(transcribe))),
m_desktopIntegration(desktopIntegration), m_requireModel(requireModel),
m_config(QStringLiteral("kastwordrc")), m_shortcut(i18n("Toggle dictation"), this) {
@@ -93,6 +95,14 @@ void AppController::initialize() {
m_recordingLimitMinutes = qBound(1, group.readEntry("RecordingLimitMinutes", 5), 60);
m_audio->setMaximumDurationSeconds(m_recordingLimitMinutes * 60);
m_audio->setSelectedDeviceId(group.readEntry("AudioInputId", QString()));
+ m_history->setMaximumEntries(group.readEntry("HistoryMaximumEntries", 100));
+ m_history->setMaximumAgeDays(group.readEntry("HistoryMaximumAgeDays", 30));
+ connect(m_history.get(), &DictationHistory::settingsChanged, this, &AppController::saveSettings);
+ const bool deletionPending = m_history->deletionPending();
+ if (deletionPending)
+ m_history->resumePendingDeletion();
+ else if (group.readEntry("HistoryEnabled", false))
+ m_history->enable();
connect(m_modelManager.get(), &ModelManager::activeModelPathChanged, this, [this] {
const QString path = m_modelManager->activeModelPath();
@@ -430,6 +440,38 @@ void AppController::copyText(const QString &text) {
setStatus(m_output->deliver(text, false));
}
+void AppController::enableHistory() { m_history->enable(); }
+
+void AppController::disableHistory(bool deleteData) { m_history->disable(deleteData); }
+
+void AppController::revealHistoryStorage() {
+ if (!m_desktopServices)
+ return;
+ const QPointer guard(this);
+ m_desktopServices->revealFile(m_history->storagePath(), [guard](bool opened) {
+ if (!guard || opened)
+ return;
+ const QString error = i18n("The history storage location could not be opened.");
+ guard->setStatus(error);
+ guard->m_desktopServices->showNotification(DesktopIntegration::NotificationKind::Error,
+ i18n("Could not open storage location"), error);
+ });
+}
+
+void AppController::openModelStorage() {
+ if (!m_desktopServices)
+ return;
+ const QPointer guard(this);
+ m_desktopServices->openDirectory(m_modelManager->storagePath(), [guard](bool opened) {
+ if (!guard || opened)
+ return;
+ const QString error = i18n("The model storage folder could not be opened.");
+ guard->setStatus(error);
+ guard->m_desktopServices->showNotification(DesktopIntegration::NotificationKind::Error,
+ i18n("Could not open storage location"), error);
+ });
+}
+
void AppController::toggle() {
if (m_audio->isRecording()) {
QByteArray audio = m_audio->stop();
@@ -521,7 +563,11 @@ void AppController::handleTranscriptionFinished(const QString &text, const QStri
}
m_transcript = trimmedText;
emit transcriptChanged();
- const QString delivery = m_output->deliver(trimmedText, m_autoPaste);
+ const bool historyStored = m_history->add(trimmedText);
+ QString delivery = m_output->deliver(trimmedText, m_autoPaste);
+ if ((m_history->enabled() || m_history->busy()) && !historyStored) {
+ delivery = i18n("%1 History was not saved: %2", delivery, m_history->status());
+ }
setStatus(delivery);
setState(State::Success);
showStatusNotification(i18n("Dictation complete"), delivery,
@@ -578,5 +624,8 @@ void AppController::saveSettings() {
group.writeEntry("PasteShortcuts", m_pasteShortcuts.toInt());
group.writeEntry("RecordingLimitMinutes", m_recordingLimitMinutes);
group.writeEntry("AudioInputId", m_audio->selectedDeviceId());
+ group.writeEntry("HistoryEnabled", m_history->enabled());
+ group.writeEntry("HistoryMaximumEntries", m_history->maximumEntries());
+ group.writeEntry("HistoryMaximumAgeDays", m_history->maximumAgeDays());
group.sync();
}
diff --git a/src/AppController.h b/src/AppController.h
index 5b575bc..9b9e676 100644
--- a/src/AppController.h
+++ b/src/AppController.h
@@ -4,6 +4,7 @@
#pragma once
#include "AudioCapture.h"
+#include "DictationHistory.h"
#include "ModelManager.h"
#include "PlatformIntegration.h"
#include "TextOutput.h"
@@ -29,6 +30,7 @@ class AppController final : public QObject {
Q_PROPERTY(bool transcribing READ isTranscribing NOTIFY stateChanged)
Q_PROPERTY(QString status READ status NOTIFY statusChanged)
Q_PROPERTY(QString transcript READ transcript NOTIFY transcriptChanged)
+ Q_PROPERTY(QObject *history READ history CONSTANT)
Q_PROPERTY(QString modelPath READ modelPath WRITE setModelPath NOTIFY modelPathChanged)
Q_PROPERTY(bool modelReady READ modelReady NOTIFY modelReadyChanged)
Q_PROPERTY(bool modelSetupRequired READ modelSetupRequired NOTIFY modelReadyChanged)
@@ -68,7 +70,8 @@ class AppController final : public QObject {
AppController(std::unique_ptr audio, std::unique_ptr output,
TranscribeFunction transcribe, bool desktopIntegration, bool requireModel,
QObject *parent = nullptr, std::unique_ptr modelManager = {},
- std::unique_ptr desktopServices = {});
+ std::unique_ptr desktopServices = {},
+ std::unique_ptr history = {});
~AppController() override;
State state() const { return m_state; }
bool isIdle() const { return m_state == State::Idle; }
@@ -76,6 +79,7 @@ class AppController final : public QObject {
bool isTranscribing() const { return m_state == State::Transcribing; }
QString status() const { return m_status; }
QString transcript() const { return m_transcript; }
+ QObject *history() { return m_history.get(); }
QString modelPath() const { return m_modelPath; }
bool modelReady() const { return !m_requireModel || m_modelManager->modelReady(); }
bool modelSetupRequired() const {
@@ -120,6 +124,10 @@ class AppController final : public QObject {
Q_INVOKABLE void toggle();
Q_INVOKABLE void copyText(const QString &text);
+ Q_INVOKABLE void enableHistory();
+ Q_INVOKABLE void disableHistory(bool deleteData);
+ Q_INVOKABLE void revealHistoryStorage();
+ Q_INVOKABLE void openModelStorage();
Q_INVOKABLE void copyTranscript();
Q_INVOKABLE void forgetTranscript();
Q_INVOKABLE void setModelUrl(const QUrl &url);
@@ -160,6 +168,7 @@ class AppController final : public QObject {
std::unique_ptr m_output;
std::unique_ptr m_modelManager;
std::unique_ptr m_desktopServices;
+ std::unique_ptr m_history;
QThread m_transcriptionThread;
TranscriptionWorker *m_transcriptionWorker;
bool m_desktopIntegration;
diff --git a/src/DictationHistory.cpp b/src/DictationHistory.cpp
new file mode 100644
index 0000000..fc8bc75
--- /dev/null
+++ b/src/DictationHistory.cpp
@@ -0,0 +1,773 @@
+// SPDX-FileCopyrightText: 2026 Sri Rang
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "DictationHistory.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+constexpr auto historyFileName = "history.enc";
+constexpr auto walletFolder = "Kastword";
+constexpr auto walletEntry = "dictation-history-key-v1";
+constexpr qint64 maximumHistoryFileSize = 64LL * 1024 * 1024;
+const QByteArray magic = QByteArrayLiteral("KWHIST01");
+
+class KWalletHistoryKeyProvider final : public HistoryKeyProvider {
+public:
+ void loadOrCreate(QObject *context, LoadCallback callback) override {
+ KWallet::Wallet *wallet = openWallet();
+ if (!wallet) {
+ callback(std::nullopt, i18n("Secure history requires an available, unlocked KDE Wallet."));
+ return;
+ }
+ wallet->setParent(context);
+ QObject::connect(wallet, &KWallet::Wallet::walletOpened, context,
+ [wallet, callback = std::move(callback)](bool success) mutable {
+ if (!success) {
+ callback(
+ std::nullopt,
+ i18n("Secure history requires an available, unlocked KDE Wallet."));
+ wallet->deleteLater();
+ return;
+ }
+ QString error;
+ std::optional key = loadKey(wallet, &error);
+ callback(std::move(key), error);
+ wallet->deleteLater();
+ });
+ }
+
+ void remove(QObject *context, RemoveCallback callback) override {
+ KWallet::Wallet *wallet = openWallet();
+ if (!wallet) {
+ callback(false, i18n("The secure history key could not be removed from KDE Wallet."));
+ return;
+ }
+ wallet->setParent(context);
+ QObject::connect(wallet, &KWallet::Wallet::walletOpened, context,
+ [wallet, callback = std::move(callback)](bool success) mutable {
+ QString error;
+ const bool removed = success && removeKey(wallet, &error);
+ if (!success)
+ error =
+ i18n("The secure history key could not be removed from KDE Wallet.");
+ callback(removed, error);
+ wallet->deleteLater();
+ });
+ }
+
+private:
+ static WId parentWindowId() {
+ QWindow *window = QGuiApplication::focusWindow();
+ if (!window)
+ window = QGuiApplication::allWindows().value(0, nullptr);
+ return window ? window->winId() : 0;
+ }
+
+ static KWallet::Wallet *openWallet() {
+ return KWallet::Wallet::openWallet(KWallet::Wallet::LocalWallet(), parentWindowId(),
+ KWallet::Wallet::Asynchronous);
+ }
+
+ static std::optional loadKey(KWallet::Wallet *wallet, QString *error) {
+ if (!wallet->hasFolder(QString::fromLatin1(walletFolder)) &&
+ !wallet->createFolder(QString::fromLatin1(walletFolder))) {
+ *error = i18n("Kastword could not create its secure wallet folder.");
+ return std::nullopt;
+ }
+ if (!wallet->setFolder(QString::fromLatin1(walletFolder))) {
+ *error = i18n("Kastword could not open its secure wallet folder.");
+ return std::nullopt;
+ }
+ QByteArray key;
+ if (wallet->hasEntry(QString::fromLatin1(walletEntry))) {
+ if (wallet->readEntry(QString::fromLatin1(walletEntry), key) != 0 ||
+ key.size() != crypto_aead_xchacha20poly1305_ietf_KEYBYTES) {
+ *error = i18n("The secure history key could not be read.");
+ return std::nullopt;
+ }
+ return key;
+ }
+ key.resize(crypto_aead_xchacha20poly1305_ietf_KEYBYTES);
+ randombytes_buf(key.data(), size_t(key.size()));
+ if (wallet->writeEntry(QString::fromLatin1(walletEntry), key) != 0) {
+ sodium_memzero(key.data(), size_t(key.size()));
+ *error = i18n("The secure history key could not be stored.");
+ return std::nullopt;
+ }
+ return key;
+ }
+
+ static bool removeKey(KWallet::Wallet *wallet, QString *error) {
+ if (!wallet->hasFolder(QString::fromLatin1(walletFolder)))
+ return true;
+ if (!wallet->setFolder(QString::fromLatin1(walletFolder)) ||
+ (wallet->hasEntry(QString::fromLatin1(walletEntry)) &&
+ wallet->removeEntry(QString::fromLatin1(walletEntry)) != 0)) {
+ *error = i18n("The secure history key could not be removed from KDE Wallet.");
+ return false;
+ }
+ return true;
+ }
+};
+} // namespace
+
+std::unique_ptr createHistoryKeyProvider() {
+ return std::make_unique();
+}
+
+DictationHistory::DictationHistory(QObject *parent)
+ : DictationHistory(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) +
+ QStringLiteral("/") + QString::fromLatin1(historyFileName),
+ createHistoryKeyProvider(), {}, {}, parent, true) {}
+
+DictationHistory::DictationHistory(QString storagePath,
+ std::unique_ptr keyProvider, Clock clock,
+ CommitFunction commit, QObject *parent,
+ bool asynchronousPersistence)
+ : QAbstractListModel(parent), m_storagePath(std::move(storagePath)),
+ m_keyProvider(std::move(keyProvider)),
+ m_clock(clock ? std::move(clock) : [] { return QDateTime::currentDateTimeUtc(); }),
+ m_commit(commit ? std::move(commit) : &DictationHistory::commitAtomically),
+ m_asynchronousPersistence(asynchronousPersistence) {
+ Q_ASSERT(m_keyProvider);
+ m_expiryTimer.setSingleShot(true);
+ connect(&m_expiryTimer, &QTimer::timeout, this, &DictationHistory::expireEntries);
+ connect(&m_saveWatcher, &QFutureWatcherBase::finished, this, &DictationHistory::finishAsyncSave);
+ connect(&m_loadWatcher, &QFutureWatcherBase::finished, this, &DictationHistory::finishAsyncLoad);
+ if (sodium_init() < 0) {
+ m_cryptoAvailable = false;
+ fail(i18n("Secure history encryption is unavailable."));
+ }
+}
+
+DictationHistory::~DictationHistory() {
+ m_loadWatcher.waitForFinished();
+ if (m_saving) {
+ m_saveWatcher.waitForFinished();
+ const SaveResult completed = m_saveWatcher.result();
+ if (m_pendingSave) {
+ persistEntries(std::move(*m_pendingSave), m_key, m_storagePath, m_commit);
+ } else if (!completed.success) {
+ persistEntries({m_stagedEntries, m_stagedMaximumEntries, m_stagedMaximumAgeDays}, m_key,
+ m_storagePath, m_commit);
+ }
+ }
+ if (!m_key.isEmpty())
+ sodium_memzero(m_key.data(), size_t(m_key.size()));
+}
+
+QVariantMap DictationHistory::entryMap(const Entry &entry) const {
+ return {{QStringLiteral("id"), entry.id},
+ {QStringLiteral("createdAt"), entry.createdAt},
+ {QStringLiteral("createdText"),
+ QLocale().toString(entry.createdAt.toLocalTime(), QLocale::ShortFormat)},
+ {QStringLiteral("text"), entry.text}};
+}
+
+QVariantList DictationHistory::entries() const {
+ QVariantList result;
+ for (const Entry &entry : m_entries)
+ result.append(entryMap(entry));
+ return result;
+}
+
+int DictationHistory::rowCount(const QModelIndex &parent) const {
+ return parent.isValid() ? 0 : int(m_entries.size());
+}
+
+QVariant DictationHistory::data(const QModelIndex &index, int role) const {
+ if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size())
+ return {};
+ const Entry &entry = m_entries.at(index.row());
+ switch (role) {
+ case EntryIdRole:
+ return entry.id;
+ case CreatedAtRole:
+ return entry.createdAt;
+ case CreatedTextRole:
+ return QLocale().toString(entry.createdAt.toLocalTime(), QLocale::ShortFormat);
+ case TextRole:
+ return entry.text;
+ default:
+ return {};
+ }
+}
+
+QHash DictationHistory::roleNames() const {
+ return {{EntryIdRole, "entryId"},
+ {CreatedAtRole, "createdAt"},
+ {CreatedTextRole, "createdText"},
+ {TextRole, "text"}};
+}
+
+void DictationHistory::replaceEntries(QList entries) {
+ beginResetModel();
+ m_entries = std::move(entries);
+ endResetModel();
+}
+
+QVariantList DictationHistory::recentEntries() const {
+ QVariantList result;
+ for (qsizetype i = 0; i < qMin(3, m_entries.size()); ++i)
+ result.append(entryMap(m_entries.at(i)));
+ return result;
+}
+
+bool DictationHistory::deletionPending() const { return QFileInfo::exists(deletionMarkerPath()); }
+
+QString DictationHistory::deletionMarkerPath() const {
+ return m_storagePath + QStringLiteral(".delete-pending");
+}
+
+void DictationHistory::enable() {
+ if (m_enabled)
+ return;
+ if (!m_cryptoAvailable || m_busy)
+ return;
+ m_available = true;
+ m_resetRequired = false;
+ m_busy = true;
+ m_status = i18n("Opening KDE Wallet…");
+ emit changed();
+ m_keyProvider->loadOrCreate(this, [this](std::optional key, const QString &error) {
+ if (!key || key->size() != crypto_aead_xchacha20poly1305_ietf_KEYBYTES) {
+ m_busy = false;
+ fail(error.isEmpty() ? i18n("The secure history key is unavailable.") : error);
+ return;
+ }
+ m_key = *key;
+ sodium_memzero(key->data(), size_t(key->size()));
+ key->clear();
+ QString directoryError;
+ if (!secureStorageDirectory(m_storagePath, &directoryError)) {
+ m_busy = false;
+ sodium_memzero(m_key.data(), size_t(m_key.size()));
+ m_key.clear();
+ fail(directoryError);
+ return;
+ }
+ if (m_asynchronousPersistence) {
+ startAsyncLoad();
+ return;
+ }
+ m_busy = false;
+ m_enabled = true;
+ if (!load()) {
+ m_enabled = false;
+ sodium_memzero(m_key.data(), size_t(m_key.size()));
+ m_key.clear();
+ emit changed();
+ return;
+ }
+ m_status = i18n("History is encrypted locally. The key is stored in KDE Wallet.");
+ scheduleExpiry();
+ emit settingsChanged();
+ emit changed();
+ });
+}
+
+void DictationHistory::disable(bool deleteData) {
+ if (busy())
+ return;
+ m_expiryTimer.stop();
+ if (deleteData) {
+ QString error;
+ if (!deletionPending() &&
+ !commitAtomically(deletionMarkerPath(), QByteArrayLiteral("pending\n"), &error)) {
+ fail(error);
+ return;
+ }
+ resumePendingDeletion();
+ return;
+ }
+ replaceEntries({});
+ m_persistedEntries.clear();
+ m_stagedEntries.clear();
+ if (!m_key.isEmpty())
+ sodium_memzero(m_key.data(), size_t(m_key.size()));
+ m_key.clear();
+ m_enabled = false;
+ m_available = true;
+ m_resetRequired = false;
+ m_status = i18n("History is disabled. Existing encrypted history was kept.");
+ emit settingsChanged();
+ emit changed();
+}
+
+void DictationHistory::resumePendingDeletion() {
+ if (!deletionPending() || busy())
+ return;
+ m_expiryTimer.stop();
+ if (QFileInfo::exists(m_storagePath) && !QFile::remove(m_storagePath)) {
+ fail(i18n("The encrypted history file could not be deleted."));
+ return;
+ }
+ replaceEntries({});
+ m_persistedEntries.clear();
+ m_stagedEntries.clear();
+ if (!m_key.isEmpty())
+ sodium_memzero(m_key.data(), size_t(m_key.size()));
+ m_key.clear();
+ m_enabled = false;
+ m_busy = true;
+ m_status = i18n("Removing the secure history key…");
+ emit settingsChanged();
+ emit changed();
+ removePendingKey();
+}
+
+void DictationHistory::removePendingKey() {
+ m_keyProvider->remove(this, [this](bool removed, const QString &error) {
+ m_busy = false;
+ if (!removed) {
+ fail(error.isEmpty() ? i18n("The encrypted history could not be deleted completely.")
+ : error);
+ return;
+ }
+ if (QFileInfo::exists(deletionMarkerPath()) && !QFile::remove(deletionMarkerPath())) {
+ fail(i18n("The secure history deletion marker could not be removed."));
+ return;
+ }
+ m_available = true;
+ m_resetRequired = false;
+ m_status = i18n("Encrypted history was deleted.");
+ emit changed();
+ });
+}
+
+bool DictationHistory::add(const QString &text) {
+ const QString trimmed = text.trimmed();
+ if (!m_enabled || trimmed.isEmpty())
+ return false;
+ QList entries = m_asynchronousPersistence ? m_stagedEntries : m_entries;
+ entries.prepend({QUuid::createUuid().toString(QUuid::WithoutBraces), m_clock(), trimmed});
+ pruneEntries(entries, m_asynchronousPersistence ? m_stagedMaximumEntries : m_maximumEntries,
+ m_asynchronousPersistence ? m_stagedMaximumAgeDays : m_maximumAgeDays);
+ if (!saveEntries(entries))
+ return false;
+ if (!m_asynchronousPersistence)
+ replaceEntries(std::move(entries));
+ scheduleExpiry();
+ emit changed();
+ return true;
+}
+
+bool DictationHistory::removeEntry(const QString &id) {
+ if (!m_enabled)
+ return false;
+ QList entries = m_asynchronousPersistence ? m_stagedEntries : m_entries;
+ const qsizetype removed = entries.removeIf([&id](const Entry &entry) { return entry.id == id; });
+ if (removed == 0)
+ return false;
+ if (!saveEntries(entries))
+ return false;
+ if (!m_asynchronousPersistence)
+ replaceEntries(std::move(entries));
+ scheduleExpiry();
+ emit changed();
+ return true;
+}
+
+bool DictationHistory::clear() {
+ if (!m_enabled)
+ return false;
+ if (!saveEntries({}))
+ return false;
+ if (!m_asynchronousPersistence)
+ replaceEntries({});
+ scheduleExpiry();
+ emit changed();
+ return true;
+}
+
+void DictationHistory::setMaximumEntries(int value) {
+ value = qBound(1, value, 10000);
+ if ((m_asynchronousPersistence ? m_stagedMaximumEntries : m_maximumEntries) == value)
+ return;
+ if (m_asynchronousPersistence && m_enabled) {
+ QList entries = m_stagedEntries;
+ pruneEntries(entries, value, m_stagedMaximumAgeDays);
+ saveEntries(entries, value, m_stagedMaximumAgeDays);
+ return;
+ }
+ if (m_enabled) {
+ QList entries = m_entries;
+ if (pruneEntries(entries, value, m_maximumAgeDays)) {
+ if (!saveEntries(entries, value, m_maximumAgeDays))
+ return;
+ replaceEntries(std::move(entries));
+ }
+ }
+ m_maximumEntries = value;
+ scheduleExpiry();
+ emit settingsChanged();
+ emit changed();
+}
+
+void DictationHistory::setMaximumAgeDays(int value) {
+ value = qBound(1, value, 3650);
+ if ((m_asynchronousPersistence ? m_stagedMaximumAgeDays : m_maximumAgeDays) == value)
+ return;
+ if (m_asynchronousPersistence && m_enabled) {
+ QList entries = m_stagedEntries;
+ pruneEntries(entries, m_stagedMaximumEntries, value);
+ saveEntries(entries, m_stagedMaximumEntries, value);
+ return;
+ }
+ if (m_enabled) {
+ QList entries = m_entries;
+ if (pruneEntries(entries, m_maximumEntries, value)) {
+ if (!saveEntries(entries, m_maximumEntries, value))
+ return;
+ replaceEntries(std::move(entries));
+ }
+ }
+ m_maximumAgeDays = value;
+ scheduleExpiry();
+ emit settingsChanged();
+ emit changed();
+}
+
+void DictationHistory::scheduleExpiry() {
+ m_expiryTimer.stop();
+ if (!m_enabled || m_entries.isEmpty())
+ return;
+ QDateTime nextExpiry;
+ for (const Entry &entry : m_entries) {
+ const QDateTime expiry = entry.createdAt.addDays(m_maximumAgeDays);
+ if (!nextExpiry.isValid() || expiry < nextExpiry)
+ nextExpiry = expiry;
+ }
+ const qint64 delay = qMax(1, m_clock().msecsTo(nextExpiry) + 1);
+ m_expiryTimer.start(int(qMin(delay, std::numeric_limits::max())));
+}
+
+void DictationHistory::expireEntries() {
+ QList entries = m_asynchronousPersistence ? m_stagedEntries : m_entries;
+ if (!pruneEntries(entries, m_asynchronousPersistence ? m_stagedMaximumEntries : m_maximumEntries,
+ m_asynchronousPersistence ? m_stagedMaximumAgeDays : m_maximumAgeDays)) {
+ scheduleExpiry();
+ return;
+ }
+ if (!saveEntries(entries)) {
+ m_expiryTimer.start(60000);
+ return;
+ }
+ if (!m_asynchronousPersistence) {
+ replaceEntries(std::move(entries));
+ emit changed();
+ }
+ scheduleExpiry();
+}
+
+bool DictationHistory::pruneEntries(QList &entries, int maximumEntries,
+ int maximumAgeDays) const {
+ const QDateTime oldest = m_clock().addDays(-maximumAgeDays);
+ const qsizetype before = entries.size();
+ entries.removeIf([&oldest](const Entry &entry) { return entry.createdAt < oldest; });
+ while (entries.size() > maximumEntries)
+ entries.removeLast();
+ return before != entries.size();
+}
+
+bool DictationHistory::load() {
+ LoadResult result =
+ loadEntries(m_storagePath, m_key, m_clock(), m_maximumEntries, m_maximumAgeDays, m_commit);
+ if (!result.success) {
+ fail(result.error, result.resetRequired);
+ return false;
+ }
+ replaceEntries(std::move(result.entries));
+ m_persistedEntries = m_entries;
+ m_stagedEntries = m_entries;
+ m_stagedMaximumEntries = m_maximumEntries;
+ m_stagedMaximumAgeDays = m_maximumAgeDays;
+ return true;
+}
+
+void DictationHistory::startAsyncLoad() {
+ QByteArray key(m_key.constData(), m_key.size());
+ const QString path = m_storagePath;
+ const QDateTime now = m_clock();
+ const int maximumEntries = m_maximumEntries;
+ const int maximumAgeDays = m_maximumAgeDays;
+ const CommitFunction commit = m_commit;
+ m_loadWatcher.setFuture(QtConcurrent::run(
+ [path, key = std::move(key), now, maximumEntries, maximumAgeDays, commit]() mutable {
+ return loadEntries(path, std::move(key), now, maximumEntries, maximumAgeDays, commit);
+ }));
+}
+
+void DictationHistory::finishAsyncLoad() {
+ m_busy = false;
+ LoadResult result = m_loadWatcher.result();
+ if (!result.success) {
+ sodium_memzero(m_key.data(), size_t(m_key.size()));
+ m_key.clear();
+ fail(result.error, result.resetRequired);
+ return;
+ }
+ m_enabled = true;
+ replaceEntries(std::move(result.entries));
+ m_persistedEntries = m_entries;
+ m_stagedEntries = m_entries;
+ m_stagedMaximumEntries = m_maximumEntries;
+ m_stagedMaximumAgeDays = m_maximumAgeDays;
+ m_status = i18n("History is encrypted locally. The key is stored in KDE Wallet.");
+ scheduleExpiry();
+ emit settingsChanged();
+ emit changed();
+}
+
+bool DictationHistory::saveEntries(const QList &historyEntries, int maximumEntries,
+ int maximumAgeDays) {
+ if (!m_enabled || m_key.size() != crypto_aead_xchacha20poly1305_ietf_KEYBYTES)
+ return false;
+ if (m_asynchronousPersistence) {
+ SaveRequest request{historyEntries, maximumEntries, maximumAgeDays};
+ m_stagedEntries = historyEntries;
+ m_stagedMaximumEntries = maximumEntries;
+ m_stagedMaximumAgeDays = maximumAgeDays;
+ if (m_saving)
+ m_pendingSave = request;
+ else
+ startAsyncSave(std::move(request));
+ return true;
+ }
+
+ SaveResult result = persistEntries({historyEntries, maximumEntries, maximumAgeDays}, m_key,
+ m_storagePath, m_commit);
+ if (!result.success) {
+ fail(result.error);
+ return false;
+ }
+ m_persistedEntries = historyEntries;
+ m_available = true;
+ m_resetRequired = false;
+ m_status = i18n("History is encrypted locally. The key is stored in KDE Wallet.");
+ return true;
+}
+
+void DictationHistory::startAsyncSave(SaveRequest request) {
+ m_saving = true;
+ m_status = i18n("Saving encrypted history…");
+ emit changed();
+ QByteArray key(m_key.constData(), m_key.size());
+ const QString path = m_storagePath;
+ const CommitFunction commit = m_commit;
+ m_saveWatcher.setFuture(QtConcurrent::run(
+ [request = std::move(request), key = std::move(key), path, commit]() mutable {
+ return persistEntries(std::move(request), std::move(key), path, commit);
+ }));
+}
+
+void DictationHistory::finishAsyncSave() {
+ SaveResult result = m_saveWatcher.result();
+ if (!result.success) {
+ m_pendingSave.reset();
+ m_saving = false;
+ m_stagedEntries = m_persistedEntries;
+ m_stagedMaximumEntries = m_maximumEntries;
+ m_stagedMaximumAgeDays = m_maximumAgeDays;
+ fail(result.error);
+ return;
+ }
+ const bool settingsWereChanged = m_maximumEntries != result.request.maximumEntries ||
+ m_maximumAgeDays != result.request.maximumAgeDays;
+ m_persistedEntries = result.request.entries;
+ replaceEntries(result.request.entries);
+ m_maximumEntries = result.request.maximumEntries;
+ m_maximumAgeDays = result.request.maximumAgeDays;
+ if (settingsWereChanged)
+ emit settingsChanged();
+ if (m_pendingSave) {
+ SaveRequest pending = std::move(*m_pendingSave);
+ m_pendingSave.reset();
+ startAsyncSave(std::move(pending));
+ return;
+ }
+ m_saving = false;
+ m_available = true;
+ m_resetRequired = false;
+ m_status = i18n("History is encrypted locally. The key is stored in KDE Wallet.");
+ scheduleExpiry();
+ emit changed();
+}
+
+DictationHistory::LoadResult DictationHistory::loadEntries(const QString &path, QByteArray key,
+ const QDateTime &now, int maximumEntries,
+ int maximumAgeDays,
+ const CommitFunction &commit) {
+ auto failure = [&key](const QString &error, bool resetRequired = false) {
+ sodium_memzero(key.data(), size_t(key.size()));
+ return LoadResult{{}, false, resetRequired, error};
+ };
+ QFile file(path);
+ if (!file.exists()) {
+ sodium_memzero(key.data(), size_t(key.size()));
+ return {{}, true, false, {}};
+ }
+ if (!file.open(QIODevice::ReadOnly))
+ return failure(i18n("The encrypted history file could not be opened."));
+ if (file.size() > maximumHistoryFileSize)
+ return failure(i18n("The encrypted history file is too large to open safely."), true);
+ const QByteArray stored = file.read(maximumHistoryFileSize + 1);
+ if (stored.size() > maximumHistoryFileSize || !file.atEnd())
+ return failure(i18n("The encrypted history file is too large to open safely."), true);
+ const qsizetype overhead = magic.size() + crypto_aead_xchacha20poly1305_ietf_NPUBBYTES +
+ crypto_aead_xchacha20poly1305_ietf_ABYTES;
+ if (stored.size() < overhead || !stored.startsWith(magic))
+ return failure(i18n("The encrypted history file is damaged or unsupported."), true);
+ const QByteArray nonce = stored.mid(magic.size(), crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
+ const QByteArray cipher = stored.mid(magic.size() + nonce.size());
+ QByteArray plain(cipher.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES, Qt::Uninitialized);
+ unsigned long long plainLength = 0;
+ if (crypto_aead_xchacha20poly1305_ietf_decrypt(
+ reinterpret_cast(plain.data()), &plainLength, nullptr,
+ reinterpret_cast(cipher.constData()),
+ static_cast(cipher.size()),
+ reinterpret_cast(magic.constData()),
+ static_cast(magic.size()),
+ reinterpret_cast(nonce.constData()),
+ reinterpret_cast(key.constData())) != 0) {
+ sodium_memzero(plain.data(), size_t(plain.size()));
+ return failure(i18n("The encrypted history could not be authenticated or decrypted."), true);
+ }
+ plain.resize(qsizetype(plainLength));
+ QJsonParseError parseError;
+ const QJsonDocument document = QJsonDocument::fromJson(plain, &parseError);
+ sodium_memzero(plain.data(), size_t(plain.size()));
+ const QJsonObject root = document.object();
+ if (parseError.error != QJsonParseError::NoError ||
+ root.value(QStringLiteral("version")).toInt() != 1)
+ return failure(i18n("The decrypted history format is damaged or unsupported."), true);
+ const QJsonArray storedEntries = root.value(QStringLiteral("entries")).toArray();
+ if (storedEntries.size() > 10000)
+ return failure(i18n("The decrypted history contains too many entries."), true);
+ QList loadedEntries;
+ for (const QJsonValue &value : storedEntries) {
+ const QJsonObject object = value.toObject();
+ const QDateTime createdAt = QDateTime::fromString(
+ object.value(QStringLiteral("createdAt")).toString(), Qt::ISODateWithMs);
+ const QString id = object.value(QStringLiteral("id")).toString();
+ const QString text = object.value(QStringLiteral("text")).toString();
+ if (!createdAt.isValid() || id.isEmpty() || text.isEmpty())
+ return failure(i18n("The decrypted history contains an invalid entry."), true);
+ loadedEntries.append({id, createdAt, text});
+ }
+ std::stable_sort(
+ loadedEntries.begin(), loadedEntries.end(),
+ [](const Entry &left, const Entry &right) { return left.createdAt > right.createdAt; });
+ const QDateTime oldest = now.addDays(-maximumAgeDays);
+ const qsizetype before = loadedEntries.size();
+ loadedEntries.removeIf([&oldest](const Entry &entry) { return entry.createdAt < oldest; });
+ while (loadedEntries.size() > maximumEntries)
+ loadedEntries.removeLast();
+ if (before != loadedEntries.size()) {
+ SaveResult saved = persistEntries({loadedEntries, maximumEntries, maximumAgeDays},
+ std::move(key), path, commit);
+ if (!saved.success)
+ return {{}, false, false, saved.error};
+ } else {
+ sodium_memzero(key.data(), size_t(key.size()));
+ }
+ return {std::move(loadedEntries), true, false, {}};
+}
+
+DictationHistory::SaveResult DictationHistory::persistEntries(SaveRequest request, QByteArray key,
+ const QString &path,
+ const CommitFunction &commit) {
+ SaveResult outcome{std::move(request), false, {}};
+ QJsonArray entries;
+ for (const Entry &entry : outcome.request.entries) {
+ entries.append(QJsonObject{
+ {QStringLiteral("id"), entry.id},
+ {QStringLiteral("createdAt"), entry.createdAt.toUTC().toString(Qt::ISODateWithMs)},
+ {QStringLiteral("text"), entry.text}});
+ }
+ QByteArray plain = QJsonDocument(QJsonObject{{QStringLiteral("version"), 1},
+ {QStringLiteral("entries"), entries}})
+ .toJson(QJsonDocument::Compact);
+ QByteArray nonce(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, Qt::Uninitialized);
+ randombytes_buf(nonce.data(), size_t(nonce.size()));
+ QByteArray cipher(plain.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES, Qt::Uninitialized);
+ unsigned long long cipherLength = 0;
+ const int result = crypto_aead_xchacha20poly1305_ietf_encrypt(
+ reinterpret_cast(cipher.data()), &cipherLength,
+ reinterpret_cast(plain.constData()),
+ static_cast(plain.size()),
+ reinterpret_cast(magic.constData()),
+ static_cast(magic.size()), nullptr,
+ reinterpret_cast(nonce.constData()),
+ reinterpret_cast(key.constData()));
+ sodium_memzero(plain.data(), size_t(plain.size()));
+ sodium_memzero(key.data(), size_t(key.size()));
+ if (result != 0) {
+ outcome.error = i18n("The history could not be encrypted.");
+ return outcome;
+ }
+ cipher.resize(qsizetype(cipherLength));
+ if (magic.size() + nonce.size() + cipher.size() > maximumHistoryFileSize) {
+ outcome.error = i18n("The encrypted history is too large to save safely.");
+ return outcome;
+ }
+ QString error;
+ if (!commit(path, magic + nonce + cipher, &error)) {
+ outcome.error = error.isEmpty() ? i18n("The encrypted history could not be saved.") : error;
+ return outcome;
+ }
+ outcome.success = true;
+ return outcome;
+}
+
+bool DictationHistory::commitAtomically(const QString &path, const QByteArray &data,
+ QString *error) {
+ if (!secureStorageDirectory(path, error))
+ return false;
+ QSaveFile file(path);
+ file.setDirectWriteFallback(false);
+ if (!file.open(QIODevice::WriteOnly) ||
+ !file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner) ||
+ file.write(data) != data.size() || !file.commit()) {
+ file.cancelWriting();
+ *error = i18n("The encrypted history could not be written atomically.");
+ return false;
+ }
+ return true;
+}
+
+bool DictationHistory::secureStorageDirectory(const QString &path, QString *error) {
+ const QString directoryPath = QFileInfo(path).absolutePath();
+ if (QDir().mkpath(directoryPath) &&
+ QFile::setPermissions(directoryPath, QFileDevice::ReadOwner | QFileDevice::WriteOwner |
+ QFileDevice::ExeOwner))
+ return true;
+ *error = i18n("The private history directory could not be secured.");
+ return false;
+}
+
+void DictationHistory::fail(const QString &message, bool resetRequired) {
+ m_available = false;
+ m_resetRequired = resetRequired;
+ m_status = message;
+ emit changed();
+}
diff --git a/src/DictationHistory.h b/src/DictationHistory.h
new file mode 100644
index 0000000..81a016f
--- /dev/null
+++ b/src/DictationHistory.h
@@ -0,0 +1,157 @@
+// SPDX-FileCopyrightText: 2026 Sri Rang
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+class HistoryKeyProvider {
+public:
+ using LoadCallback = std::function, const QString &)>;
+ using RemoveCallback = std::function;
+ virtual ~HistoryKeyProvider() = default;
+ virtual void loadOrCreate(QObject *context, LoadCallback callback) = 0;
+ virtual void remove(QObject *context, RemoveCallback callback) = 0;
+};
+
+std::unique_ptr createHistoryKeyProvider();
+
+class DictationHistory final : public QAbstractListModel {
+ Q_OBJECT
+ Q_PROPERTY(bool enabled READ enabled NOTIFY changed)
+ Q_PROPERTY(bool busy READ busy NOTIFY changed)
+ Q_PROPERTY(bool deletionPending READ deletionPending NOTIFY changed)
+ Q_PROPERTY(bool available READ available NOTIFY changed)
+ Q_PROPERTY(bool resetRequired READ resetRequired NOTIFY changed)
+ Q_PROPERTY(QString status READ status NOTIFY changed)
+ Q_PROPERTY(QString storagePath READ storagePath CONSTANT)
+ Q_PROPERTY(QAbstractItemModel *entryModel READ entryModel CONSTANT)
+ Q_PROPERTY(QVariantList recentEntries READ recentEntries NOTIFY changed)
+ Q_PROPERTY(int maximumEntries READ maximumEntries WRITE setMaximumEntries NOTIFY changed)
+ Q_PROPERTY(int maximumAgeDays READ maximumAgeDays WRITE setMaximumAgeDays NOTIFY changed)
+
+public:
+ enum Role { EntryIdRole = Qt::UserRole + 1, CreatedAtRole, CreatedTextRole, TextRole };
+
+ struct Entry {
+ QString id;
+ QDateTime createdAt;
+ QString text;
+ };
+ using Clock = std::function;
+ using CommitFunction = std::function;
+
+ explicit DictationHistory(QObject *parent = nullptr);
+ DictationHistory(QString storagePath, std::unique_ptr keyProvider,
+ Clock clock = {}, CommitFunction commit = {}, QObject *parent = nullptr,
+ bool asynchronousPersistence = false);
+ ~DictationHistory() override;
+
+ bool enabled() const { return m_enabled; }
+ bool busy() const { return m_busy || m_saving; }
+ bool deletionPending() const;
+ bool available() const { return m_available; }
+ bool resetRequired() const { return m_resetRequired; }
+ QString status() const { return m_status; }
+ QString storagePath() const { return m_storagePath; }
+ QVariantList entries() const;
+ QAbstractItemModel *entryModel() { return this; }
+ QVariantList recentEntries() const;
+ int maximumEntries() const { return m_maximumEntries; }
+ int maximumAgeDays() const { return m_maximumAgeDays; }
+
+ int rowCount(const QModelIndex &parent = {}) const override;
+ QVariant data(const QModelIndex &index, int role) const override;
+ QHash roleNames() const override;
+
+ void enable();
+ void resumePendingDeletion();
+ void disable(bool deleteData);
+ bool add(const QString &text);
+ Q_INVOKABLE bool removeEntry(const QString &id);
+ Q_INVOKABLE bool clear();
+ void setMaximumEntries(int value);
+ void setMaximumAgeDays(int value);
+
+signals:
+ void changed();
+ void settingsChanged();
+
+private:
+ struct SaveRequest {
+ QList entries;
+ int maximumEntries = 100;
+ int maximumAgeDays = 30;
+ };
+ struct SaveResult {
+ SaveRequest request;
+ bool success = false;
+ QString error;
+ };
+ struct LoadResult {
+ QList entries;
+ bool success = false;
+ bool resetRequired = false;
+ QString error;
+ };
+
+ Q_SLOT void expireEntries();
+ bool load();
+ void startAsyncLoad();
+ void finishAsyncLoad();
+ bool saveEntries(const QList &entries, int maximumEntries, int maximumAgeDays);
+ bool saveEntries(const QList &entries) {
+ return saveEntries(entries, m_stagedMaximumEntries, m_stagedMaximumAgeDays);
+ }
+ void startAsyncSave(SaveRequest request);
+ void finishAsyncSave();
+ bool pruneEntries(QList &entries, int maximumEntries, int maximumAgeDays) const;
+ void scheduleExpiry();
+ QString deletionMarkerPath() const;
+ void removePendingKey();
+ void replaceEntries(QList entries);
+ void fail(const QString &message, bool resetRequired = false);
+ static bool commitAtomically(const QString &path, const QByteArray &data, QString *error);
+ static bool secureStorageDirectory(const QString &path, QString *error);
+ QVariantMap entryMap(const Entry &entry) const;
+
+ static SaveResult persistEntries(SaveRequest request, QByteArray key, const QString &path,
+ const CommitFunction &commit);
+ static LoadResult loadEntries(const QString &path, QByteArray key, const QDateTime &now,
+ int maximumEntries, int maximumAgeDays,
+ const CommitFunction &commit);
+
+ QString m_storagePath;
+ std::unique_ptr m_keyProvider;
+ Clock m_clock;
+ CommitFunction m_commit;
+ QTimer m_expiryTimer;
+ QByteArray m_key;
+ QList m_entries;
+ QList m_persistedEntries;
+ QList m_stagedEntries;
+ std::optional m_pendingSave;
+ QFutureWatcher m_saveWatcher;
+ QFutureWatcher m_loadWatcher;
+ bool m_enabled = false;
+ bool m_busy = false;
+ bool m_available = true;
+ bool m_resetRequired = false;
+ bool m_cryptoAvailable = true;
+ bool m_asynchronousPersistence = false;
+ bool m_saving = false;
+ QString m_status;
+ int m_maximumEntries = 100;
+ int m_maximumAgeDays = 30;
+ int m_stagedMaximumEntries = 100;
+ int m_stagedMaximumAgeDays = 30;
+};
diff --git a/src/PlatformIntegration.cpp b/src/PlatformIntegration.cpp
index 8d54702..e64e111 100644
--- a/src/PlatformIntegration.cpp
+++ b/src/PlatformIntegration.cpp
@@ -7,7 +7,15 @@
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
#include
+#include
namespace {
class KdeDesktopIntegration final : public DesktopIntegration {
@@ -56,6 +64,47 @@ class KdeDesktopIntegration final : public DesktopIntegration {
m_statusNotification.clear();
}
+ void revealFile(const QString &path, OpenCallback callback) override {
+ const QFileInfo file(path);
+ const QUrl parentUrl = QUrl::fromLocalFile(file.absolutePath());
+ if (!file.exists()) {
+ const bool opened = QDesktopServices::openUrl(parentUrl);
+ if (callback)
+ callback(opened);
+ return;
+ }
+ const QUrl url = QUrl::fromLocalFile(path);
+ QDBusInterface fileManager(QStringLiteral("org.freedesktop.FileManager1"),
+ QStringLiteral("/org/freedesktop/FileManager1"),
+ QStringLiteral("org.freedesktop.FileManager1"),
+ QDBusConnection::sessionBus());
+ if (fileManager.isValid()) {
+ auto *watcher =
+ new QDBusPendingCallWatcher(fileManager.asyncCall(QStringLiteral("ShowItems"),
+ QStringList{url.toString()}, QString()),
+ QGuiApplication::instance());
+ QObject::connect(watcher, &QDBusPendingCallWatcher::finished, watcher,
+ [watcher, parentUrl, callback = std::move(callback)] {
+ const QDBusPendingReply<> reply = *watcher;
+ const bool opened =
+ !reply.isError() || QDesktopServices::openUrl(parentUrl);
+ if (callback)
+ callback(opened);
+ watcher->deleteLater();
+ });
+ return;
+ }
+ const bool opened = QDesktopServices::openUrl(parentUrl);
+ if (callback)
+ callback(opened);
+ }
+
+ void openDirectory(const QString &path, OpenCallback callback) override {
+ const bool opened = QDesktopServices::openUrl(QUrl::fromLocalFile(path));
+ if (callback)
+ callback(opened);
+ }
+
private:
QPointer m_statusNotification;
};
diff --git a/src/PlatformIntegration.h b/src/PlatformIntegration.h
index 4fe486a..00ff93f 100644
--- a/src/PlatformIntegration.h
+++ b/src/PlatformIntegration.h
@@ -14,6 +14,7 @@ class KNotification;
class DesktopIntegration {
public:
enum class NotificationKind { Information, Error };
+ using OpenCallback = std::function;
virtual ~DesktopIntegration() = default;
virtual void configureShortcut(QAction *action, const QList &shortcuts) = 0;
@@ -26,6 +27,8 @@ class DesktopIntegration {
virtual void showNotification(NotificationKind kind, const QString &title, const QString &text,
const QString &iconName = {}, bool persistent = false) = 0;
virtual void closeStatusNotification() = 0;
+ virtual void revealFile(const QString &path, OpenCallback callback = {}) = 0;
+ virtual void openDirectory(const QString &path, OpenCallback callback = {}) = 0;
};
std::unique_ptr createDesktopIntegration();
diff --git a/src/qml/FooterContainer.qml b/src/qml/FooterContainer.qml
new file mode 100644
index 0000000..e1fb5dd
--- /dev/null
+++ b/src/qml/FooterContainer.qml
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2026 Sri Rang
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import QtQuick.Controls as Controls
+import org.kde.kirigami as Kirigami
+
+Controls.Pane {
+ id: root
+
+ readonly property real contentMargin: Kirigami.Units.largeSpacing
+
+ padding: contentMargin
+ background: null
+}
diff --git a/src/qml/HistoryEntryCard.qml b/src/qml/HistoryEntryCard.qml
new file mode 100644
index 0000000..dec8cad
--- /dev/null
+++ b/src/qml/HistoryEntryCard.qml
@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: 2026 Sri Rang
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import QtQuick
+import QtQuick.Controls as Controls
+import QtQuick.Layouts
+
+Controls.Frame {
+ id: root
+
+ property string entryIdentifier: ""
+ property string timestampText: ""
+ property string transcriptText: ""
+ property string timestampDescription: ""
+ property string copyLabel: ""
+ property string deleteLabel: ""
+ property string deleteDescription: ""
+ property bool showDelete: false
+ property bool deleteEnabled: true
+ signal copyRequested(string text)
+ signal deleteRequested(string entryId)
+
+ implicitHeight: contentLayout.implicitHeight + topPadding + bottomPadding
+
+ ColumnLayout {
+ id: contentLayout
+ anchors.fill: parent
+
+ Controls.Label {
+ Layout.fillWidth: true
+ text: root.timestampText
+ opacity: 0.7
+ }
+
+ Controls.Label {
+ objectName: "historyEntryText"
+ Layout.fillWidth: true
+ text: root.transcriptText
+ wrapMode: Text.Wrap
+ maximumLineCount: 4
+ elide: Text.ElideRight
+ Accessible.name: root.transcriptText
+ Accessible.description: root.timestampDescription
+ }
+
+ RowLayout {
+ Layout.alignment: Qt.AlignRight
+
+ Controls.ToolButton {
+ text: root.copyLabel
+ icon.name: "edit-copy"
+ display: Controls.AbstractButton.TextBesideIcon
+ onClicked: root.copyRequested(root.transcriptText)
+ }
+
+ Controls.ToolButton {
+ objectName: "historyEntryDeleteButton"
+ visible: root.showDelete
+ enabled: root.deleteEnabled
+ text: root.deleteLabel
+ icon.name: "edit-delete"
+ display: Controls.AbstractButton.TextBesideIcon
+ onClicked: root.deleteRequested(root.entryIdentifier)
+ Accessible.description: root.deleteDescription
+ }
+ }
+ }
+}
diff --git a/src/qml/Main.qml b/src/qml/Main.qml
index 1633873..3b3343c 100644
--- a/src/qml/Main.qml
+++ b/src/qml/Main.qml
@@ -14,7 +14,7 @@ Kirigami.ApplicationWindow {
id: root
objectName: "mainWindow"
width: 760
- height: 520
+ height: 700
minimumWidth: 380
minimumHeight: 400
visible: false
@@ -25,19 +25,19 @@ Kirigami.ApplicationWindow {
}
function openModelManager() {
- root.currentView = 1
+ root.currentView = 2
}
function openSettings() {
- root.currentView = 3
+ root.currentView = 4
}
function openAudioInput() {
- root.currentView = 2
+ root.currentView = 3
}
function updateAudioInputMonitoring() {
- appController.setAudioInputMonitoringEnabled(root.visible && root.currentView === 2)
+ appController.setAudioInputMonitoringEnabled(root.visible && root.currentView === 3)
}
function copyModelUrl(url) {
@@ -47,6 +47,7 @@ Kirigami.ApplicationWindow {
}
property string pendingRemovalId: ""
+ property string pendingHistoryId: ""
property int currentView: 0
property bool shortcutChangeFailed: false
property var passiveNotificationHandler: function(message) {
@@ -56,6 +57,8 @@ Kirigami.ApplicationWindow {
onVisibleChanged: updateAudioInputMonitoring()
onCurrentViewChanged: updateAudioInputMonitoring()
+ pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.None
+
Connections {
target: appController
function onModelSetupRequested() {
@@ -85,14 +88,19 @@ Kirigami.ApplicationWindow {
onAccepted: root.selectModel(selectedFile)
}
- MessageDialog {
+ Kirigami.PromptDialog {
id: removeDialog
title: i18n("Remove speech model?")
- text: i18n("The model file will be deleted from this user account.")
- buttons: MessageDialog.Ok | MessageDialog.Cancel
- onAccepted: {
- appController.removeModel(root.pendingRemovalId)
- root.pendingRemovalId = ""
+ subtitle: i18n("The model file will be deleted from this user account.")
+ standardButtons: Kirigami.Dialog.Cancel
+ customFooterActions: Kirigami.Action {
+ text: i18n("Remove")
+ icon.name: "edit-delete"
+ onTriggered: {
+ appController.removeModel(root.pendingRemovalId)
+ root.pendingRemovalId = ""
+ removeDialog.close()
+ }
}
onRejected: root.pendingRemovalId = ""
}
@@ -122,46 +130,363 @@ Kirigami.ApplicationWindow {
}
}
+ Kirigami.PromptDialog {
+ id: disableHistoryDialog
+ objectName: "disableHistoryDialog"
+ title: i18n("Disable dictation history?")
+ subtitle: i18n("You can keep the encrypted history for later, or delete it together with its KDE Wallet key.")
+ standardButtons: Kirigami.Dialog.Cancel
+ customFooterActions: [
+ Kirigami.Action {
+ objectName: "disableKeepHistoryAction"
+ text: i18n("Disable and keep")
+ icon.name: "document-save"
+ onTriggered: {
+ appController.disableHistory(false)
+ disableHistoryDialog.close()
+ }
+ },
+ Kirigami.Action {
+ objectName: "disableDeleteHistoryAction"
+ text: i18n("Disable and delete")
+ icon.name: "edit-delete"
+ onTriggered: {
+ appController.disableHistory(true)
+ disableHistoryDialog.close()
+ }
+ }
+ ]
+ }
+
+ Kirigami.PromptDialog {
+ id: clearHistoryDialog
+ title: i18n("Clear all dictation history?")
+ subtitle: i18n("All entries in Kastword's encrypted history will be deleted. This cannot remove copies retained by other applications or backups.")
+ standardButtons: Kirigami.Dialog.Cancel
+ customFooterActions: Kirigami.Action {
+ text: i18n("Clear all")
+ icon.name: "edit-clear-history"
+ onTriggered: {
+ appController.history.clear()
+ clearHistoryDialog.close()
+ }
+ }
+ }
+
+ Kirigami.PromptDialog {
+ id: resetHistoryDialog
+ title: i18n("Reset encrypted history?")
+ subtitle: i18n("The unreadable encrypted file and its KDE Wallet key will be deleted. Existing entries cannot be recovered afterward.")
+ standardButtons: Kirigami.Dialog.Cancel
+ customFooterActions: Kirigami.Action {
+ text: i18n("Reset and delete")
+ icon.name: "edit-delete"
+ onTriggered: {
+ appController.disableHistory(true)
+ resetHistoryDialog.close()
+ }
+ }
+ }
+
+ Kirigami.PromptDialog {
+ id: deleteHistoryEntryDialog
+ title: i18n("Delete this history entry?")
+ subtitle: i18n("The selected entry will be removed from Kastword's encrypted history.")
+ standardButtons: Kirigami.Dialog.Cancel
+ customFooterActions: Kirigami.Action {
+ text: i18n("Delete")
+ icon.name: "edit-delete"
+ onTriggered: {
+ appController.history.removeEntry(root.pendingHistoryId)
+ root.pendingHistoryId = ""
+ deleteHistoryEntryDialog.close()
+ }
+ }
+ onRejected: root.pendingHistoryId = ""
+ }
+
Component {
- id: modelManagerPage
+ id: historyPage
- Kirigami.ScrollablePage {
- objectName: "modelManagerPage"
- title: i18n("Speech models")
+ Kirigami.Page {
+ objectName: "historyPage"
+ title: i18n("History")
+
+ footer: FooterContainer {
+ id: historyFooter
+ objectName: "historyFooter"
+ visible: appController.history.status.length > 0
+
+ contentItem: ColumnLayout {
+ width: historyFooter.availableWidth
+ Kirigami.InlineMessage {
+ objectName: "historyStatus"
+ Layout.fillWidth: true
+ visible: true
+ type: appController.history.available ? Kirigami.MessageType.Information
+ : Kirigami.MessageType.Error
+ text: appController.history.status
+ actions: Kirigami.Action {
+ objectName: "resetHistoryAction"
+ visible: appController.history.resetRequired
+ text: i18n("Reset encrypted history")
+ icon.name: "edit-clear-history"
+ onTriggered: resetHistoryDialog.open()
+ }
+ }
+ }
+ }
ColumnLayout {
- width: parent.width
- spacing: Kirigami.Units.largeSpacing
+ anchors.fill: parent
- Kirigami.InlineMessage {
- objectName: "modelSetupWarning"
+ RowLayout {
Layout.fillWidth: true
- visible: !appController.modelReady
- && !appController.modelManager.verificationPending
- type: Kirigami.MessageType.Warning
- text: i18n("Choose and download a model before using dictation.")
+
+ Controls.Switch {
+ objectName: "historyEnabledSwitch"
+ text: i18n("Enable history")
+ checked: appController.history.enabled
+ checkable: false
+ enabled: !appController.history.busy
+ onClicked: {
+ if (appController.history.enabled)
+ disableHistoryDialog.open()
+ else
+ appController.enableHistory()
+ }
+ Accessible.description: i18n("Store transcription text locally using authenticated encryption and a key protected by KDE Wallet")
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Controls.Button {
+ objectName: "clearHistoryButton"
+ visible: appController.history.enabled && historyList.count > 0
+ enabled: !appController.history.busy
+ text: i18n("Clear all history")
+ icon.name: "edit-clear-history"
+ onClicked: clearHistoryDialog.open()
+ }
}
- Controls.Label {
+ ListView {
+ id: historyList
+ objectName: "historyList"
Layout.fillWidth: true
- wrapMode: Text.Wrap
- text: i18n("Models are downloaded only when you request them. Transcription remains offline after the download.")
+ Layout.fillHeight: true
+ model: appController.history.enabled ? appController.history.entryModel : null
+ spacing: Kirigami.Units.smallSpacing
+ clip: true
+ reuseItems: true
+
+ header: ColumnLayout {
+ width: historyList.width
+ spacing: Kirigami.Units.largeSpacing
+
+ Kirigami.FormLayout {
+ Layout.fillWidth: true
+ enabled: appController.history.enabled && !appController.history.busy
+
+ Controls.SpinBox {
+ objectName: "historyMaximumEntries"
+ Kirigami.FormData.label: i18n("Maximum entries:")
+ from: 1
+ to: 10000
+ value: appController.history.maximumEntries
+ onValueModified: appController.history.maximumEntries = value
+ Accessible.name: i18n("Maximum history entries")
+ }
+
+ Controls.SpinBox {
+ objectName: "historyMaximumAgeDays"
+ Kirigami.FormData.label: i18n("Maximum age (days):")
+ from: 1
+ to: 3650
+ value: appController.history.maximumAgeDays
+ textFromValue: function(value, locale) {
+ return Number(value).toLocaleString(locale, "f", 0)
+ }
+ valueFromText: function(text, locale) {
+ return Number.fromLocaleString(locale, text)
+ }
+ onValueModified: appController.history.maximumAgeDays = value
+ Accessible.name: i18n("Maximum history age in days")
+ }
+
+ RowLayout {
+ Kirigami.FormData.label: i18n("History storage:")
+ Layout.fillWidth: true
+
+ Controls.TextField {
+ objectName: "historyStoragePath"
+ Layout.fillWidth: true
+ readOnly: true
+ selectByMouse: true
+ text: appController.history.storagePath
+ Accessible.name: i18n("Encrypted history storage path")
+ }
+
+ Controls.ToolButton {
+ objectName: "openHistoryStorageButton"
+ icon.name: "document-open-folder"
+ onClicked: appController.revealHistoryStorage()
+ Accessible.name: i18n("Show history file in file manager")
+ Controls.ToolTip.visible: hovered
+ Controls.ToolTip.text: Accessible.name
+ }
+ }
+ }
+
+ Controls.Label {
+ Layout.fillWidth: true
+ visible: appController.history.enabled && historyList.count === 0
+ horizontalAlignment: Text.AlignHCenter
+ text: i18n("No saved dictations yet")
+ opacity: 0.7
+ }
}
- Controls.ComboBox {
- id: modelLanguageFilter
- objectName: "modelLanguageFilter"
- Layout.fillWidth: true
- textRole: "text"
- valueRole: "code"
- model: [
- { text: i18n("Recommended"), code: "recommended" },
- { text: i18n("English-only models"), code: "english" },
- { text: i18n("Multilingual models"), code: "multilingual" },
- { text: i18n("All models"), code: "all" }
- ]
- Accessible.name: i18n("Filter speech models")
+ delegate: HistoryEntryCard {
+ required property string entryId
+ required property string createdText
+ required property string text
+ width: historyList.width
+ entryIdentifier: entryId
+ timestampText: createdText
+ transcriptText: text
+ timestampDescription: i18n("Dictation from %1", createdText)
+ copyLabel: i18n("Copy")
+ deleteLabel: i18n("Delete")
+ deleteDescription: i18n("Delete the dictation from encrypted history")
+ showDelete: true
+ deleteEnabled: !appController.history.busy
+ onCopyRequested: function(entryText) {
+ appController.copyText(entryText)
+ }
+ onDeleteRequested: function(id) {
+ root.pendingHistoryId = id
+ deleteHistoryEntryDialog.open()
+ }
}
+ }
+ }
+ }
+ }
+
+ Component {
+ id: modelManagerPage
+
+ Kirigami.ScrollablePage {
+ id: modelsPage
+ objectName: "modelManagerPage"
+ title: i18n("Models")
+ property string selectedFilter: "recommended"
+
+ header: Controls.Pane {
+ padding: Kirigami.Units.largeSpacing
+
+ contentItem: ColumnLayout {
+ spacing: Kirigami.Units.smallSpacing
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 0
+
+ Controls.TabButton {
+ objectName: "modelFilterRecommended"
+ Layout.fillWidth: true
+ text: i18n("Recommended")
+ checked: modelsPage.selectedFilter === "recommended"
+ checkable: false
+ onClicked: modelsPage.selectedFilter = "recommended"
+ }
+ Controls.TabButton {
+ objectName: "modelFilterEnglish"
+ Layout.fillWidth: true
+ text: i18n("English")
+ checked: modelsPage.selectedFilter === "english"
+ checkable: false
+ onClicked: modelsPage.selectedFilter = "english"
+ }
+ Controls.TabButton {
+ objectName: "modelFilterMultilingual"
+ Layout.fillWidth: true
+ text: i18n("Multilingual")
+ checked: modelsPage.selectedFilter === "multilingual"
+ checkable: false
+ onClicked: modelsPage.selectedFilter = "multilingual"
+ }
+ Controls.TabButton {
+ objectName: "modelFilterAll"
+ Layout.fillWidth: true
+ text: i18n("All")
+ checked: modelsPage.selectedFilter === "all"
+ checkable: false
+ onClicked: modelsPage.selectedFilter = "all"
+ }
+ }
+
+ Kirigami.FormLayout {
+ Layout.fillWidth: true
+
+ RowLayout {
+ Kirigami.FormData.label: i18n("Model storage:")
+ Layout.fillWidth: true
+
+ Controls.TextField {
+ objectName: "modelStoragePath"
+ Layout.fillWidth: true
+ readOnly: true
+ selectByMouse: true
+ text: appController.modelManager.storagePath
+ Accessible.name: i18n("Model storage path")
+ }
+
+ Controls.ToolButton {
+ objectName: "openModelStorageButton"
+ icon.name: "document-open-folder"
+ onClicked: appController.openModelStorage()
+ Accessible.name: i18n("Open model storage folder")
+ Controls.ToolTip.visible: hovered
+ Controls.ToolTip.text: Accessible.name
+ }
+ }
+ }
+ }
+ }
+
+ footer: FooterContainer {
+ id: modelsFooter
+ objectName: "modelsFooter"
+
+ contentItem: ColumnLayout {
+ width: modelsFooter.availableWidth
+ Kirigami.InlineMessage {
+ objectName: "modelSetupWarning"
+ Layout.fillWidth: true
+ visible: true
+ type: appController.modelManager.error.length > 0
+ ? Kirigami.MessageType.Error
+ : !appController.modelReady && !appController.modelManager.verificationPending
+ ? Kirigami.MessageType.Warning
+ : Kirigami.MessageType.Information
+ text: appController.modelManager.error.length > 0
+ ? appController.modelManager.error
+ : !appController.modelReady && !appController.modelManager.verificationPending
+ ? i18n("Choose and download a model before using dictation.")
+ : appController.modelManager.status.length > 0
+ ? appController.modelManager.status
+ : i18n("Only open Whisper models from sources you trust. Models are parsed inside Kastword.")
+ }
+ }
+ }
+
+ ColumnLayout {
+ width: parent.width
+ spacing: Kirigami.Units.largeSpacing
Repeater {
model: appController.modelManager.models
@@ -171,10 +496,10 @@ Kirigami.ApplicationWindow {
required property var modelData
objectName: "modelCard-" + modelData.id
Layout.fillWidth: true
- visible: modelLanguageFilter.currentValue === "all"
- || (modelLanguageFilter.currentValue === "recommended" && modelData.recommended)
- || (modelLanguageFilter.currentValue === "english" && modelData.englishOnly)
- || (modelLanguageFilter.currentValue === "multilingual" && !modelData.englishOnly)
+ visible: modelsPage.selectedFilter === "all"
+ || (modelsPage.selectedFilter === "recommended" && modelData.recommended)
+ || (modelsPage.selectedFilter === "english" && modelData.englishOnly)
+ || (modelsPage.selectedFilter === "multilingual" && !modelData.englishOnly)
implicitHeight: visible
? modelDetails.implicitHeight + padding * 2 + Kirigami.Units.smallSpacing
: 0
@@ -367,28 +692,6 @@ Kirigami.ApplicationWindow {
}
}
- Kirigami.InlineMessage {
- Layout.fillWidth: true
- visible: appController.modelManager.error.length > 0
- type: Kirigami.MessageType.Error
- text: appController.modelManager.error
- }
-
- Controls.Label {
- Layout.fillWidth: true
- visible: appController.modelManager.status.length > 0
- wrapMode: Text.Wrap
- text: appController.modelManager.status
- }
-
- Kirigami.InlineMessage {
- objectName: "externalModelWarning"
- Layout.fillWidth: true
- visible: true
- type: Kirigami.MessageType.Information
- text: i18n("Only open Whisper models from sources you trust. Models are parsed inside Kastword.")
- }
-
Controls.Button {
text: i18n("Use an existing model…")
enabled: !appController.modelManager.busy
@@ -396,12 +699,6 @@ Kirigami.ApplicationWindow {
Accessible.description: i18n("Select an existing compatible Whisper model file")
}
- Controls.Label {
- Layout.fillWidth: true
- wrapMode: Text.WrapAnywhere
- opacity: 0.7
- text: i18n("Model storage: %1", appController.modelManager.storagePath)
- }
}
}
}
@@ -411,7 +708,44 @@ Kirigami.ApplicationWindow {
Kirigami.ScrollablePage {
objectName: "audioInputPage"
- title: i18n("Audio input")
+ title: i18n("Audio")
+
+ footer: FooterContainer {
+ id: audioFooter
+ objectName: "audioFooter"
+
+ contentItem: ColumnLayout {
+ width: audioFooter.availableWidth
+ spacing: Kirigami.Units.smallSpacing
+
+ Kirigami.InlineMessage {
+ objectName: "audioInputStatus"
+ Layout.fillWidth: true
+ visible: true
+ type: appController.audioInputReady ? Kirigami.MessageType.Positive
+ : Kirigami.MessageType.Warning
+ text: appController.audioInputStatus
+ Accessible.name: i18n("Audio input status")
+ Accessible.description: text
+ }
+
+ Kirigami.InlineMessage {
+ objectName: "audioInputMonitoringError"
+ Layout.fillWidth: true
+ visible: appController.audioInputMonitoringError.length > 0
+ type: Kirigami.MessageType.Error
+ text: i18n("Input level monitoring failed: %1",
+ appController.audioInputMonitoringError)
+ Accessible.name: i18n("Input level monitoring failed")
+ Accessible.description: text
+ actions: Kirigami.Action {
+ text: i18n("Retry")
+ icon.name: "view-refresh"
+ onTriggered: appController.retryAudioInputMonitoring()
+ }
+ }
+ }
+ }
ColumnLayout {
width: Math.min(parent.width, Kirigami.Units.gridUnit * 44)
@@ -456,35 +790,6 @@ Kirigami.ApplicationWindow {
}
}
- Kirigami.InlineMessage {
- objectName: "audioInputStatus"
- Layout.fillWidth: true
- visible: true
- type: appController.audioInputReady ? Kirigami.MessageType.Positive
- : Kirigami.MessageType.Warning
- text: appController.audioInputStatus
- Accessible.name: i18n("Audio input status")
- Accessible.description: text
- }
-
- Kirigami.InlineMessage {
- objectName: "audioInputMonitoringError"
- Layout.fillWidth: true
- visible: appController.audioInputMonitoringError.length > 0
- type: Kirigami.MessageType.Error
- text: i18n("Input level monitoring failed: %1",
- appController.audioInputMonitoringError)
- Accessible.name: i18n("Input level monitoring failed")
- Accessible.description: text
- actions: [
- Kirigami.Action {
- text: i18n("Retry")
- icon.name: "view-refresh"
- onTriggered: appController.retryAudioInputMonitoring()
- }
- ]
- }
-
Controls.Label {
text: i18n("Input level:")
}
@@ -521,6 +826,33 @@ Kirigami.ApplicationWindow {
objectName: "settingsPage"
title: i18n("Settings")
+ footer: FooterContainer {
+ id: settingsFooter
+ objectName: "settingsFooter"
+ visible: autoPasteCheckBox.checked || root.shortcutChangeFailed
+
+ contentItem: ColumnLayout {
+ width: settingsFooter.availableWidth
+ spacing: Kirigami.Units.smallSpacing
+
+ Kirigami.InlineMessage {
+ objectName: "autoPasteWarning"
+ Layout.fillWidth: true
+ visible: autoPasteCheckBox.checked
+ type: Kirigami.MessageType.Warning
+ text: i18n("Automatic paste sends each selected shortcut to the focused application after a short delay. On Wayland, Kastword cannot verify that focus stayed unchanged.")
+ }
+
+ Kirigami.InlineMessage {
+ objectName: "shortcutError"
+ Layout.fillWidth: true
+ visible: root.shortcutChangeFailed
+ type: Kirigami.MessageType.Error
+ text: i18n("The global shortcut could not be changed. Choose another shortcut.")
+ }
+ }
+ }
+
Kirigami.FormLayout {
width: parent.width
@@ -581,14 +913,6 @@ Kirigami.ApplicationWindow {
onToggled: appController.pasteShiftInsert = checked
}
- Kirigami.InlineMessage {
- Kirigami.FormData.isSection: true
- Layout.fillWidth: true
- visible: autoPasteCheckBox.checked
- type: Kirigami.MessageType.Warning
- text: i18n("Automatic paste sends each selected shortcut to the focused application after a short delay. On Wayland, Kastword cannot verify that focus stayed unchanged.")
- }
-
KQuickControls.KeySequenceItem {
id: shortcutEditor
objectName: "shortcutEditor"
@@ -608,14 +932,6 @@ Kirigami.ApplicationWindow {
}
}
- Kirigami.InlineMessage {
- objectName: "shortcutError"
- Kirigami.FormData.isSection: true
- Layout.fillWidth: true
- visible: root.shortcutChangeFailed
- type: Kirigami.MessageType.Error
- text: i18n("The global shortcut could not be changed. Choose another shortcut.")
- }
}
}
}
@@ -624,7 +940,26 @@ Kirigami.ApplicationWindow {
id: dictationPage
Kirigami.Page {
- title: i18n("Offline dictation")
+ title: i18n("Dictation")
+
+ footer: FooterContainer {
+ id: dictationFooter
+ objectName: "dictationFooter"
+
+ contentItem: ColumnLayout {
+ width: dictationFooter.availableWidth
+ Kirigami.InlineMessage {
+ objectName: "statusMessage"
+ Layout.fillWidth: true
+ visible: true
+ text: appController.status
+ type: appController.recording ? Kirigami.MessageType.Positive
+ : Kirigami.MessageType.Information
+ Accessible.name: i18n("Dictation status")
+ Accessible.description: appController.status
+ }
+ }
+ }
ColumnLayout {
id: mainContent
@@ -633,17 +968,6 @@ Kirigami.ApplicationWindow {
anchors.right: parent.right
spacing: Kirigami.Units.largeSpacing
- Kirigami.InlineMessage {
- objectName: "statusMessage"
- Layout.fillWidth: true
- visible: true
- text: appController.status
- type: appController.recording ? Kirigami.MessageType.Positive
- : Kirigami.MessageType.Information
- Accessible.name: i18n("Dictation status")
- Accessible.description: appController.status
- }
-
Controls.Button {
objectName: "dictationButton"
Layout.alignment: Qt.AlignHCenter
@@ -764,6 +1088,45 @@ Kirigami.ApplicationWindow {
}
}
}
+
+ ColumnLayout {
+ id: recentHistoryPanel
+ objectName: "recentHistoryPanel"
+ Layout.fillWidth: true
+ visible: appController.history.enabled
+ && appController.history.recentEntries.length > 0
+ spacing: Kirigami.Units.smallSpacing
+
+ RowLayout {
+ Layout.fillWidth: true
+ Controls.Label {
+ Layout.fillWidth: true
+ font.bold: true
+ text: i18n("Recent history")
+ }
+ Controls.ToolButton {
+ text: i18n("View all")
+ onClicked: root.currentView = 1
+ Accessible.name: i18n("View all dictation history")
+ }
+ }
+
+ Repeater {
+ model: appController.history.recentEntries
+ HistoryEntryCard {
+ required property var modelData
+ Layout.fillWidth: true
+ entryIdentifier: modelData.id || ""
+ timestampText: modelData.createdText
+ transcriptText: modelData.text
+ timestampDescription: i18n("Dictation from %1", modelData.createdText)
+ copyLabel: i18n("Copy")
+ onCopyRequested: function(entryText) {
+ appController.copyText(entryText)
+ }
+ }
+ }
+ }
}
}
}
@@ -771,10 +1134,6 @@ Kirigami.ApplicationWindow {
pageStack.initialPage: Kirigami.Page {
id: mainPage
padding: 0
- title: root.currentView === 0 ? i18n("Offline dictation")
- : root.currentView === 1 ? i18n("Speech models")
- : root.currentView === 2 ? i18n("Audio input")
- : i18n("Settings")
RowLayout {
anchors.fill: parent
@@ -788,7 +1147,7 @@ Kirigami.ApplicationWindow {
Layout.fillHeight: true
Layout.preferredWidth: compact ? Kirigami.Units.gridUnit * 3
- : Kirigami.Units.gridUnit * 11
+ : Kirigami.Units.gridUnit * 8
padding: Kirigami.Units.smallSpacing
ColumnLayout {
@@ -802,69 +1161,96 @@ Kirigami.ApplicationWindow {
Controls.TabButton {
id: dictationTab
objectName: "dictationTab"
+ readonly property string label: i18n("Dictation")
Layout.fillWidth: true
- text: i18n("Offline dictation")
+ text: navigationPane.compact ? "" : label
icon.name: "audio-input-microphone"
+ Accessible.name: label
display: navigationPane.compact ? Controls.AbstractButton.IconOnly
: Controls.AbstractButton.TextBesideIcon
checked: root.currentView === 0
Controls.ButtonGroup.group: navigationGroup
KeyNavigation.up: settingsTab
- KeyNavigation.down: modelsTab
+ KeyNavigation.down: historyTab
onClicked: root.currentView = 0
Controls.ToolTip.visible: hovered && navigationPane.compact
- Controls.ToolTip.text: text
+ Controls.ToolTip.text: label
+ }
+
+ Controls.TabButton {
+ id: historyTab
+ objectName: "historyTab"
+ readonly property string label: i18n("History")
+ Layout.fillWidth: true
+ text: navigationPane.compact ? "" : label
+ icon.name: "view-history"
+ Accessible.name: label
+ display: navigationPane.compact ? Controls.AbstractButton.IconOnly
+ : Controls.AbstractButton.TextBesideIcon
+ checked: root.currentView === 1
+ Controls.ButtonGroup.group: navigationGroup
+ KeyNavigation.up: dictationTab
+ KeyNavigation.down: modelsTab
+ onClicked: root.currentView = 1
+ Controls.ToolTip.visible: hovered && navigationPane.compact
+ Controls.ToolTip.text: label
}
Controls.TabButton {
id: modelsTab
objectName: "modelsTab"
+ readonly property string label: i18n("Models")
Layout.fillWidth: true
- text: i18n("Speech models")
+ text: navigationPane.compact ? "" : label
icon.name: "system-software-install"
+ Accessible.name: label
display: navigationPane.compact ? Controls.AbstractButton.IconOnly
: Controls.AbstractButton.TextBesideIcon
- checked: root.currentView === 1
+ checked: root.currentView === 2
Controls.ButtonGroup.group: navigationGroup
- KeyNavigation.up: dictationTab
+ KeyNavigation.up: historyTab
KeyNavigation.down: audioInputTab
- onClicked: root.currentView = 1
+ onClicked: root.currentView = 2
Controls.ToolTip.visible: hovered && navigationPane.compact
- Controls.ToolTip.text: text
+ Controls.ToolTip.text: label
}
Controls.TabButton {
id: audioInputTab
objectName: "audioInputTab"
+ readonly property string label: i18n("Audio")
Layout.fillWidth: true
- text: i18n("Audio input")
+ text: navigationPane.compact ? "" : label
icon.name: "audio-input-microphone"
+ Accessible.name: label
display: navigationPane.compact ? Controls.AbstractButton.IconOnly
: Controls.AbstractButton.TextBesideIcon
- checked: root.currentView === 2
+ checked: root.currentView === 3
Controls.ButtonGroup.group: navigationGroup
KeyNavigation.up: modelsTab
KeyNavigation.down: settingsTab
- onClicked: root.currentView = 2
+ onClicked: root.currentView = 3
Controls.ToolTip.visible: hovered && navigationPane.compact
- Controls.ToolTip.text: text
+ Controls.ToolTip.text: label
}
Controls.TabButton {
id: settingsTab
objectName: "settingsTab"
+ readonly property string label: i18n("Settings")
Layout.fillWidth: true
- text: i18n("Settings")
+ text: navigationPane.compact ? "" : label
icon.name: "preferences-system"
+ Accessible.name: label
display: navigationPane.compact ? Controls.AbstractButton.IconOnly
: Controls.AbstractButton.TextBesideIcon
- checked: root.currentView === 3
+ checked: root.currentView === 4
Controls.ButtonGroup.group: navigationGroup
KeyNavigation.up: audioInputTab
KeyNavigation.down: dictationTab
- onClicked: root.currentView = 3
+ onClicked: root.currentView = 4
Controls.ToolTip.visible: hovered && navigationPane.compact
- Controls.ToolTip.text: text
+ Controls.ToolTip.text: label
}
Item {
@@ -887,6 +1273,10 @@ Kirigami.ApplicationWindow {
sourceComponent: dictationPage
}
+ Loader {
+ sourceComponent: historyPage
+ }
+
Loader {
sourceComponent: modelManagerPage
}
diff --git a/tests/AppControllerTest.cpp b/tests/AppControllerTest.cpp
index efb2ae4..1e057db 100644
--- a/tests/AppControllerTest.cpp
+++ b/tests/AppControllerTest.cpp
@@ -12,11 +12,13 @@
#include
#include
#include
+#include
#include
#include
#include
#include
#include
+#include
#include
#include
@@ -140,6 +142,24 @@ class FakeTextOutput final : public TextOutput {
bool *destroyed = nullptr;
};
+class ControllerHistoryKeyProvider final : public HistoryKeyProvider {
+public:
+ void loadOrCreate(QObject *, LoadCallback callback) override {
+ callback(QByteArray(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'h'), {});
+ }
+ void remove(QObject *, RemoveCallback callback) override { callback(true, {}); }
+};
+
+class ControllerDeferredHistoryKeyProvider final : public HistoryKeyProvider {
+public:
+ void loadOrCreate(QObject *, LoadCallback callback) override {
+ pendingLoad = std::move(callback);
+ }
+ void remove(QObject *, RemoveCallback callback) override { callback(true, {}); }
+
+ LoadCallback pendingLoad;
+};
+
class FakeDesktopIntegration final : public DesktopIntegration {
public:
void configureShortcut(QAction *action, const QList &shortcuts) override {
@@ -167,6 +187,16 @@ class FakeDesktopIntegration final : public DesktopIntegration {
notifications.append({kind, title, text, iconName, persistent});
}
void closeStatusNotification() override { ++closeCount; }
+ void revealFile(const QString &path, OpenCallback callback) override {
+ revealedFile = path;
+ if (callback)
+ callback(fileOpenAccepted);
+ }
+ void openDirectory(const QString &path, OpenCallback callback) override {
+ openedDirectory = path;
+ if (callback)
+ callback(fileOpenAccepted);
+ }
struct Notification {
NotificationKind kind;
@@ -181,10 +211,13 @@ class FakeDesktopIntegration final : public DesktopIntegration {
QList migratedShortcuts;
bool migratedWithAutoload = true;
bool shortcutChangeAccepted = true;
+ bool fileOpenAccepted = true;
std::function shortcutChangedHandler;
QString cleanedComponent;
QList notifications;
int closeCount = 0;
+ QString revealedFile;
+ QString openedDirectory;
};
class FakeAudioBackend final : public AudioCaptureBackend {
@@ -212,6 +245,9 @@ private slots:
void reportsMicrophoneFailure();
void recoversFromCaptureFailure();
void completesDictationFlow();
+ void savesOnlySuccessfulNonEmptyDictationsToEnabledHistory();
+ void reportsHistorySaveFailureWithoutBlockingDelivery();
+ void reportsHistoryStillOpeningWithoutBlockingDelivery();
void recoversFromTranscriptionFailure();
void ignoresEmptyTranscription();
void recoversFromTranscriptionException();
@@ -366,6 +402,86 @@ void AppControllerTest::completesDictationFlow() {
QTRY_COMPARE_WITH_TIMEOUT(controller.state(), AppController::State::Idle, 2000);
}
+void AppControllerTest::savesOnlySuccessfulNonEmptyDictationsToEnabledHistory() {
+ QTemporaryDir directory;
+ auto history =
+ std::make_unique(directory.filePath(QStringLiteral("history.enc")),
+ std::make_unique());
+ auto *historyPtr = history.get();
+ historyPtr->enable();
+ auto audio = std::make_unique();
+ auto output = std::make_unique();
+ AppController controller(
+ std::move(audio), std::move(output),
+ [](const QByteArray &, const QString &, const QString &) {
+ return qMakePair(QStringLiteral("saved locally"), QString());
+ },
+ false, false, nullptr, {}, {}, std::move(history));
+
+ controller.toggle();
+ controller.toggle();
+ QTRY_COMPARE(historyPtr->entries().size(), 1);
+ QCOMPARE(historyPtr->entries().constFirst().toMap().value(QStringLiteral("text")),
+ QStringLiteral("saved locally"));
+}
+
+void AppControllerTest::reportsHistorySaveFailureWithoutBlockingDelivery() {
+ QTemporaryDir directory;
+ auto history = std::make_unique(
+ directory.filePath(QStringLiteral("history.enc")),
+ std::make_unique(), DictationHistory::Clock{},
+ [](const QString &, const QByteArray &, QString *error) {
+ *error = QStringLiteral("Storage unavailable");
+ return false;
+ });
+ auto *historyPtr = history.get();
+ historyPtr->enable();
+ auto audio = std::make_unique();
+ auto output = std::make_unique();
+ auto *outputPtr = output.get();
+ AppController controller(
+ std::move(audio), std::move(output),
+ [](const QByteArray &, const QString &, const QString &) {
+ return qMakePair(QStringLiteral("delivered but unsaved"), QString());
+ },
+ false, false, nullptr, {}, {}, std::move(history));
+
+ controller.toggle();
+ controller.toggle();
+ QTRY_COMPARE(controller.state(), AppController::State::Success);
+ QCOMPARE(outputPtr->deliveredText, QStringLiteral("delivered but unsaved"));
+ QVERIFY(controller.status().contains(QStringLiteral("History was not saved")));
+ QVERIFY(controller.status().contains(QStringLiteral("Storage unavailable")));
+}
+
+void AppControllerTest::reportsHistoryStillOpeningWithoutBlockingDelivery() {
+ QTemporaryDir directory;
+ auto keys = std::make_unique();
+ auto *keysPtr = keys.get();
+ auto history = std::make_unique(
+ directory.filePath(QStringLiteral("history.enc")), std::move(keys));
+ auto *historyPtr = history.get();
+ historyPtr->enable();
+ QVERIFY(historyPtr->busy());
+ QVERIFY(keysPtr->pendingLoad);
+ auto audio = std::make_unique();
+ auto output = std::make_unique();
+ auto *outputPtr = output.get();
+ AppController controller(
+ std::move(audio), std::move(output),
+ [](const QByteArray &, const QString &, const QString &) {
+ return qMakePair(QStringLiteral("delivered while opening"), QString());
+ },
+ false, false, nullptr, {}, {}, std::move(history));
+
+ controller.toggle();
+ controller.toggle();
+ QTRY_COMPARE(controller.state(), AppController::State::Success);
+ QCOMPARE(outputPtr->deliveredText, QStringLiteral("delivered while opening"));
+ QVERIFY(controller.status().contains(QStringLiteral("History was not saved")));
+ QVERIFY(controller.status().contains(QStringLiteral("Opening KDE Wallet")));
+}
+
void AppControllerTest::recoversFromTranscriptionFailure() {
auto audio = std::make_unique();
auto output = std::make_unique();
@@ -1117,6 +1233,11 @@ void AppControllerTest::configuresDesktopIntegrationAndReportsFailures() {
QList{QKeySequence(QStringLiteral("Meta+Z"))});
QCOMPARE(desktopPtr->cleanedComponent, QStringLiteral("Kastword"));
+ controller.revealHistoryStorage();
+ QCOMPARE(desktopPtr->revealedFile, controller.history()->property("storagePath").toString());
+ controller.openModelStorage();
+ QCOMPARE(desktopPtr->openedDirectory, controller.modelManager()->storagePath());
+
QSignalSpy shortcutChanged(&controller, &AppController::shortcutChanged);
desktopPtr->reportShortcutChange(QKeySequence(QStringLiteral("Meta+Shift+W")));
QCOMPARE(controller.shortcut(), QKeySequence(QStringLiteral("Meta+Shift+W")));
@@ -1172,6 +1293,15 @@ void AppControllerTest::configuresDesktopIntegrationAndReportsFailures() {
QCOMPARE(desktopPtr->notifications.constLast().title, QStringLiteral("Audio input disabled"));
QCOMPARE(desktopPtr->notifications.constLast().text,
QStringLiteral("No audio input is selected. Choose an input to enable dictation."));
+
+ desktopPtr->fileOpenAccepted = false;
+ controller.revealHistoryStorage();
+ QCOMPARE(controller.status(),
+ QStringLiteral("The history storage location could not be opened."));
+ QCOMPARE(desktopPtr->notifications.constLast().kind, DesktopIntegration::NotificationKind::Error);
+ controller.openModelStorage();
+ QCOMPARE(controller.status(), QStringLiteral("The model storage folder could not be opened."));
+ QCOMPARE(desktopPtr->notifications.constLast().text, controller.status());
}
void AppControllerTest::reportsAutomaticPasteFailuresToDesktop_data() {
diff --git a/tests/DictationHistoryTest.cpp b/tests/DictationHistoryTest.cpp
new file mode 100644
index 0000000..ac61d2a
--- /dev/null
+++ b/tests/DictationHistoryTest.cpp
@@ -0,0 +1,778 @@
+// SPDX-FileCopyrightText: 2026 Sri Rang
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "DictationHistory.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+class FakeKeyProvider final : public HistoryKeyProvider {
+public:
+ QByteArray key = QByteArray(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'k');
+ bool failLoad = false;
+ bool failRemove = false;
+ int loads = 0;
+ int removals = 0;
+
+ void loadOrCreate(QObject *, LoadCallback callback) override {
+ ++loads;
+ if (failLoad) {
+ callback(std::nullopt, QStringLiteral("Wallet unavailable"));
+ return;
+ }
+ callback(key, {});
+ }
+ void remove(QObject *, RemoveCallback callback) override {
+ ++removals;
+ if (failRemove) {
+ callback(false, QStringLiteral("Key removal failed"));
+ return;
+ }
+ callback(true, {});
+ }
+};
+
+class DeferredKeyProvider final : public HistoryKeyProvider {
+public:
+ void loadOrCreate(QObject *, LoadCallback callback) override {
+ pendingLoad = std::move(callback);
+ }
+ void remove(QObject *, RemoveCallback callback) override { callback(true, {}); }
+ void resolveLoad() {
+ pendingLoad(QByteArray(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'd'), {});
+ }
+
+ LoadCallback pendingLoad;
+};
+
+class DeferredRemovalKeyProvider final : public HistoryKeyProvider {
+public:
+ void loadOrCreate(QObject *, LoadCallback callback) override {
+ callback(QByteArray(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'r'), {});
+ }
+ void remove(QObject *, RemoveCallback callback) override { pendingRemoval = std::move(callback); }
+
+ RemoveCallback pendingRemoval;
+};
+
+std::unique_ptr provider(QByteArray key = {}) {
+ auto result = std::make_unique();
+ if (!key.isEmpty())
+ result->key = std::move(key);
+ return result;
+}
+
+void writeEncryptedHistory(const QString &path, const QByteArray &key, const QJsonArray &entries) {
+ const QByteArray magic = QByteArrayLiteral("KWHIST01");
+ const QByteArray plain = QJsonDocument(QJsonObject{{QStringLiteral("version"), 1},
+ {QStringLiteral("entries"), entries}})
+ .toJson(QJsonDocument::Compact);
+ QByteArray nonce(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, 'n');
+ QByteArray cipher(plain.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES, Qt::Uninitialized);
+ unsigned long long cipherLength = 0;
+ QCOMPARE(crypto_aead_xchacha20poly1305_ietf_encrypt(
+ reinterpret_cast(cipher.data()), &cipherLength,
+ reinterpret_cast(plain.constData()),
+ static_cast(plain.size()),
+ reinterpret_cast(magic.constData()),
+ static_cast(magic.size()), nullptr,
+ reinterpret_cast(nonce.constData()),
+ reinterpret_cast(key.constData())),
+ 0);
+ cipher.resize(qsizetype(cipherLength));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly));
+ QCOMPARE(file.write(magic + nonce + cipher), magic.size() + nonce.size() + cipher.size());
+}
+
+QStringList decryptHistoryTexts(const QByteArray &stored, const QByteArray &key) {
+ const QByteArray magic = QByteArrayLiteral("KWHIST01");
+ const QByteArray nonce = stored.mid(magic.size(), crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
+ const QByteArray cipher = stored.mid(magic.size() + nonce.size());
+ QByteArray plain(cipher.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES, Qt::Uninitialized);
+ unsigned long long plainLength = 0;
+ if (crypto_aead_xchacha20poly1305_ietf_decrypt(
+ reinterpret_cast(plain.data()), &plainLength, nullptr,
+ reinterpret_cast(cipher.constData()),
+ static_cast(cipher.size()),
+ reinterpret_cast(magic.constData()),
+ static_cast(magic.size()),
+ reinterpret_cast(nonce.constData()),
+ reinterpret_cast(key.constData())) != 0)
+ return {};
+ plain.resize(qsizetype(plainLength));
+ const QJsonArray entries =
+ QJsonDocument::fromJson(plain).object().value(QStringLiteral("entries")).toArray();
+ sodium_memzero(plain.data(), size_t(plain.size()));
+ QStringList texts;
+ for (const QJsonValue &entry : entries)
+ texts.append(entry.toObject().value(QStringLiteral("text")).toString());
+ return texts;
+}
+} // namespace
+
+class DictationHistoryTest final : public QObject {
+ Q_OBJECT
+
+private slots:
+ void initTestCase() { KLocalizedString::setApplicationDomain("kastword"); }
+
+ void remainsAbsentUntilEnabled() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("private/history.enc"));
+ auto keys = provider();
+ auto *keysPtr = keys.get();
+ DictationHistory history(path, std::move(keys));
+
+ QVERIFY(!history.enabled());
+ QVERIFY(history.entries().isEmpty());
+ QVERIFY(!history.add(QStringLiteral("not retained")));
+ QVERIFY(!QFileInfo::exists(path));
+ QCOMPARE(keysPtr->loads, 0);
+ }
+
+ void enablesWithoutBlockingForKeyProvider() {
+ QTemporaryDir directory;
+ auto keys = std::make_unique();
+ auto *keysPtr = keys.get();
+ DictationHistory history(directory.filePath(QStringLiteral("history.enc")), std::move(keys));
+
+ history.enable();
+ QVERIFY(history.busy());
+ QVERIFY(!history.enabled());
+ QVERIFY(keysPtr->pendingLoad);
+ keysPtr->resolveLoad();
+ QVERIFY(!history.busy());
+ QVERIFY(history.enabled());
+ }
+
+ void persistsSnapshotsAsynchronouslyInOrder() {
+ QTemporaryDir directory;
+ QSemaphore saveStarted;
+ QSemaphore allowSave;
+ std::atomic_int saves = 0;
+ std::atomic_bool ranOffThread = false;
+ const Qt::HANDLE testThread = QThread::currentThreadId();
+ DictationHistory history(
+ directory.filePath(QStringLiteral("history.enc")), provider(), {},
+ [&saveStarted, &allowSave, &saves, &ranOffThread,
+ testThread](const QString &, const QByteArray &, QString *error) {
+ ++saves;
+ ranOffThread = QThread::currentThreadId() != testThread;
+ saveStarted.release();
+ if (!allowSave.tryAcquire(1, 2000)) {
+ *error = QStringLiteral("Timed out waiting for test release");
+ return false;
+ }
+ return true;
+ },
+ nullptr, true);
+ history.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(history.enabled(), 1000);
+
+ QVERIFY(history.add(QStringLiteral("first")));
+ QVERIFY(saveStarted.tryAcquire(1, 1000));
+ QVERIFY(ranOffThread.load());
+ QVERIFY(history.busy());
+ QVERIFY(history.add(QStringLiteral("second")));
+
+ allowSave.release();
+ QTRY_COMPARE_WITH_TIMEOUT(saves.load(), 2, 1000);
+ QVERIFY(saveStarted.tryAcquire(1));
+ allowSave.release();
+ QTRY_VERIFY_WITH_TIMEOUT(!history.busy(), 1000);
+ QCOMPARE(saves.load(), 2);
+ QCOMPARE(history.entries().size(), 2);
+ QCOMPARE(history.entries().constFirst().toMap().value(QStringLiteral("text")).toString(),
+ QStringLiteral("second"));
+ }
+
+ void rollsBackOptimisticEntriesAfterAsyncFailure() {
+ QTemporaryDir directory;
+ DictationHistory history(
+ directory.filePath(QStringLiteral("history.enc")), provider(), {},
+ [](const QString &, const QByteArray &, QString *error) {
+ *error = QStringLiteral("Injected asynchronous failure");
+ return false;
+ },
+ nullptr, true);
+ history.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(history.enabled(), 1000);
+
+ QVERIFY(history.add(QStringLiteral("not persisted")));
+ QCOMPARE(history.status(), QStringLiteral("Saving encrypted history…"));
+ QTRY_VERIFY_WITH_TIMEOUT(!history.busy(), 1000);
+ QVERIFY(history.entries().isEmpty());
+ QVERIFY(!history.available());
+ QCOMPARE(history.status(), QStringLiteral("Injected asynchronous failure"));
+ }
+
+ void publishesDestructiveChangesOnlyAfterCommit() {
+ QTemporaryDir directory;
+ QSemaphore saveStarted;
+ QSemaphore allowSave;
+ DictationHistory history(
+ directory.filePath(QStringLiteral("history.enc")), provider(), {},
+ [&saveStarted, &allowSave](const QString &, const QByteArray &, QString *error) {
+ saveStarted.release();
+ if (!allowSave.tryAcquire(1, 2000)) {
+ *error = QStringLiteral("Timed out waiting for test release");
+ return false;
+ }
+ return true;
+ },
+ nullptr, true);
+ history.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(history.enabled(), 1000);
+ QVERIFY(history.add(QStringLiteral("persisted")));
+ QVERIFY(saveStarted.tryAcquire(1, 1000));
+ allowSave.release();
+ QTRY_VERIFY_WITH_TIMEOUT(!history.busy(), 1000);
+ QCOMPARE(history.entries().size(), 1);
+
+ QVERIFY(history.clear());
+ QVERIFY(saveStarted.tryAcquire(1, 1000));
+ QCOMPARE(history.entries().size(), 1);
+ allowSave.release();
+ QTRY_VERIFY_WITH_TIMEOUT(!history.busy(), 1000);
+ QVERIFY(history.entries().isEmpty());
+ }
+
+ void rollsBackRetentionSettingsWithFailedSnapshot() {
+ QTemporaryDir directory;
+ std::atomic_bool rejectWrites = false;
+ DictationHistory history(
+ directory.filePath(QStringLiteral("history.enc")), provider(), {},
+ [&rejectWrites](const QString &, const QByteArray &, QString *error) {
+ if (rejectWrites) {
+ *error = QStringLiteral("Injected retention failure");
+ return false;
+ }
+ return true;
+ },
+ nullptr, true);
+ history.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(history.enabled(), 1000);
+ QVERIFY(history.add(QStringLiteral("first")));
+ QVERIFY(history.add(QStringLiteral("second")));
+ QTRY_VERIFY_WITH_TIMEOUT(!history.busy(), 1000);
+ QCOMPARE(history.entries().size(), 2);
+
+ rejectWrites = true;
+ history.setMaximumEntries(1);
+ QCOMPARE(history.maximumEntries(), 100);
+ QCOMPARE(history.entries().size(), 2);
+ QTRY_VERIFY_WITH_TIMEOUT(!history.busy(), 1000);
+ QCOMPARE(history.maximumEntries(), 100);
+ QCOMPARE(history.entries().size(), 2);
+ }
+
+ void loadsAndPrunesEncryptedHistoryOffTheUiThread() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ const QByteArray key(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'l');
+ writeEncryptedHistory(
+ path, key,
+ {QJsonObject{{QStringLiteral("id"), QStringLiteral("expired")},
+ {QStringLiteral("createdAt"), QStringLiteral("2026-01-01T12:00:00.000Z")},
+ {QStringLiteral("text"), QStringLiteral("expired text")}}});
+ const Qt::HANDLE testThread = QThread::currentThreadId();
+ std::atomic_bool committedOffThread = false;
+ DictationHistory history(
+ path, provider(key),
+ [] { return QDateTime(QDate(2026, 8, 8), QTime(12, 0), QTimeZone::UTC); },
+ [&committedOffThread, testThread](const QString &, const QByteArray &, QString *) {
+ committedOffThread = QThread::currentThreadId() != testThread;
+ return true;
+ },
+ nullptr, true);
+
+ history.enable();
+ QVERIFY(history.busy());
+ QTRY_VERIFY_WITH_TIMEOUT(history.enabled(), 1000);
+ QVERIFY(committedOffThread.load());
+ QVERIFY(history.entries().isEmpty());
+ }
+
+ void flushesLatestSnapshotDuringShutdown() {
+ QTemporaryDir directory;
+ const QByteArray key(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'z');
+ QByteArray finalPayload;
+ QMutex payloadMutex;
+ {
+ DictationHistory history(
+ directory.filePath(QStringLiteral("history.enc")), provider(key), {},
+ [&finalPayload, &payloadMutex](const QString &, const QByteArray &data, QString *) {
+ QMutexLocker locker(&payloadMutex);
+ finalPayload = data;
+ return true;
+ },
+ nullptr, true);
+ history.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(history.enabled(), 1000);
+ QVERIFY(history.add(QStringLiteral("first")));
+ QVERIFY(history.add(QStringLiteral("second")));
+ }
+ QMutexLocker locker(&payloadMutex);
+ QCOMPARE(decryptHistoryTexts(finalPayload, key),
+ QStringList({QStringLiteral("second"), QStringLiteral("first")}));
+ }
+
+ void doesNotRepeatCompletedSaveDuringShutdown() {
+ QTemporaryDir directory;
+ std::atomic_int commits = 0;
+ {
+ DictationHistory history(
+ directory.filePath(QStringLiteral("history.enc")), provider(), {},
+ [&commits](const QString &, const QByteArray &, QString *) {
+ ++commits;
+ return true;
+ },
+ nullptr, true);
+ history.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(history.enabled(), 1000);
+ QVERIFY(history.add(QStringLiteral("only once")));
+ }
+ QCOMPARE(commits.load(), 1);
+ }
+
+ void encryptsAndReloadsWithoutPlaintext() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("private/history.enc"));
+ const QByteArray key(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 's');
+ const QDateTime now(QDate(2026, 8, 8), QTime(12, 0), QTimeZone::UTC);
+ {
+ DictationHistory history(path, provider(key), [now] { return now; });
+ history.enable();
+ QVERIFY(QFileInfo::exists(QFileInfo(path).absolutePath()));
+ QCOMPARE(QFileInfo(QFileInfo(path).absolutePath()).permissions() &
+ QFileDevice::Permissions(0x077),
+ QFileDevice::Permissions{});
+ QVERIFY(history.add(QStringLiteral("sensitive dictation")));
+ QCOMPARE(history.entries().size(), 1);
+ }
+ QFile encrypted(path);
+ QVERIFY(encrypted.open(QIODevice::ReadOnly));
+ const QByteArray bytes = encrypted.readAll();
+ QVERIFY(!bytes.contains("sensitive dictation"));
+ QCOMPARE(QFileInfo(path).permissions() & QFileDevice::Permissions(0x077),
+ QFileDevice::Permissions{});
+ QCOMPARE(QFileInfo(QFileInfo(path).absolutePath()).permissions() &
+ QFileDevice::Permissions(0x077),
+ QFileDevice::Permissions{});
+
+ DictationHistory restored(path, provider(key), [now] { return now; });
+ restored.enable();
+ QCOMPARE(restored.entries().size(), 1);
+ QCOMPARE(restored.entries().constFirst().toMap().value(QStringLiteral("text")).toString(),
+ QStringLiteral("sensitive dictation"));
+ }
+
+ void rejectsTamperingAndWrongKeys() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ const QByteArray key(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'a');
+ DictationHistory history(path, provider(key));
+ history.enable();
+ QVERIFY(history.add(QStringLiteral("private")));
+
+ DictationHistory wrongKey(
+ path, provider(QByteArray(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'b')));
+ wrongKey.enable();
+ QVERIFY(!wrongKey.available());
+ QVERIFY(wrongKey.resetRequired());
+ QVERIFY(wrongKey.entries().isEmpty());
+
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadWrite));
+ QVERIFY(file.seek(file.size() - 1));
+ char byte = 0;
+ QCOMPARE(file.read(&byte, 1), 1);
+ QVERIFY(file.seek(file.size() - 1));
+ byte ^= 1;
+ QCOMPARE(file.write(&byte, 1), 1);
+ file.close();
+ DictationHistory tampered(path, provider(key));
+ tampered.enable();
+ QVERIFY(tampered.entries().isEmpty());
+ QVERIFY(tampered.resetRequired());
+ }
+
+ void rejectsOversizedFilesWithResetRecovery() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly));
+ QVERIFY(file.resize(64LL * 1024 * 1024 + 1));
+ file.close();
+
+ DictationHistory history(path, provider());
+ history.enable();
+
+ QVERIFY(!history.enabled());
+ QVERIFY(!history.available());
+ QVERIFY(history.resetRequired());
+ QVERIFY(history.status().contains(QStringLiteral("too large")));
+ }
+
+ void refusesToPersistHistoryThatCannotBeLoaded() {
+ QTemporaryDir directory;
+ bool commitCalled = false;
+ DictationHistory history(directory.filePath(QStringLiteral("history.enc")), provider(), {},
+ [&commitCalled](const QString &, const QByteArray &, QString *) {
+ commitCalled = true;
+ return true;
+ });
+ history.enable();
+
+ const qsizetype charactersBeyondFileLimit = (64LL * 1024 * 1024) / 3 + 1;
+ QVERIFY(!history.add(QString(charactersBeyondFileLimit, QChar(0x0800))));
+
+ QVERIFY(!commitCalled);
+ QVERIFY(!history.available());
+ QVERIFY(history.entries().isEmpty());
+ QVERIFY(history.status().contains(QStringLiteral("too large")));
+ }
+
+ void rejectsTamperingAndWrongKeysAsynchronously() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ const QByteArray key(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'a');
+ const QJsonArray entries{
+ QJsonObject{{QStringLiteral("id"), QStringLiteral("private")},
+ {QStringLiteral("createdAt"), QStringLiteral("2026-08-08T12:00:00.000Z")},
+ {QStringLiteral("text"), QStringLiteral("private")}}};
+ writeEncryptedHistory(path, key, entries);
+
+ DictationHistory wrongKey(
+ path, provider(QByteArray(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'b')), {}, {},
+ nullptr, true);
+ wrongKey.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(!wrongKey.busy(), 1000);
+ QVERIFY(!wrongKey.enabled());
+ QVERIFY(!wrongKey.available());
+ QVERIFY(wrongKey.resetRequired());
+ QVERIFY(wrongKey.entries().isEmpty());
+
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadWrite));
+ QVERIFY(file.seek(file.size() - 1));
+ char byte = 0;
+ QCOMPARE(file.read(&byte, 1), 1);
+ QVERIFY(file.seek(file.size() - 1));
+ byte ^= 1;
+ QCOMPARE(file.write(&byte, 1), 1);
+ file.close();
+
+ DictationHistory tampered(path, provider(key), {}, {}, nullptr, true);
+ tampered.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(!tampered.busy(), 1000);
+ QVERIFY(!tampered.enabled());
+ QVERIFY(tampered.resetRequired());
+ QVERIFY(tampered.entries().isEmpty());
+ }
+
+ void failedLoadNeverPublishesPartiallyDecryptedEntries() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ const QByteArray key(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 'p');
+ const QJsonArray entries{
+ QJsonObject{{QStringLiteral("id"), QStringLiteral("valid")},
+ {QStringLiteral("createdAt"), QStringLiteral("2026-08-08T12:00:00.000Z")},
+ {QStringLiteral("text"), QStringLiteral("must remain private")}},
+ QJsonObject{{QStringLiteral("id"), QStringLiteral("invalid")},
+ {QStringLiteral("createdAt"), QStringLiteral("not-a-date")},
+ {QStringLiteral("text"), QStringLiteral("invalid")}}};
+ writeEncryptedHistory(path, key, entries);
+
+ DictationHistory history(path, provider(key));
+ history.enable();
+ QVERIFY(!history.enabled());
+ QVERIFY(!history.available());
+ QVERIFY(history.entries().isEmpty());
+ QVERIFY(history.recentEntries().isEmpty());
+ }
+
+ void failsClosedWhenKeyOrCommitUnavailable() {
+ QTemporaryDir directory;
+ auto failingKeys = provider();
+ failingKeys->failLoad = true;
+ DictationHistory noKey(directory.filePath(QStringLiteral("history.enc")),
+ std::move(failingKeys));
+ noKey.enable();
+ QVERIFY(!noKey.enabled());
+ QVERIFY(!noKey.available());
+ QVERIFY(!noKey.resetRequired());
+ QVERIFY(!QFileInfo::exists(noKey.storagePath()));
+
+ DictationHistory noWrite(directory.filePath(QStringLiteral("failed.enc")), provider(), {},
+ [](const QString &, const QByteArray &, QString *error) {
+ *error = QStringLiteral("Atomic write failed");
+ return false;
+ });
+ noWrite.enable();
+ QVERIFY(!noWrite.add(QStringLiteral("must not leak")));
+ QVERIFY(!noWrite.available());
+ QVERIFY(!noWrite.resetRequired());
+ QVERIFY(!QFileInfo::exists(noWrite.storagePath()));
+ }
+
+ void exposesEntriesThroughStableItemModel() {
+ QTemporaryDir directory;
+ DictationHistory history(directory.filePath(QStringLiteral("history.enc")), provider());
+ history.enable();
+ QSignalSpy modelReset(&history, &QAbstractItemModel::modelReset);
+ QVERIFY(history.add(QStringLiteral("model entry")));
+ QCOMPARE(history.rowCount(), 1);
+ QCOMPARE(history.data(history.index(0), DictationHistory::TextRole).toString(),
+ QStringLiteral("model entry"));
+ const int resetsAfterContentChange = modelReset.count();
+
+ history.setMaximumEntries(200);
+ QCOMPARE(modelReset.count(), resetsAfterContentChange);
+ QCOMPARE(history.rowCount(), 1);
+ }
+
+ void failedAtomicUpdatePreservesFileAndVisibleEntries() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ bool rejectWrites = false;
+ DictationHistory history(
+ path, provider(), {},
+ [&rejectWrites](const QString &target, const QByteArray &data, QString *error) {
+ if (rejectWrites) {
+ *error = QStringLiteral("Injected failure");
+ return false;
+ }
+ QSaveFile file(target);
+ if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit())
+ return false;
+ return true;
+ });
+ history.enable();
+ QVERIFY(history.add(QStringLiteral("persisted")));
+ QFile beforeFile(path);
+ QVERIFY(beforeFile.open(QIODevice::ReadOnly));
+ const QByteArray before = beforeFile.readAll();
+ beforeFile.close();
+
+ rejectWrites = true;
+ QVERIFY(!history.add(QStringLiteral("not persisted")));
+ QCOMPARE(history.entries().size(), 1);
+ QCOMPARE(history.entries().constFirst().toMap().value(QStringLiteral("text")),
+ QStringLiteral("persisted"));
+ QFile afterFile(path);
+ QVERIFY(afterFile.open(QIODevice::ReadOnly));
+ QCOMPARE(afterFile.readAll(), before);
+ }
+
+ void successfulRetryClearsTransientFailure() {
+ QTemporaryDir directory;
+ bool rejectWrites = true;
+ DictationHistory history(
+ directory.filePath(QStringLiteral("history.enc")), provider(), {},
+ [&rejectWrites](const QString &target, const QByteArray &data, QString *error) {
+ if (rejectWrites) {
+ *error = QStringLiteral("Injected failure");
+ return false;
+ }
+ QSaveFile file(target);
+ return file.open(QIODevice::WriteOnly) && file.write(data) == data.size() &&
+ file.commit();
+ });
+ history.enable();
+ QVERIFY(!history.add(QStringLiteral("first")));
+ QVERIFY(!history.available());
+
+ rejectWrites = false;
+ QVERIFY(history.add(QStringLiteral("second")));
+ QVERIFY(history.available());
+ QVERIFY(history.status().contains(QStringLiteral("encrypted locally")));
+ }
+
+ void failedRetentionUpdatesPreserveLimitsFileAndVisibleEntries() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ QDateTime now(QDate(2026, 8, 8), QTime(12, 0), QTimeZone::UTC);
+ bool rejectWrites = false;
+ DictationHistory history(
+ path, provider(), [&now] { return now; },
+ [&rejectWrites](const QString &target, const QByteArray &data, QString *error) {
+ if (rejectWrites) {
+ *error = QStringLiteral("Injected failure");
+ return false;
+ }
+ QSaveFile file(target);
+ return file.open(QIODevice::WriteOnly) && file.write(data) == data.size() &&
+ file.commit();
+ });
+ history.enable();
+ QVERIFY(history.add(QStringLiteral("first")));
+ now = now.addDays(2);
+ QVERIFY(history.add(QStringLiteral("second")));
+ QFile beforeFile(path);
+ QVERIFY(beforeFile.open(QIODevice::ReadOnly));
+ const QByteArray before = beforeFile.readAll();
+
+ rejectWrites = true;
+ history.setMaximumEntries(1);
+ QCOMPARE(history.maximumEntries(), 100);
+ QCOMPARE(history.entries().size(), 2);
+ history.setMaximumAgeDays(1);
+ QCOMPARE(history.maximumAgeDays(), 30);
+ QCOMPARE(history.entries().size(), 2);
+ QFile afterFile(path);
+ QVERIFY(afterFile.open(QIODevice::ReadOnly));
+ QCOMPARE(afterFile.readAll(), before);
+ }
+
+ void enforcesOrderingAndRetention() {
+ QTemporaryDir directory;
+ QDateTime now(QDate(2026, 8, 8), QTime(12, 0), QTimeZone::UTC);
+ DictationHistory history(directory.filePath(QStringLiteral("history.enc")), provider(),
+ [&now] { return now; });
+ history.enable();
+ history.setMaximumEntries(2);
+ history.setMaximumAgeDays(2);
+ QVERIFY(history.add(QStringLiteral("first")));
+ now = now.addDays(1);
+ QVERIFY(history.add(QStringLiteral("second")));
+ now = now.addDays(1);
+ QVERIFY(history.add(QStringLiteral("third")));
+ QCOMPARE(history.entries().size(), 2);
+ QCOMPARE(history.entries().at(0).toMap().value(QStringLiteral("text")),
+ QStringLiteral("third"));
+ QCOMPARE(history.entries().at(1).toMap().value(QStringLiteral("text")),
+ QStringLiteral("second"));
+ now = now.addDays(2);
+ history.setMaximumAgeDays(1);
+ QVERIFY(history.entries().isEmpty());
+ }
+
+ void expiresEntriesWhileRunningWithoutChangingSettings() {
+ QTemporaryDir directory;
+ QDateTime now(QDate(2026, 8, 8), QTime(12, 0), QTimeZone::UTC);
+ DictationHistory history(directory.filePath(QStringLiteral("history.enc")), provider(),
+ [&now] { return now; });
+ history.enable();
+ history.setMaximumAgeDays(1);
+ QVERIFY(history.add(QStringLiteral("expires")));
+ QSignalSpy settingsChanged(&history, &DictationHistory::settingsChanged);
+
+ now = now.addDays(2);
+ QVERIFY(QMetaObject::invokeMethod(&history, "expireEntries"));
+ QVERIFY(history.entries().isEmpty());
+ QCOMPARE(settingsChanged.count(), 0);
+ }
+
+ void rejectsOversizedOpenedFile() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly));
+ QVERIFY(file.resize(64LL * 1024 * 1024 + 1));
+ file.close();
+
+ DictationHistory history(path, provider());
+ history.enable();
+ QVERIFY(!history.enabled());
+ QVERIFY(!history.available());
+ QVERIFY(history.entries().isEmpty());
+ }
+
+ void rejectsOversizedOpenedFileAsynchronously() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly));
+ QVERIFY(file.resize(64LL * 1024 * 1024 + 1));
+ file.close();
+
+ DictationHistory history(path, provider(), {}, {}, nullptr, true);
+ history.enable();
+ QTRY_VERIFY_WITH_TIMEOUT(!history.busy(), 1000);
+ QVERIFY(!history.enabled());
+ QVERIFY(!history.available());
+ QVERIFY(history.entries().isEmpty());
+ }
+
+ void contentChangesDoNotEmitSettingsChanges() {
+ QTemporaryDir directory;
+ DictationHistory history(directory.filePath(QStringLiteral("history.enc")), provider());
+ history.enable();
+ QSignalSpy settingsChanged(&history, &DictationHistory::settingsChanged);
+ QVERIFY(history.add(QStringLiteral("entry")));
+ const QString id =
+ history.entries().constFirst().toMap().value(QStringLiteral("id")).toString();
+ QVERIFY(history.removeEntry(id));
+ QCOMPARE(settingsChanged.count(), 0);
+ }
+
+ void deletesEntriesAndStoredData() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ auto keys = provider();
+ auto *keysPtr = keys.get();
+ DictationHistory history(path, std::move(keys));
+ history.enable();
+ QVERIFY(history.add(QStringLiteral("one")));
+ QVERIFY(history.add(QStringLiteral("two")));
+ const QString id =
+ history.entries().constFirst().toMap().value(QStringLiteral("id")).toString();
+ QVERIFY(history.removeEntry(id));
+ QCOMPARE(history.entries().size(), 1);
+ QVERIFY(history.clear());
+ QVERIFY(history.entries().isEmpty());
+ QVERIFY(QFileInfo::exists(path));
+ history.disable(false);
+ QVERIFY(history.entries().isEmpty());
+ QVERIFY(QFileInfo::exists(path));
+ history.enable();
+ history.disable(true);
+ QVERIFY(!QFileInfo::exists(path));
+ QCOMPARE(keysPtr->removals, 1);
+ }
+
+ void retriesInterruptedKeyDeletionAfterRestart() {
+ QTemporaryDir directory;
+ const QString path = directory.filePath(QStringLiteral("history.enc"));
+ {
+ auto keys = std::make_unique();
+ auto *keysPtr = keys.get();
+ DictationHistory history(path, std::move(keys));
+ history.enable();
+ QVERIFY(history.add(QStringLiteral("delete me")));
+ history.disable(true);
+ QVERIFY(history.busy());
+ QVERIFY(history.deletionPending());
+ QVERIFY(keysPtr->pendingRemoval);
+ QVERIFY(!QFileInfo::exists(path));
+ }
+
+ auto keys = provider();
+ auto *keysPtr = keys.get();
+ DictationHistory restarted(path, std::move(keys));
+ QVERIFY(restarted.deletionPending());
+ restarted.resumePendingDeletion();
+ QVERIFY(!restarted.busy());
+ QVERIFY(!restarted.deletionPending());
+ QCOMPARE(keysPtr->removals, 1);
+ }
+};
+
+QTEST_GUILESS_MAIN(DictationHistoryTest)
+#include "DictationHistoryTest.moc"
diff --git a/tests/FakeAppController.h b/tests/FakeAppController.h
index ba90f9c..5e1d506 100644
--- a/tests/FakeAppController.h
+++ b/tests/FakeAppController.h
@@ -91,6 +91,109 @@ class FakeModelManager final : public QObject {
QString m_installedId;
};
+class FakeDictationHistory final : public QObject {
+ Q_OBJECT
+ Q_PROPERTY(bool enabled READ enabled NOTIFY changed)
+ Q_PROPERTY(bool busy READ busy NOTIFY changed)
+ Q_PROPERTY(bool available READ available NOTIFY changed)
+ Q_PROPERTY(bool resetRequired READ resetRequired NOTIFY changed)
+ Q_PROPERTY(QString status READ status NOTIFY changed)
+ Q_PROPERTY(QString storagePath READ storagePath CONSTANT)
+ Q_PROPERTY(QVariantList entryModel READ entries NOTIFY changed)
+ Q_PROPERTY(QVariantList recentEntries READ recentEntries NOTIFY changed)
+ Q_PROPERTY(int maximumEntries READ maximumEntries WRITE setMaximumEntries NOTIFY changed)
+ Q_PROPERTY(int maximumAgeDays READ maximumAgeDays WRITE setMaximumAgeDays NOTIFY changed)
+
+public:
+ FakeDictationHistory() {
+ m_entries = {
+ QVariantMap{{QStringLiteral("entryId"), QStringLiteral("recent")},
+ {QStringLiteral("createdText"), QStringLiteral("8 Aug 2026, 12:00")},
+ {QStringLiteral("text"), QStringLiteral("Recent private dictation")}},
+ QVariantMap{{QStringLiteral("entryId"), QStringLiteral("older")},
+ {QStringLiteral("createdText"), QStringLiteral("7 Aug 2026, 09:30")},
+ {QStringLiteral("text"), QStringLiteral("Earlier dictation")}},
+ };
+ }
+ bool enabled() const { return m_enabled; }
+ bool busy() const { return m_busy; }
+ bool available() const { return m_available; }
+ bool resetRequired() const { return m_resetRequired; }
+ QString status() const {
+ if (m_busy)
+ return QStringLiteral("Saving encrypted history…");
+ return m_available
+ ? QStringLiteral("History is encrypted locally. The key is stored in KDE Wallet.")
+ : QStringLiteral("Secure history requires an available, unlocked KDE Wallet.");
+ }
+ QString storagePath() const { return QStringLiteral("/tmp/history.enc"); }
+ QVariantList entries() const { return m_enabled ? m_entries : QVariantList{}; }
+ QVariantList recentEntries() const { return entries().mid(0, 1); }
+ int maximumEntries() const { return m_maximumEntries; }
+ int maximumAgeDays() const { return m_maximumAgeDays; }
+ void setMaximumEntries(int value) {
+ m_maximumEntries = value;
+ emit changed();
+ }
+ void setMaximumAgeDays(int value) {
+ m_maximumAgeDays = value;
+ emit changed();
+ }
+ void setEnabled(bool enabled) {
+ m_enabled = enabled;
+ emit changed();
+ }
+ Q_INVOKABLE void setAvailable(bool available) {
+ m_available = available;
+ emit changed();
+ }
+ Q_INVOKABLE void setBusy(bool busy) {
+ if (m_busy == busy)
+ return;
+ m_busy = busy;
+ emit changed();
+ }
+ Q_INVOKABLE void setResetRequired(bool required) {
+ m_resetRequired = required;
+ emit changed();
+ }
+ Q_INVOKABLE void setEntryCount(int count) {
+ m_entries.clear();
+ m_entries.reserve(count);
+ for (int i = 0; i < count; ++i) {
+ m_entries.append(
+ QVariantMap{{QStringLiteral("entryId"), QString::number(i)},
+ {QStringLiteral("createdText"), QStringLiteral("8 Aug 2026, 12:00")},
+ {QStringLiteral("text"), QStringLiteral("Private dictation %1").arg(i)}});
+ }
+ emit changed();
+ }
+ Q_INVOKABLE bool removeEntry(const QString &id) {
+ m_entries.removeIf([&id](const QVariant &entry) {
+ return entry.toMap().value(QStringLiteral("entryId")).toString() == id;
+ });
+ emit changed();
+ return true;
+ }
+ Q_INVOKABLE bool clear() {
+ m_entries.clear();
+ emit changed();
+ return true;
+ }
+
+signals:
+ void changed();
+
+private:
+ bool m_enabled = true;
+ bool m_busy = false;
+ bool m_available = true;
+ bool m_resetRequired = false;
+ int m_maximumEntries = 100;
+ int m_maximumAgeDays = 30;
+ QVariantList m_entries;
+};
+
class FakeAppController final : public QObject {
Q_OBJECT
Q_PROPERTY(bool idle READ idle NOTIFY stateChanged)
@@ -98,6 +201,7 @@ class FakeAppController final : public QObject {
Q_PROPERTY(bool transcribing READ transcribing NOTIFY stateChanged)
Q_PROPERTY(QString status READ status NOTIFY statusChanged)
Q_PROPERTY(QString transcript READ transcript NOTIFY transcriptChanged)
+ Q_PROPERTY(QObject *history READ history CONSTANT)
Q_PROPERTY(QString modelPath READ modelPath WRITE setModelPath NOTIFY modelPathChanged)
Q_PROPERTY(bool modelReady READ modelReady NOTIFY modelReadyChanged)
Q_PROPERTY(bool modelSetupRequired READ modelSetupRequired NOTIFY modelReadyChanged)
@@ -129,6 +233,8 @@ class FakeAppController final : public QObject {
Q_PROPERTY(QString shortcutText READ shortcutText NOTIFY shortcutChanged)
Q_PROPERTY(int toggleCount READ toggleCount NOTIFY toggleCountChanged)
Q_PROPERTY(QString copiedText READ copiedText NOTIFY copiedTextChanged)
+ Q_PROPERTY(QString revealedFile READ revealedFile NOTIFY fileManagerChanged)
+ Q_PROPERTY(QString openedDirectory READ openedDirectory NOTIFY fileManagerChanged)
public:
bool idle() const { return !m_recording && !m_transcribing; }
@@ -136,6 +242,7 @@ class FakeAppController final : public QObject {
bool transcribing() const { return m_transcribing; }
QString status() const { return m_status; }
QString transcript() const { return QStringLiteral("Test transcription"); }
+ QObject *history() { return &m_history; }
QString modelPath() const { return m_modelPath; }
bool modelReady() const { return m_modelReady; }
bool modelSetupRequired() const { return !m_modelReady && !m_restoringModel; }
@@ -193,6 +300,8 @@ class FakeAppController final : public QObject {
QString shortcutText() const { return m_shortcut.toString(QKeySequence::NativeText); }
int toggleCount() const { return m_toggleCount; }
QString copiedText() const { return m_copiedText; }
+ QString revealedFile() const { return m_revealedFile; }
+ QString openedDirectory() const { return m_openedDirectory; }
void setModelPath(const QString &path) {
if (m_modelPath == path)
@@ -263,6 +372,24 @@ class FakeAppController final : public QObject {
}
Q_INVOKABLE void forgetTranscript() {}
Q_INVOKABLE void copyTranscript() {}
+ Q_INVOKABLE void enableHistory() {
+ if (!m_history.available())
+ return;
+ m_history.setEnabled(true);
+ }
+ Q_INVOKABLE void disableHistory(bool deleteData) {
+ if (deleteData)
+ m_history.clear();
+ m_history.setEnabled(false);
+ }
+ Q_INVOKABLE void revealHistoryStorage() {
+ m_revealedFile = m_history.storagePath();
+ emit fileManagerChanged();
+ }
+ Q_INVOKABLE void openModelStorage() {
+ m_openedDirectory = m_modelManager.storagePath();
+ emit fileManagerChanged();
+ }
Q_INVOKABLE bool removeModel(const QString &) { return true; }
Q_INVOKABLE void setTestState(bool recording, bool transcribing) {
m_recording = recording;
@@ -317,6 +444,7 @@ class FakeAppController final : public QObject {
void audioInputSetupRequested();
void modelReadyChanged();
void copiedTextChanged();
+ void fileManagerChanged();
void audioInputsChanged();
void dictationAvailabilityChanged();
void audioInputMonitoringEnabledChanged();
@@ -345,8 +473,11 @@ class FakeAppController final : public QObject {
QString m_modelPath;
int m_toggleCount = 0;
QString m_copiedText;
+ QString m_revealedFile;
+ QString m_openedDirectory;
QKeySequence m_shortcut{QStringLiteral("Meta+Z")};
FakeModelManager m_modelManager;
+ FakeDictationHistory m_history;
bool m_modelReady = true;
bool m_restoringModel = false;
bool m_shortcutChangeAccepted = true;
diff --git a/tests/ModelManagerTest.cpp b/tests/ModelManagerTest.cpp
index 4c8da59..8c0c97f 100644
--- a/tests/ModelManagerTest.cpp
+++ b/tests/ModelManagerTest.cpp
@@ -520,10 +520,10 @@ void ModelManagerTest::retainsModelOnHashReadFailure() {
manager.selectModel(item.id);
- QTRY_VERIFY(!manager.busy());
+ QTRY_VERIFY(manager.error().contains(QStringLiteral("could not be read")));
+ QVERIFY(!manager.busy());
QVERIFY(QFileInfo::exists(model.fileName()));
QVERIFY(!manager.modelReady());
- QVERIFY(manager.error().contains(QStringLiteral("could not be read")));
}
void ModelManagerTest::invalidatesChangedManagedModel() {
diff --git a/tests/check-screenshot-workflow.sh b/tests/check-screenshot-workflow.sh
index b938e00..c1a9673 100755
--- a/tests/check-screenshot-workflow.sh
+++ b/tests/check-screenshot-workflow.sh
@@ -19,7 +19,7 @@ fixture_capture="$fixture_root/tools/capture-screenshots.sh"
fixture_generator="$fixture_root/success-generator"
printf '%s\n' \
'#!/bin/sh' \
- 'for file in 01-offline-dictation.png 02-speech-models.png 03-audio-input.png 04-settings.png; do' \
+ 'for file in 01-offline-dictation.png 02-history.png 03-speech-models.png 04-audio-input.png 05-settings.png; do' \
' printf fixture > "$1/$file"' \
'done' > "$fixture_generator"
chmod +x "$fixture_generator"
@@ -52,13 +52,14 @@ test ! -e "$fixture_root/.screenshots-transaction."*
for screenshot in \
01-offline-dictation.png \
- 02-speech-models.png \
- 03-audio-input.png \
- 04-settings.png
+ 02-history.png \
+ 03-speech-models.png \
+ 04-audio-input.png \
+ 05-settings.png
do
test -f "$fixture_root/screenshots/$screenshot"
done
-test "$(find "$fixture_root/screenshots" -mindepth 1 -maxdepth 1 | wc -l)" -eq 4
+test "$(find "$fixture_root/screenshots" -mindepth 1 -maxdepth 1 | wc -l)" -eq 5
serial_generator="$fixture_root/serial-generator"
printf '%s\n' \
@@ -67,7 +68,7 @@ printf '%s\n' \
'trap '\''rmdir "$SERIAL_ROOT/active"'\'' EXIT HUP INT TERM' \
'cmake -E touch "$SERIAL_ROOT/entered"' \
'sleep 0.2' \
- 'for file in 01-offline-dictation.png 02-speech-models.png 03-audio-input.png 04-settings.png; do' \
+ 'for file in 01-offline-dictation.png 02-history.png 03-speech-models.png 04-audio-input.png 05-settings.png; do' \
' printf serialized > "$1/$file"' \
'done' > "$serial_generator"
chmod +x "$serial_generator"
@@ -83,7 +84,7 @@ SERIAL_ROOT="$fixture_root" "$fixture_capture" "$serial_generator" &
second_capture=$!
wait "$first_capture"
wait "$second_capture"
-test "$(find "$fixture_root/screenshots" -mindepth 1 -maxdepth 1 | wc -l)" -eq 4
+test "$(find "$fixture_root/screenshots" -mindepth 1 -maxdepth 1 | wc -l)" -eq 5
cmake -E touch "$fixture_root/screenshots/stale.png"
"$fixture_capture" "$fixture_generator"
diff --git a/tests/qml/tst_Main.qml b/tests/qml/tst_Main.qml
index 507e84a..a23ff46 100644
--- a/tests/qml/tst_Main.qml
+++ b/tests/qml/tst_Main.qml
@@ -4,6 +4,7 @@
import QtQuick
import QtQuick.Controls as Controls
import QtTest
+import org.kde.kirigami as Kirigami
TestCase {
id: testCase
@@ -35,6 +36,10 @@ TestCase {
appController.setAudioInputMonitoringError("")
appController.resetMonitoringRetryCount()
appController.audioInputId = ""
+ appController.history.setAvailable(true)
+ appController.history.setBusy(false)
+ appController.history.setResetRequired(false)
+ appController.history.setEntryCount(2)
const loader = createTemporaryObject(applicationComponent, testCase)
verify(loader)
tryCompare(loader, "status", Loader.Ready)
@@ -67,9 +72,15 @@ TestCase {
return page
}
+ function historyPage() {
+ const page = findChild(applicationWindow.contentItem, "historyPage")
+ verify(page)
+ return page
+ }
+
function test_missingModelOpensSetupAndDisablesDictation() {
appController.setModelReady(false)
- tryCompare(applicationWindow, "currentView", 1)
+ tryCompare(applicationWindow, "currentView", 2)
compare(modelPage().objectName, "modelManagerPage")
const button = findChild(applicationWindow.contentItem, "dictationButton")
verify(button)
@@ -79,7 +90,7 @@ TestCase {
function test_missingAudioInputOpensAudioSetup() {
applicationWindow.currentView = 0
appController.requestAudioInputSetup()
- tryCompare(applicationWindow, "currentView", 2)
+ tryCompare(applicationWindow, "currentView", 3)
compare(audioInputPage().visible, true)
}
@@ -88,14 +99,15 @@ TestCase {
appController.setModelReady(false)
compare(applicationWindow.currentView, 0)
applicationWindow.openModelManager()
- compare(applicationWindow.currentView, 1)
+ compare(applicationWindow.currentView, 2)
const models = modelPage()
const warning = findChild(models, "modelSetupWarning")
verify(warning)
- compare(warning.visible, false)
+ compare(warning.visible, true)
+ verify(warning.text.indexOf("sources you trust") >= 0)
appController.setRestoringModel(false)
- compare(applicationWindow.currentView, 1)
+ compare(applicationWindow.currentView, 2)
}
function cleanup() {
@@ -158,24 +170,37 @@ TestCase {
function test_modelControlsHaveAccessibleNames() {
applicationWindow.openModelManager()
const models = modelPage()
- const filter = findChild(models, "modelLanguageFilter")
- const warning = findChild(models, "externalModelWarning")
+ const filter = findChild(models, "modelFilterRecommended")
+ const storagePath = findChild(models, "modelStoragePath")
+ const openStorage = findChild(models, "openModelStorageButton")
+ const warning = findChild(models, "modelSetupWarning")
verify(filter)
+ verify(storagePath)
verify(warning)
- compare(filter.Accessible.name, "Filter speech models")
+ compare(filter.text, "Recommended")
+ compare(storagePath.Accessible.name, "Model storage path")
+ compare(storagePath.selectByMouse, true)
+ verify(openStorage)
+ openStorage.forceActiveFocus()
+ keyClick(Qt.Key_Space)
+ compare(appController.openedDirectory, "/tmp/models")
compare(warning.visible, true)
}
function test_modelFiltersDefaultToRecommendedAndSeparateCapabilities() {
applicationWindow.openModelManager()
const models = modelPage()
- const filter = findChild(models, "modelLanguageFilter")
+ const recommendedFilter = findChild(models, "modelFilterRecommended")
+ const englishFilter = findChild(models, "modelFilterEnglish")
+ const multilingualFilter = findChild(models, "modelFilterMultilingual")
const english = findChild(models, "modelCard-base.en")
const recommendedMultilingual = findChild(models, "modelCard-small")
const otherMultilingual = findChild(models, "modelCard-tiny")
const recommendedIcon = findChild(models, "recommendedModel-base.en")
const regularIcon = findChild(models, "recommendedModel-tiny")
- verify(filter)
+ verify(recommendedFilter)
+ verify(englishFilter)
+ verify(multilingualFilter)
verify(english)
verify(recommendedMultilingual)
verify(otherMultilingual)
@@ -185,17 +210,17 @@ TestCase {
compare(recommendedIcon.Accessible.name, "Recommended")
compare(regularIcon.visible, false)
- compare(filter.currentValue, "recommended")
+ compare(recommendedFilter.checked, true)
compare(english.visible, true)
compare(recommendedMultilingual.visible, true)
compare(otherMultilingual.visible, false)
- filter.currentIndex = filter.indexOfValue("multilingual")
+ multilingualFilter.clicked()
compare(english.visible, false)
compare(recommendedMultilingual.visible, true)
compare(otherMultilingual.visible, true)
- filter.currentIndex = filter.indexOfValue("english")
+ englishFilter.clicked()
compare(english.visible, true)
compare(recommendedMultilingual.visible, false)
compare(otherMultilingual.visible, false)
@@ -205,9 +230,9 @@ TestCase {
applicationWindow.width = 1200
applicationWindow.openModelManager()
const models = modelPage()
- const filter = findChild(models, "modelLanguageFilter")
+ const filter = findChild(models, "modelFilterAll")
verify(filter)
- filter.currentIndex = filter.indexOfValue("all")
+ filter.clicked()
appController.modelManager.setModelStates("base.en", "small")
waitForRendering(applicationWindow.contentItem)
@@ -285,9 +310,9 @@ TestCase {
applicationWindow.width = applicationWindow.minimumWidth
applicationWindow.openModelManager()
const models = modelPage()
- const filter = findChild(models, "modelLanguageFilter")
+ const filter = findChild(models, "modelFilterAll")
verify(filter)
- filter.currentIndex = filter.indexOfValue("all")
+ filter.clicked()
waitForRendering(applicationWindow.contentItem)
const card = findChild(models, "modelCard-tiny")
@@ -312,9 +337,9 @@ TestCase {
function test_verificationCanBeCancelled() {
applicationWindow.openModelManager()
const models = modelPage()
- const filter = findChild(models, "modelLanguageFilter")
+ const filter = findChild(models, "modelFilterAll")
verify(filter)
- filter.currentIndex = filter.indexOfValue("all")
+ filter.clicked()
appController.modelManager.setVerifyingId("tiny")
const cancel = findChild(models, "cancelModel-tiny")
@@ -326,9 +351,9 @@ TestCase {
function test_partialDownloadCanBeRemoved() {
applicationWindow.openModelManager()
const models = modelPage()
- const filter = findChild(models, "modelLanguageFilter")
+ const filter = findChild(models, "modelFilterAll")
verify(filter)
- filter.currentIndex = filter.indexOfValue("all")
+ filter.clicked()
appController.modelManager.setPartialId("tiny")
const partial = findChild(models, "partialModel-tiny")
@@ -353,28 +378,45 @@ TestCase {
function test_verticalTabsSwitchViewsInOneWindow() {
const dictationTab = findChild(applicationWindow.contentItem, "dictationTab")
+ const historyTab = findChild(applicationWindow.contentItem, "historyTab")
const modelsTab = findChild(applicationWindow.contentItem, "modelsTab")
const audioInputTab = findChild(applicationWindow.contentItem, "audioInputTab")
const settingsTab = findChild(applicationWindow.contentItem, "settingsTab")
verify(dictationTab)
+ verify(historyTab)
verify(modelsTab)
verify(audioInputTab)
verify(settingsTab)
compare(dictationTab.checked, true)
+ compare(applicationWindow.height, 700)
+ compare(dictationTab.text, "Dictation")
+ compare(historyTab.text, "History")
+ compare(modelsTab.text, "Models")
+ compare(audioInputTab.text, "Audio")
+ compare(settingsTab.text, "Settings")
+ compare(applicationWindow.pageStack.globalToolBar.style,
+ Kirigami.ApplicationHeaderStyle.None)
+ const navigation = findChild(applicationWindow.contentItem, "navigationPane")
+ verify(navigation.width < 200)
- mouseClick(modelsTab)
+ mouseClick(historyTab)
tryCompare(applicationWindow, "currentView", 1)
+ compare(historyTab.checked, true)
+ compare(historyPage().visible, true)
+
+ mouseClick(modelsTab)
+ tryCompare(applicationWindow, "currentView", 2)
compare(modelsTab.checked, true)
compare(modelPage().visible, true)
mouseClick(audioInputTab)
- tryCompare(applicationWindow, "currentView", 2)
+ tryCompare(applicationWindow, "currentView", 3)
compare(appController.audioInputMonitoringEnabled, true)
compare(audioInputTab.checked, true)
compare(audioInputPage().visible, true)
mouseClick(settingsTab)
- tryCompare(applicationWindow, "currentView", 3)
+ tryCompare(applicationWindow, "currentView", 4)
compare(appController.audioInputMonitoringEnabled, false)
compare(settingsTab.checked, true)
compare(settingsPage().visible, true)
@@ -383,10 +425,12 @@ TestCase {
function test_verticalTabsSupportArrowKeys() {
const dictationTab = findChild(applicationWindow.contentItem, "dictationTab")
+ const historyTab = findChild(applicationWindow.contentItem, "historyTab")
const modelsTab = findChild(applicationWindow.contentItem, "modelsTab")
const audioInputTab = findChild(applicationWindow.contentItem, "audioInputTab")
const settingsTab = findChild(applicationWindow.contentItem, "settingsTab")
verify(dictationTab)
+ verify(historyTab)
verify(modelsTab)
verify(audioInputTab)
verify(settingsTab)
@@ -394,9 +438,11 @@ TestCase {
dictationTab.forceActiveFocus()
verify(dictationTab.activeFocus)
keyClick(Qt.Key_Down)
+ verify(historyTab.activeFocus)
+ keyClick(Qt.Key_Down)
verify(modelsTab.activeFocus)
keyClick(Qt.Key_Space)
- compare(applicationWindow.currentView, 1)
+ compare(applicationWindow.currentView, 2)
keyClick(Qt.Key_Down)
verify(audioInputTab.activeFocus)
@@ -407,7 +453,135 @@ TestCase {
keyClick(Qt.Key_Up)
verify(settingsTab.activeFocus)
keyClick(Qt.Key_Space)
- compare(applicationWindow.currentView, 3)
+ compare(applicationWindow.currentView, 4)
+ }
+
+ function test_historyShowsRecentAndFullEntriesAccessibly() {
+ applicationWindow.currentView = 0
+ const recent = findChild(applicationWindow.contentItem, "recentHistoryPanel")
+ verify(recent)
+ compare(recent.visible, true)
+
+ applicationWindow.currentView = 1
+ const page = historyPage()
+ const enabled = findChild(page, "historyEnabledSwitch")
+ const count = findChild(page, "historyMaximumEntries")
+ const age = findChild(page, "historyMaximumAgeDays")
+ const clear = findChild(page, "clearHistoryButton")
+ const storagePath = findChild(page, "historyStoragePath")
+ const openStorage = findChild(page, "openHistoryStorageButton")
+ verify(enabled)
+ verify(count)
+ verify(age)
+ verify(clear)
+ verify(storagePath)
+ verify(openStorage)
+ compare(enabled.checked, true)
+ compare(enabled.text, "Enable history")
+ verify(enabled.Accessible.description.indexOf("authenticated encryption") >= 0)
+ compare(count.Accessible.name, "Maximum history entries")
+ compare(age.Accessible.name, "Maximum history age in days")
+ compare(clear.visible, true)
+ compare(storagePath.selectByMouse, true)
+ openStorage.forceActiveFocus()
+ keyClick(Qt.Key_Space)
+ compare(appController.revealedFile, "/tmp/history.enc")
+ verify(Math.abs(enabled.mapToItem(page, 0, 0).y
+ - clear.mapToItem(page, 0, 0).y) <= 4)
+ const list = findChild(page, "historyList")
+ list.positionViewAtIndex(0, ListView.Beginning)
+ tryVerify(function() {
+ return findChild(page, "historyEntryText") !== null
+ })
+ const entryText = findChild(page, "historyEntryText")
+ verify(entryText)
+ compare(entryText.Accessible.name, "Private dictation 0")
+ verify(entryText.Accessible.description.indexOf("8 Aug 2026") >= 0)
+
+ const arabicLocale = Qt.locale("ar_EG")
+ const localizedAge = age.textFromValue(123, arabicLocale)
+ compare(age.valueFromText(localizedAge, arabicLocale), 123)
+ }
+
+ function test_cancellingHistoryDisableKeepsSwitchEnabled() {
+ applicationWindow.currentView = 1
+ const enabled = findChild(historyPage(), "historyEnabledSwitch")
+ const dialog = findChild(applicationWindow.contentItem, "disableHistoryDialog")
+ verify(enabled)
+ verify(dialog)
+ compare(enabled.checked, true)
+
+ mouseClick(enabled)
+ compare(enabled.checked, true)
+ compare(dialog.visible, true)
+ dialog.reject()
+ tryCompare(dialog, "visible", false)
+ compare(enabled.checked, true)
+ }
+
+ function test_historyMutationControlsFollowBusyState() {
+ applicationWindow.currentView = 1
+ const page = historyPage()
+ const historySwitch = findChild(page, "historyEnabledSwitch")
+ const count = findChild(page, "historyMaximumEntries")
+ const age = findChild(page, "historyMaximumAgeDays")
+ const clear = findChild(page, "clearHistoryButton")
+ const status = findChild(page, "historyStatus")
+ const list = findChild(page, "historyList")
+ list.positionViewAtIndex(0, ListView.Beginning)
+ tryVerify(function() {
+ return findChild(page, "historyEntryDeleteButton") !== null
+ })
+ const deleteButton = findChild(page, "historyEntryDeleteButton")
+
+ appController.history.setBusy(true)
+ tryCompare(status, "text", "Saving encrypted history…")
+ compare(historySwitch.enabled, false)
+ compare(count.enabled, false)
+ compare(age.enabled, false)
+ compare(clear.enabled, false)
+ compare(deleteButton.enabled, false)
+
+ appController.history.setBusy(false)
+ tryCompare(historySwitch, "enabled", true)
+ compare(count.enabled, true)
+ compare(age.enabled, true)
+ compare(clear.enabled, true)
+ compare(deleteButton.enabled, true)
+ }
+
+ function test_historyResetIsLimitedToUnreadableData() {
+ applicationWindow.currentView = 1
+ const reset = findChild(historyPage(), "resetHistoryAction")
+ verify(reset)
+ compare(reset.visible, false)
+
+ appController.history.setAvailable(false)
+ waitForRendering(historyPage())
+ compare(reset.visible, false)
+
+ appController.history.setResetRequired(true)
+ waitForRendering(historyPage())
+ compare(reset.visible, true)
+ }
+
+ function test_historyVirtualizesLargeEntryCollections() {
+ appController.history.setEntryCount(10000)
+ applicationWindow.currentView = 1
+ const list = findChild(historyPage(), "historyList")
+ verify(list)
+ tryCompare(list, "count", 10000)
+ const clear = findChild(historyPage(), "clearHistoryButton")
+ verify(clear)
+ verify(clear.visible)
+ tryVerify(function() { return list.itemAtIndex(0) !== null })
+ verify(list.itemAtIndex(9999) === null)
+ const clearY = clear.mapToItem(historyPage(), 0, 0).y
+ list.positionViewAtIndex(9999, ListView.End)
+ waitForRendering(list)
+ compare(clear.mapToItem(historyPage(), 0, 0).y, clearY)
+ verify(clear.visible)
+ verify(list.reuseItems)
}
function test_audioInputPageSelectsAndReportsDevices() {
@@ -539,21 +713,57 @@ TestCase {
compare(button.enabled, true)
}
- function test_navigationAdaptsToMinimumWidth() {
- const dictationTab = findChild(applicationWindow.contentItem, "dictationTab")
+ function test_compactNavigationUsesAccessibleIconOnlyTabs() {
const button = findChild(applicationWindow.contentItem, "dictationButton")
const navigation = findChild(applicationWindow.contentItem, "navigationPane")
- verify(dictationTab)
verify(button)
verify(navigation)
- applicationWindow.width = applicationWindow.minimumWidth
- tryCompare(applicationWindow, "width", applicationWindow.minimumWidth)
+ applicationWindow.width = 600
+ tryCompare(applicationWindow, "width", 600)
tryCompare(navigation, "compact", true)
- compare(dictationTab.display, Controls.AbstractButton.IconOnly)
+ const tabs = [
+ ["dictationTab", "Dictation"],
+ ["historyTab", "History"],
+ ["modelsTab", "Models"],
+ ["audioInputTab", "Audio"],
+ ["settingsTab", "Settings"]
+ ]
+ for (const tabData of tabs) {
+ const tab = findChild(applicationWindow.contentItem, tabData[0])
+ verify(tab)
+ compare(tab.display, Controls.AbstractButton.IconOnly)
+ compare(tab.text, "")
+ compare(tab.Accessible.name, tabData[1])
+ compare(tab.Controls.ToolTip.text, tabData[1])
+ }
verify(button.width <= applicationWindow.pageStack.currentItem.width)
}
+ function test_tabFooterAlertsUseConsistentOuterMargins() {
+ const footers = [
+ [0, "dictationFooter", "statusMessage"],
+ [1, "historyFooter", "historyStatus"],
+ [2, "modelsFooter", "modelSetupWarning"],
+ [3, "audioFooter", "audioInputStatus"],
+ [4, "settingsFooter", "autoPasteWarning"]
+ ]
+ for (const footerData of footers) {
+ applicationWindow.currentView = footerData[0]
+ if (footerData[0] === 4)
+ appController.autoPaste = true
+ const footer = findChild(applicationWindow.contentItem, footerData[1])
+ const alert = findChild(applicationWindow.contentItem, footerData[2])
+ verify(footer)
+ verify(alert)
+ tryCompare(footer, "visible", true)
+ compare(footer.leftPadding, footer.contentMargin)
+ compare(footer.rightPadding, footer.contentMargin)
+ compare(footer.topPadding, footer.contentMargin)
+ compare(footer.bottomPadding, footer.contentMargin)
+ }
+ }
+
function test_settingsCanModifyGlobalShortcut() {
applicationWindow.openSettings()
const editor = findChild(settingsPage(), "shortcutEditor")
diff --git a/tools/ScreenshotGenerator.cpp b/tools/ScreenshotGenerator.cpp
index 36bd803..494b954 100644
--- a/tools/ScreenshotGenerator.cpp
+++ b/tools/ScreenshotGenerator.cpp
@@ -20,7 +20,7 @@
namespace {
constexpr int screenshotWidth = 760;
-constexpr int screenshotHeight = 520;
+constexpr int screenshotHeight = 700;
struct ScreenshotView {
int index;
@@ -28,10 +28,9 @@ struct ScreenshotView {
};
constexpr std::array views = {
- ScreenshotView{0, "01-offline-dictation.png"},
- ScreenshotView{1, "02-speech-models.png"},
- ScreenshotView{2, "03-audio-input.png"},
- ScreenshotView{3, "04-settings.png"},
+ ScreenshotView{0, "01-offline-dictation.png"}, ScreenshotView{1, "02-history.png"},
+ ScreenshotView{2, "03-speech-models.png"}, ScreenshotView{3, "04-audio-input.png"},
+ ScreenshotView{4, "05-settings.png"},
};
void waitForRendering() {
diff --git a/tools/capture-screenshots.sh b/tools/capture-screenshots.sh
index 50c8de2..8b26b39 100755
--- a/tools/capture-screenshots.sh
+++ b/tools/capture-screenshots.sh
@@ -66,13 +66,14 @@ cmake -E make_directory "$stage_directory"
for screenshot in \
01-offline-dictation.png \
- 02-speech-models.png \
- 03-audio-input.png \
- 04-settings.png
+ 02-history.png \
+ 03-speech-models.png \
+ 04-audio-input.png \
+ 05-settings.png
do
test -f "$stage_directory/$screenshot"
done
-test "$(find "$stage_directory" -mindepth 1 -maxdepth 1 | wc -l)" -eq 4
+test "$(find "$stage_directory" -mindepth 1 -maxdepth 1 | wc -l)" -eq 5
if [ -e "$output_directory" ] || [ -L "$output_directory" ]; then
mv -- "$output_directory" "$backup_directory"