Skip to content

Commit 158d5a4

Browse files
committed
First UI implementation (tray)
1 parent cedb687 commit 158d5a4

27 files changed

Lines changed: 1689 additions & 175 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,15 @@ jobs:
6161
- name: Run tests
6262
run: ctest --test-dir build --output-on-failure
6363

64+
- name: Validate install tree
65+
run: |
66+
INSTALL_DIR="$(mktemp -d)"
67+
cmake --install build --prefix "$INSTALL_DIR"
68+
test -x "$INSTALL_DIR/bin/mutterkey"
69+
test -x "$INSTALL_DIR/bin/mutterkey-tray"
70+
6471
- name: Validate headless CLI startup
6572
run: QT_QPA_PLATFORM=offscreen ./build/mutterkey --help
73+
74+
- name: Validate headless tray startup
75+
run: timeout 2s env QT_QPA_PLATFORM=offscreen ./build/mutterkey-tray

.github/workflows/release-checks.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,15 @@ jobs:
5050
- name: Run tests
5151
run: ctest --test-dir build --output-on-failure
5252

53+
- name: Validate install tree
54+
run: |
55+
INSTALL_DIR="$(mktemp -d)"
56+
cmake --install build --prefix "$INSTALL_DIR"
57+
test -x "$INSTALL_DIR/bin/mutterkey"
58+
test -x "$INSTALL_DIR/bin/mutterkey-tray"
59+
60+
- name: Validate headless tray startup
61+
run: timeout 2s env QT_QPA_PLATFORM=offscreen ./build/mutterkey-tray
62+
5363
- name: Run Valgrind Memcheck lane
5464
run: bash scripts/run-valgrind.sh build

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
/third_party/whisper.cpp/build/
66
/third_party/whisper.cpp/build-*/
77
/Makefile
8+
/next_feature/
89
*.o
910
*.obj
1011
*.moc

AGENTS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Current architecture:
1010
- Audio capture uses Qt Multimedia
1111
- Transcription is in-process through vendored `whisper.cpp`
1212
- Clipboard writes prefer `KSystemClipboard` with `QClipboard` fallback
13-
- There is no GUI yet; the entrypoint is the `mutterkey` binary with `daemon`, `once`, and `diagnose` modes
13+
- There is an early Qt Widgets tray shell in `mutterkey-tray`, but the daemon remains the product core
1414
- The recommended day-to-day runtime path is the `systemd --user` service
1515
- The installed desktop entry is intentionally hidden from normal app menus with `NoDisplay=true`
1616
- `daemon` is the default runtime mode; `once` and `diagnose` are validation helpers
@@ -36,9 +36,13 @@ This repository is intentionally kept minimal:
3636
- `src/transcription/transcriptionworker.*`: worker object hosted on a dedicated `QThread`
3737
- `src/transcription/transcriptiontypes.h`: normalized audio and transcription result value types
3838
- `src/config.*`: JSON config loading and defaults
39+
- `src/app/*`: shared CLI/runtime command helpers used by the main entrypoint
40+
- `src/control/*`: local daemon control transport, typed snapshots, and session/client APIs
41+
- `src/tray/*`: Qt Widgets tray-shell UI scaffolding
3942
- `contrib/mutterkey.service`: example user service
4043
- `contrib/org.mutterkey.mutterkey.desktop`: hidden desktop entry used for desktop identity/integration
4144
- `scripts/check-release-hygiene.sh`: repo hygiene checks for publication-facing content
45+
- `next_feature/`: tracked upcoming feature plans as Markdown; keep only plan `.md` files and the folder-local `.gitignore`
4246
- `docs/Doxyfile.in`: Doxygen config template for repo-owned API docs
4347
- `docs/mainpage.md`: Doxygen landing page used instead of the full README
4448
- `scripts/run-valgrind.sh`: deterministic Valgrind Memcheck runner for release-readiness checks
@@ -118,6 +122,7 @@ Notes:
118122
- Do not add broad Valgrind suppressions by default; only add narrow suppressions after reproducing stable third-party noise and keep them clearly scoped
119123
- 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
120124
- For tool-driven cleanups, preserve the existing design and behavior; do not perform broad rewrites just to satisfy style-oriented recommendations
125+
- Keep forward-looking feature plans under `next_feature/` as tracked Markdown files; do not leave scratch notes, binaries, or generated artifacts there
121126

