vibetunnel Documentation

Turn any browser into your terminal & command your agents on the go.

4,694 stars TypeScript TypeScript 57 files · ~127,298 tokens #remote#terminal#vibecoding
RAW Doc

File: README.md

VibeTunnel 🚇 - Turn any browser into your terminal; command agents from the road (vt.sh)

Table of Contents

Why VibeTunnel?

Ever wanted to check on your AI agents while you're away? Need to monitor that long-running build from your phone? Want to share a terminal session with a colleague without complex SSH setups? VibeTunnel makes it happen with zero friction.

Installation Options

The native macOS app provides the best experience with menu bar integration and automatic updates.

npm Package (Linux & Headless Systems)

For Linux servers, Docker containers, or headless macOS systems, install via npm:

bash
npm install -g vibetunnel

This gives you the full VibeTunnel server with web UI, just without the macOS menu bar app. See the npm Package section for detailed usage.

Quick Start

Requirements

macOS App: Requires an Apple Silicon Mac (M1+). Intel Macs are not supported for the native app.

npm Package: Works on Node.js 22.12 through 24.x, including Intel Macs and Linux. Windows is not yet supported (#252).

1. Download & Install

Option 1: Direct Download

Download VibeTunnel and drag it to your Applications folder.

Option 2: Homebrew

bash
brew install --cask vibetunnel

2. Launch VibeTunnel

VibeTunnel lives in your menu bar. Click the icon to start the server.

3. Use the `vt` Command

The vt command is a smart wrapper that forwards your terminal sessions through VibeTunnel:

How it works:

  • vt is a bash script that internally calls vibetunnel fwd to forward terminal output
  • It provides additional features like shell alias resolution and session title management
  • Available from both the Mac app and npm package installations

Installation sources:

  • macOS App: Installs the vt wrapper script at /usr/local/bin/vt during installation
  • npm Package: Installs vt globally, with intelligent Mac app detection

Smart detection:
When you run vt from the npm package, it:

  1. Checks if the Mac app is installed at /Applications/VibeTunnel.app
  2. If found, forwards to the Mac app's vt for the best experience
  3. If not found, uses the npm-installed vibetunnel fwd
  4. This ensures vt always uses the best available implementation
bash
# Run any command in the browser
vt pnpm run dev
vt npm test
vt python script.py

# Use your shell aliases
vt gs              # Your 'git status' alias works!
vt claude-danger   # Custom aliases are resolved

# Open an interactive shell
vt --shell         # or vt -i

# Git follow mode
vt follow          # Follow current branch
vt follow main     # Switch to main and follow
vt unfollow       # Stop following

# For more examples and options, see "The vt Forwarding Command" section below

Git Repository Scanning on First Session

When opening a new session for the first time, VibeTunnel's working directory scanner will look for Git repositories. By default, this scans your home directory, which may trigger macOS permission prompts for accessing protected folders (like Desktop, Documents, Downloads, iCloud Drive, or external volumes).

To avoid these prompts:

  • Option 1: Navigate to your actual projects directory before opening a session
  • Option 2: Accept the one-time permission prompts (they won't appear again)

This only happens on the first session when the scanner discovers your Git repositories. For more details about macOS privacy-protected folders, see this explanation.

4. Open Your Dashboard

Visit http://localhost:4020 to see all your terminal sessions.

Features

  • 🌐 Browser-Based Access - Control your Mac terminal from any device with a web browser
  • 🚀 Zero Configuration - No SSH keys, no port forwarding, no complexity
  • 🤖 AI Agent Friendly - Perfect for monitoring Claude Code, ChatGPT, or any terminal-based AI tools
  • 📊 Session Activity Indicators - Real-time activity tracking shows which sessions are active or idle
  • 🔄 Git Follow Mode - Terminal automatically follows your IDE's branch switching
  • ⌨️ Smart Keyboard Handling - Intelligent shortcut routing with toggleable capture modes. When capture is active, use Cmd+1...9/0 (Mac) or Ctrl+1...9/0 (Linux) to quickly switch between sessions
  • 🔒 Secure by Design - Multiple authentication modes, localhost-only mode, or secure tunneling via Tailscale/ngrok
  • 📱 Mobile Ready - Native iOS app and responsive web interface for phones and tablets
  • 🎬 Session Recording - All sessions recorded in asciinema format for later playback
  • ⚡ High Performance - Optimized Node.js server with minimal resource usage
  • 🍎 Apple Silicon Native - Optimized for Apple Silicon (M1+) Macs with ARM64-only binaries
  • 🐚 Shell Alias Support - Your custom aliases and shell functions work automatically

Note: The iOS app is still work in progress and not recommended for production use yet.

Architecture

VibeTunnel consists of three main components:

  1. macOS Menu Bar App - Native Swift application that manages the server lifecycle
  2. Node.js Server - High-performance TypeScript server handling terminal sessions
  3. Web Frontend - Modern web interface using Lit components and ghostty-web

The server runs as a standalone Node.js executable with embedded modules, providing excellent performance and minimal resource usage.

Remote Access Options

Tailscale creates a secure peer-to-peer VPN network between your devices. It's the most secure option as traffic stays within your private network without exposing VibeTunnel to the public internet.

How it works: Tailscale creates an encrypted WireGuard tunnel between your devices, allowing them to communicate as if they were on the same local network, regardless of their physical location.

Basic Setup

  1. Install Tailscale on your Mac: Download from Mac App Store, Direct Download, or a CLI/daemon package such as nix-darwin
  2. Install Tailscale on your remote device:
  3. Sign in to both devices with the same account
  4. If using VibeTunnel's Tailscale Serve integration, ensure Tailscale Serve is enabled in your tailnet settings
  5. Find your Mac's Tailscale hostname in the menu bar app or with tailscale status (e.g., my-mac.tailnet-name.ts.net)
  6. Access VibeTunnel at http://[your-tailscale-hostname]:4020

VibeTunnel detects standard CLI locations and nix-darwin system/user profiles, and falls back to tailscale status --json when the macOS app API is unavailable.

Enhanced Tailscale Features

VibeTunnel now supports advanced Tailscale integration with Private and Public access modes:

Private Mode (Default)

  • What it does: Provides secure HTTPS access within your Tailscale network only
  • Access URL: https://[your-machine-name].[tailnet-name].ts.net
  • Security: Traffic stays within your private tailnet
  • Best for: Personal use, accessing your terminals from your own devices

Public Mode (Tailscale Funnel)

  • What it does: Exposes VibeTunnel to the public internet via Tailscale Funnel
  • Access URL: Same as Private mode but accessible from anywhere
  • Security: Still uses HTTPS encryption, but accessible without Tailscale login
  • Best for: Sharing terminal sessions with colleagues, temporary public access
  • Requirements: Funnel must be enabled on your tailnet (see configuration below)

Configuring Tailscale Funnel

To use Public mode, you need to enable Funnel on your tailnet:

  1. Enable Funnel for your tailnet by adding this ACL policy in the Tailscale Admin Console:

    json
    "nodeAttrs": [
        {
            "target": ["autogroup:member"], // All members of your tailnet
            "attr":   ["funnel"], 
        },
    ],
  2. Switch between modes in VibeTunnel:

    • Open VibeTunnel Settings → Remote Access
    • Toggle between "Private (Tailnet Only)" and "Public (Internet)"
    • The UI will show the transition status and confirm when the mode is active

HTTPS Support

Both Private and Public modes automatically provide HTTPS access:

  • Tailscale Serve creates an HTTPS proxy to VibeTunnel's local server
  • SSL certificates are managed automatically by Tailscale
  • No manual certificate configuration needed
  • WebSocket connections work seamlessly over HTTPS/WSS

Benefits:

  • End-to-end encrypted traffic
  • Automatic HTTPS with valid certificates
  • Works behind NAT and firewalls
  • Zero configuration after initial setup
  • Flexible access control - choose between private tailnet or public internet access
  • No port forwarding required

Troubleshooting

"Tailscale Serve unavailable - using fallback mode": This is normal if you don't have Tailscale admin permissions. VibeTunnel will work perfectly using direct HTTP access at http://[your-tailscale-hostname]:4020.

"Applying mode configuration...": When switching between Private and Public modes, it may take a few seconds for Tailscale to reconfigure. This is normal.

"Funnel requires admin permissions": You need to be a tailnet admin to enable Funnel. Contact your tailnet admin or create your own tailnet if needed.

WebSocket connections fail: Make sure you're using the HTTPS URL when accessing VibeTunnel through Tailscale Serve. The WebSocket authentication tokens are automatically handled.

Option 2: ngrok

ngrok creates secure tunnels to your localhost, making VibeTunnel accessible via a public URL. Perfect for quick sharing or temporary access.

How it works: ngrok establishes a secure tunnel from a public endpoint to your local VibeTunnel server, handling SSL/TLS encryption and providing a unique URL for access.

Setup Guide:

  1. Create a free ngrok account: Sign up for ngrok
  2. Copy your auth token from the ngrok dashboard
  3. Add the token in VibeTunnel settings (Settings → Remote Access → ngrok)
  4. Enable ngrok tunneling in VibeTunnel
  5. Share the generated https://[random].ngrok-free.app URL

Benefits:

  • Public HTTPS URL with SSL certificate
  • No firewall configuration needed
  • Built-in request inspection and replay
  • Custom domains available (paid plans)

Note: Free ngrok URLs change each time you restart the tunnel. You can claim one free static domain per user, or upgrade to a paid plan for multiple domains.

Option 3: Local Network

  1. Configure authentication (see Authentication section)
  2. Switch to "Network" mode
  3. Access via http://[your-mac-ip]:4020

Option 4: Cloudflare Quick Tunnel

  1. Install cloudflared
  2. Run cloudflared tunnel --url http://localhost:4020
  3. Access via the generated *.trycloudflare.com URL

Option 5: Pinggy

  1. Install the Pinggy CLI: npm install pinggy
  2. Open VibeTunnel Settings → Remote → Pinggy Integration
  3. Optionally save a Pinggy access token for a persistent subdomain or custom domain
  4. Enable the tunnel and open the displayed public HTTPS URL

Free tunnels use a new URL after reconnecting. A persistent subdomain or custom domain requires a Pinggy plan that supports it and must be assigned to the saved token in the Pinggy dashboard.

Option 6: Pangolin

  1. Create a Site in Pangolin and copy its Newt endpoint, ID, and secret
  2. Install Newt: curl -fsSL https://static.pangolin.net/get-newt.sh | bash
  3. Open VibeTunnel Settings → Remote → Pangolin Integration and save the site credentials
  4. In Pangolin, create a public Resource targeting http://localhost:4020
  5. Connect the site in VibeTunnel and use the Resource URL configured by Pangolin

VibeTunnel stores the Newt site credentials in Keychain and starts Newt with a protected temporary config file, keeping the secret out of process arguments and environment variables.

Git Follow Mode

Git Follow Mode keeps your main repository checkout synchronized with the branch you're working on in a Git worktree. This allows agents to work in worktrees while your IDE, server, and other tools stay open on the main repository - they'll automatically update when the worktree switches branches.

What is Follow Mode?

Follow mode creates a seamless workflow for agent-assisted development:

  • Agents work in worktrees → Main repository automatically follows their branch switches
  • Keep Xcode/IDE open → It updates automatically without reopening projects
  • Server stays running → No need to restart servers in different folders
  • Zero manual intervention → Main repo stays in sync with active development

Quick Start

bash
# From a worktree - enable follow mode for this worktree
vt follow

# From main repo - follow current branch's worktree (if it exists)
vt follow

# From main repo - follow a specific branch's worktree
vt follow feature/new-api

# From main repo - follow a worktree by path
vt follow ~/project-feature

# Disable follow mode
vt unfollow

How It Works

  1. Git Hooks: VibeTunnel installs lightweight Git hooks (post-commit, post-checkout) in worktrees that detect branch changes
  2. Main Repo Sync: When you switch branches in a worktree, the main repository automatically checks out to the same branch
  3. Smart Handling: If the main repo has uncommitted changes, follow mode pauses to prevent data loss
  4. Development Continuity: Your IDE, servers, and tools running on the main repo seamlessly follow your active work
  5. Clean Uninstall: When you run vt unfollow, Git hooks are automatically removed and any original hooks are restored

Common Workflows

Agent Development with Worktrees

bash
# Create a worktree for agent development
git worktree add ../project-agent feature/new-feature

# Enable follow mode on the main repo
cd ../project && vt follow

# Agent works in the worktree while you stay in main repo
# When agent switches branches in worktree, your main repo follows!
# Your Xcode/IDE and servers stay running without interruption

Technical Details

Follow mode stores the worktree path in your main repository's Git config:

bash
# Check which worktree is being followed
git config vibetunnel.followWorktree

# Follow mode is active when this returns a path
# The config is managed by vt commands - manual editing not recommended

For more advanced Git worktree workflows, see our detailed worktree documentation.

Terminal Title Management

VibeTunnel provides terminal title management to help you track sessions:

Title Modes

  • Static Mode: Shows working directory and command
    • Example: ~/projects/app — npm run dev
  • Filter Mode: Blocks all title changes from applications
    • Useful when you have your own terminal management system
  • None Mode: No title management - applications control their own titles

Activity Detection

Activity indicators are based on recent input/output and drive active/idle UI states.

Authentication

VibeTunnel provides multiple authentication modes to secure your terminal sessions:

Authentication Modes

1. System Authentication (Default)

Uses your operating system's native authentication:

  • macOS: Authenticates against local user accounts
  • Linux: Uses PAM (Pluggable Authentication Modules)
  • Login with your system username and password

2. Environment Variable Authentication

Simple authentication for deployments:

bash
export VIBETUNNEL_USERNAME=admin
export VIBETUNNEL_PASSWORD=your-secure-password
npm run start

JWT signing keys are generated once in ~/.vibetunnel/jwt-secret with owner-only
permissions, so browser sessions remain valid across server restarts. Set JWT_SECRET
to use an operator-managed signing secret instead; rotating either value logs out clients.

3. SSH Key Authentication

Use Ed25519 SSH keys from ~/.ssh/authorized_keys:

bash
# Enable SSH key authentication
npm run start -- --enable-ssh-keys

# Make SSH keys mandatory (disable password auth)
npm run start -- --enable-ssh-keys --disallow-user-password

4. No Authentication

For trusted environments only:

bash
npm run start -- --no-auth

5. Local Bypass (Development Only)

Allow localhost connections to bypass authentication:

bash
# Basic local bypass (DEVELOPMENT ONLY - NOT FOR PRODUCTION)
npm run start -- --allow-local-bypass

# With token for additional security (minimum for production)
npm run start -- --allow-local-bypass --local-auth-token mytoken

Security Note: Local bypass uses req.socket.remoteAddress which cannot be spoofed remotely due to TCP's three-way handshake. The implementation also rejects requests with proxy headers (X-Forwarded-For, etc.) to prevent header injection attacks. However:

  • Development only: Basic bypass without token should never be used in production
  • Local processes: Any process on the same machine can access the API
  • Always use tokens: In production, always require --local-auth-token
  • Consider alternatives: For production, use proper authentication instead of local bypass

macOS App Authentication

The macOS menu bar app supports these authentication modes:

  • No Authentication: For trusted environments only
  • System Authentication: Uses your macOS user account credentials
  • SSH Key Authentication: Uses Ed25519 SSH keys from ~/.ssh/authorized_keys
  • Configure via Settings → Security when in "Network" mode

Security Best Practices

  1. Always use authentication when binding to network interfaces (--bind 0.0.0.0)
  2. Use HTTPS in production with a reverse proxy (nginx, Caddy)
  3. Rotate credentials regularly
  4. Consider SSH keys for stronger security
  5. Never use local bypass without tokens in production environments
  6. Monitor access logs for suspicious authentication patterns
  7. Default to secure - explicitly enable less secure options only when needed

SSH Key Authentication Troubleshooting

If SSH key generation fails with crypto errors, see the detailed troubleshooting guide for solutions.

npm Package

The VibeTunnel npm package provides the full server functionality for Linux, Docker, CI/CD environments, and headless macOS systems.

Installation

bash
# Install globally via npm
npm install -g vibetunnel

# Or with yarn
yarn global add vibetunnel

# Or with pnpm
pnpm add -g vibetunnel

Requirements: Node.js 22.12 through 24.x

One-shot (no install)

bash
# Start local server (no auth)
npx -y vibetunnel --no-auth

# Quick remote share (ngrok)
npx -y vibetunnel --no-auth --ngrok

# One-shot vt wrapper
npx -y --package vibetunnel vt npm test

Running the VibeTunnel Server

Basic Usage

bash
# Start with default settings (localhost:4020)
vibetunnel

# Bind to all network interfaces
vibetunnel --bind 0.0.0.0

# Use a custom port
vibetunnel --port 8080

# With authentication
VIBETUNNEL_USERNAME=admin VIBETUNNEL_PASSWORD=secure vibetunnel --bind 0.0.0.0

# Enable debug logging
VIBETUNNEL_DEBUG=1 vibetunnel

# Run without authentication (trusted networks only!)
vibetunnel --no-auth

Using the `vt` Command

The vt command wrapper makes it easy to forward terminal sessions:

bash
# Run any command and see it in the browser
vt npm test
vt python script.py
vt cargo build --release

# Open an interactive shell
vt --shell
vt -i  # short form

# Control terminal titles
vt --title-mode static npm run dev     # Shows path and command
vt --title-mode filter vim             # Blocks vim from changing title

# Control output verbosity
vt -q npm test         # Quiet mode - no console output
vt -v npm run dev      # Verbose mode - show more information
vt -vv cargo build     # Extra verbose - all except debug
vt -vvv python app.py  # Debug mode - show everything

# Shell aliases work automatically!
vt claude-danger  # Your custom alias for claude --dangerously-skip-permissions

# Update session title (inside a VibeTunnel session)
vt title "My Project - Testing"

The `vt` Forwarding Command

The vt command is VibeTunnel's terminal forwarding wrapper that allows you to run any command while making its output visible in the browser. Under the hood, vt is a convenient shortcut for vibetunnel fwd - it's a bash script that calls the full command with proper path resolution and additional features like shell alias support. The vt wrapper acts as a transparent proxy between your terminal and the command, forwarding all input and output through VibeTunnel's infrastructure.

Command Syntax

bash
vt [options] <command> [args...]

Options

Terminal Title Control:

  • --title-mode <mode> - Control how terminal titles are managed:
    • none - No title management, apps control their own titles (default)
    • filter - Block all title changes from applications
    • static - Show working directory and command in title

Verbosity Control:

  • -q, --quiet - Quiet mode, no console output (logs to file only)
  • -v, --verbose - Verbose mode, show errors, warnings, and info messages
  • -vv - Extra verbose, show all messages except debug
  • -vvv - Debug mode, show all messages including debug

Other Options:

  • --shell, -i - Launch your current shell interactively
  • --no-shell-wrap, -S - Execute command directly without shell interpretation
  • --log-file - Override default log file location (defaults to ~/.vibetunnel/log.txt)
  • --help, -h - Show help message with all options

Verbosity Levels

VibeTunnel uses a hierarchical logging system where each level includes all messages from more severe levels:

Level Flag Environment Variable Shows
SILENT -q VIBETUNNEL_LOG_LEVEL=silent No console output (file logging only)
ERROR (default) VIBETUNNEL_LOG_LEVEL=error Errors only
WARN - VIBETUNNEL_LOG_LEVEL=warn Errors and warnings
INFO -v VIBETUNNEL_LOG_LEVEL=info Errors, warnings, and informational messages
VERBOSE -vv VIBETUNNEL_LOG_LEVEL=verbose All messages except debug
DEBUG -vvv VIBETUNNEL_LOG_LEVEL=debug Everything including debug traces

Note: All logs are always written to ~/.vibetunnel/log.txt regardless of verbosity settings. The verbosity only controls terminal output.

Examples

bash
# Basic command forwarding
vt ls -la                    # List files with VibeTunnel monitoring
vt npm run dev              # Run development server
vt python script.py         # Execute Python script

# With verbosity control
vt -q npm test              # Run tests silently
vt -v npm install           # See detailed installation progress
vt -vvv python debug.py     # Full debug output
vt --log-file debug.log npm run dev  # Write logs to custom file

# Terminal title management
vt --title-mode static npm run dev     # Fixed title showing command
vt --title-mode filter vim             # Prevent vim from changing title

# Shell handling
vt --shell                  # Open interactive shell
vt -S /usr/bin/python      # Run python directly without shell

How It Works

  1. Command Resolution: The vt wrapper first checks if your command is an alias, shell function, or binary
  2. Session Creation: It creates a new VibeTunnel session with a unique ID
  3. PTY Allocation: A pseudo-terminal is allocated to preserve terminal features (colors, cursor control, etc.)
  4. I/O Forwarding: All input/output is forwarded between your terminal and the browser in real-time
  5. Process Management: The wrapper monitors the process and handles signals, exit codes, and cleanup

Environment Variables

  • VIBETUNNEL_LOG_LEVEL - Set default verbosity level (silent, error, warn, info, verbose, debug)
  • VIBETUNNEL_TITLE_MODE - Set default title mode (none, filter, static)
  • VIBETUNNEL_DEBUG - Legacy debug flag, equivalent to VIBETUNNEL_LOG_LEVEL=debug

Special Features

Shell Alias Support: Your shell aliases and functions work transparently through vt:

bash
alias gs='git status'
vt gs  # Works as expected

Session Title Updates: Inside a VibeTunnel session, use vt title to update the session name:

bash
vt title "Building Production Release"

Mac App Interoperability

The npm package is designed to work seamlessly alongside the Mac app:

Smart Command Routing

  • The vt command automatically detects if the Mac app is installed
  • If found at /Applications/VibeTunnel.app, it defers to the Mac app
  • If not found, it uses the npm-installed server
  • This ensures you always get the best available implementation

Installation Behavior

  • If /usr/local/bin/vt already exists (from another tool), npm won't overwrite it
  • You'll see a helpful warning with alternatives: vibetunnel or npx vt
  • The installation always succeeds, even if the vt symlink can't be created

When to Use Each Version

  • Mac app only: Best for macOS users who want menu bar integration
  • npm only: Perfect for Linux, Docker, CI/CD, or headless servers
  • Both installed: Mac app takes precedence, npm serves as fallback
  • Development: npm package useful for testing without affecting Mac app

Package Contents

The npm package includes:

  • Full VibeTunnel server with web UI
  • CLI tools (vibetunnel and vt commands)
  • Native PTY support via node-pty
  • Pre-built binaries for common platforms
  • Complete feature parity with macOS app (minus menu bar)

Building the npm Package

For maintainers who need to build the npm package:

Unified Build (Multi-Platform by Default)

bash
# Build with prebuilt binaries for all platforms
# Requires Docker for Linux cross-compilation
npm run build:npm

This creates prebuilt binaries for:

  • macOS (x64, arm64) - Node.js 22, 23, 24
  • Linux (x64, arm64) - Node.js 22, 23, 24

Build Options

bash
# Current platform only (faster for development)
node scripts/build-npm.js --current-only

# Specific platform/architecture
node scripts/build-npm.js --platform darwin --arch arm64

# Skip Docker builds
node scripts/build-npm.js --no-docker

Publishing

bash
# Test the package locally
npm pack

# Publish to npm
npm publish

Building from Source

Prerequisites

  • macOS 14.0+ (Sonoma) on Apple Silicon (M1+)
  • Xcode 16.0+
  • Node.js 22.12 through 24.x
  • Rustup; native/vt-fwd/rust-toolchain.toml installs the pinned Rust toolchain for the native forwarder

Build Steps

bash
# Clone the repository
git clone https://github.com/amantus-ai/vibetunnel.git
cd vibetunnel

# Set up code signing (required for macOS/iOS development)
# Create Local.xcconfig files with your Apple Developer Team ID
# Note: These files must be in the same directory as Shared.xcconfig
cat > mac/VibeTunnel/Local.xcconfig << EOF
// Local Development Configuration
// DO NOT commit this file to version control
DEVELOPMENT_TEAM = YOUR_TEAM_ID
CODE_SIGN_STYLE = Automatic
EOF

cat > ios/VibeTunnel/Local.xcconfig << EOF
// Local Development Configuration  
// DO NOT commit this file to version control
DEVELOPMENT_TEAM = YOUR_TEAM_ID
CODE_SIGN_STYLE = Automatic
EOF

# Build the web server
cd web
pnpm install
pnpm run build

# Optional: Build with custom Node.js for smaller binary (46% size reduction)
# export VIBETUNNEL_USE_CUSTOM_NODE=YES
# node build-custom-node.js  # Build optimized Node.js (one-time, ~20 min)
# pnpm run build              # Will use custom Node.js automatically

# Build the macOS app
cd ../mac
./scripts/build.sh --configuration Release

Custom Node.js Builds

VibeTunnel supports building with a custom Node.js for a 46% smaller executable (61MB vs 107MB):

bash
# Build custom Node.js (one-time, ~20 minutes)
node build-custom-node.js

# Use environment variable for all builds
export VIBETUNNEL_USE_CUSTOM_NODE=YES

# Or use in Xcode Build Settings
# Add User-Defined Setting: VIBETUNNEL_USE_CUSTOM_NODE = YES

See Custom Node Build Flags for detailed optimization information.

Development

For development setup and contribution guidelines, see CONTRIBUTING.md.

Key Files

  • macOS App: mac/VibeTunnel/VibeTunnelApp.swift
  • Server: web/src/server/ (TypeScript/Node.js)
  • Web UI: web/src/client/ (Lit/TypeScript)
  • iOS App: ios/VibeTunnel/

Testing & Code Coverage

VibeTunnel has comprehensive test suites with code coverage enabled for all projects:

bash
# Run all tests with coverage
./scripts/test-all-coverage.sh

# macOS tests with coverage (Swift Testing)
cd mac && swift test --enable-code-coverage

# iOS tests with coverage (using xcodebuild)
cd ios && ./scripts/test-with-coverage.sh

# Web tests with coverage (Vitest)
cd web && ./scripts/coverage-report.sh

Coverage Requirements:

  • macOS/iOS: 75% minimum (enforced in CI)
  • Web: 80% minimum for lines, functions, branches, and statements

Development Server & Hot Reload

VibeTunnel includes a development server with automatic rebuilding for faster iteration:

Development Mode

bash
cd web
pnpm run dev

What this provides:

  • Automatic Rebuilds: esbuild watches for file changes and rebuilds bundles instantly
  • Fast Feedback: Changes are compiled within seconds of saving
  • Manual Refresh Required: Browser needs manual refresh to see changes (no hot module replacement)

How it works:

  • esbuild watch mode detects file changes in src/
  • Automatically rebuilds JavaScript bundles and CSS
  • Express server serves updated files immediately
  • Visit http://localhost:4020 and refresh to see changes

Testing on External Devices (iPad, iPhone, etc.)

When developing the web interface, you often need to test changes on external devices to debug browser-specific issues. Here's how to do it:

Quick Setup

  1. Run the dev server with network access:

    bash
    cd web
    pnpm run dev --port 4021 --bind 0.0.0.0

    This binds to all network interfaces, making it accessible from other devices.

  2. Find your Mac's IP address:

    • System Preferences → Network → Wi-Fi → Details
    • Or run: ipconfig getifaddr en0
  3. Access from your external device:

    text
    http://[your-mac-ip]:4021

Important Notes

  • Port conflict: The Mac app runs on port 4020, so use a different port (e.g., 4021) for development
  • Same network: Ensure both devices are on the same Wi-Fi network
  • Firewall: macOS may prompt to allow incoming connections - click "Allow"
  • Auto-rebuild: Changes to the web code are automatically rebuilt, but you need to manually refresh the browser

Pasting on Mobile Devices

When using VibeTunnel on mobile browsers (Safari, Chrome), pasting works differently than on desktop:

To paste on mobile:

  1. Press the paste button on the keyboard toolbar
  2. A white input box will appear
  3. Long-press inside the white box to bring up the paste menu
  4. Select "Paste" from the menu
  5. The text will be pasted into your terminal session

Note: Due to browser security restrictions on non-HTTPS connections, the paste API is limited on mobile devices. The white input box is a workaround that allows clipboard access through the browser's native paste functionality.

Future: Hot Module Replacement

For true hot module replacement without manual refresh, see our Vite migration plan which would provide:

  • Instant updates without page refresh
  • Preserved application state during development
  • Sub-second feedback loops
  • Modern development tooling

Mac App Development Server Mode

The VibeTunnel Mac app includes a special development server mode that integrates with the web development workflow:

Setup:

  1. Open VibeTunnel Settings → Debug tab (enable Debug Mode first in General settings)
  2. Enable "Use Development Server"
  3. Set the path to your web/ directory
  4. Restart the VibeTunnel server

How it works:

  • Instead of using the bundled production server, the Mac app runs pnpm run dev in your web directory
  • Provides hot reload and automatic rebuilding during development
  • Maintains all Mac app functionality (session management, logging, etc.)
  • Shows "Dev Server" in the menu bar and status indicators

Benefits:

  • No need to manually rebuild after code changes
  • Automatic esbuild watch mode for instant compilation
  • Full integration with Mac app features
  • Same terminal session management as production

Alternative: Standalone Development

If you prefer working outside the Mac app:

  1. Build the web project: cd web && pnpm run build
  2. In VibeTunnel settings, set Dashboard Access to "Network"
  3. Access from external device: http://[your-mac-ip]:4020

Note: This requires rebuilding after each change, so the dev server mode above is preferred for rapid iteration.

Debug Logging

Enable debug logging for troubleshooting:

bash
# Enable debug mode
export VIBETUNNEL_DEBUG=1

# Or use inline
VIBETUNNEL_DEBUG=1 vt your-command

Debug logs are written to ~/.vibetunnel/log.txt.

Using Development Builds with vt

When developing VibeTunnel, you can use the VIBETUNNEL_PREFER_DERIVED_DATA environment variable to make the vt command prefer development builds from Xcode's DerivedData folder:

bash
# Enable DerivedData preference
export VIBETUNNEL_PREFER_DERIVED_DATA=1

# vt will now search for and use the latest VibeTunnel build from DerivedData
vt your-command

When this environment variable is set, vt will:

  1. First search for VibeTunnel builds in ~/Library/Developer/Xcode/DerivedData
  2. Use the most recently modified build found there
  3. Fall back to /Applications/VibeTunnel.app if no DerivedData build exists
  4. Log the exact binary location, version, and build timestamp being used

This is particularly useful for:

  • Testing changes without installing to /Applications
  • Working with multiple VibeTunnel builds simultaneously
  • Quickly switching between development and production versions
  • Debugging which version of VibeTunnel is being used

The version information is also:

  • Stored in session.json for each session
  • Displayed in vt status output
  • Shown in the initial log output when VIBETUNNEL_PREFER_DERIVED_DATA is set

Verbosity Control

Control the amount of output from VibeTunnel commands:

bash
# Command-line flags
vt -q npm test                # Quiet mode - no console output
vt npm test                   # Default - errors only
vt -v npm run dev            # Verbose - errors, warnings, and info
vt -vv cargo build           # Extra verbose - all except debug
vt -vvv python script.py     # Debug mode - everything

# Environment variable
export VIBETUNNEL_LOG_LEVEL=error    # Default
export VIBETUNNEL_LOG_LEVEL=warn     # Show errors and warnings
export VIBETUNNEL_LOG_LEVEL=info     # Show errors, warnings, and info
export VIBETUNNEL_LOG_LEVEL=verbose  # All except debug
export VIBETUNNEL_LOG_LEVEL=debug    # Everything

# Or use inline
VIBETUNNEL_LOG_LEVEL=silent vt npm test

Note: All logs are always written to ~/.vibetunnel/log.txt regardless of verbosity level. The verbosity settings only control what's displayed in the terminal.

Poltergeist Integration

Poltergeist is an intelligent file watcher and auto-builder that can automatically rebuild VibeTunnel as you develop. This is particularly useful for native app development where manual rebuilds can interrupt your flow.

Setting Up Poltergeist

  1. Install Poltergeist (if not already installed):

    bash
    npm install -g poltergeist
  2. Start Poltergeist in the VibeTunnel directory:

    bash
    cd /path/to/vibetunnel
    poltergeist
  3. Make changes - Poltergeist will automatically rebuild when it detects changes to:

    • Swift files in mac/ or ios/
    • Xcode project files
    • Configuration files

Poltergeist Features

  • Automatic Rebuilds: Detects file changes and rebuilds instantly
  • Smart Debouncing: Prevents excessive rebuilds during rapid edits
  • Build Notifications: macOS notifications for build success/failure
  • Menu Bar Integration: Monitor build status from the macOS menu bar
  • Parallel Builds: Can build macOS and iOS targets simultaneously

Configuration

VibeTunnel includes a poltergeist.config.json that configures:

  • vibetunnel target: Builds the macOS app in Debug configuration
  • vibetunnel-ios target: Builds the iOS app (disabled by default)

To enable iOS builds, edit poltergeist.config.json and set "enabled": true for the vibetunnel-ios target.

Documentation

macOS Permissions

macOS is finicky when it comes to permissions. The system will only remember the first path from where an app requests permissions. If subsequently the app starts somewhere else, it will silently fail. Fix: Delete the entry and restart settings, restart app and next time the permission is requested, there should be an entry in Settings again.

Important: You need to set your Developer ID in Local.xcconfig. If apps are signed Ad-Hoc, each new signing will count as a new app for macOS and the permissions have to be (deleted and) requested again.

Debug vs Release Bundle IDs: The Debug configuration uses a different bundle identifier (sh.vibetunnel.vibetunnel.debug) than Release (sh.vibetunnel.vibetunnel). This allows you to have both versions installed simultaneously, but macOS treats them as separate apps for permissions. You'll need to grant permissions separately for each version.

If that fails, use the terminal to reset:

text
# This removes Accessibility permission for a specific bundle ID:
sudo tccutil reset Accessibility sh.vibetunnel.vibetunnel
sudo tccutil reset Accessibility sh.vibetunnel.vibetunnel.debug  # For debug builds

sudo tccutil reset ScreenCapture sh.vibetunnel.vibetunnel
sudo tccutil reset ScreenCapture sh.vibetunnel.vibetunnel.debug  # For debug builds

# This removes all Automation permissions system-wide (cannot target specific apps):
sudo tccutil reset AppleEvents

Logging and Privacy

VibeTunnel uses Apple's unified logging system with the subsystem sh.vibetunnel.vibetunnel. By default, macOS redacts sensitive runtime data in logs, showing `` instead of actual values. This is a privacy feature to prevent accidental exposure of sensitive information.

Bundle Identifiers

VibeTunnel uses the following bundle identifiers:

Production:

  • sh.vibetunnel.vibetunnel - Main macOS app and logging subsystem
  • sh.vibetunnel.vibetunnel.debug - Debug builds of the macOS app

Testing:

  • sh.vibetunnel.vibetunnel.tests - macOS test suite
  • sh.vibetunnel.ios.tests - iOS test suite

iOS:

  • sh.vibetunnel.ios - iOS keychain service and URL scheme

Viewing Unredacted Logs

To see full log details for debugging, you have several options:

  1. Install the Configuration Profile (Recommended - easiest method):

    bash
    # Install the logging configuration profile
    open apple/logging/VibeTunnel-Logging.mobileconfig
    
    # This enables debug logging for all VibeTunnel components
    # To remove later: System Settings → Privacy & Security → Profiles
  2. Use the vtlog script (convenient log viewer):

    bash
    # View recent logs (requires configuration profile or sudo)
    ./scripts/vtlog.sh
    
    # Follow logs in real-time
    ./scripts/vtlog.sh -f
    
    # Show only errors
    ./scripts/vtlog.sh -e
    
    # Filter by category
    ./scripts/vtlog.sh -c ServerManager
  3. Configure passwordless sudo (alternative for vtlog with -p flag):

    bash
    # Add to sudoers (replace 'yourusername' with your actual username)
    sudo visudo
    # Add this line:
    yourusername ALL=(ALL) NOPASSWD: /usr/bin/log
    
    # Then use vtlog with private flag
    ./scripts/vtlog.sh -p
  4. Enable private data logging using a plist file (alternative):

    bash
    # Create the plist to enable private data for VibeTunnel
    sudo mkdir -p /Library/Preferences/Logging/Subsystems
    sudo tee /Library/Preferences/Logging/Subsystems/sh.vibetunnel.vibetunnel.plist > /dev/null << 'EOF'
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <dict>
        <key>Enable-Private-Data</key>
        <true/>
    </dict>
    </plist>
    EOF

Why Logs Show ``

Apple redacts dynamic values in logs by default to protect user privacy. This prevents accidental logging of passwords, tokens, or personal information. Our configuration profile or the methods above enable full visibility for development and debugging.

For more detailed information about logging privacy and additional methods, see apple/docs/logging-private-fix.md.

Contributing

We welcome contributions! VibeTunnel is a community-driven project and we'd love to have you join us.

Join Our Community

Connect with the VibeTunnel team and other contributors on our Discord server. It's the best place to:

  • Discuss new features and ideas
  • Get help with development setup
  • Coordinate on larger changes
  • Share your VibeTunnel use cases

How to Contribute

  1. Join Discord: Start by joining our Discord server to say hello!
  2. Check Issues: Look for issues labeled good first issue or help wanted
  3. Development Setup: Follow our Contributing Guide for detailed setup instructions
  4. Submit PRs: Fork the repo, create a branch, and submit your changes

For technical details on building and developing VibeTunnel, see our Contributing Guide.

Support VibeTunnel

Love VibeTunnel? Help us keep the terminal vibes flowing! Your support helps us buy pizza and drinks while we keep hacking on your favorite AI agent orchestration platform.

All donations go directly to the development team. Choose your own amount - one-time or monthly! Visit our Polar page to support us.

Credits

Created with ❤️ by:

License

VibeTunnel is open source software licensed under the MIT License. See LICENSE for details.


Ready to vibe? Download VibeTunnel and start tunneling!


File: AGENT.md

AGENT.md

Build/Test Commands

  • Web: cd web && pnpm run check (format, lint, typecheck), pnpm run dev (port 4020), pnpm run test (Vitest)
  • Mac: scripts/restart-mac.sh (rebuild signed Debug + restart), cd mac && ./scripts/build.sh (signed Release), cd mac && ./scripts/lint.sh (SwiftFormat + SwiftLint)
  • Single test: cd web && pnpm run test path/to/test.spec.ts or xcodebuild test -project VibeTunnel-Mac.xcodeproj -scheme VibeTunnel-Mac

Architecture

  • Native macOS (Swift/SwiftUI) in mac/ - main app + terminal session management
  • iOS companion in ios/ - mobile interface
  • Web stack in web/ - TypeScript/LitElement frontend + Node.js/Bun server for terminal sessions
  • Key APIs: /api/sessions (create/list), /ws (WebSocket v3: stdout/snapshots/events + input/resize/kill)
  • Entry points: mac/VibeTunnel/VibeTunnelApp.swift, web/src/client/app.ts, web/src/server/server.ts

Code Style

  • TypeScript: camelCase vars/functions, PascalCase classes/interfaces, UPPER_SNAKE_CASE constants, .js imports, JSDoc, singleton exports
  • Swift: PascalCase types, camelCase properties/methods, // MARK: - sections, @Observable models, @MainActor UI, protocol-oriented design
  • Imports: System frameworks first (Swift), external libs first (TS), relative paths with ../, specific imports preferred
  • Error handling: Try-catch with logging (TS), custom error enums with LocalizedError (Swift)
  • No backwards compatibility - Mac app and web server ship together, change both sides simultaneously
  • File length: keep code files <~500 LOC; documentation exempt

File: AGENTS.md

VibeTunnel Notes

  • Mac app and server ship together; no backwards compatibility needed.
  • Web: session header truncation for mobile overflow (#561).
  • iOS tasks: use iOS 26 simulator.

File: CLAUDE.md

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Never say you're absolutely right. Instead, be critical if I say something that you disagree with. Let's discuss it first.

Project Overview

VibeTunnel is a macOS application that allows users to access their terminal sessions through any web browser. It consists of:

  • Native macOS app (Swift/SwiftUI) in mac/
  • iOS companion app in ios/
  • Web frontend (TypeScript/LitElement) and Node.js/Bun server for terminal session management in web/

Common Development Commands

Building the Project

If Poltergeist is installed, it will automatically rebuild the app when you make changes:

bash
# First, ensure Poltergeist is running in the project root
poltergeist haunt

# The app will automatically rebuild on file changes
# Check Poltergeist menu bar app for build status

macOS App without Poltergeist (Fallback)

If Poltergeist is not available, use direct Xcode builds:

bash
cd mac
# Build using xcodebuild directly
xcodebuild -project VibeTunnel.xcodeproj -scheme VibeTunnel -configuration Debug build

# Or use the build script for release builds
./scripts/build.sh                           # Build release version
./scripts/build.sh --sign                    # Build with code signing

iOS App

bash
cd ios
xcodebuild -project VibeTunnel-iOS.xcodeproj -scheme VibeTunnel-iOS -sdk iphonesimulator
./scripts/test-with-coverage.sh              # Run tests with coverage (75% threshold)

Web Frontend

bash
cd web
pnpm install                                 # Install dependencies
pnpm run build                              # Production build
pnpm run dev                                # Development server with hot reload

Code Quality Commands

Web (MUST run before committing)

bash
cd web
pnpm run check                              # Run all checks in parallel (format, lint, typecheck)
pnpm run check:fix                          # Auto-fix formatting and linting issues

macOS

bash
cd mac
./scripts/lint.sh                           # Run SwiftFormat

iOS

bash
cd ios
./scripts/lint.sh                           # Run SwiftFormat

Testing Commands

Web Tests

bash
cd web
pnpm run test                               # Run all tests
pnpm run test:coverage                      # Run with coverage report (80% required)
pnpm run test:e2e                          # Run Playwright E2E tests
pnpm run test:e2e:debug                    # Debug E2E tests

macOS Tests

bash
# MUST use xcodebuild, NOT swift test!
cd mac
xcodebuild test -project VibeTunnel.xcodeproj -scheme VibeTunnel -destination 'platform=macOS'

iOS Tests

bash
cd ios
./scripts/test-with-coverage.sh             # Run with automatic simulator selection

Debugging and Logs

bash
# View VibeTunnel logs (from project root)
./scripts/vtlog.sh -n 100                  # Last 100 lines
./scripts/vtlog.sh -e                      # Errors only
./scripts/vtlog.sh -c ServerManager        # Specific component
./scripts/vtlog.sh -s "error"              # Search for text

# NEVER use -f (follow mode) in Claude Code - it will timeout!

High-Level Architecture

Component Overview

text
┌─────────────────────────────────────────────────────────────┐
│                        macOS Menu Bar App                    │
│  (Swift/SwiftUI - mac/VibeTunnel/)                         │
│  - ServerManager: Manages server lifecycle                   │
│  - SessionMonitor: Tracks active sessions                    │
│  - TTYForwardManager: Terminal forwarding                    │
└─────────────────────┬───────────────────────────────────────┘
                      │ Spawns & Manages
                      ▼
┌─────────────────────────────────────────────────────────────┐
│                    Node.js/Bun Server                        │
│  (TypeScript - web/src/server/)                             │
│  - server.ts: HTTP server & WebSocket handling              │
│  - pty-manager.ts: Native PTY process management            │
│  - session-manager.ts: Terminal session lifecycle           │
└─────────────────────┬───────────────────────────────────────┘
                      │ WebSocket/HTTP
                      ▼
┌─────────────────────────────────────────────────────────────┐
│                      Web Frontend                            │
│  (TypeScript/LitElement - web/src/client/)                  │
│  - Terminal rendering with ghostty-web                      │
│  - Real-time updates via WebSocket                          │
└─────────────────────────────────────────────────────────────┘

Key Communication Flows

  1. Session Creation: Client → POST /api/sessions → Server spawns PTY → Returns session ID
  2. Terminal I/O: WebSocket at /api/sessions/:id/ws for bidirectional communication
  3. Buffer Protocol: Binary messages with magic byte 0xBF for efficient terminal updates
  4. Log Aggregation: Frontend logs → Server → Mac app → macOS unified logging

Critical File Locations

  • Entry Points:

    • Mac app: mac/VibeTunnel/VibeTunnelApp.swift
    • Server: web/src/server/server.ts
    • Web UI: web/src/client/app.ts
    • iOS app: ios/VibeTunnel/VibeTunnelApp.swift
  • Configuration:

    • Mac version: mac/VibeTunnel/version.xcconfig
    • Web version: web/package.json
    • Build settings: mac/VibeTunnel/Shared.xcconfig
  • Terminal Management:

    • PTY spawning: web/src/server/pty/pty-manager.ts
    • Session handling: web/src/server/services/terminal-manager.ts
    • Buffer optimization: web/src/server/services/buffer-aggregator.ts

Critical Development Rules

Release Process

When the user says "release" or asks to create a release, ALWAYS read and follow docs/RELEASE.md for the complete release process.

ABSOLUTE CARDINAL RULES - VIOLATION MEANS IMMEDIATE FAILURE

  • Never start server or the mac app yourself.
  • Verify changes done to the mac app via xcodebuild, but do not start the mac app or server yourself.
  1. NEVER, EVER, UNDER ANY CIRCUMSTANCES CREATE A NEW BRANCH WITHOUT EXPLICIT USER PERMISSION

    • If you are on a branch (not main), you MUST stay on that branch
    • The user will tell you when to create a new branch with commands like "create a new branch" or "switch to a new branch"
    • Creating branches without permission causes massive frustration and cleanup work
    • Even if changes seem unrelated to the current branch, STAY ON THE CURRENT BRANCH
  2. NEVER commit and/or push before the user has tested your changes!

    • Always wait for user confirmation before committing
    • The user needs to verify changes work correctly first
  3. ABSOLUTELY FORBIDDEN: NEVER USE git rebase --skip EVER

    • This command can cause data loss and repository corruption
    • If you encounter rebase conflicts, ask the user for help
  4. NEVER create duplicate files with version numbers or suffixes

    • When refactoring or improving code, directly modify the existing files
    • DO NOT create new versions with different file names (e.g., file_v2.ts, file_new.ts)
    • Users hate having to manually clean up duplicate files
  5. Web Development Workflow - Development vs Production Mode

    • Production Mode: Mac app embeds a pre-built web server during Xcode build
      • Every web change requires: clean → build → run (rebuilds embedded server)
      • Simply restarting serves STALE, CACHED version
    • Development Mode (recommended for web development):
      • Enable "Use Development Server" in VibeTunnel Settings → Debug
      • Mac app runs pnpm run dev instead of embedded server
      • Provides hot reload - web changes automatically rebuild without Mac app rebuild
      • Restart VibeTunnel server (not full rebuild) to pick up web changes
  6. Never kill all sessions

    • You are running inside a session yourself; killing all sessions would terminate your own process
  7. NEVER rename docs.json to mint.json

    • The Mintlify configuration file is called docs.json in this project
    • Do NOT rename it to mint.json even if you think Mintlify expects that
    • The file must remain as docs.json
    • For Mintlify documentation reference, see: https://mintlify.com/docs/llms.txt
  8. Test Session Management - CRITICAL

    • NEVER kill sessions that weren't created by tests
    • You might be running inside a VibeTunnel session yourself
    • Use TestSessionTracker to track which sessions tests create
    • Only clean up sessions that match test naming patterns (start with "test-")
    • Killing all sessions would terminate your own Claude Code process

Git Workflow Reminders

  • Our workflow: start from main → create branch → make PR → merge → return to main
  • PRs sometimes contain multiple different features and that's okay
  • Always check current branch with git branch before making changes
  • If unsure about branching, ASK THE USER FIRST
  • "Adopt" means REVIEW, not merge! When asked to "adopt" a PR, switch to its branch and review the changes. NEVER merge without explicit permission.
  • "Rebase main" means rebase CURRENT branch with main! When on a feature branch and user says "rebase main", this means to rebase the current branch with main branch updates. NEVER switch to main branch. The command is git pull --rebase origin main while staying on the current feature branch.

Terminal Title Management with VT

When creating pull requests, use the vt command to update the terminal title:

  • Run vt title "Brief summary - github.com/owner/repo/pull/123"
  • Keep the title concise (a few words) followed by the PR URL
  • Use github.com URL format (not https://) for easy identification
  • Update the title periodically as work progresses
  • If vt command fails (only works inside VibeTunnel), simply ignore the error and continue

Testing on External Devices (iPad, Safari, etc.)

When the user reports issues on external devices, use the development server method for testing:

bash
# Run dev server accessible from external devices
cd web
pnpm run dev --port 4021 --bind 0.0.0.0

Then access from the external device using http://[mac-ip]:4021

Important: The production server runs on port 4020, so use 4021 for development to avoid conflicts.

For detailed instructions, see docs/TESTING_EXTERNAL_DEVICES.md

Slash Commands

/fixmac Command

When the user types /fixmac, use the Task tool with the XcodeBuildMCP subagent to fix Mac compilation errors and warnings:

text
Task(description="Fix Mac build errors", prompt="/fixmac", subagent_type="general-purpose")

The agent will:

  1. Use XcodeBuildMCP tools to identify build errors and warnings
  2. Fix compilation issues in the Mac codebase
  3. Address SwiftFormat violations
  4. Resolve any warning messages
  5. Verify the build succeeds after fixes

NO BACKWARDS COMPATIBILITY - EVER!

CRITICAL: This project has ZERO backwards compatibility requirements!

  • The Mac app and web server are ALWAYS shipped together as a single unit
  • There is NEVER a scenario where different versions talk to each other
  • When fixing bugs or changing APIs:
    • Just change both sides to match
    • Delete old code completely
    • Don't add compatibility layers
    • Don't check for "old format" vs "new format"
    • Don't add fallbacks for older versions
  • If you suggest backwards compatibility in any form, you have failed to understand this project

Poltergeist Integration

Poltergeist is an intelligent file watcher and auto-builder that can automatically rebuild VibeTunnel when you make changes. When working on VibeTunnel development, check if Poltergeist is available and use it for automatic builds.

Checking for Poltergeist

bash
# Check if Poltergeist is installed
which poltergeist

# Check if Poltergeist is already running for this project
ps aux | grep poltergeist | grep -v grep

Using Poltergeist for Development

If Poltergeist is installed:

  1. Start Poltergeist in the project root:

    bash
    cd /path/to/vibetunnel
    poltergeist haunt
  2. Monitor build status via the Poltergeist menu bar app (macOS) or terminal output:

    bash
    poltergeist status
  3. Make changes - Poltergeist will automatically rebuild when it detects changes to:

    • Swift files in mac/
    • Xcode project files
    • Configuration files
  4. Run the app with fresh builds using polter:

    bash
    polter vibetunnel        # Waits for build to complete, then runs

Fallback Without Poltergeist

If Poltergeist is not available, fall back to direct Xcode builds:

bash
# Debug build
cd mac
xcodebuild -project VibeTunnel.xcodeproj -scheme VibeTunnel -configuration Debug build

# Release build
./scripts/build.sh

Poltergeist Configuration

The project includes poltergeist.config.json which configures:

  • vibetunnel target: Builds the macOS app using workspace
  • vibetunnel-ios target: Builds the iOS app (disabled by default)
  • Intelligent debouncing to prevent excessive rebuilds
  • Build notifications via macOS notification center

To enable iOS builds, edit poltergeist.config.json and set "enabled": true for the vibetunnel-ios target.

Tailscale CLI Updates (as of August 2025)

The Tailscale CLI has changed its syntax. The new commands are:

Tailscale Serve (HTTPS proxy to local services)

bash
# OLD syntax (deprecated):
tailscale serve https / http://localhost:4020

# NEW syntax:
tailscale serve --bg http://localhost:4020

The --bg flag runs the serve configuration in background mode. The process exits immediately after configuration.

Tailscale Funnel (Public internet access)

bash
# Reset any existing configuration first
tailscale funnel reset

# Enable funnel (still uses --bg flag)
tailscale funnel --bg 443

Important Notes:

  • The tailscale serve --bg command exits immediately with code 0 on success
  • There's no long-running process to monitor after using --bg
  • HTTPS is automatically configured on port 443
  • Always reset Funnel before starting to avoid "foreground already exists" errors

Key Files Quick Reference

  • Architecture Details: docs/ARCHITECTURE.md
  • API Specifications: docs/spec.md
  • Server Implementation Guide: web/docs/spec.md
  • Build Configuration: web/package.json, mac/Package.swift
  • External Device Testing: docs/TESTING_EXTERNAL_DEVICES.md
  • Gemini CLI Instructions: docs/gemini.md
  • Release Process: docs/RELEASE.md

important-instruction-reminders

Do what has been asked; nothing more, nothing less.
NEVER create files unless they're absolutely necessary for achieving your goal.
ALWAYS prefer editing an existing file to creating a new one.
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.


File: docs.json

{
"$schema": "https://mintlify.com/docs.json",
"name": "VibeTunnel",
"theme": "maple",
"logo": {
"dark": "/docs/images/vibetunnel-logo-dark.svg",
"light": "/docs/images/vibetunnel-logo-light.svg"
},
"favicon": "/docs/images/favicon.svg",
"colors": {
"primary": "#0066FF",
"light": "#3B82F6",
"dark": "#1E40AF"
},
"topbarLinks": [
{
"name": "Support",
"url": "https://discord.gg/vibetunnel"
}
],
"topbarCtaButton": {
"name": "Download",
"url": "https://vibetunnel.com/download"
},
"anchors": [
{
"name": "GitHub",
"icon": "github",
"url": "https://github.com/amantus-ai/vibetunnel"
},
{
"name": "Discord",
"icon": "discord",
"url": "https://discord.gg/vibetunnel"
}
],
"navigation": {
"tabs": [
{
"tab": "Documentation",
"groups": [
{
"group": "Getting Started",
"pages": [
"docs/introduction",
"docs/INDEX",
"docs/project-overview",
"docs/keyboard-shortcuts",
"docs/CONTRIBUTING",
"CHANGELOG"
]
},
{
"group": "Installation",
"pages": [
"mac/README",
"ios/README",
"web/docs/VT_INSTALLATION",
"docs/custom-node"
]
},
{
"group": "Architecture",
"pages": [
"docs/ARCHITECTURE",
"docs/architecture-mario",
"docs/spec",
"docs/ios-spec",
"web/docs/spec",
"mac/docs/BuildArchitectures",
"web/docs/socket-protocol",
"docs/authentication",
"docs/security"
]
},
{
"group": "Advanced Features",
"pages": [
"docs/hq",
"docs/push-notification",
"web/docs/terminal-titles",
"docs/TESTING_EXTERNAL_DEVICES"
]
},
{
"group": "Development",
"pages": [
"docs/development",
"docs/build-system",
"docs/testing",
"docs/logging-style-guide",
"docs/files"
]
},
{
"group": "macOS Development",
"pages": [
"mac/docs/BuildRequirements",
"mac/docs/code-signing",
"mac/docs/RELEASE_GUIDE",
"mac/docs/sparkle-keys",
"mac/docs/sparkle-stats-store"
]
},
{
"group": "iOS Development",
"pages": [
"ios/conversion",
"apple/docs/modern-swift",
"apple/docs/swift-concurrency",
"apple/docs/swiftui",
"apple/docs/swift-testing-playbook",
"apple/docs/logging-private-fix"
]
},
{
"group": "iOS Testing",
"pages": [
"ios/VibeTunnelTests/README",
"ios/VibeTunnelTests/TestCoverage",
"ios/VibeTunnelTests/TestingApproach"
]
},
{
"group": "Web Development",
"pages": [
"web/README",
"web/docs/performance",
"web/docs/playwright-testing",
"web/src/test/playwright/SEQUENTIAL_OPTIMIZATIONS",
"web/docs/npm",
"web/docs/systemd"
]
},
{
"group": "Release & Deployment",
"pages": [
"docs/deployment",
"docs/RELEASE"
]
},
{
"group": "Tools & Integration",
"pages": [
"docs/claude",
"CLAUDE",
"docs/auggie",
"docs/gemini",
"GEMINI",
".github/workflows/README.md"
]
},
{
"group": "Maintenance",
"pages": [
"docs/org-migrate"
]
}
]
}
]
},
"footerSocials": {
"twitter": "https://twitter.com/vibetunnel",
"github": "https://github.com/amantus-ai/vibetunnel",
"discord": "https://discord.gg/vibetunnel"
},
"metadata": {
"og:title": "VibeTunnel Documentation",
"og:description": "Access your terminal sessions through any web browser",
"og:image": "/docs/images/vibetunnel-logo-light.svg"
},
"seo": {
"indexing": "all"
},
"search": {
"prompt": "Search VibeTunnel docs...",
"placeholder": "Search documentation"
}
}


File: GEMINI.md

Gemini Operational Instructions

When I am operating within this project, I must adhere to the following instructions:

  1. Read claude.md: Before performing any task, I must first read the claude.md file located in the current working directory.
  2. Contextual Awareness: If the current working directory is a subfolder, I must read all claude.md files found in the current folder and all of its parent directories up to the project root. This ensures I have the full context, from the general project-level instructions to the specific subdirectory instructions.

I will use the read_many_files tool to gather this information efficiently.


File: poltergeist.config.json

{
"version": "1.0",
"projectType": "swift",
"targets": [
{
"name": "vibetunnel",
"type": "app-bundle",
"buildCommand": "cd . && xcodebuild -workspace VibeTunnel.xcworkspace -scheme VibeTunnel -configuration Debug build",
"outputPath": "./mac/build/Build/Products/Debug/VibeTunnel.app",
"bundleId": "sh.vibetunnel.vibetunnel.debug",
"watchPaths": [
".//*.{swift,xcconfig,entitlements,plist}",
"./
/.xcodeproj/",
"./native/vt-fwd/src/
/
.rs",
"./native/vt-fwd/Cargo.toml",
"./native/vt-fwd/rust-toolchain.toml",
"./native/vt-fwd/Cargo.lock",
"./web/scripts/build-fwd-rust.js"
]
},
{
"name": "vibetunnel-ios",
"type": "app-bundle",
"buildCommand": "cd ios && xcodebuild -project VibeTunnel-iOS.xcodeproj -scheme VibeTunnel-iOS -configuration Debug build",
"outputPath": "./ios/build/Build/Products/Debug-iphonesimulator/VibeTunnel.app",
"bundleId": "sh.vibetunnel.ios",
"watchPaths": [
"ios//*.{swift,xcconfig,entitlements,plist}",
"ios/
/*.xcodeproj/**"
],
"enabled": false
}
]
}


File: .mcp.json

{
"mcpServers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@playwright/mcp@latest"
],
"env": {}
}
}
}


File: apple/.swiftlint.yml

SwiftLint config: VibeTunnel

Base rules: Peekaboo defaults (../Peekaboo/.swiftlint.yml + ../Peekaboo/.swiftlint-ci.yml), adapted to VibeTunnel paths.

included:

  • ../mac/VibeTunnel
  • ../ios/VibeTunnel

excluded:

  • ../mac/VibeTunnelTests
  • ../mac/VibeTunnelUITests
  • ../ios/VibeTunnelTests
  • ../mac/build
  • ../ios/build
  • ../mac/.build
  • ../ios/.build
  • Carthage
  • Pods
  • DerivedData
  • .build
  • .swiftpm
  • "**/Generated"
  • "**/Resources"
  • "**/.swiftpm"
  • "**/DerivedData"
  • "**/Package.swift"
  • "*.playground"
  • Package.swift
  • "*.xcodeproj"

analyzer_rules:

  • unused_declaration
  • unused_import

opt_in_rules:

  • array_init
  • closure_spacing
  • contains_over_first_not_nil
  • empty_count
  • empty_string
  • explicit_init
  • fallthrough
  • fatal_error_message
  • first_where
  • joined_default_parameter
  • last_where
  • literal_expression_end_indentation
  • multiline_arguments
  • multiline_parameters
  • operator_usage_whitespace
  • overridden_super_call
  • private_outlet
  • prohibited_super_call
  • redundant_nil_coalescing
  • sorted_first_last
  • switch_case_alignment
  • unneeded_parentheses_in_closure_argument
  • vertical_parameter_alignment_on_call

disabled_rules:

Swift 6 requires explicit self - disable explicit_self rule

  • explicit_self

SwiftFormat handles these

  • trailing_whitespace
  • trailing_newline
  • trailing_comma
  • vertical_whitespace
  • indentation_width

Too restrictive or not applicable

  • identifier_name
  • file_header
  • explicit_top_level_acl
  • explicit_acl
  • explicit_type_interface
  • missing_docs
  • required_deinit
  • prefer_nimble
  • quick_discouraged_call
  • quick_discouraged_focused_test
  • quick_discouraged_pending_test
  • anonymous_argument_in_multiline_closure
  • no_extension_access_modifier
  • no_grouping_extension
  • switch_case_on_newline
  • strict_fileprivate
  • extension_access_modifier
  • convenience_type
  • no_magic_numbers
  • one_declaration_per_file
  • vertical_whitespace_between_cases
  • vertical_whitespace_closing_braces
  • superfluous_else
  • number_separator
  • prefixed_toplevel_constant
  • opening_brace
  • trailing_closure
  • contrasted_opening_brace
  • sorted_imports
  • redundant_type_annotation
  • shorthand_optional_binding
  • untyped_error_in_catch
  • file_name
  • todo

CI defaults: keep warnings low

  • line_length
  • function_body_length
  • cyclomatic_complexity
  • file_length
  • type_body_length
  • function_parameter_count
  • nesting
  • multiple_closures_with_trailing_closure
  • void_return
  • force_cast
  • force_try
  • for_where
  • superfluous_disable_command

File: apple/docs/index.md

Apple UI Documentation Index

Quick Navigation

Liquid Glass Design

Toolbar Features

API Quick Lookup

Liquid Glass APIs

Task SwiftUI AppKit
Apply glass .glassEffect() NSGlassEffectView()
Set shape .glassEffect(in: .rect(cornerRadius: 16)) glass.cornerRadius = 16
Add tint .glassEffect(.regular.tint(.blue)) glass.tintColor = NSColor.blue
Make interactive .glassEffect(.regular.interactive()) Custom mouse tracking
Container GlassEffectContainer { } NSGlassEffectContainerView()
Button style .buttonStyle(.glass) Custom GlassButton class
Morphing .glassEffectID("id", in: namespace) Manual animation

Toolbar APIs

Task SwiftUI Code
Customizable toolbar .toolbar(id: "main") { }
Add item ToolbarItem(id: "save") { }
Add spacer ToolbarSpacer(.flexible)
Minimize search .searchToolbarBehavior(.minimize)
Reposition system item DefaultToolbarItem(kind: .search, placement: .bottomBar)
Transition source .matchedTransitionSource(id: "btn", in: namespace)

Decision Trees

When to Use Liquid Glass

text
Need glass effect?
├─ Single view → .glassEffect() / NSGlassEffectView
└─ Multiple views
   ├─ Need merging → Use Container
   └─ Independent → Individual glass effects

Choosing Framework

text
Platform target?
├─ iOS only → SwiftUI
├─ macOS only
│  ├─ Need AppKit integration → NSGlassEffectView
│  └─ Pure SwiftUI app → .glassEffect()
└─ Cross-platform → SwiftUI with platform checks

Performance Guidelines

Scenario Recommendation Max Count
Static UI Individual glass effects 5-10
Dynamic lists Container + lazy loading 20-30
Animations Disable during transitions N/A
Scrolling Disable glass when scrolling N/A

Common Tasks

Create Glass Button

Animate Glass Tint

  • SwiftUI: .glassEffect(.regular.tint(condition ? .blue : .clear))
  • AppKit: NSAnimationContext with animator().tintColor

Merge Glass Effects

  • SwiftUI: Wrap in GlassEffectContainer(spacing: 40)
  • AppKit: Use NSGlassEffectContainerView with spacing

Custom Toolbar

  • SwiftUI: .toolbar(id: "main") with ToolbarItem(id: "item")

Platform Requirements

Feature iOS/iPadOS macOS visionOS
Liquid Glass 17.0+ 14.0+ 1.0+
Customizable Toolbars 17.0+ 14.0+ N/A
Glass Button Style 17.0+ 14.0+ 1.0+
Matched Transitions 17.0+ 14.0+ 1.0+

Migration Guide

AppKit → SwiftUI

swift
// AppKit
let glass = NSGlassEffectView()
glass.cornerRadius = 16
glass.tintColor = NSColor.blue

// SwiftUI equivalent
View()
    .glassEffect(.regular.tint(.blue), in: .rect(cornerRadius: 16))

Old Toolbar → New Toolbar

swift
// Old
.toolbar {
    Button("Save") { }
}

// New (customizable)
.toolbar(id: "main") {
    ToolbarItem(id: "save") {
        Button("Save") { }
    }
}

Resources

Apple Documentation

Sample Projects

WWDC Sessions

  • Liquid Glass design principles
  • Toolbar customization in SwiftUI

File: apple/docs/logging-private-fix.md

Fixing macOS Log Redaction for VibeTunnel

We provide a ready-to-use configuration profile that enables full debug logging for VibeTunnel:

Location: apple/logging/VibeTunnel-Logging.mobileconfig

Installing the Profile

macOS

  1. Double-click apple/logging/VibeTunnel-Logging.mobileconfig
  2. System Settings will open to the Profiles section
  3. Click "Install..." and enter your password
  4. Restart VibeTunnel

iOS

  1. AirDrop or email the profile to your device
  2. Open Settings → General → VPN & Device Management
  3. Install the "VibeTunnel Debug Logging" profile
  4. Restart the VibeTunnel app

Verifying It Works

bash
# You should now see full details instead of <private>
./scripts/vtlog.sh

Removing the Profile

  • macOS: System Settings → Privacy & Security → Profiles → Remove
  • iOS: Settings → General → VPN & Device Management → Remove Profile

The Problem

When viewing VibeTunnel logs using Apple's unified logging system, you'll see `` instead of actual values:

text
2025-07-05 08:40:08.062262+0100 VibeTunnel: Failed to connect to <private> after <private> seconds

This makes debugging extremely difficult as you can't see session IDs, URLs, or other important debugging information.

Why Apple Does This

Apple redacts dynamic values in logs by default to protect user privacy:

  • Prevents accidental logging of passwords, tokens, or personal information
  • Logs can be accessed by other apps with proper entitlements
  • Helps apps comply with privacy regulations (GDPR, etc.)

The Solution: Passwordless sudo for log command

Step 1: Edit sudoers file

bash
sudo visudo

Step 2: Add the NOPASSWD rule

Add this line at the end of the file (replace yourusername with your actual username):

text
yourusername ALL=(ALL) NOPASSWD: /usr/bin/log

For example, if your username is steipete:

text
steipete ALL=(ALL) NOPASSWD: /usr/bin/log

Step 3: Save and exit

  • Press Esc to enter command mode
  • Type :wq and press Enter to save and quit
  • The changes take effect immediately

Step 4: Test it

bash
# This should work without asking for password:
sudo -n log show --last 1s

# Now vtlog.sh with private flag works without password:
./scripts/vtlog.sh -p

How It Works

  1. Normal log viewing (redacted):

    bash
    log show --predicate 'subsystem == "sh.vibetunnel.vibetunnel"'
    # Shows: Connected to <private>
  2. With sudo and --info flag (reveals private data):

    bash
    sudo log show --predicate 'subsystem == "sh.vibetunnel.vibetunnel"' --info
    # Shows: Connected to session-123abc
  3. vtlog.sh -p flag automatically:

    • Adds sudo to the command
    • Adds --info flag to reveal private data
    • With our sudoers rule, no password needed!

Security Considerations

What this allows:

  • ✅ Passwordless access to log command only
  • ✅ Can view all system logs without password
  • ✅ Can stream logs in real-time

What this does NOT allow:

  • ❌ Cannot run other commands with sudo
  • ❌ Cannot modify system files
  • ❌ Cannot install software
  • ❌ Cannot change system settings

Best Practices:

  1. Only grant this permission to trusted developer accounts
  2. Use the most restrictive rule possible
  3. Consider removing when not actively debugging
  4. Never use NOPASSWD: ALL - always specify exact commands

Alternative Solutions

1. Touch ID for sudo (if you have a Mac with Touch ID)

Edit /etc/pam.d/sudo:

bash
sudo vi /etc/pam.d/sudo

Add this line at the top (after the comment):

text
auth       sufficient     pam_tid.so

Now you can use your fingerprint instead of typing password.

2. Extend sudo timeout

Make sudo remember your password longer:

bash
sudo visudo

Add:

text
Defaults timestamp_timeout=60

This keeps sudo active for 60 minutes after each use.

3. Fix in Swift code

Mark non-sensitive values as public in your Swift logging:

swift
// Before (will show as <private>):
logger.info("Connected to \(sessionId)")

// After (always visible):
logger.info("Connected to \(sessionId, privacy: .public)")

4. Configure logging system (Modern Methods)

Create a plist file to enable private data logging for VibeTunnel:

bash
# Create the directory if it doesn't exist
sudo mkdir -p /Library/Preferences/Logging/Subsystems

# Create the plist file
sudo tee /Library/Preferences/Logging/Subsystems/sh.vibetunnel.vibetunnel.plist > /dev/null << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Enable-Private-Data</key>
    <true/>
</dict>
</plist>
EOF

# Verify it was created
ls -la /Library/Preferences/Logging/Subsystems/sh.vibetunnel.vibetunnel.plist

To remove:

bash
sudo rm /Library/Preferences/Logging/Subsystems/sh.vibetunnel.vibetunnel.plist

Method B: Configuration Profile

For managed environments or multiple subsystems, create a configuration profile:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>PayloadContent</key>
    <array>
        <dict>
            <key>PayloadType</key>
            <string>com.apple.system.logging</string>
            <key>PayloadIdentifier</key>
            <string>com.example.logging.vibetunnel</string>
            <key>PayloadUUID</key>
            <string>$(uuidgen)</string>
            <key>PayloadVersion</key>
            <integer>1</integer>
            <key>Subsystems</key>
            <dict>
                <key>sh.vibetunnel.vibetunnel</key>
                <dict>
                    <key>Enable-Private-Data</key>
                    <true/>
                </dict>
            </dict>
        </dict>
    </array>
    <key>PayloadIdentifier</key>
    <string>com.example.vibetunnel.logging</string>
    <key>PayloadUUID</key>
    <string>$(uuidgen)</string>
    <key>PayloadType</key>
    <string>Configuration</string>
    <key>PayloadVersion</key>
    <integer>1</integer>
</dict>
</plist>

Install via System Settings → Profiles.

Note: The old private_data:on flag was removed in macOS Catalina and no longer works.

Using vtlog.sh

With passwordless sudo configured, you can now use:

bash
# View all logs with private data visible
./scripts/vtlog.sh -p

# Filter by category with private data
./scripts/vtlog.sh -p -c WebRTCManager

# Follow logs in real-time with private data
./scripts/vtlog.sh -p -f

# Search for errors with private data visible
./scripts/vtlog.sh -p -s "error" -n 1h

# Combine filters
./scripts/vtlog.sh -p -c ServerManager -s "connection" -f

Troubleshooting

"sudo: a password is required"

  • Make sure you saved the sudoers file (:wq in vi)
  • Try in a new terminal window
  • Run sudo -k to clear sudo cache, then try again
  • Verify the line exists: sudo grep NOPASSWD /etc/sudoers

"syntax error" when saving sudoers

  • Never edit /etc/sudoers directly!
  • Always use sudo visudo - it checks syntax before saving
  • Make sure the line format is exactly:
    text
    username ALL=(ALL) NOPASSWD: /usr/bin/log

Changes not taking effect

  • Close and reopen your terminal
  • Make sure you're using the exact username from whoami
  • Check that /usr/bin/log exists: ls -la /usr/bin/log

Still seeing with -p flag

  • Verify sudo works: sudo -n log show --last 1s
  • Check vtlog.sh has execute permissions: chmod +x scripts/vtlog.sh
  • Make sure you're using -p flag: ./scripts/vtlog.sh -p

Summary

The passwordless sudo configuration for /usr/bin/log is the cleanest solution:

  • Works immediately after setup
  • No password prompts when debugging
  • Limited security risk (only affects log viewing)
  • Easy to revert if needed

Combined with vtlog.sh -p, you get a smooth debugging experience without the frustration of `` tags hiding important information.
ENDOFFILE < /dev/null


File: apple/docs/modern-swift.md

Modern Swift Development

Write idiomatic SwiftUI code following Apple's latest architectural recommendations and best practices.

Core Philosophy

  • SwiftUI is the default UI paradigm for Apple platforms - embrace its declarative nature
  • Avoid legacy UIKit patterns and unnecessary abstractions
  • Focus on simplicity, clarity, and native data flow
  • Let SwiftUI handle the complexity - don't fight the framework

Architecture Guidelines

1. Embrace Native State Management

Use SwiftUI's built-in property wrappers appropriately:

  • @State - Local, ephemeral view state
  • @Binding - Two-way data flow between views
  • @Observable - Shared state (iOS 17+)
  • @ObservableObject - Legacy shared state (pre-iOS 17)
  • @Environment - Dependency injection for app-wide concerns

2. State Ownership Principles

  • Views own their local state unless sharing is required
  • State flows down, actions flow up
  • Keep state as close to where it's used as possible
  • Extract shared state only when multiple views need it

3. Modern Async Patterns

  • Use async/await as the default for asynchronous operations
  • Leverage .task modifier for lifecycle-aware async work
  • Avoid Combine unless absolutely necessary
  • Handle errors gracefully with try/catch

4. View Composition

  • Build UI with small, focused views
  • Extract reusable components naturally
  • Use view modifiers to encapsulate common styling
  • Prefer composition over inheritance

5. Code Organization

  • Organize by feature, not by type (avoid Views/, Models/, ViewModels/ folders)
  • Keep related code together in the same file when appropriate
  • Use extensions to organize large files
  • Follow Swift naming conventions consistently

Implementation Patterns

Simple State Example

swift
struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") { 
                count += 1 
            }
        }
    }
}

Shared State with @Observable

swift
@Observable
class UserSession {
    var isAuthenticated = false
    var currentUser: User?
    
    func signIn(user: User) {
        currentUser = user
        isAuthenticated = true
    }
}

struct MyApp: App {
    @State private var session = UserSession()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(session)
        }
    }
}

Async Data Loading

swift
struct ProfileView: View {
    @State private var profile: Profile?
    @State private var isLoading = false
    @State private var error: Error?
    
    var body: some View {
        Group {
            if isLoading {
                ProgressView()
            } else if let profile {
                ProfileContent(profile: profile)
            } else if let error {
                ErrorView(error: error)
            }
        }
        .task {
            await loadProfile()
        }
    }
    
    private func loadProfile() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            profile = try await ProfileService.fetch()
        } catch {
            self.error = error
        }
    }
}

