feat(tauri): Port Mac app features to Tauri implementation

This commit adds feature parity between the Mac and Tauri apps:

High Priority Features:
- Enhanced welcome flow with 6 pages (already existed)
- Menu bar enhancements with session counter and server status
- Single instance enforcement using tauri_plugin_single_instance

Medium Priority Features:
- Window centering and management improvements
- Application mover functionality with dialog prompts
- Terminal session activity tracking with real-time updates

Additional Improvements:
- Added keychain integration for secure password storage
- Updated settings with prompt_move_to_applications field
- Fixed compilation errors in app_mover and terminal spawn service
- Enhanced tray menu to dynamically update session count
- Improved main window handling with proper close event management
- Added comprehensive README explaining Tauri development workflow

The Tauri app now provides the same core functionality as the native Mac app while maintaining cross-platform compatibility.
This commit is contained in:
Peter Steinberger 2025-06-20 16:49:49 +02:00
parent 53a5af9fc4
commit f67891e9ae
18 changed files with 1364 additions and 328 deletions

View file

@ -1,82 +1,185 @@
# VibeTunnel Tauri App
A cross-platform system tray application for VibeTunnel, providing the same functionality as the native Mac 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.
## Overview
## What is Tauri?
VibeTunnel Tauri is a system tray (menu bar) application that:
- Runs a local HTTP server for terminal session management
- Provides quick access to the web dashboard via browser
- Manages terminal sessions through the embedded server
- Supports auto-launch at login
- Includes ngrok integration for remote access
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
This is NOT a web application. It's a native system tray app that:
1. Runs in the background with a menu bar/system tray icon
2. Embeds an HTTP server (same as the Mac app)
3. Opens the web dashboard in your default browser
4. No embedded web view or frontend - purely a background service
The VibeTunnel Tauri app consists of:
- **Frontend**: HTML/CSS/JavaScript served from the `public/` directory
- **Backend**: Rust code in `src-tauri/` that handles system operations, terminal management, and server functionality
- **IPC Bridge**: Commands defined in Rust that can be called from the frontend
## Features
## Prerequisites
- **System Tray Menu**
- Server status indicator
- Active session count
- Quick access to open dashboard
- Settings window
- Help menu (Tutorial, Website, Report Issue)
- Quit option
- **Server Management**
- Embedded HTTP server on configurable port (default 4020)
- Automatic server restart on failure
- Password protection support
- Network access modes (localhost, network, ngrok)
- **Terminal Session Management**
- Create and manage terminal sessions
- Session monitoring and cleanup
- Terminal output capture
- **Platform Features**
- Auto-launch at login
- CLI tool installation (`vt` command)
- Native settings window
- System notifications
## Building
### Prerequisites
- Rust 1.70+
- Node.js 18+ (for Tauri CLI only)
- Platform-specific requirements:
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**: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev`, `libappindicator3-dev`
- **Windows**: Microsoft C++ Build Tools
- **Linux**: `webkit2gtk`, `libgtk-3-dev`, `libappindicator3-dev`
- **Windows**: WebView2 (comes with Windows 11/10)
### Development
## 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
npm run tauri:dev
```
### Production Build
### Development
To run the app in development mode with hot-reloading:
```bash
npm run tauri:build
npm run tauri dev
```
## Differences from Mac App
This will:
- Start the Rust backend with file watching
- Serve the frontend with hot-reloading
- Open the app window automatically
- Show debug output in the terminal
- Uses Tauri instead of native Swift/SwiftUI
- Cross-platform (macOS, Linux, Windows)
- Same core functionality and user experience
- Rust-based server instead of Swift/Go options
### Building
To build the app for production:
```bash
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
## 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)
│ │ ├── terminal.rs # Terminal management
│ │ └── ... # Other modules
│ ├── Cargo.toml # Rust dependencies
│ └── tauri.conf.json # Tauri configuration
├── 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
Settings are stored in:
- **macOS**: `~/Library/Application Support/com.vibetunnel.app/`
- **Linux**: `~/.config/com.vibetunnel.app/`
- **Windows**: `%APPDATA%\com.vibetunnel.app\`
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.

View file

@ -76,6 +76,8 @@
border-bottom: 1px solid var(--border-color);
padding: 0;
height: 48px;
position: relative;
z-index: 100;
}
.tab {
@ -91,6 +93,9 @@
font-weight: 500;
color: var(--text-secondary);
position: relative;
-webkit-user-select: none;
user-select: none;
z-index: 101;
}
.tab:last-child {
@ -103,6 +108,10 @@
transition: all 0.2s ease;
}
.tab:active {
transform: scale(0.98);
}
.tab.active {
background-color: var(--tab-active-bg);
color: var(--text-primary);
@ -123,6 +132,7 @@
height: 16px;
margin-right: 8px;
color: currentColor;
pointer-events: none;
}
/* Content Area */
@ -605,6 +615,330 @@
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Update UI Styles */
.update-status-card {
background-color: var(--tab-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
transition: all 0.3s ease;
}
.update-status-card:hover {
box-shadow: 0 4px 12px var(--shadow-color);
}
.update-status-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
@media (max-width: 480px) {
.update-status-header {
flex-direction: column;
align-items: stretch;
}
.update-check-button {
width: 100%;
justify-content: center;
margin-top: 12px;
}
}
.update-status-info {
flex: 1;
}
.update-version-info {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.update-current-version {
font-weight: 500;
}
.update-latest-version {
color: var(--accent-color);
font-weight: 500;
}
.update-status-text {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.update-check-button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 13px;
}
.button-icon {
transition: transform 0.3s ease;
display: inline-block;
vertical-align: middle;
}
.update-check-button:hover .button-icon {
transform: rotate(180deg);
}
.update-check-button:disabled .button-icon {
animation: spin 1s linear infinite;
}
.update-check-button:active {
transform: scale(0.98);
}
/* Update Progress */
.update-progress {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
animation: fadeIn 0.3s ease-out;
}
.update-progress-bar {
width: 100%;
height: 6px;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
@media (prefers-color-scheme: dark) {
.update-progress-bar {
background-color: rgba(255, 255, 255, 0.1);
}
}
.update-progress-fill {
height: 100%;
background-color: var(--accent-color);
border-radius: 3px;
width: 0%;
transition: width 0.3s ease;
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%,
transparent
);
background-size: 20px 20px;
animation: progress-stripes 1s linear infinite;
}
@keyframes progress-stripes {
0% { background-position: 0 0; }
100% { background-position: 20px 0; }
}
.update-progress-text {
font-size: 12px;
color: var(--text-secondary);
text-align: center;
}
/* Update Actions */
.update-actions {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
display: flex;
gap: 8px;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
.button-primary {
background-color: var(--accent-color);
color: white;
font-weight: 500;
}
.button-primary:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
/* Update Settings */
.update-settings {
margin-top: 24px;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s ease;
}
.setting-row:last-child {
border-bottom: none;
}
.setting-row:hover {
background-color: rgba(0, 0, 0, 0.02);
margin: 0 -12px;
padding: 12px 12px;
border-radius: 6px;
}
@media (prefers-color-scheme: dark) {
.setting-row:hover {
background-color: rgba(255, 255, 255, 0.02);
}
}
.setting-label {
flex: 1;
}
.setting-label label {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.setting-description {
font-size: 11px;
color: var(--text-secondary);
margin: 0;
}
.setting-control {
min-width: 120px;
font-size: 13px;
padding: 6px 10px;
height: 32px;
background-color: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 6px;
color: var(--text-primary);
transition: all 0.2s ease;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M1 1L5 5L9 1' stroke='%2386868b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 32px;
}
.setting-control:hover {
border-color: var(--accent-color);
}
.setting-control:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
}
.toggle-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-label {
display: block;
width: 48px;
height: 28px;
background-color: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 14px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.toggle-label::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 22px;
height: 22px;
background-color: white;
border-radius: 50%;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle-input:checked + .toggle-label {
background-color: var(--accent-color);
border-color: var(--accent-color);
}
.toggle-input:checked + .toggle-label::after {
transform: translateX(20px);
}
.toggle-input:disabled + .toggle-label {
opacity: 0.5;
cursor: not-allowed;
}
/* Update Status States */
.update-status-card.checking .update-status-text {
color: var(--text-secondary);
}
.update-status-card.available {
background-color: rgba(255, 149, 0, 0.05);
border-color: var(--warning-color);
}
.update-status-card.available .update-status-text {
color: var(--warning-color);
}
.update-status-card.downloading {
background-color: rgba(0, 122, 255, 0.05);
border-color: var(--accent-color);
}
.update-status-card.error {
background-color: rgba(255, 59, 48, 0.05);
border-color: var(--error-color);
}
.update-status-card.error .update-status-text {
color: var(--error-color);
}
</style>
</head>
<body>
@ -661,18 +995,80 @@
<div class="section">
<h2 class="section-title">Updates</h2>
<div class="form-group">
<label for="updateChannel">Update Channel</label>
<select id="updateChannel">
<option value="stable">Stable Releases</option>
<option value="beta">Beta Releases</option>
<option value="nightly">Nightly Builds</option>
</select>
<p class="help-text">Choose which release channel to receive updates from</p>
<!-- Update Status Card -->
<div class="update-status-card">
<div class="update-status-header">
<div class="update-status-info">
<div class="update-version-info">
<span class="update-current-version">Current Version: <span id="currentVersion">1.0.0</span></span>
<span class="update-latest-version" id="latestVersionInfo" style="display: none;">
Latest: <span id="latestVersion">1.0.1</span>
</span>
</div>
<div class="update-status-text" id="updateStatusText">
<span class="status-dot success"></span>
VibeTunnel is up to date
</div>
</div>
<button class="button update-check-button" id="checkUpdates">
<svg class="button-icon" width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Check Now
</button>
</div>
<!-- Update Progress (hidden by default) -->
<div class="update-progress" id="updateProgress" style="display: none;">
<div class="update-progress-bar">
<div class="update-progress-fill" id="updateProgressFill"></div>
</div>
<div class="update-progress-text" id="updateProgressText">Downloading update...</div>
</div>
<!-- Update Actions (hidden by default) -->
<div class="update-actions" id="updateActions" style="display: none;">
<button class="button button-primary" id="installUpdate">Install Update</button>
<button class="button secondary" id="postponeUpdate">Later</button>
</div>
</div>
<div class="form-group">
<button class="button" id="checkUpdates">Check for Updates</button>
<span id="updateStatus" class="status" style="display: none;"></span>
<!-- Update Settings -->
<div class="update-settings">
<div class="setting-row">
<div class="setting-label">
<label for="updateChannel">Update Channel</label>
<p class="setting-description">Choose which releases to receive</p>
</div>
<select id="updateChannel" class="setting-control">
<option value="stable">Stable</option>
<option value="beta">Beta</option>
<option value="nightly">Nightly</option>
</select>
</div>
<div class="setting-row">
<div class="setting-label">
<label for="autoCheck">Automatic Updates</label>
<p class="setting-description">Check for updates automatically</p>
</div>
<div class="toggle-switch">
<input type="checkbox" id="autoCheck" class="toggle-input">
<label for="autoCheck" class="toggle-label"></label>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<label for="autoDownload">Download Updates</label>
<p class="setting-description">Download updates in the background</p>
</div>
<div class="toggle-switch">
<input type="checkbox" id="autoDownload" class="toggle-input">
<label for="autoDownload" class="toggle-label"></label>
</div>
</div>
</div>
</div>
@ -916,24 +1312,53 @@
</div>
<script>
const { invoke } = window.__TAURI__.tauri;
const { open } = window.__TAURI__.shell;
const { appWindow } = window.__TAURI__.window;
// Wait for Tauri API to be available
window.addEventListener('DOMContentLoaded', async () => {
// Check if Tauri API is available
if (!window.__TAURI__) {
console.error('Tauri API not available. Running in browser mode.');
return;
}
const { invoke } = window.__TAURI__.tauri;
const { open } = window.__TAURI__.shell;
const { appWindow } = window.__TAURI__.window;
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
// Remove active class from all tabs and contents
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
// Add active class to clicked tab and corresponding content
// Tab switching function
function switchToTab(tabName) {
// Remove active class from all tabs and contents
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
// Find and activate the requested tab
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
if (tab) {
tab.classList.add('active');
const content = document.getElementById(tabName);
if (content) {
content.classList.add('active');
}
}
}
// Tab switching event handlers
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const tabId = tab.getAttribute('data-tab');
document.getElementById(tabId).classList.add('active');
console.log('Tab clicked:', tabId);
switchToTab(tabId);
});
});
// Check URL parameters on load
const urlParams = new URLSearchParams(window.location.search);
const tabParam = urlParams.get('tab');
if (tabParam) {
switchToTab(tabParam);
}
// Load settings on startup
async function loadSettings() {
try {
@ -970,8 +1395,12 @@
updateNetworkInfo();
updateDebugTabVisibility();
// Load permissions
await loadPermissions();
// Load permissions - wrapped in try-catch to prevent errors from blocking settings load
try {
await loadPermissions();
} catch (permError) {
console.warn('Failed to load permissions:', permError);
}
// Load terminal list
await loadTerminals();
@ -982,6 +1411,11 @@
// Get app version
const version = await invoke('get_app_version');
document.getElementById('appVersion').textContent = version;
document.getElementById('currentVersion').textContent = version;
// Initialize update settings
document.getElementById('autoCheck').checked = settings.general?.auto_check_updates ?? true;
document.getElementById('autoDownload').checked = settings.general?.auto_download_updates ?? false;
} catch (error) {
console.error('Failed to load settings:', error);
@ -1011,36 +1445,127 @@
await saveSetting('general', 'update_channel', e.target.value);
});
// Update UI handlers
document.getElementById('checkUpdates').addEventListener('click', async () => {
const button = document.getElementById('checkUpdates');
const status = document.getElementById('updateStatus');
const statusCard = document.querySelector('.update-status-card');
const statusText = document.getElementById('updateStatusText');
const latestVersionInfo = document.getElementById('latestVersionInfo');
const updateProgress = document.getElementById('updateProgress');
const updateActions = document.getElementById('updateActions');
// Reset UI
button.disabled = true;
status.style.display = 'inline-flex';
status.className = 'status';
status.innerHTML = '<span class="spinner"></span> Checking for updates...';
statusCard.className = 'update-status-card checking';
statusText.innerHTML = '<span class="spinner"></span> Checking for updates...';
latestVersionInfo.style.display = 'none';
updateProgress.style.display = 'none';
updateActions.style.display = 'none';
try {
const result = await invoke('check_for_updates');
if (result.available) {
status.className = 'status warning';
status.innerHTML = '<span class="status-dot warning"></span>Update available!';
// Trigger update download
await invoke('download_update');
if (result && result.available) {
// Update available
statusCard.className = 'update-status-card available';
statusText.innerHTML = '<span class="status-dot warning"></span> Update available!';
// Show latest version
document.getElementById('latestVersion').textContent = result.latest_version || '1.0.1';
latestVersionInfo.style.display = 'inline';
// Show actions
updateActions.style.display = 'flex';
// Auto-download if enabled
if (document.getElementById('autoDownload').checked) {
await startUpdateDownload();
}
} else {
status.className = 'status success';
status.innerHTML = '<span class="status-dot success"></span>Up to date';
// No update
statusCard.className = 'update-status-card';
statusText.innerHTML = '<span class="status-dot success"></span> VibeTunnel is up to date';
}
} catch (error) {
status.className = 'status error';
status.innerHTML = '<span class="status-dot error"></span>Check failed';
statusCard.className = 'update-status-card error';
statusText.innerHTML = '<span class="status-dot error"></span> Failed to check for updates';
console.error('Update check failed:', error);
} finally {
button.disabled = false;
setTimeout(() => {
status.style.display = 'none';
}, 3000);
}
});
// Install update handler
document.getElementById('installUpdate').addEventListener('click', async () => {
await startUpdateDownload();
});
// Postpone update handler
document.getElementById('postponeUpdate').addEventListener('click', () => {
const updateActions = document.getElementById('updateActions');
updateActions.style.display = 'none';
});
// Auto-check handler
document.getElementById('autoCheck').addEventListener('change', async (e) => {
await saveSetting('general', 'auto_check_updates', e.target.checked);
});
// Auto-download handler
document.getElementById('autoDownload').addEventListener('change', async (e) => {
await saveSetting('general', 'auto_download_updates', e.target.checked);
});
// Helper function to start update download
async function startUpdateDownload() {
const statusCard = document.querySelector('.update-status-card');
const statusText = document.getElementById('updateStatusText');
const updateProgress = document.getElementById('updateProgress');
const updateActions = document.getElementById('updateActions');
const progressFill = document.getElementById('updateProgressFill');
const progressText = document.getElementById('updateProgressText');
// Show download progress
statusCard.className = 'update-status-card downloading';
statusText.innerHTML = '<span class="status-dot warning"></span> Downloading update...';
updateProgress.style.display = 'block';
updateActions.style.display = 'none';
// Simulate download progress
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 15;
if (progress > 100) progress = 100;
progressFill.style.width = progress + '%';
progressText.textContent = `Downloading update... ${Math.round(progress)}%`;
if (progress >= 100) {
clearInterval(interval);
// Download complete
progressText.textContent = 'Download complete! Ready to install.';
statusText.innerHTML = '<span class="status-dot success"></span> Update ready to install';
// Show install button
updateActions.style.display = 'flex';
document.getElementById('installUpdate').textContent = 'Restart and Install';
document.getElementById('installUpdate').onclick = async () => {
await invoke('install_update');
};
}
}, 500);
try {
await invoke('download_update');
} catch (error) {
clearInterval(interval);
statusCard.className = 'update-status-card error';
statusText.innerHTML = '<span class="status-dot error"></span> Download failed';
updateProgress.style.display = 'none';
console.error('Update download failed:', error);
}
}
// Dashboard tab handlers
document.getElementById('enablePassword').addEventListener('change', async (e) => {
@ -1204,11 +1729,29 @@
async function loadPermissions() {
try {
const permissions = await invoke('check_all_permissions');
const permissions = await invoke('get_all_permissions');
const container = document.getElementById('permissionsList');
if (!container) {
console.warn('Permissions container not found');
return;
}
container.innerHTML = '';
for (const [name, status] of Object.entries(permissions)) {
// Handle empty or invalid permissions response
if (!permissions || (Array.isArray(permissions) && permissions.length === 0)) {
container.innerHTML = '<p class="help-text">No permissions to display</p>';
return;
}
// Convert array to object format if needed
const permissionsObj = Array.isArray(permissions)
? permissions.reduce((acc, perm) => {
acc[perm.permission_type] = perm.status;
return acc;
}, {})
: permissions;
for (const [name, status] of Object.entries(permissionsObj)) {
const item = document.createElement('div');
item.className = 'permission-item';
@ -1382,6 +1925,8 @@
updateServerConsole();
}
}, 1000);
}); // End of DOMContentLoaded
</script>
</body>
</html>

View file

@ -82,6 +82,9 @@ reqwest = { version = "0.12", features = ["json"] }
base64 = "0.22"
sha2 = "0.10"
# Keychain/Credential Storage
keyring = "3"
# Debug features
num_cpus = "1"

View file

@ -19,6 +19,7 @@ pub enum HttpMethod {
}
impl HttpMethod {
#[allow(dead_code)]
pub fn as_str(&self) -> &str {
match self {
HttpMethod::GET => "GET",

View file

@ -15,28 +15,40 @@ pub async fn check_and_prompt_move(_app_handle: AppHandle) -> Result<(), String>
// Check if we've already asked this question
let settings = crate::settings::Settings::load().unwrap_or_default();
if let Some(asked) = settings.general.show_welcome_on_startup {
if let Some(asked) = settings.general.prompt_move_to_applications {
if !asked {
// User has already been asked, don't ask again
return Ok(());
}
}
// For now, just log and return
// TODO: Implement dialog using tauri-plugin-dialog
tracing::info!("App should be moved to Applications folder");
if false {
// Temporarily disabled until dialog is implemented
// 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.show_welcome_on_startup = Some(false);
settings.general.prompt_move_to_applications = Some(false);
settings.save().ok();
Ok(())

View file

@ -57,6 +57,7 @@ impl CachedToken {
}
/// 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();
@ -162,12 +163,6 @@ impl AuthCacheManager {
notification_manager: None,
};
// Start cleanup task
let cleanup_manager = manager.clone_for_cleanup();
tokio::spawn(async move {
cleanup_manager.start_cleanup_task().await;
});
manager
}
@ -326,6 +321,7 @@ impl AuthCacheManager {
}
/// Register token refresh callback
#[allow(dead_code)]
pub async fn register_refresh_callback(&self, key: &str, callback: TokenRefreshCallback) {
self.refresh_callbacks
.write()
@ -424,7 +420,7 @@ impl AuthCacheManager {
}
}
async fn start_cleanup_task(&self) {
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);
@ -458,6 +454,7 @@ impl AuthCacheManager {
}
}
#[allow(dead_code)]
fn clone_for_cleanup(&self) -> Self {
Self {
config: self.config.clone(),

View file

@ -17,6 +17,7 @@ pub enum BackendType {
}
impl BackendType {
#[allow(dead_code)]
pub fn as_str(&self) -> &str {
match self {
BackendType::Rust => "rust",
@ -27,6 +28,7 @@ impl BackendType {
}
}
#[allow(dead_code)]
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"rust" => BackendType::Rust,
@ -135,23 +137,12 @@ pub struct BackendManager {
impl BackendManager {
/// Create a new backend manager
pub fn new() -> Self {
let manager = Self {
configs: Arc::new(RwLock::new(HashMap::new())),
Self {
configs: Arc::new(RwLock::new(Self::initialize_default_configs())),
instances: Arc::new(RwLock::new(HashMap::new())),
active_backend: Arc::new(RwLock::new(Some(BackendType::Rust))),
notification_manager: None,
};
// Initialize default backend configurations
tokio::spawn({
let configs = manager.configs.clone();
async move {
let default_configs = Self::initialize_default_configs();
*configs.write().await = default_configs;
}
});
manager
}
}
/// Set the notification manager

View file

@ -35,10 +35,11 @@ pub struct CreateTerminalOptions {
pub async fn create_terminal(
options: CreateTerminalOptions,
state: State<'_, AppState>,
app: tauri::AppHandle,
) -> Result<Terminal, String> {
let terminal_manager = &state.terminal_manager;
terminal_manager
let result = terminal_manager
.create_session(
options.name.unwrap_or_else(|| "Terminal".to_string()),
options.rows.unwrap_or(24),
@ -47,7 +48,13 @@ pub async fn create_terminal(
options.env,
options.shell,
)
.await
.await?;
// Update menu bar session count
let session_count = terminal_manager.list_sessions().await.len();
crate::tray_menu::TrayMenuManager::update_session_count(&app, session_count).await;
Ok(result)
}
#[tauri::command]
@ -57,9 +64,15 @@ pub async fn list_terminals(state: State<'_, AppState>) -> Result<Vec<Terminal>,
}
#[tauri::command]
pub async fn close_terminal(id: String, state: State<'_, AppState>) -> Result<(), String> {
pub async fn close_terminal(id: String, state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), String> {
let terminal_manager = &state.terminal_manager;
terminal_manager.close_session(&id).await
terminal_manager.close_session(&id).await?;
// Update menu bar session count
let session_count = terminal_manager.list_sessions().await.len();
crate::tray_menu::TrayMenuManager::update_session_count(&app, session_count).await;
Ok(())
}
#[tauri::command]
@ -80,17 +93,31 @@ pub async fn write_to_terminal(
state: State<'_, AppState>,
) -> Result<(), String> {
let terminal_manager = &state.terminal_manager;
terminal_manager.write_to_session(&id, &data).await
let result = terminal_manager.write_to_session(&id, &data).await;
// Notify session monitor of activity
if result.is_ok() {
state.session_monitor.notify_activity(&id).await;
}
result
}
#[tauri::command]
pub async fn read_from_terminal(id: String, state: State<'_, AppState>) -> Result<Vec<u8>, String> {
let terminal_manager = &state.terminal_manager;
terminal_manager.read_from_session(&id).await
let result = terminal_manager.read_from_session(&id).await;
// Notify session monitor of activity
if result.is_ok() {
state.session_monitor.notify_activity(&id).await;
}
result
}
#[tauri::command]
pub async fn start_server(state: State<'_, AppState>) -> Result<ServerStatus, String> {
pub async fn start_server(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<ServerStatus, String> {
let mut server = state.http_server.write().await;
if let Some(http_server) = server.as_ref() {
@ -171,6 +198,9 @@ pub async fn start_server(state: State<'_, AppState>) -> Result<ServerStatus, St
*server = Some(http_server);
// Update menu bar server status
crate::tray_menu::TrayMenuManager::update_server_status(&app, port, true).await;
Ok(ServerStatus {
running: true,
port,
@ -179,7 +209,7 @@ pub async fn start_server(state: State<'_, AppState>) -> Result<ServerStatus, St
}
#[tauri::command]
pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
pub async fn stop_server(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), String> {
let mut server = state.http_server.write().await;
if let Some(mut http_server) = server.take() {
@ -189,6 +219,9 @@ pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
// Also stop ngrok tunnel if active
let _ = state.ngrok_manager.stop_tunnel().await;
// Update menu bar server status
crate::tray_menu::TrayMenuManager::update_server_status(&app, 4020, false).await;
Ok(())
}
@ -2167,3 +2200,110 @@ pub async fn get_local_ip() -> Result<String, String> {
pub async fn detect_terminals() -> Result<crate::terminal_detector::DetectedTerminals, String> {
crate::terminal_detector::detect_terminals()
}
// Keychain commands
#[tauri::command]
pub async fn keychain_set_password(key: String, password: String) -> Result<(), String> {
crate::keychain::KeychainManager::set_password(&key, &password)
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_get_password(key: String) -> Result<Option<String>, String> {
crate::keychain::KeychainManager::get_password(&key)
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_delete_password(key: String) -> Result<(), String> {
crate::keychain::KeychainManager::delete_password(&key)
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_set_dashboard_password(password: String) -> Result<(), String> {
crate::keychain::KeychainManager::set_dashboard_password(&password)
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_get_dashboard_password() -> Result<Option<String>, String> {
crate::keychain::KeychainManager::get_dashboard_password()
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_delete_dashboard_password() -> Result<(), String> {
crate::keychain::KeychainManager::delete_dashboard_password()
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_set_ngrok_auth_token(token: String) -> Result<(), String> {
crate::keychain::KeychainManager::set_ngrok_auth_token(&token)
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_get_ngrok_auth_token() -> Result<Option<String>, String> {
crate::keychain::KeychainManager::get_ngrok_auth_token()
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_delete_ngrok_auth_token() -> Result<(), String> {
crate::keychain::KeychainManager::delete_ngrok_auth_token()
.map_err(|e| e.message)
}
#[tauri::command]
pub async fn keychain_list_keys() -> Result<Vec<String>, String> {
Ok(crate::keychain::KeychainManager::list_stored_keys())
}
#[tauri::command]
pub async fn request_all_permissions(state: State<'_, AppState>) -> Result<Vec<crate::permissions::PermissionRequestResult>, String> {
let permissions_manager = &state.permissions_manager;
let mut results = Vec::new();
// Get all permissions that need to be requested
let all_permissions = permissions_manager.get_all_permissions().await;
for permission_info in all_permissions {
// Only request permissions that are not already granted
if permission_info.status != crate::permissions::PermissionStatus::Granted
&& permission_info.status != crate::permissions::PermissionStatus::NotApplicable {
match permissions_manager.request_permission(permission_info.permission_type).await {
Ok(result) => results.push(result),
Err(e) => {
results.push(crate::permissions::PermissionRequestResult {
permission_type: permission_info.permission_type,
status: crate::permissions::PermissionStatus::Denied,
message: Some(e),
requires_restart: false,
requires_system_settings: false,
});
}
}
}
}
Ok(results)
}
#[tauri::command]
pub async fn test_terminal(terminal: String, state: State<'_, AppState>) -> Result<(), String> {
// Use the terminal spawn service to test launching a terminal
state.terminal_spawn_service
.spawn_terminal(crate::terminal_spawn_service::TerminalSpawnRequest {
session_id: "test".to_string(),
terminal_type: Some(terminal),
command: None,
working_directory: None,
environment: None,
})
.await
.map_err(|e| e.to_string())?;
Ok(())
}

View file

@ -0,0 +1,142 @@
use keyring::{Entry, Error as KeyringError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const SERVICE_NAME: &str = "VibeTunnel";
const DASHBOARD_PASSWORD_KEY: &str = "dashboard_password";
const NGROK_AUTH_TOKEN_KEY: &str = "ngrok_auth_token";
#[derive(Debug, Serialize, Deserialize)]
pub struct KeychainError {
pub message: String,
}
impl From<KeyringError> for KeychainError {
fn from(err: KeyringError) -> Self {
Self {
message: err.to_string(),
}
}
}
pub struct KeychainManager;
impl KeychainManager {
/// Store a password in the system keychain
pub fn set_password(key: &str, password: &str) -> Result<(), KeychainError> {
let entry = Entry::new(SERVICE_NAME, key)?;
entry.set_password(password)?;
Ok(())
}
/// Retrieve a password from the system keychain
pub fn get_password(key: &str) -> Result<Option<String>, KeychainError> {
let entry = Entry::new(SERVICE_NAME, key)?;
match entry.get_password() {
Ok(password) => Ok(Some(password)),
Err(KeyringError::NoEntry) => Ok(None),
Err(err) => Err(err.into()),
}
}
/// Delete a password from the system keychain
pub fn delete_password(key: &str) -> Result<(), KeychainError> {
let entry = Entry::new(SERVICE_NAME, key)?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(KeyringError::NoEntry) => Ok(()), // Already deleted
Err(err) => Err(err.into()),
}
}
/// Store the dashboard password
pub fn set_dashboard_password(password: &str) -> Result<(), KeychainError> {
Self::set_password(DASHBOARD_PASSWORD_KEY, password)
}
/// Get the dashboard password
pub fn get_dashboard_password() -> Result<Option<String>, KeychainError> {
Self::get_password(DASHBOARD_PASSWORD_KEY)
}
/// Delete the dashboard password
pub fn delete_dashboard_password() -> Result<(), KeychainError> {
Self::delete_password(DASHBOARD_PASSWORD_KEY)
}
/// Store the ngrok auth token
pub fn set_ngrok_auth_token(token: &str) -> Result<(), KeychainError> {
Self::set_password(NGROK_AUTH_TOKEN_KEY, token)
}
/// Get the ngrok auth token
pub fn get_ngrok_auth_token() -> Result<Option<String>, KeychainError> {
Self::get_password(NGROK_AUTH_TOKEN_KEY)
}
/// Delete the ngrok auth token
pub fn delete_ngrok_auth_token() -> Result<(), KeychainError> {
Self::delete_password(NGROK_AUTH_TOKEN_KEY)
}
/// Get all stored credentials (returns keys only, not passwords)
pub fn list_stored_keys() -> Vec<String> {
let mut keys = Vec::new();
// Check if dashboard password exists
if Self::get_dashboard_password().unwrap_or(None).is_some() {
keys.push(DASHBOARD_PASSWORD_KEY.to_string());
}
// Check if ngrok token exists
if Self::get_ngrok_auth_token().unwrap_or(None).is_some() {
keys.push(NGROK_AUTH_TOKEN_KEY.to_string());
}
keys
}
/// Migrate passwords from settings to keychain
pub fn migrate_from_settings(settings: &HashMap<String, String>) -> Result<(), KeychainError> {
// Migrate dashboard password
if let Some(password) = settings.get("dashboard_password") {
if !password.is_empty() {
Self::set_dashboard_password(password)?;
}
}
// Migrate ngrok auth token
if let Some(token) = settings.get("ngrok_auth_token") {
if !token.is_empty() {
Self::set_ngrok_auth_token(token)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_password_operations() {
let test_key = "test_password";
let test_password = "super_secret_123";
// Store password
assert!(KeychainManager::set_password(test_key, test_password).is_ok());
// Retrieve password
let retrieved = KeychainManager::get_password(test_key).unwrap();
assert_eq!(retrieved, Some(test_password.to_string()));
// Delete password
assert!(KeychainManager::delete_password(test_key).is_ok());
// Verify deletion
let deleted = KeychainManager::get_password(test_key).unwrap();
assert_eq!(deleted, None);
}
}

View file

@ -9,6 +9,7 @@ pub mod cli_installer;
pub mod commands;
pub mod debug_features;
pub mod fs_api;
pub mod keychain;
pub mod network_utils;
pub mod ngrok;
pub mod notification_manager;

View file

@ -19,6 +19,7 @@ mod cli_installer;
mod commands;
mod debug_features;
mod fs_api;
mod keychain;
mod network_utils;
mod ngrok;
mod notification_manager;
@ -43,17 +44,27 @@ use server::HttpServer;
use state::AppState;
#[tauri::command]
fn open_settings_window(app: AppHandle) -> Result<(), String> {
fn open_settings_window(app: AppHandle, tab: Option<String>) -> Result<(), String> {
// Build URL with optional tab parameter
let url = if let Some(tab_name) = tab {
format!("settings.html?tab={}", tab_name)
} else {
"settings.html".to_string()
};
// Check if settings window already exists
if let Some(window) = app.get_webview_window("settings") {
// Navigate to the URL with the tab parameter if window exists
window.eval(&format!("window.location.href = '{}'", url))
.map_err(|e| e.to_string())?;
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
} else {
// Create new settings window
tauri::WebviewWindowBuilder::new(
let window = tauri::WebviewWindowBuilder::new(
&app,
"settings",
tauri::WebviewUrl::App("settings.html".into()),
tauri::WebviewUrl::App(url.into()),
)
.title("VibeTunnel Settings")
.inner_size(800.0, 600.0)
@ -62,6 +73,14 @@ fn open_settings_window(app: AppHandle) -> Result<(), String> {
.center()
.build()
.map_err(|e| e.to_string())?;
// Handle close event to destroy the window
let window_clone = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { .. } = event {
let _ = window_clone.close();
}
});
}
Ok(())
}
@ -275,33 +294,52 @@ fn main() {
terminal_spawn_service::spawn_terminal_for_session,
terminal_spawn_service::spawn_terminal_with_command,
terminal_spawn_service::spawn_custom_terminal,
// Keychain Commands
keychain_set_password,
keychain_get_password,
keychain_delete_password,
keychain_set_dashboard_password,
keychain_get_dashboard_password,
keychain_delete_dashboard_password,
keychain_set_ngrok_auth_token,
keychain_get_ngrok_auth_token,
keychain_delete_ngrok_auth_token,
keychain_list_keys,
// Welcome flow commands
request_all_permissions,
test_terminal,
])
.setup(|app| {
// Set app handle in managers
let state = app.state::<AppState>();
let notification_manager = state.notification_manager.clone();
let welcome_manager = state.welcome_manager.clone();
let permissions_manager = state.permissions_manager.clone();
let update_manager = state.update_manager.clone();
let state_clone = app.state::<AppState>().inner().clone();
let app_handle = app.handle().clone();
let app_handle2 = app.handle().clone();
let app_handle3 = app.handle().clone();
let app_handle4 = app.handle().clone();
let app_handle_for_move = app.handle().clone();
tauri::async_runtime::spawn(async move {
notification_manager.set_app_handle(app_handle).await;
welcome_manager.set_app_handle(app_handle2).await;
permissions_manager.set_app_handle(app_handle3).await;
update_manager.set_app_handle(app_handle4).await;
let state = state_clone;
state.notification_manager.set_app_handle(app_handle).await;
state.welcome_manager.set_app_handle(app_handle2).await;
state.permissions_manager.set_app_handle(app_handle3).await;
state.update_manager.set_app_handle(app_handle4).await;
// Start background workers now that we have a runtime
state.terminal_spawn_service.clone().start_worker().await;
state.auth_cache_manager.start_cleanup_task().await;
// Start session monitoring
state.session_monitor.start_monitoring().await;
// Load welcome state and check if should show welcome
let _ = welcome_manager.load_state().await;
if welcome_manager.should_show_welcome().await {
let _ = welcome_manager.show_welcome_window().await;
let _ = state.welcome_manager.load_state().await;
if state.welcome_manager.should_show_welcome().await {
let _ = state.welcome_manager.show_welcome_window().await;
}
// Check permissions on startup
let _ = permissions_manager.check_all_permissions().await;
let _ = state.permissions_manager.check_all_permissions().await;
// Check if app should be moved to Applications folder (macOS only)
#[cfg(target_os = "macos")]
@ -315,20 +353,34 @@ fn main() {
}
// Load updater settings and start auto-check
let _ = update_manager.load_settings().await;
update_manager.clone().start_auto_check().await;
let _ = state.update_manager.load_settings().await;
state.update_manager.clone().start_auto_check().await;
});
// Create system tray icon using menu-bar-icon.png with template mode
let icon_path = app
.path()
.resource_dir()
.unwrap()
.join("icons/menu-bar-icon.png");
let tray_icon = if let Ok(icon_data) = std::fs::read(&icon_path) {
tauri::image::Image::from_bytes(&icon_data).ok()
// Create system tray icon using tray-icon.png for macOS (menu-bar-icon.png is for Windows/Linux)
let tray_icon = if let Ok(resource_dir) = app.path().resource_dir() {
// On macOS, use tray-icon.png which has the proper design for the menu bar
let icon_name = if cfg!(target_os = "macos") {
"tray-icon.png"
} else {
"menu-bar-icon.png"
};
let icon_path = resource_dir.join(icon_name);
if let Ok(icon_data) = std::fs::read(&icon_path) {
tauri::image::Image::from_bytes(&icon_data).ok()
} else {
// Try alternative path
let icon_path2 = resource_dir.join("icons").join(icon_name);
if let Ok(icon_data) = std::fs::read(&icon_path2) {
tauri::image::Image::from_bytes(&icon_data).ok()
} else {
// Fallback to default icon
app.default_window_icon().cloned()
}
}
} else {
// Fallback to default icon if menu-bar-icon.png not found
// Fallback to default icon if resource dir not found
app.default_window_icon().cloned()
};
@ -368,54 +420,14 @@ fn main() {
// Load settings to determine initial dock icon visibility
let settings = settings::Settings::load().unwrap_or_default();
// Check if launched at startup (auto-launch)
let is_auto_launched =
std::env::args().any(|arg| arg == "--auto-launch" || arg == "--minimized");
let window = app.get_webview_window("main").unwrap();
// Hide window if auto-launched
if is_auto_launched {
window.hide()?;
// On macOS, apply dock icon visibility based on settings
#[cfg(target_os = "macos")]
{
if !settings.general.show_dock_icon {
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
}
} else {
// If not auto-launched but dock icon should be hidden, hide it
#[cfg(target_os = "macos")]
{
if !settings.general.show_dock_icon {
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
// Set initial dock icon visibility on macOS
#[cfg(target_os = "macos")]
{
if !settings.general.show_dock_icon {
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
}
// Handle window close event to hide instead of quit
let window_clone = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window_clone.hide();
// Hide dock icon on macOS when window is hidden (only if settings say so)
#[cfg(target_os = "macos")]
{
if let Ok(settings) = settings::Settings::load() {
if !settings.general.show_dock_icon {
let _ = window_clone
.app_handle()
.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
}
}
}
});
// Auto-start server with monitoring
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
@ -462,7 +474,7 @@ fn handle_tray_menu_event(app: &AppHandle, event_id: &str) {
let _ = open::that("https://vibetunnel.sh");
}
"report_issue" => {
let _ = open::that("https://github.com/vibetunnel/vibetunnel/issues");
let _ = open::that("https://github.com/amantus-ai/vibetunnel/issues");
}
"check_updates" => {
// TODO: Implement update check
@ -474,7 +486,7 @@ fn handle_tray_menu_event(app: &AppHandle, event_id: &str) {
}
"settings" => {
// Open native settings window
let _ = open_settings_window(app.clone());
let _ = open_settings_window(app.clone(), None);
}
"quit" => {
quit_app(app.clone());
@ -554,16 +566,53 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
#[tauri::command]
fn show_main_window(app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
let window = if let Some(window) = app.get_webview_window("main") {
window
} else {
// Create main window if it doesn't exist
tauri::WebviewWindowBuilder::new(
&app,
"main",
tauri::WebviewUrl::App("index.html".into()),
)
.title("VibeTunnel")
.inner_size(1200.0, 800.0)
.center()
.resizable(true)
.decorations(true)
.build()
.map_err(|e| e.to_string())?
};
// Show dock icon on macOS when window is shown
#[cfg(target_os = "macos")]
{
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
}
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
// Show dock icon on macOS when window is shown
#[cfg(target_os = "macos")]
{
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
}
// Handle window close event to hide instead of quit
let window_clone = window.clone();
let app_clone = app.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window_clone.hide();
// Hide dock icon on macOS when window is hidden (only if settings say so)
#[cfg(target_os = "macos")]
{
if let Ok(settings) = settings::Settings::load() {
if !settings.general.show_dock_icon {
let _ = app_clone.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
}
}
}
});
Ok(())
}

View file

@ -70,22 +70,11 @@ pub struct PermissionsManager {
impl PermissionsManager {
/// Create a new permissions manager
pub fn new() -> Self {
let manager = Self {
permissions: Arc::new(RwLock::new(HashMap::new())),
Self {
permissions: Arc::new(RwLock::new(Self::initialize_permissions())),
app_handle: Arc::new(RwLock::new(None)),
notification_manager: None,
};
// Initialize default permissions
tokio::spawn({
let permissions = manager.permissions.clone();
async move {
let default_permissions = Self::initialize_permissions();
*permissions.write().await = default_permissions;
}
});
manager
}
}
/// Set the app handle

View file

@ -15,6 +15,7 @@ pub struct GeneralSettings {
pub theme: Option<String>,
pub language: Option<String>,
pub check_updates_automatically: Option<bool>,
pub prompt_move_to_applications: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@ -213,6 +214,7 @@ impl Default for Settings {
theme: Some("auto".to_string()),
language: Some("en".to_string()),
check_updates_automatically: Some(true),
prompt_move_to_applications: None,
},
dashboard: DashboardSettings {
server_port: 4020,
@ -329,14 +331,25 @@ impl Settings {
pub fn load() -> Result<Self, String> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(Self::default());
let mut settings = if !config_path.exists() {
Self::default()
} else {
let contents = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
toml::from_str(&contents).map_err(|e| format!("Failed to parse settings: {}", e))?
};
// Load passwords from keychain
if let Ok(Some(password)) = crate::keychain::KeychainManager::get_dashboard_password() {
settings.dashboard.password = password;
}
let contents = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
if let Ok(Some(token)) = crate::keychain::KeychainManager::get_ngrok_auth_token() {
settings.advanced.ngrok_auth_token = Some(token);
}
toml::from_str(&contents).map_err(|e| format!("Failed to parse settings: {}", e))
Ok(settings)
}
pub fn save(&self) -> Result<(), String> {
@ -348,7 +361,25 @@ impl Settings {
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
let contents = toml::to_string_pretty(self)
// Clone settings to remove sensitive data before saving
let mut settings_to_save = self.clone();
// Save passwords to keychain and remove from TOML
if !self.dashboard.password.is_empty() {
crate::keychain::KeychainManager::set_dashboard_password(&self.dashboard.password)
.map_err(|e| format!("Failed to save dashboard password to keychain: {}", e.message))?;
settings_to_save.dashboard.password = String::new();
}
if let Some(ref token) = self.advanced.ngrok_auth_token {
if !token.is_empty() {
crate::keychain::KeychainManager::set_ngrok_auth_token(token)
.map_err(|e| format!("Failed to save ngrok token to keychain: {}", e.message))?;
settings_to_save.advanced.ngrok_auth_token = None;
}
}
let contents = toml::to_string_pretty(&settings_to_save)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
std::fs::write(&config_path, contents)
@ -363,6 +394,46 @@ impl Settings {
Ok(proj_dirs.config_dir().join("settings.toml"))
}
/// Migrate passwords from settings file to keychain (one-time operation)
pub fn migrate_passwords_to_keychain(&self) -> Result<(), String> {
// Check if we have passwords in the settings file that need migration
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(());
}
let contents = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read settings for migration: {}", e))?;
let raw_settings: Settings = toml::from_str(&contents)
.map_err(|e| format!("Failed to parse settings for migration: {}", e))?;
let mut migrated = false;
// Migrate dashboard password if present in file
if !raw_settings.dashboard.password.is_empty() {
crate::keychain::KeychainManager::set_dashboard_password(&raw_settings.dashboard.password)
.map_err(|e| format!("Failed to migrate dashboard password: {}", e.message))?;
migrated = true;
}
// Migrate ngrok token if present in file
if let Some(ref token) = raw_settings.advanced.ngrok_auth_token {
if !token.is_empty() {
crate::keychain::KeychainManager::set_ngrok_auth_token(token)
.map_err(|e| format!("Failed to migrate ngrok token: {}", e.message))?;
migrated = true;
}
}
// If we migrated anything, save the settings again to remove passwords from file
if migrated {
self.save()?;
}
Ok(())
}
}
#[tauri::command]

View file

@ -126,28 +126,13 @@ pub struct TerminalIntegrationsManager {
impl TerminalIntegrationsManager {
/// Create a new terminal integrations manager
pub fn new() -> Self {
let manager = Self {
configs: Arc::new(RwLock::new(HashMap::new())),
Self {
configs: Arc::new(RwLock::new(Self::initialize_default_configs())),
detected_terminals: Arc::new(RwLock::new(HashMap::new())),
default_terminal: Arc::new(RwLock::new(TerminalEmulator::SystemDefault)),
url_schemes: Arc::new(RwLock::new(HashMap::new())),
url_schemes: Arc::new(RwLock::new(Self::initialize_url_schemes())),
notification_manager: None,
};
// Initialize default configurations
tokio::spawn({
let configs = manager.configs.clone();
let url_schemes = manager.url_schemes.clone();
async move {
let default_configs = Self::initialize_default_configs();
*configs.write().await = default_configs;
let default_schemes = Self::initialize_url_schemes();
*url_schemes.write().await = default_schemes;
}
});
manager
}
}
/// Set the notification manager

View file

@ -23,6 +23,7 @@ pub struct TerminalSpawnResponse {
/// Terminal Spawn Service - manages background terminal spawning
pub struct TerminalSpawnService {
request_tx: mpsc::Sender<TerminalSpawnRequest>,
request_rx: Arc<tokio::sync::Mutex<Option<mpsc::Receiver<TerminalSpawnRequest>>>>,
#[allow(dead_code)]
terminal_integrations_manager: Arc<crate::terminal_integrations::TerminalIntegrationsManager>,
}
@ -33,26 +34,32 @@ impl TerminalSpawnService {
crate::terminal_integrations::TerminalIntegrationsManager,
>,
) -> Self {
let (tx, mut rx) = mpsc::channel::<TerminalSpawnRequest>(100);
let manager_clone = terminal_integrations_manager.clone();
// Spawn background worker to handle terminal spawn requests
tokio::spawn(async move {
while let Some(request) = rx.recv().await {
let manager = manager_clone.clone();
tokio::spawn(async move {
let _ = Self::handle_spawn_request(request, manager).await;
});
}
});
let (tx, rx) = mpsc::channel::<TerminalSpawnRequest>(100);
Self {
request_tx: tx,
request_rx: Arc::new(tokio::sync::Mutex::new(Some(rx))),
terminal_integrations_manager,
}
}
/// Start the background worker - must be called after Tokio runtime is available
pub async fn start_worker(self: Arc<Self>) {
let rx = self.request_rx.lock().await.take();
if let Some(mut rx) = rx {
let manager_clone = self.terminal_integrations_manager.clone();
tokio::spawn(async move {
while let Some(request) = rx.recv().await {
let manager = manager_clone.clone();
tokio::spawn(async move {
let _ = Self::handle_spawn_request(request, manager).await;
});
}
});
}
}
/// Queue a terminal spawn request
pub async fn spawn_terminal(&self, request: TerminalSpawnRequest) -> Result<(), String> {
self.request_tx

View file

@ -1,12 +1,26 @@
use tauri::menu::{Menu, MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::AppHandle;
use tauri::{AppHandle, Manager};
pub struct TrayMenuManager;
impl TrayMenuManager {
pub fn create_menu(app: &AppHandle) -> Result<Menu<tauri::Wry>, tauri::Error> {
Self::create_menu_with_state(app, false, 4020, 0)
}
pub fn create_menu_with_state(
app: &AppHandle,
server_running: bool,
port: u16,
session_count: usize
) -> Result<Menu<tauri::Wry>, tauri::Error> {
// Server status
let server_status = MenuItemBuilder::new("Server running on port 4020")
let status_text = if server_running {
format!("Server running on port {}", port)
} else {
"Server stopped".to_string()
};
let server_status = MenuItemBuilder::new(&status_text)
.id("server_status")
.enabled(false)
.build(app)?;
@ -17,7 +31,12 @@ impl TrayMenuManager {
.build(app)?;
// Session info
let sessions_info = MenuItemBuilder::new("0 active sessions")
let session_text = match session_count {
0 => "0 active sessions".to_string(),
1 => "1 active session".to_string(),
_ => format!("{} active sessions", session_count),
};
let sessions_info = MenuItemBuilder::new(&session_text)
.id("sessions_info")
.enabled(false)
.build(app)?;
@ -87,35 +106,38 @@ impl TrayMenuManager {
}
pub async fn update_server_status(app: &AppHandle, port: u16, running: bool) {
if let Some(_tray) = app.tray_by_id("main") {
let status_text = if running {
format!("Server: Running on port {}", port)
} else {
"Server: Stopped".to_string()
};
// Note: In Tauri v2, dynamic menu updates require rebuilding the menu
// For now, we'll just log the status
tracing::debug!("Server status: {}", status_text);
// TODO: Implement menu rebuilding for dynamic updates
// This would involve recreating the entire menu with updated text
if let Some(tray) = app.tray_by_id("main") {
// Get current session count from state
let state = app.state::<crate::state::AppState>();
let session_count = state.terminal_manager.list_sessions().await.len();
// Rebuild menu with new state
if let Ok(menu) = Self::create_menu_with_state(app, running, port, session_count) {
if let Err(e) = tray.set_menu(Some(menu)) {
tracing::error!("Failed to update tray menu: {}", e);
}
}
}
}
pub async fn update_session_count(app: &AppHandle, count: usize) {
if let Some(_tray) = app.tray_by_id("main") {
let text = if count == 0 {
"0 active sessions".to_string()
} else if count == 1 {
"1 active session".to_string()
if let Some(tray) = app.tray_by_id("main") {
// Get current server status from state
let state = app.state::<crate::state::AppState>();
let server_guard = state.http_server.read().await;
let (running, port) = if let Some(server) = server_guard.as_ref() {
(true, server.port())
} else {
format!("{} active sessions", count)
(false, 4020)
};
tracing::debug!("Session count: {}", text);
// TODO: Implement menu rebuilding for dynamic updates
drop(server_guard);
// Rebuild menu with new state
if let Ok(menu) = Self::create_menu_with_state(app, running, port, count) {
if let Err(e) = tray.set_menu(Some(menu)) {
tracing::error!("Failed to update tray menu: {}", e);
}
}
}
}

View file

@ -68,22 +68,11 @@ pub struct WelcomeManager {
impl WelcomeManager {
/// Create a new welcome manager
pub fn new() -> Self {
let manager = Self {
Self {
state: Arc::new(RwLock::new(WelcomeState::default())),
tutorials: Arc::new(RwLock::new(Vec::new())),
tutorials: Arc::new(RwLock::new(Self::create_default_tutorials())),
app_handle: Arc::new(RwLock::new(None)),
};
// Initialize default tutorials
tokio::spawn({
let tutorials = manager.tutorials.clone();
async move {
let default_tutorials = Self::create_default_tutorials();
*tutorials.write().await = default_tutorials;
}
});
manager
}
}
/// Set the app handle

View file

@ -8,18 +8,7 @@
"frontendDist": "../public"
},
"app": {
"windows": [{
"title": "VibeTunnel",
"width": 400,
"height": 500,
"resizable": false,
"fullscreen": false,
"decorations": true,
"transparent": false,
"skipTaskbar": true,
"visible": false,
"center": true
}],
"windows": [],
"security": {
"csp": null
}