Merge main into monaco branch
69
.github/actions/lint-reporter/action.yml
vendored
|
|
@ -17,8 +17,18 @@ inputs:
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- name: Create or Update PR Comment
|
- name: Find Comment
|
||||||
if: github.event_name == 'pull_request'
|
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
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ inputs.github-token }}
|
github-token: ${{ inputs.github-token }}
|
||||||
|
|
@ -26,6 +36,7 @@ runs:
|
||||||
const title = ${{ toJSON(inputs.title) }};
|
const title = ${{ toJSON(inputs.title) }};
|
||||||
const result = ${{ toJSON(inputs.lint-result) }};
|
const result = ${{ toJSON(inputs.lint-result) }};
|
||||||
const output = ${{ toJSON(inputs.lint-output) }};
|
const output = ${{ toJSON(inputs.lint-output) }};
|
||||||
|
const existingCommentId = '${{ steps.fc.outputs.comment-id }}';
|
||||||
|
|
||||||
const icon = result === 'success' ? '✅' : '❌';
|
const icon = result === 'success' ? '✅' : '❌';
|
||||||
const status = result === 'success' ? 'Passed' : 'Failed';
|
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`;
|
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;
|
let body;
|
||||||
if (botComment) {
|
if (existingCommentId) {
|
||||||
// Update existing comment
|
// Get existing comment body
|
||||||
const existingBody = botComment.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 sectionHeader = `### ${title}`;
|
||||||
const nextSectionRegex = /^###\s/m;
|
const nextSectionRegex = /^###\s/m;
|
||||||
|
|
||||||
|
|
@ -86,21 +89,19 @@ runs:
|
||||||
// Add new section at the end
|
// Add new section at the end
|
||||||
body = existingBody + '\n\n' + sectionContent;
|
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 {
|
} else {
|
||||||
// Create new comment
|
// Create new comment
|
||||||
body = `## 🔍 Code Quality Report\n${commentMarker}\n\nThis comment is automatically updated with linting results from CI.\n\n${sectionContent}`;
|
body = `## 🔍 Code Quality Report\n<!-- lint-results -->\n\nThis comment is automatically updated with linting results from CI.\n\n${sectionContent}`;
|
||||||
|
}
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
// Store the body for the next step
|
||||||
repo: context.repo.repo,
|
core.setOutput('comment_body', body);
|
||||||
issue_number: issue_number,
|
|
||||||
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
|
# 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
|
## [1.0.0-beta.2] - 2025-06-19
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
|
|
||||||
23
README.md
|
|
@ -122,13 +122,34 @@ EOF
|
||||||
cd web
|
cd web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
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
|
# Build the macOS app
|
||||||
cd ../mac
|
cd ../mac
|
||||||
./scripts/build.sh --configuration Release
|
./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
|
## Development
|
||||||
|
|
||||||
For development setup and contribution guidelines, see [CONTRIBUTING.md](docs/CONTRIBUTING.md).
|
For development setup and contribution guidelines, see [CONTRIBUTING.md](docs/CONTRIBUTING.md).
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,48 @@
|
||||||
<link>https://github.com/amantus-ai/vibetunnel</link>
|
<link>https://github.com/amantus-ai/vibetunnel</link>
|
||||||
<description>VibeTunnel pre-release and beta updates feed</description>
|
<description>VibeTunnel pre-release and beta updates feed</description>
|
||||||
<language>en</language>
|
<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>
|
<item>
|
||||||
<title>VibeTunnel 1.0.0-beta.2</title>
|
<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>
|
<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
|
## Usage Instructions
|
||||||
|
|
||||||
1. **Build custom Node.js**:
|
### Building Custom Node.js
|
||||||
```bash
|
|
||||||
node build-custom-node.js --version=24.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Use with vibetunnel**:
|
```bash
|
||||||
```bash
|
node build-custom-node.js # Builds Node.js 24.2.0 (default)
|
||||||
node build-native.js --custom-node="/path/to/custom/node"
|
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
|
## 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"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
shouldAutocreateTestPlan = "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>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,20 @@ import Foundation
|
||||||
/// and terminal dimensions.
|
/// and terminal dimensions.
|
||||||
struct Session: Codable, Identifiable, Equatable {
|
struct Session: Codable, Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let command: String
|
let command: [String] // Changed from String to [String] to match server
|
||||||
let workingDir: String
|
let workingDir: String
|
||||||
let name: String?
|
let name: String
|
||||||
let status: SessionStatus
|
let status: SessionStatus
|
||||||
let exitCode: Int?
|
let exitCode: Int?
|
||||||
let startedAt: String
|
let startedAt: String
|
||||||
let lastModified: String?
|
let lastModified: String?
|
||||||
let pid: Int?
|
let pid: Int?
|
||||||
let waiting: Bool?
|
|
||||||
let width: Int?
|
// Optional fields from HQ mode
|
||||||
let height: Int?
|
let source: String?
|
||||||
|
let remoteId: String?
|
||||||
|
let remoteName: String?
|
||||||
|
let remoteUrl: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
|
|
@ -29,16 +32,20 @@ struct Session: Codable, Identifiable, Equatable {
|
||||||
case startedAt
|
case startedAt
|
||||||
case lastModified
|
case lastModified
|
||||||
case pid
|
case pid
|
||||||
case waiting
|
case source
|
||||||
case width
|
case remoteId
|
||||||
case height
|
case remoteName
|
||||||
|
case remoteUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User-friendly display name for the session.
|
/// 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 {
|
var displayName: String {
|
||||||
name ?? command
|
if !name.isEmpty {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return command.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Indicates whether the session is currently active.
|
/// Indicates whether the session is currently active.
|
||||||
|
|
@ -128,14 +135,14 @@ struct SessionCreateData: Codable {
|
||||||
/// - command: Command to execute (default: "zsh").
|
/// - command: Command to execute (default: "zsh").
|
||||||
/// - workingDir: Working directory for the session.
|
/// - workingDir: Working directory for the session.
|
||||||
/// - name: Optional custom name.
|
/// - 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).
|
/// - cols: Terminal width in columns (default: 120).
|
||||||
/// - rows: Terminal height in rows (default: 30).
|
/// - rows: Terminal height in rows (default: 30).
|
||||||
init(
|
init(
|
||||||
command: String = "zsh",
|
command: String = "zsh",
|
||||||
workingDir: String,
|
workingDir: String,
|
||||||
name: String? = nil,
|
name: String? = nil,
|
||||||
spawnTerminal: Bool = false,
|
spawnTerminal: Bool = true,
|
||||||
cols: Int = 120,
|
cols: Int = 120,
|
||||||
rows: Int = 30
|
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)
|
try validateResponse(response)
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
if let jsonString = String(data: data, encoding: .utf8) {
|
||||||
|
print("[APIClient] getSessions response: \(jsonString)")
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try decoder.decode([Session].self, from: data)
|
return try decoder.decode([Session].self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
|
print("[APIClient] Decoding error: \(error)")
|
||||||
|
if let decodingError = error as? DecodingError {
|
||||||
|
print("[APIClient] Decoding error details: \(decodingError)")
|
||||||
|
}
|
||||||
throw APIError.decodingError(error)
|
throw APIError.decodingError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +264,7 @@ class APIClient: APIClientProtocol {
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
|
|
||||||
// Handle empty response (204 No Content) from Go server
|
// Handle empty response (204 No Content)
|
||||||
if data.isEmpty {
|
if data.isEmpty {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
@ -589,4 +598,135 @@ class APIClient: APIClientProtocol {
|
||||||
|
|
||||||
return try decoder.decode(FileInfo.self, from: data)
|
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
|
break
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -393,7 +399,21 @@ class BufferWebSocketClient: NSObject {
|
||||||
print(
|
print(
|
||||||
"[BufferWebSocket] Unknown row marker: 0x\(String(format: "%02X", marker)) at offset \(offset - 1)"
|
"[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 hasBg = (typeByte & 0x10) != 0
|
||||||
let isRgbFg = (typeByte & 0x08) != 0
|
let isRgbFg = (typeByte & 0x08) != 0
|
||||||
let isRgbBg = (typeByte & 0x04) != 0
|
let isRgbBg = (typeByte & 0x04) != 0
|
||||||
|
let charType = typeByte & 0x03
|
||||||
|
|
||||||
// Read character
|
// Read character
|
||||||
var char: String
|
var char: String
|
||||||
var width: Int = 1
|
var width: Int = 1
|
||||||
|
|
||||||
if isUnicode {
|
if charType == 0x00 {
|
||||||
|
// Simple space
|
||||||
|
char = " "
|
||||||
|
} else if isUnicode {
|
||||||
|
// Unicode character
|
||||||
// Read character length first
|
// Read character length first
|
||||||
guard currentOffset < data.count else {
|
guard currentOffset < data.count else {
|
||||||
print("[BufferWebSocket] Unicode char decode failed: missing length byte")
|
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 {
|
enum Theme {
|
||||||
// MARK: - Colors
|
// MARK: - Colors
|
||||||
|
|
||||||
/// Color palette for the app.
|
/// Color palette for the app with automatic light/dark mode support.
|
||||||
enum Colors {
|
enum Colors {
|
||||||
// Terminal-inspired colors
|
// Background colors
|
||||||
static let terminalBackground = Color(hex: "0A0E14")
|
static let terminalBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "0A0E14"))
|
||||||
static let terminalForeground = Color(hex: "B3B1AD")
|
static let cardBackground = Color(light: Color(hex: "F8F9FA"), dark: Color(hex: "0D1117"))
|
||||||
static let terminalSelection = Color(hex: "273747")
|
static let headerBackground = Color(light: Color(hex: "FFFFFF"), dark: Color(hex: "010409"))
|
||||||
|
|
||||||
// Accent colors
|
// Border colors
|
||||||
static let primaryAccent = Color(hex: "39BAE6")
|
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 secondaryAccent = Color(hex: "59C2FF")
|
||||||
static let successAccent = Color(hex: "AAD94C")
|
static let successAccent = Color(hex: "AAD94C")
|
||||||
static let warningAccent = Color(hex: "FFB454")
|
static let warningAccent = Color(hex: "FFB454")
|
||||||
static let errorAccent = Color(hex: "F07178")
|
static let errorAccent = Color(hex: "F07178")
|
||||||
|
|
||||||
// UI colors
|
// Selection colors
|
||||||
static let cardBackground = Color(hex: "0D1117")
|
static let terminalSelection = Color(light: Color(hex: "E1E4E8"), dark: Color(hex: "273747"))
|
||||||
static let cardBorder = Color(hex: "1C2128")
|
|
||||||
static let headerBackground = Color(hex: "010409")
|
// Overlay colors
|
||||||
static let overlayBackground = Color.black.opacity(0.7)
|
static let overlayBackground = Color(light: Color.black.opacity(0.5), dark: Color.black.opacity(0.7))
|
||||||
|
|
||||||
// Additional UI colors for FileBrowser
|
// Additional UI colors for FileBrowser
|
||||||
static let terminalAccent = primaryAccent
|
static let terminalAccent = primaryAccent
|
||||||
static let terminalGray = Color(hex: "8B949E")
|
static let terminalGray = Color(light: Color(hex: "586069"), dark: Color(hex: "8B949E"))
|
||||||
static let terminalDarkGray = Color(hex: "161B22")
|
static let terminalDarkGray = Color(light: Color(hex: "F6F8FA"), dark: Color(hex: "161B22"))
|
||||||
static let terminalWhite = Color.white
|
static let terminalWhite = Color(light: Color(hex: "000000"), dark: Color.white)
|
||||||
|
|
||||||
// Terminal ANSI colors
|
// Terminal ANSI colors - using slightly adjusted colors for light mode
|
||||||
static let ansiBlack = Color(hex: "01060E")
|
static let ansiBlack = Color(light: Color(hex: "24292E"), dark: Color(hex: "01060E"))
|
||||||
static let ansiRed = Color(hex: "EA6C73")
|
static let ansiRed = Color(light: Color(hex: "D73A49"), dark: Color(hex: "EA6C73"))
|
||||||
static let ansiGreen = Color(hex: "91B362")
|
static let ansiGreen = Color(light: Color(hex: "28A745"), dark: Color(hex: "91B362"))
|
||||||
static let ansiYellow = Color(hex: "F9AF4F")
|
static let ansiYellow = Color(light: Color(hex: "DBAB09"), dark: Color(hex: "F9AF4F"))
|
||||||
static let ansiBlue = Color(hex: "53BDFA")
|
static let ansiBlue = Color(light: Color(hex: "0366D6"), dark: Color(hex: "53BDFA"))
|
||||||
static let ansiMagenta = Color(hex: "FAE994")
|
static let ansiMagenta = Color(light: Color(hex: "6F42C1"), dark: Color(hex: "FAE994"))
|
||||||
static let ansiCyan = Color(hex: "90E1C6")
|
static let ansiCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "90E1C6"))
|
||||||
static let ansiWhite = Color(hex: "C7C7C7")
|
static let ansiWhite = Color(light: Color(hex: "586069"), dark: Color(hex: "C7C7C7"))
|
||||||
|
|
||||||
// Bright ANSI colors
|
// Bright ANSI colors
|
||||||
static let ansiBrightBlack = Color(hex: "686868")
|
static let ansiBrightBlack = Color(light: Color(hex: "959DA5"), dark: Color(hex: "686868"))
|
||||||
static let ansiBrightRed = Color(hex: "F07178")
|
static let ansiBrightRed = Color(light: Color(hex: "CB2431"), dark: Color(hex: "F07178"))
|
||||||
static let ansiBrightGreen = Color(hex: "C2D94C")
|
static let ansiBrightGreen = Color(light: Color(hex: "22863A"), dark: Color(hex: "C2D94C"))
|
||||||
static let ansiBrightYellow = Color(hex: "FFB454")
|
static let ansiBrightYellow = Color(light: Color(hex: "B08800"), dark: Color(hex: "FFB454"))
|
||||||
static let ansiBrightBlue = Color(hex: "59C2FF")
|
static let ansiBrightBlue = Color(light: Color(hex: "005CC5"), dark: Color(hex: "59C2FF"))
|
||||||
static let ansiBrightMagenta = Color(hex: "FFEE99")
|
static let ansiBrightMagenta = Color(light: Color(hex: "5A32A3"), dark: Color(hex: "FFEE99"))
|
||||||
static let ansiBrightCyan = Color(hex: "95E6CB")
|
static let ansiBrightCyan = Color(light: Color(hex: "0598BC"), dark: Color(hex: "95E6CB"))
|
||||||
static let ansiBrightWhite = Color(hex: "FFFFFF")
|
static let ansiBrightWhite = Color(light: Color(hex: "24292E"), dark: Color(hex: "FFFFFF"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Typography
|
// MARK: - Typography
|
||||||
|
|
@ -107,21 +113,21 @@ enum Theme {
|
||||||
// MARK: - Shadows
|
// MARK: - Shadows
|
||||||
|
|
||||||
enum CardShadow {
|
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 radius: CGFloat = 8
|
||||||
static let xOffset: CGFloat = 0
|
static let xOffset: CGFloat = 0
|
||||||
static let yOffset: CGFloat = 2
|
static let yOffset: CGFloat = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ButtonShadow {
|
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 radius: CGFloat = 4
|
||||||
static let xOffset: CGFloat = 0
|
static let xOffset: CGFloat = 0
|
||||||
static let yOffset: CGFloat = 1
|
static let yOffset: CGFloat = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Color Extension
|
// MARK: - Color Extensions
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
init(hex: String) {
|
init(hex: String) {
|
||||||
|
|
@ -148,6 +154,18 @@ extension Color {
|
||||||
opacity: Double(alpha) / 255
|
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
|
// MARK: - View Modifiers
|
||||||
|
|
@ -187,6 +205,14 @@ extension View {
|
||||||
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
|
.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
|
// MARK: - Haptic Feedback
|
||||||
|
|
@ -274,4 +300,4 @@ extension View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,25 +6,45 @@ import SwiftUI
|
||||||
/// styled to match the terminal theme.
|
/// styled to match the terminal theme.
|
||||||
struct LoadingView: View {
|
struct LoadingView: View {
|
||||||
let message: String
|
let message: String
|
||||||
|
let useUnicodeSpinner: Bool
|
||||||
|
|
||||||
@State private var isAnimating = false
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: Theme.Spacing.large) {
|
VStack(spacing: Theme.Spacing.large) {
|
||||||
ZStack {
|
if useUnicodeSpinner {
|
||||||
Circle()
|
Text(spinnerFrames[spinnerFrame])
|
||||||
.stroke(Theme.Colors.cardBorder, lineWidth: 3)
|
.font(Theme.Typography.terminalSystem(size: 24))
|
||||||
.frame(width: 50, height: 50)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
.onAppear {
|
||||||
|
startUnicodeAnimation()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Theme.Colors.cardBorder, lineWidth: 3)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: 0.2)
|
.trim(from: 0, to: 0.2)
|
||||||
.stroke(Theme.Colors.primaryAccent, lineWidth: 3)
|
.stroke(Theme.Colors.primaryAccent, lineWidth: 3)
|
||||||
.frame(width: 50, height: 50)
|
.frame(width: 50, height: 50)
|
||||||
.rotationEffect(Angle(degrees: isAnimating ? 360 : 0))
|
.rotationEffect(Angle(degrees: isAnimating ? 360 : 0))
|
||||||
.animation(
|
.animation(
|
||||||
Animation.linear(duration: 1)
|
Animation.linear(duration: 1)
|
||||||
.repeatForever(autoreverses: false),
|
.repeatForever(autoreverses: false),
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(message)
|
Text(message)
|
||||||
|
|
@ -32,7 +52,17 @@ struct LoadingView: View {
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||||
}
|
}
|
||||||
.onAppear {
|
.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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ScrollView {
|
||||||
// Background
|
|
||||||
Theme.Colors.terminalBackground
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
VStack(spacing: Theme.Spacing.extraExtraLarge) {
|
VStack(spacing: Theme.Spacing.extraExtraLarge) {
|
||||||
// Logo and Title
|
// Logo and Title
|
||||||
|
|
@ -83,7 +79,13 @@ struct ConnectionView: View {
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.background {
|
||||||
|
// Background
|
||||||
|
Theme.Colors.terminalBackground
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationViewStyle(StackNavigationViewStyle())
|
.navigationViewStyle(StackNavigationViewStyle())
|
||||||
.preferredColorScheme(.dark)
|
.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
|
@State private var showingDeleteAlert = false
|
||||||
@StateObject private var quickLookManager = QuickLookManager.shared
|
@StateObject private var quickLookManager = QuickLookManager.shared
|
||||||
@State private var showingQuickLook = false
|
@State private var showingQuickLook = false
|
||||||
|
@State private var showingFilePreview = false
|
||||||
|
@State private var previewPath: String?
|
||||||
|
|
||||||
let onSelect: (String) -> Void
|
let onSelect: (String) -> Void
|
||||||
let initialPath: String
|
let initialPath: String
|
||||||
let mode: FileBrowserMode
|
let mode: FileBrowserMode
|
||||||
|
let onInsertPath: ((String, Bool) -> Void)? // Path and isDirectory
|
||||||
|
|
||||||
enum FileBrowserMode {
|
enum FileBrowserMode {
|
||||||
case selectDirectory
|
case selectDirectory
|
||||||
case browseFiles
|
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.initialPath = initialPath
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.onSelect = onSelect
|
self.onSelect = onSelect
|
||||||
|
self.onInsertPath = onInsertPath
|
||||||
}
|
}
|
||||||
|
|
||||||
private var navigationHeader: some View {
|
private var navigationHeader: some View {
|
||||||
|
|
@ -159,14 +164,15 @@ struct FileBrowserView: View {
|
||||||
modifiedTime: entry.formattedDate,
|
modifiedTime: entry.formattedDate,
|
||||||
gitStatus: entry.gitStatus
|
gitStatus: entry.gitStatus
|
||||||
) {
|
) {
|
||||||
if entry.isDir {
|
if entry.isDir && mode != .insertPath {
|
||||||
viewModel.navigate(to: entry.path)
|
viewModel.navigate(to: entry.path)
|
||||||
} else if mode == .browseFiles {
|
} else if mode == .browseFiles {
|
||||||
// Preview file with Quick Look
|
// Preview file with our custom preview
|
||||||
selectedFile = entry
|
previewPath = entry.path
|
||||||
Task {
|
showingFilePreview = true
|
||||||
await viewModel.previewFile(entry)
|
} else if mode == .insertPath {
|
||||||
}
|
// Insert the path into terminal
|
||||||
|
insertPath(entry.path, isDirectory: entry.isDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
|
@ -364,6 +370,11 @@ struct FileBrowserView: View {
|
||||||
QuickLookWrapper(quickLookManager: quickLookManager)
|
QuickLookWrapper(quickLookManager: quickLookManager)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingFilePreview) {
|
||||||
|
if let path = previewPath {
|
||||||
|
FilePreviewView(path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if quickLookManager.isDownloading {
|
if quickLookManager.isDownloading {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|
@ -397,6 +408,22 @@ struct FileBrowserView: View {
|
||||||
viewModel.loadDirectory(path: initialPath)
|
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.
|
/// Row component for displaying file or directory information.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ struct SessionCardView: View {
|
||||||
@State private var isKilling = false
|
@State private var isKilling = false
|
||||||
@State private var opacity: Double = 1.0
|
@State private var opacity: Double = 1.0
|
||||||
@State private var scale: CGFloat = 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 {
|
private var displayWorkingDir: String {
|
||||||
// Convert absolute paths back to ~ notation for display
|
// Convert absolute paths back to ~ notation for display
|
||||||
|
|
@ -33,7 +35,7 @@ struct SessionCardView: View {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
||||||
// Header with session ID/name and kill button
|
// Header with session ID/name and kill button
|
||||||
HStack {
|
HStack {
|
||||||
Text(session.name ?? String(session.id.prefix(8)))
|
Text(session.name)
|
||||||
.font(Theme.Typography.terminalSystem(size: 14))
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
|
|
@ -49,13 +51,19 @@ struct SessionCardView: View {
|
||||||
animateCleanup()
|
animateCleanup()
|
||||||
}
|
}
|
||||||
}, label: {
|
}, label: {
|
||||||
Image(systemName: session.isRunning ? "xmark.circle" : "trash.circle")
|
if isKilling {
|
||||||
.font(.system(size: 18))
|
LoadingView(message: "", useUnicodeSpinner: true)
|
||||||
.foregroundColor(session.isRunning ? Theme.Colors.errorAccent : Theme.Colors
|
.scaleEffect(0.7)
|
||||||
.terminalForeground.opacity(0.6)
|
.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
|
// Terminal content area showing command and terminal output preview
|
||||||
|
|
@ -103,7 +111,7 @@ struct SessionCardView: View {
|
||||||
Text("$")
|
Text("$")
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
Text(session.command)
|
Text(session.command.joined(separator: " "))
|
||||||
.font(Theme.Typography.terminalSystem(size: 12))
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
.foregroundColor(Theme.Colors.terminalForeground)
|
||||||
}
|
}
|
||||||
|
|
@ -203,8 +211,10 @@ struct SessionCardView: View {
|
||||||
)
|
)
|
||||||
.scaleEffect(isPressed ? 0.98 : scale)
|
.scaleEffect(isPressed ? 0.98 : scale)
|
||||||
.opacity(opacity)
|
.opacity(opacity)
|
||||||
|
.rotationEffect(.degrees(rotation))
|
||||||
|
.brightness(brightness)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(.plain)
|
||||||
.onLongPressGesture(
|
.onLongPressGesture(
|
||||||
minimumDuration: 0.1,
|
minimumDuration: 0.1,
|
||||||
maximumDistance: .infinity,
|
maximumDistance: .infinity,
|
||||||
|
|
@ -280,14 +290,21 @@ struct SessionCardView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func animateCleanup() {
|
private func animateCleanup() {
|
||||||
// Shrink and fade animation for cleanup
|
// Black hole collapse animation matching web
|
||||||
withAnimation(.easeOut(duration: 0.3)) {
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
scale = 0.8
|
scale = 0
|
||||||
|
rotation = 360
|
||||||
|
brightness = 0.3
|
||||||
opacity = 0
|
opacity = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
onCleanup()
|
onCleanup()
|
||||||
|
// Reset values for potential reuse
|
||||||
|
scale = 1.0
|
||||||
|
rotation = 0
|
||||||
|
brightness = 1.0
|
||||||
|
opacity = 1.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// Main view displaying the list of terminal sessions.
|
/// Main view displaying the list of terminal sessions.
|
||||||
///
|
///
|
||||||
|
|
@ -16,6 +17,8 @@ struct SessionListView: View {
|
||||||
@State private var showingFileBrowser = false
|
@State private var showingFileBrowser = false
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
|
@State private var showingCastImporter = false
|
||||||
|
@State private var importedCastFile: CastFileItem?
|
||||||
|
|
||||||
var filteredSessions: [Session] {
|
var filteredSessions: [Session] {
|
||||||
let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning }
|
let sessions = viewModel.sessions.filter { showExitedSessions || $0.isRunning }
|
||||||
|
|
@ -26,11 +29,11 @@ struct SessionListView: View {
|
||||||
|
|
||||||
return sessions.filter { session in
|
return sessions.filter { session in
|
||||||
// Search in session name
|
// Search in session name
|
||||||
if let name = session.name, name.localizedCaseInsensitiveContains(searchText) {
|
if session.name.localizedCaseInsensitiveContains(searchText) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Search in command
|
// Search in command
|
||||||
if session.command.localizedCaseInsensitiveContains(searchText) {
|
if session.command.joined(separator: " ").localizedCaseInsensitiveContains(searchText) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Search in working directory
|
// Search in working directory
|
||||||
|
|
@ -94,14 +97,25 @@ struct SessionListView: View {
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: Theme.Spacing.medium) {
|
HStack(spacing: Theme.Spacing.medium) {
|
||||||
Button(action: {
|
Menu {
|
||||||
HapticFeedback.impact(.light)
|
Button(action: {
|
||||||
showingSettings = true
|
HapticFeedback.impact(.light)
|
||||||
}, label: {
|
showingSettings = true
|
||||||
Image(systemName: "gearshape.fill")
|
}) {
|
||||||
|
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)
|
.font(.title3)
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
})
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
|
|
@ -134,7 +148,7 @@ struct SessionListView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedSession) { session in
|
.fullScreenCover(item: $selectedSession) { session in
|
||||||
TerminalView(session: session)
|
TerminalView(session: session)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingFileBrowser) {
|
.sheet(isPresented: $showingFileBrowser) {
|
||||||
|
|
@ -145,6 +159,23 @@ struct SessionListView: View {
|
||||||
.sheet(isPresented: $showingSettings) {
|
.sheet(isPresented: $showingSettings) {
|
||||||
SettingsView()
|
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 {
|
.refreshable {
|
||||||
await viewModel.loadSessions()
|
await viewModel.loadSessions()
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +187,6 @@ struct SessionListView: View {
|
||||||
viewModel.stopAutoRefresh()
|
viewModel.stopAutoRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in
|
.onChange(of: navigationManager.shouldNavigateToSession) { _, shouldNavigate in
|
||||||
if shouldNavigate,
|
if shouldNavigate,
|
||||||
let sessionId = navigationManager.selectedSessionId,
|
let sessionId = navigationManager.selectedSessionId,
|
||||||
|
|
@ -248,6 +278,12 @@ struct SessionListView: View {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, Theme.Spacing.small)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||||
|
.fill(Theme.Colors.terminalForeground.opacity(0.03))
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Sessions grid
|
// Sessions grid
|
||||||
LazyVGrid(columns: [
|
LazyVGrid(columns: [
|
||||||
|
|
@ -462,54 +498,58 @@ struct SessionHeaderView: View {
|
||||||
private var exitedCount: Int { sessions.count(where: { !$0.isRunning }) }
|
private var exitedCount: Int { sessions.count(where: { !$0.isRunning }) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
VStack(spacing: Theme.Spacing.medium) {
|
||||||
SessionCountView(runningCount: runningCount, exitedCount: exitedCount)
|
// Session counts
|
||||||
|
HStack(spacing: Theme.Spacing.extraLarge) {
|
||||||
Spacer()
|
SessionCountBadge(
|
||||||
|
label: "Running",
|
||||||
if exitedCount > 0 {
|
count: runningCount,
|
||||||
ExitedSessionToggle(showExitedSessions: $showExitedSessions)
|
color: Theme.Colors.successAccent
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionCountBadge(
|
||||||
|
label: "Exited",
|
||||||
|
count: exitedCount,
|
||||||
|
color: Theme.Colors.errorAccent
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessions.contains(where: \.isRunning) {
|
// Action buttons
|
||||||
KillAllButton(onKillAll: onKillAll)
|
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 {
|
struct SessionCountBadge: View {
|
||||||
let runningCount: Int
|
let label: String
|
||||||
let exitedCount: Int
|
let count: Int
|
||||||
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Theme.Spacing.medium) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
if runningCount > 0 {
|
Text(label)
|
||||||
HStack(spacing: 4) {
|
.font(Theme.Typography.terminalSystem(size: 12))
|
||||||
Text("Running:")
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
|
||||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
.textCase(.uppercase)
|
||||||
Text("\(runningCount)")
|
|
||||||
.foregroundColor(Theme.Colors.successAccent)
|
Text("\(count)")
|
||||||
.fontWeight(.semibold)
|
.font(Theme.Typography.terminalSystem(size: 28))
|
||||||
}
|
.fontWeight(.bold)
|
||||||
}
|
.foregroundColor(color)
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.font(Theme.Typography.terminalSystem(size: 16))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,18 +563,22 @@ struct ExitedSessionToggle: View {
|
||||||
showExitedSessions.toggle()
|
showExitedSessions.toggle()
|
||||||
}
|
}
|
||||||
}, label: {
|
}, label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: showExitedSessions ? "eye.slash" : "eye")
|
Image(systemName: showExitedSessions ? "eye.slash" : "eye")
|
||||||
.font(.caption)
|
.font(.system(size: 14))
|
||||||
Text(showExitedSessions ? "Hide Exited" : "Show Exited")
|
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))
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.8))
|
||||||
.padding(.horizontal, Theme.Spacing.small)
|
.padding(.horizontal, Theme.Spacing.medium)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, Theme.Spacing.small)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
.fill(Theme.Colors.terminalForeground.opacity(0.1))
|
.fill(Theme.Colors.terminalForeground.opacity(0.08))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(Theme.Colors.terminalForeground.opacity(0.15), lineWidth: 1)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
@ -549,21 +593,19 @@ struct KillAllButton: View {
|
||||||
HapticFeedback.impact(.medium)
|
HapticFeedback.impact(.medium)
|
||||||
onKillAll()
|
onKillAll()
|
||||||
}, label: {
|
}, label: {
|
||||||
HStack(spacing: Theme.Spacing.small) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "stop.circle")
|
Image(systemName: "stop.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
Text("Kill All")
|
Text("Kill All")
|
||||||
|
.fontWeight(.medium)
|
||||||
}
|
}
|
||||||
.font(Theme.Typography.terminalSystem(size: 14))
|
.font(Theme.Typography.terminalSystem(size: 14))
|
||||||
.foregroundColor(Theme.Colors.errorAccent)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, Theme.Spacing.medium)
|
.padding(.horizontal, Theme.Spacing.medium)
|
||||||
.padding(.vertical, Theme.Spacing.small)
|
.padding(.vertical, Theme.Spacing.small)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
.fill(Theme.Colors.errorAccent.opacity(0.1))
|
.fill(Theme.Colors.errorAccent)
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
|
||||||
.stroke(Theme.Colors.errorAccent.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.buttonStyle(PlainButtonStyle())
|
.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
|
private var verboseLogging = false
|
||||||
@AppStorage("debugModeEnabled")
|
@AppStorage("debugModeEnabled")
|
||||||
private var debugModeEnabled = false
|
private var debugModeEnabled = false
|
||||||
|
@State private var showingSystemLogs = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.large) {
|
||||||
|
|
@ -209,6 +210,24 @@ struct AdvancedSettingsView: View {
|
||||||
.padding()
|
.padding()
|
||||||
.background(Theme.Colors.cardBackground)
|
.background(Theme.Colors.cardBackground)
|
||||||
.cornerRadius(Theme.CornerRadius.card)
|
.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()
|
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 {
|
struct ScrollToBottomButton: View {
|
||||||
let isVisible: Bool
|
let isVisible: Bool
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
@State private var isHovered = false
|
||||||
|
@State private var isPressed = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
action()
|
action()
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "arrow.down.to.line")
|
Text("↓")
|
||||||
.font(.system(size: 20, weight: .medium))
|
.font(.system(size: 24, weight: .bold))
|
||||||
.foregroundColor(Theme.Colors.terminalForeground)
|
.foregroundColor(isHovered ? Theme.Colors.primaryAccent : Theme.Colors.terminalForeground)
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 48, height: 48)
|
||||||
.background(
|
.background(
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Theme.Colors.cardBackground.opacity(0.95))
|
.fill(isHovered ? Theme.Colors.cardBackground : Theme.Colors.cardBackground.opacity(0.8))
|
||||||
.overlay(
|
.overlay(
|
||||||
Circle()
|
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)
|
.opacity(isVisible ? 1 : 0)
|
||||||
.scaleEffect(isVisible ? 1 : 0.8)
|
.scaleEffect(isVisible ? 1 : 0.8)
|
||||||
|
.animation(Theme.Animation.quick, value: isHovered)
|
||||||
|
.animation(Theme.Animation.quick, value: isPressed)
|
||||||
.animation(Theme.Animation.smooth, value: isVisible)
|
.animation(Theme.Animation.smooth, value: isVisible)
|
||||||
.allowsHitTesting(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) {
|
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
|
||||||
let font: UIFont = if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
// Use system monospaced font which has better compatibility with SwiftTerm
|
||||||
customFont
|
// The custom SF Mono font seems to have rendering issues
|
||||||
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
let font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||||
fallbackFont
|
|
||||||
} else {
|
|
||||||
UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
|
||||||
}
|
|
||||||
// SwiftTerm uses the font property directly
|
// SwiftTerm uses the font property directly
|
||||||
terminal.font = font
|
terminal.font = font
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +178,8 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
/// Update terminal buffer from binary buffer data using optimized ANSI sequences
|
/// Update terminal buffer from binary buffer data using optimized ANSI sequences
|
||||||
func updateBuffer(from snapshot: BufferSnapshot) {
|
func updateBuffer(from snapshot: BufferSnapshot) {
|
||||||
guard let terminal else { return }
|
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
|
// Update terminal dimensions if needed
|
||||||
let currentCols = terminal.getTerminal().cols
|
let currentCols = terminal.getTerminal().cols
|
||||||
|
|
@ -204,11 +203,13 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
let ansiData: String
|
let ansiData: String
|
||||||
if isFirstUpdate || previousSnapshot == nil || viewportChanged {
|
if isFirstUpdate || previousSnapshot == nil || viewportChanged {
|
||||||
// Full redraw needed
|
// Full redraw needed
|
||||||
ansiData = convertBufferToOptimizedANSI(snapshot)
|
ansiData = convertBufferToOptimizedANSI(snapshot, clearScreen: isFirstUpdate)
|
||||||
isFirstUpdate = false
|
isFirstUpdate = false
|
||||||
|
print("[Terminal] Full redraw performed")
|
||||||
} else {
|
} else {
|
||||||
// Incremental update
|
// Incremental update
|
||||||
ansiData = generateIncrementalUpdate(from: previousSnapshot!, to: snapshot)
|
ansiData = generateIncrementalUpdate(from: previousSnapshot!, to: snapshot)
|
||||||
|
print("[Terminal] Incremental update performed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store current snapshot for next update
|
// 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 = ""
|
var output = ""
|
||||||
|
|
||||||
// Clear screen and reset cursor
|
if clearScreen {
|
||||||
output += "\u{001B}[2J\u{001B}[H"
|
// 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
|
// Track current attributes to minimize escape sequences
|
||||||
var currentFg: Int?
|
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
|
// MARK: - TerminalViewDelegate
|
||||||
|
|
||||||
|
|
@ -614,6 +645,14 @@ struct TerminalHostingView: UIViewRepresentable {
|
||||||
terminal.feed(text: "\u{001b}[B")
|
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) {
|
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {
|
||||||
// Handle title change if needed
|
// Handle title change if needed
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ struct TerminalToolbar: View {
|
||||||
|
|
||||||
HStack(spacing: Theme.Spacing.extraSmall) {
|
HStack(spacing: Theme.Spacing.extraSmall) {
|
||||||
// Tab key
|
// Tab key
|
||||||
ToolbarButton(label: "TAB", systemImage: "arrow.right.to.line.compact") {
|
ToolbarButton(label: "⇥") {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
onSpecialKey(.tab)
|
onSpecialKey(.tab)
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +133,7 @@ struct TerminalToolbar: View {
|
||||||
onSpecialKey(.ctrlZ)
|
onSpecialKey(.ctrlZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarButton(label: "ENTER") {
|
ToolbarButton(label: "⏎") {
|
||||||
HapticFeedback.impact(.light)
|
HapticFeedback.impact(.light)
|
||||||
onSpecialKey(.enter)
|
onSpecialKey(.enter)
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +198,7 @@ struct ToolbarButton: View {
|
||||||
let height: CGFloat?
|
let height: CGFloat?
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
@State private var isPressed = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
label: String? = nil,
|
label: String? = nil,
|
||||||
|
|
@ -227,23 +228,37 @@ struct ToolbarButton: View {
|
||||||
.font(.system(size: 16))
|
.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(width: width, height: height ?? 44)
|
||||||
.frame(maxWidth: width == nil ? .infinity : nil)
|
.frame(maxWidth: width == nil ? .infinity : nil)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
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(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||||
.stroke(
|
.stroke(
|
||||||
isActive ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder,
|
isActive || isPressed ? Theme.Colors.primaryAccent : Theme.Colors.cardBorder,
|
||||||
lineWidth: 1
|
lineWidth: isActive || isPressed ? 2 : 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.shadow(
|
||||||
|
color: isActive || isPressed ? Theme.Colors.primaryAccent.opacity(0.2) : .clear,
|
||||||
|
radius: isActive || isPressed ? 4 : 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.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: 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 keyboardHeight: CGFloat = 0
|
||||||
@State private var showScrollToBottom = false
|
@State private var showScrollToBottom = false
|
||||||
@State private var showingFileBrowser = 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
|
@FocusState private var isInputFocused: Bool
|
||||||
|
|
||||||
init(session: Session) {
|
init(session: Session) {
|
||||||
|
|
@ -71,10 +77,16 @@ struct TerminalView: View {
|
||||||
.sheet(isPresented: $showingFileBrowser) {
|
.sheet(isPresented: $showingFileBrowser) {
|
||||||
FileBrowserView(
|
FileBrowserView(
|
||||||
initialPath: session.workingDir,
|
initialPath: session.workingDir,
|
||||||
mode: .browseFiles
|
mode: .insertPath,
|
||||||
) { selectedPath in
|
onSelect: { _ in
|
||||||
showingFileBrowser = false
|
showingFileBrowser = false
|
||||||
}
|
},
|
||||||
|
onInsertPath: { [weak viewModel] path, isDirectory in
|
||||||
|
// Insert the path into the terminal
|
||||||
|
viewModel?.sendInput(path)
|
||||||
|
showingFileBrowser = false
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture()
|
DragGesture()
|
||||||
|
|
@ -104,6 +116,14 @@ struct TerminalView: View {
|
||||||
viewModel.resize(cols: width, rows: newHeight)
|
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
|
.onChange(of: viewModel.isAtBottom) { _, newValue in
|
||||||
withAnimation(Theme.Animation.smooth) {
|
withAnimation(Theme.Animation.smooth) {
|
||||||
showScrollToBottom = !newValue
|
showScrollToBottom = !newValue
|
||||||
|
|
@ -116,6 +136,33 @@ struct TerminalView: View {
|
||||||
}
|
}
|
||||||
return .ignored
|
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
|
// MARK: - View Components
|
||||||
|
|
@ -146,7 +193,9 @@ struct TerminalView: View {
|
||||||
.foregroundColor(Theme.Colors.primaryAccent)
|
.foregroundColor(Theme.Colors.primaryAccent)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
|
fileBrowserButton
|
||||||
|
widthSelectorButton
|
||||||
menuButton
|
menuButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,6 +205,7 @@ struct TerminalView: View {
|
||||||
ToolbarItemGroup(placement: .bottomBar) {
|
ToolbarItemGroup(placement: .bottomBar) {
|
||||||
terminalSizeIndicator
|
terminalSizeIndicator
|
||||||
Spacer()
|
Spacer()
|
||||||
|
connectionStatusIndicator
|
||||||
sessionStatusIndicator
|
sessionStatusIndicator
|
||||||
pidIndicator
|
pidIndicator
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +221,44 @@ struct TerminalView: View {
|
||||||
|
|
||||||
// MARK: - Toolbar Components
|
// 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 {
|
private var menuButton: some View {
|
||||||
Menu {
|
Menu {
|
||||||
terminalMenuItems
|
terminalMenuItems
|
||||||
|
|
@ -186,9 +274,39 @@ struct TerminalView: View {
|
||||||
Label("Clear", systemImage: "clear")
|
Label("Clear", systemImage: "clear")
|
||||||
})
|
})
|
||||||
|
|
||||||
Button(action: { showingFontSizeSheet = true }, label: {
|
Menu {
|
||||||
Label("Font Size", systemImage: "textformat.size")
|
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: {
|
Button(action: { showingTerminalWidthSheet = true }, label: {
|
||||||
Label("Terminal Width", systemImage: "arrow.left.and.right")
|
Label("Terminal Width", systemImage: "arrow.left.and.right")
|
||||||
|
|
@ -206,12 +324,20 @@ struct TerminalView: View {
|
||||||
})
|
})
|
||||||
|
|
||||||
Button(action: { viewModel.copyBuffer() }, label: {
|
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()
|
Divider()
|
||||||
|
|
||||||
recordingMenuItems
|
recordingMenuItems
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
debugMenuItems
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -236,6 +362,28 @@ struct TerminalView: View {
|
||||||
.disabled(viewModel.castRecorder.events.isEmpty)
|
.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
|
@ViewBuilder
|
||||||
private var terminalSizeIndicator: some View {
|
private var terminalSizeIndicator: some View {
|
||||||
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
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 {
|
private var sessionStatusIndicator: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Circle()
|
Circle()
|
||||||
|
|
@ -338,21 +504,41 @@ struct TerminalView: View {
|
||||||
|
|
||||||
private var terminalContent: some View {
|
private var terminalContent: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Terminal hosting view
|
// Terminal view based on selected renderer
|
||||||
TerminalHostingView(
|
Group {
|
||||||
session: session,
|
switch selectedRenderer {
|
||||||
fontSize: $fontSize,
|
case .swiftTerm:
|
||||||
theme: selectedTheme,
|
TerminalHostingView(
|
||||||
onInput: { text in
|
session: session,
|
||||||
viewModel.sendInput(text)
|
fontSize: $fontSize,
|
||||||
},
|
theme: selectedTheme,
|
||||||
onResize: { cols, rows in
|
onInput: { text in
|
||||||
viewModel.terminalCols = cols
|
viewModel.sendInput(text)
|
||||||
viewModel.terminalRows = rows
|
},
|
||||||
viewModel.resize(cols: cols, rows: rows)
|
onResize: { cols, rows in
|
||||||
},
|
viewModel.terminalCols = cols
|
||||||
viewModel: viewModel
|
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)
|
.id(viewModel.terminalViewId)
|
||||||
.background(selectedTheme.background)
|
.background(selectedTheme.background)
|
||||||
.focused($isInputFocused)
|
.focused($isInputFocused)
|
||||||
|
|
@ -363,12 +549,6 @@ struct TerminalView: View {
|
||||||
showScrollToBottom = false
|
showScrollToBottom = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.fileBrowserFABOverlay(
|
|
||||||
isVisible: !keyboardHeight.isZero && session.isRunning,
|
|
||||||
action: {
|
|
||||||
showingFileBrowser = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keyboard toolbar
|
// Keyboard toolbar
|
||||||
if keyboardHeight > 0 {
|
if keyboardHeight > 0 {
|
||||||
|
|
@ -407,7 +587,7 @@ class TerminalViewModel {
|
||||||
|
|
||||||
let session: Session
|
let session: Session
|
||||||
let castRecorder: CastRecorder
|
let castRecorder: CastRecorder
|
||||||
private var bufferWebSocketClient: BufferWebSocketClient?
|
var bufferWebSocketClient: BufferWebSocketClient?
|
||||||
private var connectionStatusTask: Task<Void, Never>?
|
private var connectionStatusTask: Task<Void, Never>?
|
||||||
private var connectionErrorTask: Task<Void, Never>?
|
private var connectionErrorTask: Task<Void, Never>?
|
||||||
weak var terminalCoordinator: TerminalHostingView.Coordinator?
|
weak var terminalCoordinator: TerminalHostingView.Coordinator?
|
||||||
|
|
@ -577,6 +757,13 @@ class TerminalViewModel {
|
||||||
if castRecorder.isRecording {
|
if castRecorder.isRecording {
|
||||||
stopRecording()
|
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):
|
case .bufferUpdate(let snapshot):
|
||||||
// Update terminal buffer directly
|
// Update terminal buffer directly
|
||||||
|
|
@ -650,6 +837,14 @@ class TerminalViewModel {
|
||||||
// Terminal copy is handled by SwiftTerm's built-in functionality
|
// Terminal copy is handled by SwiftTerm's built-in functionality
|
||||||
HapticFeedback.notification(.success)
|
HapticFeedback.notification(.success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getBufferContent() -> String? {
|
||||||
|
// Get the current terminal buffer content
|
||||||
|
if let coordinator = terminalCoordinator {
|
||||||
|
return coordinator.getBufferContent()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleTerminalBell() {
|
private func handleTerminalBell() {
|
||||||
|
|
@ -701,4 +896,20 @@ class TerminalViewModel {
|
||||||
resize(cols: optimalCols, rows: terminalRows)
|
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
|
import Testing
|
||||||
@testable import VibeTunnel
|
@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))
|
@Suite("BufferWebSocketClient Tests", .tags(.critical, .websocket))
|
||||||
@MainActor
|
@MainActor
|
||||||
struct BufferWebSocketClientTests {
|
struct BufferWebSocketClientTests {
|
||||||
|
|
@ -270,8 +342,7 @@ private func saveTestServerConfig() {
|
||||||
let config = ServerConfig(
|
let config = ServerConfig(
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 8888,
|
port: 8888,
|
||||||
useSSL: false,
|
name: nil,
|
||||||
username: nil,
|
|
||||||
password: nil
|
password: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ enum TestFixtures {
|
||||||
|
|
||||||
static let validSession = Session(
|
static let validSession = Session(
|
||||||
id: "test-session-123",
|
id: "test-session-123",
|
||||||
command: "/bin/bash",
|
command: ["/bin/bash"],
|
||||||
workingDir: "/Users/test",
|
workingDir: "/Users/test",
|
||||||
name: "Test Session",
|
name: "Test Session",
|
||||||
status: .running,
|
status: .running,
|
||||||
|
|
@ -26,14 +26,15 @@ enum TestFixtures {
|
||||||
startedAt: "2024-01-01T10:00:00Z",
|
startedAt: "2024-01-01T10:00:00Z",
|
||||||
lastModified: "2024-01-01T10:05:00Z",
|
lastModified: "2024-01-01T10:05:00Z",
|
||||||
pid: 12_345,
|
pid: 12_345,
|
||||||
waiting: false,
|
source: nil,
|
||||||
width: 80,
|
remoteId: nil,
|
||||||
height: 24
|
remoteName: nil,
|
||||||
|
remoteUrl: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
static let exitedSession = Session(
|
static let exitedSession = Session(
|
||||||
id: "exited-session-456",
|
id: "exited-session-456",
|
||||||
command: "/usr/bin/echo",
|
command: ["/usr/bin/echo"],
|
||||||
workingDir: "/tmp",
|
workingDir: "/tmp",
|
||||||
name: "Exited Session",
|
name: "Exited Session",
|
||||||
status: .exited,
|
status: .exited,
|
||||||
|
|
@ -41,38 +42,33 @@ enum TestFixtures {
|
||||||
startedAt: "2024-01-01T09:00:00Z",
|
startedAt: "2024-01-01T09:00:00Z",
|
||||||
lastModified: "2024-01-01T09:00:05Z",
|
lastModified: "2024-01-01T09:00:05Z",
|
||||||
pid: nil,
|
pid: nil,
|
||||||
waiting: false,
|
source: nil,
|
||||||
width: 80,
|
remoteId: nil,
|
||||||
height: 24
|
remoteName: nil,
|
||||||
|
remoteUrl: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
static let sessionsJSON = """
|
static let sessionsJSON = """
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "test-session-123",
|
"id": "test-session-123",
|
||||||
"command": "/bin/bash",
|
"command": ["/bin/bash"],
|
||||||
"workingDir": "/Users/test",
|
"workingDir": "/Users/test",
|
||||||
"name": "Test Session",
|
"name": "Test Session",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"startedAt": "2024-01-01T10:00:00Z",
|
"startedAt": "2024-01-01T10:00:00Z",
|
||||||
"lastModified": "2024-01-01T10:05:00Z",
|
"lastModified": "2024-01-01T10:05:00Z",
|
||||||
"pid": 12345,
|
"pid": 12345
|
||||||
"waiting": false,
|
|
||||||
"width": 80,
|
|
||||||
"height": 24
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "exited-session-456",
|
"id": "exited-session-456",
|
||||||
"command": "/usr/bin/echo",
|
"command": ["/usr/bin/echo"],
|
||||||
"workingDir": "/tmp",
|
"workingDir": "/tmp",
|
||||||
"name": "Exited Session",
|
"name": "Exited Session",
|
||||||
"status": "exited",
|
"status": "exited",
|
||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"startedAt": "2024-01-01T09:00:00Z",
|
"startedAt": "2024-01-01T09:00:00Z",
|
||||||
"lastModified": "2024-01-01T09:00:05Z",
|
"lastModified": "2024-01-01T09:00:05Z"
|
||||||
"waiting": false,
|
|
||||||
"width": 80,
|
|
||||||
"height": 24
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
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/
|
VibeTunnel/Resources/node-server/
|
||||||
|
|
||||||
# Local development configuration
|
# 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_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
VIBETUNNEL_USE_CUSTOM_NODE = YES;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
|
@ -501,6 +502,7 @@
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
VIBETUNNEL_USE_CUSTOM_NODE = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
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
|
// This file contains the version and build number for the app
|
||||||
|
|
||||||
MARKETING_VERSION = 1.0.0-beta.3
|
MARKETING_VERSION = 1.0.0-beta.3
|
||||||
CURRENT_PROJECT_VERSION = 108
|
CURRENT_PROJECT_VERSION = 110
|
||||||
|
|
||||||
// Domain and GitHub configuration
|
// Domain and GitHub configuration
|
||||||
APP_DOMAIN = vibetunnel.sh
|
APP_DOMAIN = vibetunnel.sh
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,25 @@ done
|
||||||
# If not found in standard locations with valid binary, search for it
|
# If not found in standard locations with valid binary, search for it
|
||||||
if [ -z "$APP_PATH" ]; then
|
if [ -z "$APP_PATH" ]; then
|
||||||
# First try DerivedData (for development)
|
# 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 still not found, use mdfind as last resort
|
||||||
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
|
if [ -z "$APP_PATH" ]; then
|
||||||
APP_PATH=$(mdfind -name "VibeTunnel.app" 2>/dev/null | grep -v "\.dSYM" | head -1)
|
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
|
fi
|
||||||
|
|
||||||
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
|
if [ -z "$APP_PATH" ]; then
|
||||||
echo "Error: VibeTunnel.app not found anywhere on the system" >&2
|
echo "Error: VibeTunnel.app with vibetunnel binary not found anywhere on the system" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
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
|
### Step 5: Verify Success
|
||||||
- Check the GitHub releases page
|
- Check the GitHub releases page
|
||||||
- Verify the appcast was updated correctly with proper changelog content
|
- 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
|
- Test updating from a previous version
|
||||||
- **Important**: Verify that the Sparkle update dialog shows the formatted changelog, not HTML tags
|
- **Important**: Verify that the Sparkle update dialog shows the formatted changelog, not HTML tags
|
||||||
|
- Check that update installs without "improperly signed" errors
|
||||||
|
|
||||||
## ⚠️ Critical Requirements
|
## ⚠️ Critical Requirements
|
||||||
|
|
||||||
|
|
@ -347,6 +355,8 @@ Edit `VibeTunnel/version.xcconfig`:
|
||||||
- Update MARKETING_VERSION
|
- Update MARKETING_VERSION
|
||||||
- Update CURRENT_PROJECT_VERSION (build number)
|
- Update CURRENT_PROJECT_VERSION (build number)
|
||||||
|
|
||||||
|
**Note**: The Xcode project file is named `VibeTunnel-Mac.xcodeproj`
|
||||||
|
|
||||||
### 2. Clean and Build Universal Binary
|
### 2. Clean and Build Universal Binary
|
||||||
```bash
|
```bash
|
||||||
rm -rf build DerivedData
|
rm -rf build DerivedData
|
||||||
|
|
@ -390,6 +400,25 @@ git push
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
## 🔍 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
|
### Debug Sparkle Updates
|
||||||
```bash
|
```bash
|
||||||
# Monitor VibeTunnel logs
|
# Monitor VibeTunnel logs
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,12 @@ fi
|
||||||
if [ "$BUILD_CONFIG" = "Release" ]; then
|
if [ "$BUILD_CONFIG" = "Release" ]; then
|
||||||
echo "Release build - checking for custom Node.js..."
|
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 "Custom Node.js not found, building it for optimal size..."
|
||||||
echo "This will take 10-20 minutes on first run but will be cached."
|
echo "This will take 10-20 minutes on first run but will be cached."
|
||||||
node build-custom-node.js --latest
|
node build-custom-node.js --latest
|
||||||
|
|
@ -147,7 +152,7 @@ if [ "$BUILD_CONFIG" = "Release" ]; then
|
||||||
CUSTOM_NODE_PATH="${CUSTOM_NODE_DIR}/out/Release/node"
|
CUSTOM_NODE_PATH="${CUSTOM_NODE_DIR}/out/Release/node"
|
||||||
fi
|
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_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")
|
CUSTOM_NODE_SIZE=$(ls -lh "$CUSTOM_NODE_PATH" 2>/dev/null | awk '{print $5}' || echo "unknown")
|
||||||
echo "Using custom Node.js for release build:"
|
echo "Using custom Node.js for release build:"
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,23 @@ fi
|
||||||
|
|
||||||
echo "Creating DMG: $DMG_NAME"
|
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
|
# Create temporary directory for DMG contents
|
||||||
DMG_TEMP="$BUILD_DIR/dmg-temp"
|
DMG_TEMP="$BUILD_DIR/dmg-temp"
|
||||||
rm -rf "$DMG_TEMP"
|
rm -rf "$DMG_TEMP"
|
||||||
|
|
@ -83,6 +100,12 @@ echo "Applying custom styling to DMG..."
|
||||||
|
|
||||||
# Mount the DMG
|
# Mount the DMG
|
||||||
MOUNT_POINT="/Volumes/$DMG_VOLUME_NAME"
|
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
|
hdiutil attach "$DMG_RW_PATH" -mountpoint "$MOUNT_POINT" -nobrowse
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -151,16 +174,27 @@ sleep 3
|
||||||
osascript -e 'tell application "Finder" to close every window'
|
osascript -e 'tell application "Finder" to close every window'
|
||||||
|
|
||||||
|
|
||||||
# Unmount with retry
|
# Unmount with retry and force
|
||||||
echo "Unmounting DMG..."
|
echo "Unmounting DMG..."
|
||||||
for i in {1..5}; do
|
for i in {1..5}; do
|
||||||
if hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null; then
|
if hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo " Retry $i/5..."
|
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
|
sleep 2
|
||||||
done
|
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
|
# Convert to compressed read-only DMG
|
||||||
echo "Converting to final DMG format..."
|
echo "Converting to final DMG format..."
|
||||||
hdiutil convert "$DMG_RW_PATH" -format ULMO -o "$DMG_PATH" -ov
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Set the Sparkle account if provided via environment
|
||||||
|
SPARKLE_ACCOUNT="${SPARKLE_ACCOUNT:-}"
|
||||||
|
|
||||||
GITHUB_REPO_FULL="${GITHUB_USERNAME}/${GITHUB_REPO}"
|
GITHUB_REPO_FULL="${GITHUB_USERNAME}/${GITHUB_REPO}"
|
||||||
SPARKLE_PRIVATE_KEY_PATH="${SPARKLE_PRIVATE_KEY_PATH:-private/sparkle_private_key}"
|
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
|
# Verify private key exists
|
||||||
if [ ! -f "$SPARKLE_PRIVATE_KEY_PATH" ]; then
|
if [ ! -f "$SPARKLE_PRIVATE_KEY_PATH" ]; then
|
||||||
|
|
@ -117,8 +124,14 @@ generate_signature() {
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Sign using the private key file (no fallback)
|
# Sign using the private key file with account if specified
|
||||||
local signature=$($sign_update_bin "$file_path" -f "$SPARKLE_PRIVATE_KEY_PATH" -p 2>/dev/null)
|
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
|
if [ -n "$signature" ] && [ "$signature" != "-----END PRIVATE KEY-----" ]; then
|
||||||
echo "$signature"
|
echo "$signature"
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -126,6 +139,11 @@ generate_signature() {
|
||||||
|
|
||||||
echo -e "${RED}❌ Error: Failed to generate signature for $filename${NC}" >&2
|
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
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,6 +343,19 @@ EOF
|
||||||
main() {
|
main() {
|
||||||
print_info "Generating appcast files for $GITHUB_REPO_FULL"
|
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
|
# Create temporary directory
|
||||||
local temp_dir=$(mktemp -d)
|
local temp_dir=$(mktemp -d)
|
||||||
trap "rm -rf $temp_dir" EXIT
|
trap "rm -rf $temp_dir" EXIT
|
||||||
|
|
|
||||||
|
|
@ -139,12 +139,12 @@ echo ""
|
||||||
# 3. Check build numbers
|
# 3. Check build numbers
|
||||||
echo "📌 Build Number Validation:"
|
echo "📌 Build Number Validation:"
|
||||||
USED_BUILD_NUMBERS=""
|
USED_BUILD_NUMBERS=""
|
||||||
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
|
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)
|
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"
|
USED_BUILD_NUMBERS+="$APPCAST_BUILDS"
|
||||||
fi
|
fi
|
||||||
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
|
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)
|
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"
|
USED_BUILD_NUMBERS+="$PRERELEASE_BUILDS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -181,7 +181,7 @@ echo ""
|
||||||
|
|
||||||
# Check if Xcode project uses version.xcconfig
|
# Check if Xcode project uses version.xcconfig
|
||||||
echo "📌 Xcode Project Configuration:"
|
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 [[ -f "$XCODEPROJ" ]]; then
|
||||||
if grep -q "version.xcconfig" "$XCODEPROJ"; then
|
if grep -q "version.xcconfig" "$XCODEPROJ"; then
|
||||||
check_pass "Xcode project references version.xcconfig"
|
check_pass "Xcode project references version.xcconfig"
|
||||||
|
|
@ -304,8 +304,8 @@ echo ""
|
||||||
# 7. Check appcast files
|
# 7. Check appcast files
|
||||||
echo "📌 Appcast Files:"
|
echo "📌 Appcast Files:"
|
||||||
|
|
||||||
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
|
if [[ -f "$PROJECT_ROOT/../appcast.xml" ]]; then
|
||||||
if xmllint --noout "$PROJECT_ROOT/appcast.xml" 2>/dev/null; then
|
if xmllint --noout "$PROJECT_ROOT/../appcast.xml" 2>/dev/null; then
|
||||||
check_pass "appcast.xml is valid XML"
|
check_pass "appcast.xml is valid XML"
|
||||||
else
|
else
|
||||||
check_fail "appcast.xml has XML errors"
|
check_fail "appcast.xml has XML errors"
|
||||||
|
|
@ -314,8 +314,8 @@ else
|
||||||
check_warn "appcast.xml not found (OK if no stable releases yet)"
|
check_warn "appcast.xml not found (OK if no stable releases yet)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
|
if [[ -f "$PROJECT_ROOT/../appcast-prerelease.xml" ]]; then
|
||||||
if xmllint --noout "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null; then
|
if xmllint --noout "$PROJECT_ROOT/../appcast-prerelease.xml" 2>/dev/null; then
|
||||||
check_pass "appcast-prerelease.xml is valid XML"
|
check_pass "appcast-prerelease.xml is valid XML"
|
||||||
else
|
else
|
||||||
check_fail "appcast-prerelease.xml has XML errors"
|
check_fail "appcast-prerelease.xml has XML errors"
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,22 @@ echo ""
|
||||||
# Additional strict pre-conditions before preflight check
|
# Additional strict pre-conditions before preflight check
|
||||||
echo -e "${BLUE}🔍 Running strict pre-conditions...${NC}"
|
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
|
# Check if we're on main branch
|
||||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
||||||
|
|
@ -170,8 +186,17 @@ fi
|
||||||
|
|
||||||
# Check if changelog file exists
|
# Check if changelog file exists
|
||||||
if [[ ! -f "$PROJECT_ROOT/CHANGELOG.md" ]]; then
|
if [[ ! -f "$PROJECT_ROOT/CHANGELOG.md" ]]; then
|
||||||
echo -e "${YELLOW}⚠️ Warning: CHANGELOG.md not found${NC}"
|
echo -e "${YELLOW}⚠️ Warning: CHANGELOG.md not found in mac/ directory${NC}"
|
||||||
echo " Release notes will be basic"
|
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
|
fi
|
||||||
|
|
||||||
# Check if we're up to date with origin/main
|
# Check if we're up to date with origin/main
|
||||||
|
|
@ -247,12 +272,12 @@ fi
|
||||||
# Verify build number hasn't been used
|
# Verify build number hasn't been used
|
||||||
echo "🔍 Checking build number uniqueness..."
|
echo "🔍 Checking build number uniqueness..."
|
||||||
EXISTING_BUILDS=""
|
EXISTING_BUILDS=""
|
||||||
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
|
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)
|
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"
|
EXISTING_BUILDS+="$APPCAST_BUILDS"
|
||||||
fi
|
fi
|
||||||
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
|
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)
|
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"
|
EXISTING_BUILDS+="$PRERELEASE_BUILDS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -308,9 +333,9 @@ fi
|
||||||
echo -e "${GREEN}✅ Version updated to: $VERSION_TO_SET${NC}"
|
echo -e "${GREEN}✅ Version updated to: $VERSION_TO_SET${NC}"
|
||||||
|
|
||||||
# Check if Xcode project was modified and commit if needed
|
# 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..."
|
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"
|
git commit -m "Update Xcode project for build $BUILD_NUMBER"
|
||||||
echo -e "${GREEN}✅ Xcode project changes committed${NC}"
|
echo -e "${GREEN}✅ Xcode project changes committed${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
@ -567,14 +592,14 @@ echo "📤 Creating GitHub release..."
|
||||||
# Generate release notes from changelog
|
# Generate release notes from changelog
|
||||||
echo "📝 Generating release notes from changelog..."
|
echo "📝 Generating release notes from changelog..."
|
||||||
CHANGELOG_HTML=""
|
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)
|
# Extract version for changelog (remove any pre-release suffixes for lookup)
|
||||||
CHANGELOG_VERSION="$RELEASE_VERSION"
|
CHANGELOG_VERSION="$RELEASE_VERSION"
|
||||||
if [[ "$CHANGELOG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
if [[ "$CHANGELOG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
CHANGELOG_BASE="${BASH_REMATCH[1]}"
|
CHANGELOG_BASE="${BASH_REMATCH[1]}"
|
||||||
# Try full version first, then base version
|
# Try full version first, then base version
|
||||||
CHANGELOG_HTML=$("$SCRIPT_DIR/changelog-to-html.sh" "$CHANGELOG_VERSION" "$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" "$PROJECT_ROOT/CHANGELOG.md" 2>/dev/null || \
|
"$SCRIPT_DIR/changelog-to-html.sh" "$CHANGELOG_BASE" "$CHANGELOG_PATH" 2>/dev/null || \
|
||||||
echo "")
|
echo "")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -611,15 +636,18 @@ echo -e "${BLUE}📋 Step 8/9: Updating appcast...${NC}"
|
||||||
|
|
||||||
# Generate appcast
|
# Generate appcast
|
||||||
echo "🔐 Generating appcast with EdDSA signatures..."
|
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"
|
"$SCRIPT_DIR/generate-appcast.sh"
|
||||||
|
|
||||||
# Verify the appcast was updated
|
# Verify the appcast was updated
|
||||||
if [[ "$RELEASE_TYPE" == "stable" ]]; then
|
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}"
|
echo -e "${YELLOW}⚠️ Appcast may not have been updated. Please check manually.${NC}"
|
||||||
fi
|
fi
|
||||||
else
|
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}"
|
echo -e "${YELLOW}⚠️ Pre-release appcast may not have been updated. Please check manually.${NC}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -633,8 +661,17 @@ echo "📤 Committing and pushing changes..."
|
||||||
# Add version.xcconfig changes
|
# Add version.xcconfig changes
|
||||||
git add "$VERSION_CONFIG" 2>/dev/null || true
|
git add "$VERSION_CONFIG" 2>/dev/null || true
|
||||||
|
|
||||||
# Add appcast files
|
# Add appcast files (they're in project root, not mac/)
|
||||||
git add "$PROJECT_ROOT/appcast.xml" "$PROJECT_ROOT/appcast-prerelease.xml" 2>/dev/null || true
|
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
|
if ! git diff --cached --quiet; then
|
||||||
git commit -m "Update appcast and version for $RELEASE_VERSION"
|
git commit -m "Update appcast and version for $RELEASE_VERSION"
|
||||||
|
|
|
||||||
|
|
@ -134,26 +134,26 @@ validate_appcast() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate both appcast files
|
# Validate both appcast files
|
||||||
validate_appcast "$PROJECT_ROOT/appcast.xml" "Stable appcast"
|
validate_appcast "$PROJECT_ROOT/../appcast.xml" "Stable appcast"
|
||||||
echo ""
|
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
|
# Cross-validation between appcasts
|
||||||
echo ""
|
echo ""
|
||||||
echo "📌 Cross-Validation:"
|
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
|
# Get all build numbers from both files
|
||||||
ALL_BUILDS=()
|
ALL_BUILDS=()
|
||||||
if [[ -f "$PROJECT_ROOT/appcast.xml" ]]; then
|
if [[ -f "$PROJECT_ROOT/../appcast.xml" ]]; then
|
||||||
while IFS= read -r build; do
|
while IFS= read -r build; do
|
||||||
ALL_BUILDS+=("$build")
|
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
|
fi
|
||||||
if [[ -f "$PROJECT_ROOT/appcast-prerelease.xml" ]]; then
|
if [[ -f "$PROJECT_ROOT/../appcast-prerelease.xml" ]]; then
|
||||||
while IFS= read -r build; do
|
while IFS= read -r build; do
|
||||||
ALL_BUILDS+=("$build")
|
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
|
fi
|
||||||
|
|
||||||
# Check for duplicates across files
|
# 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()
|
||||||
|
}
|
||||||