Skip to content

port(linux): 2.0 UI starts and connects to the Linux engine (Phase B1) - #83

Merged
kalopsia-space merged 18 commits into
linux-2.0from
port/linux-2.0-ui-starts
Oct 17, 2026
Merged

kalopsia-space merged 18 commits into
linux-2.0from
port/linux-2.0-ui-starts

Conversation

@kalopsia-space

Copy link
Copy Markdown
Collaborator

Phase B1 of the Linux catch-up (docs/linux-2.0-catch-up.md): the 2.0 UI imports, starts and connects to the Linux capture engine on Linux. Windows code paths are unchanged; every addition sits behind sys.platform, and the Linux-only modules are imported only off Windows.

Restored

Imports on Linux

  • core/hotkey_manager.py: ctypes.WinDLL (user32, wtsapi32, xinput) is created only on win32.
  • field_diagnostics, windows_monitor, third_party_keyboard, capture_settings_widget, windows_dpi, frameless_window_windows, encoder_capabilities, export_profiles and camera_recorder get back the base-era platform guards. This covers /proc/cpuinfo, sysconf memory, no registry, no winId(), and no CREATE_NO_WINDOW off Windows.

Capture bridge

  • The Linux c_char layout (ui_string[1024] / engine_string[2048]), /dev/shm mmap with the size-mismatch guard, UTF-8 paths truncated on a character boundary, and the BACKEND_FAILED detail.
  • The Windows c_wchar struct is not reused on Linux: c_wchar is 4 bytes there, so the text would be UTF-32. 2.0's RLock and its singleton-replacement guard are kept.
  • resume_recording() clears ui_string on Linux, so a resume never sends the last clip path as a manual-recording request (matches Linux engine: 2.0 engine contract (Phase C) #82).
  • Manual recording is refused at the bridge on Linux.
  • The Linux bridge tests are back. The two kernel32 tests are skipped off Windows.

Hotkeys (same public API as 2.0's manager; Windows keeps the Raw Input listener)

  • XDG GlobalShortcuts portal through core/portal_shortcuts.py, with the desktop-entry app id from fix(linux): register the fthr-clips app id with the desktop portal #80. The desktop's real key is adopted and kept keys are reported.
  • Hyprland socket plus generated conf/Lua binds, and the manual-setup warning.
  • 2.0's custom clip actions work on Linux too: they are announced to the portal, written as Hyprland binds and accepted on the socket.
  • Clear sticks on portal desktops. The portal cannot unbind, so a cleared action is remembered in ~/.fthr/hotkeys-portal-cleared.json. The desktop's old key is ignored and not adopted back until a key is assigned in FTHR again.
  • The socket server now starts once. register_all() runs after every capture, and before this fix it reported its own live socket as "in use".

Start-up (main.py)

Engine launch

  • Engine lookup: _MEIPASS/FTHRclips, then $FTHR_ENGINE, then FTHRcapture_linux/build/FTHRclips.
  • Monitor argument: the QScreen output name on Wayland, xrandr on native X11. Windows paths and sentinels from a copied settings file fall back to the default output. 2.0's resolve_monitor_selection / validate_engine_monitor_argument are skipped on Linux.
  • The source popup lists QScreen outputs on Linux again.
  • The engine runs with child_process_environment().
  • The 26-entry argv is unchanged.
  • The 255-character clip path limit applies only on Windows.
  • The connect-worker race guard is back, next to 2.0's handshake-generation check.

Manual recording

  • The Linux early return in _start_manual_recording() is back.
  • The record button is disabled with an explanation, and the start/stop recording hotkey rows are hidden on Linux (2.0's own popup test expects this).

Other base-era Linux bits

  • focus_monitor (hyprctl/xdotool), the card process under XWayland, and libFTHRPlaybackMixer.so lookup.
  • The hyprctl/xdotool window enumeration in game_detector; it is not yet wired into the 2.0 game popup.
  • The frameless resize filter, the shutdown button hidden on Linux, xdg-open for folders, the compositor warning and the hidden autostart checkbox.
  • The linux_only pytest marker.

Tests

  • New tests for clear-on-portal, custom clips on portal/Hyprland, starting the socket server once, resume with an empty path, and refused manual recording.
  • An autouse fixture keeps hotkey tests away from the developer's real ~/.config/hypr, the live hyprctl reload and the real hotkey socket. Without it, a test reaching set_hotkey() on a Hyprland box rewrote the user's binds.

Deferred to later PRs

  • Screenshots (grim/portal worker, test_wayland_screenshot_portal).
  • Game detection: the 2.0 popup on Linux and Linux crop profiles (test_game_detection_popup, test_alpha_ui_truth crop case).
  • Camera and microphone selection (test_linux_camera_refresh, test_linux_microphone_selection, test_camera_provider_failures).
  • The Linux settings-page variants (test_encoder_capabilities capture-card row).
  • Wiring the playback-device picker to FTHRclips --list-playback-devices.
  • The uploader, packaging/spec/CI and docs, including tools/qualify_alpha_stability.py passing creationflags on Linux.
  • The Windows-only monitor-resolution tests in test_capture_settings_popup still run on Linux and fail there.

Verification (Arch, Hyprland/Wayland, AMD VA-API, Python 3.14, pinned deps)

Each test file was run in its own process with a throwaway HOME:

passed failed errors collection errors skipped
linux-2.0 (9b49995) 1242 166 26 23 13
this branch 1701 73 0 0 30

Every failure that is new against the base comes from a file that could not be collected on the base. Those failures are out of scope (camera, microphone, screenshots, game popup) or environment-only:

  • test_capture_backend_detection: the engine in tree has no RPATH to the pinned FFmpeg; all 4 pass with LD_LIBRARY_PATH set.
  • test_simple_dropdowns: popup grab under offscreen.
  • test_ui_efficiency: camera overlay repaint.
  • test_design_overhaul_repairs: 2.0's master-volume row versus its own test. This is not a Linux issue.

test_editor_sessions (the QtQuick editor) aborted the interpreter in 2 of 4 runs under offscreen. That belongs to Phase D editor qualification.

tools/verify_exception_handling.py (92/92), verify_shared_memory_contract.py and ruff check . all pass.

Real launch from source (engine built from this tree, FTHR_ENGINE set, real HOME, stopped with SIGTERM):

Engine found: …/FTHRclips
[Hotkey] Socket server listening on /run/user/1000/fthr/hotkey.sock
Starting engine  |  FPS=240  Buffer=122s  Bitrate=50000kbps  Pool=2048MB  Scale=stretch  Monitor=DP-3
Connected to Linux capture engine
[Encoder] Using: h264_vaapi  1920x1080  240fps  50000kbps  preset=P4
Connected to capture engine.
capture.health: Capture health: INITIALIZING -> HEALTHY reason=frames progressing frame_count=62
[UI] Encoding active: h264_vaapi

The window renders, and Record is disabled. The engine exits with the UI.

Known start-up noise on Hyprland: Qt logs Could not register app ID … 'fthr-clips' because the desktop entry is installed only on portal desktops. The linux branch behaves the same.

2.0's bridge only knows the kernel32 mapping and the UTF-16 layout, so
initialize() raised on Linux and the UI could never attach to the Linux
engine. Restore the Linux side from the linux branch: the c_char layout
with the 1024/2048-byte UTF-8 string buffers, the /dev/shm mmap with the
size-mismatch guard, UTF-8 path encoding truncated on a character
boundary, and the BACKEND_FAILED detail read from engine_string.

The Windows c_wchar struct is deliberately not reused on Linux: c_wchar
is four bytes there, so the offsets would line up by accident and the
engine would receive UTF-32 instead of UTF-8.

Manual recording stays refused on Linux at the bridge because the Linux
engine has no continuous recorder yet. 2.0's mapping RLock and
singleton-replacement guard are kept and now cover the Linux mapping
too. The Linux bridge tests come back; the two kernel32-specific tests
are skipped off Windows.
2.0's HotkeyManager calls ctypes.WinDLL at import time, so main.py could
not even be imported on Linux, and it only knows the Windows Raw Input
listener. Keep that listener unchanged for win32 and put the Linux
backends from the linux branch back behind the same public API:

- the XDG GlobalShortcuts portal (core/portal_shortcuts.py) with the
  desktop-entry app id from #80, adopting the key the desktop really
  bound and reporting kept keys;
- the private hotkey socket plus generated Hyprland binds (conf and
  Lua), which Hyprland keeps using unless FTHR_FORCE_PORTAL_SHORTCUTS=1;
- the manual-setup warning when neither path works.

2.0 additions are carried over to Linux: configurable custom clip
actions are announced to the portal, written as Hyprland binds and
accepted on the socket, and dispatch through custom_clip_triggered.

Clearing a shortcut must stick as it does on Windows. The portal has no
unbind call and the desktop keeps answering with its old key, so a
cleared action is remembered (~/.fthr/hotkeys-portal-cleared.json): its
activations are ignored and the desktop's key is not adopted back until
the user assigns a key in FTHR again.

register_all() runs again after every shortcut capture; the socket
server now starts once instead of failing on its own live socket.
On Linux a failed registration no longer refuses the change, because
there is no authoritative collision result there (as on the linux
branch).

Windows: all additions sit behind sys.platform checks and the Linux
modules are imported only off win32.
The 2.0 snapshot dropped the sys.platform checks the shared modules had
at the common base, so on Linux they reach ctypes.windll, winreg or
subprocess.CREATE_NO_WINDOW, none of which exist there. Most of these
were caught as AttributeError and turned into wrong diagnostics
('query_failed' instead of 'not_windows'), and some forced a native
window through winId() first.

Put the base-era guards back, unchanged on win32:
- field_diagnostics: /proc/cpuinfo CPU model, sysconf memory, rusage
  working set and no registry probe off Windows;
- windows_monitor: monitor enumeration returns empty off Windows;
- third_party_keyboard: the keyboard-visualizer source is Windows-only;
- capture_settings_widget: no Win32 window enumeration on Linux (the
  Linux engine captures the whole output; the Linux window list for
  game detection returns with the game-detection port);
- windows_dpi / frameless_window_windows: report the 96-DPI baseline and
  skip WS_THICKFRAME without touching winId() off Windows;
- encoder_capabilities, export_profiles, camera_recorder: no
  CREATE_NO_WINDOW reference off Windows.
These modules had working Linux branches at the common base that the
2.0 snapshot deleted in lines the linux branch never touched, so the
merge kept the Windows-only version:

- single_instance: nonblocking flock on ~/.fthr/fthr.lock, so a second
  Linux UI cannot share the single-writer capture IPC channel;
- focus_monitor: active window title through hyprctl on Hyprland and
  xdotool elsewhere instead of the Win32 foreground detector;
- capture_card_client: run the notification card under XWayland on
  Wayland sessions as before;
- ffmpeg_playback: load libFTHRPlaybackMixer.so, including the
  FTHRcapture_linux/build development copy, and only call
  add_dll_directory on Windows.
resume_recording() writes START_RECORDING without touching ui_string,
which still holds the path of the last SAVE_CLIP. The Linux engine reads
START_RECORDING with a path as a manual-recording request and answers
MANUAL_RECORDING_ERROR, so a focus resume after a save would be refused.
Clear the string on Linux before the command. The engine clears it on
its side too (PR #82); doing both keeps either side safe alone.

Windows is unchanged.
2.0 replaced the Linux start-up hooks with 'pass' or dropped them:

- crash logging from #79: Qt message handler plus faulthandler writing
  ~/.fthr/logs/fthr-crash.log;
- restore_library_search_path_for_children() so the engine, ffmpeg and
  host tools never inherit PyInstaller's _internal library path;
- linux_tools.report() so missing helpers are visible in the log;
- the desktop entry before QApplication on portal desktops (#80) and
  setDesktopFileName('fthr-clips') for portal identity and Wayland
  icon grouping, also for the second-instance activation path;
- QT_MEDIA_BACKEND=windows only on Windows: Linux has no Media
  Foundation backend and must keep Qt's FFmpeg plugin;
- the Media Foundation pre-warm only on Windows, as before.

Qt style setup is 2.0's configure_qt_style(), the renamed
configure_qt_for_linux_ui(). Every addition is behind sys.platform, and
the Linux-only modules are imported only off Windows.
The 2.0 engine path only knows FTHRClips.exe and the Windows monitor
model, so the UI never found or started the Linux engine:

- engine lookup: _MEIPASS/FTHRclips when frozen, else $FTHR_ENGINE and
  FTHRcapture_linux/build/FTHRclips (newest wins, as on Windows);
- monitor argument: 2.0's resolve_monitor_selection() and
  validate_engine_monitor_argument() accept only Windows device paths
  and fail closed with NO_DISPLAYS on Linux. Linux sends the output
  name instead (QScreen name on Wayland, xrandr resolution on native
  X11 via core/x11_monitor.py); Windows paths, desktop identities and
  sentinels from a copied settings file fall back to the default
  output. The source popup lists QScreen outputs on Linux again;
- the engine gets linux_runtime.child_process_environment();
- the 'not found' hint names the Linux build;
- the clip path length check is the Windows UTF-16 buffer limit; the
  Linux buffer is 1024 bytes of UTF-8 and the bridge truncates safely.

The argv is 2.0's 26-entry list unchanged; the Linux engine ignores
what it does not parse.

The connect-worker race guard from the linux branch comes back: the
status poll must not shut the singleton bridge down while the connect
worker is inside initialize(). It sits next to 2.0's handshake
generation check, which covers the same window on Windows, so the
Windows reconnect decisions are unchanged. The race test stub learns
the 2.0 status-poll collaborators.
2.0 replaced the Linux early return in _start_manual_recording() with
'pass', so the UI would ask the Linux engine for a recording it cannot
write. Restore the skip, keep the record button disabled with the
explanation it had, and hide the start/stop recording shortcut rows on
Linux (2.0's own popup test already expects them hidden off Windows).
The bridge refuses the command on Linux as a second line.
- showEvent installs the compositor-driven resize filter
  (ui/frameless_window_linux.py) on Linux; WS_THICKFRAME stays
  Windows-only;
- the hotkey popup explains who owns the keys: on portal desktops a
  note plus 'EDIT IN DESKTOP SETTINGS' and live refresh from
  desktop_bindings_changed, elsewhere the socket bind instructions;
- the limited-feature warning on compositors without xdotool;
- open clips/recordings folder through xdg-open instead of
  os.startfile, which does not exist on Linux;
- the Windows autostart checkbox is hidden on Linux as before.
The 2.0 snapshot deleted the hyprctl/xdotool window enumeration from
core/game_detector.py, leaving tests/test_game_detector_compat.py
unable to import, and capture_settings_widget's window list used it on
Linux at the common base. Put the helpers back unchanged (wiring the
Linux game detector into the 2.0 popup is the game-detection PR) and
let the window list use them on Linux again.

pyproject.toml loses the linux_only marker registration in the merge,
so --strict-markers rejected the Wayland screenshot portal tests at
collection.
With the Linux backends back in HotkeyManager, any test that reaches
set_hotkey() or register_all() on a Hyprland session wrote binds to the
developer's ~/.config/hypr (paths fixed at import time, so a patched
Path.home() did not help), ran hyprctl reload on the live compositor
and bound the real hotkey socket. An autouse fixture now redirects the
Hyprland paths and the socket into tmp_path and drops
HYPRLAND_INSTANCE_SIGNATURE so no reload runs.

_write_hyprland_lua_config() bound its default path at definition time,
so redirecting the module path did not reach _apply_compositor_config();
it now resolves the default at call time.
Patching linux_runtime.hotkey_socket_path() replaced the function under
test in test_linux_runtime.py. Point XDG_RUNTIME_DIR at tmp_path
instead, which keeps a running FTHR's socket untouched just the same.
- The title-bar shutdown button is Windows-only: 2.0 dropped the
  platform check in _on_close_to_tray_changed(), so toggling
  close-to-tray showed it on Linux (the initial visibility kept the
  check).
- _connect_worker_active() tolerates hosts without a real thread and is
  called through the class, so 2.0's SimpleNamespace engine tests (and
  their Windows runs) keep working with the race guard in place.
Redirecting XDG_RUNTIME_DIR also hid the Wayland compositor socket, so
the engine backend-detection tests could no longer connect. Give the
hotkey module a delegating stand-in for linux_runtime whose
hotkey_socket_path() binds into tmp_path; path queries for displayed
commands still use the real resolver that test_linux_tools checks.
2.0 records the microphone natively in the Windows engine, so its
_apply_active_audio_state() only stops the Python MicRecorder and
_start_mic_recorder() is an empty stub. The Linux engine does not
capture the microphone, so Linux clips lost their mic track: the save
route still picked the mic mux, but MicRecorder had nothing recorded.

Restore the linux branch behaviour off Windows: start the Python
recorder when audio is enabled (at start-up, on capture config changes
and through the Gary/visualiser sync), resolve the saved device by
unique name, mux the recorded segment into the saved clip, and restart
the recorder when the device or gain changes in Settings. Windows keeps
2.0's bodies unchanged.

tests/test_linux_microphone_selection.py passes again; new tests cover
start/stop with the capture config and the mux route.
Removing HYPRLAND_INSTANCE_SIGNATURE was not enough. With it gone,
detect_compositor() no longer saw Hyprland, so HotkeyManager went for the
GlobalShortcuts portal and opened a real D-Bus session on the developer's
bus. HotkeyManager also read and could rewrite the real
~/.fthr/hotkeys.json and the cleared-shortcut list. Several modules also
build ~/.fthr paths at import time (log dir, single-instance lock,
diagnostics, playback cache, thumbnails), and main.py writes its log as
soon as it is imported during collection.

Off Windows, conftest now replaces HOME and the XDG config, data, cache
and state directories with a session temp dir at import time, before
anything is collected. It also drops HYPRLAND_INSTANCE_SIGNATURE. Every
test then gets its own home (Path.home(), HOME and the XDG variables
under tmp_path). The portal reports itself absent unless a test stubs
it, and the cached portal and compositor answers are reset for both
module import names. XDG_RUNTIME_DIR stays, because Wayland clients
need it.

test_test_isolation.py checks the isolation itself: a private home,
HotkeyManager state and binds under tmp_path, import-time paths outside
the real home, and no portal or cached compositor.
Every silent handler in hotkey_manager.py now says why it is silent.
The exception gate goes from 85 to 81 undocumented handlers (linux-2.0
has 83; the baseline stays 92).

_start_socket_server() now closes the socket when bind, listen or
settimeout fails instead of leaking the descriptor. Stray blank lines
are removed.
Several tests make tmp_path/'home' themselves or assert on exactly what
tmp_path contains (library scans, SFTP and screenshot staging). Others
set HOME or patch Path.home() and expect their value to win. The home
now comes from tmp_path_factory, and Path.home() is left to follow HOME
as POSIX does.
@kalopsia-space

Copy link
Copy Markdown
Collaborator Author

Review follow-up pushed:

  • Linux mic recording is back. _apply_active_audio_state, _start_mic_recorder, _mux_mic_into_clip, _sync_gary_settings, _resolved_mic_device_index and the Settings device/gain handlers take the linux-branch path off Windows. The Windows bodies are unchanged. tests/test_linux_microphone_selection.py passes again (9 failures before), and tests/test_linux_mic_recording.py is new.
  • Tests run in a private home. Off Windows, conftest swaps HOME and the XDG config, data, cache and state directories for a temp dir at import, before collection, and drops HYPRLAND_INSTANCE_SIGNATURE. Each test then gets its own home from tmp_path_factory. The portal reports itself absent unless a test stubs it, and the cached portal and compositor answers are reset. tests/test_test_isolation.py checks all of this.
  • Exception gate. Every silent handler in hotkey_manager.py now has a justification: 81 undocumented handlers, against 83 on linux-2.0 and a baseline of 92.
  • Small fixes. A failed hotkey socket is closed, and stray blank lines are gone.

Tests. Each file ran in its own process under env -u HYPRLAND_INSTANCE_SIGNATURE HOME=<temp>: 1711 passed, 65 failed, 0 collection errors. The previous run of this PR had 1701 passed and 73 failed. The remaining failures are the same out-of-scope or environment groups as before. test_settings_transitions, test_ui_memory_gpu and test_keyboard_navigation fail identically at the previous PR head when run alone.

Launch. Run with a throwaway HOME: the engine reaches HEALTHY on h264_vaapi with [Mic] Recorder started. A save_clip sent over the hotkey socket logged [Mic] Mixed mic into desktop_clip_….mp4, and ffprobe shows h264 video plus one stereo AAC track. Nothing under the real ~/.fthr, ~/.config/hypr or ~/.local/share/applications changed during these runs.

@kalopsia-space
kalopsia-space merged commit 6bff247 into linux-2.0 Oct 17, 2026
2 of 9 checks passed
@kalopsia-space
kalopsia-space deleted the port/linux-2.0-ui-starts branch October 17, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant