ptn 0.1.0__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 (71) hide show
  1. porterminal/__init__.py +288 -0
  2. porterminal/__main__.py +8 -0
  3. porterminal/app.py +315 -0
  4. porterminal/application/__init__.py +1 -0
  5. porterminal/application/ports/__init__.py +9 -0
  6. porterminal/application/ports/config_port.py +42 -0
  7. porterminal/application/ports/connection_port.py +34 -0
  8. porterminal/application/services/__init__.py +9 -0
  9. porterminal/application/services/session_service.py +240 -0
  10. porterminal/application/services/terminal_service.py +305 -0
  11. porterminal/asgi.py +38 -0
  12. porterminal/cli/__init__.py +19 -0
  13. porterminal/cli/args.py +91 -0
  14. porterminal/cli/display.py +157 -0
  15. porterminal/composition.py +172 -0
  16. porterminal/config.py +202 -0
  17. porterminal/container.py +50 -0
  18. porterminal/domain/__init__.py +77 -0
  19. porterminal/domain/entities/__init__.py +13 -0
  20. porterminal/domain/entities/output_buffer.py +73 -0
  21. porterminal/domain/entities/session.py +86 -0
  22. porterminal/domain/ports/__init__.py +10 -0
  23. porterminal/domain/ports/pty_port.py +80 -0
  24. porterminal/domain/ports/session_repository.py +58 -0
  25. porterminal/domain/services/__init__.py +14 -0
  26. porterminal/domain/services/environment_sanitizer.py +61 -0
  27. porterminal/domain/services/rate_limiter.py +63 -0
  28. porterminal/domain/services/session_limits.py +104 -0
  29. porterminal/domain/values/__init__.py +23 -0
  30. porterminal/domain/values/environment_rules.py +156 -0
  31. porterminal/domain/values/rate_limit_config.py +21 -0
  32. porterminal/domain/values/session_id.py +20 -0
  33. porterminal/domain/values/shell_command.py +37 -0
  34. porterminal/domain/values/terminal_dimensions.py +45 -0
  35. porterminal/domain/values/user_id.py +25 -0
  36. porterminal/infrastructure/__init__.py +16 -0
  37. porterminal/infrastructure/cloudflared.py +222 -0
  38. porterminal/infrastructure/config/__init__.py +9 -0
  39. porterminal/infrastructure/config/shell_detector.py +84 -0
  40. porterminal/infrastructure/config/yaml_loader.py +34 -0
  41. porterminal/infrastructure/network.py +58 -0
  42. porterminal/infrastructure/repositories/__init__.py +7 -0
  43. porterminal/infrastructure/repositories/in_memory_session.py +70 -0
  44. porterminal/infrastructure/server.py +161 -0
  45. porterminal/infrastructure/web/__init__.py +7 -0
  46. porterminal/infrastructure/web/websocket_adapter.py +78 -0
  47. porterminal/logging_setup.py +48 -0
  48. porterminal/pty/__init__.py +105 -0
  49. porterminal/pty/env.py +97 -0
  50. porterminal/pty/fake.py +117 -0
  51. porterminal/pty/manager.py +163 -0
  52. porterminal/pty/protocol.py +84 -0
  53. porterminal/pty/unix.py +162 -0
  54. porterminal/pty/windows.py +131 -0
  55. porterminal/static/assets/app-BS392zlj.css +32 -0
  56. porterminal/static/assets/app-BSV5CpnM.css +32 -0
  57. porterminal/static/assets/app-C6UZmnjW.js +70 -0
  58. porterminal/static/assets/app-CjDvandz.js +70 -0
  59. porterminal/static/assets/app-D8Zu184y.js +70 -0
  60. porterminal/static/assets/app-D8kJUFHo.js +70 -0
  61. porterminal/static/assets/app-sDllFolD.js +70 -0
  62. porterminal/static/icon.svg +34 -0
  63. porterminal/static/index.html +121 -0
  64. porterminal/static/manifest.json +31 -0
  65. porterminal/static/sw.js +72 -0
  66. porterminal/updater.py +257 -0
  67. ptn-0.1.0.dist-info/METADATA +139 -0
  68. ptn-0.1.0.dist-info/RECORD +71 -0
  69. ptn-0.1.0.dist-info/WHEEL +4 -0
  70. ptn-0.1.0.dist-info/entry_points.txt +2 -0
  71. ptn-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,288 @@
