Compare commits

...

7 Commits

Author SHA1 Message Date
33f02e65c0 Move mic button from terminal overlay to project action buttons
All checks were successful
Build App / build-macos (push) Successful in 2m53s
Build App / build-windows (push) Successful in 3m26s
Build App / build-linux (push) Successful in 5m59s
Build App / sync-to-github (push) Successful in 11s
Relocates the voice/mic toggle from a floating overlay on the terminal
view to the project command row (alongside Stop, Terminal, Config) so
it no longer blocks access to the terminal window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:58:03 -08:00
c5e28f9caa feat: add microphone selection to settings
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m42s
Build App / sync-to-github (push) Successful in 18s
Adds a dropdown in Settings to choose which audio input device to
use for voice mode. Enumerates devices via the browser's
mediaDevices API and persists the selection in AppSettings.
The useVoice hook passes the selected deviceId to getUserMedia().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:15:47 -08:00
86176d8830 feat: add voice mode support via mic passthrough to container
Some checks failed
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m24s
Build App / sync-to-github (push) Has been cancelled
Build App / build-linux (push) Has been cancelled
Build Container / build-container (push) Successful in 54s
Enables Claude Code's /voice command inside Docker containers by
capturing microphone audio in the Tauri webview and streaming it
into the container via a FIFO pipe.

