Files
Triple-C/app/src/lib/tauri-commands.ts
T
shadow-testandClaude Opus 5 f239fa1c82
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Fix three things found by actually using it
**The drag showed no tab.** Moving to pointer events lost the drag image
the OS used to supply, leaving a dimmed source tab and a 2px line —
which reads as "some setting changed", not "I am holding this tab". A
copy of the tab now follows the cursor, carrying its glyph and its real
label, grabbed at the offset it was picked up by so it sits where the
tab was.

**The URL relay opened a different URL than the one on screen.**
Observed: `repo.anhonesthost.net/…/tag/preview-63f3c54` arrived as
`repo.anhonsthost.nt/…/preview-63f3c54Butitprovesyournitpick…`. The
detector deleted *every* line break to undo PTY hard-wrapping, but a
terminal that wraps at a space emits the break **instead of** the space
— so deleting breaks also deletes the separators, gluing the following
paragraph onto the link and running the match past the host.

Only breaks the terminal inserted may be deleted, and those are exactly
the ones at the column width. The detector now takes a live column
getter and rejoins a line only when it is exactly that wide; every other
break becomes a space, which is also what stops a URL match. Lines
*longer* than the width are left alone — the stream had no break there,
so the one that follows is the application's own.

One case stays ambiguous: a URL whose length is an exact multiple of the
width is indistinguishable from one that was cut. That is pinned in a
test as known behaviour rather than papered over — the candidate is
shown in full and nothing opens without the user pressing Open.

**"In container" opened a page nobody could see.** It bound the browser
and stopped, leaving the user to find the Browser tab and press Start,
with nothing saying so — and from a terminal, no pane on screen at all.
Opening a page now starts the viewer if it isn't running, and the
terminal's prompt raises the pop-out window, because that caller has
nowhere else to put it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:36:30 -07:00