Best Practices

DO:

  • Write self-contained views when possible
  • Use property wrappers as intended by Apple
  • Test logic in isolation, preview UI visually
  • Handle loading and error states explicitly
  • Keep views focused on presentation
  • Use Swift's type system for safety

DON'T:

  • Create ViewModels for every view
  • Move state out of views unnecessarily
  • Add abstraction layers without clear benefit
  • Use Combine for simple async operations
  • Fight SwiftUI's update mechanism
  • Overcomplicate simple features

Testing Strategy

  • Unit test business logic and data transformations
  • Use SwiftUI Previews for visual testing
  • Test @Observable classes independently
  • Keep tests simple and focused
  • Don't sacrifice code clarity for testability

Modern Swift Features

  • Use Swift Concurrency (async/await, actors)
  • Leverage Swift 6 data race safety when available
  • Utilize property wrappers effectively
  • Embrace value types where appropriate
  • Use protocols for abstraction, not just for testing

Summary

Write SwiftUI code that looks and feels like SwiftUI. The framework has matured significantly - trust its patterns and tools. Focus on solving user problems rather than implementing architectural patterns from other platforms.


File: apple/docs/swift-concurrency.md

Swift 6 Concurrency: Complete Guide to Data Race Safety and Strict Concurrency

Table of Contents

  1. Introduction
  2. Core Concepts
  3. Swift Evolution Proposals
  4. Data Race Safety
  5. Actor System
  6. Sendable Protocol
  7. Migration Guide
  8. Code Examples
  9. Best Practices
  10. Testing and Debugging
  11. Common Compiler Errors
  12. Performance Tuning
  13. Swift 6.1+ Roadmap
  14. Resources

