ptn 0.3.2__py3-none-any.whl → 0.4.2__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.
- porterminal/_version.py +2 -2
- porterminal/application/services/management_service.py +28 -52
- porterminal/application/services/session_service.py +3 -11
- porterminal/application/services/terminal_service.py +97 -56
- porterminal/cli/args.py +39 -31
- porterminal/cli/display.py +18 -16
- porterminal/cli/script_discovery.py +112 -0
- porterminal/composition.py +2 -7
- porterminal/config.py +4 -2
- porterminal/domain/__init__.py +0 -9
- porterminal/domain/entities/output_buffer.py +56 -1
- porterminal/domain/entities/tab.py +11 -10
- porterminal/domain/services/__init__.py +0 -2
- porterminal/domain/values/__init__.py +0 -4
- porterminal/domain/values/environment_rules.py +3 -0
- porterminal/infrastructure/cloudflared.py +13 -11
- porterminal/infrastructure/config/shell_detector.py +86 -20
- porterminal/infrastructure/repositories/in_memory_session.py +1 -4
- porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
- porterminal/pty/env.py +16 -78
- porterminal/pty/manager.py +6 -4
- porterminal/static/assets/app-DlWNJWFE.js +87 -0
- porterminal/static/assets/app-xPAM7YhQ.css +1 -0
- porterminal/static/index.html +2 -2
- porterminal/updater.py +13 -5
- {ptn-0.3.2.dist-info → ptn-0.4.2.dist-info}/METADATA +54 -16
- {ptn-0.3.2.dist-info → ptn-0.4.2.dist-info}/RECORD +30 -35
- porterminal/static/assets/app-BkHv5qu0.css +0 -32
- porterminal/static/assets/app-CaIGfw7i.js +0 -72
- porterminal/static/assets/app-D9ELFbEO.js +0 -72
- porterminal/static/assets/app-DF3nl_io.js +0 -72
- porterminal/static/assets/app-DQePboVd.css +0 -32
- porterminal/static/assets/app-DoBiVkTD.js +0 -72
- porterminal/static/assets/app-azbHOsRw.css +0 -32
- porterminal/static/assets/app-nMNFwMa6.css +0 -32
- {ptn-0.3.2.dist-info → ptn-0.4.2.dist-info}/WHEEL +0 -0
- {ptn-0.3.2.dist-info → ptn-0.4.2.dist-info}/entry_points.txt +0 -0
- {ptn-0.3.2.dist-info → ptn-0.4.2.dist-info}/licenses/LICENSE +0 -0
porterminal/_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 = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.4.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 4, 2)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -36,6 +36,23 @@ class ManagementService:
|
|
|
36
36
|
self._get_shell = shell_provider
|
|
37
37
|
self._default_dims = default_dimensions
|
|
38
38
|
|
|
39
|
+
async def _send_error(
|
|
40
|
+
self,
|
|
41
|
+
connection: ConnectionPort,
|
|
42
|
+
response_type: str,
|
|
43
|
+
request_id: str,
|
|
44
|
+
error: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Send an error response to a connection."""
|
|
47
|
+
await connection.send_message(
|
|
48
|
+
{
|
|
49
|
+
"type": response_type,
|
|
50
|
+
"request_id": request_id,
|
|
51
|
+
"success": False,
|
|
52
|
+
"error": error,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
39
56
|
async def handle_message(
|
|
40
57
|
self,
|
|
41
58
|
user_id: UserId,
|
|
@@ -76,13 +93,8 @@ class ManagementService:
|
|
|
76
93
|
# Get shell
|
|
77
94
|
shell = self._get_shell(shell_id)
|
|
78
95
|
if not shell:
|
|
79
|
-
await
|
|
80
|
-
|
|
81
|
-
"type": "create_tab_response",
|
|
82
|
-
"request_id": request_id,
|
|
83
|
-
"success": False,
|
|
84
|
-
"error": "Invalid shell",
|
|
85
|
-
}
|
|
96
|
+
await self._send_error(
|
|
97
|
+
connection, "create_tab_response", request_id, "Invalid shell"
|
|
86
98
|
)
|
|
87
99
|
return
|
|
88
100
|
|
|
@@ -127,14 +139,7 @@ class ManagementService:
|
|
|
127
139
|
|
|
128
140
|
except ValueError as e:
|
|
129
141
|
logger.warning("Tab creation failed: %s", e)
|
|
130
|
-
await
|
|
131
|
-
{
|
|
132
|
-
"type": "create_tab_response",
|
|
133
|
-
"request_id": request_id,
|
|
134
|
-
"success": False,
|
|
135
|
-
"error": str(e),
|
|
136
|
-
}
|
|
137
|
-
)
|
|
142
|
+
await self._send_error(connection, "create_tab_response", request_id, str(e))
|
|
138
143
|
|
|
139
144
|
async def _handle_close_tab(
|
|
140
145
|
self,
|
|
@@ -147,27 +152,13 @@ class ManagementService:
|
|
|
147
152
|
tab_id = message.get("tab_id")
|
|
148
153
|
|
|
149
154
|
if not tab_id:
|
|
150
|
-
await
|
|
151
|
-
{
|
|
152
|
-
"type": "close_tab_response",
|
|
153
|
-
"request_id": request_id,
|
|
154
|
-
"success": False,
|
|
155
|
-
"error": "Missing tab_id",
|
|
156
|
-
}
|
|
157
|
-
)
|
|
155
|
+
await self._send_error(connection, "close_tab_response", request_id, "Missing tab_id")
|
|
158
156
|
return
|
|
159
157
|
|
|
160
158
|
# Get tab and session info before closing
|
|
161
159
|
tab = self._tab_service.get_tab(tab_id)
|
|
162
160
|
if not tab:
|
|
163
|
-
await
|
|
164
|
-
{
|
|
165
|
-
"type": "close_tab_response",
|
|
166
|
-
"request_id": request_id,
|
|
167
|
-
"success": False,
|
|
168
|
-
"error": "Tab not found",
|
|
169
|
-
}
|
|
170
|
-
)
|
|
161
|
+
await self._send_error(connection, "close_tab_response", request_id, "Tab not found")
|
|
171
162
|
return
|
|
172
163
|
|
|
173
164
|
session_id = tab.session_id
|
|
@@ -175,13 +166,8 @@ class ManagementService:
|
|
|
175
166
|
# Close the tab
|
|
176
167
|
closed_tab = self._tab_service.close_tab(tab_id, user_id)
|
|
177
168
|
if not closed_tab:
|
|
178
|
-
await
|
|
179
|
-
|
|
180
|
-
"type": "close_tab_response",
|
|
181
|
-
"request_id": request_id,
|
|
182
|
-
"success": False,
|
|
183
|
-
"error": "Failed to close tab",
|
|
184
|
-
}
|
|
169
|
+
await self._send_error(
|
|
170
|
+
connection, "close_tab_response", request_id, "Failed to close tab"
|
|
185
171
|
)
|
|
186
172
|
return
|
|
187
173
|
|
|
@@ -222,26 +208,16 @@ class ManagementService:
|
|
|
222
208
|
new_name = message.get("name")
|
|
223
209
|
|
|
224
210
|
if not tab_id or not new_name:
|
|
225
|
-
await
|
|
226
|
-
|
|
227
|
-
"type": "rename_tab_response",
|
|
228
|
-
"request_id": request_id,
|
|
229
|
-
"success": False,
|
|
230
|
-
"error": "Missing tab_id or name",
|
|
231
|
-
}
|
|
211
|
+
await self._send_error(
|
|
212
|
+
connection, "rename_tab_response", request_id, "Missing tab_id or name"
|
|
232
213
|
)
|
|
233
214
|
return
|
|
234
215
|
|
|
235
216
|
# Rename the tab
|
|
236
217
|
tab = self._tab_service.rename_tab(tab_id, user_id, new_name)
|
|
237
218
|
if not tab:
|
|
238
|
-
await
|
|
239
|
-
|
|
240
|
-
"type": "rename_tab_response",
|
|
241
|
-
"request_id": request_id,
|
|
242
|
-
"success": False,
|
|
243
|
-
"error": "Failed to rename tab",
|
|
244
|
-
}
|
|
219
|
+
await self._send_error(
|
|
220
|
+
connection, "rename_tab_response", request_id, "Failed to rename tab"
|
|
245
221
|
)
|
|
246
222
|
return
|
|
247
223
|
|
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
-
import os
|
|
6
5
|
import uuid
|
|
7
6
|
from collections.abc import Awaitable, Callable
|
|
8
7
|
from datetime import UTC, datetime
|
|
9
8
|
|
|
10
9
|
from porterminal.domain import (
|
|
11
|
-
EnvironmentRules,
|
|
12
|
-
EnvironmentSanitizer,
|
|
13
10
|
PTYPort,
|
|
14
11
|
Session,
|
|
15
12
|
SessionId,
|
|
@@ -32,17 +29,13 @@ class SessionService:
|
|
|
32
29
|
def __init__(
|
|
33
30
|
self,
|
|
34
31
|
repository: SessionRepository[PTYPort],
|
|
35
|
-
pty_factory: Callable[
|
|
36
|
-
[ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort
|
|
37
|
-
],
|
|
32
|
+
pty_factory: Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort],
|
|
38
33
|
limit_checker: SessionLimitChecker | None = None,
|
|
39
|
-
environment_sanitizer: EnvironmentSanitizer | None = None,
|
|
40
34
|
working_directory: str | None = None,
|
|
41
35
|
) -> None:
|
|
42
36
|
self._repository = repository
|
|
43
37
|
self._pty_factory = pty_factory
|
|
44
38
|
self._limit_checker = limit_checker or SessionLimitChecker()
|
|
45
|
-
self._sanitizer = environment_sanitizer or EnvironmentSanitizer(EnvironmentRules())
|
|
46
39
|
self._cwd = working_directory
|
|
47
40
|
self._running = False
|
|
48
41
|
self._cleanup_task: asyncio.Task | None = None
|
|
@@ -108,9 +101,8 @@ class SessionService:
|
|
|
108
101
|
if not limit_result.allowed:
|
|
109
102
|
raise ValueError(limit_result.reason)
|
|
110
103
|
|
|
111
|
-
# Create PTY
|
|
112
|
-
|
|
113
|
-
pty = self._pty_factory(shell, dimensions, env, self._cwd)
|
|
104
|
+
# Create PTY (environment sanitization handled by PTY layer)
|
|
105
|
+
pty = self._pty_factory(shell, dimensions, self._cwd)
|
|
114
106
|
|
|
115
107
|
# Create session (starts with 0 clients, caller adds via add_client())
|
|
116
108
|
now = datetime.now(UTC)
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
5
|
import re
|
|
6
|
+
import time
|
|
6
7
|
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass
|
|
7
9
|
from datetime import UTC, datetime
|
|
8
10
|
from typing import Any
|
|
9
11
|
|
|
@@ -19,6 +21,20 @@ from ..ports.connection_port import ConnectionPort
|
|
|
19
21
|
|
|
20
22
|
logger = logging.getLogger(__name__)
|
|
21
23
|
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ConnectionFlowState:
|
|
27
|
+
"""Per-connection flow control state.
|
|
28
|
+
|
|
29
|
+
Implements xterm.js recommended watermark-based flow control.
|
|
30
|
+
When client sends 'pause', we stop sending to that connection.
|
|
31
|
+
When client sends 'ack', we resume sending.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
paused: bool = False
|
|
35
|
+
pause_time: float | None = None
|
|
36
|
+
|
|
37
|
+
|
|
22
38
|
# Terminal response sequences that should NOT be written to PTY.
|
|
23
39
|
# These are responses from the terminal emulator to queries from applications.
|
|
24
40
|
# If written to PTY, they get echoed back and displayed as garbage.
|
|
@@ -31,11 +47,20 @@ TERMINAL_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]*c|\x1b\[[\d;]*R")
|
|
|
31
47
|
# Constants
|
|
32
48
|
HEARTBEAT_INTERVAL = 30 # seconds
|
|
33
49
|
HEARTBEAT_TIMEOUT = 300 # 5 minutes
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
|
|
51
|
+
# Adaptive PTY read interval: fast when data flowing, slow when idle
|
|
52
|
+
PTY_READ_INTERVAL_MIN = 0.001 # 1ms when data is flowing (high throughput)
|
|
53
|
+
PTY_READ_INTERVAL_MAX = 0.008 # 8ms when idle (save CPU)
|
|
54
|
+
PTY_READ_BURST_THRESHOLD = 5 # Consecutive reads with data before going fast
|
|
55
|
+
|
|
56
|
+
# Tiered batch intervals: faster for interactive, slower for bulk
|
|
57
|
+
OUTPUT_BATCH_INTERVAL_INTERACTIVE = 0.004 # 4ms for small data (<256 bytes)
|
|
58
|
+
OUTPUT_BATCH_INTERVAL_BULK = 0.016 # 16ms for larger data
|
|
59
|
+
OUTPUT_BATCH_SIZE_THRESHOLD = 256 # Bytes - threshold for interactive vs bulk
|
|
36
60
|
OUTPUT_BATCH_MAX_SIZE = 16384 # Flush if batch exceeds 16KB
|
|
37
|
-
INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for small
|
|
61
|
+
INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for very small data
|
|
38
62
|
MAX_INPUT_SIZE = 4096
|
|
63
|
+
FLOW_PAUSE_TIMEOUT = 5.0 # seconds - auto-resume if client stops sending ACKs (was 15s)
|
|
39
64
|
|
|
40
65
|
|
|
41
66
|
class AsyncioClock:
|
|
@@ -65,6 +90,8 @@ class TerminalService:
|
|
|
65
90
|
self._session_read_tasks: dict[str, asyncio.Task[None]] = {}
|
|
66
91
|
# Per-session locks to prevent race between buffer replay and broadcast
|
|
67
92
|
self._session_locks: dict[str, asyncio.Lock] = {}
|
|
93
|
+
# Per-connection flow control state (watermark-based backpressure)
|
|
94
|
+
self._flow_state: dict[ConnectionPort, ConnectionFlowState] = {}
|
|
68
95
|
|
|
69
96
|
# -------------------------------------------------------------------------
|
|
70
97
|
# Multi-client connection tracking
|
|
@@ -72,9 +99,7 @@ class TerminalService:
|
|
|
72
99
|
|
|
73
100
|
def _get_session_lock(self, session_id: str) -> asyncio.Lock:
|
|
74
101
|
"""Get or create a lock for a session."""
|
|
75
|
-
|
|
76
|
-
self._session_locks[session_id] = asyncio.Lock()
|
|
77
|
-
return self._session_locks[session_id]
|
|
102
|
+
return self._session_locks.setdefault(session_id, asyncio.Lock())
|
|
78
103
|
|
|
79
104
|
def _cleanup_session_lock(self, session_id: str) -> None:
|
|
80
105
|
"""Remove session lock when no longer needed."""
|
|
@@ -82,13 +107,17 @@ class TerminalService:
|
|
|
82
107
|
|
|
83
108
|
def _register_connection(self, session_id: str, connection: ConnectionPort) -> int:
|
|
84
109
|
"""Register a connection for a session. Returns connection count."""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
110
|
+
connections = self._session_connections.setdefault(session_id, set())
|
|
111
|
+
connections.add(connection)
|
|
112
|
+
# Initialize flow control state for this connection
|
|
113
|
+
self._flow_state[connection] = ConnectionFlowState()
|
|
114
|
+
return len(connections)
|
|
89
115
|
|
|
90
116
|
def _unregister_connection(self, session_id: str, connection: ConnectionPort) -> int:
|
|
91
117
|
"""Unregister a connection. Returns remaining count."""
|
|
118
|
+
# Clean up flow control state
|
|
119
|
+
self._flow_state.pop(connection, None)
|
|
120
|
+
|
|
92
121
|
if session_id not in self._session_connections:
|
|
93
122
|
return 0
|
|
94
123
|
self._session_connections[session_id].discard(connection)
|
|
@@ -98,12 +127,27 @@ class TerminalService:
|
|
|
98
127
|
return count
|
|
99
128
|
|
|
100
129
|
async def _send_to_connections(self, connections: list[ConnectionPort], data: bytes) -> None:
|
|
101
|
-
"""Send data to
|
|
130
|
+
"""Send data to connections, respecting flow control.
|
|
131
|
+
|
|
132
|
+
Skips paused connections (client overwhelmed) but auto-resumes
|
|
133
|
+
after FLOW_PAUSE_TIMEOUT to prevent permanent pause from dead clients.
|
|
134
|
+
"""
|
|
135
|
+
current_time = time.time()
|
|
102
136
|
for conn in connections:
|
|
137
|
+
flow = self._flow_state.get(conn)
|
|
138
|
+
if flow and flow.paused:
|
|
139
|
+
# Check timeout - auto-resume if client stopped responding
|
|
140
|
+
if flow.pause_time and (current_time - flow.pause_time) > FLOW_PAUSE_TIMEOUT:
|
|
141
|
+
flow.paused = False
|
|
142
|
+
flow.pause_time = None
|
|
143
|
+
logger.debug("Auto-resumed paused connection after timeout")
|
|
144
|
+
else:
|
|
145
|
+
continue # Skip paused connection
|
|
146
|
+
|
|
103
147
|
try:
|
|
104
148
|
await conn.send_output(data)
|
|
105
|
-
except Exception:
|
|
106
|
-
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.debug("Failed to send output to connection: %s", e)
|
|
107
151
|
|
|
108
152
|
async def _broadcast_output(self, session_id: str, data: bytes) -> None:
|
|
109
153
|
"""Broadcast PTY output to all connections for a session.
|
|
@@ -264,6 +308,7 @@ class TerminalService:
|
|
|
264
308
|
batch_buffer: list[bytes] = []
|
|
265
309
|
batch_size = 0
|
|
266
310
|
last_flush_time = asyncio.get_running_loop().time()
|
|
311
|
+
consecutive_data_reads = 0 # Track consecutive reads with data for adaptive sleep
|
|
267
312
|
|
|
268
313
|
async def flush_batch() -> None:
|
|
269
314
|
"""Flush batched data with lock protection."""
|
|
@@ -295,6 +340,10 @@ class TerminalService:
|
|
|
295
340
|
data = session.pty_handle.read(4096)
|
|
296
341
|
if data:
|
|
297
342
|
session.touch(datetime.now(UTC))
|
|
343
|
+
# Track consecutive reads with data for adaptive sleep
|
|
344
|
+
consecutive_data_reads = min(
|
|
345
|
+
consecutive_data_reads + 1, PTY_READ_BURST_THRESHOLD
|
|
346
|
+
)
|
|
298
347
|
|
|
299
348
|
# Small data (interactive): flush immediately for responsiveness
|
|
300
349
|
if len(data) < INTERACTIVE_THRESHOLD and not batch_buffer:
|
|
@@ -312,6 +361,9 @@ class TerminalService:
|
|
|
312
361
|
# Flush if batch is large enough
|
|
313
362
|
if batch_size >= OUTPUT_BATCH_MAX_SIZE:
|
|
314
363
|
await flush_batch()
|
|
364
|
+
else:
|
|
365
|
+
# No data - reset burst counter
|
|
366
|
+
consecutive_data_reads = 0
|
|
315
367
|
|
|
316
368
|
except Exception as e:
|
|
317
369
|
logger.error("PTY read error session_id=%s: %s", session.id, e)
|
|
@@ -319,12 +371,25 @@ class TerminalService:
|
|
|
319
371
|
await self._broadcast_output(session_id, f"\r\n[PTY error: {e}]\r\n".encode())
|
|
320
372
|
break
|
|
321
373
|
|
|
374
|
+
# Tiered batch interval: faster for small batches, slower for large
|
|
375
|
+
batch_interval = (
|
|
376
|
+
OUTPUT_BATCH_INTERVAL_INTERACTIVE
|
|
377
|
+
if batch_size < OUTPUT_BATCH_SIZE_THRESHOLD
|
|
378
|
+
else OUTPUT_BATCH_INTERVAL_BULK
|
|
379
|
+
)
|
|
380
|
+
|
|
322
381
|
# Check if we should flush based on time
|
|
323
382
|
current_time = asyncio.get_running_loop().time()
|
|
324
|
-
if batch_buffer and (current_time - last_flush_time) >=
|
|
383
|
+
if batch_buffer and (current_time - last_flush_time) >= batch_interval:
|
|
325
384
|
await flush_batch()
|
|
326
385
|
|
|
327
|
-
|
|
386
|
+
# Adaptive sleep: fast when data flowing, slow when idle
|
|
387
|
+
sleep_time = (
|
|
388
|
+
PTY_READ_INTERVAL_MIN
|
|
389
|
+
if consecutive_data_reads >= PTY_READ_BURST_THRESHOLD
|
|
390
|
+
else PTY_READ_INTERVAL_MAX
|
|
391
|
+
)
|
|
392
|
+
await asyncio.sleep(sleep_time)
|
|
328
393
|
|
|
329
394
|
# Flush any remaining data
|
|
330
395
|
await flush_batch()
|
|
@@ -358,7 +423,7 @@ class TerminalService:
|
|
|
358
423
|
if isinstance(message, bytes):
|
|
359
424
|
await self._handle_binary_input(session, message, rate_limiter, connection)
|
|
360
425
|
elif isinstance(message, dict):
|
|
361
|
-
await self._handle_json_message(session, message,
|
|
426
|
+
await self._handle_json_message(session, message, connection)
|
|
362
427
|
|
|
363
428
|
async def _handle_binary_input(
|
|
364
429
|
self,
|
|
@@ -400,7 +465,6 @@ class TerminalService:
|
|
|
400
465
|
self,
|
|
401
466
|
session: Session[PTYPort],
|
|
402
467
|
message: dict[str, Any],
|
|
403
|
-
rate_limiter: TokenBucketRateLimiter,
|
|
404
468
|
connection: ConnectionPort,
|
|
405
469
|
) -> None:
|
|
406
470
|
"""Handle JSON control message."""
|
|
@@ -408,13 +472,27 @@ class TerminalService:
|
|
|
408
472
|
|
|
409
473
|
if msg_type == "resize":
|
|
410
474
|
await self._handle_resize(session, message, connection)
|
|
411
|
-
elif msg_type == "input":
|
|
412
|
-
await self._handle_json_input(session, message, rate_limiter, connection)
|
|
413
475
|
elif msg_type == "ping":
|
|
414
476
|
await connection.send_message({"type": "pong"})
|
|
415
477
|
session.touch(datetime.now(UTC))
|
|
416
478
|
elif msg_type == "pong":
|
|
417
479
|
session.touch(datetime.now(UTC))
|
|
480
|
+
elif msg_type == "pause":
|
|
481
|
+
# Client is overwhelmed - stop sending data to this connection
|
|
482
|
+
flow = self._flow_state.get(connection)
|
|
483
|
+
if flow:
|
|
484
|
+
flow.paused = True
|
|
485
|
+
flow.pause_time = time.time()
|
|
486
|
+
# Send confirmation so client knows pause was received
|
|
487
|
+
await connection.send_message({"type": "pause_ack"})
|
|
488
|
+
logger.debug("Connection paused (client overwhelmed) session_id=%s", session.id)
|
|
489
|
+
elif msg_type == "ack":
|
|
490
|
+
# Client caught up - resume sending data
|
|
491
|
+
flow = self._flow_state.get(connection)
|
|
492
|
+
if flow and flow.paused:
|
|
493
|
+
flow.paused = False
|
|
494
|
+
flow.pause_time = None
|
|
495
|
+
logger.debug("Connection resumed (client caught up) session_id=%s", session.id)
|
|
418
496
|
else:
|
|
419
497
|
logger.warning("Unknown message type session_id=%s type=%s", session.id, msg_type)
|
|
420
498
|
|
|
@@ -475,40 +553,3 @@ class TerminalService:
|
|
|
475
553
|
new_dims.cols,
|
|
476
554
|
new_dims.rows,
|
|
477
555
|
)
|
|
478
|
-
|
|
479
|
-
async def _handle_json_input(
|
|
480
|
-
self,
|
|
481
|
-
session: Session[PTYPort],
|
|
482
|
-
message: dict[str, Any],
|
|
483
|
-
rate_limiter: TokenBucketRateLimiter,
|
|
484
|
-
connection: ConnectionPort,
|
|
485
|
-
) -> None:
|
|
486
|
-
"""Handle JSON-encoded terminal input."""
|
|
487
|
-
data = message.get("data", "")
|
|
488
|
-
|
|
489
|
-
if len(data) > self._max_input_size:
|
|
490
|
-
await connection.send_message(
|
|
491
|
-
{
|
|
492
|
-
"type": "error",
|
|
493
|
-
"message": "Input too large",
|
|
494
|
-
}
|
|
495
|
-
)
|
|
496
|
-
return
|
|
497
|
-
|
|
498
|
-
if data:
|
|
499
|
-
input_bytes = data.encode("utf-8")
|
|
500
|
-
# Filter terminal response sequences
|
|
501
|
-
filtered = TERMINAL_RESPONSE_PATTERN.sub(b"", input_bytes)
|
|
502
|
-
if not filtered:
|
|
503
|
-
return
|
|
504
|
-
|
|
505
|
-
if rate_limiter.try_acquire(len(filtered)):
|
|
506
|
-
session.pty_handle.write(filtered)
|
|
507
|
-
session.touch(datetime.now(UTC))
|
|
508
|
-
else:
|
|
509
|
-
await connection.send_message(
|
|
510
|
-
{
|
|
511
|
-
"type": "error",
|
|
512
|
-
"message": "Rate limit exceeded",
|
|
513
|
-
}
|
|
514
|
-
)
|
porterminal/cli/args.py
CHANGED
|
@@ -22,7 +22,6 @@ def parse_args() -> argparse.Namespace:
|
|
|
22
22
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
23
23
|
)
|
|
24
24
|
parser.add_argument(
|
|
25
|
-
"-V",
|
|
26
25
|
"--version",
|
|
27
26
|
action="version",
|
|
28
27
|
version=f"%(prog)s {__version__}",
|
|
@@ -34,6 +33,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
34
33
|
help="Starting directory for the shell (default: current directory)",
|
|
35
34
|
)
|
|
36
35
|
parser.add_argument(
|
|
36
|
+
"-n",
|
|
37
37
|
"--no-tunnel",
|
|
38
38
|
action="store_true",
|
|
39
39
|
help="Start server only, without Cloudflare tunnel",
|
|
@@ -45,12 +45,13 @@ def parse_args() -> argparse.Namespace:
|
|
|
45
45
|
help="Show detailed startup logs",
|
|
46
46
|
)
|
|
47
47
|
parser.add_argument(
|
|
48
|
-
"-
|
|
48
|
+
"-u",
|
|
49
49
|
"--update",
|
|
50
50
|
action="store_true",
|
|
51
51
|
help="Update to the latest version",
|
|
52
52
|
)
|
|
53
53
|
parser.add_argument(
|
|
54
|
+
"-c",
|
|
54
55
|
"--check-update",
|
|
55
56
|
action="store_true",
|
|
56
57
|
help="Check if a newer version is available",
|
|
@@ -62,6 +63,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
62
63
|
help="Run in background and return immediately",
|
|
63
64
|
)
|
|
64
65
|
parser.add_argument(
|
|
66
|
+
"-i",
|
|
65
67
|
"--init",
|
|
66
68
|
action="store_true",
|
|
67
69
|
help="Create .ptn/ptn.yaml config file in current directory",
|
|
@@ -107,7 +109,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
107
109
|
|
|
108
110
|
if args.init:
|
|
109
111
|
_init_config()
|
|
110
|
-
|
|
112
|
+
# Continue to launch ptn after creating config
|
|
111
113
|
|
|
112
114
|
if args.default_password:
|
|
113
115
|
_toggle_password_requirement()
|
|
@@ -116,45 +118,51 @@ def parse_args() -> argparse.Namespace:
|
|
|
116
118
|
return args
|
|
117
119
|
|
|
118
120
|
|
|
119
|
-
DEFAULT_CONFIG = """\
|
|
120
|
-
# ptn configuration file
|
|
121
|
-
# Docs: https://github.com/lyehe/porterminal/blob/master/docs/configuration.md
|
|
122
|
-
|
|
123
|
-
# Custom buttons (appear in third toolbar row)
|
|
124
|
-
buttons:
|
|
125
|
-
- label: "git"
|
|
126
|
-
send: "git status\\r"
|
|
127
|
-
- label: "build"
|
|
128
|
-
send: "npm run build\\r"
|
|
129
|
-
# Multi-step button with delays (ms):
|
|
130
|
-
# - label: "deploy"
|
|
131
|
-
# send:
|
|
132
|
-
# - "npm run build"
|
|
133
|
-
# - 100
|
|
134
|
-
# - "\\r"
|
|
135
|
-
|
|
136
|
-
# Terminal settings (optional)
|
|
137
|
-
# terminal:
|
|
138
|
-
# default_shell: bash
|
|
139
|
-
# cols: 120
|
|
140
|
-
# rows: 30
|
|
141
|
-
"""
|
|
142
|
-
|
|
143
|
-
|
|
144
121
|
def _init_config() -> None:
|
|
145
|
-
"""Create .ptn/ptn.yaml in current directory."""
|
|
122
|
+
"""Create .ptn/ptn.yaml in current directory with auto-discovered scripts."""
|
|
146
123
|
from pathlib import Path
|
|
147
124
|
|
|
148
|
-
|
|
125
|
+
import yaml
|
|
126
|
+
|
|
127
|
+
from porterminal.cli.script_discovery import discover_scripts
|
|
128
|
+
|
|
129
|
+
cwd = Path.cwd()
|
|
130
|
+
config_dir = cwd / ".ptn"
|
|
149
131
|
config_file = config_dir / "ptn.yaml"
|
|
150
132
|
|
|
151
133
|
if config_file.exists():
|
|
152
134
|
print(f"Config already exists: {config_file}")
|
|
153
135
|
return
|
|
154
136
|
|
|
137
|
+
# Build config with default buttons (row 1: AI coding tools)
|
|
138
|
+
config: dict = {
|
|
139
|
+
"buttons": [
|
|
140
|
+
{"label": "new", "send": ["/new", 100, "\r"]},
|
|
141
|
+
{"label": "init", "send": ["/init", 100, "\r"]},
|
|
142
|
+
{"label": "resume", "send": ["/resume", 100, "\r"]},
|
|
143
|
+
{"label": "compact", "send": ["/compact", 100, "\r"]},
|
|
144
|
+
{"label": "claude", "send": ["claude", 100, "\r"]},
|
|
145
|
+
{"label": "codex", "send": ["codex", 100, "\r"]},
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Auto-discover project scripts and add to row 2
|
|
150
|
+
discovered = discover_scripts(cwd)
|
|
151
|
+
if discovered:
|
|
152
|
+
config["buttons"].extend(discovered)
|
|
153
|
+
|
|
155
154
|
config_dir.mkdir(exist_ok=True)
|
|
156
|
-
|
|
155
|
+
|
|
156
|
+
# Write YAML with comment header
|
|
157
|
+
header = "# ptn configuration file\n# Docs: https://github.com/lyehe/porterminal\n\n"
|
|
158
|
+
yaml_content = yaml.safe_dump(config, default_flow_style=False, sort_keys=False)
|
|
159
|
+
config_file.write_text(header + yaml_content)
|
|
160
|
+
|
|
157
161
|
print(f"Created: {config_file}")
|
|
162
|
+
if discovered:
|
|
163
|
+
print(
|
|
164
|
+
f"Discovered {len(discovered)} project script(s): {', '.join(b['label'] for b in discovered)}"
|
|
165
|
+
)
|
|
158
166
|
|
|
159
167
|
|
|
160
168
|
def _toggle_password_requirement() -> None:
|
porterminal/cli/display.py
CHANGED
|
@@ -58,6 +58,14 @@ def get_caution() -> str:
|
|
|
58
58
|
return CAUTION_DEFAULT
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _apply_gradient(lines: list[str], colors: list[str]) -> list[str]:
|
|
62
|
+
"""Apply color gradient to text lines."""
|
|
63
|
+
return [
|
|
64
|
+
f"[{colors[min(i, len(colors) - 1)]}]{line}[/{colors[min(i, len(colors) - 1)]}]"
|
|
65
|
+
for i, line in enumerate(lines)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
61
69
|
def get_qr_code(url: str) -> str:
|
|
62
70
|
"""Generate QR code as ASCII string.
|
|
63
71
|
|
|
@@ -112,21 +120,15 @@ def display_startup_screen(
|
|
|
112
120
|
else:
|
|
113
121
|
status = "[yellow]●[/yellow] LOCAL MODE"
|
|
114
122
|
|
|
115
|
-
# Build logo with
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
tagline_lines = TAGLINE.split("\n")
|
|
125
|
-
tagline_colors = ["bright_magenta", "magenta"]
|
|
126
|
-
tagline_colored = []
|
|
127
|
-
for i, line in enumerate(tagline_lines):
|
|
128
|
-
color = tagline_colors[i] if i < len(tagline_colors) else tagline_colors[-1]
|
|
129
|
-
tagline_colored.append(f"[{color}]{line}[/{color}]")
|
|
123
|
+
# Build logo and tagline with gradients
|
|
124
|
+
logo_colored = _apply_gradient(
|
|
125
|
+
LOGO.strip().split("\n"),
|
|
126
|
+
["bold bright_cyan", "bright_cyan", "cyan", "bright_blue", "blue"],
|
|
127
|
+
)
|
|
128
|
+
tagline_colored = _apply_gradient(
|
|
129
|
+
TAGLINE.split("\n"),
|
|
130
|
+
["bright_magenta", "magenta"],
|
|
131
|
+
)
|
|
130
132
|
|
|
131
133
|
# Left side content
|
|
132
134
|
left_lines = [
|
|
@@ -136,7 +138,7 @@ def display_startup_screen(
|
|
|
136
138
|
*tagline_colored,
|
|
137
139
|
"",
|
|
138
140
|
f"[bold yellow]{get_caution()}[/bold yellow]",
|
|
139
|
-
"[
|
|
141
|
+
"[bright_red]Use -p for password protection if your screen is exposed[/bright_red]",
|
|
140
142
|
status,
|
|
141
143
|
f"[bold cyan]{url}[/bold cyan]",
|
|
142
144
|
]
|