122127
## Coding Guidelines
123128

@@ -128,6 +133,8 @@ Notes:
128133
- Avoid introducing optional backends, plugin systems, or cross-platform abstractions unless the task requires them
129134
- Keep the audio path explicit: recorder output may not already match Whisper input requirements, so preserve normalization behavior
130135
- 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
136+
- 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
137+
- Prefer dependency injection for tray-shell and control-surface code from the first implementation so headless Qt tests stay simple
131138
- Preserve the current product direction: embedded `whisper.cpp`, KDE-first, CLI/service-first
132139

133140
## C++ Core Guidelines Priorities
@@ -193,10 +200,13 @@ Typical model location:
193200

194201
- Read `README.md` first, especially `Overview`, `Quick Start`, `Run As Service`, and `Development`, then read the touched source files before editing
195202
- Prefer targeted changes over speculative cleanup
203+
- 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`
196204
- Update `README.md` and `config.example.json` when behavior or setup changes
197205
- Update `contrib/mutterkey.service` and `contrib/org.mutterkey.mutterkey.desktop` when service/desktop behavior changes
198206
- Update `LICENSE`, `THIRD_PARTY_NOTICES.md`, CMake install rules, and `third_party/whisper.cpp.UPSTREAM.md` when packaging, licensing, or vendored dependency behavior changes
199207
- 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
208+
- 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
209+
- 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
200210
- Verify with a fresh CMake build when the change affects compilation or linkage
201211
- Run `ctest` when touching covered code in `src/config.*` or `src/audio/recordingnormalizer.*`, and extend the deterministic headless tests when practical
202212
- Prefer expanding tests around pure parsing, value normalization, and other environment-independent logic before adding KDE-session or device-heavy coverage

CMakeLists.txt

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ option(MUTTERKEY_ENABLE_WHISPER_BLAS "Enable whisper.cpp BLAS CPU acceleration"
2121
set(MUTTERKEY_WHISPER_BLAS_VENDOR "Generic" CACHE STRING "BLAS vendor passed to whisper.cpp when BLAS acceleration is enabled")
2222
set_property(CACHE MUTTERKEY_WHISPER_BLAS_VENDOR PROPERTY STRINGS "Generic;OpenBLAS;FLAME;ATLAS;FlexiBLAS;Intel;NVHPC;Apple")
2323

24-
find_package(Qt6 REQUIRED COMPONENTS Core Gui Multimedia)
24+
find_package(Qt6 REQUIRED COMPONENTS Core Gui Multimedia Network Widgets)
2525
find_package(KF6GlobalAccel CONFIG REQUIRED)
2626
find_package(KF6GuiAddons CONFIG REQUIRED)
2727
find_package(Doxygen QUIET)
2828

29-
set(MUTTERKEY_APP_SOURCES
29+
set(MUTTERKEY_CORE_SOURCES
3030
src/audio/audiorecorder.cpp
3131
src/audio/audiorecorder.h
3232
src/audio/recordingnormalizer.cpp
@@ -40,7 +40,6 @@ set(MUTTERKEY_APP_SOURCES
4040
src/config.h
4141
src/hotkeymanager.cpp
4242
src/hotkeymanager.h
43-
src/main.cpp
4443
src/service.cpp
4544
src/service.h
4645
src/transcription/transcriptiontypes.h
@@ -50,14 +49,52 @@ set(MUTTERKEY_APP_SOURCES
5049
src/transcription/whispercpptranscriber.h
5150
)
5251

53-
add_executable(mutterkey ${MUTTERKEY_APP_SOURCES})
52+
set(MUTTERKEY_CONTROL_SOURCES
53+
src/control/daemoncontrolclient.cpp
54+
src/control/daemoncontrolclient.h
55+
src/control/daemoncontrolprotocol.cpp
56+
src/control/daemoncontrolprotocol.h
57+
src/control/daemoncontrolserver.cpp
58+
src/control/daemoncontrolserver.h
59+
src/control/daemoncontroltypes.cpp
60+
src/control/daemoncontroltypes.h
61+
)
62+
63+
add_library(mutterkey_core STATIC ${MUTTERKEY_CORE_SOURCES})
64+
add_library(mutterkey_control STATIC ${MUTTERKEY_CONTROL_SOURCES})
65+
add_library(mutterkey_app STATIC
66+
src/app/applicationcommands.cpp
67+
src/app/applicationcommands.h
68+
)
69+
70+
add_executable(mutterkey
71+
src/main.cpp
72+
)
73+
74+
add_executable(mutterkey-tray
75+
src/tray/traystatuswindow.cpp
76+
src/tray/traystatuswindow.h
77+
src/traymain.cpp
78+
)
5479

80+
target_include_directories(mutterkey_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
81+
target_include_directories(mutterkey_control PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
82+
target_include_directories(mutterkey_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
5583
target_include_directories(mutterkey PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
56-
target_link_libraries(mutterkey PRIVATE Qt6::Core Qt6::Gui Qt6::Multimedia KF6::GlobalAccel KF6::GuiAddons)
84+
target_include_directories(mutterkey-tray PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
85+
target_link_libraries(mutterkey_core PUBLIC Qt6::Core Qt6::Gui Qt6::Multimedia KF6::GlobalAccel KF6::GuiAddons)
86+
target_link_libraries(mutterkey_control PUBLIC Qt6::Core Qt6::Network mutterkey_core)
87+
target_link_libraries(mutterkey_app PUBLIC Qt6::Core Qt6::Gui mutterkey_control)
88+
target_link_libraries(mutterkey PRIVATE mutterkey_app whisper)
89+
target_link_libraries(mutterkey-tray PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets mutterkey_control)
5790
set_target_properties(mutterkey PROPERTIES
5891
BUILD_RPATH "$ORIGIN/../lib"
5992
INSTALL_RPATH "$ORIGIN/../lib"
6093
)
94+
set_target_properties(mutterkey-tray PROPERTIES
95+
BUILD_RPATH "$ORIGIN/../lib"
96+
INSTALL_RPATH "$ORIGIN/../lib"
97+
)
6198

6299
function(mutterkey_enable_sanitizers target_name)
63100
if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
@@ -84,7 +121,11 @@ function(mutterkey_enable_sanitizers target_name)
84121
endif()
85122
endfunction()
86123

124+
mutterkey_enable_sanitizers(mutterkey_core)
125+
mutterkey_enable_sanitizers(mutterkey_control)
126+
mutterkey_enable_sanitizers(mutterkey_app)
87127
mutterkey_enable_sanitizers(mutterkey)
128+
mutterkey_enable_sanitizers(mutterkey-tray)
88129

89130
set(MUTTERKEY_CLAZY_CHECKS "level0" CACHE STRING "Checks passed to clazy-standalone")
90131

@@ -163,9 +204,10 @@ add_subdirectory(third_party/whisper.cpp EXCLUDE_FROM_ALL)
163204
# upstream public headers as part of its own package layout.
164205
set_target_properties(whisper ggml PROPERTIES PUBLIC_HEADER "")
165206

166-
target_link_libraries(mutterkey PRIVATE whisper)
207+
target_link_libraries(mutterkey_core PUBLIC whisper)
167208

168209
install(TARGETS mutterkey RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
210+
install(TARGETS mutterkey-tray RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
169211
install(TARGETS whisper ggml ggml-base
170212
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
171213
)

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Current direction:
1818
- KDE-first
1919
- local-only transcription
2020
- CLI/service-first operation
21-
- no GUI yet
21+
- tray-shell work has started, but the daemon remains the product core
2222
- minimal and developer-oriented rather than a hardened end-user security product
2323

2424
Recommended startup path:
@@ -46,7 +46,7 @@ Supported environment:
4646

4747
Build requirements:
4848

49-
1. Qt 6 development packages with `Core`, `Gui`, and `Multimedia`
49+
1. Qt 6 development packages with `Core`, `Gui`, `Multimedia`, `Network`, and `Widgets`
5050
2. KDE Frameworks development packages for `KGlobalAccel` and `KGuiAddons`
5151
3. `g++`
5252
4. `cmake`
@@ -107,6 +107,7 @@ cmake --install "$BUILD_DIR"
107107
This installs:
108108

109109
- `~/.local/bin/mutterkey`
110+
- `~/.local/bin/mutterkey-tray`
110111
- `~/.local/lib/libwhisper.so*` and the required `ggml` libraries
111112
- `~/.local/share/applications/org.mutterkey.mutterkey.desktop`
112113

@@ -353,6 +354,9 @@ Repository layout:
353354
- `src/transcription/transcriptiontypes.h`: normalized-audio and transcription result value types
354355
- `src/clipboardwriter.*`: clipboard writes with KDE-first fallback behavior
355356
- `src/config.*`: JSON config loading and defaults
357+
- `src/app/*`: shared CLI/runtime command helpers used by the main entrypoint
358+
- `src/control/*`: local daemon control protocol, typed snapshots, and local-socket session/server wiring
359+
- `src/tray/*`: Qt Widgets tray-shell UI scaffolding
356360
- `contrib/mutterkey.service`: example user service
357361

358362
Build and test:

RELEASE_CHECKLIST.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ ctest --test-dir "$SANITIZER_BUILD_DIR" --output-on-failure
105105
QT_QPA_PLATFORM=offscreen "$BUILD_DIR/mutterkey" --help
106106
```
107107

108+
- Validate tray-shell startup in a headless environment:
109+
110+
```bash
111+
timeout 2s env QT_QPA_PLATFORM=offscreen "$BUILD_DIR/mutterkey-tray"
112+
```
113+
114+
- Treat exit code `124` from the tray-shell smoke check as expected when the
115+
process stays alive until `timeout` stops it.
116+
108117
- If the change affects startup, service wiring, or config handling, also run:
109118

110119
```bash
@@ -122,6 +131,7 @@ cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR"
122131

123132
- Confirm the installed tree contains:
124133
- `bin/mutterkey`
134+
- `bin/mutterkey-tray`
125135
- required `libwhisper` / `ggml` shared libraries
126136
- the desktop file under `share/applications`
127137
- license files under `share/licenses/mutterkey`

src/app/applicationcommands.cpp

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#include "app/applicationcommands.h"
2+
3+
#include "audio/audiorecorder.h"
4+
#include "clipboardwriter.h"
5+
#include "control/daemoncontrolserver.h"
6+
#include "service.h"
7+
#include "transcription/whispercpptranscriber.h"
8+
9+
#include <QGuiApplication>
10+
#include <QJsonDocument>
11+
#include <QTextStream>
12+
#include <QTimer>
13+
14+
Q_LOGGING_CATEGORY(appLog, "mutterkey.app")
15+
16+
void configureLogging(const QString &level)
17+
{
18+
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}"));
19+
20+
if (level.compare(QStringLiteral("DEBUG"), Qt::CaseInsensitive) == 0) {
21+
QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true"));
22+
} else {
23+
QLoggingCategory::setFilterRules(QStringLiteral("*.debug=false"));
24+
}
25+
}
26+
27+
int runDaemon(QGuiApplication &app, const AppConfig &config, const QString &configPath)
28+
{
29+
MutterkeyService service(config, app.clipboard());
30+
DaemonControlServer controlServer(configPath, config, &service);
31+
QObject::connect(&app, &QCoreApplication::aboutToQuit, &service, &MutterkeyService::stop);
32+
QObject::connect(&app, &QCoreApplication::aboutToQuit, &controlServer, &DaemonControlServer::stop);
33+
34+
QString errorMessage;
35+
if (!service.start(&errorMessage)) {
36+
qCCritical(appLog) << "Failed to start daemon:" << errorMessage;
37+
return 1;
38+
}
39+
if (!controlServer.start(&errorMessage)) {
40+
qCCritical(appLog) << "Failed to start daemon control server:" << errorMessage;
41+
service.stop();
42+
return 1;
43+
}
44+
45+
qCInfo(appLog) << "Mutterkey daemon running. Hold" << config.shortcut.sequence << "to talk.";
46+
return app.exec();
47+
}
48+
49+
int runOnce(QGuiApplication &app, const AppConfig &config, double seconds)
50+
{
51+
AudioRecorder recorder(config.audio);
52+
WhisperCppTranscriber transcriber(config.transcriber);
53+
ClipboardWriter clipboardWriter(app.clipboard());
54+
55+
if (config.transcriber.warmupOnStart) {
56+
QString warmupError;
57+
if (!transcriber.warmup(&warmupError)) {
58+
qCCritical(appLog) << "Failed to warm up transcriber:" << warmupError;
59+
return 1;
60+
}
61+
}
62+
63+
QTimer::singleShot(0, &app, [&app, &recorder, &transcriber, &clipboardWriter, seconds]() {
64+
QString errorMessage;
65+
if (!recorder.start(&errorMessage)) {
66+
qCCritical(appLog) << "Failed to start one-shot recording:" << errorMessage;
67+
app.exit(1);
68+
return;
69+
}
70+
71+
qCInfo(appLog) << "Recording for" << seconds << "seconds";
72+
QTimer::singleShot(static_cast<int>(seconds * 1000), &app, [&app, &recorder, &transcriber, &clipboardWriter]() {
73+
const Recording recording = recorder.stop();
74+
if (!recording.isValid()) {
75+
qCCritical(appLog) << "Recorder returned no audio";
76+
app.exit(1);
77+
return;
78+
}
79+
80+
const TranscriptionResult result = transcriber.transcribe(recording);
81+
if (!result.success) {
82+
qCCritical(appLog) << "One-shot transcription failed:" << result.error;
83+
app.exit(1);
84+
return;
85+
}
86+
87+
if (!result.text.trimmed().isEmpty()) {
88+
const QString trimmedText = result.text.trimmed();
89+
if (!clipboardWriter.copy(trimmedText)) {
90+
qCWarning(appLog) << "Clipboard update appears to have failed";
91+
}
92+
QTextStream(stdout) << trimmedText << Qt::endl;
93+
} else {
94+
qCInfo(appLog) << "No speech detected";
95+
}
96+
app.exit(0);
97+
});
98+
});
99+
100+
return app.exec();
101+
}
102+
103+
int runDiagnose(QGuiApplication &app, const AppConfig &config, double seconds, bool invokeShortcut)
104+
{
105+
MutterkeyService service(config, app.clipboard());
106+
QObject::connect(&app, &QCoreApplication::aboutToQuit, &service, &MutterkeyService::stop);
107+
108+
QString errorMessage;
109+
if (!service.start(&errorMessage)) {
110+
qCCritical(appLog) << "Diagnostic startup failed:" << errorMessage;
111+
return 1;
112+
}
113+
114+
qCInfo(appLog) << "Diagnostic mode active for" << seconds << "seconds. Press the configured shortcut now.";
115+
if (invokeShortcut) {
116+
QTimer::singleShot(750, &app, [&service]() {
117+
QString invokeError;
118+
if (!service.invokeShortcut(&invokeError)) {
119+
qCWarning(appLog) << "Diagnostic shortcut invoke failed:" << invokeError;
120+
} else {
121+
qCInfo(appLog) << "Invoked the registered shortcut through KGlobalAccel";
122+
}
123+
});
124+
}
125+
126+
QTimer::singleShot(static_cast<int>(seconds * 1000), &app, [&app, &service]() {
127+
QTextStream(stdout) << QJsonDocument(service.diagnostics()).toJson(QJsonDocument::Indented);
128+
app.exit(0);
129+
});
130+
131+
return app.exec();
132+
}

0 commit comments

Comments
 (0)