Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions keepercommander/commands/pam_debug/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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():
Expand Down Expand Up @@ -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.
Expand All @@ -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).
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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)):
Expand Down
70 changes: 70 additions & 0 deletions keepercommander/commands/pam_launch/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
53 changes: 23 additions & 30 deletions keepercommander/commands/pam_launch/terminal_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import base64
import json
import secrets
import shutil
import time
import uuid
from typing import TYPE_CHECKING, Optional, Dict, Any
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down
Loading