portacode 1.3.32__py3-none-any.whl → 1.4.11.dev5__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 (56) hide show
  1. portacode/_version.py +2 -2
  2. portacode/cli.py +158 -14
  3. portacode/connection/client.py +127 -8
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +370 -4
  5. portacode/connection/handlers/__init__.py +16 -1
  6. portacode/connection/handlers/diff_handlers.py +603 -0
  7. portacode/connection/handlers/file_handlers.py +674 -17
  8. portacode/connection/handlers/project_aware_file_handlers.py +11 -0
  9. portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
  10. portacode/connection/handlers/project_state/git_manager.py +139 -572
  11. portacode/connection/handlers/project_state/handlers.py +28 -14
  12. portacode/connection/handlers/project_state/manager.py +226 -101
  13. portacode/connection/handlers/proxmox_infra.py +790 -0
  14. portacode/connection/handlers/session.py +465 -84
  15. portacode/connection/handlers/system_handlers.py +181 -8
  16. portacode/connection/handlers/tab_factory.py +1 -47
  17. portacode/connection/handlers/update_handler.py +61 -0
  18. portacode/connection/terminal.py +55 -10
  19. portacode/keypair.py +63 -1
  20. portacode/link_capture/__init__.py +38 -0
  21. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  22. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  23. portacode/link_capture/bin/elinks +3 -0
  24. portacode/link_capture/bin/gio-open +3 -0
  25. portacode/link_capture/bin/gnome-open +3 -0
  26. portacode/link_capture/bin/gvfs-open +3 -0
  27. portacode/link_capture/bin/kde-open +3 -0
  28. portacode/link_capture/bin/kfmclient +3 -0
  29. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  30. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  31. portacode/link_capture/bin/links +3 -0
  32. portacode/link_capture/bin/links2 +3 -0
  33. portacode/link_capture/bin/lynx +3 -0
  34. portacode/link_capture/bin/mate-open +3 -0
  35. portacode/link_capture/bin/netsurf +3 -0
  36. portacode/link_capture/bin/sensible-browser +3 -0
  37. portacode/link_capture/bin/w3m +3 -0
  38. portacode/link_capture/bin/x-www-browser +3 -0
  39. portacode/link_capture/bin/xdg-open +3 -0
  40. portacode/pairing.py +103 -0
  41. portacode/static/js/utils/ntp-clock.js +170 -79
  42. portacode/utils/diff_apply.py +456 -0
  43. portacode/utils/diff_renderer.py +371 -0
  44. portacode/utils/ntp_clock.py +45 -131
  45. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/METADATA +71 -3
  46. portacode-1.4.11.dev5.dist-info/RECORD +97 -0
  47. test_modules/test_device_online.py +1 -1
  48. test_modules/test_login_flow.py +8 -4
  49. test_modules/test_play_store_screenshots.py +294 -0
  50. testing_framework/.env.example +4 -1
  51. testing_framework/core/playwright_manager.py +63 -9
  52. portacode-1.3.32.dist-info/RECORD +0 -70
  53. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/WHEEL +0 -0
  54. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/entry_points.txt +0 -0
  55. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/licenses/LICENSE +0 -0
  56. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/top_level.txt +0 -0
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.3.32'
32
- __version_tuple__ = version_tuple = (1, 3, 32)
31
+ __version__ = version = '1.4.11.dev5'
32
+ __version_tuple__ = version_tuple = (1, 4, 11, 'dev5')
33
33
 
34
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
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()
@@ -33,7 +41,24 @@ def cli() -> None:
33
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.")
34
42
  @click.option("--non-interactive", "non_interactive", is_flag=True, envvar="PORTACODE_NON_INTERACTIVE", hidden=True,
35
43
  help="Skip interactive prompts (used by background service)")
36
- def connect(gateway: str | None, detach: bool, debug: bool, log_categories: str | None, 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
37
62
  """Connect this machine to Portacode gateway."""
38
63
 
39
64
  # Set up debug logging if requested
@@ -74,17 +99,31 @@ def connect(gateway: str | None, detach: bool, debug: bool, log_categories: str
74
99
  other_pid = None
75
100
 
76
101
  if other_pid and is_process_running(other_pid):
77
- click.echo(
78
- click.style(
79
- 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
+ )
80
109
  )
81
- )
82
- if click.confirm("Terminate the existing connection?", default=False):
83
- _terminate_process(other_pid)
84
110
  pid_file.unlink(missing_ok=True)
85
111
  else:
86
- click.echo("Aborting.")
87
- 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)
88
127
  else:
89
128
  # Stale pidfile
90
129
  pid_file.unlink(missing_ok=True)
