portacode 1.3.32__py3-none-any.whl → 1.4.15.dev3__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.
Files changed (57) 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 +600 -4
  5. portacode/connection/handlers/__init__.py +30 -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 +2082 -0
  14. portacode/connection/handlers/session.py +465 -84
  15. portacode/connection/handlers/system_handlers.py +311 -9
  16. portacode/connection/handlers/tab_factory.py +1 -47
  17. portacode/connection/handlers/test_proxmox_infra.py +13 -0
  18. portacode/connection/handlers/update_handler.py +61 -0
  19. portacode/connection/terminal.py +64 -10
  20. portacode/keypair.py +63 -1
  21. portacode/link_capture/__init__.py +38 -0
  22. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  23. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  24. portacode/link_capture/bin/elinks +3 -0
  25. portacode/link_capture/bin/gio-open +3 -0
  26. portacode/link_capture/bin/gnome-open +3 -0
  27. portacode/link_capture/bin/gvfs-open +3 -0
  28. portacode/link_capture/bin/kde-open +3 -0
  29. portacode/link_capture/bin/kfmclient +3 -0
  30. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  31. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  32. portacode/link_capture/bin/links +3 -0
  33. portacode/link_capture/bin/links2 +3 -0
  34. portacode/link_capture/bin/lynx +3 -0
  35. portacode/link_capture/bin/mate-open +3 -0
  36. portacode/link_capture/bin/netsurf +3 -0
  37. portacode/link_capture/bin/sensible-browser +3 -0
  38. portacode/link_capture/bin/w3m +3 -0
  39. portacode/link_capture/bin/x-www-browser +3 -0
  40. portacode/link_capture/bin/xdg-open +3 -0
  41. portacode/pairing.py +103 -0
  42. portacode/static/js/utils/ntp-clock.js +170 -79
  43. portacode/utils/diff_apply.py +456 -0
  44. portacode/utils/diff_renderer.py +371 -0
  45. portacode/utils/ntp_clock.py +45 -131
  46. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/METADATA +71 -3
  47. portacode-1.4.15.dev3.dist-info/RECORD +98 -0
  48. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/WHEEL +1 -1
  49. test_modules/test_device_online.py +1 -1
  50. test_modules/test_login_flow.py +8 -4
  51. test_modules/test_play_store_screenshots.py +294 -0
  52. testing_framework/.env.example +4 -1
  53. testing_framework/core/playwright_manager.py +63 -9
  54. portacode-1.3.32.dist-info/RECORD +0 -70
  55. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/entry_points.txt +0 -0
  56. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/licenses/LICENSE +0 -0
  57. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/top_level.txt +0 -0
portacode/keypair.py CHANGED
@@ -98,4 +98,66 @@ def fingerprint_public_key(pem: bytes) -> str:
98
98
  """Return a short fingerprint for display purposes (SHA-256)."""
99
99
  digest = hashes.Hash(hashes.SHA256())
100
100
  digest.update(pem)
