Merge main into monaco branch

This commit is contained in:
Mario Zechner 2025-06-23 05:59:04 +02:00
commit eb4f358fa2
140 changed files with 34715 additions and 978 deletions

View file

@ -17,8 +17,18 @@ inputs:
runs:
using: 'composite'
steps:
- name: Create or Update PR Comment
- name: Find Comment
if: github.event_name == 'pull_request'
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: '<!-- lint-results -->'
- name: Prepare Comment Body
if: github.event_name == 'pull_request'
id: prepare
uses: actions/github-script@v7
with:
github-token: ${{ inputs.github-token }}
@ -26,6 +36,7 @@ runs:
const title = ${{ toJSON(inputs.title) }};
const result = ${{ toJSON(inputs.lint-result) }};
const output = ${{ toJSON(inputs.lint-output) }};
const existingCommentId = '${{ steps.fc.outputs.comment-id }}';
const icon = result === 'success' ? '✅' : '❌';
const status = result === 'success' ? 'Passed' : 'Failed';
@ -37,24 +48,16 @@ runs:
sectionContent += `\n<details>\n<summary>Click to see details</summary>\n\n\`\`\`\n${output}\n\`\`\`\n\n</details>\n`;
}
const commentMarker = '<!-- lint-results -->';
const issue_number = context.issue.number;
// Find existing comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' && comment.body.includes(commentMarker)
);
let body;
if (botComment) {
// Update existing comment
const existingBody = botComment.body;
if (existingCommentId) {
// Get existing comment body
const { data: comment } = await github.rest.issues.getComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: parseInt(existingCommentId),
});
const existingBody = comment.body;
const sectionHeader = `### ${title}`;
const nextSectionRegex = /^###\s/m;
@ -86,21 +89,19 @@ runs:
// Add new section at the end
body = existingBody + '\n\n' + sectionContent;
}
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body,
});
} else {
// Create new comment
body = `## 🔍 Code Quality Report\n${commentMarker}\n\nThis comment is automatically updated with linting results from CI.\n\n${sectionContent}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: body,
});
}
body = `## 🔍 Code Quality Report\n<!-- lint-results -->\n\nThis comment is automatically updated with linting results from CI.\n\n${sectionContent}`;
}
// Store the body for the next step
core.setOutput('comment_body', body);
- name: Create or Update Comment
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: ${{ steps.prepare.outputs.comment_body }}
edit-mode: replace

View file

@ -1,5 +1,25 @@
# Changelog
## [1.0.0-beta.3] - 2025-06-23
There's too much to list! This is the version you've been waiting for.
- Redesigned, responsive, animated frontend.
- Improved terminal width spanning and layout optimization
- File-Picker to see files on-the-go.
- Creating new Terminals is now much more reliable.
- Added terminal font size adjustment in the settings dropdown
- Fresh new icon for Progressive Web App installations
- Refined bounce animations for a more subtle, professional feel
- Added retro CRT-style phosphor decay visual effect for closed terminals
- Fixed buffer aggregator message handling for smoother terminal updates
- Better support for shell aliases and improved debug logging
- Enhanced Unix socket server implementation for faster local communication
- Special handling for Warp terminal with custom enter key behavior
- New dock menu with quick actions when right-clicking the app icon
- More resilient vt command-line tool with better error handling
- Ensured vibetunnel server properly terminates when Mac app is killed
## [1.0.0-beta.2] - 2025-06-19
### 🎨 Improvements

View file

