From f67891e9ae8e05414da3d5f3d33343b4ccd82f43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Jun 2025 16:49:49 +0200 Subject: [PATCH] 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. --- tauri/README.md | 223 +++++-- tauri/public/settings.html | 631 ++++++++++++++++-- tauri/src-tauri/Cargo.toml | 3 + tauri/src-tauri/src/api_testing.rs | 1 + tauri/src-tauri/src/app_mover.rs | 32 +- tauri/src-tauri/src/auth_cache.rs | 11 +- tauri/src-tauri/src/backend_manager.rs | 19 +- tauri/src-tauri/src/commands.rs | 156 ++++- tauri/src-tauri/src/keychain.rs | 142 ++++ tauri/src-tauri/src/lib.rs | 1 + tauri/src-tauri/src/main.rs | 213 +++--- tauri/src-tauri/src/permissions.rs | 17 +- tauri/src-tauri/src/settings.rs | 83 ++- tauri/src-tauri/src/terminal_integrations.rs | 23 +- tauri/src-tauri/src/terminal_spawn_service.rs | 33 +- tauri/src-tauri/src/tray_menu.rs | 74 +- tauri/src-tauri/src/welcome.rs | 17 +- tauri/src-tauri/tauri.conf.json | 13 +- 18 files changed, 1364 insertions(+), 328 deletions(-) create mode 100644 tauri/src-tauri/src/keychain.rs diff --git a/tauri/README.md b/tauri/README.md index caa9c577..0b44e839 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -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 { + 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\` \ No newline at end of file +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. \ No newline at end of file diff --git a/tauri/public/settings.html b/tauri/public/settings.html index ba6e8cbf..5732ee39 100644 --- a/tauri/public/settings.html +++ b/tauri/public/settings.html @@ -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); + } @@ -661,18 +995,80 @@

Updates

-
- - -

Choose which release channel to receive updates from

+ + +
+
+
+
+ Current Version: 1.0.0 + +
+
+ + VibeTunnel is up to date +
+
+ +
+ + + + + +
-
- - + + +
+
+
+ +

Choose which releases to receive

+
+ +
+ +
+
+ +

Check for updates automatically

+
+
+ + +
+
+ +
+
+ +

Download updates in the background

+
+
+ + +
+
@@ -916,24 +1312,53 @@
\ No newline at end of file diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index 6308cd85..0ab5a9a2 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -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" diff --git a/tauri/src-tauri/src/api_testing.rs b/tauri/src-tauri/src/api_testing.rs index 281ef764..6397715e 100644 --- a/tauri/src-tauri/src/api_testing.rs +++ b/tauri/src-tauri/src/api_testing.rs @@ -19,6 +19,7 @@ pub enum HttpMethod { } impl HttpMethod { + #[allow(dead_code)] pub fn as_str(&self) -> &str { match self { HttpMethod::GET => "GET", diff --git a/tauri/src-tauri/src/app_mover.rs b/tauri/src-tauri/src/app_mover.rs index e8bbcc35..4d970b24 100644 --- a/tauri/src-tauri/src/app_mover.rs +++ b/tauri/src-tauri/src/app_mover.rs @@ -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(()) diff --git a/tauri/src-tauri/src/auth_cache.rs b/tauri/src-tauri/src/auth_cache.rs index e403fe66..09b90a89 100644 --- a/tauri/src-tauri/src/auth_cache.rs +++ b/tauri/src-tauri/src/auth_cache.rs @@ -57,6 +57,7 @@ impl CachedToken { } /// Get remaining lifetime in seconds + #[allow(dead_code)] pub fn remaining_lifetime_seconds(&self) -> Option { 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(), diff --git a/tauri/src-tauri/src/backend_manager.rs b/tauri/src-tauri/src/backend_manager.rs index e8b22efd..a9386895 100644 --- a/tauri/src-tauri/src/backend_manager.rs +++ b/tauri/src-tauri/src/backend_manager.rs @@ -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 diff --git a/tauri/src-tauri/src/commands.rs b/tauri/src-tauri/src/commands.rs index b1abf977..cd49ec40 100644 --- a/tauri/src-tauri/src/commands.rs +++ b/tauri/src-tauri/src/commands.rs @@ -35,10 +35,11 @@ pub struct CreateTerminalOptions { pub async fn create_terminal( options: CreateTerminalOptions, state: State<'_, AppState>, + app: tauri::AppHandle, ) -> Result { 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, } #[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, 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 { +pub async fn start_server(state: State<'_, AppState>, app: tauri::AppHandle) -> Result { 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) -> Result) -> 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 { pub async fn detect_terminals() -> Result { 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, 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, 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, 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, String> { + Ok(crate::keychain::KeychainManager::list_stored_keys()) +} + +#[tauri::command] +pub async fn request_all_permissions(state: State<'_, AppState>) -> Result, 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(()) +} diff --git a/tauri/src-tauri/src/keychain.rs b/tauri/src-tauri/src/keychain.rs new file mode 100644 index 00000000..f370cd0d --- /dev/null +++ b/tauri/src-tauri/src/keychain.rs @@ -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 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, 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, 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, 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 { + 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) -> 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); + } +} \ No newline at end of file diff --git a/tauri/src-tauri/src/lib.rs b/tauri/src-tauri/src/lib.rs index 61f47a71..8b7cc90d 100644 --- a/tauri/src-tauri/src/lib.rs +++ b/tauri/src-tauri/src/lib.rs @@ -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; diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 508aebc2..ee8d6630 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -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) -> 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::(); - 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::().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(()) } diff --git a/tauri/src-tauri/src/permissions.rs b/tauri/src-tauri/src/permissions.rs index fd8745cc..d1fad3a9 100644 --- a/tauri/src-tauri/src/permissions.rs +++ b/tauri/src-tauri/src/permissions.rs @@ -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 diff --git a/tauri/src-tauri/src/settings.rs b/tauri/src-tauri/src/settings.rs index 18cd3d93..b064d300 100644 --- a/tauri/src-tauri/src/settings.rs +++ b/tauri/src-tauri/src/settings.rs @@ -15,6 +15,7 @@ pub struct GeneralSettings { pub theme: Option, pub language: Option, pub check_updates_automatically: Option, + pub prompt_move_to_applications: Option, } #[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 { 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] diff --git a/tauri/src-tauri/src/terminal_integrations.rs b/tauri/src-tauri/src/terminal_integrations.rs index 8af72c78..73adea9a 100644 --- a/tauri/src-tauri/src/terminal_integrations.rs +++ b/tauri/src-tauri/src/terminal_integrations.rs @@ -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 diff --git a/tauri/src-tauri/src/terminal_spawn_service.rs b/tauri/src-tauri/src/terminal_spawn_service.rs index 329cbddb..a45affa4 100644 --- a/tauri/src-tauri/src/terminal_spawn_service.rs +++ b/tauri/src-tauri/src/terminal_spawn_service.rs @@ -23,6 +23,7 @@ pub struct TerminalSpawnResponse { /// Terminal Spawn Service - manages background terminal spawning pub struct TerminalSpawnService { request_tx: mpsc::Sender, + request_rx: Arc>>>, #[allow(dead_code)] terminal_integrations_manager: Arc, } @@ -33,26 +34,32 @@ impl TerminalSpawnService { crate::terminal_integrations::TerminalIntegrationsManager, >, ) -> Self { - let (tx, mut rx) = mpsc::channel::(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::(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) { + 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 diff --git a/tauri/src-tauri/src/tray_menu.rs b/tauri/src-tauri/src/tray_menu.rs index cc0e170f..b6ac2ba1 100644 --- a/tauri/src-tauri/src/tray_menu.rs +++ b/tauri/src-tauri/src/tray_menu.rs @@ -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, 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, 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::(); + 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::(); + 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); + } + } } } diff --git a/tauri/src-tauri/src/welcome.rs b/tauri/src-tauri/src/welcome.rs index 8ad0d87a..e09f27b3 100644 --- a/tauri/src-tauri/src/welcome.rs +++ b/tauri/src-tauri/src/welcome.rs @@ -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 diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json index 84178e97..5261c917 100644 --- a/tauri/src-tauri/tauri.conf.json +++ b/tauri/src-tauri/tauri.conf.json @@ -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 }