Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f990dd28b | ||
|
|
4df59da2d8 | ||
|
|
a72406f0d8 | ||
|
|
9b2f4fe79f | ||
|
|
e9ec2f8e26 | ||
|
|
fa82d54afa | ||
|
|
4c962ebd9c | ||
|
|
d15faa923b |
@@ -1,7 +1,33 @@
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="branding/triple-c-lockup-dark.svg">
|
||||
<img src="branding/triple-c-lockup-light.svg" alt="Triple-C — Coding Container" width="429" height="112">
|
||||
</picture>
|
||||
|
||||
# Triple-C (Claude-Code-Container)
|
||||
|
||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
||||
|
||||
This file is the architectural tour: what each subsystem is and why it works the way it does.
|
||||
|
||||
| Document | For |
|
||||
|---|---|
|
||||
| [HOW-TO-USE.md](HOW-TO-USE.md) | Using the app — first launch, projects, settings, troubleshooting |
|
||||
| [BUILDING.md](BUILDING.md) | Building from source on Linux, macOS and Windows |
|
||||
| [TECHNICAL.md](TECHNICAL.md) | Technology choices and the dependency inventory |
|
||||
| [ROADMAP.md](ROADMAP.md) | Claude Code feature parity, gaps and sequencing |
|
||||
| [CLAUDE.md](CLAUDE.md) | Working *on* this repo, for Claude Code |
|
||||
| [branding/](branding/README.md) | The mark, the palette, and how the icons are generated |
|
||||
|
||||
## Contents
|
||||
|
||||
- [Architecture](#architecture) — layout, tabs, shortcuts, Project Home
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view
|
||||
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
||||
@@ -29,6 +55,13 @@ two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a termi
|
||||
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||
thing is current at a time.
|
||||
|
||||
Tabs are user-reorderable — drag one, or move the active tab with `Ctrl+Shift+←/→`. A tab's
|
||||
position is therefore never its identity: tabs are addressed by key, and indexed only through
|
||||
`tabOrder`. The drag is built on pointer events rather than HTML5 drag-and-drop, deliberately:
|
||||
Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||
switched off because `TerminalView` needs Tauri's native drag-drop event — the only one that
|
||||
carries dropped *file paths*.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||
@@ -39,31 +72,34 @@ Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||
| `Ctrl+Shift+W` | Close the active tab |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
||||
| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Move the active tab left / right |
|
||||
|
||||
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||
terminal this app is built around. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
||||
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||
why moving a tab takes Shift as well. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
||||
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
||||
|
||||
### Project Home
|
||||
|
||||
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
||||
view, with tabs **Overview · Sessions · Automation · Config · Files**. The sidebar row itself is
|
||||
select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration.
|
||||
Per-project configuration lives in the Config tab rather than in modals.
|
||||
view, with tabs **Overview · Sessions · Automation · Config · Files · Browser**. The sidebar row
|
||||
itself is select-only (plus hover controls for start/stop and opening a terminal); it holds no
|
||||
configuration. Per-project configuration lives in the Config tab rather than in modals.
|
||||
|
||||
| Tab | Contents |
|
||||
|---|---|
|
||||
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks |
|
||||
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks, base-image staleness banner |
|
||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||
|
||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||
progress modal.
|
||||
|
||||
### Permission Modes
|
||||
## Permission Modes
|
||||
|
||||
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||
mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||
@@ -87,15 +123,196 @@ back into flags for its headless `claude -p` run. Because it travels as containe
|
||||
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||
forces that).
|
||||
|
||||
### Container Introspection (Capability Tiles)
|
||||
## Containers
|
||||
|
||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||
### Container Lifecycle
|
||||
|
||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, installs any CA certificates, injects Claude Code settings, rebuilds the scheduler crontab
|
||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
|
||||
|
||||
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
|
||||
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
|
||||
clears those after a recreation and after a migration is accepted. It only ever removes images that
|
||||
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
|
||||
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
|
||||
Docker itself refuses while any container — including a stopped project's — is still built from the
|
||||
image.
|
||||
7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||
|
||||
### Base-Image Migration
|
||||
|
||||
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||
every recreation re-commits it. So without an explicit act, a project stays on the base image it was
|
||||
first built from **forever** — it never picks up a new `/usr/local/bin` shim, a new `socat`, or a
|
||||
security update. **Update container base…** (Project Home → overflow menu) is the non-destructive
|
||||
way out; Reset is the destructive one. `docker/migration.rs` owns it.
|
||||
|
||||
- **Staleness is surfaced, not acted on.** `triple-c.base-image-id` records the lineage and
|
||||
`get_container_staleness` reports it as a banner, but it is deliberately *not* compared in
|
||||
`container_needs_recreation`. Comparing it there would recreate every project *from its own
|
||||
snapshot* on the next base bump: churn on the old base, and the "you should migrate" signal
|
||||
consumed without migrating. A missing lineage label means "unknown, probe instead" — never
|
||||
"stale".
|
||||
- **What comes across**: the apt package delta and user-authored files, computed by diffing two
|
||||
filesystem manifests through dpkg ownership and presence-in-the-new-base. (`docker diff` is
|
||||
useless here — on a snapshot-derived container it only reports changes since the last commit.
|
||||
Measured on a real project, manifest diffing turned 8,677 raw path differences into 2 genuinely
|
||||
user-authored ones.) Both named volumes are untouched at every step, so `$HOME`, the OAuth login,
|
||||
skills, transcripts and scheduler tasks simply re-attach.
|
||||
- **What does not**: `/etc` is reported but never copied — the old lineage has
|
||||
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||
having both breaks every `apt-get update`. `/var` is not copied either, and that is the one way
|
||||
migration is *more* destructive than an ordinary recreate: a database under `/var/lib` rides along
|
||||
on a recreate, but a migration builds from the base and the apt replay hands back an empty
|
||||
cluster. `unpreserved_data()` names those directories in the pre-flight, the banner and the final
|
||||
report.
|
||||
- **Crash-safety**: `:latest` keeps pointing at the old lineage until the final commit, so any
|
||||
failure before that self-heals — the next start just recreates from the old snapshot. After the
|
||||
container swap, a `triple-c.migration-state=in-progress` label plus a persisted state file let the
|
||||
app offer **resume** or **rollback**. Rollback restores the system layer only; work done in
|
||||
`$HOME` during a migrated session survives it.
|
||||
|
||||
### Mounts
|
||||
|
||||
| Target in Container | Source | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
|
||||
| `/tmp/.host-ca` | CA certificate file or directory | Bind | Read-only; entrypoint installs into the system and NSS stores |
|
||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||
|
||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||
removal, and by nothing else.
|
||||
|
||||
### Corporate CA Certificates
|
||||
|
||||
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
|
||||
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
|
||||
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
|
||||
every start — so it survives recreation, migration and Reset.
|
||||
|
||||
- **Certificates are renamed to `.crt`.** `update-ca-certificates` globs `*.crt`, case-sensitively;
|
||||
a `.pem` merely copied into `/usr/local/share/ca-certificates/` is ignored in total silence.
|
||||
`container_cert_name()` in Rust does the renaming, mirrored in a few lines of shell in the
|
||||
entrypoint. A single-file mount lands at `/tmp/.host-ca/<name>.crt`, so the entrypoint only ever
|
||||
sees a directory.
|
||||
- **The system store is not enough.** Only curl, git and apt read it. Node — and therefore Claude
|
||||
Code itself — needs `NODE_EXTRA_CA_CERTS`; Python and requests need
|
||||
`REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`; Chromium reads neither and wants its own NSS database at
|
||||
`~/.pki/nssdb`, seeded with `certutil` (from `libnss3-tools`). The NSS step warns and continues
|
||||
rather than failing the start.
|
||||
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
|
||||
session is a `docker exec`, which inherits the container's configured env and sees nothing the
|
||||
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. They are emitted
|
||||
**empty** when no CA is configured, because `docker commit` bakes env into the snapshot image.
|
||||
- **`triple-c.ca-fingerprint` covers the certificate bytes, not the path.** Replacing a rotated CA
|
||||
at the same location still forces the recreation that copies it in. Clearing the setting actively
|
||||
**removes** `triple-c-*.crt` from the container — `/usr/local/share` rides the project's snapshot,
|
||||
so turning the feature off has to undo, not merely stop.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||
|
||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
- **Linux/macOS**: `/var/run/docker.sock`
|
||||
- **Windows**: `//./pipe/docker_engine`
|
||||
|
||||
Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
## Models and Authentication
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
Each project can independently use one of:
|
||||
|
||||
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
|
||||
|
||||
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||
> they get first-class backends of their own rather than going through a translation layer.
|
||||
|
||||
#### Model alias variables
|
||||
|
||||
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||
|
||||
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||
Triple-C therefore sets all four:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||
|
||||
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||
|
||||
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||
|
||||
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Model Gateway (LiteLLM sibling container)
|
||||
|
||||
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
|
||||
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
|
||||
requires. Settings → Gateway configures the provider prefix (`openai`, `azure`, `gemini`, `groq`,
|
||||
…), an optional API base override, the models to serve, and the host port (default `4000`). A
|
||||
project then consumes it with the OpenAI Compatible backend. It mirrors the STT container's
|
||||
lifecycle, including auto-start with the app.
|
||||
|
||||
Its bind address is **detected, never `0.0.0.0`**. Unlike STT, the consumers are *project
|
||||
containers*, so loopback alone is not always enough: Docker Desktop binds `127.0.0.1` and advertises
|
||||
`host.docker.internal`; native Linux binds the default bridge gateway (`172.17.0.1`) and advertises
|
||||
the same literal. `GatewayBinding` derives the bind address and the advertised `base_url` together
|
||||
so the two cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host
|
||||
firewalls — in front of a config file holding a billed provider key. A LiteLLM `master_key` is
|
||||
**always** set, because LiteLLM without one accepts any key.
|
||||
|
||||
### Shared Claude Authentication Token
|
||||
|
||||
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||
anything resembling an `sk-ant-` secret.
|
||||
|
||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||
|
||||
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||
it cleared.
|
||||
|
||||
## Bridges to the Host
|
||||
|
||||
### URL Relay (host browser)
|
||||
|
||||
@@ -175,96 +392,54 @@ recreates the container. The poller stops on its own when the container stops.
|
||||
unauthenticated service inside the container, so widening those addresses would publish container
|
||||
internals to the LAN. Nothing else on the network can reach a bridged port.
|
||||
|
||||
### Shared Claude Authentication Token
|
||||
### Browser View
|
||||
|
||||
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||
anything resembling an `sk-ant-` secret.
|
||||
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
|
||||
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
|
||||
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
|
||||
per project.
|
||||
|
||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
|
||||
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
|
||||
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
|
||||
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
|
||||
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
|
||||
another monitor or pinned on top while the main window is used for work. No capability lists that
|
||||
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
|
||||
top-level document rather than a frame — the token gate is what protects the port in both cases.
|
||||
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
|
||||
iframe while popped out, and both viewers can drive the browser.
|
||||
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
|
||||
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
|
||||
listener is *in* the container, so a container-side browser closes the loop with no host round
|
||||
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
|
||||
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
|
||||
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
|
||||
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
|
||||
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
|
||||
continuously and each event costs a container exec.
|
||||
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules` —
|
||||
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
|
||||
hops from a wrapper `playwright` to its **nested** `playwright-core`, because npm does not hoist
|
||||
for global installs and the wrapper ships no type definitions to read a version from. Installing
|
||||
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
|
||||
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
|
||||
recreation *and* migration.
|
||||
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
|
||||
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||
once Playwright is present — not a setup route.
|
||||
|
||||
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||
it cleared.
|
||||
## Inside a Project
|
||||
|
||||
### Container Lifecycle
|
||||
### Container Introspection (Capability Tiles)
|
||||
|
||||
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab
|
||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||
6. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||
|
||||
### Mounts
|
||||
|
||||
| Target in Container | Source | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||
|
||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||
removal, and by nothing else.
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
Each project can independently use one of:
|
||||
|
||||
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
||||
|
||||
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||
> they get first-class backends of their own rather than going through a translation layer.
|
||||
|
||||
#### Model alias variables
|
||||
|
||||
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||
|
||||
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||
Triple-C therefore sets all four:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||
|
||||
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||
|
||||
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||
|
||||
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||
|
||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||
|
||||
### Mission Control Integration
|
||||
|
||||
@@ -289,87 +464,117 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||
- **Port**: Default `9876` (configurable)
|
||||
- **Input device**: Selectable in Settings when the host exposes more than one microphone
|
||||
- **Language**: Optional language hint for transcription
|
||||
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
||||
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
||||
|
||||
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
- **Linux/macOS**: `/var/run/docker.sock`
|
||||
- **Windows**: `//./pipe/docker_engine`
|
||||
|
||||
Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
## Key Files
|
||||
|
||||
### Frontend — layout and projects
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
||||
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs) |
|
||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
||||
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
|
||||
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||
|
||||
### Frontend — Project Home
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
||||
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
||||
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
||||
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9` |
|
||||
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, shared auth, and global settings |
|
||||
|
||||
### Frontend — settings, terminal and hooks
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, certificates, gateway, web terminal, STT, shared auth and global settings |
|
||||
| `app/src/components/settings/CertificateSettings.tsx` | Corporate CA certificate path (global), with `CaCertPathInput` |
|
||||
| `app/src/components/settings/GatewaySettings.tsx` | LiteLLM gateway: provider, API base, models, port, container controls |
|
||||
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
|
||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, device, container controls) |
|
||||
| `app/src/components/settings/UpdateDialog.tsx` | New-release notice with download links (`update_commands.rs`) |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, OSC 7777 URL relay, image paste |
|
||||
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||
| `app/src-tauri/src/docker/gateway.rs` | LiteLLM sibling container: binding detection, config rendering, lifecycle |
|
||||
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
||||
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
||||
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
||||
| `app/src-tauri/src/browser_view/` | Browser view: `detect.rs`, `install.rs`, `page.rs`, `popout.rs`, `proxy.rs`, `commands.rs` |
|
||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||
| `app/src-tauri/src/commands/migration_commands.rs` | Staleness, migrate, confirm, rollback, reconcile, `is_migrating` |
|
||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, CA path, Claude Code settings, web terminal, STT, gateway) |
|
||||
| `app/src-tauri/src/models/gateway_settings.rs` | Gateway provider, models, port and API base |
|
||||
| `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/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
|
||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||
| `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, Claude Code settings injection, Mission Control setup |
|
||||
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, gateway keys, rotation id) |
|
||||
|
||||
### Container and packaging
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims + browser runtime libraries |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, CA installation, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
||||
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, rotation id) |
|
||||
| `gateway-container/` | LiteLLM image and rendered `config.yaml` for the model gateway |
|
||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||
| `branding/` | Logo sources, palette, and `build-icons.py`, which generates every packaged icon |
|
||||
|
||||
## CSS / Styling Notes
|
||||
|
||||
@@ -382,7 +587,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
**Base**: Ubuntu 24.04
|
||||
|
||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential, `libnss3-tools` (for `certutil`, used to seed Chromium's CA store)
|
||||
|
||||
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
|
||||
|
||||
@@ -406,4 +611,10 @@ The libraries are the opposite — a runtime `apt-get install` lands in the cont
|
||||
layer, is re-paid after every Reset, and is lost on migration (which replays apt from a manifest
|
||||
against the new base). Baking one and not the other puts each half where it already persists.
|
||||
|
||||
**`/home/claude` in the image is seed-only.** It is the mount point of the `triple-c-home-{projectId}`
|
||||
volume, so after a project's *first* start the image's copy of that directory is masked permanently.
|
||||
A change made under `/home/claude` in the Dockerfile reaches **new projects only** — with or without
|
||||
a base-image migration. Anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or
|
||||
must be seeded by `entrypoint.sh` on every start.
|
||||
|
||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Triple-C</title>
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||
<title>Triple-C application icon, small-size variant</title>
|
||||
<!-- Source for every raster ≤ 32 px: the small mark, drawn at 82% so the strokes
|
||||
survive being resampled down to 16 px. See build-icons.py. -->
|
||||
<rect width="128" height="128" rx="28.16" fill="#0D1117"/>
|
||||
<g transform="translate(2.9236 2.9236) scale(0.95418)">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M112 50 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 78"
|
||||
stroke="#58A6FF" stroke-width="14"/>
|
||||
<path d="M46 50 L62 66 L46 82" stroke="#F0821E" stroke-width="13"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 812 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 16 KiB |
@@ -833,6 +833,16 @@ pub async fn confirm_migration(
|
||||
migration_store::clear_staging(&project_id)?;
|
||||
migration_store::clear(&project_id)?;
|
||||
log::info!("Migration confirmed for project {}", project_id);
|
||||
|
||||
// Dropping the pin above is what turns the pre-migration image into an
|
||||
// orphan: it was the only tag holding a multi-gigabyte pre-migration
|
||||
// snapshot. Accepting the update is therefore the moment to sweep, and
|
||||
// waiting for the project's next recreation would leave it lying around
|
||||
// indefinitely.
|
||||
tauri::async_runtime::spawn(async {
|
||||
crate::docker::sweep_orphaned_snapshots().await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -450,6 +450,18 @@ pub async fn start_project_container(
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
|
||||
// The commit above moved `:latest` and orphaned the image it
|
||||
// used to point at; the container holding that image open was
|
||||
// removed a few lines up, so now is when Docker will actually
|
||||
// let it go. Detached because this is housekeeping and the
|
||||
// project is already running — and it sweeps every orphan, not
|
||||
// just this one, so recreations that happened before the sweep
|
||||
// existed are cleaned up too.
|
||||
tauri::async_runtime::spawn(async {
|
||||
docker::sweep_orphaned_snapshots().await;
|
||||
});
|
||||
|
||||
new_id
|
||||
} else {
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
|
||||
@@ -211,6 +211,12 @@ pub const SECRET_ENV_KEYS: &[&str] = &[
|
||||
];
|
||||
|
||||
/// Env var name prefixes Triple-C manages itself; users cannot set these by hand.
|
||||
/// The label every container Triple-C creates carries — and, because
|
||||
/// `docker commit` copies a container's labels onto the image, every snapshot it
|
||||
/// commits. [`sweep_orphaned_snapshots`] treats it as the mark of provenance,
|
||||
/// which is what keeps the sweep away from the user's own images.
|
||||
const LABEL_MANAGED: &str = "triple-c.managed";
|
||||
|
||||
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||
|
||||
/// Exact env var names Triple-C manages itself. Not covered by
|
||||
@@ -318,8 +324,19 @@ fn is_reserved_env_key(key: &str) -> bool {
|
||||
|| RESERVED_ENV_EXACT.iter().any(|e| upper == *e)
|
||||
}
|
||||
|
||||
/// Compute a fingerprint string for the custom environment variables.
|
||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||
/// Compute a fingerprint for the custom environment variables.
|
||||
///
|
||||
/// Sorted alphabetically so order changes do not cause spurious recreation, and
|
||||
/// **hashed**, because this value is written as the
|
||||
/// `triple-c.custom-env-fingerprint` label. Labels are readable by anything on
|
||||
/// the host through `docker inspect`, `docker commit` copies them onto the
|
||||
/// project's snapshot image, and `container_needs_recreation` logs both sides on
|
||||
/// a mismatch — so a plaintext `KEY=VALUE` join published every custom
|
||||
/// variable's *value*, API tokens included, to all three places. Same treatment
|
||||
/// as `triple-c.git-token-hash`.
|
||||
///
|
||||
/// Empty stays empty rather than becoming the hash of the empty string: an empty
|
||||
/// label is how every other `triple-c.*` key says "nothing configured".
|
||||
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for env_var in custom_env_vars {
|
||||
@@ -330,7 +347,10 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||
parts.push(format!("{}={}", key, env_var.value));
|
||||
}
|
||||
parts.sort();
|
||||
parts.join(",")
|
||||
if parts.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
sha256_hex(&parts.join(","))
|
||||
}
|
||||
|
||||
/// The shared Claude Code OAuth token to inject for this project, paired with
|
||||
@@ -1341,7 +1361,7 @@ pub async fn create_container(
|
||||
}
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||
labels.insert(LABEL_MANAGED.to_string(), "true".to_string());
|
||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
||||
labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend));
|
||||
@@ -1689,6 +1709,128 @@ fn env_holds_a_secret(env: &[String]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Outcome of [`sweep_orphaned_snapshots`].
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
pub struct SnapshotSweepReport {
|
||||
/// Image ids that were removed.
|
||||
pub removed: Vec<String>,
|
||||
/// Bytes the removed images accounted for, as Docker reported them. A
|
||||
/// shared-layer estimate, not a disk-usage measurement.
|
||||
pub reclaimed_bytes: i64,
|
||||
/// Orphans Docker refused to delete because a container is still built
|
||||
/// from them. Normal, not a failure — the next sweep gets them.
|
||||
pub in_use: usize,
|
||||
/// Orphans that could not be removed for any other reason, with the error.
|
||||
pub failed: Vec<(String, String)>,
|
||||
/// Set when the engine could not be reached or listed at all.
|
||||
pub unavailable: Option<String>,
|
||||
}
|
||||
|
||||
/// The filter every sweep runs under. Extracted so a test can hold the two
|
||||
/// conditions in place: **dangling** and **labelled as ours**. Losing either
|
||||
/// one turns a snapshot sweep into a prune of the user's whole image store.
|
||||
fn orphan_sweep_filters() -> HashMap<String, Vec<String>> {
|
||||
HashMap::from([
|
||||
("dangling".to_string(), vec!["true".to_string()]),
|
||||
(
|
||||
"label".to_string(),
|
||||
vec![format!("{}=true", LABEL_MANAGED)],
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
/// Remove the untagged snapshot commits left behind by recreation.
|
||||
///
|
||||
/// Every recreation commits the container to `triple-c-snapshot-{id}:latest`
|
||||
/// and moves that tag; the image the tag pointed at before keeps its layers and
|
||||
/// loses its name. Nothing else deletes those, so a project that has been
|
||||
/// recreated a dozen times leaves a dozen multi-gigabyte orphans behind.
|
||||
///
|
||||
/// Two conditions, and the safety of this whole function rests on them:
|
||||
///
|
||||
/// * **Dangling** — untagged. Every image the app relies on carries a tag:
|
||||
/// `triple-c-snapshot-{id}:latest` is what a project is rebuilt from, and a
|
||||
/// migration's `pre-migration-*` pin is the only copy of a rollback target.
|
||||
/// Neither can ever match this filter, so neither can be swept.
|
||||
/// * **`triple-c.managed=true`** — only images Triple-C itself committed.
|
||||
/// `docker commit` copies the container's labels onto the image, which is what
|
||||
/// makes the label a reliable mark of provenance. The user's own dangling
|
||||
/// images are none of our business.
|
||||
///
|
||||
/// Removal is not forced, so Docker refuses (409) while any container is still
|
||||
/// built from the image — including the stopped containers of projects that are
|
||||
/// not running. That refusal is the third safety net and it is the daemon's,
|
||||
/// not ours; those orphans are simply counted and left for a later sweep.
|
||||
///
|
||||
/// Never fails the caller: this is housekeeping, and a full disk is a better
|
||||
/// outcome than a project that will not start.
|
||||
pub async fn sweep_orphaned_snapshots() -> SnapshotSweepReport {
|
||||
use bollard::image::ListImagesOptions;
|
||||
|
||||
let mut report = SnapshotSweepReport::default();
|
||||
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
report.unavailable = Some(e);
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
let images = match docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
all: false,
|
||||
filters: orphan_sweep_filters(),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
report.unavailable = Some(format!("Could not list orphaned snapshots: {}", e));
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
for summary in images {
|
||||
match docker
|
||||
.remove_image(
|
||||
&summary.id,
|
||||
Some(RemoveImageOptions {
|
||||
force: false,
|
||||
noprune: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
report.reclaimed_bytes += summary.size;
|
||||
report.removed.push(summary.id);
|
||||
}
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 409, ..
|
||||
}) => {
|
||||
report.in_use += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
report.failed.push((summary.id, e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !report.removed.is_empty() || report.in_use > 0 {
|
||||
log::info!(
|
||||
"Snapshot sweep: removed {} orphan(s) ({:.2} GB), {} still in use by a container",
|
||||
report.removed.len(),
|
||||
report.reclaimed_bytes as f64 / 1_073_741_824.0,
|
||||
report.in_use
|
||||
);
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
/// Outcome of [`scrub_secrets_from_snapshots`], so callers can tell the user
|
||||
/// what actually happened rather than guessing.
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
@@ -2352,7 +2494,7 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
if let Some(labels) = &c.labels {
|
||||
!labels.contains_key("triple-c.managed")
|
||||
!labels.contains_key(LABEL_MANAGED)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
@@ -2473,6 +2615,45 @@ mod tests {
|
||||
assert_eq!(fp, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_orphan_sweep_only_ever_looks_at_our_own_untagged_images() {
|
||||
// Both conditions are load-bearing. Without `dangling` the sweep would
|
||||
// match `triple-c-snapshot-{id}:latest` — what every project is rebuilt
|
||||
// from — and a migration's `pre-migration-*` pin, which is the only copy
|
||||
// of a rollback target. Without the label it would match every dangling
|
||||
// image on the user's machine.
|
||||
let filters = orphan_sweep_filters();
|
||||
assert_eq!(filters.get("dangling"), Some(&vec!["true".to_string()]));
|
||||
assert_eq!(
|
||||
filters.get("label"),
|
||||
Some(&vec!["triple-c.managed=true".to_string()])
|
||||
);
|
||||
assert_eq!(filters.len(), 2, "an extra filter widens or narrows the sweep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_custom_env_fingerprint_never_carries_the_value() {
|
||||
// It goes into `triple-c.custom-env-fingerprint`, which `docker inspect`
|
||||
// hands to anything on the host, `docker commit` copies onto the
|
||||
// project's snapshot image, and the recreation check logs on a mismatch.
|
||||
let secret = "33da01c1b320644920c20d6b5e0a1c6b3c3451c2";
|
||||
let fp = compute_env_fingerprint(&[EnvVar {
|
||||
key: "TEA_TOKEN".to_string(),
|
||||
value: secret.to_string(),
|
||||
}]);
|
||||
assert!(!fp.contains(secret), "fingerprint leaked the value: {}", fp);
|
||||
assert!(!fp.contains("TEA_TOKEN"), "fingerprint leaked the key: {}", fp);
|
||||
assert_eq!(fp.len(), 64, "expected a sha256 hex digest, got {:?}", fp);
|
||||
|
||||
// It still has to move when the value does, or a rotated token would
|
||||
// never reach the container.
|
||||
let rotated = compute_env_fingerprint(&[EnvVar {
|
||||
key: "TEA_TOKEN".to_string(),
|
||||
value: "rotated".to_string(),
|
||||
}]);
|
||||
assert_ne!(fp, rotated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_deprecated_small_fast_model_var_is_never_emitted() {
|
||||
let rendered: Vec<String> = aliases(Some("m"), Some("h"))
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.png"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -43,8 +43,15 @@ export default function EnvVarsEditor({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The row's widths live on wrapper divs, not on the inputs. `inputClass`
|
||||
carries `w-full`, and a width utility on the input itself does not beat
|
||||
it — class-attribute order is not what resolves the conflict, stylesheet
|
||||
order is. Sizing the key input directly left it asking for the whole row
|
||||
and collapsed the value input, whose `flex-1` basis of 0 gave it only the
|
||||
leftover space, to an unusable sliver. */}
|
||||
{vars.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<div className="w-2/5 shrink-0">
|
||||
<input
|
||||
value={ev.key}
|
||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||
@@ -52,8 +59,10 @@ export default function EnvVarsEditor({
|
||||
placeholder="KEY"
|
||||
aria-label={`Environment variable ${i + 1} name`}
|
||||
disabled={disabled}
|
||||
className={`w-2/5 ${monoInputClass}`}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
value={ev.value}
|
||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||
@@ -61,8 +70,9 @@ export default function EnvVarsEditor({
|
||||
placeholder="value"
|
||||
aria-label={`Environment variable ${i + 1} value`}
|
||||
disabled={disabled}
|
||||
className={`flex-1 ${monoInputClass}`}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -33,4 +33,33 @@ describe("Window icon configuration", () => {
|
||||
expect(config.bundle.icon).toContain("icons/icon.ico");
|
||||
expect(config.bundle.icon).toContain("icons/icon.png");
|
||||
});
|
||||
|
||||
it("icon.ico carries the small sizes Windows draws in the taskbar", () => {
|
||||
// A single-image .ico is the taskbar bug: Windows upscales 16x16 into every
|
||||
// other slot. Regenerate with `python3 branding/build-icons.py`.
|
||||
const ico = readFileSync(resolve(srcTauriDir, "icons/icon.ico"));
|
||||
const count = ico.readUInt16LE(4);
|
||||
expect(count).toBeGreaterThan(1);
|
||||
|
||||
// Directory entries start at byte 6; width/height of 0 means 256.
|
||||
const widths = new Set<number>();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const w = ico[6 + i * 16];
|
||||
widths.add(w === 0 ? 256 : w);
|
||||
}
|
||||
for (const size of [16, 24, 32, 48, 256]) {
|
||||
expect(widths).toContain(size);
|
||||
}
|
||||
});
|
||||
|
||||
it("icon.icns exists and is bundled for macOS", () => {
|
||||
const icnsPath = resolve(srcTauriDir, "icons/icon.icns");
|
||||
expect(existsSync(icnsPath)).toBe(true);
|
||||
expect(readFileSync(icnsPath).subarray(0, 4).toString("ascii")).toBe("icns");
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(resolve(srcTauriDir, "tauri.conf.json"), "utf-8")
|
||||
);
|
||||
expect(config.bundle.icon).toContain("icons/icon.icns");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Branding
|
||||
|
||||
The mark is a container with its right wall opened, so the enclosure itself is the letter **C**,
|
||||
holding a `>_` prompt. Container plus shell, in one closed shape — no type inside the icon, so
|
||||
nothing goes illegible when the app is 16 px tall in a taskbar.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `triple-c-mark.svg` | The mark alone, transparent. Use on any ground. |
|
||||
| `triple-c-mark-small.svg` | Optical variant for 32 px and below. |
|
||||
| `triple-c-icon.svg` | The app icon: mark at 70% on a `#0D1117` tile, 22% corner radius. |
|
||||
| `triple-c-icon-small.svg` | The app icon at small sizes — small mark, drawn at 82%. |
|
||||
| `triple-c-lockup-dark.svg` | Horizontal lockup for dark backgrounds. Wordmark is outlined, so no font is needed to render it. |
|
||||
| `triple-c-lockup-light.svg` | The same for light backgrounds. |
|
||||
| `triple-c-icon-1024.png` | Master raster, generated. |
|
||||
| `build-icons.py` | Regenerates every packaged icon from the SVGs. |
|
||||
|
||||
## Palette
|
||||
|
||||
| Role | Dark ground | Light ground |
|
||||
|---|---|---|
|
||||
| Frame | `#58A6FF` (`--accent`) | `#1F6FEB` (`--accent-emphasis`) |
|
||||
| Prompt | `#F0821E` | `#C4610F` |
|
||||
| Tile | `#0D1117` (`--bg-primary`) | — |
|
||||
| Wordmark | `#E6EDF3` (`--text-primary`) | `#131C25` |
|
||||
|
||||
The frame and prompt colours are the app's own accent tokens from `app/src/index.css`, which is why
|
||||
the icon sits on the app's chrome instead of fighting it. Orange is the only equity carried over
|
||||
from the previous marks, and it is now an accent rather than a background.
|
||||
|
||||
## Two sources, not one
|
||||
|
||||
`build-icons.py` renders sizes ≥ 48 px from `triple-c-icon.svg` and sizes ≤ 32 px from
|
||||
`triple-c-icon-small.svg`. At small sizes the cursor bar closes up against the chevron and a 12 px
|
||||
frame stroke resamples to a grey smear, so the small variant drops the cursor, widens the mouth,
|
||||
thickens the strokes and draws the mark larger inside the tile.
|
||||
|
||||
This matters most for `icon.ico`: Windows picks the 16 px entry for the window corner and 24/32 px
|
||||
for the taskbar. The previous `.ico` contained a *single* 16 px image, which Windows then upscaled
|
||||
everywhere else — the likely cause of `screenshot_for_fix/task_bar_icon_not_correct.png`. The
|
||||
current one carries 16, 24, 32, 48, 64, 128 and 256, each rendered from vector rather than
|
||||
downsampled from one bitmap.
|
||||
|
||||
## Regenerating
|
||||
|
||||
```bash
|
||||
pip install cairosvg pillow
|
||||
python3 branding/build-icons.py
|
||||
```
|
||||
|
||||
Writes `app/src-tauri/icons/{32x32,128x128,128x128@2x,icon}.png`, `icon.ico`, `icon.icns`,
|
||||
`app/public/favicon.svg` and `branding/triple-c-icon-1024.png`. Do not hand-edit those — edit the
|
||||
SVG and re-run.
|
||||
|
||||
The lockups are committed sources, not generated: their wordmark is Liberation Mono Bold converted
|
||||
to outlines (`-1.6` tracking, 44 px cap height), so re-cutting it needs the font and `fonttools`.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Archive
|
||||
|
||||
The marks Triple-C shipped before the current one, kept for reference.
|
||||
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `triple-c-app-logo.png` | First mark: orange tile, overlapping C letterforms, "TRIPLE-C / CONTAINERIZED CODING" set inside the icon. |
|
||||
| `triple-c-app-logov2.png` | Second mark: orange sunburst with "Triple-C / Coding Container" over it, transparent ground. |
|
||||
|
||||
Both were drawn at poster size and carried type inside the icon, so nothing in them survived the
|
||||
16–32 px range where an app icon actually lives. Neither is referenced by the app or the build.
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 191 KiB |
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render every packaged icon from the SVG sources in this directory.
|
||||
|
||||
pip install cairosvg pillow
|
||||
python3 branding/build-icons.py
|
||||
|
||||
The SVGs are the source of truth; everything under app/src-tauri/icons/ and the
|
||||
favicon are generated. Two sources, not one, on purpose:
|
||||
|
||||
triple-c-icon.svg used for 48 px and up
|
||||
triple-c-icon-small.svg used for 32 px and down
|
||||
|
||||
At 16-32 px the cursor bar closes up against the chevron and the frame's 12 px
|
||||
stroke resamples to a grey smear, so the small source drops the cursor, widens
|
||||
the mouth and draws the mark larger in the tile. Windows picks the 16 and 24 px
|
||||
entries out of the .ico for the taskbar and window corner, which is exactly
|
||||
where the old single-size .ico was falling over.
|
||||
"""
|
||||
|
||||
import io
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import cairosvg
|
||||
from PIL import Image
|
||||
|
||||
BRANDING = Path(__file__).resolve().parent
|
||||
REPO = BRANDING.parent
|
||||
ICONS = REPO / "app" / "src-tauri" / "icons"
|
||||
PUBLIC = REPO / "app" / "public"
|
||||
|
||||
FULL_SRC = BRANDING / "triple-c-icon.svg"
|
||||
SMALL_SRC = BRANDING / "triple-c-icon-small.svg"
|
||||
|
||||
# Below this, render from the small source.
|
||||
SMALL_MAX = 32
|
||||
|
||||
# .ico entries. Windows uses 16 in the window corner and 24/32 in the taskbar,
|
||||
# 48 in list views and 256 in the "extra large icons" view.
|
||||
ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
|
||||
|
||||
# .icns entries: (chunk type, pixel size). PNG-backed chunks, macOS 10.7+.
|
||||
ICNS_ENTRIES = [
|
||||
(b"ic11", 32), # 16pt @2x
|
||||
(b"ic12", 64), # 32pt @2x
|
||||
(b"ic07", 128), # 128pt
|
||||
(b"ic13", 256), # 128pt @2x
|
||||
(b"ic08", 256), # 256pt
|
||||
(b"ic14", 512), # 256pt @2x
|
||||
(b"ic09", 512), # 512pt
|
||||
(b"ic10", 1024), # 512pt @2x
|
||||
]
|
||||
|
||||
|
||||
def render(size: int) -> Image.Image:
|
||||
"""Rasterise the right source at `size` px square."""
|
||||
src = SMALL_SRC if size <= SMALL_MAX else FULL_SRC
|
||||
png = cairosvg.svg2png(url=str(src), output_width=size, output_height=size)
|
||||
return Image.open(io.BytesIO(png)).convert("RGBA")
|
||||
|
||||
|
||||
def write_png(size: int, path: Path) -> None:
|
||||
render(size).save(path, "PNG", optimize=True)
|
||||
print(f" {path.relative_to(REPO)} {size}x{size}")
|
||||
|
||||
|
||||
def write_ico(path: Path) -> None:
|
||||
"""Hand-assemble the .ico so each entry can come from its own source.
|
||||
|
||||
Pillow's save(sizes=...) downsamples one image, which would put the
|
||||
12 px-stroke artwork into the 16 px entry — the thing this avoids.
|
||||
"""
|
||||
images = [render(s) for s in ICO_SIZES]
|
||||
payloads = []
|
||||
for img in images:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG", optimize=True) # PNG-compressed entries, Vista+
|
||||
payloads.append(buf.getvalue())
|
||||
|
||||
offset = 6 + 16 * len(images)
|
||||
header = struct.pack("<HHH", 0, 1, len(images))
|
||||
entries, blob = b"", b""
|
||||
for img, data in zip(images, payloads):
|
||||
w = 0 if img.width >= 256 else img.width
|
||||
h = 0 if img.height >= 256 else img.height
|
||||
entries += struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(data), offset)
|
||||
blob += data
|
||||
offset += len(data)
|
||||
path.write_bytes(header + entries + blob)
|
||||
print(f" {path.relative_to(REPO)} {', '.join(str(s) for s in ICO_SIZES)}")
|
||||
|
||||
|
||||
def write_icns(path: Path) -> None:
|
||||
chunks = b""
|
||||
for kind, size in ICNS_ENTRIES:
|
||||
buf = io.BytesIO()
|
||||
render(size).save(buf, "PNG", optimize=True)
|
||||
data = buf.getvalue()
|
||||
chunks += kind + struct.pack(">I", len(data) + 8) + data
|
||||
path.write_bytes(b"icns" + struct.pack(">I", len(chunks) + 8) + chunks)
|
||||
print(f" {path.relative_to(REPO)} {', '.join(str(s) for _, s in ICNS_ENTRIES)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("branding/")
|
||||
write_png(1024, BRANDING / "triple-c-icon-1024.png")
|
||||
|
||||
print("app/src-tauri/icons/")
|
||||
write_png(32, ICONS / "32x32.png")
|
||||
write_png(128, ICONS / "128x128.png")
|
||||
write_png(256, ICONS / "128x128@2x.png")
|
||||
write_png(512, ICONS / "icon.png")
|
||||
write_ico(ICONS / "icon.ico")
|
||||
write_icns(ICONS / "icon.icns")
|
||||
|
||||
print("app/public/")
|
||||
(PUBLIC / "favicon.svg").write_text(SMALL_SRC.read_text())
|
||||
print(f" {(PUBLIC / 'favicon.svg').relative_to(REPO)} (copy of {SMALL_SRC.name})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||
<title>Triple-C application icon, small-size variant</title>
|
||||
<!-- Source for every raster ≤ 32 px: the small mark, drawn at 82% so the strokes
|
||||
survive being resampled down to 16 px. See build-icons.py. -->
|
||||
<rect width="128" height="128" rx="28.16" fill="#0D1117"/>
|
||||
<g transform="translate(2.9236 2.9236) scale(0.95418)">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M112 50 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 78"
|
||||
stroke="#58A6FF" stroke-width="14"/>
|
||||
<path d="M46 50 L62 66 L46 82" stroke="#F0821E" stroke-width="13"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 812 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||
<title>Triple-C application icon</title>
|
||||
<!-- App icon: the mark on the app's own ground (bg-primary), at 70% of the tile.
|
||||
Source for every raster ≥ 48 px. See build-icons.py. -->
|
||||
<rect width="128" height="128" rx="28.16" fill="#0D1117"/>
|
||||
<g transform="translate(10.8988 10.8988) scale(0.82963)">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M112 46 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 82"
|
||||
stroke="#58A6FF" stroke-width="12"/>
|
||||
<path d="M42 50 L58 66 L42 82" stroke="#F0821E" stroke-width="11"/>
|
||||
<path d="M68 82 L88 82" stroke="#F0821E" stroke-width="11"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||
<title>Triple-C mark, small-size variant</title>
|
||||
<!-- Optical variant for 32 px and below: heavier strokes, wider mouth, no cursor bar.
|
||||
Below ~40 px the cursor closes up against the chevron and the frame goes to mush. -->
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M112 50 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 78"
|
||||
stroke="#58A6FF" stroke-width="14"/>
|
||||
<path d="M46 50 L62 66 L46 82" stroke="#F0821E" stroke-width="13"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 690 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||
<title>Triple-C mark</title>
|
||||
<!-- The container frame, opened on the right so the enclosure itself is the letter C. -->
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M112 46 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 82"
|
||||
stroke="#58A6FF" stroke-width="12"/>
|
||||
<!-- The prompt: chevron and cursor. -->
|
||||
<path d="M42 50 L58 66 L42 82" stroke="#F0821E" stroke-width="11"/>
|
||||
<path d="M68 82 L88 82" stroke="#F0821E" stroke-width="11"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |