diff --git a/.opencode/skills/clack-cli-patterns/SKILL.md b/.opencode/skills/clack-cli-patterns/SKILL.md new file mode 100644 index 00000000..23e9468e --- /dev/null +++ b/.opencode/skills/clack-cli-patterns/SKILL.md @@ -0,0 +1,216 @@ +--- +name: clack-cli-patterns +description: Use when creating or modifying terminal CLI commands, prompts, or output formatting in OpenChamber. Enforces Clack UX standards with strict parity and safety across TTY/non-TTY, --quiet, and --json modes. +license: MIT +compatibility: opencode +--- + +## Overview + +OpenChamber terminal CLI uses `@clack/prompts` for interactive UX, but command policy and validation must be mode-agnostic. + +**Core principle:** policy-first, UX-second. Clack is presentation, not enforcement. + +## Scope + +Use this skill for terminal CLI work only (for example `packages/web/bin/*`). + +Do not use this skill for web UI or VS Code webview styling work. + +## Mandatory Rules + +1. **Validation first** + - Safety and correctness checks must run in all modes. + - Prompts may help collect input, but cannot be the only guard. + +2. **Mode parity is required** + - Behavior must be equivalent in: + - Interactive TTY + - Non-interactive shells + - `--quiet` + - `--json` + - Fully pre-specified flags + - Invalid operations must fail deterministically with non-zero exit code. + +3. **Prompt guard contract** + - Only prompt when all are true: + - stdout is TTY + - not `--quiet` + - not `--json` + - not automated/non-interactive context + +4. **Output contract** + - `--json`: machine-readable output only. + - `--quiet`: suppress non-essential output only. + - Neither mode weakens policy enforcement. + +5. **Cancellation contract** + - Handle prompt cancellation with `isCancel` + `cancel(...)`. + - Handle SIGINT cleanly and use consistent exit semantics. + +## Clack Primitive Standard + +- **Flow framing:** `intro`, `outro`, `cancel` +- **Status lines:** `log.info`, `log.success`, `log.warn`, `log.error`, `log.step` +- **Guidance blocks:** + - default: `note` + - high-severity warnings only: `box` +- **Prompts:** `select`, `confirm`, `text`, `password` +- **Long-running feedback:** + - unknown duration: `spinner` + - known duration: `progress` + - multi-stage: `tasks` + +## Preferred Pattern + +Centralize Clack imports and formatting helpers in one adapter module (for example `cli-output.js`) so command logic stays focused on behavior and policy. + +### Thin framework (recommended) + +Use a small shared helper surface rather than command-specific formatting logic. + +- `isJsonMode(options)` +- `isQuietMode(options)` +- `shouldRenderHumanOutput(options)` +- `canPrompt(options)` +- `createSpinner(options)` +- `createProgress(options, config)` +- `printJson(payload)` + +Keep this layer minimal. Do not hide core validation or command semantics inside output helpers. + +## Output Contracts by Mode + +### `--quiet` contract + +`--quiet` should still return essential result data. + +- Read/list commands: emit concise machine-friendly lines (not framed Clack blocks). +- Action commands: emit one minimal success line and concise errors. +- Do not suppress required outcomes entirely. + +Quiet output should still be complete enough for scripts and quick human scanning. + +- Status-like commands should list all active items, not only `running`/`ok`. +- Prefer compact stable key tokens in quiet lines (for example `port 3000 pass:yes`). + +### `--json` contract (strict) + +- Output must be JSON only (no extra text before/after payload). +- Warnings/info should be represented in JSON fields (for example `status`, `messages`). +- Preserve non-zero exit codes for failures. + +## Human UX Consistency + +### Framing completeness + +- If human flow uses `intro`, close with `outro` (or `outro('')` when you want structure without text). +- Avoid orphan frame/spinner artifacts (prefer `spinner.clear()` when a trailing spinner line is not wanted). +- If a structured summary section immediately follows a spinner, prefer `spinner.clear()` to avoid duplicate success lines. + +### Progress feedback for visible operations + +- For operations users wait on (start/stop/restart/tunnel lifecycle), show in-progress spinner in interactive mode. +- Resolve each spinner explicitly to done/error so users can see completion state at the same visual location. +- Keep quiet/json modes non-animated. + +### Prompt flow design + +- Ask required inputs in dependency order (for example hostname before token when token depends on chosen host/mode context). +- When offering save-vs-run flows, ask intent before collecting optional metadata (for example profile name only if user chooses save). +- Prefill editable values with `initialValue` (not only `placeholder`) so users can accept or edit quickly. +- Reuse latest relevant values when safe (for example last managed-local config path, last managed-remote hostname). + +### Readability on narrow terminals + +- Prefer short lines. +- Split long guidance into multiple detail lines. +- Use warning/info codes (`[CODE]`) when the message has follow-up docs or repeat use. + +### Guidance tone + +- Use `Optional Tips` for non-required next actions. +- Avoid wording that implies mandatory follow-up unless it is truly required. + +### Guidance rendering style (preferred) + +- Prefer structured status lines for reusable hints: + - `logStatus('info', '[CODE]', '')` +- Use short, stable codes (for example `[START_PROFILE]`, `[PORT_MISMATCH]`) so users can quickly scan and recognize repeated guidance. +- Prefer this style over boxed notes for routine follow-up actions. +- Reserve `note`/boxed callouts for rare, high-context guidance where a long paragraph is truly necessary. + +## Parity Verification Matrix + +For each command/subcommand, manually verify: + +1. default interactive TTY output +2. `--quiet` output (minimal but informative) +3. `--json` output (JSON-only) +4. non-TTY behavior (e.g. piped) +5. error path in both human and json modes + +## Copy/Paste Snippets + +### Prompt Guard + +```js +if (canPrompt(options)) { + const value = await select({ + message: 'Choose an option', + options: [{ value: 'a', label: 'Option A' }], + }); + if (isCancel(value)) { + cancel('Operation cancelled.'); + return; + } +} +``` + +### Non-Interactive Fallback + +```js +if (!resolvedValue) { + if (canPrompt(options)) { + // prompt path + } else { + throw new Error('Missing required value. Provide --flag .'); + } +} +``` + +### Spinner Guard + +```js +const spin = createSpinner(options); +spin?.start('Running operation...'); +// ...work... +spin?.stop('Done'); +``` + +### JSON vs Human Output + +```js +if (options.json) { + printJson({ ok: true, data }); + return; +} + +intro('Operation'); +log.success('Completed'); +outro('done'); +``` + +## Implementation Checklist + +1. Add or update core validators first. +2. Ensure validators execute in all modes. +3. Add interactive Clack UX only as enhancement. +4. Verify parity between interactive and non-interactive flows. +5. Ensure script-safe deterministic failure behavior. + +## References + +- Policy source: `AGENTS.md` (CLI Parity and Safety Policy) +- Terminal CLI precedent: `packages/web/bin/cli.js` +- Output adapter precedent: `packages/web/bin/cli-output.js` diff --git a/AGENTS.md b/AGENTS.md index e6fbf176..53c09084 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,50 @@ All scripts are in `package.json`. - No new deps unless asked. - Never add secrets (`.env`, keys) or log sensitive data. +## CLI Parity and Safety Policy (MANDATORY) + +### Principle: policy-first, UX-second + +All safety and correctness rules MUST be enforced in core command logic, independent of output mode. + +Interactive/pretty UX (`@clack/prompts`) is a presentation layer only. +It must never be the only place where validation or restriction is enforced. + +### Required parity across modes + +The same functional outcome and safety gates MUST hold for all execution modes: + +- Interactive TTY (full Clack UX) +- Non-interactive shells (piped/stdin-less automation) +- `--quiet` +- `--json` +- Fully pre-specified flags (no prompts) + +In all modes, invalid operations MUST fail with non-zero exit code and deterministic error semantics. + +### Non-negotiable rule + +Do not rely on prompts to enforce policy. + +- Prompts MAY help users choose valid inputs. +- Core validators MUST run even when prompts are unavailable or skipped. +- `--quiet` suppresses non-essential output only; it does not weaken validation. +- `--json` changes output shape only; it does not weaken validation. + +Detailed Clack UX patterns (primitives, prompt gating, and implementation checklist) +are defined in the `clack-cli-patterns` skill and should not be duplicated here. + +## Clack CLI Skill (MANDATORY for terminal CLI work) + +When working on terminal CLI commands, prompts, or output formatting, agents **MUST** study the Clack CLI skill first. + +**Before starting terminal CLI work:** +``` +skill({ name: "clack-cli-patterns" }) +``` + +Scope: terminal CLI only (for example `packages/web/bin/*`). Do not apply this requirement to VS Code or web UI work. + ## Theme System (MANDATORY for UI work) When working on any UI components, styling, or visual changes, agents **MUST** study the theme system skill first. diff --git a/README.md b/README.md index 91755872..ab8d1935 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,53 @@ -## Highlights +## Why use OpenChamber? + +- **Cross-device continuity**: Start in TUI, continue on tablet/phone, return to terminal - same session +- **Remote access**: Use OpenCode from anywhere via browser +- **Familiarity**: A visual alternative for developers who prefer GUI workflows + +## Features + +### Core (all app versions) + +- Branchable chat timeline with `/undo`, `/redo`, and one-click forks from earlier turns +- Smart tool UIs for diffs, file operations, permissions, and long-running task progress +- Voice mode with speech input and read-aloud responses for hands-free workflows +- Multi-agent runs from one prompt with isolated worktrees for safe side-by-side comparisons +- Git workflows in-app: identities, commits, PR creation, checks, and merge actions +- GitHub-native workflows: start sessions from issues and pull requests with context already attached +- Plan/Build mode with a dedicated plan view for drafting and iterating implementation steps +- Inline comment drafts on diffs, files, and plans that can be sent back to the agent +- Context visibility tools (token/cost breakdowns, raw message inspection, and activity summaries) +- Integrated terminal with per-directory sessions and stable performance on heavy output +- Built-in skills catalog and local skill management for reusable automation workflows + +### Web / PWA + +- Provider-aware tunnel access model with Cloudflare `quick`, `managed-remote`, and `managed-local` modes +- One-scan onboarding with tunnel QR + password URL helpers +- Mobile-first experience: optimized chat controls, keyboard-safe layouts, and attachment-friendly UI +- Background notifications plus reliable cross-tab session activity tracking +- Built-in self-update + restart flow that keeps your server settings intact + +### Desktop (macOS) + +- Native macOS menu integration with polished app actions and deep-link handling +- Multi-window support for parallel project/session workflows +- "Open In" shortcuts for Finder, Terminal, and your preferred editor +- Fast switching between local and remote instances +- Workspace-first startup flow with directory picker and steadier window restore behavior + +### VS Code Extension + +- Editor-native workflow: open files directly from tool output and keep sessions beside your code +- Agent Manager for parallel multi-model runs from a single prompt +- Right-click actions to add context, explain selections, and improve code in-place +- In-extension settings, responsive layout, and theme mapping that matches your editor +- Hardened runtime lifecycle and health checks for faster startup and fewer stuck reconnect states + +### Custom Themes - **Use it from anywhere** - Cloudflare tunnel with QR code onboarding. Scan, connect, code from your couch. - **Branchable chat timeline** - Undo, redo, fork from any turn. Explore different approaches without losing your place. @@ -59,11 +105,18 @@ openchamber --ui-password be-creative-here --daemon ```bash openchamber --port 8080 # Custom port -openchamber --daemon # Background mode openchamber --ui-password secret # Password-protect UI -openchamber --try-cf-tunnel # Cloudflare Quick Tunnel -openchamber --try-cf-tunnel --tunnel-qr # + QR code -openchamber --try-cf-tunnel --tunnel-password-url # + password in URL +openchamber tunnel help # Tunnel lifecycle commands +openchamber tunnel providers # Show provider capabilities +openchamber tunnel profile add --provider cloudflare --mode managed-remote --name prod-main --hostname app.example.com --token +openchamber tunnel start --profile prod-main +openchamber tunnel start --provider cloudflare --mode quick --qr +openchamber tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml +openchamber tunnel status --all # Show tunnel state across instances +openchamber tunnel stop --port 3000 # Stop tunnel only (server stays running) +openchamber logs # Follow latest instance logs +OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server +OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber # Connect via custom host/HTTPS openchamber stop # Stop server openchamber update # Update to latest ``` @@ -103,7 +156,24 @@ environment: | `qr` | Enable tunnel + QR code | | `password` | Enable tunnel + password in URL | -**Data directory permissions:** The `data/` directory is mounted for persistent storage. Before running: +### Managed Cloudflare Tunnel (persistent hostname) + +OpenChamber also supports managed-remote mode for more reliable long-lived access with your Cloudflare account and custom hostname. + +- Configure it in-app at **Settings -> OpenChamber -> Tunnel** and switch mode to **Managed Remote Tunnel**. +- Managed-remote tunnels require a domain in your Cloudflare account. +- Cloudflare setup guide: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/ +- Managed-local mode uses your local cloudflared config, for example: + - `openchamber tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml` + +### Tunnel behavior notes + +- OpenChamber supports one active tunnel per running instance (port). +- Starting a tunnel with a different mode/provider on the same instance replaces the current tunnel. +- Replacing or stopping a tunnel revokes existing connect links and invalidates remote tunnel sessions for that instance. +- Connect links are one-time tokens; generating a new link revokes the previous unused link. + +**Data Directory Permission Note:** The `data/` directory is mounted into the container for persistent storage (config, sessions, SSH keys, workspaces). Before running, ensure the directory exists and has proper permissions: ```bash mkdir -p data/openchamber data/opencode/share data/opencode/config data/ssh @@ -117,6 +187,9 @@ chown -R 1000:1000 data/
Named Cloudflare Tunnel (persistent hostname) +- [OpenCode CLI](https://opencode.ai) installed +- Node.js 20+ (for web version) +- [cloudflared](https://github.com/cloudflare/cloudflared/releases) (required for Cloudflare tunnel modes) For reliable long-lived access with a custom hostname from your Cloudflare account: - Configure in-app at **Settings > OpenChamber > Tunnel**, switch to **Named** mode. diff --git a/bun.lock b/bun.lock index a488d8ed..952347b5 100644 --- a/bun.lock +++ b/bun.lock @@ -239,6 +239,7 @@ "openchamber": "./bin/cli.js", }, "dependencies": { + "@clack/prompts": "^1.1.0", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-go": "^6.0.1", "@fontsource/ibm-plex-mono": "^5.2.7", @@ -534,6 +535,10 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], + + "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg=="], "@codemirror/commands": ["@codemirror/commands@6.10.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ=="], @@ -2624,6 +2629,8 @@ "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], "slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 626809fa..19516dac 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -25,8 +25,34 @@ Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). A Plus everything from the shared OpenChamber UI: branchable timeline, Git sidebar, terminal, voice mode, and more. -
-Development +## Features + +### Core UI + +- Branchable chat timeline with `/undo`, `/redo`, and one-click forks from earlier turns +- Smart tool UIs for diffs, file operations, permissions, and long-running task progress +- Multi-agent runs from one prompt with isolated worktrees for safe comparisons +- Git workflows in-app: identities, commits, PR creation, checks, and merge actions +- Context visibility tools (token/cost breakdowns, raw message inspection, and activity summaries) +- Integrated terminal with per-directory sessions and stable performance on heavy output + +### Desktop (macOS) + +- Native macOS menu integration with polished app actions and deep-link handling +- Multi-window support for parallel project/session workflows +- "Open In" shortcuts for Finder, Terminal, and your preferred editor +- Fast switching between local and remote instances +- Workspace-first startup flow with directory picker and steadier window restore behavior + +### Remote Tunnel (Desktop) + +- Configure in **Settings -> OpenChamber -> Remote Tunnel**. +- Supported Cloudflare modes: **Quick**, **Managed Remote**, **Managed Local**. +- One active tunnel per Desktop instance. Starting a different mode replaces the current tunnel. +- Replacing or stopping a tunnel revokes existing connect links and invalidates remote tunnel sessions. +- Connect links are one-time tokens; generate a new link for each new connection attempt. + +## Development ```bash git clone https://github.com/btriapitsyn/openchamber.git diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx index 8b66a380..d09c82f0 100644 --- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx @@ -6,10 +6,13 @@ import { RiArrowRightSLine, RiCheckboxBlankCircleFill, RiCheckLine, + RiCloseLine, + RiCloudLine, RiDeleteBinLine, RiErrorWarningLine, RiExternalLinkLine, RiFileCopyLine, + RiFolderLine, RiInformationLine, RiLoader4Line, RiRestartLine, @@ -21,6 +24,7 @@ import { GridLoader } from '@/components/ui/grid-loader'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { requestFileAccess } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { cn } from '@/lib/utils'; @@ -34,9 +38,10 @@ type TunnelState = | 'error'; type TtlOption = { value: string; label: string; ms: number | null }; -type TunnelMode = 'quick' | 'named'; +type TunnelMode = 'quick' | 'managed-remote' | 'managed-local'; +type ApiTunnelMode = TunnelMode; -interface NamedTunnelPreset { +interface ManagedRemoteTunnelPreset { id: string; name: string; hostname: string; @@ -57,9 +62,22 @@ const SESSION_TTL_OPTIONS: TtlOption[] = [ { value: '86400000', label: '24h', ms: 24 * 60 * 60 * 1000 }, ]; -const QUICK_TUNNEL_TOOLTIP = 'Quick Tunnel is best effort and Cloudflare does not guarantee uptime. For more reliable long-lived access, switch to Named Tunnel mode.'; -const NAMED_TUNNEL_TOOLTIP = 'Named Tunnel mode uses your Cloudflare account and custom hostname for more reliable remote access.'; -const NAMED_TUNNEL_DOC_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/'; +const MANAGED_REMOTE_TUNNEL_DOC_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/'; +const MANAGED_LOCAL_TUNNEL_DOC_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/'; + +const TUNNEL_MODE_OPTIONS: Array<{ value: TunnelMode; label: string; tooltip: string }> = [ + { value: 'quick', label: 'Quick', tooltip: 'Quick Tunnel is best effort and Cloudflare does not guarantee uptime.' }, + { value: 'managed-remote', label: 'Managed Remote', tooltip: 'Managed Remote uses your Cloudflare account and hostname for long-lived access.' }, + { value: 'managed-local', label: 'Managed Local', tooltip: 'Managed Local uses your local cloudflared configuration file.' }, +]; + +const MANAGED_LOCAL_CONFIG_ALLOWED_EXTENSIONS = ['.yml', '.yaml', '.json']; +const MANAGED_LOCAL_CONFIG_EXTENSION_ERROR = 'Config file must use .yml, .yaml, or .json extension.'; + +const hasAllowedManagedLocalConfigExtension = (filePath: string): boolean => { + const normalized = filePath.trim().toLowerCase(); + return MANAGED_LOCAL_CONFIG_ALLOWED_EXTENSIONS.some((extension) => normalized.endsWith(extension)); +}; interface TunnelInfo { url: string; @@ -81,13 +99,18 @@ interface TunnelSessionRecord { interface TunnelStatusResponse { active: boolean; url: string | null; - mode?: TunnelMode; - hasNamedTunnelToken?: boolean; - namedTunnelHostname?: string | null; + mode?: ApiTunnelMode; + hasManagedRemoteTunnelToken?: boolean; + managedRemoteTunnelHostname?: string | null; hasBootstrapToken?: boolean; bootstrapExpiresAt?: number | null; - namedTunnelTokenPresetIds?: string[]; - activeTunnelMode?: TunnelMode | null; + managedRemoteTunnelTokenPresetIds?: string[]; + managedRemoteTunnelPresets?: ManagedRemoteTunnelPreset[]; + activeTunnelMode?: ApiTunnelMode | null; + providerMetadata?: { + configPath?: string | null; + resolvedHostname?: string | null; + }; activeSessions?: TunnelSessionRecord[]; localPort?: number; policy?: string; @@ -97,6 +120,64 @@ interface TunnelStatusResponse { }; } +interface TunnelStartResponse { + ok?: boolean; + error?: string; + url?: string; + connectUrl?: string | null; + bootstrapExpiresAt?: number | null; + activeTunnelMode?: ApiTunnelMode | null; + mode?: ApiTunnelMode; + activeSessions?: TunnelSessionRecord[]; + managedRemoteTunnelTokenPresetIds?: string[]; + localPort?: number; + replacedTunnel?: boolean; + revokedBootstrapCount?: number; + invalidatedSessionCount?: number; +} + +interface TunnelProviderModeDescriptor { + key: TunnelMode; + label: string; +} + +interface TunnelProviderCapability { + provider: string; + modes?: TunnelProviderModeDescriptor[]; +} + +const getProviderLabel = (provider: string): string => { + if (provider === 'cloudflare') { + return 'Cloudflare'; + } + return provider; +}; + +const ProviderOptionLabel: React.FC<{ provider: string }> = ({ provider }) => { + const label = getProviderLabel(provider); + const isCloudflare = provider === 'cloudflare'; + + return ( + + + {label} + + ); +}; + +const toUiTunnelMode = (mode: string | null | undefined): TunnelMode => { + if (mode === 'quick') { + return 'quick'; + } + if (mode === 'managed-remote') { + return 'managed-remote'; + } + if (mode === 'managed-local') { + return 'managed-local'; + } + return 'quick'; +}; + const ttlOptionValue = (options: TtlOption[], ttlMs: number | null, fallback: string) => { const matched = options.find((entry) => entry.ms === ttlMs); return matched?.value || fallback; @@ -136,14 +217,14 @@ const normalizePresetHostname = (value: string): string => { } }; -const sanitizePresets = (value: unknown): NamedTunnelPreset[] => { +const sanitizePresets = (value: unknown): ManagedRemoteTunnelPreset[] => { if (!Array.isArray(value)) { return []; } const seenIds = new Set(); const seenHosts = new Set(); - const result: NamedTunnelPreset[] = []; + const result: ManagedRemoteTunnelPreset[] = []; for (const entry of value) { if (!entry || typeof entry !== 'object') { @@ -180,13 +261,16 @@ export const TunnelSettings: React.FC = () => { const [activeTunnelMode, setActiveTunnelMode] = React.useState(null); const [qrDataUrl, setQrDataUrl] = React.useState(null); const [errorMessage, setErrorMessage] = React.useState(null); - const [namedValidationError, setNamedValidationError] = React.useState(null); + const [managedRemoteValidationError, setManagedRemoteValidationError] = React.useState(null); const [copied, setCopied] = React.useState(false); const [isSavingTtl, setIsSavingTtl] = React.useState(false); const [isSavingMode, setIsSavingMode] = React.useState(false); + const [tunnelProvider, setTunnelProvider] = React.useState('cloudflare'); + const [providerCapabilities, setProviderCapabilities] = React.useState([]); const [tunnelMode, setTunnelMode] = React.useState('quick'); - const [namedTunnelPresets, setNamedTunnelPresets] = React.useState([]); - const [expandedNamedTunnels, setExpandedNamedTunnels] = React.useState>({}); + const [managedLocalConfigPath, setManagedLocalConfigPath] = React.useState(null); + const [managedRemoteTunnelPresets, setManagedRemoteTunnelPresets] = React.useState([]); + const [expandedManagedRemoteTunnels, setExpandedManagedRemoteTunnels] = React.useState>({}); const [selectedPresetId, setSelectedPresetId] = React.useState(''); const [sessionTokensByPresetId, setSessionTokensByPresetId] = React.useState>({}); const [savedTokenPresetIds, setSavedTokenPresetIds] = React.useState>(new Set()); @@ -200,10 +284,17 @@ export const TunnelSettings: React.FC = () => { const [sessionRecords, setSessionRecords] = React.useState([]); const [nowTs, setNowTs] = React.useState(() => Date.now()); const [localPort, setLocalPort] = React.useState(null); + const managedLocalConfigFileInputRef = React.useRef(null); + const isManagedLocalConfigPathInvalid = React.useMemo(() => { + if (!managedLocalConfigPath) { + return false; + } + return !hasAllowedManagedLocalConfigExtension(managedLocalConfigPath); + }, [managedLocalConfigPath]); const selectedPreset = React.useMemo( - () => namedTunnelPresets.find((preset) => preset.id === selectedPresetId) || namedTunnelPresets[0] || null, - [namedTunnelPresets, selectedPresetId] + () => managedRemoteTunnelPresets.find((preset) => preset.id === selectedPresetId) || managedRemoteTunnelPresets[0] || null, + [managedRemoteTunnelPresets, selectedPresetId] ); const renderedSessionRecords = React.useMemo(() => { return sessionRecords.map((record) => { @@ -216,7 +307,7 @@ export const TunnelSettings: React.FC = () => { ? 'Expired' : (record.inactiveReason === 'tunnel-revoked' ? 'Revoked' : 'Inactive'); - const mode = record.mode === 'named' ? 'named' : 'quick'; + const mode = toUiTunnelMode(record.mode); return { ...record, isActive, @@ -244,6 +335,15 @@ export const TunnelSettings: React.FC = () => { } return activeTunnelMode === tunnelMode; }, [activeTunnelMode, state, tunnelInfo, tunnelMode]); + const willReplaceActiveTunnel = React.useMemo(() => { + if (!tunnelInfo || state !== 'active') { + return false; + } + if (!activeTunnelMode) { + return false; + } + return activeTunnelMode !== tunnelMode; + }, [activeTunnelMode, state, tunnelInfo, tunnelMode]); const suggestedConnectorPort = React.useMemo(() => { if (typeof localPort === 'number' && Number.isFinite(localPort) && localPort > 0) { return localPort; @@ -282,15 +382,17 @@ export const TunnelSettings: React.FC = () => { const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => { try { - const [checkRes, statusRes, settingsRes] = await Promise.all([ + const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([ fetch('/api/openchamber/tunnel/check', { signal }), fetch('/api/openchamber/tunnel/status', { signal }), fetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }), + fetch('/api/openchamber/tunnel/providers', { signal }), ]); const checkData = await checkRes.json(); const statusData = (await statusRes.json()) as TunnelStatusResponse; const settingsData = settingsRes.ok ? await settingsRes.json() : {}; + const providersData = providersRes.ok ? await providersRes.json() : {}; const loadedBootstrapTtl = statusData.ttlConfig?.bootstrapTtlMs ?? (settingsData?.tunnelBootstrapTtlMs === null @@ -304,41 +406,45 @@ export const TunnelSettings: React.FC = () => { ? settingsData.tunnelSessionTtlMs : 8 * 60 * 60 * 1000; - const loadedMode: TunnelMode = statusData.mode === 'named' - ? 'named' - : settingsData?.tunnelMode === 'named' - ? 'named' - : 'quick'; + const loadedMode: TunnelMode = toUiTunnelMode(statusData.mode ?? settingsData?.tunnelMode); + const loadedProvider = typeof settingsData?.tunnelProvider === 'string' && settingsData.tunnelProvider.trim().length > 0 + ? settingsData.tunnelProvider.trim().toLowerCase() + : 'cloudflare'; + const loadedManagedLocalConfigPath = typeof settingsData?.managedLocalTunnelConfigPath === 'string' + ? settingsData.managedLocalTunnelConfigPath.trim() || null + : null; - const loadedHostname = typeof statusData.namedTunnelHostname === 'string' - ? statusData.namedTunnelHostname - : typeof settingsData?.namedTunnelHostname === 'string' - ? settingsData.namedTunnelHostname - : ''; - - const loadedPresets = sanitizePresets(settingsData?.namedTunnelPresets); - const presets = loadedPresets.length > 0 - ? loadedPresets + const loadedPresetsFromStatus = sanitizePresets(statusData?.managedRemoteTunnelPresets); + const loadedHostname = typeof statusData.managedRemoteTunnelHostname === 'string' + ? statusData.managedRemoteTunnelHostname + : ''; + const presets = loadedPresetsFromStatus.length > 0 + ? loadedPresetsFromStatus : (loadedHostname - ? [{ id: 'legacy-default', name: 'Default', hostname: normalizePresetHostname(loadedHostname) }] + ? [{ + id: `legacy-${normalizePresetHostname(loadedHostname)}`, + name: loadedHostname, + hostname: normalizePresetHostname(loadedHostname), + }] : []); - const requestedPresetId = typeof settingsData?.namedTunnelSelectedPresetId === 'string' - ? settingsData.namedTunnelSelectedPresetId.trim() - : ''; - - const selectedId = (requestedPresetId && presets.some((preset) => preset.id === requestedPresetId)) - ? requestedPresetId - : presets[0]?.id || ''; + const selectedId = presets[0]?.id || ''; setBootstrapTtlMs(loadedBootstrapTtl); setSessionTtlMs(loadedSessionTtl); + setTunnelProvider(loadedProvider); + setProviderCapabilities(Array.isArray(providersData?.providers) ? providersData.providers : []); setTunnelMode(loadedMode); - setNamedTunnelPresets(presets); + setManagedLocalConfigPath(loadedManagedLocalConfigPath); + setManagedRemoteTunnelPresets(presets); setSelectedPresetId(selectedId); setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []); - setActiveTunnelMode(statusData.activeTunnelMode || (statusData.active && statusData.mode ? statusData.mode : null)); - setSavedTokenPresetIds(new Set(Array.isArray(statusData.namedTunnelTokenPresetIds) ? statusData.namedTunnelTokenPresetIds : [])); + setActiveTunnelMode( + statusData.activeTunnelMode + ? toUiTunnelMode(statusData.activeTunnelMode) + : (statusData.active && statusData.mode ? toUiTunnelMode(statusData.mode) : null) + ); + setSavedTokenPresetIds(new Set(Array.isArray(statusData.managedRemoteTunnelTokenPresetIds) ? statusData.managedRemoteTunnelTokenPresetIds : [])); setLocalPort(typeof statusData.localPort === 'number' ? statusData.localPort : null); if (statusData.active && statusData.url) { @@ -434,7 +540,7 @@ export const TunnelSettings: React.FC = () => { return; } setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []); - setSavedTokenPresetIds(new Set(Array.isArray(statusData.namedTunnelTokenPresetIds) ? statusData.namedTunnelTokenPresetIds : [])); + setSavedTokenPresetIds(new Set(Array.isArray(statusData.managedRemoteTunnelTokenPresetIds) ? statusData.managedRemoteTunnelTokenPresetIds : [])); setLocalPort(typeof statusData.localPort === 'number' ? statusData.localPort : null); } catch { // ignore transient refresh failures @@ -452,11 +558,11 @@ export const TunnelSettings: React.FC = () => { }, [state]); const saveTunnelSettings = React.useCallback(async (payload: { + tunnelProvider?: string; tunnelMode?: TunnelMode; - namedTunnelHostname?: string; - namedTunnelPresets?: NamedTunnelPreset[]; - namedTunnelSelectedPresetId?: string; - namedTunnelPresetTokens?: Record; + managedLocalTunnelConfigPath?: string | null; + managedRemoteTunnelPresets?: ManagedRemoteTunnelPreset[]; + managedRemoteTunnelPresetTokens?: Record; tunnelBootstrapTtlMs?: number | null; tunnelSessionTtlMs?: number; }) => { @@ -466,11 +572,14 @@ export const TunnelSettings: React.FC = () => { if (Object.prototype.hasOwnProperty.call(payload, 'tunnelMode') && payload.tunnelMode) { setTunnelMode(payload.tunnelMode); } - if (Object.prototype.hasOwnProperty.call(payload, 'namedTunnelPresets') && payload.namedTunnelPresets) { - setNamedTunnelPresets(payload.namedTunnelPresets); + if (Object.prototype.hasOwnProperty.call(payload, 'tunnelProvider') && typeof payload.tunnelProvider === 'string') { + setTunnelProvider(payload.tunnelProvider); } - if (Object.prototype.hasOwnProperty.call(payload, 'namedTunnelSelectedPresetId')) { - setSelectedPresetId(payload.namedTunnelSelectedPresetId || ''); + if (Object.prototype.hasOwnProperty.call(payload, 'managedLocalTunnelConfigPath')) { + setManagedLocalConfigPath(payload.managedLocalTunnelConfigPath ?? null); + } + if (Object.prototype.hasOwnProperty.call(payload, 'managedRemoteTunnelPresets') && payload.managedRemoteTunnelPresets) { + setManagedRemoteTunnelPresets(payload.managedRemoteTunnelPresets); } } catch { toast.error('Failed to save tunnel settings'); @@ -493,7 +602,7 @@ export const TunnelSettings: React.FC = () => { } }, []); - const persistNamedTunnelToken = React.useCallback(async (payload: { + const persistManagedRemoteTunnelToken = React.useCallback(async (payload: { presetId: string; presetName: string; hostname: string; @@ -510,7 +619,7 @@ export const TunnelSettings: React.FC = () => { [payload.presetId]: token, }; await updateDesktopSettings({ - namedTunnelPresetTokens: tokenMap, + managedRemoteTunnelPresetTokens: tokenMap, }); setSavedTokenPresetIds((prev) => { const next = new Set(prev); @@ -518,35 +627,103 @@ export const TunnelSettings: React.FC = () => { return next; }); } catch { - toast.error('Failed to save named tunnel token'); + toast.error('Failed to save managed remote tunnel token'); } }, [sessionTokensByPresetId]); - const handleStart = React.useCallback(async () => { - setState('starting'); + const handleProviderChange = React.useCallback(async (provider: string) => { + setManagedRemoteValidationError(null); setErrorMessage(null); - setNamedValidationError(null); + await saveTunnelSettings({ tunnelProvider: provider }); + }, [saveTunnelSettings]); + + const handleBrowseManagedLocalConfig = React.useCallback(async () => { + const result = await requestFileAccess({ + filters: [{ name: 'Config', extensions: ['yml', 'yaml', 'json'] }], + }); + + if (result.success && typeof result.path === 'string' && result.path.trim().length > 0) { + const nextPath = result.path.trim(); + if (!hasAllowedManagedLocalConfigExtension(nextPath)) { + toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR); + return; + } + setManagedLocalConfigPath(nextPath); + await saveTunnelSettings({ managedLocalTunnelConfigPath: nextPath }); + return; + } + + managedLocalConfigFileInputRef.current?.click(); + }, [saveTunnelSettings]); + + const handleManagedLocalConfigInputChange = React.useCallback((value: string) => { + const trimmed = value.trim(); + setManagedLocalConfigPath(trimmed.length > 0 ? trimmed : null); + }, []); + + const handleManagedLocalConfigInputBlur = React.useCallback(async () => { + if (managedLocalConfigPath && !hasAllowedManagedLocalConfigExtension(managedLocalConfigPath)) { + toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR); + return; + } + await saveTunnelSettings({ managedLocalTunnelConfigPath: managedLocalConfigPath }); + }, [managedLocalConfigPath, saveTunnelSettings]); + + const handleManagedLocalConfigClear = React.useCallback(async () => { + setManagedLocalConfigPath(null); + await saveTunnelSettings({ managedLocalTunnelConfigPath: null }); + }, [saveTunnelSettings]); + + const handleManagedLocalConfigFileSelected = React.useCallback(async (event: React.ChangeEvent) => { + const selected = event.target.files?.[0]; + if (!selected) { + return; + } + + const fallbackPath = selected.name.trim(); + if (fallbackPath.length === 0) { + return; + } + if (!hasAllowedManagedLocalConfigExtension(fallbackPath)) { + toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR); + return; + } + + setManagedLocalConfigPath(fallbackPath); + await saveTunnelSettings({ managedLocalTunnelConfigPath: fallbackPath }); + event.target.value = ''; + }, [saveTunnelSettings]); + + const handleStart = React.useCallback(async () => { + setErrorMessage(null); + setManagedRemoteValidationError(null); + + if (tunnelMode === 'managed-local' && managedLocalConfigPath && !hasAllowedManagedLocalConfigExtension(managedLocalConfigPath)) { + setErrorMessage(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR); + toast.error(MANAGED_LOCAL_CONFIG_EXTENSION_ERROR); + return; + } + + setState('starting'); try { - let namedTunnelHostname = ''; - let namedTunnelToken = ''; + let managedRemoteTunnelHostname = ''; + let managedRemoteTunnelToken = ''; - if (tunnelMode === 'named') { + if (tunnelMode === 'managed-remote') { if (!selectedPreset) { setState('idle'); - setNamedValidationError('Select or add a named tunnel first'); - toast.error('Select or add a named tunnel first'); + setManagedRemoteValidationError('Select or add a managed remote tunnel first'); + toast.error('Select or add a managed remote tunnel first'); return; } - namedTunnelHostname = selectedPreset.hostname; - namedTunnelToken = (sessionTokensByPresetId[selectedPreset.id] || '').trim(); + managedRemoteTunnelHostname = selectedPreset.hostname; + managedRemoteTunnelToken = (sessionTokensByPresetId[selectedPreset.id] || '').trim(); await saveTunnelSettings({ - tunnelMode: 'named', - namedTunnelHostname, - namedTunnelPresets, - namedTunnelSelectedPresetId: selectedPreset.id, + tunnelMode: 'managed-remote', + managedRemoteTunnelPresets, }); } @@ -554,22 +731,24 @@ export const TunnelSettings: React.FC = () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + provider: tunnelProvider, mode: tunnelMode, - ...(tunnelMode === 'named' && selectedPreset ? { - namedTunnelPresetId: selectedPreset.id, - namedTunnelPresetName: selectedPreset.name, + ...(tunnelMode === 'managed-remote' && selectedPreset ? { + managedRemoteTunnelPresetId: selectedPreset.id, + managedRemoteTunnelPresetName: selectedPreset.name, } : {}), - ...(tunnelMode === 'named' && namedTunnelHostname ? { namedTunnelHostname } : {}), - ...(tunnelMode === 'named' && namedTunnelToken ? { namedTunnelToken } : {}), + ...(tunnelMode === 'managed-remote' && managedRemoteTunnelHostname ? { managedRemoteTunnelHostname } : {}), + ...(tunnelMode === 'managed-remote' && managedRemoteTunnelToken ? { managedRemoteTunnelToken } : {}), + ...(tunnelMode === 'managed-local' && managedLocalConfigPath ? { configPath: managedLocalConfigPath } : {}), }), }); - const data = await res.json(); + const data = (await res.json()) as TunnelStartResponse; if (!res.ok || !data.ok) { - if (tunnelMode === 'named' && typeof data.error === 'string' && data.error.includes('Named tunnel token is required')) { + if (tunnelMode === 'managed-remote' && typeof data.error === 'string' && data.error.includes('Managed remote tunnel token is required')) { setState('idle'); - setNamedValidationError('Named tunnel token is required before starting'); - toast.error('Add a named tunnel token before starting'); + setManagedRemoteValidationError('Managed remote tunnel token is required before starting'); + toast.error('Add a managed remote tunnel token before starting'); return; } setState('error'); @@ -578,37 +757,55 @@ export const TunnelSettings: React.FC = () => { return; } + const startedUrl = typeof data.url === 'string' ? data.url : ''; + if (!startedUrl) { + setState('error'); + setErrorMessage('Tunnel started but no public URL was returned'); + toast.error('Tunnel started but no public URL was returned'); + return; + } + setTunnelInfo({ - url: data.url, + url: startedUrl, connectUrl: typeof data.connectUrl === 'string' ? data.connectUrl : null, bootstrapExpiresAt: typeof data.bootstrapExpiresAt === 'number' ? data.bootstrapExpiresAt : null, }); - setActiveTunnelMode(data.activeTunnelMode === 'named' || data.activeTunnelMode === 'quick' - ? data.activeTunnelMode - : (data.mode === 'named' || data.mode === 'quick' ? data.mode : tunnelMode)); + setActiveTunnelMode( + data.activeTunnelMode + ? toUiTunnelMode(data.activeTunnelMode) + : (data.mode ? toUiTunnelMode(data.mode) : tunnelMode) + ); setSessionRecords(Array.isArray(data.activeSessions) ? data.activeSessions : []); - if (Array.isArray(data.namedTunnelTokenPresetIds)) { - setSavedTokenPresetIds(new Set(data.namedTunnelTokenPresetIds)); + if (Array.isArray(data.managedRemoteTunnelTokenPresetIds)) { + setSavedTokenPresetIds(new Set(data.managedRemoteTunnelTokenPresetIds)); } if (typeof data.localPort === 'number') { setLocalPort(data.localPort); } - if (data.mode === 'named' || data.mode === 'quick') { - setTunnelMode(data.mode); + if (typeof data.mode === 'string') { + setTunnelMode(toUiTunnelMode(data.mode)); } setState('active'); - toast.success('Tunnel link ready'); + if (data.replacedTunnel) { + const revokedBootstrapCount = typeof data.revokedBootstrapCount === 'number' ? data.revokedBootstrapCount : 0; + const invalidatedSessionCount = typeof data.invalidatedSessionCount === 'number' ? data.invalidatedSessionCount : 0; + toast.warning(`Replaced previous tunnel: revoked ${revokedBootstrapCount} link${revokedBootstrapCount === 1 ? '' : 's'}, invalidated ${invalidatedSessionCount} session${invalidatedSessionCount === 1 ? '' : 's'}.`); + } else { + toast.success('Tunnel link ready'); + } } catch { setState('error'); setErrorMessage('Failed to start tunnel'); toast.error('Failed to start tunnel'); } }, [ - namedTunnelPresets, + managedRemoteTunnelPresets, saveTunnelSettings, selectedPreset, sessionTokensByPresetId, + tunnelProvider, tunnelMode, + managedLocalConfigPath, ]); const handleStop = React.useCallback(async () => { @@ -620,7 +817,7 @@ export const TunnelSettings: React.FC = () => { if (statusRes.ok) { const statusData = (await statusRes.json()) as TunnelStatusResponse; setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []); - setSavedTokenPresetIds(new Set(Array.isArray(statusData.namedTunnelTokenPresetIds) ? statusData.namedTunnelTokenPresetIds : [])); + setSavedTokenPresetIds(new Set(Array.isArray(statusData.managedRemoteTunnelTokenPresetIds) ? statusData.managedRemoteTunnelTokenPresetIds : [])); setLocalPort(typeof statusData.localPort === 'number' ? statusData.localPort : null); } setTunnelInfo(null); @@ -669,43 +866,38 @@ export const TunnelSettings: React.FC = () => { }, [bootstrapTtlMs, saveTtlSettings]); const handleModeChange = React.useCallback(async (value: TunnelMode) => { - setNamedValidationError(null); + setManagedRemoteValidationError(null); setErrorMessage(null); if (state !== 'active' && state !== 'stopping' && state !== 'starting') { setState('idle'); } - const nextHostname = value === 'named' && selectedPreset ? selectedPreset.hostname : undefined; await saveTunnelSettings({ tunnelMode: value, - ...(nextHostname ? { namedTunnelHostname: nextHostname } : {}), - namedTunnelPresets, - namedTunnelSelectedPresetId: selectedPresetId || undefined, + managedRemoteTunnelPresets, }); - }, [namedTunnelPresets, saveTunnelSettings, selectedPreset, selectedPresetId, state]); + }, [managedRemoteTunnelPresets, saveTunnelSettings, state]); - const persistSelectedPreset = React.useCallback(async (preset: NamedTunnelPreset, presets: NamedTunnelPreset[]) => { + const persistSelectedPreset = React.useCallback(async (preset: ManagedRemoteTunnelPreset, presets: ManagedRemoteTunnelPreset[]) => { try { await updateDesktopSettings({ - namedTunnelSelectedPresetId: preset.id, - namedTunnelHostname: preset.hostname, - namedTunnelPresets: presets, + managedRemoteTunnelPresets: presets, }); } catch { - toast.error('Failed to save selected named tunnel'); + toast.error('Failed to save selected managed remote tunnel'); } }, []); const handleSelectPreset = React.useCallback((presetId: string) => { - const preset = namedTunnelPresets.find((entry) => entry.id === presetId); + const preset = managedRemoteTunnelPresets.find((entry) => entry.id === presetId); if (!preset) { return; } setSelectedPresetId(preset.id); - setNamedValidationError(null); - void persistSelectedPreset(preset, namedTunnelPresets); - }, [namedTunnelPresets, persistSelectedPreset]); + setManagedRemoteValidationError(null); + void persistSelectedPreset(preset, managedRemoteTunnelPresets); + }, [managedRemoteTunnelPresets, persistSelectedPreset]); const handleSaveNewPreset = React.useCallback(async () => { const name = newPresetName.trim(); @@ -717,74 +909,70 @@ export const TunnelSettings: React.FC = () => { return; } if (!hostname) { - toast.error('Named tunnel hostname is required'); + toast.error('Managed remote tunnel hostname is required'); return; } if (!token) { - toast.error('Named tunnel token is required'); + toast.error('Managed remote tunnel token is required'); return; } - if (namedTunnelPresets.some((preset) => preset.hostname === hostname)) { + if (managedRemoteTunnelPresets.some((preset) => preset.hostname === hostname)) { toast.error('This hostname already exists'); return; } - const nextPreset: NamedTunnelPreset = { + const nextPreset: ManagedRemoteTunnelPreset = { id: createPresetId(), name, hostname, }; - const nextPresets = [...namedTunnelPresets, nextPreset]; + const nextPresets = [...managedRemoteTunnelPresets, nextPreset]; - setNamedTunnelPresets(nextPresets); + setManagedRemoteTunnelPresets(nextPresets); setSelectedPresetId(nextPreset.id); - setExpandedNamedTunnels((prev) => ({ ...prev, [nextPreset.id]: true })); + setExpandedManagedRemoteTunnels((prev) => ({ ...prev, [nextPreset.id]: true })); setSessionTokensByPresetId((prev) => ({ ...prev, [nextPreset.id]: token })); - setNamedValidationError(null); + setManagedRemoteValidationError(null); setIsAddingPreset(false); setNewPresetName(''); setNewPresetHostname(''); setNewPresetToken(''); await saveTunnelSettings({ - tunnelMode: 'named', - namedTunnelHostname: nextPreset.hostname, - namedTunnelPresets: nextPresets, - namedTunnelSelectedPresetId: nextPreset.id, - namedTunnelPresetTokens: { + tunnelMode: 'managed-remote', + managedRemoteTunnelPresets: nextPresets, + managedRemoteTunnelPresetTokens: { ...sessionTokensByPresetId, [nextPreset.id]: token, }, }); - await persistNamedTunnelToken({ + await persistManagedRemoteTunnelToken({ presetId: nextPreset.id, presetName: nextPreset.name, hostname: nextPreset.hostname, token, }); - toast.success('Named tunnel saved'); - }, [namedTunnelPresets, newPresetHostname, newPresetName, newPresetToken, persistNamedTunnelToken, saveTunnelSettings, sessionTokensByPresetId]); + toast.success('Managed remote tunnel saved'); + }, [managedRemoteTunnelPresets, newPresetHostname, newPresetName, newPresetToken, persistManagedRemoteTunnelToken, saveTunnelSettings, sessionTokensByPresetId]); const handleRemovePreset = React.useCallback(async (presetId: string) => { - const preset = namedTunnelPresets.find((entry) => entry.id === presetId); + const preset = managedRemoteTunnelPresets.find((entry) => entry.id === presetId); if (!preset) { return; } - const nextPresets = namedTunnelPresets.filter((entry) => entry.id !== preset.id); + const nextPresets = managedRemoteTunnelPresets.filter((entry) => entry.id !== preset.id); const fallbackSelectedId = nextPresets[0]?.id || ''; const nextSelectedId = selectedPresetId === preset.id ? fallbackSelectedId : selectedPresetId; - const nextSelectedPreset = nextPresets.find((entry) => entry.id === nextSelectedId) || null; - const nextHostname = nextSelectedPreset?.hostname; const nextTokenMap = Object.fromEntries( Object.entries(sessionTokensByPresetId) .filter(([id, tokenValue]) => id !== preset.id && tokenValue.trim().length > 0) ); - setNamedTunnelPresets(nextPresets); + setManagedRemoteTunnelPresets(nextPresets); setSelectedPresetId(nextSelectedId); - setExpandedNamedTunnels((prev) => { + setExpandedManagedRemoteTunnels((prev) => { const next = { ...prev }; delete next[preset.id]; return next; @@ -799,17 +987,15 @@ export const TunnelSettings: React.FC = () => { next.delete(preset.id); return next; }); - setNamedValidationError(null); + setManagedRemoteValidationError(null); await saveTunnelSettings({ - namedTunnelPresets: nextPresets, - namedTunnelSelectedPresetId: nextSelectedId || undefined, - namedTunnelHostname: nextHostname, - namedTunnelPresetTokens: nextTokenMap, + managedRemoteTunnelPresets: nextPresets, + managedRemoteTunnelPresetTokens: nextTokenMap, }); - toast.success('Named tunnel removed'); - }, [namedTunnelPresets, saveTunnelSettings, selectedPresetId, sessionTokensByPresetId]); + toast.success('Managed remote tunnel removed'); + }, [managedRemoteTunnelPresets, saveTunnelSettings, selectedPresetId, sessionTokensByPresetId]); const primaryCtaClass = 'gap-2 border-[var(--primary-base)] bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:bg-[var(--primary-hover)] hover:text-[var(--primary-foreground)]'; @@ -826,7 +1012,7 @@ export const TunnelSettings: React.FC = () => {

Remote Tunnel

- Configure secure remote access with quick links or your own named Cloudflare tunnel. + Configure secure remote access with quick links or your own managed remote Cloudflare tunnel.

Secure Tunnel access is enforced server-side. @@ -846,12 +1032,16 @@ export const TunnelSettings: React.FC = () => {

{renderedSessionRecords.map((record) => { const isQuick = record.mode === 'quick'; + const isManagedRemote = record.mode === 'managed-remote'; const modeBadgeClass = isQuick ? 'border-[var(--status-warning-border)] bg-[var(--status-warning-background)] text-[var(--status-warning)]' - : 'border-[var(--status-info-border)] bg-[var(--status-info-background)] text-[var(--status-info)]'; + : isManagedRemote + ? 'border-[var(--status-info-border)] bg-[var(--status-info-background)] text-[var(--status-info)]' + : 'border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]'; const statusDotClass = record.isActive - ? (isQuick ? 'text-[var(--status-warning)]' : 'text-[var(--status-info)]') + ? (isQuick ? 'text-[var(--status-warning)]' : isManagedRemote ? 'text-[var(--status-info)]' : 'text-[var(--status-success)]') : 'text-muted-foreground/50'; + const modeLabel = isQuick ? 'QUICK' : isManagedRemote ? 'REMOTE' : 'LOCAL'; return (
{ > - {isQuick ? 'QUICK' : 'NAMED'} + {modeLabel} Redeemed {formatAbsoluteTime(record.createdAt)} @@ -895,56 +1085,65 @@ export const TunnelSettings: React.FC = () => { {state !== 'not-available' && (
-
-

Tunnel type

-
- - - +
+

Provider

+ +
- - - { - void handleModeChange('named'); - }} - disabled={isSavingMode || state === 'starting' || state === 'stopping'} - > - Named Tunnel - - - - {NAMED_TUNNEL_TOOLTIP} - - +
+

Tunnel type

+
+ {TUNNEL_MODE_OPTIONS.map((option) => ( + + + { + void handleModeChange(option.value); + }} + disabled={isSavingMode || state === 'starting' || state === 'stopping'} + > + {option.label} + + + + {option.tooltip} + + + ))} +
@@ -999,14 +1198,14 @@ export const TunnelSettings: React.FC = () => { Quick Tunnel is best effort and Cloudflare does not guarantee uptime.

- For more reliable long-lived access, switch to Named Tunnel mode. + For more reliable long-lived access, switch to Managed Remote or Managed Local tunnel mode.

)} - {tunnelMode === 'named' && ( + {tunnelMode === 'managed-remote' && (
{typeof suggestedConnectorPort === 'number' && (
@@ -1017,7 +1216,7 @@ export const TunnelSettings: React.FC = () => { )}
-

Saved named tunnels

+

Saved managed remote tunnels

{
- {namedTunnelPresets.length > 0 ? ( + {managedRemoteTunnelPresets.length > 0 ? (
- {namedTunnelPresets.map((preset, index) => { + {managedRemoteTunnelPresets.map((preset, index) => { const rowToken = sessionTokensByPresetId[preset.id] || ''; const hasSavedToken = savedTokenPresetIds.has(preset.id); - const isOpen = expandedNamedTunnels[preset.id] ?? false; + const isOpen = expandedManagedRemoteTunnels[preset.id] ?? false; return (
{ - setExpandedNamedTunnels((prev) => ({ ...prev, [preset.id]: open })); + setExpandedManagedRemoteTunnels((prev) => ({ ...prev, [preset.id]: open })); if (open) { void handleSelectPreset(preset.id); } @@ -1086,7 +1285,7 @@ export const TunnelSettings: React.FC = () => { value={rowToken} onChange={(event) => { const nextValue = event.target.value; - setNamedValidationError(null); + setManagedRemoteValidationError(null); setSessionTokensByPresetId((prev) => ({ ...prev, [preset.id]: nextValue })); }} onBlur={(event) => { @@ -1094,7 +1293,7 @@ export const TunnelSettings: React.FC = () => { if (!tokenToSave) { return; } - void persistNamedTunnelToken({ + void persistManagedRemoteTunnelToken({ presetId: preset.id, presetName: preset.name, hostname: preset.hostname, @@ -1112,7 +1311,7 @@ export const TunnelSettings: React.FC = () => { className="!font-normal" disabled={state === 'starting' || state === 'stopping' || rowToken.trim().length === 0} onClick={() => { - void persistNamedTunnelToken({ + void persistManagedRemoteTunnelToken({ presetId: preset.id, presetName: preset.name, hostname: preset.hostname, @@ -1131,7 +1330,7 @@ export const TunnelSettings: React.FC = () => { })}
) : ( -

No named tunnels saved yet.

+

No managed remote tunnels saved yet.

)} {isAddingPreset && ( @@ -1200,58 +1399,140 @@ export const TunnelSettings: React.FC = () => { - Tokens are saved in ~/.config/openchamber/cloudflare-named-tunnels.json. + Tokens are saved in ~/.config/openchamber/cloudflare-managed-remote-tunnels.json.
- {!selectedPreset && namedValidationError && ( -

{namedValidationError}

+ {!selectedPreset && managedRemoteValidationError && ( +

{managedRemoteValidationError}

)}
)} + {tunnelMode === 'managed-local' && ( +
+
+

Configuration file

+ { + void handleManagedLocalConfigFileSelected(event); + }} + /> +
+ { + handleManagedLocalConfigInputChange(event.target.value); + }} + onBlur={() => { + void handleManagedLocalConfigInputBlur(); + }} + placeholder="Using default cloudflared config" + className="h-7" + disabled={state === 'starting' || state === 'stopping' || isSavingMode} + /> + { + void handleBrowseManagedLocalConfig(); + }} + disabled={state === 'starting' || state === 'stopping' || isSavingMode} + > + + + {managedLocalConfigPath && ( + { + void handleManagedLocalConfigClear(); + }} + disabled={state === 'starting' || state === 'stopping' || isSavingMode} + > + + + )} +
+

+ {managedLocalConfigPath + ? 'Custom config file will be used when starting the tunnel.' + : 'When empty, cloudflared uses its default config (~/.cloudflared/config.yml).'} +

+ {isManagedLocalConfigPathInvalid && ( +

{MANAGED_LOCAL_CONFIG_EXTENSION_ERROR}

+ )} +
+
+ )} + {!isSelectedModeTunnelReady && (
- {tunnelMode === 'named' && ( + {tunnelMode === 'managed-remote' && ( <>

- Named tunnels require a bought domain in your Cloudflare account. + Managed remote tunnels require a bought domain in your Cloudflare account.

+ + )} + {tunnelMode === 'managed-local' && ( + <> +

+ Managed local tunnels use your local cloudflared configuration file. +

+ )}

- Start a {tunnelMode === 'named' ? 'named' : 'quick'} tunnel and generate a one-time connect link. Do not close the app while this tunnel is in use. + Start a {tunnelMode} tunnel and generate a one-time connect link. Do not close the app while this tunnel is in use.

- {tunnelMode === 'named' && ( + {tunnelMode === 'managed-remote' && (
-

Named tunnel to connect

+

Managed remote tunnel to connect