Compare commits
8 Commits
v0.2.17-wi
...
v0.2.25-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| 57a7cee544 | |||
| 6369f7e0a8 | |||
| 9ee0d34c19 | |||
| 922543cc04 | |||
| 13038989b8 | |||
| b55de8d75e | |||
| 8512ca615d | |||
| ebae39026f |
@@ -62,7 +62,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
|
||||
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
|
||||
- **`components/settings/`** — Settings panels for API keys, Docker, AWS
|
||||
- **`components/settings/`** — Settings panels for API keys, Docker, AWS, Web Terminal
|
||||
|
||||
### Backend Structure (`app/src-tauri/src/`)
|
||||
|
||||
@@ -72,7 +72,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
|
||||
- `image.rs` — Image build/pull with progress streaming
|
||||
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
|
||||
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
||||
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
|
||||
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
|
||||
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend.
|
||||
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||
|
||||
### Container (`container/`)
|
||||
|
||||
@@ -16,6 +16,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
- [Ollama Configuration](#ollama-configuration)
|
||||
- [OpenAI Compatible Configuration](#openai-compatible-configuration)
|
||||
- [Settings](#settings)
|
||||
- [Web Terminal (Remote Access)](#web-terminal-remote-access)
|
||||
- [Terminal Features](#terminal-features)
|
||||
- [Scheduled Tasks (Inside the Container)](#scheduled-tasks-inside-the-container)
|
||||
- [What's Inside the Container](#whats-inside-the-container)
|
||||
@@ -480,6 +481,17 @@ Instructions applied to **all** projects. Written to `~/.claude/CLAUDE.md` in ev
|
||||
|
||||
Environment variables applied to **all** project containers. Per-project variables with the same key take precedence.
|
||||
|
||||
### Web Terminal
|
||||
|
||||
Enable remote access to your project terminals from any device on the local network (tablets, phones, other computers).
|
||||
|
||||
- **Toggle** — Click ON/OFF to start or stop the web terminal server.
|
||||
- **URL** — When running, shows the full URL including the access token. Click **Copy URL** to copy it to your clipboard, then open it in a browser on your tablet or phone.
|
||||
- **Token** — An access token is auto-generated on first enable. Click **Copy** to copy the token, or **Regenerate** to create a new one (this disconnects existing web sessions).
|
||||
- **Port** — Defaults to 7681. Configurable in `settings.json` if needed.
|
||||
|
||||
The web terminal server auto-starts on app launch if it was previously enabled, and stops when the app closes.
|
||||
|
||||
### Updates
|
||||
|
||||
- **Current Version** — The installed version of Triple-C.
|
||||
@@ -490,6 +502,43 @@ When an update is available, a pulsing **Update** button appears in the top bar.
|
||||
|
||||
---
|
||||
|
||||
## Web Terminal (Remote Access)
|
||||
|
||||
The web terminal lets you access your running project terminals from a tablet, phone, or any other device on the local network — no app installation required, just a web browser.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Go to **Settings** in the sidebar.
|
||||
2. Find the **Web Terminal** section and click the toggle to **ON**.
|
||||
3. A URL appears (e.g., `http://192.168.1.100:7681?token=...`). Click **Copy URL**.
|
||||
4. Open the URL in a browser on your tablet or other device.
|
||||
|
||||
### Using the Web Terminal
|
||||
|
||||
The web terminal UI mirrors the desktop app's terminal experience:
|
||||
|
||||
- **Project picker** — Select a running project from the dropdown at the top.
|
||||
- **Claude / Bash buttons** — Open a new Claude Code or bash session for the selected project.
|
||||
- **Tab bar** — Switch between multiple open sessions. Click the **x** on a tab to close it.
|
||||
- **Input bar** — A text input at the bottom optimized for mobile/tablet keyboards. Characters are sent immediately without waiting for autocomplete. Helper buttons for **Enter**, **Tab**, and **^C** (Ctrl+C) are provided for keys that are awkward on virtual keyboards.
|
||||
- **Scroll to bottom** — A floating arrow button appears when you scroll up, letting you jump back to the latest output.
|
||||
|
||||
### Security
|
||||
|
||||
- Access requires a token in the URL query string. Without the correct token, connections are rejected.
|
||||
- The token is auto-generated (32 bytes, base64url-encoded) and can be regenerated at any time from Settings.
|
||||
- The server only listens on port 7681 (configurable) — make sure this port is not exposed to the public internet.
|
||||
- All sessions opened from a browser tab are automatically cleaned up when the tab is closed or the WebSocket disconnects.
|
||||
|
||||
### Tips
|
||||
|
||||
- **Bookmark the URL** on your tablet for quick access.
|
||||
- The web terminal works best in landscape orientation on tablets.
|
||||
- If the connection drops (e.g., Wi-Fi interruption), the web terminal auto-reconnects after 2 seconds.
|
||||
- Regenerating the token invalidates all existing browser sessions — you'll need to update bookmarks with the new URL.
|
||||
|
||||
---
|
||||
|
||||
## Terminal Features
|
||||
|
||||
### Multiple Sessions
|
||||
|
||||
21
README.md
21
README.md
@@ -85,6 +85,18 @@ Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io
|
||||
|
||||
Optional per-project integration with [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology. When enabled, the repo is cloned into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
|
||||
|
||||
### Web Terminal (Remote Access)
|
||||
|
||||
Triple-C includes an optional web terminal server for accessing project terminals from tablets, phones, or other devices on the local network. When enabled in Settings, an axum HTTP+WebSocket server starts inside the Tauri process, serving a standalone xterm.js-based terminal UI.
|
||||
|
||||
- **URL**: `http://<LAN_IP>:7681?token=...` (port configurable)
|
||||
- **Authentication**: Token-based (auto-generated, copyable from Settings)
|
||||
- **Protocol**: JSON over WebSocket with base64-encoded terminal data
|
||||
- **Features**: Project picker, multiple tabs (Claude + bash sessions), mobile-optimized input bar, scroll-to-bottom button
|
||||
- **Session cleanup**: All terminal sessions are closed when the browser disconnects
|
||||
|
||||
The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stores — same Docker exec sessions, different transport (WebSocket instead of Tauri IPC events).
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
@@ -108,7 +120,8 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
||||
| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) |
|
||||
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, and global settings |
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, and global settings |
|
||||
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
|
||||
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
@@ -124,7 +137,11 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, MCP servers, Mission Control) |
|
||||
| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, web terminal) |
|
||||
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
||||
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
||||
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Mission Control setup |
|
||||
|
||||
23
TECHNICAL.md
23
TECHNICAL.md
@@ -100,9 +100,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
||||
│ │ Project Management │◄─┤ ProjectsStore │ │
|
||||
│ │ Settings UI │ │ bollard Docker Client │ │
|
||||
│ │ │ │ keyring Credential Mgr │ │
|
||||
│ └───────────┬───────────┘ └────────────┬─────────────┘ │
|
||||
│ └───────────┬───────────┘ │ Web Terminal Server │ │
|
||||
│ │ └────────────┬─────────────┘ │
|
||||
│ │ Tauri IPC (invoke/emit) │ │
|
||||
│ └───────────┬───────────────┘ │
|
||||
│ ▲ │
|
||||
│ axum HTTP+WS│(port 7681) │
|
||||
│ │ │
|
||||
└──────────────────────────┼───────────────────────────────┘
|
||||
│ Docker Socket
|
||||
▼
|
||||
@@ -129,6 +133,8 @@ The application uses two IPC mechanisms between the React frontend and Rust back
|
||||
|
||||
**Request/Response** (`invoke()`): Used for discrete operations — starting containers, saving settings, listing projects. The frontend calls `invoke("command_name", { args })` and awaits a typed result.
|
||||
|
||||
**WebSocket Streaming** (Web Terminal): Used for remote terminal access from browsers on the local network. An axum HTTP+WebSocket server runs inside the Tauri process, sharing the same `ExecSessionManager` via `Arc`-wrapped stores. The WebSocket uses a JSON protocol with base64-encoded terminal data. Each browser connection can open multiple terminal sessions; all sessions are cleaned up when the WebSocket disconnects.
|
||||
|
||||
**Event Streaming** (`emit()`/`listen()`): Used for continuous data — terminal I/O. When a terminal session is opened, the Rust backend spawns two tokio tasks:
|
||||
1. **Output reader** — Reads from the Docker exec stdout stream and emits `terminal-output-{sessionId}` events to the frontend.
|
||||
2. **Input writer** — Listens on an `mpsc::unbounded_channel` for data sent from the frontend via `invoke("terminal_input")` and writes it to the Docker exec stdin.
|
||||
@@ -263,7 +269,7 @@ triple-c/
|
||||
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
|
||||
│ │ # FileManagerModal, ContainerProgressModal, modals
|
||||
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||
│ │ # UpdateDialog
|
||||
│ │ # WebTerminalSettings, UpdateDialog
|
||||
│ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
|
||||
│
|
||||
└── src-tauri/ # Rust backend
|
||||
@@ -282,7 +288,13 @@ triple-c/
|
||||
│ ├── project_commands.rs # Start/stop/rebuild containers
|
||||
│ ├── settings_commands.rs # Settings CRUD
|
||||
│ ├── terminal_commands.rs # Terminal I/O, resize
|
||||
│ └── update_commands.rs # App update checking
|
||||
│ ├── update_commands.rs # App update checking
|
||||
│ └── web_terminal_commands.rs # Web terminal start/stop/status
|
||||
├── web_terminal/ # Remote terminal access
|
||||
│ ├── mod.rs # Module root
|
||||
│ ├── server.rs # Axum HTTP+WS server lifecycle
|
||||
│ ├── ws_handler.rs # WebSocket connection handler
|
||||
│ └── terminal.html # Embedded xterm.js web UI
|
||||
├── docker/ # Docker API layer
|
||||
│ ├── client.rs # bollard singleton connection
|
||||
│ ├── container.rs # Create, start, stop, remove, fingerprinting
|
||||
@@ -323,6 +335,11 @@ triple-c/
|
||||
| `tar` | 0.4 | In-memory tar archives for Docker build context |
|
||||
| `dirs` | 6.x | Cross-platform app data directory paths |
|
||||
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
|
||||
| `axum` | 0.8 | HTTP+WebSocket server for web terminal |
|
||||
| `tower-http` | 0.6 | CORS middleware for web terminal |
|
||||
| `base64` | 0.22 | Terminal data encoding over WebSocket |
|
||||
| `rand` | 0.9 | Access token generation |
|
||||
| `local-ip-address` | 0.6 | LAN IP detection for web terminal URL |
|
||||
|
||||
### JavaScript (Frontend)
|
||||
|
||||
|
||||
279
app/src-tauri/Cargo.lock
generated
279
app/src-tauri/Cargo.lock
generated
@@ -213,6 +213,61 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -664,14 +719,38 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.21.3",
|
||||
"darling_macro 0.21.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -688,17 +767,34 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core 0.20.11",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.21.3",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -709,6 +805,37 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||
dependencies = [
|
||||
"derive_builder_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_core"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling 0.20.11",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_macro"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -841,6 +968,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -1309,6 +1442,18 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getset"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912"
|
||||
dependencies = [
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -2085,6 +2230,17 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
|
||||
[[package]]
|
||||
name = "local-ip-address"
|
||||
version = "0.6.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"neli",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2143,6 +2299,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -2246,6 +2408,35 @@ dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neli"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"derive_builder",
|
||||
"getset",
|
||||
"libc",
|
||||
"log",
|
||||
"neli-proc-macros",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neli-proc-macros"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
|
||||
dependencies = [
|
||||
"either",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -2916,6 +3107,28 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-hack"
|
||||
version = "0.5.20+deprecated"
|
||||
@@ -3594,6 +3807,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -3660,7 +3884,7 @@ version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.21.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -3698,6 +3922,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4459,6 +4694,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4581,6 +4828,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4619,6 +4867,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -4670,6 +4919,8 @@ dependencies = [
|
||||
name = "triple-c"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
"chrono",
|
||||
"dirs",
|
||||
@@ -4677,7 +4928,9 @@ dependencies = [
|
||||
"futures-util",
|
||||
"iana-time-zone",
|
||||
"keyring",
|
||||
"local-ip-address",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4689,6 +4942,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -4698,6 +4952,23 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
|
||||
@@ -31,6 +31,11 @@ tar = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
iana-time-zone = "0.1"
|
||||
sha2 = "0.10"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.9"
|
||||
local-ip-address = "0.6"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -7,3 +7,4 @@ pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod terminal_commands;
|
||||
pub mod update_commands;
|
||||
pub mod web_terminal_commands;
|
||||
|
||||
143
app/src-tauri/src/commands/web_terminal_commands.rs
Normal file
143
app/src-tauri/src/commands/web_terminal_commands.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::web_terminal::WebTerminalServer;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WebTerminalInfo {
|
||||
pub running: bool,
|
||||
pub port: u16,
|
||||
pub access_token: String,
|
||||
pub local_ip: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
|
||||
}
|
||||
|
||||
fn get_local_ip() -> Option<String> {
|
||||
local_ip_address::local_ip().ok().map(|ip| ip.to_string())
|
||||
}
|
||||
|
||||
fn build_info(running: bool, port: u16, token: &str) -> WebTerminalInfo {
|
||||
let local_ip = get_local_ip();
|
||||
let url = if running {
|
||||
local_ip
|
||||
.as_ref()
|
||||
.map(|ip| format!("http://{}:{}?token={}", ip, port, token))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
WebTerminalInfo {
|
||||
running,
|
||||
port,
|
||||
access_token: token.to_string(),
|
||||
local_ip,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_web_terminal(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||
let mut server_guard = state.web_terminal_server.lock().await;
|
||||
if server_guard.is_some() {
|
||||
return Err("Web terminal server is already running".to_string());
|
||||
}
|
||||
|
||||
let mut settings = state.settings_store.get();
|
||||
|
||||
// Auto-generate token if not set
|
||||
if settings.web_terminal.access_token.is_none() {
|
||||
settings.web_terminal.access_token = Some(generate_token());
|
||||
settings.web_terminal.enabled = true;
|
||||
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
}
|
||||
|
||||
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
|
||||
let port = settings.web_terminal.port;
|
||||
|
||||
let server = WebTerminalServer::start(
|
||||
port,
|
||||
token.clone(),
|
||||
state.exec_manager.clone(),
|
||||
state.projects_store.clone(),
|
||||
state.settings_store.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
*server_guard = Some(server);
|
||||
|
||||
// Mark as enabled in settings
|
||||
if !settings.web_terminal.enabled {
|
||||
settings.web_terminal.enabled = true;
|
||||
let _ = state.settings_store.update(settings);
|
||||
}
|
||||
|
||||
Ok(build_info(true, port, &token))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_web_terminal(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut server_guard = state.web_terminal_server.lock().await;
|
||||
if let Some(server) = server_guard.take() {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// Mark as disabled in settings
|
||||
let mut settings = state.settings_store.get();
|
||||
if settings.web_terminal.enabled {
|
||||
settings.web_terminal.enabled = false;
|
||||
let _ = state.settings_store.update(settings);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_web_terminal_status(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||
let server_guard = state.web_terminal_server.lock().await;
|
||||
let settings = state.settings_store.get();
|
||||
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
|
||||
let running = server_guard.is_some();
|
||||
Ok(build_info(running, settings.web_terminal.port, &token))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn regenerate_web_terminal_token(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
|
||||
// Stop current server if running
|
||||
{
|
||||
let mut server_guard = state.web_terminal_server.lock().await;
|
||||
if let Some(server) = server_guard.take() {
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new token and save
|
||||
let new_token = generate_token();
|
||||
let mut settings = state.settings_store.get();
|
||||
settings.web_terminal.access_token = Some(new_token.clone());
|
||||
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
// Restart if was enabled
|
||||
if settings.web_terminal.enabled {
|
||||
let server = WebTerminalServer::start(
|
||||
settings.web_terminal.port,
|
||||
new_token.clone(),
|
||||
state.exec_manager.clone(),
|
||||
state.projects_store.clone(),
|
||||
state.settings_store.clone(),
|
||||
)
|
||||
.await?;
|
||||
let mut server_guard = state.web_terminal_server.lock().await;
|
||||
*server_guard = Some(server);
|
||||
return Ok(build_info(true, settings.web_terminal.port, &new_token));
|
||||
}
|
||||
|
||||
Ok(build_info(false, settings.web_terminal.port, &new_token))
|
||||
}
|
||||
@@ -704,6 +704,13 @@ pub async fn create_container(
|
||||
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
||||
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
||||
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
|
||||
labels.insert("triple-c.instructions-fingerprint".to_string(),
|
||||
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
|
||||
labels.insert("triple-c.git-user-name".to_string(), project.git_user_name.clone().unwrap_or_default());
|
||||
labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default());
|
||||
labels.insert("triple-c.git-token-hash".to_string(),
|
||||
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
|
||||
|
||||
let host_config = HostConfig {
|
||||
mounts: Some(mounts),
|
||||
@@ -1000,41 +1007,32 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Git environment variables ────────────────────────────────────────
|
||||
let env_vars = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.env.as_ref());
|
||||
|
||||
let get_env = |name: &str| -> Option<String> {
|
||||
env_vars.and_then(|vars| {
|
||||
vars.iter()
|
||||
.find(|v| v.starts_with(&format!("{}=", name)))
|
||||
.map(|v| v[name.len() + 1..].to_string())
|
||||
})
|
||||
};
|
||||
|
||||
let container_git_name = get_env("GIT_USER_NAME");
|
||||
let container_git_email = get_env("GIT_USER_EMAIL");
|
||||
let container_git_token = get_env("GIT_TOKEN");
|
||||
|
||||
if container_git_name.as_deref() != project.git_user_name.as_deref() {
|
||||
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
|
||||
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
|
||||
let expected_git_name = project.git_user_name.clone().unwrap_or_default();
|
||||
let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
|
||||
if container_git_name != expected_git_name {
|
||||
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, expected_git_name);
|
||||
return Ok(true);
|
||||
}
|
||||
if container_git_email.as_deref() != project.git_user_email.as_deref() {
|
||||
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email);
|
||||
|
||||
let expected_git_email = project.git_user_email.clone().unwrap_or_default();
|
||||
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
|
||||
if container_git_email != expected_git_email {
|
||||
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, expected_git_email);
|
||||
return Ok(true);
|
||||
}
|
||||
if container_git_token.as_deref() != project.git_token.as_deref() {
|
||||
|
||||
let expected_git_token_hash = project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default();
|
||||
let container_git_token_hash = get_label("triple-c.git-token-hash").unwrap_or_default();
|
||||
if container_git_token_hash != expected_git_token_hash {
|
||||
log::info!("GIT_TOKEN mismatch");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Custom environment variables ──────────────────────────────────────
|
||||
// ── Custom environment variables (label-based fingerprint) ──────────
|
||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
||||
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
|
||||
let container_fingerprint = get_label("triple-c.custom-env-fingerprint").unwrap_or_default();
|
||||
if container_fingerprint != expected_fingerprint {
|
||||
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
||||
return Ok(true);
|
||||
@@ -1048,15 +1046,16 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Claude instructions ───────────────────────────────────────────────
|
||||
// ── Claude instructions (label-based fingerprint) ─────────────────────
|
||||
let expected_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
project.mission_control_enabled,
|
||||
);
|
||||
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
||||
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
||||
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
|
||||
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
|
||||
if container_instructions_fp != expected_instructions_fp {
|
||||
log::info!("CLAUDE_INSTRUCTIONS mismatch");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -3,44 +3,55 @@ mod docker;
|
||||
mod logging;
|
||||
mod models;
|
||||
mod storage;
|
||||
pub mod web_terminal;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use docker::exec::ExecSessionManager;
|
||||
use storage::projects_store::ProjectsStore;
|
||||
use storage::settings_store::SettingsStore;
|
||||
use storage::mcp_store::McpStore;
|
||||
use tauri::Manager;
|
||||
use web_terminal::WebTerminalServer;
|
||||
|
||||
pub struct AppState {
|
||||
pub projects_store: ProjectsStore,
|
||||
pub settings_store: SettingsStore,
|
||||
pub mcp_store: McpStore,
|
||||
pub exec_manager: ExecSessionManager,
|
||||
pub projects_store: Arc<ProjectsStore>,
|
||||
pub settings_store: Arc<SettingsStore>,
|
||||
pub mcp_store: Arc<McpStore>,
|
||||
pub exec_manager: Arc<ExecSessionManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
logging::init();
|
||||
|
||||
let projects_store = match ProjectsStore::new() {
|
||||
let projects_store = Arc::new(match ProjectsStore::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to initialize projects store: {}", e);
|
||||
panic!("Failed to initialize projects store: {}", e);
|
||||
}
|
||||
};
|
||||
let settings_store = match SettingsStore::new() {
|
||||
});
|
||||
let settings_store = Arc::new(match SettingsStore::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to initialize settings store: {}", e);
|
||||
panic!("Failed to initialize settings store: {}", e);
|
||||
}
|
||||
};
|
||||
let mcp_store = match McpStore::new() {
|
||||
});
|
||||
let mcp_store = Arc::new(match McpStore::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to initialize MCP store: {}", e);
|
||||
panic!("Failed to initialize MCP store: {}", e);
|
||||
}
|
||||
};
|
||||
});
|
||||
let exec_manager = Arc::new(ExecSessionManager::new());
|
||||
|
||||
// Clone Arcs for the setup closure (web terminal auto-start)
|
||||
let projects_store_setup = projects_store.clone();
|
||||
let settings_store_setup = settings_store.clone();
|
||||
let exec_manager_setup = exec_manager.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
@@ -50,9 +61,10 @@ pub fn run() {
|
||||
projects_store,
|
||||
settings_store,
|
||||
mcp_store,
|
||||
exec_manager: ExecSessionManager::new(),
|
||||
exec_manager,
|
||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
})
|
||||
.setup(|app| {
|
||||
.setup(move |app| {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
Ok(icon) => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -63,12 +75,54 @@ pub fn run() {
|
||||
log::error!("Failed to load window icon: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start web terminal server if enabled in settings
|
||||
let settings = settings_store_setup.get();
|
||||
if settings.web_terminal.enabled {
|
||||
if let Some(token) = &settings.web_terminal.access_token {
|
||||
let token = token.clone();
|
||||
let port = settings.web_terminal.port;
|
||||
let exec_mgr = exec_manager_setup.clone();
|
||||
let proj_store = projects_store_setup.clone();
|
||||
let set_store = settings_store_setup.clone();
|
||||
let state = app.state::<AppState>();
|
||||
let web_server_mutex = state.web_terminal_server.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match WebTerminalServer::start(
|
||||
port,
|
||||
token,
|
||||
exec_mgr,
|
||||
proj_store,
|
||||
set_store,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(server) => {
|
||||
let mut guard = web_server_mutex.lock().await;
|
||||
*guard = Some(server);
|
||||
log::info!("Web terminal auto-started on port {}", port);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-start web terminal: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
let state = window.state::<AppState>();
|
||||
tauri::async_runtime::block_on(async {
|
||||
// Stop web terminal server
|
||||
let mut server_guard = state.web_terminal_server.lock().await;
|
||||
if let Some(server) = server_guard.take() {
|
||||
server.stop();
|
||||
}
|
||||
// Close all exec sessions
|
||||
state.exec_manager.close_all_sessions().await;
|
||||
});
|
||||
}
|
||||
@@ -122,6 +176,11 @@ pub fn run() {
|
||||
commands::update_commands::check_image_update,
|
||||
// Help
|
||||
commands::help_commands::get_help_content,
|
||||
// Web Terminal
|
||||
commands::web_terminal_commands::start_web_terminal,
|
||||
commands::web_terminal_commands::stop_web_terminal,
|
||||
commands::web_terminal_commands::get_web_terminal_status,
|
||||
commands::web_terminal_commands::regenerate_web_terminal_token,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -74,6 +74,32 @@ pub struct AppSettings {
|
||||
pub default_microphone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dismissed_image_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub web_terminal: WebTerminalSettings,
|
||||
}
|
||||
|
||||
fn default_web_terminal_port() -> u16 {
|
||||
7681
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebTerminalSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_web_terminal_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub access_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for WebTerminalSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: 7681,
|
||||
access_token: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -93,6 +119,7 @@ impl Default for AppSettings {
|
||||
timezone: None,
|
||||
default_microphone: None,
|
||||
dismissed_image_digest: None,
|
||||
web_terminal: WebTerminalSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
app/src-tauri/src/web_terminal/mod.rs
Normal file
4
app/src-tauri/src/web_terminal/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod server;
|
||||
mod ws_handler;
|
||||
|
||||
pub use server::WebTerminalServer;
|
||||
155
app/src-tauri/src/web_terminal/server.rs
Normal file
155
app/src-tauri/src/web_terminal/server.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State as AxumState, WebSocketUpgrade};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::watch;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::docker::exec::ExecSessionManager;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
use crate::storage::settings_store::SettingsStore;
|
||||
|
||||
use super::ws_handler;
|
||||
|
||||
/// Shared state passed to all axum handlers.
|
||||
pub struct WebTerminalState {
|
||||
pub exec_manager: Arc<ExecSessionManager>,
|
||||
pub projects_store: Arc<ProjectsStore>,
|
||||
pub settings_store: Arc<SettingsStore>,
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
/// Manages the lifecycle of the axum HTTP+WS server.
|
||||
pub struct WebTerminalServer {
|
||||
shutdown_tx: watch::Sender<()>,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TokenQuery {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProjectInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl WebTerminalServer {
|
||||
/// Start the web terminal server on the given port.
|
||||
pub async fn start(
|
||||
port: u16,
|
||||
access_token: String,
|
||||
exec_manager: Arc<ExecSessionManager>,
|
||||
projects_store: Arc<ProjectsStore>,
|
||||
settings_store: Arc<SettingsStore>,
|
||||
) -> Result<Self, String> {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(());
|
||||
|
||||
let shared_state = Arc::new(WebTerminalState {
|
||||
exec_manager,
|
||||
projects_store,
|
||||
settings_store,
|
||||
access_token,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(serve_html))
|
||||
.route("/ws", get(ws_upgrade))
|
||||
.route("/api/projects", get(list_projects))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(shared_state);
|
||||
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind web terminal to {}: {}", addr, e))?;
|
||||
|
||||
log::info!("Web terminal server listening on {}", addr);
|
||||
|
||||
let mut shutdown_rx_clone = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx_clone.changed().await;
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::error!("Web terminal server error: {}", e);
|
||||
});
|
||||
log::info!("Web terminal server shut down");
|
||||
});
|
||||
|
||||
Ok(Self { shutdown_tx, port })
|
||||
}
|
||||
|
||||
/// Stop the server gracefully.
|
||||
pub fn stop(&self) {
|
||||
log::info!("Stopping web terminal server on port {}", self.port);
|
||||
let _ = self.shutdown_tx.send(());
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the embedded HTML page.
|
||||
async fn serve_html() -> Html<&'static str> {
|
||||
Html(include_str!("terminal.html"))
|
||||
}
|
||||
|
||||
/// Validate token from query params.
|
||||
fn validate_token(state: &WebTerminalState, token: &Option<String>) -> bool {
|
||||
match token {
|
||||
Some(t) => t == &state.access_token,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket upgrade handler.
|
||||
async fn ws_upgrade(
|
||||
ws: WebSocketUpgrade,
|
||||
AxumState(state): AxumState<Arc<WebTerminalState>>,
|
||||
Query(query): Query<TokenQuery>,
|
||||
) -> impl IntoResponse {
|
||||
if !validate_token(&state, &query.token) {
|
||||
return (axum::http::StatusCode::UNAUTHORIZED, "Invalid token").into_response();
|
||||
}
|
||||
ws.on_upgrade(move |socket| ws_handler::handle_connection(socket, state))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// List running projects (REST endpoint).
|
||||
async fn list_projects(
|
||||
AxumState(state): AxumState<Arc<WebTerminalState>>,
|
||||
Query(query): Query<TokenQuery>,
|
||||
) -> impl IntoResponse {
|
||||
if !validate_token(&state, &query.token) {
|
||||
return (
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
axum::Json(serde_json::json!({"error": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let projects = state.projects_store.list();
|
||||
let infos: Vec<ProjectInfo> = projects
|
||||
.into_iter()
|
||||
.map(|p| ProjectInfo {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status: serde_json::to_value(&p.status)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
axum::Json(infos).into_response()
|
||||
}
|
||||
669
app/src-tauri/src/web_terminal/terminal.html
Normal file
669
app/src-tauri/src/web_terminal/terminal.html
Normal file
@@ -0,0 +1,669 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Triple-C Web Terminal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
--bg-secondary: #24283b;
|
||||
--bg-tertiary: #2f3347;
|
||||
--text-primary: #c0caf5;
|
||||
--text-secondary: #565f89;
|
||||
--accent: #7aa2f7;
|
||||
--accent-hover: #89b4fa;
|
||||
--border: #3b3f57;
|
||||
--success: #9ece6a;
|
||||
--warning: #e0af68;
|
||||
--error: #f7768e;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* dynamic viewport height — shrinks when mobile keyboard opens */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Top Bar ─────────────────────────────── */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
min-height: 42px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--error);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.connected { background: var(--success); }
|
||||
.status-dot.reconnecting { background: var(--warning); animation: pulse 1s infinite; }
|
||||
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
select, button {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
select:focus, button:focus { outline: none; border-color: var(--accent); }
|
||||
button:hover { background: var(--border); }
|
||||
button:active { background: var(--accent); color: var(--bg-primary); }
|
||||
|
||||
.btn-new {
|
||||
font-weight: 600;
|
||||
min-width: 44px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* ── Tab Bar ─────────────────────────────── */
|
||||
.tabbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 0 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-height: 32px;
|
||||
position: sticky;
|
||||
top: 42px; /* below .topbar min-height */
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
.tab.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-width: unset;
|
||||
min-height: unset;
|
||||
}
|
||||
.tab-close:hover { background: var(--error); color: white; }
|
||||
|
||||
/* ── Terminal Area ───────────────────────── */
|
||||
.terminal-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
padding: 4px;
|
||||
}
|
||||
.terminal-container.active { display: block; }
|
||||
|
||||
/* ── Input Bar (mobile/tablet) ──────────── */
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-bar input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 16px; /* prevents iOS zoom on focus */
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.input-bar input:focus { border-color: var(--accent); }
|
||||
|
||||
.input-bar .key-btn {
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 40px;
|
||||
min-height: 36px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Scroll-to-bottom FAB ──────────────── */
|
||||
.scroll-bottom-btn {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
min-width: unset;
|
||||
min-height: unset;
|
||||
line-height: 1;
|
||||
}
|
||||
.scroll-bottom-btn:hover { background: var(--accent-hover); }
|
||||
.scroll-bottom-btn.visible { display: flex; }
|
||||
|
||||
/* ── Empty State ─────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ───────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="topbar">
|
||||
<span class="topbar-title">Triple-C</span>
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<select id="projectSelect" style="flex:1; max-width:240px;">
|
||||
<option value="">Select project...</option>
|
||||
</select>
|
||||
<button class="btn-new" id="btnClaude" title="New Claude session">Claude</button>
|
||||
<button class="btn-new" id="btnBash" title="New Bash session">Bash</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="tabbar" id="tabbar"></div>
|
||||
|
||||
<!-- Terminal Area -->
|
||||
<div class="terminal-area" id="terminalArea">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div>Select a project and open a terminal session</div>
|
||||
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
|
||||
</div>
|
||||
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">↓</button>
|
||||
</div>
|
||||
|
||||
<!-- Input Bar for mobile/tablet -->
|
||||
<div class="input-bar" id="inputBar">
|
||||
<input type="text" id="mobileInput" placeholder="Type here..."
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
enterkeyhint="send" inputmode="text">
|
||||
<button class="key-btn" id="btnEnter">Enter</button>
|
||||
<button class="key-btn" id="btnTab">Tab</button>
|
||||
<button class="key-btn" id="btnCtrlC">^C</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── State ──────────────────────────────────
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const TOKEN = params.get('token') || '';
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let sessions = {}; // { sessionId: { term, fitAddon, projectName, type, containerId } }
|
||||
let activeSessionId = null;
|
||||
|
||||
// ── DOM refs ───────────────────────────────
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const projectSelect = document.getElementById('projectSelect');
|
||||
const btnClaude = document.getElementById('btnClaude');
|
||||
const btnBash = document.getElementById('btnBash');
|
||||
const tabbar = document.getElementById('tabbar');
|
||||
const terminalArea = document.getElementById('terminalArea');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const mobileInput = document.getElementById('mobileInput');
|
||||
const btnEnter = document.getElementById('btnEnter');
|
||||
const btnTab = document.getElementById('btnTab');
|
||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||
|
||||
// ── WebSocket ──────────────────────────────
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(TOKEN)}`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
statusDot.className = 'status-dot connected';
|
||||
clearTimeout(reconnectTimer);
|
||||
send({ type: 'list_projects' });
|
||||
// Start keepalive
|
||||
ws._pingInterval = setInterval(() => send({ type: 'ping' }), 30000);
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
statusDot.className = 'status-dot reconnecting';
|
||||
if (ws && ws._pingInterval) clearInterval(ws._pingInterval);
|
||||
reconnectTimer = setTimeout(connect, 2000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
function send(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message handling ───────────────────────
|
||||
function handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'projects':
|
||||
updateProjectList(msg.projects);
|
||||
break;
|
||||
case 'opened':
|
||||
onSessionOpened(msg.session_id, msg.project_name);
|
||||
break;
|
||||
case 'output':
|
||||
onSessionOutput(msg.session_id, msg.data);
|
||||
break;
|
||||
case 'exit':
|
||||
onSessionExit(msg.session_id);
|
||||
break;
|
||||
case 'error':
|
||||
console.error('Server error:', msg.message);
|
||||
// Show in active terminal if available
|
||||
if (activeSessionId && sessions[activeSessionId]) {
|
||||
sessions[activeSessionId].term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||
}
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateProjectList(projects) {
|
||||
const current = projectSelect.value;
|
||||
projectSelect.innerHTML = '<option value="">Select project...</option>';
|
||||
projects.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.name} (${p.status})`;
|
||||
opt.disabled = p.status !== 'running';
|
||||
projectSelect.appendChild(opt);
|
||||
});
|
||||
// Restore selection if still valid
|
||||
if (current) projectSelect.value = current;
|
||||
}
|
||||
|
||||
// ── Session management ─────────────────────
|
||||
let pendingSessionType = null;
|
||||
|
||||
function openSession(type) {
|
||||
const projectId = projectSelect.value;
|
||||
if (!projectId) {
|
||||
alert('Please select a running project first.');
|
||||
return;
|
||||
}
|
||||
pendingSessionType = type;
|
||||
send({
|
||||
type: 'open',
|
||||
project_id: projectId,
|
||||
session_type: type,
|
||||
});
|
||||
}
|
||||
|
||||
function onSessionOpened(sessionId, projectName) {
|
||||
const sessionType = pendingSessionType || 'claude';
|
||||
pendingSessionType = null;
|
||||
|
||||
// Create terminal
|
||||
const term = new Terminal({
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
selectionBackground: '#33467c',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5',
|
||||
},
|
||||
fontSize: 14,
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// Create container div
|
||||
const container = document.createElement('div');
|
||||
container.className = 'terminal-container';
|
||||
container.id = `term-${sessionId}`;
|
||||
terminalArea.appendChild(container);
|
||||
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
// Send initial resize
|
||||
send({
|
||||
type: 'resize',
|
||||
session_id: sessionId,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
});
|
||||
|
||||
// Handle user input
|
||||
term.onData(data => {
|
||||
const bytes = new TextEncoder().encode(data);
|
||||
const b64 = btoa(String.fromCharCode(...bytes));
|
||||
send({
|
||||
type: 'input',
|
||||
session_id: sessionId,
|
||||
data: b64,
|
||||
});
|
||||
});
|
||||
|
||||
// Track scroll position for scroll-to-bottom button
|
||||
term.onScroll(() => updateScrollButton());
|
||||
|
||||
// Store session
|
||||
sessions[sessionId] = { term, fitAddon, projectName, type: sessionType, container };
|
||||
|
||||
// Add tab and switch to it
|
||||
addTab(sessionId, projectName, sessionType);
|
||||
switchToSession(sessionId);
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
}
|
||||
|
||||
function onSessionOutput(sessionId, b64data) {
|
||||
const session = sessions[sessionId];
|
||||
if (!session) return;
|
||||
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
|
||||
session.term.write(bytes);
|
||||
// Update scroll button if this is the active session
|
||||
if (sessionId === activeSessionId) updateScrollButton();
|
||||
}
|
||||
|
||||
function onSessionExit(sessionId) {
|
||||
const session = sessions[sessionId];
|
||||
if (!session) return;
|
||||
session.term.writeln('\r\n\x1b[90m[Session ended]\x1b[0m');
|
||||
}
|
||||
|
||||
function closeSession(sessionId) {
|
||||
send({ type: 'close', session_id: sessionId });
|
||||
removeSession(sessionId);
|
||||
}
|
||||
|
||||
function removeSession(sessionId) {
|
||||
const session = sessions[sessionId];
|
||||
if (!session) return;
|
||||
|
||||
session.term.dispose();
|
||||
session.container.remove();
|
||||
delete sessions[sessionId];
|
||||
|
||||
// Remove tab
|
||||
const tab = document.getElementById(`tab-${sessionId}`);
|
||||
if (tab) tab.remove();
|
||||
|
||||
// Switch to another session or show empty state
|
||||
const remaining = Object.keys(sessions);
|
||||
if (remaining.length > 0) {
|
||||
switchToSession(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
activeSessionId = null;
|
||||
emptyState.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab bar ────────────────────────────────
|
||||
function addTab(sessionId, projectName, sessionType) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'tab';
|
||||
tab.id = `tab-${sessionId}`;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `${projectName} (${sessionType})`;
|
||||
tab.appendChild(label);
|
||||
|
||||
const close = document.createElement('button');
|
||||
close.className = 'tab-close';
|
||||
close.textContent = '\u00d7';
|
||||
close.onclick = (e) => { e.stopPropagation(); closeSession(sessionId); };
|
||||
tab.appendChild(close);
|
||||
|
||||
tab.onclick = () => switchToSession(sessionId);
|
||||
tabbar.appendChild(tab);
|
||||
}
|
||||
|
||||
function switchToSession(sessionId) {
|
||||
activeSessionId = sessionId;
|
||||
|
||||
// Update tab styles
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
const tab = document.getElementById(`tab-${sessionId}`);
|
||||
if (tab) tab.classList.add('active');
|
||||
|
||||
// Show/hide terminal containers
|
||||
document.querySelectorAll('.terminal-container').forEach(c => c.classList.remove('active'));
|
||||
const container = document.getElementById(`term-${sessionId}`);
|
||||
if (container) {
|
||||
container.classList.add('active');
|
||||
const session = sessions[sessionId];
|
||||
if (session) {
|
||||
// Fit after making visible
|
||||
requestAnimationFrame(() => {
|
||||
session.fitAddon.fit();
|
||||
session.term.focus();
|
||||
updateScrollButton();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resize handling ────────────────────────
|
||||
function handleResize() {
|
||||
if (activeSessionId && sessions[activeSessionId]) {
|
||||
const session = sessions[activeSessionId];
|
||||
session.fitAddon.fit();
|
||||
send({
|
||||
type: 'resize',
|
||||
session_id: activeSessionId,
|
||||
cols: session.term.cols,
|
||||
rows: session.term.rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(handleResize, 100);
|
||||
});
|
||||
|
||||
// ── Send helper ─────────────────────────────
|
||||
function sendTerminalInput(str) {
|
||||
if (!activeSessionId) return;
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
const b64 = btoa(String.fromCharCode(...bytes));
|
||||
send({
|
||||
type: 'input',
|
||||
session_id: activeSessionId,
|
||||
data: b64,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Input bar (mobile/tablet) ──────────────
|
||||
// Send characters immediately, bypassing IME composition buffering.
|
||||
// Clearing value on each input event cancels any active composition.
|
||||
mobileInput.addEventListener('input', () => {
|
||||
const val = mobileInput.value;
|
||||
if (val) {
|
||||
sendTerminalInput(val);
|
||||
mobileInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Catch Enter in the input field itself
|
||||
mobileInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const val = mobileInput.value;
|
||||
if (val) {
|
||||
sendTerminalInput(val);
|
||||
mobileInput.value = '';
|
||||
}
|
||||
sendTerminalInput('\r');
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
sendTerminalInput('\t');
|
||||
}
|
||||
});
|
||||
|
||||
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
||||
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||
|
||||
// ── Scroll to bottom ──────────────────────
|
||||
function updateScrollButton() {
|
||||
if (!activeSessionId || !sessions[activeSessionId]) {
|
||||
scrollBottomBtn.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
const term = sessions[activeSessionId].term;
|
||||
const isAtBottom = term.buffer.active.viewportY >= term.buffer.active.baseY;
|
||||
scrollBottomBtn.classList.toggle('visible', !isAtBottom);
|
||||
}
|
||||
|
||||
scrollBottomBtn.onclick = () => {
|
||||
if (activeSessionId && sessions[activeSessionId]) {
|
||||
sessions[activeSessionId].term.scrollToBottom();
|
||||
scrollBottomBtn.classList.remove('visible');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Event listeners ────────────────────────
|
||||
btnClaude.onclick = () => openSession('claude');
|
||||
btnBash.onclick = () => openSession('bash');
|
||||
|
||||
// ── Init ───────────────────────────────────
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
324
app/src-tauri/src/web_terminal/ws_handler.rs
Normal file
324
app/src-tauri/src/web_terminal/ws_handler.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus};
|
||||
|
||||
use super::server::WebTerminalState;
|
||||
|
||||
// ── Wire protocol types ──────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ClientMessage {
|
||||
ListProjects,
|
||||
Open {
|
||||
project_id: String,
|
||||
session_type: Option<String>,
|
||||
},
|
||||
Input {
|
||||
session_id: String,
|
||||
data: String, // base64
|
||||
},
|
||||
Resize {
|
||||
session_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
},
|
||||
Close {
|
||||
session_id: String,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ServerMessage {
|
||||
Projects {
|
||||
projects: Vec<ProjectEntry>,
|
||||
},
|
||||
Opened {
|
||||
session_id: String,
|
||||
project_name: String,
|
||||
},
|
||||
Output {
|
||||
session_id: String,
|
||||
data: String, // base64
|
||||
},
|
||||
Exit {
|
||||
session_id: String,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
Pong,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProjectEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
// ── Connection handler ───────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>) {
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// Channel for sending messages from session output tasks → WS writer
|
||||
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||
|
||||
// Track session IDs owned by this connection for cleanup
|
||||
let owned_sessions: Arc<tokio::sync::Mutex<Vec<String>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
|
||||
// Writer task: serializes ServerMessages and sends as WS text frames
|
||||
let writer_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = out_rx.recv().await {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
if ws_tx.send(Message::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reader loop: parse incoming messages and dispatch
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
let text = match &msg {
|
||||
Message::Text(t) => t.to_string(),
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let client_msg: ClientMessage = match serde_json::from_str(&text) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
let _ = out_tx.send(ServerMessage::Error {
|
||||
message: format!("Invalid message: {}", e),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match client_msg {
|
||||
ClientMessage::Ping => {
|
||||
let _ = out_tx.send(ServerMessage::Pong);
|
||||
}
|
||||
|
||||
ClientMessage::ListProjects => {
|
||||
let projects = state.projects_store.list();
|
||||
let entries: Vec<ProjectEntry> = projects
|
||||
.into_iter()
|
||||
.map(|p| ProjectEntry {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status: serde_json::to_value(&p.status)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
})
|
||||
.collect();
|
||||
let _ = out_tx.send(ServerMessage::Projects { projects: entries });
|
||||
}
|
||||
|
||||
ClientMessage::Open {
|
||||
project_id,
|
||||
session_type,
|
||||
} => {
|
||||
let result = handle_open(
|
||||
&state,
|
||||
&project_id,
|
||||
session_type.as_deref(),
|
||||
&out_tx,
|
||||
&owned_sessions,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
let _ = out_tx.send(ServerMessage::Error { message: e });
|
||||
}
|
||||
}
|
||||
|
||||
ClientMessage::Input { session_id, data } => {
|
||||
match BASE64.decode(&data) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = state.exec_manager.send_input(&session_id, bytes).await {
|
||||
let _ = out_tx.send(ServerMessage::Error {
|
||||
message: format!("Input error: {}", e),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = out_tx.send(ServerMessage::Error {
|
||||
message: format!("Base64 decode error: {}", e),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClientMessage::Resize {
|
||||
session_id,
|
||||
cols,
|
||||
rows,
|
||||
} => {
|
||||
if let Err(e) = state.exec_manager.resize(&session_id, cols, rows).await {
|
||||
let _ = out_tx.send(ServerMessage::Error {
|
||||
message: format!("Resize error: {}", e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ClientMessage::Close { session_id } => {
|
||||
state.exec_manager.close_session(&session_id).await;
|
||||
// Remove from owned list
|
||||
owned_sessions
|
||||
.lock()
|
||||
.await
|
||||
.retain(|id| id != &session_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection closed — clean up all owned sessions
|
||||
log::info!("Web terminal WebSocket disconnected, cleaning up sessions");
|
||||
let sessions = owned_sessions.lock().await.clone();
|
||||
for session_id in sessions {
|
||||
state.exec_manager.close_session(&session_id).await;
|
||||
}
|
||||
|
||||
writer_handle.abort();
|
||||
}
|
||||
|
||||
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
|
||||
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
let mut cmd = vec!["claude".to_string()];
|
||||
if project.full_permissions {
|
||||
cmd.push("--dangerously-skip-permissions".to_string());
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
let profile = project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
.and_then(|b| b.aws_profile.clone())
|
||||
.or_else(|| settings_store.get().global_aws.aws_profile.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
let claude_cmd = if project.full_permissions {
|
||||
"exec claude --dangerously-skip-permissions"
|
||||
} else {
|
||||
"exec claude"
|
||||
};
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
echo "Validating AWS session for profile '{profile}'..."
|
||||
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "AWS session valid."
|
||||
else
|
||||
echo "AWS session expired or invalid."
|
||||
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
|
||||
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "Starting SSO login..."
|
||||
echo ""
|
||||
triple-c-sso-refresh
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
||||
echo "You may see authentication errors."
|
||||
echo ""
|
||||
fi
|
||||
else
|
||||
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
|
||||
echo "Starting Claude anyway..."
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
{claude_cmd}
|
||||
"#,
|
||||
profile = profile,
|
||||
claude_cmd = claude_cmd
|
||||
);
|
||||
|
||||
vec!["bash".to_string(), "-c".to_string(), script]
|
||||
}
|
||||
|
||||
/// Open a new terminal session for a project.
|
||||
async fn handle_open(
|
||||
state: &WebTerminalState,
|
||||
project_id: &str,
|
||||
session_type: Option<&str>,
|
||||
out_tx: &mpsc::UnboundedSender<ServerMessage>,
|
||||
owned_sessions: &Arc<tokio::sync::Mutex<Vec<String>>>,
|
||||
) -> Result<(), String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
if project.status != ProjectStatus::Running {
|
||||
return Err(format!("Project '{}' is not running", project.name));
|
||||
}
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = match session_type {
|
||||
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||
_ => build_terminal_cmd(&project, &state.settings_store),
|
||||
};
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let project_name = project.name.clone();
|
||||
|
||||
// Set up output routing through the WS channel
|
||||
let out_tx_output = out_tx.clone();
|
||||
let session_id_output = session_id.clone();
|
||||
let on_output = move |data: Vec<u8>| {
|
||||
let encoded = BASE64.encode(&data);
|
||||
let _ = out_tx_output.send(ServerMessage::Output {
|
||||
session_id: session_id_output.clone(),
|
||||
data: encoded,
|
||||
});
|
||||
};
|
||||
|
||||
let out_tx_exit = out_tx.clone();
|
||||
let session_id_exit = session_id.clone();
|
||||
let on_exit = Box::new(move || {
|
||||
let _ = out_tx_exit.send(ServerMessage::Exit {
|
||||
session_id: session_id_exit,
|
||||
});
|
||||
});
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(container_id, &session_id, cmd, on_output, on_exit)
|
||||
.await?;
|
||||
|
||||
// Track this session for cleanup on disconnect
|
||||
owned_sessions.lock().await.push(session_id.clone());
|
||||
|
||||
let _ = out_tx.send(ServerMessage::Opened {
|
||||
session_id,
|
||||
project_name,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import EnvVarsModal from "../projects/EnvVarsModal";
|
||||
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||
import type { EnvVar } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
import WebTerminalSettings from "./WebTerminalSettings";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
@@ -116,6 +117,9 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Web Terminal */}
|
||||
<WebTerminalSettings />
|
||||
|
||||
{/* Updates section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Updates<Tooltip text="Check for new versions of the Triple-C app and container image." /></label>
|
||||
|
||||
128
app/src/components/settings/WebTerminalSettings.tsx
Normal file
128
app/src/components/settings/WebTerminalSettings.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { startWebTerminal, stopWebTerminal, getWebTerminalStatus, regenerateWebTerminalToken } from "../../lib/tauri-commands";
|
||||
import type { WebTerminalInfo } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
export default function WebTerminalSettings() {
|
||||
const [info, setInfo] = useState<WebTerminalInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getWebTerminalStatus().then(setInfo).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (info?.running) {
|
||||
await stopWebTerminal();
|
||||
const updated = await getWebTerminalStatus();
|
||||
setInfo(updated);
|
||||
} else {
|
||||
const updated = await startWebTerminal();
|
||||
setInfo(updated);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Web terminal toggle failed:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
try {
|
||||
const updated = await regenerateWebTerminalToken();
|
||||
setInfo(updated);
|
||||
} catch (e) {
|
||||
console.error("Token regeneration failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
if (info?.url) {
|
||||
await navigator.clipboard.writeText(info.url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (info?.access_token) {
|
||||
await navigator.clipboard.writeText(info.access_token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Web Terminal
|
||||
<Tooltip text="Access your terminals from a tablet or phone on the local network via a web browser." />
|
||||
</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-2">
|
||||
Serves a browser-based terminal UI on your local network for remote access to running projects.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={loading}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||
info?.running
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{loading ? "..." : info?.running ? "ON" : "OFF"}
|
||||
</button>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{info?.running
|
||||
? `Running on port ${info.port}`
|
||||
: "Stopped"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* URL + Copy */}
|
||||
{info?.running && info.url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs text-[var(--accent)] bg-[var(--bg-primary)] px-2 py-1 rounded border border-[var(--border-color)] truncate flex-1">
|
||||
{info.url}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy URL"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token */}
|
||||
{info && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-secondary)]">Token:</span>
|
||||
<code className="text-xs text-[var(--text-primary)] bg-[var(--bg-primary)] px-2 py-0.5 rounded border border-[var(--border-color)] truncate max-w-[160px]">
|
||||
{info.access_token ? `${info.access_token.slice(0, 12)}...` : "None"}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyToken}
|
||||
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
className="text-xs px-2 py-0.5 text-[var(--warning,#f59e0b)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
||||
const isAtBottomRef = useRef(true);
|
||||
// Tracks user intent to follow output — only set to false by explicit user
|
||||
// actions (mouse wheel up), not by xterm scroll events during writes.
|
||||
const autoFollowRef = useRef(true);
|
||||
const lastUserScrollTimeRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -132,6 +137,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
sendInput(sessionId, data);
|
||||
});
|
||||
|
||||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||||
// Captured during capture phase so it fires before xterm's own handler.
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
lastUserScrollTimeRef.current = Date.now();
|
||||
if (e.deltaY < 0) {
|
||||
autoFollowRef.current = false;
|
||||
setIsAutoFollow(false);
|
||||
isAtBottomRef.current = false;
|
||||
setIsAtBottom(false);
|
||||
}
|
||||
};
|
||||
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
|
||||
|
||||
// Track scroll position to show "Jump to Current" button.
|
||||
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
|
||||
let scrollStateRafId: number | null = null;
|
||||
@@ -139,6 +157,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const buf = term.buffer.active;
|
||||
const atBottom = buf.viewportY >= buf.baseY;
|
||||
isAtBottomRef.current = atBottom;
|
||||
|
||||
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
|
||||
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
|
||||
if (atBottom && isUserScroll && !autoFollowRef.current) {
|
||||
autoFollowRef.current = true;
|
||||
setIsAutoFollow(true);
|
||||
}
|
||||
|
||||
if (scrollStateRafId === null) {
|
||||
scrollStateRafId = requestAnimationFrame(() => {
|
||||
scrollStateRafId = null;
|
||||
@@ -198,11 +224,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const outputPromise = onOutput(sessionId, (data) => {
|
||||
if (aborted) return;
|
||||
term.write(data, () => {
|
||||
// Keep viewport pinned to bottom when user hasn't scrolled up.
|
||||
// Check ref at callback time (not capture time) so that a user
|
||||
// scroll-up between the write() call and callback is respected.
|
||||
if (isAtBottomRef.current) {
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
if (!isAtBottomRef.current) {
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
detector.feed(data);
|
||||
@@ -246,11 +273,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = null;
|
||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||
const wasAtBottom = isAtBottomRef.current;
|
||||
fitAddon.fit();
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
// Maintain scroll position after resize reflow
|
||||
if (wasAtBottom) {
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
});
|
||||
@@ -268,6 +293,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
scrollDisposable.dispose();
|
||||
selectionDisposable.dispose();
|
||||
setTerminalHasSelection(false);
|
||||
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
@@ -303,6 +329,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
}
|
||||
fitRef.current?.fit();
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
term.focus();
|
||||
} else {
|
||||
// Release WebGL context for inactive terminals
|
||||
@@ -339,7 +368,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
// Re-fit first to fix viewport desync (same thing a resize does)
|
||||
autoFollowRef.current = true;
|
||||
setIsAutoFollow(true);
|
||||
fitRef.current?.fit();
|
||||
term.scrollToBottom();
|
||||
isAtBottomRef.current = true;
|
||||
@@ -347,6 +377,21 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleAutoFollow = useCallback(() => {
|
||||
const next = !autoFollowRef.current;
|
||||
autoFollowRef.current = next;
|
||||
setIsAutoFollow(next);
|
||||
if (next) {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
fitRef.current?.fit();
|
||||
term.scrollToBottom();
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalContainerRef}
|
||||
@@ -367,6 +412,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
{imagePasteMsg}
|
||||
</div>
|
||||
)}
|
||||
{/* Auto-follow toggle - top right */}
|
||||
<button
|
||||
onClick={handleToggleAutoFollow}
|
||||
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
||||
isAutoFollow
|
||||
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
||||
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
||||
}`}
|
||||
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
||||
>
|
||||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||||
</button>
|
||||
{/* Jump to Current - bottom right, when scrolled up */}
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={handleScrollToBottom}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -88,3 +88,13 @@ export const checkImageUpdate = () =>
|
||||
|
||||
// Help
|
||||
export const getHelpContent = () => invoke<string>("get_help_content");
|
||||
|
||||
// Web Terminal
|
||||
export const startWebTerminal = () =>
|
||||
invoke<WebTerminalInfo>("start_web_terminal");
|
||||
export const stopWebTerminal = () =>
|
||||
invoke<void>("stop_web_terminal");
|
||||
export const getWebTerminalStatus = () =>
|
||||
invoke<WebTerminalInfo>("get_web_terminal_status");
|
||||
export const regenerateWebTerminalToken = () =>
|
||||
invoke<WebTerminalInfo>("regenerate_web_terminal_token");
|
||||
|
||||
@@ -118,6 +118,21 @@ export interface AppSettings {
|
||||
timezone: string | null;
|
||||
default_microphone: string | null;
|
||||
dismissed_image_digest: string | null;
|
||||
web_terminal: WebTerminalSettings;
|
||||
}
|
||||
|
||||
export interface WebTerminalSettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
access_token: string | null;
|
||||
}
|
||||
|
||||
export interface WebTerminalInfo {
|
||||
running: boolean;
|
||||
port: number;
|
||||
access_token: string;
|
||||
local_ip: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
|
||||
Reference in New Issue
Block a user