@ -122,13 +122,34 @@ EOF
cd web
npm install
npm run build
node build-native.js # Creates Bun executable
# 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)
# npm run build # Will use custom Node.js automatically
# Build the macOS app
cd ../mac
./scripts/build.sh --configuration Release
```
### Custom Node.js Builds
VibeTunnel supports building with a custom Node.js for a 46% smaller executable (61MB vs 107MB):
```bash
# Build custom Node.js (one-time, ~20 minutes)
node build-custom-node.js
# Use environment variable for all builds
export VIBETUNNEL_USE_CUSTOM_NODE=YES
# Or use in Xcode Build Settings
# Add User-Defined Setting: VIBETUNNEL_USE_CUSTOM_NODE = YES
```
See [Custom Node Build Flags](docs/custom-node-build-flags.md) for detailed optimization information.
## Development
For development setup and contribution guidelines, see [CONTRIBUTING.md](docs/CONTRIBUTING.md).

View file

@ -5,6 +5,48 @@
<link>https://github.com/amantus-ai/vibetunnel</link>
<description>VibeTunnel pre-release and beta updates feed</description>
<language>en</language>
<item>
<title>VibeTunnel 1.0.0-beta.3</title>
<link>https://github.com/amantus-ai/vibetunnel/releases/download/v1.0.0-beta.3/VibeTunnel-1.0.0-beta.3.dmg</link>
<sparkle:version>110</sparkle:version>
<sparkle:shortVersionString>1.0.0-beta.3</sparkle:shortVersionString>
<description><![CDATA[
<h2>VibeTunnel 1.0.0-beta.3</h2><p><strong>Pre-release version</strong></p><div><h3>🎉 Beta 3 Release</h3>
<p>There's too much to list! This is the version you've been waiting for.</p>
<h3>✨ Highlights</h3>
<ul>
<li>Redesigned, responsive, animated frontend</li>
<li>File-Picker to see files on-the-go</li>
<li>Creating new Terminals is now much more reliable</li>
<li>Fresh new icon for Progressive Web App installations</li>
</ul>
<h3>🎨 Improvements</h3>
<ul>
<li>Improved terminal width spanning and layout optimization</li>
<li>Added terminal font size adjustment in the settings dropdown</li>
<li>Refined bounce animations for a more subtle, professional feel</li>
<li>Added retro CRT-style phosphor decay visual effect for closed terminals</li>
<li>Fixed buffer aggregator message handling for smoother terminal updates</li>
<li>Better support for shell aliases and improved debug logging</li>
<li>Enhanced Unix socket server implementation for faster local communication</li>
<li>Special handling for Warp terminal with custom enter key behavior</li>
<li>New dock menu with quick actions when right-clicking the app icon</li>
<li>More resilient vt command-line tool with better error handling</li>
<li>Ensured vibetunnel server properly terminates when Mac app is killed</li>
</ul>
<p><a href="https://github.com/amantus-ai/vibetunnel/blob/main/CHANGELOG.md#100-beta3---2025-06-23">View full changelog</a></p></div>
]]></description>
<pubDate>Sun, 23 Jun 2025 04:30:00 +0000</pubDate>
<enclosure
url="https://github.com/amantus-ai/vibetunnel/releases/download/v1.0.0-beta.3/VibeTunnel-1.0.0-beta.3.dmg"
length="20156342"
type="application/octet-stream"
sparkle:edSignature="d4G5pUTLuoRUMVg+SSHHaupV2QycU3fEXJhzFjXNSB79htcVo3mGn+oaABL1suvCMJTBK7shJJQS3mf7yDFQDg=="
/>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
</item>
<item>
<title>VibeTunnel 1.0.0-beta.2</title>
<link>https://github.com/amantus-ai/vibetunnel/releases/download/v1.0-beta.2/VibeTunnel-1.0.0-beta.2.dmg</link>

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

View file

@ -121,17 +121,37 @@ Each `--without-*` flag contributes to size reduction:
## Usage Instructions
1. **Build custom Node.js**:
```bash
node build-custom-node.js --version=24.2.0
```
### Building Custom Node.js
2. **Use with vibetunnel**:
```bash
node build-native.js --custom-node="/path/to/custom/node"
```
```bash
node build-custom-node.js # Builds Node.js 24.2.0 (default)
node build-custom-node.js --version=24.2.0 # Specific version
node build-custom-node.js --latest # Latest version
```
3. **Result**: A 61MB portable executable (vs 107MB with standard Node.js)
### Using Custom Node.js
#### Option 1: Command Line
```bash
node build-native.js --custom-node # Auto-detect from .node-builds/
node build-native.js --custom-node="/path/to/custom/node" # Specific path
```
#### Option 2: Environment Variable (Recommended for Xcode)
```bash
export VIBETUNNEL_USE_CUSTOM_NODE=YES # Use custom Node.js
export VIBETUNNEL_USE_CUSTOM_NODE=NO # Use system Node.js
node build-native.js # Respects environment variable
```
### Xcode Integration
For macOS development, set `VIBETUNNEL_USE_CUSTOM_NODE` in Build Settings:
- **YES**: Always use custom Node.js (61MB executable)
- **NO**: Always use system Node.js (107MB executable)
- **(not set)**: Auto-detect based on build configuration
See [Xcode Custom Node Setup](xcode-custom-node-setup.md) for detailed instructions.
## Future Optimization Opportunities

62
docs/ios-enhancements.md Normal file
View file

@ -0,0 +1,62 @@
# iOS App Enhancements
This document tracks additional enhancements made to the VibeTunnel iOS app after achieving feature parity with the JavaScript front-end.
## Completed Enhancements
### 1. **Connection Status Indicator**
- Added real-time WebSocket connection status to terminal toolbar
- Shows "Connecting", "Connected", or "Disconnected" states
- Visual indicators: WiFi icon for connected, WiFi slash for disconnected
- Progress spinner during connection attempts
- File: `TerminalView.swift` - Added `connectionStatusIndicator` view
### 2. **Session Export Functionality**
- Added "Export as Text" option to terminal menu
- Exports current terminal buffer content as a text file
- Uses iOS share sheet for saving/sharing
- Temporary file cleanup after sharing
- Files modified:
- `TerminalView.swift` - Added export menu item and sheet
- `TerminalViewModel.swift` - Added `getBufferContent()` method
- `TerminalHostingView.swift` - Added buffer content extraction
## Architecture Improvements
### Connection Status
The connection status indicator provides immediate visual feedback about the WebSocket connection state, helping users understand if their terminal is actively connected to the server.
### Export Functionality
The export feature allows users to save terminal session output for documentation, debugging, or sharing purposes. The implementation reads the entire terminal buffer and formats it as plain text.
## User Experience Enhancements
1. **Visual Feedback**: Connection status is always visible in the toolbar
2. **Export Workflow**: Simple menu action → Share sheet → Save/Share options
3. **File Naming**: Exported files include session name and timestamp
## Technical Implementation
### Buffer Content Extraction
The `getBufferContent()` method in `TerminalHostingView.Coordinator`:
- Iterates through all terminal rows
- Extracts characters from each column
- Trims trailing whitespace
- Returns formatted text content
### Share Sheet Integration
Uses native iOS `UIActivityViewController` wrapped in SwiftUI:
- Temporary file creation in app's temp directory
- Automatic cleanup after sharing
- Support for all iOS sharing destinations
## Future Enhancement Ideas
1. **Haptic Feedback**: Add subtle haptics for terminal interactions (already has HapticFeedback utility)
2. **iPad Keyboard Shortcuts**: Command palette, quick actions
3. **Improved Error Messages**: User-friendly error descriptions with suggested actions
4. **WebSocket Optimization**: Better reconnection strategies, connection pooling
5. **Session Templates**: Save and reuse common session configurations
6. **Multi-Window Support**: iPad multitasking with multiple terminal windows
The iOS app now exceeds feature parity with the web version and includes native platform enhancements that improve the mobile terminal experience.

115
docs/ios-update-progress.md Normal file
View file

@ -0,0 +1,115 @@
# iOS App Update Progress
This document tracks the implementation progress of updating the VibeTunnel iOS app to match all features available in the JavaScript front-end.
## Update Progress
### Completed Features ✅
1. **Fixed Session Creation API**
- Changed `spawnTerminal` default from `false` to `true` in `Session.swift`
- This was the critical bug preventing sessions from being created
2. **Fixed Session Cleanup Endpoint**
- Changed from `/api/cleanup-exited` to `DELETE /api/sessions` in `APIClient.swift`
- Now matches the JavaScript implementation
3. **Implemented SSE Client**
- Created `SSEClient.swift` for Server-Sent Events streaming
- Handles text-based terminal output streaming
- Parses event format: `[timestamp, type, data]`
- Handles exit events: `['exit', exitCode, sessionId]`
4. **Added Terminal Renderer Switcher**
- Created `TerminalRenderer.swift` enum for renderer selection
- Added debug menu in `TerminalView.swift` to switch between renderers
- Persists selection in UserDefaults
5. **Created xterm WebView Implementation**
- Created `XtermWebView.swift` using WKWebView
- Loads xterm.js from CDN
- Handles terminal input/output via message handlers
- Supports both WebSocket and SSE data sources
6. **Added File Preview with Syntax Highlighting**
- Added `previewFile()` and `getGitDiff()` methods to `APIClient.swift`
- Created `FilePreviewView.swift` with WebView-based syntax highlighting
- Uses highlight.js for code highlighting
- Supports text, image, and binary file previews
7. **Added Git Diff Viewer**
- Integrated into `FilePreviewView.swift`
- Shows diffs with proper syntax highlighting
- Accessible from file preview screen
8. **Updated File Browser**
- Modified `FileBrowserView.swift` to use new preview system
- Replaced QuickLook with custom FilePreviewView
9. **Added System Logs Viewer**
- Added logs API endpoints to `APIClient.swift` (`getLogsRaw`, `getLogsInfo`, `clearLogs`)
- Created `SystemLogsView.swift` with full feature parity:
- Real-time log display with 2-second auto-refresh
- Filter by log level (All, Error, Warn, Log, Debug)
- Filter by source (Client/Server)
- Text search functionality
- Auto-scroll toggle
- Download logs capability
- Clear logs with confirmation
- Added access from Settings → Advanced → View System Logs
10. **Added URL Detection in Terminal**
- SwiftTerm already has built-in URL detection (confirmed in code)
- xterm.js implementation includes WebLinksAddon for URL detection
- Settings toggle exists: "Detect URLs" in General Settings
11. **Added Cast File Import**
- Added file importer to SessionListView
- Menu option: "Import Recording" in ellipsis menu
- Supports .json and .data file types (Asciinema cast files)
- Opens CastPlayerView with imported file
- Created CastFileItem wrapper for Identifiable conformance
### All Features Completed! ✅
All features from the JavaScript front-end have been successfully implemented in the iOS app.
## Key Files Modified
- `Session.swift` - Fixed spawn_terminal default value
- `APIClient.swift` - Fixed endpoints, added preview/diff/logs APIs
- `SSEClient.swift` - New SSE implementation
- `TerminalRenderer.swift` - New renderer selection enum
- `XtermWebView.swift` - New WebView-based terminal
- `FilePreviewView.swift` - New file preview with syntax highlighting
- `TerminalView.swift` - Added renderer switcher
- `FileBrowserView.swift` - Updated to use new preview
- `SystemLogsView.swift` - New system logs viewer
- `SettingsView.swift` - Added logs viewer access
- `SessionListView.swift` - Added cast file import functionality
## Testing Checklist
- [x] Create new sessions
- [x] Terminal output appears correctly
- [x] Terminal input and special keys work
- [x] WebSocket reconnection works
- [x] File browser and preview work
- [x] Git integration features work
- [x] Session management operations work
- [x] Error handling and offline mode work
- [x] Terminal renderer switching works
- [x] System logs viewer works
## Summary
The iOS app has been successfully updated with all critical and most medium-priority features from the JavaScript front-end. The app now has:
- Full server communication compatibility
- Multiple terminal renderer options (native SwiftTerm and web-based xterm.js)
- File preview with syntax highlighting
- Git diff viewing
- System logs viewer
- All necessary API endpoint fixes
The remaining features (URL detection and cast file import) are low priority and the app is now fully functional with the current server implementation.

135
docs/ios-web-parity-plan.md Normal file
View file

@ -0,0 +1,135 @@
# iOS Web Parity Implementation Plan
This document outlines the missing features in the iOS app compared to the web frontend and the implementation plan to achieve full feature parity.
## Missing Features Analysis
### High Priority Features
1. **Terminal Width Selector**
- Web has: Width button showing current width (∞, 80, 100, 120, 132, 160, custom)
- iOS has: Basic width adjustment in sheet, no quick selector
- Need: Quick width selector button with common presets
2. **File Browser Path Insertion**
- Web has: Direct path insertion into terminal when selecting files
- iOS has: Only copy to clipboard
- Need: Insert path functionality with proper escaping for spaces
3. **Mobile Control Buttons**
- Web has: On-screen buttons for arrows, ESC, Tab, Enter, Ctrl
- iOS has: Limited toolbar with some special keys
- Need: Complete set of control buttons
4. **Full-Screen Text Input**
- Web has: Full-screen overlay for mobile text input
- iOS has: Native keyboard only
- Need: Optional full-screen input mode
5. **Ctrl+Key Overlay**
- Web has: Grid selector for Ctrl combinations
- iOS has: No Ctrl+key selector
- Need: Grid overlay for Ctrl sequences
### Medium Priority Features
6. **Font Size Controls** ⚠️
- Web has: +/- buttons with reset, range 8-32px
- iOS has: Slider in sheet, no quick controls
- Need: Quick adjustment buttons in toolbar
7. **Session Snapshot Loading**
- Web has: Loads final snapshot for exited sessions
- iOS has: No snapshot loading
- Need: Implement snapshot API and display
8. **Keyboard Shortcuts** ⚠️
- Web has: Cmd+O for file browser, various shortcuts
- iOS has: Limited keyboard support
- Need: Comprehensive keyboard shortcut support
9. **Enhanced File Browser** ⚠️
- Web has: Syntax highlighting, image preview, diff viewer
- iOS has: Basic preview, no diff integration
- Need: Enhanced preview capabilities
### Low Priority Features
10. **Git Status in File Browser** ⚠️
- Web has: Inline git status indicators
- iOS has: Git status but less prominent
- Need: Better git status visualization
11. **Swipe Gestures**
- Web has: Swipe from left edge to go back
- iOS has: Native swipe back gesture
- Status: Already implemented
## Implementation Order
### Phase 1: Core Terminal UX (High Priority)
1. Terminal Width Selector
2. Font Size Quick Controls
3. Mobile Control Buttons
### Phase 2: Enhanced Input (High Priority)
4. File Browser Path Insertion
5. Full-Screen Text Input
6. Ctrl+Key Overlay
### Phase 3: Session Management (Medium Priority)
7. Session Snapshot Loading
8. Keyboard Shortcuts
9. Enhanced File Preview
### Phase 4: Polish (Low Priority)
10. Improved Git Status Display
11. Additional gestures and animations
## Technical Considerations
### Width Management
- Store preferred widths in UserDefaults
- Common widths: [0 (∞), 80, 100, 120, 132, 160]
- Custom width input with validation (20-500)
### Mobile Input
- Full-screen UITextView for text input
- Send options: text only, text + enter
- Keyboard shortcuts for quick send
### File Path Handling
- Escape paths with spaces using quotes
- Support both absolute and relative paths
- Integration with terminal input system
### Performance
- Debounce resize operations
- Cache terminal dimensions
- Optimize control button layout for different screen sizes
## UI/UX Guidelines
### Visual Consistency
- Match web frontend's visual style where appropriate
- Use native iOS patterns for better platform integration
- Maintain terminal aesthetic with modern touches
### Accessibility
- VoiceOver support for all controls
- Dynamic Type support
- High contrast mode compatibility
### Responsive Design
- Adapt control layout for different device sizes
- Handle keyboard appearance/disappearance smoothly
- Support both portrait and landscape orientations
## Success Metrics
- [ ] All high-priority features implemented
- [ ] Feature parity with web frontend
- [ ] Native iOS advantages utilized
- [ ] Performance on par or better than web
- [ ] User feedback incorporated
- [ ] Comprehensive testing completed

View file

@ -29,6 +29,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9B67BEFAD916B893D5F56E87"
BuildableName = "VibeTunnelTests.xctest"
BlueprintName = "VibeTunnelTests"
ReferencedContainer = "container:VibeTunnel-iOS.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"

View file

@ -7,17 +7,20 @@ import Foundation
/// and terminal dimensions.
struct Session: Codable, Identifiable, Equatable {
let id: String
let command: String
let command: [String] // Changed from String to [String] to match server
let workingDir: String
let name: String?
let name: String
let status: SessionStatus
let exitCode: Int?
let startedAt: String
let lastModified: String?
let pid: Int?
let waiting: Bool?
let width: Int?
let height: Int?
// Optional fields from HQ mode
let source: String?
let remoteId: String?
let remoteName: String?
let remoteUrl: String?
enum CodingKeys: String, CodingKey {
case id
@ -29,16 +32,20 @@ struct Session: Codable, Identifiable, Equatable {
case startedAt
case lastModified
case pid
case waiting
case width
case height
case source
case remoteId
case remoteName
case remoteUrl
}
/// User-friendly display name for the session.
///
/// Returns the custom name if set, otherwise the command.
/// Returns the custom name if not empty, otherwise the command.
var displayName: String {
name ?? command
if !name.isEmpty {
return name
}
return command.joined(separator: " ")
}
/// Indicates whether the session is currently active.
@ -128,14 +135,14 @@ struct SessionCreateData: Codable {
/// - command: Command to execute (default: "zsh").
/// - workingDir: Working directory for the session.
/// - name: Optional custom name.
/// - spawnTerminal: Whether to spawn a terminal (default: false).
/// - spawnTerminal: Whether to spawn a terminal (default: true).
/// - cols: Terminal width in columns (default: 120).
/// - rows: Terminal height in rows (default: 30).
init(
command: String = "zsh",
workingDir: String,
name: String? = nil,
spawnTerminal: Bool = false,
spawnTerminal: Bool = true,
cols: Int = 120,
rows: Int = 30
) {

View file

@ -0,0 +1,39 @@
import Foundation
/// Available terminal renderer implementations
enum TerminalRenderer: String, CaseIterable, Codable {
case swiftTerm = "SwiftTerm"
case xterm = "xterm.js"
var displayName: String {
switch self {
case .swiftTerm:
return "SwiftTerm (Native)"
case .xterm:
return "xterm.js (WebView)"
}
}
var description: String {
switch self {
case .swiftTerm:
return "Native Swift terminal emulator with best performance"
case .xterm:
return "JavaScript-based terminal, identical to web version"
}
}
/// The currently selected renderer (persisted in UserDefaults)
static var selected: TerminalRenderer {
get {
if let rawValue = UserDefaults.standard.string(forKey: "selectedTerminalRenderer"),
let renderer = TerminalRenderer(rawValue: rawValue) {
return renderer
}
return .swiftTerm // Default
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: "selectedTerminalRenderer")
}
}
}

View file

@ -0,0 +1,127 @@
import Foundation
/// Common terminal width presets
enum TerminalWidth: CaseIterable, Equatable {
case unlimited
case classic80
case modern100
case wide120
case mainframe132
case ultraWide160
case custom(Int)
var value: Int {
switch self {
case .unlimited: return 0
case .classic80: return 80
case .modern100: return 100
case .wide120: return 120
case .mainframe132: return 132
case .ultraWide160: return 160
case .custom(let width): return width
}
}
var label: String {
switch self {
case .unlimited: return ""
case .classic80: return "80"
case .modern100: return "100"
case .wide120: return "120"
case .mainframe132: return "132"
case .ultraWide160: return "160"
case .custom(let width): return "\(width)"
}
}
var description: String {
switch self {
case .unlimited: return "Unlimited"
case .classic80: return "Classic terminal"
case .modern100: return "Modern standard"
case .wide120: return "Wide terminal"
case .mainframe132: return "Mainframe width"
case .ultraWide160: return "Ultra-wide"
case .custom: return "Custom width"
}
}
static var allCases: [TerminalWidth] {
[.unlimited, .classic80, .modern100, .wide120, .mainframe132, .ultraWide160]
}
static func from(value: Int) -> TerminalWidth {
switch value {
case 0: return .unlimited
case 80: return .classic80
case 100: return .modern100
case 120: return .wide120
case 132: return .mainframe132
case 160: return .ultraWide160
default: return .custom(value)
}
}
/// Check if this is a standard preset width
var isPreset: Bool {
switch self {
case .custom: return false
default: return true
}
}
}
/// Manager for terminal width preferences
@MainActor
class TerminalWidthManager {
static let shared = TerminalWidthManager()
private let defaultWidthKey = "defaultTerminalWidth"
private let customWidthsKey = "customTerminalWidths"
private init() {}
/// Get the default terminal width
var defaultWidth: Int {
get {
UserDefaults.standard.integer(forKey: defaultWidthKey)
}
set {
UserDefaults.standard.set(newValue, forKey: defaultWidthKey)
}
}
/// Get saved custom widths
var customWidths: [Int] {
get {
UserDefaults.standard.array(forKey: customWidthsKey) as? [Int] ?? []
}
set {
UserDefaults.standard.set(newValue, forKey: customWidthsKey)
}
}
/// Add a custom width to saved list
func addCustomWidth(_ width: Int) {
var widths = customWidths
if !widths.contains(width) && width >= 20 && width <= 500 {
widths.append(width)
// Keep only last 5 custom widths
if widths.count > 5 {
widths.removeFirst()
}
customWidths = widths
}
}
/// Get all available widths including custom ones
func allWidths() -> [TerminalWidth] {
var widths = TerminalWidth.allCases
for customWidth in customWidths {
if !TerminalWidth.allCases.contains(where: { $0.value == customWidth }) {
widths.append(.custom(customWidth))
}
}
return widths
}
}

View file

@ -118,9 +118,18 @@ class APIClient: APIClientProtocol {
try validateResponse(response)
// Debug logging
if let jsonString = String(data: data, encoding: .utf8) {
print("[APIClient] getSessions response: \(jsonString)")
}
do {
return try decoder.decode([Session].self, from: data)
} catch {
print("[APIClient] Decoding error: \(error)")
if let decodingError = error as? DecodingError {
print("[APIClient] Decoding error details: \(decodingError)")
}
throw APIError.decodingError(error)
}
}
@ -255,7 +264,7 @@ class APIClient: APIClientProtocol {
let (data, response) = try await session.data(for: request)
try validateResponse(response)
// Handle empty response (204 No Content) from Go server
// Handle empty response (204 No Content)
if data.isEmpty {
return []
}
@ -589,4 +598,135 @@ class APIClient: APIClientProtocol {
return try decoder.decode(FileInfo.self, from: data)
}
func previewFile(path: String) async throws -> FilePreview {
guard let baseURL else {
throw APIError.noServerConfigured
}
guard var components = URLComponents(
url: baseURL.appendingPathComponent("api/fs/preview"),
resolvingAgainstBaseURL: false
) else {
throw APIError.invalidURL
}
components.queryItems = [URLQueryItem(name: "path", value: path)]
guard let url = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
return try decoder.decode(FilePreview.self, from: data)
}
func getGitDiff(path: String) async throws -> FileDiff {
guard let baseURL else {
throw APIError.noServerConfigured
}
guard var components = URLComponents(
url: baseURL.appendingPathComponent("api/fs/diff"),
resolvingAgainstBaseURL: false
) else {
throw APIError.invalidURL
}
components.queryItems = [URLQueryItem(name: "path", value: path)]
guard let url = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
return try decoder.decode(FileDiff.self, from: data)
}
// MARK: - System Logs
func getLogsRaw() async throws -> String {
guard let baseURL else {
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/logs/raw")
var request = URLRequest(url: url)
request.httpMethod = "GET"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
guard let logContent = String(data: data, encoding: .utf8) else {
throw APIError.invalidResponse
}
return logContent
}
func getLogsInfo() async throws -> LogsInfo {
guard let baseURL else {
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/logs/info")
var request = URLRequest(url: url)
request.httpMethod = "GET"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
return try decoder.decode(LogsInfo.self, from: data)
}
func clearLogs() async throws {
guard let baseURL else {
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/logs/clear")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
addAuthenticationIfNeeded(&request)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}
}
// MARK: - File Preview Types
struct FilePreview: Codable {
let type: FilePreviewType
let content: String?
let language: String?
let size: Int64?
let mimeType: String?
}
enum FilePreviewType: String, Codable {
case text
case image
case binary
}
struct FileDiff: Codable {
let diff: String
let path: String
}
struct LogsInfo: Codable {
let size: Int64
let lastModified: String?
}

View file

@ -382,7 +382,13 @@ class BufferWebSocketClient: NSObject {
break
}
} else {
print("[BufferWebSocket] Failed to decode cell \(i) in row \(totalRows)")
print("[BufferWebSocket] Failed to decode cell \(i) in row \(totalRows) at offset \(offset)")
// Log the type byte for debugging
if offset < data.count {
let typeByte = data[offset]
print("[BufferWebSocket] Type byte: 0x\(String(format: "%02X", typeByte))")
print("[BufferWebSocket] Bits: hasExt=\((typeByte & 0x80) != 0), isUni=\((typeByte & 0x40) != 0), hasFg=\((typeByte & 0x20) != 0), hasBg=\((typeByte & 0x10) != 0), charType=\(typeByte & 0x03)")
}
break
}
}
@ -393,7 +399,21 @@ class BufferWebSocketClient: NSObject {
print(
"[BufferWebSocket] Unknown row marker: 0x\(String(format: "%02X", marker)) at offset \(offset - 1)"
)
// Try to continue parsing
// Log surrounding bytes for debugging
let context = 10
let start = max(0, offset - 1 - context)
let end = min(data.count, offset - 1 + context)
var contextBytes = ""
for i in start..<end {
if i == offset - 1 {
contextBytes += "[\(String(format: "%02X", data[i]))] "
} else {
contextBytes += "\(String(format: "%02X", data[i])) "
}
}
print("[BufferWebSocket] Context bytes: \(contextBytes)")
// Skip this byte and try to continue parsing
break
}
}
@ -436,12 +456,17 @@ class BufferWebSocketClient: NSObject {
let hasBg = (typeByte & 0x10) != 0
let isRgbFg = (typeByte & 0x08) != 0
let isRgbBg = (typeByte & 0x04) != 0
let charType = typeByte & 0x03
// Read character
var char: String
var width: Int = 1
if isUnicode {
if charType == 0x00 {
// Simple space
char = " "
} else if isUnicode {
// Unicode character
// Read character length first
guard currentOffset < data.count else {
print("[BufferWebSocket] Unicode char decode failed: missing length byte")

View file

@ -0,0 +1,172 @@
import Foundation
/// Server-Sent Events (SSE) client for real-time terminal output streaming.
///
/// SSEClient handles the text-based streaming protocol used by the VibeTunnel server
/// to send terminal output in real-time. It parses the event stream format and
/// provides decoded events to a delegate.
final class SSEClient: NSObject, @unchecked Sendable {
private var task: URLSessionDataTask?
private var session: URLSession!
private let url: URL
private var buffer = Data()
weak var delegate: SSEClientDelegate?
/// Events received from the SSE stream
enum SSEEvent {
case terminalOutput(timestamp: Double, type: String, data: String)
case exit(exitCode: Int, sessionId: String)
case error(String)
}
init(url: URL) {
self.url = url
super.init()
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 0 // No timeout for SSE
configuration.timeoutIntervalForResource = 0
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
}
@MainActor
func start() {
var request = URLRequest(url: url)
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
// Add authentication if needed
if let authHeader = ConnectionManager.shared.currentServerConfig?.authorizationHeader {
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
}
task = session.dataTask(with: request)
task?.resume()
}
func stop() {
task?.cancel()
task = nil
}
private func processBuffer() {
// Convert buffer to string
guard let string = String(data: buffer, encoding: .utf8) else { return }
// Split by double newline (SSE event separator)
let events = string.components(separatedBy: "\n\n")
// Keep the last incomplete event in buffer
if !string.hasSuffix("\n\n") && events.count > 1 {
if let lastEvent = events.last, let lastEventData = lastEvent.data(using: .utf8) {
buffer = lastEventData
}
} else {
buffer = Data()
}
// Process complete events
for (index, eventString) in events.enumerated() {
// Skip the last event if buffer wasn't cleared (it's incomplete)
if index == events.count - 1 && !buffer.isEmpty {
continue
}
if !eventString.isEmpty {
processEvent(eventString)
}
}
}
private func processEvent(_ eventString: String) {
var eventType: String?
var eventData: String?
// Parse SSE format
let lines = eventString.components(separatedBy: "\n")
for line in lines {
if line.hasPrefix("event:") {
eventType = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("data:") {
let data = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
if eventData == nil {
eventData = data
} else {
eventData! += "\n" + data
}
}
}
// Process based on event type
if eventType == "message" || eventType == nil, let data = eventData {
parseTerminalData(data)
}
}
private func parseTerminalData(_ data: String) {
// The data should be a JSON array: [timestamp, type, data] or ['exit', exitCode, sessionId]
guard let jsonData = data.data(using: .utf8) else { return }
do {
if let array = try JSONSerialization.jsonObject(with: jsonData) as? [Any] {
if array.count >= 3 {
// Check for exit event
if let firstElement = array[0] as? String, firstElement == "exit",
let exitCode = array[1] as? Int,
let sessionId = array[2] as? String {
delegate?.sseClient(self, didReceiveEvent: .exit(exitCode: exitCode, sessionId: sessionId))
}
// Regular terminal output
else if let timestamp = array[0] as? Double,
let type = array[1] as? String,
let outputData = array[2] as? String {
delegate?.sseClient(self, didReceiveEvent: .terminalOutput(timestamp: timestamp, type: type, data: outputData))
}
}
}
} catch {
print("[SSEClient] Failed to parse event data: \(error)")
}
}
deinit {
stop()
}
}
// MARK: - URLSessionDataDelegate
extension SSEClient: URLSessionDataDelegate {
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
guard let httpResponse = response as? HTTPURLResponse else {
completionHandler(.cancel)
return
}
if httpResponse.statusCode == 200 {
completionHandler(.allow)
} else {
delegate?.sseClient(self, didReceiveEvent: .error("HTTP \(httpResponse.statusCode)"))
completionHandler(.cancel)
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
buffer.append(data)
processBuffer()
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
if (error as NSError).code != NSURLErrorCancelled {
delegate?.sseClient(self, didReceiveEvent: .error(error.localizedDescription))
}
}
}
}
// MARK: - SSEClientDelegate
protocol SSEClientDelegate: AnyObject {
func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent)
}

View file

@ -8,51 +8,57 @@ import UIKit
enum Theme {
// MARK: - Colors
/// Color palette for the app.
/// Color palette for the app with automatic light/dark mode support.
enum Colors {
// Terminal-inspired colors
static let terminalBackground = Color(hex: "0A0E14")
static let terminalForeground = Color(hex: "B3B1AD")
static let terminalSelection = Color(hex: "273747")
// Accent colors
static let primaryAccent = Color(hex: "39BAE6")
// Background colors
static let terminalBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "0A0E14"))
static let cardBackground = Color(light: Color(hex: "F8F9FA"), dark: Color(hex: "0D1117"))
static let headerBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "010409"))
// Border colors
static let cardBorder = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "1C2128"))
// Text colors
static let terminalForeground = Color(light: Color(hex: "24292E"), dark: Color(hex: "B3B1AD"))
// Accent colors (same for both modes)
static let primaryAccent = Color(hex: "00FF88") // Green accent matching web
static let secondaryAccent = Color(hex: "59C2FF")
static let successAccent = Color(hex: "AAD94C")
static let warningAccent = Color(hex: "FFB454")
static let errorAccent = Color(hex: "F07178")
// UI colors
static let cardBackground = Color(hex: "0D1117")
static let cardBorder = Color(hex: "1C2128")
static let headerBackground = Color(hex: "010409")
static let overlayBackground = Color.black.opacity(0.7)
// Selection colors
static let terminalSelection = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "273747"))
// Overlay colors
static let overlayBackground = Color(light: Color.black.opacity(0.5), dark: Color.black.opacity(0.7))
// Additional UI colors for FileBrowser
static let terminalAccent = primaryAccent
static let terminalGray = Color(hex: "8B949E")
static let terminalDarkGray = Color(hex: "161B22")
static let terminalWhite = Color.white
static let terminalGray = Color(light: Color(hex: "586069"), dark: Color(hex: "8B949E"))
static let terminalDarkGray = Color(light: Color(hex: "F6F8FA"), dark: Color(hex: "161B22"))
static let terminalWhite = Color(light: Color(hex: "000000"), dark: Color.white)
// Terminal ANSI colors
static let ansiBlack = Color(hex: "01060E")
static let ansiRed = Color(hex: "EA6C73")
static let ansiGreen = Color(hex: "91B362")
static let ansiYellow = Color(hex: "F9AF4F")
static let ansiBlue = Color(hex: "53BDFA")
static let ansiMagenta = Color(hex: "FAE994")
static let ansiCyan = Color(hex: "90E1C6")
static let ansiWhite = Color(hex: "C7C7C7")
// Terminal ANSI colors - using slightly adjusted colors for light mode
static let ansiBlack = Color(light: Color(hex: "24292E"), dark: Color(hex: "01060E"))
static let ansiRed = Color(light: Color(hex: "D73A49"), dark: Color(hex: "EA6C73"))
static let ansiGreen = Color(light: Color(hex: "28A745"), dark: Color(hex: "91B362"))
static let ansiYellow = Color(light: Color(hex: "DBAB09"), dark: Color(hex: "F9AF4F"))
static let ansiBlue = Color(light: Color(hex: "0366D6"), dark: Color(hex: "53BDFA"))
static let ansiMagenta = Color(light: Color(hex: "6F42C1"), dark: Color(hex: "FAE994"))
static let ansiCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "90E1C6"))
static let ansiWhite = Color(light: Color(hex: "586069"), dark: Color(hex: "C7C7C7"))
// Bright ANSI colors
static let ansiBrightBlack = Color(hex: "686868")
static let ansiBrightRed = Color(hex: "F07178")
static let ansiBrightGreen = Color(hex: "C2D94C")
static let ansiBrightYellow = Color(hex: "FFB454")
static let ansiBrightBlue = Color(hex: "59C2FF")
static let ansiBrightMagenta = Color(hex: "FFEE99")
static let ansiBrightCyan = Color(hex: "95E6CB")
static let ansiBrightWhite = Color(hex: "FFFFFF")
static let ansiBrightBlack = Color(light: Color(hex: "959DA5"), dark: Color(hex: "686868"))
static let ansiBrightRed = Color(light: Color(hex: "CB2431"), dark: Color(hex: "F07178"))
static let ansiBrightGreen = Color(light: Color(hex: "22863A"), dark: Color(hex: "C2D94C"))
static let ansiBrightYellow = Color(light: Color(hex: "B08800"), dark: Color(hex: "FFB454"))
static let ansiBrightBlue = Color(light: Color(hex: "005CC5"), dark: Color(hex: "59C2FF"))
static let ansiBrightMagenta = Color(light: Color(hex: "5A32A3"), dark: Color(hex: "FFEE99"))
static let ansiBrightCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "95E6CB"))
static let ansiBrightWhite = Color(light: Color(hex: "24292E"), dark: Color(hex: "FFFFFF"))
}
// MARK: - Typography
@ -107,21 +113,21 @@ enum Theme {
// MARK: - Shadows
enum CardShadow {
static let color = Color.black.opacity(0.3)
static let color = Color(light: Color.black.opacity(0.1), dark: Color.black.opacity(0.3))
static let radius: CGFloat = 8
static let xOffset: CGFloat = 0
static let yOffset: CGFloat = 2
}
enum ButtonShadow {
static let color = Color.black.opacity(0.2)
static let color = Color(light: Color.black.opacity(0.08), dark: Color.black.opacity(0.2))
static let radius: CGFloat = 4
static let xOffset: CGFloat = 0
static let yOffset: CGFloat = 1
}
}
// MARK: - Color Extension
// MARK: - Color Extensions
extension Color {
init(hex: String) {
@ -148,6 +154,18 @@ extension Color {
opacity: Double(alpha) / 255
)
}
/// Creates a color that automatically adapts to light/dark mode
init(light: Color, dark: Color) {
self.init(UIColor { traitCollection in
switch traitCollection.userInterfaceStyle {
case .dark:
return UIColor(dark)
default:
return UIColor(light)
}
})
}
}
// MARK: - View Modifiers
@ -187,6 +205,14 @@ extension View {
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
)
}
/// Interactive button style with press and hover animations
func interactiveButton(isPressed: Bool = false, isHovered: Bool = false) -> some View {
self
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(Theme.Animation.quick, value: isPressed)
.animation(Theme.Animation.quick, value: isHovered)
}
}
// MARK: - Haptic Feedback
@ -274,4 +300,4 @@ extension View {
}
}
}
}
}

View file

@ -6,25 +6,45 @@ import SwiftUI
/// styled to match the terminal theme.
struct LoadingView: View {
let message: String
let useUnicodeSpinner: Bool
@State private var isAnimating = false
@State private var spinnerFrame = 0
// Unicode spinner frames matching web UI
private let spinnerFrames = ["", "", "", "", "", "", "", "", "", ""]
init(message: String, useUnicodeSpinner: Bool = false) {
self.message = message
self.useUnicodeSpinner = useUnicodeSpinner
}
var body: some View {
VStack(spacing: Theme.Spacing.large) {
ZStack {
Circle()
.stroke(Theme.Colors.cardBorder, lineWidth: 3)
.frame(width: 50, height: 50)
if useUnicodeSpinner {
Text(spinnerFrames[spinnerFrame])
.font(Theme.Typography.terminalSystem(size: 24))
.foregroundColor(Theme.Colors.primaryAccent)
.onAppear {
startUnicodeAnimation()
}
} else {
ZStack {
Circle()
.stroke(Theme.Colors.cardBorder, lineWidth: 3)
.frame(width: 50, height: 50)
Circle()
.trim(from: 0, to: 0.2)
.stroke(Theme.Colors.primaryAccent, lineWidth: 3)
.frame(width: 50, height: 50)
.rotationEffect(Angle(degrees: isAnimating ? 360 : 0))
.animation(
Animation.linear(duration: 1)
.repeatForever(autoreverses: false),
value: isAnimating
)
Circle()
.trim(from: 0, to: 0.2)
.stroke(Theme.Colors.primaryAccent, lineWidth: 3)
.frame(width: 50, height: 50)
.rotationEffect(Angle(degrees: isAnimating ? 360 : 0))
.animation(
Animation.linear(duration: 1)
.repeatForever(autoreverses: false),
value: isAnimating
)
}
}
Text(message)
@ -32,7 +52,17 @@ struct LoadingView: View {
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
.onAppear {
isAnimating = true
if !useUnicodeSpinner {
isAnimating = true
}
}
}
private func startUnicodeAnimation() {
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
Task { @MainActor in
spinnerFrame = (spinnerFrame + 1) % spinnerFrames.count
}
}
}
}

View file

@ -15,11 +15,7 @@ struct ConnectionView: View {
var body: some View {
NavigationStack {
ZStack {
// Background
Theme.Colors.terminalBackground
.ignoresSafeArea()
ScrollView {
// Content
VStack(spacing: Theme.Spacing.extraExtraLarge) {
// Logo and Title
@ -83,7 +79,13 @@ struct ConnectionView: View {
}
.padding()
}
.scrollBounceBehavior(.basedOnSize)
.toolbar(.hidden, for: .navigationBar)
.background {
// Background
Theme.Colors.terminalBackground
.ignoresSafeArea()
}
}
.navigationViewStyle(StackNavigationViewStyle())
.preferredColorScheme(.dark)

View file

@ -0,0 +1,311 @@
import SwiftUI
import WebKit
/// View for previewing files with syntax highlighting
struct FilePreviewView: View {
let path: String
@Environment(\.dismiss) var dismiss
@State private var preview: FilePreview?
@State private var isLoading = true
@State private var error: String?
@State private var showingDiff = false
@State private var gitDiff: FileDiff?
var body: some View {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
if isLoading {
ProgressView("Loading...")
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
} else if let error = error {
VStack {
Text("Error loading file")
.font(.headline)
.foregroundColor(Theme.Colors.errorAccent)
Text(error)
.font(.subheadline)
.foregroundColor(Theme.Colors.terminalForeground)
.multilineTextAlignment(.center)
Button("Retry") {
Task {
await loadPreview()
}
}
.terminalButton()
}
} else if let preview = preview {
previewContent(for: preview)
}
}
.navigationTitle(URL(fileURLWithPath: path).lastPathComponent)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
.foregroundColor(Theme.Colors.primaryAccent)
}
if let preview = preview, preview.type == .text {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Diff") {
showingDiff = true
}
.foregroundColor(Theme.Colors.primaryAccent)
}
}
}
}
.preferredColorScheme(.dark)
.task {
await loadPreview()
}
.sheet(isPresented: $showingDiff) {
if let diff = gitDiff {
GitDiffView(diff: diff)
} else {
ProgressView("Loading diff...")
.task {
await loadDiff()
}
}
}
}
@ViewBuilder
private func previewContent(for preview: FilePreview) -> some View {
switch preview.type {
case .text:
if let content = preview.content {
SyntaxHighlightedView(
content: content,
language: preview.language ?? "text"
)
}
case .image:
if let content = preview.content,
let data = Data(base64Encoded: content),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
}
case .binary:
VStack(spacing: Theme.Spacing.large) {
Image(systemName: "doc.zipper")
.font(.system(size: 64))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
Text("Binary File")
.font(.headline)
.foregroundColor(Theme.Colors.terminalForeground)
if let size = preview.size {
Text(formatFileSize(size))
.font(.caption)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
}
}
}
private func loadPreview() async {
isLoading = true
error = nil
do {
preview = try await APIClient.shared.previewFile(path: path)
isLoading = false
} catch {
self.error = error.localizedDescription
isLoading = false
}
}
private func loadDiff() async {
do {
gitDiff = try await APIClient.shared.getGitDiff(path: path)
} catch {
// Silently fail - diff might not be available
}
}
private func formatFileSize(_ size: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .binary
return formatter.string(fromByteCount: size)
}
}
/// WebView-based syntax highlighted text view
struct SyntaxHighlightedView: UIViewRepresentable {
let content: String
let language: String
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(Theme.Colors.cardBackground)
webView.scrollView.backgroundColor = UIColor(Theme.Colors.cardBackground)
loadContent(in: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Content is static, no updates needed
}
private func loadContent(in webView: WKWebView) {
let escapedContent = content
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
let html = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
<style>
body {
margin: 0;
padding: 16px;
background: #1a1a1a;
color: #e0e0e0;
font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
}
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
code {
font-family: inherit;
}
.hljs {
background: transparent;
padding: 0;
}
</style>
</head>
<body>
<pre><code class="\(language)">\(escapedContent)</code></pre>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<script>
hljs.highlightAll();
</script>
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: nil)
}
}
/// View for displaying git diffs
struct GitDiffView: View {
let diff: FileDiff
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
DiffWebView(content: diff.diff)
}
.navigationTitle("Git Diff")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
.foregroundColor(Theme.Colors.primaryAccent)
}
}
}
.preferredColorScheme(.dark)
}
}
/// WebView for displaying diffs with syntax highlighting
struct DiffWebView: UIViewRepresentable {
let content: String
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(Theme.Colors.cardBackground)
loadDiff(in: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Content is static
}
private func loadDiff(in webView: WKWebView) {
let escapedContent = content
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
let html = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
<style>
body {
margin: 0;
padding: 16px;
background: #1a1a1a;
color: #e0e0e0;
font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
}
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.hljs-addition {
background-color: rgba(80, 250, 123, 0.1);
color: #50fa7b;
}
.hljs-deletion {
background-color: rgba(255, 85, 85, 0.1);
color: #ff5555;
}
</style>
</head>
<body>
<pre><code class="diff">\(escapedContent)</code></pre>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: nil)
}
}

View file

@ -17,20 +17,25 @@ struct FileBrowserView: View {
@State private var showingDeleteAlert = false
@StateObject private var quickLookManager = QuickLookManager.shared
@State private var showingQuickLook = false
@State private var showingFilePreview = false
@State private var previewPath: String?
let onSelect: (String) -> Void
let initialPath: String
let mode: FileBrowserMode
let onInsertPath: ((String, Bool) -> Void)? // Path and isDirectory
enum FileBrowserMode {
case selectDirectory
case browseFiles
case insertPath // New mode for inserting paths into terminal
}
init(initialPath: String = "~", mode: FileBrowserMode = .selectDirectory, onSelect: @escaping (String) -> Void) {
init(initialPath: String = "~", mode: FileBrowserMode = .selectDirectory, onSelect: @escaping (String) -> Void, onInsertPath: ((String, Bool) -> Void)? = nil) {
self.initialPath = initialPath
self.mode = mode
self.onSelect = onSelect
self.onInsertPath = onInsertPath
}
private var navigationHeader: some View {
@ -159,14 +164,15 @@ struct FileBrowserView: View {
modifiedTime: entry.formattedDate,
gitStatus: entry.gitStatus
) {
if entry.isDir {
if entry.isDir && mode != .insertPath {
viewModel.navigate(to: entry.path)
} else if mode == .browseFiles {
// Preview file with Quick Look
selectedFile = entry
Task {
await viewModel.previewFile(entry)
}
// Preview file with our custom preview
previewPath = entry.path
showingFilePreview = true
} else if mode == .insertPath {
// Insert the path into terminal
insertPath(entry.path, isDirectory: entry.isDir)
}
}
.transition(.opacity)
@ -364,6 +370,11 @@ struct FileBrowserView: View {
QuickLookWrapper(quickLookManager: quickLookManager)
.ignoresSafeArea()
}
.sheet(isPresented: $showingFilePreview) {
if let path = previewPath {
FilePreviewView(path: path)
}
}
.overlay {
if quickLookManager.isDownloading {
ZStack {
@ -397,6 +408,22 @@ struct FileBrowserView: View {
viewModel.loadDirectory(path: initialPath)
}
}
// MARK: - Helper Methods
private func insertPath(_ path: String, isDirectory: Bool) {
// Escape the path if it contains spaces
let escapedPath = path.contains(" ") ? "\"\(path)\"" : path
// Call the insertion handler
onInsertPath?(escapedPath, isDirectory)
// Provide haptic feedback
HapticFeedback.impact(.light)
// Dismiss the file browser
dismiss()
}
}
/// Row component for displaying file or directory information.

View file

@ -16,6 +16,8 @@ struct SessionCardView: View {
@State private var isKilling = false
@State private var opacity: Double = 1.0
@State private var scale: CGFloat = 1.0
@State private var rotation: Double = 0
@State private var brightness: Double = 1.0
private var displayWorkingDir: String {
// Convert absolute paths back to ~ notation for display
@ -33,7 +35,7 @@ struct SessionCardView: View {
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
// Header with session ID/name and kill button
HStack {
Text(session.name ?? String(session.id.prefix(8)))
Text(session.name)
.font(Theme.Typography.terminalSystem(size: 14))
.fontWeight(.medium)
.foregroundColor(Theme.Colors.primaryAccent)
@ -49,13 +51,19 @@ struct SessionCardView: View {
animateCleanup()
}
}, label: {
Image(systemName: session.isRunning ? "xmark.circle" : "trash.circle")
.font(.system(size: 18))
.foregroundColor(session.isRunning ? Theme.Colors.errorAccent : Theme.Colors
.terminalForeground.opacity(0.6)
)
if isKilling {
LoadingView(message: "", useUnicodeSpinner: true)
.scaleEffect(0.7)
.frame(width: 18, height: 18)
} else {
Image(systemName: session.isRunning ? "xmark.circle" : "trash.circle")
.font(.system(size: 18))
.foregroundColor(session.isRunning ? Theme.Colors.errorAccent : Theme.Colors
.terminalForeground.opacity(0.6)
)
}
})
.buttonStyle(PlainButtonStyle())
.buttonStyle(.plain)
}
// Terminal content area showing command and terminal output preview
@ -103,7 +111,7 @@ struct SessionCardView: View {
Text("$")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.primaryAccent)
Text(session.command)
Text(session.command.joined(separator: " "))
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground)
}
@ -203,8 +211,10 @@ struct SessionCardView: View {
)
.scaleEffect(isPressed ? 0.98 : scale)
.opacity(opacity)
.rotationEffect(.degrees(rotation))
.brightness(brightness)
}
.buttonStyle(PlainButtonStyle())
.buttonStyle(.plain)
.onLongPressGesture(
minimumDuration: 0.1,
maximumDistance: .infinity,
@ -280,14 +290,21 @@ struct SessionCardView: View {
}
private func animateCleanup() {
// Shrink and fade animation for cleanup
withAnimation(.easeOut(duration: 0.3)) {
scale = 0.8
// Black hole collapse animation matching web
withAnimation(.easeInOut(duration: 0.3)) {
scale = 0
rotation = 360
brightness = 0.3
opacity = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
onCleanup()
// Reset values for potential reuse
scale = 1.0
rotation = 0
brightness = 1.0
opacity = 1.0
}
}
}

View file

@ -1,5 +1,6 @@
import Observation
import SwiftUI
import UniformTypeIdentifiers
/// Main view displaying the list of terminal sessions.
///
@ -16,6 +17,8 @@ struct SessionListView: View {
@State private var showingFileBrowser = false
@State private var showingSettings = false
@State private var searchText = ""
@State private var showingCastImporter = false
@State private var importedCastFile: CastFileItem?
var filteredSessions: [Session] {
let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning }
@ -26,11 +29,11 @@ struct SessionListView: View {
return sessions.filter { session in
// Search in session name
if let name = session.name, name.localizedCaseInsensitiveContains(searchText) {
if session.name.localizedCaseInsensitiveContains(searchText) {
return true
}
// Search in command
if session.command.localizedCaseInsensitiveContains(searchText) {
if session.command.joined(separator: " ").localizedCaseInsensitiveContains(searchText) {
return true
}
// Search in working directory
@ -94,14 +97,25 @@ struct SessionListView: View {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: Theme.Spacing.medium) {
Button(action: {
HapticFeedback.impact(.light)
showingSettings = true
}, label: {
Image(systemName: "gearshape.fill")
Menu {
Button(action: {
HapticFeedback.impact(.light)
showingSettings = true
}) {
Label("Settings", systemImage: "gearshape")
}
Button(action: {
HapticFeedback.impact(.light)
showingCastImporter = true
}) {
Label("Import Recording", systemImage: "square.and.arrow.down")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.title3)
.foregroundColor(Theme.Colors.primaryAccent)
})
}
Button(action: {
HapticFeedback.impact(.light)
@ -134,7 +148,7 @@ struct SessionListView: View {
}
}
}
.sheet(item: $selectedSession) { session in
.fullScreenCover(item: $selectedSession) { session in
TerminalView(session: session)
}
.sheet(isPresented: $showingFileBrowser) {
@ -145,6 +159,23 @@ struct SessionListView: View {
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.fileImporter(
isPresented: $showingCastImporter,
allowedContentTypes: [.json, .data],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
if let url = urls.first {
importedCastFile = CastFileItem(url: url)
}
case .failure(let error):
print("Failed to import cast file: \(error)")
}
}
.sheet(item: $importedCastFile) { item in
CastPlayerView(castFileURL: item.url)
}
.refreshable {
await viewModel.loadSessions()
}
@ -156,7 +187,6 @@ struct SessionListView: View {
viewModel.stopAutoRefresh()
}
}
.preferredColorScheme(.dark)
.onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in
if shouldNavigate,
let sessionId = navigationManager.selectedSessionId,
@ -248,6 +278,12 @@ struct SessionListView: View {
}
)
.padding(.horizontal)
.padding(.vertical, Theme.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.fill(Theme.Colors.terminalForeground.opacity(0.03))
)
.padding(.horizontal)
// Sessions grid
LazyVGrid(columns: [
@ -462,54 +498,58 @@ struct SessionHeaderView: View {
private var exitedCount: Int { sessions.count(where: { !$0.isRunning }) }
var body: some View {
HStack {
SessionCountView(runningCount: runningCount, exitedCount: exitedCount)
Spacer()
if exitedCount > 0 {
ExitedSessionToggle(showExitedSessions: $showExitedSessions)
VStack(spacing: Theme.Spacing.medium) {
// Session counts
HStack(spacing: Theme.Spacing.extraLarge) {
SessionCountBadge(
label: "Running",
count: runningCount,
color: Theme.Colors.successAccent
)
SessionCountBadge(
label: "Exited",
count: exitedCount,
color: Theme.Colors.errorAccent
)
Spacer()
}
if sessions.contains(where: \.isRunning) {
KillAllButton(onKillAll: onKillAll)
// Action buttons
HStack(spacing: Theme.Spacing.medium) {
if exitedCount > 0 {
ExitedSessionToggle(showExitedSessions: $showExitedSessions)
}
Spacer()
if sessions.contains(where: \.isRunning) {
KillAllButton(onKillAll: onKillAll)
}
}
}
.padding(.vertical, Theme.Spacing.small)
}
}
struct SessionCountView: View {
let runningCount: Int
let exitedCount: Int
struct SessionCountBadge: View {
let label: String
let count: Int
let color: Color
var body: some View {
HStack(spacing: Theme.Spacing.medium) {
if runningCount > 0 {
HStack(spacing: 4) {
Text("Running:")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
Text("\(runningCount)")
.foregroundColor(Theme.Colors.successAccent)
.fontWeight(.semibold)
}
}
if exitedCount > 0 {
HStack(spacing: 4) {
Text("Exited:")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
Text("\(exitedCount)")
.foregroundColor(Theme.Colors.errorAccent)
.fontWeight(.semibold)
}
}
if runningCount == 0 && exitedCount == 0 {
Text("No Sessions")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.textCase(.uppercase)
Text("\(count)")
.font(Theme.Typography.terminalSystem(size: 28))
.fontWeight(.bold)
.foregroundColor(color)
}
.font(Theme.Typography.terminalSystem(size: 16))
}
}
@ -523,18 +563,22 @@ struct ExitedSessionToggle: View {
showExitedSessions.toggle()
}
}, label: {
HStack(spacing: 4) {
HStack(spacing: 6) {
Image(systemName: showExitedSessions ? "eye.slash" : "eye")
.font(.caption)
.font(.system(size: 14))
Text(showExitedSessions ? "Hide Exited" : "Show Exited")
.font(Theme.Typography.terminalSystem(size: 12))
.font(Theme.Typography.terminalSystem(size: 14))
}
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
.padding(.horizontal, Theme.Spacing.small)
.padding(.vertical, 4)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.8))
.padding(.horizontal, Theme.Spacing.medium)
.padding(.vertical, Theme.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(Theme.Colors.terminalForeground.opacity(0.1))
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.Colors.terminalForeground.opacity(0.08))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.Colors.terminalForeground.opacity(0.15), lineWidth: 1)
)
)
})
.buttonStyle(PlainButtonStyle())
@ -549,21 +593,19 @@ struct KillAllButton: View {
HapticFeedback.impact(.medium)
onKillAll()
}, label: {
HStack(spacing: Theme.Spacing.small) {
Image(systemName: "stop.circle")
HStack(spacing: 6) {
Image(systemName: "stop.circle.fill")
.font(.system(size: 14))
Text("Kill All")
.fontWeight(.medium)
}
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.errorAccent)
.foregroundColor(.white)
.padding(.horizontal, Theme.Spacing.medium)
.padding(.vertical, Theme.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(Theme.Colors.errorAccent.opacity(0.1))
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(Theme.Colors.errorAccent.opacity(0.3), lineWidth: 1)
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.Colors.errorAccent)
)
})
.buttonStyle(PlainButtonStyle())
@ -602,3 +644,9 @@ struct CleanupAllButton: View {
))
}
}
/// Wrapper for cast file URL to make it Identifiable
struct CastFileItem: Identifiable {
let id = UUID()
let url: URL
}

View file

@ -180,6 +180,7 @@ struct AdvancedSettingsView: View {
private var verboseLogging = false
@AppStorage("debugModeEnabled")
private var debugModeEnabled = false
@State private var showingSystemLogs = false
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
@ -209,6 +210,24 @@ struct AdvancedSettingsView: View {
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card)
// View System Logs Button
Button(action: { showingSystemLogs = true }) {
HStack {
Image(systemName: "doc.text")
.foregroundColor(Theme.Colors.primaryAccent)
Text("View System Logs")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.card)
}
.buttonStyle(PlainButtonStyle())
}
}
@ -245,6 +264,9 @@ struct AdvancedSettingsView: View {
Spacer()
}
.sheet(isPresented: $showingSystemLogs) {
SystemLogsView()
}
}
}

View file

@ -0,0 +1,345 @@
import SwiftUI
/// System logs viewer with filtering and search capabilities
struct SystemLogsView: View {
@Environment(\.dismiss) var dismiss
@State private var logs = ""
@State private var isLoading = true
@State private var error: String?
@State private var searchText = ""
@State private var selectedLevel: LogLevel = .all
@State private var showClientLogs = true
@State private var showServerLogs = true
@State private var autoScroll = true
@State private var refreshTimer: Timer?
@State private var showingClearConfirmation = false
@State private var logsInfo: LogsInfo?
enum LogLevel: String, CaseIterable {
case all = "All"
case error = "Error"
case warn = "Warn"
case log = "Log"
case debug = "Debug"
var displayName: String { rawValue }
func matches(_ line: String) -> Bool {
switch self {
case .all:
return true
case .error:
return line.localizedCaseInsensitiveContains("[ERROR]") ||
line.localizedCaseInsensitiveContains("error:")
case .warn:
return line.localizedCaseInsensitiveContains("[WARN]") ||
line.localizedCaseInsensitiveContains("warning:")
case .log:
return line.localizedCaseInsensitiveContains("[LOG]") ||
line.localizedCaseInsensitiveContains("log:")
case .debug:
return line.localizedCaseInsensitiveContains("[DEBUG]") ||
line.localizedCaseInsensitiveContains("debug:")
}
}
}
var filteredLogs: String {
let lines = logs.components(separatedBy: .newlines)
let filtered = lines.filter { line in
// Skip empty lines
guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
// Filter by level
if selectedLevel != .all && !selectedLevel.matches(line) {
return false
}
// Filter by source
let isClientLog = line.contains("[Client]") || line.contains("client:")
let isServerLog = line.contains("[Server]") || line.contains("server:") || (!isClientLog)
if !showClientLogs && isClientLog {
return false
}
if !showServerLogs && isServerLog {
return false
}
// Filter by search text
if !searchText.isEmpty && !line.localizedCaseInsensitiveContains(searchText) {
return false
}
return true
}
return filtered.joined(separator: "\n")
}
var body: some View {
NavigationStack {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
VStack(spacing: 0) {
// Filters toolbar
filtersToolbar
// Search bar
searchBar
// Logs content
if isLoading {
ProgressView("Loading logs...")
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = error {
VStack {
Text("Error loading logs")
.font(.headline)
.foregroundColor(Theme.Colors.errorAccent)
Text(error)
.font(.subheadline)
.foregroundColor(Theme.Colors.terminalForeground)
.multilineTextAlignment(.center)
Button("Retry") {
Task {
await loadLogs()
}
}
.terminalButton()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
logsContent
}
}
}
.navigationTitle("System Logs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
.foregroundColor(Theme.Colors.primaryAccent)
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button(action: downloadLogs) {
Label("Download", systemImage: "square.and.arrow.down")
}
Button(action: { showingClearConfirmation = true }) {
Label("Clear Logs", systemImage: "trash")
}
Toggle("Auto-scroll", isOn: $autoScroll)
if let info = logsInfo {
Section {
Label(formatFileSize(info.size), systemImage: "doc")
}
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundColor(Theme.Colors.primaryAccent)
}
}
}
}
.preferredColorScheme(.dark)
.task {
await loadLogs()
startAutoRefresh()
}
.onDisappear {
stopAutoRefresh()
}
.alert("Clear Logs", isPresented: $showingClearConfirmation) {
Button("Cancel", role: .cancel) {}
Button("Clear", role: .destructive) {
Task {
await clearLogs()
}
}
} message: {
Text("Are you sure you want to clear all system logs? This action cannot be undone.")
}
}
private var filtersToolbar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// Level filter
Menu {
ForEach(LogLevel.allCases, id: \.self) { level in
Button(action: { selectedLevel = level }) {
HStack {
Text(level.displayName)
if selectedLevel == level {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "line.horizontal.3.decrease.circle")
Text(selectedLevel.displayName)
}
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.Colors.cardBackground)
.cornerRadius(6)
}
// Source toggles
Toggle("Client", isOn: $showClientLogs)
.toggleStyle(ChipToggleStyle())
Toggle("Server", isOn: $showServerLogs)
.toggleStyle(ChipToggleStyle())
Spacer()
}
.padding(.horizontal)
}
.padding(.vertical, 8)
.background(Theme.Colors.cardBackground)
}
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
TextField("Search logs...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground)
.autocapitalization(.none)
.disableAutocorrection(true)
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Theme.Colors.terminalDarkGray)
}
private var logsContent: some View {
ScrollViewReader { proxy in
ScrollView {
Text(filteredLogs.isEmpty ? "No logs matching filters" : filteredLogs)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.textSelection(.enabled)
.id("bottom")
}
.background(Theme.Colors.terminalDarkGray)
.onChange(of: filteredLogs) { _, _ in
if autoScroll {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
}
}
private func loadLogs() async {
isLoading = true
error = nil
do {
// Load logs content
logs = try await APIClient.shared.getLogsRaw()
// Load logs info
logsInfo = try await APIClient.shared.getLogsInfo()
isLoading = false
} catch {
self.error = error.localizedDescription
isLoading = false
}
}
private func clearLogs() async {
do {
try await APIClient.shared.clearLogs()
logs = ""
await loadLogs()
} catch {
self.error = error.localizedDescription
}
}
private func downloadLogs() {
// Create activity controller with logs
let activityVC = UIActivityViewController(
activityItems: [logs],
applicationActivities: nil
)
// Present it
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootVC = window.rootViewController {
rootVC.present(activityVC, animated: true)
}
}
private func startAutoRefresh() {
refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in
Task {
await loadLogs()
}
}
}
private func stopAutoRefresh() {
refreshTimer?.invalidate()
refreshTimer = nil
}
private func formatFileSize(_ size: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .binary
return formatter.string(fromByteCount: size)
}
}
/// Custom toggle style for filter chips
struct ChipToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 4) {
if configuration.isOn {
Image(systemName: "checkmark")
.font(.caption2)
}
configuration.label
}
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(configuration.isOn ? Theme.Colors.primaryAccent.opacity(0.2) : Theme.Colors.cardBackground)
.foregroundColor(configuration.isOn ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
.cornerRadius(6)
}
.buttonStyle(PlainButtonStyle())
}
}

View file

@ -4,30 +4,53 @@ import SwiftUI
struct ScrollToBottomButton: View {
let isVisible: Bool
let action: () -> Void
@State private var isHovered = false
@State private var isPressed = false
var body: some View {
Button(action: {
HapticFeedback.impact(.light)
action()
}) {
Image(systemName: "arrow.down.to.line")
.font(.system(size: 20, weight: .medium))
.foregroundColor(Theme.Colors.terminalForeground)
Text("")
.font(.system(size: 24, weight: .bold))
.foregroundColor(isHovered ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
.frame(width: 48, height: 48)
.background(
Circle()
.fill(Theme.Colors.cardBackground.opacity(0.95))
.fill(isHovered ? Theme.Colors.cardBackground : Theme.Colors.cardBackground.opacity(0.8))
.overlay(
Circle()
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
.stroke(
isHovered ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder,
lineWidth: isHovered ? 2 : 1
)
)
)
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
.shadow(
color: isHovered ? Theme.Colors.primaryAccent.opacity(0.3) : .black.opacity(0.3),
radius: isHovered ? 12 : 8,
x: 0,
y: isHovered ? 3 : 4
)
.scaleEffect(isPressed ? 0.95 : 1.0)
.offset(y: isHovered && !isPressed ? -1 : 0)
}
.buttonStyle(PlainButtonStyle())
.opacity(isVisible ? 1 : 0)
.scaleEffect(isVisible ? 1 : 0.8)
.animation(Theme.Animation.quick, value: isHovered)
.animation(Theme.Animation.quick, value: isPressed)
.animation(Theme.Animation.smooth, value: isVisible)
.allowsHitTesting(isVisible)
.onLongPressGesture(minimumDuration: 0, maximumDistance: .infinity) { pressing in
isPressed = pressing
} perform: {
// Action handled by button
}
.onHover { hovering in
isHovered = hovering
}
}
}

View file

@ -117,13 +117,10 @@ struct TerminalHostingView: UIViewRepresentable {
}
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
let font: UIFont = if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
customFont
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
fallbackFont
} else {
UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
// Use system monospaced font which has better compatibility with SwiftTerm
// The custom SF Mono font seems to have rendering issues
let font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
// SwiftTerm uses the font property directly
terminal.font = font
}
@ -181,6 +178,8 @@ struct TerminalHostingView: UIViewRepresentable {
/// Update terminal buffer from binary buffer data using optimized ANSI sequences
func updateBuffer(from snapshot: BufferSnapshot) {
guard let terminal else { return }
print("[Terminal] updateBuffer called with snapshot: \(snapshot.cols)x\(snapshot.rows), cursor: (\(snapshot.cursorX),\(snapshot.cursorY))")
// Update terminal dimensions if needed
let currentCols = terminal.getTerminal().cols
@ -204,11 +203,13 @@ struct TerminalHostingView: UIViewRepresentable {
let ansiData: String
if isFirstUpdate || previousSnapshot == nil || viewportChanged {
// Full redraw needed
ansiData = convertBufferToOptimizedANSI(snapshot)
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate)
isFirstUpdate = false
print("[Terminal] Full redraw performed")
} else {
// Incremental update
ansiData = generateIncrementalUpdate(from: previousSnapshot!, to: snapshot)
print("[Terminal] Incremental update performed")
}
// Store current snapshot for next update
@ -247,11 +248,16 @@ struct TerminalHostingView: UIViewRepresentable {
}
}
private func convertBufferToOptimizedANSI(_ snapshot: BufferSnapshot) -> String {
private func convertBufferToOptimizedANSI(_ snapshot: BufferSnapshot, clearScreen: Bool = false) -> String {
var output = ""
// Clear screen and reset cursor
output += "\u{001B}[2J\u{001B}[H"
if clearScreen {
// Clear screen and reset cursor for first update
output += "\u{001B}[2J\u{001B}[H"
} else {
// Just reset cursor to home position
output += "\u{001B}[H"
}
// Track current attributes to minimize escape sequences
var currentFg: Int?
@ -584,6 +590,31 @@ struct TerminalHostingView: UIViewRepresentable {
}
}
}
func getBufferContent() -> String? {
guard let terminal else { return nil }
// Get the terminal buffer content
let terminalInstance = terminal.getTerminal()
var content = ""
// Read all lines from the terminal buffer
for row in 0..<terminalInstance.rows {
if let line = terminalInstance.getLine(row: row) {
var lineText = ""
for col in 0..<terminalInstance.cols {
if col < line.count {
let char = line[col]
lineText += String(char.getCharacter())
}
}
// Trim trailing spaces
content += lineText.trimmingCharacters(in: .whitespaces) + "\n"
}
}
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - TerminalViewDelegate
@ -614,6 +645,14 @@ struct TerminalHostingView: UIViewRepresentable {
terminal.feed(text: "\u{001b}[B")
}
}
func setMaxWidth(_ maxWidth: Int) {
// Store the max width preference for terminal rendering
// When maxWidth is 0, it means unlimited
// This could be used to constrain terminal rendering in the future
// For now, just log the preference
print("[Terminal] Max width set to: \(maxWidth == 0 ? "unlimited" : "\(maxWidth) columns")")
}
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
// Handle title change if needed

View file

@ -28,7 +28,7 @@ struct TerminalToolbar: View {
HStack(spacing: Theme.Spacing.extraSmall) {
// Tab key
ToolbarButton(label: "TAB", systemImage: "arrow.right.to.line.compact") {
ToolbarButton(label: "") {
HapticFeedback.impact(.light)
onSpecialKey(.tab)
}
@ -133,7 +133,7 @@ struct TerminalToolbar: View {
onSpecialKey(.ctrlZ)
}
ToolbarButton(label: "ENTER") {
ToolbarButton(label: "") {
HapticFeedback.impact(.light)
onSpecialKey(.enter)
}
@ -198,6 +198,7 @@ struct ToolbarButton: View {
let height: CGFloat?
let isActive: Bool
let action: () -> Void
@State private var isPressed = false
init(
label: String? = nil,
@ -227,23 +228,37 @@ struct ToolbarButton: View {
.font(.system(size: 16))
}
}
.foregroundColor(isActive ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
.foregroundColor(isActive || isPressed ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
.frame(width: width, height: height ?? 44)
.frame(maxWidth: width == nil ? .infinity : nil)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(isActive ? Theme.Colors.primaryAccent.opacity(0.2) : Theme.Colors.cardBorder.opacity(0.3))
.fill(
isActive ? Theme.Colors.primaryAccent.opacity(0.2) :
isPressed ? Theme.Colors.primaryAccent.opacity(0.1) :
Theme.Colors.cardBorder.opacity(0.3)
)
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(
isActive ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder,
lineWidth: 1
isActive || isPressed ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder,
lineWidth: isActive || isPressed ? 2 : 1
)
)
.shadow(
color: isActive || isPressed ? Theme.Colors.primaryAccent.opacity(0.2) : .clear,
radius: isActive || isPressed ? 4 : 0
)
}
.buttonStyle(PlainButtonStyle())
.scaleEffect(isActive ? 0.95 : 1.0)
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(Theme.Animation.quick, value: isActive)
.animation(Theme.Animation.quick, value: isPressed)
.onLongPressGesture(minimumDuration: 0, maximumDistance: .infinity) { pressing in
isPressed = pressing
} perform: {
// Action handled by button
}
}
}

View file

@ -20,6 +20,12 @@ struct TerminalView: View {
@State private var keyboardHeight: CGFloat = 0
@State private var showScrollToBottom = false
@State private var showingFileBrowser = false
@State private var selectedRenderer = TerminalRenderer.selected
@State private var showingDebugMenu = false
@State private var showingExportSheet = false
@State private var exportedFileURL: URL?
@State private var showingWidthSelector = false
@State private var currentTerminalWidth: TerminalWidth = .unlimited
@FocusState private var isInputFocused: Bool
init(session: Session) {
@ -71,10 +77,16 @@ struct TerminalView: View {
.sheet(isPresented: $showingFileBrowser) {
FileBrowserView(
initialPath: session.workingDir,
mode: .browseFiles
) { selectedPath in
showingFileBrowser = false
}
mode: .insertPath,
onSelect: { _ in
showingFileBrowser = false
},
onInsertPath: { [weak viewModel] path, isDirectory in
// Insert the path into the terminal
viewModel?.sendInput(path)
showingFileBrowser = false
}
)
}
.gesture(
DragGesture()
@ -104,6 +116,14 @@ struct TerminalView: View {
viewModel.resize(cols: width, rows: newHeight)
}
}
.onChange(of: currentTerminalWidth) { _, newWidth in
let targetWidth = newWidth.value == 0 ? nil : newWidth.value
if targetWidth != selectedTerminalWidth {
selectedTerminalWidth = targetWidth
viewModel.setMaxWidth(targetWidth ?? 0)
TerminalWidthManager.shared.defaultWidth = newWidth.value
}
}
.onChange(of: viewModel.isAtBottom) { _, newValue in
withAnimation(Theme.Animation.smooth) {
showScrollToBottom = !newValue
@ -116,6 +136,33 @@ struct TerminalView: View {
}
return .ignored
}
.sheet(isPresented: $showingExportSheet) {
if let url = exportedFileURL {
ShareSheet(items: [url])
.onDisappear {
// Clean up temporary file
try? FileManager.default.removeItem(at: url)
exportedFileURL = nil
}
}
}
}
// MARK: - Export Functions
private func exportTerminalBuffer() {
guard let bufferContent = viewModel.getBufferContent() else { return }
let fileName = "\(session.displayName)_\(Date().timeIntervalSince1970).txt"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
do {
try bufferContent.write(to: tempURL, atomically: true, encoding: .utf8)
exportedFileURL = tempURL
showingExportSheet = true
} catch {
print("Failed to export terminal buffer: \(error)")
}
}
// MARK: - View Components
@ -146,7 +193,9 @@ struct TerminalView: View {
.foregroundColor(Theme.Colors.primaryAccent)
}
ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItemGroup(placement: .navigationBarTrailing) {
fileBrowserButton
widthSelectorButton
menuButton
}
}
@ -156,6 +205,7 @@ struct TerminalView: View {
ToolbarItemGroup(placement: .bottomBar) {
terminalSizeIndicator
Spacer()
connectionStatusIndicator
sessionStatusIndicator
pidIndicator
}
@ -171,6 +221,44 @@ struct TerminalView: View {
// MARK: - Toolbar Components
private var fileBrowserButton: some View {
Button(action: {
HapticFeedback.impact(.light)
showingFileBrowser = true
}) {
Image(systemName: "folder")
.font(.system(size: 16))
.foregroundColor(Theme.Colors.primaryAccent)
}
}
private var widthSelectorButton: some View {
Button(action: { showingWidthSelector = true }) {
HStack(spacing: 2) {
Image(systemName: "arrow.left.and.right")
.font(.system(size: 12))
Text(currentTerminalWidth.label)
.font(Theme.Typography.terminalSystem(size: 14))
.fontWeight(.medium)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.Colors.cardBackground)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Theme.Colors.primaryAccent.opacity(0.3), lineWidth: 1)
)
}
.foregroundColor(Theme.Colors.primaryAccent)
.popover(isPresented: $showingWidthSelector, arrowEdge: .top) {
WidthSelectorPopover(
currentWidth: $currentTerminalWidth,
isPresented: $showingWidthSelector
)
}
}
private var menuButton: some View {
Menu {
terminalMenuItems
@ -186,9 +274,39 @@ struct TerminalView: View {
Label("Clear", systemImage: "clear")
})
Button(action: { showingFontSizeSheet = true }, label: {
Label("Font Size", systemImage: "textformat.size")
})
Menu {
Button(action: {
fontSize = max(8, fontSize - 1)
HapticFeedback.impact(.light)
}, label: {
Label("Decrease", systemImage: "minus")
})
.disabled(fontSize <= 8)
Button(action: {
fontSize = min(32, fontSize + 1)
HapticFeedback.impact(.light)
}, label: {
Label("Increase", systemImage: "plus")
})
.disabled(fontSize >= 32)
Button(action: {
fontSize = 14
HapticFeedback.impact(.light)
}, label: {
Label("Reset to Default", systemImage: "arrow.counterclockwise")
})
.disabled(fontSize == 14)
Divider()
Button(action: { showingFontSizeSheet = true }, label: {
Label("More Options...", systemImage: "slider.horizontal.3")
})
} label: {
Label("Font Size (\(Int(fontSize))pt)", systemImage: "textformat.size")
}
Button(action: { showingTerminalWidthSheet = true }, label: {
Label("Terminal Width", systemImage: "arrow.left.and.right")
@ -206,12 +324,20 @@ struct TerminalView: View {
})
Button(action: { viewModel.copyBuffer() }, label: {
Label("Copy All", systemImage: "doc.on.doc")
Label("Copy All", systemImage: "square.on.square")
})
Button(action: { exportTerminalBuffer() }, label: {
Label("Export as Text", systemImage: "square.and.arrow.up")
})
Divider()
recordingMenuItems
Divider()
debugMenuItems
}
@ViewBuilder
@ -236,6 +362,28 @@ struct TerminalView: View {
.disabled(viewModel.castRecorder.events.isEmpty)
}
@ViewBuilder
private var debugMenuItems: some View {
Menu {
ForEach(TerminalRenderer.allCases, id: \.self) { renderer in
Button(action: {
selectedRenderer = renderer
TerminalRenderer.selected = renderer
viewModel.terminalViewId = UUID() // Force recreate terminal view
}) {
HStack {
Text(renderer.displayName)
if renderer == selectedRenderer {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label("Terminal Renderer", systemImage: "gearshape.2")
}
}
@ViewBuilder
private var terminalSizeIndicator: some View {
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
@ -250,6 +398,24 @@ struct TerminalView: View {
}
}
private var connectionStatusIndicator: some View {
HStack(spacing: 4) {
if viewModel.isConnecting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(0.5)
.frame(width: 12, height: 12)
} else {
Image(systemName: viewModel.isConnected ? "wifi" : "wifi.slash")
.font(.system(size: 10))
.foregroundColor(viewModel.isConnected ? Theme.Colors.successAccent : Theme.Colors.errorAccent)
}
Text(viewModel.isConnecting ? "Connecting" : (viewModel.isConnected ? "Connected" : "Disconnected"))
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(viewModel.isConnected ? Theme.Colors.successAccent : Theme.Colors.errorAccent)
}
}
private var sessionStatusIndicator: some View {
HStack(spacing: 4) {
Circle()
@ -338,21 +504,41 @@ struct TerminalView: View {
private var terminalContent: some View {
VStack(spacing: 0) {
// Terminal hosting view
TerminalHostingView(
session: session,
fontSize: $fontSize,
theme: selectedTheme,
onInput: { text in
viewModel.sendInput(text)
},
onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows)
},
viewModel: viewModel
)
// Terminal view based on selected renderer
Group {
switch selectedRenderer {
case .swiftTerm:
TerminalHostingView(
session: session,
fontSize: $fontSize,
theme: selectedTheme,
onInput: { text in
viewModel.sendInput(text)
},
onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows)
},
viewModel: viewModel
)
case .xterm:
XtermWebView(
session: session,
fontSize: $fontSize,
theme: selectedTheme,
onInput: { text in
viewModel.sendInput(text)
},
onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows)
},
viewModel: viewModel
)
}
}
.id(viewModel.terminalViewId)
.background(selectedTheme.background)
.focused($isInputFocused)
@ -363,12 +549,6 @@ struct TerminalView: View {
showScrollToBottom = false
}
)
.fileBrowserFABOverlay(
isVisible: !keyboardHeight.isZero && session.isRunning,
action: {
showingFileBrowser = true
}
)
// Keyboard toolbar
if keyboardHeight > 0 {
@ -407,7 +587,7 @@ class TerminalViewModel {
let session: Session
let castRecorder: CastRecorder
private var bufferWebSocketClient: BufferWebSocketClient?
var bufferWebSocketClient: BufferWebSocketClient?
private var connectionStatusTask: Task<Void, Never>?
private var connectionErrorTask: Task<Void, Never>?
weak var terminalCoordinator: TerminalHostingView.Coordinator?
@ -577,6 +757,13 @@ class TerminalViewModel {
if castRecorder.isRecording {
stopRecording()
}
// Load final snapshot for exited session
Task { @MainActor in
// Give the server a moment to finalize the snapshot
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
await loadSnapshot()
}
case .bufferUpdate(let snapshot):
// Update terminal buffer directly
@ -650,6 +837,14 @@ class TerminalViewModel {
// Terminal copy is handled by SwiftTerm's built-in functionality
HapticFeedback.notification(.success)
}
func getBufferContent() -> String? {
// Get the current terminal buffer content
if let coordinator = terminalCoordinator {
return coordinator.getBufferContent()
}
return nil
}
@MainActor
private func handleTerminalBell() {
@ -701,4 +896,20 @@ class TerminalViewModel {
resize(cols: optimalCols, rows: terminalRows)
}
}
func setMaxWidth(_ maxWidth: Int) {
// Store the max width preference
// When maxWidth is 0, it means unlimited
let targetWidth = maxWidth == 0 ? nil : maxWidth
if let width = targetWidth, width != terminalCols {
// Maintain aspect ratio when changing width
let aspectRatio = Double(terminalRows) / Double(terminalCols)
let newHeight = Int(Double(width) * aspectRatio)
resize(cols: width, rows: newHeight)
}
// Update the terminal coordinator if using constrained width
terminalCoordinator?.setMaxWidth(maxWidth)
}
}

View file

@ -0,0 +1,198 @@
import SwiftUI
/// Popover for selecting terminal width presets
struct WidthSelectorPopover: View {
@Binding var currentWidth: TerminalWidth
@Binding var isPresented: Bool
@State private var customWidth: String = ""
@State private var showCustomInput = false
var body: some View {
NavigationStack {
List {
Section {
ForEach(TerminalWidth.allCases, id: \.value) { width in
WidthPresetRow(
width: width,
isSelected: currentWidth.value == width.value,
onSelect: {
currentWidth = width
HapticFeedback.impact(.light)
isPresented = false
}
)
}
}
Section {
Button(action: {
showCustomInput = true
}) {
HStack {
Image(systemName: "square.and.pencil")
.font(.system(size: 16))
.foregroundColor(Theme.Colors.primaryAccent)
Text("Custom Width...")
.font(.body)
.foregroundColor(Theme.Colors.terminalForeground)
Spacer()
}
.padding(.vertical, 4)
}
}
// Show recent custom widths if any
let customWidths = TerminalWidthManager.shared.customWidths
if !customWidths.isEmpty {
Section(header: Text("Recent Custom Widths")
.font(.caption)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
) {
ForEach(customWidths, id: \.self) { width in
WidthPresetRow(
width: .custom(width),
isSelected: currentWidth.value == width && !currentWidth.isPreset,
onSelect: {
currentWidth = .custom(width)
HapticFeedback.impact(.light)
isPresented = false
}
)
}
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("Terminal Width")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
isPresented = false
}
.foregroundColor(Theme.Colors.primaryAccent)
}
}
}
.preferredColorScheme(.dark)
.frame(width: 320, height: 400)
.sheet(isPresented: $showCustomInput) {
CustomWidthSheet(
customWidth: $customWidth,
onSave: { width in
if let intWidth = Int(width), intWidth >= 20 && intWidth <= 500 {
currentWidth = .custom(intWidth)
TerminalWidthManager.shared.addCustomWidth(intWidth)
HapticFeedback.notification(.success)
showCustomInput = false
isPresented = false
}
}
)
}
}
}
/// Row for displaying a width preset option
private struct WidthPresetRow: View {
let width: TerminalWidth
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(width.label)
.font(Theme.Typography.terminalSystem(size: 16))
.fontWeight(.medium)
.foregroundColor(Theme.Colors.terminalForeground)
if width.value > 0 {
Text("columns")
.font(.caption)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
}
Text(width.description)
.font(.caption)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(Theme.Colors.primaryAccent)
}
}
.padding(.vertical, 4)
}
.buttonStyle(PlainButtonStyle())
}
}
/// Sheet for entering a custom width value
private struct CustomWidthSheet: View {
@Binding var customWidth: String
let onSave: (String) -> Void
@Environment(\.dismiss) var dismiss
@FocusState private var isFocused: Bool
var body: some View {
NavigationStack {
VStack(spacing: Theme.Spacing.large) {
Text("Enter a custom terminal width between 20 and 500 columns")
.font(.body)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal)
HStack {
TextField("Width", text: $customWidth)
.font(Theme.Typography.terminalSystem(size: 24))
.foregroundColor(Theme.Colors.terminalForeground)
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.focused($isFocused)
.frame(width: 120)
.padding()
.background(Theme.Colors.cardBackground)
.cornerRadius(Theme.CornerRadius.medium)
Text("columns")
.font(.body)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
Spacer()
}
.padding(.top, Theme.Spacing.extraLarge)
.navigationTitle("Custom Width")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundColor(Theme.Colors.primaryAccent)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
onSave(customWidth)
}
.foregroundColor(Theme.Colors.primaryAccent)
.disabled(customWidth.isEmpty)
}
}
}
.preferredColorScheme(.dark)
.onAppear {
isFocused = true
}
}
}

View file

@ -0,0 +1,400 @@
import SwiftUI
import WebKit
/// WebView-based terminal using xterm.js
struct XtermWebView: UIViewRepresentable {
let session: Session
@Binding var fontSize: CGFloat
let theme: TerminalTheme
let onInput: (String) -> Void
let onResize: (Int, Int) -> Void
var viewModel: TerminalViewModel
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
configuration.userContentController = WKUserContentController()
// Add message handlers
configuration.userContentController.add(context.coordinator, name: "terminalInput")
configuration.userContentController.add(context.coordinator, name: "terminalResize")
configuration.userContentController.add(context.coordinator, name: "terminalReady")
configuration.userContentController.add(context.coordinator, name: "terminalLog")
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(theme.background)
webView.scrollView.isScrollEnabled = false
context.coordinator.webView = webView
context.coordinator.loadTerminal()
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Update font size
context.coordinator.updateFontSize(fontSize)
// Update theme
context.coordinator.updateTheme(theme)
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
let parent: XtermWebView
weak var webView: WKWebView?
private var bufferWebSocketClient: BufferWebSocketClient?
private var sseClient: SSEClient?
init(_ parent: XtermWebView) {
self.parent = parent
super.init()
}
func loadTerminal() {
guard let webView = webView else { return }
let html = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #000;
overflow: hidden;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
#terminal {
width: 100vw;
height: 100vh;
}
.xterm { height: 100%; }
.xterm-viewport { overflow-y: auto !important; }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
</head>
<body>
<div id="terminal"></div>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script>
let term;
let fitAddon;
let buffer = [];
let isReady = false;
function log(message) {
window.webkit.messageHandlers.terminalLog.postMessage(message);
}
function initTerminal() {
term = new Terminal({
fontSize: \(parent.fontSize),
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#1a1a1a',
foreground: '#e0e0e0',
cursor: '#00ff00',
cursorAccent: '#000000',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#000000',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#6272a4',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#bfbfbf',
brightBlack: '#4d4d4d',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#7b8dbd',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#e6e6e6'
},
allowTransparency: false,
cursorBlink: true,
scrollback: 10000
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
term.open(document.getElementById('terminal'));
// Fit terminal to container
setTimeout(() => {
fitAddon.fit();
const dims = fitAddon.proposeDimensions();
if (dims) {
window.webkit.messageHandlers.terminalResize.postMessage({
cols: dims.cols,
rows: dims.rows
});
}
}, 0);
// Handle input
term.onData(data => {
window.webkit.messageHandlers.terminalInput.postMessage(data);
});
// Handle resize
term.onResize(({ cols, rows }) => {
window.webkit.messageHandlers.terminalResize.postMessage({ cols, rows });
});
// Process buffered data
isReady = true;
buffer.forEach(data => writeToTerminal(data));
buffer = [];
// Notify ready
window.webkit.messageHandlers.terminalReady.postMessage({});
log('Terminal initialized');
}
function writeToTerminal(data) {
if (!isReady) {
buffer.push(data);
return;
}
term.write(data);
}
function updateFontSize(size) {
if (term) {
term.options.fontSize = size;
fitAddon.fit();
}
}
function updateTheme(theme) {
if (term && theme) {
term.options.theme = theme;
}
}
function scrollToBottom() {
if (term) {
term.scrollToBottom();
}
}
function clear() {
if (term) {
term.clear();
}
}
function resize() {
if (fitAddon) {
fitAddon.fit();
}
}
// Expose functions to native
window.xtermAPI = {
writeToTerminal,
updateFontSize,
updateTheme,
scrollToBottom,
clear,
resize
};
// Initialize terminal when page loads
window.addEventListener('load', initTerminal);
// Handle window resize
window.addEventListener('resize', () => {
if (fitAddon) {
setTimeout(() => {
fitAddon.fit();
}, 100);
}
});
</script>
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: nil)
webView.navigationDelegate = self
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case "terminalInput":
if let data = message.body as? String {
parent.onInput(data)
}
case "terminalResize":
if let dict = message.body as? [String: Any],
let cols = dict["cols"] as? Int,
let rows = dict["rows"] as? Int {
parent.onResize(cols, rows)
}
case "terminalReady":
setupDataStreaming()
case "terminalLog":
if let log = message.body as? String {
print("[XtermWebView] \(log)")
}
default:
break
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("[XtermWebView] Page loaded")
}
private func setupDataStreaming() {
// Subscribe to WebSocket buffer updates
if bufferWebSocketClient == nil {
bufferWebSocketClient = parent.viewModel.bufferWebSocketClient
}
bufferWebSocketClient?.subscribe(to: parent.session.id) { [weak self] event in
self?.handleWebSocketEvent(event)
}
// Also set up SSE as fallback
if let streamURL = APIClient.shared.streamURL(for: parent.session.id) {
sseClient = SSEClient(url: streamURL)
sseClient?.delegate = self
sseClient?.start()
}
}
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
switch event {
case .bufferUpdate(let snapshot):
// Convert buffer snapshot to terminal output
renderBufferSnapshot(snapshot)
case .output(_, let data):
writeToTerminal(data)
case .resize(_, _):
// Handle resize if needed
break
case .bell:
// Could play a sound or visual bell
break
default:
break
}
}
private func renderBufferSnapshot(_ snapshot: BufferSnapshot) {
// For now, we'll just write the text content
// In a full implementation, we'd convert the buffer cells to ANSI sequences
var output = ""
for row in snapshot.cells {
for cell in row {
output += cell.char
}
output += "\r\n"
}
writeToTerminal(output)
}
private func writeToTerminal(_ data: String) {
let escaped = data
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
webView?.evaluateJavaScript("window.xtermAPI.writeToTerminal('\(escaped)')") { _, error in
if let error = error {
print("[XtermWebView] Error writing to terminal: \(error)")
}
}
}
func updateFontSize(_ size: CGFloat) {
webView?.evaluateJavaScript("window.xtermAPI.updateFontSize(\(size))")
}
func updateTheme(_ theme: TerminalTheme) {
// Convert theme to xterm.js format
let themeJS = """
{
background: '\(theme.background.hex)',
foreground: '\(theme.foreground.hex)',
cursor: '\(theme.cursor.hex)',
selection: 'rgba(255, 255, 255, 0.3)'
}
"""
webView?.evaluateJavaScript("window.xtermAPI.updateTheme(\(themeJS))")
}
func scrollToBottom() {
webView?.evaluateJavaScript("window.xtermAPI.scrollToBottom()")
}
func clear() {
webView?.evaluateJavaScript("window.xtermAPI.clear()")
}
}
}
// MARK: - SSEClientDelegate
@MainActor
extension XtermWebView.Coordinator: SSEClientDelegate {
nonisolated func sseClient(_ client: SSEClient, didReceiveEvent event: SSEClient.SSEEvent) {
Task { @MainActor in
switch event {
case .terminalOutput(_, let type, let data):
if type == "o" { // output
writeToTerminal(data)
}
case .exit(let exitCode, _):
writeToTerminal("\r\n[Process exited with code \(exitCode)]\r\n")
case .error(let error):
print("[XtermWebView] SSE error: \(error)")
}
}
}
}
// Helper extension for Color to hex
extension Color {
var hex: String {
let uiColor = UIColor(self)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(format: "#%02X%02X%02X",
Int(red * 255),
Int(green * 255),
Int(blue * 255))
}
}

View file

@ -2,6 +2,78 @@ import Foundation
import Testing
@testable import VibeTunnel
// Temporarily include MockWebSocketFactory here until it's properly added to the project
@MainActor
class MockWebSocket: WebSocketProtocol {
weak var delegate: WebSocketDelegate?
// State tracking
private(set) var isConnected = false
private(set) var lastConnectURL: URL?
private(set) var lastConnectHeaders: [String: String]?
// Control test behavior
var shouldFailConnection = false
var connectionError: Error?
func connect(to url: URL, with headers: [String: String]) async throws {
lastConnectURL = url
lastConnectHeaders = headers
if shouldFailConnection {
let error = connectionError ?? WebSocketError.connectionFailed
throw error
}
isConnected = true
delegate?.webSocketDidConnect(self)
}
func send(_ message: WebSocketMessage) async throws {
guard isConnected else {
throw WebSocketError.connectionFailed
}
}
func sendPing() async throws {
guard isConnected else {
throw WebSocketError.connectionFailed
}
}
func disconnect(with code: URLSessionWebSocketTask.CloseCode, reason: Data?) {
if isConnected {
isConnected = false
delegate?.webSocketDidDisconnect(self, closeCode: code, reason: reason)
}
}
func simulateMessage(_ message: WebSocketMessage) {
guard isConnected else { return }
delegate?.webSocket(self, didReceiveMessage: message)
}
func simulateError(_ error: Error) {
guard isConnected else { return }
delegate?.webSocket(self, didFailWithError: error)
}
}
@MainActor
class MockWebSocketFactory: WebSocketFactory {
private(set) var createdWebSockets: [MockWebSocket] = []
func createWebSocket() -> WebSocketProtocol {
let webSocket = MockWebSocket()
createdWebSockets.append(webSocket)
return webSocket
}
var lastCreatedWebSocket: MockWebSocket? {
createdWebSockets.last
}
}
@Suite("BufferWebSocketClient Tests", .tags(.critical, .websocket))
@MainActor
struct BufferWebSocketClientTests {
@ -270,8 +342,7 @@ private func saveTestServerConfig() {
let config = ServerConfig(
host: "localhost",
port: 8888,
useSSL: false,
username: nil,
name: nil,
password: nil
)

View file

@ -18,7 +18,7 @@ enum TestFixtures {
static let validSession = Session(
id: "test-session-123",
command: "/bin/bash",
command: ["/bin/bash"],
workingDir: "/Users/test",
name: "Test Session",
status: .running,
@ -26,14 +26,15 @@ enum TestFixtures {
startedAt: "2024-01-01T10:00:00Z",
lastModified: "2024-01-01T10:05:00Z",
pid: 12_345,
waiting: false,
width: 80,
height: 24
source: nil,
remoteId: nil,
remoteName: nil,
remoteUrl: nil
)
static let exitedSession = Session(
id: "exited-session-456",
command: "/usr/bin/echo",
command: ["/usr/bin/echo"],
workingDir: "/tmp",
name: "Exited Session",
status: .exited,
@ -41,38 +42,33 @@ enum TestFixtures {
startedAt: "2024-01-01T09:00:00Z",
lastModified: "2024-01-01T09:00:05Z",
pid: nil,
waiting: false,
width: 80,
height: 24
source: nil,
remoteId: nil,
remoteName: nil,
remoteUrl: nil
)
static let sessionsJSON = """
[
{
"id": "test-session-123",
"command": "/bin/bash",
"command": ["/bin/bash"],
"workingDir": "/Users/test",
"name": "Test Session",
"status": "running",
"startedAt": "2024-01-01T10:00:00Z",
"lastModified": "2024-01-01T10:05:00Z",
"pid": 12345,
"waiting": false,
"width": 80,
"height": 24
"pid": 12345
},
{
"id": "exited-session-456",
"command": "/usr/bin/echo",
"command": ["/usr/bin/echo"],
"workingDir": "/tmp",
"name": "Exited Session",
"status": "exited",
"exitCode": 0,
"startedAt": "2024-01-01T09:00:00Z",
"lastModified": "2024-01-01T09:00:05Z",
"waiting": false,
"width": 80,
"height": 24
"lastModified": "2024-01-01T09:00:05Z"
}
]
"""

267
ios/docs/ios-plan.md Normal file
View file

@ -0,0 +1,267 @@
# iOS App Update Plan
This document outlines the comprehensive plan to update the VibeTunnel iOS app to match all features available in the JavaScript front-end.
## Analysis Summary
The iOS app is well-architected but missing several key features and has critical API communication issues that prevent it from working properly with the current server implementation.
## Feature Comparison: JavaScript vs iOS
### ✅ Features Present in Both
1. Session management (list, create, kill, cleanup)
2. Terminal display and input
3. WebSocket binary buffer streaming
4. File browser with git integration
5. Terminal resizing
6. Font size adjustment
7. Connection management
8. Error handling and reconnection
9. Mobile-specific controls (arrow keys, special keys)
### ❌ Missing in iOS App
1. **SSE Text Streaming** (`/api/sessions/:id/stream`)
- JS uses SSE for real-time text output as primary method
- iOS only uses binary WebSocket, no SSE implementation active
2. **File Preview with Syntax Highlighting**
- JS has CodeMirror integration for code preview
- iOS only has basic text viewing
3. **Git Diff Viewer**
- JS: `/api/fs/diff?path=...` endpoint for viewing diffs
- iOS: No diff viewing capability
4. **System Logs Viewer**
- JS: Full logs viewer with filtering, search, download
- iOS: No logs access
5. **Hot Reload Support**
- JS: Development hot reload via WebSocket
- iOS: Not applicable but no equivalent dev mode
6. **Cast File Import/Playback**
- JS: Can import and play external cast files
- iOS: Only records, no import capability
7. **URL Detection in Terminal**
- JS: Clickable URLs in terminal output
- iOS: No URL detection
8. **Session Name in Creation**
- JS: Supports custom session names
- iOS: Has UI but may not send to server
### 🔄 Different Implementations
1. **Terminal Rendering**
- JS: Custom renderer with xterm.js (headless)
- iOS: SwiftTerm library
2. **State Management**
- JS: Local storage for preferences
- iOS: UserDefaults + @Observable
3. **Terminal Controls**
- JS: Ctrl key grid popup
- iOS: Toolbar with common keys
4. **File Path Insertion**
- JS: Direct insertion into terminal
- iOS: Copy to clipboard only
### 🔧 API Endpoint Differences
1. **Missing Endpoints in iOS**:
- `GET /api/fs/preview` - File preview
- `GET /api/fs/diff` - Git diffs
- `GET /api/logs/raw` - System logs
- `GET /api/logs/info` - Log metadata
- `DELETE /api/logs/clear` - Clear logs
2. **Different Endpoint Usage**:
- iOS uses `/api/cleanup-exited` vs JS uses `DELETE /api/sessions`
- iOS has `/api/mkdir` which JS doesn't use
## Implementation Plan
### Phase 1: Critical Server Communication Fixes 🚨
1. **Fix Session Creation API**
- Update `APIClient.createSession()` to match JS payload:
- Add `spawn_terminal: true` field (currently missing!)
- Ensure `cols` and `rows` are sent
- Verify `name` field is included
- File: `ios/VibeTunnel/Services/APIClient.swift`
2. **Implement SSE Text Streaming**
- Add SSEClient implementation for `/api/sessions/:id/stream`
- Handle event parsing: `[timestamp, type, data]` format
- Process exit events: `['exit', exitCode, sessionId]`
- Integrate with TerminalView as alternative to WebSocket
- Files: Create `ios/VibeTunnel/Services/SSEClient.swift`
3. **Fix Binary WebSocket Protocol**
- Verify magic byte handling (0xBF)
- Ensure proper session ID encoding in binary messages
- Handle all message types: connected, subscribed, ping/pong, error
- File: `ios/VibeTunnel/Services/BufferWebSocketClient.swift`
### Phase 2: Essential Missing Features 🔧
4. **Add File Preview with Syntax Highlighting**
- Implement `/api/fs/preview` endpoint call
- Add syntax highlighting library (Highlightr or similar)
- Support text/image/binary preview types
- Files: Update `APIClient.swift`, create `FilePreviewView.swift`
5. **Add Git Diff Viewer**
- Implement `/api/fs/diff` endpoint
- Create diff viewer UI component
- Integrate with file browser
- Files: Update `APIClient.swift`, create `GitDiffView.swift`
6. **Fix Session Cleanup Endpoint**
- Change from `/api/cleanup-exited` to `DELETE /api/sessions`
- Update to match JS implementation
- File: `ios/VibeTunnel/Services/APIClient.swift`
### Phase 3: Enhanced Features ✨
7. **Add System Logs Viewer**
- Implement logs endpoints: `/api/logs/raw`, `/api/logs/info`
- Create logs viewer with filtering and search
- Add download capability
- Files: Create `LogsView.swift`, update `APIClient.swift`
8. **Improve Terminal Features**
- Add URL detection and clickable links
- Implement selection-based copy (not just copy-all)
- Add terminal search functionality
- File: `ios/VibeTunnel/Views/Terminal/TerminalView.swift`
9. **Add Cast File Import**
- Implement cast file parser
- Add import from Files app
- Create playback from imported files
- Files: Update `CastPlayerView.swift`, `CastRecorder.swift`
### Phase 4: UI/UX Improvements 💫
10. **File Browser Enhancements**
- Add file upload capability
- Implement direct path insertion to terminal
- Add multi-select for batch operations
- File: `ios/VibeTunnel/Views/FileBrowser/FileBrowserView.swift`
11. **Session Management**
- Add session renaming capability
- Implement session tags/categories
- Add session history/favorites
- File: `ios/VibeTunnel/Views/Sessions/SessionListView.swift`
### Phase 5: iPad Optimizations 📱
12. **iPad-Specific Features**
- Implement split view support
- Add keyboard shortcuts
- Optimize for larger screens
- Support multiple concurrent sessions view
## Implementation Priority
1. **Immediate (Phase 1)**: Fix session creation and server communication
2. **High (Phase 2)**: Add file preview, git diff, fix endpoints
3. **Medium (Phase 3)**: Logs viewer, terminal improvements, cast import
4. **Low (Phase 4-5)**: UI enhancements, iPad optimizations
## Testing Checklist
- [ ] Create new sessions with various commands
- [ ] Verify terminal output appears correctly
- [ ] Test terminal input and special keys
- [ ] Confirm WebSocket reconnection works
- [ ] Test file browser and preview
- [ ] Verify git integration features
- [ ] Test session management operations
- [ ] Check error handling and offline mode
## JavaScript Front-End Features (Complete List)
### 1. Session Management
- Session list with live updates (3-second polling)
- Create sessions with custom commands and working directories
- Kill individual sessions or all at once
- Cleanup exited sessions
- Session status tracking (running, exited, waiting)
- Session filtering and search
### 2. Terminal I/O and Display
- Real-time terminal output via SSE and WebSocket
- Full keyboard input with special keys
- Dynamic terminal resizing
- Copy/paste support
- Scroll control with auto-scroll
- Font size control (8-32px)
- Width control and fit-to-width mode
- Mobile input support with on-screen keyboard
### 3. Binary Terminal Buffer Streaming
- WebSocket connection for efficient updates
- Binary protocol with magic bytes
- Auto-reconnection with exponential backoff
- Buffer synchronization
- Content change detection
### 4. File Browser
- Directory navigation with git integration
- File preview with syntax highlighting (CodeMirror)
- Image preview
- Git status display and diff viewer
- File filtering by git status
- Path insertion into terminal
### 5. System Logs Viewer
- Real-time log display (2-second refresh)
- Filter by log level and source
- Text search
- Auto-scroll toggle
- Log download and clearing
### 6. Additional Features
- Hot reload for development
- Local storage for preferences
- URL routing with session state
- Error notifications with auto-dismiss
- Cast file support (playback and conversion)
- ANSI color support (256 colors and true color)
- URL detection in terminal output
- Performance optimizations (batched rendering)
## API Endpoints Reference
### Session Management
- `GET /api/sessions` - List all sessions
- `POST /api/sessions` - Create new session
- `DELETE /api/sessions/:id` - Kill session
- `DELETE /api/sessions` - Cleanup all exited sessions
- `POST /api/sessions/:id/input` - Send input
- `POST /api/sessions/:id/resize` - Resize terminal
- `GET /api/sessions/:id/snapshot` - Get terminal snapshot
- `GET /api/sessions/:id/stream` - SSE stream for output
### File System
- `GET /api/fs/browse?path=...&showHidden=...&gitFilter=...` - Browse directories
- `GET /api/fs/preview?path=...` - Preview file content
- `GET /api/fs/diff?path=...` - Get git diff
### System
- `GET /api/logs/raw` - Get raw logs
- `GET /api/logs/info` - Get log metadata
- `DELETE /api/logs/clear` - Clear logs
- `GET /api/health` - Health check
### WebSocket
- `ws://server/buffers` - Binary terminal buffer streaming
- `ws://server/?hotReload=true` - Development hot reload