1
+ """
2
+ Porterminal - Web-based terminal accessible via Cloudflare Tunnel.
3
+
4
+ This package provides:
5
+ - FastAPI server with WebSocket terminal endpoint
6
+ - PTY management with cross-platform support (Windows/Unix)
7
+ - Session management with reconnection support
8
+ - Configuration system with shell auto-detection
9
+ """
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from threading import Thread
19
+
20
+ from rich.console import Console
21
+
22
+ from porterminal.cli import display_startup_screen, parse_args
23
+ from porterminal.infrastructure import (
24
+ CloudflaredInstaller,
25
+ drain_process_output,
26
+ find_available_port,
27
+ is_port_available,
28
+ start_cloudflared,
29
+ start_server,
30
+ wait_for_server,
31
+ )
32
+
33
+ console = Console()
34
+
35
+
36
+ def _run_in_background(args) -> int:
37
+ """Spawn the server in background and return immediately."""
38
+ import tempfile
39
+
40
+ # Create temp file for URL communication
41
+ url_file = Path(tempfile.gettempdir()) / f"porterminal-{os.getpid()}.url"
42
+
43
+ # Build command without --background flag, with URL file
44
+ cmd = [sys.executable, "-m", "porterminal", f"--_url-file={url_file}"]
45
+ if args.path:
46
+ cmd.append(args.path)
47
+ if args.no_tunnel:
48
+ cmd.append("--no-tunnel")
49
+ if args.verbose:
50
+ cmd.append("--verbose")
51
+
52
+ # Start subprocess
53
+ popen_kwargs = {
54
+ "stdout": subprocess.DEVNULL,
55
+ "stderr": subprocess.DEVNULL,
56
+ "stdin": subprocess.DEVNULL,
57
+ }
58
+
59
+ if sys.platform == "win32":
60
+ # Windows: CREATE_NO_WINDOW hides console window
61
+ CREATE_NO_WINDOW = 0x08000000
62
+ popen_kwargs["creationflags"] = CREATE_NO_WINDOW
63
+ else:
64
+ # Unix: start_new_session to detach from terminal
65
+ popen_kwargs["start_new_session"] = True
66
+
67
+ try:
68
+ proc = subprocess.Popen(cmd, **popen_kwargs)
69
+ except Exception as e:
70
+ console.print(f"[red]Error starting process:[/red] {e}")
71
+ return 1
72
+
73
+ # Wait for URL file to be created (with timeout)
74
+ timeout = 30
75
+ start_time = time.time()
76
+
77
+ with console.status("[cyan]Starting in background...[/cyan]", spinner="dots") as status:
78
+ while time.time() - start_time < timeout:
79
+ if url_file.exists():
80
+ try:
81
+ content = url_file.read_text().strip()
82
+ if content:
83
+ status.stop()
84
+ url = content
85
+ is_tunnel = url.startswith("https://")
86
+ cwd = args.path or os.getcwd()
87
+
88
+ # Display full startup screen with QR code
89
+ display_startup_screen(url, is_tunnel=is_tunnel, cwd=cwd)
90
+
91
+ # Add background mode info
92
+ console.print(
93
+ f"[green]Running in background[/green] [dim](PID: {proc.pid})[/dim]"
94
+ )
95
+ stop_cmd = (
96
+ f"taskkill /T /PID {proc.pid} /F"
97
+ if sys.platform == "win32"
98
+ else f"kill {proc.pid}"
99
+ )
100
+ console.print(f"[dim]Stop with: {stop_cmd}[/dim]\n")
101
+
102
+ # Cleanup temp file
103
+ try:
104
+ url_file.unlink()
105
+ except OSError:
106
+ pass
107
+ return 0
108
+ except OSError:
109
+ pass
110
+
111
+ if proc.poll() is not None:
112
+ status.stop()
113
+ console.print(
114
+ f"[red]Error:[/red] Process exited unexpectedly (code: {proc.returncode})"
115
+ )
116
+ # Cleanup temp file
117
+ try:
118
+ url_file.unlink()
119
+ except OSError:
120
+ pass
121
+ return 1
122
+
123
+ time.sleep(0.2)
124
+
125
+ console.print("[red]Error:[/red] Timeout waiting for server to start")
126
+ # Kill the process tree (includes child shells)
127
+ if sys.platform == "win32":
128
+ subprocess.run(["taskkill", "/T", "/PID", str(proc.pid), "/F"], capture_output=True)
129
+ else:
130
+ proc.terminate()
131
+ try:
132
+ proc.wait(timeout=3)
133
+ except subprocess.TimeoutExpired:
134
+ proc.kill()
135
+ proc.wait()
136
+ return 1
137
+
138
+
139
+ def main() -> int:
140
+ """Main entry point."""
141
+ args = parse_args()
142
+ verbose = args.verbose
143
+
144
+ # Handle background mode
145
+ if args.background:
146
+ return _run_in_background(args)
147
+
148
+ # Set log level based on verbose flag
149
+ if verbose:
150
+ os.environ["PORTERMINAL_LOG_LEVEL"] = "DEBUG"
151
+
152
+ # Validate and set working directory
153
+ cwd_str = None
154
+ if args.path:
155
+ cwd = Path(args.path).resolve()
156
+ if not cwd.exists():
157
+ console.print(f"[red]Error:[/red] Path does not exist: {cwd}")
158
+ return 1
159
+ if not cwd.is_dir():
160
+ console.print(f"[red]Error:[/red] Path is not a directory: {cwd}")
161
+ return 1
162
+ cwd_str = str(cwd)
163
+ os.environ["PORTERMINAL_CWD"] = cwd_str
164
+
165
+ from porterminal.config import get_config
166
+
167
+ config = get_config()
168
+ bind_host = config.server.host
169
+ preferred_port = config.server.port
170
+ port = preferred_port
171
+ # Use 127.0.0.1 for health checks (can't connect to 0.0.0.0)
172
+ check_host = "127.0.0.1" if bind_host == "0.0.0.0" else bind_host
173
+
174
+ # Check cloudflared (skip if --no-tunnel)
175
+ if not args.no_tunnel and not CloudflaredInstaller.is_installed():
176
+ console.print("[yellow]cloudflared not found[/yellow]")
177
+ if not CloudflaredInstaller.install():
178
+ console.print("[red]Error:[/red] Failed to install cloudflared")
179
+ console.print()
180
+ console.print("Install manually: [cyan]winget install cloudflare.cloudflared[/cyan]")
181
+ return 1
182
+ # Verify installation
183
+ if not CloudflaredInstaller.is_installed():
184
+ console.print("[red]Error:[/red] cloudflared still not found after install")
185
+ return 1
186
+
187
+ # Show startup status
188
+ with console.status("[cyan]Starting...[/cyan]", spinner="dots") as status:
189
+ # Start or reuse server
190
+ if wait_for_server(check_host, port, timeout=1):
191
+ if verbose:
192
+ console.print(f"[dim]Reusing server on {bind_host}:{port}[/dim]")
193
+ server_process = None
194
+ else:
195
+ if not is_port_available(bind_host, port):
196
+ port = find_available_port(bind_host, preferred_port)
197
+ if verbose:
198
+ console.print(f"[dim]Using port {port}[/dim]")
199
+
200
+ status.update("[cyan]Starting server...[/cyan]")
201
+ server_process = start_server(bind_host, port, verbose=verbose)
202
+
203
+ if not wait_for_server(check_host, port, timeout=30):
204
+ console.print("[red]Error:[/red] Server failed to start")
205
+ if server_process and server_process.poll() is None:
206
+ server_process.terminate()
207
+ try:
208
+ server_process.wait(timeout=3)
209
+ except subprocess.TimeoutExpired:
210
+ server_process.kill()
211
+ server_process.wait()
212
+ return 1
213
+
214
+ tunnel_process = None
215
+ tunnel_url = None
216
+
217
+ if not args.no_tunnel:
218
+ status.update("[cyan]Establishing tunnel...[/cyan]")
219
+ tunnel_process, tunnel_url = start_cloudflared(port)
220
+
221
+ if not tunnel_url:
222
+ console.print("[red]Error:[/red] Failed to establish tunnel")
223
+ for proc in [server_process, tunnel_process]:
224
+ if proc and proc.poll() is None:
225
+ proc.terminate()
226
+ try:
227
+ proc.wait(timeout=3)
228
+ except subprocess.TimeoutExpired:
229
+ proc.kill()
230
+ proc.wait()
231
+ return 1
232
+
233
+ # Determine final URL
234
+ display_cwd = cwd_str or os.getcwd()
235
+ if args.no_tunnel:
236
+ display_url = f"http://{check_host}:{port}"
237
+ else:
238
+ display_url = tunnel_url
239
+
240
+ # If running as background child, write URL to file and skip display
241
+ if args.url_file:
242
+ try:
243
+ Path(args.url_file).write_text(display_url)
244
+ except OSError as e:
245
+ console.print(f"[red]Error writing URL file:[/red] {e}")
246
+ else:
247
+ # Display final screen (only in foreground mode)
248
+ display_startup_screen(display_url, is_tunnel=not args.no_tunnel, cwd=display_cwd)
249
+
250
+ # Drain process output silently in background (only when not verbose)
251
+ if server_process is not None and not verbose:
252
+ Thread(target=drain_process_output, args=(server_process,), daemon=True).start()
253
+ if tunnel_process is not None:
254
+ Thread(target=drain_process_output, args=(tunnel_process,), daemon=True).start()
255
+
256
+ # Wait for Ctrl+C or process exit
257
+ try:
258
+ while True:
259
+ if server_process is not None and server_process.poll() is not None:
260
+ console.print("\n[red]Server stopped unexpectedly[/red]")
261
+ break
262
+ if tunnel_process is not None and tunnel_process.poll() is not None:
263
+ console.print("\n[red]Tunnel stopped unexpectedly[/red]")
264
+ break
265
+ time.sleep(1)
266
+
267
+ except KeyboardInterrupt:
268
+ console.print("\n[dim]Shutting down...[/dim]")
269
+
270
+ # Cleanup - terminate gracefully, then kill if needed
271
+ def cleanup_process(proc: subprocess.Popen | None, name: str) -> None:
272
+ if proc is None or proc.poll() is not None:
273
+ return
274
+ try:
275
+ proc.terminate()
276
+ proc.wait(timeout=5)
277
+ except subprocess.TimeoutExpired:
278
+ proc.kill()
279
+ proc.wait() # Reap the killed process
280
+
281
+ cleanup_process(server_process, "server")
282
+ cleanup_process(tunnel_process, "tunnel")
283
+
284
+ return 0
285
+
286
+
287
+ if __name__ == "__main__":
288
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ """Allow running as python -m porterminal."""
2
+
3
+ import sys
4
+
5
+ from porterminal import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
porterminal/app.py ADDED
@@ -0,0 +1,315 @@
1
+ """FastAPI application with security checks and WebSocket endpoint."""
2
+
3
+ import asyncio
4
+ import ctypes
5
+ import logging
6
+ import os
7
+ import signal
8
+ from contextlib import asynccontextmanager, suppress
9
+ from pathlib import Path
10
+
11
+ from fastapi import FastAPI, Query, Request, Response, WebSocket, WebSocketDisconnect
12
+ from fastapi.responses import HTMLResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from starlette.middleware.base import RequestResponseEndpoint
15
+
16
+ from .composition import create_container
17
+ from .container import Container
18
+ from .domain import SessionId, TerminalDimensions, UserId
19
+ from .infrastructure.web import FastAPIWebSocketAdapter
20
+ from .logging_setup import setup_logging_from_env
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Path to static files (inside package)
25
+ STATIC_DIR = Path(__file__).parent / "static"
26
+
27
+
28
+ def is_admin() -> bool:
29
+ """Check if running as administrator (Windows)."""
30
+ try:
31
+ return ctypes.windll.shell32.IsUserAnAdmin() != 0
32
+ except Exception:
33
+ return False
34
+
35
+
36
+ def security_preflight_checks() -> None:
37
+ """Run security checks before starting the application."""
38
+ # Check not running as admin
39
+ if is_admin():
40
+ logger.warning(
41
+ "SECURITY WARNING: Running as Administrator is not recommended. "
42
+ "This exposes excessive privileges to remote users."
43
+ )
44
+
45
+
46
+ @asynccontextmanager
47
+ async def lifespan(app: FastAPI):
48
+ """Application lifespan manager."""
49
+ # Startup
50
+ setup_logging_from_env()
51
+ security_preflight_checks()
52
+
53
+ # Create DI container with all wired dependencies
54
+ config_path = os.environ.get("PORTERMINAL_CONFIG_PATH", "config.yaml")
55
+ cwd = os.environ.get("PORTERMINAL_CWD")
56
+
57
+ container = create_container(config_path=config_path, cwd=cwd)
58
+ app.state.container = container
59
+
60
+ await container.session_service.start()
61
+
62
+ logger.info("Porterminal server started")
63
+
64
+ yield
65
+
66
+ # Shutdown
67
+ await container.session_service.stop()
68
+ logger.info("Porterminal server stopped")
69
+
70
+
71
+ def create_app() -> FastAPI:
72
+ """Create and configure the FastAPI application."""
73
+ app = FastAPI(
74
+ title="Porterminal",
75
+ description="Web-based terminal accessible from phone via Cloudflare Tunnel",
76
+ version="0.1.0",
77
+ lifespan=lifespan,
78
+ )
79
+
80
+ @app.middleware("http")
81
+ async def no_cache_static_assets(
82
+ request: Request, call_next: RequestResponseEndpoint
83
+ ) -> Response:
84
+ """Disable caching for static assets to ensure live updates during development."""
85
+ response = await call_next(request)
86
+ if request.url.path.startswith("/static/"):
87
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
88
+ response.headers["Pragma"] = "no-cache"
89
+ response.headers["Expires"] = "0"
90
+ return response
91
+
92
+ # Mount static files
93
+ if STATIC_DIR.exists():
94
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
95
+
96
+ @app.get("/", response_class=HTMLResponse)
97
+ async def index():
98
+ """Serve the main page."""
99
+ index_path = STATIC_DIR / "index.html"
100
+ if index_path.exists():
101
+ content = index_path.read_text(encoding="utf-8")
102
+ return HTMLResponse(
103
+ content=content,
104
+ headers={
105
+ "Cache-Control": "no-cache, no-store, must-revalidate",
106
+ "Pragma": "no-cache",
107
+ "Expires": "0",
108
+ },
109
+ )
110
+ return JSONResponse(
111
+ {"error": "index.html not found"},
112
+ status_code=404,
113
+ )
114
+
115
+ @app.get("/health")
116
+ async def health():
117
+ """Health check endpoint."""
118
+ container: Container = app.state.container
119
+ return {
120
+ "status": "healthy",
121
+ "sessions": container.session_service.session_count(),
122
+ }
123
+
124
+ @app.get("/api/config")
125
+ async def get_client_config():
126
+ """Get client configuration (shells and buttons)."""
127
+ container: Container = app.state.container
128
+ return {
129
+ "shells": [{"id": s.id, "name": s.name} for s in container.available_shells],
130
+ "buttons": container.buttons,
131
+ "default_shell": container.default_shell_id,
132
+ }
133
+
134
+ @app.post("/api/config/reload")
135
+ async def reload_configuration():
136
+ """Reload configuration from file.
137
+
138
+ Note: With the new DI architecture, hot-reload requires server restart.
139
+ """
140
+ return JSONResponse(
141
+ {"status": "info", "message": "Config reload requires server restart"},
142
+ status_code=501,
143
+ )
144
+
145
+ @app.post("/api/shutdown")
146
+ async def shutdown_server(request: Request):
147
+ """Shutdown the server and tunnel.
148
+
149
+ Only allowed from localhost or authenticated Cloudflare Access users.
150
+ """
151
+ # Check if request is from localhost
152
+ client_host = request.client.host if request.client else None
153
+ is_localhost = client_host in ("127.0.0.1", "::1", "localhost")
154
+
155
+ # Check for Cloudflare Access authentication
156
+ cf_user = request.headers.get("cf-access-authenticated-user-email")
157
+
158
+ if not is_localhost and not cf_user:
159
+ logger.warning(
160
+ "Unauthorized shutdown attempt from %s",
161
+ client_host,
162
+ )
163
+ return JSONResponse(
164
+ {
165
+ "error": "Unauthorized - must be localhost or authenticated via Cloudflare Access"
166
+ },
167
+ status_code=403,
168
+ )
169
+
170
+ logger.info("Shutdown requested via API by %s", cf_user or client_host)
171
+
172
+ # Send response before shutting down
173
+ asyncio.get_running_loop().call_later(0.5, lambda: os.kill(os.getpid(), signal.SIGTERM))
174
+
175
+ return {"status": "ok", "message": "Server shutting down..."}
176
+
177
+ @app.websocket("/ws")
178
+ async def websocket_terminal(
179
+ websocket: WebSocket,
180
+ session_id: str | None = Query(None),
181
+ shell: str | None = Query(None),
182
+ skip_buffer: str | None = Query(None),
183
+ ):
184
+ """WebSocket endpoint for terminal communication."""
185
+ logger.info(
186
+ "WebSocket connect attempt client=%s session_id=%s shell=%s skip_buffer=%s",
187
+ getattr(websocket.client, "host", None),
188
+ session_id,
189
+ shell,
190
+ skip_buffer,
191
+ )
192
+ # Accept the connection
193
+ await websocket.accept()
194
+
195
+ # Get dependencies from container
196
+ container: Container = websocket.app.state.container
197
+ session_service = container.session_service
198
+ terminal_service = container.terminal_service
199
+
200
+ # Get user ID from headers (Cloudflare Access)
201
+ # For local testing, use a default user
202
+ user_id = UserId(websocket.headers.get("cf-access-authenticated-user-email", "local-user"))
203
+ connection = FastAPIWebSocketAdapter(websocket)
204
+ session = None
205
+
206
+ logger.info(
207
+ "WebSocket accepted client=%s user_id=%s session_id=%s shell=%s",
208
+ getattr(websocket.client, "host", None),
209
+ user_id,
210
+ session_id,
211
+ shell,
212
+ )
213
+
214
+ try:
215
+ if session_id:
216
+ # Reconnect to existing session
217
+ logger.info(
218
+ "WebSocket reconnect requested user_id=%s session_id=%s", user_id, session_id
219
+ )
220
+ session = await session_service.reconnect_session(SessionId(session_id), user_id)
221
+ if not session:
222
+ await connection.send_message(
223
+ {
224
+ "type": "error",
225
+ "message": "Session not found or unauthorized",
226
+ }
227
+ )
228
+ await connection.close(code=4004)
229
+ logger.warning(
230
+ "WebSocket reconnect denied user_id=%s session_id=%s", user_id, session_id
231
+ )
232
+ return
233
+ # reconnect_session already calls add_client()
234
+ else:
235
+ # Try to auto-join existing session first (for device switching)
236
+ session = session_service.get_active_session(user_id)
237
+ if session:
238
+ # Join existing session
239
+ session.add_client()
240
+ logger.info(
241
+ "WebSocket auto-joined existing session user_id=%s session_id=%s client_count=%d",
242
+ user_id,
243
+ session.session_id,
244
+ session.connected_clients,
245
+ )
246
+ else:
247
+ # Create new session
248
+ shell_cmd = container.get_shell(shell)
249
+ if not shell_cmd:
250
+ await connection.send_message(
251
+ {"type": "error", "message": "No shell available"}
252
+ )
253
+ await connection.close(code=4006)
254
+ return
255
+
256
+ dims = TerminalDimensions(container.default_cols, container.default_rows)
257
+
258
+ logger.info(
259
+ "WebSocket creating new session user_id=%s shell=%s cols=%d rows=%d",
260
+ user_id,
261
+ shell_cmd.id,
262
+ dims.cols,
263
+ dims.rows,
264
+ )
265
+ session = await session_service.create_session(
266
+ user_id=user_id,
267
+ shell=shell_cmd,
268
+ dimensions=dims,
269
+ )
270
+ session.add_client()
271
+ logger.info(
272
+ "WebSocket created session user_id=%s session_id=%s shell_id=%s",
273
+ user_id,
274
+ session.session_id,
275
+ session.shell_id,
276
+ )
277
+
278
+ # Handle the session using TerminalService
279
+ await terminal_service.handle_session(
280
+ session, connection, skip_buffer=bool(skip_buffer)
281
+ )
282
+
283
+ except ValueError as e:
284
+ # Session limit exceeded
285
+ await connection.send_message(
286
+ {
287
+ "type": "error",
288
+ "message": str(e),
289
+ }
290
+ )
291
+ await connection.close(code=4005)
292
+ logger.warning("WebSocket session limit user_id=%s error=%s", user_id, e)
293
+
294
+ except WebSocketDisconnect:
295
+ logger.info("Client disconnected user_id=%s", user_id)
296
+
297
+ except Exception:
298
+ logger.exception("WebSocket error user_id=%s", user_id)
299
+ with suppress(Exception):
300
+ await connection.close(code=1011)
301
+ finally:
302
+ if session:
303
+ session_service.disconnect_session(session.id)
304
+ actual_session_id = session_id or (session.session_id if session else None)
305
+ logger.info(
306
+ "WebSocket handler finished user_id=%s session_id=%s",
307
+ user_id,
308
+ actual_session_id,
309
+ )
310
+
311
+ return app
312
+
313
+
314
+ # Create the app instance
315
+ app = create_app()
@@ -0,0 +1 @@
1
+ """Application layer - use cases and orchestration."""
@@ -0,0 +1,9 @@
1
+ """Application layer ports - interfaces for presentation layer."""
2
+
3
+ from .config_port import ConfigPort
4
+ from .connection_port import ConnectionPort
5
+
6
+ __all__ = [
7
+ "ConnectionPort",
8
+ "ConfigPort",
9
+ ]
@@ -0,0 +1,42 @@
1
+ """Configuration port - interface for configuration access."""
2
+
3
+ from typing import Any, Protocol
4
+
5
+
6
+ class ConfigPort(Protocol):
7
+ """Protocol for configuration access.
8
+
9
+ Infrastructure layer implements this with actual config loading.
10
+ """
11
+
12
+ def get_server_host(self) -> str:
13
+ """Get server bind host."""
14
+ ...
15
+
16
+ def get_server_port(self) -> int:
17
+ """Get server port."""
18
+ ...
19
+
20
+ def get_default_cols(self) -> int:
21
+ """Get default terminal columns."""
22
+ ...
23
+
24
+ def get_default_rows(self) -> int:
25
+ """Get default terminal rows."""
26
+ ...
27
+
28
+ def get_default_shell_id(self) -> str:
29
+ """Get default shell ID."""
30
+ ...
31
+
32
+ def get_shell_by_id(self, shell_id: str) -> dict[str, Any] | None:
33
+ """Get shell configuration by ID."""
34
+ ...
35
+
36
+ def get_available_shells(self) -> list[dict[str, Any]]:
37
+ """Get list of available shells."""
38
+ ...
39
+
40
+ def get_buttons(self) -> list[dict[str, Any]]:
41
+ """Get custom button configurations."""
42
+ ...