Merge main into monaco branch
69
.github/actions/lint-reporter/action.yml
vendored
|
|
@ -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
|
||||
20
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
23
README.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
BIN
assets/appicon-borderless.png
Normal file
|
After Width: | Height: | Size: 971 KiB |
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
39
ios/VibeTunnel/Models/TerminalRenderer.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
127
ios/VibeTunnel/Models/TerminalWidth.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
172
ios/VibeTunnel/Services/SSEClient.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
311
ios/VibeTunnel/Views/FileBrowser/FilePreviewView.swift
Normal 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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
|
||||
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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
345
ios/VibeTunnel/Views/SystemLogsView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
ios/VibeTunnel/Views/Terminal/WidthSelectorPopover.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
400
ios/VibeTunnel/Views/Terminal/XtermWebView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 */;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 954 KiB |
266
tauri/public/index.html
Normal 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>
|
||||
634
tauri/public/server-console.html
Normal 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>
|
||||
364
tauri/public/session-detail.html
Normal 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
638
tauri/public/welcome.html
Normal 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>
|
||||
15
tauri/src-tauri/.cargo/config.toml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
50
tauri/src-tauri/capabilities/all-windows.json
Normal 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"
|
||||
]
|
||||
}
|
||||
50
tauri/src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
49
tauri/src-tauri/capabilities/settings.json
Normal 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"
|
||||
]
|
||||
}
|
||||
8
tauri/src-tauri/clippy.toml
Normal 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
|
||||
18
tauri/src-tauri/entitlements.plist
Normal 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>
|
||||
1
tauri/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
tauri/src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||
6590
tauri/src-tauri/gen/schemas/desktop-schema.json
Normal file
6590
tauri/src-tauri/gen/schemas/macOS-schema.json
Normal file
BIN
tauri/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
tauri/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tauri/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
tauri/src-tauri/icons/icon.icns
Normal file
0
tauri/src-tauri/icons/icon.ico
Normal file
BIN
tauri/src-tauri/icons/icon.iconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
tauri/src-tauri/icons/icon.iconset/icon_128x128@2x.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
tauri/src-tauri/icons/icon.iconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
tauri/src-tauri/icons/icon.iconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
tauri/src-tauri/icons/icon.iconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
tauri/src-tauri/icons/icon.iconset/icon_256x256@2x.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
tauri/src-tauri/icons/icon.iconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
tauri/src-tauri/icons/icon.iconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
tauri/src-tauri/icons/icon.iconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
tauri/src-tauri/icons/icon.iconset/icon_512x512@2x.png
Normal file
|
After Width: | Height: | Size: 954 KiB |
BIN
tauri/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 954 KiB |
BIN
tauri/src-tauri/icons/menu-bar-icon.png
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
tauri/src-tauri/icons/menu-bar-icon@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
tauri/src-tauri/icons/tray-icon.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
tauri/src-tauri/icons/tray-icon@2x.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
22
tauri/src-tauri/rustfmt.toml
Normal 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"
|
||||
182
tauri/src-tauri/src/api_client.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
689
tauri/src-tauri/src/api_testing.rs
Normal 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)>,
|
||||
}
|
||||
175
tauri/src-tauri/src/app_mover.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
505
tauri/src-tauri/src/auth_cache.rs
Normal 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>>,
|
||||
}
|
||||
78
tauri/src-tauri/src/auto_launch.rs
Normal 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()
|
||||
}
|
||||