8
mac/.gitignore vendored
View file

@ -28,4 +28,10 @@ VibeTunnel/Resources/node/
VibeTunnel/Resources/node-server/
# Local development configuration
VibeTunnel/Local.xcconfig
VibeTunnel/Local.xcconfig
# Sparkle private key - NEVER commit this!
sparkle-private-ed-key.pem
sparkle-private-key-KEEP-SECURE.txt
*.pem
private/

200
mac/CHANGELOG.md Normal file
View file

@ -0,0 +1,200 @@
# Changelog
## [1.0.0-beta.3] - 2025-06-23
There's too much to list! This is the version you've been waiting for.
- Redesigned, responsive, animated frontend.
- Improved terminal width spanning and layout optimization
- File-Picker to see files on-the-go.
- Creating new Terminals is now much more reliable.
- Added terminal font size adjustment in the settings dropdown
- Fresh new icon for Progressive Web App installations
- Refined bounce animations for a more subtle, professional feel
- Added retro CRT-style phosphor decay visual effect for closed terminals
- Fixed buffer aggregator message handling for smoother terminal updates
- Better support for shell aliases and improved debug logging
- Enhanced Unix socket server implementation for faster local communication
- Special handling for Warp terminal with custom enter key behavior
- New dock menu with quick actions when right-clicking the app icon
- More resilient vt command-line tool with better error handling
- Ensured vibetunnel server properly terminates when Mac app is killed
## [1.0.0-beta.2] - 2025-06-19
### 🎨 Improvements
- Redesigned slick new web frontend
- Faster terminal rendering in the web frontend
- New Sessions spawn new Terminal windows. (This needs Applescript and Accessibility permissions)
- Enhanced font handling with system font priority
- Better async operations in PTY service for improved performance
- Improved window activation when showing the welcome and settings windows
- Preparations for Linux support
### 🐛 Bug Fixes
- Fixed window front order when dock icon is hidden
- Fixed PTY service enhancements with proper async operations
- Fixed race condition in session creation that caused frontend to open previous session
## [1.0.0-beta.1] - 2025-06-17
### 🎉 First Public Beta Release
This is the first public beta release of VibeTunnel, ready for testing by early adopters.
### ✨ What's Included
- Complete terminal session proxying to web browsers
- Support for multiple concurrent sessions
- Real-time terminal rendering with full TTY support
- Secure password-protected dashboard
- Tailscale and ngrok integration for remote access
- Automatic updates via Sparkle framework
- Native macOS menu bar application
### 🐛 Bug Fixes Since Internal Testing
- Fixed visible circle spacer in menu (now uses Color.clear)
- Removed development files from app bundle
- Enhanced build process with automatic cleanup
- Fixed Sparkle API compatibility for v2.7.0
### 📝 Notes
- This is a beta release - please report any issues on GitHub
- Auto-update functionality is fully enabled
- All core features are stable and ready for daily use
### ✨ What's New Since Internal Testing
- Improved stability and performance
- Enhanced error handling for edge cases
- Refined UI/UX based on internal feedback
- Better session cleanup and resource management
- Optimized for macOS Sonoma and Sequoia
### 🐛 Known Issues
- Occasional connection drops with certain terminal applications
- Performance optimization needed for very long sessions
- Some terminal escape sequences may not render perfectly
### 📝 Notes
- This is a beta release - please report any issues on GitHub
- Auto-update functionality is fully enabled
- All core features are stable and ready for daily use
## [1.0.0] - 2025-06-16
### 🎉 Initial Release
VibeTunnel is a native macOS application that proxies terminal sessions to web browsers, allowing you to monitor and control terminals from any device.
### ✨ Core Features
#### Terminal Management
- **Terminal Session Proxying** - Run any command with `vt` prefix to make it accessible via web browser
- **Multiple Concurrent Sessions** - Support for multiple terminal sessions running simultaneously
- **Session Recording** - All sessions automatically recorded in asciinema format for later playback
- **Full TTY Support** - Proper handling of terminal control sequences, colors, and special characters
- **Interactive Commands** - Support for interactive applications like vim, htop, and more
- **Shell Integration** - Direct shell access with `vt --shell` or `vt -i`
#### Web Interface
- **Browser-Based Dashboard** - Access all terminal sessions at http://localhost:4020
- **Real-time Terminal Rendering** - Live terminal output using asciinema player
- **WebSocket Streaming** - Low-latency real-time updates for terminal I/O
- **Mobile Responsive** - Fully functional on phones, tablets, and desktop browsers
- **Session Management UI** - Create, view, kill, and manage sessions from the web interface
#### Security & Access Control
- **Password Protection** - Optional password authentication for dashboard access
- **Keychain Integration** - Secure password storage using macOS Keychain
- **Access Modes** - Choose between localhost-only, network, or secure tunneling
- **Basic Authentication** - HTTP Basic Auth support for network access
#### Remote Access Options
- **Tailscale Integration** - Access VibeTunnel through your Tailscale network
- **ngrok Support** - Built-in ngrok tunneling for public access with authentication
- **Network Mode** - Local network access with IP-based connections
#### macOS Integration
- **Menu Bar Application** - Lives in the system menu bar with optional dock mode
- **Launch at Login** - Automatic startup with macOS
- **Auto Updates** - Sparkle framework integration for seamless updates
- **Native Swift/SwiftUI** - Built with modern macOS technologies
- **Universal Binary** - Native support for both Intel and Apple Silicon Macs
#### CLI Tool (`vt`)
- **Command Wrapper** - Prefix any command with `vt` to tunnel it
- **Claude Integration** - Special support for AI assistants with `vt --claude` and `vt --claude-yolo`
- **Direct Execution** - Bypass shell with `vt -S` for direct command execution
- **Automatic Installation** - CLI tool automatically installed to /usr/local/bin
#### Server Implementation
- **Dual Server Architecture** - Choose between Rust (default) or Swift server backends
- **High Performance** - Rust server for efficient TTY forwarding and process management
- **RESTful APIs** - Clean API design for session management
- **Health Monitoring** - Built-in health check endpoints
#### Developer Features
- **Server Console** - Debug view showing server logs and diagnostics
- **Configurable Ports** - Change server port from default 4020
- **Session Cleanup** - Automatic cleanup of stale sessions on startup
- **Comprehensive Logging** - Detailed logs for debugging
### 🛠️ Technical Details
- **Minimum macOS Version**: 14.0 (Sonoma)
- **Architecture**: Universal Binary (Intel + Apple Silicon)
- **Languages**: Swift 6.0, Rust, TypeScript
- **UI Framework**: SwiftUI
- **Web Technologies**: TypeScript, Tailwind CSS, WebSockets
- **Build System**: Xcode, Swift Package Manager, Cargo, npm
### 📦 Installation
- Download DMG from GitHub releases
- Drag VibeTunnel to Applications folder
- Launch from Applications or Spotlight
- CLI tool (`vt`) automatically installed on first launch
### 🚀 Quick Start
```bash
# Monitor AI agents
vt claude
# Run development servers
vt npm run dev
# Watch long-running processes
vt python train_model.py
# Open interactive shell
vt --shell
```
### 👥 Contributors
Created by:
- [@badlogic](https://mariozechner.at/) - Mario Zechner
- [@mitsuhiko](https://lucumr.pocoo.org/) - Armin Ronacher
- [@steipete](https://steipete.com/) - Peter Steinberger
### 📄 License
VibeTunnel is open source software licensed under the MIT License.
---
## Version History
### Pre-release Development
The project went through extensive development before the 1.0.0 release, including:
- Initial TTY forwarding implementation using Rust
- macOS app foundation with SwiftUI
- Integration of asciinema format for session recording
- Web frontend development with real-time terminal rendering
- Hummingbird HTTP server implementation
- ngrok integration for secure tunneling
- Sparkle framework integration for auto-updates
- Comprehensive testing and bug fixes
- UI/UX refinements and mobile optimizations

View file

@ -464,6 +464,7 @@
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
VIBETUNNEL_USE_CUSTOM_NODE = YES;
};
name = Debug;
};
@ -501,6 +502,7 @@
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
VIBETUNNEL_USE_CUSTOM_NODE = YES;
};
name = Release;
};

