File: README.md
VibeTunnel 🚇 - Turn any browser into your terminal; command agents from the road (vt.sh)
Table of Contents
- Why VibeTunnel?
- Installation Options
- Quick Start
- Features
- Architecture
- Remote Access Options
- Git Follow Mode
- Terminal Title Management
- Authentication
- npm Package
- Building from Source
- Development
- Poltergeist Integration
- Documentation
- macOS Permissions
- Contributing
- Support VibeTunnel
- Credits
- License
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
macOS App (Recommended for Mac users)
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:
npm install -g vibetunnelThis 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
brew install --cask vibetunnel2. 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:
vtis a bash script that internally callsvibetunnel fwdto 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
vtwrapper script at/usr/local/bin/vtduring installation - npm Package: Installs
vtglobally, with intelligent Mac app detection
Smart detection:
When you run vt from the npm package, it:
- Checks if the Mac app is installed at
/Applications/VibeTunnel.app - If found, forwards to the Mac app's
vtfor the best experience - If not found, uses the npm-installed
vibetunnel fwd - This ensures
vtalways uses the best available implementation
# 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 belowGit 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:
- macOS Menu Bar App - Native Swift application that manages the server lifecycle
- Node.js Server - High-performance TypeScript server handling terminal sessions
- 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
Option 1: Tailscale (Recommended)
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
- Install Tailscale on your Mac: Download from Mac App Store, Direct Download, or a CLI/daemon package such as nix-darwin
- Install Tailscale on your remote device:
- iOS: Download from App Store
- Android: Download from Google Play
- Other platforms: All Downloads
- Sign in to both devices with the same account
- If using VibeTunnel's Tailscale Serve integration, ensure Tailscale Serve is enabled in your tailnet settings
- Find your Mac's Tailscale hostname in the menu bar app or with
tailscale status(e.g.,my-mac.tailnet-name.ts.net) - 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:
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"], }, ],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:
- Create a free ngrok account: Sign up for ngrok
- Copy your auth token from the ngrok dashboard
- Add the token in VibeTunnel settings (Settings → Remote Access → ngrok)
- Enable ngrok tunneling in VibeTunnel
- Share the generated
https://[random].ngrok-free.appURL
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
- Configure authentication (see Authentication section)
- Switch to "Network" mode
- Access via
http://[your-mac-ip]:4020
Option 4: Cloudflare Quick Tunnel
- Install cloudflared
- Run
cloudflared tunnel --url http://localhost:4020 - Access via the generated
*.trycloudflare.comURL
Option 5: Pinggy
- Install the Pinggy CLI:
npm install pinggy - Open VibeTunnel Settings → Remote → Pinggy Integration
- Optionally save a Pinggy access token for a persistent subdomain or custom domain
- 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
- Create a Site in Pangolin and copy its Newt endpoint, ID, and secret
- Install Newt:
curl -fsSL https://static.pangolin.net/get-newt.sh | bash - Open VibeTunnel Settings → Remote → Pangolin Integration and save the site credentials
- In Pangolin, create a public Resource targeting
http://localhost:4020 - 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
# 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 unfollowHow It Works
- Git Hooks: VibeTunnel installs lightweight Git hooks (post-commit, post-checkout) in worktrees that detect branch changes
- Main Repo Sync: When you switch branches in a worktree, the main repository automatically checks out to the same branch
- Smart Handling: If the main repo has uncommitted changes, follow mode pauses to prevent data loss
- Development Continuity: Your IDE, servers, and tools running on the main repo seamlessly follow your active work
- Clean Uninstall: When you run
vt unfollow, Git hooks are automatically removed and any original hooks are restored
Common Workflows
Agent Development with Worktrees
# 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 interruptionTechnical Details
Follow mode stores the worktree path in your main repository's Git config:
# 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 recommendedFor 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
- Example:
- 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:
export VIBETUNNEL_USERNAME=admin
export VIBETUNNEL_PASSWORD=your-secure-password
npm run startJWT 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:
# 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-password4. No Authentication
For trusted environments only:
npm run start -- --no-auth5. Local Bypass (Development Only)
Allow localhost connections to bypass authentication:
# 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 mytokenSecurity 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
- Always use authentication when binding to network interfaces (
--bind 0.0.0.0) - Use HTTPS in production with a reverse proxy (nginx, Caddy)
- Rotate credentials regularly
- Consider SSH keys for stronger security
- Never use local bypass without tokens in production environments
- Monitor access logs for suspicious authentication patterns
- 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
# Install globally via npm
npm install -g vibetunnel
# Or with yarn
yarn global add vibetunnel
# Or with pnpm
pnpm add -g vibetunnelRequirements: Node.js 22.12 through 24.x
One-shot (no install)
# 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 testRunning the VibeTunnel Server
Basic Usage
# 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-authUsing the `vt` Command
The vt command wrapper makes it easy to forward terminal sessions:
# 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
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 applicationsstatic- 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
# 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 shellHow It Works
- Command Resolution: The
vtwrapper first checks if your command is an alias, shell function, or binary - Session Creation: It creates a new VibeTunnel session with a unique ID
- PTY Allocation: A pseudo-terminal is allocated to preserve terminal features (colors, cursor control, etc.)
- I/O Forwarding: All input/output is forwarded between your terminal and the browser in real-time
- 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 toVIBETUNNEL_LOG_LEVEL=debug
Special Features
Shell Alias Support: Your shell aliases and functions work transparently through vt:
alias gs='git status'
vt gs # Works as expectedSession Title Updates: Inside a VibeTunnel session, use vt title to update the session name:
vt title "Building Production Release"Mac App Interoperability
The npm package is designed to work seamlessly alongside the Mac app:
Smart Command Routing
- The
vtcommand 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/vtalready exists (from another tool), npm won't overwrite it - You'll see a helpful warning with alternatives:
vibetunnelornpx vt - The installation always succeeds, even if the
vtsymlink 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 (
vibetunnelandvtcommands) - 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)
# Build with prebuilt binaries for all platforms
# Requires Docker for Linux cross-compilation
npm run build:npmThis creates prebuilt binaries for:
- macOS (x64, arm64) - Node.js 22, 23, 24
- Linux (x64, arm64) - Node.js 22, 23, 24
Build Options
# 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-dockerPublishing
# Test the package locally
npm pack
# Publish to npm
npm publishBuilding 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.tomlinstalls the pinned Rust toolchain for the native forwarder
Build Steps
# 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 ReleaseCustom Node.js Builds
VibeTunnel supports building with a custom Node.js for a 46% smaller executable (61MB vs 107MB):
# 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 = YESSee 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:
# 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.shCoverage 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
cd web
pnpm run devWhat 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:4020and 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
Run the dev server with network access:
bashcd web pnpm run dev --port 4021 --bind 0.0.0.0This binds to all network interfaces, making it accessible from other devices.
Find your Mac's IP address:
- System Preferences → Network → Wi-Fi → Details
- Or run:
ipconfig getifaddr en0
Access from your external device:
texthttp://[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:
- Press the paste button on the keyboard toolbar
- A white input box will appear
- Long-press inside the white box to bring up the paste menu
- Select "Paste" from the menu
- 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:
- Open VibeTunnel Settings → Debug tab (enable Debug Mode first in General settings)
- Enable "Use Development Server"
- Set the path to your
web/directory - Restart the VibeTunnel server
How it works:
- Instead of using the bundled production server, the Mac app runs
pnpm run devin 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:
- Build the web project:
cd web && pnpm run build - In VibeTunnel settings, set Dashboard Access to "Network"
- 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:
# Enable debug mode
export VIBETUNNEL_DEBUG=1
# Or use inline
VIBETUNNEL_DEBUG=1 vt your-commandDebug 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:
# Enable DerivedData preference
export VIBETUNNEL_PREFER_DERIVED_DATA=1
# vt will now search for and use the latest VibeTunnel build from DerivedData
vt your-commandWhen this environment variable is set, vt will:
- First search for VibeTunnel builds in
~/Library/Developer/Xcode/DerivedData - Use the most recently modified build found there
- Fall back to
/Applications/VibeTunnel.appif no DerivedData build exists - 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.jsonfor each session - Displayed in
vt statusoutput - Shown in the initial log output when
VIBETUNNEL_PREFER_DERIVED_DATAis set
Verbosity Control
Control the amount of output from VibeTunnel commands:
# 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 testNote: 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
Install Poltergeist (if not already installed):
bashnpm install -g poltergeistStart Poltergeist in the VibeTunnel directory:
bashcd /path/to/vibetunnel poltergeistMake changes - Poltergeist will automatically rebuild when it detects changes to:
- Swift files in
mac/orios/ - Xcode project files
- Configuration files
- Swift files in
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
- Keyboard Shortcuts - Complete keyboard shortcut reference
- Quick Reference Card - Printable shortcuts cheat sheet
- Technical Specification - Detailed architecture and implementation
- Contributing Guide - Development setup and guidelines
- Architecture - System design overview
- Build System - Build process details
- Push Notifications - How web push notifications work
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:
# 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 AppleEventsLogging 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 subsystemsh.vibetunnel.vibetunnel.debug- Debug builds of the macOS app
Testing:
sh.vibetunnel.vibetunnel.tests- macOS test suitesh.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:
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 → ProfilesUse 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 ServerManagerConfigure 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 -pEnable 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
- Join Discord: Start by joining our Discord server to say hello!
- Check Issues: Look for issues labeled
good first issueorhelp wanted - Development Setup: Follow our Contributing Guide for detailed setup instructions
- 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:
- @badlogic - Mario Zechner
- @mitsuhiko - Armin Ronacher
- @steipete - Peter Steinberger
- @hjanuschka - Helmut Januschka
- @manuelmaly - Manuel Maly
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.tsorxcodebuild 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,
.jsimports, JSDoc, singleton exports - Swift: PascalCase types, camelCase properties/methods,
// MARK: -sections,@Observablemodels,@MainActorUI, 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
macOS App with Poltergeist (Recommended if installed)
If Poltergeist is installed, it will automatically rebuild the app when you make changes:
# 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 statusmacOS App without Poltergeist (Fallback)
If Poltergeist is not available, use direct Xcode builds:
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 signingiOS App
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
cd web
pnpm install # Install dependencies
pnpm run build # Production build
pnpm run dev # Development server with hot reloadCode Quality Commands
Web (MUST run before committing)
cd web
pnpm run check # Run all checks in parallel (format, lint, typecheck)
pnpm run check:fix # Auto-fix formatting and linting issuesmacOS
cd mac
./scripts/lint.sh # Run SwiftFormatiOS
cd ios
./scripts/lint.sh # Run SwiftFormatTesting Commands
Web Tests
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 testsmacOS Tests
# MUST use xcodebuild, NOT swift test!
cd mac
xcodebuild test -project VibeTunnel.xcodeproj -scheme VibeTunnel -destination 'platform=macOS'iOS Tests
cd ios
./scripts/test-with-coverage.sh # Run with automatic simulator selectionDebugging and Logs
# 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
┌─────────────────────────────────────────────────────────────┐
│ 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
- Session Creation: Client → POST /api/sessions → Server spawns PTY → Returns session ID
- Terminal I/O: WebSocket at /api/sessions/:id/ws for bidirectional communication
- Buffer Protocol: Binary messages with magic byte 0xBF for efficient terminal updates
- 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
- Mac app:
Configuration:
- Mac version:
mac/VibeTunnel/version.xcconfig - Web version:
web/package.json - Build settings:
mac/VibeTunnel/Shared.xcconfig
- Mac version:
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
- PTY spawning:
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.
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
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
ABSOLUTELY FORBIDDEN: NEVER USE
git rebase --skipEVER- This command can cause data loss and repository corruption
- If you encounter rebase conflicts, ask the user for help
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
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 devinstead 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
- Production Mode: Mac app embeds a pre-built web server during Xcode build
Never kill all sessions
- You are running inside a session yourself; killing all sessions would terminate your own process
NEVER rename docs.json to mint.json
- The Mintlify configuration file is called
docs.jsonin 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
- The Mintlify configuration file is called
Test Session Management - CRITICAL
- NEVER kill sessions that weren't created by tests
- You might be running inside a VibeTunnel session yourself
- Use
TestSessionTrackerto 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 branchbefore 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 mainwhile 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
vtcommand 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:
# Run dev server accessible from external devices
cd web
pnpm run dev --port 4021 --bind 0.0.0.0Then 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:
Task(description="Fix Mac build errors", prompt="/fixmac", subagent_type="general-purpose")The agent will:
- Use XcodeBuildMCP tools to identify build errors and warnings
- Fix compilation issues in the Mac codebase
- Address SwiftFormat violations
- Resolve any warning messages
- 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
# Check if Poltergeist is installed
which poltergeist
# Check if Poltergeist is already running for this project
ps aux | grep poltergeist | grep -v grepUsing Poltergeist for Development
If Poltergeist is installed:
Start Poltergeist in the project root:
bashcd /path/to/vibetunnel poltergeist hauntMonitor build status via the Poltergeist menu bar app (macOS) or terminal output:
bashpoltergeist statusMake changes - Poltergeist will automatically rebuild when it detects changes to:
- Swift files in
mac/ - Xcode project files
- Configuration files
- Swift files in
Run the app with fresh builds using
polter:bashpolter vibetunnel # Waits for build to complete, then runs
Fallback Without Poltergeist
If Poltergeist is not available, fall back to direct Xcode builds:
# Debug build
cd mac
xcodebuild -project VibeTunnel.xcodeproj -scheme VibeTunnel -configuration Debug build
# Release build
./scripts/build.shPoltergeist 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)
# OLD syntax (deprecated):
tailscale serve https / http://localhost:4020
# NEW syntax:
tailscale serve --bg http://localhost:4020The --bg flag runs the serve configuration in background mode. The process exits immediately after configuration.
Tailscale Funnel (Public internet access)
# Reset any existing configuration first
tailscale funnel reset
# Enable funnel (still uses --bg flag)
tailscale funnel --bg 443Important Notes:
- The
tailscale serve --bgcommand 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:
- Read
claude.md: Before performing any task, I must first read theclaude.mdfile located in the current working directory. - Contextual Awareness: If the current working directory is a subfolder, I must read all
claude.mdfiles 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
- Overview - Core concepts, platform availability
- AppKit Implementation - NSGlassEffectView usage
- SwiftUI Implementation - glassEffect() modifier
- Common Patterns - Reusable components & techniques
Toolbar Features
- SwiftUI Toolbar Features - Customization, search, transitions
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
Need glass effect?
├─ Single view → .glassEffect() / NSGlassEffectView
└─ Multiple views
├─ Need merging → Use Container
└─ Independent → Individual glass effectsChoosing Framework
Platform target?
├─ iOS only → SwiftUI
├─ macOS only
│ ├─ Need AppKit integration → NSGlassEffectView
│ └─ Pure SwiftUI app → .glassEffect()
└─ Cross-platform → SwiftUI with platform checksPerformance 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
- SwiftUI:
Button("Click").buttonStyle(.glass) - AppKit: See Glass Button Pattern
Animate Glass Tint
- SwiftUI:
.glassEffect(.regular.tint(condition ? .blue : .clear)) - AppKit:
NSAnimationContextwithanimator().tintColor
Merge Glass Effects
- SwiftUI: Wrap in
GlassEffectContainer(spacing: 40) - AppKit: Use
NSGlassEffectContainerViewwith spacing
Custom Toolbar
- SwiftUI:
.toolbar(id: "main")withToolbarItem(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
// 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
// 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
Quick Fix: Configuration Profile (Recommended)
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
- Double-click
apple/logging/VibeTunnel-Logging.mobileconfig - System Settings will open to the Profiles section
- Click "Install..." and enter your password
- Restart VibeTunnel
iOS
- AirDrop or email the profile to your device
- Open Settings → General → VPN & Device Management
- Install the "VibeTunnel Debug Logging" profile
- Restart the VibeTunnel app
Verifying It Works
# You should now see full details instead of <private>
./scripts/vtlog.shRemoving 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:
2025-07-05 08:40:08.062262+0100 VibeTunnel: Failed to connect to <private> after <private> secondsThis 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
sudo visudoStep 2: Add the NOPASSWD rule
Add this line at the end of the file (replace yourusername with your actual username):
yourusername ALL=(ALL) NOPASSWD: /usr/bin/logFor example, if your username is steipete:
steipete ALL=(ALL) NOPASSWD: /usr/bin/logStep 3: Save and exit
- Press
Escto enter command mode - Type
:wqand press Enter to save and quit - The changes take effect immediately
Step 4: Test it
# 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 -pHow It Works
Normal log viewing (redacted):
bashlog show --predicate 'subsystem == "sh.vibetunnel.vibetunnel"' # Shows: Connected to <private>With sudo and --info flag (reveals private data):
bashsudo log show --predicate 'subsystem == "sh.vibetunnel.vibetunnel"' --info # Shows: Connected to session-123abcvtlog.sh -p flag automatically:
- Adds
sudoto the command - Adds
--infoflag to reveal private data - With our sudoers rule, no password needed!
- Adds
Security Considerations
What this allows:
- ✅ Passwordless access to
logcommand 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:
- Only grant this permission to trusted developer accounts
- Use the most restrictive rule possible
- Consider removing when not actively debugging
- 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:
sudo vi /etc/pam.d/sudoAdd this line at the top (after the comment):
auth sufficient pam_tid.soNow you can use your fingerprint instead of typing password.
2. Extend sudo timeout
Make sudo remember your password longer:
sudo visudoAdd:
Defaults timestamp_timeout=60This keeps sudo active for 60 minutes after each use.
3. Fix in Swift code
Mark non-sensitive values as public in your Swift logging:
// 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)
Method A: Plist File (Recommended)
Create a plist file to enable private data logging for VibeTunnel:
# 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.plistTo remove:
sudo rm /Library/Preferences/Logging/Subsystems/sh.vibetunnel.vibetunnel.plistMethod B: Configuration Profile
For managed environments or multiple subsystems, create a configuration profile:
<?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:
# 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" -fTroubleshooting
"sudo: a password is required"
- Make sure you saved the sudoers file (
:wqin vi) - Try in a new terminal window
- Run
sudo -kto clear sudo cache, then try again - Verify the line exists:
sudo grep NOPASSWD /etc/sudoers
"syntax error" when saving sudoers
- Never edit
/etc/sudoersdirectly! - 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/logexists: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
-pflag:./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/awaitas the default for asynchronous operations - Leverage
.taskmodifier 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
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1
}
}
}
}Shared State with @Observable
@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
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
- Introduction
- Core Concepts
- Swift Evolution Proposals
- Data Race Safety
- Actor System
- Sendable Protocol
- Migration Guide
- Code Examples
- Best Practices
- Testing and Debugging
- Common Compiler Errors
- Performance Tuning
- Swift 6.1+ Roadmap
- 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:
Build Settings → Swift Compiler - Language → Strict Concurrency Checking → CompleteCommand Line:
swiftc -strict-concurrency=complete -swift-version 6 MyFile.swiftPackage.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
- Compile-time Safety: Catch data races before runtime
- Progressive Migration: Adopt incrementally with warnings first
- Better Performance: Compiler optimizations enabled by isolation guarantees
- Clearer Intent: Explicit concurrency boundaries in code
Core Concepts
1. Isolation Domains
Swift 6 defines clear isolation domains where data can be safely accessed:
// 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 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:
// 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:
// 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:
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:
@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:
// Package.swift
.target(
name: "MyTarget",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableUpcomingFeature("CompleteAsync"),
.enableExperimentalFeature("StrictConcurrency=minimal")
]
)
// Or via compiler flag
// swiftc -strict-concurrency=completeSE-0401: Remove Actor Isolation Inference from Property Wrappers
Eliminates unexpected isolation:
// 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 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:
// 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:
// 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:
// '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:
// 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:
// 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:
// 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 checkingCommon Data Race Patterns and Fixes
Pattern 1: Shared Mutable State
// ❌ 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
// ❌ 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
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
// 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
// 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
// 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
// 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
// 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
// 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
// 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 = completeStep 2: Fix Global Variables
// 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
// 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
// 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
// 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
- Start with leaf modules: Begin with modules that have few dependencies
- Fix simple issues first: Immutable globals, missing Sendable conformances
- Isolate UI layer: Add @MainActor to view controllers and view models
- Address shared state: Convert to actors or use synchronization
- Enable Swift 6 mode: Once warnings are resolved
Code Examples
Example 1: Image Processing Pipeline
// 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
// 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
// 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
// ❌ 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
// ❌ 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
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
// ✅ 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
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:
# Enable in Xcode
Product → Scheme → Edit Scheme → Diagnostics → Thread Sanitizer
# Or via command line
swift test -Xswiftc -sanitize=threadWhen 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:
- Task Tree View: Visualize parent-child task relationships
- Actor Memory Graph: See actor isolation boundaries
- Hop Tracking: Follow execution across isolation domains
// 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
// 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
// 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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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
// 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)
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
// 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
- SE-0449: Allow nonisolated to prevent global actor inference
- SE-0450: Limiting actor isolation inference
- SE-0451: Isolated synchronous deinit
- Typed Throws in Concurrency: Better error propagation
- 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
- SE-0401: Remove Actor Isolation Inference
- SE-0414: Region-based Isolation
- SE-0420: Inheritance of Actor Isolation
- SE-0430: Sending Parameter Values
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
- Swift Forums - Concurrency
- Concurrency Index Thread
- Swift Package Index - Shows "Safe from data races" badge
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-testingSPM package to yourPackage.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 Testingwill 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:
@Test("User count meets minimum requirement")
func testUserCount() {
let userCount = 5
// This check will fail
#expect(userCount > 10)
}Failure Output in Xcode:
▽ Expected expression to be true
#expect(userCount > 10)
| | |
5 | 10
falsePower 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
// 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
@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
XCTUnwrapcalls totry #require(). This is a direct and superior replacement. - Convert most
XCTAssertcalls 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
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)
@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
XCTestCasetostructs (preferred for automatic state isolation) orfinal classes. - Move
setUpWithErrorlogic into the suite'sinit(). - Move
tearDownWithErrorlogic into the suite'sdeinit(and use aclassoractorif needed). - Define the SUT and its dependencies as
letproperties, initialized ininit().
**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
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
@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.
- 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 } - 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
asyncand useawait. - 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 youawaitto pause the test until your confirmations are fulfilled or a timeout is reached.
@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.
@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.
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@Testor@Suiteto 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.
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).
- The Pitfall: Using
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.
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.
- The Pitfall: Passing multiple collections to a parameterized test without
Ignoring the
.serializedTrait 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
.serializedtrait 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
XCTMetricandmeasure { ... }) - 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
@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
@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
@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
@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
/// 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
// 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:
// 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
# 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 .gitRepository13.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
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 = myContentViewInteractive Glass
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
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
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
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
// 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
contentViewguaranteed 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
NSAnimationContextfor smooth transitions - Standard duration: 0.2-0.5 seconds
- Animate
tintColorfor 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 appearancetintColor/.tint(): Color overlayspacing: Merge distance threshold
Quick Start
SwiftUI
Text("Hello").glassEffect()
Button("Click").buttonStyle(.glass)AppKit
let glass = NSGlassEffectView()
glass.cornerRadius = 12.0
glass.contentView = myViewPerformance Guidelines
- Use containers for multiple glass views
- Limit total glass effects on screen
- Consider GPU impact on older devices
- Batch similar effects together
Next Steps
File: apple/docs/liquid-glass/patterns.md
Liquid Glass Common Patterns
Cross-Platform Patterns
State-Based Glass
SwiftUI
.glassEffect(.regular.tint(isActive ? .blue : .clear))AppKit
glassView.tintColor = isActive ? NSColor.systemBlue.withAlphaComponent(0.3) : nilHover Effects
SwiftUI
@State private var isHovered = false
Text("Hover")
.glassEffect(.regular.tint(isHovered ? .blue : .clear))
.onHover { isHovered = $0 }AppKit
override func mouseEntered(with: NSEvent) {
animator().tintColor = NSColor.systemBlue.withAlphaComponent(0.2)
}Animated Transitions
SwiftUI
.animation(.spring(duration: 0.3), value: glassState)AppKit
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
// animations
}UI Component Patterns
Glass Card
// 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
// SwiftUI
struct GlassBadge: View {
let count: Int
var body: some View {
Text("\(count)")
.padding(.horizontal, 8)
.glassEffect(.regular.tint(.red))
}
}Glass Toolbar
// SwiftUI
.toolbar {
ToolbarItemGroup {
Button("One") { }.buttonStyle(.glass)
Button("Two") { }.buttonStyle(.glass)
}
}Layout Patterns
Grid of Glass Items
// SwiftUI
GlassEffectContainer(spacing: 20) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(items) { item in
ItemView(item).glassEffect()
}
}
}Merging Glass Groups
// 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
// 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
// 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
// SwiftUI
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemView(item)
.glassEffect(isEnabled: item.isVisible)
}
}
}Batch Processing
// AppKit
let container = NSGlassEffectContainerView()
container.spacing = 20
// Add all glass views to container's contentViewDecision 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
- Merge Control: Adjust container spacing to control merge distance
- State Changes: Use tint color for visual feedback
- Touch Feedback: Enable
.interactive()for user interaction - Performance: Disable glass when off-screen
- 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
Text("Hello")
.padding()
.glassEffect() // Default: regular glass, capsule shapeCustom Shape & Tint
Image(systemName: "star")
.glassEffect(
.regular.tint(.blue).interactive(),
in: .rect(cornerRadius: 12)
)Glass Variants
Configuration Options
// 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
GlassEffectContainer(spacing: 40) {
HStack(spacing: 40) {
ForEach(items) { item in
ItemView(item)
.glassEffect()
}
}
}Union Effect
@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
@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
// Standard glass button
Button("Action") { }
.buttonStyle(.glass)
// Prominent glass button
Button("Important") { }
.buttonStyle(.glassProminent)Toolbar Integration
Glass in Toolbar
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Save") { }
.buttonStyle(.glass)
}
}Custom Toolbar Background
.toolbar {
ToolbarItem(placement: .principal) {
Text("Title")
.sharedBackgroundVisibility(.hidden)
}
}Advanced Techniques
Conditional Glass
@State private var glassEnabled = true
Text("Dynamic")
.glassEffect(isEnabled: glassEnabled)Navigation Transitions
@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
- Container Usage: Always wrap multiple glass views
- Spacing: Smaller = merge closer, larger = merge farther
- Limit Count: 5-10 glass effects maximum
- Disable When Hidden: Use
isEnabledparameter
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
.toolbar(id: "main") {
ToolbarItem(id: "save") { SaveButton() }
ToolbarItem(id: "share") { ShareButton() }
ToolbarSpacer(.flexible)
ToolbarItem(id: "more") { MoreButton() }
}Spacer Types
ToolbarSpacer(.fixed) // Fixed width
ToolbarSpacer(.flexible) // Pushes items apartSearch Integration
Minimize Behavior
@State private var searchText = ""
NavigationStack {
ContentView()
.searchable($searchText)
.searchToolbarBehavior(.minimize) // Compact search button
}Repositioning Search
.toolbar {
ToolbarItem(placement: .bottomBar) { Button1() }
DefaultToolbarItem(kind: .search, placement: .bottomBar)
ToolbarItem(placement: .bottomBar) { Button2() }
}Placement Options
Common Placements
.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
NavigationStack {
Content()
.navigationTitle("Title")
.navigationSubtitle("Subtitle")
.toolbar {
ToolbarItem(placement: .largeSubtitle) {
// Overrides navigationSubtitle
CustomSubtitleView()
}
}
}Visual Effects
Matched Transitions
@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
.toolbar(id: "main") {
ToolbarItem(id: "status", placement: .principal) {
StatusView()
.sharedBackgroundVisibility(.hidden) // No glass background
}
}System Items
Default Items
.toolbar {
// Reposition system search
DefaultToolbarItem(kind: .search, placement: .bottomBar)
// Sidebar toggle
DefaultToolbarItem(kind: .sidebar, placement: .navigationBarLeading)
}Platform Considerations
iOS/iPadOS
#if os(iOS)
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
// Bottom bar items for iPhone
}
}
#endifmacOS
#if os(macOS)
.toolbar {
ToolbarItem(placement: .automatic) {
// macOS toolbar items
}
}
#endifCommon Patterns
Dynamic Toolbar
@State private var isEditing = false
.toolbar {
if isEditing {
ToolbarItem(id: "done") { DoneButton() }
} else {
ToolbarItem(id: "edit") { EditButton() }
}
}Grouped Actions
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button("One") { }
Button("Two") { }
Button("Three") { }
}
}Contextual Items
@State private var selection: Item?
.toolbar {
if selection != nil {
ToolbarItem { DeleteButton() }
ToolbarItem { ShareButton() }
}
}Best Practices
- Unique IDs: Use meaningful identifiers for customizable items
- Logical Groups: Use spacers to group related actions
- Platform Awareness: Test on all target platforms
- Consistent Placement: Follow platform conventions
- 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
- Double-click
VibeTunnel-Logging.mobileconfig - System Settings will open
- Go to Privacy & Security → Profiles
- Click on "VibeTunnel Debug Logging"
- Click "Install..."
- Enter your password when prompted
- Restart VibeTunnel for changes to take effect
iOS
- AirDrop or email the
VibeTunnel-Logging.mobileconfigto your iOS device - Tap the file to open it
- iOS will prompt to review the profile
- Go to Settings → General → VPN & Device Management
- Tap on "VibeTunnel Debug Logging"
- Tap "Install" and enter your passcode
- Restart the VibeTunnel app
Verification
After installation, logs should show full details:
# macOS - using vtlog script
./scripts/vtlog.sh
# iOS - in Xcode console or Console.app
# You should see actual values instead of <private>Removal
macOS
- System Settings → Privacy & Security → Profiles
- Select "VibeTunnel Debug Logging"
- Click the minus (-) button
- Confirm removal
iOS
- Settings → General → VPN & Device Management
- Tap "VibeTunnel Debug Logging"
- Tap "Remove Profile"
- 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 componentssh.vibetunnel.vibetunnel.debug- Debug buildssh.vibetunnel.vibetunnel.tests- Test suitesh.vibetunnel.vibetunnel.tests.debug- Debug test builds
iOS
sh.vibetunnel.ios- Main iOS app and all componentssh.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:
- 850MB Session Bug: External terminal sessions (via
vibetunnel-fwd) bypass the clear sequence truncation instream-watcher.ts, sending entire gigabyte files instead of the last 2MB - 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
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client │────▶│ Web Server │────▶│ PTY Process │
└─────────────┘ └──────────────┘ └─────────────┘
│ │ │
│ ▼ ▼
│ ┌──────────┐ ┌──────────┐
└─────────────▶│ Terminal │ │ Ascinema │
│ Manager │ │ Files │
└──────────┘ └──────────┘Detailed Sequence Flow
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)
- Browser captures key press
- WebSocket v3 sends to
/ws(INPUT_TEXT/INPUT_KEY/RESIZE) - Server writes to IPC socket
- PTY Manager writes to process stdin
- Process executes command
Output Path (Terminal → Browser)
- Process writes to stdout
- PTY Manager captures via
onDatahandler - Writes to asciinema cast file (with write queue for backpressure)
- CastOutputHub tails file + scans for last clear sequence (pruning)
- Client receives via
/wsv3: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:
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:
// 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:
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:
// 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 eventClear sequence detection:
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:
- Trace how
vibetunnel-fwdconnects to client streams - Determine why it bypasses stream-watcher's truncation
- Ensure external terminals use same code path as server sessions
- 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:
- Instrument session-detail-view with resize observer logging
- Identify what causes viewport expansion
- Implement resize event debouncing
- Fix mobile-specific issues:
- Keyboard state affects scrolling
- Touch vs mouse event handling
- Scrollbar visibility problems
Code to Add:
// 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:
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
# 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 devDebug Resize Events
// Add to any component to detect resize loops
window.addEventListener('resize', () => {
console.count('resize');
console.trace('Resize triggered from:');
});Monitor Network Traffic
- Check
/wstraffic (v3 frames) forSTDOUTandSNAPSHOT_VT - Verify pruning works (no replay before last clear)
- Use
/api/sessions/:id/snapshotfor snapshot/export debugging
Architectural Insights
Why Current Architecture Works (When Not Bugged)
- Simplicity: "Es ist die todeleinfachste Variante" - It's the simplest possible approach
- Efficiency: 2MB instead of 980MB transmission after clear sequence truncation
- 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
Immediate (End of Week): Fix external terminal truncation bug
- Debug why
vibetunnel-fwdbypassessendExistingContent() - Deploy fix for immediate user relief
- Debug why
Short Term: Comprehensive resize fix
- Debug session-detail-view triggers
- Implement proper debouncing
- Fix mobile-specific issues
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
/wsWebSocket 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
- Client request → POST /api/sessions (web/src/server/routes/sessions.ts:createSessionRoutes)
- TerminalManager.createTerminal() (web/src/server/services/terminal-manager.ts)
- PtyManager.spawn() (web/src/server/pty/pty-manager.ts) - Spawns native PTY process
- Session stored in manager, WebSocket upgrade prepared
- Response with
sessionId(client connects to/wsand subscribes)
Terminal I/O Stream
- Client connects → WebSocket upgrade to
/ws(v3 framing) - Client subscribes →
SUBSCRIBE(sessionId, flags)frames - Client input/resize →
INPUT_*/RESIZEframes - Server routes frames →
WsV3Hub(web/src/server/services/ws-v3-hub.ts) - PTY output → cast tail (
CastOutputHub) →STDOUTframes - Server-side Ghostty →
SNAPSHOT_VTframes for previews/resync - Client renders:
- interactive:
ghostty-web(vibe-terminal) - previews: VT snapshots (
vibe-terminal-buffer)
- interactive:
Buffer Optimization Protocol
- WS v3 framing uses
VTmagic + version3(see docs/websocket.md) - Full VT snapshots (
SNAPSHOT_VT) for previews/resync - Stdout streaming (
STDOUT) for interactive terminals
Server Lifecycle Management
- ServerManager.start() (mac/VibeTunnel/Core/Services/ServerManager.swift)
- Creates BunServer instance
- BaseProcessServer.start() spawns server process
- Health checks via HTTP /health endpoint
- Log streaming through Process.standardOutput pipe
- 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:
npm install -g @augmentcode/auggieAuthenticate once:
auggie loginSee Augment's installation and authentication guides for current requirements.
Run with VibeTunnel
Start an interactive Auggie session through the vt wrapper:
cd /path/to/project
vt auggieYou can also pass an initial prompt:
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:
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
npm run dev
# or
./vibetunnelBehavior:
- 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
npm run dev -- --enable-ssh-keys
# or
./vibetunnel --enable-ssh-keysBehavior:
- 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
./vibetunnel --disallow-user-password
# or
./vibetunnel --disallow-user-password --enable-ssh-keys # redundant, auto-enabledBehavior:
- 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
npm run dev -- --no-auth
# or
./vibetunnel --no-authBehavior:
- 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
npm run dev -- --enable-tailscale-serve
# or
./vibetunnel --enable-tailscale-serveBehavior:
- Automatically starts
tailscale serveas 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 JPEGPhototo extract avatar - Format: Converts hex data to base64 JPEG
- Fallback: Uses
Pictureattribute 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
# 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 loggingExample Commands
# 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 automaticallySecurity Considerations
Password Authentication
- Uses system PAM authentication
- Uses the configured VibeTunnel username and password instead when both
VIBETUNNEL_USERNAMEandVIBETUNNEL_PASSWORDare 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:
// 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:
- Browser generates Ed25519 key pair using
crypto.subtle.generateKey() - Private key optionally encrypted with user-provided password
- Public key formatted in SSH wire format for server validation
- 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:
- Challenge Request: Client requests authentication challenge from
/api/auth/challenge - Challenge Generation: Server creates 32-byte random challenge with 5-minute expiry
- Key Selection: Client selects appropriate SSH key from browser storage
- Signature Creation: Browser signs challenge using private key via Web Crypto API
- Signature Submission: Client sends signed challenge to
/api/auth/ssh-login - 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_keysfile 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:
- Generate SSH key in VibeTunnel web interface
- Download public key file
- 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 - 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:
- Receives requests from your tailnet
- Adds identity headers based on the authenticated Tailscale user
- 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 logintailscale-user-name: The user's display nametailscale-user-profile-pic: URL to the user's profile picture
Setup Instructions
Start VibeTunnel with integrated Tailscale Serve:
bash./vibetunnel --enable-tailscale-serve --port 4020Or use the macOS app and enable the toggle in Settings → Remote Access
Access via Tailscale:
texthttps://[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.1when using Tailscale authentication
Integration with Other Auth Modes
Tailscale Serve integration can be combined with other authentication modes:
# 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-bypassNote: The --enable-tailscale-serve flag automatically manages both the Tailscale proxy and authentication.
Implementation Details
Authentication Flow
- Server startup determines available auth modes
- Frontend queries
/api/auth/configfor configuration - UI renders appropriate authentication options
- User authenticates via chosen method
- JWT token issued for session management
- Subsequent requests use Bearer token authentication
Avatar Implementation
# macOS avatar extraction
dscl . -read /Users/$USER JPEGPhoto | tail -1 | xxd -r -p > avatar.jpg
# Server endpoint
GET /api/auth/avatar/:userIdFile Structure
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 managementTroubleshooting
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-keysflag - 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:
npm run dev -- --debug --enable-ssh-keysThis 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):
# Start Poltergeist for automatic rebuilds
poltergeist
# Make changes - app rebuilds automaticallyDevelopment Build without Poltergeist:
cd mac
xcodebuild -project VibeTunnel.xcodeproj -scheme VibeTunnel -configuration Debug buildRelease Build - Full build with code signing:
cd mac
./scripts/build.sh --configuration Release --signKey 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:
cd web
pnpm run devProduction Build - Optimized bundles:
cd web
pnpm run buildBun Executable - Standalone binary with native modules:
cd web
node build-native.jsKey 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:
cd native/vt-fwd
cargo build --release --lockedThe 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:
cd ios
xcodegen generateBuild 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:
cd mac
./scripts/release.sh stable # Stable release
./scripts/release.sh beta 1 # Beta releaseKey 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 settingsmac/VibeTunnel/version.xcconfig- Version numbersmac/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 modulespawn-helper- Process spawning helpervibetunnel- Bun executablevibetunnel-fwd- Rust terminal forwarder
Linux (Ubuntu) Requirements
Packages:
curl,ca-certificates,xz-utils,python3,make,g++,gitlibpam0g-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:
web/scripts/linux-bootstrap.shiOS 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 watchersbuild- Production TypeScript compilationbundle- Client-side asset bundlingtypecheck- TypeScript validationlint- ESLint code quality checks
Build Scripts
Core Build Scripts (mac/scripts/):
build.sh- Main build orchestratorbuild-bun-executable.sh- Bun compilation (lines 31-92)copy-bun-executable.sh- Bundle integrationcodesign-app.sh- Code signingnotarize-app.sh- Apple notarizationcreate-dmg.sh- DMG packaginggenerate-appcast.sh- Sparkle updates
Helper Scripts:
preflight-check.sh- Pre-build validationversion.sh- Version managementclean.sh- Build cleanupverify-app.sh- Post-build verification
Troubleshooting
Common Issues:
- Bun build fails - Check
web/build-native.jspatches (lines 11-79) - Code signing errors - Verify
apple/Local.xcconfigsettings - Notarization fails - Check API keys in environment
- 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:
cd mac && ./scripts/clean.sh
cd ../web && npm run cleanFile: 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:
# In Claude Code, simply type:
/changelogThis 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
## [1.0.0-beta.13] - 2024-12-20Categories
- 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:
- 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:
### First-time Contributors
- [@hewigovens](https://github.com/hewigovens) - Added systemd service management for Linux (#419)Identifying Contributors
Finding First-time Contributors
# 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-timersMapping Changes to Contributors
# 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.13Special 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:
- Feature description (via [@user1](https://github.com/user1), [@user2](https://github.com/user2)) (#123)GitHub Releases
Creating a Release
- Generate changelog using
/changelogcommand - Review and edit the generated content
- Update CHANGELOG.md with the new version section
- 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:
- Highlights - 2-3 major changes
- Full changelog - Copy from CHANGELOG.md
- First-time contributors - If applicable
- Installation instructions - Brief reminder
Example:
## 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:
# Edit a release
gh release edit v1.0.0-beta.13 --notes-file updated-notes.md
# Or use the GitHub web UIWorkflow Summary
- Before release: Run
/changelogto analyze changes - Review output: Ensure proper attribution and user-friendly descriptions
- Update CHANGELOG.md: Add new version section with proper formatting
- Create release notes: Include highlights and first-time contributors
- Create GitHub release: Use
gh release createor web UI - 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
# 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.mdFile: 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
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
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
- Character Measurement: Dynamically measures actual character width using font metrics
- Absolute Positioning: Calculates page-absolute cursor coordinates
- Container Relative: Converts to position relative to
#session-terminalcontainer - 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): Usesbuffer.cursorX/Yfrom 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
nullwhen session is not running - Returns
nullwhen container element is not found - Returns
nullwhen 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:
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:
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:
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:
- User taps terminal area
- Native virtual keyboard appears with CJK support
- User types or selects from IME candidates
- Input is sent directly to terminal
- 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
// 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
// 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 requiredUser 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.isComposingplus thedata-ime-composingDOM 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
User clicks terminal → Cursor-positioned input focuses → Types CJK →
Browser shows IME candidates → User selects → Text appears in terminalMobile Workflow
User taps terminal → Virtual keyboard appears → Types CJK →
OS shows IME candidates → User selects → Text appears in terminalVisual 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 cleanupinput-manager.ts- Input coordination, desktop/mobile selection, terminal routing, and lifecycleterminal.ts- Ghostty renderer cursor geometry used to anchor the native inputlifecycle-event-manager.ts- Keyboard interception that leaves active composition to the browserdirect-keyboard-manager.ts- Mobile keyboard handlingmobile-utils.ts- Mobile detection utilities
Supporting Files
session-view.ts- Container element and terminal integrationime-constants.ts- IME-related key filtering utilitiesterminal-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
- User has CJK input method enabled in their OS
- Desktop: User clicks in terminal area to focus
- Mobile: User taps terminal or input field
- 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.tsfor 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.tsto centralize all critical terminal element IDs - Prevention of Breakage: Changes to IDs like
session-terminal,buffer-container, orterminal-containernow only require updates in one location - Consistent References: All components now import
TERMINAL_IDSconstants 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