-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathheadless.py
More file actions
229 lines (208 loc) · 10.4 KB
/
Copy pathheadless.py
File metadata and controls
229 lines (208 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
"""Headless mode (headless-core plan, H3 / M2).
``python -m polyhost --headless`` runs the operational core + the control
socket with **no Qt import anywhere** in the process — for machines without
a display, or SSH/service use. Drive it with ``polyctl`` (or any client
speaking ``polyhost.server.protocol``).
This module must never import PyQt5 (guarded by
``tests/core/import_guard_test.py`` and ``tests/headless/*``). It owns the
same operational pieces as the GUI client, minus the GUI:
- a :class:`~polyhost.core.poly_core.PolyCore` (device stack, worker,
periodics, reconnect),
- a :class:`~polyhost.server.control_server.ControlServer` on the same
control socket the tray app uses (so ``polyctl`` is identical either way),
- the active-window tick on the **core's own thread** (the GUI uses a
main-thread QTimer instead; here there is no main loop).
"""
import logging
import threading
from polyhost._version import __version__
from polyhost.core.poly_core import PolyCore
from polyhost.server.control_server import ControlServer
class HeadlessHost:
"""Qt-free host: core + control server + core-owned window tick."""
def __init__(self, log, ignore_version=False, allow_key_injection=False):
self.log = log
self._stop = threading.Event()
self._stopped = False
# Set when a self-update lands so run() re-execs into the new version
# after a clean stop (there is no GUI prompt to drive the restart).
self._restart_after_stop = False
self._relay_path = None
self.core = PolyCore(log=log, ignore_version=ignore_version,
start_worker=False, apply_reconnect_in_core=True,
allow_key_injection=allow_key_injection,
telemetry_mode="daemon")
# React to a core-driven self-update (`polyctl update install`): the
# core only applies + emits; the host owns the restart.
self.core.subscribe(self._on_update_event)
# Persist the keyboard console (uprintf output: "Stop idle." etc.) to
# its own file, like the GUI does — the daemon owns the device, so it's
# the right writer. The handler is installed by run_headless; here we
# just log to the named logger when a `console` event arrives.
self.keeb_log = logging.getLogger("PolyKybdConsole")
self.keeb_log.setLevel(logging.INFO)
self.core.subscribe(self._on_console_event)
self.control_server = ControlServer(
self.core, __version__, log, on_shutdown=self.request_stop)
# Optional network window-report listener (H4d) — opt-in, off by
# default. Serves ONLY `window.report` (auth + version gated) so a
# remote forwarder can push the active window over an authenticated
# connection instead of the plaintext TCP relay. Built in start() so a
# bind failure can't break construction; never exposes device control.
self._winreport_server = None
def _on_console_event(self, name, payload):
"""Mirror the GUI: route the keyboard's console output to its log file.
Fires on the core/worker thread (logging is thread-safe)."""
if name != "console":
return
kb_serial, kb_log = payload
if kb_serial:
self.log.info("Received serial communication: %s", kb_serial)
if kb_log:
self.keeb_log.info(kb_log)
def _on_update_event(self, name, payload):
"""Restart (or hand off to the Windows relay) once an update applies.
Fires on the installer thread — just flag + request stop; run()'s
finally does the actual re-exec after server/core are down."""
if name == "update_finished_ok":
self.log.info("Self-update applied; restarting headless host.")
self._restart_after_stop = True
self.request_stop()
elif name == "update_relay_needed":
self._relay_path = (payload or {}).get("relay_path")
self._restart_after_stop = True
self.request_stop()
def start(self):
self.core.worker.start()
self.core.start_telemetry()
# Core-owned active-window tracking (no-op without a display).
self.core.start_window_tracking()
self.control_server.start()
self._maybe_start_window_report_server()
self.log.info("PolyKybdHost running headless. Drive it with `polyctl`.")
def _maybe_start_window_report_server(self):
"""Start the opt-in network window-report listener if enabled."""
try:
enabled = bool(self.core.settings_get("window_report_network_enabled"))
except Exception:
enabled = False
if not enabled:
return
from polyhost.server.window_report_server import WindowReportServer
try:
self._winreport_server = WindowReportServer(
self.core.report_window, __version__, self.log)
self._winreport_server.start()
except Exception:
self.log.exception("Could not start the window-report network listener")
self._winreport_server = None
def request_stop(self):
"""Signal the run loop to exit (called from a server thread)."""
self._stop.set()
def stop(self):
# Idempotent: run() calls this in its finally, but a signal handler or
# test may call it too. Stop accepting clients first, then the
# operational shutdown.
if self._stopped:
return
self._stopped = True
try:
if self._winreport_server is not None:
try:
self._winreport_server.stop()
except Exception:
pass
self.control_server.stop()
finally:
self.core.shutdown()
def run(self):
"""Start and block until a shutdown is requested (or KeyboardInterrupt)."""
self.start()
try:
while not self._stop.wait(0.5):
pass
except KeyboardInterrupt:
self.log.info("Interrupted — shutting down.")
finally:
self.stop()
self._restart_if_requested()
def _restart_if_requested(self):
"""Re-exec into the freshly-installed version (or launch the Windows
locked-file relay, which relaunches us). No-op unless a self-update
completed."""
if not self._restart_after_stop:
return
from polyhost.services import updater
if self._relay_path:
# The relay waits for this process to exit, copies the locked files,
# then relaunches — so we just spawn it and let run() return.
# Detached: this daemon itself normally runs with no console, and a
# plain Popen would make Windows allocate a *new console window* for
# the relay — which the restarted daemon then inherits and dies with
# when someone closes it (see updater.detached_creationflags).
updater.spawn_detached([updater.relaunch_executable(), self._relay_path])
return
# restart_app() re-execs and never returns, so main_app's clean-exit
# marker would never be written — record it here or every self-update
# would read as a crash in crash_log.txt.
from polyhost.util import crash_log
crash_log.note_clean_exit(self.log, "headless daemon (self-update re-exec)")
updater.restart_app()
def run_headless(log_level=logging.INFO, ignore_version=False, developer=None):
"""Entry point for ``--headless`` (see polyhost/main_app.py).
Logs to a rotating ``daemon_log.txt`` **and** to the stream. The file
matters because a GUI-spawned daemon (daemon-by-default, H4b) runs detached
with its stdio sent to DEVNULL — without a file its logs would be lost. The
stream handler keeps a manual ``--headless`` run in a terminal readable. The
daemon uses its own filename (the GUI writes ``host_log.txt``) so a
co-located daemon + ``--connect`` GUI never write the same log file. Both
handlers get the repeat-collapse wrapper so the reconnect-probe spam while
the keyboard is in the bootloader doesn't flood the file."""
from logging.handlers import RotatingFileHandler
from polyhost.util.log_util import (
make_stream_handler, make_collapse_handler, MultiLineFormatter)
fmt = "[%(asctime)s] %(levelname)-7s %(message)s"
file_handler = RotatingFileHandler(
filename="daemon_log.txt", maxBytes=5 * 1024 * 1024, backupCount=3,
encoding="utf-8")
file_handler.setFormatter(logging.Formatter(fmt))
logging.basicConfig(level=log_level, handlers=[
make_collapse_handler(file_handler),
make_collapse_handler(make_stream_handler(fmt)),
])
# The keyboard console gets its own file (matching the GUI's
# polykybd_console.txt), fed by HeadlessHost's `console` subscription. The
# daemon is the device owner, so it's the writer; a co-located --connect GUI
# only reads this file in its log viewer (its own console handler is a
# NullHandler — see host.py).
keeb_log = logging.getLogger("PolyKybdConsole")
keeb_log.setLevel(logging.INFO)
keeb_handler = RotatingFileHandler(
filename="polykybd_console.txt", maxBytes=5 * 1024 * 1024, backupCount=3,
encoding="utf-8")
keeb_handler.setFormatter(MultiLineFormatter(fmt="[%(asctime)s] %(message)s"))
keeb_log.addHandler(keeb_handler)
keeb_log.propagate = False
import os as _os
import platform as _platform
log = logging.getLogger("PolyHost")
log.info("PolyKybdHost %s running headless.", __version__)
log.info(
"Platform: %s %s | Desktop: %s | Session: %s | "
"DISPLAY: %s | WAYLAND_DISPLAY: %s",
_platform.system(), _platform.release(),
_os.getenv("XDG_CURRENT_DESKTOP", "n/a"),
_os.getenv("XDG_SESSION_TYPE", "n/a"),
_os.getenv("DISPLAY", "n/a"),
_os.getenv("WAYLAND_DISPLAY", "n/a"),
)
# Only a DEVELOPER-mode daemon may honour press/release key-injection
# commands. `developer` is resolved by main_app (--dev flag, else the
# developer_mode setting); None means "caller didn't say", in which case fall
# back to the log level as before (--dev 1/2 both map to <= DEBUG).
if developer is None:
developer = log_level <= logging.DEBUG
log.info("Developer mode: %s", developer)
host = HeadlessHost(log, ignore_version=ignore_version,
allow_key_injection=developer)
host.run()