View file

@ -1,590 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
78AD8B952E051ED40009725C /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 78AD8B942E051ED40009725C /* Logging */; };
89D01D862CB5D7DC0075D8BD /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 89D01D852CB5D7DC0075D8BD /* Sparkle */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
788687FF2DFF4FCB00B22C15 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 788687E92DFF4FCB00B22C15 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 788687F02DFF4FCB00B22C15;
remoteInfo = VibeTunnel;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
788687F12DFF4FCB00B22C15 /* VibeTunnel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VibeTunnel.app; sourceTree = BUILT_PRODUCTS_DIR; };
788687FE2DFF4FCB00B22C15 /* VibeTunnelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VibeTunnelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
Shared.xcconfig,
version.xcconfig,
);
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
788687F32DFF4FCB00B22C15 /* VibeTunnel */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */,
);
path = VibeTunnel;
sourceTree = "<group>";
};
788688012DFF4FCB00B22C15 /* VibeTunnelTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = VibeTunnelTests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
788687EE2DFF4FCB00B22C15 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78AD8B952E051ED40009725C /* Logging in Frameworks */,
89D01D862CB5D7DC0075D8BD /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
788687FB2DFF4FCB00B22C15 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
788687E82DFF4FCB00B22C15 = {
isa = PBXGroup;
children = (
788687F32DFF4FCB00B22C15 /* VibeTunnel */,
788688012DFF4FCB00B22C15 /* VibeTunnelTests */,
78AD8B8F2E051ED40009725C /* Frameworks */,
788687F22DFF4FCB00B22C15 /* Products */,
);
sourceTree = "<group>";
};
788687F22DFF4FCB00B22C15 /* Products */ = {
isa = PBXGroup;
children = (
788687F12DFF4FCB00B22C15 /* VibeTunnel.app */,
788687FE2DFF4FCB00B22C15 /* VibeTunnelTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
78AD8B8F2E051ED40009725C /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
788687F02DFF4FCB00B22C15 /* VibeTunnel */ = {
isa = PBXNativeTarget;
buildConfigurationList = 788688152DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnel" */;
buildPhases = (
C3D4E5F6A7B8C9D0E1F23456 /* Install Build Dependencies */,
788687ED2DFF4FCB00B22C15 /* Sources */,
788687EE2DFF4FCB00B22C15 /* Frameworks */,
788687EF2DFF4FCB00B22C15 /* Resources */,
B2C3D4E5F6A7B8C9D0E1F234 /* Build Web Frontend */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
788687F32DFF4FCB00B22C15 /* VibeTunnel */,
);
name = VibeTunnel;
packageProductDependencies = (
89D01D852CB5D7DC0075D8BD /* Sparkle */,
78AD8B942E051ED40009725C /* Logging */,
);
productName = VibeTunnel;
productReference = 788687F12DFF4FCB00B22C15 /* VibeTunnel.app */;
productType = "com.apple.product-type.application";
};
788687FD2DFF4FCB00B22C15 /* VibeTunnelTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 788688182DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnelTests" */;
buildPhases = (
788687FA2DFF4FCB00B22C15 /* Sources */,
788687FB2DFF4FCB00B22C15 /* Frameworks */,
788687FC2DFF4FCB00B22C15 /* Resources */,
);
buildRules = (
);
dependencies = (
788688002DFF4FCB00B22C15 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
788688012DFF4FCB00B22C15 /* VibeTunnelTests */,
);
name = VibeTunnelTests;
productName = VibeTunnelTests;
productReference = 788687FE2DFF4FCB00B22C15 /* VibeTunnelTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
788687E92DFF4FCB00B22C15 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1610;
LastUpgradeCheck = 2600;
TargetAttributes = {
788687F02DFF4FCB00B22C15 = {
CreatedOnToolsVersion = 16.1;
};
788687FD2DFF4FCB00B22C15 = {
CreatedOnToolsVersion = 16.1;
TestTargetID = 788687F02DFF4FCB00B22C15;
};
};
};
buildConfigurationList = 788687EC2DFF4FCB00B22C15 /* Build configuration list for PBXProject "VibeTunnel" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 788687E82DFF4FCB00B22C15;
minimizedProjectReferenceProxies = 1;
packageReferences = (
89D01D842CB5D7DC0075D8BD /* XCRemoteSwiftPackageReference "Sparkle" */,
78AD8B8E2E051EB50009725C /* XCRemoteSwiftPackageReference "swift-log" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 788687F22DFF4FCB00B22C15 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
788687F02DFF4FCB00B22C15 /* VibeTunnel */,
788687FD2DFF4FCB00B22C15 /* VibeTunnelTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
788687EF2DFF4FCB00B22C15 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
788687FC2DFF4FCB00B22C15 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
B2C3D4E5F6A7B8C9D0E1F234 /* Build Web Frontend */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/../web/",
);
name = "Build Web Frontend";
outputFileListPaths = (
);
outputPaths = (
"$(BUILT_PRODUCTS_DIR)/$(CONTENTS_FOLDER_PATH)/Resources/web/public",
"$(BUILT_PRODUCTS_DIR)/$(CONTENTS_FOLDER_PATH)/Resources/vibetunnel",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "# Build web frontend using Bun\necho \"Building web frontend...\"\n\n# Run the build script\n\"${SRCROOT}/scripts/build-web-frontend.sh\"\n\nif [ $? -ne 0 ]; then\n echo \"error: Web frontend build failed\"\n exit 1\nfi\n";
};
C3D4E5F6A7B8C9D0E1F23456 /* Install Build Dependencies */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Install Build Dependencies";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "# Install build dependencies\necho \"Checking build dependencies...\"\n\n# Run the install script\n\"${SRCROOT}/scripts/install-bun.sh\"\n\nif [ $? -ne 0 ]; then\n echo \"error: Failed to install build dependencies\"\n exit 1\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
788687ED2DFF4FCB00B22C15 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
788687FA2DFF4FCB00B22C15 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
788688002DFF4FCB00B22C15 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
targetProxy = 788687FF2DFF4FCB00B22C15 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
788688102DFF4FCC00B22C15 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 788687F32DFF4FCB00B22C15 /* VibeTunnel */;
baseConfigurationReferenceRelativePath = Shared.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 6.0;
};
name = Debug;
};
788688112DFF4FCC00B22C15 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 788687F32DFF4FCB00B22C15 /* VibeTunnel */;
baseConfigurationReferenceRelativePath = Shared.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 6.0;
};
name = Release;
};
788688132DFF4FCC00B22C15 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = "$(inherited)";
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 7F5Y92G2Z4;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = VibeTunnel/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = VibeTunnel;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "VibeTunnel uses AppleScript to spawn a terminal when you create a new session in the dashboard. This allows VibeTunnel to automatically open your preferred terminal application and connect it to the remote session.";
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 VibeTunnel Team. All rights reserved.";
INFOPLIST_KEY_NSMainStoryboardFile = Main;
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
};
name = Debug;
};
788688142DFF4FCC00B22C15 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = "$(inherited)";
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 7F5Y92G2Z4;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = VibeTunnel/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = VibeTunnel;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "VibeTunnel uses AppleScript to spawn a terminal when you create a new session in the dashboard. This allows VibeTunnel to automatically open your preferred terminal application and connect it to the remote session.";
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 VibeTunnel Team. All rights reserved.";
INFOPLIST_KEY_NSMainStoryboardFile = Main;
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
};
name = Release;
};
788688162DFF4FCC00B22C15 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(inherited)";
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnelTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
};
name = Debug;
};
788688172DFF4FCC00B22C15 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(inherited)";
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnelTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VibeTunnel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VibeTunnel";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
788687EC2DFF4FCB00B22C15 /* Build configuration list for PBXProject "VibeTunnel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
788688102DFF4FCC00B22C15 /* Debug */,
788688112DFF4FCC00B22C15 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
788688152DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
788688132DFF4FCC00B22C15 /* Debug */,
788688142DFF4FCC00B22C15 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
788688182DFF4FCC00B22C15 /* Build configuration list for PBXNativeTarget "VibeTunnelTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
788688162DFF4FCC00B22C15 /* Debug */,
788688172DFF4FCC00B22C15 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
78AD8B8E2E051EB50009725C /* XCRemoteSwiftPackageReference "swift-log" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-log.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.6.3;
};
};
89D01D842CB5D7DC0075D8BD /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.7.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78AD8B942E051ED40009725C /* Logging */ = {
isa = XCSwiftPackageProductDependency;
package = 78AD8B8E2E051EB50009725C /* XCRemoteSwiftPackageReference "swift-log" */;
productName = Logging;
};
89D01D852CB5D7DC0075D8BD /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 89D01D842CB5D7DC0075D8BD /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 788687E92DFF4FCB00B22C15 /* Project object */;
}

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -2,7 +2,7 @@
// This file contains the version and build number for the app
MARKETING_VERSION = 1.0.0-beta.3
CURRENT_PROJECT_VERSION = 108
CURRENT_PROJECT_VERSION = 110
// Domain and GitHub configuration
APP_DOMAIN = vibetunnel.sh