Container: fake rec/arecord shims read PCM from a FIFO instead of
a real mic. Audio bridge exec writes PCM from Tauri into the FIFO.
Frontend: getUserMedia() + AudioWorklet captures 16kHz mono PCM
and streams it to the container via invoke("send_audio_data").
UI: "Mic Off/On" toggle button in the terminal view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:11:33 -08:00
58a10c65e9 feat: add OSC 52 clipboard support for container-to-host copy
All checks were successful
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m57s
Build App / build-linux (push) Successful in 8m28s
Build Container / build-container (push) Successful in 1m47s
Build App / sync-to-github (push) Successful in 12s
Programs inside the container (e.g. Claude Code's "hit c to copy") can
now write to the host system clipboard. A shell script shim installed as
xclip/xsel/pbcopy emits OSC 52 escape sequences, which the xterm.js
frontend intercepts and forwards to navigator.clipboard.writeText().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:47:42 -08:00
d56c6e3845 fix: validate AWS SSO session before launching Claude for Bedrock Profile auth
All checks were successful
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 5m41s
Build Container / build-container (push) Successful in 1m27s
Build App / sync-to-github (push) Successful in 12s
When using AWS Profile auth (SSO) with Bedrock, expired SSO sessions
caused Claude Code to spin indefinitely. Three root causes fixed:

1. Mount host .aws at /tmp/.host-aws (read-only) and copy to
   /home/claude/.aws in entrypoint, mirroring the SSH key pattern.
   This gives AWS CLI writable sso/cache and cli/cache directories.

2. For Bedrock Profile projects, wrap the claude command in a bash
   script that validates credentials via `aws sts get-caller-identity`
   before launch. If SSO session is expired, runs `aws sso login`
   with the auth URL visible and clickable in the terminal.

3. Non-SSO profiles with bad creds get a warning but Claude still
   starts. Non-Bedrock projects are unaffected.

Note: existing containers need a rebuild to pick up the new mount path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:41:42 -08:00
574fca633a fix: sync build artifacts to GitHub instead of empty source archives
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 4m37s
Build App / sync-to-github (push) Successful in 11s
Move GitHub release sync into build-app.yml as a final sync-to-github
job that runs after all 3 platform builds complete. This eliminates the
race condition where sync-release.yml triggered before artifacts were
uploaded to Gitea. The old sync-release.yml is changed to manual-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:57:27 -08:00
e07c0e6150 fix: use SHA-256 for container fingerprints instead of DefaultHasher
All checks were successful
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 3m17s
Build App / build-linux (push) Successful in 4m30s
Sync Release to GitHub / sync-release (release) Successful in 2s
DefaultHasher is not stable across Rust compiler versions or binary
rebuilds, causing unnecessary container recreations on every app update.
Replace with SHA-256 for deterministic, cross-build-stable fingerprints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:42:06 -08:00
22 changed files with 696 additions and 30 deletions

View File

@@ -19,6 +19,8 @@ env:
jobs: jobs:
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps: steps:
- name: Install Node.js 22 - name: Install Node.js 22
run: | run: |
@@ -374,3 +376,96 @@ jobs:
echo Uploading %%~nxf... echo Uploading %%~nxf...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf" curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
) )
sync-to-github:
runs-on: ubuntu-latest
needs: [build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
steps:
- name: Download artifacts from Gitea releases
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.build-linux.outputs.version }}
run: |
set -e
mkdir -p artifacts
# Download assets from all 3 platform releases
for TAG_SUFFIX in "" "-mac" "-win"; do
TAG="v${VERSION}${TAG_SUFFIX}"
echo "==> Fetching assets for release ${TAG}..."
RELEASE_JSON=$(curl -sf \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.name) \(.browser_download_url)"' | while read -r NAME URL; do
[ -z "$NAME" ] && continue
echo " Downloading ${NAME}..."
curl -sfL \
-H "Authorization: token ${TOKEN}" \
-o "artifacts/${NAME}" \
"$URL"
done
done
echo "==> All downloaded artifacts:"
ls -la artifacts/
- name: Create GitHub release and upload artifacts
env:
VERSION: ${{ needs.build-linux.outputs.version }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -e
TAG="v${VERSION}"
echo "==> Creating unified release ${TAG} on GitHub..."
# Delete existing release if present (idempotent re-runs)
EXISTING=$(curl -sf \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
if [ -n "$EXISTING_ID" ]; then
echo " Deleting existing GitHub release ${TAG} (id: ${EXISTING_ID})..."
curl -sf -X DELETE \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${EXISTING_ID}"
fi
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"Triple-C ${TAG}\",
\"body\": \"Automated build from commit ${COMMIT_SHA}\n\nIncludes Linux, macOS, and Windows artifacts.\",
\"draft\": false,
\"prerelease\": false
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "==> Upload URL: ${UPLOAD_URL}"
for file in artifacts/*; do
[ -f "$file" ] || continue
FILENAME=$(basename "$file")
MIME="application/octet-stream"
echo "==> Uploading ${FILENAME}..."
curl -sf -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: ${MIME}" \
--data-binary "@${file}" \
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${FILENAME}")"
done
echo "==> GitHub release sync complete."

View File

@@ -1,8 +1,7 @@
name: Sync Release to GitHub name: Sync Release to GitHub
on: on:
release: workflow_dispatch:
types: [published]
jobs: jobs:
sync-release: sync-release:

View File

@@ -0,0 +1,17 @@
class AudioCaptureProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input && input.length > 0 && input[0].length > 0) {
const samples = input[0]; // Float32Array, mono channel
const int16 = new Int16Array(samples.length);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
this.port.postMessage(int16.buffer, [int16.buffer]);
}
return true;
}
}
registerProcessor('audio-capture-processor', AudioCaptureProcessor);

View File

@@ -4681,6 +4681,7 @@ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tar", "tar",
"tauri", "tauri",
"tauri-build", "tauri-build",

View File

@@ -30,6 +30,7 @@ fern = { version = "0.7", features = ["date-based"] }
tar = "0.4" tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
iana-time-zone = "0.1" iana-time-zone = "0.1"
sha2 = "0.10"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }

View File

@@ -1,7 +1,74 @@
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use crate::models::{AuthMode, BedrockAuthMethod, Project};
use crate::AppState; use crate::AppState;
/// Build the command to run in the container terminal.
///
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock
&& project
.bedrock_config
.as_ref()
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
.unwrap_or(false);
if !is_bedrock_profile {
return vec![
"claude".to_string(),
"--dangerously-skip-permissions".to_string(),
];
}
// Resolve AWS profile: project-level → global settings → "default"
let profile = project
.bedrock_config
.as_ref()
.and_then(|b| b.aws_profile.clone())
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
.unwrap_or_else(|| "default".to_string());
// Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into 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."
# Check if this profile uses SSO (has sso_start_url configured)
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1; then
echo "Starting SSO login — click the URL below to authenticate:"
echo ""
aws sso login --profile '{profile}'
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
exec claude --dangerously-skip-permissions
"#,
profile = profile
);
vec![
"bash".to_string(),
"-c".to_string(),
script,
]
}
#[tauri::command] #[tauri::command]
pub async fn open_terminal_session( pub async fn open_terminal_session(
project_id: String, project_id: String,
@@ -19,10 +86,7 @@ pub async fn open_terminal_session(
.as_ref() .as_ref()
.ok_or_else(|| "Container not running".to_string())?; .ok_or_else(|| "Container not running".to_string())?;
let cmd = vec![ let cmd = build_terminal_cmd(&project, &state);
"claude".to_string(),
"--dangerously-skip-permissions".to_string(),
];
let output_event = format!("terminal-output-{}", session_id); let output_event = format!("terminal-output-{}", session_id);
let exit_event = format!("terminal-exit-{}", session_id); let exit_event = format!("terminal-exit-{}", session_id);
@@ -69,6 +133,10 @@ pub async fn close_terminal_session(
session_id: String, session_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
// Close audio bridge if it exists
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.close_session(&audio_session_id).await;
// Close terminal session
state.exec_manager.close_session(&session_id).await; state.exec_manager.close_session(&session_id).await;
Ok(()) Ok(())
} }
@@ -92,3 +160,53 @@ pub async fn paste_image_to_terminal(
.write_file_to_container(&container_id, &file_name, &image_data) .write_file_to_container(&container_id, &file_name, &image_data)
.await .await
} }
#[tauri::command]
pub async fn start_audio_bridge(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Get container_id from the terminal session
let container_id = state.exec_manager.get_container_id(&session_id).await?;
// Create audio bridge exec session with ID "audio-{session_id}"
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
let audio_session_id = format!("audio-{}", session_id);
let cmd = vec![
"bash".to_string(),
"-c".to_string(),
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
];
state
.exec_manager
.create_session_with_tty(
&container_id,
&audio_session_id,
cmd,
false,
|_data| { /* ignore output from the audio bridge */ },
Box::new(|| { /* no exit handler needed */ }),
)
.await
}
#[tauri::command]
pub async fn send_audio_data(
session_id: String,
data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<(), String> {
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.send_input(&audio_session_id, data).await
}
#[tauri::command]
pub async fn stop_audio_bridge(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.close_session(&audio_session_id).await;
Ok(())
}

View File

@@ -5,8 +5,7 @@ use bollard::container::{
use bollard::image::{CommitContainerOptions, RemoveImageOptions}; use bollard::image::{CommitContainerOptions, RemoveImageOptions};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding}; use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher; use sha2::{Sha256, Digest};
use std::hash::{Hash, Hasher};
use super::client::get_docker; use super::client::get_docker;
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
@@ -129,20 +128,28 @@ fn merge_claude_instructions(
} }
} }
/// Hash a string with SHA-256 and return the hex digest.
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Compute a fingerprint for the Bedrock configuration so we can detect changes. /// Compute a fingerprint for the Bedrock configuration so we can detect changes.
fn compute_bedrock_fingerprint(project: &Project) -> String { fn compute_bedrock_fingerprint(project: &Project) -> String {
if let Some(ref bedrock) = project.bedrock_config { if let Some(ref bedrock) = project.bedrock_config {
let mut hasher = DefaultHasher::new(); let parts = vec![
format!("{:?}", bedrock.auth_method).hash(&mut hasher); format!("{:?}", bedrock.auth_method),
bedrock.aws_region.hash(&mut hasher); bedrock.aws_region.clone(),
bedrock.aws_access_key_id.hash(&mut hasher); bedrock.aws_access_key_id.as_deref().unwrap_or("").to_string(),
bedrock.aws_secret_access_key.hash(&mut hasher); bedrock.aws_secret_access_key.as_deref().unwrap_or("").to_string(),
bedrock.aws_session_token.hash(&mut hasher); bedrock.aws_session_token.as_deref().unwrap_or("").to_string(),
bedrock.aws_profile.hash(&mut hasher); bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
bedrock.aws_bearer_token.hash(&mut hasher); bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
bedrock.model_id.hash(&mut hasher); bedrock.model_id.as_deref().unwrap_or("").to_string(),
bedrock.disable_prompt_caching.hash(&mut hasher); format!("{}", bedrock.disable_prompt_caching),
format!("{:x}", hasher.finish()) ];
sha256_hex(&parts.join("|"))
} else { } else {
String::new() String::new()
} }
@@ -157,9 +164,7 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
.collect(); .collect();
parts.sort(); parts.sort();
let joined = parts.join(","); let joined = parts.join(",");
let mut hasher = DefaultHasher::new(); sha256_hex(&joined)
joined.hash(&mut hasher);
format!("{:x}", hasher.finish())
} }
/// Compute a fingerprint for port mappings so we can detect changes. /// Compute a fingerprint for port mappings so we can detect changes.
@@ -171,9 +176,7 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
.collect(); .collect();
parts.sort(); parts.sort();
let joined = parts.join(","); let joined = parts.join(",");
let mut hasher = DefaultHasher::new(); sha256_hex(&joined)
joined.hash(&mut hasher);
format!("{:x}", hasher.finish())
} }
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json. /// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
@@ -250,9 +253,7 @@ fn compute_mcp_fingerprint(servers: &[McpServer]) -> String {
return String::new(); return String::new();
} }
let json = build_mcp_servers_json(servers); let json = build_mcp_servers_json(servers);
let mut hasher = DefaultHasher::new(); sha256_hex(&json)
json.hash(&mut hasher);
format!("{:x}", hasher.finish())
} }
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> { pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
@@ -507,7 +508,7 @@ pub async fn create_container(
if let Some(ref aws_path) = aws_dir { if let Some(ref aws_path) = aws_dir {
if aws_path.exists() { if aws_path.exists() {
mounts.push(Mount { mounts.push(Mount {
target: Some("/home/claude/.aws".to_string()), target: Some("/tmp/.host-aws".to_string()),
source: Some(aws_path.to_string_lossy().to_string()), source: Some(aws_path.to_string_lossy().to_string()),
typ: Some(MountTypeEnum::BIND), typ: Some(MountTypeEnum::BIND),
read_only: Some(true), read_only: Some(true),

View File

@@ -60,6 +60,22 @@ impl ExecSessionManager {
on_output: F, on_output: F,
on_exit: Box<dyn FnOnce() + Send>, on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String> ) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
.await
}
pub async fn create_session_with_tty<F>(
&self,
container_id: &str,
session_id: &str,
cmd: Vec<String>,
tty: bool,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where where
F: Fn(Vec<u8>) + Send + 'static, F: Fn(Vec<u8>) + Send + 'static,
{ {
@@ -72,7 +88,7 @@ impl ExecSessionManager {
attach_stdin: Some(true), attach_stdin: Some(true),
attach_stdout: Some(true), attach_stdout: Some(true),
attach_stderr: Some(true), attach_stderr: Some(true),
tty: Some(true), tty: Some(tty),
cmd: Some(cmd), cmd: Some(cmd),
user: Some("claude".to_string()), user: Some("claude".to_string()),
working_dir: Some("/workspace".to_string()), working_dir: Some("/workspace".to_string()),

View File

@@ -101,6 +101,9 @@ pub fn run() {
commands::terminal_commands::terminal_resize, commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session, commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal, commands::terminal_commands::paste_image_to_terminal,
commands::terminal_commands::start_audio_bridge,
commands::terminal_commands::send_audio_data,
commands::terminal_commands::stop_audio_bridge,
// MCP // MCP
commands::mcp_commands::list_mcp_servers, commands::mcp_commands::list_mcp_servers,
commands::mcp_commands::add_mcp_server, commands::mcp_commands::add_mcp_server,

View File

@@ -70,6 +70,8 @@ pub struct AppSettings {
pub dismissed_update_version: Option<String>, pub dismissed_update_version: Option<String>,
#[serde(default)] #[serde(default)]
pub timezone: Option<String>, pub timezone: Option<String>,
#[serde(default)]
pub default_microphone: Option<String>,
} }
impl Default for AppSettings { impl Default for AppSettings {
@@ -87,6 +89,7 @@ impl Default for AppSettings {
auto_check_updates: true, auto_check_updates: true,
dismissed_update_version: None, dismissed_update_version: None,
timezone: None, timezone: None,
default_microphone: None,
} }
} }
} }

View File

@@ -5,6 +5,8 @@ import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod }
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
import { useMcpServers } from "../../hooks/useMcpServers"; import { useMcpServers } from "../../hooks/useMcpServers";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useSettings } from "../../hooks/useSettings";
import { useVoice } from "../../hooks/useVoice";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import EnvVarsModal from "./EnvVarsModal"; import EnvVarsModal from "./EnvVarsModal";
import PortMappingsModal from "./PortMappingsModal"; import PortMappingsModal from "./PortMappingsModal";
@@ -21,6 +23,15 @@ export default function ProjectCard({ project }: Props) {
const { start, stop, rebuild, remove, update } = useProjects(); const { start, stop, rebuild, remove, update } = useProjects();
const { mcpServers } = useMcpServers(); const { mcpServers } = useMcpServers();
const { open: openTerminal } = useTerminal(); const { open: openTerminal } = useTerminal();
const { appSettings } = useSettings();
const sessions = useAppState(s => s.sessions);
const activeSessionId = useAppState(s => s.activeSessionId);
// Find the active terminal session for this project (prefer the currently viewed one)
const projectSession = sessions.find(s => s.projectId === project.id && s.id === activeSessionId)
?? sessions.find(s => s.projectId === project.id);
const voice = useVoice(projectSession?.id ?? "", appSettings?.default_microphone);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
@@ -371,6 +382,9 @@ export default function ProjectCard({ project }: Props) {
<> <>
<ActionButton onClick={handleStop} disabled={loading} label="Stop" /> <ActionButton onClick={handleStop} disabled={loading} label="Stop" />
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent /> <ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
{projectSession && (
<MicButton voice={voice} />
)}
</> </>
) : ( ) : (
<> <>
@@ -869,3 +883,35 @@ function ActionButton({
</button> </button>
); );
} }
function MicButton({ voice }: { voice: ReturnType<typeof useVoice> }) {
const color =
voice.state === "active"
? "text-[var(--success)] hover:text-[var(--success)]"
: voice.state === "starting"
? "text-[var(--warning)] opacity-75"
: voice.state === "error"
? "text-[var(--error)] hover:text-[var(--error)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
return (
<button
onClick={(e) => { e.stopPropagation(); voice.toggle(); }}
disabled={voice.state === "starting"}
title={
voice.state === "active"
? "Voice active — click to stop"
: voice.error
? `Voice error: ${voice.error}`
: "Enable voice input for /voice mode"
}
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
>
{voice.state === "active"
? "Mic On"
: voice.state === "starting"
? "Mic..."
: "Mic Off"}
</button>
);
}

View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback } from "react";
import { useSettings } from "../../hooks/useSettings";
interface AudioDevice {
deviceId: string;
label: string;
}
export default function MicrophoneSettings() {
const { appSettings, saveSettings } = useSettings();
const [devices, setDevices] = useState<AudioDevice[]>([]);
const [selected, setSelected] = useState(appSettings?.default_microphone ?? "");
const [loading, setLoading] = useState(false);
const [permissionNeeded, setPermissionNeeded] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
setSelected(appSettings?.default_microphone ?? "");
}, [appSettings?.default_microphone]);
const enumerateDevices = useCallback(async () => {
setLoading(true);
setPermissionNeeded(false);
try {
// Request mic permission first so device labels are available
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((t) => t.stop());
const allDevices = await navigator.mediaDevices.enumerateDevices();
const mics = allDevices
.filter((d) => d.kind === "audioinput")
.map((d) => ({
deviceId: d.deviceId,
label: d.label || `Microphone (${d.deviceId.slice(0, 8)}...)`,
}));
setDevices(mics);
} catch {
setPermissionNeeded(true);
} finally {
setLoading(false);
}
}, []);
// Enumerate devices on mount
useEffect(() => {
enumerateDevices();
}, [enumerateDevices]);
const handleChange = async (deviceId: string) => {
setSelected(deviceId);
if (appSettings) {
await saveSettings({ ...appSettings, default_microphone: deviceId || null });
}
};
return (
<div>
<label className="block text-sm font-medium mb-1">Microphone</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Audio input device for Claude Code voice mode (/voice)
</p>
{permissionNeeded ? (
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--text-secondary)]">
Microphone permission required
</span>
<button
onClick={enumerateDevices}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Grant Access
</button>
</div>
) : (
<div className="flex items-center gap-2">
<select
value={selected}
onChange={(e) => handleChange(e.target.value)}
disabled={loading}
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
>
<option value="">System Default</option>
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
<button
onClick={enumerateDevices}
disabled={loading}
title="Refresh microphone list"
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
>
{loading ? "..." : "Refresh"}
</button>
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import ApiKeyInput from "./ApiKeyInput"; import ApiKeyInput from "./ApiKeyInput";
import DockerSettings from "./DockerSettings"; import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings"; import AwsSettings from "./AwsSettings";
import MicrophoneSettings from "./MicrophoneSettings";
import { useSettings } from "../../hooks/useSettings"; import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates"; import { useUpdates } from "../../hooks/useUpdates";
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal"; import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
@@ -59,6 +60,8 @@ export default function SettingsPanel() {
<DockerSettings /> <DockerSettings />
<AwsSettings /> <AwsSettings />
<MicrophoneSettings />
{/* Container Timezone */} {/* Container Timezone */}
<div> <div>
<label className="block text-sm font-medium mb-1">Container Timezone</label> <label className="block text-sm font-medium mb-1">Container Timezone</label>

View File

@@ -82,6 +82,25 @@ export default function TerminalView({ sessionId, active }: Props) {
// Send initial size // Send initial size
resize(sessionId, term.cols, term.rows); resize(sessionId, term.cols, term.rows);
// Handle OSC 52 clipboard write sequences from programs inside the container.
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
const idx = data.indexOf(";");
if (idx === -1) return false;
const payload = data.substring(idx + 1);
if (payload === "?") return false; // clipboard read request, not supported
try {
const decoded = atob(payload);
navigator.clipboard.writeText(decoded).catch((e) =>
console.error("OSC 52 clipboard write failed:", e),
);
} catch (e) {
console.error("OSC 52 decode failed:", e);
}
return true;
});
// Handle user input -> backend // Handle user input -> backend
const inputDisposable = term.onData((data) => { const inputDisposable = term.onData((data) => {
sendInput(sessionId, data); sendInput(sessionId, data);
@@ -170,6 +189,7 @@ export default function TerminalView({ sessionId, active }: Props) {
aborted = true; aborted = true;
detector.dispose(); detector.dispose();
detectorRef.current = null; detectorRef.current = null;
osc52Disposable.dispose();
inputDisposable.dispose(); inputDisposable.dispose();
scrollDisposable.dispose(); scrollDisposable.dispose();
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
* Extracted here to validate the decode/write path independently.
*/
// Mirrors the handler registered in TerminalView.tsx
function handleOsc52(data: string): string | null {
const idx = data.indexOf(";");
if (idx === -1) return null;
const payload = data.substring(idx + 1);
if (payload === "?") return null;
try {
return atob(payload);
} catch {
return null;
}
}
describe("OSC 52 clipboard handler", () => {
it("decodes a valid clipboard write sequence", () => {
// "c;BASE64" where BASE64 encodes "https://example.com"
const encoded = btoa("https://example.com");
const result = handleOsc52(`c;${encoded}`);
expect(result).toBe("https://example.com");
});
it("decodes multi-line content", () => {
const text = "line1\nline2\nline3";
const encoded = btoa(text);
const result = handleOsc52(`c;${encoded}`);
expect(result).toBe(text);
});
it("handles primary selection target (p)", () => {
const encoded = btoa("selected text");
const result = handleOsc52(`p;${encoded}`);
expect(result).toBe("selected text");
});
it("returns null for clipboard read request (?)", () => {
expect(handleOsc52("c;?")).toBe(null);
});
it("returns null for missing semicolon", () => {
expect(handleOsc52("invalid")).toBe(null);
});
it("returns null for invalid base64", () => {
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
});
it("handles empty payload after selection target", () => {
// btoa("") = ""
const result = handleOsc52("c;");
expect(result).toBe("");
});
});

103
app/src/hooks/useVoice.ts Normal file
View File

@@ -0,0 +1,103 @@
import { useCallback, useRef, useState } from "react";
import * as commands from "../lib/tauri-commands";
type VoiceState = "inactive" | "starting" | "active" | "error";
export function useVoice(sessionId: string, deviceId?: string | null) {
const [state, setState] = useState<VoiceState>("inactive");
const [error, setError] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null);
const start = useCallback(async () => {
if (state === "active" || state === "starting") return;
setState("starting");
setError(null);
try {
// 1. Start the audio bridge in the container (creates FIFO writer)
await commands.startAudioBridge(sessionId);
// 2. Get microphone access (use specific device if configured)
const audioConstraints: MediaTrackConstraints = {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
if (deviceId) {
audioConstraints.deviceId = { exact: deviceId };
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
});
streamRef.current = stream;
// 3. Create AudioContext at 16kHz (browser handles resampling)
const audioContext = new AudioContext({ sampleRate: 16000 });
audioContextRef.current = audioContext;
// 4. Load AudioWorklet processor
await audioContext.audioWorklet.addModule("/audio-capture-processor.js");
// 5. Connect: mic → worklet → (silent) destination
const source = audioContext.createMediaStreamSource(stream);
const processor = new AudioWorkletNode(audioContext, "audio-capture-processor");
workletRef.current = processor;
// 6. Handle PCM chunks from the worklet
processor.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
const bytes = Array.from(new Uint8Array(event.data));
commands.sendAudioData(sessionId, bytes).catch(() => {
// Audio bridge may have been closed — ignore send errors
});
};
source.connect(processor);
processor.connect(audioContext.destination);
setState("active");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
setState("error");
// Clean up on failure
await commands.stopAudioBridge(sessionId).catch(() => {});
}
}, [sessionId, state, deviceId]);
const stop = useCallback(async () => {
// Tear down audio pipeline
workletRef.current?.disconnect();
workletRef.current = null;
if (audioContextRef.current) {
await audioContextRef.current.close().catch(() => {});
audioContextRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
// Stop the container-side audio bridge
await commands.stopAudioBridge(sessionId).catch(() => {});
setState("inactive");
setError(null);
}, [sessionId]);
const toggle = useCallback(async () => {
if (state === "active") {
await stop();
} else {
await start();
}
}, [state, start, stop]);
return { state, error, start, stop, toggle };
}

View File

@@ -49,6 +49,12 @@ export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId }); invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData }); invoke<string>("paste_image_to_terminal", { sessionId, imageData });
export const startAudioBridge = (sessionId: string) =>
invoke<void>("start_audio_bridge", { sessionId });
export const sendAudioData = (sessionId: string, data: number[]) =>
invoke<void>("send_audio_data", { sessionId, data });
export const stopAudioBridge = (sessionId: string) =>
invoke<void>("stop_audio_bridge", { sessionId });
// MCP Servers // MCP Servers
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers"); export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");

View File

@@ -100,6 +100,7 @@ export interface AppSettings {
auto_check_updates: boolean; auto_check_updates: boolean;
dismissed_update_version: string | null; dismissed_update_version: string | null;
timezone: string | null; timezone: string | null;
default_microphone: string | null;
} }
export interface UpdateInfo { export interface UpdateInfo {

View File

@@ -101,6 +101,24 @@ WORKDIR /workspace
# ── Switch back to root for entrypoint (handles UID/GID remapping) ───────── # ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
USER root USER root
# ── OSC 52 clipboard support ─────────────────────────────────────────────
# Provides xclip/xsel/pbcopy shims that emit OSC 52 escape sequences,
# allowing programs inside the container to copy to the host clipboard.
COPY osc52-clipboard /usr/local/bin/osc52-clipboard
RUN chmod +x /usr/local/bin/osc52-clipboard \
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xclip \
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xsel \
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/pbcopy
# ── Audio capture shim (voice mode) ────────────────────────────────────────
# Provides fake rec/arecord that read PCM from a FIFO instead of a real mic,
# allowing Claude Code voice mode to work inside the container.
COPY audio-shim /usr/local/bin/audio-shim
RUN chmod +x /usr/local/bin/audio-shim \
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler

16
container/audio-shim Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Audio capture shim for Triple-C voice mode.
# Claude Code spawns `rec` or `arecord` to capture mic audio.
# Inside Docker there is no mic, so this shim reads PCM data from a
# FIFO that the Tauri host app writes to, and outputs it on stdout.
FIFO=/tmp/triple-c-audio-input
# Create the FIFO if it doesn't already exist
[ -p "$FIFO" ] || mkfifo "$FIFO" 2>/dev/null
# Clean exit on SIGTERM (Claude Code sends this when recording stops)
trap 'exit 0' TERM INT
# Stream PCM from the FIFO to stdout until we get a signal or EOF
cat "$FIFO"

View File

@@ -73,6 +73,19 @@ su -s /bin/bash claude -c '
sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts
' '
# ── AWS config setup ──────────────────────────────────────────────────────────
# Host AWS dir is mounted read-only at /tmp/.host-aws.
# Copy to /home/claude/.aws so AWS CLI can write to sso/cache and cli/cache.
if [ -d /tmp/.host-aws ]; then
rm -rf /home/claude/.aws
cp -a /tmp/.host-aws /home/claude/.aws
chown -R claude:claude /home/claude/.aws
chmod 700 /home/claude/.aws
# Ensure writable cache directories exist
mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache
chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli
fi
# ── Git credential helper (for HTTPS token) ───────────────────────────────── # ── Git credential helper (for HTTPS token) ─────────────────────────────────
if [ -n "$GIT_TOKEN" ]; then if [ -n "$GIT_TOKEN" ]; then
CRED_FILE="/home/claude/.git-credentials" CRED_FILE="/home/claude/.git-credentials"

26
container/osc52-clipboard Normal file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# OSC 52 clipboard provider — sends clipboard data to the host system clipboard
# via OSC 52 terminal escape sequences. Installed as xclip/xsel/pbcopy so that
# programs inside the container (e.g. Claude Code) can copy to clipboard.
#
# Supports common invocations:
# echo "text" | xclip -selection clipboard
# echo "text" | xsel --clipboard --input
# echo "text" | pbcopy
#
# Paste/output requests exit silently (not supported via OSC 52).
# Detect paste/output mode — exit silently since we can't read the host clipboard
for arg in "$@"; do
case "$arg" in
-o|--output) exit 0 ;;
esac
done
# Read all input from stdin
data=$(cat)
[ -z "$data" ] && exit 0
# Base64 encode and write OSC 52 escape sequence to the controlling terminal
encoded=$(printf '%s' "$data" | base64 | tr -d '\n')
printf '\033]52;c;%s\a' "$encoded" > /dev/tty 2>/dev/null