ptn 0.3.1__tar.gz → 0.4.2__tar.gz
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.
- {ptn-0.3.1 → ptn-0.4.2}/PKG-INFO +54 -16
- {ptn-0.3.1 → ptn-0.4.2}/README.md +53 -15
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/_version.py +2 -2
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/management_service.py +28 -52
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/session_service.py +3 -11
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/terminal_service.py +97 -56
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/cli/args.py +39 -31
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/cli/display.py +18 -16
- ptn-0.4.2/porterminal/cli/script_discovery.py +112 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/composition.py +2 -7
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/config.py +4 -2
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/__init__.py +0 -9
- ptn-0.4.2/porterminal/domain/entities/output_buffer.py +124 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/entities/tab.py +11 -10
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/__init__.py +0 -2
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/__init__.py +0 -4
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/environment_rules.py +3 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/cloudflared.py +13 -11
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/config/shell_detector.py +115 -16
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/repositories/in_memory_session.py +1 -4
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
- ptn-0.4.2/porterminal/pty/env.py +35 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/manager.py +6 -4
- ptn-0.4.2/porterminal/static/assets/app-DlWNJWFE.js +87 -0
- ptn-0.4.2/porterminal/static/assets/app-xPAM7YhQ.css +1 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/static/index.html +2 -2
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/updater.py +13 -5
- ptn-0.3.1/porterminal/domain/entities/output_buffer.py +0 -69
- ptn-0.3.1/porterminal/pty/env.py +0 -97
- ptn-0.3.1/porterminal/static/assets/app-BkHv5qu0.css +0 -32
- ptn-0.3.1/porterminal/static/assets/app-CaIGfw7i.js +0 -72
- ptn-0.3.1/porterminal/static/assets/app-D9ELFbEO.js +0 -72
- ptn-0.3.1/porterminal/static/assets/app-DF3nl_io.js +0 -72
- ptn-0.3.1/porterminal/static/assets/app-DQePboVd.css +0 -32
- ptn-0.3.1/porterminal/static/assets/app-DoBiVkTD.js +0 -72
- ptn-0.3.1/porterminal/static/assets/app-azbHOsRw.css +0 -32
- ptn-0.3.1/porterminal/static/assets/app-nMNFwMa6.css +0 -32
- {ptn-0.3.1 → ptn-0.4.2}/.gitignore +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/LICENSE +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/__main__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/app.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/ports/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/ports/connection_port.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/ports/connection_registry_port.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/application/services/tab_service.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/asgi.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/cli/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/container.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/entities/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/entities/session.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/ports/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/ports/pty_port.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/ports/session_repository.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/ports/tab_repository.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/environment_sanitizer.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/rate_limiter.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/session_limits.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/services/tab_limits.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/rate_limit_config.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/session_id.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/shell_command.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/tab_id.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/terminal_dimensions.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/domain/values/user_id.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/auth.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/config/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/network.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/registry/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/registry/user_connection_registry.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/repositories/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/server.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/web/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/infrastructure/web/websocket_adapter.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/logging_setup.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/__init__.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/protocol.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/unix.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/pty/windows.py +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/porterminal/static/icon.svg +0 -0
- {ptn-0.3.1 → ptn-0.4.2}/pyproject.toml +0 -0
{ptn-0.3.1 → ptn-0.4.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ptn
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Web-based terminal accessible from phone via Cloudflare Tunnel
|
|
5
5
|
Project-URL: Homepage, https://github.com/lyehe/porterminal
|
|
6
6
|
Project-URL: Repository, https://github.com/lyehe/porterminal
|
|
@@ -75,9 +75,10 @@ So I built something simpler: **run a command, scan a QR, start typing.**
|
|
|
75
75
|
## Features
|
|
76
76
|
|
|
77
77
|
- **One command, instant access** - No SSH, no port forwarding, no config files. Cloudflare tunnel + QR code.
|
|
78
|
-
- **Actually usable on mobile** -
|
|
79
|
-
- **
|
|
80
|
-
- **
|
|
78
|
+
- **Actually usable on mobile** - Touch-optimized with momentum scrolling, pinch-to-zoom, swipe gestures, and modifier keys (Ctrl, Alt).
|
|
79
|
+
- **Full terminal apps** - vim, htop, less, tmux all work correctly with proper alt-screen buffer handling.
|
|
80
|
+
- **Persistent multi-tab sessions** - Sessions survive disconnects. Close the browser, switch networks, reconnect from another device—your shell and running processes are still there. Multiple devices can view the same session simultaneously.
|
|
81
|
+
- **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish, Nushell, and any shell via `$SHELL`). Auto-detects your shells.
|
|
81
82
|
|
|
82
83
|
## Install
|
|
83
84
|
|
|
@@ -102,31 +103,66 @@ Requires Python 3.12+ and [cloudflared](https://developers.cloudflare.com/cloudf
|
|
|
102
103
|
```bash
|
|
103
104
|
ptn # Start in current directory
|
|
104
105
|
ptn ~/projects/myapp # Start in specific folder
|
|
105
|
-
ptn --no-tunnel # Local network only
|
|
106
|
-
ptn -b # Run in background
|
|
107
|
-
ptn -p # Enable password protection
|
|
108
|
-
ptn -dp # Toggle default password requirement in config
|
|
109
|
-
ptn -v # Verbose startup logs
|
|
110
|
-
ptn --init # Create .ptn/ptn.yaml config
|
|
111
|
-
ptn -V # Show version
|
|
112
|
-
ptn -U # Update to latest version
|
|
113
|
-
ptn --check-update # Check if update available
|
|
114
106
|
```
|
|
115
107
|
|
|
108
|
+
| Flag | Description |
|
|
109
|
+
|------|-------------|
|
|
110
|
+
| `-n, --no-tunnel` | Local network only (no Cloudflare tunnel) |
|
|
111
|
+
| `-b, --background` | Run in background and return immediately |
|
|
112
|
+
| `-p, --password` | Prompt for password to protect this session |
|
|
113
|
+
| `-dp, --default-password` | Toggle password requirement in config (on/off) |
|
|
114
|
+
| `-v, --verbose` | Show detailed startup logs |
|
|
115
|
+
| `-i, --init` | Create `.ptn/ptn.yaml` config with auto-discovered project scripts |
|
|
116
|
+
| `-u, --update` | Update to the latest version |
|
|
117
|
+
| `-c, --check-update` | Check if a newer version is available |
|
|
118
|
+
| `-V, --version` | Show version |
|
|
119
|
+
|
|
120
|
+
## Mobile Gestures
|
|
121
|
+
|
|
122
|
+
| Gesture | Action |
|
|
123
|
+
|---------|--------|
|
|
124
|
+
| **Tap** | Focus terminal, clear selection |
|
|
125
|
+
| **Long-press** | Start text selection |
|
|
126
|
+
| **Double-tap** | Select word |
|
|
127
|
+
| **Swipe left/right** | Arrow keys (← →) |
|
|
128
|
+
| **Scroll** | Momentum scrolling with physics |
|
|
129
|
+
| **Pinch** | Zoom text (10-24px) |
|
|
130
|
+
|
|
131
|
+
**Modifier keys** (Ctrl, Alt, Shift): Tap once for sticky (one keystroke), double-tap for lock.
|
|
132
|
+
|
|
116
133
|
## Configuration
|
|
117
134
|
|
|
118
|
-
Run `ptn --init` to create a starter config
|
|
135
|
+
Run `ptn --init` to create a starter config. It auto-discovers project scripts from `package.json`, `pyproject.toml`, or `Makefile` and adds them as buttons:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
ptn -i
|
|
139
|
+
# Created: .ptn/ptn.yaml
|
|
140
|
+
# Discovered 3 project script(s): build, dev, test
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Or create `ptn.yaml` manually:
|
|
119
144
|
|
|
120
145
|
```yaml
|
|
146
|
+
# Terminal settings
|
|
147
|
+
terminal:
|
|
148
|
+
default_shell: nu # Default shell ID
|
|
149
|
+
shells: # Custom shell definitions
|
|
150
|
+
- id: nu
|
|
151
|
+
name: Nushell
|
|
152
|
+
command: nu
|
|
153
|
+
args: []
|
|
154
|
+
|
|
121
155
|
# Custom buttons (appear in toolbar)
|
|
156
|
+
# row: 1 = default row, 2+ = additional rows
|
|
122
157
|
buttons:
|
|
123
158
|
- label: "claude"
|
|
124
159
|
send:
|
|
125
160
|
- "claude"
|
|
126
161
|
- 100 # delay in ms
|
|
127
162
|
- "\r"
|
|
128
|
-
- label: "
|
|
129
|
-
send: "
|
|
163
|
+
- label: "build"
|
|
164
|
+
send: "npm run build\r"
|
|
165
|
+
row: 2 # second button row
|
|
130
166
|
|
|
131
167
|
# Update checker settings
|
|
132
168
|
update:
|
|
@@ -155,6 +191,8 @@ Password is per-session (never saved to disk). See [docs/security.md](docs/secur
|
|
|
155
191
|
|
|
156
192
|
**Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
|
|
157
193
|
|
|
194
|
+
**Shell not detected?** Set your `$SHELL` environment variable or configure shells in `ptn.yaml`.
|
|
195
|
+
|
|
158
196
|
## Contributing
|
|
159
197
|
|
|
160
198
|
Issues and PRs welcome.
|
|
@@ -35,9 +35,10 @@ So I built something simpler: **run a command, scan a QR, start typing.**
|
|
|
35
35
|
## Features
|
|
36
36
|
|
|
37
37
|
- **One command, instant access** - No SSH, no port forwarding, no config files. Cloudflare tunnel + QR code.
|
|
38
|
-
- **Actually usable on mobile** -
|
|
39
|
-
- **
|
|
40
|
-
- **
|
|
38
|
+
- **Actually usable on mobile** - Touch-optimized with momentum scrolling, pinch-to-zoom, swipe gestures, and modifier keys (Ctrl, Alt).
|
|
39
|
+
- **Full terminal apps** - vim, htop, less, tmux all work correctly with proper alt-screen buffer handling.
|
|
40
|
+
- **Persistent multi-tab sessions** - Sessions survive disconnects. Close the browser, switch networks, reconnect from another device—your shell and running processes are still there. Multiple devices can view the same session simultaneously.
|
|
41
|
+
- **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish, Nushell, and any shell via `$SHELL`). Auto-detects your shells.
|
|
41
42
|
|
|
42
43
|
## Install
|
|
43
44
|
|
|
@@ -62,31 +63,66 @@ Requires Python 3.12+ and [cloudflared](https://developers.cloudflare.com/cloudf
|
|
|
62
63
|
```bash
|
|
63
64
|
ptn # Start in current directory
|
|
64
65
|
ptn ~/projects/myapp # Start in specific folder
|
|
65
|
-
ptn --no-tunnel # Local network only
|
|
66
|
-
ptn -b # Run in background
|
|
67
|
-
ptn -p # Enable password protection
|
|
68
|
-
ptn -dp # Toggle default password requirement in config
|
|
69
|
-
ptn -v # Verbose startup logs
|
|
70
|
-
ptn --init # Create .ptn/ptn.yaml config
|
|
71
|
-
ptn -V # Show version
|
|
72
|
-
ptn -U # Update to latest version
|
|
73
|
-
ptn --check-update # Check if update available
|
|
74
66
|
```
|
|
75
67
|
|
|
68
|
+
| Flag | Description |
|
|
69
|
+
|------|-------------|
|
|
70
|
+
| `-n, --no-tunnel` | Local network only (no Cloudflare tunnel) |
|
|
71
|
+
| `-b, --background` | Run in background and return immediately |
|
|
72
|
+
| `-p, --password` | Prompt for password to protect this session |
|
|
73
|
+
| `-dp, --default-password` | Toggle password requirement in config (on/off) |
|
|
74
|
+
| `-v, --verbose` | Show detailed startup logs |
|
|
75
|
+
| `-i, --init` | Create `.ptn/ptn.yaml` config with auto-discovered project scripts |
|
|
76
|
+
| `-u, --update` | Update to the latest version |
|
|
77
|
+
| `-c, --check-update` | Check if a newer version is available |
|
|
78
|
+
| `-V, --version` | Show version |
|
|
79
|
+
|
|
80
|
+
## Mobile Gestures
|
|
81
|
+
|
|
82
|
+
| Gesture | Action |
|
|
83
|
+
|---------|--------|
|
|
84
|
+
| **Tap** | Focus terminal, clear selection |
|
|
85
|
+
| **Long-press** | Start text selection |
|
|
86
|
+
| **Double-tap** | Select word |
|
|
87
|
+
| **Swipe left/right** | Arrow keys (← →) |
|
|
88
|
+
| **Scroll** | Momentum scrolling with physics |
|
|
89
|
+
| **Pinch** | Zoom text (10-24px) |
|
|
90
|
+
|
|
91
|
+
**Modifier keys** (Ctrl, Alt, Shift): Tap once for sticky (one keystroke), double-tap for lock.
|
|
92
|
+
|
|
76
93
|
## Configuration
|
|
77
94
|
|
|
78
|
-
Run `ptn --init` to create a starter config
|
|
95
|
+
Run `ptn --init` to create a starter config. It auto-discovers project scripts from `package.json`, `pyproject.toml`, or `Makefile` and adds them as buttons:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
ptn -i
|
|
99
|
+
# Created: .ptn/ptn.yaml
|
|
100
|
+
# Discovered 3 project script(s): build, dev, test
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or create `ptn.yaml` manually:
|
|
79
104
|
|
|
80
105
|
```yaml
|
|
106
|
+
# Terminal settings
|
|
107
|
+
terminal:
|
|
108
|
+
default_shell: nu # Default shell ID
|
|
109
|
+
shells: # Custom shell definitions
|
|
110
|
+
- id: nu
|
|
111
|
+
name: Nushell
|
|
112
|
+
command: nu
|
|
113
|
+
args: []
|
|
114
|
+
|
|
81
115
|
# Custom buttons (appear in toolbar)
|
|
116
|
+
# row: 1 = default row, 2+ = additional rows
|
|
82
117
|
buttons:
|
|
83
118
|
- label: "claude"
|
|
84
119
|
send:
|
|
85
120
|
- "claude"
|
|
86
121
|
- 100 # delay in ms
|
|
87
122
|
- "\r"
|
|
88
|
-
- label: "
|
|
89
|
-
send: "
|
|
123
|
+
- label: "build"
|
|
124
|
+
send: "npm run build\r"
|
|
125
|
+
row: 2 # second button row
|
|
90
126
|
|
|
91
127
|
# Update checker settings
|
|
92
128
|
update:
|
|
@@ -115,6 +151,8 @@ Password is per-session (never saved to disk). See [docs/security.md](docs/secur
|
|
|
115
151
|
|
|
116
152
|
**Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
|
|
117
153
|
|
|
154
|
+
**Shell not detected?** Set your `$SHELL` environment variable or configure shells in `ptn.yaml`.
|
|
155
|
+
|
|
118
156
|
## Contributing
|
|
119
157
|
|
|
120
158
|
Issues and PRs welcome.
|
|
@@ -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
|
-
)
|