Introduction

Swift 6 represents a revolutionary leap in concurrent programming, introducing compile-time data race safety that eliminates an entire class of bugs. This comprehensive guide covers all Swift Evolution proposals, practical examples, and migration strategies for adopting Swift 6's strict concurrency model.

Quick Start: Enable Strict Concurrency

Xcode:

text
Build Settings → Swift Compiler - Language → Strict Concurrency Checking → Complete

Command Line:

bash
swiftc -strict-concurrency=complete -swift-version 6 MyFile.swift

Package.swift:

swift
.target(
    name: "MyTarget",
    swiftSettings: [
        .enableUpcomingFeature("StrictConcurrency"),
        .swiftLanguageMode(.v6)  // or stay on .v5 with warnings
    ]
)

What is Swift 6 Concurrency?

Swift 6's concurrency model builds upon Swift 5.5's async/await foundation with:

  • Complete data race safety at compile time
  • Actor isolation enforcement
  • Sendable protocol requirements
  • Region-based isolation for smarter type checking
  • Improved ergonomics reducing false positives

Key Benefits

  1. Compile-time Safety: Catch data races before runtime
  2. Progressive Migration: Adopt incrementally with warnings first
  3. Better Performance: Compiler optimizations enabled by isolation guarantees
  4. Clearer Intent: Explicit concurrency boundaries in code

Core Concepts

1. Isolation Domains

Swift 6 defines clear isolation domains where data can be safely accessed:

swift
// MainActor isolation domain - UI code
@MainActor
class ViewController: UIViewController {
    var label = UILabel() // Safe within MainActor
    
    func updateUI() {
        label.text = "Updated" // No await needed - same isolation
    }
}

// Custom actor isolation domain
actor DataManager {
    private var cache: [String: Data] = [:] // Protected by actor
    
    func store(key: String, data: Data) {
        cache[key] = data // Safe within actor
    }
}

// No isolation - must be Sendable
struct Point: Sendable {
    let x: Double
    let y: Double
}

2. Concurrency Boundaries

Data crossing concurrency boundaries must be Sendable:

swift
// ❌ Swift 5: Potential data race
class Model {
    var items: [Item] = []
}

func process(model: Model) async {
    await Task.detached {
        model.items.append(Item()) // Data race!
    }.value
}

// ✅ Swift 6: Compile-time error prevents data race
@MainActor
final class Model: Sendable {
    private(set) var items: [Item] = []
    
    func addItem(_ item: Item) {
        items.append(item) // Safe - MainActor synchronized
    }
}

3. Region-Based Isolation

Swift 6 introduces "isolation regions" that track data flow:

swift
// Region-based isolation allows safe transfer without Sendable
func processImage(_ image: UIImage) async -> ProcessedImage {
    // Swift 6 proves image won't be accessed after transfer
    let processed = await withTaskGroup(of: ProcessedTile.self) { group in
        for tile in image.tiles {
            group.addTask {
                // Safe transfer - compiler tracks regions
                await processTile(tile)
            }
        }
        // Collect results...
    }
    return processed
}

