diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96e38cd..f6e9c9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,5 +61,21 @@ jobs: - name: Run tests run: ctest --test-dir build --output-on-failure + - name: Validate install tree + run: | + INSTALL_DIR="$(mktemp -d)" + cmake --install build --prefix "$INSTALL_DIR" + test -x "$INSTALL_DIR/bin/mutterkey" + test -x "$INSTALL_DIR/bin/mutterkey-tray" + - name: Validate headless CLI startup run: QT_QPA_PLATFORM=offscreen ./build/mutterkey --help + + - name: Validate headless tray startup + run: | + if timeout 2s env QT_QPA_PLATFORM=offscreen ./build/mutterkey-tray; then + exit 0 + else + status=$? + test "$status" -eq 124 + fi diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index e6a52f0..b7a02d8 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -50,5 +50,21 @@ jobs: - name: Run tests run: ctest --test-dir build --output-on-failure + - name: Validate install tree + run: | + INSTALL_DIR="$(mktemp -d)" + cmake --install build --prefix "$INSTALL_DIR" + test -x "$INSTALL_DIR/bin/mutterkey" + test -x "$INSTALL_DIR/bin/mutterkey-tray" + + - name: Validate headless tray startup + run: | + if timeout 2s env QT_QPA_PLATFORM=offscreen ./build/mutterkey-tray; then + exit 0 + else + status=$? + test "$status" -eq 124 + fi + - name: Run Valgrind Memcheck lane run: bash scripts/run-valgrind.sh build diff --git a/.gitignore b/.gitignore index 8e125ad..2273d33 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /third_party/whisper.cpp/build/ /third_party/whisper.cpp/build-*/ /Makefile +/next_feature/ *.o *.obj *.moc diff --git a/AGENTS.md b/AGENTS.md index 77e082a..ae83b96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Current architecture: - Audio capture uses Qt Multimedia - Transcription is in-process through vendored `whisper.cpp` - Clipboard writes prefer `KSystemClipboard` with `QClipboard` fallback -- There is no GUI yet; the entrypoint is the `mutterkey` binary with `daemon`, `once`, and `diagnose` modes +- There is an early Qt Widgets tray shell in `mutterkey-tray`, but the daemon remains the product core - The recommended day-to-day runtime path is the `systemd --user` service - The installed desktop entry is intentionally hidden from normal app menus with `NoDisplay=true` - `daemon` is the default runtime mode; `once` and `diagnose` are validation helpers @@ -36,9 +36,13 @@ This repository is intentionally kept minimal: - `src/transcription/transcriptionworker.*`: worker object hosted on a dedicated `QThread` - `src/transcription/transcriptiontypes.h`: normalized audio and transcription result value types - `src/config.*`: JSON config loading and defaults +- `src/app/*`: shared CLI/runtime command helpers used by the main entrypoint +- `src/control/*`: local daemon control transport, typed snapshots, and session/client APIs +- `src/tray/*`: Qt Widgets tray-shell UI scaffolding - `contrib/mutterkey.service`: example user service - `contrib/org.mutterkey.mutterkey.desktop`: hidden desktop entry used for desktop identity/integration - `scripts/check-release-hygiene.sh`: repo hygiene checks for publication-facing content +- `next_feature/`: tracked upcoming feature plans as Markdown; keep only plan `.md` files and the folder-local `.gitignore` - `docs/Doxyfile.in`: Doxygen config template for repo-owned API docs - `docs/mainpage.md`: Doxygen landing page used instead of the full README - `scripts/run-valgrind.sh`: deterministic Valgrind Memcheck runner for release-readiness checks @@ -64,6 +68,10 @@ cmake -S . -B "$BUILD_DIR" cmake --build "$BUILD_DIR" -j"$(nproc)" ``` +If a sandboxed build fails with `ccache: error: Read-only file system`, treat +that as an environment limitation rather than a repo regression and rerun the +build with `CCACHE_DISABLE=1`. + If the task affects install layout, licensing, or packaging, also validate a temporary install prefix: ```bash @@ -94,6 +102,7 @@ Notes: - A small `Qt Test` + `CTest` suite exists for config loading and audio normalization, including malformed JSON, wrong-type config inputs, and recording-normalizer edge cases - Config loading is intentionally forgiving: invalid runtime values fall back to defaults and log warnings - Use `ctest --test-dir "$BUILD_DIR" --output-on-failure` for changes that affect covered code +- Keep Qt GUI or Widgets tests headless under `CTest`: set `QT_QPA_PLATFORM=offscreen` in the test registration or test properties rather than relying on the caller environment - Use `bash scripts/run-valgrind.sh "$BUILD_DIR"` or `cmake --build "$BUILD_DIR" --target valgrind` when validating memory behavior for release readiness or after fixing memory-lifetime issues - On Debian-family systems, install `libc6-dbg` if Valgrind fails at startup with a `ld-linux` / mandatory redirection error - Use `cmake --build "$BUILD_DIR" --target clang-tidy` after C++ changes when static-analysis noise is likely to matter @@ -114,10 +123,12 @@ Notes: - Keep the Doxygen main page in `docs/mainpage.md` small and API-focused. The release-facing `README.md` may link to files outside the Doxygen input set and should not be used as the Doxygen main page unless the input set is expanded deliberately - Keep analyzer fixes targeted to `src/` and `tests/`; do not churn `third_party/` or generated Qt autogen output to satisfy tooling - Reconfigure the build directory after installing new tools so cached `find_program()` results are refreshed +- When validating inside a restricted sandbox, be ready to disable `ccache` with `CCACHE_DISABLE=1` if the cache location is read-only; that is an execution-environment issue, not a Mutterkey build failure - Prefer fixing the code over weakening `.clang-tidy` or the Clazy check set; only relax tool config when the warning is clearly low-value for this repo - Do not add broad Valgrind suppressions by default; only add narrow suppressions after reproducing stable third-party noise and keep them clearly scoped - When adding tests, prefer small `Qt Test` cases that run headlessly under `CTest` and avoid microphone, clipboard, or KDE session dependencies unless the task is specifically integration-focused - For tool-driven cleanups, preserve the existing design and behavior; do not perform broad rewrites just to satisfy style-oriented recommendations +- Keep forward-looking feature plans under `next_feature/` as tracked Markdown files; do not leave scratch notes, binaries, or generated artifacts there ## Coding Guidelines @@ -128,6 +139,8 @@ Notes: - Avoid introducing optional backends, plugin systems, or cross-platform abstractions unless the task requires them - Keep the audio path explicit: recorder output may not already match Whisper input requirements, so preserve normalization behavior - Prefer narrow shared value types across subsystems; for example, consumers that only need captured audio should include `src/audio/recording.h`, not the full recorder class +- Keep JSON and other transport details at subsystem boundaries; prefer typed C++ snapshots/results once data crosses into app-owned control, tray, or service code +- Prefer dependency injection for tray-shell and control-surface code from the first implementation so headless Qt tests stay simple - Preserve the current product direction: embedded `whisper.cpp`, KDE-first, CLI/service-first ## C++ Core Guidelines Priorities @@ -193,12 +206,16 @@ Typical model location: - Read `README.md` first, especially `Overview`, `Quick Start`, `Run As Service`, and `Development`, then read the touched source files before editing - Prefer targeted changes over speculative cleanup +- If a change grows daemon, tray, or control-plane behavior, prefer extracting or extending repo-owned libraries under `src/app/`, `src/control/`, or other focused modules instead of piling more orchestration into `src/main.cpp` - Update `README.md` and `config.example.json` when behavior or setup changes - Update `contrib/mutterkey.service` and `contrib/org.mutterkey.mutterkey.desktop` when service/desktop behavior changes - Update `LICENSE`, `THIRD_PARTY_NOTICES.md`, CMake install rules, and `third_party/whisper.cpp.UPSTREAM.md` when packaging, licensing, or vendored dependency behavior changes - Keep `README.md`, `AGENTS.md`, and any relevant local skills aligned with the current `scripts/update-whisper.sh` workflow when the vendor-update process changes +- Store upcoming feature plans in `next_feature/` as Markdown files, and update the existing plan there when refining the same upcoming feature instead of scattering notes across the repo +- Treat `mutterkey-tray` as a shipped artifact once it is installed or validated in CI; keep install rules, README/setup notes, release checklist items, and workflow checks aligned with that status - Verify with a fresh CMake build when the change affects compilation or linkage - Run `ctest` when touching covered code in `src/config.*` or `src/audio/recordingnormalizer.*`, and extend the deterministic headless tests when practical +- When adding or fixing Qt GUI tests, make the `CTest` registration itself headless with `QT_QPA_PLATFORM=offscreen` so CI does not try to load `xcb` - Prefer expanding tests around pure parsing, value normalization, and other environment-independent logic before adding KDE-session or device-heavy coverage - Use `-DMUTTERKEY_ENABLE_ASAN=ON` and `-DMUTTERKEY_ENABLE_UBSAN=ON` for fast iteration on memory and UB bugs, and use the repo-owned Valgrind lane as the slower release-focused confirmation step - Run `clang-tidy` and `clazy` targets for non-trivial C++/Qt changes when the tools are installed in the environment diff --git a/CMakeLists.txt b/CMakeLists.txt index d188e46..8b35c4e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,12 +21,12 @@ option(MUTTERKEY_ENABLE_WHISPER_BLAS "Enable whisper.cpp BLAS CPU acceleration" set(MUTTERKEY_WHISPER_BLAS_VENDOR "Generic" CACHE STRING "BLAS vendor passed to whisper.cpp when BLAS acceleration is enabled") set_property(CACHE MUTTERKEY_WHISPER_BLAS_VENDOR PROPERTY STRINGS "Generic;OpenBLAS;FLAME;ATLAS;FlexiBLAS;Intel;NVHPC;Apple") -find_package(Qt6 REQUIRED COMPONENTS Core Gui Multimedia) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Multimedia Network Widgets) find_package(KF6GlobalAccel CONFIG REQUIRED) find_package(KF6GuiAddons CONFIG REQUIRED) find_package(Doxygen QUIET) -set(MUTTERKEY_APP_SOURCES +set(MUTTERKEY_CORE_SOURCES src/audio/audiorecorder.cpp src/audio/audiorecorder.h src/audio/recordingnormalizer.cpp @@ -40,7 +40,6 @@ set(MUTTERKEY_APP_SOURCES src/config.h src/hotkeymanager.cpp src/hotkeymanager.h - src/main.cpp src/service.cpp src/service.h src/transcription/transcriptiontypes.h @@ -50,14 +49,52 @@ set(MUTTERKEY_APP_SOURCES src/transcription/whispercpptranscriber.h ) -add_executable(mutterkey ${MUTTERKEY_APP_SOURCES}) +set(MUTTERKEY_CONTROL_SOURCES + src/control/daemoncontrolclient.cpp + src/control/daemoncontrolclient.h + src/control/daemoncontrolprotocol.cpp + src/control/daemoncontrolprotocol.h + src/control/daemoncontrolserver.cpp + src/control/daemoncontrolserver.h + src/control/daemoncontroltypes.cpp + src/control/daemoncontroltypes.h +) + +add_library(mutterkey_core STATIC ${MUTTERKEY_CORE_SOURCES}) +add_library(mutterkey_control STATIC ${MUTTERKEY_CONTROL_SOURCES}) +add_library(mutterkey_app STATIC + src/app/applicationcommands.cpp + src/app/applicationcommands.h +) + +add_executable(mutterkey + src/main.cpp +) + +add_executable(mutterkey-tray + src/tray/traystatuswindow.cpp + src/tray/traystatuswindow.h + src/traymain.cpp +) +target_include_directories(mutterkey_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_include_directories(mutterkey_control PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_include_directories(mutterkey_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) target_include_directories(mutterkey PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) -target_link_libraries(mutterkey PRIVATE Qt6::Core Qt6::Gui Qt6::Multimedia KF6::GlobalAccel KF6::GuiAddons) +target_include_directories(mutterkey-tray PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_link_libraries(mutterkey_core PUBLIC Qt6::Core Qt6::Gui Qt6::Multimedia KF6::GlobalAccel KF6::GuiAddons) +target_link_libraries(mutterkey_control PUBLIC Qt6::Core Qt6::Network mutterkey_core) +target_link_libraries(mutterkey_app PUBLIC Qt6::Core Qt6::Gui mutterkey_control) +target_link_libraries(mutterkey PRIVATE mutterkey_app whisper) +target_link_libraries(mutterkey-tray PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets mutterkey_control) set_target_properties(mutterkey PROPERTIES BUILD_RPATH "$ORIGIN/../lib" INSTALL_RPATH "$ORIGIN/../lib" ) +set_target_properties(mutterkey-tray PROPERTIES + BUILD_RPATH "$ORIGIN/../lib" + INSTALL_RPATH "$ORIGIN/../lib" +) function(mutterkey_enable_sanitizers target_name) if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") @@ -84,7 +121,11 @@ function(mutterkey_enable_sanitizers target_name) endif() endfunction() +mutterkey_enable_sanitizers(mutterkey_core) +mutterkey_enable_sanitizers(mutterkey_control) +mutterkey_enable_sanitizers(mutterkey_app) mutterkey_enable_sanitizers(mutterkey) +mutterkey_enable_sanitizers(mutterkey-tray) set(MUTTERKEY_CLAZY_CHECKS "level0" CACHE STRING "Checks passed to clazy-standalone") @@ -163,9 +204,10 @@ add_subdirectory(third_party/whisper.cpp EXCLUDE_FROM_ALL) # upstream public headers as part of its own package layout. set_target_properties(whisper ggml PROPERTIES PUBLIC_HEADER "") -target_link_libraries(mutterkey PRIVATE whisper) +target_link_libraries(mutterkey_core PUBLIC whisper) install(TARGETS mutterkey RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(TARGETS mutterkey-tray RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) install(TARGETS whisper ggml ggml-base LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ) diff --git a/README.md b/README.md index 7e97176..62ce4f7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Current direction: - KDE-first - local-only transcription - CLI/service-first operation -- no GUI yet +- tray-shell work has started, but the daemon remains the product core - minimal and developer-oriented rather than a hardened end-user security product Recommended startup path: @@ -46,7 +46,7 @@ Supported environment: Build requirements: -1. Qt 6 development packages with `Core`, `Gui`, and `Multimedia` +1. Qt 6 development packages with `Core`, `Gui`, `Multimedia`, `Network`, and `Widgets` 2. KDE Frameworks development packages for `KGlobalAccel` and `KGuiAddons` 3. `g++` 4. `cmake` @@ -107,6 +107,7 @@ cmake --install "$BUILD_DIR" This installs: - `~/.local/bin/mutterkey` +- `~/.local/bin/mutterkey-tray` - `~/.local/lib/libwhisper.so*` and the required `ggml` libraries - `~/.local/share/applications/org.mutterkey.mutterkey.desktop` @@ -353,6 +354,9 @@ Repository layout: - `src/transcription/transcriptiontypes.h`: normalized-audio and transcription result value types - `src/clipboardwriter.*`: clipboard writes with KDE-first fallback behavior - `src/config.*`: JSON config loading and defaults +- `src/app/*`: shared CLI/runtime command helpers used by the main entrypoint +- `src/control/*`: local daemon control protocol, typed snapshots, and local-socket session/server wiring +- `src/tray/*`: Qt Widgets tray-shell UI scaffolding - `contrib/mutterkey.service`: example user service Build and test: diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 19e24f3..84e0dac 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -105,6 +105,15 @@ ctest --test-dir "$SANITIZER_BUILD_DIR" --output-on-failure QT_QPA_PLATFORM=offscreen "$BUILD_DIR/mutterkey" --help ``` +- Validate tray-shell startup in a headless environment: + +```bash +timeout 2s env QT_QPA_PLATFORM=offscreen "$BUILD_DIR/mutterkey-tray" +``` + +- Treat exit code `124` from the tray-shell smoke check as expected when the + process stays alive until `timeout` stops it. + - If the change affects startup, service wiring, or config handling, also run: ```bash @@ -122,6 +131,7 @@ cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" - Confirm the installed tree contains: - `bin/mutterkey` + - `bin/mutterkey-tray` - required `libwhisper` / `ggml` shared libraries - the desktop file under `share/applications` - license files under `share/licenses/mutterkey` diff --git a/src/app/applicationcommands.cpp b/src/app/applicationcommands.cpp new file mode 100644 index 0000000..cb4c340 --- /dev/null +++ b/src/app/applicationcommands.cpp @@ -0,0 +1,132 @@ +#include "app/applicationcommands.h" + +#include "audio/audiorecorder.h" +#include "clipboardwriter.h" +#include "control/daemoncontrolserver.h" +#include "service.h" +#include "transcription/whispercpptranscriber.h" + +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(appLog, "mutterkey.app") + +void configureLogging(const QString &level) +{ + qSetMessagePattern(QStringLiteral("%{time yyyy-MM-dd hh:mm:ss.zzz} %{if-debug}DEBUG%{endif}%{if-info}INFO%{endif}%{if-warning}WARNING%{endif}%{if-critical}ERROR%{endif}%{if-fatal}FATAL%{endif} %{category}: %{message}")); + + if (level.compare(QStringLiteral("DEBUG"), Qt::CaseInsensitive) == 0) { + QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true")); + } else { + QLoggingCategory::setFilterRules(QStringLiteral("*.debug=false")); + } +} + +int runDaemon(QGuiApplication &app, const AppConfig &config, const QString &configPath) +{ + MutterkeyService service(config, app.clipboard()); + DaemonControlServer controlServer(configPath, config, &service); + QObject::connect(&app, &QCoreApplication::aboutToQuit, &service, &MutterkeyService::stop); + QObject::connect(&app, &QCoreApplication::aboutToQuit, &controlServer, &DaemonControlServer::stop); + + QString errorMessage; + if (!service.start(&errorMessage)) { + qCCritical(appLog) << "Failed to start daemon:" << errorMessage; + return 1; + } + if (!controlServer.start(&errorMessage)) { + qCCritical(appLog) << "Failed to start daemon control server:" << errorMessage; + service.stop(); + return 1; + } + + qCInfo(appLog) << "Mutterkey daemon running. Hold" << config.shortcut.sequence << "to talk."; + return app.exec(); +} + +int runOnce(QGuiApplication &app, const AppConfig &config, double seconds) +{ + AudioRecorder recorder(config.audio); + WhisperCppTranscriber transcriber(config.transcriber); + ClipboardWriter clipboardWriter(app.clipboard()); + + if (config.transcriber.warmupOnStart) { + QString warmupError; + if (!transcriber.warmup(&warmupError)) { + qCCritical(appLog) << "Failed to warm up transcriber:" << warmupError; + return 1; + } + } + + QTimer::singleShot(0, &app, [&app, &recorder, &transcriber, &clipboardWriter, seconds]() { + QString errorMessage; + if (!recorder.start(&errorMessage)) { + qCCritical(appLog) << "Failed to start one-shot recording:" << errorMessage; + app.exit(1); + return; + } + + qCInfo(appLog) << "Recording for" << seconds << "seconds"; + QTimer::singleShot(static_cast(seconds * 1000), &app, [&app, &recorder, &transcriber, &clipboardWriter]() { + const Recording recording = recorder.stop(); + if (!recording.isValid()) { + qCCritical(appLog) << "Recorder returned no audio"; + app.exit(1); + return; + } + + const TranscriptionResult result = transcriber.transcribe(recording); + if (!result.success) { + qCCritical(appLog) << "One-shot transcription failed:" << result.error; + app.exit(1); + return; + } + + if (!result.text.trimmed().isEmpty()) { + const QString trimmedText = result.text.trimmed(); + if (!clipboardWriter.copy(trimmedText)) { + qCWarning(appLog) << "Clipboard update appears to have failed"; + } + QTextStream(stdout) << trimmedText << Qt::endl; + } else { + qCInfo(appLog) << "No speech detected"; + } + app.exit(0); + }); + }); + + return app.exec(); +} + +int runDiagnose(QGuiApplication &app, const AppConfig &config, double seconds, bool invokeShortcut) +{ + MutterkeyService service(config, app.clipboard()); + QObject::connect(&app, &QCoreApplication::aboutToQuit, &service, &MutterkeyService::stop); + + QString errorMessage; + if (!service.start(&errorMessage)) { + qCCritical(appLog) << "Diagnostic startup failed:" << errorMessage; + return 1; + } + + qCInfo(appLog) << "Diagnostic mode active for" << seconds << "seconds. Press the configured shortcut now."; + if (invokeShortcut) { + QTimer::singleShot(750, &app, [&service]() { + QString invokeError; + if (!service.invokeShortcut(&invokeError)) { + qCWarning(appLog) << "Diagnostic shortcut invoke failed:" << invokeError; + } else { + qCInfo(appLog) << "Invoked the registered shortcut through KGlobalAccel"; + } + }); + } + + QTimer::singleShot(static_cast(seconds * 1000), &app, [&app, &service]() { + QTextStream(stdout) << QJsonDocument(service.diagnostics()).toJson(QJsonDocument::Indented); + app.exit(0); + }); + + return app.exec(); +} diff --git a/src/app/applicationcommands.h b/src/app/applicationcommands.h new file mode 100644 index 0000000..d96bb31 --- /dev/null +++ b/src/app/applicationcommands.h @@ -0,0 +1,48 @@ +#pragma once + +#include "config.h" + +#include + +class QGuiApplication; + +Q_DECLARE_LOGGING_CATEGORY(appLog) + +/** + * @file + * @brief Runtime command helpers shared by the CLI entrypoint. + */ + +/** + * @brief Configures runtime log filtering for Mutterkey commands. + * @param level Configured log level string. + */ +void configureLogging(const QString &level); + +/** + * @brief Runs the background daemon mode. + * @param app GUI application object. + * @param config Resolved runtime config snapshot. + * @param configPath Config path associated with the current daemon session. + * @return Process exit code. + */ +int runDaemon(QGuiApplication &app, const AppConfig &config, const QString &configPath); + +/** + * @brief Runs one-shot recording and transcription mode. + * @param app GUI application object. + * @param config Resolved runtime config snapshot. + * @param seconds Recording duration in seconds. + * @return Process exit code. + */ +int runOnce(QGuiApplication &app, const AppConfig &config, double seconds); + +/** + * @brief Runs temporary daemon diagnostics. + * @param app GUI application object. + * @param config Resolved runtime config snapshot. + * @param seconds Diagnostic capture duration in seconds. + * @param invokeShortcut Whether to trigger a synthetic shortcut invoke. + * @return Process exit code. + */ +int runDiagnose(QGuiApplication &app, const AppConfig &config, double seconds, bool invokeShortcut); diff --git a/src/config.cpp b/src/config.cpp index 55465ee..6c4400c 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -390,31 +390,9 @@ AppConfig defaultAppConfig() return config; } -AppConfig loadConfig(const QString &path, QString *errorMessage) +AppConfig loadConfigObject(const QJsonObject &root, const QString &sourceName) { AppConfig config = defaultAppConfig(); - QFile file(path); - if (!file.exists()) { - return config; - } - - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - if (errorMessage != nullptr) { - *errorMessage = QStringLiteral("Could not open config file: %1").arg(path); - } - return config; - } - - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) { - if (errorMessage != nullptr) { - *errorMessage = QStringLiteral("Invalid JSON config %1: %2").arg(path, parseError.errorString()); - } - return config; - } - - const QJsonObject root = document.object(); const QJsonObject shortcut = root.value(QStringLiteral("shortcut")).toObject(); config.shortcut.componentUnique = readString(shortcut, QStringLiteral("component_unique"), config.shortcut.componentUnique); config.shortcut.componentFriendly = readString(shortcut, QStringLiteral("component_friendly"), config.shortcut.componentFriendly); @@ -422,7 +400,7 @@ AppConfig loadConfig(const QString &path, QString *errorMessage) config.shortcut.actionFriendly = readString(shortcut, QStringLiteral("action_friendly"), config.shortcut.actionFriendly); const QString shortcutSequence = sanitizedNonEmptyString(readString(shortcut, QStringLiteral("sequence"), config.shortcut.sequence)); if (shortcutSequence.isEmpty()) { - warnAboutInvalidValue(path, + warnAboutInvalidValue(sourceName, QStringLiteral("shortcut.sequence"), QStringLiteral("value is empty"), config.shortcut.sequence); @@ -431,16 +409,16 @@ AppConfig loadConfig(const QString &path, QString *errorMessage) } const QJsonObject audio = root.value(QStringLiteral("audio")).toObject(); - config.audio.sampleRate = validatedAudioSampleRate(path, readInt(audio, QStringLiteral("sample_rate"), config.audio.sampleRate)); - config.audio.channels = validatedAudioChannels(path, readInt(audio, QStringLiteral("channels"), config.audio.channels)); + config.audio.sampleRate = validatedAudioSampleRate(sourceName, readInt(audio, QStringLiteral("sample_rate"), config.audio.sampleRate)); + config.audio.channels = validatedAudioChannels(sourceName, readInt(audio, QStringLiteral("channels"), config.audio.channels)); config.audio.minimumSeconds = - validatedMinimumSeconds(path, readDouble(audio, QStringLiteral("minimum_seconds"), config.audio.minimumSeconds)); + validatedMinimumSeconds(sourceName, readDouble(audio, QStringLiteral("minimum_seconds"), config.audio.minimumSeconds)); config.audio.deviceId = readString(audio, QStringLiteral("device_id"), config.audio.deviceId); const QJsonObject transcriber = root.value(QStringLiteral("transcriber")).toObject(); const QString modelPath = sanitizedNonEmptyString(readString(transcriber, QStringLiteral("model_path"), defaultModelPath())); if (modelPath.isEmpty()) { - warnAboutInvalidValue(path, + warnAboutInvalidValue(sourceName, QStringLiteral("transcriber.model_path"), QStringLiteral("value is empty"), defaultModelPath()); @@ -452,14 +430,14 @@ AppConfig loadConfig(const QString &path, QString *errorMessage) if (resolveWhisperLanguage(config.transcriber.language, &resolvedLanguage)) { config.transcriber.language = resolvedLanguage; } else { - warnAboutInvalidValue(path, + warnAboutInvalidValue(sourceName, QStringLiteral("transcriber.language"), QStringLiteral("unsupported Whisper language"), defaultAppConfig().transcriber.language); config.transcriber.language = defaultAppConfig().transcriber.language; } config.transcriber.translate = readBool(transcriber, QStringLiteral("translate"), config.transcriber.translate); - config.transcriber.threads = validatedThreads(path, readInt(transcriber, QStringLiteral("threads"), config.transcriber.threads)); + config.transcriber.threads = validatedThreads(sourceName, readInt(transcriber, QStringLiteral("threads"), config.transcriber.threads)); config.transcriber.warmupOnStart = readBool(transcriber, QStringLiteral("warmup_on_start"), config.transcriber.warmupOnStart); @@ -467,17 +445,44 @@ AppConfig loadConfig(const QString &path, QString *errorMessage) if (isSupportedLogLevel(logLevel)) { config.logLevel = logLevel; } else { - warnAboutInvalidValue(path, + warnAboutInvalidValue(sourceName, QStringLiteral("log_level"), QStringLiteral("expected one of DEBUG, INFO, WARNING, ERROR"), config.logLevel); } - qCInfo(configLog) << "Loaded config from" << path; + qCInfo(configLog) << "Loaded config from" << sourceName; return config; } -QByteArray serializeConfig(const AppConfig &config) +AppConfig loadConfig(const QString &path, QString *errorMessage) +{ + AppConfig config = defaultAppConfig(); + QFile file(path); + if (!file.exists()) { + return config; + } + + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Could not open config file: %1").arg(path); + } + return config; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Invalid JSON config %1: %2").arg(path, parseError.errorString()); + } + return config; + } + + return loadConfigObject(document.object(), path); +} + +QJsonObject configToJsonObject(const AppConfig &config) { QJsonObject shortcut; shortcut.insert(QStringLiteral("component_unique"), config.shortcut.componentUnique); @@ -505,7 +510,12 @@ QByteArray serializeConfig(const AppConfig &config) root.insert(QStringLiteral("transcriber"), transcriber); root.insert(QStringLiteral("log_level"), config.logLevel); - return QJsonDocument(root).toJson(QJsonDocument::Indented); + return root; +} + +QByteArray serializeConfig(const AppConfig &config) +{ + return QJsonDocument(configToJsonObject(config)).toJson(QJsonDocument::Indented); } bool saveConfig(const QString &path, const AppConfig &config, QString *errorMessage) diff --git a/src/config.h b/src/config.h index 08ce5ee..a9d8898 100644 --- a/src/config.h +++ b/src/config.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -103,6 +104,18 @@ AppConfig defaultAppConfig(); */ AppConfig loadConfig(const QString &path, QString *errorMessage = nullptr); +/** + * @brief Loads a config object and applies the same fallback rules as file loading. + * + * This is primarily used by in-memory control-plane payloads so the tray and + * daemon share one config contract instead of maintaining parallel JSON readers. + * + * @param root JSON object shaped like the saved config file. + * @param sourceName Human-readable source label used in warning messages. + * @return Resolved application config snapshot. + */ +AppConfig loadConfigObject(const QJsonObject &root, const QString &sourceName = QStringLiteral("")); + /** * @brief Serializes a config snapshot to indented JSON. * @param config Config snapshot to serialize. @@ -110,6 +123,13 @@ AppConfig loadConfig(const QString &path, QString *errorMessage = nullptr); */ QByteArray serializeConfig(const AppConfig &config); +/** + * @brief Converts a config snapshot to the saved JSON object shape. + * @param config Config snapshot to convert. + * @return JSON object matching the on-disk config structure. + */ +QJsonObject configToJsonObject(const AppConfig &config); + /** * @brief Saves a config snapshot to disk, creating parent directories as needed. * @param path Destination config path. diff --git a/src/control/daemoncontrolclient.cpp b/src/control/daemoncontrolclient.cpp new file mode 100644 index 0000000..72a2dfb --- /dev/null +++ b/src/control/daemoncontrolclient.cpp @@ -0,0 +1,117 @@ +#include "control/daemoncontrolclient.h" + +#include +#include + +LocalDaemonControlSession::LocalDaemonControlSession(QString socketName) + : m_socketName(std::move(socketName)) +{ +} + +DaemonControlResponse LocalDaemonControlSession::requestRaw(DaemonControlMethod method, + int timeoutMs, + QString *transportError) const +{ + if (transportError != nullptr) { + transportError->clear(); + } + + QLocalSocket socket; + socket.connectToServer(m_socketName); + if (!socket.waitForConnected(timeoutMs)) { + if (transportError != nullptr) { + *transportError = socket.errorString(); + } + return {}; + } + + DaemonControlRequest request; + request.requestId = QUuid::createUuid().toString(QUuid::WithoutBraces); + request.method = method; + + const QByteArray payload = serializeDaemonControlRequest(request); + if (socket.write(payload) != payload.size() || !socket.waitForBytesWritten(timeoutMs)) { + if (transportError != nullptr) { + *transportError = socket.errorString(); + } + return {}; + } + + while (!socket.canReadLine()) { + if (!socket.waitForReadyRead(timeoutMs)) { + if (transportError != nullptr) { + *transportError = socket.errorString(); + } + return {}; + } + } + + const QByteArray responsePayload = socket.readLine(); + DaemonControlResponse response; + QString parseError; + if (!parseDaemonControlResponse(responsePayload, &response, &parseError)) { + if (transportError != nullptr) { + *transportError = parseError; + } + return {}; + } + + if (response.requestId != request.requestId) { + if (transportError != nullptr) { + *transportError = QStringLiteral("Mismatched daemon response id"); + } + return {}; + } + + return response; +} + +DaemonStatusResult LocalDaemonControlSession::fetchStatus(int timeoutMs) const +{ + QString transportError; + const DaemonControlResponse response = requestRaw(DaemonControlMethod::GetStatus, timeoutMs, &transportError); + + DaemonStatusResult result; + if (!transportError.isEmpty()) { + result.errorMessage = transportError; + return result; + } + if (!response.success) { + result.errorMessage = response.errorMessage; + return result; + } + + QString parseError; + if (!parseDaemonStatusSnapshot(response.result, &result.snapshot, &parseError)) { + result.errorMessage = parseError; + return result; + } + + result.success = true; + return result; +} + +DaemonConfigResult LocalDaemonControlSession::fetchConfig(int timeoutMs) const +{ + QString transportError; + const DaemonControlResponse response = requestRaw(DaemonControlMethod::GetConfig, timeoutMs, &transportError); + + DaemonConfigResult result; + if (!transportError.isEmpty()) { + result.errorMessage = transportError; + return result; + } + if (!response.success) { + result.errorMessage = response.errorMessage; + return result; + } + + QString parseError; + if (!parseDaemonConfigSnapshot(response.result, &result.snapshot, &parseError)) { + result.errorMessage = parseError; + return result; + } + + result.success = true; + return result; +} diff --git a/src/control/daemoncontrolclient.h b/src/control/daemoncontrolclient.h new file mode 100644 index 0000000..07fbe77 --- /dev/null +++ b/src/control/daemoncontrolclient.h @@ -0,0 +1,96 @@ +#pragma once + +#include "control/daemoncontrolprotocol.h" +#include "control/daemoncontroltypes.h" + +/** + * @file + * @brief Synchronous client for the local Mutterkey daemon control socket. + */ + +/** + * @brief Result of a daemon status query. + */ +struct DaemonStatusResult { + /// Indicates whether the daemon query completed successfully. + bool success = false; + /// Parsed typed snapshot when `success` is `true`. + DaemonStatusSnapshot snapshot; + /// Human-readable failure reason when `success` is `false`. + QString errorMessage; +}; + +/** + * @brief Result of a daemon config query. + */ +struct DaemonConfigResult { + /// Indicates whether the daemon query completed successfully. + bool success = false; + /// Parsed typed snapshot when `success` is `true`. + DaemonConfigSnapshot snapshot; + /// Human-readable failure reason when `success` is `false`. + QString errorMessage; +}; + +/** + * @brief Interface used by tray-facing code to query daemon state. + */ +class DaemonControlSession +{ +public: + DaemonControlSession() = default; + virtual ~DaemonControlSession() = default; + DaemonControlSession(const DaemonControlSession &) = delete; + DaemonControlSession &operator=(const DaemonControlSession &) = delete; + DaemonControlSession(DaemonControlSession &&) = delete; + DaemonControlSession &operator=(DaemonControlSession &&) = delete; + + /** + * @brief Requests a typed daemon status snapshot. + * @param timeoutMs Connect, write, and read timeout in milliseconds. + * @return Typed status result. + */ + [[nodiscard]] virtual DaemonStatusResult fetchStatus(int timeoutMs) const = 0; + + /** + * @brief Requests a typed daemon config snapshot. + * @param timeoutMs Connect, write, and read timeout in milliseconds. + * @return Typed config result. + */ + [[nodiscard]] virtual DaemonConfigResult fetchConfig(int timeoutMs) const = 0; +}; + +/** + * @brief Concrete local-socket implementation of the daemon control session API. + */ +class LocalDaemonControlSession final : public DaemonControlSession +{ +public: + /** + * @brief Creates a session bound to a named local socket endpoint. + * @param socketName Local socket name to connect to. + */ + explicit LocalDaemonControlSession(QString socketName = daemonControlSocketName()); + + /** + * @brief Requests a typed daemon status snapshot. + * @param timeoutMs Connect, write, and read timeout in milliseconds. + * @return Typed status result. + */ + [[nodiscard]] DaemonStatusResult fetchStatus(int timeoutMs) const override; + + /** + * @brief Requests a typed daemon config snapshot. + * @param timeoutMs Connect, write, and read timeout in milliseconds. + * @return Typed config result. + */ + [[nodiscard]] DaemonConfigResult fetchConfig(int timeoutMs) const override; + +private: + [[nodiscard]] DaemonControlResponse requestRaw(DaemonControlMethod method, + int timeoutMs, + QString *transportError = nullptr) const; + + /// Local socket endpoint name used for daemon requests. + QString m_socketName; +}; diff --git a/src/control/daemoncontrolprotocol.cpp b/src/control/daemoncontrolprotocol.cpp new file mode 100644 index 0000000..53a828a --- /dev/null +++ b/src/control/daemoncontrolprotocol.cpp @@ -0,0 +1,228 @@ +#include "control/daemoncontrolprotocol.h" + +#include +#include + +namespace { + +constexpr int kDaemonControlProtocolVersion = 1; +constexpr auto kSocketName = "mutterkey-daemon-control-v1"; + +bool readVersion(const QJsonObject &object, int *versionOut, QString *errorMessage) +{ + if (versionOut == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing version output target"); + } + return false; + } + + const QJsonValue versionValue = object.value(QStringLiteral("version")); + if (!versionValue.isDouble()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Missing numeric protocol version"); + } + return false; + } + + const int version = versionValue.toInt(-1); + if (version != kDaemonControlProtocolVersion) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Unsupported protocol version: %1").arg(version); + } + return false; + } + + *versionOut = version; + return true; +} + +bool parseObjectDocument(const QByteArray &payload, QJsonObject *objectOut, QString *errorMessage) +{ + if (objectOut == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing JSON object output target"); + } + return false; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload.trimmed(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Invalid JSON payload: %1").arg(parseError.errorString()); + } + return false; + } + + *objectOut = document.object(); + return true; +} + +} // namespace + +QString daemonControlSocketName() +{ + return QString::fromLatin1(kSocketName); +} + +QString daemonControlMethodToString(DaemonControlMethod method) +{ + switch (method) { + case DaemonControlMethod::Ping: + return QStringLiteral("ping"); + case DaemonControlMethod::GetStatus: + return QStringLiteral("get_status"); + case DaemonControlMethod::GetConfig: + return QStringLiteral("get_config"); + } + + return QStringLiteral("unknown"); +} + +bool parseDaemonControlMethod(QStringView value, DaemonControlMethod *methodOut) +{ + if (methodOut == nullptr) { + return false; + } + + if (value == QStringLiteral("ping")) { + *methodOut = DaemonControlMethod::Ping; + return true; + } + if (value == QStringLiteral("get_status")) { + *methodOut = DaemonControlMethod::GetStatus; + return true; + } + if (value == QStringLiteral("get_config")) { + *methodOut = DaemonControlMethod::GetConfig; + return true; + } + + return false; +} + +QByteArray serializeDaemonControlRequest(const DaemonControlRequest &request) +{ + QJsonObject object; + object.insert(QStringLiteral("version"), request.version); + object.insert(QStringLiteral("request_id"), request.requestId); + object.insert(QStringLiteral("method"), daemonControlMethodToString(request.method)); + return QJsonDocument(object).toJson(QJsonDocument::Compact) + '\n'; +} + +QByteArray serializeDaemonControlResponse(const DaemonControlResponse &response) +{ + QJsonObject object; + object.insert(QStringLiteral("version"), response.version); + object.insert(QStringLiteral("request_id"), response.requestId); + object.insert(QStringLiteral("ok"), response.success); + if (response.success) { + object.insert(QStringLiteral("result"), response.result); + } else { + object.insert(QStringLiteral("error"), response.errorMessage); + } + return QJsonDocument(object).toJson(QJsonDocument::Compact) + '\n'; +} + +bool parseDaemonControlRequest(const QByteArray &payload, DaemonControlRequest *requestOut, QString *errorMessage) +{ + if (requestOut == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing request output target"); + } + return false; + } + + QJsonObject object; + if (!parseObjectDocument(payload, &object, errorMessage)) { + return false; + } + + if (!readVersion(object, &requestOut->version, errorMessage)) { + return false; + } + + const QJsonValue requestIdValue = object.value(QStringLiteral("request_id")); + if (!requestIdValue.isString() || requestIdValue.toString().trimmed().isEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Missing non-empty request_id"); + } + return false; + } + requestOut->requestId = requestIdValue.toString().trimmed(); + + const QJsonValue methodValue = object.value(QStringLiteral("method")); + if (!methodValue.isString() + || !parseDaemonControlMethod(methodValue.toString(), &requestOut->method)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Unsupported daemon control method"); + } + return false; + } + + return true; +} + +bool parseDaemonControlResponse(const QByteArray &payload, DaemonControlResponse *responseOut, QString *errorMessage) +{ + if (responseOut == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing response output target"); + } + return false; + } + + QJsonObject object; + if (!parseObjectDocument(payload, &object, errorMessage)) { + return false; + } + + if (!readVersion(object, &responseOut->version, errorMessage)) { + return false; + } + + const QJsonValue requestIdValue = object.value(QStringLiteral("request_id")); + if (!requestIdValue.isString() || requestIdValue.toString().trimmed().isEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Missing non-empty request_id"); + } + return false; + } + responseOut->requestId = requestIdValue.toString().trimmed(); + + const QJsonValue okValue = object.value(QStringLiteral("ok")); + if (!okValue.isBool()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Missing boolean ok field"); + } + return false; + } + + responseOut->success = okValue.toBool(); + responseOut->result = QJsonObject{}; + responseOut->errorMessage.clear(); + + if (responseOut->success) { + const QJsonValue resultValue = object.value(QStringLiteral("result")); + if (!resultValue.isObject()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Successful response is missing result object"); + } + return false; + } + responseOut->result = resultValue.toObject(); + return true; + } + + const QJsonValue errorValue = object.value(QStringLiteral("error")); + if (!errorValue.isString() || errorValue.toString().trimmed().isEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Failed response is missing error text"); + } + return false; + } + + responseOut->errorMessage = errorValue.toString().trimmed(); + return true; +} diff --git a/src/control/daemoncontrolprotocol.h b/src/control/daemoncontrolprotocol.h new file mode 100644 index 0000000..8856f78 --- /dev/null +++ b/src/control/daemoncontrolprotocol.h @@ -0,0 +1,107 @@ +#pragma once + +#include + +#include +#include +#include + +/** + * @file + * @brief Local daemon control protocol types and JSON serialization helpers. + */ + +/** + * @brief Supported operations on the local daemon control socket. + */ +enum class DaemonControlMethod : std::uint8_t { + /// Lightweight health check that returns daemon identity information. + Ping, + /// Returns daemon-authoritative runtime status and service diagnostics. + GetStatus, + /// Returns the daemon's current config snapshot and config-path metadata. + GetConfig, +}; + +/** + * @brief One line-delimited local control request. + */ +struct DaemonControlRequest { + /// Protocol version expected by the daemon and tray client. + int version = 1; + /// Caller-generated correlation id echoed by the daemon response. + QString requestId; + /// Requested daemon operation. + DaemonControlMethod method = DaemonControlMethod::Ping; +}; + +/** + * @brief One line-delimited local control response. + */ +struct DaemonControlResponse { + /// Protocol version returned by the daemon. + int version = 1; + /// Correlation id copied from the triggering request. + QString requestId; + /// Indicates whether the request completed successfully. + bool success = false; + /// Successful result object returned by the daemon. + QJsonObject result; + /// Human-readable error for failed requests. + QString errorMessage; +}; + +/** + * @brief Returns the local server name used by the daemon control socket. + * @return Transport-specific socket/server name. + */ +QString daemonControlSocketName(); + +/** + * @brief Converts a control method enum to its wire-format string name. + * @param method Method value to convert. + * @return Canonical protocol string for the method. + */ +QString daemonControlMethodToString(DaemonControlMethod method); + +/** + * @brief Parses a wire-format control method name. + * @param value Method string from a protocol payload. + * @param methodOut Output target for the parsed method. + * @return `true` when the method is recognized. + */ +bool parseDaemonControlMethod(QStringView value, DaemonControlMethod *methodOut); + +/** + * @brief Serializes a request to compact line-delimited JSON. + * @param request Request value to serialize. + * @return UTF-8 payload terminated with `\n`. + */ +QByteArray serializeDaemonControlRequest(const DaemonControlRequest &request); + +/** + * @brief Serializes a response to compact line-delimited JSON. + * @param response Response value to serialize. + * @return UTF-8 payload terminated with `\n`. + */ +QByteArray serializeDaemonControlResponse(const DaemonControlResponse &response); + +/** + * @brief Parses a line-delimited JSON request payload. + * @param payload UTF-8 request payload. + * @param requestOut Output target for the parsed request. + * @param errorMessage Optional output for parse failures. + * @return `true` when the payload is valid. + */ +bool parseDaemonControlRequest(const QByteArray &payload, DaemonControlRequest *requestOut, QString *errorMessage = nullptr); + +/** + * @brief Parses a line-delimited JSON response payload. + * @param payload UTF-8 response payload. + * @param responseOut Output target for the parsed response. + * @param errorMessage Optional output for parse failures. + * @return `true` when the payload is valid. + */ +bool parseDaemonControlResponse(const QByteArray &payload, + DaemonControlResponse *responseOut, + QString *errorMessage = nullptr); diff --git a/src/control/daemoncontrolserver.cpp b/src/control/daemoncontrolserver.cpp new file mode 100644 index 0000000..a3c4c98 --- /dev/null +++ b/src/control/daemoncontrolserver.cpp @@ -0,0 +1,120 @@ +#include "control/daemoncontrolserver.h" + +#include "control/daemoncontrolprotocol.h" + +#include +#include +#include +#include + +DaemonControlServer::DaemonControlServer(QString configPath, + AppConfig config, + const MutterkeyService *service, + QObject *parent) + : QObject(parent) + , m_configPath(std::move(configPath)) + , m_config(std::move(config)) + , m_service(service) + , m_server(new QLocalServer(this)) +{ + connect(m_server, &QLocalServer::newConnection, this, &DaemonControlServer::onNewConnection); +} + +DaemonControlServer::~DaemonControlServer() +{ + stop(); +} + +bool DaemonControlServer::start(QString *errorMessage) +{ + QLocalServer::removeServer(daemonControlSocketName()); + if (m_server->listen(daemonControlSocketName())) { + return true; + } + + if (errorMessage != nullptr) { + *errorMessage = m_server->errorString(); + } + return false; +} + +void DaemonControlServer::stop() +{ + if (m_server->isListening()) { + m_server->close(); + QLocalServer::removeServer(daemonControlSocketName()); + } +} + +void DaemonControlServer::onNewConnection() +{ + while (QLocalSocket *socket = m_server->nextPendingConnection()) { + socket->setParent(this); + connect(socket, &QLocalSocket::readyRead, this, &DaemonControlServer::onSocketReadyRead); + connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater); + } +} + +void DaemonControlServer::onSocketReadyRead() +{ + auto *socket = qobject_cast(sender()); + if (socket == nullptr) { + return; + } + + while (socket->canReadLine()) { + const QByteArray response = handleRequest(socket->readLine()); + socket->write(response); + socket->flush(); + } +} + +QByteArray DaemonControlServer::handleRequest(const QByteArray &payload) const +{ + DaemonControlRequest request; + QString parseError; + if (!parseDaemonControlRequest(payload, &request, &parseError)) { + DaemonControlResponse response; + response.requestId = QStringLiteral("invalid"); + response.errorMessage = parseError; + return serializeDaemonControlResponse(response); + } + + DaemonControlResponse response; + response.requestId = request.requestId; + response.success = true; + + switch (request.method) { + case DaemonControlMethod::Ping: + response.result.insert(QStringLiteral("application"), QCoreApplication::applicationName()); + response.result.insert(QStringLiteral("socket_name"), daemonControlSocketName()); + break; + case DaemonControlMethod::GetStatus: + response.result = daemonStatusSnapshotToJsonObject(buildStatusSnapshot()); + break; + case DaemonControlMethod::GetConfig: + response.result = daemonConfigSnapshotToJsonObject(buildConfigSnapshot()); + break; + } + + return serializeDaemonControlResponse(response); +} + +DaemonStatusSnapshot DaemonControlServer::buildStatusSnapshot() const +{ + DaemonStatusSnapshot snapshot; + snapshot.daemonRunning = true; + snapshot.configPath = m_configPath; + snapshot.configExists = QFileInfo::exists(m_configPath); + snapshot.serviceDiagnostics = m_service != nullptr ? m_service->diagnostics() : QJsonObject{}; + return snapshot; +} + +DaemonConfigSnapshot DaemonControlServer::buildConfigSnapshot() const +{ + DaemonConfigSnapshot snapshot; + snapshot.configPath = m_configPath; + snapshot.configExists = QFileInfo::exists(m_configPath); + snapshot.config = m_config; + return snapshot; +} diff --git a/src/control/daemoncontrolserver.h b/src/control/daemoncontrolserver.h new file mode 100644 index 0000000..aac2419 --- /dev/null +++ b/src/control/daemoncontrolserver.h @@ -0,0 +1,67 @@ +#pragma once + +#include "control/daemoncontroltypes.h" +#include "config.h" +#include "service.h" + +#include + +class QLocalServer; + +/** + * @file + * @brief Local socket server that exposes a narrow daemon control API. + */ + +/** + * @brief Serves daemon status and config requests over a local socket. + */ +class DaemonControlServer final : public QObject +{ + Q_OBJECT + +public: + /** + * @brief Creates the local control server for a running daemon. + * @param configPath Resolved daemon config path. + * @param config Startup config snapshot exposed through the control API. + * @param service Non-owning pointer to the running daemon service. + * @param parent Optional QObject parent. + */ + explicit DaemonControlServer(QString configPath, + AppConfig config, + const MutterkeyService *service, + QObject *parent = nullptr); + /** + * @brief Stops the local server and releases the socket name. + */ + ~DaemonControlServer() override; + + Q_DISABLE_COPY_MOVE(DaemonControlServer) + + /** + * @brief Starts listening on the local daemon control socket. + * @param errorMessage Optional output for startup failures. + * @return `true` when the server is listening. + */ + bool start(QString *errorMessage = nullptr); + + /** + * @brief Stops listening and removes the local socket endpoint. + */ + void stop(); + +private slots: + void onNewConnection(); + void onSocketReadyRead(); + +private: + [[nodiscard]] QByteArray handleRequest(const QByteArray &payload) const; + [[nodiscard]] DaemonStatusSnapshot buildStatusSnapshot() const; + [[nodiscard]] DaemonConfigSnapshot buildConfigSnapshot() const; + + QString m_configPath; + AppConfig m_config; + const MutterkeyService *m_service = nullptr; + QLocalServer *m_server = nullptr; +}; diff --git a/src/control/daemoncontroltypes.cpp b/src/control/daemoncontroltypes.cpp new file mode 100644 index 0000000..791b3b5 --- /dev/null +++ b/src/control/daemoncontroltypes.cpp @@ -0,0 +1,74 @@ +#include "control/daemoncontroltypes.h" + +#include + +QJsonObject daemonStatusSnapshotToJsonObject(const DaemonStatusSnapshot &snapshot) +{ + QJsonObject object; + object.insert(QStringLiteral("daemon_running"), snapshot.daemonRunning); + object.insert(QStringLiteral("config_path"), snapshot.configPath); + object.insert(QStringLiteral("config_exists"), snapshot.configExists); + object.insert(QStringLiteral("service"), snapshot.serviceDiagnostics); + return object; +} + +QJsonObject daemonConfigSnapshotToJsonObject(const DaemonConfigSnapshot &snapshot) +{ + QJsonObject object; + object.insert(QStringLiteral("config_path"), snapshot.configPath); + object.insert(QStringLiteral("config_exists"), snapshot.configExists); + object.insert(QStringLiteral("config"), configToJsonObject(snapshot.config)); + return object; +} + +bool parseDaemonStatusSnapshot(const QJsonObject &object, DaemonStatusSnapshot *snapshotOut, QString *errorMessage) +{ + if (snapshotOut == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing status snapshot output target"); + } + return false; + } + + const QJsonValue daemonRunningValue = object.value(QStringLiteral("daemon_running")); + const QJsonValue configPathValue = object.value(QStringLiteral("config_path")); + const QJsonValue configExistsValue = object.value(QStringLiteral("config_exists")); + const QJsonValue serviceValue = object.value(QStringLiteral("service")); + if (!daemonRunningValue.isBool() || !configPathValue.isString() || !configExistsValue.isBool() || !serviceValue.isObject()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Malformed daemon status payload"); + } + return false; + } + + snapshotOut->daemonRunning = daemonRunningValue.toBool(); + snapshotOut->configPath = configPathValue.toString(); + snapshotOut->configExists = configExistsValue.toBool(); + snapshotOut->serviceDiagnostics = serviceValue.toObject(); + return true; +} + +bool parseDaemonConfigSnapshot(const QJsonObject &object, DaemonConfigSnapshot *snapshotOut, QString *errorMessage) +{ + if (snapshotOut == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing config snapshot output target"); + } + return false; + } + + const QJsonValue configPathValue = object.value(QStringLiteral("config_path")); + const QJsonValue configExistsValue = object.value(QStringLiteral("config_exists")); + const QJsonValue configValue = object.value(QStringLiteral("config")); + if (!configPathValue.isString() || !configExistsValue.isBool() || !configValue.isObject()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Malformed daemon config payload"); + } + return false; + } + + snapshotOut->configPath = configPathValue.toString(); + snapshotOut->configExists = configExistsValue.toBool(); + snapshotOut->config = loadConfigObject(configValue.toObject(), QStringLiteral("daemon control config payload")); + return true; +} diff --git a/src/control/daemoncontroltypes.h b/src/control/daemoncontroltypes.h new file mode 100644 index 0000000..c8cfe9f --- /dev/null +++ b/src/control/daemoncontroltypes.h @@ -0,0 +1,69 @@ +#pragma once + +#include "config.h" + +#include +#include + +/** + * @file + * @brief Typed daemon-control payloads used above the transport layer. + */ + +/** + * @brief Daemon-owned runtime status snapshot for tray and CLI clients. + */ +struct DaemonStatusSnapshot { + /// Indicates whether the daemon reported itself as running. + bool daemonRunning = false; + /// Resolved config path used by the daemon process. + QString configPath; + /// Indicates whether the config path currently exists on disk. + bool configExists = false; + /// Daemon-owned service diagnostics payload for troubleshooting and status UI. + QJsonObject serviceDiagnostics; +}; + +/** + * @brief Daemon-owned config snapshot for tray and CLI clients. + */ +struct DaemonConfigSnapshot { + /// Resolved config path used by the daemon process. + QString configPath; + /// Indicates whether the config path currently exists on disk. + bool configExists = false; + /// Resolved config snapshot exposed to control-plane clients. + AppConfig config; +}; + +/** + * @brief Converts a typed daemon status snapshot to its transport JSON shape. + * @param snapshot Snapshot to serialize. + * @return JSON object suitable for the daemon control protocol payload. + */ +QJsonObject daemonStatusSnapshotToJsonObject(const DaemonStatusSnapshot &snapshot); + +/** + * @brief Converts a typed daemon config snapshot to its transport JSON shape. + * @param snapshot Snapshot to serialize. + * @return JSON object suitable for the daemon control protocol payload. + */ +QJsonObject daemonConfigSnapshotToJsonObject(const DaemonConfigSnapshot &snapshot); + +/** + * @brief Parses a typed daemon status snapshot from a protocol payload object. + * @param object JSON object returned by the daemon control protocol. + * @param snapshotOut Output target for the parsed snapshot. + * @param errorMessage Optional output for parse failures. + * @return `true` when the payload is valid. + */ +bool parseDaemonStatusSnapshot(const QJsonObject &object, DaemonStatusSnapshot *snapshotOut, QString *errorMessage = nullptr); + +/** + * @brief Parses a typed daemon config snapshot from a protocol payload object. + * @param object JSON object returned by the daemon control protocol. + * @param snapshotOut Output target for the parsed snapshot. + * @param errorMessage Optional output for parse failures. + * @return `true` when the payload is valid. + */ +bool parseDaemonConfigSnapshot(const QJsonObject &object, DaemonConfigSnapshot *snapshotOut, QString *errorMessage = nullptr); diff --git a/src/main.cpp b/src/main.cpp index 2bb81ee..aac6c8b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,18 +1,12 @@ -#include "audio/audiorecorder.h" -#include "clipboardwriter.h" +#include "app/applicationcommands.h" #include "commanddispatch.h" #include "config.h" -#include "service.h" -#include "transcription/whispercpptranscriber.h" #include #include #include #include -#include -#include #include -#include #if defined(Q_OS_WIN) #include @@ -20,8 +14,6 @@ #include #endif -Q_LOGGING_CATEGORY(appLog, "mutterkey.app") - namespace { struct ConfigOverride { @@ -434,125 +426,6 @@ int runConfigCommand(const QStringList &arguments) return exitWithError(QStringLiteral("Unknown config subcommand: %1").arg(subcommand)); } -// -// logging -// - -void configureLogging(const QString &level) -{ - qSetMessagePattern(QStringLiteral("%{time yyyy-MM-dd hh:mm:ss.zzz} %{if-debug}DEBUG%{endif}%{if-info}INFO%{endif}%{if-warning}WARNING%{endif}%{if-critical}ERROR%{endif}%{if-fatal}FATAL%{endif} %{category}: %{message}")); - - if (level.compare(QStringLiteral("DEBUG"), Qt::CaseInsensitive) == 0) { - QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true")); - } else { - QLoggingCategory::setFilterRules(QStringLiteral("*.debug=false")); - } -} - -// -// command entrypoints -// - -int runDaemon(QGuiApplication &app, const AppConfig &config) -{ - MutterkeyService service(config, app.clipboard()); - QObject::connect(&app, &QCoreApplication::aboutToQuit, &service, &MutterkeyService::stop); - - QString errorMessage; - if (!service.start(&errorMessage)) { - qCCritical(appLog) << "Failed to start daemon:" << errorMessage; - return 1; - } - - qCInfo(appLog) << "Mutterkey daemon running. Hold" << config.shortcut.sequence << "to talk."; - return app.exec(); -} - -int runOnce(QGuiApplication &app, const AppConfig &config, double seconds) -{ - AudioRecorder recorder(config.audio); - WhisperCppTranscriber transcriber(config.transcriber); - ClipboardWriter clipboardWriter(app.clipboard()); - - if (config.transcriber.warmupOnStart) { - QString warmupError; - if (!transcriber.warmup(&warmupError)) { - qCCritical(appLog) << "Failed to warm up transcriber:" << warmupError; - return 1; - } - } - - QTimer::singleShot(0, &app, [&app, &recorder, &transcriber, &clipboardWriter, seconds]() { - QString errorMessage; - if (!recorder.start(&errorMessage)) { - qCCritical(appLog) << "Failed to start one-shot recording:" << errorMessage; - app.exit(1); - return; - } - - qCInfo(appLog) << "Recording for" << seconds << "seconds"; - QTimer::singleShot(static_cast(seconds * 1000), &app, [&app, &recorder, &transcriber, &clipboardWriter]() { - const Recording recording = recorder.stop(); - if (!recording.isValid()) { - qCCritical(appLog) << "Recorder returned no audio"; - app.exit(1); - return; - } - - const TranscriptionResult result = transcriber.transcribe(recording); - if (!result.success) { - qCCritical(appLog) << "One-shot transcription failed:" << result.error; - app.exit(1); - return; - } - - if (!result.text.trimmed().isEmpty()) { - const QString trimmedText = result.text.trimmed(); - if (!clipboardWriter.copy(trimmedText)) { - qCWarning(appLog) << "Clipboard update appears to have failed"; - } - QTextStream(stdout) << trimmedText << Qt::endl; - } else { - qCInfo(appLog) << "No speech detected"; - } - app.exit(0); - }); - }); - - return app.exec(); -} - -int runDiagnose(QGuiApplication &app, const AppConfig &config, double seconds, bool invokeShortcut) -{ - MutterkeyService service(config, app.clipboard()); - QObject::connect(&app, &QCoreApplication::aboutToQuit, &service, &MutterkeyService::stop); - - QString errorMessage; - if (!service.start(&errorMessage)) { - qCCritical(appLog) << "Diagnostic startup failed:" << errorMessage; - return 1; - } - - qCInfo(appLog) << "Diagnostic mode active for" << seconds << "seconds. Press the configured shortcut now."; - if (invokeShortcut) { - QTimer::singleShot(750, &app, [&service]() { - QString invokeError; - if (!service.invokeShortcut(&invokeError)) { - qCWarning(appLog) << "Diagnostic shortcut invoke failed:" << invokeError; - } else { - qCInfo(appLog) << "Invoked the registered shortcut through KGlobalAccel"; - } - }); - } - - QTimer::singleShot(static_cast(seconds * 1000), &app, [&app, &service]() { - QTextStream(stdout) << QJsonDocument(service.diagnostics()).toJson(QJsonDocument::Indented); - app.exit(0); - }); - - return app.exec(); -} - } // namespace int main(int argc, char *argv[]) @@ -697,5 +570,5 @@ int main(int argc, char *argv[]) parser.showHelp(1); } - return runDaemon(app, config); + return runDaemon(app, config, configPath); } diff --git a/src/tray/traystatuswindow.cpp b/src/tray/traystatuswindow.cpp new file mode 100644 index 0000000..042aeb5 --- /dev/null +++ b/src/tray/traystatuswindow.cpp @@ -0,0 +1,90 @@ +#include "tray/traystatuswindow.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QString prettyJson(const QJsonObject &object) +{ + return QString::fromUtf8(QJsonDocument(object).toJson(QJsonDocument::Indented)); +} + +} // namespace + +TrayStatusWindow::TrayStatusWindow(DaemonControlSession *client, QWidget *parent) + : QWidget(parent) + , m_session(client != nullptr ? client : &m_ownedSession) +{ + setWindowTitle(QStringLiteral("Mutterkey Status")); + resize(720, 480); + + auto *layout = new QVBoxLayout(this); + auto *formLayout = new QFormLayout; + + m_connectionValue = new QLabel(this); + m_connectionValue->setObjectName(QStringLiteral("connectionValue")); + m_configPathValue = new QLabel(this); + m_configPathValue->setObjectName(QStringLiteral("configPathValue")); + m_shortcutValue = new QLabel(this); + m_shortcutValue->setObjectName(QStringLiteral("shortcutValue")); + m_modelPathValue = new QLabel(this); + m_modelPathValue->setObjectName(QStringLiteral("modelPathValue")); + + formLayout->addRow(QStringLiteral("Connection"), m_connectionValue); + formLayout->addRow(QStringLiteral("Config path"), m_configPathValue); + formLayout->addRow(QStringLiteral("Shortcut"), m_shortcutValue); + formLayout->addRow(QStringLiteral("Model path"), m_modelPathValue); + + m_statusJsonView = new QPlainTextEdit(this); + m_statusJsonView->setObjectName(QStringLiteral("statusJsonView")); + m_statusJsonView->setReadOnly(true); + + auto *refreshButton = new QPushButton(QStringLiteral("Refresh"), this); + connect(refreshButton, &QPushButton::clicked, this, &TrayStatusWindow::refresh); + + layout->addLayout(formLayout); + layout->addWidget(m_statusJsonView, 1); + layout->addWidget(refreshButton); + + refresh(); +} + +void TrayStatusWindow::refresh() +{ + const DaemonStatusResult statusResult = m_session->fetchStatus(1500); + if (!statusResult.success) { + setOfflineState(statusResult.errorMessage); + return; + } + + const DaemonConfigResult configResult = m_session->fetchConfig(1500); + if (!configResult.success) { + setOfflineState(configResult.errorMessage); + return; + } + + m_connectionValue->setText(QStringLiteral("Connected")); + m_configPathValue->setText(statusResult.snapshot.configPath); + m_shortcutValue->setText(configResult.snapshot.config.shortcut.sequence); + m_modelPathValue->setText(configResult.snapshot.config.transcriber.modelPath); + m_statusJsonView->setPlainText(prettyJson(daemonStatusSnapshotToJsonObject(statusResult.snapshot))); +} + +void TrayStatusWindow::setOfflineState(const QString &message) +{ + m_connectionValue->setText(QStringLiteral("Daemon unavailable")); + m_configPathValue->setText(QStringLiteral("-")); + m_shortcutValue->setText(QStringLiteral("-")); + m_modelPathValue->setText(QStringLiteral("-")); + + QJsonObject object; + object.insert(QStringLiteral("error"), message); + object.insert(QStringLiteral("hint"), QStringLiteral("Start `mutterkey daemon` and refresh the tray window.")); + m_statusJsonView->setPlainText(prettyJson(object)); +} diff --git a/src/tray/traystatuswindow.h b/src/tray/traystatuswindow.h new file mode 100644 index 0000000..63192f6 --- /dev/null +++ b/src/tray/traystatuswindow.h @@ -0,0 +1,46 @@ +#pragma once + +#include "control/daemoncontrolclient.h" + +#include + +class QLabel; +class QPlainTextEdit; + +/** + * @file + * @brief Basic tray-shell status window backed by daemon control requests. + */ + +/** + * @brief Read-only status window for the initial tray-shell slice. + */ +class TrayStatusWindow final : public QWidget +{ + Q_OBJECT + +public: + /** + * @brief Creates the status window and performs an initial refresh. + * @param client Optional non-owning daemon session used for refreshes. + * @param parent Optional parent widget. + */ + explicit TrayStatusWindow(DaemonControlSession *client = nullptr, QWidget *parent = nullptr); + +public slots: + /** + * @brief Refreshes the window from the daemon control API. + */ + void refresh(); + +private: + void setOfflineState(const QString &message); + + LocalDaemonControlSession m_ownedSession; + DaemonControlSession *m_session = nullptr; + QLabel *m_connectionValue = nullptr; + QLabel *m_configPathValue = nullptr; + QLabel *m_shortcutValue = nullptr; + QLabel *m_modelPathValue = nullptr; + QPlainTextEdit *m_statusJsonView = nullptr; +}; diff --git a/src/traymain.cpp b/src/traymain.cpp new file mode 100644 index 0000000..6d5b522 --- /dev/null +++ b/src/traymain.cpp @@ -0,0 +1,55 @@ +#include "tray/traystatuswindow.h" + +#include +#include +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + app.setApplicationName(QStringLiteral("mutterkey-tray")); + app.setApplicationDisplayName(QStringLiteral("Mutterkey Tray")); + app.setQuitOnLastWindowClosed(false); + + TrayStatusWindow statusWindow; + + QIcon icon = QIcon::fromTheme(QStringLiteral("audio-input-microphone")); + if (icon.isNull()) { + icon = QApplication::windowIcon(); + } + + QSystemTrayIcon trayIcon(icon); + auto *menu = new QMenu; + + QAction *showStatusAction = menu->addAction(QStringLiteral("Status")); + QAction *refreshAction = menu->addAction(QStringLiteral("Refresh")); + menu->addSeparator(); + QAction *quitAction = menu->addAction(QStringLiteral("Quit")); + + QObject::connect(showStatusAction, &QAction::triggered, &statusWindow, [&statusWindow]() { + statusWindow.show(); + statusWindow.raise(); + statusWindow.activateWindow(); + }); + QObject::connect(refreshAction, &QAction::triggered, &statusWindow, &TrayStatusWindow::refresh); + QObject::connect(&trayIcon, &QSystemTrayIcon::activated, &statusWindow, [&statusWindow](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { + statusWindow.show(); + statusWindow.raise(); + statusWindow.activateWindow(); + } + }); + QObject::connect(quitAction, &QAction::triggered, &app, &QCoreApplication::quit); + + trayIcon.setContextMenu(menu); + if (QSystemTrayIcon::isSystemTrayAvailable()) { + trayIcon.show(); + } else { + statusWindow.show(); + } + + statusWindow.refresh(); + return app.exec(); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b036562..cdd3d30 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,8 +1,11 @@ function(mutterkey_add_qt_test target_name) add_executable(${target_name} ${ARGN}) target_include_directories(${target_name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src) - target_link_libraries(${target_name} PRIVATE Qt6::Core Qt6::Multimedia Qt6::Test) + target_link_libraries(${target_name} PRIVATE Qt6::Core Qt6::Gui Qt6::Multimedia Qt6::Test Qt6::Network Qt6::Widgets) add_test(NAME ${target_name} COMMAND ${target_name}) + set_tests_properties(${target_name} PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) endfunction() mutterkey_add_qt_test(configtest @@ -21,13 +24,35 @@ mutterkey_add_qt_test(recordingnormalizertest ../src/audio/recordingnormalizer.cpp ) +mutterkey_add_qt_test(daemoncontrolprotocoltest + daemoncontrolprotocoltest.cpp + ../src/control/daemoncontrolprotocol.cpp +) + +mutterkey_add_qt_test(daemoncontroltypestest + daemoncontroltypestest.cpp + ../src/config.cpp + ../src/control/daemoncontroltypes.cpp +) + +mutterkey_add_qt_test(traystatuswindowtest + traystatuswindowtest.cpp + ../src/control/daemoncontrolclient.cpp + ../src/control/daemoncontrolprotocol.cpp + ../src/control/daemoncontroltypes.cpp + ../src/config.cpp + ../src/tray/traystatuswindow.cpp +) + target_link_libraries(configtest PRIVATE whisper) target_link_libraries(commanddispatchtest PRIVATE whisper) +target_link_libraries(daemoncontroltypestest PRIVATE whisper) +target_link_libraries(traystatuswindowtest PRIVATE whisper) if(TARGET clang-tidy) - add_dependencies(clang-tidy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen) + add_dependencies(clang-tidy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen traystatuswindowtest_autogen) endif() if(TARGET clazy) - add_dependencies(clazy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen) + add_dependencies(clazy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen traystatuswindowtest_autogen) endif() diff --git a/tests/daemoncontrolprotocoltest.cpp b/tests/daemoncontrolprotocoltest.cpp new file mode 100644 index 0000000..a01f98d --- /dev/null +++ b/tests/daemoncontrolprotocoltest.cpp @@ -0,0 +1,64 @@ +#include "control/daemoncontrolprotocol.h" + +#include + +class DaemonControlProtocolTest final : public QObject +{ + Q_OBJECT + +private slots: + void requestRoundTrip(); + void responseRoundTrip(); + void rejectsUnknownMethod(); + void rejectsMissingRequestId(); +}; + +void DaemonControlProtocolTest::requestRoundTrip() +{ + DaemonControlRequest request; + request.requestId = QStringLiteral("abc123"); + request.method = DaemonControlMethod::GetStatus; + + DaemonControlRequest parsedRequest; + QString errorMessage; + QVERIFY(parseDaemonControlRequest(serializeDaemonControlRequest(request), &parsedRequest, &errorMessage)); + QCOMPARE(parsedRequest.version, 1); + QCOMPARE(parsedRequest.requestId, request.requestId); + QCOMPARE(parsedRequest.method, request.method); +} + +void DaemonControlProtocolTest::responseRoundTrip() +{ + DaemonControlResponse response; + response.requestId = QStringLiteral("pong123"); + response.success = true; + response.result.insert(QStringLiteral("daemon_running"), true); + + DaemonControlResponse parsedResponse; + QString errorMessage; + QVERIFY(parseDaemonControlResponse(serializeDaemonControlResponse(response), &parsedResponse, &errorMessage)); + QCOMPARE(parsedResponse.version, 1); + QCOMPARE(parsedResponse.requestId, response.requestId); + QVERIFY(parsedResponse.success); + QVERIFY(parsedResponse.result.value(QStringLiteral("daemon_running")).toBool()); +} + +void DaemonControlProtocolTest::rejectsUnknownMethod() +{ + DaemonControlRequest request; + QString errorMessage; + QVERIFY(!parseDaemonControlRequest("{\"version\":1,\"request_id\":\"x\",\"method\":\"explode\"}\n", &request, &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("Unsupported daemon control method"))); +} + +void DaemonControlProtocolTest::rejectsMissingRequestId() +{ + DaemonControlResponse response; + QString errorMessage; + QVERIFY(!parseDaemonControlResponse("{\"version\":1,\"ok\":true,\"result\":{}}\n", &response, &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("request_id"))); +} + +QTEST_APPLESS_MAIN(DaemonControlProtocolTest) + +#include "daemoncontrolprotocoltest.moc" diff --git a/tests/daemoncontroltypestest.cpp b/tests/daemoncontroltypestest.cpp new file mode 100644 index 0000000..cf13cf2 --- /dev/null +++ b/tests/daemoncontroltypestest.cpp @@ -0,0 +1,57 @@ +#include "control/daemoncontroltypes.h" + +#include + +class DaemonControlTypesTest final : public QObject +{ + Q_OBJECT + +private slots: + void parseStatusSnapshot(); + void parseConfigSnapshot(); + void rejectMalformedStatusSnapshot(); +}; + +void DaemonControlTypesTest::parseStatusSnapshot() +{ + DaemonStatusSnapshot input; + input.daemonRunning = true; + input.configPath = QStringLiteral("/tmp/mutterkey.json"); + input.configExists = true; + input.serviceDiagnostics.insert(QStringLiteral("transcriptions_completed"), 4); + + DaemonStatusSnapshot parsed; + QString errorMessage; + QVERIFY(parseDaemonStatusSnapshot(daemonStatusSnapshotToJsonObject(input), &parsed, &errorMessage)); + QVERIFY(parsed.daemonRunning); + QCOMPARE(parsed.configPath, input.configPath); + QCOMPARE(parsed.serviceDiagnostics.value(QStringLiteral("transcriptions_completed")).toInt(), 4); +} + +void DaemonControlTypesTest::parseConfigSnapshot() +{ + DaemonConfigSnapshot input; + input.configPath = QStringLiteral("/tmp/mutterkey.json"); + input.configExists = true; + input.config.shortcut.sequence = QStringLiteral("Meta+F8"); + input.config.transcriber.modelPath = QStringLiteral("/tmp/model.bin"); + + DaemonConfigSnapshot parsed; + QString errorMessage; + QVERIFY(parseDaemonConfigSnapshot(daemonConfigSnapshotToJsonObject(input), &parsed, &errorMessage)); + QCOMPARE(parsed.configPath, input.configPath); + QCOMPARE(parsed.config.shortcut.sequence, QStringLiteral("Meta+F8")); + QCOMPARE(parsed.config.transcriber.modelPath, QStringLiteral("/tmp/model.bin")); +} + +void DaemonControlTypesTest::rejectMalformedStatusSnapshot() +{ + DaemonStatusSnapshot parsed; + QString errorMessage; + QVERIFY(!parseDaemonStatusSnapshot(QJsonObject{{QStringLiteral("config_path"), QStringLiteral("/tmp/x")}}, &parsed, &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("Malformed daemon status payload"))); +} + +QTEST_APPLESS_MAIN(DaemonControlTypesTest) + +#include "daemoncontroltypestest.moc" diff --git a/tests/traystatuswindowtest.cpp b/tests/traystatuswindowtest.cpp new file mode 100644 index 0000000..7c3e090 --- /dev/null +++ b/tests/traystatuswindowtest.cpp @@ -0,0 +1,132 @@ +#include "control/daemoncontrolclient.h" +#include "tray/traystatuswindow.h" + +#include +#include +#include +#include + +class FakeDaemonControlSession final : public DaemonControlSession +{ +public: + [[nodiscard]] DaemonStatusResult fetchStatus(int timeoutMs) const override + { + Q_UNUSED(timeoutMs) + return m_statusResult; + } + + [[nodiscard]] DaemonConfigResult fetchConfig(int timeoutMs) const override + { + Q_UNUSED(timeoutMs) + return m_configResult; + } + + void setStatusResult(DaemonStatusResult result) { m_statusResult = std::move(result); } + void setConfigResult(DaemonConfigResult result) { m_configResult = std::move(result); } + +private: + DaemonStatusResult m_statusResult; + DaemonConfigResult m_configResult; +}; + +class TrayStatusWindowTest final : public QObject +{ + Q_OBJECT + +private slots: + void refreshShowsOfflineStateWhenTransportFails(); + void refreshPopulatesValuesOnSuccessfulResponses(); + void refreshShowsOfflineStateWhenConfigRequestFails(); +}; + +void TrayStatusWindowTest::refreshShowsOfflineStateWhenTransportFails() +{ + FakeDaemonControlSession client; + DaemonStatusResult statusResult; + statusResult.errorMessage = QStringLiteral("Connection refused"); + client.setStatusResult(statusResult); + + TrayStatusWindow window(&client); + + auto *connectionValue = window.findChild(QStringLiteral("connectionValue")); + auto *configPathValue = window.findChild(QStringLiteral("configPathValue")); + auto *statusJsonView = window.findChild(QStringLiteral("statusJsonView")); + + QVERIFY(connectionValue != nullptr); + QVERIFY(configPathValue != nullptr); + QVERIFY(statusJsonView != nullptr); + + QCOMPARE(connectionValue->text(), QStringLiteral("Daemon unavailable")); + QCOMPARE(configPathValue->text(), QStringLiteral("-")); + QVERIFY(statusJsonView->toPlainText().contains(QStringLiteral("Connection refused"))); +} + +void TrayStatusWindowTest::refreshPopulatesValuesOnSuccessfulResponses() +{ + FakeDaemonControlSession client; + + DaemonStatusResult statusResult; + statusResult.success = true; + statusResult.snapshot.daemonRunning = true; + statusResult.snapshot.configPath = QStringLiteral("/tmp/mutterkey.json"); + statusResult.snapshot.configExists = true; + statusResult.snapshot.serviceDiagnostics.insert(QStringLiteral("daemon_running"), true); + client.setStatusResult(statusResult); + + DaemonConfigResult configResult; + configResult.success = true; + configResult.snapshot.configPath = QStringLiteral("/tmp/mutterkey.json"); + configResult.snapshot.configExists = true; + configResult.snapshot.config.shortcut.sequence = QStringLiteral("Meta+F8"); + configResult.snapshot.config.transcriber.modelPath = QStringLiteral("/tmp/model.bin"); + client.setConfigResult(configResult); + + TrayStatusWindow window(&client); + + auto *connectionValue = window.findChild(QStringLiteral("connectionValue")); + auto *configPathValue = window.findChild(QStringLiteral("configPathValue")); + auto *shortcutValue = window.findChild(QStringLiteral("shortcutValue")); + auto *modelPathValue = window.findChild(QStringLiteral("modelPathValue")); + auto *statusJsonView = window.findChild(QStringLiteral("statusJsonView")); + + QVERIFY(connectionValue != nullptr); + QVERIFY(configPathValue != nullptr); + QVERIFY(shortcutValue != nullptr); + QVERIFY(modelPathValue != nullptr); + QVERIFY(statusJsonView != nullptr); + + QCOMPARE(connectionValue->text(), QStringLiteral("Connected")); + QCOMPARE(configPathValue->text(), QStringLiteral("/tmp/mutterkey.json")); + QCOMPARE(shortcutValue->text(), QStringLiteral("Meta+F8")); + QCOMPARE(modelPathValue->text(), QStringLiteral("/tmp/model.bin")); + QVERIFY(statusJsonView->toPlainText().contains(QStringLiteral("daemon_running"))); +} + +void TrayStatusWindowTest::refreshShowsOfflineStateWhenConfigRequestFails() +{ + FakeDaemonControlSession client; + + DaemonStatusResult statusResult; + statusResult.success = true; + statusResult.snapshot.configPath = QStringLiteral("/tmp/mutterkey.json"); + client.setStatusResult(statusResult); + + DaemonConfigResult configResult; + configResult.errorMessage = QStringLiteral("Config unavailable"); + client.setConfigResult(configResult); + + TrayStatusWindow window(&client); + + auto *connectionValue = window.findChild(QStringLiteral("connectionValue")); + auto *statusJsonView = window.findChild(QStringLiteral("statusJsonView")); + + QVERIFY(connectionValue != nullptr); + QVERIFY(statusJsonView != nullptr); + + QCOMPARE(connectionValue->text(), QStringLiteral("Daemon unavailable")); + QVERIFY(statusJsonView->toPlainText().contains(QStringLiteral("Config unavailable"))); +} + +QTEST_MAIN(TrayStatusWindowTest) + +#include "traystatuswindowtest.moc"