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
@@ -1,17 +1,189 @@
1
1
  """System command handlers."""
2
+ from __future__ import annotations
2
3
 
4
+ import concurrent.futures
5
+ import getpass
6
+ import importlib.util
3
7
  import logging
4
8
  import os
5
9
  import platform
10
+ import shutil
11
+ import subprocess
12
+ import threading
6
13
  from pathlib import Path
7
14
  from typing import Any, Dict
15
+
8
16
  from portacode import __version__
9
17
  import psutil
10
18
 
19
+ try:
20
+ from importlib import metadata as importlib_metadata
21
+ except ImportError: # pragma: no cover - py<3.8
22
+ import importlib_metadata
23
+
11
24
  from .base import SyncHandler
25
+ from .proxmox_infra import get_infra_snapshot
12
26
 
13
27
  logger = logging.getLogger(__name__)
14
28
 
29
+ # Global CPU monitoring
30
+ _cpu_percent = 0.0
31
+ _cpu_thread = None
32
+ _cpu_lock = threading.Lock()
33
+
34
+ def _cpu_monitor():
35
+ """Background thread to update CPU usage every 5 seconds."""
36
+ global _cpu_percent
37
+ while True:
38
+ _cpu_percent = psutil.cpu_percent(interval=5.0)
39
+
40
+ def _ensure_cpu_thread():
41
+ """Ensure CPU monitoring thread is running (singleton)."""
42
+ global _cpu_thread
43
+ with _cpu_lock:
44
+ if _cpu_thread is None or not _cpu_thread.is_alive():
45
+ _cpu_thread = threading.Thread(target=_cpu_monitor, daemon=True)
46
+ _cpu_thread.start()
47
+
48
+
49
+ def _get_user_context() -> Dict[str, Any]:
50
+ """Gather current CLI user plus permission hints."""
51
+ context = {}
52
+ login_source = "os.getlogin"
53
+ try:
54
+ username = os.getlogin()
55
+ except Exception:
56
+ login_source = "getpass"
57
+ username = getpass.getuser()
58
+
59
+ context["username"] = username
60
+ context["username_source"] = login_source
61
+ context["home"] = str(Path.home())
62
+
63
+ uid = getattr(os, "getuid", None)
64
+ euid = getattr(os, "geteuid", None)
65
+ context["uid"] = uid() if uid else None
66
+ context["euid"] = euid() if euid else context["uid"]
67
+ if os.name == "nt":
68
+ try:
69
+ import ctypes
70
+
71
+ context["is_root"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
72
+ except Exception:
73
+ context["is_root"] = None
74
+ else:
75
+ context["is_root"] = context["euid"] == 0 if context["euid"] is not None else False
76
+
77
+ context["has_sudo"] = shutil.which("sudo") is not None
78
+ context["sudo_user"] = os.environ.get("SUDO_USER")
79
+ context["is_sudo_session"] = bool(os.environ.get("SUDO_UID"))
80
+ return context
81
+
82
+
83
+ def _get_playwright_info() -> Dict[str, Any]:
84
+ """Return Playwright presence, version, and browser binaries if available."""
85
+ result: Dict[str, Any] = {
86
+ "installed": False,
87
+ "version": None,
88
+ "browsers": {},
89
+ "error": None,
90
+ }
91
+
92
+ if importlib.util.find_spec("playwright") is None:
93
+ return result
94
+
95
+ result["installed"] = True
96
+ try:
97
+ result["version"] = importlib_metadata.version("playwright")
98
+ except Exception as exc:
99
+ logger.debug("Unable to read Playwright version metadata: %s", exc)
100
+
101
+ def _inspect_browsers() -> Dict[str, Any]:
102
+ from playwright.sync_api import sync_playwright
103
+
104
+ browsers_data: Dict[str, Any] = {}
105
+ with sync_playwright() as p:
106
+ for name in ("chromium", "firefox", "webkit"):
107
+ browser_type = getattr(p, name, None)
108
+ if browser_type is None:
109
+ continue
110
+ exec_path = getattr(browser_type, "executable_path", None)
111
+ browsers_data[name] = {
112
+ "available": bool(exec_path),
113
+ "executable_path": exec_path,
114
+ }
115
+ return browsers_data
116
+
117
+ try:
118
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
119
+ future = executor.submit(_inspect_browsers)
120
+ browsers = future.result(timeout=5)
121
+ result["browsers"] = browsers
122
+ except concurrent.futures.TimeoutError:
123
+ msg = "Playwright inspection timed out"
124
+ logger.warning(msg)
125
+ result["error"] = msg
126
+ except Exception as exc:
127
+ logger.warning("Playwright browser inspection failed: %s", exc)
128
+ result["error"] = str(exc)
129
+
130
+ return result
131
+
132
+
133
+ def _run_probe_command(cmd: list[str]) -> str | None:
134
+ try:
135
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
136
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
137
+ return None
138
+ return result.stdout.strip()
139
+
140
+
141
+ def _parse_pveversion(output: str) -> str | None:
142
+ first_token = output.split(None, 1)[0] if output else ""
143
+ if not first_token:
144
+ return None
145
+ if "/" in first_token:
146
+ return first_token.split("/", 1)[1]
147
+ return first_token
148
+
149
+
150
+ def _parse_dpkg_version(output: str) -> str | None:
151
+ for line in output.splitlines():
152
+ if line.lower().startswith("version:"):
153
+ return line.split(":", 1)[1].strip()
154
+ return None
155
+
156
+
157
+ def _get_proxmox_version() -> str | None:
158
+ release_file = Path("/etc/proxmox-release")
159
+ if release_file.exists():
160
+ try:
161
+ return release_file.read_text().strip()
162
+ except Exception:
163
+ pass
164
+ value = _run_probe_command(["pveversion"])
165
+ parsed = _parse_pveversion(value or "")
166
+ if parsed:
167
+ return parsed
168
+ for pkg in ("pve-manager", "proxmox-ve"):
169
+ pkg_output = _run_probe_command(["dpkg", "-s", pkg])
170
+ parsed = _parse_dpkg_version(pkg_output or "")
171
+ if parsed:
172
+ return parsed
173
+ return None
174
+
175
+
176
+ def _get_proxmox_info() -> Dict[str, Any]:
177
+ """Detect if the current host is a Proxmox node."""
178
+ info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
179
+ if Path("/etc/proxmox-release").exists() or Path("/etc/pve").exists():
180
+ info["is_proxmox_node"] = True
181
+ version = _get_proxmox_version()
182
+ if version:
183
+ info["version"] = version
184
+ info["infra"] = get_infra_snapshot()
185
+ return info
186
+
15
187
 
16
188
  def _get_os_info() -> Dict[str, Any]:
17
189
  """Get operating system information with robust error handling."""
@@ -98,15 +270,13 @@ class SystemInfoHandler(SyncHandler):
98
270
  """Get system information including OS details."""
99
271
  logger.debug("Collecting system information...")
100
272
 
273
+ # Ensure CPU monitoring thread is running
274
+ _ensure_cpu_thread()
275
+
101
276
  # Collect basic system metrics
102
277
  info = {}
103
278
 
104
- try:
105
- info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
106
- logger.debug("CPU usage: %s%%", info["cpu_percent"])
107
- except Exception as e:
108
- logger.warning("Failed to get CPU info: %s", e)
109
- info["cpu_percent"] = 0.0
279
+ info["cpu_percent"] = _cpu_percent
110
280
 
111
281
  try:
112
282
  info["memory"] = psutil.virtual_memory()._asdict()
@@ -124,11 +294,14 @@ class SystemInfoHandler(SyncHandler):
124
294
 
125
295
  # Add OS information - this is critical for proper shell detection
126
296
  info["os_info"] = _get_os_info()
127
- logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
297
+ info["user_context"] = _get_user_context()
298
+ info["playwright"] = _get_playwright_info()
299
+ info["proxmox"] = _get_proxmox_info()
300
+ # logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
128
301
 
129
302
  info["portacode_version"] = __version__
130
303
 
131
304
  return {
132
305
  "event": "system_info",
133
306
  "info": info,
134
- }
307
+ }
@@ -162,53 +162,7 @@ class TabFactory:
162
162
  await self._load_binary_content(file_path, tab_info, file_size)
