ptn 0.1.4__py3-none-any.whl → 0.2.5__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 (33) hide show
  1. porterminal/__init__.py +19 -3
  2. porterminal/_version.py +34 -0
  3. porterminal/app.py +8 -4
  4. porterminal/application/services/terminal_service.py +116 -28
  5. porterminal/asgi.py +8 -3
  6. porterminal/cli/args.py +50 -0
  7. porterminal/composition.py +13 -5
  8. porterminal/config.py +54 -70
  9. porterminal/container.py +0 -11
  10. porterminal/domain/__init__.py +0 -2
  11. porterminal/domain/entities/output_buffer.py +0 -4
  12. porterminal/domain/ports/__init__.py +1 -2
  13. porterminal/domain/ports/pty_port.py +0 -29
  14. porterminal/domain/ports/tab_repository.py +0 -5
  15. porterminal/infrastructure/config/__init__.py +0 -2
  16. porterminal/infrastructure/config/shell_detector.py +1 -0
  17. porterminal/infrastructure/repositories/in_memory_tab.py +0 -4
  18. porterminal/infrastructure/server.py +10 -3
  19. porterminal/static/assets/app-By4EXMHC.js +72 -0
  20. porterminal/static/assets/app-DQePboVd.css +32 -0
  21. porterminal/static/index.html +16 -25
  22. porterminal/updater.py +115 -168
  23. ptn-0.2.5.dist-info/METADATA +148 -0
  24. {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/RECORD +27 -29
  25. porterminal/infrastructure/config/yaml_loader.py +0 -34
  26. porterminal/static/assets/app-BQiuUo6Q.css +0 -32
  27. porterminal/static/assets/app-YNN_jEhv.js +0 -71
  28. porterminal/static/manifest.json +0 -31
  29. porterminal/static/sw.js +0 -66
  30. ptn-0.1.4.dist-info/METADATA +0 -191
  31. {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/WHEEL +0 -0
  32. {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/entry_points.txt +0 -0
  33. {ptn-0.1.4.dist-info → ptn-0.2.5.dist-info}/licenses/LICENSE +0 -0
porterminal/__init__.py CHANGED
@@ -8,7 +8,10 @@ This package provides:
8
8
  - Configuration system with shell auto-detection
9
9
  """
10
10
 
11
- __version__ = "0.1.4"
11
+ try:
12
+ from ._version import __version__
13
+ except ImportError:
14
+ __version__ = "0.0.0-dev" # Fallback before first build
12
15
 
13
16
  import os
14
17
  import subprocess
@@ -139,6 +142,11 @@ def _run_in_background(args) -> int:
139
142
  def main() -> int:
140
143
  """Main entry point."""
141
144
  args = parse_args()
145
+
146
+ # Check for updates (notification only, never exec's)
147
+ from porterminal.updater import check_and_notify
148
+
149
+ check_and_notify()
142
150
  verbose = args.verbose
143
151
 
144
152
  # Handle background mode
@@ -257,10 +265,18 @@ def main() -> int:
257
265
  try:
258
266
  while True:
259
267
  if server_process is not None and server_process.poll() is not None:
260
- console.print("\n[red]Server stopped unexpectedly[/red]")
268
+ code = server_process.returncode
269
+ if code == 0 or code < 0:
270
+ console.print("\n[dim]Server stopped[/dim]")
271
+ else:
272
+ console.print(f"\n[yellow]Server stopped (exit code {code})[/yellow]")
261
273
  break
262
274
  if tunnel_process is not None and tunnel_process.poll() is not None:
263
- console.print("\n[red]Tunnel stopped unexpectedly[/red]")
275
+ code = tunnel_process.returncode
276
+ if code == 0 or code < 0:
277
+ console.print("\n[dim]Tunnel closed[/dim]")
278
+ else:
279
+ console.print(f"\n[yellow]Tunnel stopped (exit code {code})[/yellow]")
264
280
  break
265
281
  time.sleep(1)
266
282
 
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.2.5'
32
+ __version_tuple__ = version_tuple = (0, 2, 5)
33
+
34
+ __commit_id__ = commit_id = None
porterminal/app.py CHANGED
@@ -13,6 +13,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
13
13
  from fastapi.staticfiles import StaticFiles
14
14
  from starlette.middleware.base import RequestResponseEndpoint
15
15
 
16
+ from . import __version__
16
17
  from .composition import create_container
17
18
  from .container import Container
18
19
  from .domain import UserId
@@ -51,10 +52,10 @@ async def lifespan(app: FastAPI):
51
52
  security_preflight_checks()
52
53
 
53
54
  # Create DI container with all wired dependencies
54
- config_path = os.environ.get("PORTERMINAL_CONFIG_PATH", "config.yaml")
55
+ # config_path=None uses find_config_file() to search standard locations
55
56
  cwd = os.environ.get("PORTERMINAL_CWD")
56
57
 
57
- container = create_container(config_path=config_path, cwd=cwd)
58
+ container = create_container(config_path=None, cwd=cwd)
58
59
  app.state.container = container
59
60
 
60
61
  # Wire up cascade: when session is destroyed, close associated tabs and broadcast
@@ -82,7 +83,7 @@ def create_app() -> FastAPI:
82
83
  app = FastAPI(
83
84
  title="Porterminal",
84
85
  description="Web-based terminal accessible from phone via Cloudflare Tunnel",
85
- version="0.1.2",
86
+ version=__version__,
86
87
  lifespan=lifespan,
87
88
  )
88
89
 
@@ -339,13 +340,16 @@ def create_app() -> FastAPI:
339
340
  # Register connection for broadcasts
340
341
  await connection_registry.register(user_id, connection)
341
342
 
342
- # Send session info
343
+ # Send session info including current dimensions
344
+ # New clients should adapt to existing dimensions to prevent rendering issues
343
345
  await connection.send_message(
344
346
  {
345
347
  "type": "session_info",
346
348
  "session_id": session.session_id,
347
349
  "shell": session.shell_id,
348
350
  "tab_id": tab.tab_id,
351
+ "cols": session.dimensions.cols,
352
+ "rows": session.dimensions.rows,
349
353
  }
350
354
  )
351
355
 
@@ -63,11 +63,23 @@ class TerminalService:
63
63
  # Multi-client support: track connections and read loops per session
64
64
  self._session_connections: dict[str, set[ConnectionPort]] = {}
65
65
  self._session_read_tasks: dict[str, asyncio.Task[None]] = {}
66
+ # Per-session locks to prevent race between buffer replay and broadcast
67
+ self._session_locks: dict[str, asyncio.Lock] = {}
66
68
 
67
69
  # -------------------------------------------------------------------------
68
70
  # Multi-client connection tracking
69
71
  # -------------------------------------------------------------------------
70
72
 
73
+ def _get_session_lock(self, session_id: str) -> asyncio.Lock:
74
+ """Get or create a lock for a session."""
75
+ if session_id not in self._session_locks:
76
+ self._session_locks[session_id] = asyncio.Lock()
77
+ return self._session_locks[session_id]
78
+
79
+ def _cleanup_session_lock(self, session_id: str) -> None:
80
+ """Remove session lock when no longer needed."""
81
+ self._session_locks.pop(session_id, None)
82
+
71
83
  def _register_connection(self, session_id: str, connection: ConnectionPort) -> int:
72
84
  """Register a connection for a session. Returns connection count."""
73
85
  if session_id not in self._session_connections:
@@ -85,8 +97,21 @@ class TerminalService:
85
97
  del self._session_connections[session_id]
86
98
  return count
87
99
 
100
+ async def _send_to_connections(self, connections: list[ConnectionPort], data: bytes) -> None:
101
+ """Send data to a list of connections (used with pre-snapshotted list)."""
102
+ for conn in connections:
103
+ try:
104
+ await conn.send_output(data)
105
+ except Exception:
106
+ pass # Connection cleanup handled elsewhere
107
+
88
108
  async def _broadcast_output(self, session_id: str, data: bytes) -> None:
89
- """Broadcast PTY output to all connections for a session."""
109
+ """Broadcast PTY output to all connections for a session.
110
+
111
+ Note: This is only used for error/status messages where the race
112
+ condition doesn't matter. For PTY data, use _send_to_connections
113
+ with a lock-protected snapshot.
114
+ """
90
115
  connections = self._session_connections.get(session_id, set())
91
116
  dead: list[ConnectionPort] = []
92
117
  for conn in list(connections): # Copy to avoid mutation during iteration
@@ -128,31 +153,41 @@ class TerminalService:
128
153
  session_id = str(session.id)
129
154
  clock = AsyncioClock()
130
155
  rate_limiter = TokenBucketRateLimiter(self._rate_limit_config, clock)
156
+ lock = self._get_session_lock(session_id)
157
+
158
+ # Register atomically to prevent race with broadcast.
159
+ # Without this lock, a new client could register between add_output and
160
+ # broadcast, receiving the same data twice (once from buffer, once broadcast).
161
+ #
162
+ # Buffer snapshot and read loop start are also under lock to ensure:
163
+ # - Buffer is captured before any new data arrives
164
+ # - Only one read loop starts per session (prevents duplicate PTY reads)
165
+ # - I/O (send_output) happens OUTSIDE lock to avoid blocking other clients
166
+ buffered = None
167
+ async with lock:
168
+ connection_count = self._register_connection(session_id, connection)
169
+ is_first_client = connection_count == 1
131
170
 
132
- # Register this connection
133
- connection_count = self._register_connection(session_id, connection)
134
- is_first_client = connection_count == 1
135
-
136
- logger.info(
137
- "Client connected session_id=%s connection_count=%d",
138
- session_id,
139
- connection_count,
140
- )
171
+ logger.info(
172
+ "Client connected session_id=%s connection_count=%d",
173
+ session_id,
174
+ connection_count,
175
+ )
141
176
 
142
- try:
143
- # First client starts the shared PTY read loop
177
+ # First client starts the shared PTY read loop (under lock to prevent duplicates)
144
178
  if is_first_client:
145
179
  self._start_broadcast_read_loop(session, session_id)
146
180
 
181
+ # Snapshot buffer while under lock (ensures consistency with broadcast)
147
182
  # Note: session_info is sent by the caller (app.py) to include tab_id
148
-
149
- # Replay buffered output to THIS connection only (not broadcast)
150
183
  if not skip_buffer and not session.output_buffer.is_empty:
151
184
  buffered = session.get_buffered_output()
152
- # Don't clear buffer - other clients may need it too
153
- if buffered:
154
- await connection.send_output(buffered)
155
185
 
186
+ # Replay buffer OUTSIDE lock to avoid blocking other clients during I/O
187
+ if buffered:
188
+ await connection.send_output(buffered)
189
+
190
+ try:
156
191
  # Start heartbeat for this connection
157
192
  heartbeat_task = asyncio.create_task(self._heartbeat_loop(connection))
158
193
 
@@ -173,9 +208,10 @@ class TerminalService:
173
208
  remaining,
174
209
  )
175
210
 
176
- # Last client: stop the read loop
211
+ # Last client: stop the read loop and cleanup lock
177
212
  if remaining == 0:
178
213
  await self._stop_broadcast_read_loop(session_id)
214
+ self._cleanup_session_lock(session_id)
179
215
 
180
216
  def _start_broadcast_read_loop(
181
217
  self,
@@ -212,6 +248,11 @@ class TerminalService:
212
248
  - Small data (<64 bytes): flush immediately for interactive responsiveness
213
249
  - Large data: batch for ~16ms to reduce WebSocket message frequency
214
250
  - Flush if batch exceeds 16KB to prevent memory buildup
251
+
252
+ Thread safety:
253
+ - Uses session lock to prevent race between add_output/broadcast and
254
+ new client registration/buffer replay. Lock is held briefly during
255
+ buffer update and connection snapshot, not during actual I/O.
215
256
  """
216
257
  # Check if PTY is alive at start
217
258
  if not session.pty_handle.is_alive():
@@ -219,18 +260,29 @@ class TerminalService:
219
260
  await self._broadcast_output(session_id, b"\r\n[PTY failed to start]\r\n")
220
261
  return
221
262
 
263
+ lock = self._get_session_lock(session_id)
222
264
  batch_buffer: list[bytes] = []
223
265
  batch_size = 0
224
266
  last_flush_time = asyncio.get_running_loop().time()
225
267
 
226
268
  async def flush_batch() -> None:
269
+ """Flush batched data with lock protection."""
227
270
  nonlocal batch_buffer, batch_size, last_flush_time
228
- if batch_buffer:
229
- combined = b"".join(batch_buffer)
230
- batch_buffer = []
231
- batch_size = 0
232
- last_flush_time = asyncio.get_running_loop().time()
233
- await self._broadcast_output(session_id, combined)
271
+ if not batch_buffer:
272
+ return
273
+
274
+ combined = b"".join(batch_buffer)
275
+ batch_buffer = []
276
+ batch_size = 0
277
+ last_flush_time = asyncio.get_running_loop().time()
278
+
279
+ # Acquire lock, add to buffer, snapshot connections, release lock
280
+ async with lock:
281
+ session.add_output(combined)
282
+ connections = list(self._session_connections.get(session_id, set()))
283
+
284
+ # Broadcast outside lock (I/O can be slow)
285
+ await self._send_to_connections(connections, combined)
234
286
 
235
287
  def has_connections() -> bool:
236
288
  return (
@@ -242,12 +294,16 @@ class TerminalService:
242
294
  try:
243
295
  data = session.pty_handle.read(4096)
244
296
  if data:
245
- session.add_output(data)
246
297
  session.touch(datetime.now(UTC))
247
298
 
248
299
  # Small data (interactive): flush immediately for responsiveness
249
300
  if len(data) < INTERACTIVE_THRESHOLD and not batch_buffer:
250
- await self._broadcast_output(session_id, data)
301
+ # Acquire lock, add to buffer, snapshot connections
302
+ async with lock:
303
+ session.add_output(data)
304
+ connections = list(self._session_connections.get(session_id, set()))
305
+ # Broadcast outside lock
306
+ await self._send_to_connections(connections, data)
251
307
  else:
252
308
  # Batch larger data
253
309
  batch_buffer.append(data)
@@ -351,7 +407,7 @@ class TerminalService:
351
407
  msg_type = message.get("type")
352
408
 
353
409
  if msg_type == "resize":
354
- await self._handle_resize(session, message)
410
+ await self._handle_resize(session, message, connection)
355
411
  elif msg_type == "input":
356
412
  await self._handle_json_input(session, message, rate_limiter, connection)
357
413
  elif msg_type == "ping":
@@ -366,8 +422,17 @@ class TerminalService:
366
422
  self,
367
423
  session: Session[PTYPort],
368
424
  message: dict[str, Any],
425
+ connection: ConnectionPort,
369
426
  ) -> None:
370
- """Handle terminal resize message."""
427
+ """Handle terminal resize message.
428
+
429
+ Multi-client strategy:
430
+ - When multiple clients share a session, PTY dimensions are locked
431
+ - Only the first client (or when all clients agree) can resize
432
+ - New clients receive current dimensions and must adapt locally
433
+ - This prevents rendering artifacts from dimension mismatches
434
+ """
435
+ session_id = str(session.id)
371
436
  cols = int(message.get("cols", 120))
372
437
  rows = int(message.get("rows", 30))
373
438
 
@@ -377,6 +442,29 @@ class TerminalService:
377
442
  if session.dimensions == new_dims:
378
443
  return
379
444
 
445
+ # Check if multiple clients are connected
446
+ connections = self._session_connections.get(session_id, set())
447
+ if len(connections) > 1:
448
+ # Multiple clients: reject resize, tell client to use current dimensions
449
+ logger.info(
450
+ "Resize rejected (multi-client) session_id=%s requested=%dx%d current=%dx%d",
451
+ session.id,
452
+ new_dims.cols,
453
+ new_dims.rows,
454
+ session.dimensions.cols,
455
+ session.dimensions.rows,
456
+ )
457
+ # Send current dimensions back so client can adapt
458
+ await connection.send_message(
459
+ {
460
+ "type": "resize_sync",
461
+ "cols": session.dimensions.cols,
462
+ "rows": session.dimensions.rows,
463
+ }
464
+ )
465
+ return
466
+
467
+ # Single client: allow resize
380
468
  session.update_dimensions(new_dims)
381
469
  session.pty_handle.resize(new_dims)
382
470
  session.touch(datetime.now(UTC))
porterminal/asgi.py CHANGED
@@ -17,15 +17,20 @@ def create_app_from_env():
17
17
 
18
18
  This is called by uvicorn when using the --factory flag.
19
19
  Environment variables:
20
- PORTERMINAL_CONFIG_PATH: Path to config file (default: config.yaml)
20
+ PORTERMINAL_CONFIG_PATH: Path to config file (overrides search)
21
21
  PORTERMINAL_CWD: Working directory for PTY sessions
22
+
23
+ Config search order (when env var not set):
24
+ 1. ptn.yaml in cwd
25
+ 2. .ptn/ptn.yaml in cwd
26
+ 3. ~/.ptn/ptn.yaml
22
27
  """
23
28
  from porterminal.app import create_app
24
29
 
25
- config_path = os.environ.get("PORTERMINAL_CONFIG_PATH", "config.yaml")
26
30
  cwd = os.environ.get("PORTERMINAL_CWD")
27
31
 
28
- container = create_container(config_path=config_path, cwd=cwd)
32
+ # config_path=None uses find_config_file() to search standard locations
33
+ container = create_container(config_path=None, cwd=cwd)
29
34
 
30
35
  # Create app with container
31
36
  # Note: The current app.py doesn't accept container yet,
porterminal/cli/args.py CHANGED
@@ -61,6 +61,11 @@ def parse_args() -> argparse.Namespace:
61
61
  action="store_true",
62
62
  help="Run in background and return immediately",
63
63
  )
64
+ parser.add_argument(
65
+ "--init",
66
+ action="store_true",
67
+ help="Create .ptn/ptn.yaml config file in current directory",
68
+ )
64
69
  # Internal argument for background mode communication
65
70
  parser.add_argument(
66
71
  "--_url-file",
@@ -88,4 +93,49 @@ def parse_args() -> argparse.Namespace:
88
93
  success = update_package()
89
94
  sys.exit(0 if success else 1)
90
95
 
96
+ if args.init:
97
+ _init_config()
98
+ sys.exit(0)
99
+
91
100
  return args
101
+
102
+
103
+ DEFAULT_CONFIG = """\
104
+ # ptn configuration file
105
+ # Docs: https://github.com/lyehe/porterminal/blob/master/docs/configuration.md
106
+
107
+ # Custom buttons (appear in third toolbar row)
108
+ buttons:
109
+ - label: "git"
110
+ send: "git status\\r"
111
+ - label: "build"
112
+ send: "npm run build\\r"
113
+ # Multi-step button with delays (ms):
114
+ # - label: "deploy"
115
+ # send:
116
+ # - "npm run build"
117
+ # - 100
118
+ # - "\\r"
119
+
120
+ # Terminal settings (optional)
121
+ # terminal:
122
+ # default_shell: bash
123
+ # cols: 120
124
+ # rows: 30
125
+ """
126
+
127
+
128
+ def _init_config() -> None:
129
+ """Create .ptn/ptn.yaml in current directory."""
130
+ from pathlib import Path
131
+
132
+ config_dir = Path.cwd() / ".ptn"
133
+ config_file = config_dir / "ptn.yaml"
134
+
135
+ if config_file.exists():
136
+ print(f"Config already exists: {config_file}")
137
+ return
138
+
139
+ config_dir.mkdir(exist_ok=True)
140
+ config_file.write_text(DEFAULT_CONFIG)
141
+ print(f"Created: {config_file}")
@@ -3,12 +3,15 @@
3
3
  from collections.abc import Callable
4
4
  from pathlib import Path
5
5
 
6
+ import yaml
7
+
6
8
  from porterminal.application.services import (
7
9
  ManagementService,
8
10
  SessionService,
9
11
  TabService,
10
12
  TerminalService,
11
13
  )
14
+ from porterminal.config import find_config_file
12
15
  from porterminal.container import Container
13
16
  from porterminal.domain import (
14
17
  EnvironmentRules,
@@ -19,7 +22,7 @@ from porterminal.domain import (
19
22
  TabLimitChecker,
20
23
  TerminalDimensions,
21
24
  )
22
- from porterminal.infrastructure.config import ShellDetector, YAMLConfigLoader
25
+ from porterminal.infrastructure.config import ShellDetector
23
26
  from porterminal.infrastructure.registry import UserConnectionRegistry
24
27
  from porterminal.infrastructure.repositories import InMemorySessionRepository, InMemoryTabRepository
25
28
 
@@ -107,7 +110,7 @@ class PTYManagerAdapter:
107
110
 
108
111
 
109
112
  def create_container(
110
- config_path: Path | str = "config.yaml",
113
+ config_path: Path | str | None = None,
111
114
  cwd: str | None = None,
112
115
  ) -> Container:
113
116
  """Create the dependency container with all wired dependencies.
@@ -116,15 +119,20 @@ def create_container(
116
119
  dependencies are created and wired together.
117
120
 
118
121
  Args:
119
- config_path: Path to config file.
122
+ config_path: Path to config file, or None to search standard locations.
120
123
  cwd: Working directory for PTY sessions.
121
124
 
122
125
  Returns:
123
126
  Fully wired dependency container.
124
127
  """
125
128
  # Load configuration
126
- loader = YAMLConfigLoader(config_path)
127
- config_data = loader.load()
129
+ if config_path is None:
130
+ config_path = find_config_file()
131
+
132
+ config_data: dict = {}
133
+ if config_path is not None and Path(config_path).exists():
134
+ with open(config_path, encoding="utf-8") as f:
135
+ config_data = yaml.safe_load(f) or {}
128
136
 
129
137
  # Detect shells
130
138
  detector = ShellDetector()