mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-28 05:29:29 +00:00
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:
parent
53a5af9fc4
commit
f67891e9ae
18 changed files with 1364 additions and 328 deletions
223
tauri/README.md
223
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<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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ pub enum HttpMethod {
|
|||
}
|
||||
|
||||
impl HttpMethod {
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
HttpMethod::GET => "GET",
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
142
tauri/src-tauri/src/keychain.rs
Normal file
142
tauri/src-tauri/src/keychain.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue