Compare commits

...

12 Commits

Author SHA1 Message Date
57a7cee544 Make topbar and tab bar sticky in web terminal
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 4m13s
Build App / build-linux (push) Successful in 4m51s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Adds position:sticky to the topbar and tab bar so they stay pinned
at the top when the virtual keyboard opens on tablets. Also uses
100dvh (dynamic viewport height) so the layout properly shrinks
when the keyboard appears on mobile browsers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:15:31 -07:00
6369f7e0a8 Document web terminal feature across all docs
Adds web terminal documentation to README (architecture, key files),
HOW-TO-USE (setup guide, usage, security tips), TECHNICAL (system
diagram, communication flow, dependencies, project structure), and
CLAUDE.md (backend structure).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:09:05 -07:00
9ee0d34c19 Fix tablet keyboard input lag and add scroll-to-bottom button
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 3m55s
Build App / build-linux (push) Successful in 4m34s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 9s
Adds a dedicated input bar at the bottom that bypasses mobile IME
composition buffering — characters are sent immediately on each input
event instead of waiting for word boundaries. Also adds helper buttons
(Enter, Tab, Ctrl+C) and a floating scroll-to-bottom button that
appears when scrolled up from the terminal output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:03:13 -07:00
922543cc04 Add web terminal for remote tablet/phone access to project terminals
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m56s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Adds an axum HTTP+WebSocket server that runs alongside the Tauri app,
serving a standalone xterm.js-based terminal UI accessible from any
browser on the local network. Shares the existing ExecSessionManager
via Arc-wrapped stores, with token-based authentication and automatic
session cleanup on disconnect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:31:16 -07:00
13038989b8 Fix spurious container snapshot on every start for projects with env vars
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 4m7s
Build App / build-linux (push) Successful in 4m58s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Migrate env-var-based checks in container_needs_recreation() to label-based
checks. When a container snapshot is committed, Docker merges the image's env
vars with the container's, causing get_env() to return stale values and
triggering an infinite snapshot→recreate loop. Labels are immutable after
creation and immune to this merging behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:17:00 -07:00
b55de8d75e Fix Jump to Current button not appearing on scroll-up
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 5m9s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 12s
The onScroll RAF optimization (only fire when atBottom changes) prevented
the button from showing because xterm's onScroll may not fire from wheel
events. Fix by setting isAtBottom(false) directly in the wheel handler
and removing the RAF guard to always schedule state updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:28:28 -07:00
8512ca615d Fix terminal scroll glitch and add auto-follow toggle button
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m4s
Build App / build-linux (push) Successful in 4m53s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 11s
Prevent viewport jumping during Claude output by only re-enabling
auto-follow on user-initiated scrolls (wheel events within 300ms),
not on write-triggered xterm scroll events. Add a "Following/Paused"
toggle button in the top-right corner of the terminal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 07:00:09 -07:00
ebae39026f Fix terminal auto-scroll and jump-to-bottom button coexistence
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 2m33s
Build App / build-linux (push) Successful in 6m3s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
The previous fix checked isAtBottomRef inside the write callback, but
xterm's own scroll events during write processing could set the ref to
false (viewport desync), breaking auto-follow entirely.

Introduce a separate autoFollowRef that tracks user intent:
- Set to false only by explicit mouse wheel scroll-up (capture phase)
- Set to true when viewport reaches bottom or user clicks the button
- Write callback uses autoFollowRef so desync doesn't kill auto-follow
  but user scroll-up correctly pauses it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:00:16 -07:00
d34e8e2c6d Fix jump-to-bottom button broken during active terminal output
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m4s
Build App / build-linux (push) Successful in 5m31s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
The shouldFollow flag was captured before term.write() but the callback
ran asynchronously — if the user scrolled up in between, the stale flag
forced the viewport back to bottom, preventing the button from appearing.

Check isAtBottomRef at callback time instead so user scroll-up is respected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:35:58 -07:00
3935104cb5 Fix xterm scroll jump and viewport desync during long output
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Auto-scroll viewport on new output when user is at bottom, debounce
scroll state updates to reduce re-renders, preserve scroll position
across resize reflows, and fix "Jump to Current" button by re-fitting
the terminal to clear viewport desync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 12:59:19 -07:00
b17c759bd6 Fix remaining repo.anhonesthost.net references in user-facing code
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m35s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 14s
- help_commands.rs: fetch HOW-TO-USE.md from GitHub raw instead of Gitea
- DockerSettings.tsx: display GHCR image address in settings UI
- HOW-TO-USE.md: update registry description to ghcr.io

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:31:46 -07:00
bab1df1c57 Switch distribution from Gitea to GitHub/GHCR
All checks were successful
Build App / compute-version (push) Successful in 6s
Build Container / build-container (push) Successful in 1m44s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m52s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Work VPN blocks repo.anhonesthost.net, breaking update checks and image
pulls. Move all user-facing distribution to GitHub (releases API) and
GHCR (container images) while keeping Gitea as the source of truth for
development and CI.

- CI: push container images to GHCR alongside Gitea registry
- App updates: switch releases API to api.github.com, filter by asset
  filename instead of tag suffix for unified releases