@@ -92,12 +131,78 @@ def connect(gateway: str | None, detach: bool, debug: bool, log_categories: str
92
131
  # Determine gateway URL
93
132
  target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
94
133
 
95
- # 2. Load or create keypair
96
- 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
+
97
202
  fingerprint = fingerprint_public_key(keypair.public_key_pem)
98
203
 
99
204
  pubkey_b64 = keypair.public_key_der_b64()
100
- if not non_interactive:
205
+ if not non_interactive and not pairing_requested:
101
206
  # Show key generation status
102
207
  if getattr(keypair, '_is_new', False):
103
208
  click.echo()
@@ -300,6 +405,45 @@ def send_control(message: str, gateway: str | None) -> None: # noqa: D401 – C
300
405
  finally:
301
406
  await mgr.stop()
302
407
 
408
+ asyncio.run(_run())
409
+
410
+
411
+ @cli.command("revert_proxmox_infra")
412
+ @click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
413
+ def revert_proxmox_infra(gateway: str | None) -> None: # noqa: D401 – Click callback
414
+ """Revert the Proxmox infrastructure configuration stored on this device."""
415
+
416
+ target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
417
+
418
+ async def _run() -> None:
419
+ keypair = get_or_create_keypair()
420
+ mgr = ConnectionManager(target_gateway, keypair, debug=False)
421
+ await mgr.start()
422
+
423
+ for _ in range(20):
424
+ if mgr.mux is not None:
425
+ break
426
+ await asyncio.sleep(0.1)
427
+ if mgr.mux is None:
428
+ click.echo("Failed to initialise connection – aborting.")
429
+ await mgr.stop()
430
+ return
431
+
432
+ ctl = mgr.mux.get_channel(0)
433
+ await ctl.send({"cmd": "revert_proxmox_infra"})
434
+
435
+ try:
436
+ with click.progressbar(length=30, label="Waiting for revert response") as bar:
437
+ for _ in range(30):
438
+ try:
439
+ reply = await asyncio.wait_for(ctl.recv(), timeout=0.1)
440
+ click.echo(click.style("< " + json.dumps(reply, indent=2), fg="cyan"))
441
+ except asyncio.TimeoutError:
442
+ pass
443
+ bar.update(1)
444
+ finally:
445
+ await mgr.stop()
446
+
303
447
  asyncio.run(_run())
304
448
 
305
449
 
@@ -399,4 +543,4 @@ def service_status(verbose: bool) -> None: # noqa: D401
399
543
  click.echo("\n--- system output ---")
400
544
  click.echo(mgr.status_verbose())
401
545
  except Exception as exc:
402
- click.echo(click.style(f"Failed: {exc}", fg="red"))
546
+ click.echo(click.style(f"Failed: {exc}", fg="red"))
@@ -8,6 +8,8 @@ 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
@@ -36,6 +38,12 @@ class ConnectionManager:
36
38
  service manager restart.
37
39
  """
38
40
 
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
+
39
47
  def __init__(self, gateway_url: str, keypair: KeyPair, reconnect_delay: float = 1.0, max_retries: int = None, debug: bool = False):
40
48
  self.gateway_url = gateway_url
41
49
  self.keypair = keypair
@@ -49,6 +57,12 @@ class ConnectionManager:
49
57
 
50
58
  self.websocket: Optional[WebSocketClientProtocol] = None
51
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
52
66
 
53
67
  async def start(self) -> None:
54
68
  """Start the background task that maintains the connection."""
@@ -93,8 +107,12 @@ class ConnectionManager:
93
107
  except Exception as exc:
94
108
  logger.warning("TerminalManager unavailable: %s", LogCategory.TERMINAL, exc)
95
109
 
96
- # Start main receive loop until closed or stop requested
97
- 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()
98
116
  except (OSError, websockets.WebSocketException, asyncio.TimeoutError) as exc:
99
117
  attempt += 1
100
118
  logger.warning("Connection error: %s", LogCategory.CONNECTION, exc)
@@ -142,6 +160,7 @@ class ConnectionManager:
142
160
  print("Press Cmd+C to close the connection.")
143
161
  else:
144
162
  print("Press Ctrl+C to close the connection.")
163
+ # Finished authentication flow.
145
164
 
146
165
  async def _listen(self) -> None:
147
166
  assert self.websocket is not None, "WebSocket not ready"
@@ -149,11 +168,17 @@ class ConnectionManager:
149
168
  try:
150
169
  message = await asyncio.wait_for(self.websocket.recv(), timeout=1.0)
151
170
 
152
- # Add device_receive timestamp if trace present
153
171
  try:
154
- import json
155
172
  data = json.loads(message)
173
+ except Exception:
174
+ data = None
175
+
176
+ if isinstance(data, dict):
156
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
157
182
  if isinstance(payload, dict) and "trace" in payload and "request_id" in payload:
158
183
  from portacode.utils.ntp_clock import ntp_clock
159
184
  device_receive_time = ntp_clock.now_ms()
@@ -161,11 +186,8 @@ class ConnectionManager:
161
186
  payload["trace"]["device_receive"] = device_receive_time
162
187
  if "client_send" in payload["trace"]:
163
188
  payload["trace"]["ping"] = device_receive_time - payload["trace"]["client_send"]
164
- # Re-serialize with updated trace
165
189
  message = json.dumps(data)
166
190
  logger.info(f"📥 Device received traced message: {payload['request_id']}")
167
- except:
168
- pass # Not a traced message, continue normally
169
191
 
170
192
  if self.mux:
171
193
  await self.mux.on_raw_message(message)
@@ -179,6 +201,103 @@ class ConnectionManager:
179
201
  except Exception:
180
202
  pass
181
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
+
182
301
 
183
302
  async def run_until_interrupt(manager: ConnectionManager) -> None:
184
303
  stop_event = asyncio.Event()
@@ -206,4 +325,4 @@ async def run_until_interrupt(manager: ConnectionManager) -> None:
206
325
  # TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
207
326
  pass
208
327
  await manager.stop()
209
- # 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)