Skip to content

Commit 7ec16be

Browse files
committed
fix: recover Wayland display at startup
1 parent 6726ed3 commit 7ec16be

4 files changed

Lines changed: 168 additions & 0 deletions

File tree

docs/CONFIGURATION.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,12 @@ If `WAYLAND_DISPLAY` is missing, add to `~/.config/hypr/hyprland.conf`:
12631263
exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE
12641264
```
12651265

1266+
hyprwhspr also has a startup fallback: if `WAYLAND_DISPLAY` is unset but a
1267+
Wayland socket exists in `XDG_RUNTIME_DIR`, it will use the newest `wayland-*`
1268+
socket for its own process and children. The compositor environment export above
1269+
is still the recommended fix because it makes the correct display available to
1270+
all systemd user services.
1271+
12661272
**Niri:**
12671273

12681274
hyprwhspr uses `niri msg --json focused-window` to detect the focused app and choose the correct paste shortcut. That requires `NIRI_SOCKET` to be available in the systemd user environment used by `hyprwhspr.service`.

lib/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def _looks_like_wlroots_session() -> bool:
7474
from config_manager import ConfigManager
7575
from audio_capture import AudioCapture
7676
from whisper_manager import WhisperManager
77+
from session_environment import ensure_wayland_display
7778
from text_injector import TextInjector
7879
from global_shortcuts import GlobalShortcuts
7980
from audio_manager import AudioManager
@@ -91,6 +92,8 @@ class hyprwhsprApp:
9192
"""Main application class for hyprwhspr voice dictation (Headless Mode)"""
9293

9394
def __init__(self):
95+
ensure_wayland_display()
96+
9497
# Initialize core components
9598
self.config = ConfigManager()
9699

lib/src/session_environment.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""
2+
Session environment fallbacks for service startup.
3+
"""
4+
5+
import os
6+
import stat
7+
from pathlib import Path
8+
9+
10+
def ensure_wayland_display():
11+
"""
12+
Populate WAYLAND_DISPLAY from XDG_RUNTIME_DIR when systemd has not imported it.
13+
14+
This is a best-effort fallback for compositor startup races. It intentionally
15+
does not override an existing WAYLAND_DISPLAY value.
16+
"""
17+
if os.environ.get("WAYLAND_DISPLAY"):
18+
return
19+
20+
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
21+
if not runtime_dir:
22+
print("[WARN] WAYLAND_DISPLAY unset and XDG_RUNTIME_DIR missing", flush=True)
23+
return
24+
25+
runtime_path = Path(runtime_dir)
26+
if not runtime_path.is_dir():
27+
print("[WARN] WAYLAND_DISPLAY unset and XDG_RUNTIME_DIR is not a directory", flush=True)
28+
return
29+
30+
candidates = []
31+
for path in runtime_path.glob("wayland-*"):
32+
try:
33+
path_stat = path.stat()
34+
except OSError:
35+
continue
36+
if stat.S_ISSOCK(path_stat.st_mode):
37+
candidates.append((path_stat.st_mtime, path.name, path))
38+
39+
if not candidates:
40+
return
41+
42+
# A newly bound compositor socket is the most likely active display after a
43+
# startup race leaves WAYLAND_DISPLAY out of the systemd user environment.
44+
_mtime, display_name, _path = max(candidates)
45+
os.environ["WAYLAND_DISPLAY"] = display_name
46+
print(f"[INIT] WAYLAND_DISPLAY was unset; using {display_name}", flush=True)

tests/test_session_environment.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import os
2+
import stat as stat_module
3+
import sys
4+
import tempfile
5+
import unittest
6+
from pathlib import Path
7+
from unittest import mock
8+
9+
10+
ROOT = Path(__file__).resolve().parents[1]
11+
sys.path.insert(0, str(ROOT / "lib" / "src"))
12+
13+
from session_environment import ensure_wayland_display
14+
15+
16+
class SessionEnvironmentTests(unittest.TestCase):
17+
def _socket_file_at(self, path, mtime=100):
18+
path.write_text("", encoding="utf-8")
19+
path.chmod(0o600)
20+
os.utime(path, (mtime, mtime))
21+
return path
22+
23+
def _non_socket_file_at(self, path, mtime=100):
24+
path.write_text("", encoding="utf-8")
25+
path.chmod(0o644)
26+
os.utime(path, (mtime, mtime))
27+
return path
28+
29+
def _socket_mode_patch(self):
30+
# The sandbox can deny AF_UNIX bind() in temp dirs, so tests mark fake
31+
# sockets with a distinct mode while production uses the real stat check.
32+
return mock.patch(
33+
"session_environment.stat.S_ISSOCK",
34+
side_effect=lambda mode: stat_module.S_IMODE(mode) == 0o600,
35+
)
36+
37+
def test_does_nothing_when_wayland_display_already_set(self):
38+
with tempfile.TemporaryDirectory() as tmpdir:
39+
self._socket_file_at(Path(tmpdir) / "wayland-1")
40+
with (
41+
mock.patch.dict(
42+
os.environ,
43+
{"WAYLAND_DISPLAY": "wayland-existing", "XDG_RUNTIME_DIR": tmpdir},
44+
clear=True,
45+
),
46+
mock.patch("builtins.print") as print_mock,
47+
self._socket_mode_patch(),
48+
):
49+
ensure_wayland_display()
50+
self.assertEqual(os.environ.get("WAYLAND_DISPLAY"), "wayland-existing")
51+
52+
print_mock.assert_not_called()
53+
54+
def test_sets_wayland_display_when_one_socket_exists(self):
55+
with tempfile.TemporaryDirectory() as tmpdir:
56+
self._socket_file_at(Path(tmpdir) / "wayland-1")
57+
with (
58+
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": tmpdir}, clear=True),
59+
self._socket_mode_patch(),
60+
):
61+
ensure_wayland_display()
62+
self.assertEqual(os.environ.get("WAYLAND_DISPLAY"), "wayland-1")
63+
64+
def test_chooses_newest_socket_when_multiple_exist(self):
65+
with tempfile.TemporaryDirectory() as tmpdir:
66+
runtime_dir = Path(tmpdir)
67+
self._socket_file_at(runtime_dir / "wayland-0", mtime=100)
68+
self._socket_file_at(runtime_dir / "wayland-1", mtime=200)
69+
70+
with (
71+
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": tmpdir}, clear=True),
72+
self._socket_mode_patch(),
73+
):
74+
ensure_wayland_display()
75+
self.assertEqual(os.environ.get("WAYLAND_DISPLAY"), "wayland-1")
76+
77+
def test_ignores_non_socket_files_matching_wayland_pattern(self):
78+
with tempfile.TemporaryDirectory() as tmpdir:
79+
runtime_dir = Path(tmpdir)
80+
self._non_socket_file_at(runtime_dir / "wayland-9", mtime=300)
81+
self._socket_file_at(runtime_dir / "wayland-1", mtime=100)
82+
83+
with (
84+
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": tmpdir}, clear=True),
85+
self._socket_mode_patch(),
86+
):
87+
ensure_wayland_display()
88+
self.assertEqual(os.environ.get("WAYLAND_DISPLAY"), "wayland-1")
89+
90+
def test_does_nothing_when_runtime_dir_has_no_sockets(self):
91+
with tempfile.TemporaryDirectory() as tmpdir:
92+
runtime_dir = Path(tmpdir)
93+
self._non_socket_file_at(runtime_dir / "wayland-0")
94+
95+
with (
96+
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": tmpdir}, clear=True),
97+
self._socket_mode_patch(),
98+
):
99+
ensure_wayland_display()
100+
self.assertIsNone(os.environ.get("WAYLAND_DISPLAY"))
101+
102+
def test_does_not_raise_when_xdg_runtime_dir_unset_or_missing(self):
103+
with mock.patch.dict(os.environ, {}, clear=True):
104+
ensure_wayland_display()
105+
self.assertIsNone(os.environ.get("WAYLAND_DISPLAY"))
106+
107+
with mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/path/that/does/not/exist"}, clear=True):
108+
ensure_wayland_display()
109+
self.assertIsNone(os.environ.get("WAYLAND_DISPLAY"))
110+
111+
112+
if __name__ == "__main__":
113+
unittest.main()

0 commit comments

Comments
 (0)