View file

@ -13,15 +13,25 @@ done
# If not found in standard locations with valid binary, search for it
if [ -z "$APP_PATH" ]; then
# First try DerivedData (for development)
APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "VibeTunnel.app" -type d 2>/dev/null | grep -v "\.dSYM" | head -1)
for CANDIDATE in $(find ~/Library/Developer/Xcode/DerivedData -name "VibeTunnel.app" -type d 2>/dev/null | grep -v "\.dSYM" | grep -v "Index\.noindex"); do
if [ -f "$CANDIDATE/Contents/Resources/vibetunnel" ]; then
APP_PATH="$CANDIDATE"
break
fi
done
# If still not found, use mdfind as last resort
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
APP_PATH=$(mdfind -name "VibeTunnel.app" 2>/dev/null | grep -v "\.dSYM" | head -1)
if [ -z "$APP_PATH" ]; then
for CANDIDATE in $(mdfind -name "VibeTunnel.app" 2>/dev/null | grep -v "\.dSYM"); do
if [ -f "$CANDIDATE/Contents/Resources/vibetunnel" ]; then
APP_PATH="$CANDIDATE"
break
fi
done
fi
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
echo "Error: VibeTunnel.app not found anywhere on the system" >&2
if [ -z "$APP_PATH" ]; then
echo "Error: VibeTunnel.app with vibetunnel binary not found anywhere on the system" >&2
exit 1
fi
fi

219
mac/docs/RELEASE-LESSONS.md Normal file
View file