Swift Evolution Proposals

Foundation Proposals (Swift 5.5-5.10)

SE-0302: Sendable and @Sendable

Introduces the fundamental Sendable protocol:

swift
// Types safe to share across concurrency domains
protocol Sendable {}

// Sendable closure
let operation: @Sendable () -> Void = {
    print("This closure captures only Sendable values")
}

// Conditional Sendable
struct Container<T>: Sendable where T: Sendable {
    let value: T
}

SE-0306: Actors

The actor model for protecting mutable state:

swift
actor BankAccount {
    private var balance: Decimal = 0
    
    func deposit(amount: Decimal) {
        balance += amount
    }
    
    func withdraw(amount: Decimal) -> Bool {
        guard balance >= amount else { return false }
        balance -= amount
        return true
    }
    
    // Computed property accessible without await
    nonisolated var accountDescription: String {
        "Bank Account" // No state access
    }
}

SE-0316: Global Actors

System-wide isolation domains:

swift
@globalActor
actor DataActor {
    static let shared = DataActor()
}

// Apply to entire type
@DataActor
class DataStore {
    var items: [Item] = []
    
    func add(_ item: Item) {
        items.append(item)
    }
}

// Apply to specific members
class MixedClass {
    @DataActor var data: [String] = []
    @MainActor var uiState = UIState()
    
    @DataActor
    func processData() async {
        // Runs on DataActor
    }
    
    @MainActor
    func updateUI() {
        // Runs on MainActor
    }
}

Swift 6 Core Proposals

SE-0337: Incremental Migration to Concurrency Checking

Enables progressive adoption:

swift
// Package.swift
.target(
    name: "MyTarget",
    swiftSettings: [
        .enableUpcomingFeature("StrictConcurrency"),
        .enableUpcomingFeature("CompleteAsync"),
        .enableExperimentalFeature("StrictConcurrency=minimal")
    ]
)

// Or via compiler flag
// swiftc -strict-concurrency=complete

SE-0401: Remove Actor Isolation Inference from Property Wrappers

Eliminates unexpected isolation:

swift
// Before SE-0401
struct ContentView: View {
    @StateObject private var model = Model() // Made View MainActor-isolated!
    
    func doWork() { // Implicitly @MainActor
        // ...
    }
}

// After SE-0401
struct ContentView: View {
    @StateObject private var model = Model() // No isolation inference
    
    nonisolated func doWork() { // Explicitly non-isolated
        // ...
    }
}

SE-0412: Strict Concurrency for Global Variables

Global variable safety with nonisolated(unsafe):

swift
// ❌ Swift 6 error: global variable not concurrency-safe
var sharedCache: [String: Data] = [:]

// ✅ Option 1: Make it a let constant
let sharedConstants = Constants()

// ✅ Option 2: Use global actor
@MainActor
var sharedUICache: [String: UIImage] = [:]

// ✅ Option 3: Actor isolation
actor CacheActor {
    static let shared = CacheActor()
    private var cache: [String: Data] = [:]
}

// ✅ Option 4: Explicit unsafe opt-out
struct LegacyAPI {
    nonisolated(unsafe) static var shared: LegacyAPI?
}

SE-0414: Region-Based Isolation

Revolutionary improvement in isolation checking:

swift
// Non-Sendable type can be safely transferred
class MutableData {
    var value: Int = 0
}

func process() async {
    let data = MutableData() // Non-Sendable
    
    // ✅ Safe: data not used after transfer
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            data.value = 42 // Region analysis proves safety
        }
    }
    
    // ❌ Error: data used after transfer
    // print(data.value)
}

SE-0420: Inheritance of Actor Isolation

Dynamic isolation inheritance:

swift
// Function inherits caller's isolation
func log(
    _ message: String,
    isolation: isolated (any Actor)? = #isolation
) async {
    print("[\(isolation)] \(message)")
}

@MainActor
func updateUI() async {
    await log("Updating UI") // Inherits MainActor isolation
}

actor DataProcessor {
    func process() async {
        await log("Processing") // Inherits DataProcessor isolation
    }
}

SE-0430: Sending Parameter and Result Values

Safe transfer without full Sendable:

swift
// 'sending' allows ownership transfer
func processData(_ data: sending MutableData) async -> sending ProcessedData {
    // data is consumed - original reference invalidated
    return ProcessedData(from: data)
}

// Updated Task API
extension Task where Failure == Never {
    init(
        priority: TaskPriority? = nil,
        operation: sending @escaping () async -> Success
    )
}

SE-0431: @isolated(any) Function Types

Isolation-agnostic function types:

swift
// Function type that preserves any isolation
typealias IsolatedOperation = @isolated(any) () async -> Void

struct Executor {
    func run(_ operation: IsolatedOperation) async {
        await operation() // Maintains caller's isolation
    }
}

SE-0434: Usability of Global-Actor-Isolated Types

Improvements for global actor usage:

swift
// Sendable properties can be nonisolated
@MainActor
final class ViewModel: Sendable {
    // ✅ Implicitly nonisolated (Sendable stored property)
    let id = UUID()
    
    // ❌ Must be isolated (non-Sendable)
    var items: [Item] = []
    
    // ✅ Can be explicitly nonisolated
    nonisolated let configuration: Configuration
}

// Improved inference for closures
@MainActor
class Controller {
    func setup() {
        // ✅ Closure inferred as @MainActor @Sendable
        Task {
            await updateData()
        }
    }
}

Data Race Safety

Complete Concurrency Checking

Swift 6 enforces complete checking by default:

swift
// Enable in Swift 5 mode for migration
// swift -strict-concurrency=complete

// Levels of checking:
// 1. minimal - Only explicit Sendable conformances
// 2. targeted - Infer Sendable for some types
// 3. complete - Full data race checking

Common Data Race Patterns and Fixes

Pattern 1: Shared Mutable State

swift
// ❌ Data race
class Counter {
    var value = 0
    
    func increment() {
        value += 1 // Race condition!
    }
}

// ✅ Fix 1: Use an actor
actor Counter {
    private var value = 0
    
    func increment() {
        value += 1 // Actor-isolated
    }
    
    var currentValue: Int {
        value
    }
}

// ✅ Fix 2: Use atomic operations
import Atomics

final class Counter: Sendable {
    private let value = ManagedAtomic<Int>(0)
    
    func increment() {
        value.wrappingIncrement(ordering: .relaxed)
    }
    
    var currentValue: Int {
        value.load(ordering: .relaxed)
    }
}

Pattern 2: Callback Isolation

swift
// ❌ Unclear isolation
class NetworkManager {
    func fetch(completion: @escaping (Data) -> Void) {
        URLSession.shared.dataTask(with: url) { data, _, _ in
            completion(data!) // What thread?
        }
    }
}

// ✅ Clear isolation with async/await
class NetworkManager {
    func fetch() async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}

// ✅ Or explicit MainActor isolation
class NetworkManager {
    func fetch(completion: @MainActor @escaping (Data) -> Void) {
        Task {
            let data = try await URLSession.shared.data(from: url).0
            await completion(data)
        }
    }
}

Actor System

Basic Actor Usage

swift
actor DatabaseConnection {
    private var isConnected = false
    private var activeQueries = 0
    
    func connect() async throws {
        guard !isConnected else { return }
        // Connection logic...
        isConnected = true
    }
    
    func query(_ sql: String) async throws -> [Row] {
        activeQueries += 1
        defer { activeQueries -= 1 }
        
        // Query execution...
        return rows
    }
    
    // Synchronous access for immutable data
    nonisolated let connectionString: String
    
    // Computed property without state access
    nonisolated var description: String {
        "Database connection to \(connectionString)"
    }
}

MainActor for UI Code

swift
// Entire class on MainActor
@MainActor
final class LoginViewModel: ObservableObject {
    @Published private(set) var isLoading = false
    @Published private(set) var error: Error?
    
    func login(username: String, password: String) async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            // This switches to background for network call
            let user = try await AuthService.shared.login(
                username: username,
                password: password
            )
            // Automatically back on MainActor
            navigateToHome(user: user)
        } catch {
            self.error = error
        }
    }
    
    // Can run on any thread
    nonisolated func validateEmail(_ email: String) -> Bool {
        // Email validation logic...
    }
}

Custom Global Actors

swift
// Define a global actor for database operations
@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
    
    // Custom executor for integration
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        DatabaseQueue.shared.unownedExecutor
    }
}

// Apply to types handling database operations
@DatabaseActor
class UserRepository {
    private var cache: [UUID: User] = [:]
    
    func findUser(id: UUID) async throws -> User {
        if let cached = cache[id] {
            return cached
        }
        
        let user = try await database.fetch(User.self, id: id)
        cache[id] = user
        return user
    }
    
    func saveUser(_ user: User) async throws {
        try await database.save(user)
        cache[user.id] = user
    }
}

// Mix different actors in one type
class DataCoordinator {
    @DatabaseActor
    private var userRepo = UserRepository()
    
    @MainActor
    private var viewModel = UserListViewModel()
    
    func refreshUsers() async {
        // Fetch on DatabaseActor
        let users = await userRepo.fetchAllUsers()
        
        // Update on MainActor
        await viewModel.update(users: users)
    }
}

Sendable Protocol

Understanding Sendable

swift
// Sendable indicates thread-safe types
public protocol Sendable {}

// Automatic conformance for:
// 1. Actors (handle synchronization)
// 2. Immutable structs/enums
// 3. Final classes with immutable storage
// 4. @unchecked Sendable for manual safety

// Examples of automatic Sendable
struct Point: Sendable { // Implicit
    let x: Double
    let y: Double
}

enum Status: Sendable { // Implicit
    case pending
    case completed(at: Date)
}

actor DataManager {} // Implicitly Sendable

final class User: Sendable {
    let id: UUID
    let name: String
    // All stored properties are immutable
}

Conditional Sendable

swift
// Generic types can be conditionally Sendable
struct Container<T> {
    let value: T
}

// Automatic conditional conformance
extension Container: Sendable where T: Sendable {}

// Custom conditional conformance
struct Cache<Key: Hashable, Value> {
    private var storage: [Key: Value] = [:]
    private let lock = NSLock()
}

extension Cache: @unchecked Sendable where Key: Sendable, Value: Sendable {
    // We ensure thread safety with lock
}

@unchecked Sendable

swift
// For types that are thread-safe but can't be proven by compiler
final class ThreadSafeCache: @unchecked Sendable {
    private var cache: [String: Data] = [:]
    private let queue = DispatchQueue(label: "cache.queue")
    
    func get(_ key: String) -> Data? {
        queue.sync { cache[key] }
    }
    
    func set(_ key: String, value: Data) {
        queue.async { self.cache[key] = value }
    }
}

// Reference types with immutable data
final class ImageWrapper: @unchecked Sendable {
    let cgImage: CGImage
    
    init(cgImage: CGImage) {
        self.cgImage = cgImage
    }
}

Sendable Functions and Closures

swift
// Sendable function types
typealias AsyncOperation = @Sendable () async -> Void
typealias CompletionHandler = @Sendable (Result<Data, Error>) -> Void

// Using Sendable closures
func performAsync(operation: @Sendable @escaping () async -> Void) {
    Task {
        await operation()
    }
}

// Sendable captures
func createTimer(interval: TimeInterval) -> AsyncStream<Date> {
    AsyncStream { continuation in
        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
            continuation.yield(Date())
        }
        
        continuation.onTermination = { @Sendable _ in
            timer.invalidate() // Must be Sendable
        }
    }
}

Migration Guide

Step 1: Enable Warnings in Swift 5 Mode

swift
// In Package.swift
.target(
    name: "MyApp",
    swiftSettings: [
        .enableUpcomingFeature("StrictConcurrency"),
        .enableUpcomingFeature("ExistentialAny"),
        .enableUpcomingFeature("ConciseMagicFile")
    ]
)

// Or in Xcode Build Settings
// Strict Concurrency Checking: Complete
// SWIFT_STRICT_CONCURRENCY = complete

Step 2: Fix Global Variables

swift
// Before
var sharedFormatter = DateFormatter()

// After - Option 1: Make immutable
let sharedFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateStyle = .short
    return formatter
}()

// After - Option 2: Add actor isolation
extension DateFormatter {
    @MainActor
    static let shared: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        return formatter
    }()
}

// After - Option 3: Use nonisolated(unsafe) for legacy code
nonisolated(unsafe) var legacyGlobal: LegacyType?

Step 3: Add Sendable Conformances

swift
// Make your model types Sendable
struct User: Codable, Sendable {
    let id: UUID
    let name: String
    let email: String
}

// For reference types, ensure immutability
final class Configuration: Sendable {
    let apiKey: String
    let baseURL: URL
    
    init(apiKey: String, baseURL: URL) {
        self.apiKey = apiKey
        self.baseURL = baseURL
    }
}

Step 4: Isolate UI Code

swift
// Before
class ViewModel: ObservableObject {
    @Published var items: [Item] = []
    
    func loadItems() {
        Task {
            items = await fetchItems() // Potential race
        }
    }
}

// After
@MainActor
final class ViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    
    func loadItems() async {
        items = await fetchItems() // Safe on MainActor
    }
}

Step 5: Handle Callbacks and Delegates

swift
// Before - Unclear isolation
protocol DataDelegate: AnyObject {
    func dataDidUpdate(_ data: Data)
}

// After - Explicit isolation
@MainActor
protocol DataDelegate: AnyObject {
    func dataDidUpdate(_ data: Data)
}

// Or use async alternatives
protocol DataProvider {
    func fetchData() async throws -> Data
}

Progressive Migration Strategy

  1. Start with leaf modules: Begin with modules that have few dependencies
  2. Fix simple issues first: Immutable globals, missing Sendable conformances
  3. Isolate UI layer: Add @MainActor to view controllers and view models
  4. Address shared state: Convert to actors or use synchronization
  5. Enable Swift 6 mode: Once warnings are resolved

Code Examples

Example 1: Image Processing Pipeline

swift
// Image processor using actors and Sendable
actor ImageProcessor {
    private let cache = ImageCache()
    
    func process(_ image: UIImage, filters: [Filter]) async throws -> UIImage {
        // Check cache
        let cacheKey = CacheKey(image: image, filters: filters)
        if let cached = await cache.get(cacheKey) {
            return cached
        }
        
        // Process image
        var result = image
        for filter in filters {
            result = try await filter.apply(to: result)
        }
        
        // Cache result
        await cache.set(cacheKey, image: result)
        return result
    }
}

// Sendable filter protocol
protocol Filter: Sendable {
    func apply(to image: UIImage) async throws -> UIImage
}

// Concrete filter implementation
struct BlurFilter: Filter {
    let radius: Double
    
    func apply(to image: UIImage) async throws -> UIImage {
        // Implementation using Core Image
        let ciImage = CIImage(image: image)!
        let filter = CIFilter.gaussianBlur()
        filter.inputImage = ciImage
        filter.radius = Float(radius)
        
        let context = CIContext()
        let output = filter.outputImage!
        let cgImage = context.createCGImage(output, from: output.extent)!
        
        return UIImage(cgImage: cgImage)
    }
}

Example 2: Network Layer with Proper Isolation

swift
// Network service with clear isolation boundaries
actor NetworkService {
    private let session: URLSession
    private let decoder = JSONDecoder()
    private var activeTasks: [UUID: URLSessionTask] = [:]
    
    init(configuration: URLSessionConfiguration = .default) {
        self.session = URLSession(configuration: configuration)
    }
    
    func fetch<T: Decodable & Sendable>(
        _ type: T.Type,
        from url: URL
    ) async throws -> T {
        let taskID = UUID()
        
        let task = session.dataTask(with: url)
        activeTasks[taskID] = task
        
        defer { activeTasks.removeValue(forKey: taskID) }
        
        let (data, response) = try await withTaskCancellationHandler {
            try await session.data(from: url)
        } onCancel: {
            task.cancel()
        }
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.invalidResponse
        }
        
        return try decoder.decode(type, from: data)
    }
    
    func cancelAll() {
        activeTasks.values.forEach { $0.cancel() }
        activeTasks.removeAll()
    }
    
    nonisolated var activeTaskCount: Int {
        get async { await activeTasks.count }
    }
}

// Usage with proper error handling
@MainActor
class UserListViewModel: ObservableObject {
    @Published private(set) var users: [User] = []
    @Published private(set) var isLoading = false
    @Published private(set) var error: Error?
    
    private let networkService = NetworkService()
    
    func loadUsers() async {
        isLoading = true
        error = nil
        
        do {
            let url = URL(string: "https://api.example.com/users")!
            users = try await networkService.fetch([User].self, from: url)
        } catch {
            self.error = error
        }
        
        isLoading = false
    }
}

Example 3: Concurrent Data Processing

swift
// Parallel processing with proper isolation
struct DataProcessor {
    func processFiles(_ urls: [URL]) async throws -> [ProcessedData] {
        try await withThrowingTaskGroup(of: ProcessedData.self) { group in
            // Add tasks for each file
            for url in urls {
                group.addTask {
                    try await processFile(url)
                }
            }
            
            // Collect results
            var results: [ProcessedData] = []
            for try await result in group {
                results.append(result)
            }
            
            return results
        }
    }
    
    private func processFile(_ url: URL) async throws -> ProcessedData {
        let data = try await readFile(url)
        let processed = try await transform(data)
        return ProcessedData(
            originalURL: url,
            processedData: processed,
            timestamp: Date()
        )
    }
}

// Result type that's Sendable
struct ProcessedData: Sendable {
    let originalURL: URL
    let processedData: Data
    let timestamp: Date
}

Best Practices

1. Design for Sendability

swift
// ❌ Avoid mutable reference types
class Settings {
    var theme: Theme
    var notifications: Bool
}

// ✅ Prefer value types or immutable reference types
struct Settings: Sendable {
    let theme: Theme
    let notifications: Bool
}

// ✅ Or use actors for mutable state
actor SettingsManager {
    private var settings: Settings
    
    func update(theme: Theme) {
        settings = Settings(
            theme: theme,
            notifications: settings.notifications
        )
    }
}

2. Minimize Actor Hops

swift
// ❌ Excessive actor hopping
@MainActor
class ViewModel {
    func processData() async {
        let data = await dataActor.getData()
        let processed = await processorActor.process(data)
        let formatted = await formatterActor.format(processed)
        updateUI(formatted)
    }
}

// ✅ Batch operations
@MainActor
class ViewModel {
    func processData() async {
        let result = await dataActor.getProcessedAndFormattedData()
        updateUI(result)
    }
}

3. Use nonisolated for Pure Functions

swift
actor Calculator {
    private var history: [Calculation] = []
    
    // ✅ Pure functions don't need isolation
    nonisolated func add(_ a: Double, _ b: Double) -> Double {
        a + b
    }
    
    nonisolated func multiply(_ a: Double, _ b: Double) -> Double {
        a * b
    }
    
    // State-modifying functions need isolation
    func recordCalculation(_ calc: Calculation) {
        history.append(calc)
    }
}

4. Leverage Structured Concurrency

swift
// ✅ Use task groups for parallel work
func downloadImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
        for (index, url) in urls.enumerated() {
            group.addTask {
                let image = try await downloadImage(from: url)
                return (index, image)
            }
        }
        
        var images = Array<UIImage?>(repeating: nil, count: urls.count)
        for try await (index, image) in group {
            images[index] = image
        }
        
        return images.compactMap { $0 }
    }
}

5. Handle Cancellation Properly

swift
func longRunningOperation() async throws -> Result {
    try await withTaskCancellationHandler {
        var progress = 0.0
        
        while progress < 1.0 {
            try Task.checkCancellation()
            
            // Do work...
            progress += 0.1
            
            try await Task.sleep(for: .seconds(1))
        }
        
        return result
    } onCancel: {
        // Cleanup resources
        cleanupOperation()
    }
}

Testing and Debugging

Thread Sanitizer (TSan)

Thread Sanitizer remains essential for catching runtime data races that escape compile-time checks:

bash
# Enable in Xcode
Product → Scheme → Edit Scheme → Diagnostics → Thread Sanitizer

# Or via command line
swift test -Xswiftc -sanitize=thread

When to use TSan:

  • Testing legacy code with @preconcurrency imports
  • Verifying @unchecked Sendable implementations
  • Catching races in C/Objective-C interop

Swift Concurrency Debugger (Xcode 16+)

New debugging tools for concurrent code:

  1. Task Tree View: Visualize parent-child task relationships
  2. Actor Memory Graph: See actor isolation boundaries
  3. Hop Tracking: Follow execution across isolation domains
swift
// Debugging helpers
extension Task {
    static func currentPriority() -> TaskPriority {
        Task.currentPriority
    }
    
    static func printTaskTree() {
        // Available in debug builds
        #if DEBUG
        print("Task: \(Task<Never, Never>.currentPriority)")
        #endif
    }
}

Unit Testing Concurrent Code

