portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
portacode/_version.py CHANGED
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '0.3.4.dev'
21
- __version_tuple__ = version_tuple = (0, 3, 4, 'dev0')
31
+ __version__ = version = '1.4.11.dev0'
32
+ __version_tuple__ = version_tuple = (1, 4, 11, 'dev0')
33
+
34
+ __commit_id__ = commit_id = None
portacode/cli.py CHANGED
@@ -7,17 +7,25 @@ from multiprocessing import Process
7
7
  from pathlib import Path
8
8
  import signal
9
9
  import json
10
+ import socket
10
11
 
11
12
  import click
12
13
  import pyperclip
13
14
 
14
15
  from . import __version__
15
16
  from .data import get_pid_file, is_process_running
16
- from .keypair import get_or_create_keypair, fingerprint_public_key
17
+ from .keypair import (
18
+ get_or_create_keypair,
19
+ fingerprint_public_key,
20
+ generate_in_memory_keypair,
21
+ keypair_files_exist,
22
+ )
23
+ from .pairing import PairingError, pair_device_with_code
17
24
  from .connection.client import ConnectionManager, run_until_interrupt
18
25
 
19
- GATEWAY_URL = "wss://device.portacode.com/gateway"
26
+ GATEWAY_URL = "wss://portacode.com/gateway"
20
27
  GATEWAY_ENV = "PORTACODE_GATEWAY"
28
+ MAX_PROJECT_PATHS = 10
21
29
 
22
30
 
23
31
  @click.group()
@@ -29,11 +37,59 @@ def cli() -> None:
29
37
  @cli.command()
30
38
  @click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
31
39
  @click.option("--detach", "detach", "-d", is_flag=True, help="Run connection in background")
40
+ @click.option("--debug", "debug", is_flag=True, help="Enable debug logging")
41
+ @click.option("--log-categories", "log_categories", help="Comma-separated list of log categories to show (e.g., 'connection,auth,git'). Use 'list' to see available categories.")
32
42
  @click.option("--non-interactive", "non_interactive", is_flag=True, envvar="PORTACODE_NON_INTERACTIVE", hidden=True,
33
43
  help="Skip interactive prompts (used by background service)")
34
- def connect(gateway: str | None, detach: bool, non_interactive: bool) -> None: # noqa: D401 Click callback
44
+ @click.option("--pairing-code", "pairing_code_opt", envvar="PORTACODE_PAIRING_CODE", help="Provide a temporary pairing code for zero-copy onboarding")
45
+ @click.option("--device-name", "device_name_opt", envvar="PORTACODE_DEVICE_NAME", help="Custom device name to display during pairing")
46
+ @click.option(
47
+ "--project-path",
48
+ "project_paths_opt",
49
+ multiple=True,
50
+ help="Project folder to register during pairing (repeat for multiple paths)",
51
+ )
52
+ def connect(
53
+ gateway: str | None,
54
+ detach: bool,
55
+ debug: bool,
56
+ log_categories: str | None,
57
+ non_interactive: bool,
58
+ pairing_code_opt: str | None,
59
+ device_name_opt: str | None,
60
+ project_paths_opt: tuple[str, ...],
61
+ ) -> None: # noqa: D401 – Click callback
35
62
  """Connect this machine to Portacode gateway."""
36
63
 
64
+ # Set up debug logging if requested
65
+ if debug:
66
+ import logging
67
+ from .logging_categories import configure_logging_categories, parse_category_string, list_available_categories
68
+
69
+ # Handle log categories
70
+ if log_categories == "list":
71
+ click.echo(click.style("Available log categories:", fg="cyan"))
72
+ for cat in list_available_categories():
73
+ click.echo(f" • {cat}")
74
+ return
75
+
76
+ enabled_categories = set()
77
+ if log_categories:
78
+ try:
79
+ enabled_categories = parse_category_string(log_categories)
80
+ configure_logging_categories(enabled_categories)
81
+ click.echo(click.style(f"🔍 Debug logging enabled for categories: {', '.join(sorted(enabled_categories))}", fg="yellow"))
82
+ except ValueError as e:
83
+ click.echo(click.style(f"Error: {e}", fg="red"))
84
+ return
85
+ else:
86
+ click.echo(click.style("🔍 Debug logging enabled (all categories)", fg="yellow"))
87
+
88
+ logging.basicConfig(
89
+ level=logging.DEBUG,
90
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
91
+ )
92
+
37
93
  # 1. Ensure only a single connection per user
