diff --git a/keepercommander/commands/pam_debug/dump.py b/keepercommander/commands/pam_debug/dump.py index 424d1b7b7..aabe2814f 100644 --- a/keepercommander/commands/pam_debug/dump.py +++ b/keepercommander/commands/pam_debug/dump.py @@ -118,7 +118,7 @@ def _on_folder(f): valid_uids.append(rec_uid) - # v6 PAM Configuration records ARE their own graph root — no rotation-cache entry exists for them. + # v6 PAM Configuration records ARE their own graph root - no rotation-cache entry exists for them. if version == 6: config_to_records.setdefault(rec_uid, []).append(rec_uid) record_config_map[rec_uid] = rec_uid @@ -187,7 +187,7 @@ def _on_folder(f): 'revision': revision, } - # data — same structure as `get --format=json` + # data - same structure as `get --format=json` data = {} try: r = api.get_record(params, rec_uid) @@ -199,12 +199,12 @@ def _on_folder(f): except Exception as err: logging.warning('Could not build data for record %s: %s', rec_uid, err) - # graph_sync — dict keyed by config_uid, then by graph name. + # graph_sync - dict keyed by config_uid, then by graph name. # A record may be referenced by more than one PAM Configuration; we query # every already-loaded DAG so cross-config references are captured. # Inner value may contain: - # "vertex_active": bool — present when the record UID is a vertex in that graph - # "edges": [...] — present only when there are active, non-deleted edges + # "vertex_active": bool - present when the record UID is a vertex in that graph + # "edges": [...] - present only when there are active, non-deleted edges # Config/graph keys are omitted when the record has no presence there. graph_sync: Dict[str, Dict[str, dict]] = {} for (c_uid, graph_id), dag in dag_cache.items(): @@ -234,8 +234,8 @@ def _collect_graph_entry(dag: 'DAGType', record_uid: str, params: 'KeeperParams' """Build the per-graph entry for record_uid. Returns a dict with zero or more of: - "vertex_active": bool — record_uid exists as a vertex in this graph - "edges": [...] — active, non-deleted edges referencing record_uid + "vertex_active": bool - record_uid exists as a vertex in this graph + "edges": [...] - active, non-deleted edges referencing record_uid Returns an empty dict when the record has no presence in the graph at all, signalling the caller to omit this graph from the output. @@ -258,7 +258,7 @@ def _collect_edges_for_record(dag: 'DAGType', record_uid: str, params: 'KeeperPa config_uid: str) -> List[dict]: """Return all non-deleted edges that reference record_uid as head or tail. - Inactive edges (active=False) are included — they may represent settings + Inactive edges (active=False) are included - they may represent settings that exist in the graph but have been superseded or are pending deletion. The 'active' field in each output dict lets the caller distinguish them. DELETION and UNDENIAL edges are still excluded (bookkeeping, not data). @@ -325,7 +325,7 @@ def _extract_edge_contents(edge, tail_uid: str, params: 'KeeperParams', config_u fast content_as_dict path has already failed, to avoid unnecessary network round trips. - config_uid is the PAM configuration that owns the DAG being traversed — + config_uid is the PAM configuration that owns the DAG being traversed - passed from the caller so records not in the rotation cache are still handled correctly. """ @@ -357,7 +357,7 @@ def _extract_edge_contents(edge, tail_uid: str, params: 'KeeperParams', config_u except Exception: pass - # All decode/decrypt attempts failed but content exists — return the first + # All decode/decrypt attempts failed but content exists - return the first # 40 bytes as hex so the caller can tell there IS data vs truly absent. raw = edge.content if isinstance(raw, (bytes, str)): diff --git a/keepercommander/commands/pam_launch/launch.py b/keepercommander/commands/pam_launch/launch.py index 2a91dc3cd..3646be303 100644 --- a/keepercommander/commands/pam_launch/launch.py +++ b/keepercommander/commands/pam_launch/launch.py @@ -22,6 +22,7 @@ from keeper_secrets_manager_core.utils import url_safe_str_to_bytes from .terminal_connection import launch_terminal_connection +from .terminal_size import get_terminal_size_pixels, is_interactive_tty from .guac_cli.stdin_handler import StdinHandler from ..base import Command from ..tunnel.port_forward.tunnel_helpers import ( @@ -573,6 +574,32 @@ def signal_handler_fn(signum, frame): stdin_handler.start() logging.debug("STDIN handler started") # (pipe/blob/end mode) + # --- Terminal resize tracking --- + # Resize polling is skipped entirely in non-interactive (piped) + # environments where get_terminal_size() returns a dummy value. + _resize_enabled = is_interactive_tty() + # Poll cols/rows cheaply every N iterations; a timestamp guard + # ensures correctness if the loop sleep interval ever changes. + _RESIZE_POLL_EVERY = 3 # iterations (~0.3 s at 0.1 s/iter) + _RESIZE_POLL_INTERVAL = 0.3 # seconds - authoritative threshold + _RESIZE_DEBOUNCE = 0.25 # seconds - max send rate during drag + _resize_poll_counter = 0 + _last_resize_poll_time = 0.0 + _last_resize_send_time = 0.0 + # Track the last *sent* size; only updated when we actually send. + # This keeps re-detecting the change each poll during rapid resize + # so the final resting size is always dispatched. + _last_sent_cols = 0 + _last_sent_rows = 0 + if _resize_enabled: + try: + _init_ts = shutil.get_terminal_size() + _last_sent_cols = _init_ts.columns + _last_sent_rows = _init_ts.lines + except Exception: + _resize_enabled = False + logging.debug("Could not query initial terminal size - resize polling disabled") + elapsed = 0 while not shutdown_requested and python_handler.running: # Check if tube/connection is closed @@ -588,6 +615,49 @@ def signal_handler_fn(signum, frame): time.sleep(0.1) elapsed += 0.1 + # --- Resize polling (Phase 1: cheap cols/rows check) --- + # Check every _RESIZE_POLL_EVERY iterations AND at least + # _RESIZE_POLL_INTERVAL seconds since the last poll, so the + # check stays correct if the loop sleep ever changes. + if _resize_enabled: + _resize_poll_counter += 1 + _now = time.time() + if ( + _resize_poll_counter % _RESIZE_POLL_EVERY == 0 + and _now - _last_resize_poll_time >= _RESIZE_POLL_INTERVAL + ): + _last_resize_poll_time = _now + try: + _cur_ts = shutil.get_terminal_size() + _cur_cols = _cur_ts.columns + _cur_rows = _cur_ts.lines + except Exception: + _cur_cols, _cur_rows = _last_sent_cols, _last_sent_rows + + if (_cur_cols, _cur_rows) != (_last_sent_cols, _last_sent_rows): + # Phase 2: size changed - apply debounce then + # fetch exact pixels and send. + if _now - _last_resize_send_time >= _RESIZE_DEBOUNCE: + try: + _si = get_terminal_size_pixels(_cur_cols, _cur_rows) + python_handler.send_size( + _si['pixel_width'], + _si['pixel_height'], + _si['dpi'], + ) + _last_sent_cols = _cur_cols + _last_sent_rows = _cur_rows + _last_resize_send_time = _now + logging.debug( + f"Terminal resized: {_cur_cols}x{_cur_rows} cols/rows " + f"-> {_si['pixel_width']}x{_si['pixel_height']}px " + f"@ {_si['dpi']}dpi" + ) + except Exception as _e: + logging.debug(f"Failed to send resize: {_e}") + # else: debounce active - _last_sent_cols/rows unchanged + # so the change is re-detected on the next eligible poll. + # Status indicator every 30 seconds if elapsed % 30.0 < 0.1 and elapsed > 0.1: rx = python_handler.messages_received diff --git a/keepercommander/commands/pam_launch/terminal_connection.py b/keepercommander/commands/pam_launch/terminal_connection.py index 90c896f00..5e182b612 100644 --- a/keepercommander/commands/pam_launch/terminal_connection.py +++ b/keepercommander/commands/pam_launch/terminal_connection.py @@ -23,7 +23,6 @@ import base64 import json import secrets -import shutil import time import uuid from typing import TYPE_CHECKING, Optional, Dict, Any @@ -101,29 +100,23 @@ class ProtocolType: ProtocolType.SQLSERVER: 1433, } -# Default terminal metrics used to translate local console dimensions into the -# pixel-based values that Guacamole expects. -DEFAULT_TERMINAL_COLUMNS = 80 -DEFAULT_TERMINAL_ROWS = 24 -DEFAULT_CELL_WIDTH_PX = 10 -DEFAULT_CELL_HEIGHT_PX = 19 -DEFAULT_SCREEN_DPI = 96 - - -def _build_screen_info(columns: int, rows: int) -> Dict[str, int]: - """Convert character columns/rows into pixel measurements for the Gateway.""" - col_value = columns if isinstance(columns, int) and columns > 0 else DEFAULT_TERMINAL_COLUMNS - row_value = rows if isinstance(rows, int) and rows > 0 else DEFAULT_TERMINAL_ROWS - return { - "columns": col_value, - "rows": row_value, - "pixel_width": col_value * DEFAULT_CELL_WIDTH_PX, - "pixel_height": row_value * DEFAULT_CELL_HEIGHT_PX, - "dpi": DEFAULT_SCREEN_DPI, - } - +from .terminal_size import ( + DEFAULT_TERMINAL_COLUMNS, + DEFAULT_TERMINAL_ROWS, + DEFAULT_CELL_WIDTH_PX, + DEFAULT_CELL_HEIGHT_PX, + DEFAULT_SCREEN_DPI, + _build_screen_info, + get_terminal_size_pixels, +) -DEFAULT_SCREEN_INFO = _build_screen_info(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS) +# Computed at import time using the best available platform APIs so the initial +# offer payload carries accurate pixel dimensions even before the connection +# loop runs. Falls back to fixed cell-size constants if the query fails. +try: + DEFAULT_SCREEN_INFO = get_terminal_size_pixels() +except Exception: + DEFAULT_SCREEN_INFO = _build_screen_info(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS) MAX_MESSAGE_SIZE_LINE = "a=max-message-size:1073741823" @@ -1213,16 +1206,16 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, # Prepare the offer data with terminal-specific parameters # Match webvault format: host, size, audio, video, image (for guacd configuration) # These parameters are needed by Gateway to configure guacd BEFORE OpenConnection - raw_columns = DEFAULT_TERMINAL_COLUMNS - raw_rows = DEFAULT_TERMINAL_ROWS - # Get terminal size for Guacamole size parameter + # Get terminal size for Guacamole size parameter (offer payload). + # get_terminal_size_pixels() queries the terminal internally and uses + # platform-specific APIs (Windows: GetCurrentConsoleFontEx; Unix: + # TIOCGWINSZ) to obtain exact pixel dimensions before falling back to + # the fixed cell-size estimate. try: - terminal_size = shutil.get_terminal_size(fallback=(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS)) - raw_columns = terminal_size.columns - raw_rows = terminal_size.lines + screen_info = get_terminal_size_pixels() except Exception: logging.debug("Falling back to default terminal size for offer payload") - screen_info = _build_screen_info(raw_columns, raw_rows) + screen_info = _build_screen_info(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS) logging.debug( f"Using terminal metrics columns={screen_info['columns']} rows={screen_info['rows']} -> " f"{screen_info['pixel_width']}x{screen_info['pixel_height']}px @ {screen_info['dpi']}dpi" diff --git a/keepercommander/commands/pam_launch/terminal_size.py b/keepercommander/commands/pam_launch/terminal_size.py new file mode 100644 index 000000000..5f304391e --- /dev/null +++ b/keepercommander/commands/pam_launch/terminal_size.py @@ -0,0 +1,302 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Dict[str, int]: + """Convert character columns/rows into pixel measurements for the Gateway.""" + col_value = columns if isinstance(columns, int) and columns > 0 else DEFAULT_TERMINAL_COLUMNS + row_value = rows if isinstance(rows, int) and rows > 0 else DEFAULT_TERMINAL_ROWS + return { + "columns": col_value, + "rows": row_value, + "pixel_width": col_value * DEFAULT_CELL_WIDTH_PX, + "pixel_height": row_value * DEFAULT_CELL_HEIGHT_PX, + "dpi": DEFAULT_SCREEN_DPI, + } + + +# --------------------------------------------------------------------------- +# Module-level caches +# --------------------------------------------------------------------------- + +# DPI is cached for the lifetime of the process. Display DPI rarely changes +# during a session - it would only change if the user moves the console window +# to a different-DPI monitor, which is not worth the overhead of re-querying +# on every resize event. +_dpi: Optional[int] = None + +# TIOCGWINSZ pixel support: None = untested, True = returns non-zero pixels, +# False = permanently disabled (returned all-zero pixel fields). When False, +# _get_pixels_unix() returns (0, 0) immediately without retrying the ioctl. +_tiocgwinsz_works: Optional[bool] = None + +# Interactive TTY flag, cached after first call. +_is_tty: Optional[bool] = None + + +# --------------------------------------------------------------------------- +# TTY detection +# --------------------------------------------------------------------------- + +def is_interactive_tty() -> bool: + """Return True if both stdin and stdout are connected to a real TTY. + + Cached after the first call. When running in a non-interactive environment + (piped I/O, CI, scripted launch) resize polling should be skipped entirely + to avoid spurious or meaningless size-change events. + """ + global _is_tty + if _is_tty is None: + try: + _is_tty = sys.stdin.isatty() and sys.stdout.isatty() + except Exception: + _is_tty = False + return _is_tty + + +# --------------------------------------------------------------------------- +# Platform DPI helpers +# --------------------------------------------------------------------------- + +def _get_dpi_windows() -> int: + """Return display DPI on Windows via ctypes, cached for the session. + + Tries GetDpiForSystem (shcore.dll, Windows 8.1+) first, then falls back + to GetDeviceCaps(LOGPIXELSX). Returns DEFAULT_SCREEN_DPI (96) on failure. + """ + global _dpi + if _dpi is not None: + return _dpi + try: + import ctypes + # GetDpiForSystem - available on Windows 8.1+ via shcore.dll + try: + dpi = ctypes.windll.shcore.GetDpiForSystem() + if dpi and dpi > 0: + _dpi = int(dpi) + return _dpi + except Exception: + pass + # Fallback: GDI GetDeviceCaps(LOGPIXELSX) + LOGPIXELSX = 88 + hdc = ctypes.windll.user32.GetDC(0) + if hdc: + try: + dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, LOGPIXELSX) + if dpi and dpi > 0: + _dpi = int(dpi) + return _dpi + finally: + ctypes.windll.user32.ReleaseDC(0, hdc) + except Exception as e: + logging.debug(f"Could not query Windows DPI: {e}") + _dpi = DEFAULT_SCREEN_DPI + return _dpi + + +def _get_dpi_unix() -> int: + """Return display DPI on Unix/macOS, cached for the session. + + There is no portable, connection-independent way to query DPI from a + terminal process on Unix without a display-server connection. Standard + Guacamole sessions use 96 DPI as the baseline, so we return that. + """ + global _dpi + if _dpi is None: + _dpi = DEFAULT_SCREEN_DPI + return _dpi + + +# --------------------------------------------------------------------------- +# Platform pixel-dimension helpers +# --------------------------------------------------------------------------- + +def _get_pixels_windows(columns: int, rows: int): + """Return (pixel_width, pixel_height) on Windows via GetCurrentConsoleFontEx. + + Retrieves the console font glyph size in pixels (dwFontSize.X / .Y) and + multiplies by columns/rows to get the total terminal window pixel size. + Returns (0, 0) on any failure so the caller can fall back gracefully. + """ + try: + import ctypes + import ctypes.wintypes + + STD_OUTPUT_HANDLE = -11 + handle = ctypes.windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + if not handle or handle == ctypes.wintypes.HANDLE(-1).value: + return 0, 0 + + class COORD(ctypes.Structure): + _fields_ = [('X', ctypes.c_short), ('Y', ctypes.c_short)] + + class CONSOLE_FONT_INFOEX(ctypes.Structure): + _fields_ = [ + ('cbSize', ctypes.c_ulong), + ('nFont', ctypes.c_ulong), + ('dwFontSize', COORD), + ('FontFamily', ctypes.c_uint), + ('FontWeight', ctypes.c_uint), + ('FaceName', ctypes.c_wchar * 32), + ] + + font_info = CONSOLE_FONT_INFOEX() + font_info.cbSize = ctypes.sizeof(CONSOLE_FONT_INFOEX) + + if ctypes.windll.kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(font_info)): + fw = font_info.dwFontSize.X + fh = font_info.dwFontSize.Y + if fw > 0 and fh > 0: + return columns * fw, rows * fh + + return 0, 0 + except Exception as e: + logging.debug(f"GetCurrentConsoleFontEx failed: {e}") + return 0, 0 + + +def _get_pixels_unix(columns: int, rows: int): + """Return (pixel_width, pixel_height) on Unix/macOS via TIOCGWINSZ. + + The kernel struct winsize includes ws_xpixel and ws_ypixel holding the + total terminal pixel dimensions. If those fields are zero on the first + attempt, the failure is cached permanently (_tiocgwinsz_works = False) + and subsequent calls return (0, 0) without retrying the ioctl. + """ + global _tiocgwinsz_works + if _tiocgwinsz_works is False: + return 0, 0 + try: + import fcntl + import termios + + buf = struct.pack('HHHH', 0, 0, 0, 0) + result = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, buf) + # struct winsize layout: ws_row, ws_col, ws_xpixel, ws_ypixel + _ws_row, _ws_col, ws_xpixel, ws_ypixel = struct.unpack('HHHH', result) + if ws_xpixel > 0 and ws_ypixel > 0: + _tiocgwinsz_works = True + return ws_xpixel, ws_ypixel + # Pixel fields are zero - terminal emulator does not populate them. + _tiocgwinsz_works = False + return 0, 0 + except Exception as e: + logging.debug(f"TIOCGWINSZ failed: {e}") + _tiocgwinsz_works = False + return 0, 0 + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def get_terminal_size_pixels( + columns: Optional[int] = None, + rows: Optional[int] = None, +) -> Dict[str, int]: + """Return terminal size in pixels and DPI for a Guacamole 'size' instruction. + + Always re-queries the terminal size internally via shutil.get_terminal_size + for maximum accuracy. The optional *columns* and *rows* arguments serve as + a fallback used only when the internal query fails. + + Platform behaviour + ------------------ + Windows + Uses GetCurrentConsoleFontEx to obtain the console font glyph size in + pixels, then multiplies columns × rows for exact pixel dimensions. + DPI is obtained via GetDpiForSystem (or GetDeviceCaps as fallback). + Both are cached for the session. + + Unix / macOS + Tries TIOCGWINSZ ws_xpixel / ws_ypixel for pixel dimensions. If those + fields are zero (common - many terminal emulators do not fill them in), + the failure is cached permanently and the cell-size fallback is used on + every subsequent call without retrying the ioctl. + + Fallback + When platform-specific pixel APIs return (0, 0), falls back to + _build_screen_info(columns, rows) which uses DEFAULT_CELL_WIDTH_PX / + DEFAULT_CELL_HEIGHT_PX to estimate pixel dimensions from char cells. + + Returns + ------- + dict with keys: columns, rows, pixel_width, pixel_height, dpi + (same structure as _build_screen_info - drop-in compatible) + """ + # Resolve caller-supplied hints as fallback values + fallback_cols = columns if (isinstance(columns, int) and columns > 0) else DEFAULT_TERMINAL_COLUMNS + fallback_rows = rows if (isinstance(rows, int) and rows > 0) else DEFAULT_TERMINAL_ROWS + + # Always re-query for maximum accuracy; use hints only if query fails + try: + ts = shutil.get_terminal_size(fallback=(fallback_cols, fallback_rows)) + actual_cols = ts.columns + actual_rows = ts.lines + except Exception: + actual_cols = fallback_cols + actual_rows = fallback_rows + + # Platform-specific pixel dimensions + if sys.platform == 'win32': + pixel_w, pixel_h = _get_pixels_windows(actual_cols, actual_rows) + dpi = _get_dpi_windows() + else: + pixel_w, pixel_h = _get_pixels_unix(actual_cols, actual_rows) + dpi = _get_dpi_unix() + + # Fallback: platform API returned (0, 0) - use fixed cell-size estimate + if pixel_w <= 0 or pixel_h <= 0: + return _build_screen_info(actual_cols, actual_rows) + + return { + "columns": actual_cols, + "rows": actual_rows, + "pixel_width": pixel_w, + "pixel_height": pixel_h, + "dpi": dpi, + }