diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 8bb35b1..cb510d7 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -18,7 +18,7 @@ namespace HermesHubSetup // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. - public const string BuildCommit = "b309972"; + public const string BuildCommit = "6cb0d7c"; public const string MIN_HERMES_VERSION = "0.20.0"; public const string MAX_TESTED_HERMES = "0.20.4"; diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index df3111f..cf00f72 100644 Binary files a/launcher/HermesHub.exe and b/launcher/HermesHub.exe differ diff --git a/launcher/HermesHubWeb.exe b/launcher/HermesHubWeb.exe index b60f253..36e7ad1 100644 Binary files a/launcher/HermesHubWeb.exe and b/launcher/HermesHubWeb.exe differ diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index 469dafb..111ed9d 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -718,6 +718,68 @@ BLOCKED_SECRET_PATTERNS: tuple[str, ...] = ( ) +def detect_graphical_session() -> tuple[dict[str, str], list[str]]: + """Найти графический сеанс владельца. Вернуть (переменные, что проверено). + + Хаб запускается через nohup и наследует окружение той оболочки, из которой + его запустили. Запуск по SSH или из службы оставляет процесс без DISPLAY — + и хаб отказывался открыть терминал, стоя при этом на рабочем столе. + + Наследование не единственный источник. systemd знает про сеанс: на сервере + владельца `loginctl show-user -p Display` даёт c1, а + `loginctl show-session c1` — Type=x11, Display=:10, Active=yes. Спросить у + системы честнее, чем сдаться. + + XAUTHORITY не выставляем: клиенты X11 по умолчанию берут ~/.Xauthority + того же пользователя, а хаб работает под ним же. + """ + checked: list[str] = [] + found: dict[str, str] = {} + + for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"): + value = os.environ.get(var, "").strip() + checked.append(f"{var} ({'задан: ' + value if value else 'не задан'})") + if value: + found[var] = value + + if found: + return found, checked + + loginctl = shutil.which("loginctl") + if not loginctl: + checked.append("loginctl (не найден)") + return {}, checked + + def _ask(args: list[str]) -> str: + try: + res = subprocess.run( + [loginctl, *args], + capture_output=True, text=True, timeout=5, + encoding="utf-8", errors="replace", + stdin=subprocess.DEVNULL, + ) + return res.stdout.strip() if res.returncode == 0 else "" + except Exception: + return "" + + user = os.environ.get("USER") or os.environ.get("LOGNAME") or "" + session = _ask(["show-user", user, "--value", "-p", "Display"]) if user else "" + if not session: + checked.append(f"loginctl show-user {user or '<пользователь неизвестен>'} (сеанс не назван)") + return {}, checked + + stype = _ask(["show-session", session, "--value", "-p", "Type"]) + display = _ask(["show-session", session, "--value", "-p", "Display"]) + checked.append(f"loginctl сеанс {session} (тип: {stype or 'Н/Д'}, дисплей: {display or 'Н/Д'})") + + if stype == "wayland" and display: + found["WAYLAND_DISPLAY"] = display + elif display: + found["DISPLAY"] = display + + return found, checked + + def _is_windows() -> bool: """Отдельная проверка системы, чтобы тестам не подменять os.name. @@ -799,20 +861,12 @@ def find_terminal_emulator( return cmd, None, checked # Linux / Unix / macOS - display = os.environ.get("DISPLAY", "").strip() - wayland = os.environ.get("WAYLAND_DISPLAY", "").strip() - mir = os.environ.get("MIR_SOCKET", "").strip() - - if not display and not wayland and not mir: - checked = [ - f"DISPLAY ({'задан: ' + os.environ['DISPLAY'] if 'DISPLAY' in os.environ else 'не задан'})", - f"WAYLAND_DISPLAY ({'задан: ' + os.environ['WAYLAND_DISPLAY'] if 'WAYLAND_DISPLAY' in os.environ else 'не задан'})", - f"MIR_SOCKET ({'задан: ' + os.environ['MIR_SOCKET'] if 'MIR_SOCKET' in os.environ else 'не задан'})", - ] + session_env, checked = detect_graphical_session() + if not session_env: err_msg = ( - "Графический дисплей не обнаружен (переменные DISPLAY/WAYLAND_DISPLAY не заданы). " - "Для входа на сервере без графического интерфейса используйте вход по ссылке через браузер " - "либо запустите Hub в сессии с графическим дисплеем." + "Графический сеанс не обнаружен: ни в окружении хаба, ни у systemd. " + "Для входа на машине без графического интерфейса используйте вход по ссылке " + "через браузер." ) return None, err_msg, checked @@ -1028,13 +1082,18 @@ def start_native_agy_login( "checked_terminals": checked, } - env = build_safe_subprocess_env( - overrides={ - "HOME": str(profile_dir), - "USERPROFILE": str(profile_dir), - "HOMEPATH": str(profile_dir), - } - ) + # Дисплей, найденный у systemd, обязан попасть в окружение терминала: + # в окружении самого хаба его может не быть, если хаб запущен по SSH или + # службой, а окно всё равно должно открыться на рабочем столе владельца. + overrides = { + "HOME": str(profile_dir), + "USERPROFILE": str(profile_dir), + "HOMEPATH": str(profile_dir), + } + session_env, _checked = detect_graphical_session() + overrides.update(session_env) + + env = build_safe_subprocess_env(overrides=overrides) try: proc = subprocess.Popen( diff --git a/tests/test_a57_agy_native_login.py b/tests/test_a57_agy_native_login.py index f5208a9..fe9a0c2 100644 --- a/tests/test_a57_agy_native_login.py +++ b/tests/test_a57_agy_native_login.py @@ -105,17 +105,24 @@ def test_terminal_discovery_linux_missing_honest_error(tmp_path, monkeypatch): @pytest.mark.unit def test_terminal_discovery_no_display_honest_error(tmp_path, monkeypatch): - """P0-1: When DISPLAY/WAYLAND_DISPLAY are absent, return clear message advising browser fallback.""" + """P0-1: сеанса нет ни в окружении, ни у systemd — отказ с перечнем проверенного. + + Пустое окружение само по себе больше не приговор: хаб запускается через + nohup и по SSH остаётся без DISPLAY, стоя при этом на рабочем столе. Отказ + правомерен, только когда и systemd графического сеанса не знает. + """ monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False) monkeypatch.delenv("DISPLAY", raising=False) monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) monkeypatch.delenv("MIR_SOCKET", raising=False) + monkeypatch.setattr(agy_subprocess.shutil, "which", lambda _n: None) cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path) assert cmd is None assert err is not None - assert "Графический дисплей не обнаружен" in err - assert "DISPLAY/WAYLAND_DISPLAY не заданы" in err + assert "Графический сеанс не обнаружен" in err + assert any("DISPLAY (не задан)" in item for item in checked) + assert any("loginctl" in item for item in checked) @pytest.mark.unit diff --git a/tests/test_a57_review_terminal_launch.py b/tests/test_a57_review_terminal_launch.py index 06663ac..adcc708 100644 --- a/tests/test_a57_review_terminal_launch.py +++ b/tests/test_a57_review_terminal_launch.py @@ -165,3 +165,83 @@ def test_asking_for_a_path_creates_nothing(tmp_path, monkeypatch): created = paths.get_profile_dir("ag-1", "antigravity", create=True) assert created.is_dir() assert sorted(p.name for p in root.iterdir()) == ["ag-1"] + + +# ── Дисплей ищется не только в окружении ── + +def test_display_from_environment_wins(monkeypatch): + from antigravity_provider.agy_subprocess import detect_graphical_session + + monkeypatch.setenv("DISPLAY", ":0") + found, checked = detect_graphical_session() + + assert found["DISPLAY"] == ":0" + assert any("DISPLAY (задан: :0)" in item for item in checked) + + +def test_display_is_asked_from_systemd_when_environment_is_empty(monkeypatch): + """Хаб запускается через nohup: по SSH он остаётся без DISPLAY. + + Измерено на сервере владельца: loginctl show-user даёт сеанс c1, а + show-session c1 — Type=x11, Display=:10. Отказываться, не спросив, рано. + """ + from antigravity_provider import agy_subprocess as m + + for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("USER", "ochenstarik") + monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None) + + answers = {("show-user", "ochenstarik"): "c1", ("show-session", "c1", "Type"): "x11", + ("show-session", "c1", "Display"): ":10"} + + class _Res: + def __init__(self, out): + self.stdout, self.returncode = out, 0 + + def _run(cmd, **kwargs): + if cmd[1] == "show-user": + return _Res(answers[("show-user", cmd[2])]) + return _Res(answers[("show-session", cmd[2], cmd[-1])]) + + monkeypatch.setattr(m.subprocess, "run", _run) + found, checked = m.detect_graphical_session() + + assert found == {"DISPLAY": ":10"} + assert any("loginctl сеанс c1" in item for item in checked) + + +def test_wayland_session_is_reported_as_wayland(monkeypatch): + from antigravity_provider import agy_subprocess as m + + for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("USER", "owner") + monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None) + + class _Res: + def __init__(self, out): + self.stdout, self.returncode = out, 0 + + def _run(cmd, **kwargs): + if cmd[1] == "show-user": + return _Res("2") + return _Res("wayland" if cmd[-1] == "Type" else "wayland-0") + + monkeypatch.setattr(m.subprocess, "run", _run) + found, _ = m.detect_graphical_session() + + assert found == {"WAYLAND_DISPLAY": "wayland-0"} + + +def test_no_session_anywhere_lists_what_was_asked(monkeypatch): + from antigravity_provider import agy_subprocess as m + + for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr(m.shutil, "which", lambda _n: None) + + found, checked = m.detect_graphical_session() + + assert found == {} + assert any("loginctl (не найден)" in item for item in checked)