swift
// Test helper for async code with timeout
func withTimeout<T>(
    _ duration: Duration = .seconds(5),
    operation: @escaping () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask {
            try await operation()
        }
        
        group.addTask {
            try await Task.sleep(for: duration)
            throw TimeoutError()
        }
        
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

// Testing actor isolation
final class ActorTests: XCTestCase {
    func testActorIsolation() async throws {
        let actor = TestActor()
        
        // Verify isolation with multiple concurrent operations
        try await withThrowingTaskGroup(of: Int.self) { group in
            for i in 0..<100 {
                group.addTask {
                    await actor.increment()
                    return await actor.value
                }
            }
            
            var results: Set<Int> = []
            for try await result in group {
                results.insert(result)
            }
            
            // All results should be unique if properly isolated
            XCTAssertEqual(results.count, 100)
        }
    }
}

Debugging Common Issues

swift
// 1. Debugging unexpected suspension points
actor DataManager {
    func debugSuspension() async {
        print("Before suspension: \(Thread.current)")
        await someAsyncOperation()
        print("After suspension: \(Thread.current)") // May be different thread
    }
}

// 2. Tracking isolation context
func debugIsolation(
    isolation: isolated (any Actor)? = #isolation
) async {
    if let isolation {
        print("Running on: \(type(of: isolation))")
    } else {
        print("Running on non-isolated context")
    }
}

// 3. Detecting priority inversions
Task(priority: .low) {
    await debugPriority() // May run at higher priority due to escalation
}

func debugPriority() async {
    print("Current priority: \(Task.currentPriority)")
}

Common Compiler Errors

Error Reference Table

Diagnostic Example Fix
Non-Sendable type crossing actor boundary Capture of 'nonSendable' with non-sendable type 'MyClass' 1. Make type Sendable2. Use sending parameter3. Copy/transform to Sendable type
Actor-isolated property referenced from non-isolated Actor-isolated property 'items' can not be referenced from a non-isolated context 1. Add await2. Move code to actor3. Make property nonisolated
Call to main actor-isolated from non-isolated Call to main actor-isolated instance method 'updateUI()' in a synchronous nonisolated context 1. Add @MainActor to caller2. Use await MainActor.run { }3. Make method nonisolated
Mutation of captured var Mutation of captured var 'counter' in concurrently-executing code 1. Use actor for state2. Make immutable3. Use Mutex (Swift 6.1+)
Sendable closure captures non-Sendable Capture of 'self' with non-sendable type 'ViewController?' in a @Sendable closure 1. Use [weak self]2. Make type Sendable3. Extract needed values before closure

Detailed Error Solutions

1. Non-Sendable Type Errors

swift
// ❌ Error: Non-Sendable type 'UIImage' crossing actor boundary
class ImageProcessor {
    func process(image: UIImage) async {
        Task.detached {
            // Error: capture of 'image' with non-sendable type
            manipulate(image)
        }
    }
}

// ✅ Solution 1: Use sending parameter
class ImageProcessor {
    func process(image: sending UIImage) async {
        Task.detached {
            manipulate(image) // Ownership transferred
        }
    }
}

// ✅ Solution 2: Convert to Sendable representation
class ImageProcessor {
    func process(image: UIImage) async {
        let imageData = image.pngData()! // Data is Sendable
        Task.detached {
            let recreated = UIImage(data: imageData)!
            manipulate(recreated)
        }
    }
}

2. Actor Isolation Errors

swift
// ❌ Error: Actor-isolated property accessed without await
actor DataStore {
    var items: [Item] = []
    
    nonisolated func getItemCount() -> Int {
        items.count // Error: actor-isolated property
    }
}

// ✅ Solution 1: Make method async
actor DataStore {
    var items: [Item] = []
    
    func getItemCount() async -> Int {
        items.count // OK: implicitly isolated to actor
    }
}

// ✅ Solution 2: Use computed property
actor DataStore {
    private var items: [Item] = []
    
    var itemCount: Int {
        items.count // OK: computed property is isolated
    }
}

3. MainActor Isolation Errors

swift
// ❌ Error: Call to MainActor-isolated from background
func backgroundWork() {
    updateUI() // Error: MainActor-isolated
}

@MainActor
func updateUI() { }

// ✅ Solution 1: Make caller MainActor
@MainActor
func backgroundWork() async {
    await fetchData()
    updateUI() // OK: both on MainActor
}

// ✅ Solution 2: Explicit MainActor.run
func backgroundWork() async {
    let data = await fetchData()
    await MainActor.run {
        updateUI()
    }
}

Performance Tuning

Task Creation Overhead

swift
// ❌ Excessive task creation
for item in items {
    Task {
        await process(item) // Creates N unstructured tasks
    }
}

// ✅ Use TaskGroup for batch operations
await withTaskGroup(of: Void.self) { group in
    for item in items {
        group.addTask {
            await process(item) // Structured, limited concurrency
        }
    }
}

// ✅ Or use concurrent forEach
await items.concurrentForEach { item in
    await process(item)
}

Clock APIs for Efficient Timing

swift
// ❌ Old-style sleep
Task {
    Thread.sleep(forTimeInterval: 1.0) // Blocks thread
}

// ❌ Task.sleep with nanoseconds
Task {
    try await Task.sleep(nanoseconds: 1_000_000_000)
}

// ✅ Modern Clock-based approach
let clock = ContinuousClock()
try await clock.sleep(for: .seconds(1))

// ✅ Measure elapsed time
let elapsed = await clock.measure {
    await expensiveOperation()
}
print("Operation took: \(elapsed)")

// ✅ Custom clock for testing
struct TestClock: Clock {
    var now: Instant { .init() }
    func sleep(until deadline: Instant) async throws {
        // Instant return for tests
    }
}

Structured vs Unstructured Tasks

swift
// ❌ Unstructured tasks lose context
class Service {
    func startBackgroundWork() {
        Task {
            await longRunningWork() // No cancellation propagation
        }
    }
}

// ✅ Structured tasks with proper lifecycle
class Service {
    private var workTask: Task<Void, Never>?
    
    func startBackgroundWork() {
        workTask = Task {
            try await withTaskCancellationHandler {
                await longRunningWork()
            } onCancel: {
                cleanup()
            }
        }
    }
    
    func stopWork() {
        workTask?.cancel()
    }
}

// ✅ Detached tasks only for true daemons
Task.detached(priority: .background) {
    // Long-lived background monitoring
    while !Task.isCancelled {
        await checkSystemHealth()
        try await Task.sleep(for: .minutes(5))
    }
}

Actor Contention Optimization

swift
// ❌ High contention on single actor
actor Counter {
    private var value = 0
    
    func increment() {
        value += 1
    }
}

// ✅ Reduce contention with batching
actor Counter {
    private var value = 0
    
    func increment(by amount: Int = 1) {
        value += amount
    }
    
    func batchIncrement(_ operations: [Int]) {
        value += operations.reduce(0, +)
    }
}

// ✅ Or use sharding for high throughput
actor ShardedCounter {
    private var shards: [Int]
    
    init(shardCount: Int = ProcessInfo.processInfo.activeProcessorCount) {
        self.shards = Array(repeating: 0, count: shardCount)
    }
    
    func increment() {
        let shard = Int.random(in: 0..<shards.count)
        shards[shard] += 1
    }
    
    var total: Int {
        shards.reduce(0, +)
    }
}

Swift 6.1+ Roadmap

Swift 6.1 (Shipped)

SE-0431: @isolated(any) Function Types

swift
// Function types that preserve isolation
typealias IsolatedHandler = @isolated(any) () async -> Void

func withIsolation(_ handler: IsolatedHandler) async {
    await handler() // Maintains caller's isolation
}

// Use with actors
actor MyActor {
    func doWork() async {
        await withIsolation {
            // Runs on MyActor
            print("Isolated to: \(self)")
        }
    }
}

SE-0433: Synchronous Mutual Exclusion Lock (Mutex)

swift
import Synchronization

// For protecting critical sections without async
final class Statistics: Sendable {
    private let mutex = Mutex<Stats>(.init())
    
    func record(value: Double) {
        mutex.withLock { stats in
            stats.count += 1
            stats.sum += value
        }
    }
    
    var average: Double {
        mutex.withLock { stats in
            stats.count > 0 ? stats.sum / Double(stats.count) : 0
        }
    }
}

private struct Stats {
    var count = 0
    var sum = 0.0
}

Swift 6.2 (In Development)

SE-0461: Isolated Default Arguments

swift
// Default values can use isolation context
@MainActor
class ViewModel {
    // ✅ Default can access MainActor state
    func configure(
        title: String = defaultTitle // Coming in 6.2
    ) { }
    
    @MainActor
    static var defaultTitle: String { "Default" }
}

Future Proposals Under Review

  1. SE-0449: Allow nonisolated to prevent global actor inference
  2. SE-0450: Limiting actor isolation inference
  3. SE-0451: Isolated synchronous deinit
  4. Typed Throws in Concurrency: Better error propagation
  5. Custom Executors v2: More control over task execution

Migration Timeline

Version Key Features Migration Impact
Swift 6.0 Strict concurrency by default Major - requires code updates
Swift 6.1 Mutex, @isolated(any) Minor - additive features
Swift 6.2 Isolated defaults, deinit Minor - quality of life
Swift 7.0 Custom executors v2 TBD - performance focused

Stay updated: Swift Evolution Dashboard

Resources

Official Documentation

Swift Evolution Proposals

WWDC Sessions

  • WWDC 2024: "Migrate your app to Swift 6" - Practical migration guide
  • WWDC 2022: "Eliminate data races using Swift Concurrency" - Foundational concepts
  • WWDC 2021: "Meet async/await in Swift" - Introduction to Swift concurrency

Community Resources

Conclusion

Swift 6's strict concurrency represents a paradigm shift in how we write concurrent code. By embracing:

  • Complete data race safety at compile time
  • Clear isolation boundaries with actors
  • Explicit Sendable requirements
  • Progressive migration strategies

We can write safer, more maintainable concurrent code. The compiler becomes our partner in preventing entire classes of bugs, leading to more reliable applications.

Remember: The goal isn't just to silence compiler warnings, but to design systems with clear concurrency boundaries that are easier to reason about and maintain over time.


File: apple/docs/swift-testing-playbook.md


globs: "**/*Tests.swift, **/*Test.swift" alwaysApply: false

The Ultimate Swift Testing Playbook (2024 WWDC Edition, expanded with Apple docs from June 2025)

https://developer.apple.com/xcode/swift-testing/

A hands-on, comprehensive guide for migrating from XCTest to Swift Testing and mastering the new framework. This playbook integrates the latest patterns and best practices from WWDC 2024 and official Apple documentation to make your tests more powerful, expressive, and maintainable.


**1. Migration & Tooling Baseline**

Ensure your environment is set up for a smooth, gradual migration.

What Why
Xcode 16 & Swift 6 Swift Testing is bundled with the latest toolchain. It leverages modern Swift features like macros, structured concurrency, and powerful type-system checks.
Keep XCTest Targets Incremental Migration is Key. You can have XCTest and Swift Testing tests in the same target, allowing you to migrate file-by-file without breaking CI. Both frameworks can coexist.
Enable Parallel Execution In your Test Plan, ensure "Use parallel execution" is enabled. Swift Testing runs tests in parallel by default, which dramatically speeds up test runs and helps surface hidden state dependencies that serial execution might miss.

Migration Action Items

  • Ensure all developer machines and CI runners are on macOS 15+ and Xcode 16+.
  • For projects supporting Linux/Windows, add the swift-testing SPM package to your Package.swift. It's bundled in Xcode and not needed for Apple platforms.
  • For existing test targets, you must explicitly enable the framework. In the target's Build Settings, find Enable Testing Frameworks and set its value to Yes. Without this, import Testing will fail.
  • In your primary test plan, confirm that “Use parallel execution” is enabled. This is the default and recommended setting.

**2. Expressive Assertions: `#expect` & `#require`**

Replace the entire XCTAssert family with two powerful, expressive macros. They accept regular Swift expressions, eliminating the need for dozens of specialized XCTAssert functions.

Macro Use Case & Behavior
#expect(expression) Soft Check. Use for most validations. If the expression is false, the issue is recorded, but the test function continues executing. This allows you to find multiple failures in a single run.
#require(expression) Hard Check. Use for critical preconditions (e.g., unwrapping an optional). If the expression is false or throws, the test is immediately aborted. This prevents cascading failures from an invalid state.

Power Move: Visual Failure Diagnostics

Unlike XCTAssert, which often only reports that a comparison failed, #expect shows you the exact values that caused the failure, directly in the IDE and logs. This visual feedback is a massive productivity boost.

Code:

swift
@Test("User count meets minimum requirement")
func testUserCount() {
    let userCount = 5
    // This check will fail
    #expect(userCount > 10)
}

Failure Output in Xcode:

text
▽ Expected expression to be true
#expect(userCount > 10)
      |         | |
      5         | 10
                false

Power Move: Optional-Safe Unwrapping

#require is the new, safer replacement for XCTUnwrap. It not only checks for nil but also unwraps the value for subsequent use.

Before: The XCTest Way

swift
// In an XCTestCase subclass...
func testFetchUser_XCTest() async throws {
    let user = try XCTUnwrap(await fetchUser(id: "123"), "Fetching user should not return nil")
    XCTAssertEqual(user.id, "123")
}

After: The Swift Testing Way

swift
@Test("Fetching a valid user succeeds")
func testFetchUser() async throws {
    // #require both checks for nil and unwraps `user` in one step.
    // If fetchUser returns nil, the test stops here and fails.
    let user = try #require(await fetchUser(id: "123"))

    // `user` is now a non-optional User, ready for further assertions.
    #expect(user.id == "123")
    #expect(user.age == 37)
}

Common Assertion Conversions Quick-Reference

Use this table as a cheat sheet when migrating your XCTest assertions.

XCTest Assertion Swift Testing Equivalent Notes
XCTAssert(expr) #expect(expr) Direct replacement for a boolean expression.
XCTAssertEqual(a, b) #expect(a == b) Use the standard == operator.
XCTAssertNotEqual(a, b) #expect(a != b) Use the standard != operator.
XCTAssertNil(a) #expect(a == nil) Direct comparison to nil.
XCTAssertNotNil(a) #expect(a != nil) Direct comparison to nil.
XCTAssertTrue(a) #expect(a) No change needed if a is already a Bool.
XCTAssertFalse(a) #expect(!a) Use the ! operator to negate the expression.
XCTAssertGreaterThan(a, b) #expect(a > b) Use any standard comparison operator: >, <, >=, <=
XCTUnwrap(a) try #require(a) The preferred, safer way to unwrap optionals.
XCTAssertThrowsError(expr) #expect(throws: Error.self) { expr } The basic form for checking any error.
XCTAssertNoThrow(expr) #expect(throws: Never.self) { expr } The explicit way to assert that no error is thrown.

Action Items

  • Run grep -R "XCTAssert" . to find all legacy assertions.
  • Convert XCTUnwrap calls to try #require(). This is a direct and superior replacement.
  • Convert most XCTAssert calls to #expect(). Use #require() only for preconditions where continuing the test makes no sense.
  • For multiple related checks on the same object, use separate #expect() statements. Each will be evaluated independently and all failures will be reported.

**3. Setup, Teardown, and State Lifecycle**

Swift Testing replaces setUpWithError and tearDownWithError with a more natural, type-safe lifecycle using init() and deinit.

The Core Concept: A fresh, new instance of the test suite (struct or class) is created for each test function it contains. This is the cornerstone of test isolation, guaranteeing that state from one test cannot leak into another.

Method Replaces... Behavior
init() setUpWithError() The initializer for your suite. Put all setup code here. It can be async and throws.
deinit tearDownWithError() The deinitializer. Put cleanup code here. It runs automatically after each test. Note: deinit is only available on class or actor suite types, not structs. This is a common reason to choose a class for your suite.

Practical Example: Migrating a Database Test Suite

Before: The XCTest Way

swift
final class DatabaseServiceXCTests: XCTestCase {
    var sut: DatabaseService!
    var tempDirectory: URL!

    override func setUpWithError() throws {
        try super.setUpWithError()
        tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
        
        let testDatabase = TestDatabase(storageURL: tempDirectory)
        sut = DatabaseService(database: testDatabase)
    }

    override func tearDownWithError() throws {
        try FileManager.default.removeItem(at: tempDirectory)
        sut = nil
        tempDirectory = nil
        try super.tearDownWithError()
    }

    func testSavingUser() throws {
        let user = User(id: "user-1", name: "Alex")
        try sut.save(user)
        let loadedUser = try sut.loadUser(id: "user-1")
        XCTAssertNotNil(loadedUser)
    }
}

After: The Swift Testing Way (using class for deinit)

swift
@Suite final class DatabaseServiceTests {
    // Using a class here to demonstrate `deinit` for cleanup.
    let sut: DatabaseService
    let tempDirectory: URL

    init() throws {
        // ARRANGE: Runs before EACH test in this suite.
        self.tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
        
        let testDatabase = TestDatabase(storageURL: tempDirectory)
        self.sut = DatabaseService(database: testDatabase)
    }
    
    deinit {
        // TEARDOWN: Runs after EACH test.
        try? FileManager.default.removeItem(at: tempDirectory)
    }

    @Test func testSavingUser() throws {
        let user = User(id: "user-1", name: "Alex")
        try sut.save(user)
        #expect(try sut.loadUser(id: "user-1") != nil)
    }
}

Action Items

  • Convert test classes from XCTestCase to structs (preferred for automatic state isolation) or final classes.
  • Move setUpWithError logic into the suite's init().
  • Move tearDownWithError logic into the suite's deinit (and use a class or actor if needed).
  • Define the SUT and its dependencies as let properties, initialized in init().

**4. Mastering Error Handling**

Go beyond do/catch with a dedicated, expressive API for validating thrown errors.

Overload Replaces... Example & Use Case
#expect(throws: Error.self) Basic XCTAssertThrowsError Verifies that any error was thrown.
#expect(throws: BrewingError.self) Typed XCTAssertThrowsError Ensures an error of a specific type is thrown.
#expect(throws: BrewingError.outOfBeans) Specific Error XCTAssertThrowsError Validates a specific error value is thrown.
#expect(throws: ... ) catch: { ... } do/catch with switch Payload Introspection. The ultimate tool for errors with associated values. It gives you a closure to inspect the thrown error. swift #expect(throws: BrewingError.self) { try brew(beans: 0) } catch: { error in guard case let .notEnoughBeans(needed) = error else { Issue.record("Wrong error case thrown"); return } #expect(needed > 0) }
#expect(throws: Never.self) XCTAssertNoThrow Explicitly asserts that a function does not throw. Ideal for happy-path tests.

**5. Parameterized Tests: Drastically Reduce Boilerplate**

Run a single test function with multiple argument sets to maximize coverage with minimal code. This is superior to a for-in loop because each argument set runs as an independent test, can be run in parallel, and failures are reported individually.

Pattern How to Use It & When
Single Collection @Test(arguments: [0, 100, -40]) The simplest form. Pass a collection of inputs.
Zipped Collections @Test(arguments: zip(inputs, expectedOutputs)) The most common and powerful pattern. Use zip to pair inputs and expected outputs, ensuring a one-to-one correspondence.
Multiple Collections @Test(arguments: ["USD", "EUR"], [1, 10, 100]) ⚠️ Caution: Cartesian Product. This creates a test case for every possible combination of arguments. Use it deliberately when you need to test all combinations.

Example: Migrating Repetitive Tests to a Parameterized One

Before: The XCTest Way

swift
func testFlavorVanillaContainsNoNuts() {
    let flavor = Flavor.vanilla
    XCTAssertFalse(flavor.containsNuts)
}
func testFlavorPistachioContainsNuts() {
    let flavor = Flavor.pistachio
    XCTAssertTrue(flavor.containsNuts)
}
func testFlavorChocolateContainsNoNuts() {
    let flavor = Flavor.chocolate
    XCTAssertFalse(flavor.containsNuts)
}

After: The Swift Testing Way using zip

swift
@Test("Flavor nut content is correct", arguments: zip(
    [Flavor.vanilla, .pistachio, .chocolate],
    [false, true, false]
))
func testFlavorContainsNuts(flavor: Flavor, expected: Bool) {
    #expect(flavor.containsNuts == expected)
}

**6. Conditional Execution & Skipping**

Dynamically control which tests run based on feature flags, environment, or known issues.

Trait What It Does & How to Use It
.disabled("Reason") Unconditionally skips a test. The test is not run, but it is still compiled. Always provide a descriptive reason for CI visibility (e.g., "Flaky on CI, see FB12345").
.enabled(if: condition) Conditionally runs a test. The test only runs if the boolean condition is true. This is perfect for tests tied to feature flags or specific environments. swift @Test(.enabled(if: FeatureFlags.isNewAPIEnabled)) func testNewAPI() { /* ... */ }
@available(...) OS Version-Specific Tests. Apply this attribute directly to the test function. It's better than a runtime #available check because it allows the test runner to know the test is skipped for platform reasons, which is cleaner in test reports.

**7. Specialized Assertions for Clearer Failures**

While #expect(a == b) works, purpose-built patterns provide sharper, more actionable failure messages by explaining why something failed, not just that it failed.

⚠️ Note: Swift Testing is still evolving and doesn't have all the specialized assertion APIs that XCTest provides. Some common patterns require manual implementation or third-party libraries like Swift Numerics.

Assertion Type Why It's Better Than a Generic Check
**Comparing Collections (Unordered)**Use Set comparison for order-independent equality A simple == check on arrays fails if elements are the same but the order is different. Converting to Sets ignores order, preventing false negatives for tests where order doesn't matter. Brittle: #expect(tags == ["ios", "swift"]) Robust: #expect(Set(tags) == Set(["swift", "ios"]))
Floating-Point AccuracyUse manual tolerance checks or Swift Numerics Floating-point math is imprecise. #expect(0.1 + 0.2 == 0.3) will fail. Use manual tolerance checking or Swift Numerics for robust floating-point comparisons. Fails: #expect(result == 0.3) Passes: #expect(abs(result - 0.3) < 0.0001) With Swift Numerics: #expect(result.isApproximatelyEqual(to: 0.3, absoluteTolerance: 0.0001))

**8. Structure and Organization at Scale**

Use suites and tags to manage large and complex test bases.

Suites and Nested Suites

A @Suite groups related tests and can be nested for a clear hierarchy. Traits applied to a suite are inherited by all tests and nested suites within it.

Tags for Cross-Cutting Concerns

Tags associate tests with common characteristics (e.g., .network, .ui, .regression) regardless of their suite. This is invaluable for filtering.

  1. Define Tags in a Central File:
    swift
    // /Tests/Support/TestTags.swift
    import Testing
    
    extension Tag {
        @Tag static var fast: Self
        @Tag static var regression: Self
        @Tag static var flaky: Self
        @Tag static var networking: Self
    }
  2. Apply Tags & Filter:
    swift
    // Apply to a test or suite
    @Test("Username validation", .tags(.fast, .regression))
    func testUsername() { /* ... */ }
    
    // Run from CLI
    // swift test --filter .fast
    // swift test --skip .flaky
    // swift test --filter .networking --filter .regression
    
    // Filter in Xcode Test Plan
    // Add "fast" to the "Include Tags" field or "flaky" to the "Exclude Tags" field.

Power Move: Xcode UI Integration for Tags

Xcode 16 deeply integrates with tags, turning them into a powerful organizational tool.

  • Grouping by Tag in Test Navigator: In the Test Navigator (Cmd-6), click the tag icon at the top. This switches the view from the file hierarchy to one where tests are grouped by their tags. It's a fantastic way to visualize and run all tests related to a specific feature.
  • Test Report Insights: After a test run, the Test Report can automatically find patterns. Go to the Insights tab to see messages like "All 7 tests with the 'networking' tag failed." This immediately points you to systemic issues, saving significant debugging time.

**9. Concurrency and Asynchronous Testing**

Async/Await and Confirmations

  • Async Tests: Simply mark your test function async and use await.
  • Confirmations: To test APIs with completion handlers or that fire multiple times (like delegates or notifications), use confirmation.
  • fulfillment(of:timeout:): This is the global function you await to pause the test until your confirmations are fulfilled or a timeout is reached.
swift
@Test("Delegate is notified 3 times")
async func testDelegateNotifications() async throws {
    // Create a confirmation that expects to be fulfilled exactly 3 times.
    let confirmation = confirmation("delegate.didUpdate was called", expectedCount: 3)
    let delegate = MockDelegate { await confirmation.fulfill() }
    let sut = SystemUnderTest(delegate: delegate)

    sut.performActionThatNotifiesThreeTimes()
    
    // Explicitly wait for the confirmation to be fulfilled with a 1-second timeout.
    try await fulfillment(of: [confirmation], timeout: .seconds(1))
}

Advanced Asynchronous Patterns

Asserting an Event Never Happens

Use a confirmation with expectedCount: 0 to verify that a callback or delegate method is never called during an operation. If fulfill() is called on it, the test will fail.

swift
@Test("Logging out does not trigger a data sync")
async func testLogoutDoesNotSync() async throws {
    let syncConfirmation = confirmation("data sync was triggered", expectedCount: 0)
    let mockSyncEngine = MockSyncEngine { await syncConfirmation.fulfill() }
    let sut = AccountManager(syncEngine: mockSyncEngine)
    
    sut.logout()
    
    // The test passes if the confirmation is never fulfilled within the timeout.
    // If it *is* fulfilled, this will throw an error and fail the test.
    await fulfillment(of: [syncConfirmation], timeout: .seconds(0.5), performing: {})
}

Bridging Legacy Completion Handlers

For older asynchronous code that uses completion handlers, use withCheckedThrowingContinuation to wrap it in a modern async/await call that Swift Testing can work with.

swift
func legacyFetch(completion: @escaping (Result<Data, Error>) -> Void) {
    // ... legacy async code ...
}

@Test func testLegacyFetch() async throws {
    let data = try await withCheckedThrowingContinuation { continuation in
        legacyFetch { result in
            continuation.resume(with: result)
        }
    }
    #expect(!data.isEmpty)
}

Controlling Parallelism

  • .serialized: Apply this trait to a @Test or @Suite to force its contents to run serially (one at a time). Use this as a temporary measure for legacy tests that are not thread-safe or have hidden state dependencies. The goal should be to refactor them to run in parallel.
  • .timeLimit: A safety net to prevent hung tests from stalling CI. The more restrictive (shorter) duration wins when applied at both the suite and test level.

**10. Advanced API Cookbook**

Feature What it Does & How to Use It
withKnownIssue Marks a test as an Expected Failure. It's better than .disabled for known bugs. The test still runs but won't fail the suite. Crucially, if the underlying bug gets fixed and the test passes, withKnownIssue will fail, alerting you to remove it.
CustomTestStringConvertible Provides custom, readable descriptions for your types in test failure logs. Conform your key models to this protocol to make debugging much easier.
.bug("JIRA-123") Trait Associates a test directly with a ticket in your issue tracker. This adds invaluable context to test reports in Xcode and Xcode Cloud.
Test.current A static property (Test.current) that gives you runtime access to the current test's metadata, such as its name, tags, and source location. Useful for advanced custom logging.
Multiple Expectations Pattern Use separate #expect() statements for validating multiple properties. Each expectation is evaluated independently, and all failures are reported even if earlier ones fail. This provides comprehensive feedback about object state. swift let user = try #require(loadUser()) #expect(user.name == "John") #expect(user.age >= 18) #expect(user.isActive)

**11. Common Pitfalls and How to Avoid Them**

A checklist of common mistakes developers make when adopting Swift Testing.

  1. Overusing #require()

    • The Pitfall: Using #require() for every check. This makes tests brittle and hides information. If the first #require() fails, the rest of the test is aborted, and you won't know if other things were also broken.
    • The Fix: Use #expect() for most checks. Only use #require() for essential setup conditions where the rest of the test would be nonsensical if they failed (e.g., a non-nil SUT, a valid URL).
  2. Forgetting State is Isolated

    • The Pitfall: Assuming that a property modified in one test will retain its value for the next test in the same suite.
    • The Fix: Remember that a new instance of the suite is created for every test. This is a feature, not a bug! All shared setup must happen in init(). Do not rely on state carrying over between tests.
  3. Accidentally Using a Cartesian Product

    • The Pitfall: Passing multiple collections to a parameterized test without zip, causing an exponential explosion of test cases (@Test(arguments: collectionA, collectionB)).
    • The Fix: Be deliberate. If you want one-to-one pairing, always use zip. Only use the multi-collection syntax when you explicitly want to test every possible combination.
  4. Ignoring the .serialized Trait for Unsafe Tests

    • The Pitfall: Migrating old, stateful tests that are not thread-safe and seeing them fail randomly due to parallel execution.
    • The Fix: As a temporary measure, apply the .serialized trait to the suite containing these tests. This forces them to run one-at-a-time, restoring the old behavior. The long-term goal should be to refactor the tests to be parallel-safe and remove the trait.

**12. Migrating from XCTest**

Swift Testing and XCTest can coexist in the same target, enabling an incremental migration.

Key Differences at a Glance

Feature XCTest Swift Testing
Test Discovery Method name must start with test... @Test attribute on any function or method.
Suite Type class MyTests: XCTestCase struct MyTests (preferred), class, or actor.
Assertions XCTAssert...() family of functions #expect() and #require() macros with Swift expressions.
Error Unwrapping try XCTUnwrap(...) try #require(...)
Setup/Teardown setUpWithError(), tearDownWithError() init(), deinit (on classes/actors)
Asynchronous Wait XCTestExpectation confirmation() and await fulfillment(of:timeout:)
Parallelism Opt-in, multi-process Opt-out, in-process via Swift Concurrency.

What NOT to Migrate (Yet)

Continue using XCTest for the following, as they are not currently supported by Swift Testing:

  • UI Automation Tests (using XCUIApplication)
  • Performance Tests (using XCTMetric and measure { ... })
  • Tests written in Objective-C

**13. Swift 6.2 Testing Enhancements (2025 Edition)**

Swift 6.2 introduces powerful new testing capabilities that take Swift Testing to the next level. These features provide enhanced debugging context, process lifecycle testing, and more sophisticated test control.

13.1 Exit Tests: Testing Process Lifecycle and Crashes

Exit tests allow you to verify that code properly handles process termination scenarios, crash recovery, and subprocess management. This is crucial for applications that spawn external processes or need robust crash recovery.

Feature Use Case Benefits
#expect(processExitsWith: .success) Test that processes terminate cleanly Verifies graceful shutdown and cleanup
#expect(processExitsWith: .failure) Test that invalid operations fail appropriately Ensures proper error handling and exit codes
Process Recovery Testing Test auto-restart and crash recovery logic Validates resilience mechanisms

Practical Example: Server Process Lifecycle

swift
@Test("Server handles unexpected termination gracefully", .tags(.exitTests))
func serverCrashRecovery() async throws {
    await #expect(processExitsWith: .success) {
        let serverManager = ServerManager.shared
        
        // Start server process
        await serverManager.start()
        
        // Simulate unexpected termination
        if let server = serverManager.bunServer {
            server.terminate()
        }
        
        // Wait for auto-restart logic
        try await Task.sleep(for: .milliseconds(2_000))
        
        // Verify recovery behavior
        // Test should verify that the system handles the crash gracefully
        
        // Clean shutdown
        await serverManager.stop()
    }
}

CLI Tool Process Validation

swift
@Test("Shell command executes successfully", .tags(.exitTests))
func shellCommandExecution() async throws {
    await #expect(processExitsWith: .success) {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/bin/sh")
        process.arguments = ["-c", "ls /tmp | head -5"]
        
        try process.run()
        process.waitUntilExit()
        
        guard process.terminationStatus == 0 else {
            throw ProcessError.commandFailed(process.terminationStatus)
        }
    }
}

13.2 Attachments: Rich Debugging Context

Attachments revolutionize test debugging by capturing system state, configuration details, and diagnostic information when tests fail. This provides invaluable context that makes debugging faster and more effective.