101
- return digest.finalize().hex()[:16]
101
+ return digest.finalize().hex()[:16]
102
+
103
+
104
+ class InMemoryKeyPair:
105
+ """Keypair kept purely in memory until explicitly persisted."""
106
+
107
+ def __init__(self, private_pem: bytes, public_pem: bytes, key_dir: Path):
108
+ self._private_pem = private_pem
109
+ self._public_pem = public_pem
110
+ self._key_dir = key_dir
111
+ self._is_new = True
112
+
113
+ @property
114
+ def private_key_pem(self) -> bytes:
115
+ return self._private_pem
116
+
117
+ @property
118
+ def public_key_pem(self) -> bytes:
119
+ return self._public_pem
120
+
121
+ def sign_challenge(self, challenge: str) -> bytes:
122
+ private_key = serialization.load_pem_private_key(self._private_pem, password=None)
123
+ return private_key.sign(
124
+ challenge.encode(),
125
+ padding.PKCS1v15(),
126
+ hashes.SHA256(),
127
+ )
128
+
129
+ def public_key_der_b64(self) -> str:
130
+ pubkey = serialization.load_pem_public_key(self._public_pem)
131
+ der = pubkey.public_bytes(
132
+ encoding=serialization.Encoding.DER,
133
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
134
+ )
135
+ return base64.b64encode(der).decode()
136
+
137
+ def persist(self) -> KeyPair:
138
+ """Write the keypair to disk and return a regular KeyPair."""
139
+ key_dir = self._key_dir
140
+ key_dir.mkdir(parents=True, exist_ok=True)
141
+ priv_path = key_dir / PRIVATE_KEY_FILE
142
+ pub_path = key_dir / PUBLIC_KEY_FILE
143
+ priv_path.write_bytes(self._private_pem)
144
+ pub_path.write_bytes(self._public_pem)
145
+ keypair = KeyPair(priv_path, pub_path)
146
+ keypair._is_new = True # type: ignore[attr-defined]
147
+ keypair._key_dir = key_dir # type: ignore[attr-defined]
148
+ return keypair
149
+
150
+
151
+ def generate_in_memory_keypair() -> InMemoryKeyPair:
152
+ """Generate a new keypair but keep it in memory until pairing succeeds."""
153
+ private_pem, public_pem = _generate_keypair()
154
+ key_dir = get_key_dir()
155
+ return InMemoryKeyPair(private_pem, public_pem, key_dir)
156
+
157
+
158
+ def keypair_files_exist() -> bool:
159
+ """Return True if the persisted keypair already exists on disk."""
160
+ key_dir = get_key_dir()
161
+ priv_path = key_dir / PRIVATE_KEY_FILE
162
+ pub_path = key_dir / PUBLIC_KEY_FILE
163
+ return priv_path.exists() and pub_path.exists()
@@ -0,0 +1,38 @@
1
+ """Helpers for the link capture wrapper scripts."""
2
+
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ try:
9
+ # Use the stdlib helpers when they are available (Python ≥3.9).
10
+ from importlib.resources import as_file, files
11
+ except ImportError: # pragma: no cover
12
+ # Fall back to the backport for older Python 3.x runtimes (>=3.6).
13
+ from importlib_resources import as_file, files
14
+
15
+ _LINK_CAPTURE_TEMP_DIR: Optional[Path] = None
16
+
17
+
18
+ def prepare_link_capture_bin() -> Optional[Path]:
19
+ """Extract the packaged link capture wrappers into a temporary dir and return it."""
20
+ global _LINK_CAPTURE_TEMP_DIR
21
+ if _LINK_CAPTURE_TEMP_DIR:
22
+ return _LINK_CAPTURE_TEMP_DIR
23
+
24
+ bin_source = files(__package__) / "bin"
25
+ if not bin_source.is_dir():
26
+ return None
27
+
28
+ temp_dir = Path(tempfile.mkdtemp(prefix="portacode-link-capture-"))
29
+ for entry in bin_source.iterdir():
30
+ if not entry.is_file():
31
+ continue
32
+ with as_file(entry) as file_path:
33
+ dest = temp_dir / entry.name
34
+ shutil.copyfile(file_path, dest)
35
+ dest.chmod(dest.stat().st_mode | 0o111)
36
+
37
+ _LINK_CAPTURE_TEMP_DIR = temp_dir
38
+ return _LINK_CAPTURE_TEMP_DIR
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" elinks "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gio-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gnome-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gvfs-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" kde-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" kfmclient "$@"
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ COMMAND_NAME="$1"
4
+ shift
5
+
6
+ if [ -z "$COMMAND_NAME" ]; then
7
+ echo "link_capture: missing command name" >&2
8
+ exit 1
9
+ fi
10
+
11
+ exec "$SCRIPT_DIR/link_capture_wrapper.py" "$COMMAND_NAME" "$@"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """Simple link capture wrapper that never executes a native browser."""
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+ from urllib.parse import urlparse
11
+
12
+ LINK_CHANNEL_ENV = "PORTACODE_LINK_CHANNEL"
13
+ TERMINAL_ID_ENV = "PORTACODE_TERMINAL_ID"
14
+
15
+
16
+ def _find_link_argument(args):
17
+ for arg in args:
18
+ if not isinstance(arg, str):
19
+ continue
20
+ parsed = urlparse(arg)
21
+ if parsed.scheme and parsed.netloc:
22
+ return arg
23
+ if arg.startswith("file://"):
24
+ return arg
25
+ return None
26
+
27
+
28
+ def _write_capture_event(cmd_name, args, link):
29
+ channel = os.environ.get(LINK_CHANNEL_ENV)
30
+ terminal_id = os.environ.get(TERMINAL_ID_ENV)
31
+ if not channel or not link:
32
+ return
33
+ payload = {
34
+ "terminal_id": terminal_id,
35
+ "command": cmd_name,
36
+ "args": args,
37
+ "url": link,
38
+ "timestamp": time.time(),
39
+ }
40
+ directory = Path(channel)
41
+ try:
42
+ directory.mkdir(parents=True, exist_ok=True)
43
+ except Exception:
44
+ return
45
+ temp_file = directory / f".{uuid.uuid4().hex}.tmp"
46
+ term_label = terminal_id or "unknown"
47
+ base_name = f"{int(time.time() * 1000)}-{term_label}"
48
+ final_file = directory / f"{base_name}.json"
49
+ suffix = 0
50
+ while final_file.exists():
51
+ suffix += 1
52
+ final_file = directory / f"{base_name}-{suffix}.json"
53
+ try:
54
+ temp_file.write_text(json.dumps(payload), encoding="utf-8")
55
+ temp_file.replace(final_file)
56
+ except Exception:
57
+ if temp_file.exists():
58
+ temp_file.unlink(missing_ok=True)
59
+
60
+
61
+ def main() -> None:
62
+ if len(sys.argv) < 2:
63
+ sys.stderr.write("link_capture: missing target command name\n")
64
+ sys.exit(1)
65
+ cmd_name = sys.argv[1]
66
+ cmd_args = sys.argv[2:]
67
+ link = _find_link_argument(cmd_args)
68
+ if link:
69
+ _write_capture_event(cmd_name, cmd_args, link)
70
+ # Never run a real browser; capture and exit successfully.
71
+ sys.exit(0)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" links "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" links2 "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" lynx "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" mate-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" netsurf "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" sensible-browser "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" w3m "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" x-www-browser "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" xdg-open "$@"
portacode/pairing.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import socket
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ import websockets
11
+
12
+ PAIRING_URL_ENV = "PORTACODE_PAIRING_URL"
13
+ DEFAULT_PAIRING_URL = "wss://portacode.com/ws/pairing/device/"
14
+
15
+
16
+ class PairingError(Exception):
17
+ """Raised when pairing fails or is rejected."""
18
+
19
+
20
+ @dataclass
21
+ class PairingResult:
22
+ status: str
23
+ payload: dict
24
+
25
+
26
+ async def _pair_device(
27
+ url: str,
28
+ public_key_b64: str,
29
+ pairing_code: str,
30
+ device_name: str,
31
+ project_paths: list[str] | None = None,
32
+ timeout: float = 300.0,
33
+ ) -> PairingResult:
34
+ async with websockets.connect(url) as ws:
35
+ # Initial ready/event
36
+ try:
37
+ ready = await asyncio.wait_for(ws.recv(), timeout=10)
38
+ except asyncio.TimeoutError as exc:
39
+ raise PairingError("Gateway did not acknowledge pairing request") from exc
40
+
41
+ try:
42
+ data = json.loads(ready)
43
+ if data.get("event") != "pairing_ready":
44
+ raise PairingError(f"Unexpected response from pairing gateway: {ready}")
45
+ except json.JSONDecodeError:
46
+ raise PairingError(f"Unexpected response from pairing gateway: {ready}") from None
47
+
48
+ payload = {
49
+ "code": pairing_code,
50
+ "device_name": device_name,
51
+ "public_key": public_key_b64,
52
+ }
53
+ if project_paths:
54
+ payload["project_paths"] = project_paths
55
+ await ws.send(json.dumps(payload))
56
+
57
+ while True:
58
+ try:
59
+ message = await asyncio.wait_for(ws.recv(), timeout=timeout)
60
+ except asyncio.TimeoutError as exc:
61
+ raise PairingError("Pairing timed out waiting for approval") from exc
62
+
63
+ try:
64
+ data = json.loads(message)
65
+ except json.JSONDecodeError:
66
+ continue
67
+
68
+ if data.get("event") != "pairing_status":
69
+ continue
70
+
71
+ status = data.get("status")
72
+ if status == "pending":
73
+ continue
74
+ if status == "approved":
75
+ return PairingResult(status="approved", payload=data)
76
+ reason = data.get("reason") or status or "unknown_error"
77
+ raise PairingError(reason)
78
+
79
+
80
+ def pair_device_with_code(
81
+ keypair,
82
+ pairing_code: str,
83
+ device_name: Optional[str] = None,
84
+ project_paths: list[str] | None = None,
85
+ *,
86
+ timeout: float = 300.0,
87
+ ) -> PairingResult:
88
+ """Run the pairing workflow synchronously."""
89
+ pairing_url = os.getenv(PAIRING_URL_ENV, DEFAULT_PAIRING_URL)
90
+ normalized_url = pairing_url if pairing_url.startswith("ws") else f"wss://{pairing_url.lstrip('/')}"
91
+ friendly_name = device_name or socket.gethostname() or "Portacode Device"
92
+
93
+ public_key_b64 = keypair.public_key_der_b64()
94
+ return asyncio.run(
95
+ _pair_device(
96
+ normalized_url,
97
+ public_key_b64=public_key_b64,
98
+ pairing_code=pairing_code,
99
+ device_name=friendly_name,
100
+ project_paths=project_paths,
101
+ timeout=timeout,
102
+ )
103
+ )
@@ -1,86 +1,74 @@
1
1
  /**
2
2
  * NTP Clock - Synchronized time source for distributed tracing
3
3
  *
4
- * Provides NTP-synchronized timestamps for accurate distributed tracing.
5
- * Uses HTTP-based time API since browsers cannot make UDP NTP requests.
6
- *
7
- * IMPORTANT: All entities (client, server, device) MUST sync to time.cloudflare.com
8
- * If sync fails, timestamps will be null to indicate sync failure.
4
+ * Provides synchronization by requesting the server clock across the WebSocket
5
+ * channel defined in `WEBSOCKET_PROTOCOL`. The client measures round-trip time
6
+ * and applies an offset that compensates for latency before exposing timestamps.
9
7
  */
