@@ -29,23 +29,47 @@ def _preload_nvidia_libs():
2929 pass # Best-effort, don't crash on failure
3030
3131 def _setup_hyprland_window_rules ():
32- """Set Hyprland window rules for the popup overlay if running under Hyprland."""
32+ """Set Hyprland window rules for the popup overlay if running under Hyprland.
33+
34+ On Wayland, Qt clients cannot position their own toplevels — `set_position()`
35+ is a silent no-op. We therefore rely on the compositor to place and pin the
36+ popup. These rules use `windowrulev2` with the correct matcher syntax
37+ `title:^(Recording)$`. The previous `windowrule "...,match:title Recording"`
38+ form was silently rejected by hyprctl (no `match:` keyword exists), which
39+ is why the popup spawned in the middle of the screen on production builds
40+ even though the Python coordinate math was correct.
41+
42+ TODO(wayland-other-compositors): KDE and GNOME need wlr-layer-shell or
43+ equivalent to dock a window — there's no portable Wayland positioning API.
44+ Only Hyprland is handled here for now (the rest of the userbase is X11/win/mac).
45+ """
3346 if not os .environ .get ('HYPRLAND_INSTANCE_SIGNATURE' ):
3447 return
3548 import subprocess
49+ # `move 50%-w/2 100%-h-100` puts the popup horizontally centered and
50+ # 100 px above the bottom of the active monitor (matches the original
51+ # Python intent at main.py: popup_y = _screen_y + _screen_height - 100).
3652 rules = [
37- "float on, match:title Recording" ,
38- "pin on, match:title Recording" ,
39- "no_initial_focus on, match:title Recording" ,
40- "border_size 0, match:title Recording" ,
41- "tag -default-opacity, match:title Recording" ,
42- "opacity 1 1, match:title Recording" ,
43- "move (monitor_w-window_w)/2 (monitor_h-100), match:title Recording" ,
53+ "float,title:^(Recording)$" ,
54+ "pin,title:^(Recording)$" ,
55+ "noinitialfocus,title:^(Recording)$" ,
56+ "nofocus,title:^(Recording)$" ,
57+ "noborder,title:^(Recording)$" ,
58+ "noshadow,title:^(Recording)$" ,
59+ "noblur,title:^(Recording)$" ,
60+ "rounding 0,title:^(Recording)$" ,
61+ "opacity 1.0 override 1.0 override,title:^(Recording)$" ,
62+ "move onscreen 50%-w/2 100%-h-100,title:^(Recording)$" ,
4463 ]
4564 for rule in rules :
4665 try :
47- subprocess .run (['hyprctl' , 'keyword' , 'windowrule' , rule ],
48- capture_output = True , timeout = 2 )
66+ result = subprocess .run (
67+ ['hyprctl' , 'keyword' , 'windowrulev2' , rule ],
68+ capture_output = True , timeout = 2 , text = True ,
69+ )
70+ if result .returncode != 0 :
71+ print (f"[WARN] hyprctl rejected rule { rule !r} : { result .stderr .strip () or result .stdout .strip ()} " ,
72+ flush = True )
4973 except (FileNotFoundError , subprocess .TimeoutExpired ):
5074 break
5175
@@ -57,6 +81,33 @@ def _setup_hyprland_window_rules():
5781 # Disable accessibility scanning — major perf bottleneck on Linux with large HTML pages
5882 os .environ .setdefault ('QTWEBENGINE_ENABLE_LINUX_ACCESSIBILITY' , '0' )
5983
84+ # ----------------------------------------------------------------------------
85+ # Register the voiceflow:// custom URL scheme BEFORE QApplication is created.
86+ # QWebEngineUrlScheme.registerScheme() is a no-op once QApplication exists.
87+ # Pyloid's __init__ instantiates QApplication, so this MUST run before the
88+ # `from pyloid import Pyloid` import below (its module init does NOT construct
89+ # QApplication; only Pyloid(...) does).
90+ #
91+ # The HTML5 <audio> element on MeetingDetailPage builds URLs of the form
92+ # `voiceflow://recording/<filename>.wav`. The matching handler subclass is
93+ # in services.recording.audio_scheme_handler and is installed on the default
94+ # QWebEngineProfile after Pyloid() returns.
95+ # ----------------------------------------------------------------------------
96+ from PySide6 .QtWebEngineCore import QWebEngineUrlScheme
97+
98+ _vf_scheme = QWebEngineUrlScheme (b"voiceflow" )
99+ _vf_scheme .setSyntax (QWebEngineUrlScheme .Syntax .Host )
100+ # PortUnspecified is the default for newly-constructed schemes; PySide6's
101+ # setDefaultPort wants a raw int (-1) rather than the SpecialPort enum, so
102+ # we just leave it at the default to avoid the type-mismatch.
103+ _vf_scheme .setFlags (
104+ QWebEngineUrlScheme .Flag .SecureScheme
105+ | QWebEngineUrlScheme .Flag .LocalAccessAllowed
106+ | QWebEngineUrlScheme .Flag .CorsEnabled
107+ | QWebEngineUrlScheme .Flag .ViewSourceAllowed
108+ )
109+ QWebEngineUrlScheme .registerScheme (_vf_scheme )
110+
60111from pyloid .tray import TrayEvent
61112from pyloid .utils import get_production_path , is_production
62113from pyloid .serve import pyloid_serve
@@ -212,6 +263,16 @@ def ensure_single_instance():
212263app = Pyloid (app_name = "VoiceFlow" , single_instance = True , server = server )
213264print ("[DEBUG] Pyloid app created" , flush = True )
214265
266+ # Install the voiceflow:// handler on the default profile. The scheme itself
267+ # was registered above (before QApplication). The handler must outlive every
268+ # request, so we hold a module-level reference — Qt holds a non-owning ref.
269+ from PySide6 .QtWebEngineCore import QWebEngineProfile
270+ from services .recording .audio_scheme_handler import VoiceFlowAudioSchemeHandler
271+ _vf_audio_handler = VoiceFlowAudioSchemeHandler (get_controller ().meetings .data_root )
272+ QWebEngineProfile .defaultProfile ().installUrlSchemeHandler (b"voiceflow" , _vf_audio_handler )
273+ log .info ("voiceflow:// scheme handler installed" ,
274+ data_root = str (get_controller ().meetings .data_root ))
275+
215276print ("[DEBUG] Setting icons..." , flush = True )
216277app .set_icon (get_production_path ("src-pyloid/icons/icon.png" ))
217278app .set_tray_icon (get_production_path ("src-pyloid/icons/icon.png" ))
@@ -287,6 +348,33 @@ def stop_active_meeting():
287348_screen_height = 1080
288349
289350
351+ def _is_hyprland () -> bool :
352+ return bool (os .environ .get ('HYPRLAND_INSTANCE_SIGNATURE' ))
353+
354+
355+ def _hypr_dispatch (* args : str ) -> None :
356+ """Run `hyprctl dispatch ...`; no-op if not on Hyprland or hyprctl missing.
357+
358+ Used at runtime to move/resize the floating popup whenever it changes
359+ state (idle ↔ active), since Qt's `set_position()` is silently dropped on
360+ Wayland — the compositor is the only authority on window placement.
361+ """
362+ if not _is_hyprland ():
363+ return
364+ import subprocess
365+ try :
366+ result = subprocess .run (
367+ ['hyprctl' , 'dispatch' , * args ],
368+ capture_output = True , timeout = 2 , text = True ,
369+ )
370+ if result .returncode != 0 :
371+ log .warning ("hyprctl dispatch failed" ,
372+ args = list (args ),
373+ stderr = (result .stderr or '' ).strip ())
374+ except (FileNotFoundError , subprocess .TimeoutExpired ) as e :
375+ log .warning ("hyprctl dispatch error" , error = str (e ))
376+
377+
290378def get_active_monitor_info ():
291379 """Get the monitor where the cursor is currently located (for multi-monitor support)."""
292380 global _screen_x , _screen_y , _screen_width , _screen_height
@@ -333,29 +421,36 @@ def resize_popup(width: int, height: int):
333421 return
334422
335423 try :
336- # Resize the window
424+ # Resize the window (works on X11 / Windows / macOS).
337425 popup_window .set_size (width , height )
338426
339- # Recenter horizontally on active monitor, keep at bottom
340- # Use monitor offset (_screen_x, _screen_y) for multi-monitor support
427+ # Recenter horizontally on active monitor, keep at bottom.
428+ # Use monitor offset (_screen_x, _screen_y) for multi-monitor support.
341429 popup_x = _screen_x + (_screen_width - width ) // 2
342430 popup_y = _screen_y + _screen_height - 100
343431 popup_window .set_position (popup_x , popup_y )
344432
345- # Ensure stay-on-top is maintained after resize
346- # Also prevent resizing and make non-focusable to reduce blinking
433+ # Ensure stay-on-top is maintained after resize.
434+ # Also prevent resizing and make non-focusable to reduce blinking.
347435 qwindow = popup_window ._window ._window
348436 qwindow .setWindowFlags (
349437 Qt .FramelessWindowHint |
350438 Qt .WindowStaysOnTopHint |
351439 Qt .Tool |
352440 Qt .WindowDoesNotAcceptFocus
353441 )
354- # Re-apply translucent background (required after setWindowFlags)
442+ # Re-apply translucent background (required after setWindowFlags).
355443 qwindow .setAttribute (Qt .WA_TranslucentBackground , True )
356- # Prevent window resizing
444+ # Prevent window resizing.
357445 qwindow .setFixedSize (width , height )
358446 qwindow .show ()
447+
448+ # Wayland fallback: ask the compositor to re-dock the existing window.
449+ # `set_position()` and `set_size()` above are no-ops on Wayland for
450+ # toplevels — the windowrulev2 from _setup_hyprland_window_rules() only
451+ # fires on initial map, so we have to dispatch the move/resize here too.
452+ _hypr_dispatch ('resizewindowpixel' , f'exact { width } { height } ,title:^(Recording)$' )
453+ _hypr_dispatch ('movewindowpixel' , f'exact { popup_x } { popup_y } ,title:^(Recording)$' )
359454 except Exception as e :
360455 log .error ("Failed to resize popup" , error = str (e ))
361456
@@ -423,6 +518,17 @@ def init_popup():
423518 x = popup_x , y = popup_y ,
424519 monitor_offset_x = _screen_x , monitor_offset_y = _screen_y )
425520
521+ # Wayland: enforce dock position once the window is mapped.
522+ # The windowrulev2 move rule fires on map, but we re-issue here in
523+ # case the rule registration race hasn't completed yet on first run.
524+ def _enforce_dock_position ():
525+ _hypr_dispatch ('resizewindowpixel' ,
526+ f'exact { POPUP_IDLE_WIDTH } { POPUP_IDLE_HEIGHT } ,title:^(Recording)$' )
527+ _hypr_dispatch ('movewindowpixel' ,
528+ f'exact { popup_x } { popup_y } ,title:^(Recording)$' )
529+
530+ QTimer .singleShot (100 , _enforce_dock_position )
531+
426532 # Send initial idle state after a brief delay to ensure page is loaded
427533 def send_initial_state ():
428534 send_popup_event ('popup-state' , {'state' : 'idle' })
0 commit comments