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.
- portacode/_version.py +16 -3
- portacode/cli.py +155 -19
- portacode/connection/client.py +152 -12
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
- portacode/connection/handlers/__init__.py +43 -1
- portacode/connection/handlers/base.py +122 -18
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -0
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/registry.py +53 -10
- portacode/connection/handlers/session.py +705 -53
- portacode/connection/handlers/system_handlers.py +142 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +150 -11
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +695 -28
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/service.py +6 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev0.dist-info/METADATA +298 -0
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.4.dev0.dist-info/METADATA +0 -236
- portacode-0.3.4.dev0.dist-info/RECORD +0 -27
- portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {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__ = [
|
|
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 = '
|
|
21
|
-
__version_tuple__ = version_tuple = (
|
|
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
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
56
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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://
|
|
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"))
|
portacode/connection/client.py
CHANGED
|
@@ -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 =
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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)
|