Skip to content

Commit aa8a735

Browse files
Added terminal size tracking and sending resize events to guacd (#1847)
1 parent 1cf8640 commit aa8a735

4 files changed

Lines changed: 405 additions & 40 deletions

File tree

keepercommander/commands/pam_debug/dump.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def _on_folder(f):
118118

119119
valid_uids.append(rec_uid)
120120

121-
# v6 PAM Configuration records ARE their own graph root no rotation-cache entry exists for them.
121+
# v6 PAM Configuration records ARE their own graph root - no rotation-cache entry exists for them.
122122
if version == 6:
123123
config_to_records.setdefault(rec_uid, []).append(rec_uid)
124124
record_config_map[rec_uid] = rec_uid
@@ -187,7 +187,7 @@ def _on_folder(f):
187187
'revision': revision,
188188
}
189189

190-
# data same structure as `get --format=json`
190+
# data - same structure as `get --format=json`
191191
data = {}
192192
try:
193193
r = api.get_record(params, rec_uid)
@@ -199,12 +199,12 @@ def _on_folder(f):
199199
except Exception as err:
200200
logging.warning('Could not build data for record %s: %s', rec_uid, err)
201201

202-
# graph_sync dict keyed by config_uid, then by graph name.
202+
# graph_sync - dict keyed by config_uid, then by graph name.
203203
# A record may be referenced by more than one PAM Configuration; we query
204204
# every already-loaded DAG so cross-config references are captured.
205205
# Inner value may contain:
206-
# "vertex_active": bool present when the record UID is a vertex in that graph
207-
# "edges": [...] present only when there are active, non-deleted edges
206+
# "vertex_active": bool - present when the record UID is a vertex in that graph
207+
# "edges": [...] - present only when there are active, non-deleted edges
208208
# Config/graph keys are omitted when the record has no presence there.
209209
graph_sync: Dict[str, Dict[str, dict]] = {}
210210
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'
234234
"""Build the per-graph entry for record_uid.
235235
236236
Returns a dict with zero or more of:
237-
"vertex_active": bool record_uid exists as a vertex in this graph
238-
"edges": [...] active, non-deleted edges referencing record_uid
237+
"vertex_active": bool - record_uid exists as a vertex in this graph
238+
"edges": [...] - active, non-deleted edges referencing record_uid
239239
240240
Returns an empty dict when the record has no presence in the graph at all,
241241
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
258258
config_uid: str) -> List[dict]:
259259
"""Return all non-deleted edges that reference record_uid as head or tail.
260260
261-
Inactive edges (active=False) are included they may represent settings
261+
Inactive edges (active=False) are included - they may represent settings
262262
that exist in the graph but have been superseded or are pending deletion.
263263
The 'active' field in each output dict lets the caller distinguish them.
264264
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
325325
fast content_as_dict path has already failed, to avoid unnecessary
326326
network round trips.
327327
328-
config_uid is the PAM configuration that owns the DAG being traversed
328+
config_uid is the PAM configuration that owns the DAG being traversed -
329329
passed from the caller so records not in the rotation cache are still
330330
handled correctly.
331331
"""
@@ -357,7 +357,7 @@ def _extract_edge_contents(edge, tail_uid: str, params: 'KeeperParams', config_u
357357
except Exception:
358358
pass
359359

360-
# All decode/decrypt attempts failed but content exists return the first
360+
# All decode/decrypt attempts failed but content exists - return the first
361361
# 40 bytes as hex so the caller can tell there IS data vs truly absent.
362362
raw = edge.content
363363
if isinstance(raw, (bytes, str)):

keepercommander/commands/pam_launch/launch.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from keeper_secrets_manager_core.utils import url_safe_str_to_bytes
2323

2424
from .terminal_connection import launch_terminal_connection
25+
from .terminal_size import get_terminal_size_pixels, is_interactive_tty
2526
from .guac_cli.stdin_handler import StdinHandler
2627
from ..base import Command
2728
from ..tunnel.port_forward.tunnel_helpers import (
@@ -573,6 +574,32 @@ def signal_handler_fn(signum, frame):
573574
stdin_handler.start()
574575
logging.debug("STDIN handler started") # (pipe/blob/end mode)
575576

577+
# --- Terminal resize tracking ---
578+
# Resize polling is skipped entirely in non-interactive (piped)
579+
# environments where get_terminal_size() returns a dummy value.
580+
_resize_enabled = is_interactive_tty()
581+
# Poll cols/rows cheaply every N iterations; a timestamp guard
582+
# ensures correctness if the loop sleep interval ever changes.
583+
_RESIZE_POLL_EVERY = 3 # iterations (~0.3 s at 0.1 s/iter)
584+
_RESIZE_POLL_INTERVAL = 0.3 # seconds - authoritative threshold
585+
_RESIZE_DEBOUNCE = 0.25 # seconds - max send rate during drag
586+
_resize_poll_counter = 0
587+
_last_resize_poll_time = 0.0
588+
_last_resize_send_time = 0.0
589+
# Track the last *sent* size; only updated when we actually send.
590+
# This keeps re-detecting the change each poll during rapid resize
591+
# so the final resting size is always dispatched.
592+
_last_sent_cols = 0
593+
_last_sent_rows = 0
594+
if _resize_enabled:
595+
try:
596+
_init_ts = shutil.get_terminal_size()
597+
_last_sent_cols = _init_ts.columns
598+
_last_sent_rows = _init_ts.lines
599+
except Exception:
600+
_resize_enabled = False
601+
logging.debug("Could not query initial terminal size - resize polling disabled")
602+
576603
elapsed = 0
577604
while not shutdown_requested and python_handler.running:
578605
# Check if tube/connection is closed
@@ -588,6 +615,49 @@ def signal_handler_fn(signum, frame):
588615
time.sleep(0.1)
589616
elapsed += 0.1
590617

618+
# --- Resize polling (Phase 1: cheap cols/rows check) ---
619+
# Check every _RESIZE_POLL_EVERY iterations AND at least
620+
# _RESIZE_POLL_INTERVAL seconds since the last poll, so the
621+
# check stays correct if the loop sleep ever changes.
622+
if _resize_enabled:
623+
_resize_poll_counter += 1
624+
_now = time.time()
625+
if (
626+
_resize_poll_counter % _RESIZE_POLL_EVERY == 0
627+
and _now - _last_resize_poll_time >= _RESIZE_POLL_INTERVAL
628+
):
629+
_last_resize_poll_time = _now
630+
try:
631+
_cur_ts = shutil.get_terminal_size()
632+
_cur_cols = _cur_ts.columns
633+
_cur_rows = _cur_ts.lines
634+
except Exception:
635+
_cur_cols, _cur_rows = _last_sent_cols, _last_sent_rows
636+
637+
if (_cur_cols, _cur_rows) != (_last_sent_cols, _last_sent_rows):
638+
# Phase 2: size changed - apply debounce then
639+
# fetch exact pixels and send.
640+
if _now - _last_resize_send_time >= _RESIZE_DEBOUNCE:
641+
try:
642+
_si = get_terminal_size_pixels(_cur_cols, _cur_rows)
643+
python_handler.send_size(
644+
_si['pixel_width'],
645+
_si['pixel_height'],
646+
_si['dpi'],
647+
)
648+
_last_sent_cols = _cur_cols
649+
_last_sent_rows = _cur_rows
650+
_last_resize_send_time = _now
651+
logging.debug(
652+
f"Terminal resized: {_cur_cols}x{_cur_rows} cols/rows "
653+
f"-> {_si['pixel_width']}x{_si['pixel_height']}px "
654+
f"@ {_si['dpi']}dpi"
655+
)
656+
except Exception as _e:
657+
logging.debug(f"Failed to send resize: {_e}")
658+
# else: debounce active - _last_sent_cols/rows unchanged
659+
# so the change is re-detected on the next eligible poll.
660+
591661
# Status indicator every 30 seconds
592662
if elapsed % 30.0 < 0.1 and elapsed > 0.1:
593663
rx = python_handler.messages_received

keepercommander/commands/pam_launch/terminal_connection.py

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import base64
2424
import json
2525
import secrets
26-
import shutil
2726
import time
2827
import uuid
2928
from typing import TYPE_CHECKING, Optional, Dict, Any
@@ -101,29 +100,23 @@ class ProtocolType:
101100
ProtocolType.SQLSERVER: 1433,
102101
}
103102

104-
# Default terminal metrics used to translate local console dimensions into the
105-
# pixel-based values that Guacamole expects.
106-
DEFAULT_TERMINAL_COLUMNS = 80
107-
DEFAULT_TERMINAL_ROWS = 24
108-
DEFAULT_CELL_WIDTH_PX = 10
109-
DEFAULT_CELL_HEIGHT_PX = 19
110-
DEFAULT_SCREEN_DPI = 96
111-
112-
113-
def _build_screen_info(columns: int, rows: int) -> Dict[str, int]:
114-
"""Convert character columns/rows into pixel measurements for the Gateway."""
115-
col_value = columns if isinstance(columns, int) and columns > 0 else DEFAULT_TERMINAL_COLUMNS
116-
row_value = rows if isinstance(rows, int) and rows > 0 else DEFAULT_TERMINAL_ROWS
117-
return {
118-
"columns": col_value,
119-
"rows": row_value,
120-
"pixel_width": col_value * DEFAULT_CELL_WIDTH_PX,
121-
"pixel_height": row_value * DEFAULT_CELL_HEIGHT_PX,
122-
"dpi": DEFAULT_SCREEN_DPI,
123-
}
124-
103+
from .terminal_size import (
104+
DEFAULT_TERMINAL_COLUMNS,
105+
DEFAULT_TERMINAL_ROWS,
106+
DEFAULT_CELL_WIDTH_PX,
107+
DEFAULT_CELL_HEIGHT_PX,
108+
DEFAULT_SCREEN_DPI,
109+
_build_screen_info,
110+
get_terminal_size_pixels,
111+
)
125112

126-
DEFAULT_SCREEN_INFO = _build_screen_info(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS)
113+
# Computed at import time using the best available platform APIs so the initial
114+
# offer payload carries accurate pixel dimensions even before the connection
115+
# loop runs. Falls back to fixed cell-size constants if the query fails.
116+
try:
117+
DEFAULT_SCREEN_INFO = get_terminal_size_pixels()
118+
except Exception:
119+
DEFAULT_SCREEN_INFO = _build_screen_info(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS)
127120

128121
MAX_MESSAGE_SIZE_LINE = "a=max-message-size:1073741823"
129122

@@ -1213,16 +1206,16 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams,
12131206
# Prepare the offer data with terminal-specific parameters
12141207
# Match webvault format: host, size, audio, video, image (for guacd configuration)
12151208
# These parameters are needed by Gateway to configure guacd BEFORE OpenConnection
1216-
raw_columns = DEFAULT_TERMINAL_COLUMNS
1217-
raw_rows = DEFAULT_TERMINAL_ROWS
1218-
# Get terminal size for Guacamole size parameter
1209+
# Get terminal size for Guacamole size parameter (offer payload).
1210+
# get_terminal_size_pixels() queries the terminal internally and uses
1211+
# platform-specific APIs (Windows: GetCurrentConsoleFontEx; Unix:
1212+
# TIOCGWINSZ) to obtain exact pixel dimensions before falling back to
1213+
# the fixed cell-size estimate.
12191214
try:
1220-
terminal_size = shutil.get_terminal_size(fallback=(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS))
1221-
raw_columns = terminal_size.columns
1222-
raw_rows = terminal_size.lines
1215+
screen_info = get_terminal_size_pixels()
12231216
except Exception:
12241217
logging.debug("Falling back to default terminal size for offer payload")
1225-
screen_info = _build_screen_info(raw_columns, raw_rows)
1218+
screen_info = _build_screen_info(DEFAULT_TERMINAL_COLUMNS, DEFAULT_TERMINAL_ROWS)
12261219
logging.debug(
12271220
f"Using terminal metrics columns={screen_info['columns']} rows={screen_info['rows']} -> "
12281221
f"{screen_info['pixel_width']}x{screen_info['pixel_height']}px @ {screen_info['dpi']}dpi"

0 commit comments

Comments
 (0)