Audited: 2026-05-01 · Re-audited: 2026-05-01 (Actions registry + SystemTray refactor)
Items marked [FIXED], [CHANGED], or [NEW] since previous audit.
| Component | Change |
|---|---|
SystemTray |
Refactored into src/ui/systemtray.hpp/cpp — owns QSystemTrayIcon, QMenu, nothing else |
Actions |
New class src/ui/actions.hpp/cpp — owns every QAction*; shared across all surfaces |
AppController |
Removed all scattered QAction* members; now owns Actions* m_actions only; calls syncActions() after every state change |
copilot-instructions.md |
Updated to reflect SystemTray architecture and the Actions shared-action pattern |
The Actions + SystemTray split is now architecturally correct per the instructions. Adding a toolbar in future requires zero changes to AppController or SystemTray — just pass m_actions to the new surface.
Files: src/appcontroller.hpp, src/appcontroller.cpp (~630 lines)
Still owns: both UI windows, worker thread lifecycle, strategy selection and wiring, state machine transitions, settings persistence, aspect-ratio resize logic, follow-mouse pan timer, macOS hotkey manager, and action registry synchronization. Recommended split:
WorkerManager— ownsQThread,RecorderWorker,RecordingStrategylifecycleAppController— thin coordinator: wires the above, owns windows andActions
The state machine itself is simple enough that a dedicated type isn't urgent, but WorkerManager extraction is.
Files: src/appcontroller.cpp, src/ui/capturewindow.cpp, src/ui/controlbar.cpp
Three independent code paths modify CaptureWindow geometry:
CaptureWindowemitsregionChanged→AppController::onRegionChanged→ re-emits →CaptureWindow(round-trip, guarded bym_suppressSignalbool)ControlBar::mouseMoveEventcallsm_captureWindow->setGeometry()directly — bypassesAppControllerentirelyAppController::applyResizeDelta(hotkey-driven grow/shrink) callscaptureWindow->setGeometry()directly
The m_suppressSignal bool is the only thing preventing an infinite loop on path 1. An RAII QSignalBlocker would be safer. Paths 2 and 3 are unguarded direct calls that violate the "signals out, slots in" rule.
Files: src/ui/controlbar.cpp (~650 lines)
The previous audit noted business logic in the view. The extent is now confirmed:
mouseMoveEventdirectly resizesCaptureWindow— callsm_captureWindow->setGeometry()and readsm_captureWindow->lockedAspect(). This is drag-resize logic implemented inside the view, bypassingAppController.- Inline preferences dialog — the settings button handler constructs ~80 lines of
QDialog/QFormLayout/QLineEdit/QComboBox/QSpinBoxinline. Should be aPrefsDialogclass insrc/ui/. - Platform call in view — demo mode toggle calls
setWindowCaptureExcluded(...)directly instead of emitting a signal. - Snap-to-region geometry duplicated — aspect-ratio constraint math present here and in two other files (see #9).
- Audio device list — populated from
QMediaDevicesat construction, not cached. - Inline stylesheet — ~250 lines of CSS string. Move to
:/styles/controlbar.qssQt resource.
Files: src/appcontroller.hpp, src/appcontroller.cpp
m_frameConn (QMetaObject::Connection) now stores the frameReady → onFrame connection. Both onEncodingFinished() and onEncodingFailed() call disconnect(m_frameConn) before deleteLater() — any frames already in the event queue are discarded cleanly rather than executing against a deleted strategy.
Files: src/capture/framestore.hpp, src/capture/framestore.cpp
m_producerThread (QThread*) is now set on the first addFrame() call and reset to nullptr in clear(). Three Q_ASSERT guards enforce the threading contract at runtime:
addFrame()— asserts called from the producer threadframeAt()— asserts not called from the producer thread (catches main-thread reads during recording)clear()— asserts not called from the producer thread (catches reset-while-recording)
Files: src/bufferedstrategy.cpp
~BufferedStrategy() calls m_encoderThread->quit() + wait() synchronously. Both the finished and failed signal handlers also call quit() + wait() — if the destructor races with a signal, the thread is quit and waited twice. Prefer connecting both handlers to a single cleanup slot, and guard the destructor with a null check.
Files: src/ui/capturewindow.cpp
The instructions designate QGraphicsScene as the future compositor for screen frame, camera PIP, and annotations. Currently the scene holds only: border rect, resize handles, and a dimension label (permanently hidden — see #15). GIF and MP4 encoding bypass it entirely. This is acceptable for the current feature set. Do not add a custom compositor; when camera PIP or annotations are needed, add QGraphicsItem subclasses to the existing scene.
Files: src/appcontroller.hpp
AppState::Positioning, AppState::Countdown, and AppState::Preview are defined but no state transition leads to any of them. Remove them or implement them — dead enum values create confusion when reading state guards.
Files: src/ui/capturewindow.cpp, src/ui/controlbar.cpp, src/appcontroller.cpp
Three independent constraint implementations (derive new W/H from a delta while holding ratio), each with slight variations in anchor-point handling. Extract:
// src/ui/uigeometry.hpp
QRect constrainToAspect(QRect current, double aspect, HitZone zone, QPoint anchor);Files: src/encoding/gifencoder.cpp, src/streamingstrategy.cpp
Both independently compute a QRect crop from a full-screen QImage to the capture region, including HiDPI scaling. Any rounding difference between the two is a latent precision bug. Extract QRect physicalCropRect(const CaptureRegion&, QSize frameSize) in src/capture/.
Files: src/appcontroller.cpp
Each slot handler independently guards with if (m_state != AppState::X) return;. An invalid transition added in future silently no-ops with no log or assertion. A transition(AppState from, AppState to, std::function<void()>) helper would make invalid transitions assertions.
Files: src/appcontroller.hpp
outputDir falls back to QStandardPaths::MoviesLocation only when the QSettings key is absent — not when the stored path has been deleted, moved, or made unwritable. BufferedStrategy calls QDir().mkpath(outputDir) but does not validate success. Recording fails with a cryptic encoder error. Validate (and fall back) at load time.
Files: src/appcontroller.hpp
showCursor, showClicks, and countdown are declared, persisted to QSettings, and surfaced in the UI, but never read by any worker or encoder. Remove or implement them.
Files: src/capture/screencaptureworker.cpp
The backend is partially constructed on the main thread (NSView access for setExcludedWindowIds) and partially initialised on the worker thread. No Q_ASSERT(QThread::currentThread() == ...) guards enforce this contract. A future backend author will get intermittent crashes without a clear failure message.
| # | File(s) | Issue |
|---|---|---|
| 15 | src/ui/capturewindow.cpp |
m_labelItem->setVisible(false) — dimension label fully implemented, permanently hidden. Remove or re-enable. |
| 16 | src/ui/capturewindow.cpp/.hpp |
m_suppressSignal bool — replace with QSignalBlocker RAII guard |
| 17 | Multiple | Magic numbers (kBarHeight, kGripSize in controlbar.cpp; kBorderWidth, kHandleSize, kMinDimension in capturewindow.hpp) defined per-file — move to src/ui/constants.hpp |
| 18 | src/appcontroller.cpp |
Sequential permission requests — if screen recording denied, accessibility permission never requested until next launch |
| 19 | src/appcontroller.cpp |
onRegionChanged() does not clamp rect to screen bounds; dragging off-screen causes silent capture failure |
| 20 | src/ui/capturewindow.hpp |
lockedAspect() is public but consumed only by ControlBar — encapsulation leak; should be package-private or removed in favour of a signal |
| 21 | src/platform/qtscreenbackend.cpp |
#if 0'd — remove dead code |
| 22 | src/platform/sckbackend.mm |
config.showsCursor = YES to enable cursor natively. |
| 23 | src/encoding/gifencoder.cpp |
qMax(1, 100 / outputFps) GIF delay loses precision above 50 fps — use floating-point intermediate |
| 24 | src/capture/framestore.hpp |
No frame buffer size limit; 30 s @ 30 fps ≈ 1.2 GB RAM. Document a hard cap or add a configurable one. |
| 25 | src/bufferedstrategy.cpp |
No timeout if encoder hangs; UI shows "Processing…" forever |
| 26 | src/ui/controlbar.cpp |
~250-line inline stylesheet — move to :/styles/controlbar.qss Qt resource |
| 27 | src/streamingstrategy.cpp |
Local kOutputSize uses k const-naming convention on a mutable local — rename to outputSize |
| 28 | CMakeLists.txt |
Code-signing identity hardcoded (ED7BA643…) — should be a CMake option or env variable |
| 29 | src/mousepanner.hpp |
Default outerThreshold = -1 (unlimited) is surprising — comment the sentinel value clearly |
| 30 | src/platform/macos_window.h |
Uses .h extension — violates the project convention (.hpp only). Rename to macos_window.hpp. [NEW] |
Actionsregistry — clean, idiomatic Qt; sharedQAction*auto-propagate to every surface [NEW ✅]SystemTray— correctly owns onlyQSystemTrayIcon+QMenu; inserts shared action pointers; no sync() needed [NEW ✅]MousePanner— pure stateless utility, zero coupling, well-tested, clean speed-ramp mathRecorderWorker/RecordingStrategy— clean abstract interfaces; thread contract documentedScreenCaptureBackend— clean platform abstraction; SCK backend correctly handles buffer lifecycle and thread dispatchFrameStore— simple and correct; append-only producer/consumer design is rightGifEncoder— solid giflib integration; palette quantization, dithering, loop extension all correctVideoEncoder— thin Qt Multimedia wrapper; CRF quality mode is the right callSckBackend— native SCK integration is sophisticated; window exclusion, dispatch queue bridging, and pixel buffer handling are all done correctly- State-machine approach in
AppControlleris the right pattern — just needs to be extracted into its own type
| Priority | Item | Risk if deferred |
|---|---|---|
| 1 | FrameStore thread assertions (#5) |
Silent corruption |
| 3 | Extract physicalCropRect (#10) |
Latent precision bug between GIF and MP4 |
| 4 | Validate outputDir on load (#12) |
Cryptic failure after dir deleted |
| 5 | Extract PrefsDialog from ControlBar (#3) |
Growing complexity, fragile inline dialog |
| 6 | Remove ControlBar direct setGeometry calls (#2, #3) |
Architectural violation; bypasses signal flow |
| 7 | Remove/implement dead RecordingSettings fields (#13) |
Confusion, wasted QSettings keys |
| 8 | Replace m_suppressSignal with QSignalBlocker (#16) |
Fragile, easy to break on refactor |
| 9 | Centralise aspect-ratio logic (#9) | Bug fix in one place won't propagate |
| 10 | Remove dead AppState values (#8) |
Reader confusion |
| 11 | Rename macos_window.h → macos_window.hpp (#30) |
Convention violation |
| 12 | Split AppController (#1) |
Growing complexity tax |
| 13 | Centralise UI constants (#17) | Low risk, high readability payoff |
Changes since last audit are marked [NEW] or [UPDATED].
Files: src/appcontroller.hpp, src/appcontroller.cpp
AppController (~520 lines) owns: both UI windows, worker thread lifecycle, strategy selection and wiring, state machine transitions, settings persistence, aspect-ratio resize logic, follow-mouse pan timer, and the macOS hotkey manager. Any single bug touches half the file. Recommended split:
AppStateMachine— pure state transition table; guards and fires on transitionWorkerManager— owns QThread, RecorderWorker, RecordingStrategy lifecycleAppController— thin coordinator: wires the above, owns windows
Files: src/appcontroller.cpp, src/ui/controlbar.cpp
Two independent code paths modify CaptureWindow geometry:
- CaptureWindow emits
regionChanged→ AppController → re-emitsregionChanged→ CaptureWindow applies it (round-trip, guarded bym_suppressSignal) ControlBar::mouseMoveEventcallsm_captureWindow->setGeometry()directly, bypassing AppController entirely
The m_suppressSignal bool flag is the only thing preventing an infinite loop. An RAII signal blocker (QSignalBlocker) would be safer and more idiomatic.
Files: src/ui/controlbar.cpp (~400 lines)
mouseMoveEventdirectly resizes CaptureWindow and implements grip-zone hit-testing (business logic in the view)- Preferences dialog built and managed inline — should be a
PrefsDialogclass - Demo mode toggle directly calls platform functions (
setWindowCaptureExcluded) instead of signalling AppController - Snap-to-region geometry calculation duplicated from AppController
- Audio device list populated at construction (no caching; refills on every instantiation)
- 250-line inline stylesheet string — unmaintainable; belongs in a
.qssresource file
Files: src/appcontroller.cpp
Frames are enqueued for m_strategy->onFrame() via Qt::QueuedConnection from the worker thread. When onRecordingFinished() fires, m_strategy->deleteLater() is called — but frames already in the event queue execute against the strategy after it has been marked for deletion. The fix: disconnect the frameReady → onFrame connection before issuing deleteLater().
Files: src/capture/framestore.hpp, src/capture/framestore.cpp
Comment states: "During recording the main thread must not call frameAt() — only addFrame() and clear() are safe." No assertions, no thread-ID checks, no mutex between clear() and frameAt(). The contract is documentation-only and will silently corrupt if violated. Add Q_ASSERT(QThread::currentThread() == …) guards at minimum.
Files: src/bufferedstrategy.cpp
~BufferedStrategy() calls m_encoderThread->quit() + wait() synchronously. Blocking in a destructor is problematic if the destructor is called from a non-main-thread context or during Qt teardown. The encoder thread is also quit/waited a second time in the encodingFinished/encodingFailed handlers — double cleanup that can race.
Files: src/ui/capturewindow.cpp
The architecture doc specifies QGraphicsScene as the shared compositor for screen frame, camera PIP, and annotations. In practice the scene holds only the border rect, dimension label (currently hidden — see #15), and resize handles. GIF and video encoding bypass the scene and process raw QImages directly. This is acceptable for the current feature set but blocks future camera PIP or annotation layers. Track it as a deliberate deferral rather than a todo comment.
Files: src/appcontroller.hpp
AppState::Countdown and AppState::Positioning are defined but no state transition leads to them. AppState::Preview is also defined but no code enters it. Either remove them or implement them — dead enum values cause confusion when reading state guards.
Files: src/ui/capturewindow.cpp, src/ui/controlbar.cpp, src/appcontroller.cpp
The constraint calculation (derive new W/H from a delta while holding ratio) appears three times with slight variations. Extract a free function QSize constrainToAspect(QSize current, QSizeF ratio) in a shared header.
Files: src/encoding/gifencoder.cpp, src/streamingstrategy.cpp [NEW]
Both files independently compute a crop rect from a full-screen QImage to the capture region, including HiDPI scaling. Any precision or rounding difference is a latent bug. Extract QRect physicalCropRect(const CaptureRegion&, QSize frameSize) as a free function in capture/.
Files: src/appcontroller.cpp
Each slot handler independently guards with if (m_state != AppState::X) return;. No central transition table; an invalid transition added in the future will silently no-op. A transition(AppState from, AppState to, std::function<void()> action) helper would make invalid transitions assertions.
Files: src/appcontroller.hpp (RecordingSettings::load)
outputDir is restored from QSettings without checking whether the directory exists or is writable. Recording fails with a cryptic encoder error if the path has been deleted or moved. Fall back to QStandardPaths::MoviesLocation if invalid.
Files: src/appcontroller.hpp
showCursor, showClicks, and countdown are declared in RecordingSettings and persisted to QSettings but are never read by any worker or encoder. Remove or implement them.
Files: src/capture/screencaptureworker.cpp
The backend is partially constructed on the main thread (for setExcludedWindowIds NSView access) and partially initialised on the worker thread. This contract is documented in a comment but has no compile-time or runtime enforcement. A future backend author will not see the comment and will get intermittent crashes. Add an Q_ASSERT(QThread::currentThread() == ...) in both the main-thread and worker-thread sections.
| # | File(s) | Issue |
|---|---|---|
| 15 | src/ui/capturewindow.cpp |
m_labelItem->setVisible(false) — dimension label is fully implemented but permanently hidden. Remove or re-enable. |
| 16 | Multiple | m_suppressSignal bool — replace with QSignalBlocker RAII guard |
| 17 | Multiple | Magic numbers (kBarHeight, kGripSize, kBorderWidth, kHandleSize, kMinDimension) defined per-file — move to src/ui/constants.hpp |
| 18 | src/appcontroller.cpp |
If screen recording permission is denied on first launch, accessibility permission is never requested until next launch (sequential early-exit) |
| 19 | src/appcontroller.cpp |
onRegionChanged() does not clamp rect to screen bounds; dragging off-screen causes silent capture failure |
| 20 | src/ui/capturewindow.hpp |
lockedAspect() is public but only consumed by ControlBar — encapsulation leak |
| 21 | src/platform/qtscreenbackend.cpp |
Cursor compositing is #if 0'd — remove dead code or replace with a TODO comment |
| 22 | src/platform/sckbackend.mm |
Same cursor dead code (#if 0) — same recommendation |
| 23 | src/encoding/gifencoder.cpp |
qMax(1, 100 / outputFps) delay calculation loses precision above 50 fps — use floating-point intermediate |
| 24 | src/capture/framestore.hpp |
No frame buffer size limit; 30s @ 30fps ≈ 1.2 GB RAM. Document a hard cap or add a configurable one |
| 25 | src/bufferedstrategy.cpp |
No timeout if encoder hangs; UI will show "Processing…" forever |
| 26 | src/ui/controlbar.cpp |
250-line inline stylesheet string — move to :/styles/controlbar.qss resource |
| 27 | src/streamingstrategy.cpp |
Local variable named kOutputSize uses k const-naming convention but is a mutable local copy — rename to outputSize |
| 28 | CMakeLists.txt |
Code-signing identity hardcoded (ED7BA643...) — should be an env variable or cmake option |
| 29 | src/mousepanner.hpp |
Default outerThreshold = -1 (unlimited) is surprising for a new caller; comment this clearly on the struct |
MousePanner— pure stateless utility, zero coupling, well-tested, clean speed-ramp mathRecorderWorker/RecordingStrategy— clean abstract interfaces; thread contract documentedScreenCaptureBackend— clean platform abstraction; SCK backend correctly handles buffer lifecycle and thread dispatchFrameStore— simple and correct; append-only producer/consumer design is rightGifEncoder— solid giflib integration; palette quantization, dithering, loop extension all correctVideoEncoder— thin Qt Multimedia wrapper; CRF quality mode is the right callSckBackend— native SCK integration is sophisticated; window exclusion, dispatch queue bridging, and pixel buffer handling are all done correctly- State-machine approach in
AppControlleris the right pattern — just needs to be extracted into its own type
| Priority | Item | Risk if deferred |
|---|---|---|
| 1 | Fix strategy race (#4) — disconnect before deleteLater |
Active crash |
| 2 | FrameStore assertions (#5) |
Silent corruption |
| 3 | Extract physicalCropRect (#10) |
Latent precision bug between GIF and MP4 output |
| 4 | Validate outputDir on load (#12) |
Cryptic failure on first record after dir deleted |
| 5 | Remove/implement dead RecordingSettings fields (#13) |
Confusion, wasted QSettings keys |
| 6 | Replace m_suppressSignal with QSignalBlocker (#16) |
Fragile, easy to break on refactor |
| 7 | Centralise aspect-ratio logic (#9) | Bug fix in one place won't propagate |
| 8 | Split AppController (#1) |
Growing complexity tax |
| 9 | Refactor ControlBar (#3) |
Growing complexity tax |
| 10 | Centralise UI constants (#17) | Low risk, high readability payoff |