Attachment Type Best For Example Use Cases
System Information Environment debugging OS version, memory, processor info
Configuration State Settings and setup issues Server ports, network config, feature flags
Process Details Service lifecycle issues PIDs, running state, error messages
Performance Metrics Performance regression analysis Timing data, memory usage, throughput

Enhanced Test with Diagnostic Attachments

swift
@Test("Network configuration with full diagnostics", .tags(.attachmentTests))
func networkConfigurationDiagnostics() throws {
    // Attach system environment for context
    Attachment.record("System Info", """
        OS: \(ProcessInfo.processInfo.operatingSystemVersionString)
        Environment: \(ProcessInfo.processInfo.environment["CI"] != nil ? "CI" : "Local")
        Timestamp: \(Date().ISO8601Format())
        """)
    
    // Attach network state
    let localIP = NetworkUtility.getLocalIPAddress()
    let allIPs = NetworkUtility.getAllIPAddresses()
    
    Attachment.record("Network Configuration", """
        Local IP: \(localIP ?? "none")
        All IPs: \(allIPs.joined(separator: ", "))
        Interface Count: \(allIPs.count)
        """)
    
    // Test logic with rich failure context
    #expect(localIP != nil || allIPs.isEmpty)
}

Performance Testing with Detailed Metrics

swift
@Test("API performance with statistical analysis", .tags(.performance, .attachmentTests))
func apiPerformanceAnalysis() async throws {
    var timings: [TimeInterval] = []
    let iterations = 100
    
    // Capture test configuration
    Attachment.record("Performance Test Setup", """
        Iterations: \(iterations)
        Test Environment: \(ProcessInfo.processInfo.environment["CI"] != nil ? "CI" : "Local")
        API Endpoint: /api/sessions
        """)
    
    // Collect timing data
    for _ in 0..<iterations {
        let start = CFAbsoluteTimeGetCurrent()
        _ = await apiCall()
        let end = CFAbsoluteTimeGetCurrent()
        timings.append(end - start)
    }
    
    // Calculate comprehensive statistics
    let average = timings.reduce(0, +) / Double(timings.count)
    let stdDev = calculateStandardDeviation(timings)
    let p95 = timings.sorted()[Int(0.95 * Double(timings.count))]
    
    // Attach detailed performance metrics
    Attachment.record("Performance Results", """
        Average: \(String(format: "%.2f", average * 1000))ms
        Standard Deviation: \(String(format: "%.2f", stdDev * 1000))ms
        95th Percentile: \(String(format: "%.2f", p95 * 1000))ms
        Min: \(String(format: "%.2f", (timings.min() ?? 0) * 1000))ms
        Max: \(String(format: "%.2f", (timings.max() ?? 0) * 1000))ms
        """)
    
    // Attach raw timing data for analysis
    let timingData = timings.enumerated().map { i, timing in
        "Sample \(i + 1): \(String(format: "%.4f", timing * 1000))ms"
    }.joined(separator: "\n")
    Attachment.record("Raw Timing Data", timingData)
    
    #expect(average < 0.05, "Average response time exceeded 50ms")
}

13.3 Enhanced Conditional Testing with ConditionTrait.evaluate()

Swift 6.2 enhances conditional testing with the ability to check trait conditions outside of test context, enabling smarter test execution based on system capabilities.

Custom Condition Traits

swift
/// Checks if the required server binary is available
struct ServerBinaryAvailableCondition: ConditionTrait {
    func evaluate(for test: Test) -> ConditionResult {
        let bunPath = "/usr/local/bin/bun"
        let altBunPath = "/opt/homebrew/bin/bun"
        
        let hasBinary = FileManager.default.fileExists(atPath: bunPath) || 
                       FileManager.default.fileExists(atPath: altBunPath)
        
        return hasBinary ? .continue : .skip(reason: "Server binary not available")
    }
}

/// Checks if network interfaces are available for testing
struct NetworkAvailableCondition: ConditionTrait {
    func evaluate(for test: Test) -> ConditionResult {
        let hasNetwork = !NetworkUtility.getAllIPAddresses().isEmpty
        return hasNetwork ? .continue : .skip(reason: "No network interfaces available")
    }
}

/// Checks if we're in a valid Git repository for repository-specific tests
struct ValidGitRepositoryCondition: ConditionTrait {
    func evaluate(for test: Test) -> ConditionResult {
        let gitDir = FileManager.default.fileExists(atPath: ".git")
        return gitDir ? .continue : .skip(reason: "Not in a Git repository")
    }
}

/// Checks if we're running in CI environment
struct NotInCICondition: ConditionTrait {
    func evaluate(for test: Test) -> ConditionResult {
        let isCI = ProcessInfo.processInfo.environment["CI"] != nil ||
                   ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] != nil
        return isCI ? .skip(reason: "Disabled in CI environment") : .continue
    }
}

Smart Test Execution

swift
// Replace broad .disabled() with intelligent conditions
@Suite("Server Manager Tests", .tags(.serverManager))
struct ServerManagerTests {
    
    @Test("Server lifecycle with full binary", ServerBinaryAvailableCondition(), NotInCICondition())
    func serverLifecycleWithBinary() async throws {
        // This test only runs when:
        // 1. The server binary is actually available
        // 2. We're not in a CI environment
        // This prevents false failures and provides clear skip reasons
    }
    
    @Test("Network configuration", NetworkAvailableCondition())
    func networkConfiguration() throws {
        // Only runs when network interfaces are available
        // Automatically skipped in containerized or network-less environments
    }
}

13.4 Enhanced Tag System for Better Organization

Expand your tag system to include the new Swift 6.2 testing capabilities:

swift
// Enhanced TestTags.swift
extension Tag {
    // Existing tags
    @Tag static var critical: Self
    @Tag static var networking: Self
    @Tag static var concurrency: Self
    
    // Swift 6.2 Enhanced Tags
    @Tag static var exitTests: Self          // Tests using processExitsWith
    @Tag static var attachmentTests: Self    // Tests with rich diagnostic attachments
    @Tag static var requiresServerBinary: Self  // Tests needing actual server binary
    @Tag static var requiresNetwork: Self   // Tests needing network interfaces
    @Tag static var processSpawn: Self      // Tests that spawn external processes
    @Tag static var gitRepository: Self     // Tests that need to run in a Git repository
}

Strategic Test Filtering

bash
# Run only tests with attachments for debugging
swift test --filter .attachmentTests

# Skip exit tests in CI environments
swift test --skip .exitTests

# Run performance tests with attachments for detailed analysis
swift test --filter .performance --filter .attachmentTests

# Run all tests that require external dependencies
swift test --filter .requiresServerBinary --filter .requiresNetwork

# Run Git repository tests only when in a Git repo
swift test --filter .gitRepository

13.5 Best Practices for Swift 6.2 Features

Exit Tests Guidelines

  • Use Sparingly: Exit tests are powerful but should be used for critical process lifecycle scenarios
  • Clean State: Always ensure proper cleanup in exit test blocks
  • Avoid Elevated Permissions: Don't test operations requiring sudo or admin rights
  • Mock When Possible: Use mock processes for testing logic without actual system processes

Attachment Strategy

  • Contextual Information: Attach system state, configuration, and environment details
  • Progressive Detail: Start with summary info, add detailed data as needed
  • Performance Focus: Use attachments extensively in performance tests for trend analysis
  • Failure Context: Attach diagnostic info that helps understand why tests failed

Condition Trait Design

  • Specific Conditions: Create focused conditions for specific capabilities
  • Clear Skip Reasons: Always provide descriptive reasons for skipped tests
  • Environment Awareness: Consider CI, local development, and different platforms
  • Combine Thoughtfully: Use multiple conditions when tests have multiple requirements

Action Items for Swift 6.2 Adoption

  • Audit Disabled Tests: Replace broad .disabled() with specific condition traits
  • Add Exit Tests: Identify critical process lifecycle scenarios and add exit tests
  • Enhance Debugging: Add attachments to complex tests and all performance tests
  • Update Tag Strategy: Add Swift 6.2 tags and update filtering strategies
  • Create Condition Traits: Build reusable conditions for common system requirements
  • Document Skip Reasons: Ensure all conditional tests have clear skip explanations
  • CI Integration: Update CI scripts to handle new test filtering and skip reasons

**Appendix: Evergreen Testing Principles (The F.I.R.S.T. Principles)**

These foundational principles are framework-agnostic, and Swift Testing is designed to make adhering to them easier than ever.

Principle Meaning Swift Testing Application
Fast Tests must execute in milliseconds. Lean on default parallelism. Use .serialized sparingly.
Isolated Tests must not depend on each other. Swift Testing enforces this by creating a new suite instance for every test. Random execution order helps surface violations.
Repeatable A test must produce the same result every time. Control all inputs (dates, network responses) with mocks/stubs. Reset state in init/deinit.
Self-Validating The test must automatically report pass or fail. Use #expect and #require. Never rely on print() for validation.
Timely Write tests alongside the production code. Use parameterized tests (@Test(arguments:)) to easily cover edge cases as you write code.

File: apple/docs/liquid-glass/appkit.md

Liquid Glass in AppKit

Quick Reference

Class Purpose Key Properties
NSGlassEffectView Single glass effect contentView, cornerRadius, tintColor
NSGlassEffectContainerView Multiple glass effects contentView, spacing

NSGlassEffectView

Basic Usage

swift
let glassView = NSGlassEffectView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
glassView.cornerRadius = 16.0
glassView.tintColor = NSColor.systemBlue.withAlphaComponent(0.3)
glassView.contentView = myContentView

Interactive Glass

swift
class InteractiveGlass: NSGlassEffectView {
    override func mouseEntered(with event: NSEvent) {
        NSAnimationContext.runAnimationGroup { context in
            context.duration = 0.2
            animator().tintColor = NSColor.accent.withAlphaComponent(0.2)
        }
    }
    
    override func mouseExited(with event: NSEvent) {
        NSAnimationContext.runAnimationGroup { _ in
            animator().tintColor = nil
        }
    }
}

NSGlassEffectContainerView

Container Setup

swift
let container = NSGlassEffectContainerView()
container.spacing = 40.0  // Merge distance

let contentView = NSView()
container.contentView = contentView

// Add multiple glass views to contentView
[glass1, glass2, glass3].forEach { contentView.addSubview($0) }

Animated Merging

swift
NSAnimationContext.runAnimationGroup { context in
    context.duration = 0.5
    // Move glass views closer to trigger merge
    glass2.animator().frame.origin.x -= 50
}

Custom Components

Glass Button

swift
class GlassButton: NSButton {
    private let glass = NSGlassEffectView()
    
    override init(frame: NSRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    
    private func setup() {
        bezelStyle = .rounded
        isBordered = false
        glass.autoresizingMask = [.width, .height]
        glass.cornerRadius = 8.0
        addSubview(glass, positioned: .below, relativeTo: nil)
    }
}

Glass Toolbar

swift
// Add glass to toolbar area
let toolbar = NSToolbar(identifier: "main")
let glassView = NSGlassEffectView()
glassView.frame = NSRect(x: 0, y: view.bounds.height - 50, 
                         width: view.bounds.width, height: 50)
glassView.autoresizingMask = [.width, .minYMargin]
view.addSubview(glassView)

Best Practices

Z-Order

  • Only contentView guaranteed inside effect
  • Arbitrary subviews may render incorrectly

Performance

  • Batch in containers when possible
  • Limit total glass views (5-10 max)
  • Disable when scrolling for performance

Animation

  • Use NSAnimationContext for smooth transitions
  • Standard duration: 0.2-0.5 seconds
  • Animate tintColor for state changes

Common Issues

Issue Solution
Glass not visible Check view hierarchy, ensure added to window
Performance lag Use containers, reduce glass count
Merge not working Check container spacing value
Content outside glass Use contentView property exclusively

See Also


File: apple/docs/liquid-glass/overview.md

Liquid Glass Design Overview

What is Liquid Glass?

Dynamic material design combining optical glass properties with fluidity:

  • Blurs content behind it
  • Reflects color and light from surroundings
  • Reacts to touch and pointer interactions
  • Morphs between shapes during transitions

Platform Availability

Platform Framework Primary Class/Modifier Min Version
macOS AppKit NSGlassEffectView macOS 14.0+
macOS SwiftUI .glassEffect() macOS 14.0+
iOS/iPadOS SwiftUI .glassEffect() iOS 17.0+

Core Concepts

Glass Variants

  • Regular: Standard glass effect
  • Prominent: Enhanced visibility with tint
  • Interactive: Responds to user input

Container Optimization

Containers improve performance and enable effect merging:

  • AppKit: NSGlassEffectContainerView
  • SwiftUI: GlassEffectContainer

Key Properties

  • cornerRadius / shape: Visual appearance
  • tintColor / .tint(): Color overlay
  • spacing: Merge distance threshold

Quick Start

SwiftUI

swift
Text("Hello").glassEffect()
Button("Click").buttonStyle(.glass)

AppKit

swift
let glass = NSGlassEffectView()
glass.cornerRadius = 12.0
glass.contentView = myView

Performance Guidelines

  1. Use containers for multiple glass views
  2. Limit total glass effects on screen
  3. Consider GPU impact on older devices
  4. Batch similar effects together

Next Steps


File: apple/docs/liquid-glass/patterns.md

Liquid Glass Common Patterns

Cross-Platform Patterns

State-Based Glass

SwiftUI

swift
.glassEffect(.regular.tint(isActive ? .blue : .clear))

AppKit

swift
glassView.tintColor = isActive ? NSColor.systemBlue.withAlphaComponent(0.3) : nil

Hover Effects

SwiftUI

swift
@State private var isHovered = false
Text("Hover")
    .glassEffect(.regular.tint(isHovered ? .blue : .clear))
    .onHover { isHovered = $0 }

AppKit

swift
override func mouseEntered(with: NSEvent) {
    animator().tintColor = NSColor.systemBlue.withAlphaComponent(0.2)
}

Animated Transitions

SwiftUI

swift
.animation(.spring(duration: 0.3), value: glassState)

AppKit

swift
NSAnimationContext.runAnimationGroup { context in
    context.duration = 0.3
    // animations
}

UI Component Patterns

Glass Card

swift
// SwiftUI
struct GlassCard<Content: View>: View {
    let content: Content
    var body: some View {
        content
            .padding()
            .glassEffect(in: .rect(cornerRadius: 16))
    }
}

// AppKit
class GlassCard: NSView {
    let glass = NSGlassEffectView()
    init(content: NSView) {
        super.init(frame: .zero)
        glass.cornerRadius = 16
        glass.contentView = content
        addSubview(glass)
    }
}

Glass Badge

swift
// SwiftUI
struct GlassBadge: View {
    let count: Int
    var body: some View {
        Text("\(count)")
            .padding(.horizontal, 8)
            .glassEffect(.regular.tint(.red))
    }
}

Glass Toolbar

swift
// SwiftUI
.toolbar {
    ToolbarItemGroup {
        Button("One") { }.buttonStyle(.glass)
        Button("Two") { }.buttonStyle(.glass)
    }
}

Layout Patterns

Grid of Glass Items

swift
// SwiftUI
GlassEffectContainer(spacing: 20) {
    LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
        ForEach(items) { item in
            ItemView(item).glassEffect()
        }
    }
}

Merging Glass Groups

swift
// SwiftUI with union
@Namespace private var ns
ForEach(items.indices) { i in
    ItemView(items[i])
        .glassEffect()
        .glassEffectUnion(id: groupID(for: i), namespace: ns)
}

Animation Patterns

Pulse Effect

swift
// SwiftUI
@State private var isPulsing = false
Circle()
    .glassEffect(.regular.tint(isPulsing ? .blue : .clear))
    .animation(.easeInOut(duration: 1).repeatForever(), value: isPulsing)
    .onAppear { isPulsing = true }

Morphing Between States

swift
// SwiftUI
@Namespace private var namespace
if expanded {
    LargeView().glassEffect().glassEffectID("morph", in: namespace)
} else {
    SmallView().glassEffect().glassEffectID("morph", in: namespace)
}

Performance Patterns

Lazy Loading Glass

swift
// SwiftUI
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemView(item)
                .glassEffect(isEnabled: item.isVisible)
        }
    }
}

Batch Processing

swift
// AppKit
let container = NSGlassEffectContainerView()
container.spacing = 20
// Add all glass views to container's contentView

Decision Matrix

Use Case SwiftUI AppKit
Simple glass .glassEffect() NSGlassEffectView
Multiple glass GlassEffectContainer NSGlassEffectContainerView
Button styling .buttonStyle(.glass) Custom GlassButton class
Morphing .glassEffectID() Manual animation
Performance Container + lazy Container + batch

Tips & Tricks

  1. Merge Control: Adjust container spacing to control merge distance
  2. State Changes: Use tint color for visual feedback
  3. Touch Feedback: Enable .interactive() for user interaction
  4. Performance: Disable glass when off-screen
  5. Consistency: Match corner radius across app

See Also


File: apple/docs/liquid-glass/swiftui.md

Liquid Glass in SwiftUI

Quick Reference

Modifier/View Purpose Parameters
.glassEffect() Apply glass to view Glass, Shape, isEnabled
GlassEffectContainer Optimize multiple glass spacing, content
.buttonStyle(.glass) Glass button style N/A
.glassEffectID() Morphing transitions id, namespace

Basic Implementation

Simple Glass Effect

swift
Text("Hello")
    .padding()
    .glassEffect()  // Default: regular glass, capsule shape

Custom Shape & Tint

swift
Image(systemName: "star")
    .glassEffect(
        .regular.tint(.blue).interactive(),
        in: .rect(cornerRadius: 12)
    )

Glass Variants

Configuration Options

swift
// Regular glass
.glassEffect(.regular)

// With tint
.glassEffect(.regular.tint(.orange))

// Interactive (responds to touch)
.glassEffect(.regular.interactive())

// Combined
.glassEffect(.regular.tint(.blue).interactive())

Container Usage

Multiple Glass Views

swift
GlassEffectContainer(spacing: 40) {
    HStack(spacing: 40) {
        ForEach(items) { item in
            ItemView(item)
                .glassEffect()
        }
    }
}

Union Effect

swift
@Namespace private var namespace

GlassEffectContainer {
    HStack {
        ForEach(items.indices, id: \.self) { index in
            ItemView(items[index])
                .glassEffect()
                .glassEffectUnion(
                    id: index < 2 ? "group1" : "group2",
                    namespace: namespace
                )
        }
    }
}

Morphing Transitions

Animated Morphing

swift
@State private var expanded = false
@Namespace private var namespace

GlassEffectContainer {
    if expanded {
        LargeView()
            .glassEffect()
            .glassEffectID("item", in: namespace)
    } else {
        SmallView()
            .glassEffect()
            .glassEffectID("item", in: namespace)
    }
}
.animation(.spring(), value: expanded)

Button Styles

Glass Buttons

swift
// Standard glass button
Button("Action") { }
    .buttonStyle(.glass)

// Prominent glass button
Button("Important") { }
    .buttonStyle(.glassProminent)

Toolbar Integration

Glass in Toolbar

swift
.toolbar {
    ToolbarItem(placement: .primaryAction) {
        Button("Save") { }
            .buttonStyle(.glass)
    }
}

Custom Toolbar Background

swift
.toolbar {
    ToolbarItem(placement: .principal) {
        Text("Title")
            .sharedBackgroundVisibility(.hidden)
    }
}

Advanced Techniques

Conditional Glass

swift
@State private var glassEnabled = true

Text("Dynamic")
    .glassEffect(isEnabled: glassEnabled)
swift
@Namespace private var namespace

NavigationStack {
    Content()
        .toolbar {
            ToolbarItem {
                Button("Open") { }
                    .matchedTransitionSource(id: "btn", in: namespace)
            }
        }
        .sheet(isPresented: $show) {
            DetailView()
                .navigationTransition(.zoom(sourceID: "btn", in: namespace))
        }
}

Performance Tips

  1. Container Usage: Always wrap multiple glass views
  2. Spacing: Smaller = merge closer, larger = merge farther
  3. Limit Count: 5-10 glass effects maximum
  4. Disable When Hidden: Use isEnabled parameter

Common Patterns

Pattern Implementation
Toggle glass .glassEffect(isEnabled: condition)
Group merge .glassEffectUnion(id:namespace:)
Custom shape .glassEffect(in: .rect(cornerRadius: 20))
State indication .tint(isActive ? .blue : .clear)

Platform Notes

  • iOS: Bottom bar placement works well
  • iPadOS: Consider larger touch targets
  • macOS: Toolbar customization expected

See Also


File: apple/docs/toolbar/swiftui-features.md

SwiftUI Toolbar Features

Quick Reference

Feature Modifier/Type Purpose
.toolbar(id:) Customizable toolbar User can add/remove/reorder
ToolbarSpacer Spacing control Fixed or flexible spacing
.searchToolbarBehavior() Search field display Minimize/expand behavior
DefaultToolbarItem System items Reposition system controls
.matchedTransitionSource() Transitions Zoom from toolbar items

Customizable Toolbars

Basic Setup

swift
.toolbar(id: "main") {
    ToolbarItem(id: "save") { SaveButton() }
    ToolbarItem(id: "share") { ShareButton() }
    ToolbarSpacer(.flexible)
    ToolbarItem(id: "more") { MoreButton() }
}

Spacer Types

swift
ToolbarSpacer(.fixed)     // Fixed width
ToolbarSpacer(.flexible)  // Pushes items apart

Search Integration

Minimize Behavior

swift
@State private var searchText = ""

NavigationStack {
    ContentView()
        .searchable($searchText)
        .searchToolbarBehavior(.minimize)  // Compact search button
}
swift
.toolbar {
    ToolbarItem(placement: .bottomBar) { Button1() }
    DefaultToolbarItem(kind: .search, placement: .bottomBar)
    ToolbarItem(placement: .bottomBar) { Button2() }
}

Placement Options

Common Placements

swift
.toolbar {
    // Navigation bar
    ToolbarItem(placement: .navigationBarLeading) { }
    ToolbarItem(placement: .navigationBarTrailing) { }
    ToolbarItem(placement: .principal) { }
    
    // Bottom bar (iOS)
    ToolbarItem(placement: .bottomBar) { }
    
    // Large title area
    ToolbarItem(placement: .largeSubtitle) { CustomSubtitle() }
}

Large Subtitle

swift
NavigationStack {
    Content()
        .navigationTitle("Title")
        .navigationSubtitle("Subtitle")
        .toolbar {
            ToolbarItem(placement: .largeSubtitle) {
                // Overrides navigationSubtitle
                CustomSubtitleView()
            }
        }
}

Visual Effects

Matched Transitions

swift
@State private var showDetail = false
@Namespace private var namespace

NavigationStack {
    Content()
        .toolbar {
            ToolbarItem {
                Button("Open") { showDetail = true }
                    .matchedTransitionSource(id: "btn", in: namespace)
            }
        }
        .sheet(isPresented: $showDetail) {
            DetailView()
                .navigationTransition(.zoom(sourceID: "btn", in: namespace))
        }
}

Background Visibility

swift
.toolbar(id: "main") {
    ToolbarItem(id: "status", placement: .principal) {
        StatusView()
            .sharedBackgroundVisibility(.hidden)  // No glass background
    }
}

System Items

Default Items

swift
.toolbar {
    // Reposition system search
    DefaultToolbarItem(kind: .search, placement: .bottomBar)
    
    // Sidebar toggle
    DefaultToolbarItem(kind: .sidebar, placement: .navigationBarLeading)
}

Platform Considerations

iOS/iPadOS

swift
#if os(iOS)
.toolbar {
    ToolbarItemGroup(placement: .bottomBar) {
        // Bottom bar items for iPhone
    }
}
#endif

macOS

swift
#if os(macOS)
.toolbar {
    ToolbarItem(placement: .automatic) {
        // macOS toolbar items
    }
}
#endif

Common Patterns

Dynamic Toolbar

swift
@State private var isEditing = false

.toolbar {
    if isEditing {
        ToolbarItem(id: "done") { DoneButton() }
    } else {
        ToolbarItem(id: "edit") { EditButton() }
    }
}

Grouped Actions

swift
.toolbar {
    ToolbarItemGroup(placement: .bottomBar) {
        Button("One") { }
        Button("Two") { }
        Button("Three") { }
    }
}

Contextual Items

swift
@State private var selection: Item?

.toolbar {
    if selection != nil {
        ToolbarItem { DeleteButton() }
        ToolbarItem { ShareButton() }
    }
}

Best Practices

  1. Unique IDs: Use meaningful identifiers for customizable items
  2. Logical Groups: Use spacers to group related actions
  3. Platform Awareness: Test on all target platforms
  4. Consistent Placement: Follow platform conventions
  5. Minimal Items: Avoid overcrowding toolbars

Troubleshooting

Issue Solution
Items not customizable Add id to toolbar and items
Search not minimizing Apply .searchToolbarBehavior(.minimize)
Transition not working Check namespace and ID match
Items hidden Check placement compatibility

References


File: apple/logging/README.md

VibeTunnel Logging Configuration Profile

This directory contains the configuration profile for enabling full debug logging in VibeTunnel apps.

What It Does

The VibeTunnel-Logging.mobileconfig profile enables:

  • Debug-level logging for both macOS and iOS apps
  • Visibility of private data (no more `` tags)
  • Persistent logging at debug level

Installation

macOS

  1. Double-click VibeTunnel-Logging.mobileconfig
  2. System Settings will open
  3. Go to Privacy & Security → Profiles
  4. Click on "VibeTunnel Debug Logging"
  5. Click "Install..."
  6. Enter your password when prompted
  7. Restart VibeTunnel for changes to take effect

iOS

  1. AirDrop or email the VibeTunnel-Logging.mobileconfig to your iOS device
  2. Tap the file to open it
  3. iOS will prompt to review the profile
  4. Go to Settings → General → VPN & Device Management
  5. Tap on "VibeTunnel Debug Logging"
  6. Tap "Install" and enter your passcode
  7. Restart the VibeTunnel app

Verification

After installation, logs should show full details:

bash
# macOS - using vtlog script
./scripts/vtlog.sh

# iOS - in Xcode console or Console.app
# You should see actual values instead of <private>

Removal

macOS

  1. System Settings → Privacy & Security → Profiles
  2. Select "VibeTunnel Debug Logging"
  3. Click the minus (-) button
  4. Confirm removal

iOS

  1. Settings → General → VPN & Device Management
  2. Tap "VibeTunnel Debug Logging"
  3. Tap "Remove Profile"
  4. Enter passcode to confirm

Security Note