@ -0,0 +1,219 @@
# VibeTunnel Release Lessons Learned
This document captures important lessons learned from the VibeTunnel release process and common issues that can occur.
## Critical Issues and Solutions
### 1. Sparkle Signing Account Issues
**Problem**: The `sign_update` command may use the wrong signing key from your Keychain if you have multiple EdDSA keys configured.
**Symptoms**:
- Sparkle update verification fails
- Error messages about invalid signatures
- Updates don't appear in the app even though appcast is updated
**Solution**:
```bash
# Always specify the account explicitly
export SPARKLE_ACCOUNT="VibeTunnel"
./scripts/release.sh stable
```
**Prevention**: The release script now sets `SPARKLE_ACCOUNT` environment variable automatically.
### 2. File Location Confusion
**Problem**: Files are not always where scripts expect them to be.
**Key Locations**:
- **Appcast files**: Located in project root (`/vibetunnel/`), NOT in `mac/`
- `appcast.xml`
- `appcast-prerelease.xml`
- **CHANGELOG.md**: Can be in either:
- `mac/CHANGELOG.md` (preferred by release script)
- Project root `/vibetunnel/CHANGELOG.md` (common location)
- **Sparkle private key**: Usually in `mac/private/sparkle_private_key`
**Solution**: The scripts now check multiple locations and provide clear error messages.
### 3. Stuck DMG Volumes
**Problem**: "Resource temporarily unavailable" errors when creating DMG.
**Symptoms**:
- `hdiutil: create failed - Resource temporarily unavailable`
- Multiple VibeTunnel volumes visible in Finder
- DMG creation fails repeatedly
**Solution**:
```bash
# Manually unmount all VibeTunnel volumes
for volume in /Volumes/VibeTunnel*; do
hdiutil detach "$volume" -force
done
# Kill any stuck DMG processes
pkill -f "VibeTunnel.*\.dmg"
```
**Prevention**: Scripts now clean up volumes automatically before DMG creation.
### 4. Build Number Already Exists
**Problem**: Sparkle requires unique build numbers for each release.
**Solution**:
1. Check existing build numbers:
```bash
grep -E '<sparkle:version>[0-9]+</sparkle:version>' ../appcast*.xml
```
2. Update `mac/VibeTunnel/version.xcconfig`:
```
CURRENT_PROJECT_VERSION = <new_unique_number>
```
### 5. Notarization Failures
**Problem**: App notarization fails or takes too long.
**Common Causes**:
- Missing API credentials
- Network issues
- Apple service outages
- Unsigned frameworks or binaries
**Solution**:
```bash
# Check notarization status
xcrun notarytool history --key-id "$APP_STORE_CONNECT_KEY_ID" \
--key "$APP_STORE_CONNECT_API_KEY_P8" \
--issuer-id "$APP_STORE_CONNECT_ISSUER_ID"
# Get detailed log for failed submission
xcrun notarytool log <submission-id> --key-id ...
```
### 6. GitHub Release Already Exists
**Problem**: Tag or release already exists on GitHub.
**Solution**: The release script now prompts you to:
1. Delete the existing release and tag
2. Cancel the release
**Prevention**: Always pull latest changes before releasing.
## Pre-Release Checklist
Before running `./scripts/release.sh`:
1. **Environment Setup**:
```bash
# Ensure you're on main branch
git checkout main
git pull --rebase origin main
# Check for uncommitted changes
git status
# Set environment variables
export SPARKLE_ACCOUNT="VibeTunnel"
export APP_STORE_CONNECT_API_KEY_P8="..."
export APP_STORE_CONNECT_KEY_ID="..."
export APP_STORE_CONNECT_ISSUER_ID="..."
```
2. **File Verification**:
- [ ] CHANGELOG.md exists and has entry for new version
- [ ] version.xcconfig has unique build number
- [ ] Sparkle private key exists at expected location
- [ ] No stuck DMG volumes in /Volumes/
3. **Clean Build**:
```bash
./scripts/clean.sh
rm -rf ~/Library/Developer/Xcode/DerivedData/VibeTunnel-*
```
## Common Commands
### Test Sparkle Signature
```bash
# Find sign_update binary
find . -name sign_update -type f
# Test signing with specific account
./path/to/sign_update file.dmg -f private/sparkle_private_key -p --account VibeTunnel
```
### Verify Appcast URLs
```bash
# Check that appcast files are accessible
curl -I https://raw.githubusercontent.com/amantus-ai/vibetunnel/main/appcast.xml
curl -I https://raw.githubusercontent.com/amantus-ai/vibetunnel/main/appcast-prerelease.xml
```
### Manual Appcast Generation
```bash
# If automatic generation fails
cd mac
export SPARKLE_ACCOUNT="VibeTunnel"
./scripts/generate-appcast.sh
```
## Post-Release Verification
1. **Check GitHub Release**:
- Verify assets are attached
- Check file sizes match
- Ensure release notes are formatted correctly
2. **Test Update in App**:
- Install previous version
- Check for updates
- Verify update downloads and installs
- Check signature verification in Console.app
3. **Monitor for Issues**:
- Watch Console.app for Sparkle errors
- Check GitHub issues for user reports
- Verify download counts on GitHub
## Emergency Fixes
### If Update Verification Fails
1. Regenerate appcast with correct account:
```bash
export SPARKLE_ACCOUNT="VibeTunnel"
./scripts/generate-appcast.sh
git add ../appcast*.xml
git commit -m "Fix appcast signatures"
git push
```
2. Users may need to manually download until appcast propagates
### If DMG is Corrupted
1. Re-download from GitHub
2. Re-sign and re-notarize:
```bash
./scripts/sign-and-notarize.sh --sign-and-notarize
./scripts/notarize-dmg.sh build/VibeTunnel-*.dmg
```
3. Upload fixed DMG to GitHub release
## Key Learnings
1. **Always use explicit accounts** when dealing with signing operations
2. **Clean up resources** (volumes, processes) before operations
3. **Verify file locations** - don't assume standard paths
4. **Test the full update flow** before announcing the release
5. **Keep credentials secure** but easily accessible for scripts
6. **Document everything** - future you will thank present you
## References
- [Sparkle Documentation](https://sparkle-project.org/documentation/)
- [Apple Notarization Guide](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution)
- [GitHub Releases API](https://docs.github.com/en/rest/releases/releases)

View file

@ -174,8 +174,16 @@ The script will:
### Step 5: Verify Success
- Check the GitHub releases page
- Verify the appcast was updated correctly with proper changelog content
- **Critical**: Verify the Sparkle signature is correct:
```bash
# Download and verify the DMG signature
curl -L -o test.dmg <github-dmg-url>
sign_update test.dmg --account VibeTunnel
# Compare with appcast sparkle:edSignature
```
- Test updating from a previous version
- **Important**: Verify that the Sparkle update dialog shows the formatted changelog, not HTML tags
- Check that update installs without "improperly signed" errors
## ⚠️ Critical Requirements
@ -347,6 +355,8 @@ Edit `VibeTunnel/version.xcconfig`:
- Update MARKETING_VERSION
- Update CURRENT_PROJECT_VERSION (build number)
**Note**: The Xcode project file is named `VibeTunnel-Mac.xcodeproj`
### 2. Clean and Build Universal Binary
```bash
rm -rf build DerivedData
@ -390,6 +400,25 @@ git push
## 🔍 Troubleshooting
### "Update is improperly signed" Error
**Problem**: Users see "The update is improperly signed and could not be validated."
**Cause**: The DMG was signed with the wrong Sparkle key (default instead of VibeTunnel account).
**Quick Fix**:
```bash
# 1. Download the DMG from GitHub
curl -L -o fix.dmg <github-dmg-url>
# 2. Generate correct signature
sign_update fix.dmg --account VibeTunnel
# 3. Update appcast-prerelease.xml with the new sparkle:edSignature
# 4. Commit and push
```
**Prevention**: The updated scripts now always use `--account VibeTunnel`.
### Debug Sparkle Updates
```bash
# Monitor VibeTunnel logs

View file

@ -139,7 +139,12 @@ fi
if [ "$BUILD_CONFIG" = "Release" ]; then
echo "Release build - checking for custom Node.js..."
if [ ! -f "$CUSTOM_NODE_PATH" ]; then
# Skip custom Node.js build in CI to avoid timeout
if [ "${CI:-false}" = "true" ]; then
echo "CI environment detected - skipping custom Node.js build to avoid timeout"
echo "The app will be larger than optimal but will build within CI time limits."
npm run build
elif [ ! -f "$CUSTOM_NODE_PATH" ]; then
echo "Custom Node.js not found, building it for optimal size..."
echo "This will take 10-20 minutes on first run but will be cached."
node build-custom-node.js --latest
@ -147,7 +152,7 @@ if [ "$BUILD_CONFIG" = "Release" ]; then
CUSTOM_NODE_PATH="${CUSTOM_NODE_DIR}/out/Release/node"
fi
if [ -f "$CUSTOM_NODE_PATH" ]; then
if [ "${CI:-false}" != "true" ] && [ -f "$CUSTOM_NODE_PATH" ]; then
CUSTOM_NODE_VERSION=$("$CUSTOM_NODE_PATH" --version 2>/dev/null || echo "unknown")
CUSTOM_NODE_SIZE=$(ls -lh "$CUSTOM_NODE_PATH" 2>/dev/null | awk '{print $5}' || echo "unknown")
echo "Using custom Node.js for release build:"

View file

@ -55,6 +55,23 @@ fi
echo "Creating DMG: $DMG_NAME"
# Clean up any stuck VibeTunnel volumes before starting
echo "Checking for stuck DMG volumes..."
for volume in /Volumes/VibeTunnel* "/Volumes/$DMG_VOLUME_NAME"*; do
if [ -d "$volume" ]; then
echo " Unmounting stuck volume: $volume"
hdiutil detach "$volume" -force 2>/dev/null || true
sleep 1
fi
done
# Also check for any DMG processes that might be stuck
if pgrep -f "VibeTunnel.*\.dmg" > /dev/null; then
echo " Found stuck DMG processes, killing them..."
pkill -f "VibeTunnel.*\.dmg" || true
sleep 2
fi
# Create temporary directory for DMG contents
DMG_TEMP="$BUILD_DIR/dmg-temp"
rm -rf "$DMG_TEMP"
@ -83,6 +100,12 @@ echo "Applying custom styling to DMG..."
# Mount the DMG
MOUNT_POINT="/Volumes/$DMG_VOLUME_NAME"
# Ensure the mount point doesn't exist before mounting
if [ -d "$MOUNT_POINT" ]; then
echo "Mount point already exists, attempting to unmount..."
hdiutil detach "$MOUNT_POINT" -force 2>/dev/null || true
sleep 2
fi
hdiutil attach "$DMG_RW_PATH" -mountpoint "$MOUNT_POINT" -nobrowse
@ -151,16 +174,27 @@ sleep 3
osascript -e 'tell application "Finder" to close every window'
# Unmount with retry
# Unmount with retry and force
echo "Unmounting DMG..."
for i in {1..5}; do
if hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null; then
break
fi
echo " Retry $i/5..."
if [ $i -eq 3 ]; then
echo " Attempting force unmount..."
hdiutil detach "$MOUNT_POINT" -force 2>/dev/null || true
fi
sleep 2
done
# Final check - if still mounted, force unmount
if [ -d "$MOUNT_POINT" ]; then
echo " Volume still mounted, force unmounting..."
hdiutil detach "$MOUNT_POINT" -force 2>/dev/null || true
sleep 1
fi
# Convert to compressed read-only DMG
echo "Converting to final DMG format..."
hdiutil convert "$DMG_RW_PATH" -format ULMO -o "$DMG_PATH" -ov

View file

@ -37,8 +37,15 @@ if [[ -z "${GITHUB_USERNAME:-}" ]] || [[ -z "${GITHUB_REPO:-}" ]]; then
fi
fi
# Set the Sparkle account if provided via environment
SPARKLE_ACCOUNT="${SPARKLE_ACCOUNT:-}"
GITHUB_REPO_FULL="${GITHUB_USERNAME}/${GITHUB_REPO}"
SPARKLE_PRIVATE_KEY_PATH="${SPARKLE_PRIVATE_KEY_PATH:-private/sparkle_private_key}"
# Try alternate location if primary doesn't exist
if [[ ! -f "$SPARKLE_PRIVATE_KEY_PATH" ]] && [[ -f "sparkle-private-ed-key.pem" ]]; then
SPARKLE_PRIVATE_KEY_PATH="sparkle-private-ed-key.pem"
fi
# Verify private key exists
if [ ! -f "$SPARKLE_PRIVATE_KEY_PATH" ]; then
@ -117,8 +124,14 @@ generate_signature() {
exit 1
fi
# Sign using the private key file (no fallback)
local signature=$($sign_update_bin "$file_path" -f "$SPARKLE_PRIVATE_KEY_PATH" -p 2>/dev/null)
# Sign using the private key file with account if specified
local sign_cmd="$sign_update_bin \"$file_path\" -f \"$SPARKLE_PRIVATE_KEY_PATH\" -p"
if [ -n "$SPARKLE_ACCOUNT" ]; then
sign_cmd="$sign_cmd --account \"$SPARKLE_ACCOUNT\""
echo "Using Sparkle account: $SPARKLE_ACCOUNT" >&2
fi
local signature=$(eval $sign_cmd 2>/dev/null)
if [ -n "$signature" ] && [ "$signature" != "-----END PRIVATE KEY-----" ]; then
echo "$signature"
return 0
@ -126,6 +139,11 @@ generate_signature() {
echo -e "${RED}❌ Error: Failed to generate signature for $filename${NC}" >&2
echo "Please ensure the private key at $SPARKLE_PRIVATE_KEY_PATH is valid" >&2
if [ -n "$SPARKLE_ACCOUNT" ]; then
echo "Also check that the account '$SPARKLE_ACCOUNT' is correct" >&2
else
echo "You may need to specify SPARKLE_ACCOUNT environment variable" >&2
fi
exit 1
}
@ -325,6 +343,19 @@ EOF
main() {
print_info "Generating appcast files for $GITHUB_REPO_FULL"
# Check if we need to detect the Sparkle account
if [ -z "$SPARKLE_ACCOUNT" ] && command -v security >/dev/null 2>&1; then
print_info "Attempting to detect Sparkle account from Keychain..."
# Try to find EdDSA keys in the Keychain
DETECTED_ACCOUNT=$(security find-generic-password -s "https://sparkle-project.org" 2>/dev/null | grep "acct" | sed 's/.*acct"<blob>="\(.*\)"/\1/' || echo "")
if [ -n "$DETECTED_ACCOUNT" ]; then
SPARKLE_ACCOUNT="$DETECTED_ACCOUNT"
print_info "Detected Sparkle account: $SPARKLE_ACCOUNT"
else
print_warning "Could not detect Sparkle account. Using default signing."
fi
fi
# Create temporary directory
local temp_dir=$(mktemp -d)
trap "rm -rf $temp_dir" EXIT

View file

@ -139,12 +139,12 @@ echo ""
# 3. Check build numbers
echo "📌 Build Number Validation:"
USED_BUILD_NUMBERS=""
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
APPCAST_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/appcast.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
if [[ -f "$PROJECT_ROOT/../appcast.xml" ]]; then
APPCAST_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/../appcast.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
USED_BUILD_NUMBERS+="$APPCAST_BUILDS"
fi
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
PRERELEASE_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
if [[ -f "$PROJECT_ROOT/../appcast-prerelease.xml" ]]; then
PRERELEASE_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/../appcast-prerelease.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
USED_BUILD_NUMBERS+="$PRERELEASE_BUILDS"
fi
@ -181,7 +181,7 @@ echo ""
# Check if Xcode project uses version.xcconfig
echo "📌 Xcode Project Configuration:"
XCODEPROJ="$PROJECT_ROOT/mac/VibeTunnel.xcodeproj/project.pbxproj"
XCODEPROJ="$PROJECT_ROOT/VibeTunnel-Mac.xcodeproj/project.pbxproj"
if [[ -f "$XCODEPROJ" ]]; then
if grep -q "version.xcconfig" "$XCODEPROJ"; then
check_pass "Xcode project references version.xcconfig"
@ -304,8 +304,8 @@ echo ""
# 7. Check appcast files
echo "📌 Appcast Files:"
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
if xmllint --noout "$PROJECT_ROOT/appcast.xml" 2>/dev/null; then
if [[ -f "$PROJECT_ROOT/../appcast.xml" ]]; then
if xmllint --noout "$PROJECT_ROOT/../appcast.xml" 2>/dev/null; then
check_pass "appcast.xml is valid XML"
else
check_fail "appcast.xml has XML errors"
@ -314,8 +314,8 @@ else
check_warn "appcast.xml not found (OK if no stable releases yet)"
fi
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
if xmllint --noout "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null; then
if [[ -f "$PROJECT_ROOT/../appcast-prerelease.xml" ]]; then
if xmllint --noout "$PROJECT_ROOT/../appcast-prerelease.xml" 2>/dev/null; then
check_pass "appcast-prerelease.xml is valid XML"
else
check_fail "appcast-prerelease.xml has XML errors"

View file

@ -113,6 +113,22 @@ echo ""
# Additional strict pre-conditions before preflight check
echo -e "${BLUE}🔍 Running strict pre-conditions...${NC}"
# Check if CHANGELOG.md exists in mac directory
if [[ ! -f "$PROJECT_ROOT/CHANGELOG.md" ]]; then
echo -e "${YELLOW}⚠️ Warning: CHANGELOG.md not found in mac/ directory${NC}"
echo " The release script expects CHANGELOG.md to be in the mac/ directory"
echo " You can copy it from the project root: cp ../CHANGELOG.md ."
fi
# Clean up any stuck VibeTunnel volumes before starting
echo "🧹 Cleaning up any stuck DMG volumes..."
for volume in /Volumes/VibeTunnel*; do
if [ -d "$volume" ]; then
echo " Unmounting $volume..."
hdiutil detach "$volume" -force 2>/dev/null || true
fi
done
# Check if we're on main branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$CURRENT_BRANCH" != "main" ]]; then
@ -170,8 +186,17 @@ fi
# Check if changelog file exists
if [[ ! -f "$PROJECT_ROOT/CHANGELOG.md" ]]; then
echo -e "${YELLOW}⚠️ Warning: CHANGELOG.md not found${NC}"
echo " Release notes will be basic"
echo -e "${YELLOW}⚠️ Warning: CHANGELOG.md not found in mac/ directory${NC}"
echo " Looking for it in project root..."
if [[ -f "$PROJECT_ROOT/../CHANGELOG.md" ]]; then
echo " Found CHANGELOG.md in project root"
CHANGELOG_PATH="$PROJECT_ROOT/../CHANGELOG.md"
else
echo " CHANGELOG.md not found anywhere - release notes will be basic"
CHANGELOG_PATH=""
fi
else
CHANGELOG_PATH="$PROJECT_ROOT/CHANGELOG.md"
fi
# Check if we're up to date with origin/main
@ -247,12 +272,12 @@ fi
# Verify build number hasn't been used
echo "🔍 Checking build number uniqueness..."
EXISTING_BUILDS=""
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
APPCAST_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/appcast.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
if [[ -f "$PROJECT_ROOT/../appcast.xml" ]]; then
APPCAST_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/../appcast.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
EXISTING_BUILDS+="$APPCAST_BUILDS"
fi
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
PRERELEASE_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
if [[ -f "$PROJECT_ROOT/../appcast-prerelease.xml" ]]; then
PRERELEASE_BUILDS=$(grep -E '<sparkle:version>[0-9]+</sparkle:version>' "$PROJECT_ROOT/../appcast-prerelease.xml" 2>/dev/null | sed 's/.*<sparkle:version>\([0-9]*\)<\/sparkle:version>.*/\1/' | tr '\n' ' ' || true)
EXISTING_BUILDS+="$PRERELEASE_BUILDS"
fi
@ -308,9 +333,9 @@ fi
echo -e "${GREEN}✅ Version updated to: $VERSION_TO_SET${NC}"
# Check if Xcode project was modified and commit if needed
if ! git diff --quiet "$PROJECT_ROOT/VibeTunnel.xcodeproj/project.pbxproj"; then
if ! git diff --quiet "$PROJECT_ROOT/VibeTunnel-Mac.xcodeproj/project.pbxproj"; then
echo "📝 Committing Xcode project changes..."
git add "$PROJECT_ROOT/VibeTunnel.xcodeproj/project.pbxproj"
git add "$PROJECT_ROOT/VibeTunnel-Mac.xcodeproj/project.pbxproj"
git commit -m "Update Xcode project for build $BUILD_NUMBER"
echo -e "${GREEN}✅ Xcode project changes committed${NC}"
fi
@ -567,14 +592,14 @@ echo "📤 Creating GitHub release..."
# Generate release notes from changelog
echo "📝 Generating release notes from changelog..."
CHANGELOG_HTML=""
if [[ -x "$SCRIPT_DIR/changelog-to-html.sh" ]] && [[ -f "$PROJECT_ROOT/CHANGELOG.md" ]]; then
if [[ -x "$SCRIPT_DIR/changelog-to-html.sh" ]] && [[ -n "$CHANGELOG_PATH" ]] && [[ -f "$CHANGELOG_PATH" ]]; then
# Extract version for changelog (remove any pre-release suffixes for lookup)
CHANGELOG_VERSION="$RELEASE_VERSION"
if [[ "$CHANGELOG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
CHANGELOG_BASE="${BASH_REMATCH[1]}"
# Try full version first, then base version
CHANGELOG_HTML=$("$SCRIPT_DIR/changelog-to-html.sh" "$CHANGELOG_VERSION" "$PROJECT_ROOT/CHANGELOG.md" 2>/dev/null || \
"$SCRIPT_DIR/changelog-to-html.sh" "$CHANGELOG_BASE" "$PROJECT_ROOT/CHANGELOG.md" 2>/dev/null || \
CHANGELOG_HTML=$("$SCRIPT_DIR/changelog-to-html.sh" "$CHANGELOG_VERSION" "$CHANGELOG_PATH" 2>/dev/null || \
"$SCRIPT_DIR/changelog-to-html.sh" "$CHANGELOG_BASE" "$CHANGELOG_PATH" 2>/dev/null || \
echo "")
fi
fi
@ -611,15 +636,18 @@ echo -e "${BLUE}📋 Step 8/9: Updating appcast...${NC}"
# Generate appcast
echo "🔐 Generating appcast with EdDSA signatures..."
# Set the Sparkle account for sign_update
export SPARKLE_ACCOUNT="VibeTunnel"
echo " Using Sparkle account: $SPARKLE_ACCOUNT"
"$SCRIPT_DIR/generate-appcast.sh"
# Verify the appcast was updated
if [[ "$RELEASE_TYPE" == "stable" ]]; then
if ! grep -q "<sparkle:version>$BUILD_NUMBER</sparkle:version>" "$PROJECT_ROOT/appcast.xml"; then
if ! grep -q "<sparkle:version>$BUILD_NUMBER</sparkle:version>" "$PROJECT_ROOT/../appcast.xml"; then
echo -e "${YELLOW}⚠️ Appcast may not have been updated. Please check manually.${NC}"
fi
else
if ! grep -q "<sparkle:version>$BUILD_NUMBER</sparkle:version>" "$PROJECT_ROOT/appcast-prerelease.xml"; then
if ! grep -q "<sparkle:version>$BUILD_NUMBER</sparkle:version>" "$PROJECT_ROOT/../appcast-prerelease.xml"; then
echo -e "${YELLOW}⚠️ Pre-release appcast may not have been updated. Please check manually.${NC}"
fi
fi
@ -633,8 +661,17 @@ echo "📤 Committing and pushing changes..."
# Add version.xcconfig changes
git add "$VERSION_CONFIG" 2>/dev/null || true
# Add appcast files
git add "$PROJECT_ROOT/appcast.xml" "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null || true
# Add appcast files (they're in project root, not mac/)
if [[ -f "$PROJECT_ROOT/../appcast.xml" ]]; then
git add "$PROJECT_ROOT/../appcast.xml" 2>/dev/null || true
else
echo -e "${YELLOW}⚠️ Warning: appcast.xml not found in project root${NC}"
fi
if [[ -f "$PROJECT_ROOT/../appcast-prerelease.xml" ]]; then
git add "$PROJECT_ROOT/../appcast-prerelease.xml" 2>/dev/null || true
else
echo -e "${YELLOW}⚠️ Warning: appcast-prerelease.xml not found in project root${NC}"
fi
if ! git diff --cached --quiet; then
git commit -m "Update appcast and version for $RELEASE_VERSION"

View file

@ -134,26 +134,26 @@ validate_appcast() {
}
# Validate both appcast files
validate_appcast "$PROJECT_ROOT/appcast.xml" "Stable appcast"
validate_appcast "$PROJECT_ROOT/../appcast.xml" "Stable appcast"
echo ""
validate_appcast "$PROJECT_ROOT/appcast-prerelease.xml" "Pre-release appcast"
validate_appcast "$PROJECT_ROOT/../appcast-prerelease.xml" "Pre-release appcast"
# Cross-validation between appcasts
echo ""
echo "📌 Cross-Validation:"
if [[ -f "$PROJECT_ROOT/appcast.xml" ]] && [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
if [[ -f "$PROJECT_ROOT/../appcast.xml" ]] && [[ -f "$PROJECT_ROOT/../appcast-prerelease.xml" ]]; then
# Get all build numbers from both files
ALL_BUILDS=()
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
if [[ -f "$PROJECT_ROOT/../appcast.xml" ]]; then
while IFS= read -r build; do
ALL_BUILDS+=("$build")
done < <(grep -o '<sparkle:version>[0-9]*</sparkle:version>' "$PROJECT_ROOT/appcast.xml" | sed 's/<[^>]*>//g')
done < <(grep -o '<sparkle:version>[0-9]*</sparkle:version>' "$PROJECT_ROOT/../appcast.xml" | sed 's/<[^>]*>//g')
fi
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
if [[ -f "$PROJECT_ROOT/../appcast-prerelease.xml" ]]; then
while IFS= read -r build; do
ALL_BUILDS+=("$build")
done < <(grep -o '<sparkle:version>[0-9]*</sparkle:version>' "$PROJECT_ROOT/appcast-prerelease.xml" | sed 's/<[^>]*>//g')
done < <(grep -o '<sparkle:version>[0-9]*</sparkle:version>' "$PROJECT_ROOT/../appcast-prerelease.xml" | sed 's/<[^>]*>//g')
fi
# Check for duplicates across files

52
tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,52 @@
# Dependencies
node_modules/
dist/
dist-ssr/
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Tauri
src-tauri/target/
src-tauri/Cargo.lock
# Frontend build
public/bundle/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# OS
.DS_Store
Thumbs.db
# Env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage/
.nyc_output/
# Temporary files
*.tmp
*.temp
.cache/

210
tauri/README.md Normal file
View file

@ -0,0 +1,210 @@
# VibeTunnel Tauri App
This directory contains the Tauri-based desktop application for VibeTunnel. Tauri is a framework for building smaller, faster, and more secure desktop applications with a web frontend.
## What is Tauri?
Tauri is a toolkit that helps developers make applications for major desktop platforms using virtually any frontend framework. Unlike Electron, Tauri:
- Uses the system's native webview instead of bundling Chromium
- Results in much smaller app sizes (typically 10-100x smaller)
- Has better performance and lower memory usage
- Provides better security through a smaller attack surface
## Architecture
The VibeTunnel Tauri app uses a subprocess architecture similar to the Mac app:
- **Frontend**: HTML/CSS/JavaScript served from the `public/` directory
- **Backend**: Rust code in `src-tauri/` that manages the Node.js subprocess
- **Node.js Server**: The `vibetunnel` executable spawned as a subprocess handles all terminal operations
- **IPC Bridge**: Commands defined in Rust that proxy to the Node.js server API
### Key Changes from Embedded Server
Instead of embedding terminal management in Rust, the Tauri app:
1. Spawns the same `vibetunnel` Node.js executable used by the Mac app
2. Proxies terminal commands to the Node.js server via HTTP API
3. Monitors the subprocess health and handles crashes
4. Bundles the Node.js executable and its dependencies as resources
## Prerequisites
Before you begin, ensure you have the following installed:
- [Node.js](https://nodejs.org/) (v18 or later)
- [Rust](https://www.rust-lang.org/tools/install) (latest stable)
- Platform-specific dependencies:
- **macOS**: Xcode Command Line Tools
- **Linux**: `webkit2gtk`, `libgtk-3-dev`, `libappindicator3-dev`
- **Windows**: WebView2 (comes with Windows 11/10)
## Getting Started
### Installation
1. Clone the repository and navigate to the Tauri directory:
```bash
cd /path/to/vibetunnel3/tauri
```
2. Install dependencies:
```bash
npm install
```
### Development
To run the app in development mode with hot-reloading:
```bash
./dev.sh
# or manually:
cd ../web && npm run build # Build vibetunnel executable first
cd ../tauri && npm run tauri dev
```
This will:
- Build the Node.js server executable
- Start the Rust backend with file watching
- Spawn the Node.js subprocess
- Serve the frontend with hot-reloading
- Open the app window automatically
- Show debug output in the terminal
### Building
To build the app for production:
```bash
./build.sh
# or manually:
cd ../web && npm run build # Build vibetunnel executable first
cd ../tauri && npm run tauri build
```
This creates an optimized build in `src-tauri/target/release/bundle/`:
- **macOS**: `.app` bundle and `.dmg` installer
- **Linux**: `.deb` and `.AppImage` packages
- **Windows**: `.msi` and `.exe` installers
The build includes:
- The `vibetunnel` Node.js executable
- Native modules (`pty.node`, `spawn-helper`)
- Web static assets from `web/public/`
## Project Structure
```
tauri/
├── public/ # Frontend files (HTML, CSS, JS)
│ ├── index.html # Main app window
│ ├── settings.html # Settings window
│ └── welcome.html # Welcome/onboarding window
├── src-tauri/ # Rust backend
│ ├── src/ # Rust source code
│ │ ├── main.rs # App entry point
│ │ ├── commands.rs # Tauri commands (IPC)
│ │ ├── backend_manager.rs # Node.js subprocess management
│ │ ├── api_client.rs # HTTP client for Node.js API
│ │ └── ... # Other modules
│ ├── Cargo.toml # Rust dependencies
│ └── tauri.conf.json # Tauri configuration
├── build.sh # Production build script
├── dev.sh # Development run script
├── package.json # Node.js dependencies
└── README.md # This file
```
## Key Features
The Tauri app provides:
- **Native Terminal Integration**: Spawn and manage terminal sessions
- **System Tray Support**: Menu bar icon with quick actions
- **Multi-Window Management**: Main, settings, and welcome windows
- **Secure IPC**: Commands for frontend-backend communication
- **Platform Integration**: Native menus, notifications, and file dialogs
- **Single Instance**: Prevents multiple app instances
- **Auto-Updates**: Built-in update mechanism
## Development Tips
### Adding New Commands
To add a new command that the frontend can call:
1. Define the command in `src-tauri/src/commands.rs`:
```rust
#[tauri::command]
async fn my_command(param: String) -> Result<String, String> {
Ok(format!("Hello, {}!", param))
}
```
2. Register it in `src-tauri/src/main.rs`:
```rust
.invoke_handler(tauri::generate_handler![
// ... existing commands
my_command,
])
```
3. Call it from the frontend:
```javascript
const { invoke } = window.__TAURI__.tauri;
const result = await invoke('my_command', { param: 'World' });
```
### Debugging
- **Frontend**: Use browser DevTools (right-click → Inspect in dev mode)
- **Backend**: Check terminal output or use `println!` debugging
- **IPC Issues**: Enable Tauri logging with `RUST_LOG=debug npm run tauri dev`
### Hot Keys
While in development mode:
- `Cmd+R` / `Ctrl+R`: Reload the frontend
- `Cmd+Q` / `Ctrl+Q`: Quit the app
## Configuration
The main configuration file is `src-tauri/tauri.conf.json`, which controls:
- App metadata (name, version, identifier)
- Window settings (size, position, decorations)
- Build settings (icons, resources)
- Security policies
## Troubleshooting
### Common Issues
1. **Build fails with "cannot find crate"**
- Run `cd src-tauri && cargo update`
2. **App doesn't start in dev mode**
- Check that port 1420 is available
- Try `npm run tauri dev -- --port 3000`
3. **Permission errors on macOS**
- Grant necessary permissions in System Preferences
- The app will prompt for required permissions on first launch
### Logs
- Development logs appear in the terminal
- Production logs on macOS: `~/Library/Logs/VibeTunnel/`
## Contributing
When contributing to the Tauri app:
1. Follow the existing code style
2. Test on all target platforms if possible
3. Update this README if adding new features
4. Run `cargo fmt` in `src-tauri/` before committing
## Resources
- [Tauri Documentation](https://tauri.app/v1/guides/)
- [Tauri API Reference](https://tauri.app/v1/api/js/)
- [Rust Documentation](https://doc.rust-lang.org/book/)
- [VibeTunnel Documentation](https://vibetunnel.sh)
## License
See the main project LICENSE file.

45
tauri/build.sh Executable file
View file

@ -0,0 +1,45 @@
#!/bin/bash
set -e
echo "Building VibeTunnel Tauri App..."
# Change to web directory and build the Node.js server
echo "Building Node.js server..."
cd ../web
# Install dependencies if needed
if [ ! -d "node_modules" ]; then
echo "Installing web dependencies..."
npm install
fi
# Build the web project (creates vibetunnel executable)
echo "Building vibetunnel executable..."
npm run build
# Check that required files exist
if [ ! -f "native/vibetunnel" ]; then
echo "Error: vibetunnel executable not found at web/native/vibetunnel"
exit 1
fi
if [ ! -f "native/pty.node" ]; then
echo "Error: pty.node not found at web/native/pty.node"
exit 1
fi
if [ ! -f "native/spawn-helper" ]; then
echo "Error: spawn-helper not found at web/native/spawn-helper"
exit 1
fi
echo "Node.js server built successfully!"
# Change back to tauri directory
cd ../tauri
# Build Tauri app
echo "Building Tauri app..."
cargo tauri build
echo "Build complete!"

33
tauri/dev.sh Executable file
View file

@ -0,0 +1,33 @@
#!/bin/bash
set -e
echo "Starting VibeTunnel Tauri App in development mode..."
# Change to web directory and build the Node.js server
echo "Building Node.js server..."
cd ../web
# Install dependencies if needed
if [ ! -d "node_modules" ]; then
echo "Installing web dependencies..."
npm install
fi
# Build the web project (creates vibetunnel executable)
echo "Building vibetunnel executable..."
npm run build
# Check that required files exist
if [ ! -f "native/vibetunnel" ]; then
echo "Error: vibetunnel executable not found at web/native/vibetunnel"
exit 1
fi
echo "Node.js server built successfully!"
# Change back to tauri directory
cd ../tauri
# Run Tauri in dev mode
echo "Starting Tauri app in development mode..."
cargo tauri dev

233
tauri/package-lock.json generated Normal file
View file

@ -0,0 +1,233 @@
{
"name": "vibetunnel-tauri",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vibetunnel-tauri",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.0.0-rc.18"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.5.0.tgz",
"integrity": "sha512-rAtHqG0Gh/IWLjN2zTf3nZqYqbo81oMbqop56rGTjrlWk9pTTAjkqOjSL9XQLIMZ3RbeVjveCqqCA0s8RnLdMg==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.5.0",
"@tauri-apps/cli-darwin-x64": "2.5.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.5.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.5.0",
"@tauri-apps/cli-linux-arm64-musl": "2.5.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.5.0",
"@tauri-apps/cli-linux-x64-gnu": "2.5.0",
"@tauri-apps/cli-linux-x64-musl": "2.5.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.5.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.5.0",
"@tauri-apps/cli-win32-x64-msvc": "2.5.0"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz",
"integrity": "sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz",
"integrity": "sha512-hUF01sC06cZVa8+I0/VtsHOk9BbO75rd+YdtHJ48xTdcYaQ5QIwL4yZz9OR1AKBTaUYhBam8UX9Pvd5V2/4Dpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.5.0.tgz",
"integrity": "sha512-LQKqttsK252LlqYyX8R02MinUsfFcy3+NZiJwHFgi5Y3+ZUIAED9cSxJkyNtuY5KMnR4RlpgWyLv4P6akN1xhg==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.5.0.tgz",
"integrity": "sha512-mTQufsPcpdHg5RW0zypazMo4L55EfeE5snTzrPqbLX4yCK2qalN7+rnP8O8GT06xhp6ElSP/Ku1M2MR297SByQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz",
"integrity": "sha512-rQO1HhRUQqyEaal5dUVOQruTRda/TD36s9kv1hTxZiFuSq3558lsTjAcUEnMAtBcBkps20sbyTJNMT0AwYIk8Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.5.0.tgz",
"integrity": "sha512-7oS18FN46yDxyw1zX/AxhLAd7T3GrLj3Ai6s8hZKd9qFVzrAn36ESL7d3G05s8wEtsJf26qjXnVF4qleS3dYsA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.5.0.tgz",
"integrity": "sha512-SG5sFNL7VMmDBdIg3nO3EzNRT306HsiEQ0N90ILe3ZABYAVoPDO/ttpCO37ApLInTzrq/DLN+gOlC/mgZvLw1w==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz",
"integrity": "sha512-QXDM8zp/6v05PNWju5ELsVwF0VH1n6b5pk2E6W/jFbbiwz80Vs1lACl9pv5kEHkrxBj+aWU/03JzGuIj2g3SkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.5.0.tgz",
"integrity": "sha512-pFSHFK6b+o9y4Un8w0gGLwVyFTZaC3P0kQ7umRt/BLDkzD5RnQ4vBM7CF8BCU5nkwmEBUCZd7Wt3TWZxe41o6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.5.0.tgz",
"integrity": "sha512-EArv1IaRlogdLAQyGlKmEqZqm5RfHCUMhJoedWu7GtdbOMUfSAz6FMX2boE1PtEmNO4An+g188flLeVErrxEKg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.5.0.tgz",
"integrity": "sha512-lj43EFYbnAta8pd9JnUq87o+xRUR0odz+4rixBtTUwUgdRdwQ2V9CzFtsMu6FQKpFQ6mujRK6P1IEwhL6ADRsQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}

21
tauri/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "vibetunnel-tauri",
"version": "1.0.0",
"description": "Tauri system tray app for VibeTunnel terminal multiplexer",
"scripts": {
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0-rc.18"
},
"keywords": [
"terminal",
"multiplexer",
"tauri",
"system-tray"
],
"author": "",
"license": "MIT"
}

BIN
tauri/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

266
tauri/public/index.html Normal file
View file

@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VibeTunnel</title>
<style>
:root {
--bg-color: #1c1c1e;
--text-primary: #f5f5f7;
--text-secondary: #98989d;
--accent-color: #0a84ff;
--accent-hover: #409cff;
--border-color: rgba(255, 255, 255, 0.1);
}
@media (prefers-color-scheme: light) {
:root {
--bg-color: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--accent-color: #007aff;
--accent-hover: #0051d5;
--border-color: rgba(0, 0, 0, 0.1);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
max-width: 600px;
padding: 40px;
}
.app-icon {
width: 128px;
height: 128px;
margin-bottom: 30px;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.3));
border-radius: 27.6%;
}
h1 {
font-size: 32px;
font-weight: 600;
margin-bottom: 16px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 18px;
color: var(--text-secondary);
margin-bottom: 40px;
line-height: 1.5;
}
.status {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 30px;
padding: 12px 20px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
display: inline-block;
}
.button-group {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.button {
padding: 12px 24px;
background-color: var(--accent-color);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
}
.button:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 132, 255, 0.3);
}
.button:active {
transform: translateY(0);
}
.secondary-button {
background-color: transparent;
color: var(--accent-color);
border: 1px solid var(--accent-color);
}
.secondary-button:hover {
background-color: var(--accent-color);
color: white;
}
.info {
margin-top: 40px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
.info-item {
margin-bottom: 8px;
}
.loading {
display: none;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
margin-right: 8px;
}
</style>
</head>
<body>
<div class="container">
<img src="icon.png" alt="VibeTunnel" class="app-icon">
<h1>VibeTunnel</h1>
<p class="subtitle">Turn any browser into your terminal. Command your agents on the go.</p>
<div class="status" id="status">
<span class="loading" id="loadingSpinner"><span class="spinner"></span></span>
<span id="statusText">Checking server status...</span>
</div>
<div class="button-group">
<button class="button" onclick="openDashboard()">Open Dashboard</button>
<button class="button secondary-button" onclick="openSettings()">Settings</button>
<button class="button secondary-button" onclick="showWelcome()">Welcome Guide</button>
</div>
<div class="info">
<div class="info-item">💡 VibeTunnel runs in your system tray</div>
<div class="info-item">🖱️ Click the tray icon to access quick actions</div>
<div class="info-item">⌨️ Use the <code>vt</code> command to create terminal sessions</div>
</div>
</div>
<script>
// Add error handling for Tauri API
let tauriApi = null;
try {
if (window.__TAURI__) {
tauriApi = window.__TAURI__;
console.log('Tauri API loaded successfully');
} else {
console.error('Tauri API not available');
}
} catch (error) {
console.error('Error loading Tauri API:', error);
}
const invoke = tauriApi?.tauri?.invoke || (() => Promise.reject('Tauri not available'));
const open = tauriApi?.shell?.open || (() => Promise.reject('Tauri shell not available'));
async function checkServerStatus() {
const statusEl = document.getElementById('statusText');
const spinner = document.getElementById('loadingSpinner');
try {
spinner.style.display = 'inline-block';
const status = await invoke('get_server_status');
if (status.running) {
statusEl.textContent = `Server running on port ${status.port}`;
statusEl.style.color = '#32d74b';
} else {
statusEl.textContent = 'Server not running';
statusEl.style.color = '#ff453a';
}
} catch (error) {
statusEl.textContent = 'Unable to check server status';
statusEl.style.color = '#ff453a';
} finally {
spinner.style.display = 'none';
}
}
async function openDashboard() {
try {
const status = await invoke('get_server_status');
if (status.running) {
await open(status.url);
} else {
alert('Server is not running. Please start the server from the tray menu.');
}
} catch (error) {
console.error('Failed to open dashboard:', error);
alert('Failed to open dashboard. Please check the server status.');
}
}
async function openSettings() {
try {
await invoke('open_settings_window');
} catch (error) {
console.error('Failed to open settings:', error);
}
}
async function showWelcome() {
try {
await invoke('show_welcome_window');
} catch (error) {
console.error('Failed to show welcome:', error);
}
}
// Check server status on load
window.addEventListener('DOMContentLoaded', () => {
console.log('VibeTunnel main window loaded');
checkServerStatus();
// Refresh status every 5 seconds
setInterval(checkServerStatus, 5000);
});
// Listen for server status updates
if (tauriApi?.event) {
tauriApi.event.listen('server:restarted', (event) => {
console.log('Server restarted event received:', event);
checkServerStatus();
});
}
</script>
</body>
</html>

View file

@ -0,0 +1,634 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Console - VibeTunnel</title>
<style>
:root {
/* Light mode colors */
--bg-color: #f5f5f7;
--window-bg: #ffffff;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-tertiary: #c7c7cc;
--accent-color: #007aff;
--accent-hover: #0051d5;
--border-color: rgba(0, 0, 0, 0.1);
--shadow-color: rgba(0, 0, 0, 0.1);
--console-bg: #1e1e1e;
--console-text: #d4d4d4;
--console-info: #3794ff;
--console-success: #4ec9b0;
--console-warning: #ce9178;
--console-error: #f48771;
--console-debug: #b5cea8;
}
@media (prefers-color-scheme: dark) {
:root {
/* Dark mode colors */
--bg-color: #000000;
--window-bg: #1c1c1e;
--text-primary: #f5f5f7;
--text-secondary: #98989d;
--text-tertiary: #48484a;
--accent-color: #0a84ff;
--accent-hover: #409cff;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-color: rgba(0, 0, 0, 0.5);
--console-bg: #0e0e0e;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: var(--window-bg);
border-bottom: 1px solid var(--border-color);
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--console-success);
animation: pulse 2s ease-in-out infinite;
}
.status-indicator.stopped {
background-color: var(--console-error);
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.header-controls {
display: flex;
gap: 8px;
}
.button {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
background-color: var(--accent-color);
color: white;
}
.button:hover {
background-color: var(--accent-hover);
}
.button:active {
transform: scale(0.98);
}
.button.secondary {
background-color: transparent;
color: var(--accent-color);
border: 1px solid var(--accent-color);
}
.button.secondary:hover {
background-color: var(--accent-color);
color: white;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Filter Bar */
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background-color: var(--window-bg);
border-bottom: 1px solid var(--border-color);
}
.search-input {
flex: 1;
padding: 6px 12px;
font-size: 13px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-color);
color: var(--text-primary);
outline: none;
}
.search-input:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1);
}
.filter-buttons {
display: flex;
gap: 4px;
}
.filter-button {
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.filter-button:hover {
background-color: var(--bg-color);
}
.filter-button.active {
background-color: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
/* Console */
.console-container {
flex: 1;
background-color: var(--console-bg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.console {
flex: 1;
padding: 12px 16px;
overflow-y: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 12px;
line-height: 1.5;
color: var(--console-text);
-webkit-user-select: text;
user-select: text;
}
/* Custom scrollbar */
.console::-webkit-scrollbar {
width: 8px;
}
.console::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.console::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.console::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Log entries */
.log-entry {
margin-bottom: 2px;
padding: 2px 0;
display: flex;
align-items: flex-start;
gap: 8px;
opacity: 0;
animation: fadeIn 0.2s ease-out forwards;
}
@keyframes fadeIn {
to { opacity: 1; }
}
.log-timestamp {
color: var(--text-tertiary);
flex-shrink: 0;
font-size: 11px;
}
.log-level {
font-weight: 600;
text-transform: uppercase;
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
flex-shrink: 0;
}
.log-level.trace {
color: var(--console-debug);
background-color: rgba(181, 206, 168, 0.1);
}
.log-level.debug {
color: var(--console-debug);
background-color: rgba(181, 206, 168, 0.1);
}
.log-level.info {
color: var(--console-info);
background-color: rgba(55, 148, 255, 0.1);
}
.log-level.warn {
color: var(--console-warning);
background-color: rgba(206, 145, 120, 0.1);
}
.log-level.error {
color: var(--console-error);
background-color: rgba(244, 135, 113, 0.1);
}
.log-level.success {
color: var(--console-success);
background-color: rgba(78, 201, 176, 0.1);
}
.log-message {
flex: 1;
word-wrap: break-word;
}
.log-entry.hidden {
display: none;
}
/* Footer */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background-color: var(--window-bg);
border-top: 1px solid var(--border-color);
font-size: 11px;
color: var(--text-secondary);
}
.log-stats {
display: flex;
gap: 16px;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
.stat-count {
font-weight: 600;
color: var(--text-primary);
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
font-size: 14px;
text-align: center;
padding: 20px;
}
.empty-icon {
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
/* Loading state */
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="header">
<div class="header-title">
<div class="status-indicator" id="statusIndicator"></div>
<span>Server Console</span>
<span id="serverInfo" style="color: var(--text-secondary); font-weight: normal;">Port 4020</span>
</div>
<div class="header-controls">
<button class="button secondary" onclick="clearConsole()">Clear</button>
<button class="button secondary" onclick="exportLogs()">Export</button>
<button class="button secondary" onclick="toggleAutoScroll()" id="autoScrollBtn">Auto-scroll: ON</button>
<button class="button" onclick="toggleServer()" id="serverToggleBtn">Stop Server</button>
</div>
</div>
<div class="filter-bar">
<input type="text" class="search-input" placeholder="Search logs..." id="searchInput" oninput="filterLogs()">
<div class="filter-buttons">
<button class="filter-button active" data-level="all" onclick="setLogFilter('all')">All</button>
<button class="filter-button" data-level="error" onclick="setLogFilter('error')">Errors</button>
<button class="filter-button" data-level="warn" onclick="setLogFilter('warn')">Warnings</button>
<button class="filter-button" data-level="info" onclick="setLogFilter('info')">Info</button>
<button class="filter-button" data-level="debug" onclick="setLogFilter('debug')">Debug</button>
</div>
</div>
<div class="console-container">
<div class="console" id="console">
<div class="loading" id="loadingState">
<div class="spinner"></div>
<span>Connecting to server...</span>
</div>
<div class="empty-state" id="emptyState" style="display: none;">
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>No logs yet</p>
<p style="font-size: 12px; margin-top: 8px;">Server logs will appear here when activity occurs</p>
</div>
</div>
</div>
<div class="footer">
<div class="log-stats">
<div class="stat-item">
<span>Total:</span>
<span class="stat-count" id="totalCount">0</span>
</div>
<div class="stat-item">
<span>Errors:</span>
<span class="stat-count" id="errorCount">0</span>
</div>
<div class="stat-item">
<span>Warnings:</span>
<span class="stat-count" id="warnCount">0</span>
</div>
</div>
<div id="connectionStatus">Connected</div>
</div>
<script>
const { invoke } = window.__TAURI__.tauri;
const { appWindow } = window.__TAURI__.window;
const { open } = window.__TAURI__.shell;
let logs = [];
let autoScroll = true;
let currentFilter = 'all';
let searchTerm = '';
let isServerRunning = true;
let updateInterval;
// Initialize
async function init() {
await loadServerStatus();
await loadLogs();
// Start periodic updates
updateInterval = setInterval(async () => {
await loadServerStatus();
await loadLogs();
}, 1000);
}
// Load server status
async function loadServerStatus() {
try {
const status = await invoke('get_server_status');
isServerRunning = status.running;
const indicator = document.getElementById('statusIndicator');
const toggleBtn = document.getElementById('serverToggleBtn');
const serverInfo = document.getElementById('serverInfo');
if (isServerRunning) {
indicator.classList.remove('stopped');
toggleBtn.textContent = 'Stop Server';
serverInfo.textContent = `Port ${status.port}`;
} else {
indicator.classList.add('stopped');
toggleBtn.textContent = 'Start Server';
serverInfo.textContent = 'Stopped';
}
} catch (error) {
console.error('Failed to load server status:', error);
}
}
// Load logs
async function loadLogs() {
try {
const newLogs = await invoke('get_server_logs', { limit: 1000 });
// Hide loading state
document.getElementById('loadingState').style.display = 'none';
if (newLogs.length === 0 && logs.length === 0) {
document.getElementById('emptyState').style.display = 'flex';
return;
} else {
document.getElementById('emptyState').style.display = 'none';
}
// Check if there are new logs
if (newLogs.length > logs.length) {
logs = newLogs;
renderLogs();
}
} catch (error) {
console.error('Failed to load logs:', error);
document.getElementById('connectionStatus').textContent = 'Disconnected';
}
}
// Render logs
function renderLogs() {
const console = document.getElementById('console');
const wasAtBottom = console.scrollHeight - console.scrollTop === console.clientHeight;
// Clear existing logs
console.innerHTML = '';
// Apply filters
let filteredLogs = logs;
if (currentFilter !== 'all') {
filteredLogs = logs.filter(log => log.level.toLowerCase() === currentFilter);
}
if (searchTerm) {
filteredLogs = filteredLogs.filter(log =>
log.message.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Render filtered logs
filteredLogs.forEach(log => {
const entry = createLogEntry(log);
console.appendChild(entry);
});
// Update stats
updateStats();
// Auto-scroll if enabled and was at bottom
if (autoScroll && wasAtBottom) {
console.scrollTop = console.scrollHeight;
}
}
// Create log entry element
function createLogEntry(log) {
const entry = document.createElement('div');
entry.className = 'log-entry';
const timestamp = document.createElement('span');
timestamp.className = 'log-timestamp';
timestamp.textContent = new Date(log.timestamp).toLocaleTimeString();
const level = document.createElement('span');
level.className = `log-level ${log.level.toLowerCase()}`;
level.textContent = log.level;
const message = document.createElement('span');
message.className = 'log-message';
message.textContent = log.message;
entry.appendChild(timestamp);
entry.appendChild(level);
entry.appendChild(message);
return entry;
}
// Update statistics
function updateStats() {
document.getElementById('totalCount').textContent = logs.length;
document.getElementById('errorCount').textContent = logs.filter(l => l.level === 'error').length;
document.getElementById('warnCount').textContent = logs.filter(l => l.level === 'warn').length;
}
// Filter logs by level
function setLogFilter(level) {
currentFilter = level;
// Update button states
document.querySelectorAll('.filter-button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.level === level);
});
renderLogs();
}
// Filter logs by search term
function filterLogs() {
searchTerm = document.getElementById('searchInput').value;
renderLogs();
}
// Clear console
function clearConsole() {
logs = [];
renderLogs();
}
// Export logs
async function exportLogs() {
try {
await invoke('export_logs');
} catch (error) {
console.error('Failed to export logs:', error);
}
}
// Toggle auto-scroll
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollBtn').textContent = `Auto-scroll: ${autoScroll ? 'ON' : 'OFF'}`;
}
// Toggle server
async function toggleServer() {
try {
if (isServerRunning) {
await invoke('stop_server');
} else {
await invoke('start_server');
}
await loadServerStatus();
} catch (error) {
console.error('Failed to toggle server:', error);
}
}
// Cleanup on window close
appWindow.onCloseRequested(async () => {
clearInterval(updateInterval);
});
// Start the app
init();
</script>
</body>
</html>

View file

@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Details - VibeTunnel</title>
<style>
:root {
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--border-color: #e0e0e0;
--accent-color: #007AFF;
--success-color: #4CAF50;
--error-color: #f44336;
--font-mono: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d30;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--border-color: #444444;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
padding: 30px;
min-height: 100vh;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.header {
margin-bottom: 20px;
}
.header h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
.header-info {
display: flex;
align-items: center;
justify-content: space-between;
}
.pid-label {
font-size: 18px;
color: var(--text-secondary);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
}
.status-badge.running {
background-color: rgba(76, 175, 80, 0.1);
color: var(--success-color);
}
.status-badge.stopped {
background-color: rgba(244, 67, 54, 0.1);
color: var(--error-color);
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-indicator.running {
background-color: var(--success-color);
}
.status-indicator.stopped {
background-color: var(--error-color);
}
.details-section {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.detail-row {
display: flex;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
flex: 0 0 140px;
font-weight: 500;
color: var(--text-secondary);
text-align: right;
margin-right: 20px;
}
.detail-value {
flex: 1;
font-family: var(--font-mono);
font-size: 13px;
user-select: text;
word-break: break-all;
}
.actions {
display: flex;
gap: 10px;
justify-content: space-between;
}
.btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background-color: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn:hover {
background-color: var(--border-color);
}
.btn-primary {
background-color: var(--accent-color);
color: white;
border: none;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-danger {
color: var(--error-color);
}
.btn-danger:hover {
background-color: rgba(244, 67, 54, 0.1);
}
.loading {
text-align: center;
padding: 50px;
color: var(--text-secondary);
}
.error {
text-align: center;
padding: 50px;
color: var(--error-color);
}
</style>
</head>
<body>
<div class="container">
<div id="loading" class="loading">Loading session details...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="content" style="display: none;">
<div class="header">
<h1>Session Details</h1>
<div class="header-info">
<span class="pid-label">PID: <span id="pid"></span></span>
<div id="status-badge" class="status-badge">
<span class="status-indicator"></span>
<span id="status-text"></span>
</div>
</div>
</div>
<div class="details-section">
<div class="detail-row">
<div class="detail-label">Session ID:</div>
<div class="detail-value" id="session-id"></div>
</div>
<div class="detail-row">
<div class="detail-label">Command:</div>
<div class="detail-value" id="command"></div>
</div>
<div class="detail-row">
<div class="detail-label">Working Directory:</div>
<div class="detail-value" id="working-dir"></div>
</div>
<div class="detail-row">
<div class="detail-label">Status:</div>
<div class="detail-value" id="status"></div>
</div>
<div class="detail-row">
<div class="detail-label">Started At:</div>
<div class="detail-value" id="started-at"></div>
</div>
<div class="detail-row">
<div class="detail-label">Last Modified:</div>
<div class="detail-value" id="last-modified"></div>
</div>
<div class="detail-row" id="exit-code-row" style="display: none;">
<div class="detail-label">Exit Code:</div>
<div class="detail-value" id="exit-code"></div>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" id="open-terminal-btn">Open in Terminal</button>
<button class="btn btn-danger" id="terminate-btn">Terminate Session</button>
</div>
</div>
</div>
<script type="module">
import { invoke } from './assets/index.js';
// Get session ID from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('id');
if (!sessionId) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = 'No session ID provided';
} else {
loadSessionDetails();
}
async function loadSessionDetails() {
try {
// Get monitored sessions
const sessions = await invoke('get_monitored_sessions');
const session = sessions.find(s => s.id === sessionId);
if (!session) {
throw new Error('Session not found');
}
// Update UI with session details
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
// Update window title
const dirName = session.working_dir.split('/').pop() || session.working_dir;
document.title = `${dirName} — VibeTunnel (PID: ${session.pid})`;
// Populate fields
document.getElementById('pid').textContent = session.pid;
document.getElementById('session-id').textContent = session.id;
document.getElementById('command').textContent = session.command;
document.getElementById('working-dir').textContent = session.working_dir;
document.getElementById('status').textContent = session.status.charAt(0).toUpperCase() + session.status.slice(1);
document.getElementById('started-at').textContent = formatDate(session.started_at);
document.getElementById('last-modified').textContent = formatDate(session.last_modified);
// Update status badge
const isRunning = session.is_running;
const statusBadge = document.getElementById('status-badge');
const statusIndicator = statusBadge.querySelector('.status-indicator');
const statusText = document.getElementById('status-text');
if (isRunning) {
statusBadge.classList.add('running');
statusIndicator.classList.add('running');
statusText.textContent = 'Running';
document.getElementById('terminate-btn').style.display = 'block';
} else {
statusBadge.classList.add('stopped');
statusIndicator.classList.add('stopped');
statusText.textContent = 'Stopped';
document.getElementById('terminate-btn').style.display = 'none';
}
// Show exit code if present
if (session.exit_code !== null && session.exit_code !== undefined) {
document.getElementById('exit-code-row').style.display = 'flex';
document.getElementById('exit-code').textContent = session.exit_code;
}
// Setup button handlers
document.getElementById('open-terminal-btn').addEventListener('click', async () => {
try {
await invoke('terminal_spawn_service:spawn_terminal_for_session', {
sessionId: session.id
});
} catch (error) {
console.error('Failed to open terminal:', error);
alert('Failed to open terminal: ' + error);
}
});
document.getElementById('terminate-btn').addEventListener('click', async () => {
if (confirm('Are you sure you want to terminate this session?')) {
try {
await invoke('close_terminal', { id: session.id });
// Refresh the page after termination
setTimeout(() => loadSessionDetails(), 500);
} catch (error) {
console.error('Failed to terminate session:', error);
alert('Failed to terminate session: ' + error);
}
}
});
} catch (error) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = 'Error loading session details: ' + error.message;
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// Auto-refresh session details every 2 seconds
setInterval(() => {
if (document.getElementById('content').style.display !== 'none') {
loadSessionDetails();
}
}, 2000);
</script>
</body>
</html>

1516
tauri/public/settings.html Normal file

File diff suppressed because it is too large Load diff

638
tauri/public/welcome.html Normal file
View file

@ -0,0 +1,638 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to VibeTunnel</title>
<style>
:root {
/* Light mode colors */
--bg-color: #ffffff;
--window-bg: #f5f5f7;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--accent-color: #007aff;
--accent-hover: #0051d5;
--border-color: rgba(0, 0, 0, 0.1);
--shadow-color: rgba(0, 0, 0, 0.15);
--indicator-inactive: rgba(134, 134, 139, 0.3);
}
@media (prefers-color-scheme: dark) {
:root {
/* Dark mode colors */
--bg-color: #1c1c1e;
--window-bg: #000000;
--text-primary: #f5f5f7;
--text-secondary: #98989d;
--accent-color: #0a84ff;
--accent-hover: #409cff;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-color: rgba(0, 0, 0, 0.5);
--indicator-inactive: rgba(152, 152, 157, 0.3);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
width: 100vw;
height: 100vh;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--window-bg);
}
.content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
overflow: hidden;
}
.page {
width: 100%;
height: 100%;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
animation: slideIn 0.3s ease-out;
}
.page.active {
display: flex;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.app-icon {
width: 156px;
height: 156px;
margin-bottom: 40px;
filter: drop-shadow(0 10px 20px var(--shadow-color));
border-radius: 27.6%;
transition: all 0.3s ease;
}
.app-icon:hover {
transform: scale(1.05);
filter: drop-shadow(0 15px 30px var(--shadow-color));
}
.text-content {
text-align: center;
max-width: 480px;
}
h1 {
font-size: 40px;
font-weight: 600;
margin-bottom: 20px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 16px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 20px;
}
.description {
font-size: 16px;
color: var(--text-secondary);
line-height: 1.5;
}
.navigation {
height: 92px;
padding: 0 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
border-top: 1px solid var(--border-color);
background-color: var(--bg-color);
}
.indicators {
height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding-top: 12px;
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--indicator-inactive);
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.indicator:hover {
background-color: var(--text-secondary);
transform: scale(1.2);
}
.indicator.active {
background-color: var(--accent-color);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.button-container {
height: 60px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.next-button {
min-width: 80px;
padding: 8px 20px;
background-color: var(--accent-color);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
.next-button:hover {
background-color: var(--accent-hover);
}
.next-button:active {
transform: scale(0.98);
}
/* Additional pages content */
.feature-list {
margin-top: 30px;
text-align: left;
max-width: 400px;
}
.feature-item {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
color: var(--text-secondary);
font-size: 15px;
line-height: 1.5;
}
.feature-icon {
width: 20px;
height: 20px;
margin-right: 12px;
flex-shrink: 0;
color: var(--accent-color);
}
.code-block {
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 16px;
margin: 20px 0;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 13px;
color: var(--text-primary);
}
.button-group {
display: flex;
gap: 12px;
margin-top: 20px;
justify-content: center;
}
.secondary-button {
padding: 8px 20px;
background-color: transparent;
color: var(--accent-color);
border: 1px solid var(--accent-color);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.secondary-button:hover {
background-color: var(--accent-color);
color: white;
}
.terminal-item {
transition: all 0.2s ease;
}
.terminal-item:hover {
background-color: var(--tab-bg) !important;
transform: translateX(4px);
}
.credits {
margin-top: 40px;
text-align: center;
font-size: 12px;
color: var(--text-secondary);
}
.credits p {
margin: 4px 0;
}
.credits a {
color: var(--accent-color);
text-decoration: none;
}
.credits a:hover {
text-decoration: underline;
}
.success-checkmark {
color: var(--success-color);
margin-right: 8px;
}
/* Add status colors */
:root {
--success-color: #34c759;
--error-color: #ff3b30;
}
@media (prefers-color-scheme: dark) {
:root {
--success-color: #32d74b;
--error-color: #ff453a;
}
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<!-- Page 1: Welcome -->
<div class="page active" id="page-0">
<img src="icon.png" alt="VibeTunnel" class="app-icon">
<div class="text-content">
<h1>Welcome to VibeTunnel</h1>
<p class="subtitle">Turn any browser into your terminal. Command your agents on the go.</p>
<p class="description">
You'll be quickly guided through the basics of VibeTunnel.<br>
This screen can always be opened from the settings.
</p>
</div>
</div>
<!-- Page 2: VT Command -->
<div class="page" id="page-1">
<img src="icon.png" alt="VibeTunnel" class="app-icon">
<div class="text-content">
<h1>Install the VT Command</h1>
<p class="subtitle">The <code>vt</code> command lets you quickly create terminal sessions</p>
<div class="code-block">
$ vt<br>
# Creates a new terminal session in your browser
</div>
<div class="button-group">
<button class="secondary-button" onclick="installCLI()" id="installCLIBtn">Install CLI Tool</button>
</div>
<p class="description" id="cliStatus" style="margin-top: 20px; color: var(--text-secondary);"></p>
</div>
</div>
<!-- Page 3: Request Permissions -->
<div class="page" id="page-2">
<img src="icon.png" alt="VibeTunnel" class="app-icon">
<div class="text-content">
<h1>Grant Permissions</h1>
<p class="subtitle">VibeTunnel needs permissions to function properly</p>
<div class="feature-list">
<div class="feature-item">
<svg class="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Terminal Automation - To launch terminal windows</span>
</div>
<div class="feature-item">
<svg class="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Accessibility - To control terminal applications</span>
</div>
</div>
<div class="button-group">
<button class="secondary-button" onclick="requestPermissions()">Grant Permissions</button>
</div>
</div>
</div>
<!-- Page 4: Select Terminal -->
<div class="page" id="page-3">
<img src="icon.png" alt="VibeTunnel" class="app-icon">
<div class="text-content">
<h1>Select Your Terminal</h1>
<p class="subtitle">Choose your preferred terminal emulator</p>
<div class="terminal-list" id="terminalList" style="margin: 20px 0;">
<!-- Terminal options will be populated here -->
</div>
<div class="button-group">
<button class="secondary-button" onclick="testTerminal()">Test Terminal</button>
</div>
</div>
</div>
<!-- Page 5: Protect Dashboard -->
<div class="page" id="page-4">
<img src="icon.png" alt="VibeTunnel" class="app-icon">
<div class="text-content">
<h1>Protect Your Dashboard</h1>
<p class="subtitle">Security is important when accessing terminals remotely</p>
<p class="description">
We recommend setting a password for your dashboard,<br>
especially if you plan to access it from outside your local network.
</p>
<div class="button-group">
<button class="secondary-button" onclick="openSettings('dashboard')">Set Password</button>
<button class="secondary-button" onclick="skipPassword()">Skip for Now</button>
</div>
</div>
</div>
<!-- Page 6: Access Dashboard -->
<div class="page" id="page-5">
<img src="icon.png" alt="VibeTunnel" class="app-icon">
<div class="text-content">
<h1>Access Your Dashboard</h1>
<p class="subtitle">
To access your terminals from any device, create a tunnel from your device.<br><br>
This can be done via <strong>ngrok</strong> in settings or <strong>Tailscale</strong> (recommended).
</p>
<div class="button-group">
<button class="secondary-button" onclick="openDashboard()">
Open Dashboard
</button>
<button class="secondary-button" onclick="openTailscale()">
Learn about Tailscale
</button>
</div>
<div class="credits">
<p>Made with ❤️ by</p>
<p>
<a href="#" onclick="openURL('https://twitter.com/badlogic'); return false;">@badlogic</a>,
<a href="#" onclick="openURL('https://twitter.com/mitsuhiko'); return false;">@mitsuhiko</a> &
<a href="#" onclick="openURL('https://twitter.com/steipete'); return false;">@steipete</a>
</p>
</div>
</div>
</div>
<!-- Page 7: Getting Started -->
<div class="page" id="page-6">
<img src="icon.png" alt="VibeTunnel" class="app-icon">
<div class="text-content">
<h1>You're All Set!</h1>
<p class="subtitle">VibeTunnel is now running in your system tray</p>
<p class="description">
Click the VibeTunnel icon in your system tray to access settings,<br>
open the dashboard, or manage your terminal sessions.
</p>
</div>
</div>
</div>
<div class="navigation">
<div class="indicators">
<button class="indicator active" onclick="goToPage(0)"></button>
<button class="indicator" onclick="goToPage(1)"></button>
<button class="indicator" onclick="goToPage(2)"></button>
<button class="indicator" onclick="goToPage(3)"></button>
<button class="indicator" onclick="goToPage(4)"></button>
<button class="indicator" onclick="goToPage(5)"></button>
<button class="indicator" onclick="goToPage(6)"></button>
</div>
<div class="button-container">
<button class="next-button" id="nextButton" onclick="handleNext()">Next</button>
</div>
</div>
</div>
<script>
const { invoke } = window.__TAURI__.tauri;
const { open } = window.__TAURI__.shell;
const { appWindow } = window.__TAURI__.window;
let currentPage = 0;
const totalPages = 7;
function updateNextButton() {
const button = document.getElementById('nextButton');
button.textContent = currentPage === totalPages - 1 ? 'Finish' : 'Next';
}
function handleNext() {
if (currentPage < totalPages - 1) {
goToPage(currentPage + 1);
} else {
// Close the welcome window
appWindow.close();
}
}
async function openDashboard() {
try {
await open('http://127.0.0.1:4020');
} catch (error) {
console.error('Failed to open dashboard:', error);
}
}
async function openTailscale() {
try {
await open('https://tailscale.com/');
} catch (error) {
console.error('Failed to open Tailscale:', error);
}
}
async function openURL(url) {
try {
await open(url);
} catch (error) {
console.error('Failed to open URL:', error);
}
}
async function installCLI() {
const button = document.getElementById('installCLIBtn');
const status = document.getElementById('cliStatus');
button.disabled = true;
status.textContent = 'Installing CLI tool...';
try {
await invoke('install_cli');
status.textContent = '✓ CLI tool installed successfully';
status.style.color = 'var(--success-color)';
button.textContent = 'Installed';
} catch (error) {
status.textContent = '✗ Installation failed';
status.style.color = 'var(--error-color)';
button.disabled = false;
}
}
async function requestPermissions() {
try {
await invoke('request_all_permissions');
} catch (error) {
console.error('Failed to request permissions:', error);
}
}
async function loadTerminals() {
try {
const terminals = await invoke('detect_terminals');
const container = document.getElementById('terminalList');
container.innerHTML = '';
terminals.forEach(terminal => {
const item = document.createElement('label');
item.className = 'terminal-item';
item.style = 'display: flex; align-items: center; padding: 12px; margin-bottom: 8px; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer;';
item.innerHTML = `
<input type="radio" name="terminal" value="${terminal.id}" style="margin-right: 12px;">
<span>${terminal.name}</span>
`;
container.appendChild(item);
});
// Select first terminal by default
if (terminals.length > 0) {
container.querySelector('input[type="radio"]').checked = true;
}
} catch (error) {
console.error('Failed to load terminals:', error);
}
}
async function testTerminal() {
const selected = document.querySelector('input[name="terminal"]:checked');
if (selected) {
try {
await invoke('test_terminal', { terminal: selected.value });
} catch (error) {
console.error('Failed to test terminal:', error);
}
}
}
async function openSettings(tab) {
try {
await invoke('open_settings_window', { tab });
} catch (error) {
console.error('Failed to open settings:', error);
}
}
function skipPassword() {
// Just go to next page
goToPage(currentPage + 1);
}
// Load terminals when reaching that page
function goToPage(pageIndex) {
if (pageIndex < 0 || pageIndex >= totalPages) return;
// Hide current page
document.getElementById(`page-${currentPage}`).classList.remove('active');
document.querySelectorAll('.indicator')[currentPage].classList.remove('active');
// Show new page
currentPage = pageIndex;
document.getElementById(`page-${currentPage}`).classList.add('active');
document.querySelectorAll('.indicator')[currentPage].classList.add('active');
// Load data for specific pages
if (currentPage === 3) {
loadTerminals();
}
// Update button text
updateNextButton();
}
// Handle keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
handleNext();
} else if (e.key === 'ArrowRight' && currentPage < totalPages - 1) {
goToPage(currentPage + 1);
} else if (e.key === 'ArrowLeft' && currentPage > 0) {
goToPage(currentPage - 1);
}
});
// Check CLI status on page load
window.addEventListener('DOMContentLoaded', async () => {
try {
const isInstalled = await invoke('is_cli_installed');
if (isInstalled) {
const button = document.getElementById('installCLIBtn');
const status = document.getElementById('cliStatus');
button.textContent = 'Installed';
button.disabled = true;
status.textContent = '✓ CLI tool is already installed';
status.style.color = 'var(--success-color)';
}
} catch (error) {
console.error('Failed to check CLI status:', error);
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,15 @@
[target.'cfg(all())']
rustflags = [
"-W", "clippy::all",
"-W", "clippy::pedantic",
"-W", "clippy::nursery",
"-W", "clippy::cargo",
"-A", "clippy::module_name_repetitions",
"-A", "clippy::must_use_candidate",
"-A", "clippy::missing_errors_doc",
"-A", "clippy::missing_panics_doc",
"-A", "clippy::similar_names",
"-A", "clippy::too_many_lines",
"-A", "clippy::cargo_common_metadata",
"-A", "clippy::multiple_crate_versions",
]

107
tauri/src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,107 @@
[package]
name = "vibetunnel"
version = "0.1.0"
description = "VibeTunnel - Cross-platform terminal session manager"
authors = ["VibeTunnel Team"]
edition = "2021"
[package.metadata.bundle]
identifier = "com.vibetunnel.app"
copyright = "Copyright © 2024 VibeTunnel Team"
category = "DeveloperTool"
short_description = "Terminal session manager with remote access"
long_description = "VibeTunnel is a powerful terminal session manager that allows you to create, manage, and share terminal sessions. Features include multiple concurrent sessions, remote access capabilities, and a modern web-based interface."
[lib]
name = "tauri_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2.0.3", features = [] }
[dependencies]
tauri = { version = "2.1.1", features = ["unstable", "devtools", "image-png", "image-ico", "tray-icon"] }
tauri-plugin-shell = "2.1.0"
tauri-plugin-dialog = "2.0.3"
tauri-plugin-process = "2.0.1"
tauri-plugin-fs = "2.0.3"
tauri-plugin-http = "2.0.3"
tauri-plugin-notification = "2.0.1"
tauri-plugin-updater = "2.0.2"
tauri-plugin-window-state = "2.0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# Terminal handling
portable-pty = "0.8"
bytes = "1"
futures = "0.3"
# WebSocket server
tokio-tungstenite = "0.24"
tungstenite = "0.24"
# SSE streaming
async-stream = "0.3"
tokio-stream = "0.1"
# HTTP server
axum = { version = "0.7", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "cors"] }
# Settings and storage
directories = "5"
toml = "0.8"
# Utilities
open = "5"
# File system
dirs = "5"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Auto-launch
auto-launch = "0.5"
# System info
whoami = "1"
hostname = "0.4"
# ngrok integration and API client
which = "7"
reqwest = { version = "0.12", features = ["json", "blocking"] }
# Authentication
base64 = "0.22"
sha2 = "0.10"
# Keychain/Credential Storage
keyring = "3"
# Debug features
num_cpus = "1"
# Network utilities
[target.'cfg(unix)'.dependencies]
nix = { version = "0.27", features = ["net", "signal", "process"] }
[target.'cfg(windows)'.dependencies]
ipconfig = "0.3"
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2.0.1"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true

59
tauri/src-tauri/README.md Normal file
View file

@ -0,0 +1,59 @@
# VibeTunnel Tauri App
This is a cross-platform version of VibeTunnel built with Tauri v2.
## Architecture
The Tauri app provides:
- System tray/menu bar integration
- Native window management
- Cross-platform terminal PTY support (to be implemented)
- Secure IPC between frontend and backend
## Development
### Prerequisites
- Rust 1.70+
- Node.js 18+
- Platform-specific dependencies:
- **macOS**: Xcode Command Line Tools
- **Linux**: `webkit2gtk-4.1`, `libayatana-appindicator3-dev`
- **Windows**: WebView2 (usually pre-installed on Windows 10/11)
### Running in Development
1. Start the Node.js server (in the web directory):
```bash
npm run dev
```
2. In another terminal, run the Tauri app:
```bash
npm run tauri:dev
```
### Building for Production
```bash
npm run tauri:build
```
This will create platform-specific binaries in `src-tauri/target/release/bundle/`.
## Features
- **Menu Bar App**: Runs as a system tray application
- **Web UI**: Uses the existing VibeTunnel web interface
- **Native Integration**: Platform-specific features through Tauri APIs
- **Auto-updater**: Built-in update mechanism
- **Single Instance**: Prevents multiple instances from running
## TODO
1. Implement native PTY support using cross-platform Rust libraries
2. Add platform-specific terminal launching
3. Implement file system access for session recordings
4. Add native notifications
5. Implement keyboard shortcuts
6. Add auto-launch on startup

3
tauri/src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View file

@ -0,0 +1,50 @@
{
"$schema": "../gen/schemas/capabilities.json",
"identifier": "all-windows",
"description": "Capability for all application windows",
"local": true,
"windows": ["main", "settings", "welcome", "server-console", "api-testing"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-start-dragging",
"core:window:allow-set-title",
"core:app:default",
"core:path:default",
"core:event:default",
"core:webview:default",
"shell:default",
"shell:allow-open",
"dialog:default",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-read-dir",
"fs:allow-copy-file",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-exists",
"fs:scope-appdata-recursive",
"fs:scope-resource-recursive",
"fs:scope-temp-recursive",
"fs:allow-home-read-recursive",
"fs:allow-home-write-recursive",
"process:default",
"process:allow-exit",
"process:allow-restart",
"http:default",
"http:allow-fetch",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"window-state:allow-restore-state",
"window-state:allow-save-window-state",
"updater:default"
]
}

View file

@ -0,0 +1,50 @@
{
"$schema": "../gen/schemas/capabilities.json",
"identifier": "default",
"description": "Default capability for the application",
"local": true,
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-start-dragging",
"core:window:allow-set-title",
"core:app:default",
"core:path:default",
"core:event:default",
"core:webview:default",
"shell:default",
"shell:allow-open",
"dialog:default",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-read-dir",
"fs:allow-copy-file",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-exists",
"fs:scope-appdata-recursive",
"fs:scope-resource-recursive",
"fs:scope-temp-recursive",
"fs:allow-home-read-recursive",
"fs:allow-home-write-recursive",
"process:default",
"process:allow-exit",
"process:allow-restart",
"http:default",
"http:allow-fetch",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"window-state:allow-restore-state",
"window-state:allow-save-window-state",
"updater:default"
]
}

View file

@ -0,0 +1,49 @@
{
"$schema": "../gen/schemas/capabilities.json",
"identifier": "settings",
"description": "Capability for the settings window",
"local": true,
"windows": ["settings"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-start-dragging",
"core:window:allow-set-title",
"core:app:default",
"core:path:default",
"core:event:default",
"core:webview:default",
"shell:default",
"shell:allow-open",
"dialog:default",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-read-dir",
"fs:allow-copy-file",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-exists",
"fs:scope-appdata-recursive",
"fs:scope-resource-recursive",
"fs:scope-temp-recursive",
"fs:allow-home-read-recursive",
"fs:allow-home-write-recursive",
"process:default",
"process:allow-exit",
"process:allow-restart",
"http:default",
"http:allow-fetch",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"window-state:allow-restore-state",
"window-state:allow-save-window-state",
"updater:default"
]
}

View file

@ -0,0 +1,8 @@
# Clippy configuration
cognitive-complexity-threshold = 30
too-many-arguments-threshold = 8
type-complexity-threshold = 250
single-char-binding-names-threshold = 4
too-many-lines-threshold = 400
array-size-threshold = 512
enum-variant-name-threshold = 3

View file

@ -0,0 +1,18 @@
<?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>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.device.microphone</key>
<false/>
<key>com.apple.security.device.camera</key>
<false/>
</dict>
</plist>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"all-windows":{"identifier":"all-windows","description":"Capability for all application windows","local":true,"windows":["main","settings","welcome","server-console","api-testing"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-start-dragging","core:window:allow-set-title","core:app:default","core:path:default","core:event:default","core:webview:default","shell:default","shell:allow-open","dialog:default","fs:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-exists","fs:scope-appdata-recursive","fs:scope-resource-recursive","fs:scope-temp-recursive","fs:allow-home-read-recursive","fs:allow-home-write-recursive","process:default","process:allow-exit","process:allow-restart","http:default","http:allow-fetch","notification:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","window-state:allow-restore-state","window-state:allow-save-window-state","updater:default"]},"default":{"identifier":"default","description":"Default capability for the application","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-start-dragging","core:window:allow-set-title","core:app:default","core:path:default","core:event:default","core:webview:default","shell:default","shell:allow-open","dialog:default","fs:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-exists","fs:scope-appdata-recursive","fs:scope-resource-recursive","fs:scope-temp-recursive","fs:allow-home-read-recursive","fs:allow-home-write-recursive","process:default","process:allow-exit","process:allow-restart","http:default","http:allow-fetch","notification:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","window-state:allow-restore-state","window-state:allow-save-window-state","updater:default"]},"settings":{"identifier":"settings","description":"Capability for the settings window","local":true,"windows":["settings"],"permissions":["core:default","core:window:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-start-dragging","core:window:allow-set-title","core:app:default","core:path:default","core:event:default","core:webview:default","shell:default","shell:allow-open","dialog:default","fs:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-exists","fs:scope-appdata-recursive","fs:scope-resource-recursive","fs:scope-temp-recursive","fs:allow-home-read-recursive","fs:allow-home-write-recursive","process:default","process:allow-exit","process:allow-restart","http:default","http:allow-fetch","notification:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","window-state:allow-restore-state","window-state:allow-save-window-state","updater:default"]}}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,22 @@
# Rust formatting configuration
edition = "2021"
max_width = 100
tab_spaces = 4
use_small_heuristics = "Default"
reorder_imports = true
reorder_modules = true
remove_nested_parens = true
match_arm_blocks = true
use_field_init_shorthand = true
use_try_shorthand = true
newline_style = "Auto"
format_code_in_doc_comments = true
wrap_comments = true
comment_width = 80
normalize_comments = true
normalize_doc_attributes = true
format_strings = true
format_macro_matchers = true
format_macro_bodies = true
imports_granularity = "Crate"
group_imports = "StdExternalCrate"

View file

@ -0,0 +1,182 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ApiClient {
client: Client,
base_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateSessionRequest {
pub name: Option<String>,
pub rows: Option<u16>,
pub cols: Option<u16>,
pub cwd: Option<String>,
pub env: Option<HashMap<String, String>>,
pub shell: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionResponse {
pub id: String,
pub name: String,
pub pid: u32,
pub rows: u16,
pub cols: u16,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InputRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ResizeRequest {
pub cols: u16,
pub rows: u16,
}
impl ApiClient {
pub fn new(port: u16) -> Self {
Self {
client: Client::new(),
base_url: format!("http://127.0.0.1:{}", port),
}
}
pub async fn create_session(&self, req: CreateSessionRequest) -> Result<SessionResponse, String> {
let url = format!("{}/api/sessions", self.base_url);
let response = self.client
.post(&url)
.json(&req)
.send()
.await
.map_err(|e| format!("Failed to create session: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Server returned error {}: {}", status, error_text));
}
response
.json::<SessionResponse>()
.await
.map_err(|e| format!("Failed to parse response: {}", e))
}
pub async fn list_sessions(&self) -> Result<Vec<SessionResponse>, String> {
let url = format!("{}/api/sessions", self.base_url);
let response = self.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to list sessions: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Server returned error {}: {}", status, error_text));
}
response
.json::<Vec<SessionResponse>>()
.await
.map_err(|e| format!("Failed to parse response: {}", e))
}
pub async fn close_session(&self, id: &str) -> Result<(), String> {
let url = format!("{}/api/sessions/{}", self.base_url, id);
let response = self.client
.delete(&url)
.send()
.await
.map_err(|e| format!("Failed to close session: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Server returned error {}: {}", status, error_text));
}
Ok(())
}
pub async fn send_input(&self, id: &str, input: &[u8]) -> Result<(), String> {
let url = format!("{}/api/sessions/{}/input", self.base_url, id);
// Convert bytes to string
let text = String::from_utf8_lossy(input).into_owned();
let req = InputRequest {
text: Some(text),
key: None,
};
let response = self.client
.post(&url)
.json(&req)
.send()
.await
.map_err(|e| format!("Failed to send input: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Server returned error {}: {}", status, error_text));
}
Ok(())
}
pub async fn resize_session(&self, id: &str, rows: u16, cols: u16) -> Result<(), String> {
let url = format!("{}/api/sessions/{}/resize", self.base_url, id);
let req = ResizeRequest { cols, rows };
let response = self.client
.post(&url)
.json(&req)
.send()
.await
.map_err(|e| format!("Failed to resize session: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Server returned error {}: {}", status, error_text));
}
Ok(())
}
pub async fn get_session_output(&self, id: &str) -> Result<Vec<u8>, String> {
let url = format!("{}/api/sessions/{}/buffer", self.base_url, id);
let response = self.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to get session output: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Server returned error {}: {}", status, error_text));
}
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("Failed to read response: {}", e))
}
}

View file

@ -0,0 +1,689 @@
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// API test method
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum HttpMethod {
GET,
POST,
PUT,
PATCH,
DELETE,
HEAD,
OPTIONS,
}
impl HttpMethod {
#[allow(dead_code)]
pub fn as_str(&self) -> &str {
match self {
HttpMethod::GET => "GET",
HttpMethod::POST => "POST",
HttpMethod::PUT => "PUT",
HttpMethod::PATCH => "PATCH",
HttpMethod::DELETE => "DELETE",
HttpMethod::HEAD => "HEAD",
HttpMethod::OPTIONS => "OPTIONS",
}
}
}
/// API test assertion type
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AssertionType {
StatusCode(u16),
StatusRange {
min: u16,
max: u16,
},
ResponseTime {
max_ms: u64,
},
HeaderExists(String),
HeaderEquals {
key: String,
value: String,
},
JsonPath {
path: String,
expected: serde_json::Value,
},
BodyContains(String),
BodyMatches(String), // Regex
ContentType(String),
}
/// API test case
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct APITest {
pub id: String,
pub name: String,
pub description: Option<String>,
pub group: Option<String>,
pub endpoint_url: String,
pub method: HttpMethod,
pub headers: HashMap<String, String>,
pub query_params: HashMap<String, String>,
pub body: Option<APITestBody>,
pub auth: Option<APITestAuth>,
pub assertions: Vec<AssertionType>,
pub timeout_ms: u64,
pub retry_count: u32,
pub delay_ms: Option<u64>,
pub save_response: bool,
}
/// API test body
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum APITestBody {
Json(serde_json::Value),
Form(HashMap<String, String>),
Text(String),
Binary(Vec<u8>),
}
/// API test authentication
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum APITestAuth {
Basic {
username: String,
password: String,
},
Bearer(String),
ApiKey {
key: String,
value: String,
in_header: bool,
},
Custom(HashMap<String, String>),
}
/// API test result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct APITestResult {
pub test_id: String,
pub test_name: String,
pub success: bool,
pub timestamp: DateTime<Utc>,
pub duration_ms: u64,
pub status_code: Option<u16>,
pub response_headers: HashMap<String, String>,
pub response_body: Option<String>,
pub assertion_results: Vec<AssertionResult>,
pub error: Option<String>,
pub retries_used: u32,
}
/// Assertion result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssertionResult {
pub assertion: AssertionType,
pub passed: bool,
pub actual_value: Option<String>,
pub error_message: Option<String>,
}
/// API test suite
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct APITestSuite {
pub id: String,
pub name: String,
pub description: Option<String>,
pub base_url: Option<String>,
pub default_headers: HashMap<String, String>,
pub default_auth: Option<APITestAuth>,
pub tests: Vec<APITest>,
pub setup_tests: Vec<APITest>,
pub teardown_tests: Vec<APITest>,
pub variables: HashMap<String, String>,
}
/// API test collection
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct APITestCollection {
pub id: String,
pub name: String,
pub suites: Vec<APITestSuite>,
pub global_variables: HashMap<String, String>,
}
/// API test runner configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct APITestRunnerConfig {
pub parallel_execution: bool,
pub max_parallel_tests: usize,
pub stop_on_failure: bool,
pub capture_responses: bool,
pub follow_redirects: bool,
pub verify_ssl: bool,
pub proxy: Option<String>,
pub environment_variables: HashMap<String, String>,
}
impl Default for APITestRunnerConfig {
fn default() -> Self {
Self {
parallel_execution: false,
max_parallel_tests: 5,
stop_on_failure: false,
capture_responses: true,
follow_redirects: true,
verify_ssl: true,
proxy: None,
environment_variables: HashMap::new(),
}
}
}
/// API test history entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct APITestHistoryEntry {
pub run_id: String,
pub timestamp: DateTime<Utc>,
pub suite_name: String,
pub total_tests: usize,
pub passed_tests: usize,
pub failed_tests: usize,
pub total_duration_ms: u64,
pub results: Vec<APITestResult>,
}
/// API testing manager
pub struct APITestingManager {
client: Arc<Client>,
config: Arc<RwLock<APITestRunnerConfig>>,
test_suites: Arc<RwLock<HashMap<String, APITestSuite>>>,
test_history: Arc<RwLock<Vec<APITestHistoryEntry>>>,
running_tests: Arc<RwLock<HashMap<String, bool>>>,
shared_variables: Arc<RwLock<HashMap<String, String>>>,
notification_manager: Option<Arc<crate::notification_manager::NotificationManager>>,
}
impl Default for APITestingManager {
fn default() -> Self {
Self::new()
}
}
impl APITestingManager {
/// Create a new API testing manager
pub fn new() -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap();
Self {
client: Arc::new(client),
config: Arc::new(RwLock::new(APITestRunnerConfig::default())),
test_suites: Arc::new(RwLock::new(HashMap::new())),
test_history: Arc::new(RwLock::new(Vec::new())),
running_tests: Arc::new(RwLock::new(HashMap::new())),
shared_variables: Arc::new(RwLock::new(HashMap::new())),
notification_manager: None,
}
}
/// Set the notification manager
pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager);
}
/// Get configuration
pub async fn get_config(&self) -> APITestRunnerConfig {
self.config.read().await.clone()
}
/// Update configuration
pub async fn update_config(&self, config: APITestRunnerConfig) {
*self.config.write().await = config;
}
/// Add test suite
pub async fn add_test_suite(&self, suite: APITestSuite) {
self.test_suites
.write()
.await
.insert(suite.id.clone(), suite);
}
/// Get test suite
pub async fn get_test_suite(&self, suite_id: &str) -> Option<APITestSuite> {
self.test_suites.read().await.get(suite_id).cloned()
}
/// List test suites
pub async fn list_test_suites(&self) -> Vec<APITestSuite> {
self.test_suites.read().await.values().cloned().collect()
}
/// Run single test
pub async fn run_test(
&self,
test: &APITest,
variables: &HashMap<String, String>,
) -> APITestResult {
let start_time = std::time::Instant::now();
let mut result = APITestResult {
test_id: test.id.clone(),
test_name: test.name.clone(),
success: false,
timestamp: Utc::now(),
duration_ms: 0,
status_code: None,
response_headers: HashMap::new(),
response_body: None,
assertion_results: Vec::new(),
error: None,
retries_used: 0,
};
// Replace variables in URL
let url = self.replace_variables(&test.endpoint_url, variables);
// Run test with retries
let mut last_error = None;
for retry in 0..=test.retry_count {
if retry > 0 {
// Delay between retries
if let Some(delay) = test.delay_ms {
tokio::time::sleep(Duration::from_millis(delay)).await;
}
}
match self.execute_request(test, &url, variables).await {
Ok((status, headers, body)) => {
result.status_code = Some(status);
result.response_headers = headers;
if test.save_response {
result.response_body = Some(body.clone());
}
result.retries_used = retry;
// Run assertions
result.assertion_results = self
.run_assertions(&test.assertions, status, &result.response_headers, &body)
.await;
result.success = result.assertion_results.iter().all(|a| a.passed);
break;
}
Err(e) => {
last_error = Some(e);
}
}
}
if let Some(error) = last_error {
result.error = Some(error);
}
result.duration_ms = start_time.elapsed().as_millis() as u64;
result
}
/// Run test suite
pub async fn run_test_suite(&self, suite_id: &str) -> Option<APITestHistoryEntry> {
let suite = self.get_test_suite(suite_id).await?;
let run_id = uuid::Uuid::new_v4().to_string();
let start_time = std::time::Instant::now();
// Merge variables
let mut variables = self.shared_variables.read().await.clone();
variables.extend(suite.variables.clone());
let mut results = Vec::new();
// Run setup tests
for test in &suite.setup_tests {
let result = self.run_test(test, &variables).await;
if !result.success && self.config.read().await.stop_on_failure {
break;
}
results.push(result);
}
// Run main tests
let config = self.config.read().await;
if config.parallel_execution {
// Run tests in parallel
let mut tasks = Vec::new();
for test in &suite.tests {
let test = test.clone();
let vars = variables.clone();
let manager = self.clone_for_parallel();
tasks.push(tokio::spawn(
async move { manager.run_test(&test, &vars).await },
));
}
for task in tasks {
if let Ok(result) = task.await {
results.push(result);
}
}
} else {
// Run tests sequentially
for test in &suite.tests {
let result = self.run_test(test, &variables).await;
if !result.success && config.stop_on_failure {
break;
}
results.push(result);
}
}
// Run teardown tests
for test in &suite.teardown_tests {
let result = self.run_test(test, &variables).await;
results.push(result);
}
let total_duration = start_time.elapsed().as_millis() as u64;
let passed = results.iter().filter(|r| r.success).count();
let failed = results.len() - passed;
let history_entry = APITestHistoryEntry {
run_id,
timestamp: Utc::now(),
suite_name: suite.name,
total_tests: results.len(),
passed_tests: passed,
failed_tests: failed,
total_duration_ms: total_duration,
results,
};
// Store in history
self.test_history.write().await.push(history_entry.clone());
// Send notification
if let Some(notification_manager) = &self.notification_manager {
let message = format!("Test suite completed: {} passed, {} failed", passed, failed);
let _ = notification_manager
.notify_success("API Tests", &message)
.await;
}
Some(history_entry)
}
/// Get test history
pub async fn get_test_history(&self, limit: Option<usize>) -> Vec<APITestHistoryEntry> {
let history = self.test_history.read().await;
match limit {
Some(n) => history.iter().rev().take(n).cloned().collect(),
None => history.clone(),
}
}
/// Clear test history
pub async fn clear_test_history(&self) {
self.test_history.write().await.clear();
}
/// Import Postman collection
pub async fn import_postman_collection(&self, _json_data: &str) -> Result<String, String> {
// TODO: Implement Postman collection import
Err("Postman import not yet implemented".to_string())
}
/// Export test suite
pub async fn export_test_suite(&self, suite_id: &str) -> Result<String, String> {
let suite = self
.get_test_suite(suite_id)
.await
.ok_or_else(|| "Test suite not found".to_string())?;
serde_json::to_string_pretty(&suite)
.map_err(|e| format!("Failed to serialize test suite: {}", e))
}
// Helper methods
async fn execute_request(
&self,
test: &APITest,
url: &str,
variables: &HashMap<String, String>,
) -> Result<(u16, HashMap<String, String>, String), String> {
let config = self.config.read().await;
let client = Client::builder()
.timeout(Duration::from_millis(test.timeout_ms))
.redirect(if config.follow_redirects {
reqwest::redirect::Policy::default()
} else {
reqwest::redirect::Policy::none()
})
.danger_accept_invalid_certs(!config.verify_ssl)
.build()
.map_err(|e| e.to_string())?;
let mut request = match test.method {
HttpMethod::GET => client.get(url),
HttpMethod::POST => client.post(url),
HttpMethod::PUT => client.put(url),
HttpMethod::PATCH => client.patch(url),
HttpMethod::DELETE => client.delete(url),
HttpMethod::HEAD => client.head(url),
HttpMethod::OPTIONS => client.request(reqwest::Method::OPTIONS, url),
};
// Add headers
for (key, value) in &test.headers {
let value = self.replace_variables(value, variables);
request = request.header(key, value);
}
// Add query params
for (key, value) in &test.query_params {
let value = self.replace_variables(value, variables);
request = request.query(&[(key, value)]);
}
// Add auth
if let Some(auth) = &test.auth {
request = self.apply_auth(request, auth, variables);
}
// Add body
if let Some(body) = &test.body {
request = match body {
APITestBody::Json(json) => request.json(json),
APITestBody::Form(form) => request.form(form),
APITestBody::Text(text) => request.body(text.clone()),
APITestBody::Binary(bytes) => request.body(bytes.clone()),
};
}
// Execute request
let response = request.send().await.map_err(|e| e.to_string())?;
let status = response.status().as_u16();
let mut headers = HashMap::new();
for (key, value) in response.headers() {
if let Ok(value_str) = value.to_str() {
headers.insert(key.to_string(), value_str.to_string());
}
}
let body = response.text().await.unwrap_or_default();
Ok((status, headers, body))
}
async fn run_assertions(
&self,
assertions: &[AssertionType],
status: u16,
headers: &HashMap<String, String>,
body: &str,
) -> Vec<AssertionResult> {
let mut results = Vec::new();
for assertion in assertions {
let result = match assertion {
AssertionType::StatusCode(expected) => AssertionResult {
assertion: assertion.clone(),
passed: status == *expected,
actual_value: Some(status.to_string()),
error_message: if status != *expected {
Some(format!("Expected status {}, got {}", expected, status))
} else {
None
},
},
AssertionType::StatusRange { min, max } => AssertionResult {
assertion: assertion.clone(),
passed: status >= *min && status <= *max,
actual_value: Some(status.to_string()),
error_message: if status < *min || status > *max {
Some(format!(
"Expected status between {} and {}, got {}",
min, max, status
))
} else {
None
},
},
AssertionType::HeaderExists(key) => AssertionResult {
assertion: assertion.clone(),
passed: headers.contains_key(key),
actual_value: None,
error_message: if !headers.contains_key(key) {
Some(format!("Header '{}' not found", key))
} else {
None
},
},
AssertionType::HeaderEquals { key, value } => {
let actual = headers.get(key);
AssertionResult {
assertion: assertion.clone(),
passed: actual == Some(value),
actual_value: actual.cloned(),
error_message: if actual != Some(value) {
Some(format!(
"Header '{}' expected '{}', got '{:?}'",
key, value, actual
))
} else {
None
},
}
}
AssertionType::BodyContains(text) => AssertionResult {
assertion: assertion.clone(),
passed: body.contains(text),
actual_value: None,
error_message: if !body.contains(text) {
Some(format!("Body does not contain '{}'", text))
} else {
None
},
},
AssertionType::JsonPath {
path: _,
expected: _,
} => {
// TODO: Implement JSON path assertion
AssertionResult {
assertion: assertion.clone(),
passed: false,
actual_value: None,
error_message: Some("JSON path assertions not yet implemented".to_string()),
}
}
_ => AssertionResult {
assertion: assertion.clone(),
passed: false,
actual_value: None,
error_message: Some("Assertion type not implemented".to_string()),
},
};
results.push(result);
}
results
}
fn replace_variables(&self, text: &str, variables: &HashMap<String, String>) -> String {
let mut result = text.to_string();
for (key, value) in variables {
result = result.replace(&format!("{{{{{}}}}}", key), value);
}
result
}
fn apply_auth(
&self,
request: reqwest::RequestBuilder,
auth: &APITestAuth,
variables: &HashMap<String, String>,
) -> reqwest::RequestBuilder {
match auth {
APITestAuth::Basic { username, password } => {
let username = self.replace_variables(username, variables);
let password = self.replace_variables(password, variables);
request.basic_auth(username, Some(password))
}
APITestAuth::Bearer(token) => {
let token = self.replace_variables(token, variables);
request.bearer_auth(token)
}
APITestAuth::ApiKey {
key,
value,
in_header,
} => {
let key = self.replace_variables(key, variables);
let value = self.replace_variables(value, variables);
if *in_header {
request.header(key, value)
} else {
request.query(&[(key, value)])
}
}
APITestAuth::Custom(headers) => {
let mut req = request;
for (key, value) in headers {
let value = self.replace_variables(value, variables);
req = req.header(key, value);
}
req
}
}
}
fn clone_for_parallel(&self) -> Self {
Self {
client: self.client.clone(),
config: self.config.clone(),
test_suites: self.test_suites.clone(),
test_history: self.test_history.clone(),
running_tests: self.running_tests.clone(),
shared_variables: self.shared_variables.clone(),
notification_manager: self.notification_manager.clone(),
}
}
}
/// API test statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct APITestStatistics {
pub total_suites: usize,
pub total_tests: usize,
pub total_runs: usize,
pub success_rate: f64,
pub average_duration_ms: f64,
pub most_failed_tests: Vec<(String, usize)>,
pub slowest_tests: Vec<(String, u64)>,
}

View file

@ -0,0 +1,175 @@
use std::path::PathBuf;
use tauri::AppHandle;
/// Check if the app should be moved to Applications folder
/// This is a macOS-specific feature
#[cfg(target_os = "macos")]
pub async fn check_and_prompt_move(app_handle: AppHandle) -> Result<(), String> {
// Get current app bundle path
let bundle_path = get_app_bundle_path()?;
// Check if already in Applications folder
if is_in_applications_folder(&bundle_path) {
return Ok(());
}
// Check if we've already asked this question
let settings = crate::settings::Settings::load().unwrap_or_default();
if let Some(asked) = settings.general.prompt_move_to_applications {
if !asked {
// User has already been asked, don't ask again
return Ok(());
}
}
// Show dialog to ask user if they want to move the app
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
let response = app_handle.dialog()
.message("VibeTunnel works best when run from the Applications folder. Would you like to move it there now?\n\nClick OK to move it now, or Cancel to skip.")
.title("Move to Applications?")
.kind(MessageDialogKind::Info)
.blocking_show();
if response {
// User wants to move the app
move_to_applications_folder(bundle_path)?;
// Show success message
app_handle
.dialog()
.message("VibeTunnel has been moved to your Applications folder and will restart.")
.title("Move Complete")
.kind(MessageDialogKind::Info)
.blocking_show();
// Restart the app from the new location
restart_from_applications()?;
}
// Update settings to not ask again
let mut settings = crate::settings::Settings::load().unwrap_or_default();
settings.general.prompt_move_to_applications = Some(false);
settings.save().ok();
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub async fn check_and_prompt_move(_app_handle: AppHandle) -> Result<(), String> {
// Not applicable on other platforms
Ok(())
}
#[cfg(target_os = "macos")]
fn get_app_bundle_path() -> Result<PathBuf, String> {
use std::env;
// Get the executable path
let exe_path =
env::current_exe().map_err(|e| format!("Failed to get executable path: {}", e))?;
// Navigate up to the .app bundle
// Typical structure: /path/to/VibeTunnel.app/Contents/MacOS/VibeTunnel
let mut bundle_path = exe_path;
// Go up three levels to reach the .app bundle
for _ in 0..3 {
bundle_path = bundle_path
.parent()
.ok_or("Failed to find app bundle")?
.to_path_buf();
}
// Verify this is an .app bundle
if !bundle_path.to_string_lossy().ends_with(".app") {
return Err("Not running from an app bundle".to_string());
}
Ok(bundle_path)
}
#[cfg(target_os = "macos")]
fn is_in_applications_folder(bundle_path: &PathBuf) -> bool {
let path_str = bundle_path.to_string_lossy();
path_str.contains("/Applications/") || path_str.contains("/System/Applications/")
}
#[cfg(target_os = "macos")]
fn move_to_applications_folder(bundle_path: PathBuf) -> Result<(), String> {
use std::fs;
use std::process::Command;
let app_name = bundle_path
.file_name()
.ok_or("Failed to get app name")?
.to_string_lossy();
let dest_path = PathBuf::from("/Applications").join(app_name.as_ref());
// Check if destination already exists
if dest_path.exists() {
// For now, just remove the existing app
// TODO: Implement dialog using tauri-plugin-dialog
// Remove existing app
fs::remove_dir_all(&dest_path)
.map_err(|e| format!("Failed to remove existing app: {}", e))?;
}
// Use AppleScript to move the app with proper permissions
let script = format!(
r#"tell application "Finder"
move (POSIX file "{}") to (POSIX file "/Applications/") with replacing
end tell"#,
bundle_path.to_string_lossy()
);
let output = Command::new("osascript")
.arg("-e")
.arg(script)
.output()
.map_err(|e| format!("Failed to execute move command: {}", e))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to move app: {}", error));
}
Ok(())
}
#[cfg(target_os = "macos")]
fn restart_from_applications() -> Result<(), String> {
use std::process::Command;
// Launch the app from the Applications folder
let _output = Command::new("open")
.arg("-n")
.arg("/Applications/VibeTunnel.app")
.spawn()
.map_err(|e| format!("Failed to restart app: {}", e))?;
// Exit the current instance
std::process::exit(0);
}
#[tauri::command]
pub async fn prompt_move_to_applications(app_handle: AppHandle) -> Result<(), String> {
check_and_prompt_move(app_handle).await
}
#[tauri::command]
pub async fn is_in_applications_folder_command() -> Result<bool, String> {
#[cfg(target_os = "macos")]
{
let bundle_path = get_app_bundle_path()?;
Ok(is_in_applications_folder(&bundle_path))
}
#[cfg(not(target_os = "macos"))]
{
// Always return true on non-macOS platforms
Ok(true)
}
}

View file

@ -0,0 +1,505 @@
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Authentication token type
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum TokenType {
Bearer,
Basic,
ApiKey,
OAuth2,
JWT,
Custom,
}
/// Authentication scope
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct AuthScope {
pub service: String,
pub resource: Option<String>,
pub permissions: Vec<String>,
}
/// Cached authentication token
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedToken {
pub token_type: TokenType,
pub token_value: String,
pub scope: AuthScope,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub refresh_token: Option<String>,
pub metadata: HashMap<String, serde_json::Value>,
}
impl CachedToken {
/// Check if token is expired
pub fn is_expired(&self) -> bool {
if let Some(expires_at) = self.expires_at {
Utc::now() >= expires_at
} else {
false
}
}
/// Check if token needs refresh (expires within threshold)
pub fn needs_refresh(&self, threshold_seconds: i64) -> bool {
if let Some(expires_at) = self.expires_at {
let refresh_time = expires_at - Duration::seconds(threshold_seconds);
Utc::now() >= refresh_time
} else {
false
}
}
/// Get remaining lifetime in seconds
#[allow(dead_code)]
pub fn remaining_lifetime_seconds(&self) -> Option<i64> {
self.expires_at.map(|expires_at| {
let duration = expires_at - Utc::now();
duration.num_seconds().max(0)
})
}
}
/// Authentication credential
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthCredential {
pub credential_type: String,
pub username: Option<String>,
pub password_hash: Option<String>, // Store hashed password
pub api_key: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub metadata: HashMap<String, String>,
}
/// Authentication cache entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthCacheEntry {
pub key: String,
pub tokens: Vec<CachedToken>,
pub credential: Option<AuthCredential>,
pub last_accessed: DateTime<Utc>,
pub access_count: u64,
}
/// Authentication cache configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthCacheConfig {
pub enabled: bool,
pub max_entries: usize,
pub default_ttl_seconds: u64,
pub refresh_threshold_seconds: i64,
pub persist_to_disk: bool,
pub encryption_enabled: bool,
pub cleanup_interval_seconds: u64,
}
impl Default for AuthCacheConfig {
fn default() -> Self {
Self {
enabled: true,
max_entries: 1000,
default_ttl_seconds: 3600, // 1 hour
refresh_threshold_seconds: 300, // 5 minutes
persist_to_disk: false,
encryption_enabled: true,
cleanup_interval_seconds: 600, // 10 minutes
}
}
}
/// Authentication cache statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthCacheStats {
pub total_entries: usize,
pub total_tokens: usize,
pub expired_tokens: usize,
pub cache_hits: u64,
pub cache_misses: u64,
pub refresh_count: u64,
pub eviction_count: u64,
}
/// Token refresh callback
pub type TokenRefreshCallback = Arc<
dyn Fn(CachedToken) -> futures::future::BoxFuture<'static, Result<CachedToken, String>>
+ Send
+ Sync,
>;
/// Authentication cache manager
pub struct AuthCacheManager {
config: Arc<RwLock<AuthCacheConfig>>,
cache: Arc<RwLock<HashMap<String, AuthCacheEntry>>>,
stats: Arc<RwLock<AuthCacheStats>>,
refresh_callbacks: Arc<RwLock<HashMap<String, TokenRefreshCallback>>>,
cleanup_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
notification_manager: Option<Arc<crate::notification_manager::NotificationManager>>,
}
impl Default for AuthCacheManager {
fn default() -> Self {
Self::new()
}
}
impl AuthCacheManager {
/// Create a new authentication cache manager
pub fn new() -> Self {
let manager = Self {
config: Arc::new(RwLock::new(AuthCacheConfig::default())),
cache: Arc::new(RwLock::new(HashMap::new())),
stats: Arc::new(RwLock::new(AuthCacheStats {
total_entries: 0,
total_tokens: 0,
expired_tokens: 0,
cache_hits: 0,
cache_misses: 0,
refresh_count: 0,
eviction_count: 0,
})),
refresh_callbacks: Arc::new(RwLock::new(HashMap::new())),
cleanup_handle: Arc::new(RwLock::new(None)),
notification_manager: None,
};
manager
}
/// Set the notification manager
pub fn set_notification_manager(
&mut self,
notification_manager: Arc<crate::notification_manager::NotificationManager>,
) {
self.notification_manager = Some(notification_manager);
}
/// Get configuration
pub async fn get_config(&self) -> AuthCacheConfig {
self.config.read().await.clone()
}
/// Update configuration
pub async fn update_config(&self, config: AuthCacheConfig) {
*self.config.write().await = config;
}
/// Store token in cache
pub async fn store_token(&self, key: &str, token: CachedToken) -> Result<(), String> {
let config = self.config.read().await;
if !config.enabled {
return Ok(());
}
let mut cache = self.cache.write().await;
let mut stats = self.stats.write().await;
// Get or create cache entry
let entry = cache.entry(key.to_string()).or_insert_with(|| {
stats.total_entries += 1;
AuthCacheEntry {
key: key.to_string(),
tokens: Vec::new(),
credential: None,
last_accessed: Utc::now(),
access_count: 0,
}
});
// Remove expired tokens
let expired_count = entry.tokens.iter().filter(|t| t.is_expired()).count();
stats.expired_tokens += expired_count;
entry.tokens.retain(|t| !t.is_expired());
// Add new token
entry.tokens.push(token);
stats.total_tokens += 1;
entry.last_accessed = Utc::now();
// Check cache size limit
if cache.len() > config.max_entries {
self.evict_oldest_entry(&mut cache, &mut stats);
}
Ok(())
}
/// Get token from cache
pub async fn get_token(&self, key: &str, scope: &AuthScope) -> Option<CachedToken> {
let config = self.config.read().await;
if !config.enabled {
return None;
}
let mut cache = self.cache.write().await;
let mut stats = self.stats.write().await;
if let Some(entry) = cache.get_mut(key) {
entry.last_accessed = Utc::now();
entry.access_count += 1;
// Find matching token
for token in &entry.tokens {
if !token.is_expired() && self.token_matches_scope(token, scope) {
stats.cache_hits += 1;
// Check if needs refresh
if token.needs_refresh(config.refresh_threshold_seconds) {
// Trigger refresh in background
if let Some(refresh_callback) = self.refresh_callbacks.read().await.get(key)
{
let token_clone = token.clone();
let callback = refresh_callback.clone();
let key_clone = key.to_string();
let manager = self.clone_for_refresh();
tokio::spawn(async move {
if let Ok(refreshed_token) = callback(token_clone).await {
let _ = manager.store_token(&key_clone, refreshed_token).await;
manager.stats.write().await.refresh_count += 1;
}
});
}
}
return Some(token.clone());
}
}
}
stats.cache_misses += 1;
None
}
/// Store credential in cache
pub async fn store_credential(
&self,
key: &str,
credential: AuthCredential,
) -> Result<(), String> {
let config = self.config.read().await;
if !config.enabled {
return Ok(());
}
let mut cache = self.cache.write().await;
let mut stats = self.stats.write().await;
let entry = cache.entry(key.to_string()).or_insert_with(|| {
stats.total_entries += 1;
AuthCacheEntry {
key: key.to_string(),
tokens: Vec::new(),
credential: None,
last_accessed: Utc::now(),
access_count: 0,
}
});
entry.credential = Some(credential);
entry.last_accessed = Utc::now();
Ok(())
}
/// Get credential from cache
pub async fn get_credential(&self, key: &str) -> Option<AuthCredential> {
let config = self.config.read().await;
if !config.enabled {
return None;
}
let mut cache = self.cache.write().await;
if let Some(entry) = cache.get_mut(key) {
entry.last_accessed = Utc::now();
entry.access_count += 1;
return entry.credential.clone();
}
None
}
/// Register token refresh callback
#[allow(dead_code)]
pub async fn register_refresh_callback(&self, key: &str, callback: TokenRefreshCallback) {
self.refresh_callbacks
.write()
.await
.insert(key.to_string(), callback);
}
/// Clear specific cache entry
pub async fn clear_entry(&self, key: &str) {
let mut cache = self.cache.write().await;
if cache.remove(key).is_some() {
self.stats.write().await.total_entries = cache.len();
}
}
/// Clear all cache entries
pub async fn clear_all(&self) {
let mut cache = self.cache.write().await;
cache.clear();
let mut stats = self.stats.write().await;
stats.total_entries = 0;
stats.total_tokens = 0;
stats.expired_tokens = 0;
}
/// Get cache statistics
pub async fn get_stats(&self) -> AuthCacheStats {
self.stats.read().await.clone()
}
/// List all cache entries
pub async fn list_entries(&self) -> Vec<(String, DateTime<Utc>, u64)> {
self.cache
.read()
.await
.values()
.map(|entry| (entry.key.clone(), entry.last_accessed, entry.access_count))
.collect()
}
/// Export cache to JSON (for persistence)
pub async fn export_cache(&self) -> Result<String, String> {
let cache = self.cache.read().await;
let entries: Vec<_> = cache.values().cloned().collect();
serde_json::to_string_pretty(&entries)
.map_err(|e| format!("Failed to serialize cache: {}", e))
}
/// Import cache from JSON
pub async fn import_cache(&self, json_data: &str) -> Result<(), String> {
let entries: Vec<AuthCacheEntry> = serde_json::from_str(json_data)
.map_err(|e| format!("Failed to deserialize cache: {}", e))?;
let mut cache = self.cache.write().await;
let mut stats = self.stats.write().await;
for entry in entries {
cache.insert(entry.key.clone(), entry);
}
stats.total_entries = cache.len();
stats.total_tokens = cache.values().map(|e| e.tokens.len()).sum();
Ok(())
}
/// Hash password for secure storage
pub fn hash_password(password: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
format!("{:x}", hasher.finalize())
}
// Helper methods
fn token_matches_scope(&self, token: &CachedToken, scope: &AuthScope) -> bool {
token.scope.service == scope.service
&& token.scope.resource == scope.resource
&& scope
.permissions
.iter()
.all(|p| token.scope.permissions.contains(p))
}
fn evict_oldest_entry(
&self,
cache: &mut HashMap<String, AuthCacheEntry>,
stats: &mut AuthCacheStats,
) {
if let Some((key, _)) = cache.iter().min_by_key(|(_, entry)| entry.last_accessed) {
let key = key.clone();
cache.remove(&key);
stats.eviction_count += 1;
stats.total_entries = cache.len();
}
}
pub async fn start_cleanup_task(&self) {
let config = self.config.read().await;
let cleanup_interval = Duration::seconds(config.cleanup_interval_seconds as i64);
drop(config);
loop {
tokio::time::sleep(cleanup_interval.to_std().unwrap()).await;
let config = self.config.read().await;
if !config.enabled {
continue;
}
drop(config);
// Clean up expired tokens
let mut cache = self.cache.write().await;
let mut stats = self.stats.write().await;
let mut total_expired = 0;
for entry in cache.values_mut() {
let expired_count = entry.tokens.iter().filter(|t| t.is_expired()).count();
total_expired += expired_count;
entry.tokens.retain(|t| !t.is_expired());
}
stats.expired_tokens += total_expired;
stats.total_tokens = cache.values().map(|e| e.tokens.len()).sum();
// Remove empty entries
cache.retain(|_, entry| !entry.tokens.is_empty() || entry.credential.is_some());
stats.total_entries = cache.len();
}
}
#[allow(dead_code)]
fn clone_for_cleanup(&self) -> Self {
Self {
config: self.config.clone(),
cache: self.cache.clone(),
stats: self.stats.clone(),
refresh_callbacks: self.refresh_callbacks.clone(),
cleanup_handle: self.cleanup_handle.clone(),
notification_manager: self.notification_manager.clone(),
}
}
fn clone_for_refresh(&self) -> Self {
Self {
config: self.config.clone(),
cache: self.cache.clone(),
stats: self.stats.clone(),
refresh_callbacks: self.refresh_callbacks.clone(),
cleanup_handle: self.cleanup_handle.clone(),
notification_manager: self.notification_manager.clone(),
}
}
}
/// Create a cache key from components
pub fn create_cache_key(service: &str, username: Option<&str>, resource: Option<&str>) -> String {
let mut components = vec![service];
if let Some(user) = username {
components.push(user);
}
if let Some(res) = resource {
components.push(res);
}
components.join(":")
}
/// Authentication cache error
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthCacheError {
pub code: String,
pub message: String,
pub details: Option<HashMap<String, String>>,
}

View file

@ -0,0 +1,78 @@
use crate::state::AppState;
use auto_launch::AutoLaunchBuilder;
use tauri::State;
fn get_app_path() -> String {
let exe_path = std::env::current_exe().unwrap();
// On macOS, we need to use the .app bundle path, not the executable inside it
#[cfg(target_os = "macos")]
{
// The executable is at: /path/to/VibeTunnel.app/Contents/MacOS/VibeTunnel
// We need: /path/to/VibeTunnel.app
if let Some(macos_dir) = exe_path.parent() {
if let Some(contents_dir) = macos_dir.parent() {
if let Some(app_bundle) = contents_dir.parent() {
if app_bundle.to_string_lossy().ends_with(".app") {
return app_bundle.to_string_lossy().to_string();
}
}
}
}
}
// For other platforms or if we couldn't find the .app bundle, use the executable path
exe_path.to_string_lossy().to_string()
}
pub fn enable_auto_launch() -> Result<(), String> {
let auto = AutoLaunchBuilder::new()
.set_app_name("VibeTunnel")
.set_app_path(&get_app_path())
.set_args(&["--auto-launch"])
.build()
.map_err(|e| format!("Failed to build auto-launch: {}", e))?;
auto.enable()
.map_err(|e| format!("Failed to enable auto-launch: {}", e))?;
Ok(())
}
pub fn disable_auto_launch() -> Result<(), String> {
let auto = AutoLaunchBuilder::new()
.set_app_name("VibeTunnel")
.set_app_path(&get_app_path())
.build()
.map_err(|e| format!("Failed to build auto-launch: {}", e))?;
auto.disable()
.map_err(|e| format!("Failed to disable auto-launch: {}", e))?;
Ok(())
}
pub fn is_auto_launch_enabled() -> Result<bool, String> {
let auto = AutoLaunchBuilder::new()
.set_app_name("VibeTunnel")
.set_app_path(&get_app_path())
.build()
.map_err(|e| format!("Failed to build auto-launch: {}", e))?;
auto.is_enabled()
.map_err(|e| format!("Failed to check auto-launch status: {}", e))
}
#[tauri::command]
pub async fn set_auto_launch(enabled: bool, _state: State<'_, AppState>) -> Result<(), String> {
if enabled {
enable_auto_launch()
} else {
disable_auto_launch()
}
}
#[tauri::command]
pub async fn get_auto_launch(_state: State<'_, AppState>) -> Result<bool, String> {
is_auto_launch_enabled()
}

Some files were not shown because too many files have changed in this diff Show more