38
94
  pid_file = get_pid_file()
39
95
  if pid_file.exists():
@@ -43,17 +99,31 @@ def connect(gateway: str | None, detach: bool, non_interactive: bool) -> None:
43
99
  other_pid = None
44
100
 
45
101
  if other_pid and is_process_running(other_pid):
46
- click.echo(
47
- click.style(
48
- f"Another portacode connection (PID {other_pid}) is active.", fg="yellow"
102
+ if other_pid == os.getpid():
103
+ # We just wrote our own PID (stale pidfile from previous run) – ignore safely.
104
+ click.echo(
105
+ click.style(
106
+ "Detected stale portacode pid file referencing the current process; ignoring.",
107
+ fg="bright_black",
108
+ )
49
109
  )
50
- )
51
- if click.confirm("Terminate the existing connection?", default=False):
52
- _terminate_process(other_pid)
53
110
  pid_file.unlink(missing_ok=True)
54
111
  else:
55
- click.echo("Aborting.")
56
- sys.exit(1)
112
+ click.echo(
113
+ click.style(
114
+ f"Another portacode connection (PID {other_pid}) is active.", fg="yellow"
115
+ )
116
+ )
117
+ if non_interactive:
118
+ click.echo(click.style("Non-interactive mode: terminating the existing connection automatically.", fg="bright_black"))
119
+ _terminate_process(other_pid)
120
+ pid_file.unlink(missing_ok=True)
121
+ elif click.confirm("Terminate the existing connection?", default=False):
122
+ _terminate_process(other_pid)
123
+ pid_file.unlink(missing_ok=True)
124
+ else:
125
+ click.echo("Aborting.")
126
+ sys.exit(1)
57
127
  else:
58
128
  # Stale pidfile
59
129
  pid_file.unlink(missing_ok=True)
@@ -61,12 +131,78 @@ def connect(gateway: str | None, detach: bool, non_interactive: bool) -> None:
61
131
  # Determine gateway URL
62
132
  target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
63
133
 
64
- # 2. Load or create keypair
65
- keypair = get_or_create_keypair()
134
+ pairing_code = pairing_code_opt.strip() if pairing_code_opt else None
135
+ device_name = device_name_opt.strip() if device_name_opt else None
136
+ normalized_project_paths: list[str] = []
137
+ for raw in project_paths_opt:
138
+ if not raw:
139
+ continue
140
+ cleaned = raw.strip()
141
+ if not cleaned:
142
+ continue
143
+ if len(cleaned) > 500:
144
+ click.echo(click.style(f"Project path too long (max 500 chars): {cleaned[:60]}…", fg="red"))
145
+ sys.exit(1)
146
+ if cleaned not in normalized_project_paths:
147
+ normalized_project_paths.append(cleaned)
148
+ if len(normalized_project_paths) > MAX_PROJECT_PATHS:
149
+ click.echo(
150
+ click.style(
151
+ f"Too many project paths provided ({len(normalized_project_paths)}). "
152
+ f"Maximum allowed is {MAX_PROJECT_PATHS}.",
153
+ fg="red",
154
+ )
155
+ )
156
+ sys.exit(1)
157
+
158
+ pairing_requested = bool(pairing_code)
159
+ existing_identity = keypair_files_exist()
160
+ should_pair = pairing_requested and not existing_identity
161
+
162
+ if normalized_project_paths and not pairing_requested:
163
+ click.echo(
164
+ click.style(
165
+ "⚠ Project paths were provided but no pairing code was supplied; "
166
+ "they will be ignored.",
167
+ fg="yellow",
168
+ )
169
+ )
170
+
171
+ # 2. Load or create keypair (in memory if pairing)
172
+ if should_pair:
173
+ keypair = generate_in_memory_keypair()
174
+ else:
175
+ keypair = get_or_create_keypair()
176
+
177
+ if should_pair:
178
+ if not device_name:
179
+ device_name = socket.gethostname() or "Portacode Device"
180
+ click.echo(click.style(f"🔐 Pairing code detected; pairing as '{device_name}'...", fg="cyan"))
181
+ try:
182
+ pair_device_with_code(
183
+ keypair,
184
+ pairing_code=pairing_code,
185
+ device_name=device_name,
186
+ project_paths=normalized_project_paths or None,
187
+ )
188
+ except PairingError as exc:
189
+ click.echo(click.style(f"Pairing failed: {exc}", fg="red"))
190
+ sys.exit(1)
191
+ # Persist keypair after successful approval
192
+ keypair = keypair.persist()
193
+ click.echo(click.style("✅ Pairing approved. Continuing with connection…", fg="green"))
194
+ elif pairing_requested and existing_identity:
195
+ click.echo(
196
+ click.style(
197
+ "ℹ Pairing code provided but an existing device identity was found; skipping pairing step.",
198
+ fg="yellow",
199
+ )
200
+ )
201
+
66
202
  fingerprint = fingerprint_public_key(keypair.public_key_pem)
67
203
 
68
204
  pubkey_b64 = keypair.public_key_der_b64()
69
- if not non_interactive:
205
+ if not non_interactive and not pairing_requested:
70
206
  # Show key generation status
71
207
  if getattr(keypair, '_is_new', False):
72
208
  click.echo()
@@ -84,7 +220,7 @@ def connect(gateway: str | None, detach: bool, non_interactive: bool) -> None:
84
220
  click.echo(click.style("🚀 Welcome to Portacode!", fg="bright_blue", bold=True))
85
221
  click.echo()
86
222
  click.echo(click.style("📱 Next steps:", fg="bright_cyan", bold=True))
87
- click.echo(click.style(" 1. Visit ", fg="white") + click.style("https://remote.portacode.com", fg="bright_blue", underline=True))
223
+ click.echo(click.style(" 1. Visit ", fg="white") + click.style("https://portacode.com", fg="bright_blue", underline=True))
88
224
  click.echo(click.style(" 2. Create your free account or sign in", fg="white"))
89
225
  click.echo(click.style(" 3. Add this device using the key below", fg="white"))
90
226
  click.echo()
@@ -172,7 +308,7 @@ def connect(gateway: str | None, detach: bool, non_interactive: bool) -> None:
172
308
  pid_file.write_text(str(os.getpid()))
173
309
 
174
310
  async def _main() -> None:
175
- mgr = ConnectionManager(target_gateway, keypair)
311
+ mgr = ConnectionManager(target_gateway, keypair, debug=debug)
176
312
  await run_until_interrupt(mgr)
177
313
 
178
314
  try:
@@ -187,7 +323,7 @@ def _run_connection_forever(url: str, keypair, pid_file: Path):
187
323
  pid_file.write_text(str(os.getpid()))
188
324
 
189
325
  async def _main() -> None:
190
- mgr = ConnectionManager(url, keypair)
326
+ mgr = ConnectionManager(url, keypair, debug=debug)
191
327
  await run_until_interrupt(mgr)
192
328
 
193
329
  asyncio.run(_main())
@@ -239,7 +375,7 @@ def send_control(message: str, gateway: str | None) -> None: # noqa: D401 – C
239
375
 
240
376
  async def _run() -> None:
241
377
  keypair = get_or_create_keypair()
242
- mgr = ConnectionManager(target_gateway, keypair)
378
+ mgr = ConnectionManager(target_gateway, keypair, debug=debug)
243
379
  await mgr.start()
244
380
 
245
381
  # Wait until mux is available & authenticated (rudimentary – 2s timeout)
@@ -368,4 +504,4 @@ def service_status(verbose: bool) -> None: # noqa: D401
368
504
  click.echo("\n--- system output ---")
369
505
  click.echo(mgr.status_verbose())
370
506
  except Exception as exc:
371
- click.echo(click.style(f"Failed: {exc}", fg="red"))
507
+ click.echo(click.style(f"Failed: {exc}", fg="red"))
@@ -8,14 +8,17 @@ from typing import Optional
8
8
  import json
9
9
  import base64
10
10
  import sys
11
+ import secrets
12
+ import time
11
13
 
12
14
  import websockets
13
15
  from websockets import WebSocketClientProtocol
14
16
 
15
17
  from ..keypair import KeyPair
16
18
  from .multiplex import Multiplexer
19
+ from ..logging_categories import get_categorized_logger, LogCategory
17
20
 
18
- logger = logging.getLogger(__name__)
21
+ logger = get_categorized_logger(__name__)
19
22
 
20
23
 
21
24
  class ConnectionManager:
@@ -24,7 +27,7 @@ class ConnectionManager:
24
27
  Parameters
25
28
  ----------
26
29
  gateway_url: str
27
- WebSocket URL, e.g. ``wss://device.portacode.com/gateway``
30
+ WebSocket URL, e.g. ``wss://portacode.com/gateway``
28
31
  keypair: KeyPair
29
32
  User's public/private keypair used for authentication.
30
33
  reconnect_delay: float
@@ -35,10 +38,17 @@ class ConnectionManager:
35
38
  service manager restart.
36
39
  """
37
40
 
38
- def __init__(self, gateway_url: str, keypair: KeyPair, reconnect_delay: float = 1.0, max_retries: int = None):
41
+ CLOCK_SYNC_INTERVAL = 60.0
42
+ CLOCK_SYNC_FAST_INTERVAL = 1.0
43
+ CLOCK_SYNC_INITIAL_REQUESTS = 5
44
+ CLOCK_SYNC_TIMEOUT = 20.0
45
+ CLOCK_SYNC_MAX_FAILURES = 3
46
+
47
+ def __init__(self, gateway_url: str, keypair: KeyPair, reconnect_delay: float = 1.0, max_retries: int = None, debug: bool = False):
39
48
  self.gateway_url = gateway_url
40
49
  self.keypair = keypair
41
50
  self.reconnect_delay = reconnect_delay
51
+ self.debug = debug
42
52
  # max_retries is now deprecated but kept for backwards compatibility
43
53
  self.max_retries = max_retries
44
54
 
@@ -47,6 +57,12 @@ class ConnectionManager:
47
57
 
48
58
  self.websocket: Optional[WebSocketClientProtocol] = None
49
59
  self.mux: Optional[Multiplexer] = None
60
+ self._clock_sync_task: Optional[asyncio.Task[None]] = None
61
+ self._clock_sync_future: Optional[asyncio.Future] = None
62
+ self._clock_sync_request_id: Optional[str] = None
63
+ self._clock_sync_sent_at: Optional[float] = None
64
+ self._clock_sync_failures = 0
65
+ self._remaining_initial_syncs = self.CLOCK_SYNC_INITIAL_REQUESTS
50
66
 
51
67
  async def start(self) -> None:
52
68
  """Start the background task that maintains the connection."""
@@ -66,9 +82,9 @@ class ConnectionManager:
66
82
  try:
67
83
  if attempt:
68
84
  delay = min(self.reconnect_delay * 2 ** (attempt - 1), 30)
69
- logger.warning("Reconnecting in %.1f s (attempt %d)…", delay, attempt)
85
+ logger.warning("Reconnecting in %.1f s (attempt %d)…", LogCategory.CONNECTION, delay, attempt)
70
86
  await asyncio.sleep(delay)
71
- logger.info("Connecting to gateway at %s", self.gateway_url)
87
+ logger.info("Connecting to gateway at %s", LogCategory.CONNECTION, self.gateway_url)
72
88
  async with websockets.connect(self.gateway_url) as ws:
73
89
  # Reset attempt counter after successful connection
74
90
  attempt = 0
@@ -87,21 +103,25 @@ class ConnectionManager:
87
103
  if getattr(self, "_terminal_manager", None):
88
104
  self._terminal_manager.attach_mux(self.mux)
89
105
  else:
90
- self._terminal_manager = TerminalManager(self.mux) # noqa: pylint=attribute-defined-outside-init
106
+ self._terminal_manager = TerminalManager(self.mux, debug=self.debug) # noqa: pylint=attribute-defined-outside-init
91
107
  except Exception as exc:
92
- logger.warning("TerminalManager unavailable: %s", exc)
108
+ logger.warning("TerminalManager unavailable: %s", LogCategory.TERMINAL, exc)
93
109
 
94
- # Start main receive loop until closed or stop requested
95
- await self._listen()
110
+ self._start_clock_sync_task()
111
+ try:
112
+ # Start main receive loop until closed or stop requested
113
+ await self._listen()
114
+ finally:
115
+ await self._stop_clock_sync_task()
96
116
  except (OSError, websockets.WebSocketException, asyncio.TimeoutError) as exc:
97
117
  attempt += 1
98
- logger.warning("Connection error: %s", exc)
118
+ logger.warning("Connection error: %s", LogCategory.CONNECTION, exc)
99
119
  # Remove the max_retries limit - keep trying indefinitely
100
120
  # The service manager (systemd) will handle any necessary restarts
101
121
  except Exception as exc:
102
122
  # For truly fatal errors (like authentication failures),
103
123
  # log and exit cleanly so systemd can restart the service
104
- logger.exception("Fatal error in connection manager: %s", exc)
124
+ logger.exception("Fatal error in connection manager: %s", LogCategory.CONNECTION, exc)
105
125
  # Exit cleanly to allow systemd restart
106
126
  sys.exit(1)
107
127
 
@@ -140,12 +160,35 @@ class ConnectionManager:
140
160
  print("Press Cmd+C to close the connection.")
141
161
  else:
142
162
  print("Press Ctrl+C to close the connection.")
163
+ # Finished authentication flow.
143
164
 
144
165
  async def _listen(self) -> None:
145
166
  assert self.websocket is not None, "WebSocket not ready"
146
167
  while not self._stop_event.is_set():
147
168
  try:
148
169
  message = await asyncio.wait_for(self.websocket.recv(), timeout=1.0)
170
+
171
+ try:
172
+ data = json.loads(message)
173
+ except Exception:
174
+ data = None
175
+
176
+ if isinstance(data, dict):
177
+ payload = data.get("payload", {})
178
+ if isinstance(payload, dict) and payload.get("event") == "clock_sync_response":
179
+ receive_time_ms = int(time.time() * 1000)
180
+ await self._handle_clock_sync_response(payload, receive_time_ms)
181
+ continue
182
+ if isinstance(payload, dict) and "trace" in payload and "request_id" in payload:
183
+ from portacode.utils.ntp_clock import ntp_clock
184
+ device_receive_time = ntp_clock.now_ms()
185
+ if device_receive_time is not None:
186
+ payload["trace"]["device_receive"] = device_receive_time
187
+ if "client_send" in payload["trace"]:
188
+ payload["trace"]["ping"] = device_receive_time - payload["trace"]["client_send"]
189
+ message = json.dumps(data)
190
+ logger.info(f"📥 Device received traced message: {payload['request_id']}")
191
+
149
192
  if self.mux:
150
193
  await self.mux.on_raw_message(message)
151
194
  except asyncio.TimeoutError:
@@ -158,6 +201,103 @@ class ConnectionManager:
158
201
  except Exception:
159
202
  pass
160
203
 
204
+ def _start_clock_sync_task(self) -> None:
205
+ if self._clock_sync_task:
206
+ self._clock_sync_task.cancel()
207
+ self._clock_sync_task = asyncio.create_task(self._clock_sync_loop())
208
+ self._remaining_initial_syncs = self.CLOCK_SYNC_INITIAL_REQUESTS
209
+
210
+ async def _stop_clock_sync_task(self) -> None:
211
+ if self._clock_sync_task:
212
+ self._clock_sync_task.cancel()
213
+ try:
214
+ await self._clock_sync_task
215
+ except asyncio.CancelledError:
216
+ pass
217
+ self._clock_sync_task = None
218
+ if self._clock_sync_future and not self._clock_sync_future.done():
219
+ self._clock_sync_future.cancel()
220
+ self._clock_sync_future = None
221
+ self._clock_sync_request_id = None
222
+ self._clock_sync_sent_at = None
223
+
224
+ async def _clock_sync_loop(self) -> None:
225
+ try:
226
+ while self._remaining_initial_syncs > 0 and not self._stop_event.is_set():
227
+ await self._perform_clock_sync()
228
+ self._remaining_initial_syncs -= 1
229
+ if self._remaining_initial_syncs > 0:
230
+ await asyncio.sleep(self.CLOCK_SYNC_FAST_INTERVAL)
231
+ except asyncio.CancelledError:
232
+ pass
233
+
234
+ async def _perform_clock_sync(self) -> None:
235
+ if not self.websocket or getattr(self.websocket, "closed", False):
236
+ return
237
+ request_id = f"clock_sync:{secrets.token_urlsafe(6)}"
238
+ payload = {
239
+ "event": "clock_sync_request",
240
+ "request_id": request_id,
241
+ }
242
+ message = json.dumps({"channel": 0, "payload": payload})
243
+ self._clock_sync_request_id = request_id
244
+ self._clock_sync_sent_at = int(time.time() * 1000)
245
+ loop = asyncio.get_running_loop()
246
+ self._clock_sync_future = loop.create_future()
247
+ try:
248
+ await self.websocket.send(message)
249
+ except Exception as exc:
250
+ logger.warning("Clock sync send failed: %s", exc)
251
+ await self._handle_clock_sync_timeout()
252
+ return
253
+
254
+ try:
255
+ await asyncio.wait_for(self._clock_sync_future, timeout=self.CLOCK_SYNC_TIMEOUT)
256
+ self._clock_sync_failures = 0
257
+ except asyncio.TimeoutError:
258
+ await self._handle_clock_sync_timeout()
259
+ finally:
260
+ self._clock_sync_future = None
261
+ self._clock_sync_request_id = None
262
+ self._clock_sync_sent_at = None
263
+
264
+ async def _handle_clock_sync_response(self, payload: dict, receive_time_ms: int) -> None:
265
+ if payload.get("request_id") != self._clock_sync_request_id:
266
+ return
267
+ if self._clock_sync_future and not self._clock_sync_future.done():
268
+ self._clock_sync_future.set_result(payload)
269
+ server_receive_time = payload.get("server_receive_time")
270
+ server_send_time = payload.get("server_send_time")
271
+ if server_receive_time is not None and server_send_time is not None:
272
+ processing_latency = max(server_send_time - server_receive_time, 0)
273
+ server_time = round(server_receive_time + processing_latency / 2)
274
+ elif server_receive_time is not None:
275
+ server_time = server_receive_time
276
+ elif server_send_time is not None:
277
+ server_time = server_send_time
278
+ else:
279
+ server_time = payload.get("server_time")
280
+ sent_at = self._clock_sync_sent_at
281
+ if server_time is None or sent_at is None:
282
+ return
283
+ round_trip_ms = max(receive_time_ms - sent_at, 0)
284
+ from portacode.utils.ntp_clock import ntp_clock
285
+ ntp_clock.update_from_server(server_time, round_trip_ms)
286
+ self._clock_sync_failures = 0
287
+
288
+ async def _handle_clock_sync_timeout(self) -> None:
289
+ self._clock_sync_failures += 1
290
+ logger.warning(
291
+ "Clock sync timeout (%d/%d), scheduling reconnect...",
292
+ self._clock_sync_failures,
293
+ self.CLOCK_SYNC_MAX_FAILURES,
294
+ )
295
+ if self._clock_sync_failures >= self.CLOCK_SYNC_MAX_FAILURES and self.websocket:
296
+ try:
297
+ await self.websocket.close()
298
+ except Exception:
299
+ pass
300
+
161
301
 
162
302
  async def run_until_interrupt(manager: ConnectionManager) -> None:
163
303
  stop_event = asyncio.Event()
@@ -185,4 +325,4 @@ async def run_until_interrupt(manager: ConnectionManager) -> None:
185
325
  # TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
186
326
  pass
187
327
  await manager.stop()
188
- # TODO: Add any final cleanup logic here (e.g., remove PID files, flush logs)
328
+ # TODO: Add any final cleanup logic here (e.g., remove PID files, flush logs)