10
8
  class NTPClock {
11
9
  constructor() {
12
- this.ntpServer = 'time.cloudflare.com'; // Hardcoded - no fallback
13
- this.offset = null; // Offset from local clock to NTP time (milliseconds), null if not synced
10
+ this.ntpServer = 'portacode-gateway';
11
+ this.offset = null;
14
12
  this.lastSync = null;
15
- this.syncInterval = 5 * 60 * 1000; // Re-sync every 5 minutes
16
- this._syncInProgress = false;
17
- this._syncAttempts = 0;
18
- this._maxSyncAttempts = 3;
13
+ this.lastLatencyMs = null;
14
+ this.syncInterval = 60 * 1000;
15
+ this.clockSyncTimeout = 20 * 1000;
16
+ this.maxSyncFailures = 3;
17
+
18
+ this._clockSyncSender = null;
19
+ this._failureCallback = null;
20
+ this._clockSyncTimer = null;
21
+ this._clockSyncTimeoutHandle = null;
22
+ this._pendingRequest = null;
23
+ this._autoSyncStarted = false;
24
+ this._clockSyncFailures = 0;
19
25
  }
20
26
 
21
27
  /**
22
- * Parse Cloudflare trace response to extract timestamp
28
+ * Register the function responsible for piping `clock_sync_request`
29
+ * packets over the dashboard WebSocket.
23
30
  */
24
- _parseCloudflareTime(text) {
25
- const tsMatch = text.match(/ts=([\d.]+)/);
26
- if (tsMatch) {
27
- return parseFloat(tsMatch[1]) * 1000; // Convert to milliseconds
31
+ setClockSyncSender(sender) {
32
+ this._clockSyncSender = sender;
33
+ if (this._autoSyncStarted && !this._pendingRequest) {
34
+ this._scheduleSync(0);
28
35
  }
29
- throw new Error('Failed to parse Cloudflare timestamp');
30
36
  }
31
37
 
32
38
  /**
33
- * Synchronize with Cloudflare NTP via HTTP
39
+ * Callback invoked when multiple sync failures occur in a row.
40
+ * Useful for triggering a transport reconnect.
34
41
  */
35
- async sync() {
36
- if (this._syncInProgress) {
37
- console.log('NTP sync already in progress, skipping');
38
- return false;
39
- }
40
-
41
- this._syncInProgress = true;
42
- this._syncAttempts++;
43
-
44
- try {
45
- // Capture local time BEFORE the fetch to avoid timing drift
46
- const localTimeBeforeFetch = Date.now();
47
- const t0 = performance.now();
48
- const response = await fetch('https://cloudflare.com/cdn-cgi/trace');
49
- const t1 = performance.now();
50
-
51
- const text = await response.text();
52
- const serverTime = this._parseCloudflareTime(text);
53
-
54
- const latency = (t1 - t0) / 2; // Estimate one-way latency
55
-
56
- // Calculate offset: server generated timestamp at local time (localTimeBeforeFetch + latency)
57
- // So offset = serverTime - (localTimeBeforeFetch + latency)
58
- this.offset = serverTime - (localTimeBeforeFetch + latency);
59
- this.lastSync = Date.now();
60
-
61
- console.log(
62
- `✅ NTP sync successful: offset=${this.offset.toFixed(2)}ms, ` +
63
- `latency=${latency.toFixed(2)}ms, server=${this.ntpServer}`
64
- );
65
-
66
- this._syncAttempts = 0; // Reset on success
67
- return true;
68
-
69
- } catch (error) {
70
- console.warn(`❌ NTP sync failed (attempt ${this._syncAttempts}/${this._maxSyncAttempts}):`, error);
42
+ onClockSyncFailure(callback) {
43
+ this._failureCallback = callback;
44
+ }
71
45
 
72
- // If all attempts fail, set offset to null to indicate sync failure
73
- if (this._syncAttempts >= this._maxSyncAttempts) {
74
- this.offset = null;
75
- this.lastSync = null;
76
- console.error(`⚠️ NTP sync failed after ${this._maxSyncAttempts} attempts. Timestamps will be null.`);
77
- this._syncAttempts = 0;
78
- }
46
+ /**
47
+ * Start the auto-sync loop (idempotent). Will skip sending until a sender
48
+ * has been registered.
49
+ */
50
+ startAutoSync() {
51
+ if (this._autoSyncStarted) {
52
+ return;
53
+ }
54
+ this._autoSyncStarted = true;
55
+ this._scheduleSync(0);
56
+ }
79
57
 
80
- return false;
81
- } finally {
82
- this._syncInProgress = false;
58
+ /**
59
+ * Stop the auto-sync loop (used primarily in tests).
60
+ */
61
+ stopAutoSync() {
62
+ this._autoSyncStarted = false;
63
+ if (this._clockSyncTimer) {
64
+ clearTimeout(this._clockSyncTimer);
65
+ this._clockSyncTimer = null;
83
66
  }
67
+ this._clearPendingRequest();
68
+ }
69
+
70
+ sync() {
71
+ return this._performClockSync();
84
72
  }
85
73
 
86
74
  /**
@@ -106,31 +94,134 @@ class NTPClock {
106
94
  return new Date(ts).toISOString();
107
95
  }
108
96
 
109
- /**
110
- * Get sync status for debugging
111
- */
97
+ handleServerSync(payload) {
98
+ if (!payload) {
99
+ return;
100
+ }
101
+ const receiveTime = Date.now();
102
+ let roundTrip = 0;
103
+ if (
104
+ payload.request_id &&
105
+ this._pendingRequest &&
106
+ payload.request_id === this._pendingRequest.requestId
107
+ ) {
108
+ roundTrip = Math.max(receiveTime - this._pendingRequest.sentAt, 0);
109
+ this._clockSyncFailures = 0;
110
+ this._clearPendingRequest();
111
+ }
112
+ const serverSend = typeof payload.server_send_time === 'number' ? payload.server_send_time : payload.server_time;
113
+ const serverReceive = payload.server_receive_time;
114
+ const serverAvg = (typeof serverReceive === 'number' && typeof serverSend === 'number')
115
+ ? (serverReceive + serverSend) / 2
116
+ : typeof serverSend === 'number'
117
+ ? serverSend
118
+ : undefined;
119
+ if (typeof serverAvg === 'number') {
120
+ this.updateFromServer(serverAvg, roundTrip);
121
+ }
122
+ if (payload.server_time_iso) {
123
+ this.serverTimeIso = payload.server_time_iso;
124
+ }
125
+ this._scheduleSync(this.syncInterval);
126
+ }
127
+
128
+ applyServerSync(payload) {
129
+ this.handleServerSync(payload);
130
+ }
131
+
132
+ updateFromServer(serverTimeMs, roundTripMs = 0) {
133
+ const receiveTime = Date.now();
134
+ const halfLatency = (roundTripMs || 0) / 2;
135
+ const estimatedServerReceived = receiveTime - halfLatency;
136
+ this.offset = serverTimeMs - estimatedServerReceived;
137
+ this.lastLatencyMs = roundTripMs;
138
+ this.lastSync = receiveTime;
139
+ }
140
+
112
141
  getStatus() {
113
142
  return {
114
143
  server: this.ntpServer,
115
144
  offset: this.offset,
116
145
  lastSync: this.lastSync ? new Date(this.lastSync).toISOString() : null,
146
+ lastLatencyMs: this.lastLatencyMs,
117
147
  timeSinceSync: this.lastSync ? Date.now() - this.lastSync : null,
118
148
  isSynced: this.offset !== null
119
149
  };
120
150
  }
121
151
 
122
- /**
123
- * Start automatic periodic synchronization
124
- */
125
- startAutoSync() {
126
- // Initial sync
127
- this.sync();
128
-
129
- // Periodic re-sync
130
- setInterval(() => {
131
- console.log('🔄 Starting periodic NTP sync...');
132
- this.sync();
133
- }, this.syncInterval);
152
+ _scheduleSync(delay) {
153
+ if (!this._autoSyncStarted) {
154
+ return;
155
+ }
156
+ if (this._clockSyncTimer) {
157
+ clearTimeout(this._clockSyncTimer);
158
+ }
159
+ this._clockSyncTimer = setTimeout(() => this._performClockSync(), delay);
160
+ }
161
+
162
+ _performClockSync() {
163
+ if (!this._autoSyncStarted) {
164
+ return false;
165
+ }
166
+ if (!this._clockSyncSender) {
167
+ this._scheduleSync(Math.min(this.syncInterval, 3000));
168
+ return false;
169
+ }
170
+ if (this._pendingRequest) {
171
+ return false;
172
+ }
173
+ const requestId = this._generateRequestId();
174
+ const payload = {
175
+ event: 'clock_sync_request',
176
+ request_id: requestId,
177
+ };
178
+ this._pendingRequest = {
179
+ requestId,
180
+ sentAt: Date.now(),
181
+ };
182
+ const sent = this._clockSyncSender(payload);
183
+ if (!sent) {
184
+ this._handleClockSyncFailure();
185
+ return false;
186
+ }
187
+ this._clockSyncTimeoutHandle = setTimeout(() => this._handleClockSyncTimeout(), this.clockSyncTimeout);
188
+ return true;
189
+ }
190
+
191
+ _handleClockSyncTimeout() {
192
+ this._clockSyncFailures += 1;
193
+ this._clearPendingRequest();
194
+ if (
195
+ this._clockSyncFailures >= this.maxSyncFailures &&
196
+ typeof this._failureCallback === 'function'
197
+ ) {
198
+ this._failureCallback();
199
+ }
200
+ this._scheduleSync(this.syncInterval);
201
+ }
202
+
203
+ _handleClockSyncFailure() {
204
+ this._clockSyncFailures += 1;
205
+ this._clearPendingRequest();
206
+ if (
207
+ this._clockSyncFailures >= this.maxSyncFailures &&
208
+ typeof this._failureCallback === 'function'
209
+ ) {
210
+ this._failureCallback();
211
+ }
212
+ this._scheduleSync(this.syncInterval);
213
+ }
214
+
215
+ _clearPendingRequest() {
216
+ if (this._clockSyncTimeoutHandle) {
217
+ clearTimeout(this._clockSyncTimeoutHandle);
218
+ this._clockSyncTimeoutHandle = null;
219
+ }
220
+ this._pendingRequest = null;
221
+ }
222
+
223
+ _generateRequestId() {
224
+ return `clock_sync:${Date.now()}:${Math.floor(Math.random() * 1000000)}`;
134
225
  }
135
226
  }
136
227