- Image updates: switch registry to ghcr.io with anonymous token auth
- Container pull: point REGISTRY_IMAGE to ghcr.io/shadowdao/triple-c-sandbox
- Rename GiteaRelease/GiteaAsset structs to GitHubRelease/GitHubAsset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:55:41 -07:00
26 changed files with 2129 additions and 99 deletions

View File

@@ -36,6 +36,13 @@ jobs:
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: shadowdao
password: ${{ secrets.GH_PAT }}
- name: Build and push container image
uses: docker/build-push-action@v5
with:
@@ -46,5 +53,7 @@ jobs:
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -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/`)

View File

@@ -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)
@@ -70,7 +71,7 @@ Choose an **Image Source**:
| Source | Description | When to Use |
|--------|-------------|-------------|
| **Registry** | Pulls the pre-built image from `repo.anhonesthost.net` | Fastest setup — recommended for most users |
| **Registry** | Pulls the pre-built image from `ghcr.io` | Fastest setup — recommended for most users |
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
@@ -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

View File

@@ -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 |

View File

@@ -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
View File

@@ -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"

View File

@@ -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 = [] }

View File

@@ -2,7 +2,7 @@ use std::sync::OnceLock;
use tokio::sync::Mutex;
const HELP_URL: &str =
"https://repo.anhonesthost.net/cybercovellc/triple-c/raw/branch/main/HOW-TO-USE.md";
"https://raw.githubusercontent.com/shadowdao/triple-c/main/HOW-TO-USE.md";
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");

View File

@@ -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;

View File

@@ -1,15 +1,20 @@
use serde::Deserialize;
use tauri::State;
use crate::docker;
use crate::models::{container_config, GiteaRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
use crate::models::{container_config, GitHubRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
use crate::AppState;
const RELEASES_URL: &str =
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
"https://api.github.com/repos/shadowdao/triple-c/releases";
/// Gitea container-registry tag object (v2 manifest).
/// GHCR container-registry API base (OCI distribution spec).
const REGISTRY_API_BASE: &str =
"https://repo.anhonesthost.net/v2/cybercovellc/triple-c/triple-c-sandbox";
"https://ghcr.io/v2/shadowdao/triple-c-sandbox";
/// GHCR token endpoint for anonymous pull access.
const GHCR_TOKEN_URL: &str =
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
#[tauri::command]
pub fn get_app_version() -> String {
@@ -23,9 +28,10 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let releases: Vec<GiteaRelease> = client
let releases: Vec<GitHubRelease> = client
.get(RELEASES_URL)
.header("Accept", "application/json")
.header("User-Agent", "triple-c-updater")
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?
@@ -36,30 +42,27 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
let current_version = env!("CARGO_PKG_VERSION");
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
// Determine platform suffix for tag filtering
let platform_suffix: &str = if cfg!(target_os = "windows") {
"-win"
// Determine platform-specific asset extensions
let platform_extensions: &[&str] = if cfg!(target_os = "windows") {
&[".msi", ".exe"]
} else if cfg!(target_os = "macos") {
"-mac"
&[".dmg", ".app.tar.gz"]
} else {
"" // Linux uses bare tags (no suffix)
&[".AppImage", ".deb", ".rpm"]
};
// Filter releases by platform tag suffix
let platform_releases: Vec<&GiteaRelease> = releases
// Filter releases that have at least one asset matching the current platform
let platform_releases: Vec<&GitHubRelease> = releases
.iter()
.filter(|r| {
if platform_suffix.is_empty() {
// Linux: bare tag only (no -win, no -mac)
!r.tag_name.ends_with("-win") && !r.tag_name.ends_with("-mac")
} else {
r.tag_name.ends_with(platform_suffix)
}
r.assets.iter().any(|a| {
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
})
})
.collect();
// Find the latest release with a higher semver version
let mut best: Option<(&GiteaRelease, (u32, u32, u32))> = None;
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
for release in &platform_releases {
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
if ver > current_semver {
@@ -72,9 +75,13 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
match best {
Some((release, _)) => {
// Only include assets matching the current platform
let assets = release
.assets
.iter()
.filter(|a| {
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
})
.map(|a| ReleaseAsset {
name: a.name.clone(),
browser_download_url: a.browser_download_url.clone(),
@@ -82,7 +89,6 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
})
.collect();
// Reconstruct version string from tag
let version = extract_version_from_tag(&release.tag_name)
.unwrap_or_else(|| release.tag_name.clone());
@@ -113,17 +119,13 @@ fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
}
}
/// Parse semver from a tag like "v0.2.5", "v0.2.5-win", "v0.2.5-mac" -> (0, 2, 5)
/// Parse semver from a tag like "v0.2.5" -> (0, 2, 5)
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
let clean = tag.trim_start_matches('v');
// Remove platform suffix
let clean = clean.strip_suffix("-win")
.or_else(|| clean.strip_suffix("-mac"))
.unwrap_or(clean);
parse_semver(clean)
}
/// Extract a clean version string from a tag like "v0.2.5-win" -> "0.2.5"
/// Extract a clean version string from a tag like "v0.2.5" -> "0.2.5"
fn extract_version_from_tag(tag: &str) -> Option<String> {
let (major, minor, patch) = parse_semver_from_tag(tag)?;
Some(format!("{}.{}.{}", major, minor, patch))
@@ -152,7 +154,7 @@ pub async fn check_image_update(
// 1. Get local image digest via Docker
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
// 2. Get remote digest from the Gitea container registry (OCI distribution spec)
// 2. Get remote digest from the GHCR container registry (OCI distribution spec)
let remote_digest = fetch_remote_digest("latest").await?;
// No remote digest available — nothing to compare
@@ -176,25 +178,36 @@ pub async fn check_image_update(
}))
}
/// Fetch the digest of a tag from the Gitea container registry using the
/// OCI / Docker Registry HTTP API v2.
/// Fetch the digest of a tag from GHCR using the OCI / Docker Registry HTTP API v2.
///
/// We issue a HEAD request to /v2/<repo>/manifests/<tag> and read the
/// `Docker-Content-Digest` header that the registry returns.
/// GHCR requires authentication even for public images, so we first obtain an
/// anonymous token, then issue a HEAD request to /v2/<repo>/manifests/<tag>
/// and read the `Docker-Content-Digest` header.
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// 1. Obtain anonymous bearer token from GHCR
let token = match fetch_ghcr_token(&client).await {
Ok(t) => t,
Err(e) => {
log::warn!("Failed to obtain GHCR token: {}", e);
return Ok(None);
}
};
// 2. HEAD the manifest with the token
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let response = client
.head(&url)
.header(
"Accept",
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
)
.header("Authorization", format!("Bearer {}", token))
.send()
.await;
@@ -221,3 +234,23 @@ async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
}
}
}
/// Fetch an anonymous bearer token from GHCR for pulling public images.
async fn fetch_ghcr_token(client: &reqwest::Client) -> Result<String, String> {
#[derive(Deserialize)]
struct TokenResponse {
token: String,
}
let resp: TokenResponse = client
.get(GHCR_TOKEN_URL)
.header("User-Agent", "triple-c-updater")
.send()
.await
.map_err(|e| format!("GHCR token request failed: {}", e))?
.json()
.await
.map_err(|e| format!("Failed to parse GHCR token response: {}", e))?;
Ok(resp.token)
}

View 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))
}

View File

@@ -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);
}

View File

@@ -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");

View File

@@ -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(),
}
}
}

View File

@@ -12,7 +12,7 @@ pub struct ContainerInfo {
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
pub const IMAGE_TAG: &str = "latest";
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
pub const REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-sandbox:latest";
pub fn local_build_image_name() -> String {
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")

View File

@@ -18,19 +18,19 @@ pub struct ReleaseAsset {
pub size: u64,
}
/// Gitea API release response (internal).
/// GitHub API release response (internal).
#[derive(Debug, Clone, Deserialize)]
pub struct GiteaRelease {
pub struct GitHubRelease {
pub tag_name: String,
pub html_url: String,
pub body: String,
pub assets: Vec<GiteaAsset>,
pub assets: Vec<GitHubAsset>,
pub published_at: String,
}
/// Gitea API asset response (internal).
/// GitHub API asset response (internal).
#[derive(Debug, Clone, Deserialize)]
pub struct GiteaAsset {
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,

View File

@@ -0,0 +1,4 @@
pub mod server;
mod ws_handler;
pub use server::WebTerminalServer;

View 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()
}

View 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">&#8595;</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>

View 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(())
}

View File

@@ -4,7 +4,7 @@ import { useSettings } from "../../hooks/useSettings";
import type { ImageSource } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
const REGISTRY_IMAGE = "ghcr.io/shadowdao/triple-c-sandbox:latest";
const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [
{ value: "registry", label: "Registry", description: "Pull from container registry" },

View File

@@ -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>

View 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>
);
}

View File

@@ -35,6 +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;
@@ -131,10 +137,40 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data);
});
// Track scroll position to show "Jump to Current" button
// 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;
const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active;
setIsAtBottom(buf.viewportY >= buf.baseY);
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;
setIsAtBottom(isAtBottomRef.current);
});
}
});
// Track text selection to show copy hint in status bar
@@ -187,7 +223,15 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data);
term.write(data, () => {
if (autoFollowRef.current) {
term.scrollToBottom();
if (!isAtBottomRef.current) {
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}
});
detector.feed(data);
// Scan for SSO refresh marker in terminal output
@@ -231,6 +275,9 @@ export default function TerminalView({ sessionId, active }: Props) {
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
fitAddon.fit();
resize(sessionId, term.cols, term.rows);
if (autoFollowRef.current) {
term.scrollToBottom();
}
});
});
resizeObserver.observe(containerRef.current);
@@ -246,9 +293,11 @@ 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?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -280,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
@@ -314,8 +366,30 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [detectedUrl]);
const handleScrollToBottom = useCallback(() => {
termRef.current?.scrollToBottom();
const term = termRef.current;
if (term) {
autoFollowRef.current = true;
setIsAutoFollow(true);
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}, []);
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 (
@@ -338,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}

View File

@@ -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");

View File

@@ -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 {