This profile enables detailed logging which may include sensitive information. Only install on development devices and remove when no longer needed for debugging.

Technical Details

The profile configures logging for all VibeTunnel subsystems:

macOS

  • sh.vibetunnel.vibetunnel - Main macOS app and all components
  • sh.vibetunnel.vibetunnel.debug - Debug builds
  • sh.vibetunnel.vibetunnel.tests - Test suite
  • sh.vibetunnel.vibetunnel.tests.debug - Debug test builds

iOS

  • sh.vibetunnel.ios - Main iOS app and all components
  • sh.vibetunnel.ios.tests - iOS test suite

All subsystems are configured to:

  • Enable at Debug level
  • Persist at Debug level
  • Show private data (no `` redaction)

File: assets/AppIcon.icon/icon.json

{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"glass" : true,
"image-name" : "vibe_tunnel_clean.png",
"name" : "vibe_tunnel_clean",
"position" : {
"scale" : 1.0,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}


File: docs/architecture-mario.md

VibeTunnel Architecture Analysis - Mario's Technical Deep Dive

This document contains comprehensive technical insights from Mario's debugging session about VibeTunnel's architecture, critical performance issues, and detailed solutions.

Executive Summary

Mario identified two critical issues causing performance problems in VibeTunnel:

  1. 850MB Session Bug: External terminal sessions (via vibetunnel-fwd) bypass the clear sequence truncation in stream-watcher.ts, sending entire gigabyte files instead of the last 2MB
  2. Resize Loop: Claude terminal app issues full clear sequence (\x1b[2J) and re-renders entire scroll buffer on every resize event, creating exponential data growth

Note: A third issue with Node-PTY's shared pipe architecture causing Electron crashes has already been resolved with a custom PTY implementation.

System Architecture

Core Components

text
┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   Client    │────▶│  Web Server  │────▶│ PTY Process │
└─────────────┘     └──────────────┘     └─────────────┘
      │                    │                     │
      │                    ▼                     ▼
      │              ┌──────────┐         ┌──────────┐
      └─────────────▶│ Terminal │         │ Ascinema │
                     │ Manager  │         │  Files   │
                     └──────────┘         └──────────┘

Detailed Sequence Flow

mermaid
sequenceDiagram
    participant UI as Browser/Electron
    participant WS as WebSocket
    participant Server as Node Server
    participant PTY as PTYManager
    participant FWD as vibetunnel-fwd
    participant Proc as User Process
    
    UI->>WS: keystrokes
    WS->>Server: /api/sessions/:id/input
    Server->>PTY: IPC Socket message
    PTY->>Proc: write to stdin
    Proc-->>PTY: stdout (ANSI sequences)
    PTY-->>Server: write to ascinema file
    alt External Terminal
        FWD-->>TTY: mirror stdout to terminal
    end
    Server-->>UI: WS v3 stream (truncated + multiplexed)

Key Files and Their Roles

File Purpose Critical Functions
server.ts Main web server HTTP endpoints, WebSocket handling
pty-manager.ts PTY lifecycle management createSession(), setupPtyHandlers()
stream-watcher.ts Monitors ascinema files sendExistingContent() - implements clear truncation
vibetunnel-fwd External terminal forwarding Process spawning, BYPASSES TRUNCATION
terminal-manager.ts Binary buffer rendering Converts ANSI to binary cells format

Data Flow Paths

Input Path (Keystroke → Terminal)

  1. Browser captures key press
  2. WebSocket v3 sends to /ws (INPUT_TEXT/INPUT_KEY/RESIZE)
  3. Server writes to IPC socket
  4. PTY Manager writes to process stdin
  5. Process executes command

Output Path (Terminal → Browser)

  1. Process writes to stdout
  2. PTY Manager captures via onData handler
  3. Writes to asciinema cast file (with write queue for backpressure)
  4. CastOutputHub tails file + scans for last clear sequence (pruning)
  5. Client receives via /ws v3:
    • STDOUT (UTF-8 bytes)
    • SNAPSHOT_VT (server-rendered VT snapshot bytes)

Binary Cell Buffer Format

The terminal manager pre-renders terminal output into a binary format for efficient transmission:

text
For each cell at (row, column):
- Character (UTF-8 encoded)
- Foreground color (RGB values)
- Background color (RGB values)
- Attributes (bold, italic, underline, etc.)

Benefits:

  • Server-side ANSI parsing eliminates client CPU usage
  • Efficient binary transmission reduces bandwidth
  • Only last 10,000 lines kept in memory
  • Client simply renders pre-computed cells

Critical Bugs Analysis

1. The 850MB Session Loading Bug

Symptom: Sessions with large output (850MB+) cause infinite loading and browser unresponsiveness.

Root Cause: External terminal sessions via vibetunnel-fwd bypass the clear sequence truncation logic.

Technical Details:

javascript
// In stream-watcher.ts - WORKING CORRECTLY
sendExistingContent() {
  // Scans backwards for last clear sequence
  const lastClear = content.lastIndexOf('\x1b[2J');
  // Sends only content after clear
  return content.slice(lastClear); // 2MB instead of 850MB
}

Evidence from Testing:

  • Test file: 980MB containing 2,400 clear sequences
  • Server-created sessions: Correctly send only last 2MB
  • External terminal sessions: Send entire 980MB file
  • Processing time: 2-3 seconds to scan 1GB file
  • Client receives instant replay for 2MB truncated content

The Issue: External terminal path doesn't trigger sendExistingContent(), sending gigabyte files to clients.

2. Resize Event Performance Catastrophe

Problem: Each resize event causes Claude to re-render the entire terminal history.

Claude's Behavior:

text
1. Resize event received
2. Claude issues clear sequence: \x1b[2J
3. Re-renders ENTIRE scroll buffer from line 1
4. Rendering causes viewport changes
5. Viewport changes trigger resize event
6. GOTO step 1 (infinite loop)

Technical Evidence:

  • In 850MB session: each resize → full buffer re-render
  • Claude renders from "Welcome to Claude" message every time
  • Mobile UI particularly problematic (frequent resize events)
  • Header button position shifts during rendering indicate viewport instability
  • Session with 39 resize events can generate 850MB+ files

Contributing Factors:

  • React Ink (TUI framework) unnecessarily re-renders entire components
  • Session-detail-view has buggy resize observer
  • Mobile Safari behaves differently than desktop at same viewport size
  • Touch events vs mouse events complicate scrolling behavior

3. Node-PTY Architecture Flaw (ALREADY FIXED)

This issue has been resolved by implementing a custom PTY solution without the shared pipe architecture.

Ascinema Format Details

VibeTunnel uses the ascinema format for recording terminal sessions:

javascript
// Format: [timestamp, event_type, data]
[1.234, "o", "Hello World\n"]     // Output event
[1.235, "i", "k"]                 // Input event (keypress)
[1.236, "r", "80x24"]             // Resize event

Clear sequence detection:

javascript
const CLEAR_SEQUENCE = '\x1b[2J';  // ANSI clear screen
const CLEAR_WITH_HOME = '\x1b[H\x1b[2J'; // Home + clear

function findLastClearSequence(buffer) {
    // Search from end for efficiency
    let lastClear = buffer.lastIndexOf(CLEAR_SEQUENCE);
    return lastClear === -1 ? 0 : lastClear;
}

Proposed Solutions

Priority 1: Fix External Terminal Clear Truncation (IMMEDIATE)

Problem: External terminal sessions don't use sendExistingContent() truncation.

Investigation Needed:

  1. Trace how vibetunnel-fwd connects to client streams
  2. Determine why it bypasses stream-watcher's truncation
  3. Ensure external terminals use same code path as server sessions
  4. Test with 980MB file to verify fix

Expected Impact: Immediate fix for users experiencing infinite loading with large sessions.

Priority 2: Fix Resize Handling (INVESTIGATION)

Debugging Approach:

  1. Instrument session-detail-view with resize observer logging
  2. Identify what causes viewport expansion
  3. Implement resize event debouncing
  4. Fix mobile-specific issues:
    • Keyboard state affects scrolling
    • Touch vs mouse event handling
    • Scrollbar visibility problems

Code to Add:

javascript
// Add to session-detail-view
let resizeCount = 0;
new ResizeObserver((entries) => {
    console.log(`Resize ${++resizeCount}:`, entries[0].contentRect);
    // Debounce resize events
    clearTimeout(this.resizeTimeout);
    this.resizeTimeout = setTimeout(() => {
        this.handleResize();
    }, 100);
}).observe(this.terminalElement);

Implementation Details

Write Queue Implementation

The PTY manager implements backpressure handling:

javascript
class WriteQueue {
    constructor(writer) {
        this.queue = [];
        this.writing = false;
        this.writer = writer;
    }
    
    async write(data) {
        this.queue.push(data);
        if (!this.writing) {
            await this.flush();
        }
    }
    
    async flush() {
        this.writing = true;
        while (this.queue.length > 0) {
            const chunk = this.queue.shift();
            await this.writer.write(chunk);
        }
        this.writing = false;
    }
}

Platform-Specific Considerations

macOS:

  • Screen Recording permission required for terminal access
  • Terminal.app specific behaviors and quirks

Mobile Safari:

  • Different behavior than desktop Safari at same viewport
  • Touch events complicate scrolling
  • Keyboard state affects scroll behavior
  • Missing/hidden scrollbars
  • Viewport meta tag issues

Windows (Future):

  • ConPTY vs WinPTY support
  • Different ANSI sequence handling
  • Path normalization requirements

Performance Metrics

Metric Current After Fix
980MB session initial load Infinite/Crash 2-3 seconds
Data sent to client 980MB 2MB
Memory per terminal 50-100MB Target: 10MB
Clear sequence scan time N/A ~2 seconds for 1GB
Resize event storms Exponential growth Debounced

Testing and Debugging

Test Large Session Handling

bash
# Create large session file
cd web
npm run dev

# In another terminal, create session
SESSION_ID=$(curl -X POST localhost:3000/api/sessions | jq -r .id)

# Stop server, inject large file
cp /path/to/850mb-test-file ~/.vibetunnel/sessions/$SESSION_ID/stdout

# Restart and verify truncation works
npm run dev

Debug Resize Events

javascript
// Add to any component to detect resize loops
window.addEventListener('resize', () => {
    console.count('resize');
    console.trace('Resize triggered from:');
});

Monitor Network Traffic

  • Check /ws traffic (v3 frames) for STDOUT and SNAPSHOT_VT
  • Verify pruning works (no replay before last clear)
  • Use /api/sessions/:id/snapshot for snapshot/export debugging

Architectural Insights

Why Current Architecture Works (When Not Bugged)

  1. Simplicity: "Es ist die todeleinfachste Variante" - It's the simplest possible approach
  2. Efficiency: 2MB instead of 980MB transmission after clear sequence truncation
  3. Server-side rendering: Binary cell format eliminates client ANSI parsing

Community Contribution Challenges

  • High development velocity makes contribution difficult
  • "Velocity kills" - rapid changes discourage contributors
  • LitElement/Web Components unfamiliar to most developers
  • Large file sizes cause AI tools to refuse processing

Future Architecture Considerations

Go Migration Benefits:

  • Automatic test dependency tracking
  • Only runs tests that changed
  • Pre-allocated buffers minimize GC
  • Better suited for AI-assisted development

Rust Benefits:

  • 2MB static binary
  • 10MB RAM usage
  • Direct C interop for PTY code
  • No garbage collection overhead

Action Plan Summary

  1. Immediate (End of Week): Fix external terminal truncation bug

    • Debug why vibetunnel-fwd bypasses sendExistingContent()
    • Deploy fix for immediate user relief
  2. Short Term: Comprehensive resize fix

    • Debug session-detail-view triggers
    • Implement proper debouncing
    • Fix mobile-specific issues
  3. Long Term: Consider architecture migration

    • Evaluate Rust forward binary
    • Consider Go for web server
    • Maintain backwards compatibility

Key Technical Quotes

  • "Wir schicken 2MB statt 980MB" - We send 2MB instead of 980MB
  • "Die haben einen Shared Pipe, wo alle reinschreiben" - They have a shared pipe where everyone writes
  • "Es gibt keinen Grund, warum ich von da weg alles neu rendern muss" - There's no reason to re-render everything from the beginning
  • "Das ist known good" - Referring to battle-tested implementations

This architecture analysis provides the technical foundation for fixing VibeTunnel's critical performance issues while maintaining its elegant simplicity.


File: docs/ARCHITECTURE.md

VibeTunnel Architecture

VibeTunnel is a modern terminal multiplexer with native macOS and iOS applications, featuring a Node.js/Bun-powered server backend and real-time web interface. The architecture prioritizes performance, security, and seamless cross-platform experience through WebSocket-based communication and native UI integration.

The system consists of four main components: a native macOS menu bar application that manages server lifecycle, a Node.js/Bun server handling terminal sessions, an iOS companion app for mobile terminal access, and a web frontend for browser-based interaction. These components communicate through a well-defined REST API and WebSocket protocol for real-time terminal I/O streaming.

Component Map

macOS Application - Native Swift app in mac/VibeTunnel/

  • ServerManager (mac/VibeTunnel/Core/Services/ServerManager.swift) - Central server lifecycle coordinator
  • BunServer (mac/VibeTunnel/Core/Services/BunServer.swift) - Bun runtime integration
  • BaseProcessServer (mac/VibeTunnel/Core/Services/BaseProcessServer.swift) - Base class for server implementations
  • TTYForwardManager (mac/VibeTunnel/Core/Services/TTYForwardManager.swift) - Terminal forwarding logic
  • SessionMonitor (mac/VibeTunnel/Core/Services/SessionMonitor.swift) - Active session tracking

Node.js/Bun Server - JavaScript backend in web/src/server/

  • app.ts - Express application setup and configuration
  • server.ts - HTTP server initialization and shutdown handling
  • pty/pty-manager.ts - Native PTY process management
  • pty/session-manager.ts - Terminal session lifecycle
  • services/terminal-manager.ts - High-level terminal operations
  • services/ws-v3-hub.ts - Unified /ws WebSocket v3 hub (multiplexed)
  • routes/sessions.ts - REST API endpoints for session management

iOS Application - Native iOS app in ios/VibeTunnel/

  • BufferWebSocketClient (ios/VibeTunnel/Services/BufferWebSocketClient.swift) - WebSocket client for terminal streaming
  • TerminalView (ios/VibeTunnel/Views/Terminal/TerminalView.swift) - Terminal rendering UI
  • GhosttyWebView (ios/VibeTunnel/Views/Terminal/GhosttyWebView.swift) - WKWebView-based terminal renderer

Web Frontend - TypeScript/React app in web/src/client/

  • Terminal rendering using ghostty-web
  • WebSocket client for real-time updates
  • Session management UI

Key Files

Server Protocol Definition

  • mac/VibeTunnel/Core/Protocols/VibeTunnelServer.swift - Defines server interface

Session Models

  • mac/VibeTunnel/Core/Models/TunnelSession.swift - Core session data structure
  • web/src/server/pty/types.ts - TypeScript session types

Binary Integration

  • mac/scripts/build-bun-executable.sh - Builds Bun runtime bundle
  • web/build-native.js - Native module compilation for pty.node

Configuration

  • mac/VibeTunnel/Core/Models/AppConstants.swift - Application constants
  • web/src/server/app.ts (lines 20-31) - Server configuration interface

Data Flow

Session Creation Flow

  1. Client request → POST /api/sessions (web/src/server/routes/sessions.ts:createSessionRoutes)
  2. TerminalManager.createTerminal() (web/src/server/services/terminal-manager.ts)
  3. PtyManager.spawn() (web/src/server/pty/pty-manager.ts) - Spawns native PTY process
  4. Session stored in manager, WebSocket upgrade prepared
  5. Response with sessionId (client connects to /ws and subscribes)

Terminal I/O Stream

  1. Client connects → WebSocket upgrade to /ws (v3 framing)
  2. Client subscribes → SUBSCRIBE(sessionId, flags) frames
  3. Client input/resize → INPUT_* / RESIZE frames
  4. Server routes frames → WsV3Hub (web/src/server/services/ws-v3-hub.ts)
  5. PTY output → cast tail (CastOutputHub) → STDOUT frames
  6. Server-side Ghostty → SNAPSHOT_VT frames for previews/resync
  7. Client renders:
    • interactive: ghostty-web (vibe-terminal)
    • previews: VT snapshots (vibe-terminal-buffer)

Buffer Optimization Protocol

  • WS v3 framing uses VT magic + version 3 (see docs/websocket.md)
  • Full VT snapshots (SNAPSHOT_VT) for previews/resync
  • Stdout streaming (STDOUT) for interactive terminals

Server Lifecycle Management

  1. ServerManager.start() (mac/VibeTunnel/Core/Services/ServerManager.swift)
  2. Creates BunServer instance
  3. BaseProcessServer.start() spawns server process
  4. Health checks via HTTP /health endpoint
  5. Log streaming through Process.standardOutput pipe
  6. Graceful shutdown on stop() with SIGTERM

Remote Access Architecture

  • NgrokService (mac/VibeTunnel/Core/Services/NgrokService.swift) - Secure tunnel creation
  • HQClient (web/src/server/services/hq-client.ts) - Headquarters mode for multi-server
  • RemoteRegistry (web/src/server/services/remote-registry.ts) - Remote server discovery

Authentication Flow

  • Basic Auth middleware (web/src/server/middleware/auth.ts)
  • Credentials stored in macOS Keychain via DashboardKeychain service
  • Optional password protection for network access

File: docs/auggie.md

Auggie CLI

VibeTunnel can monitor and control Augment Code's Auggie CLI like other terminal-based coding agents. Auggie sessions are recognized as AI assistant sessions in both the web dashboard and macOS menu, including the action that asks the agent to update its terminal title.

Install Auggie

Auggie requires Node.js 20 or later. Install the CLI from npm:

bash
npm install -g @augmentcode/auggie

Authenticate once:

bash
auggie login

See Augment's installation and authentication guides for current requirements.

Run with VibeTunnel

Start an interactive Auggie session through the vt wrapper:

bash
cd /path/to/project
vt auggie

You can also pass an initial prompt:

bash
vt auggie "Review the current changes"

The session then appears in VibeTunnel's web, macOS, and mobile clients. Terminal input, output, reconnection, and session controls work the same as for other interactive CLI tools.

Quick Start

auggie is included in the default Quick Start command list. Existing customized lists are preserved; add auggie in Settings > Quick Start or reset the list to defaults to expose the button.

Terminal Titles

For an active Auggie session, use the wand action in the web dashboard or macOS menu. VibeTunnel sends a prompt asking Auggie to run:

bash
vt title "Brief description of current task"

This replaces a generic process name with the agent's current task in session lists.


File: docs/authentication.md

VibeTunnel Authentication System

VibeTunnel supports multiple authentication modes to balance security and convenience for different use cases.

Authentication Modes

1. Default Mode (Password Authentication)

Usage: Start VibeTunnel without any auth flags

bash
npm run dev
# or
./vibetunnel

Behavior:

  • Shows login page with user avatar (on macOS)
  • Requires system user password authentication
  • Uses JWT tokens for session management
  • SSH key functionality is hidden

Best for: Personal use with secure password authentication

2. SSH Key Mode

Usage: Enable SSH key authentication alongside password

bash
npm run dev -- --enable-ssh-keys
# or
./vibetunnel --enable-ssh-keys

Behavior:

  • Shows login page with both password and SSH key options
  • Users can generate Ed25519 SSH keys in the browser
  • SSH keys are stored securely in browser localStorage
  • Optional password protection for private keys
  • SSH keys work for both web and terminal authentication

Best for: Power users who prefer SSH key authentication

3. SSH Keys Only Mode

Usage: Disable password authentication, SSH keys only

bash
./vibetunnel --disallow-user-password
# or
./vibetunnel --disallow-user-password --enable-ssh-keys  # redundant, auto-enabled

Behavior:

  • Shows login page with SSH key options only
  • Password authentication form is hidden
  • Automatically enables --enable-ssh-keys
  • User avatar still displayed with "SSH key authentication required" message
  • Most secure authentication mode

Best for: High-security environments, organizations requiring key-based auth

4. No Authentication Mode

Usage: Disable authentication completely

bash
npm run dev -- --no-auth
# or
./vibetunnel --no-auth

Behavior:

  • Bypasses login page entirely
  • Direct access to dashboard
  • No authentication required
  • Auto-logs in as current system user
  • Overrides all other auth flags

Best for: Local development, trusted networks, or demo environments

5. Tailscale Serve Integration Mode

Usage: Enable integrated Tailscale Serve support

bash
npm run dev -- --enable-tailscale-serve
# or
./vibetunnel --enable-tailscale-serve

Behavior:

  • Automatically starts tailscale serve as a background process
  • Forces server to bind to localhost (127.0.0.1) for security
  • Enables Tailscale identity header authentication
  • Provides HTTPS access without exposing ports
  • No manual Tailscale configuration required

macOS App Integration:

  • Toggle available in Settings → Remote Access → Tailscale Integration
  • Shows HTTPS URL in menu bar when enabled
  • Automatically manages the Tailscale Serve process lifecycle

Security Model:

  • Server only listens on localhost when enabled
  • All external access goes through Tailscale's secure proxy
  • Identity headers are automatically validated
  • No risk of header spoofing from external sources

Best for: Easy, secure remote access through Tailscale network

User Avatar System

macOS Integration

On macOS, VibeTunnel automatically displays the user's system profile picture:

  • Data Source: Uses dscl . -read /Users/$USER JPEGPhoto to extract avatar
  • Format: Converts hex data to base64 JPEG
  • Fallback: Uses Picture attribute if JPEGPhoto unavailable
  • Display: Shows in login form with welcome message

Other Platforms

On non-macOS systems:

  • Displays a generic SVG avatar icon
  • Maintains consistent UI layout
  • No system integration required

Command Line Options

Server Startup Flags

bash
# Authentication options
--enable-ssh-keys         Enable SSH key authentication UI and functionality
--disallow-user-password  Disable password auth, SSH keys only (auto-enables --enable-ssh-keys)
--no-auth                 Disable authentication (auto-login as current user)
--enable-tailscale-serve  Enable Tailscale Serve integration (auto-starts proxy, forces localhost)

# Other options
--port <number>       Server port (default: 4020)
--bind <address>      Bind address (default: 0.0.0.0)
--debug               Enable debug logging

Example Commands

bash
# Default password-only authentication
npm run dev

# Enable SSH keys alongside password
npm run dev -- --enable-ssh-keys

# SSH keys only (most secure)
./vibetunnel --disallow-user-password

# No authentication for local development (npm run dev uses this by default)
npm run dev -- --no-auth

# Production with SSH keys on custom port
./vibetunnel --enable-ssh-keys --port 8080

# High-security production (SSH keys only)
./vibetunnel --disallow-user-password --port 8080

# Tailscale Serve integration (secure remote access)
./vibetunnel --enable-tailscale-serve --port 4020
# No manual configuration needed - everything handled automatically

Security Considerations

Password Authentication

  • Uses system PAM authentication
  • Uses the configured VibeTunnel username and password instead when both VIBETUNNEL_USERNAME and VIBETUNNEL_PASSWORD are set
  • Sends the password to the VibeTunnel host over the current connection for verification
  • Does not persist the password
  • Use an encrypted connection; SSH key mode avoids sending a password through the browser
  • Otherwise validates against actual system user passwords
  • JWT tokens expire after 24 hours
  • Secure session management

SSH Key Authentication

  • Generates Ed25519 keys (most secure)
  • Private keys stored in browser localStorage
  • Optional password protection for private keys
  • Keys work for both web and terminal access
  • Challenge-response authentication flow

No Authentication Mode

  • ⚠️ Security Warning: Only use in trusted environments
  • Suitable for local development or demo purposes
  • Not recommended for production or public networks

Tailscale Authentication

  • ⚠️ Security Warning: Only use when bound to localhost
  • Requires Tailscale Serve proxy for header injection
  • Provides SSO-like experience for Tailscale users
  • Headers are trusted only from Tailscale proxy

Configuration API

Frontend Configuration Endpoint

The frontend can query the server's authentication configuration:

javascript
// GET /api/auth/config
{
  "enableSSHKeys": false,
  "disallowUserPassword": false,
  "noAuth": false
}

This allows the UI to:

  • Show/hide SSH key options dynamically
  • Hide password form when disallowed
  • Skip login page when no-auth is enabled
  • Adapt interface based on server configuration

SSH Key Management

Key Generation Process

  • Algorithm: Ed25519 (most secure and modern SSH key type)
  • Browser Implementation: Uses Web Crypto API for secure key generation
  • Storage: Browser localStorage (optionally encrypted with user password)
  • Format: PEM format for compatibility with standard SSH tools
  • Naming: User-defined names for organization

Detailed Process:

  1. Browser generates Ed25519 key pair using crypto.subtle.generateKey()
  2. Private key optionally encrypted with user-provided password
  3. Public key formatted in SSH wire format for server validation
  4. Keys stored in browser localStorage with unique identifiers

Key Import

  • Supports unencrypted Ed25519 keys in OpenSSH or PKCS#8 PEM format
  • Derives and validates the real public key before storing the private key
  • Rejects encrypted, malformed, truncated, or internally inconsistent keys with a clear error
  • Compatibility with keys generated by ssh-keygen -t ed25519

SSH Key Authentication Flow

Challenge-Response Process:

  1. Challenge Request: Client requests authentication challenge from /api/auth/challenge
  2. Challenge Generation: Server creates 32-byte random challenge with 5-minute expiry
  3. Key Selection: Client selects appropriate SSH key from browser storage
  4. Signature Creation: Browser signs challenge using private key via Web Crypto API
  5. Signature Submission: Client sends signed challenge to /api/auth/ssh-login
  6. Server Verification:
    • Server parses SSH public key wire format
    • Validates signature using Node.js crypto module
    • Checks public key against user's ~/.ssh/authorized_keys
    • Issues JWT token upon successful verification

Key Authorization:

  • Server reads ~/.ssh/authorized_keys file for target user
  • Validates submitted public key is present in authorized keys
  • Supports both current user and other system users
  • Handles standard SSH authorized_keys format

Key Setup Instructions

For Users:

  1. Generate SSH key in VibeTunnel web interface
  2. Download public key file
  3. Add to server's authorized_keys:
    bash
    # Append public key to authorized_keys
    cat vibetunnel-key.pub >> ~/.ssh/authorized_keys
    
    # Set proper permissions
    chmod 600 ~/.ssh/authorized_keys
    chmod 700 ~/.ssh
  4. Test authentication through VibeTunnel login

Security Best Practices:

  • Use password protection for private keys in shared environments
  • Regularly rotate SSH keys (recommended: every 90 days)
  • Remove unused keys from authorized_keys
  • Monitor authentication logs for suspicious activity

Tailscale Authentication Details

How Tailscale Serve Works

Tailscale Serve acts as a reverse proxy that:

  1. Receives requests from your tailnet
  2. Adds identity headers based on the authenticated Tailscale user
  3. Forwards requests to your local service

Identity Headers

When a request comes through Tailscale Serve, these headers are added:

  • tailscale-user-login: The user's email address or login
  • tailscale-user-name: The user's display name
  • tailscale-user-profile-pic: URL to the user's profile picture

Setup Instructions

  1. Start VibeTunnel with integrated Tailscale Serve:

    bash
    ./vibetunnel --enable-tailscale-serve --port 4020

    Or use the macOS app and enable the toggle in Settings → Remote Access

  2. Access via Tailscale:

    text
    https://[your-machine-name].[tailnet-name].ts.net

Security Model

  • VibeTunnel trusts identity headers ONLY from localhost connections
  • Tailscale Serve ensures headers cannot be spoofed by external users
  • Direct access to VibeTunnel port would allow header forgery
  • Always bind to 127.0.0.1 when using Tailscale authentication

Integration with Other Auth Modes

Tailscale Serve integration can be combined with other authentication modes:

bash
# Tailscale Serve + SSH keys as fallback
./vibetunnel --enable-tailscale-serve --enable-ssh-keys

# Tailscale Serve + local bypass for scripts
./vibetunnel --enable-tailscale-serve --allow-local-bypass

Note: The --enable-tailscale-serve flag automatically manages both the Tailscale proxy and authentication.

Implementation Details

Authentication Flow

  1. Server startup determines available auth modes
  2. Frontend queries /api/auth/config for configuration
  3. UI renders appropriate authentication options
  4. User authenticates via chosen method
  5. JWT token issued for session management
  6. Subsequent requests use Bearer token authentication

Avatar Implementation

bash
# macOS avatar extraction
dscl . -read /Users/$USER JPEGPhoto | tail -1 | xxd -r -p > avatar.jpg

# Server endpoint
GET /api/auth/avatar/:userId

File Structure

text
src/
├── server/
│   ├── middleware/auth.ts       # Authentication middleware
│   ├── routes/auth.ts          # Authentication routes
│   ├── services/auth-service.ts # JWT and user management
│   └── server.ts               # Server configuration
└── client/
    ├── components/auth-login.ts # Login UI component
    ├── services/auth-client.ts  # Frontend auth service
    └── services/ssh-agent.ts    # SSH key management

Troubleshooting

Common Issues

Login page shows briefly then disappears (no-auth mode)

  • This is expected behavior - the page quickly redirects to dashboard

SSH section not showing

  • Ensure server started with --enable-ssh-keys flag
  • Check browser console for configuration loading errors

Avatar not displaying

  • macOS only feature - other platforms show generic icon
  • Check user has profile picture set in System Preferences

Authentication fails

  • Verify system password is correct
  • Check server logs for detailed error messages
  • Ensure proper permissions for PAM authentication

Debug Mode

Enable debug logging for detailed authentication flow:

bash
npm run dev -- --debug --enable-ssh-keys

This provides verbose logging of:

  • Authentication attempts
  • Token validation
  • SSH key operations
  • Configuration loading

File: docs/build-system.md

Build System

VibeTunnel uses platform-specific build systems for each component: Xcode for macOS and iOS applications, pnpm for the web frontend, Cargo for the native forwarder, and Bun for creating standalone executables. The build system supports both development and release builds with comprehensive automation scripts for code signing, notarization, and distribution.

The main build orchestration happens through shell scripts in mac/scripts/ that coordinate building native applications, bundling the web frontend, and packaging everything together. Release builds include code signing, notarization, DMG creation, and automated GitHub releases with Sparkle update support.

Build Workflows

macOS Application Build

Development Build with Poltergeist (Recommended):

bash
# Start Poltergeist for automatic rebuilds
poltergeist
# Make changes - app rebuilds automatically

Development Build without Poltergeist:

bash
cd mac
xcodebuild -project VibeTunnel.xcodeproj -scheme VibeTunnel -configuration Debug build

Release Build - Full build with code signing:

bash
cd mac
./scripts/build.sh --configuration Release --sign

Key Script: mac/scripts/build.sh (lines 39-222)

  • Builds Bun executable from web frontend
  • Compiles macOS app using xcodebuild
  • Handles code signing if requested
  • Verifies version consistency with mac/VibeTunnel/version.xcconfig

Web Frontend Build

Development Mode - Watch mode with hot reload:

bash
cd web
pnpm run dev

Production Build - Optimized bundles:

bash
cd web
pnpm run build

Bun Executable - Standalone binary with native modules:

bash
cd web
node build-native.js

Key Files:

  • web/package.json - Build scripts and dependencies (lines 6-34)
  • web/build-native.js - Bun compilation and native module bundling (lines 83-135)

Native Forwarder Build

Rustup selects Rust 1.97.0 from native/vt-fwd/rust-toolchain.toml:

bash
cd native/vt-fwd
cargo build --release --locked

The binary is written to native/vt-fwd/target/release/vibetunnel-fwd. Web and macOS build scripts copy it into their packaged resources.

iOS Application Build

Generate Xcode Project - From project.yml:

bash
cd ios
xcodegen generate

Build via Xcode - Open ios/VibeTunnel.xcodeproj and build

Key File: ios/project.yml - XcodeGen configuration (lines 1-92)

Release Workflow

Complete Release - Build, sign, notarize, and publish:

bash
cd mac
./scripts/release.sh stable           # Stable release
./scripts/release.sh beta 1          # Beta release

Key Script: mac/scripts/release.sh (lines 1-100+)

  • Validates environment and dependencies
  • Builds with appropriate flags
  • Signs and notarizes app
  • Creates DMG
  • Publishes GitHub release
  • Updates Sparkle appcast

Platform Setup

macOS Requirements

Development Tools:

  • Xcode 16.0+ with command line tools
  • Node.js 22.12 through 24.x and pnpm
  • Bun runtime (installed via npm)
  • Rustup; the forwarder toolchain is pinned by native/vt-fwd/rust-toolchain.toml
  • xcbeautify (optional, for cleaner output)

Release Requirements:

  • Valid Apple Developer certificate
  • App Store Connect API keys for notarization
  • Sparkle EdDSA keys in mac/private/

Configuration Files:

  • apple/Local.xcconfig - Local development settings
  • mac/VibeTunnel/version.xcconfig - Version numbers
  • mac/Shared.xcconfig - Shared build settings

Web Frontend Requirements

Tools:

  • Node.js 22.12 through 24.x with npm
  • Bun runtime for standalone builds
  • Rustup for native forwarder builds

Native Modules:

  • @homebridge/node-pty-prebuilt-multiarch - Terminal emulation
  • Platform-specific binaries in web/native/:
    • pty.node - Native PTY module
    • spawn-helper - Process spawning helper
    • vibetunnel - Bun executable
    • vibetunnel-fwd - Rust terminal forwarder

Linux (Ubuntu) Requirements

Packages:

  • curl, ca-certificates, xz-utils, python3, make, g++, git
  • libpam0g-dev (authenticate-pam native module)

Tooling:

  • Node.js 22.12 through 24.x (NodeSource 24.x recommended)
  • Rust 1.97.0 (pinned by native/vt-fwd/rust-toolchain.toml)

Bootstrap:

bash
web/scripts/linux-bootstrap.sh

iOS Requirements

Tools:

  • Xcode 16.0+
  • XcodeGen (install via Homebrew)
  • iOS 18.0+ deployment target

Dependencies:

  • ghostty-web resources (JS + WASM) bundled in the iOS app

Reference

Build Targets

macOS Xcode Workspace (mac/VibeTunnel.xcworkspace):

  • VibeTunnel scheme - Main application
  • Debug configuration - Development builds
  • Release configuration - Distribution builds

Web Build Scripts (web/package.json):

  • dev - Development server with watchers
  • build - Production TypeScript compilation
  • bundle - Client-side asset bundling
  • typecheck - TypeScript validation
  • lint - ESLint code quality checks

Build Scripts

Core Build Scripts (mac/scripts/):

  • build.sh - Main build orchestrator
  • build-bun-executable.sh - Bun compilation (lines 31-92)
  • copy-bun-executable.sh - Bundle integration
  • codesign-app.sh - Code signing
  • notarize-app.sh - Apple notarization
  • create-dmg.sh - DMG packaging
  • generate-appcast.sh - Sparkle updates

Helper Scripts:

  • preflight-check.sh - Pre-build validation
  • version.sh - Version management
  • clean.sh - Build cleanup
  • verify-app.sh - Post-build verification

Troubleshooting

Common Issues:

  1. Bun build fails - Check web/build-native.js patches (lines 11-79)
  2. Code signing errors - Verify apple/Local.xcconfig settings
  3. Notarization fails - Check API keys in environment
  4. Version mismatch - Update mac/VibeTunnel/version.xcconfig

Build Artifacts:

  • macOS app: mac/build/Build/Products/Release/VibeTunnel.app
  • Web bundles: web/public/bundle/
  • Native executables: web/native/
  • iOS app: ios/build/

Clean Build:

bash
cd mac && ./scripts/clean.sh
cd ../web && npm run clean

File: docs/changelog-management.md

Changelog Management Guide

This guide explains how to maintain the CHANGELOG.md and GitHub releases for VibeTunnel.

Overview

We maintain a comprehensive changelog that:

  • Documents all user-facing changes
  • Credits contributors properly with GitHub links
  • Tracks first-time contributors for each release
  • Provides clear, user-friendly descriptions

Using the `/changelog` Command

The /changelog command in Claude Code analyzes git history to generate comprehensive changelogs:

bash
# In Claude Code, simply type:
/changelog

This command:

  • Analyzes commit history beyond just titles
  • Examines actual file changes to understand user impact
  • Groups changes by category (Features, Bug Fixes, Performance, etc.)
  • Writes user-focused descriptions (not developer jargon)

Changelog Format

Version Header

markdown
## [1.0.0-beta.13] - 2024-12-20

Categories

  • Features - New functionality
  • Improvements - Enhancements to existing features
  • Bug Fixes - Fixed issues
  • Performance - Speed/efficiency improvements
  • Developer Experience - Build, test, or development improvements

Attribution Format

Every change should credit its contributor:

markdown
- Added systemd service management for Linux deployments (via [@hewigovens](https://github.com/hewigovens)) (#419)

Format: - Description (via [@username](https://github.com/username)) (#PR)

First-time Contributors Section

For releases with new contributors:

markdown
### First-time Contributors
- [@hewigovens](https://github.com/hewigovens) - Added systemd service management for Linux (#419)

Identifying Contributors

Finding First-time Contributors

bash
# Get all contributors up to a specific release
git log --format="%an|%ae" v1.0.0-beta.12 | sort -u

# Get contributors for a specific release
git log --format="%an|%ae" v1.0.0-beta.12..v1.0.0-beta.13 | sort -u

# Compare to find first-timers

Mapping Changes to Contributors

bash
# Find who made specific changes
git log --oneline --author="username" v1.0.0-beta.12..v1.0.0-beta.13

# Get detailed commit info with files
git log --stat --author="username" v1.0.0-beta.12..v1.0.0-beta.13

Special Cases

Bot Contributors

Do not highlight bot contributors as first-time contributors or include them in the contributors list:

  • devin-ai-integration[bot]
  • blacksmith-sh[bot]
  • Other *[bot] accounts

Important: Bot contributions should be completely excluded from:

  • First-time contributors sections
  • The main contributors list at the end of CHANGELOG.md
  • GitHub release notes contributors sections

Bot changes can be mentioned in regular changelog entries (e.g., "Added SwiftLint hooks") but without attribution.

Core Team

Core team members (repository owners) don't need "(via @username)" attribution unless specifically requested.

Multiple Contributors

If multiple people worked on a feature:

markdown
- Feature description (via [@user1](https://github.com/user1), [@user2](https://github.com/user2)) (#123)

GitHub Releases

Creating a Release

  1. Generate changelog using /changelog command
  2. Review and edit the generated content
  3. Update CHANGELOG.md with the new version section
  4. Create GitHub release:
    bash
    gh release create v1.0.0-beta.14 \
      --title "v1.0.0-beta.14" \
      --notes-file release-notes.md \
      --prerelease

Release Notes Format

The GitHub release should include:

  1. Highlights - 2-3 major changes
  2. Full changelog - Copy from CHANGELOG.md
  3. First-time contributors - If applicable
  4. Installation instructions - Brief reminder

Example:

markdown
## Highlights
- 🐧 Linux systemd service support for production deployments
- 🔧 Improved authentication reliability
- 🚀 Better performance for large terminal outputs

## What's Changed
[Copy from CHANGELOG.md]

## First-time Contributors
- @hewigovens made their first contribution in #419

## Installation
See [installation instructions](https://github.com/vibetunnel/vibetunnel#installation)

Updating Existing Releases

To add first-time contributors to existing releases:

bash
# Edit a release
gh release edit v1.0.0-beta.13 --notes-file updated-notes.md

# Or use the GitHub web UI

Workflow Summary

  1. Before release: Run /changelog to analyze changes
  2. Review output: Ensure proper attribution and user-friendly descriptions
  3. Update CHANGELOG.md: Add new version section with proper formatting
  4. Create release notes: Include highlights and first-time contributors
  5. Create GitHub release: Use gh release create or web UI
  6. Verify: Check that all contributors are properly credited

Tips

  • Always verify contributor GitHub usernames for correct links
  • Use clear, non-technical language in descriptions
  • Include PR numbers for traceability
  • Group related changes together
  • Highlight breaking changes prominently
  • Credit everyone who contributed, no matter how small

Example Workflow

bash
# 1. Generate changelog
/changelog

# 2. Create release notes file
cat > release-notes.md << 'EOF'
## Highlights
- 🎯 Major feature one
- 🐛 Critical bug fix
- ⚡ Performance improvement

## What's Changed
[Paste from CHANGELOG.md]

## First-time Contributors
- @newcontributor made their first contribution in #123

## Installation
See [installation instructions](https://github.com/vibetunnel/vibetunnel#installation)
EOF

# 3. Create release
gh release create v1.0.0-beta.14 \
  --title "v1.0.0-beta.14" \
  --notes-file release-notes.md \
  --prerelease

# 4. Clean up
rm release-notes.md

File: docs/cjk-ime-input.md

VibeTunnel CJK IME Input Implementation

Overview

VibeTunnel provides comprehensive Chinese, Japanese, and Korean (CJK) Input Method Editor (IME) support across both desktop and mobile platforms. The implementation uses platform-specific approaches to ensure optimal user experience:

  • Desktop: Cursor-positioned text input with native browser IME integration
  • Mobile: Native virtual keyboard with direct input handling

Architecture

Core Components

text
SessionView
├── InputManager (Main input coordination layer)
│   ├── Platform detection (mobile vs desktop)
│   ├── DesktopIMEInput component integration (desktop only)
│   ├── Keyboard input handling
│   ├── WebSocket/HTTP input routing
│   └── Terminal cursor position access
├── DesktopIMEInput (Desktop-specific IME component)
│   ├── Cursor-positioned input element creation
│   ├── IME composition event handling
│   ├── Global paste handling
│   ├── Dynamic cursor positioning
│   └── Focus management
├── DirectKeyboardManager (Mobile input handling)
│   ├── Native virtual keyboard integration
│   ├── Direct input processing
│   └── Quick keys toolbar
├── LifecycleEventManager (Event interception & coordination)
└── Terminal Components (Cursor position providers)

Implementation Details

Cursor Position Tracking

File: cursor-position.ts

The cursor position tracking system uses renderer-specific cursor coordinates:

Coordinate System

typescript
export function calculateCursorPosition(
  cursorX: number, // 0-based column position
  cursorY: number, // 0-based row position
  fontSize: number, // Terminal font size in pixels
  container: Element, // Terminal container element
  sessionStatus: string, // Session status for validation
): { x: number; y: number } | null;

Position Calculation Process

  1. Character Measurement: Dynamically measures actual character width using font metrics
  2. Absolute Positioning: Calculates page-absolute cursor coordinates
  3. Container Relative: Converts to position relative to #session-terminal container
  4. IME Positioning: Returns coordinates suitable for IME input placement

Terminal Type Support

  • Ghostty Terminal (vibe-terminal): Uses the active buffer cursor and renderer cell metrics.
  • Buffer Terminal (vibe-terminal-buffer): Uses buffer.cursorX/Y from VT snapshot data.

Key Features

  • Precise Alignment: Accounts for exact character width and line height
  • Container Aware: Handles side panels and complex layouts
  • Font Responsive: Adapts to different font sizes and families
  • Platform Consistent: Same calculation logic across all terminal types

Error Handling

The function includes comprehensive error handling and graceful fallbacks:

  • Returns null when session is not running
  • Returns null when container element is not found
  • Returns null when character measurement fails
  • Falls back to absolute coordinates if session container is missing

Platform Detection

File: mobile-utils.ts

VibeTunnel automatically detects the platform and chooses the appropriate IME strategy:

typescript
export function detectMobile(): boolean {
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
    navigator.userAgent,
  );
}

Desktop Implementation

1. DesktopIMEInput Component

File: ime-input.ts

A dedicated component for desktop browsers that creates and manages a native text input:

  • Positioned dynamically at terminal cursor location
  • Uses a normal input size so browsers can anchor native candidate windows reliably
  • Transparent background and hidden caret keep the terminal visually unobstructed
  • Handles all CJK composition events through standard DOM APIs
  • Focuses when the user clicks the terminal, with short animation-frame retries for browser timing
  • Clean lifecycle management with proper cleanup

2. Desktop Input Manager Integration

File: input-manager.ts

The InputManager detects platform and creates the appropriate IME component:

typescript
private setupIMEInput(): void {
  // Skip IME input setup on mobile devices (they use native keyboard)
  if (detectMobile()) {
    logger.log('Skipping IME input setup on mobile device');
    return;
  }

  // Create desktop IME input component
  this.imeInput = new DesktopIMEInput({
    container: terminalContainer,
    onTextInput: (text: string) => this.sendInputText(text),
    onSpecialKey: (key: string) => this.sendInput(key),
    getCursorInfo: () => {
      const terminalElement = this.callbacks?.getTerminalElement?.();
      if (
        terminalElement &&
        'getCursorInfo' in terminalElement &&
        typeof terminalElement.getCursorInfo === 'function'
      ) {
        return terminalElement.getCursorInfo();
      }
      return null;
    }
  });
}

3. Desktop Focus Management

File: ime-input.ts

Desktop IME focus follows terminal clicks. The implementation avoids polling because repeatedly stealing focus interferes with native candidate selection:

typescript
focus(): void {
  this.updatePosition();
  this.input.focus();

  requestAnimationFrame(() => {
    if (document.activeElement !== this.input) {
      requestAnimationFrame(() => this.input.focus());
    }
  });
}

Mobile Implementation

1. Direct Keyboard Manager

File: direct-keyboard-manager.ts

Mobile devices use the native virtual keyboard with a visible input field:

  • Standard HTML input element (not hidden)
  • Native virtual keyboard with CJK support
  • Quick keys toolbar for common terminal operations
  • No special IME handling needed (OS provides it)

2. Mobile Input Flow

Files: session-view.ts, lifecycle-event-manager.ts

Mobile input handling follows a different flow:

  1. User taps terminal area
  2. Native virtual keyboard appears with CJK support
  3. User types or selects from IME candidates
  4. Input is sent directly to terminal
  5. No desktop composition bridge is needed

Platform Differences

Key Implementation Differences

Aspect Desktop Mobile
Input Element Cursor-positioned standard input Visible standard input field
IME Handling Custom composition events Native OS keyboard
Positioning Follows terminal cursor Fixed position or overlay
Focus Management Click focus with bounded retries Standard focus behavior
Keyboard Physical + software IME Virtual keyboard with IME
Integration Transparent terminal overlay Visible UI component
Performance Minimal overhead Standard input performance

Technical Architecture Differences

Desktop Implementation

typescript
// Creates a browser-compatible input at the terminal cursor
const input = document.createElement("input");
input.style.width = "200px";
input.style.height = "24px";
input.style.backgroundColor = "transparent";
input.style.caretColor = "transparent";

// Handles IME composition events
input.addEventListener("compositionstart", handleStart);
input.addEventListener("compositionend", handleEnd);

// Positions at terminal cursor
input.style.left = `${cursorX}px`;
input.style.top = `${cursorY}px`;

Mobile Implementation

typescript
// Uses DirectKeyboardManager with visible input
const input = document.createElement("input");
input.type = "text";
input.placeholder = "Type here...";
// Standard visible input - no special IME handling needed

// OS handles IME automatically through virtual keyboard
// No composition event handling required

User Experience Differences

Desktop Experience

  • Seamless: No visible UI changes
  • Cursor following: IME popup appears at terminal cursor
  • Click to focus: Click anywhere in terminal area
  • Traditional: Works like native terminal IME
  • Paste support: Global paste handling anywhere in terminal

Mobile Experience

  • Touch-first: Designed for finger interaction
  • Visible input: Clear indication of where to type
  • Quick keys: Easy access to terminal-specific keys
  • Gesture support: Touch gestures and haptic feedback
  • Keyboard management: Handles virtual keyboard show/hide

Platform-Specific Features

Desktop Features

  • Dynamic cursor positioning: IME popup follows terminal cursor exactly
  • Global paste handling: Paste works anywhere in terminal area
  • Composition state tracking: Via native KeyboardEvent.isComposing plus the data-ime-composing DOM attribute
  • Focus management: Click focus plus bounded browser-timing retries
  • Transparent integration: Native input text appears at the terminal cursor without a separate control
  • Performance optimized: Minimal resource usage when not composing

Mobile Features

  • Native virtual keyboard: Full OS-level CJK IME integration
  • Quick keys toolbar: Touch-friendly terminal keys (Tab, Esc, Ctrl, etc.)
  • Touch-optimized UI: Larger tap targets and touch gestures
  • Auto-capitalization control: Intelligently disabled for terminal accuracy
  • Viewport management: Graceful handling of keyboard show/hide animations
  • Direct input mode: Option to use hidden input for power users

User Experience

Desktop Workflow

text
User clicks terminal → Cursor-positioned input focuses → Types CJK →
Browser shows IME candidates → User selects → Text appears in terminal

Mobile Workflow

text
User taps terminal → Virtual keyboard appears → Types CJK →
OS shows IME candidates → User selects → Text appears in terminal

Visual Behavior

  • Desktop: Transparent input text and native IME popup at the terminal cursor
  • Mobile: Standard input field with native virtual keyboard
  • Both platforms: Seamless CJK text input with full IME support

Performance

Optimization Features

  • One input element and listener set per active desktop session
  • Dynamic positioning only calculated when needed
  • Minimal DOM footprint (single input element)
  • Clean event delegation and lifecycle management
  • Click-to-focus behavior without polling
  • Proper cleanup prevents memory leaks during session changes

Code Reference

Primary Files

  • ime-input.ts - Desktop input creation, composition events, positioning, focus, paste, and cleanup
  • input-manager.ts - Input coordination, desktop/mobile selection, terminal routing, and lifecycle
  • terminal.ts - Ghostty renderer cursor geometry used to anchor the native input
  • lifecycle-event-manager.ts - Keyboard interception that leaves active composition to the browser
  • direct-keyboard-manager.ts - Mobile keyboard handling
  • mobile-utils.ts - Mobile detection utilities

Supporting Files

  • session-view.ts - Container element and terminal integration
  • ime-constants.ts - IME-related key filtering utilities
  • terminal-constants.ts - Terminal element IDs, font settings, and IME positioning constants

Browser Compatibility

Works with all major browsers that support:

  • IME composition events (compositionstart, compositionupdate, compositionend)
  • Clipboard API for paste functionality
  • Standard DOM positioning APIs

The affected macOS boundary has live coverage with Chrome and Safari using Chinese Pinyin. Automated coverage verifies the composition lifecycle and cursor anchoring independently of the operating-system candidate UI.

Configuration

Automatic Platform Detection

CJK IME support is automatically configured based on the detected platform:

  • Desktop: Cursor-positioned IME input with native candidate-window anchoring
  • Mobile: Native virtual keyboard with OS IME

Requirements

  1. User has CJK input method enabled in their OS
  2. Desktop: User clicks in terminal area to focus
  3. Mobile: User taps terminal or input field
  4. User switches to CJK input mode in their OS

Troubleshooting

Common Issues

  • IME candidates not showing: Ensure browser supports composition events
  • Text not appearing: Check if terminal session is active and receiving input
  • Paste not working: Verify clipboard permissions in browser

Debug Information

Comprehensive logging available in browser console:

  • 🔍 Setting up IME input on desktop device - Platform detection
  • [ime-input] - Desktop IME component events
  • [direct-keyboard-manager] - Mobile keyboard events
  • State tracking through DOM attributes:
    • data-ime-composing - IME composition active (desktop)
    • data-ime-input-focused - IME input has focus (desktop)
  • Mobile detection logs showing user agent analysis

Recent Improvements (v1.0.0-beta.16+)

Unified Cursor Position Tracking

  • Shared Utility: Created cursor-position.ts for consistent cursor calculation across all terminal types
  • Container-Aware Positioning: Fixed IME positioning issues with side panels and complex layouts
  • Precise Alignment: Improved character width measurement for pixel-perfect cursor alignment
  • Debug Logging: Enhanced debug output with comprehensive coordinate information

Technical Improvements

  • Code Deduplication: Eliminated ~120 lines of duplicate cursor calculation code
  • Maintainability: Single source of truth for cursor positioning logic
  • Type Safety: Improved TypeScript interfaces and error handling
  • Performance: More efficient coordinate conversion with optimized calculations

Element ID Centralization

  • Constants File: Created terminal-constants.ts to centralize all critical terminal element IDs
  • Prevention of Breakage: Changes to IDs like session-terminal, buffer-container, or terminal-container now only require updates in one location
  • Consistent References: All components now import TERMINAL_IDS constants instead of using hardcoded strings
  • Type Safety: Constants are strongly typed to prevent typos and ensure consistent usage across the codebase

Status: ✅ Production Ready
Platforms: Desktop (Windows, macOS, Linux) and Mobile (iOS, Android)
Version: VibeTunnel Web v1.0.0-beta.16+
Last Updated: 2026-06-13