163
163
 
164
164
  return TabInfo(**tab_info)
165
-
166
- async def create_diff_tab(self, file_path: str, original_content: str,
167
- modified_content: str, tab_id: Optional[str] = None,
168
- diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
169
- """Create a diff tab for comparing file versions.
170
-
171
- Args:
172
- file_path: Path to the file being compared
173
- original_content: Original version of the file
174
- modified_content: Modified version of the file
175
- tab_id: Optional tab ID, will generate UUID if not provided
176
- diff_details: Optional detailed diff information from diff-match-patch
177
-
178
- Returns:
179
- TabInfo object configured for diff viewing
180
- """
181
- if tab_id is None:
182
- tab_id = str(uuid.uuid4())
183
-
184
- file_path = Path(file_path)
185
-
186
- metadata = {'diff_mode': True}
187
- if diff_details:
188
- metadata['diff_details'] = diff_details
189
-
190
- # Cache diff content
191
- original_hash = generate_content_hash(original_content)
192
- modified_hash = generate_content_hash(modified_content)
193
- cache_content(original_hash, original_content)
194
- cache_content(modified_hash, modified_content)
195
-
196
- return TabInfo(
197
- tab_id=tab_id,
198
- tab_type='diff',
199
- title=f"{file_path.name} (diff)",
200
- file_path=str(file_path),
201
- content=None, # Diff tabs don't use regular content
202
- original_content=original_content,
203
- modified_content=modified_content,
204
- original_content_hash=original_hash,
205
- modified_content_hash=modified_hash,
206
- is_dirty=False,
207
- mime_type=None,
208
- encoding='utf-8',
209
- metadata=metadata
210
- )
211
-
165
+
212
166
  async def create_diff_tab_with_title(self, file_path: str, original_content: str,
213
167
  modified_content: str, title: str,
214
168
  tab_id: Optional[str] = None,
@@ -0,0 +1,61 @@
1
+ """Update handler for Portacode CLI."""
2
+
3
+ import subprocess
4
+ import sys
5
+ import logging
6
+ from typing import Any, Dict
7
+ from .base import AsyncHandler
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class UpdatePortacodeHandler(AsyncHandler):
13
+ """Handler for updating Portacode CLI."""
14
+
15
+ @property
16
+ def command_name(self) -> str:
17
+ return "update_portacode_cli"
18
+
19
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
20
+ """Update Portacode package and restart process."""
21
+ try:
22
+ logger.info("Starting Portacode CLI update...")
23
+
24
+ # Update the package
25
+ result = subprocess.run([
26
+ sys.executable, "-m", "pip", "install", "--upgrade", "portacode"
27
+ ], capture_output=True, text=True, timeout=120)
28
+
29
+ if result.returncode != 0:
30
+ logger.error("Update failed: %s", result.stderr)
31
+ return {
32
+ "event": "update_portacode_response",
33
+ "success": False,
34
+ "error": f"Update failed: {result.stderr}"
35
+ }
36
+
37
+ logger.info("Update successful, restarting process...")
38
+
39
+ # Send success response before exit
40
+ await self.send_response({
41
+ "event": "update_portacode_response",
42
+ "success": True,
43
+ "message": "Update completed. Process restarting..."
44
+ })
45
+
46
+ # Exit with special code to trigger restart
47
+ sys.exit(42)
48
+
49
+ except subprocess.TimeoutExpired:
50
+ return {
51
+ "event": "update_portacode_response",
52
+ "success": False,
53
+ "error": "Update timed out after 120 seconds"
54
+ }
55
+ except Exception as e:
56
+ logger.exception("Update failed with exception")
57
+ return {
58
+ "event": "update_portacode_response",
59
+ "success": False,
60
+ "error": str(e)
61
+ }
@@ -21,6 +21,8 @@ import time
21
21
  from dataclasses import asdict
22
22
  from typing import Any, Dict, Optional, List
23
23
 
24
+ from websockets.exceptions import ConnectionClosedError
25
+
24
26
  from .multiplex import Multiplexer, Channel
25
27
  from .handlers import (
26
28
  CommandRegistry,
@@ -33,8 +35,11 @@ from .handlers import (
33
35
  DirectoryListHandler,
34
36
  FileInfoHandler,
35
37
  FileDeleteHandler,
38
+ FileSearchHandler,
36
39
  FileRenameHandler,
37
40
  ContentRequestHandler,
41
+ FileApplyDiffHandler,
42
+ FilePreviewDiffHandler,
38
43
  ProjectStateFolderExpandHandler,
39
44
  ProjectStateFolderCollapseHandler,
40
45
  ProjectStateFileOpenHandler,
@@ -46,6 +51,10 @@ from .handlers import (
46
51
  ProjectStateGitUnstageHandler,
47
52
  ProjectStateGitRevertHandler,
48
53
  ProjectStateGitCommitHandler,
54
+ UpdatePortacodeHandler,
55
+ ConfigureProxmoxInfraHandler,
56
+ CreateProxmoxContainerHandler,
57
+ RevertProxmoxInfraHandler,
49
58
  )
50
59
  from .handlers.project_aware_file_handlers import (
51
60
  ProjectAwareFileWriteHandler,
@@ -108,7 +117,7 @@ class ClientSessionManager:
108
117
  self._write_debug_file()
109
118
  return newly_added_sessions
110
119
 
111
- def cleanup_client_session_explicitly(self, client_session_id: str):
120
+ async def cleanup_client_session_explicitly(self, client_session_id: str):
112
121
  """Explicitly clean up resources for a client session when notified by server."""
113
122
  logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
114
123
 
@@ -124,7 +133,7 @@ class ClientSessionManager:
124
133
  if control_channel:
125
134
  project_manager = _get_or_create_project_state_manager(context, control_channel)
126
135
  logger.info("Cleaning up project state for client session: %s", client_session_id)
127
- project_manager.cleanup_projects_by_client_session(client_session_id)
136
+ await project_manager.cleanup_projects_by_client_session(client_session_id)
128
137
  else:
129
138
  logger.warning("No control channel available for project state cleanup")
130
139
  else:
@@ -173,7 +182,7 @@ class ClientSessionManager:
173
182
  for session_id in existing_project_states:
174
183
  if session_id not in current_project_sessions:
175
184
  logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
176
- project_manager.cleanup_project(session_id)
185
+ await project_manager.cleanup_project(session_id)
177
186
 
178
187
  # Initialize project states for new project sessions
179
188
  for session_name in newly_added_sessions:
@@ -185,6 +194,9 @@ class ClientSessionManager:
185
194
  project_folder_path = session.get('project_folder_path')
186
195
 
187
196
  if project_id is not None and project_folder_path:
197
+ if session_name in project_manager.projects:
198
+ logger.info("Project state already exists for session %s, skipping re-init", session_name)
199
+ continue
188
200
  logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
189
201
 
190
202
  try:
@@ -197,7 +209,6 @@ class ClientSessionManager:
197
209
 
198
210
  except Exception as e:
199
211
  logger.error(f"Failed to initialize project state for {session_name}: {e}")
200
-
201
212
  except Exception as e:
202
213
  logger.error("Error managing project states for session changes: %s", e)
203
214
 
@@ -216,7 +227,7 @@ class ClientSessionManager:
216
227
  control_channel = getattr(self._terminal_manager, '_control_channel', None)
217
228
  if control_channel:
218
229
  project_manager = _get_or_create_project_state_manager(context, control_channel)
219
- project_manager.cleanup_orphaned_project_states(current_sessions)
230
+ await project_manager.cleanup_orphaned_project_states(current_sessions)
220
231
  else:
221
232
  logger.warning("No control channel available for orphaned project state cleanup")
222
233
  else:
@@ -419,6 +430,14 @@ class TerminalManager:
419
430
  pass
420
431
  self._ctl_task = asyncio.create_task(self._control_loop())
421
432
 
433
+ # Start periodic system info sender
434
+ if getattr(self, "_system_info_task", None):
435
+ try:
436
+ self._system_info_task.cancel()
437
+ except Exception:
438
+ pass
439
+ self._system_info_task = asyncio.create_task(self._periodic_system_info())
440
+
422
441
  # For initial connections, request client sessions after control loop starts
423
442
  if is_initial:
424
443
  asyncio.create_task(self._initial_connection_setup())
@@ -439,7 +458,10 @@ class TerminalManager:
439
458
  self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
440
459
  self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
441
460
  self._command_registry.register(FileRenameHandler)
461
+ self._command_registry.register(FileSearchHandler)
442
462
  self._command_registry.register(ContentRequestHandler)
463
+ self._command_registry.register(FileApplyDiffHandler)
464
+ self._command_registry.register(FilePreviewDiffHandler)
443
465
  # Project state handlers
444
466
  self._command_registry.register(ProjectStateFolderExpandHandler)
445
467
  self._command_registry.register(ProjectStateFolderCollapseHandler)
@@ -452,6 +474,11 @@ class TerminalManager:
452
474
  self._command_registry.register(ProjectStateGitUnstageHandler)
453
475
  self._command_registry.register(ProjectStateGitRevertHandler)
454
476
  self._command_registry.register(ProjectStateGitCommitHandler)
477
+ # System management handlers
478
+ self._command_registry.register(ConfigureProxmoxInfraHandler)
479
+ self._command_registry.register(CreateProxmoxContainerHandler)
480
+ self._command_registry.register(RevertProxmoxInfraHandler)
481
+ self._command_registry.register(UpdatePortacodeHandler)
455
482
 
456
483
  # ---------------------------------------------------------------------
457
484
  # Control loop – receives commands from gateway
@@ -516,6 +543,20 @@ class TerminalManager:
516
543
  # Continue processing other messages
517
544
  continue
518
545
 
546
+ async def _periodic_system_info(self) -> None:
547
+ """Send system_info event every 10 seconds when clients are connected."""
548
+ while True:
549
+ try:
550
+ await asyncio.sleep(10)
551
+ if self._client_session_manager.has_interested_clients():
552
+ from .handlers.system_handlers import SystemInfoHandler
553
+ handler = SystemInfoHandler(self._control_channel, self._context)
554
+ system_info = handler.execute({})
555
+ await self._send_session_aware(system_info)
556
+ except Exception as exc:
557
+ logger.exception("Error in periodic system info: %s", exc)
558
+ continue
559
+
519
560
  async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
520
561
  """Send initial system info and terminal list to connected clients.
521
562
 
@@ -767,11 +808,15 @@ class TerminalManager:
767
808
  terminal_id = payload.get("channel", "unknown")
768
809
  logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
769
810
  event_type, terminal_id, data_size, len(target_sessions))
770
- else:
771
- logger.info("terminal_manager: Dispatching %s event to %d client sessions",
772
- event_type, len(target_sessions))
811
+ # else:
812
+ # logger.info("terminal_manager: Dispatching %s event to %d client sessions",
813
+ # event_type, len(target_sessions))
773
814
 
774
- await self._control_channel.send(enhanced_payload)
815
+ try:
816
+ await self._control_channel.send(enhanced_payload)
817
+ except ConnectionClosedError as exc:
818
+ logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
819
+ return
775
820
 
776
821
  async def _send_terminal_list(self) -> None:
777
822
  """Send terminal list for reconnection reconciliation."""
@@ -828,4 +873,4 @@ class TerminalManager:
828
873
  await self._request_client_sessions()
829
874
  logger.info("Client session request sent after reconnection")
830
875
  except Exception as exc:
831
- logger.error("Failed to handle reconnection: %s", exc)
876
+ logger.error("Failed to handle reconnection: %s", exc)
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" "$@"