340 lines
18 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
export const checkImageExists = () => invoke<boolean>("check_image_exists");
export const buildImage = () => invoke<void>("build_image");
export const getContainerInfo = (projectId: string) =>
invoke<ContainerInfo | null>("get_container_info", { projectId });
export const listSiblingContainers = () =>
invoke<SiblingContainer[]>("list_sibling_containers");
// Projects
export const listProjects = () => invoke<Project[]>("list_projects");
export const addProject = (name: string, paths: ProjectPath[]) =>
invoke<Project>("add_project", { name, paths });
export const removeProject = (projectId: string) =>
invoke<void>("remove_project", { projectId });
export const updateProject = (project: Project) =>
invoke<Project>("update_project", { project });
export const startProjectContainer = (projectId: string) =>
invoke<Project>("start_project_container", { projectId });
export const stopProjectContainer = (projectId: string) =>
invoke<void>("stop_project_container", { projectId });
export const rebuildProjectContainer = (projectId: string) =>
invoke<Project>("rebuild_project_container", { projectId });
export const reconcileProjectStatuses = () =>
invoke<Project[]>("reconcile_project_statuses");
// Settings
export const getSettings = () => invoke<AppSettings>("get_settings");
export const updateSettings = (settings: AppSettings) =>
invoke<AppSettings>("update_settings", { settings });
export const pullImage = (imageName: string) =>
invoke<void>("pull_image", { imageName });
export const detectAwsConfig = () =>
invoke<string | null>("detect_aws_config");
export const listAwsProfiles = () =>
invoke<string[]>("list_aws_profiles");
/** Check a corporate CA path and report what would be installed. Never
* rejects for a bad path — the reason comes back in `error`. */
export const inspectCaCertPath = (path: string) =>
invoke<CaCertInfo>("inspect_ca_cert_path", { path });
export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone");
// AWS
export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId });
// Terminal
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
export const terminalInput = (sessionId: string, data: number[]) =>
invoke<void>("terminal_input", { sessionId, data });
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
invoke<void>("terminal_resize", { sessionId, cols, rows });
export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
export const uploadHostFileToTerminal = (sessionId: string, hostPath: string) =>
invoke<string>("upload_host_file_to_terminal", { sessionId, hostPath });
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 });
// Files
export const listContainerFiles = (projectId: string, path: string) =>
invoke<FileEntry[]>("list_container_files", { projectId, path });
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");
export const checkForUpdates = () =>
invoke<UpdateInfo | null>("check_for_updates");
export const checkImageUpdate = () =>
invoke<ImageUpdateInfo | null>("check_image_update");
// Help
export const getHelpContent = () => invoke<string>("get_help_content");
// Web Terminal
export const startWebTerminal = () =>
invoke<WebTerminalInfo>("start_web_terminal");
export const stopWebTerminal = () =>
invoke<void>("stop_web_terminal");
export const getWebTerminalStatus = () =>
invoke<WebTerminalInfo>("get_web_terminal_status");
export const regenerateWebTerminalToken = () =>
invoke<WebTerminalInfo>("regenerate_web_terminal_token");
// STT
export const getSttStatus = () => invoke<SttStatus>("get_stt_status");
export const startStt = () => invoke<SttStatus>("start_stt");
export const stopStt = () => invoke<void>("stop_stt");
export const buildSttImage = () => invoke<void>("build_stt_image");
export const pullSttImage = () => invoke<void>("pull_stt_image");
export const transcribeAudio = (audioData: number[]) =>
invoke<string>("transcribe_audio", { audioData });
// Model gateway (LiteLLM)
export const getGatewayStatus = () => invoke<GatewayStatus>("get_gateway_status");
export const startGateway = () => invoke<GatewayStatus>("start_gateway");
export const stopGateway = () => invoke<void>("stop_gateway");
export const checkGatewayHealth = () => invoke<boolean>("check_gateway_health");
export const buildGatewayImage = () => invoke<void>("build_gateway_image");
export const pullGatewayImage = () => invoke<void>("pull_gateway_image");
/** Write-only: the provider API key is never read back out of the keychain. */
export const setGatewayApiKey = (apiKey: string) =>
invoke<void>("set_gateway_api_key", { apiKey });
export const clearGatewayApiKey = () => invoke<void>("clear_gateway_api_key");
export const getGatewayAuthToken = () => invoke<string>("get_gateway_auth_token");
export const regenerateGatewayAuthToken = () =>
invoke<string>("regenerate_gateway_auth_token");
// Docker install helper
export const detectInstallOptions = () =>
invoke<InstallOptions>("detect_install_options");
export const runDockerInstall = () => invoke<void>("run_docker_install");
// Container introspection — sessions
export const listClaudeSessions = (projectId: string) =>
invoke<ClaudeSession[]>("list_claude_sessions", { projectId });
export const resumeSessionCommand = (projectId: string, sessionId: string) =>
invoke<string>("resume_session_command", { projectId, sessionId });
// Container introspection — capabilities
export const listContainerCapabilities = (projectId: string) =>
invoke<ContainerCapabilities>("list_container_capabilities", { projectId });
// Container introspection — scheduler
export const listScheduledTasks = (projectId: string) =>
invoke<ScheduledTask[]>("list_scheduled_tasks", { projectId });
/** Returns the new task's id. */
export const addScheduledTask = (projectId: string, input: ScheduledTaskInput) =>
invoke<string>("add_scheduled_task", { projectId, ...input });
/** Edit = add + remove, so this returns a *new* task id (see the Rust doc). */
export const updateScheduledTask = (
projectId: string,
taskId: string,
input: ScheduledTaskInput,
enabled: boolean,
) => invoke<string>("update_scheduled_task", { projectId, taskId, enabled, ...input });
export const getScheduledTaskLog = (projectId: string, taskId: string, tailLines?: number) =>
invoke<string>("get_scheduled_task_log", { projectId, taskId, tailLines });
export const setScheduledTaskEnabled = (projectId: string, taskId: string, enabled: boolean) =>
invoke<string>("set_scheduled_task_enabled", { projectId, taskId, enabled });
export const runScheduledTaskNow = (projectId: string, taskId: string) =>
invoke<string>("run_scheduled_task_now", { projectId, taskId });
export const removeScheduledTask = (projectId: string, taskId: string) =>
invoke<string>("remove_scheduled_task", { projectId, taskId });
export const getSchedulerNotifications = (projectId: string) =>
invoke<SchedulerNotification[]>("get_scheduler_notifications", { projectId });
export const clearSchedulerNotifications = (projectId: string) =>
invoke<void>("clear_scheduler_notifications", { projectId });
// Auth bridge — mirrors container loopback listeners onto host loopback so
// browser OAuth logins started inside the container can complete.
export const setAuthBridgeEnabled = (projectId: string, enabled: boolean) =>
invoke<AuthBridgeStatus>("set_auth_bridge_enabled", { projectId, enabled });
export const getAuthBridgeStatus = (projectId: string) =>
invoke<AuthBridgeStatus>("get_auth_bridge_status", { projectId });
// Browser view — watch and take over the browser Claude drives with Playwright
// inside the container. Off by default, per project. Enabling probes the
// container, starts the Playwright dashboard in it, and puts a token-gated
// listener on the host's loopback in front of it; the returned `url` is the
// only way in, and it is never reachable off the machine.
export const setBrowserViewEnabled = (projectId: string, enabled: boolean) =>
invoke<BrowserViewStatus>("set_browser_view_enabled", { projectId, enabled });
export const getBrowserViewStatus = (projectId: string) =>
invoke<BrowserViewStatus>("get_browser_view_status", { projectId });
/** Probe for Playwright without starting anything — used to re-check after installing it. */
export const checkBrowserViewSupport = (projectId: string) =>
invoke<PlaywrightDetection>("check_browser_view_support", { projectId });
/**
* Install `playwright` + `@playwright/cli` into the container's `/workspace`.
*
* A container mutation, so it only ever runs from an explicit click. Progress
* streams on the existing `container-progress` event; the result carries a
* fresh probe. Browsers are a separate action — see below.
*/
export const installBrowserViewSupport = (projectId: string) =>
invoke<BrowserSetupOutcome>("install_browser_view_support", { projectId });
/**
* Install a browser and the apt libraries it needs, then verify it launches.
* `chromium` is Playwright's own build; `chrome` is the channel
* `@playwright/mcp` asks for. Hundreds of MB — never call this implicitly.
*/
export const installBrowserViewBrowser = (
projectId: string,
browser: BrowserInstallTarget,
) => invoke<BrowserSetupOutcome>("install_browser_view_browser", { projectId, browser });
/**
* Detach the live view into its own OS window, or raise it if already open.
*
* Window-only: the viewer, the proxy and the container are untouched, so
* popping out and back costs nothing. The window loads the same token-bearing
* loopback URL as the pane, and has no IPC access.
*/
export const openBrowserViewPopout = (projectId: string, alwaysOnTop: boolean) =>
invoke<void>("open_browser_view_popout", { projectId, alwaysOnTop });
/** Close the pop-out and put the view back in the tab. No-op if it isn't open. */
export const closeBrowserViewPopout = (projectId: string) =>
invoke<void>("close_browser_view_popout", { projectId });
/**
* Whether the pop-out is open and whether it is pinned, read from the window.
*
* Asked on every mount: the pane is unmounted whenever another Project Home
* sub-tab is selected, while the window carries on — so neither fact can live
* in component state and survive.
*/
export const getBrowserViewPopoutState = (projectId: string) =>
invoke<BrowserViewPopoutState>("get_browser_view_popout_state", { projectId });
/**
* Open a URL in a browser *inside* the container, published so the pane shows it.
*
* The same action serves an auth URL — the OAuth callback listener is in the
* container too, so the loop closes without the host — and a dev server on
* container loopback, which is how you watch a UI Claude is building. Only
* http/https; the backend rejects anything else.
*
* The viewer is started if it isn't already: asking for a page is asking to
* watch it, and leaving the user to go and press Start themselves — with no
* hint that they had to — is what the first version did.
*/
export const openPageInContainerBrowser = (
projectId: string,
url: string,
width: number,
height: number,
/** Also raise the pop-out window — for callers with no pane on screen. */
showWindow = false,
) =>
invoke<BrowserPageState>("open_page_in_container_browser", {
projectId,
url,
width,
height,
showWindow,
});
/** Resize that page. Real reflow, not a scaled screencast — see BrowserTab. */
export const setContainerPageViewport = (projectId: string, width: number, height: number) =>
invoke<void>("set_container_page_viewport", { projectId, width, height });
export const getContainerPageState = (projectId: string) =>
invoke<BrowserPageState>("get_container_page_state", { projectId });
export const closeContainerPage = (projectId: string) =>
invoke<void>("close_container_page", { projectId });
/**
* Make the page track the pop-out window's size as it is dragged.
*
* Only affects a page this app opened: a bound browser admits no second client,
* so one `@playwright/mcp` launched keeps the viewport it was given.
*/
export const setBrowserViewMatchWindow = (projectId: string, enabled: boolean) =>
invoke<void>("set_browser_view_match_window", { projectId, enabled });
export const getBrowserViewMatchWindow = (projectId: string) =>
invoke<boolean>("get_browser_view_match_window", { projectId });
/** Pin the pop-out above other windows — the point of popping it out at all. */
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
// Shared Claude Code auth token — one `claude setup-token` run authenticates
// every Anthropic-backend project. The token itself is never exposed here: it
// lives in the OS keychain and is injected as a container env var.
//
// `acquireClaudeToken` borrows the given project's running container to run the
// login (temporarily enabling its auth bridge), and streams progress on the
// `claude-token-progress` and `claude-token-output` events. It resolves only
// once the whole flow finishes, so call it without awaiting the UI on it.
//
// Partway through, `claude setup-token` prints a sign-in URL and then waits at
// a "Paste code here" prompt: the user signs in, copies the code shown by
// Anthropic, and it is delivered with `submitClaudeTokenCode`. One flow at a
// time — a second `acquireClaudeToken` call rejects while one is in progress.
export const acquireClaudeToken = (projectId: string) =>
invoke<void>("acquire_claude_token", { projectId });
export const submitClaudeTokenCode = (code: string) =>
invoke<void>("submit_claude_token_code", { code });
/** Abort an in-flight acquisition and release the single-flight guard. No-op if nothing is running. */
export const cancelClaudeToken = () => invoke<void>("cancel_claude_token");
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
/** Revoke the shared token. Also rewrites any snapshot image that still has it
* baked into its env — see `ClearTokenOutcome` for what may be left behind. */
export const clearClaudeToken = () =>
invoke<ClearTokenOutcome>("clear_claude_token");
// Container base-image migration — move a project onto the current base image
// without deleting its volumes. Reset is the destructive alternative: it wipes
// ~/.claude, the OAuth credential, installed skills and every transcript.
//
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
// it on demand rather than polling) → migrateProjectToBase → the project sits
// in "awaiting-confirmation" while the user tries it → confirmMigration or
// rollbackMigration.
//
// Rollback restores the **system layer only**. Both named volumes are untouched
// throughout, so anything written to $HOME during the migrated session — a new
// login, new skills, new transcripts — survives a rollback.
//
// Progress arrives on the existing `container-progress` event.
/** Read-only. Runs two container/image filesystem probes; not for polling. */
export const getContainerStaleness = (projectId: string) =>
invoke<ContainerStaleness>("get_container_staleness", { projectId });
/** Runs the whole migration and resolves with its report. Long-running — the
* apt replay alone was measured at ~70s for 8 packages. Calling it again while
* a migration is `interrupted` resumes that one instead of starting a new one. */
export const migrateProjectToBase = (projectId: string, options: MigrationOptions) =>
invoke<MigrationReport>("migrate_project_to_base", { projectId, options });
/** Accept the migration: drops the rollback tag and the staged payload, and
* clears the record. Idempotent. */
export const confirmMigration = (projectId: string) =>
invoke<void>("confirm_migration", { projectId });
/** Undo the migration: recreates the container from its pre-migration image.
* Fails if the migration kept no rollback image (`keep_rollback: false`). */
export const rollbackMigration = (projectId: string) =>
invoke<void>("rollback_migration", { projectId });
/** The persisted record, or null when no migration is in flight. Worth calling
* after `reconcileProjectStatuses` at startup: a migration interrupted by an
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });