diff --git a/kvmd/apps/_scheme.py b/kvmd/apps/_scheme.py index 17f2523aa..2d3e8183b 100644 --- a/kvmd/apps/_scheme.py +++ b/kvmd/apps/_scheme.py @@ -407,6 +407,10 @@ def make_config_scheme() -> dict: "post_stop_cmd_append": Option([], type=valid_options), }, + "presence": { + "enabled": Option(False, type=valid_bool), + }, + "ocr": { "langs": Option(["eng"], type=valid_string_list, unpack_as="default_langs"), "tessdata": Option("/usr/share/tessdata", type=valid_stripped_string_not_empty, unpack_as="data_dir_path") diff --git a/kvmd/apps/kvmd/__init__.py b/kvmd/apps/kvmd/__init__.py index 0bba817d9..36cfa8755 100644 --- a/kvmd/apps/kvmd/__init__.py +++ b/kvmd/apps/kvmd/__init__.py @@ -113,4 +113,5 @@ def main() -> None: keymap_path=config.hid.keymap, stream_forever=config.streamer.forever, + presence_enabled=config.presence.enabled, ).run(**config.server._unpack()) diff --git a/kvmd/apps/kvmd/api/hid.py b/kvmd/apps/kvmd/api/hid.py index d78a2b139..a59c74057 100644 --- a/kvmd/apps/kvmd/api/hid.py +++ b/kvmd/apps/kvmd/api/hid.py @@ -47,6 +47,8 @@ from ....plugins.hid import BaseHid +from .. import presence + from ....validators import raise_error from ....validators.basic import valid_bool from ....validators.basic import valid_number @@ -173,7 +175,7 @@ def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, i # ===== @exposed_ws(1) - async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None: + async def __ws_bin_key_handler(self, ws: WsSession, data: bytes) -> None: try: state = bool(data[0] & 0b01) finish = bool(data[0] & 0b10) @@ -184,9 +186,10 @@ async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None: except Exception: return self.__hid.send_key_event(key, state, finish) + presence.record_input(ws.token) @exposed_ws(2) - async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None: + async def __ws_bin_mouse_button_handler(self, ws: WsSession, data: bytes) -> None: try: state = bool(data[0] & 0b01) if data[0] & 0b10000000: @@ -197,9 +200,10 @@ async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None except Exception: return self.__hid.send_mouse_button_event(button, state) + presence.record_input(ws.token) @exposed_ws(3) - async def __ws_bin_mouse_move_handler(self, _: WsSession, data: bytes) -> None: + async def __ws_bin_mouse_move_handler(self, ws: WsSession, data: bytes) -> None: try: (to_x, to_y) = struct.unpack(">hh", data) to_x = valid_hid_mouse_move(to_x) @@ -207,14 +211,17 @@ async def __ws_bin_mouse_move_handler(self, _: WsSession, data: bytes) -> None: except Exception: return self.__hid.send_mouse_move_event(to_x, to_y) + presence.record_input(ws.token) @exposed_ws(4) - async def __ws_bin_mouse_relative_handler(self, _: WsSession, data: bytes) -> None: + async def __ws_bin_mouse_relative_handler(self, ws: WsSession, data: bytes) -> None: self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_relative_events) + presence.record_input(ws.token) @exposed_ws(5) - async def __ws_bin_mouse_wheel_handler(self, _: WsSession, data: bytes) -> None: + async def __ws_bin_mouse_wheel_handler(self, ws: WsSession, data: bytes) -> None: self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_wheel_events) + presence.record_input(ws.token) def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None: try: @@ -231,7 +238,7 @@ def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[Iterabl # ===== @exposed_ws("key") - async def __ws_key_handler(self, _: WsSession, event: dict) -> None: + async def __ws_key_handler(self, ws: WsSession, event: dict) -> None: try: key = WEB_TO_EVDEV[valid_hid_key(event["key"])] state = valid_bool(event["state"]) @@ -239,32 +246,37 @@ async def __ws_key_handler(self, _: WsSession, event: dict) -> None: except Exception: return self.__hid.send_key_event(key, state, finish) + presence.record_input(ws.token) @exposed_ws("mouse_button") - async def __ws_mouse_button_handler(self, _: WsSession, event: dict) -> None: + async def __ws_mouse_button_handler(self, ws: WsSession, event: dict) -> None: try: button = MOUSE_TO_EVDEV[valid_hid_mouse_button(event["button"])] state = valid_bool(event["state"]) except Exception: return self.__hid.send_mouse_button_event(button, state) + presence.record_input(ws.token) @exposed_ws("mouse_move") - async def __ws_mouse_move_handler(self, _: WsSession, event: dict) -> None: + async def __ws_mouse_move_handler(self, ws: WsSession, event: dict) -> None: try: to_x = valid_hid_mouse_move(event["to"]["x"]) to_y = valid_hid_mouse_move(event["to"]["y"]) except Exception: return self.__hid.send_mouse_move_event(to_x, to_y) + presence.record_input(ws.token) @exposed_ws("mouse_relative") - async def __ws_mouse_relative_handler(self, _: WsSession, event: dict) -> None: + async def __ws_mouse_relative_handler(self, ws: WsSession, event: dict) -> None: self.__process_ws_delta_event(event, self.__hid.send_mouse_relative_events) + presence.record_input(ws.token) @exposed_ws("mouse_wheel") - async def __ws_mouse_wheel_handler(self, _: WsSession, event: dict) -> None: + async def __ws_mouse_wheel_handler(self, ws: WsSession, event: dict) -> None: self.__process_ws_delta_event(event, self.__hid.send_mouse_wheel_events) + presence.record_input(ws.token) def __process_ws_delta_event(self, event: dict, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None: try: diff --git a/kvmd/apps/kvmd/presence.py b/kvmd/apps/kvmd/presence.py new file mode 100644 index 000000000..4170f2a8d --- /dev/null +++ b/kvmd/apps/kvmd/presence.py @@ -0,0 +1,126 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +""" +User presence awareness registry for PiKVM. + +Design: + This module uses module-level state to track which users are connected + (via WebSocket) and which users are actively sending HID input events. + This is safe because kvmd runs as a single-threaded asyncio application; + all calls happen on the same event loop with no concurrent mutations. + +Rate-limiting: + record_input() is called from HID handlers on every key/mouse event, + which can reach ~1kHz during mouse drags. To avoid unnecessary overhead, + each call is rate-limited per user: the time.monotonic() syscall and + dict write are skipped if the last recorded timestamp for that user is + less than 0.25 seconds old. On the common (throttled) path, the function + performs only a dict lookup, a subtraction, and a comparison. + +Memory bounds: + _last_input entries are auto-pruned when older than 1 hour. Pruning + runs inside get_controllers() and get_active(), which are called every + 0.5 seconds by the presence broadcast loop. The _users dict is bounded + by the number of concurrent WebSocket connections. +""" + + +import time + +from ...logging import get_logger + + +# ===== +# token -> username +_users: dict[str, str] = {} + +# username -> last input monotonic timestamp +_last_input: dict[str, float] = {} + +# username -> last record_input monotonic timestamp (for rate-limiting) +_last_record_ts: dict[str, float] = {} + +_RATE_LIMIT_INTERVAL = 0.25 # seconds +_PRUNE_AGE = 3600.0 # 1 hour + + +# ===== +def set_user(token: str, user: str) -> None: + if user: + _users[token] = user + get_logger(0).info("Presence: user %r connected (token=%s...)", user, token[:8]) + + +def unset_user(token: str) -> None: + user = _users.pop(token, None) + if user: + get_logger(0).info("Presence: user %r disconnected (token=%s...)", user, token[:8]) + # Clean up input tracking if no other sessions for this user + if user not in _users.values(): + _last_input.pop(user, None) + _last_record_ts.pop(user, None) + + +def record_input(token: str) -> None: + user = _users.get(token) + if not user: + return + now = time.monotonic() + prev = _last_record_ts.get(user, 0.0) + if (now - prev) < _RATE_LIMIT_INTERVAL: + return + _last_record_ts[user] = now + _last_input[user] = now + + +def get_controllers(window: float=10.0) -> list[str]: + _prune() + now = time.monotonic() + return sorted( + user for (user, ts) in _last_input.items() + if (now - ts) <= window + ) + + +def get_active(idle: float=300.0) -> list[str]: + _prune() + now = time.monotonic() + return sorted( + user for (user, ts) in _last_input.items() + if (now - ts) <= idle + ) + + +def get_connected_users() -> list[str]: + return sorted(set(_users.values())) + + +def _prune() -> None: + now = time.monotonic() + stale = [ + user for (user, ts) in _last_input.items() + if (now - ts) > _PRUNE_AGE + ] + for user in stale: + _last_input.pop(user, None) + _last_record_ts.pop(user, None) diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index ba079b58f..7e0e14950 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -20,6 +20,7 @@ # ========================================================================== # +import asyncio import dataclasses from typing import Callable @@ -84,6 +85,8 @@ from .api.redfish.atx import RedfishAtxApi from .api.redfish.msd import RedfishMsdApi +from . import presence + # ===== class StreamerQualityNotSupported(OperationError): @@ -143,6 +146,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins __EV_INFO_STATE = "info" __EV_SWITCH_STATE = "switch" __EV_CLIENTS_STATE = "clients" # FIXME + __EV_PRESENCE_STATE = "presence" def __init__( # pylint: disable=too-many-arguments,too-many-locals self, @@ -164,6 +168,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals keymap_path: str, stream_forever: bool, + presence_enabled: bool=False, ) -> None: super().__init__() @@ -174,6 +179,9 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals self.__snapshoter = snapshoter # Not a component: No state or cleanup self.__stream_forever = stream_forever + self.__presence_enabled = presence_enabled + self.__presence_loop_task: (asyncio.Task | None) = None + self.__prev_presence_state: (dict | None) = None self.__hid_api = HidApi(hid, keymap_path) # Ugly hack to get keymaps state self.__apis: list[object] = [ @@ -316,11 +324,21 @@ def _on_ws_added(self, ws: WsSession) -> None: self.__auth.start_ws_session(ws.token) self.__hid.clear_events() self.__streamer_notifier.notify() + if self.__presence_enabled: + user = self.__auth.check(ws.token) + if user: + presence.set_user(ws.token, user) + if self.__presence_loop_task is None: + self.__presence_loop_task = asyncio.ensure_future(self.__presence_loop()) + asyncio.ensure_future(self.__broadcast_presence()) def _on_ws_removed(self, ws: WsSession) -> None: self.__auth.stop_ws_session(ws.token) self.__hid.clear_events() self.__streamer_notifier.notify() + if self.__presence_enabled: + presence.unset_user(ws.token) + asyncio.ensure_future(self.__broadcast_presence()) def __get_stream_clients(self) -> int: return sum(map( @@ -328,6 +346,34 @@ def __get_stream_clients(self) -> int: self._get_wss(), )) + # ===== PRESENCE + + async def __broadcast_presence(self) -> None: + try: + connected = presence.get_connected_users() + controllers = presence.get_controllers() + active = presence.get_active() + state = { + "connected": connected, + "controllers": controllers, + "active": active, + } + if state != self.__prev_presence_state: + self.__prev_presence_state = state + await self._broadcast_ws_event(self.__EV_PRESENCE_STATE, state) + except Exception: + get_logger(0).exception("Presence broadcast error") + + async def __presence_loop(self) -> None: + try: + while True: + await asyncio.sleep(0.5) + await self.__broadcast_presence() + except asyncio.CancelledError: + pass + except Exception: + get_logger(0).exception("Presence loop error") + # ===== SYSTEM TASKS async def __stream_controller(self) -> None: diff --git a/web/kvm/index.html b/web/kvm/index.html index 5c9433f4a..b690e4eab 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -46,6 +46,7 @@ + @@ -502,6 +503,15 @@ + + Show who is watching/controlling: + +
+ + +
+ + diff --git a/web/share/css/kvm/presence.css b/web/share/css/kvm/presence.css new file mode 100644 index 000000000..2719b9b87 --- /dev/null +++ b/web/share/css/kvm/presence.css @@ -0,0 +1,56 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +*****************************************************************************/ + + +:root { + --cs-presence-fg: #fff; + --cs-presence-controlling-fg: #4caf50; + --cs-presence-idle-opacity: 0.5; + --cs-presence-shadow: 1px 1px 2px rgba(0,0,0,0.8), -1px -1px 2px rgba(0,0,0,0.8), 1px -1px 2px rgba(0,0,0,0.8), -1px 1px 2px rgba(0,0,0,0.8); + --cs-presence-font-size: 12px; + --cs-presence-top: 6px; + --cs-presence-left: 6px; +} + +.presence-overlay { + position: absolute; + top: var(--cs-presence-top); + left: var(--cs-presence-left); + z-index: 99999; + background: none; + color: var(--cs-presence-fg); + padding: 0; + margin: 0; + font-size: var(--cs-presence-font-size); + line-height: 1.5; + font-family: sans-serif; + pointer-events: none; + text-shadow: var(--cs-presence-shadow); +} + +.presence-user-controlling { + font-weight: bold; + color: var(--cs-presence-controlling-fg); +} + +.presence-user-idle { + opacity: var(--cs-presence-idle-opacity); +} diff --git a/web/share/js/kvm/session.js b/web/share/js/kvm/session.js index 3791851ce..d12fedb89 100644 --- a/web/share/js/kvm/session.js +++ b/web/share/js/kvm/session.js @@ -60,6 +60,12 @@ export function Session() { var __switch = new Switch(); var __init__ = function() { + tools.storage.bindSimpleSwitch($("presence-overlay-switch"), "presence.overlay.visible", true, function(visible) { + let el = $("presence-overlay"); + if (el) { + el.style.display = (visible ? "block" : "none"); + } + }); __streamer.ensureDeps(() => __startSession()); }; @@ -113,6 +119,35 @@ export function Session() { } }; + + var __updatePresenceOverlay = function(ev) { + let el = document.getElementById("presence-overlay"); + if (!el) { + el = document.createElement("div"); + el.id = "presence-overlay"; + el.className = "presence-overlay"; + (document.getElementById("stream-box") || document.body).appendChild(el); + } + let sw = document.getElementById("presence-overlay-switch"); + el.style.display = (sw && !sw.checked) ? "none" : "block"; + let connected = (ev.connected || []); + let controllers = (ev.controllers || []); + let active = (ev.active || []); + let lines = []; + for (let user of connected) { + if (user === "anon") continue; + let name = user.charAt(0).toUpperCase() + user.slice(1); + if (controllers.indexOf(user) >= 0) { + lines.push("" + name + " is controlling"); + } else if (active.indexOf(user) < 0) { + lines.push("" + name + " is watching (idle)"); + } else { + lines.push(name + " is watching"); + } + } + el.innerHTML = lines.length > 0 ? lines.join("
") : ""; + }; + var __wsJsonHandler = function(ev_type, ev) { switch (ev_type) { case "info": __info.setState(ev); break; @@ -121,6 +156,10 @@ export function Session() { case "hid_keymaps": __paste.setState(ev); break; case "atx": __atx.setState(ev); break; case "streamer": __streamer.setState(ev); break; + + case "presence": + __updatePresenceOverlay(ev); + break; case "ocr": __ocr.setState(ev); break; case "msd": @@ -138,6 +177,7 @@ export function Session() { } __switch.setState(ev); break; + } }; @@ -188,6 +228,7 @@ export function Session() { } }; + var __ascii_encoder = new TextEncoder("ascii"); var __sendHidEvent = function(ws, ev_type, ev) {